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
28 changes: 24 additions & 4 deletions hooks/hooks_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand All @@ -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"}' \
Expand Down
11 changes: 11 additions & 0 deletions src/cmd/guard.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
51 changes: 46 additions & 5 deletions src/cmd/guard_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
Loading