diff --git a/hooks/hooks_test.sh b/hooks/hooks_test.sh index 936a054..f39ae20 100644 --- a/hooks/hooks_test.sh +++ b/hooks/hooks_test.sh @@ -478,8 +478,9 @@ run_guard '{"status":"done","step_type":"command","enforce":"hard","current_step '{"tool_name":"Bash","tool_input":{"command":"ls"}}' \ 0 "status=done → allow" -# Prompt step + hard enforce: read-only evidence tools allowed, -# Write/Bash/Task blocked. Closes the drift hole from issue #63. +# Prompt step + hard enforce: read-only evidence tools + subagent +# dispatch (Task/Agent) allowed, Write/Bash/Edit blocked so the main +# model cannot fabricate output. Closes the drift hole from issue #63. run_guard '{"status":"running","step_type":"prompt","enforce":"hard","current_step":"analyse"}' \ '{"tool_name":"Read","tool_input":{"file_path":"main.go"}}' \ 0 "prompt+hard+Read → allow" @@ -492,9 +493,18 @@ run_guard '{"status":"running","step_type":"prompt","enforce":"hard","current_st run_guard '{"status":"running","step_type":"prompt","enforce":"hard","current_step":"analyse"}' \ '{"tool_name":"Write","tool_input":{"file_path":"x.go","content":"x"}}' \ 2 "prompt+hard+Write → block" -run_guard '{"status":"running","step_type":"prompt","enforce":"hard","current_step":"analyse"}' \ +run_guard '{"status":"running","step_type":"prompt","enforce":"hard","current_step":"review-smart"}' \ '{"tool_name":"Task","tool_input":{}}' \ - 2 "prompt+hard+Task → block" + 0 "prompt+hard+Task → allow (subagent dispatch)" +run_guard '{"status":"running","step_type":"prompt","enforce":"hard","current_step":"review-smart"}' \ + '{"tool_name":"Agent","tool_input":{}}' \ + 0 "prompt+hard+Agent → allow (subagent dispatch)" +run_guard '{"status":"running","step_type":"prompt","enforce":"hard","current_step":"analyse"}' \ + '{"tool_name":"Edit","tool_input":{"file_path":"x.go","old_string":"a","new_string":"b"}}' \ + 2 "prompt+hard+Edit → block" +run_guard '{"status":"running","step_type":"prompt","enforce":"hard","current_step":"analyse"}' \ + '{"tool_name":"WebFetch","tool_input":{"url":"https://example.com"}}' \ + 2 "prompt+hard+WebFetch → block" run_guard '{"status":"running","step_type":"prompt","enforce":"hard","current_step":"analyse"}' \ '{"tool_name":"devkit_advance"}' \ 0 "prompt+hard+devkit_advance → allow" @@ -519,6 +529,16 @@ run_guard '{"status":"running","step_type":"command","enforce":"hard","current_s '{"tool_name":"Write","tool_input":{"file_path":"x.go","content":"package x"}}' \ 2 "command+hard+Write → block" +# Command step + hard enforce + Agent/Task → block. Subagent dispatch is +# a prompt-step privilege; command steps are engine-driven, so allowing +# Agent/Task there would let the model bypass the engine. +run_guard '{"status":"running","step_type":"command","enforce":"hard","current_step":"build"}' \ + '{"tool_name":"Agent","tool_input":{}}' \ + 2 "command+hard+Agent → block" +run_guard '{"status":"running","step_type":"command","enforce":"hard","current_step":"build"}' \ + '{"tool_name":"Task","tool_input":{}}' \ + 2 "command+hard+Task → block" + # Command step + hard enforce + devkit_advance → allow run_guard '{"status":"running","step_type":"command","enforce":"hard","current_step":"build"}' \ '{"tool_name":"devkit_advance"}' \ diff --git a/src/cmd/guard.go b/src/cmd/guard.go index 73a57ac..507bc67 100644 --- a/src/cmd/guard.go +++ b/src/cmd/guard.go @@ -395,6 +395,17 @@ func runPreToolGuard() { case "Read", "Grep", "Glob", "TodoWrite", "NotebookRead", "Skill": guardExit(0) return + case "Agent", "Task": + // Subagent dispatch. The main agent hands off to a + // subagent whose tool list is gated independently — + // Write/Edit/Bash remain blocked at this layer, so the + // main model cannot cheat (e.g. fake a tri-review by + // writing the verdict itself instead of dispatching to + // an external reviewer). Subagent tool calls re-enter + // this guard with the same session state, so the fence + // applies uniformly to both layers. + guardExit(0) + return } fmt.Fprintf(guardStderr, "BLOCKED: devkit workflow %s is at a prompt step — gather evidence with Read/Grep/Glob then call devkit_advance. (attempted tool: %s)\n", diff --git a/src/cmd/guard_test.go b/src/cmd/guard_test.go index b691a8f..4538d9c 100644 --- a/src/cmd/guard_test.go +++ b/src/cmd/guard_test.go @@ -179,6 +179,33 @@ func TestGuardPreToolUse(t *testing.T) { wantExit: 2, wantStderrSubstr: "attempted tool: Write", }, + { + // Subagent dispatch is a prompt-step privilege. Command steps + // are run by the engine directly, not by the model — dispatch + // there would mean the model bypassing the engine, defeating + // determinism. Pin this so a future refactor can't quietly + // extend the prompt carve-out to command steps. + name: "command+hard+Agent → block", + dataDir: true, + hasSession: true, + session: lib.SessionState{ + Status: "running", StepType: "command", StepEnforce: lib.EnforceHard, CurrentStep: "build", + }, + stdin: `{"tool_name":"Agent"}`, + wantExit: 2, + wantStderrSubstr: "attempted tool: Agent", + }, + { + name: "command+hard+Task → block", + dataDir: true, + hasSession: true, + session: lib.SessionState{ + Status: "running", StepType: "command", StepEnforce: lib.EnforceHard, CurrentStep: "build", + }, + stdin: `{"tool_name":"Task"}`, + wantExit: 2, + wantStderrSubstr: "attempted tool: Task", + }, { name: "command+hard+devkit_advance → allow", dataDir: true, @@ -278,15 +305,29 @@ func TestGuardPreToolUse(t *testing.T) { wantStderrSubstr: "tri-review step", }, { - name: "prompt+hard+Task → block", + // Subagent dispatch (Task / Agent) is allowed on prompt+hard + // so tri-* workflows can hand off to an external-model + // reviewer without the main Claude model faking the verdict. + // Write/Edit/Bash remain blocked on prompt+hard so the + // main agent cannot cheat by writing its own output. + name: "prompt+hard+Task → allow (subagent dispatch)", dataDir: true, hasSession: true, session: lib.SessionState{ - Status: "running", StepType: "prompt", StepEnforce: lib.EnforceHard, CurrentStep: "analyse", + Status: "running", StepType: "prompt", StepEnforce: lib.EnforceHard, CurrentStep: "review-smart", }, - stdin: `{"tool_name":"Task"}`, - wantExit: 2, - wantStderrSubstr: "attempted tool: Task", + stdin: `{"tool_name":"Task"}`, + wantExit: 0, + }, + { + name: "prompt+hard+Agent → allow (subagent dispatch)", + dataDir: true, + hasSession: true, + session: lib.SessionState{ + Status: "running", StepType: "prompt", StepEnforce: lib.EnforceHard, CurrentStep: "review-smart", + }, + stdin: `{"tool_name":"Agent"}`, + wantExit: 0, }, { name: "prompt+hard+devkit_advance → allow",