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
1 change: 1 addition & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
63 changes: 52 additions & 11 deletions nerve/agent/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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",
Expand Down
14 changes: 14 additions & 0 deletions nerve/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {}),
)

Expand Down
69 changes: 69 additions & 0 deletions tests/test_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading