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
22 changes: 14 additions & 8 deletions hooks/hooks_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -551,12 +551,15 @@ else
fail "devkit-guard: corrupt JSON (exit $corrupt_exit, want 2)"
fi

# Valid JSON but missing enforce field — Python .get() returns default
# "hard", so a command step with no enforce must still block like hard.
# This catches the "schema drift silently degrades enforcement" class.
# Valid JSON but missing enforce field — used to silently default to
# "hard" via Python .get() / effectiveEnforce(). Post-#81 the Go binary
# rejects the parse at SessionState.UnmarshalJSON and the guard fails
# closed via the "cannot read session state" path. Exit is still 2 so
# the contract (missing enforce → block) is preserved, but via type-
# level rejection instead of silent coercion.
run_guard '{"status":"running","step_type":"command","current_step":"build"}' \
'{"tool_name":"Bash","tool_input":{"command":"ls"}}' \
2 "command step with missing enforce field → block (default hard)"
2 "command step with missing enforce field → block (parse-reject)"

# Valid JSON missing step_type — should default to empty string, which
# is NOT "command", so fall through to allow. This verifies the guard
Expand Down Expand Up @@ -608,16 +611,19 @@ else
fail "devkit-stop-guard: no session file (got: $out)"
fi

# Running workflow → block
run_stop_guard '{"status":"running","workflow":"test","total_steps":5,"current_index":2}' \
# Running workflow → block. Fixtures must include enforce: the Go
# binary's SessionState.UnmarshalJSON rejects session.json with a
# missing/empty enforce at read time (closes #81 silent-soft fall-
# through), so the stop hook fails closed before even reading status.
run_stop_guard '{"status":"running","workflow":"test","enforce":"hard","total_steps":5,"current_index":2}' \
"block" "running workflow → block"

# Done workflow → approve
run_stop_guard '{"status":"done","workflow":"test","total_steps":5,"current_index":4}' \
run_stop_guard '{"status":"done","workflow":"test","enforce":"hard","total_steps":5,"current_index":4}' \
"approve" "done workflow → approve"

# Failed workflow → approve (user should see the failure, not be stuck in a loop)
run_stop_guard '{"status":"failed","workflow":"test","total_steps":5,"current_index":2}' \
run_stop_guard '{"status":"failed","workflow":"test","enforce":"hard","total_steps":5,"current_index":2}' \
"approve" "failed workflow → approve"

# Corrupt JSON → block (fail closed)
Expand Down
22 changes: 7 additions & 15 deletions src/cmd/guard.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,17 +201,6 @@ func sessionFileExists(dataDir string) bool {
return err == nil
}

// effectiveEnforce defaults an empty Enforce field to "hard". The shell
// version relied on python3 .get('enforce','hard'); the fixture matrix
// explicitly asserts that a command step with no enforce field still
// blocks. See hooks_test.sh "missing enforce field" case.
func effectiveEnforce(s *lib.SessionState) string {
if s.Enforce == "" {
return "hard"
}
return s.Enforce
}

// isDevkitMCPTool identifies tools that are part of the devkit MCP
// server's own surface — and therefore safe to allow during a command
// or prompt step because they drive the engine, not the agent.
Expand Down Expand Up @@ -319,7 +308,10 @@ func runPreToolGuard() {
}
tool = t
}
enforce := effectiveEnforce(state)
// state.StepEnforce is guaranteed valid ("hard" or "soft") by
// SessionState.UnmarshalJSON — ReadSessionJSON would have rejected
// a stale/corrupt session with a missing or invalid enforce field.
enforce := state.StepEnforce

// attemptedTool is what we print in veto diagnostics. An empty
// tool name means we failed to parse it from stdin (or got an
Expand All @@ -332,7 +324,7 @@ func runPreToolGuard() {

switch state.StepType {
case "command":
if enforce != "hard" {
if enforce != lib.EnforceHard {
guardExit(0)
return
}
Expand All @@ -347,7 +339,7 @@ func runPreToolGuard() {
return

case "prompt":
if enforce == "hard" {
if enforce == lib.EnforceHard {
if isDevkitMCPTool(tool) {
guardExit(0)
return
Expand Down Expand Up @@ -444,7 +436,7 @@ func runStopGuard() {
// state == nil handles the TOCTOU where the file was removed
// between sessionFileExists and ReadSessionJSON.
// Stop is enforce-agnostic — any running workflow blocks Stop
// regardless of soft/hard — so we don't consult effectiveEnforce.
// regardless of soft/hard — so we don't branch on state.StepEnforce.
writeStopVerdict(stopVerdict{Decision: "approve"})
guardExit(0)
return
Expand Down
Loading
Loading