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
17 changes: 14 additions & 3 deletions go/cmd/coding-ethos-run/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
}
Expand Down
57 changes: 45 additions & 12 deletions go/internal/agenthooks/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -1470,7 +1470,7 @@ func codexHookProbes() []hookProbe {
"tool": "functions.exec_command",
"input": {"cmd": "git switch main"}
}`,
validate: validateCodexBlockProbe,
validate: validateCodexGitPolicyBlockProbe,
},
{
provider: string(ProviderCodex),
Expand All @@ -1482,7 +1482,7 @@ func codexHookProbes() []hookProbe {
"tool": "exec_command",
"input": {"command": "/usr/bin/git status --short"}
}`,
validate: validateCodexBlockProbe,
validate: validateCodexWrapperRefusalProbe,
},
{
provider: string(ProviderCodex),
Expand All @@ -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,
},
}
}
Expand Down Expand Up @@ -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")
}
Expand All @@ -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(
Expand Down
10 changes: 6 additions & 4 deletions go/internal/e2e/agent_hook_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`,
Expand All @@ -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"`,
Expand Down
10 changes: 1 addition & 9 deletions go/internal/hookcli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
12 changes: 8 additions & 4 deletions go/internal/hookcli/main_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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{}
Expand Down
14 changes: 13 additions & 1 deletion go/internal/toolchaincli/cutover_verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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)
}
Expand Down
Loading