fix(code): reject --auto-approve in headless mode (#4617)

`dcode` now rejects `--auto-approve` in headless mode and points users
to `--shell-allow-list` for shell access. Interactive behavior is
unchanged.

---

Running `dcode` headlessly with `--auto-approve` currently accepts a
flag that does not control shell access. This can leave users believing
shell commands are approved when headless execution actually uses
`--shell-allow-list`.

Reject the unsupported combination with exit code 2 and direct users to
`--shell-allow-list`. Clarify the command help while preserving
`--auto-approve` for interactive launches, including `-m` startup
messages.
This commit is contained in:
Mason Daugherty
2026-07-09 18:31:26 -04:00
committed by GitHub
parent 3d1a9a9450
commit 997be1643a
3 changed files with 118 additions and 7 deletions
+24 -6
View File
@@ -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
@@ -1639,11 +1640,13 @@ def parse_args() -> argparse.Namespace:
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')."
),
)
@@ -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
+1 -1
View File
@@ -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(
@@ -172,6 +172,99 @@ class TestResolveAutoApprove:
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"),
[