diff --git a/config.example.yaml b/config.example.yaml index 65af4ed..5fd7e09 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -39,6 +39,7 @@ agent: title_model: claude-haiku-4-5-20251001 # Session title generation max_turns: 50 # Max agentic turns per request max_concurrent: 4 # Max concurrent agent sessions + background_agent_permissions: true # Background sub-agents (Agent run_in_background) get the same tool permissions as foreground; false denies their Write/Edit/Bash # First-prompt rewrite — the web UI can refine the opening message of a # new chat with a fast model, preview it, and send only after approval. prompt_rewrite: diff --git a/nerve/agent/engine.py b/nerve/agent/engine.py index b23172b..3dcdf96 100644 --- a/nerve/agent/engine.py +++ b/nerve/agent/engine.py @@ -1192,7 +1192,7 @@ def _build_hooks(self, session_id: str) -> dict: through ``engine.run(..., source="wakeup")`` (the CLI's own autonomous firing is suppressed — see ``_build_env``). """ - from nerve.agent.interactive import _read_file_safe + from nerve.agent.interactive import INTERACTIVE_TOOLS, _read_file_safe captured_files: set[str] = set() @@ -1265,17 +1265,58 @@ async def _capture_wakeup_hook(hook_input, tool_use_id, context): ) return {"hookSpecificOutput": {"hookEventName": "PostToolUse"}} + async def _grant_permission_hook(hook_input, tool_use_id, context): + """PreToolUse hook: pre-approve non-interactive tools. + + Background sub-agents (the Agent tool with run_in_background) run + detached and non-blocking, so the CLI never surfaces an approval + prompt for their nested tool calls — the ``can_use_tool`` callback + is never invoked for them and the CLI denies their Write/Edit/Bash + by default. A PreToolUse hook, however, DOES fire for those nested + calls (it is a programmatic callback, not a user-facing prompt), so + returning ``permissionDecision: "allow"`` here grants the same + auto-approval foreground agents already get via ``can_use_tool``. + + Interactive tools and Read are left untouched: interactive tools + defer to ``can_use_tool`` (pause / inject answers / deny), and Read + defers to the image validator above plus the CLI's read-only + auto-allow. This keeps the web pause-for-input flow intact while + giving background sub-agents permission parity with the foreground. + """ + tool_name = hook_input.get("tool_name", "") + if tool_name in INTERACTIVE_TOOLS or tool_name == "Read": + return {"hookSpecificOutput": {"hookEventName": "PreToolUse"}} + return { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow", + "permissionDecisionReason": ( + "nerve: auto-approved (background-agent permission parity)" + ), + } + } + + pre_tool_use = [ + HookMatcher( + matcher="Edit|Write|NotebookEdit", + hooks=[_snapshot_hook], + ), + HookMatcher( + matcher="Read", + hooks=[_validate_image_hook], + ), + ] + # Catch-all permission grant so background sub-agents (whose nested + # tool calls never reach can_use_tool) inherit foreground's tool + # permissions. Registered last so the snapshot/validator hooks still + # run for their tools; a deny from the validator wins over this allow. + if self.config.agent.background_agent_permissions: + pre_tool_use.append( + HookMatcher(matcher=None, hooks=[_grant_permission_hook]) + ) + return { - "PreToolUse": [ - HookMatcher( - matcher="Edit|Write|NotebookEdit", - hooks=[_snapshot_hook], - ), - HookMatcher( - matcher="Read", - hooks=[_validate_image_hook], - ), - ], + "PreToolUse": pre_tool_use, "PostToolUse": [ HookMatcher( matcher="ScheduleWakeup", diff --git a/nerve/config.py b/nerve/config.py index e3e3b14..1488733 100644 --- a/nerve/config.py +++ b/nerve/config.py @@ -151,6 +151,17 @@ class AgentConfig: # behaviour: turns can hang forever). 900s comfortably covers a 10-min # Bash tool call plus SDK round-trips while still catching real hangs. cli_idle_timeout_seconds: int = 900 + # When True, background sub-agents (the Agent tool with run_in_background, or + # background Bash) get the SAME auto-approved tool permissions as foreground + # agents, via a PreToolUse hook that pre-approves all non-interactive tools. + # Background tasks are detached and non-blocking, so the CLI never surfaces an + # approval prompt for them — the can_use_tool callback is never invoked for + # their nested Write/Edit/Bash calls, and the CLI denies them by default. + # A PreToolUse hook DOES fire for those nested calls (it is a programmatic + # callback, not a user prompt), so returning permissionDecision="allow" there + # grants the permission. Set False to restore the CLI default (background + # sub-agent writes denied; build/write agents must then run in foreground). + background_agent_permissions: bool = True prompt_rewrite: PromptRewriteConfig = field(default_factory=PromptRewriteConfig) @classmethod @@ -168,6 +179,9 @@ def from_dict(cls, d: dict) -> AgentConfig: d.get("context_1m_excluded_models", []) or [] ), cli_idle_timeout_seconds=int(d.get("cli_idle_timeout_seconds", 900)), + background_agent_permissions=bool( + d.get("background_agent_permissions", True) + ), prompt_rewrite=PromptRewriteConfig.from_dict(d.get("prompt_rewrite") or {}), ) diff --git a/tests/test_engine.py b/tests/test_engine.py index f48b3e4..00bb57c 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -280,3 +280,72 @@ def test_falls_back_to_unresolved_path(self, tmp_path, monkeypatch): engine = _make_engine(str(link_ws)) assert engine._sdk_resume_file_exists(sid) is True + + +# --------------------------------------------------------------------------- +# _build_hooks — background-agent permission parity +# --------------------------------------------------------------------------- + +def _make_hook_engine(background_agent_permissions: bool) -> AgentEngine: + """Minimal engine stub for exercising _build_hooks's PreToolUse wiring.""" + engine = AgentEngine.__new__(AgentEngine) + engine.config = SimpleNamespace( + agent=SimpleNamespace( + background_agent_permissions=background_agent_permissions, + ), + ) + return engine + + +def _catch_all_grant_hook(hooks: dict): + """Return the catch-all (matcher=None) PreToolUse hook callback, or None.""" + for matcher in hooks.get("PreToolUse", []): + if matcher.matcher is None: + return matcher.hooks[0] + return None + + +class TestBuildHooksBackgroundPermissions: + """The catch-all PreToolUse hook gives background sub-agents (whose nested + tool calls never reach can_use_tool) the same permissions as foreground.""" + + @pytest.mark.asyncio + async def test_grants_non_interactive_tools_when_enabled(self): + engine = _make_hook_engine(True) + hooks = engine._build_hooks("sess-x") + grant = _catch_all_grant_hook(hooks) + assert grant is not None, "permission-grant hook should be registered" + + # Permission-requiring, non-interactive tools are pre-approved so a + # detached background sub-agent can run them without a prompt. + for tool in ("Bash", "Write", "Edit", "NotebookEdit", "Glob", + "mcp__some_server__write_thing"): + out = await grant({"tool_name": tool}, "tid", None) + spec = out["hookSpecificOutput"] + assert spec.get("permissionDecision") == "allow", tool + + @pytest.mark.asyncio + async def test_defers_interactive_and_read_when_enabled(self): + engine = _make_hook_engine(True) + grant = _catch_all_grant_hook(engine._build_hooks("sess-x")) + + # Interactive tools defer to can_use_tool (pause / inject / deny): + # the hook must NOT pre-decide them, or the web pause-for-input breaks. + for tool in ("AskUserQuestion", "ExitPlanMode", "EnterPlanMode"): + out = await grant({"tool_name": tool}, "tid", None) + assert "permissionDecision" not in out["hookSpecificOutput"], tool + + # Read defers to the image validator (a deny there must win), so the + # catch-all hook leaves it untouched. + out = await grant({"tool_name": "Read"}, "tid", None) + assert "permissionDecision" not in out["hookSpecificOutput"] + + @pytest.mark.asyncio + async def test_no_grant_hook_when_disabled(self): + engine = _make_hook_engine(False) + hooks = engine._build_hooks("sess-y") + assert _catch_all_grant_hook(hooks) is None + # Snapshot + image-validator hooks stay registered regardless. + matchers = {m.matcher for m in hooks["PreToolUse"]} + assert "Edit|Write|NotebookEdit" in matchers + assert "Read" in matchers