From c90f0eed749737af59dd583e4b32032217e22b59 Mon Sep 17 00:00:00 2001 From: Tym Rabchuk Date: Fri, 17 Apr 2026 15:50:27 -0400 Subject: [PATCH 1/3] fix(guard): allow Agent/Task dispatch on prompt+hard steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tri-* workflows (tri-review, tri-debug, tri-security, tri-dispatch) instruct the calling model to hand off to an external-model subagent so the verdict isn't written by Claude itself. Commit 7b5fd2b fixed the prompt text to say "DISPATCH, DO NOT ANSWER"; the guard still blocked the Agent/Task tool that the instructions require, so the whole dispatch path was dead. Add Agent and Task to the prompt+hard allowlist. Write/Edit/Bash remain blocked so the main model cannot cheat by fabricating a review — it must go through a subagent whose tools are independently gated. --- src/cmd/guard.go | 12 ++++++++++++ src/cmd/guard_test.go | 24 +++++++++++++++++++----- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/cmd/guard.go b/src/cmd/guard.go index 73a57ac..f83c4ef 100644 --- a/src/cmd/guard.go +++ b/src/cmd/guard.go @@ -395,6 +395,18 @@ 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). The subagent's own tool calls + // re-enter this guard with the same session state; if + // they need broader tools, they must be explicitly + // authorized via enforce: soft on the step. + 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..ea01c8b 100644 --- a/src/cmd/guard_test.go +++ b/src/cmd/guard_test.go @@ -278,15 +278,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", From be53de3ef07fa0ffc59bb69c536e40a26433d946 Mon Sep 17 00:00:00 2001 From: Tym Rabchuk Date: Fri, 17 Apr 2026 15:59:44 -0400 Subject: [PATCH 2/3] test(hooks): update shell smoke test for Agent/Task allow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the Go-level guard_test.go change: flip prompt+hard+Task from expect-block to expect-allow, add prompt+hard+Agent → allow. Keeps the shell smoke suite in sync with the Go unit tests. --- hooks/hooks_test.sh | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/hooks/hooks_test.sh b/hooks/hooks_test.sh index 936a054..f9947f5 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,12 @@ 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":"devkit_advance"}' \ 0 "prompt+hard+devkit_advance → allow" From 85effb7395db5e0120bfe969626a3661c7aa895e Mon Sep 17 00:00:00 2001 From: Tym Rabchuk Date: Fri, 17 Apr 2026 16:05:11 -0400 Subject: [PATCH 3/3] address review: tighten guard comment, add negative coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from pr-review-toolkit (2026-04-17): - test-analyzer: pin command+hard+Agent/Task as block so a future refactor can't silently extend the prompt carve-out to command steps (dispatch is a prompt-step privilege; command steps are engine-driven). - test-analyzer: add prompt+hard+Edit and prompt+hard+WebFetch to the shell smoke suite — the PR body names these as anti-cheat must-holds, the Go suite had them but the shell mirror did not. - comment-analyzer: drop the guard.go comment's final sentence recommending enforce: soft as the escape hatch for subagent tool needs. That workaround would also unlock the main agent's Write/Edit/Bash, which is exactly what this PR is designed to prevent. Keep the re-entry note (verified accurate by the silent-failure reviewer). --- hooks/hooks_test.sh | 16 ++++++++++++++++ src/cmd/guard.go | 7 +++---- src/cmd/guard_test.go | 27 +++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/hooks/hooks_test.sh b/hooks/hooks_test.sh index f9947f5..f39ae20 100644 --- a/hooks/hooks_test.sh +++ b/hooks/hooks_test.sh @@ -499,6 +499,12 @@ run_guard '{"status":"running","step_type":"prompt","enforce":"hard","current_st 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" @@ -523,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 f83c4ef..507bc67 100644 --- a/src/cmd/guard.go +++ b/src/cmd/guard.go @@ -401,10 +401,9 @@ func runPreToolGuard() { // 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). The subagent's own tool calls - // re-enter this guard with the same session state; if - // they need broader tools, they must be explicitly - // authorized via enforce: soft on the step. + // 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 } diff --git a/src/cmd/guard_test.go b/src/cmd/guard_test.go index ea01c8b..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,