Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions libs/code/deepagents_code/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -602,8 +602,9 @@ def _resolve_interpreter_enabled(args: argparse.Namespace) -> bool:


def _resolve_auto_approve(args: argparse.Namespace) -> bool:
"""Return whether tool calls should be auto-approved for these CLI args.
"""Return whether the interactive TUI should auto-approve tool calls.

Headless mode uses `--shell-allow-list` instead and never calls this resolver.
An explicit `-y`/`--auto-approve` wins; when the flag is omitted
(`args.auto_approve is None`), the persistent `[startup].mode` config
default decides — `dangerously-auto` enables auto-approval, anything else
Expand Down Expand Up @@ -1639,11 +1640,13 @@ def help_parent(help_fn: Callable[[], None]) -> list[argparse.ArgumentParser]:
action="store_true",
default=None,
help=(
"Auto-approve all tool calls without prompting "
"(disables human-in-the-loop). Affected tools: shell "
"execution, file writes/edits, web search, and URL fetch. "
"Use with caution — the agent can execute arbitrary commands. "
"When omitted, the launch default comes from [startup].mode in "
"Interactive mode only: auto-approve all tool calls without prompting "
"(disables human-in-the-loop). Affected tools: shell execution, file "
"writes/edits, web search, and URL fetch. Headless mode approves "
"non-shell tools; shell is disabled unless allowed via "
"--shell-allow-list. "
"Use with caution — the agent can execute arbitrary commands. When "
"omitted, the launch default comes from [startup].mode in "
"~/.deepagents/config.toml ('manual' or 'dangerously-auto')."
),
)
Expand Down Expand Up @@ -2774,6 +2777,21 @@ def cli_main() -> None:

apply_stdin_pipe(args)

# Validated here, before mode dispatch and any heavy session setup:
# `apply_stdin_pipe` has finalized `non_interactive_message` (the same
# predicate that selects the headless branch below), so this reliably
# rejects `--auto-approve` on both the `-n` and piped-stdin paths while
# leaving interactive launches untouched.
if args.auto_approve and args.non_interactive_message:
from rich.console import Console as _Console

_Console(stderr=True).print(
"[bold red]Error:[/bold red] --auto-approve is only supported in "
"interactive mode. Headless mode already approves non-shell tools; "
"use --shell-allow-list to control shell access."
)
sys.exit(2)

if getattr(args, "no_mcp", False) and getattr(args, "mcp_config", None):
from rich.console import Console as _Console

Expand Down
2 changes: 1 addition & 1 deletion libs/code/deepagents_code/ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ def show_help() -> None:
" --startup-cmd CMD Shell command to run at startup, before first prompt" # noqa: E501
)
console.print(
" -y, --auto-approve Auto-approve all tool calls (toggle: Shift+Tab)"
" -y, --auto-approve Auto-approve all tool calls in interactive mode (toggle: Shift+Tab)" # noqa: E501
)
console.print(" --sandbox TYPE Remote sandbox for execution")
console.print(
Expand Down
93 changes: 93 additions & 0 deletions libs/code/tests/unit_tests/test_main_args.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,99 @@ def test_omitted_flag_dangerously_auto_config_resolves_true(self) -> None:
assert _resolve_auto_approve(args) is True


class TestAutoApproveHeadlessValidation:
"""Tests that headless mode rejects the interactive approval flag."""

def test_rejects_explicit_non_interactive_mode(
self, capsys: pytest.CaptureFixture[str]
) -> None:
"""`-n` must reject `--auto-approve` instead of silently ignoring it."""
from deepagents_code.main import cli_main

mock_stdin = MagicMock()
mock_stdin.isatty.return_value = True
with (
patch.object(
sys,
"argv",
["deepagents", "--auto-approve", "-n", "do the thing"],
),
patch.object(sys, "stdin", mock_stdin),
pytest.raises(SystemExit) as exc_info,
):
cli_main()

assert exc_info.value.code == 2
stderr = capsys.readouterr().err
assert "--auto-approve is only supported in interactive mode" in stderr
assert "--shell-allow-list" in stderr

def test_rejects_piped_stdin_mode(self, capsys: pytest.CaptureFixture[str]) -> None:
"""Piped stdin must reject the flag after selecting headless mode."""
from deepagents_code.main import cli_main

mock_stdin = MagicMock()
mock_stdin.isatty.return_value = False
mock_stdin.read.return_value = "do the thing"
with (
patch.object(sys, "argv", ["deepagents", "--auto-approve"]),
patch.object(sys, "stdin", mock_stdin),
patch("os.open", side_effect=OSError("No controlling terminal")),
pytest.raises(SystemExit) as exc_info,
):
cli_main()

assert exc_info.value.code == 2
stderr = capsys.readouterr().err
assert "--auto-approve is only supported in interactive mode" in stderr
assert "--shell-allow-list" in stderr

def test_accepts_auto_approve_in_interactive_mode(self) -> None:
"""`--auto-approve` must still be honored on an interactive launch.

The guard rejects only when `args.non_interactive_message` is also set.
Without that conjunct it would wrongly reject `dcode -m ... -y`; this
pins the interactive path so a dropped conjunct fails loudly instead of
silently breaking the flag's primary use. Also asserts the resolved
value flows through to the TUI (`auto_approve=True`).
"""
from deepagents_code.main import cli_main

mock_stdin = MagicMock()
mock_stdin.isatty.return_value = True

fake_result = MagicMock()
fake_result.return_code = 0
fake_result.thread_id = None
fake_result.update_available = (False, None)
fake_result.session_stats = MagicMock(request_count=0)
run_tui = AsyncMock(return_value=fake_result)

with (
patch.object(sys, "argv", ["deepagents", "--auto-approve", "-m", "hello"]),
patch.object(sys, "stdin", mock_stdin),
patch("deepagents_code.main.run_textual_cli_async", run_tui),
patch("deepagents_code.main._run_startup_auto_update"),
patch("deepagents_code.main._resolve_agent_arg", return_value="agent"),
patch("deepagents_code.main._check_mcp_project_trust", return_value=False),
patch(
"deepagents_code.main._resolve_interpreter_enabled",
return_value=False,
),
patch("deepagents_code.main._print_session_stats"),
patch(
"deepagents_code.main._should_check_teardown_thread",
return_value=False,
),
):
cli_main()

run_tui.assert_awaited_once()
await_args = run_tui.await_args
assert await_args is not None
assert await_args.kwargs["auto_approve"] is True


@pytest.mark.parametrize(
("input_str", "expected"),
[
Expand Down
Loading