From cd7f4ba7963657b615afc787b4ab2fff14171f53 Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Mon, 6 Jul 2026 13:31:05 -0600 Subject: [PATCH 1/3] fix(toolchain): scrub exec-guard stack for cutover verify children runCutoverCommand spawned coding-ethos-run children (agent-hooks verify, policy-lint --scope cutover, git-hook validate) with the parent's full environment, so they inherited CODING_ETHOS_EXEC_STACK and exited 96 (recursive coding-ethos executable invocation blocked), failing every make cutover-verify and cutover-install run. Scrub the exec-guard stack variable before spawning, matching the other sanctioned spawn paths in mcp/cerun.go, gitwrap git_env.go, and agenthooks settings.go. Also scope the managed-lint target assertion in main_test.go to the argv after the -- separator: hook-managed sandboxes place go test TMPDIR under the repo, so temp paths in --bundle/--ethos-root flags legitimately contain the parent directory name and made the broad substring check fail. --- go/cmd/coding-ethos-run/main_test.go | 17 ++++++++++++++--- go/internal/toolchaincli/cutover_verify.go | 13 ++++++++++++- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/go/cmd/coding-ethos-run/main_test.go b/go/cmd/coding-ethos-run/main_test.go index 33015075..4b7bffff 100644 --- a/go/cmd/coding-ethos-run/main_test.go +++ b/go/cmd/coding-ethos-run/main_test.go @@ -2544,9 +2544,20 @@ func TestManagedLintUsesParentPythonTargetsForUnscopedFormatters(t *testing.T) { } } - for _, unwanted := range []string{"coding_ethos", "missing"} { - if strings.Contains(got, unwanted) { - t.Fatalf("managed lint calls contain %q:\n%s", unwanted, got) + // Only inspect the lint target list after the "--" separator. The + // surrounding argv carries sandbox temp paths that legitimately contain + // the parent repo directory name (for example "coding_ethos") when the + // tests run under hook-managed sandboxes. + for _, call := range calls { + _, targets, found := strings.Cut(call, " -- ") + if !found { + continue + } + + for _, unwanted := range []string{"coding_ethos", "missing"} { + if strings.Contains(targets, unwanted) { + t.Fatalf("managed lint call targets contain %q:\n%s", unwanted, call) + } } } } diff --git a/go/internal/toolchaincli/cutover_verify.go b/go/internal/toolchaincli/cutover_verify.go index 86d6ad83..0b70b4de 100644 --- a/go/internal/toolchaincli/cutover_verify.go +++ b/go/internal/toolchaincli/cutover_verify.go @@ -10,6 +10,7 @@ import ( "strings" "blackcat.ca/coding-ethos/go/internal/apperror" + "blackcat.ca/coding-ethos/go/internal/execguard" "blackcat.ca/coding-ethos/go/internal/safeexec" ) @@ -298,7 +299,17 @@ func runCutoverCommand(args []string, env map[string]string) (string, error) { command := safeexec.Command(args[0], args[1:]...) - command.Env = os.Environ() + // Verification children are legitimate nested coding-ethos invocations; + // without scrubbing the exec-guard stack they block themselves as + // recursive and every cutover verify fails. + command.Env = make([]string, 0, len(os.Environ())+len(env)) + stackPrefix := execguard.EnvStack + "=" + + for _, item := range os.Environ() { + if !strings.HasPrefix(item, stackPrefix) { + command.Env = append(command.Env, item) + } + } for key, value := range env { command.Env = append(command.Env, key+"="+value) } From 553848c0e38824d021e132fe69b6ae748747177e Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Mon, 6 Jul 2026 13:37:21 -0600 Subject: [PATCH 2/3] chore(toolchain): add blank line before env range loop for wsl_v5 --- go/internal/toolchaincli/cutover_verify.go | 1 + 1 file changed, 1 insertion(+) diff --git a/go/internal/toolchaincli/cutover_verify.go b/go/internal/toolchaincli/cutover_verify.go index 0b70b4de..2ebde07c 100644 --- a/go/internal/toolchaincli/cutover_verify.go +++ b/go/internal/toolchaincli/cutover_verify.go @@ -310,6 +310,7 @@ func runCutoverCommand(args []string, env map[string]string) (string, error) { command.Env = append(command.Env, item) } } + for key, value := range env { command.Env = append(command.Env, key+"="+value) } From ac7d7ae95f2e2a0a3b31ef4716bae125cf78bf69 Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Mon, 6 Jul 2026 14:54:43 -0600 Subject: [PATCH 3/3] fix(agenthooks): exit nonzero on provider blocks and match live probe shapes hookcli returned exit 0 for blocked codex and gemini agent-hook results whenever --json output was enabled (regression from 0a32d43), so cutover-verify's codex raw git probe reported allow even though the command was blocked. Blocked provider results now exit with the blocked exit code regardless of output mode. The codex probe validators also encoded remediation shapes that never matched the live compiled bundle: stronger policies such as git.checkout_protected_branch win over git.wrapper_required, and the wrapper refusal path carries no cerun template. Probe validation is now split into shape-specific checks (strict wrapper+cerun for plain git, git.* block for protected-branch switch, wrapper refusal for raw-path/bash-c/python-subprocess, hard block for tamper). --- go/internal/agenthooks/settings.go | 57 ++++++++++++++++++----- go/internal/e2e/agent_hook_test.go | 10 ++-- go/internal/hookcli/main.go | 10 +--- go/internal/hookcli/main_internal_test.go | 12 +++-- 4 files changed, 60 insertions(+), 29 deletions(-) diff --git a/go/internal/agenthooks/settings.go b/go/internal/agenthooks/settings.go index e473de24..864cffb2 100644 --- a/go/internal/agenthooks/settings.go +++ b/go/internal/agenthooks/settings.go @@ -1470,7 +1470,7 @@ func codexHookProbes() []hookProbe { "tool": "functions.exec_command", "input": {"cmd": "git switch main"} }`, - validate: validateCodexBlockProbe, + validate: validateCodexGitPolicyBlockProbe, }, { provider: string(ProviderCodex), @@ -1482,7 +1482,7 @@ func codexHookProbes() []hookProbe { "tool": "exec_command", "input": {"command": "/usr/bin/git status --short"} }`, - validate: validateCodexBlockProbe, + validate: validateCodexWrapperRefusalProbe, }, { provider: string(ProviderCodex), @@ -1494,21 +1494,21 @@ func codexHookProbes() []hookProbe { "tool": "exec_command", "input": {"command": "bash -c 'git status --short'"} }`, - validate: validateCodexBlockProbe, + validate: validateCodexWrapperRefusalProbe, }, { provider: string(ProviderCodex), event: eventPreToolUse, tool: "exec_command", payload: codexExecProbePayload(pythonSubprocessGitProbeCommand), - validate: validateCodexBlockProbe, + validate: validateCodexWrapperRefusalProbe, }, { provider: string(ProviderCodex), event: eventPreToolUse, tool: "exec_command", payload: codexExecProbePayload(hookTamperProbeCommand), - validate: validateCodexBlockProbe, + validate: validateCodexPolicyBlockProbe, }, } } @@ -1809,7 +1809,38 @@ func validateRewriteProbe(result hookProbeResult, provider string) error { return nil } +// validateCodexBlockProbe checks the managed rewrite-remediation block +// shape: the wrapper policy must block and carry a concrete cerun resubmit +// command. func validateCodexBlockProbe(result hookProbeResult) error { + return validateCodexBlockReason(result, "git.wrapper_required", "cerun --") +} + +// validateCodexGitPolicyBlockProbe checks that a git policy blocked the +// command. The winning policy id is configuration-dependent: semantic git +// policies such as git.checkout_protected_branch legitimately preempt the +// wrapper remediation for protected-branch targets. +func validateCodexGitPolicyBlockProbe(result hookProbeResult) error { + return validateCodexBlockReason(result, "git.") +} + +// validateCodexWrapperRefusalProbe checks the circumvention-refusal block +// shape: the wrapper policy refuses the command without offering a cerun +// resubmit template. +func validateCodexWrapperRefusalProbe(result hookProbeResult) error { + return validateCodexBlockReason(result, "git.wrapper_required") +} + +// validateCodexPolicyBlockProbe checks that enforcement hard-blocked the +// command with an actionable reason, regardless of which policy fired. +func validateCodexPolicyBlockProbe(result hookProbeResult) error { + return validateCodexBlockReason(result) +} + +func validateCodexBlockReason( + result hookProbeResult, + reasonMarkers ...string, +) error { if result.exitCode == 0 { return apperror.StaticError("codex raw git probe should block") } @@ -1833,13 +1864,15 @@ func validateCodexBlockProbe(result hookProbeResult) error { ) } - if !strings.Contains(reason, "git.wrapper_required") || - !strings.Contains(reason, "cerun --") { - return apperror.Wrapf( - apperror.StaticError("codex block reason lost git remediation: %s"), - "codex block reason lost git remediation: %s", - reason, - ) + for _, marker := range reasonMarkers { + if !strings.Contains(reason, marker) { + return apperror.Wrapf( + apperror.StaticError("codex block reason lost marker %q: %s"), + "codex block reason lost marker %q: %s", + marker, + reason, + ) + } } permissionReason, found := nestedString( diff --git a/go/internal/e2e/agent_hook_test.go b/go/internal/e2e/agent_hook_test.go index f20b54e1..d3a2f442 100644 --- a/go/internal/e2e/agent_hook_test.go +++ b/go/internal/e2e/agent_hook_test.go @@ -62,8 +62,9 @@ func agentHookFixtureCases() []agentHookFixtureCase { }, }, { - name: "codex pretool read-only git denial", - fixture: "codex-pretool-git-status.json", + name: "codex pretool read-only git denial", + fixture: "codex-pretool-git-status.json", + wantExit: 1, wantStdout: []string{ `"decision": "block"`, `"permissionDecision": "deny"`, @@ -72,8 +73,9 @@ func agentHookFixtureCases() []agentHookFixtureCase { }, }, { - name: "codex pretool git bypass denial", - fixture: "codex-pretool-git-bypass.json", + name: "codex pretool git bypass denial", + fixture: "codex-pretool-git-bypass.json", + wantExit: 1, wantStdout: []string{ `"decision": "block"`, `"permissionDecision": "deny"`, diff --git a/go/internal/hookcli/main.go b/go/internal/hookcli/main.go index ae34b26c..ce6120d8 100644 --- a/go/internal/hookcli/main.go +++ b/go/internal/hookcli/main.go @@ -110,20 +110,12 @@ func runWithIO(args []string, stdin io.Reader, stdout, stderr io.Writer) int { printBlocked(stderr, result) } - return blockedResultExitCode(result, *jsonOutput) + return blockedExitCode } return 0 } -func blockedResultExitCode(result hooks.Result, jsonOutput bool) int { - if jsonOutput && result.Provider != "" { - return 0 - } - - return blockedExitCode -} - func persistHookResult(event hooks.Event, result hooks.Result) error { err := hooks.WriteAgentHookTraceFromEnv(event, result) if err != nil { diff --git a/go/internal/hookcli/main_internal_test.go b/go/internal/hookcli/main_internal_test.go index 3eda0b4f..ce9986a1 100644 --- a/go/internal/hookcli/main_internal_test.go +++ b/go/internal/hookcli/main_internal_test.go @@ -29,8 +29,8 @@ func TestHookCLIBlocksBashBypass(t *testing.T) { "command": "git commit --no-verify -m test", }), ) - if status != 0 { - t.Fatalf("status mismatch: got %d", status) + if status != blockedExitCode { + t.Fatalf("status mismatch: got %d, want %d", status, blockedExitCode) } if !hookOutputDenies(result) { @@ -139,8 +139,12 @@ func TestRunWithIOBlocksBashBypass(t *testing.T) { &stdout, &stderr, ) - if status != 0 { - t.Fatalf("status = %d, want provider JSON deny exit 0", status) + if status != blockedExitCode { + t.Fatalf( + "status = %d, want blocked provider JSON deny exit %d", + status, + blockedExitCode, + ) } result := map[string]any{}