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/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{} diff --git a/go/internal/toolchaincli/cutover_verify.go b/go/internal/toolchaincli/cutover_verify.go index 86d6ad83..2ebde07c 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,18 @@ 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) }