From e3c8f4da3ab4c85d2260038ac688d103c8596cbd Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Thu, 23 Jul 2026 17:21:55 -0600 Subject: [PATCH 01/13] feat(agenthooks): add supervisor hook contract --- README.md | 81 +- docs/HOOK_CONTRACT_V1.md | 165 ++++ docs/PROVIDER_CAPABILITY_MATRIX.md | 77 ++ go/cmd/coding-ethos-run/args.go | 21 +- go/cmd/coding-ethos-run/dispatch.go | 20 +- go/cmd/coding-ethos-run/main_test.go | 49 + go/cmd/coding-ethos-run/parent_workflow.go | 37 + .../agenthooks/doctor_contract_test.go | 4 + .../agenthooks/provider_capabilities.go | 112 +++ go/internal/agenthooks/settings.go | 916 +++++++----------- go/internal/agenthooks/settings_probe.go | 748 ++++++++++++++ go/internal/agenthooks/settings_test.go | 319 +++++- go/internal/agenthooks/spec.go | 2 + go/internal/agenthooks/state_artifacts.go | 70 +- go/internal/agenthookscli/main.go | 171 +++- .../agenthookscli/main_internal_test.go | 142 +++ go/internal/hookcli/main.go | 117 ++- go/internal/hookcli/main_internal_test.go | 176 ++++ go/internal/hooks/contract_v1.go | 432 +++++++++ go/internal/hooks/contract_v1_test.go | 171 ++++ go/internal/hooks/event.go | 9 + go/internal/hooks/inspection.go | 9 +- go/internal/hooks/json.go | 52 +- go/internal/hooks/provider_output.go | 38 + go/internal/hooks/provider_output_test.go | 40 + go/internal/hooks/result.go | 22 +- go/internal/hooks/runner.go | 13 +- .../hooks/testdata/neutral_v1_allowed.json | 19 + .../hooks/testdata/neutral_v1_blocked.json | 25 + go/internal/syncstate/state.go | 5 + go/internal/toolaliases/aliases.go | 16 +- go/internal/toolaliases/aliases_test.go | 8 + 32 files changed, 3448 insertions(+), 638 deletions(-) create mode 100644 docs/HOOK_CONTRACT_V1.md create mode 100644 go/internal/agenthooks/settings_probe.go create mode 100644 go/internal/hooks/contract_v1.go create mode 100644 go/internal/hooks/contract_v1_test.go create mode 100644 go/internal/hooks/testdata/neutral_v1_allowed.json create mode 100644 go/internal/hooks/testdata/neutral_v1_blocked.json diff --git a/README.md b/README.md index 7219bfb6..8e881798 100644 --- a/README.md +++ b/README.md @@ -1566,6 +1566,7 @@ bin/coding-ethos-run agent-hooks sync bin/coding-ethos-run agent-hooks sync --root /path/to/repo --ethos-root . --dry-run --format toon bin/coding-ethos-run agent-hooks doctor bin/coding-ethos-run agent-hooks verify +bin/coding-ethos-run agent-hooks capabilities --json ``` Agent hook generation is all-or-nothing. `sync` writes every supported @@ -1574,6 +1575,48 @@ MCP setup, generated targets, memory behavior, response shapes, and unsupported surfaces are generated from the registry into [Provider Capability Matrix](docs/PROVIDER_CAPABILITY_MATRIX.md). +Supervisors can keep provider settings outside the target checkout. In overlay +mode, `--root` is the private settings/state root and `--repo-root` is the +actual repository used as the hook probe working directory: + +```bash +bin/coding-ethos-run agent-hooks sync \ + --root /private/settings-overlay \ + --repo-root /path/to/repo +bin/coding-ethos-run agent-hooks verify \ + --root /private/settings-overlay \ + --repo-root /path/to/repo +``` + +Omitting `--repo-root` preserves the repo-local behavior. Capability discovery +reports both flags and `supports_private_overlay: true`. + +An external supervisor can own provider hook execution while Coding Ethos keeps +ownership of MCP and code intelligence. Pass the same split commands to +`sync`, `doctor`, and `verify`: + +```bash +bin/coding-ethos-run agent-hooks sync \ + --root /private/settings-overlay \ + --repo-root /path/to/repo \ + --hook-command 'env NYAR_HOME=/private/nyar NYAR_CODING_ETHOS_ROOT=/opt/coding-ethos /absolute/path/nyar hook' \ + --mcp-command '/opt/coding-ethos/bin/coding-ethos-run mcp' +bin/coding-ethos-run agent-hooks verify \ + --root /private/settings-overlay \ + --repo-root /path/to/repo \ + --hook-command 'env NYAR_HOME=/private/nyar NYAR_CODING_ETHOS_ROOT=/opt/coding-ethos /absolute/path/nyar hook' \ + --mcp-command '/opt/coding-ethos/bin/coding-ethos-run mcp' +``` + +`--hook-command` accepts one static command: the existing +`coding-ethos-run agent-hook`, or an absolute external `... hook` executable +optionally prefixed by `env KEY=value ...`. Shell operators, substitutions, +redirects, background execution, raw leading assignments, and relative +external executables are rejected. An explicit `--mcp-command` must be exactly +an absolute `coding-ethos-run mcp` command. When `--mcp-command` is omitted, +the current MCP command is still derived from `--hook-command`; this preserves +existing repo-local behavior. + Codex runs one native command hook per supported event so current Codex sessions enter the same policy runtime without depending on unstable tool matcher names. Generated Codex config does not inline `PATH=` mutations, @@ -1585,9 +1628,11 @@ reports. The same sync path also installs the local `coding-ethos` MCP server for all supported agents. Claude receives a project `.mcp.json` entry, Codex receives a managed `[mcp_servers.coding-ethos]` block in `.codex/config.toml`, and Gemini -receives a `mcpServers.coding-ethos` entry in `.gemini/settings.json`. `doctor` -checks those entries along with hooks so MCP drift is not a separate hidden -setup step. +receives a `mcpServers.coding-ethos` entry in `.gemini/settings.json`. Kimi +receives managed hooks in `.kimi-code/config.toml` and the MCP server in +`.kimi-code/mcp.json`; launch it with `KIMI_CODE_HOME` set to that generated +overlay. `doctor` checks those entries along with hooks so MCP drift is not a +separate hidden setup step. Generated ETHOS skills and native agent settings use the same managed-output model. `make build` refreshes the checkout-local skill surfaces, hook settings, @@ -1604,13 +1649,16 @@ repo-local surface. Providers that cannot rewrite a memory file tool request get a `memory.centralized` denial that points at the allowed memory path instead of silently writing durable notes into provider-private state. -`agent-hooks verify` runs doctor first, then invokes the configured hook command -with provider-native Claude, Codex, and Gemini payloads. The probes cover: +`agent-hooks verify` runs doctor first, then safely invokes the configured hook +command—including an external supervisor wrapper—with provider-native Claude, +Codex, Gemini, and Kimi payloads. The probes cover: - Claude transparent Git wrapper rewrite - Codex blocks for raw Git, absolute Git paths, nested shell Git, and Python subprocess Git when rewrite is unavailable - Gemini deny responses for raw shell Git and write-tool policy denial +- Kimi exit-2 policy denial with a stderr reason and structured-deny Stop + continuation - managed hook-binary tampering: `rm ...coding-ethos-git-hook && go build -o ...coding-ethos-git-hook` @@ -1669,10 +1717,25 @@ policy failures, not ordinary lint failures. Blocked tamper and Git-bypass responses use the normal structured provider output with a policy-specific finding and remediation that points back to the approved git workflow. -Agent hook JSON mode writes the hook result to stdout and keeps stderr reserved -for runner/configuration errors. A blocked provider decision exits with code 1 -and carries the denial details in the JSON result instead of duplicating a -second compact denial line on stderr. +Agent hook JSON mode keeps the existing provider-native response as its default. +Existing blocked decisions exit with code 1 and carry denial details in JSON. +Kimi policy denials use its native exit code 2 plus a stderr reason; Kimi Stop +guidance uses a successful structured deny so the agent can continue the turn +once. + +Supervisors select the stable provider-neutral contract explicitly: + +```bash +bin/coding-ethos-run agent-hook --json --contract neutral-v1 < event.json +``` + +The v1 response includes `contract_version`, `correlation_id`, a normalized +event identity, `decision`, `effect`, policy decisions/advice, optional +`updated_input`, and the denial tracking ID. The request accepts the normal +hook event shape plus optional `contract_version` and `correlation_id`. +Provider-native output remains unchanged unless the selector (or +`CODE_ETHOS_HOOK_CONTRACT=neutral-v1`) is present. See +[Provider-Neutral Hook Contract v1](docs/HOOK_CONTRACT_V1.md). Provider output uses the strongest native shape each agent supports: the generated [Provider Capability Matrix](docs/PROVIDER_CAPABILITY_MATRIX.md) diff --git a/docs/HOOK_CONTRACT_V1.md b/docs/HOOK_CONTRACT_V1.md new file mode 100644 index 00000000..23784bb5 --- /dev/null +++ b/docs/HOOK_CONTRACT_V1.md @@ -0,0 +1,165 @@ + + + +# Provider-Neutral Hook Contract v1 + +The provider-neutral hook contract is the stable process boundary for +supervisors that need coding-ethos decisions without depending on Claude, +Codex, Gemini, or Kimi response schemas. + +Provider-native output remains the default. Select v1 explicitly: + +```bash +bin/coding-ethos-run agent-hook --json --contract neutral-v1 < event.json +``` + +The equivalent validated environment setting is +`CODE_ETHOS_HOOK_CONTRACT=neutral-v1`. An unknown selector fails before policy +evaluation. + +## Request + +The request is the existing normalized hook event object. These two fields are +optional additions: + +- `contract_version`: `coding-ethos.hook/v1`; declaring it enables strict + canonical-field validation. +- `correlation_id`: an operator-provided identifier of at most 128 bytes. The + runtime generates a `hook-...` identifier when it is absent. + +Canonical v1 field names are: + +```json +{ + "contract_version": "coding-ethos.hook/v1", + "correlation_id": "lane-01-turn-42-hook-03", + "provider": "claude", + "hook_event_name": "PreToolUse", + "session_id": "provider-session-id", + "cwd": "/path/to/repo", + "tool_name": "Bash", + "tool_input": { + "command": "git status --short" + } +} +``` + +Requests are bounded to 1 MiB before decoding. A declared v1 request rejects +unknown top-level fields, unknown providers/events, overlong identifiers, +control characters in identifiers and paths, trailing JSON values, and an +unsupported contract version. Requests that do not declare +`contract_version` retain the provider alias normalization used by current +hooks. + +## Response + +Every v1 response is a single JSON object: + +```json +{ + "contract_version": "coding-ethos.hook/v1", + "correlation_id": "lane-01-turn-42-hook-03", + "event": { + "name": "PreToolUse", + "provider": "claude", + "tool": "Bash" + }, + "decision": "deny", + "effect": { + "action": "block", + "reason": "policy-grounded denial" + }, + "status": "blocked", + "tracking_id": "hook-0123456789abcdef", + "decisions": [], + "advice": {}, + "runtime_ms": 4 +} +``` + +`decision` is `allow` or `deny`. `effect.action` is one of: + +- `allow`: proceed without changing provider input. +- `rewrite`: use `effect.updated_input` before the provider executes the tool. +- `block`: reject the requested operation using `effect.reason`. +- `continue`: reject a premature `Stop` and continue the current turn using + `effect.reason`. + +`effect.additional_context` is advisory context for the current event. +`tracking_id` is present on policy denials and connects the response to +coding-ethos remediation and trace evidence. + +The neutral process exits 0 for `allow` and 1 for `deny`, independent of the +source provider. Provider-native modes retain their provider-specific exit +semantics. + +## Capability Discovery + +Discover the runtime version, contract selector, input limit, supported events, +effects, provider adapters, and private-overlay flags without loading DuckDB: + +```bash +bin/coding-ethos-run agent-hooks capabilities --json +``` + +The response schema is `coding-ethos.agent-hooks/v1`. `runtime_version` comes +from the checkout's `pyproject.toml`. The command is read-only and does not +require a policy bundle or code-intelligence store. The report advertises +`mcp_command_flag: "--mcp-command"` alongside the settings and repository root +flags. + +## Kimi Native Semantics + +Kimi settings are generated under `.kimi-code/` in the selected settings root. +Set `KIMI_CODE_HOME` to that directory when starting Kimi. + +- Policy denials exit with code 2 and write the reason to stderr. +- A structured `hookSpecificOutput.permissionDecision = deny` is also emitted. +- Stop checkpoint guidance exits successfully with a structured deny. Kimi + injects the reason and continues the model once. +- Other non-zero Kimi hook exits are fail-open by provider design. + +For a settings overlay separate from the repository: + +```bash +bin/coding-ethos-run agent-hooks sync \ + --root /private/settings-overlay \ + --repo-root /path/to/repo +bin/coding-ethos-run agent-hooks verify \ + --root /private/settings-overlay \ + --repo-root /path/to/repo +``` + +Provider settings and install state are written under the first path. Skill +checks and runnable hook probes use the second path. + +When a provider-neutral supervisor owns hook execution, keep Coding Ethos as +the MCP and code-intelligence owner with separate commands: + +```bash +bin/coding-ethos-run runtime-policy sync --repo /path/to/repo +bin/coding-ethos-run agent-hooks sync \ + --root /private/settings-overlay \ + --repo-root /path/to/repo \ + --hook-command 'env NYAR_HOME=/private/nyar NYAR_CODING_ETHOS_ROOT=/opt/coding-ethos /absolute/path/nyar hook' \ + --mcp-command '/opt/coding-ethos/bin/coding-ethos-run mcp' +bin/coding-ethos-run agent-hooks verify \ + --root /private/settings-overlay \ + --repo-root /path/to/repo \ + --hook-command 'env NYAR_HOME=/private/nyar NYAR_CODING_ETHOS_ROOT=/opt/coding-ethos /absolute/path/nyar hook' \ + --mcp-command '/opt/coding-ethos/bin/coding-ethos-run mcp' +bin/coding-ethos-run runtime-policy check --repo /path/to/repo +``` + +Pass both flags unchanged to `doctor` as well. The external hook form is one +statically parsed command with an absolute executable and `hook` subcommand. +A leading `env KEY=value ...` is supported. Operators, substitutions, +redirects, background execution, raw leading assignments, and relative +external executables are rejected. The explicit MCP form is exactly an +absolute `coding-ethos-run mcp`; omitting it preserves the existing derivation +from `coding-ethos-run agent-hook`. Verification sends all provider-native +smoke payloads through the external supervisor command, while generated +Claude, Codex, Gemini, and Kimi MCP entries continue to invoke Coding Ethos +directly. `runtime-policy sync/check` owns only the consumer-scoped compiled +bundle below Git metadata; it does not generate or rewrite tracked repository +configuration. diff --git a/docs/PROVIDER_CAPABILITY_MATRIX.md b/docs/PROVIDER_CAPABILITY_MATRIX.md index f86deb2b..3a1c8a09 100644 --- a/docs/PROVIDER_CAPABILITY_MATRIX.md +++ b/docs/PROVIDER_CAPABILITY_MATRIX.md @@ -16,6 +16,7 @@ It lists supported, partially supported, and unsupported adapter surfaces. | `claude` | Claude Code | full | | `codex` | Codex | partial | | `gemini` | Gemini CLI | partial | +| `kimi` | Kimi Code CLI | partial | | `generic` | Generic fallback | unsupported | ## Provider Details @@ -205,6 +206,82 @@ Safety caveats: - none +### Kimi Code CLI + +- Provider id: `kimi` +- Coverage: partial +- Settings target: .kimi-code/config.toml +- MCP setup: .kimi-code/mcp.json stdio server in the generated KIMI_CODE_HOME overlay +- Block response shape: exit 2 with stderr reason or hookSpecificOutput.permissionDecision = deny +- Context/advice shape: message for context; Stop deny continues the turn once +- Memory interception: central memory guidance through portable AGENTS.md +- Memory fallback: read and write .coding-ethos/memories/MEMORY.md +- Verification: `TestSyncAndVerifySettingsRunsProviderSmokePayloads` + +Native settings: + +- .kimi-code/config.toml +- .kimi-code/mcp.json + +Hook events: + +- PreToolUse +- PostToolUse +- PostToolUseFailure +- PermissionRequest +- PermissionResult +- UserPromptSubmit +- Stop +- StopFailure +- Interrupt +- SessionStart +- SessionEnd +- SubagentStart +- SubagentStop +- PreCompact +- PostCompact +- Notification + +Generated targets: + +- AGENTS.md +- .agents/skills/*/SKILL.md +- .kimi-code/config.toml +- .kimi-code/mcp.json + +Supported surfaces: + +- PreToolUse block +- PostToolUse context +- PostToolUseFailure observation +- PermissionRequest observation +- PermissionResult observation +- UserPromptSubmit block and context +- Stop continuation through deny +- SessionStart context +- SessionEnd observation +- SubagentStart observation +- SubagentStop observation +- PreCompact observation +- PostCompact observation +- Notification observation +- MCP stdio server + +Partially supported surfaces: + +- hook command failures other than exit 2 are fail-open in Kimi +- only PreToolUse, UserPromptSubmit, and Stop are blockable by Kimi + +Unsupported surfaces: + +- PreToolUse updatedInput rewrite +- provider-native skill generation + +Safety caveats: + +- launch Kimi with KIMI_CODE_HOME set to the generated .kimi-code overlay +- Kimi hooks are not a substitute for provider permission approval + ### Generic fallback - Provider id: `generic` diff --git a/go/cmd/coding-ethos-run/args.go b/go/cmd/coding-ethos-run/args.go index ecf20305..85288755 100644 --- a/go/cmd/coding-ethos-run/args.go +++ b/go/cmd/coding-ethos-run/args.go @@ -137,6 +137,19 @@ func withDefaultHookCommand(paths runtimePaths, args []string) []string { return next } +func agentHooksArgs(paths runtimePaths, args []string) []string { + if len(args) == 0 || args[0] != "capabilities" { + return withDefaultHookCommand(paths, args) + } + + next := append([]string(nil), args...) + if !hasFlag(args, "--ethos-root") { + next = append(next, "--ethos-root", paths.EthosRoot) + } + + return next +} + func hasFlag(args []string, name string) bool { for _, arg := range args { if arg == name || strings.HasPrefix(arg, name+"=") { @@ -148,12 +161,16 @@ func hasFlag(args []string, name string) bool { } func rootFlagValue(args []string, fallback string) string { + return flagValue(args, "--root", fallback) +} + +func flagValue(args []string, name, fallback string) string { for index, arg := range args { - if arg == "--root" && index+1 < len(args) { + if arg == name && index+1 < len(args) { return args[index+1] } - if value, ok := strings.CutPrefix(arg, "--root="); ok { + if value, ok := strings.CutPrefix(arg, name+"="); ok { return value } } diff --git a/go/cmd/coding-ethos-run/dispatch.go b/go/cmd/coding-ethos-run/dispatch.go index a5457bd4..542b9c00 100644 --- a/go/cmd/coding-ethos-run/dispatch.go +++ b/go/cmd/coding-ethos-run/dispatch.go @@ -106,6 +106,7 @@ func runCommandEntries() []runCommandEntry { {Command: "parent-install", Handler: runParentInstall}, {Command: "parent-check", Handler: runParentCheck}, {Command: "parent-lint", Handler: runParentLint}, + {Command: "runtime-policy", Handler: runRuntimePolicy}, {Command: "mcp", Handler: runMCPHandler}, } } @@ -157,6 +158,10 @@ func runHelpMessage() feedback.Message { {"policy-lint", "Internal compiled policy lint entrypoint."}, {"policy-tool ", "Run one managed captured tool."}, {"policy-tool-group ", "Run a managed tool group."}, + { + "runtime-policy sync|check --repo ", + "Manage only the consumer-scoped hook policy bundle.", + }, {"git-hook", "Git hook entrypoint; not for manual lint."}, }, ), @@ -972,13 +977,20 @@ func runAgentHook(paths runtimePaths, rest []string) { } func runAgentHooksCommand(paths runtimePaths, rest []string) { - installGitWrapperShim(paths) - installLintToolShims(paths) - _ = os.Setenv("CODE_ETHOS_CONSUMER_ROOT", rootFlagValue(rest, paths.Root)) + if len(rest) == 0 || rest[0] != "capabilities" { + installGitWrapperShim(paths) + installLintToolShims(paths) + } + + settingsRoot := rootFlagValue(rest, paths.Root) + _ = os.Setenv( + "CODE_ETHOS_CONSUMER_ROOT", + flagValue(rest, "--repo-root", settingsRoot), + ) runtimeExecTool( paths, "coding-ethos-agent-hooks", - withDefaultHookCommand(paths, rest)...) + agentHooksArgs(paths, rest)...) } func runPolicyTool(paths runtimePaths, rest []string) error { diff --git a/go/cmd/coding-ethos-run/main_test.go b/go/cmd/coding-ethos-run/main_test.go index 4b7bffff..9bec2a84 100644 --- a/go/cmd/coding-ethos-run/main_test.go +++ b/go/cmd/coding-ethos-run/main_test.go @@ -61,6 +61,18 @@ func TestRunnerArgsPreserveExplicitCommand(t *testing.T) { } } +func TestRuntimePolicyRequiresKnownAction(t *testing.T) { + t.Parallel() + + for _, args := range [][]string{nil, {"remove", "--repo", "/repo"}} { + err := runRuntimePolicy(runtimePaths{}, args) + if err == nil || + !strings.Contains(err.Error(), "runtime-policy requires sync or check") { + t.Fatalf("runRuntimePolicy(%#v) error = %v", args, err) + } + } +} + func TestDebugRunnerArgsStripInternalFlag(t *testing.T) { t.Parallel() @@ -190,6 +202,43 @@ func TestCodeIntelArgsKeepExplicitRoot(t *testing.T) { } } +func TestAgentHooksArgsInjectCapabilityEthosRootWithoutHookCommand(t *testing.T) { + t.Parallel() + + paths := runtimePaths{ + EthosRoot: "/opt/coding-ethos", + RunBinary: "/opt/coding-ethos/bin/coding-ethos-run", + } + + got := agentHooksArgs(paths, []string{"capabilities", "--json"}) + want := []string{ + "capabilities", + "--json", + "--ethos-root", + "/opt/coding-ethos", + } + if !slices.Equal(got, want) { + t.Fatalf("agentHooksArgs() = %#v, want %#v", got, want) + } +} + +func TestFlagValueReadsPrivateOverlayRepoRoot(t *testing.T) { + t.Parallel() + + args := []string{ + "sync", + "--root", + "/private/overlay", + "--repo-root=/repo", + } + if got := rootFlagValue(args, "/fallback"); got != "/private/overlay" { + t.Fatalf("rootFlagValue() = %q", got) + } + if got := flagValue(args, "--repo-root", "/fallback"); got != "/repo" { + t.Fatalf("repo-root flagValue() = %q", got) + } +} + func TestOutputArgsInsertRootAfterSubcommand(t *testing.T) { t.Parallel() diff --git a/go/cmd/coding-ethos-run/parent_workflow.go b/go/cmd/coding-ethos-run/parent_workflow.go index c23dab5f..e8656fa3 100644 --- a/go/cmd/coding-ethos-run/parent_workflow.go +++ b/go/cmd/coding-ethos-run/parent_workflow.go @@ -114,6 +114,43 @@ func runParentLint(paths runtimePaths, rest []string) error { return nil } +func runRuntimePolicy(paths runtimePaths, rest []string) error { + if len(rest) == 0 || (rest[0] != "sync" && rest[0] != "check") { + return apperror.StaticError( + "runtime-policy requires sync or check followed by --repo ", + ) + } + + action := rest[0] + + options, err := parseParentWorkflowFlags( + paths, + "runtime-policy "+action, + rest[1:], + ) + if err != nil { + return err + } + + step := runParentStep("policy_bundle", func() error { + if action == "sync" { + return syncParentPolicyBundle(paths, options) + } + + return checkParentPolicyBundle(paths, options) + }) + steps := []parentWorkflowStep{step} + printParentWorkflowReport( + "runtime-policy "+action, + parentStepStatus(steps), + options.Repo, + steps, + ) + exitForFailedParentSteps(steps) + + return nil +} + func parseParentWorkflowFlags( paths runtimePaths, command string, diff --git a/go/internal/agenthooks/doctor_contract_test.go b/go/internal/agenthooks/doctor_contract_test.go index 5c316759..2e367ea3 100644 --- a/go/internal/agenthooks/doctor_contract_test.go +++ b/go/internal/agenthooks/doctor_contract_test.go @@ -39,6 +39,10 @@ func TestDoctorProbesCoverProviderRewriteContracts(t *testing.T) { t.Fatalf("missing block doctor probe for %s", provider) } } + + if !blockProviders[string(ProviderKimi)] { + t.Fatal("missing block doctor probe for Kimi") + } } func TestClaudeDoctorRewriteRequiresRedirection(t *testing.T) { diff --git a/go/internal/agenthooks/provider_capabilities.go b/go/internal/agenthooks/provider_capabilities.go index 2baa9dc2..0e9b46d9 100644 --- a/go/internal/agenthooks/provider_capabilities.go +++ b/go/internal/agenthooks/provider_capabilities.go @@ -9,6 +9,8 @@ import ( "os" "path/filepath" "strings" + + "blackcat.ca/coding-ethos/go/internal/hooks" ) const ( @@ -25,10 +27,46 @@ func ProviderCapabilities() []ProviderCapability { claudeProviderCapability(), codexProviderCapability(), geminiProviderCapability(), + kimiProviderCapability(), genericProviderCapability(), } } +// AgentHooksAPIVersion identifies the capability-discovery response schema. +const AgentHooksAPIVersion = "coding-ethos.agent-hooks/v1" + +// CapabilityReport is the machine-readable agent-hook capability response. +type CapabilityReport struct { + APIVersion string `json:"api_version"` + RuntimeVersion string `json:"runtime_version"` + SettingsRootFlag string `json:"settings_root_flag"` + RepositoryRootFlag string `json:"repository_root_flag"` + MCPCommandFlag string `json:"mcp_command_flag"` + RuntimePolicyCommand string `json:"runtime_policy_command"` + + HookContracts []hooks.HookContractCapability `json:"hook_contracts"` + Providers []ProviderCapability `json:"providers"` + + SupportsPrivateOverlay bool `json:"supports_private_overlay"` +} + +// Capabilities returns versioned hook contracts and provider adapters. +func Capabilities(runtimeVersion string) CapabilityReport { + return CapabilityReport{ + APIVersion: AgentHooksAPIVersion, + RuntimeVersion: runtimeVersion, + HookContracts: []hooks.HookContractCapability{ + hooks.HookContractV1Capability(), + }, + Providers: ProviderCapabilities(), + SettingsRootFlag: "--root", + RepositoryRootFlag: "--repo-root", + MCPCommandFlag: "--mcp-command", + RuntimePolicyCommand: "runtime-policy", + SupportsPrivateOverlay: true, + } +} + func claudeProviderCapability() ProviderCapability { return ProviderCapability{ Provider: string(ProviderClaude), @@ -182,6 +220,80 @@ func geminiProviderCapability() ProviderCapability { } } +func kimiProviderCapability() ProviderCapability { + return ProviderCapability{ + Provider: string(ProviderKimi), + DisplayName: "Kimi Code CLI", + Coverage: "partial", + NativeFiles: []string{ + ".kimi-code/config.toml", + ".kimi-code/mcp.json", + }, + HookEvents: []string{ + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "PermissionRequest", + "PermissionResult", + "UserPromptSubmit", + "Stop", + "StopFailure", + "Interrupt", + "SessionStart", + "SessionEnd", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PostCompact", + "Notification", + }, + SettingsTarget: ".kimi-code/config.toml", + BlockResponseShape: "exit 2 with stderr reason or " + + "hookSpecificOutput.permissionDecision = deny", + ContextAdviceShape: "message for context; Stop deny continues the turn once", + MCPSetup: ".kimi-code/mcp.json stdio server in the generated " + + "KIMI_CODE_HOME overlay", + GeneratedTargets: []string{ + "AGENTS.md", + ".agents/skills/*/SKILL.md", + ".kimi-code/config.toml", + ".kimi-code/mcp.json", + }, + MemoryInterception: "central memory guidance through portable AGENTS.md", + MemoryFallback: "read and write .coding-ethos/memories/MEMORY.md", + Supported: []string{ + "PreToolUse block", + "PostToolUse context", + "PostToolUseFailure observation", + "PermissionRequest observation", + "PermissionResult observation", + "UserPromptSubmit block and context", + "Stop continuation through deny", + "SessionStart context", + "SessionEnd observation", + "SubagentStart observation", + "SubagentStop observation", + "PreCompact observation", + "PostCompact observation", + "Notification observation", + "MCP stdio server", + }, + ProviderLimited: []string{ + "hook command failures other than exit 2 are fail-open in Kimi", + "only PreToolUse, UserPromptSubmit, and Stop are blockable by Kimi", + }, + Unsupported: []string{ + "PreToolUse updatedInput rewrite", + "provider-native skill generation", + }, + SafetyCaveats: []string{ + "launch Kimi with KIMI_CODE_HOME set to the generated .kimi-code overlay", + "Kimi hooks are not a substitute for provider permission approval", + }, + VerificationFixture: "TestSyncAndVerifySettingsRunsProviderSmokePayloads", + } +} + func genericProviderCapability() ProviderCapability { return ProviderCapability{ Provider: string(ProviderGeneric), diff --git a/go/internal/agenthooks/settings.go b/go/internal/agenthooks/settings.go index 864cffb2..b902243b 100644 --- a/go/internal/agenthooks/settings.go +++ b/go/internal/agenthooks/settings.go @@ -6,33 +6,31 @@ package agenthooks import ( "bytes" - "context" "encoding/json" "errors" "fmt" "io" "maps" "os" - "os/exec" "path/filepath" "regexp" "slices" "strings" "time" + "github.com/pelletier/go-toml/v2" "go.yaml.in/yaml/v3" "blackcat.ca/coding-ethos/go/internal/apperror" - "blackcat.ca/coding-ethos/go/internal/execguard" "blackcat.ca/coding-ethos/go/internal/memories" - "blackcat.ca/coding-ethos/go/internal/safeexec" - "blackcat.ca/coding-ethos/go/internal/shellparse" "blackcat.ca/coding-ethos/go/internal/toolaliases" ) const ( codexConfigGrowth = 2 + kimiBlockExitCode = 2 mcpServerName = "coding-ethos" + minimumHookArgs = 2 probeTimeout = 30 * time.Second settingsDirMode = 0o755 settingsFileMode = 0o600 @@ -66,9 +64,13 @@ var ( errUnsupportedHookCommand = apperror.StaticError( "unsupported hook command for direct probe", ) + errUnsupportedMCPCommand = apperror.StaticError( + "unsupported Coding Ethos MCP command", + ) errCodexTrustMismatch = apperror.StaticError( "Codex user config does not trust generated project hooks", ) + externalEnvironmentName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) ) type commandHook struct { @@ -89,6 +91,16 @@ type claudeSettings struct { HooksConfig map[string]any `json:"hooksConfig,omitempty"` } +type kimiHook struct { + Event string `json:"event" toml:"event"` + Matcher string `json:"matcher,omitempty" toml:"matcher,omitempty"` + Command string `json:"command" toml:"command"` +} + +type kimiSettings struct { + Hooks []kimiHook `json:"hooks"` +} + type ProviderCapability struct { Provider string `json:"provider"` DisplayName string `json:"display_name"` @@ -113,6 +125,7 @@ type allSettings struct { Claude claudeSettings `json:"claude"` Codex claudeSettings `json:"codex"` Gemini claudeSettings `json:"gemini"` + Kimi kimiSettings `json:"kimi"` Capabilities []ProviderCapability `json:"capabilities"` } @@ -140,7 +153,30 @@ func (server mcpServer) geminiJSON() map[string]any { return payload } -func mcpServerConfig(hookCommand string) (mcpServer, error) { +func mcpServerConfig(hookCommand, mcpCommand string) (mcpServer, error) { + if strings.TrimSpace(mcpCommand) != "" { + command, err := staticSingleCommand(mcpCommand, errUnsupportedMCPCommand) + if err != nil { + return mcpServer{}, err + } + + if len(command.Argv) != 2 || + !filepath.IsAbs(command.Argv[0]) || + filepath.Base(command.Argv[0]) != "coding-ethos-run" || + command.Argv[1] != "mcp" { + return mcpServer{}, fmt.Errorf( + "%w: expected /absolute/path/coding-ethos-run mcp: %s", + errUnsupportedMCPCommand, + mcpCommand, + ) + } + + return mcpServer{ + Command: command.Argv[0], + Args: []string{"mcp"}, + }, nil + } + command, found := strings.CutSuffix(strings.TrimSpace(hookCommand), " agent-hook") if !found || strings.TrimSpace(command) == "" { return mcpServer{}, fmt.Errorf( @@ -162,6 +198,8 @@ type SettingsPaths struct { CodexConfig string CodexHooks string Gemini string + KimiConfig string + KimiMCP string } // VerifyReport describes the installed native hook surfaces and runnable smoke @@ -213,6 +251,8 @@ func DefaultSettingsPaths(root string) SettingsPaths { CodexConfig: filepath.Join(root, ".codex", "config.toml"), CodexHooks: filepath.Join(root, ".codex", "hooks.json"), Gemini: filepath.Join(root, ".gemini", "settings.json"), + KimiConfig: filepath.Join(root, ".kimi-code", "config.toml"), + KimiMCP: filepath.Join(root, ".kimi-code", "mcp.json"), } } @@ -235,17 +275,46 @@ func WriteSettings(writer io.Writer, hookCommand string) error { } func SyncSettings(root, hookCommand string) error { + return SyncSettingsForRepository(root, root, hookCommand) +} + +// SyncSettingsForRepository writes provider settings under settingsRoot while +// importing repository-owned memory from repoRoot. Keeping the roots separate +// supports private runtime overlays without writing provider config into the +// target checkout. +func SyncSettingsForRepository( + settingsRoot string, + repoRoot string, + hookCommand string, +) error { + return SyncSettingsForRepositoryWithMCPCommand( + settingsRoot, + repoRoot, + hookCommand, + "", + ) +} + +// SyncSettingsForRepositoryWithMCPCommand writes provider hook settings and +// keeps the Coding Ethos MCP command independent from an external supervisor +// hook command. +func SyncSettingsForRepositoryWithMCPCommand( + settingsRoot string, + repoRoot string, + hookCommand string, + mcpCommand string, +) error { settings, err := buildAllSettings(hookCommand) if err != nil { return err } - serverConfig, err := mcpServerConfig(hookCommand) + serverConfig, err := mcpServerConfig(hookCommand, mcpCommand) if err != nil { return err } - paths := DefaultSettingsPaths(root) + paths := DefaultSettingsPaths(settingsRoot) err = syncSettingsFile(paths.Claude, func(payload map[string]any) { payload["hooks"] = settings.Claude.Hooks @@ -269,7 +338,7 @@ func SyncSettings(root, hookCommand string) error { return err } - _, err = memories.ImportExisting(root) + _, err = memories.ImportExisting(repoRoot) if err != nil { return fmt.Errorf("import existing memories: %w", err) } @@ -283,6 +352,20 @@ func SyncSettings(root, hookCommand string) error { return err } + err = syncTextSettingsFile(paths.KimiConfig, func(content string) string { + return ensureKimiConfig(content, settings.Kimi) + }) + if err != nil { + return err + } + + err = syncSettingsFile(paths.KimiMCP, func(payload map[string]any) { + syncMCPServers(payload, serverConfig.geminiJSON()) + }) + if err != nil { + return err + } + return nil } @@ -704,17 +787,67 @@ func existingSettingsPayload(path string) (map[string]any, error) { } func DoctorSettings(root, hookCommand string) error { + return DoctorSettingsForRepository(root, root, hookCommand) +} + +// DoctorSettingsForRepository validates provider settings in settingsRoot and +// repository-owned memory surfaces in repoRoot. +func DoctorSettingsForRepository( + settingsRoot string, + repoRoot string, + hookCommand string, +) error { + return DoctorSettingsForRepositoryWithMCPCommand( + settingsRoot, + repoRoot, + hookCommand, + "", + ) +} + +// DoctorSettingsForRepositoryWithMCPCommand validates provider hooks against +// an external supervisor while retaining Coding Ethos as the MCP owner. +func DoctorSettingsForRepositoryWithMCPCommand( + settingsRoot string, + repoRoot string, + hookCommand string, + mcpCommand string, +) error { expected, err := buildAllSettings(hookCommand) if err != nil { return err } - expectedMCP, err := mcpServerConfig(hookCommand) + expectedMCP, err := mcpServerConfig(hookCommand, mcpCommand) if err != nil { return err } - paths := DefaultSettingsPaths(root) + paths := DefaultSettingsPaths(settingsRoot) + + err = doctorJSONSettings(paths, expected, expectedMCP) + if err != nil { + return err + } + + err = doctorTextSettings(paths, expected, expectedMCP) + if err != nil { + return err + } + + err = memories.Verify(repoRoot) + if err != nil { + return fmt.Errorf("verify memory surfaces: %w", err) + } + + return nil +} + +func doctorJSONSettings( + paths SettingsPaths, + expected allSettings, + expectedMCP mcpServer, +) error { checks := []struct { found func(map[string]any) bool path string @@ -729,6 +862,9 @@ func DoctorSettings(root, hookCommand string) error { {path: paths.ClaudeMCP, found: func(payload map[string]any) bool { return payloadContainsExpectedMCPServer(payload, expectedMCP.claudeJSON()) }}, + {path: paths.KimiMCP, found: func(payload map[string]any) bool { + return payloadContainsExpectedMCPServer(payload, expectedMCP.geminiJSON()) + }}, } for _, check := range checks { @@ -742,6 +878,14 @@ func DoctorSettings(root, hookCommand string) error { } } + return nil +} + +func doctorTextSettings( + paths SettingsPaths, + expected allSettings, + expectedMCP mcpServer, +) error { config, readErr := os.ReadFile(paths.CodexConfig) if readErr != nil { return fmt.Errorf("read Codex config: %w", readErr) @@ -759,9 +903,13 @@ func DoctorSettings(root, hookCommand string) error { return errSettingsMismatch } - err = memories.Verify(root) - if err != nil { - return fmt.Errorf("verify memory surfaces: %w", err) + kimiConfig, readErr := os.ReadFile(paths.KimiConfig) + if readErr != nil { + return fmt.Errorf("read Kimi config: %w", readErr) + } + + if !kimiConfigContainsExpectedHooks(string(kimiConfig), expected.Kimi) { + return errSettingsMismatch } return nil @@ -851,7 +999,38 @@ func codexConfigContainsExpectedMCPServer(content string, expected mcpServer) bo } func VerifySettings(root, hookCommand string) (VerifyReport, error) { - err := DoctorSettings(root, hookCommand) + return VerifySettingsForRepository(root, root, hookCommand) +} + +// VerifySettingsForRepository validates a private settings overlay, then runs +// provider probes and skill checks against the actual repository root. +func VerifySettingsForRepository( + settingsRoot string, + repoRoot string, + hookCommand string, +) (VerifyReport, error) { + return VerifySettingsForRepositoryWithMCPCommand( + settingsRoot, + repoRoot, + hookCommand, + "", + ) +} + +// VerifySettingsForRepositoryWithMCPCommand validates split supervisor-hook +// and Coding Ethos MCP ownership, then runs provider probes through the hook. +func VerifySettingsForRepositoryWithMCPCommand( + settingsRoot string, + repoRoot string, + hookCommand string, + mcpCommand string, +) (VerifyReport, error) { + err := DoctorSettingsForRepositoryWithMCPCommand( + settingsRoot, + repoRoot, + hookCommand, + mcpCommand, + ) if err != nil { return VerifyReport{ Status: verifyStatusInvalid, @@ -870,13 +1049,13 @@ func VerifySettings(root, hookCommand string) (VerifyReport, error) { Capabilities: ProviderCapabilities(), } - err = appendSkillSurfaceChecks(root, &report) + err = appendSkillSurfaceChecks(repoRoot, &report) if err != nil { return report, err } for _, probe := range hookProbes() { - result, err := runHookProbe(root, hookCommand, probe) + result, err := runHookProbe(repoRoot, hookCommand, probe) check := VerifyCheck{ Provider: probe.provider, @@ -1082,18 +1261,162 @@ func parseSkillFrontmatter(content string) (skillFrontmatter, error) { } func buildAllSettings(hookCommand string) (allSettings, error) { - if hookCommand == "" { + if strings.TrimSpace(hookCommand) == "" { return allSettings{}, errHookCommandRequired } + _, err := hookProbeArgs("", hookCommand) + if err != nil { + return allSettings{}, err + } + return allSettings{ Claude: buildClaudeSettings(RuntimeHookSpecs(), hookCommand), Codex: buildCodexSettings(RuntimeHookSpecs(), hookCommand), Gemini: buildGeminiSettings(RuntimeHookSpecs(), hookCommand), + Kimi: buildKimiSettings(RuntimeHookSpecs(), hookCommand), Capabilities: ProviderCapabilities(), }, nil } +func buildKimiSettings(specs []HookSpec, hookCommand string) kimiSettings { + command := strings.TrimSpace(hookCommand) + " --provider kimi" + settings := kimiSettings{ + Hooks: make([]kimiHook, 0, len(specs)+kimiObservationEventCount), + } + + for _, spec := range specs { + if spec.Event == "PostToolBatch" { + continue + } + + settings.Hooks = append(settings.Hooks, kimiHook{ + Event: spec.Event, + Matcher: spec.Tool, + Command: command, + }) + } + + for _, alias := range toolaliases.ProviderAliases( + toolaliases.ProviderKimi, + toolaliases.CanonicalNoop, + ) { + settings.Hooks = append( + settings.Hooks, + kimiHook{ + Event: eventPreToolUse, + Matcher: providerMatcher(alias), + Command: command, + }, + kimiHook{ + Event: eventPostToolUse, + Matcher: providerMatcher(alias), + Command: command, + }, + ) + } + + for _, event := range kimiObservationEvents() { + settings.Hooks = append(settings.Hooks, kimiHook{ + Event: event, + Command: command, + }) + } + + return settings +} + +const kimiObservationEventCount = 7 + +func kimiObservationEvents() []string { + return []string{ + "PostToolUseFailure", + eventPermissionRequest, + "PermissionResult", + "StopFailure", + "Interrupt", + "PostCompact", + "Notification", + } +} + +const ( + kimiManagedHooksStart = "# BEGIN coding-ethos managed Kimi hooks" + kimiManagedHooksEnd = "# END coding-ethos managed Kimi hooks" +) + +func ensureKimiConfig(content string, settings kimiSettings) string { + base := removeKimiManagedHooks(content) + + var builder strings.Builder + + builder.WriteString(strings.TrimRight(base, "\n")) + + if builder.Len() > 0 { + builder.WriteString("\n\n") + } + + builder.WriteString(kimiManagedHooksStart) + builder.WriteByte('\n') + + for _, hook := range settings.Hooks { + builder.WriteString("[[hooks]]\n") + builder.WriteString("event = " + tomlString(hook.Event) + "\n") + + if hook.Matcher != "" { + builder.WriteString("matcher = " + tomlString(hook.Matcher) + "\n") + } + + builder.WriteString("command = " + tomlString(hook.Command) + "\n\n") + } + + builder.WriteString(kimiManagedHooksEnd) + builder.WriteByte('\n') + + return builder.String() +} + +func removeKimiManagedHooks(content string) string { + lines := strings.Split(content, "\n") + output := make([]string, 0, len(lines)) + inManagedBlock := false + + for _, line := range lines { + switch strings.TrimSpace(line) { + case kimiManagedHooksStart: + inManagedBlock = true + case kimiManagedHooksEnd: + inManagedBlock = false + default: + if !inManagedBlock { + output = append(output, line) + } + } + } + + return strings.TrimRight(strings.Join(output, "\n"), "\n") +} + +func kimiConfigContainsExpectedHooks( + content string, + expected kimiSettings, +) bool { + var actual kimiSettings + + err := toml.Unmarshal([]byte(content), &actual) + if err != nil { + return false + } + + for _, expectedHook := range expected.Hooks { + if !slices.Contains(actual.Hooks, expectedHook) { + return false + } + } + + return true +} + func buildClaudeSettings(specs []HookSpec, hookCommand string) claudeSettings { hooks := make(map[string][]matcherHook) for _, spec := range specs { @@ -1388,560 +1711,3 @@ func containsMatcher(actual []matcherHook, expected matcherHook) bool { func containsCommandHook(actual []commandHook, expected commandHook) bool { return slices.Contains(actual, expected) } - -func hookProbes() []hookProbe { - probes := make([]hookProbe, 0, hookProbeCapacity) - probes = append(probes, claudeHookProbes()...) - probes = append(probes, codexHookProbes()...) - probes = append(probes, geminiHookProbes()...) - - return probes -} - -// HookProbeSummaries returns provider/payload metadata for doctor probes. -func HookProbeSummaries() []HookProbeSummary { - probes := hookProbes() - - summaries := make([]HookProbeSummary, 0, len(probes)) - for _, probe := range probes { - summaries = append(summaries, HookProbeSummary{ - Provider: probe.provider, - Payload: probe.payload, - }) - } - - return summaries -} - -const hookProbeCapacity = 11 - -const ( - hookTamperProbeCommand = "rm /repo/.git/coding-ethos-hooks/coding-ethos-git-hook" + - " && go build -o /repo/.git/coding-ethos-hooks/coding-ethos-git-hook ." - pythonSubprocessGitProbeCommand = "python3 -c " + - `"import subprocess; subprocess.run(['/usr/bin/git','status'])"` -) - -func claudeHookProbes() []hookProbe { - return []hookProbe{ - { - provider: string(ProviderClaude), - event: eventPreToolUse, - tool: toolaliases.CanonicalShell, - payload: `{ - "provider": "claude", - "hook_event_name": "PreToolUse", - "tool_name": "Bash", - "tool_input": {"command": "pwd && git status --short 2>&1"} - }`, - validate: validateClaudeRewriteProbe, - }, - { - provider: string(ProviderClaude), - event: eventPreToolUse, - tool: toolaliases.CanonicalShell, - payload: claudeBashProbePayload(hookTamperProbeCommand), - validate: validateClaudeBlockProbe, - }, - } -} - -func codexHookProbes() []hookProbe { - return []hookProbe{ - { - provider: string(ProviderCodex), - event: eventPreToolUse, - tool: "exec_command", - payload: `{ - "provider": "codex", - "event": "PreToolUse", - "tool": "exec_command", - "input": {"command": "git status --short"} - }`, - validate: validateCodexBlockProbe, - }, - { - provider: string(ProviderCodex), - event: eventPreToolUse, - tool: "functions.exec_command", - payload: `{ - "provider": "codex", - "event": "PreToolUse", - "tool": "functions.exec_command", - "input": {"cmd": "git switch main"} - }`, - validate: validateCodexGitPolicyBlockProbe, - }, - { - provider: string(ProviderCodex), - event: eventPreToolUse, - tool: "exec_command", - payload: `{ - "provider": "codex", - "event": "PreToolUse", - "tool": "exec_command", - "input": {"command": "/usr/bin/git status --short"} - }`, - validate: validateCodexWrapperRefusalProbe, - }, - { - provider: string(ProviderCodex), - event: eventPreToolUse, - tool: "exec_command", - payload: `{ - "provider": "codex", - "event": "PreToolUse", - "tool": "exec_command", - "input": {"command": "bash -c 'git status --short'"} - }`, - validate: validateCodexWrapperRefusalProbe, - }, - { - provider: string(ProviderCodex), - event: eventPreToolUse, - tool: "exec_command", - payload: codexExecProbePayload(pythonSubprocessGitProbeCommand), - validate: validateCodexWrapperRefusalProbe, - }, - { - provider: string(ProviderCodex), - event: eventPreToolUse, - tool: "exec_command", - payload: codexExecProbePayload(hookTamperProbeCommand), - validate: validateCodexPolicyBlockProbe, - }, - } -} - -func geminiHookProbes() []hookProbe { - return []hookProbe{ - { - provider: string(ProviderGemini), - event: eventBeforeTool, - tool: "run_shell_command", - payload: `{ - "provider": "gemini-cli", - "hookEventName": "BeforeTool", - "toolName": "run_shell_command", - "toolInput": {"command": "git status --short"} - }`, - validate: validateGeminiRewriteProbe, - }, - { - provider: string(ProviderGemini), - event: eventBeforeTool, - tool: "run_shell_command", - payload: geminiShellProbePayload(hookTamperProbeCommand), - validate: validateGeminiDenyProbe, - }, - { - provider: string(ProviderGemini), - event: eventBeforeTool, - tool: "write_file", - payload: `{ - "provider": "gemini-cli", - "hookEventName": "BeforeTool", - "toolName": "write_file", - "toolInput": { - "file_path": "/repo/.git/coding-ethos-hooks/coding-ethos-git-hook", - "content": "binary" - } - }`, - validate: validateGeminiDenyProbe, - }, - } -} - -func claudeBashProbePayload(command string) string { - return fmt.Sprintf(`{ - "provider": "claude", - "hook_event_name": "PreToolUse", - "tool_name": "Bash", - "tool_input": {"command": %q} - }`, command) -} - -func codexExecProbePayload(command string) string { - return fmt.Sprintf(`{ - "provider": "codex", - "event": "PreToolUse", - "tool": "exec_command", - "input": {"command": %q} - }`, command) -} - -func geminiShellProbePayload(command string) string { - return fmt.Sprintf(`{ - "provider": "gemini-cli", - "hookEventName": "BeforeTool", - "toolName": "run_shell_command", - "toolInput": {"command": %q} - }`, command) -} - -func runHookProbe( - root string, - hookCommand string, - probe hookProbe, -) (hookProbeResult, error) { - var ( - stdout bytes.Buffer - stderr bytes.Buffer - ) - - args, err := hookProbeArgs(root, hookCommand) - if err != nil { - return hookProbeResult{}, err - } - - ctx, cancel := context.WithTimeout(context.Background(), probeTimeout) - defer cancel() - - command := safeexec.CommandContext(ctx, args[0], args[1:]...) - command.Dir = root - command.Env = withoutHookProbeProcessState(os.Environ()) - command.Stdin = strings.NewReader(probe.payload) - command.Stdout = &stdout - command.Stderr = &stderr - - runErr := command.Run() - exitCode := 0 - - if runErr != nil { - var exitErr *exec.ExitError - if errors.As(runErr, &exitErr) { - exitCode = exitErr.ExitCode() - } else { - return hookProbeResult{}, fmt.Errorf("run hook probe: %w", runErr) - } - } - - if ctx.Err() != nil { - return hookProbeResult{}, fmt.Errorf("run hook probe: %w", ctx.Err()) - } - - result := hookProbeResult{ - exitCode: exitCode, - stdout: stdout.String(), - stderr: stderr.String(), - } - - if result.stdout != "" { - payload, decodeErr := decodeHookProbePayload(result.stdout) - if decodeErr != nil { - return result, decodeErr - } - - result.payload = payload - } - - return result, nil -} - -func withoutHookProbeProcessState(environ []string) []string { - const hookLoggingActiveEnv = "CODE_ETHOS_HOOK_LOGGING_ACTIVE" - - filtered := make([]string, 0, len(environ)) - for _, entry := range environ { - key, _, _ := strings.Cut(entry, "=") - if key == execguard.EnvStack || key == hookLoggingActiveEnv { - continue - } - - filtered = append(filtered, entry) - } - - return filtered -} - -func hookProbeArgs(root, hookCommand string) ([]string, error) { - commands, err := shellparse.Commands(hookCommand) - if err != nil { - return nil, fmt.Errorf("parse direct probe hook command: %w", err) - } - - if len(commands) != 1 || len(commands[0].Argv) < 2 { - return nil, fmt.Errorf("%w: %s", errUnsupportedHookCommand, hookCommand) - } - - argv := commands[0].Argv - - runnerPath := argv[0] - if filepath.Base(runnerPath) != "coding-ethos-run" || argv[1] != "agent-hook" { - return nil, fmt.Errorf("%w: %s", errUnsupportedHookCommand, hookCommand) - } - - if !filepath.IsAbs(runnerPath) { - runnerPath = filepath.Join(root, runnerPath) - } - - argv[0] = runnerPath - - return argv, nil -} - -func decodeHookProbePayload(output string) (map[string]any, error) { - decoder := json.NewDecoder(strings.NewReader(output)) - payload := map[string]any{} - - err := decoder.Decode(&payload) - if err != nil { - return nil, fmt.Errorf("decode hook probe JSON: %w", err) - } - - return payload, nil -} - -func validateClaudeRewriteProbe(result hookProbeResult) error { - err := validateRewriteProbe(result, "Claude") - if err != nil { - return err - } - - command, found := nestedString( - result.payload, - "hookSpecificOutput", - "updatedInput", - "command", - ) - if !found { - return apperror.Wrapf( - apperror.StaticError("missing Claude updatedInput command in %s"), - "missing Claude updatedInput command in %s", - result.stdout, - ) - } - - if !strings.Contains(command, "2>&1") { - return apperror.Wrapf( - apperror.StaticError("claude rewrite lost redirection: %s"), - "claude rewrite lost redirection: %s", - command, - ) - } - - return nil -} - -// ValidateClaudeRewritePayload validates Claude doctor rewrite output. -func ValidateClaudeRewritePayload(stdout string, payload map[string]any) error { - return validateClaudeRewriteProbe(hookProbeResult{ - exitCode: 0, - stdout: stdout, - payload: payload, - }) -} - -func validateCodexRewriteProbe(result hookProbeResult) error { - hookOutput, found := result.payload["hookSpecificOutput"].(map[string]any) - if found { - if _, hasUpdatedInput := hookOutput["updatedInput"]; hasUpdatedInput { - return apperror.Wrapf( - apperror.StaticError( - "codex rewrite emitted unsupported updatedInput in %s", - ), - "codex rewrite emitted unsupported updatedInput in %s", - result.stdout, - ) - } - } - - return validateCodexBlockProbe(result) -} - -// ValidateCodexRewritePayload validates Codex doctor rewrite output. -func ValidateCodexRewritePayload(stdout string, payload map[string]any) error { - return validateCodexRewriteProbe(hookProbeResult{ - exitCode: 0, - stdout: stdout, - payload: payload, - }) -} - -func validateGeminiRewriteProbe(result hookProbeResult) error { - return validateRewriteProbe(result, "Gemini") -} - -// ValidateGeminiRewritePayload validates Gemini doctor rewrite output. -func ValidateGeminiRewritePayload(stdout string, payload map[string]any) error { - return validateGeminiRewriteProbe(hookProbeResult{ - exitCode: 0, - stdout: stdout, - payload: payload, - }) -} - -func validateRewriteProbe(result hookProbeResult, provider string) error { - if result.exitCode != 0 { - return apperror.Wrapf( - apperror.StaticError("%s git rewrite probe should allow, got exit %d"), - "%s git rewrite probe should allow, got exit %d", - provider, - result.exitCode, - ) - } - - command, found := nestedString( - result.payload, - "hookSpecificOutput", - "updatedInput", - "command", - ) - if !found { - return apperror.Wrapf( - apperror.StaticError("missing %s updatedInput command in %s"), - "missing %s updatedInput command in %s", - provider, - result.stdout, - ) - } - - if !strings.Contains(command, "agent-shell --") || - !strings.Contains(command, "git status --short") { - return apperror.Wrapf( - apperror.StaticError("%s rewrite lost git wrapper or redirection: %s"), - "%s rewrite lost git wrapper or redirection: %s", - provider, - command, - ) - } - - 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") - } - - actual, found := result.payload["decision"].(string) - if !found || actual != "block" { - return apperror.Wrapf( - apperror.StaticError("decision = %q, want block; stdout=%s"), - "decision = %q, want block; stdout=%s", - actual, - result.stdout, - ) - } - - reason, found := result.payload["reason"].(string) - if !found || strings.TrimSpace(reason) == "" { - return apperror.Wrapf( - apperror.StaticError("missing reason in %s"), - "missing reason in %s", - result.stdout, - ) - } - - 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( - result.payload, - "hookSpecificOutput", - "permissionDecisionReason", - ) - if !found || strings.TrimSpace(permissionReason) == "" { - return apperror.Wrapf( - apperror.StaticError("missing permissionDecisionReason in %s"), - "missing permissionDecisionReason in %s", - result.stdout, - ) - } - - return nil -} - -func validateClaudeBlockProbe(result hookProbeResult) error { - return validateDecisionProbe(result, "block") -} - -func validateGeminiDenyProbe(result hookProbeResult) error { - if result.exitCode == 0 { - return apperror.StaticError("gemini probe should deny") - } - - return validateDecisionProbe(result, "deny") -} - -func validateDecisionProbe(result hookProbeResult, decision string) error { - actual, found := result.payload["decision"].(string) - if !found || actual != decision { - return apperror.Wrapf( - apperror.StaticError("decision = %q, want %q; stdout=%s"), - "decision = %q, want %q; stdout=%s", - actual, - decision, - result.stdout, - ) - } - - message, found := result.payload["systemMessage"].(string) - if !found || strings.TrimSpace(message) == "" { - return apperror.Wrapf( - apperror.StaticError("missing systemMessage in %s"), - "missing systemMessage in %s", - result.stdout, - ) - } - - return nil -} - -func nestedString(payload map[string]any, keys ...string) (string, bool) { - current := any(payload) - for _, key := range keys { - object, found := current.(map[string]any) - if !found { - return "", false - } - - current, found = object[key] - if !found { - return "", false - } - } - - value, found := current.(string) - - return value, found -} diff --git a/go/internal/agenthooks/settings_probe.go b/go/internal/agenthooks/settings_probe.go new file mode 100644 index 00000000..d9604307 --- /dev/null +++ b/go/internal/agenthooks/settings_probe.go @@ -0,0 +1,748 @@ +// SPDX-FileCopyrightText: 2026 Blackcat Informatics Inc. +// SPDX-License-Identifier: AGPL-3.0-only + +package agenthooks + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "blackcat.ca/coding-ethos/go/internal/apperror" + "blackcat.ca/coding-ethos/go/internal/execguard" + "blackcat.ca/coding-ethos/go/internal/safeexec" + "blackcat.ca/coding-ethos/go/internal/shellparse" + "blackcat.ca/coding-ethos/go/internal/toolaliases" +) + +func hookProbes() []hookProbe { + probes := make([]hookProbe, 0, hookProbeCapacity) + probes = append(probes, claudeHookProbes()...) + probes = append(probes, codexHookProbes()...) + probes = append(probes, geminiHookProbes()...) + probes = append(probes, kimiHookProbes()...) + + return probes +} + +// HookProbeSummaries returns provider/payload metadata for doctor probes. +func HookProbeSummaries() []HookProbeSummary { + probes := hookProbes() + + summaries := make([]HookProbeSummary, 0, len(probes)) + for _, probe := range probes { + summaries = append(summaries, HookProbeSummary{ + Provider: probe.provider, + Payload: probe.payload, + }) + } + + return summaries +} + +const hookProbeCapacity = 13 + +const ( + hookTamperProbeCommand = "rm /repo/.git/coding-ethos-hooks/coding-ethos-git-hook" + + " && go build -o /repo/.git/coding-ethos-hooks/coding-ethos-git-hook ." + pythonSubprocessGitProbeCommand = "python3 -c " + + `"import subprocess; subprocess.run(['/usr/bin/git','status'])"` +) + +func claudeHookProbes() []hookProbe { + return []hookProbe{ + { + provider: string(ProviderClaude), + event: eventPreToolUse, + tool: toolaliases.CanonicalShell, + payload: `{ + "provider": "claude", + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": {"command": "pwd && git status --short 2>&1"} + }`, + validate: validateClaudeRewriteProbe, + }, + { + provider: string(ProviderClaude), + event: eventPreToolUse, + tool: toolaliases.CanonicalShell, + payload: claudeBashProbePayload(hookTamperProbeCommand), + validate: validateClaudeBlockProbe, + }, + } +} + +func codexHookProbes() []hookProbe { + return []hookProbe{ + { + provider: string(ProviderCodex), + event: eventPreToolUse, + tool: "exec_command", + payload: `{ + "provider": "codex", + "event": "PreToolUse", + "tool": "exec_command", + "input": {"command": "git status --short"} + }`, + validate: validateCodexBlockProbe, + }, + { + provider: string(ProviderCodex), + event: eventPreToolUse, + tool: "functions.exec_command", + payload: `{ + "provider": "codex", + "event": "PreToolUse", + "tool": "functions.exec_command", + "input": {"cmd": "git switch main"} + }`, + validate: validateCodexGitPolicyBlockProbe, + }, + { + provider: string(ProviderCodex), + event: eventPreToolUse, + tool: "exec_command", + payload: `{ + "provider": "codex", + "event": "PreToolUse", + "tool": "exec_command", + "input": {"command": "/usr/bin/git status --short"} + }`, + validate: validateCodexWrapperRefusalProbe, + }, + { + provider: string(ProviderCodex), + event: eventPreToolUse, + tool: "exec_command", + payload: `{ + "provider": "codex", + "event": "PreToolUse", + "tool": "exec_command", + "input": {"command": "bash -c 'git status --short'"} + }`, + validate: validateCodexWrapperRefusalProbe, + }, + { + provider: string(ProviderCodex), + event: eventPreToolUse, + tool: "exec_command", + payload: codexExecProbePayload(pythonSubprocessGitProbeCommand), + validate: validateCodexWrapperRefusalProbe, + }, + { + provider: string(ProviderCodex), + event: eventPreToolUse, + tool: "exec_command", + payload: codexExecProbePayload(hookTamperProbeCommand), + validate: validateCodexPolicyBlockProbe, + }, + } +} + +func geminiHookProbes() []hookProbe { + return []hookProbe{ + { + provider: string(ProviderGemini), + event: eventBeforeTool, + tool: "run_shell_command", + payload: `{ + "provider": "gemini-cli", + "hookEventName": "BeforeTool", + "toolName": "run_shell_command", + "toolInput": {"command": "git status --short"} + }`, + validate: validateGeminiRewriteProbe, + }, + { + provider: string(ProviderGemini), + event: eventBeforeTool, + tool: "run_shell_command", + payload: geminiShellProbePayload(hookTamperProbeCommand), + validate: validateGeminiDenyProbe, + }, + { + provider: string(ProviderGemini), + event: eventBeforeTool, + tool: "write_file", + payload: `{ + "provider": "gemini-cli", + "hookEventName": "BeforeTool", + "toolName": "write_file", + "toolInput": { + "file_path": "/repo/.git/coding-ethos-hooks/coding-ethos-git-hook", + "content": "binary" + } + }`, + validate: validateGeminiDenyProbe, + }, + } +} + +func kimiHookProbes() []hookProbe { + return []hookProbe{ + { + provider: string(ProviderKimi), + event: eventPreToolUse, + tool: toolaliases.CanonicalShell, + payload: fmt.Sprintf(`{ + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": {"command": %q} + }`, hookTamperProbeCommand), + validate: validateKimiDenyProbe, + }, + { + provider: string(ProviderKimi), + event: eventStop, + payload: `{ + "hook_event_name": "Stop" + }`, + validate: validateKimiStopContinuationProbe, + }, + } +} + +func claudeBashProbePayload(command string) string { + return fmt.Sprintf(`{ + "provider": "claude", + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": {"command": %q} + }`, command) +} + +func codexExecProbePayload(command string) string { + return fmt.Sprintf(`{ + "provider": "codex", + "event": "PreToolUse", + "tool": "exec_command", + "input": {"command": %q} + }`, command) +} + +func geminiShellProbePayload(command string) string { + return fmt.Sprintf(`{ + "provider": "gemini-cli", + "hookEventName": "BeforeTool", + "toolName": "run_shell_command", + "toolInput": {"command": %q} + }`, command) +} + +func runHookProbe( + root string, + hookCommand string, + probe hookProbe, +) (hookProbeResult, error) { + var ( + stdout bytes.Buffer + stderr bytes.Buffer + ) + + args, err := hookProbeArgs(root, hookCommand) + if err != nil { + return hookProbeResult{}, err + } + + if probe.provider == string(ProviderKimi) { + args = append(args, "--provider", string(ProviderKimi)) + } + + ctx, cancel := context.WithTimeout(context.Background(), probeTimeout) + defer cancel() + + command := safeexec.CommandContext(ctx, args[0], args[1:]...) + command.Dir = root + command.Env = withoutHookProbeProcessState(os.Environ()) + command.Stdin = strings.NewReader(probe.payload) + command.Stdout = &stdout + command.Stderr = &stderr + + runErr := command.Run() + exitCode := 0 + + if runErr != nil { + var exitErr *exec.ExitError + if errors.As(runErr, &exitErr) { + exitCode = exitErr.ExitCode() + } else { + return hookProbeResult{}, fmt.Errorf("run hook probe: %w", runErr) + } + } + + if ctx.Err() != nil { + return hookProbeResult{}, fmt.Errorf("run hook probe: %w", ctx.Err()) + } + + result := hookProbeResult{ + exitCode: exitCode, + stdout: stdout.String(), + stderr: stderr.String(), + } + + if result.stdout != "" { + payload, decodeErr := decodeHookProbePayload(result.stdout) + if decodeErr != nil { + return result, decodeErr + } + + result.payload = payload + } + + return result, nil +} + +func withoutHookProbeProcessState(environ []string) []string { + const hookLoggingActiveEnv = "CODE_ETHOS_HOOK_LOGGING_ACTIVE" + + filtered := make([]string, 0, len(environ)) + for _, entry := range environ { + key, _, _ := strings.Cut(entry, "=") + if key == execguard.EnvStack || key == hookLoggingActiveEnv { + continue + } + + filtered = append(filtered, entry) + } + + return filtered +} + +func hookProbeArgs(root, hookCommand string) ([]string, error) { + command, err := staticSingleCommand(hookCommand, errUnsupportedHookCommand) + if err != nil { + return nil, err + } + + argv := append([]string(nil), command.Argv...) + + if len(argv) < minimumHookArgs { + return nil, fmt.Errorf("%w: %s", errUnsupportedHookCommand, hookCommand) + } + + executableIndex := 0 + + if filepath.Base(argv[0]) == "env" { + var found bool + + executableIndex, found = externalHookExecutableIndex(argv) + if !found { + return nil, fmt.Errorf("%w: %s", errUnsupportedHookCommand, hookCommand) + } + } + + if executableIndex+1 >= len(argv) { + return nil, fmt.Errorf("%w: %s", errUnsupportedHookCommand, hookCommand) + } + + runnerPath := argv[executableIndex] + subcommand := argv[executableIndex+1] + + isCodingEthosHook := filepath.Base(runnerPath) == "coding-ethos-run" && + subcommand == "agent-hook" + + isExternalSupervisorHook := filepath.IsAbs(runnerPath) && subcommand == "hook" + if !isCodingEthosHook && !isExternalSupervisorHook { + return nil, fmt.Errorf("%w: %s", errUnsupportedHookCommand, hookCommand) + } + + if !filepath.IsAbs(runnerPath) { + if executableIndex != 0 { + return nil, fmt.Errorf("%w: %s", errUnsupportedHookCommand, hookCommand) + } + + runnerPath = filepath.Join(root, runnerPath) + } + + argv[executableIndex] = runnerPath + + return argv, nil +} + +func staticSingleCommand( + commandText string, + unsupported error, +) (shellparse.Command, error) { + commands, err := shellparse.Commands(commandText) + if err != nil { + return shellparse.Command{}, fmt.Errorf( + "%w: parse %q: %w", + unsupported, + commandText, + err, + ) + } + + if len(commands) != 1 { + return shellparse.Command{}, fmt.Errorf("%w: %s", unsupported, commandText) + } + + command := commands[0] + if unsafeStaticCommand(command) { + return shellparse.Command{}, fmt.Errorf("%w: %s", unsupported, commandText) + } + + return command, nil +} + +func unsafeStaticCommand(command shellparse.Command) bool { + return len(command.Argv) == 0 || + len(command.Assignments) != 0 || + len(command.Redirects) != 0 || + command.Background || + command.HasCommandSubstitution || + command.HasDynamicExpansion || + command.HasHeredoc || + command.HasProcessSubstitution || + command.HasSubshell || + command.IsFunctionDeclaration +} + +func externalHookExecutableIndex(argv []string) (int, bool) { + index := 1 + for index < len(argv) && externalEnvironmentAssignment(argv[index]) { + index++ + } + + return index, index > 1 && index < len(argv) +} + +func externalEnvironmentAssignment(value string) bool { + name, _, found := strings.Cut(value, "=") + + return found && externalEnvironmentName.MatchString(name) +} + +func decodeHookProbePayload(output string) (map[string]any, error) { + decoder := json.NewDecoder(strings.NewReader(output)) + payload := map[string]any{} + + err := decoder.Decode(&payload) + if err != nil { + return nil, fmt.Errorf("decode hook probe JSON: %w", err) + } + + return payload, nil +} + +func validateClaudeRewriteProbe(result hookProbeResult) error { + err := validateRewriteProbe(result, "Claude") + if err != nil { + return err + } + + command, found := nestedString( + result.payload, + "hookSpecificOutput", + "updatedInput", + "command", + ) + if !found { + return apperror.Wrapf( + apperror.StaticError("missing Claude updatedInput command in %s"), + "missing Claude updatedInput command in %s", + result.stdout, + ) + } + + if !strings.Contains(command, "2>&1") { + return apperror.Wrapf( + apperror.StaticError("claude rewrite lost redirection: %s"), + "claude rewrite lost redirection: %s", + command, + ) + } + + return nil +} + +// ValidateClaudeRewritePayload validates Claude doctor rewrite output. +func ValidateClaudeRewritePayload(stdout string, payload map[string]any) error { + return validateClaudeRewriteProbe(hookProbeResult{ + exitCode: 0, + stdout: stdout, + payload: payload, + }) +} + +func validateCodexRewriteProbe(result hookProbeResult) error { + hookOutput, found := result.payload["hookSpecificOutput"].(map[string]any) + if found { + if _, hasUpdatedInput := hookOutput["updatedInput"]; hasUpdatedInput { + return apperror.Wrapf( + apperror.StaticError( + "codex rewrite emitted unsupported updatedInput in %s", + ), + "codex rewrite emitted unsupported updatedInput in %s", + result.stdout, + ) + } + } + + return validateCodexBlockProbe(result) +} + +// ValidateCodexRewritePayload validates Codex doctor rewrite output. +func ValidateCodexRewritePayload(stdout string, payload map[string]any) error { + return validateCodexRewriteProbe(hookProbeResult{ + exitCode: 0, + stdout: stdout, + payload: payload, + }) +} + +func validateGeminiRewriteProbe(result hookProbeResult) error { + return validateRewriteProbe(result, "Gemini") +} + +// ValidateGeminiRewritePayload validates Gemini doctor rewrite output. +func ValidateGeminiRewritePayload(stdout string, payload map[string]any) error { + return validateGeminiRewriteProbe(hookProbeResult{ + exitCode: 0, + stdout: stdout, + payload: payload, + }) +} + +func validateRewriteProbe(result hookProbeResult, provider string) error { + if result.exitCode != 0 { + return apperror.Wrapf( + apperror.StaticError("%s git rewrite probe should allow, got exit %d"), + "%s git rewrite probe should allow, got exit %d", + provider, + result.exitCode, + ) + } + + command, found := nestedString( + result.payload, + "hookSpecificOutput", + "updatedInput", + "command", + ) + if !found { + return apperror.Wrapf( + apperror.StaticError("missing %s updatedInput command in %s"), + "missing %s updatedInput command in %s", + provider, + result.stdout, + ) + } + + if !strings.Contains(command, "agent-shell --") || + !strings.Contains(command, "git status --short") { + return apperror.Wrapf( + apperror.StaticError("%s rewrite lost git wrapper or redirection: %s"), + "%s rewrite lost git wrapper or redirection: %s", + provider, + command, + ) + } + + 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") + } + + actual, found := result.payload["decision"].(string) + if !found || actual != "block" { + return apperror.Wrapf( + apperror.StaticError("decision = %q, want block; stdout=%s"), + "decision = %q, want block; stdout=%s", + actual, + result.stdout, + ) + } + + reason, found := result.payload["reason"].(string) + if !found || strings.TrimSpace(reason) == "" { + return apperror.Wrapf( + apperror.StaticError("missing reason in %s"), + "missing reason in %s", + result.stdout, + ) + } + + 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( + result.payload, + "hookSpecificOutput", + "permissionDecisionReason", + ) + if !found || strings.TrimSpace(permissionReason) == "" { + return apperror.Wrapf( + apperror.StaticError("missing permissionDecisionReason in %s"), + "missing permissionDecisionReason in %s", + result.stdout, + ) + } + + return nil +} + +func validateClaudeBlockProbe(result hookProbeResult) error { + return validateDecisionProbe(result, "block") +} + +func validateGeminiDenyProbe(result hookProbeResult) error { + if result.exitCode == 0 { + return apperror.StaticError("gemini probe should deny") + } + + return validateDecisionProbe(result, "deny") +} + +func validateKimiDenyProbe(result hookProbeResult) error { + if result.exitCode != kimiBlockExitCode { + return apperror.Wrapf( + apperror.StaticError("Kimi deny probe exit = %d, want 2"), + "Kimi deny probe exit = %d, want 2", + result.exitCode, + ) + } + + if strings.TrimSpace(result.stderr) == "" { + return apperror.StaticError("Kimi deny probe must emit a stderr reason") + } + + return validateKimiStructuredDeny(result) +} + +func validateKimiStopContinuationProbe(result hookProbeResult) error { + if result.exitCode != 0 { + return apperror.Wrapf( + apperror.StaticError("Kimi Stop continuation exit = %d, want 0"), + "Kimi Stop continuation exit = %d, want 0", + result.exitCode, + ) + } + + return validateKimiStructuredDeny(result) +} + +func validateKimiStructuredDeny(result hookProbeResult) error { + decision, found := nestedString( + result.payload, + "hookSpecificOutput", + "permissionDecision", + ) + if !found || decision != "deny" { + return apperror.Wrapf( + apperror.StaticError( + "Kimi hook permissionDecision = %q, want deny; stdout=%s", + ), + "Kimi hook permissionDecision = %q, want deny; stdout=%s", + decision, + result.stdout, + ) + } + + reason, found := nestedString( + result.payload, + "hookSpecificOutput", + "permissionDecisionReason", + ) + if !found || strings.TrimSpace(reason) == "" { + return apperror.Wrapf( + apperror.StaticError("missing Kimi permissionDecisionReason in %s"), + "missing Kimi permissionDecisionReason in %s", + result.stdout, + ) + } + + return nil +} + +func validateDecisionProbe(result hookProbeResult, decision string) error { + actual, found := result.payload["decision"].(string) + if !found || actual != decision { + return apperror.Wrapf( + apperror.StaticError("decision = %q, want %q; stdout=%s"), + "decision = %q, want %q; stdout=%s", + actual, + decision, + result.stdout, + ) + } + + message, found := result.payload["systemMessage"].(string) + if !found || strings.TrimSpace(message) == "" { + return apperror.Wrapf( + apperror.StaticError("missing systemMessage in %s"), + "missing systemMessage in %s", + result.stdout, + ) + } + + return nil +} + +func nestedString(payload map[string]any, keys ...string) (string, bool) { + current := any(payload) + for _, key := range keys { + object, found := current.(map[string]any) + if !found { + return "", false + } + + current, found = object[key] + if !found { + return "", false + } + } + + value, found := current.(string) + + return value, found +} diff --git a/go/internal/agenthooks/settings_test.go b/go/internal/agenthooks/settings_test.go index 1c9da63d..742eb73f 100644 --- a/go/internal/agenthooks/settings_test.go +++ b/go/internal/agenthooks/settings_test.go @@ -37,6 +37,7 @@ func TestWriteSettingsIncludesAllProviders(t *testing.T) { `"claude": {`, `"codex": {`, `"gemini": {`, + `"kimi": {`, `"capabilities": [`, `"display_name": "Claude Code"`, `"provider": "generic"`, @@ -77,7 +78,7 @@ func TestProviderCapabilitiesDocumentProviderLimits(t *testing.T) { t.Parallel() capabilities := agenthooks.ProviderCapabilities() - if len(capabilities) != 4 { + if len(capabilities) != 5 { t.Fatalf("capability count mismatch: %#v", capabilities) } @@ -109,6 +110,12 @@ func TestProviderCapabilitiesDocumentProviderLimits(t *testing.T) { string(agenthooks.ProviderGeneric), "native hook settings generation", ) + assertUnsupported( + t, + capabilities, + string(agenthooks.ProviderKimi), + "PreToolUse updatedInput rewrite", + ) } type providerCapabilityExpectation struct { @@ -144,6 +151,9 @@ func providerCapabilityExpectations() []providerCapabilityExpectation { {string(agenthooks.ProviderGemini), "partial", "BeforeAgent additionalContext"}, {string(agenthooks.ProviderGemini), "partial", "SessionEnd additionalContext"}, {string(agenthooks.ProviderGemini), "partial", "MCP stdio server"}, + {string(agenthooks.ProviderKimi), "partial", "PreToolUse block"}, + {string(agenthooks.ProviderKimi), "partial", "Stop continuation through deny"}, + {string(agenthooks.ProviderKimi), "partial", "MCP stdio server"}, {string(agenthooks.ProviderGeneric), "unsupported", "portable skill surfaces"}, } } @@ -267,6 +277,8 @@ func TestStateArtifactsDescribeManagedHookSurfaces(t *testing.T) { filepath.ToSlash(".mcp.json"): "claude-mcp", filepath.ToSlash(filepath.Join(".codex", "config.toml")): "codex-config", filepath.ToSlash(filepath.Join(".gemini", "settings.json")): "gemini-settings", + filepath.ToSlash(filepath.Join(".kimi-code", "config.toml")): "kimi-config", + filepath.ToSlash(filepath.Join(".kimi-code", "mcp.json")): "kimi-mcp", } if len(artifacts) != len(expected) { t.Fatalf( @@ -352,12 +364,71 @@ func TestGeminiSettingsDoNotClaimUnsupportedPostToolUse(t *testing.T) { output := buffer.String() - geminiSettings := providerSettingsSection(t, output, "gemini", "capabilities") + geminiSettings := providerSettingsSection(t, output, "gemini", "kimi") if strings.Contains(geminiSettings, `"PostToolUse"`) { t.Fatalf("Gemini must not claim unsupported PostToolUse:\n%s", output) } } +func TestKimiSettingsUseNativeHooksAndPreserveExistingConfig(t *testing.T) { + t.Parallel() + + root := t.TempDir() + paths := agenthooks.DefaultSettingsPaths(root) + if err := os.MkdirAll(filepath.Dir(paths.KimiConfig), 0o700); err != nil { + t.Fatalf("create Kimi config dir: %v", err) + } + + existing := `default_model = "local" + +[[hooks]] +event = "Notification" +matcher = "custom" +command = "notify-custom" +` + if err := os.WriteFile(paths.KimiConfig, []byte(existing), 0o600); err != nil { + t.Fatalf("write existing Kimi config: %v", err) + } + + for range 2 { + if err := agenthooks.SyncSettings(root, testHookCommand); err != nil { + t.Fatalf("sync Kimi settings: %v", err) + } + } + + payload, err := os.ReadFile(paths.KimiConfig) + if err != nil { + t.Fatalf("read Kimi config: %v", err) + } + + output := string(payload) + for _, expected := range []string{ + `default_model = "local"`, + `matcher = "custom"`, + `command = "notify-custom"`, + `event = "PreToolUse"`, + `event = "PostToolUseFailure"`, + `event = "PermissionRequest"`, + `event = "Stop"`, + `event = "PostCompact"`, + `command = "/repo/bin/coding-ethos-run agent-hook --provider kimi"`, + } { + if !strings.Contains(output, expected) { + t.Fatalf("Kimi config missing %q:\n%s", expected, output) + } + } + if count := strings.Count( + output, + "# BEGIN coding-ethos managed Kimi hooks", + ); count != 1 { + t.Fatalf("managed Kimi hook block count = %d:\n%s", count, output) + } + + if err := agenthooks.DoctorSettings(root, testHookCommand); err != nil { + t.Fatalf("doctor Kimi settings: %v", err) + } +} + func TestCodexSettingsInstallEnforcementAndCompactPostToolHooks(t *testing.T) { t.Parallel() @@ -489,6 +560,9 @@ func TestSyncSettingsWritesMCPServersForAllProviders(t *testing.T) { geminiSettings := readJSONSettings(t, paths.Gemini) assertMCPServer(t, geminiSettings, "/repo/bin/coding-ethos-run", false) + kimiMCP := readJSONSettings(t, paths.KimiMCP) + assertMCPServer(t, kimiMCP, "/repo/bin/coding-ethos-run", false) + codexConfig, err := os.ReadFile(paths.CodexConfig) if err != nil { t.Fatalf("read Codex config: %v", err) @@ -700,6 +774,8 @@ func TestSyncAndDoctorSettingsWritesAllProviderFiles(t *testing.T) { paths.ClaudeMCP, paths.CodexConfig, paths.Gemini, + paths.KimiConfig, + paths.KimiMCP, } { _, statErr := os.Stat(path) if statErr != nil { @@ -739,8 +815,8 @@ func TestSyncAndVerifySettingsRunsProviderSmokePayloads(t *testing.T) { t.Fatalf("status = %q, want valid: %#v", report.Status, report) } - if len(report.Checks) != 15 { - t.Fatalf("check count = %d, want 15: %#v", len(report.Checks), report.Checks) + if len(report.Checks) != 17 { + t.Fatalf("check count = %d, want 17: %#v", len(report.Checks), report.Checks) } knownProviders := providerIDsByRegistry() @@ -755,6 +831,179 @@ func TestSyncAndVerifySettingsRunsProviderSmokePayloads(t *testing.T) { } } +func TestSyncAndVerifySettingsUsesPrivateOverlayAndRepositoryCWD(t *testing.T) { + t.Parallel() + + settingsRoot := t.TempDir() + repoRoot := t.TempDir() + hookCommand := fakeAgentHookCommand(t) + writeGeneratedSkillSurfaces(t, repoRoot, "conditional-imports") + + err := agenthooks.SyncSettingsForRepository( + settingsRoot, + repoRoot, + hookCommand, + ) + if err != nil { + t.Fatalf("sync overlay settings: %v", err) + } + + report, err := agenthooks.VerifySettingsForRepository( + settingsRoot, + repoRoot, + hookCommand, + ) + if err != nil { + t.Fatalf("verify overlay settings: %v", err) + } + if report.Status != "valid" || len(report.Checks) != 17 { + t.Fatalf("overlay report = %#v", report) + } + + overlayPaths := agenthooks.DefaultSettingsPaths(settingsRoot) + for _, path := range []string{ + overlayPaths.Claude, + overlayPaths.ClaudeMCP, + overlayPaths.CodexConfig, + overlayPaths.Gemini, + overlayPaths.KimiConfig, + overlayPaths.KimiMCP, + } { + if _, statErr := os.Stat(path); statErr != nil { + t.Fatalf("overlay setting missing %s: %v", path, statErr) + } + } + + repoPaths := agenthooks.DefaultSettingsPaths(repoRoot) + for _, path := range []string{ + repoPaths.Claude, + repoPaths.ClaudeMCP, + repoPaths.CodexConfig, + repoPaths.Gemini, + repoPaths.KimiConfig, + repoPaths.KimiMCP, + } { + if _, statErr := os.Stat(path); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("private overlay sync wrote target-repo setting %s", path) + } + } +} + +func TestSyncAndVerifySettingsUsesExternalSupervisorHookAndCodingEthosMCP( + t *testing.T, +) { + t.Parallel() + + settingsRoot := t.TempDir() + repoRoot := t.TempDir() + hookCommand, mcpCommand, mcpRunner := fakeExternalSupervisorCommands(t) + writeGeneratedSkillSurfaces(t, repoRoot, "conditional-imports") + + err := agenthooks.SyncSettingsForRepositoryWithMCPCommand( + settingsRoot, + repoRoot, + hookCommand, + mcpCommand, + ) + if err != nil { + t.Fatalf("sync external supervisor overlay: %v", err) + } + + report, err := agenthooks.VerifySettingsForRepositoryWithMCPCommand( + settingsRoot, + repoRoot, + hookCommand, + mcpCommand, + ) + if err != nil { + t.Fatalf("verify external supervisor overlay: %v", err) + } + if report.Status != "valid" || len(report.Checks) != 17 { + t.Fatalf("external supervisor report = %#v", report) + } + + seenProviders := map[string]bool{} + for _, check := range report.Checks { + seenProviders[check.Provider] = true + } + for _, provider := range []string{"claude", "codex", "kimi"} { + if !seenProviders[provider] { + t.Fatalf("external supervisor probes omitted %s: %#v", provider, report.Checks) + } + } + + paths := agenthooks.DefaultSettingsPaths(settingsRoot) + for _, expectation := range []struct { + path string + text string + }{ + {path: paths.Claude, text: hookCommand}, + {path: paths.CodexConfig, text: hookCommand}, + {path: paths.KimiConfig, text: hookCommand + " --provider kimi"}, + {path: paths.ClaudeMCP, text: mcpRunner}, + {path: paths.CodexConfig, text: mcpRunner}, + {path: paths.KimiMCP, text: mcpRunner}, + } { + payload, readErr := os.ReadFile(expectation.path) + if readErr != nil { + t.Fatalf("read generated overlay %s: %v", expectation.path, readErr) + } + if !strings.Contains(string(payload), expectation.text) { + t.Fatalf( + "generated overlay %s missing %q:\n%s", + expectation.path, + expectation.text, + payload, + ) + } + } +} + +func TestSyncSettingsRejectsUnsafeExternalSupervisorHookCommands(t *testing.T) { + t.Parallel() + + mcpCommand := "/opt/coding-ethos/bin/coding-ethos-run mcp" + for _, hookCommand := range []string{ + "env NYAR_HOME=/tmp /opt/nyar hook; /bin/true", + "env NYAR_HOME=$(pwd) /opt/nyar hook", + "env NYAR_HOME=/tmp relative/nyar hook", + "NYAR_HOME=/tmp /opt/nyar hook", + "env /opt/nyar hook", + } { + err := agenthooks.SyncSettingsForRepositoryWithMCPCommand( + t.TempDir(), + t.TempDir(), + hookCommand, + mcpCommand, + ) + if err == nil || !strings.Contains(err.Error(), "unsupported hook command") { + t.Fatalf("unsafe hook command %q error = %v", hookCommand, err) + } + } +} + +func TestSyncSettingsRejectsUnsafeOrNonCodingEthosMCPCommands(t *testing.T) { + t.Parallel() + + for _, mcpCommand := range []string{ + "/opt/coding-ethos/bin/coding-ethos-run mcp; /bin/true", + "/opt/nyar mcp", + "bin/coding-ethos-run mcp", + "/opt/coding-ethos/bin/coding-ethos-run agent-hook", + } { + err := agenthooks.SyncSettingsForRepositoryWithMCPCommand( + t.TempDir(), + t.TempDir(), + testHookCommand, + mcpCommand, + ) + if err == nil || + !strings.Contains(err.Error(), "unsupported Coding Ethos MCP command") { + t.Fatalf("unsafe MCP command %q error = %v", mcpCommand, err) + } + } +} + func TestVerifySettingsRejectsInvalidPortableSkillSurface(t *testing.T) { t.Parallel() @@ -1228,6 +1477,12 @@ func containsString(values []string, expected string) bool { func fakeAgentHookCommand(t *testing.T) string { t.Helper() + return shellSingleQuote(fakeAgentHookRunner(t)) + " agent-hook" +} + +func fakeAgentHookRunner(t *testing.T) string { + t.Helper() + ethosRoot := t.TempDir() binDir := filepath.Join(ethosRoot, "bin") bundleDir := filepath.Join(ethosRoot, "build", "policy") @@ -1262,6 +1517,21 @@ func fakeAgentHookCommand(t *testing.T) string { runner := filepath.Join(binDir, "coding-ethos-run") script := `#!/bin/sh payload=$(cat) +case "$*" in +*'--provider kimi'*) +case "$payload" in +*'"hook_event_name": "Stop"'*) +printf '%s\n' '{"message":"Before ending: planned work remains","hookSpecificOutput":{"permissionDecision":"deny","permissionDecisionReason":"Before ending: planned work remains"}}' +exit 0 +;; +*) +printf '%s\n' '{"decision":"deny","message":"blocked","hookSpecificOutput":{"permissionDecision":"deny","permissionDecisionReason":"blocked"}}' +printf '%s\n' 'blocked by coding-ethos' >&2 +exit 2 +;; +esac +;; +esac case "$payload" in *'"provider": "claude"'*'git status --short'*) printf '%s\n' '{"hookSpecificOutput":{"updatedInput":{"command":"coding-ethos-run agent-shell -- '\''pwd && git status --short 2>&1'\''"}}}' @@ -1290,5 +1560,44 @@ esac t.Fatalf("write fake runner: %v", err) } - return "'" + strings.ReplaceAll(runner, "'", "'\\''") + "' agent-hook" + return runner +} + +func fakeExternalSupervisorCommands(t *testing.T) (string, string, string) { + t.Helper() + + mcpRunner := fakeAgentHookRunner(t) + ethosRoot := filepath.Dir(filepath.Dir(mcpRunner)) + nyarHome := t.TempDir() + wrapper := filepath.Join(t.TempDir(), "nyar") + script := fmt.Sprintf(`#!/bin/sh +if [ "$NYAR_HOME" != %s ] || [ "$NYAR_CODING_ETHOS_ROOT" != %s ]; then + printf 'missing supervisor environment\n' >&2 + exit 70 +fi +if [ "$1" != "hook" ]; then + printf 'expected hook subcommand\n' >&2 + exit 71 +fi +shift +exec %s agent-hook "$@" +`, + shellSingleQuote(nyarHome), + shellSingleQuote(ethosRoot), + shellSingleQuote(mcpRunner), + ) + if err := os.WriteFile(wrapper, []byte(script), 0o700); err != nil { + t.Fatalf("write fake supervisor wrapper: %v", err) + } + + hookCommand := "env NYAR_HOME=" + shellSingleQuote(nyarHome) + + " NYAR_CODING_ETHOS_ROOT=" + shellSingleQuote(ethosRoot) + + " " + shellSingleQuote(wrapper) + " hook" + mcpCommand := shellSingleQuote(mcpRunner) + " mcp" + + return hookCommand, mcpCommand, mcpRunner +} + +func shellSingleQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" } diff --git a/go/internal/agenthooks/spec.go b/go/internal/agenthooks/spec.go index 52acab5f..53a97e27 100644 --- a/go/internal/agenthooks/spec.go +++ b/go/internal/agenthooks/spec.go @@ -13,6 +13,8 @@ const ( ProviderCodex Provider = "codex" // ProviderGemini renders a Gemini-owned coding-ethos hook manifest. ProviderGemini Provider = "gemini" + // ProviderKimi renders Kimi Code hooks in an isolated KIMI_CODE_HOME overlay. + ProviderKimi Provider = "kimi" // ProviderGeneric identifies portable agent surfaces with no native hooks. ProviderGeneric Provider = "generic" ) diff --git a/go/internal/agenthooks/state_artifacts.go b/go/internal/agenthooks/state_artifacts.go index 6f16a6a2..f2454bc9 100644 --- a/go/internal/agenthooks/state_artifacts.go +++ b/go/internal/agenthooks/state_artifacts.go @@ -10,12 +10,22 @@ import ( ) func StateArtifacts(root, hookCommand string) ([]syncstate.Artifact, error) { + return StateArtifactsWithMCPCommand(root, hookCommand, "") +} + +// StateArtifactsWithMCPCommand renders hook settings while keeping Coding +// Ethos MCP ownership independent from an external supervisor hook command. +func StateArtifactsWithMCPCommand( + root string, + hookCommand string, + mcpCommand string, +) ([]syncstate.Artifact, error) { settings, err := buildAllSettings(hookCommand) if err != nil { return nil, err } - serverConfig, err := mcpServerConfig(hookCommand) + serverConfig, err := mcpServerConfig(hookCommand, mcpCommand) if err != nil { return nil, err } @@ -58,9 +68,22 @@ func StateArtifacts(root, hookCommand string) ([]syncstate.Artifact, error) { return nil, err } + kimiConfig, kimiMCP, err := renderKimiStateArtifacts(paths, settings, serverConfig) + if err != nil { + return nil, err + } + artifacts, err := syncstate.Artifacts( root, - agentHookStateArtifactInputs(paths, claude, claudeMCP, codex, gemini), + agentHookStateArtifactInputs( + paths, + claude, + claudeMCP, + codex, + gemini, + kimiConfig, + kimiMCP, + ), ) if err != nil { return nil, fmt.Errorf("build agent hook state artifacts: %w", err) @@ -69,12 +92,39 @@ func StateArtifacts(root, hookCommand string) ([]syncstate.Artifact, error) { return artifacts, nil } +func renderKimiStateArtifacts( + paths SettingsPaths, + settings allSettings, + serverConfig mcpServer, +) (string, string, error) { + config, err := renderTextSettingsFileContent( + paths.KimiConfig, + func(content string) string { + return ensureKimiConfig(content, settings.Kimi) + }, + ) + if err != nil { + return "", "", err + } + + mcp, err := renderSettingsFileContent(paths.KimiMCP, func(payload map[string]any) { + syncMCPServers(payload, serverConfig.geminiJSON()) + }) + if err != nil { + return "", "", err + } + + return config, mcp, nil +} + func agentHookStateArtifactInputs( paths SettingsPaths, claude, claudeMCP, codex, - gemini string, + gemini, + kimiConfig, + kimiMCP string, ) []syncstate.ArtifactInput { const verifyCommand = "bin/coding-ethos-run agent-hooks doctor" @@ -107,5 +157,19 @@ func agentHookStateArtifactInputs( Surface: "gemini-settings", VerificationCommand: verifyCommand, }, + { + RelativePath: paths.KimiConfig, + Content: kimiConfig, + Provider: "agent-hooks", + Surface: "kimi-config", + VerificationCommand: verifyCommand, + }, + { + RelativePath: paths.KimiMCP, + Content: kimiMCP, + Provider: "agent-hooks", + Surface: "kimi-mcp", + VerificationCommand: verifyCommand, + }, } } diff --git a/go/internal/agenthookscli/main.go b/go/internal/agenthookscli/main.go index 85b4d3c6..9c5a109b 100644 --- a/go/internal/agenthookscli/main.go +++ b/go/internal/agenthookscli/main.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "os" + "path/filepath" "strings" "blackcat.ca/coding-ethos/go/internal/agenthooks" @@ -25,6 +26,9 @@ var ( errProviderMatrixDrift = apperror.StaticError( "provider capability matrix out of sync", ) + errRuntimeVersionUnavailable = apperror.StaticError( + "runtime version is unavailable from pyproject.toml", + ) errUnknownCommand = apperror.StaticError("unknown agent-hooks command") ) @@ -46,6 +50,8 @@ func runCLI(args []string) int { err = doctorSettings(args[1:]) case "verify": err = verifySettings(args[1:]) + case "capabilities": + err = capabilities(args[1:]) case "sync-provider-matrix": err = syncProviderMatrix(args[1:]) case "check-provider-matrix": @@ -69,6 +75,32 @@ func runCLI(args []string) int { return 0 } +func capabilities(args []string) error { + flags := flag.NewFlagSet("capabilities", flag.ContinueOnError) + ethosRoot := flags.String("ethos-root", ".", "Path to coding-ethos checkout") + _ = flags.Bool("json", false, "Emit JSON capability report") + + err := flags.Parse(args) + if err != nil { + return fmt.Errorf("parse capabilities flags: %w", err) + } + + runtimeVersion := syncstate.RuntimeVersion(*ethosRoot) + if runtimeVersion == "" { + return fmt.Errorf("%w: %s", errRuntimeVersionUnavailable, *ethosRoot) + } + + err = feedback.WriteJSON( + os.Stdout, + agenthooks.Capabilities(runtimeVersion), + ) + if err != nil { + return fmt.Errorf("encode agent hook capabilities: %w", err) + } + + return nil +} + func printSettings(args []string) error { flags := flag.NewFlagSet("print", flag.ContinueOnError) hookCommand := flags.String("hook-command", "", "Agent hook command") @@ -100,8 +132,18 @@ func printSettings(args []string) error { func syncSettings(args []string) error { flags := flag.NewFlagSet("sync", flag.ContinueOnError) root := flags.String("root", ".", "Repository root for agent settings") + repoRoot := flags.String( + "repo-root", + "", + "Actual repository root when --root is a private settings overlay", + ) ethosRoot := flags.String("ethos-root", ".", "Path to coding-ethos checkout") hookCommand := flags.String("hook-command", "", "Agent hook command") + mcpCommand := flags.String( + "mcp-command", + "", + "Coding Ethos MCP command; derived from --hook-command when omitted", + ) dryRun := flags.Bool("dry-run", false, "Report planned writes without mutating files") format := flags.String( "format", @@ -115,8 +157,13 @@ func syncSettings(args []string) error { } resolvedHookCommand := defaultHookCommand(*hookCommand) + resolvedRepoRoot := defaultRepoRoot(*root, *repoRoot) - artifacts, err := agenthooks.StateArtifacts(*root, resolvedHookCommand) + artifacts, err := agenthooks.StateArtifactsWithMCPCommand( + *root, + resolvedHookCommand, + *mcpCommand, + ) if err != nil { return fmt.Errorf("plan agent hook settings: %w", err) } @@ -128,22 +175,50 @@ func syncSettings(args []string) error { ) } - err = agenthooks.SyncSettings(*root, resolvedHookCommand) + err = agenthooks.SyncSettingsForRepositoryWithMCPCommand( + *root, + resolvedRepoRoot, + resolvedHookCommand, + *mcpCommand, + ) if err != nil { return fmt.Errorf("sync agent hook settings: %w", err) } - err = agenthooks.SyncCodexTrustState(*root, resolvedHookCommand, "") + err = agenthooks.SyncCodexTrustState( + *root, + resolvedHookCommand, + codexTrustConfigForRoots(*root, resolvedRepoRoot), + ) if err != nil { return fmt.Errorf("sync Codex hook trust: %w", err) } - _, err = syncstate.Upsert(syncstate.UpsertOptions{ - RepoRoot: *root, - EthosRoot: *ethosRoot, + if privateSettingsOverlay(*root, resolvedRepoRoot) { + artifacts, err = agenthooks.StateArtifactsWithMCPCommand( + *root, + resolvedHookCommand, + *mcpCommand, + ) + if err != nil { + return fmt.Errorf("refresh private overlay state artifacts: %w", err) + } + } + + return upsertAgentHookSyncState(*root, *ethosRoot, artifacts) +} + +func upsertAgentHookSyncState( + root string, + ethosRoot string, + artifacts []syncstate.Artifact, +) error { + _, err := syncstate.Upsert(syncstate.UpsertOptions{ + RepoRoot: root, + EthosRoot: ethosRoot, RequestedAction: "agent-hooks sync", ProviderTargets: []syncstate.ProviderTarget{ - {Provider: "agent-hooks", Root: *root}, + {Provider: "agent-hooks", Root: root}, }, Artifacts: artifacts, }) @@ -166,19 +241,40 @@ func writeSyncStateReport(report syncstate.Report, format string) error { func doctorSettings(args []string) error { flags := flag.NewFlagSet("doctor", flag.ContinueOnError) root := flags.String("root", ".", "Repository root for agent settings") + repoRoot := flags.String( + "repo-root", + "", + "Actual repository root when --root is a private settings overlay", + ) hookCommand := flags.String("hook-command", "", "Agent hook command") + mcpCommand := flags.String( + "mcp-command", + "", + "Coding Ethos MCP command; derived from --hook-command when omitted", + ) err := flags.Parse(args) if err != nil { return fmt.Errorf("parse doctor flags: %w", err) } - err = agenthooks.DoctorSettings(*root, defaultHookCommand(*hookCommand)) + err = agenthooks.DoctorSettingsForRepositoryWithMCPCommand( + *root, + defaultRepoRoot(*root, *repoRoot), + defaultHookCommand(*hookCommand), + *mcpCommand, + ) if err != nil { return fmt.Errorf("doctor agent hook settings: %w", err) } - err = agenthooks.VerifyCodexTrustState(*root, defaultHookCommand(*hookCommand), "") + resolvedRepoRoot := defaultRepoRoot(*root, *repoRoot) + + err = agenthooks.VerifyCodexTrustState( + *root, + defaultHookCommand(*hookCommand), + codexTrustConfigForRoots(*root, resolvedRepoRoot), + ) if err != nil { return fmt.Errorf("doctor Codex hook trust: %w", err) } @@ -194,14 +290,29 @@ func doctorSettings(args []string) error { func verifySettings(args []string) error { flags := flag.NewFlagSet("verify", flag.ContinueOnError) root := flags.String("root", ".", "Repository root for agent settings") + repoRoot := flags.String( + "repo-root", + "", + "Actual repository root when --root is a private settings overlay", + ) hookCommand := flags.String("hook-command", "", "Agent hook command") + mcpCommand := flags.String( + "mcp-command", + "", + "Coding Ethos MCP command; derived from --hook-command when omitted", + ) err := flags.Parse(args) if err != nil { return fmt.Errorf("parse verify flags: %w", err) } - report, err := agenthooks.VerifySettings(*root, defaultHookCommand(*hookCommand)) + report, err := agenthooks.VerifySettingsForRepositoryWithMCPCommand( + *root, + defaultRepoRoot(*root, *repoRoot), + defaultHookCommand(*hookCommand), + *mcpCommand, + ) if err != nil { encodeErr := writeJSONReport(os.Stdout, report) if encodeErr != nil { @@ -211,7 +322,13 @@ func verifySettings(args []string) error { return fmt.Errorf("verify agent hook settings: %w", err) } - err = agenthooks.VerifyCodexTrustState(*root, defaultHookCommand(*hookCommand), "") + resolvedRepoRoot := defaultRepoRoot(*root, *repoRoot) + + err = agenthooks.VerifyCodexTrustState( + *root, + defaultHookCommand(*hookCommand), + codexTrustConfigForRoots(*root, resolvedRepoRoot), + ) if err != nil { report.Status = "invalid" report.Checks = append(report.Checks, agenthooks.VerifyCheck{ @@ -300,6 +417,35 @@ func defaultHookCommand(hookCommand string) string { return runner + " agent-hook" } +func defaultRepoRoot(settingsRoot, repoRoot string) string { + if strings.TrimSpace(repoRoot) != "" { + return repoRoot + } + + return settingsRoot +} + +func privateSettingsOverlay(settingsRoot, repoRoot string) bool { + var ( + settingsPath, settingsErr = filepath.Abs(settingsRoot) + repoPath, repoErr = filepath.Abs(repoRoot) + ) + + if settingsErr != nil || repoErr != nil { + return filepath.Clean(settingsRoot) != filepath.Clean(repoRoot) + } + + return filepath.Clean(settingsPath) != filepath.Clean(repoPath) +} + +func codexTrustConfigForRoots(settingsRoot, repoRoot string) string { + if !privateSettingsOverlay(settingsRoot, repoRoot) { + return "" + } + + return agenthooks.DefaultSettingsPaths(settingsRoot).CodexConfig +} + func writeDoctorReport(file *os.File) error { payload := map[string]any{ "status": "valid", @@ -324,7 +470,8 @@ func usage() { func usageTo(writer io.Writer) { const text = "Usage: coding-ethos-agent-hooks " + - " " + + " " + "[flags]; sync supports --dry-run --format json|toon" feedback.Emit( diff --git a/go/internal/agenthookscli/main_internal_test.go b/go/internal/agenthookscli/main_internal_test.go index 05d0600a..c1946df6 100644 --- a/go/internal/agenthookscli/main_internal_test.go +++ b/go/internal/agenthookscli/main_internal_test.go @@ -11,6 +11,7 @@ import ( "strings" "testing" + "blackcat.ca/coding-ethos/go/internal/agenthooks" "blackcat.ca/coding-ethos/go/internal/syncstate" "blackcat.ca/coding-ethos/go/internal/testlock" ) @@ -72,6 +73,37 @@ func TestWriteJSONReportFormatsPayload(t *testing.T) { } } +func TestCapabilitiesReportsRuntimeContractAndKimi(t *testing.T) { + ethosRoot := t.TempDir() + writeAgentHooksCLITestFile( + t, + filepath.Join(ethosRoot, "pyproject.toml"), + "[project]\nversion = \"7.8.9\"\n", + ) + + var err error + output := captureStdout(t, func() { + err = capabilities([]string{"--json", "--ethos-root", ethosRoot}) + }) + if err != nil { + t.Fatalf("capabilities returned error: %v", err) + } + + for _, expected := range []string{ + `"api_version": "coding-ethos.agent-hooks/v1"`, + `"runtime_version": "7.8.9"`, + `"contract_version": "coding-ethos.hook/v1"`, + `"selector": "neutral-v1"`, + `"mcp_command_flag": "--mcp-command"`, + `"runtime_policy_command": "runtime-policy"`, + `"provider": "kimi"`, + } { + if !strings.Contains(output, expected) { + t.Fatalf("capability output missing %q:\n%s", expected, output) + } + } +} + func TestPrintSyncDoctorVerifySettingsCommands(t *testing.T) { root := t.TempDir() t.Setenv("CODEX_HOME", filepath.Join(root, "codex-home")) @@ -124,6 +156,8 @@ func TestSyncSettingsDryRunDoesNotWriteSettingsOrState(t *testing.T) { filepath.Join(root, ".mcp.json"), filepath.Join(root, ".codex", "config.toml"), filepath.Join(root, ".gemini", "settings.json"), + filepath.Join(root, ".kimi-code", "config.toml"), + filepath.Join(root, ".kimi-code", "mcp.json"), syncstate.FilePath(root), } { if _, statErr := os.Stat(path); statErr == nil { @@ -163,6 +197,114 @@ func TestSyncSettingsUsesEthosRootForInstallState(t *testing.T) { } } +func TestSyncAndDoctorSettingsAcceptPrivateOverlayRepoRoot(t *testing.T) { + settingsRoot := t.TempDir() + repoRoot := t.TempDir() + globalCodexHome := filepath.Join(t.TempDir(), "must-remain-absent") + t.Setenv("CODEX_HOME", globalCodexHome) + + hookCommand := filepath.Join(settingsRoot, "bin", "coding-ethos-run") + + " agent-hook" + + err := syncSettings([]string{ + "--root", settingsRoot, + "--repo-root", repoRoot, + "--hook-command", hookCommand, + }) + if err != nil { + t.Fatalf("syncSettings overlay returned error: %v", err) + } + + err = doctorSettings([]string{ + "--root", settingsRoot, + "--repo-root", repoRoot, + "--hook-command", hookCommand, + }) + if err != nil { + t.Fatalf("doctorSettings overlay returned error: %v", err) + } + + codexConfig := filepath.Join(settingsRoot, ".codex", "config.toml") + configPayload, err := os.ReadFile(codexConfig) + if err != nil { + t.Fatalf("read private Codex overlay config: %v", err) + } + if !strings.Contains(string(configPayload), "[hooks.state.") { + t.Fatalf("private Codex overlay lacks hook trust state:\n%s", configPayload) + } + if _, statErr := os.Stat( + filepath.Join(globalCodexHome, "config.toml"), + ); !os.IsNotExist( + statErr, + ) { + t.Fatalf("private overlay mutated global Codex config: %v", statErr) + } + + for _, path := range []string{ + filepath.Join(repoRoot, ".claude", "settings.local.json"), + filepath.Join(repoRoot, ".mcp.json"), + filepath.Join(repoRoot, ".codex", "config.toml"), + filepath.Join(repoRoot, ".gemini", "settings.json"), + filepath.Join(repoRoot, ".kimi-code", "config.toml"), + filepath.Join(repoRoot, ".kimi-code", "mcp.json"), + } { + if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { + t.Fatalf("overlay CLI wrote target-repo setting %s", path) + } + } +} + +func TestSyncAndDoctorSettingsAcceptExternalHookAndMCPCommands(t *testing.T) { + settingsRoot := t.TempDir() + repoRoot := t.TempDir() + t.Setenv("CODEX_HOME", filepath.Join(t.TempDir(), "must-remain-absent")) + + hookCommand := "env NYAR_HOME=/private/nyar " + + "NYAR_CODING_ETHOS_ROOT=/opt/coding-ethos /opt/nyar hook" + mcpCommand := "/opt/coding-ethos/bin/coding-ethos-run mcp" + + err := syncSettings([]string{ + "--root", settingsRoot, + "--repo-root", repoRoot, + "--hook-command", hookCommand, + "--mcp-command", mcpCommand, + }) + if err != nil { + t.Fatalf("syncSettings external wrapper returned error: %v", err) + } + + err = doctorSettings([]string{ + "--root", settingsRoot, + "--repo-root", repoRoot, + "--hook-command", hookCommand, + "--mcp-command", mcpCommand, + }) + if err != nil { + t.Fatalf("doctorSettings external wrapper returned error: %v", err) + } + + paths := agenthooks.DefaultSettingsPaths(settingsRoot) + for _, expectation := range []struct { + path string + text string + }{ + {path: paths.Claude, text: hookCommand}, + {path: paths.CodexConfig, text: hookCommand}, + {path: paths.KimiConfig, text: hookCommand + " --provider kimi"}, + {path: paths.ClaudeMCP, text: "/opt/coding-ethos/bin/coding-ethos-run"}, + {path: paths.CodexConfig, text: "/opt/coding-ethos/bin/coding-ethos-run"}, + {path: paths.KimiMCP, text: "/opt/coding-ethos/bin/coding-ethos-run"}, + } { + payload, readErr := os.ReadFile(expectation.path) + if readErr != nil { + t.Fatalf("read %s: %v", expectation.path, readErr) + } + if !strings.Contains(string(payload), expectation.text) { + t.Fatalf("%s missing %q:\n%s", expectation.path, expectation.text, payload) + } + } +} + func TestRunCLIDispatchesAgentHookCommands(t *testing.T) { root := t.TempDir() t.Setenv("CODEX_HOME", filepath.Join(root, "codex-home")) diff --git a/go/internal/hookcli/main.go b/go/internal/hookcli/main.go index ce6120d8..ddb053b7 100644 --- a/go/internal/hookcli/main.go +++ b/go/internal/hookcli/main.go @@ -27,8 +27,11 @@ import ( const blockedExitCode = hooks.AgentHookBlockedExitCode var ( - errBundleRequired = apperror.StaticError("--bundle is required") - errInvalidBundle = apperror.StaticError("invalid policy bundle") + errBundleRequired = apperror.StaticError("--bundle is required") + errInvalidBundle = apperror.StaticError("invalid policy bundle") + errInvalidContract = apperror.StaticError( + "--contract must be neutral-v1", + ) ) type codeIntelStoreOpener func( @@ -40,6 +43,16 @@ func runWithIO(args []string, stdin io.Reader, stdout, stderr io.Writer) int { flags := flag.NewFlagSet("coding-ethos-hook", flag.ExitOnError) bundlePath := flags.String("bundle", "", "Path to policy-bundle.json") jsonOutput := flags.Bool("json", false, "Emit JSON result to stdout") + contract := flags.String( + "contract", + "", + "Provider-neutral hook contract (neutral-v1)", + ) + provider := flags.String( + "provider", + "", + "Provider override for native hook adapters", + ) err := flags.Parse(args) if err != nil { @@ -54,19 +67,16 @@ func runWithIO(args []string, stdin io.Reader, stdout, stderr io.Writer) int { return 1 } - bundle, err := readBundle(*bundlePath) + selectedContract, err := resolveHookContract(*contract) if err != nil { printErr(stderr, err) return 1 } - err = bundle.Validate() + bundle, err := loadValidatedBundle(*bundlePath) if err != nil { - printErr( - stderr, - fmt.Errorf("%w:\n%s", errInvalidBundle, policy.FormatValidationError(err)), - ) + printErr(stderr, err) return 1 } @@ -78,6 +88,10 @@ func runWithIO(args []string, stdin io.Reader, stdout, stderr io.Writer) int { return 1 } + if *provider != "" { + event.ProviderHint = *provider + } + startedAt := time.Now() result, err := hooks.Run(bundle, hooks.Options{Event: event}) @@ -96,8 +110,37 @@ func runWithIO(args []string, stdin io.Reader, stdout, stderr io.Writer) int { return 1 } - if *jsonOutput { - err = hooks.EncodeResult(stdout, result) + return emitHookResult( + stdout, + stderr, + result, + *jsonOutput, + selectedContract, + ) +} + +func resolveHookContract(contract string) (string, error) { + selected := contract + if selected == "" { + selected = os.Getenv("CODE_ETHOS_HOOK_CONTRACT") + } + + if selected != "" && selected != hooks.HookContractV1Selector { + return "", errInvalidContract + } + + return selected, nil +} + +func emitHookResult( + stdout io.Writer, + stderr io.Writer, + result hooks.Result, + jsonOutput bool, + selectedContract string, +) int { + if jsonOutput || selectedContract != "" { + err := encodeHookResult(stdout, result, selectedContract) if err != nil { printErr(stderr, err) @@ -105,15 +148,59 @@ func runWithIO(args []string, stdin io.Reader, stdout, stderr io.Writer) int { } } - if result.Blocked() { - if !*jsonOutput { - printBlocked(stderr, result) + if !result.Blocked() { + return 0 + } + + if selectedContract == hooks.HookContractV1Selector { + return hooks.AgentHookBlockedExitCode + } + + if !jsonOutput || result.Provider == "kimi" { + printBlocked(stderr, result) + } + + return hooks.AgentHookBlockedExitCodeForProvider(result.Provider) +} + +func encodeHookResult( + writer io.Writer, + result hooks.Result, + selectedContract string, +) error { + if selectedContract == hooks.HookContractV1Selector { + err := hooks.EncodeNeutralHookResultV1(writer, result) + if err != nil { + return fmt.Errorf("encode neutral hook result: %w", err) } - return blockedExitCode + return nil } - return 0 + err := hooks.EncodeResult(writer, result) + if err != nil { + return fmt.Errorf("encode provider hook result: %w", err) + } + + return nil +} + +func loadValidatedBundle(path string) (policy.Bundle, error) { + bundle, err := readBundle(path) + if err != nil { + return policy.Bundle{}, err + } + + err = bundle.Validate() + if err != nil { + return policy.Bundle{}, fmt.Errorf( + "%w:\n%s", + errInvalidBundle, + policy.FormatValidationError(err), + ) + } + + return bundle, nil } func persistHookResult(event hooks.Event, result hooks.Result) error { diff --git a/go/internal/hookcli/main_internal_test.go b/go/internal/hookcli/main_internal_test.go index ce9986a1..76ffc00b 100644 --- a/go/internal/hookcli/main_internal_test.go +++ b/go/internal/hookcli/main_internal_test.go @@ -163,6 +163,182 @@ func TestRunWithIOBlocksBashBypass(t *testing.T) { } } +func TestRunWithIOEmitsNeutralV1Contract(t *testing.T) { + t.Parallel() + + var ( + stdout bytes.Buffer + stderr bytes.Buffer + ) + + status := runWithIO( + []string{ + "--bundle", writeCLITestBundle(t), + "--json", + "--contract", "neutral-v1", + }, + strings.NewReader(`{ + "provider": "codex", + "event": "SessionStart", + "correlation_id": "nyar-001" + }`), + &stdout, + &stderr, + ) + if status != 0 { + t.Fatalf( + "status=%d stdout=%q stderr=%q", + status, + stdout.String(), + stderr.String(), + ) + } + + for _, expected := range []string{ + `"contract_version": "coding-ethos.hook/v1"`, + `"correlation_id": "nyar-001"`, + `"decision": "allow"`, + `"effect":`, + `"advice":`, + } { + if !strings.Contains(stdout.String(), expected) { + t.Fatalf("neutral output missing %q:\n%s", expected, stdout.String()) + } + } +} + +func TestRunWithIOKeepsNeutralV1BlockSemanticsProviderIndependent(t *testing.T) { + t.Parallel() + + var ( + stdout bytes.Buffer + stderr bytes.Buffer + ) + + status := runWithIO( + []string{ + "--bundle", writeCLITestBundle(t), + "--json", + "--contract", "neutral-v1", + "--provider", "kimi", + }, + strings.NewReader(`{ + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": {"command": "git commit --no-verify -m test"} + }`), + &stdout, + &stderr, + ) + if status != blockedExitCode || + !strings.Contains(stdout.String(), `"decision": "deny"`) || + !strings.Contains(stdout.String(), `"action": "block"`) || + strings.TrimSpace(stderr.String()) != "" { + t.Fatalf( + "status=%d stdout=%q stderr=%q", + status, + stdout.String(), + stderr.String(), + ) + } +} + +func TestRunWithIOSelectsNeutralV1ContractFromEnvironment(t *testing.T) { + t.Setenv("CODE_ETHOS_HOOK_CONTRACT", "neutral-v1") + + var ( + stdout bytes.Buffer + stderr bytes.Buffer + ) + + status := runWithIO( + []string{"--bundle", writeCLITestBundle(t), "--json"}, + strings.NewReader(`{ + "provider": "codex", + "event": "SessionStart" + }`), + &stdout, + &stderr, + ) + if status != 0 || + !strings.Contains(stdout.String(), `"contract_version": "coding-ethos.hook/v1"`) { + t.Fatalf( + "status=%d stdout=%q stderr=%q", + status, + stdout.String(), + stderr.String(), + ) + } +} + +func TestRunWithIOUsesKimiNativeBlockAndStopSemantics(t *testing.T) { + t.Parallel() + + t.Run("policy block exits two with reason", func(t *testing.T) { + t.Parallel() + + var ( + stdout bytes.Buffer + stderr bytes.Buffer + ) + + status := runWithIO( + []string{ + "--bundle", writeCLITestBundle(t), + "--json", + "--provider", "kimi", + }, + strings.NewReader(`{ + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": {"command": "git commit --no-verify -m test"} + }`), + &stdout, + &stderr, + ) + if status != 2 || + !strings.Contains(stdout.String(), `"permissionDecision": "deny"`) || + strings.TrimSpace(stderr.String()) == "" { + t.Fatalf( + "status=%d stdout=%q stderr=%q", + status, + stdout.String(), + stderr.String(), + ) + } + }) + + t.Run("Stop guidance continues through structured deny", func(t *testing.T) { + t.Parallel() + + var ( + stdout bytes.Buffer + stderr bytes.Buffer + ) + + status := runWithIO( + []string{ + "--bundle", writeCLITestBundle(t), + "--json", + "--provider", "kimi", + }, + strings.NewReader(`{"hook_event_name":"Stop"}`), + &stdout, + &stderr, + ) + if status != 0 || + !strings.Contains(stdout.String(), `"permissionDecision": "deny"`) || + strings.TrimSpace(stderr.String()) != "" { + t.Fatalf( + "status=%d stdout=%q stderr=%q", + status, + stdout.String(), + stderr.String(), + ) + } + }) +} + func TestRunWithIOReturnsErrorsWithoutExiting(t *testing.T) { t.Parallel() diff --git a/go/internal/hooks/contract_v1.go b/go/internal/hooks/contract_v1.go new file mode 100644 index 00000000..6d5c6f91 --- /dev/null +++ b/go/internal/hooks/contract_v1.go @@ -0,0 +1,432 @@ +// SPDX-FileCopyrightText: 2026 Blackcat Informatics Inc. +// SPDX-License-Identifier: AGPL-3.0-only + +package hooks + +import ( + "encoding/json" + "fmt" + "io" + "slices" + "strings" + "unicode" + + "blackcat.ca/coding-ethos/go/internal/apperror" + "blackcat.ca/coding-ethos/go/internal/policy" +) + +const ( + // HookContractV1 identifies the stable provider-neutral hook JSON contract. + HookContractV1 = "coding-ethos.hook/v1" + // HookContractV1Selector is the CLI value that selects provider-neutral v1 output. + HookContractV1Selector = "neutral-v1" + // HookContractV1MaxInputBytes bounds one hook request before JSON decoding. + HookContractV1MaxInputBytes = 1 << 20 + + hookContractV1MaxCorrelationBytes = 128 + hookContractV1MaxIdentifierBytes = 256 + hookContractV1MaxPathBytes = 4096 +) + +var ( + errHookContractField = apperror.StaticError( + "unsupported field", + ) + errHookContractEvent = apperror.StaticError( + "unsupported hook_event_name", + ) + errHookContractProvider = apperror.StaticError( + "unsupported provider", + ) + errHookContractVersion = apperror.StaticError( + "unsupported contract_version", + ) + errHookContractIdentifierRequired = apperror.StaticError( + "required hook contract identifier is empty", + ) + errHookContractIdentifierTooLong = apperror.StaticError( + "hook contract identifier exceeds its byte limit", + ) + errHookContractIdentifierControl = apperror.StaticError( + "hook contract identifier contains control characters", + ) + errHookContractNegativeContext = apperror.StaticError( + "context_window_tokens must be non-negative", + ) +) + +// HookContractCapability describes one stable hook request/response contract. +type HookContractCapability struct { + ContractVersion string `json:"contract_version"` + Selector string `json:"selector"` + InputEncoding string `json:"input_encoding"` + OutputEncoding string `json:"output_encoding"` + Events []string `json:"events"` + Outcomes []string `json:"outcomes"` + Effects []string `json:"effects"` + MaxInputBytes int64 `json:"max_input_bytes"` +} + +// HookContractV1Capability returns the machine-readable neutral v1 contract. +func HookContractV1Capability() HookContractCapability { + return HookContractCapability{ + ContractVersion: HookContractV1, + Selector: HookContractV1Selector, + MaxInputBytes: HookContractV1MaxInputBytes, + InputEncoding: "application/json", + OutputEncoding: "application/json", + Events: hookContractV1Events(), + Outcomes: []string{"allow", "deny"}, + Effects: []string{ + "allow", + "block", + "continue", + "rewrite", + }, + } +} + +// ValidateHookContractV1 validates canonical v1 fields when a request declares +// the neutral contract. Provider-native requests without contract_version keep +// the existing alias-compatible decoder. +func ValidateHookContractV1( + payload map[string]json.RawMessage, + event Event, +) error { + err := validateHookContractVersionAndFields(payload, event) + if err != nil { + return err + } + + err = validateHookContractEvent(event) + if err != nil { + return err + } + + err = validateHookContractProvider(event) + if err != nil { + return err + } + + err = validateHookContractOptionalFields(event) + if err != nil { + return err + } + + if event.ContextWindowTokens < 0 { + return fmt.Errorf( + "%w: %s", + errHookContractNegativeContext, + HookContractV1, + ) + } + + return nil +} + +func validateHookContractVersionAndFields( + payload map[string]json.RawMessage, + event Event, +) error { + if event.ContractVersion != HookContractV1 { + return fmt.Errorf( + "%w: %q", + errHookContractVersion, + event.ContractVersion, + ) + } + + for field := range payload { + if !hookContractV1FieldSupported(field) { + return fmt.Errorf( + "%w: %s %q", + errHookContractField, + HookContractV1, + field, + ) + } + } + + return nil +} + +func validateHookContractEvent(event Event) error { + err := validateHookContractIdentifier( + "correlation_id", + event.CorrelationID, + hookContractV1MaxCorrelationBytes, + false, + ) + if err != nil { + return err + } + + err = validateHookContractIdentifier( + "hook_event_name", + event.HookEventName, + hookContractV1MaxIdentifierBytes, + true, + ) + if err != nil { + return err + } + + if !slices.Contains(hookContractV1Events(), event.HookEventName) { + return fmt.Errorf( + "%w: %s %q", + errHookContractEvent, + HookContractV1, + event.HookEventName, + ) + } + + return nil +} + +func validateHookContractProvider(event Event) error { + err := validateHookContractIdentifier( + "provider", + event.ProviderHint, + hookContractV1MaxIdentifierBytes, + true, + ) + if err != nil { + return err + } + + if !slices.Contains(hookContractV1Providers(), event.Provider()) { + return fmt.Errorf( + "%w: %s %q", + errHookContractProvider, + HookContractV1, + event.ProviderHint, + ) + } + + return nil +} + +func validateHookContractOptionalFields(event Event) error { + for _, value := range []struct { + name string + value string + maxBytes int + }{ + {name: "cwd", value: event.Cwd, maxBytes: hookContractV1MaxPathBytes}, + { + name: "transcript_path", + value: event.TranscriptPath, + maxBytes: hookContractV1MaxPathBytes, + }, + {name: "matcher", value: event.Matcher, maxBytes: hookContractV1MaxIdentifierBytes}, + {name: "model", value: event.Model, maxBytes: hookContractV1MaxIdentifierBytes}, + { + name: "session_id", + value: event.SessionID, + maxBytes: hookContractV1MaxIdentifierBytes, + }, + {name: "source", value: event.Source, maxBytes: hookContractV1MaxIdentifierBytes}, + { + name: "tool_name", + value: event.ToolName, + maxBytes: hookContractV1MaxIdentifierBytes, + }, + } { + err := validateHookContractIdentifier( + value.name, + value.value, + value.maxBytes, + false, + ) + if err != nil { + return err + } + } + + return nil +} + +func validateHookContractIdentifier( + name string, + value string, + maxBytes int, + required bool, +) error { + if required && strings.TrimSpace(value) == "" { + return fmt.Errorf( + "%w: %s %s is required", + errHookContractIdentifierRequired, + HookContractV1, + name, + ) + } + + if len(value) > maxBytes { + return fmt.Errorf( + "%w: %s %s exceeds %d bytes", + errHookContractIdentifierTooLong, + HookContractV1, + name, + maxBytes, + ) + } + + if strings.IndexFunc(value, unicode.IsControl) >= 0 { + return fmt.Errorf( + "%w: %s %s contains control characters", + errHookContractIdentifierControl, + HookContractV1, + name, + ) + } + + return nil +} + +// NeutralHookResultV1 is the stable provider-neutral hook response. +type NeutralHookResultV1 struct { + ContractVersion string `json:"contract_version"` + CorrelationID string `json:"correlation_id"` + Event NeutralHookEventV1 `json:"event"` + Decision string `json:"decision"` + Effect NeutralHookEffectV1 `json:"effect"` + Status string `json:"status"` + TrackingID string `json:"tracking_id,omitempty"` + Decisions []policy.Decision `json:"decisions,omitempty"` + Advice policy.Advice `json:"advice,omitzero"` + RuntimeMS int64 `json:"runtime_ms,omitempty"` +} + +// NeutralHookEventV1 identifies the evaluated event without provider-specific names. +type NeutralHookEventV1 struct { + Name string `json:"name"` + Provider string `json:"provider,omitempty"` + Tool string `json:"tool,omitempty"` +} + +// NeutralHookEffectV1 describes what a supervisor should do with the decision. +type NeutralHookEffectV1 struct { + UpdatedInput map[string]any `json:"updated_input,omitempty"` + Action string `json:"action"` + Reason string `json:"reason,omitempty"` + AdditionalContext string `json:"additional_context,omitempty"` +} + +// EncodeNeutralHookResultV1 writes a stable provider-neutral hook response. +func EncodeNeutralHookResultV1(writer io.Writer, result Result) error { + effect := neutralHookEffectV1(result) + + decision := "allow" + if result.Blocked() { + decision = "deny" + } + + output := NeutralHookResultV1{ + ContractVersion: HookContractV1, + CorrelationID: result.CorrelationID, + Event: NeutralHookEventV1{ + Name: result.Event, + Provider: result.Provider, + Tool: result.Tool, + }, + Decision: decision, + Effect: effect, + Status: result.Status, + TrackingID: result.TrackingID, + Decisions: result.Decisions, + Advice: result.Advice, + RuntimeMS: result.RuntimeMS, + } + + encoder := json.NewEncoder(writer) + encoder.SetEscapeHTML(false) + encoder.SetIndent("", " ") + + err := encoder.Encode(output) + if err != nil { + return fmt.Errorf("encode neutral hook result %s: %w", HookContractV1, err) + } + + return nil +} + +func neutralHookEffectV1(result Result) NeutralHookEffectV1 { + effect := NeutralHookEffectV1{Action: "allow"} + + if result.Blocked() { + effect.Action = "block" + + effect.Reason = ProviderBlockMessage(result) + if result.Event == eventStop { + effect.Action = "continue" + } + } + + if result.HookSpecificOutput == nil { + return effect + } + + effect.AdditionalContext = result.HookSpecificOutput.AdditionalContext + effect.UpdatedInput = result.HookSpecificOutput.UpdatedInput + + if len(effect.UpdatedInput) > 0 && !result.Blocked() { + effect.Action = "rewrite" + } + + return effect +} + +func hookContractV1FieldSupported(field string) bool { + switch field { + case "contract_version", + "correlation_id", + "context_window_tokens", + "cwd", + "hook_event_name", + "matcher", + "model", + "provider", + "session_id", + "source", + "tool_input", + "tool_name", + "tool_response", + "transcript_path": + return true + default: + return false + } +} + +func hookContractV1Events() []string { + return []string{ + "Interrupt", + "Notification", + "PermissionRequest", + "PermissionResult", + "PostCompact", + "PostToolBatch", + "PostToolUse", + "PostToolUseFailure", + "PreCompact", + "PreToolUse", + "SessionEnd", + "SessionStart", + "Stop", + "StopFailure", + "SubagentStart", + "SubagentStop", + "UserPromptSubmit", + } +} + +func hookContractV1Providers() []string { + return []string{ + providerClaude, + providerCodex, + providerCodingEthos, + providerGemini, + providerKimi, + "generic", + } +} diff --git a/go/internal/hooks/contract_v1_test.go b/go/internal/hooks/contract_v1_test.go new file mode 100644 index 00000000..a6b4a2ca --- /dev/null +++ b/go/internal/hooks/contract_v1_test.go @@ -0,0 +1,171 @@ +// SPDX-FileCopyrightText: 2026 Blackcat Informatics Inc. +// SPDX-License-Identifier: AGPL-3.0-only + +package hooks_test + +import ( + "strings" + "testing" + + . "blackcat.ca/coding-ethos/go/internal/hooks" + "blackcat.ca/coding-ethos/go/internal/policy" +) + +func TestNeutralHookContractV1MatchesGoldenFixtures(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + payload string + fixture string + }{ + { + name: "allowed", + payload: `{ + "contract_version": "coding-ethos.hook/v1", + "correlation_id": "request-allowed-001", + "provider": "codex", + "hook_event_name": "PreToolUse", + "tool_name": "functions.update_plan", + "tool_input": {} + }`, + fixture: "testdata/neutral_v1_allowed.json", + }, + { + name: "blocked", + payload: `{ + "contract_version": "coding-ethos.hook/v1", + "correlation_id": "request-blocked-001", + "provider": "codex", + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": {"command": "git commit --no-verify -m test"} + }`, + fixture: "testdata/neutral_v1_blocked.json", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + event, err := DecodeEvent(strings.NewReader(test.payload)) + if err != nil { + t.Fatalf("decode event: %v", err) + } + + result, err := Run(policy.ExampleBundle(), Options{Event: event}) + if err != nil { + t.Fatalf("run hook: %v", err) + } + + var output strings.Builder + if err := EncodeNeutralHookResultV1(&output, result); err != nil { + t.Fatalf("encode neutral result: %v", err) + } + + assertJSONMatchesFixture(t, output.String(), test.fixture) + }) + } +} + +func TestNeutralHookContractV1GeneratesCorrelationID(t *testing.T) { + t.Parallel() + + event, err := DecodeEvent(strings.NewReader(`{ + "provider": "codex", + "event": "SessionStart" + }`)) + if err != nil { + t.Fatalf("decode event: %v", err) + } + + result, err := Run(policy.ExampleBundle(), Options{Event: event}) + if err != nil { + t.Fatalf("run hook: %v", err) + } + if !strings.HasPrefix(result.CorrelationID, "hook-") { + t.Fatalf("generated correlation_id = %q", result.CorrelationID) + } +} + +func TestDeclaredNeutralHookContractV1RejectsInvalidShape(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + payload string + want string + }{ + { + name: "unsupported contract", + payload: `{ + "contract_version": "coding-ethos.hook/v2", + "provider": "codex", + "hook_event_name": "Stop" + }`, + want: "unsupported contract_version", + }, + { + name: "provider required", + payload: `{ + "contract_version": "coding-ethos.hook/v1", + "hook_event_name": "Stop" + }`, + want: "provider is required", + }, + { + name: "unknown field", + payload: `{ + "contract_version": "coding-ethos.hook/v1", + "provider": "codex", + "hook_event_name": "Stop", + "unexpected": true + }`, + want: "unsupported field", + }, + { + name: "multiple values", + payload: `{ + "provider": "codex", + "event": "Stop" + } {}`, + want: "multiple JSON values", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + _, err := DecodeEvent(strings.NewReader(test.payload)) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("DecodeEvent error = %v, want %q", err, test.want) + } + }) + } +} + +func TestHookEventInputIsBounded(t *testing.T) { + t.Parallel() + + payload := `{"provider":"codex","event":"Stop","padding":"` + + strings.Repeat("x", HookContractV1MaxInputBytes) + + `"}` + + _, err := DecodeEvent(strings.NewReader(payload)) + if err == nil || !strings.Contains(err.Error(), "payload exceeds") { + t.Fatalf("DecodeEvent error = %v, want payload limit", err) + } +} + +func TestHookContractV1CapabilityIsMachineReadable(t *testing.T) { + t.Parallel() + + capability := HookContractV1Capability() + if capability.ContractVersion != HookContractV1 || + capability.Selector != HookContractV1Selector || + capability.MaxInputBytes != HookContractV1MaxInputBytes { + t.Fatalf("capability = %#v", capability) + } +} diff --git a/go/internal/hooks/event.go b/go/internal/hooks/event.go index d4d71a4a..46239e19 100644 --- a/go/internal/hooks/event.go +++ b/go/internal/hooks/event.go @@ -14,6 +14,7 @@ const ( providerCodex = "codex" providerCodingEthos = "coding-ethos" providerGemini = "gemini" + providerKimi = "kimi" ) const ( @@ -28,6 +29,8 @@ const ( type Event struct { ToolInput map[string]any `json:"tool_input,omitempty"` ToolResponse map[string]any `json:"tool_response,omitempty"` + ContractVersion string `json:"contract_version,omitempty"` + CorrelationID string `json:"correlation_id,omitempty"` ProviderHint string `json:"provider,omitempty"` Cwd string `json:"cwd,omitempty"` HookEventName string `json:"hook_event_name"` @@ -45,6 +48,8 @@ func (event Event) Provider() string { switch { case strings.Contains(providerHint, providerCodingEthos): return providerCodingEthos + case strings.Contains(providerHint, providerKimi): + return providerKimi case strings.Contains(providerHint, providerGemini): return providerGemini case strings.Contains(providerHint, providerCodex): @@ -57,6 +62,8 @@ func (event Event) Provider() string { switch { case strings.Contains(source, providerCodingEthos): return providerCodingEthos + case strings.Contains(source, providerKimi): + return providerKimi case strings.Contains(source, providerGemini): return providerGemini case strings.Contains(source, providerCodex): @@ -76,6 +83,8 @@ func providerFromEnvironment() string { return providerCodex case strings.TrimSpace(os.Getenv("GEMINI_CLI")) != "": return providerGemini + case strings.TrimSpace(os.Getenv("KIMI_CODE_HOME")) != "": + return providerKimi case strings.TrimSpace(os.Getenv("CLAUDECODE")) != "" || strings.TrimSpace(os.Getenv("CLAUDE_CODE_ENTRYPOINT")) != "": return providerClaude diff --git a/go/internal/hooks/inspection.go b/go/internal/hooks/inspection.go index f11a6bd8..95676799 100644 --- a/go/internal/hooks/inspection.go +++ b/go/internal/hooks/inspection.go @@ -194,9 +194,10 @@ func providerSupportsUpdatedInput(provider string) bool { func (ctx InspectionContext) allowedResult() Result { return Result{ - Event: ctx.Event.HookEventName, - Provider: ctx.Provider, - Tool: ctx.Event.ToolName, - Status: statusAllowed, + CorrelationID: ctx.Event.CorrelationID, + Event: ctx.Event.HookEventName, + Provider: ctx.Provider, + Tool: ctx.Event.ToolName, + Status: statusAllowed, } } diff --git a/go/internal/hooks/json.go b/go/internal/hooks/json.go index b9d47129..9facdff0 100644 --- a/go/internal/hooks/json.go +++ b/go/internal/hooks/json.go @@ -4,25 +4,69 @@ package hooks import ( + "bytes" "encoding/json" + "errors" "fmt" "io" "strconv" + "blackcat.ca/coding-ethos/go/internal/apperror" "blackcat.ca/coding-ethos/go/internal/toolaliases" ) +var ( + errHookEventMultipleJSON = apperror.StaticError( + "hook event contains multiple JSON values", + ) + errHookEventPayloadTooLarge = apperror.StaticError( + "hook event payload exceeds its byte limit", + ) +) + func DecodeEvent(reader io.Reader) (Event, error) { + payloadBytes, err := io.ReadAll(io.LimitReader(reader, HookContractV1MaxInputBytes+1)) + if err != nil { + return Event{}, fmt.Errorf("read hook event: %w", err) + } + + if len(payloadBytes) > HookContractV1MaxInputBytes { + return Event{}, fmt.Errorf( + "%w: payload exceeds %d bytes", + errHookEventPayloadTooLarge, + HookContractV1MaxInputBytes, + ) + } + payload := map[string]json.RawMessage{} - decoder := json.NewDecoder(reader) + decoder := json.NewDecoder(bytes.NewReader(payloadBytes)) - err := decoder.Decode(&payload) + err = decoder.Decode(&payload) if err != nil { return Event{}, fmt.Errorf("decode hook event: %w", err) } - return normalizeEvent(payload), nil + var trailing json.RawMessage + + err = decoder.Decode(&trailing) + if !errors.Is(err, io.EOF) { + if err == nil { + return Event{}, errHookEventMultipleJSON + } + + return Event{}, fmt.Errorf("decode hook event trailing data: %w", err) + } + + event := normalizeEvent(payload) + if event.ContractVersion != "" { + err = ValidateHookContractV1(payload, event) + if err != nil { + return Event{}, err + } + } + + return event, nil } func EncodeResult(writer io.Writer, result Result) error { @@ -44,6 +88,8 @@ func EncodeResult(writer io.Writer, result Result) error { func normalizeEvent(payload map[string]json.RawMessage) Event { event := Event{ + ContractVersion: firstString(payload, "contract_version"), + CorrelationID: firstString(payload, "correlation_id"), ContextWindowTokens: firstPositiveInt( payload, "context_window_tokens", diff --git a/go/internal/hooks/provider_output.go b/go/internal/hooks/provider_output.go index d6c1cca1..22a0c4ed 100644 --- a/go/internal/hooks/provider_output.go +++ b/go/internal/hooks/provider_output.go @@ -17,6 +17,7 @@ import ( type providerHookOutput struct { HookSpecificOutput *HookSpecificOutput `json:"hookSpecificOutput,omitempty"` Decision string `json:"decision,omitempty"` + Message string `json:"message,omitempty"` Reason string `json:"reason,omitempty"` SystemMessage string `json:"systemMessage,omitempty"` TraceID string `json:"traceId,omitempty"` @@ -58,6 +59,7 @@ func neutralCodexPreToolOutput(result Result) bool { func (output providerHookOutput) empty() bool { return output.HookSpecificOutput == nil && output.Decision == "" && + output.Message == "" && output.Reason == "" && output.SystemMessage == "" && output.TraceID == "" && @@ -79,11 +81,33 @@ func providerOutput(result Result) providerHookOutput { return codexAllowedOutput(result) case "gemini": return geminiAllowedOutput(result) + case providerKimi: + return kimiAllowedOutput(result) default: return claudeAllowedOutput(result) } } +func kimiAllowedOutput(result Result) providerHookOutput { + output := result.HookSpecificOutput + if output.AdditionalContext == "" { + return providerHookOutput{} + } + + if output.HookEventName == eventStop { + return providerHookOutput{ + Message: output.AdditionalContext, + HookSpecificOutput: &HookSpecificOutput{ + HookEventName: output.HookEventName, + PermissionDecision: "deny", + PermissionDecisionReason: output.AdditionalContext, + }, + } + } + + return providerHookOutput{Message: output.AdditionalContext} +} + func claudeAllowedOutput(result Result) providerHookOutput { output := result.HookSpecificOutput if output.AdditionalContext == "" { @@ -186,6 +210,20 @@ func providerBlockedOutput(result Result) providerHookOutput { } return output + case providerKimi: + return providerHookOutput{ + Decision: "deny", + Message: message, + Reason: message, + TraceID: result.TrackingID, + TrackingID: result.TrackingID, + AgentRemediation: remediation, + HookSpecificOutput: &HookSpecificOutput{ + HookEventName: result.Event, + PermissionDecision: "deny", + PermissionDecisionReason: message, + }, + } default: return providerHookOutput{ Decision: "block", diff --git a/go/internal/hooks/provider_output_test.go b/go/internal/hooks/provider_output_test.go index 59366e64..834d5a83 100644 --- a/go/internal/hooks/provider_output_test.go +++ b/go/internal/hooks/provider_output_test.go @@ -140,6 +140,46 @@ func TestProviderDenialIncludesTrackingID(t *testing.T) { } } +func TestEncodeProviderResultUsesKimiStopContinuationShape(t *testing.T) { + t.Parallel() + + output := encodedProviderOutput(t, `{ + "provider": "kimi", + "hook_event_name": "Stop" + }`) + + for _, expected := range []string{ + `"message": "Before ending:`, + `"permissionDecision": "deny"`, + `"permissionDecisionReason": "Before ending:`, + } { + if !strings.Contains(output, expected) { + t.Fatalf("missing %q in Kimi Stop output: %s", expected, output) + } + } +} + +func TestEncodeProviderResultUsesKimiStructuredDeny(t *testing.T) { + t.Parallel() + + output := encodedProviderOutput(t, `{ + "provider": "kimi", + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": {"command": "git commit --no-verify -m test"} + }`) + + for _, expected := range []string{ + `"decision": "deny"`, + `"permissionDecision": "deny"`, + `"trackingID": "hook-`, + } { + if !strings.Contains(output, expected) { + t.Fatalf("missing %q in Kimi deny output: %s", expected, output) + } + } +} + func TestBlockedAdviceTOONIncludesAgentRemediation(t *testing.T) { t.Setenv("CODE_ETHOS_HOOK_OUTPUT_FORMAT", "toon") diff --git a/go/internal/hooks/result.go b/go/internal/hooks/result.go index b579663f..76d1376d 100644 --- a/go/internal/hooks/result.go +++ b/go/internal/hooks/result.go @@ -9,14 +9,28 @@ import ( "blackcat.ca/coding-ethos/go/internal/policy" ) -// AgentHookBlockedExitCode is the standalone hook CLI exit code for a denied -// action when no provider JSON contract is active. Provider hooks communicate -// denies through their JSON payload and still exit successfully. -const AgentHookBlockedExitCode = 1 +// AgentHookBlockedExitCode is the historical hook CLI exit code for a denied +// action. Provider-native adapters may override it when their hook protocol +// assigns a different blocking status. +const ( + AgentHookBlockedExitCode = 1 + kimiBlockedExitCode = 2 +) + +// AgentHookBlockedExitCodeForProvider returns the provider-native blocking exit +// status while preserving the historical standalone status for existing hooks. +func AgentHookBlockedExitCodeForProvider(provider string) int { + if provider == providerKimi { + return kimiBlockedExitCode + } + + return AgentHookBlockedExitCode +} type Result struct { HookSpecificOutput *HookSpecificOutput `json:"hookSpecificOutput,omitempty"` ProxyEvents []agentproxy.ProviderEvent `json:"-"` + CorrelationID string `json:"correlation_id,omitempty"` Event string `json:"event"` Provider string `json:"provider,omitempty"` Status string `json:"status"` diff --git a/go/internal/hooks/runner.go b/go/internal/hooks/runner.go index f56ad821..6d65e722 100644 --- a/go/internal/hooks/runner.go +++ b/go/internal/hooks/runner.go @@ -99,6 +99,7 @@ func RunWithRegistry( ) result := ctx.allowedResult() + ensureResultCorrelationID(options.Event, &result) result.RuntimeMS = time.Since(startedAt).Milliseconds() logHookRuntime(result.RuntimeMS) @@ -115,6 +116,7 @@ func RunWithRegistry( } result := buildResult(bundle, ctx.Event, decision) + ensureResultCorrelationID(options.Event, &result) result.RuntimeMS = time.Since(startedAt).Milliseconds() logHookRuntime(result.RuntimeMS) @@ -245,6 +247,7 @@ func buildResult( hookOutput, proxyEvents := hookSpecificOutput(bundle, event, decision.Route) result := Result{ + CorrelationID: event.CorrelationID, Event: event.HookEventName, Advice: bundle.Advice, Provider: event.Provider(), @@ -265,6 +268,14 @@ func buildResult( return result } +func ensureResultCorrelationID(event Event, result *Result) { + if result.CorrelationID != "" { + return + } + + result.CorrelationID = hookTraceID(event, *result) +} + func blockedHookSpecificOutput(result Result) *HookSpecificOutput { if !result.Blocked() || result.Event != eventPreToolUse || result.Provider != "" { return nil @@ -1018,7 +1029,7 @@ func pathMatches(pattern, name string) (bool, error) { func resultStatus(decisions []policy.Decision) string { for _, decision := range decisions { - if decision.Decision == "block" || decision.Severity == "block" { + if decision.Decision == modeBlock || decision.Severity == modeBlock { return statusBlocked } } diff --git a/go/internal/hooks/testdata/neutral_v1_allowed.json b/go/internal/hooks/testdata/neutral_v1_allowed.json new file mode 100644 index 00000000..49610fa6 --- /dev/null +++ b/go/internal/hooks/testdata/neutral_v1_allowed.json @@ -0,0 +1,19 @@ +{ + "contract_version": "coding-ethos.hook/v1", + "correlation_id": "request-allowed-001", + "event": { + "name": "PreToolUse", + "provider": "codex", + "tool": "Noop" + }, + "decision": "allow", + "effect": { + "action": "allow" + }, + "status": "allowed", + "advice": { + "reminders": { + "ambient_frequency_percent": 25 + } + } +} diff --git a/go/internal/hooks/testdata/neutral_v1_blocked.json b/go/internal/hooks/testdata/neutral_v1_blocked.json new file mode 100644 index 00000000..9cd94332 --- /dev/null +++ b/go/internal/hooks/testdata/neutral_v1_blocked.json @@ -0,0 +1,25 @@ +{ + "contract_version": "coding-ethos.hook/v1", + "correlation_id": "request-blocked-001", + "event": { + "name": "PreToolUse", + "provider": "codex", + "tool": "Bash" + }, + "decision": "deny", + "effect": { + "action": "block" + }, + "status": "blocked", + "decisions": [ + { + "policy_id": "git.hook_bypass", + "decision": "block" + } + ], + "advice": { + "reminders": { + "ambient_frequency_percent": 25 + } + } +} diff --git a/go/internal/syncstate/state.go b/go/internal/syncstate/state.go index b116095b..52b46d2f 100644 --- a/go/internal/syncstate/state.go +++ b/go/internal/syncstate/state.go @@ -763,6 +763,11 @@ func runtimeVersion(ethosRoot string) string { return "" } +// RuntimeVersion returns the project version declared by pyproject.toml. +func RuntimeVersion(ethosRoot string) string { + return runtimeVersion(ethosRoot) +} + func runtimeCommit(ethosRoot string) string { ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() diff --git a/go/internal/toolaliases/aliases.go b/go/internal/toolaliases/aliases.go index c5bde568..13b44233 100644 --- a/go/internal/toolaliases/aliases.go +++ b/go/internal/toolaliases/aliases.go @@ -16,6 +16,7 @@ const ( ProviderClaude = "claude" ProviderCodex = "codex" ProviderGemini = "gemini" + ProviderKimi = "kimi" ) func IsWriteLike(canonical string) bool { @@ -38,11 +39,24 @@ func KnownAliases() []Alias { aliases = append(aliases, claudeAliases()...) aliases = append(aliases, codexAliases()...) aliases = append(aliases, geminiAliases()...) + aliases = append(aliases, kimiAliases()...) return aliases } -const knownAliasCapacity = 64 +const knownAliasCapacity = 80 + +func kimiAliases() []Alias { + claude := claudeAliases() + + aliases := make([]Alias, 0, len(claude)) + for _, alias := range claude { + alias.Provider = ProviderKimi + aliases = append(aliases, alias) + } + + return aliases +} func claudeAliases() []Alias { aliases := make([]Alias, 0, claudeAliasCapacity) diff --git a/go/internal/toolaliases/aliases_test.go b/go/internal/toolaliases/aliases_test.go index da44d12b..512071cb 100644 --- a/go/internal/toolaliases/aliases_test.go +++ b/go/internal/toolaliases/aliases_test.go @@ -64,6 +64,14 @@ func TestProviderAliasesAndNoopCanonical(t *testing.T) { t.Fatal("Codex shell aliases should be registered") } + kimiAliases := toolaliases.ProviderAliases( + toolaliases.ProviderKimi, + toolaliases.CanonicalNoop, + ) + if len(kimiAliases) == 0 { + t.Fatal("Kimi no-op aliases should be registered") + } + if !toolaliases.NoopCanonical("functions.update_plan") { t.Fatal("known no-op tool should be recognized") } From d2c274d0fbb4131a29911ddc894f6a00b3f7c8a5 Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Sun, 26 Jul 2026 19:02:29 -0600 Subject: [PATCH 02/13] feat(runtime): add private state roots for supervisors --- .coding-ethos/.gitignore | 12 ++ .gitignore | 1 + README.md | 62 ++++++-- docs/HOOK_CONTRACT_V1.md | 48 ++++-- go/cmd/coding-ethos-run/args.go | 102 +++++++++++- go/cmd/coding-ethos-run/dispatch.go | 17 +- go/cmd/coding-ethos-run/hook_policy_paths.go | 10 ++ go/cmd/coding-ethos-run/main.go | 25 ++- go/cmd/coding-ethos-run/main_test.go | 143 ++++++++++++++++- go/cmd/coding-ethos-run/parent_workflow.go | 42 ++++- .../agenthooks/provider_capabilities.go | 2 + go/internal/agenthooks/settings.go | 147 +++++++++++++++++- go/internal/agenthooks/settings_test.go | 62 +++++++- go/internal/agenthooks/state_artifacts.go | 76 ++++++--- go/internal/agenthookscli/main.go | 44 +++++- .../agenthookscli/main_internal_test.go | 21 +++ go/internal/managedcapture/capture_test.go | 2 + go/internal/managedcapture/writable_paths.go | 1 + go/internal/mcp/code_intel.go | 34 +++- go/internal/mcp/code_intel_workspace.go | 11 +- go/internal/mcp/server.go | 1 + go/internal/mcp/server_test.go | 46 ++++++ go/internal/mcpcli/main.go | 6 + go/internal/memories/memory.go | 66 ++++++-- go/internal/memories/memory_test.go | 49 ++++++ 25 files changed, 939 insertions(+), 91 deletions(-) create mode 100644 .coding-ethos/.gitignore diff --git a/.coding-ethos/.gitignore b/.coding-ethos/.gitignore new file mode 100644 index 00000000..0c2276d8 --- /dev/null +++ b/.coding-ethos/.gitignore @@ -0,0 +1,12 @@ +# coding-ethos generated runtime output +.claude/settings.local.json +.coding-ethos/cache/ +.coding-ethos/code-intel.duckdb +.coding-ethos/code-intel.duckdb.wal +.coding-ethos/events/ +.coding-ethos/hook-runs/ +.coding-ethos/lint-runs/ +.coding-ethos/prune-runs/ +.coding-ethos/state/ +.gemini/settings.json +.mcp.json diff --git a/.gitignore b/.gitignore index 5db3645f..4415769a 100644 --- a/.gitignore +++ b/.gitignore @@ -99,6 +99,7 @@ sandbox-tmp/ .gemini/extensions/* !.gemini/extensions/coding-ethos/ !.gemini/extensions/coding-ethos/** +.kimi-code/ logs/ *.log *.tmp diff --git a/README.md b/README.md index 8e881798..cb4de2e6 100644 --- a/README.md +++ b/README.md @@ -491,6 +491,18 @@ session events, remediation records, hook review labels, LCOV coverage, health snapshots, architectural decisions, and vector metadata. They are not replacements for hooks or CEL policy evaluation. +The top-level runner can keep that analytical state outside the indexed +checkout while still reading source and repo configuration from it: + +```bash +bin/coding-ethos-run code-intel repo-map \ + --root /path/to/repo \ + --state-root /private/coding-ethos-state +``` + +When `--state-root` is present and `--db` is omitted, the runner uses +`/private/coding-ethos-state/.coding-ethos/code-intel.duckdb`. + ## Modern Web Guidance Modern Web Guidance is exposed as an advisory, latest-on-demand external @@ -1575,9 +1587,10 @@ MCP setup, generated targets, memory behavior, response shapes, and unsupported surfaces are generated from the registry into [Provider Capability Matrix](docs/PROVIDER_CAPABILITY_MATRIX.md). -Supervisors can keep provider settings outside the target checkout. In overlay -mode, `--root` is the private settings/state root and `--repo-root` is the -actual repository used as the hook probe working directory: +Supervisors can keep provider settings and durable runtime state outside the +target checkout. In overlay mode, `--root` is the private provider-settings +root, `--repo-root` is the actual source repository, and `--state-root` is the +private Coding Ethos state root: ```bash bin/coding-ethos-run agent-hooks sync \ @@ -1588,8 +1601,14 @@ bin/coding-ethos-run agent-hooks verify \ --repo-root /path/to/repo ``` -Omitting `--repo-root` preserves the repo-local behavior. Capability discovery -reports both flags and `supports_private_overlay: true`. +Generated provider settings and install metadata live under `--root`. Hook +probes and code indexing read `--repo-root`. Centralized memories, +code-intelligence databases, runtime-policy artifacts, and hook traces live +under `--state-root`. Add `--state-root /private/coding-ethos-state` to both +commands to split that durable state from the settings overlay. The state root +defaults to the settings root; omitting all three flags preserves repo-local +behavior. Capability discovery reports all three flags and +`supports_private_overlay: true`. An external supervisor can own provider hook execution while Coding Ethos keeps ownership of MCP and code intelligence. Pass the same split commands to @@ -1599,11 +1618,13 @@ ownership of MCP and code intelligence. Pass the same split commands to bin/coding-ethos-run agent-hooks sync \ --root /private/settings-overlay \ --repo-root /path/to/repo \ + --state-root /private/coding-ethos-state \ --hook-command 'env NYAR_HOME=/private/nyar NYAR_CODING_ETHOS_ROOT=/opt/coding-ethos /absolute/path/nyar hook' \ --mcp-command '/opt/coding-ethos/bin/coding-ethos-run mcp' bin/coding-ethos-run agent-hooks verify \ --root /private/settings-overlay \ --repo-root /path/to/repo \ + --state-root /private/coding-ethos-state \ --hook-command 'env NYAR_HOME=/private/nyar NYAR_CODING_ETHOS_ROOT=/opt/coding-ethos /absolute/path/nyar hook' \ --mcp-command '/opt/coding-ethos/bin/coding-ethos-run mcp' ``` @@ -1617,6 +1638,19 @@ an absolute `coding-ethos-run mcp` command. When `--mcp-command` is omitted, the current MCP command is still derived from `--hook-command`; this preserves existing repo-local behavior. +For split roots, the generated MCP configuration keeps that statically +validated base command and appends `--repo-root` plus `--state-root`. Prepare +and verify its compiled consumer policy in the same private state root: + +```bash +bin/coding-ethos-run runtime-policy sync \ + --repo /path/to/repo \ + --state-root /private/coding-ethos-state +bin/coding-ethos-run runtime-policy check \ + --repo /path/to/repo \ + --state-root /private/coding-ethos-state +``` + Codex runs one native command hook per supported event so current Codex sessions enter the same policy runtime without depending on unstable tool matcher names. Generated Codex config does not inline `PATH=` mutations, @@ -1643,11 +1677,13 @@ settings without rewriting parent root agent docs. Agent memory uses the same centralization model. `agent-hooks sync` creates and verifies `.coding-ethos/memories/MEMORY.md` plus -`.coding-ethos/memories/index.yaml`, imports existing Claude/Codex/Gemini -memory files idempotently, and keeps provider memory paths routed to the central -repo-local surface. Providers that cannot rewrite a memory file tool request get -a `memory.centralized` denial that points at the allowed memory path instead of -silently writing durable notes into provider-private state. +`.coding-ethos/memories/index.yaml` under the selected state root, imports +existing Claude, Codex, and Gemini memory files from the source repository +idempotently, and keeps provider memory paths routed to the central surface. +Without a split state root this remains repo-local. Providers that cannot +rewrite a memory file tool request get a `memory.centralized` denial that points +at the allowed memory path instead of silently writing durable notes into +provider-private state. `agent-hooks verify` runs doctor first, then safely invokes the configured hook command—including an external supervisor wrapper—with provider-native Claude, @@ -1726,7 +1762,11 @@ once. Supervisors select the stable provider-neutral contract explicitly: ```bash -bin/coding-ethos-run agent-hook --json --contract neutral-v1 < event.json +bin/coding-ethos-run agent-hook \ + --json \ + --contract neutral-v1 \ + --repo-root /path/to/repo \ + --state-root /private/coding-ethos-state < event.json ``` The v1 response includes `contract_version`, `correlation_id`, a normalized diff --git a/docs/HOOK_CONTRACT_V1.md b/docs/HOOK_CONTRACT_V1.md index 23784bb5..cc56c91f 100644 --- a/docs/HOOK_CONTRACT_V1.md +++ b/docs/HOOK_CONTRACT_V1.md @@ -17,6 +17,17 @@ The equivalent validated environment setting is `CODE_ETHOS_HOOK_CONTRACT=neutral-v1`. An unknown selector fails before policy evaluation. +Supervisors with external state pass the consumer and state roots through the +same process boundary: + +```bash +bin/coding-ethos-run agent-hook \ + --json \ + --contract neutral-v1 \ + --repo-root /path/to/repo \ + --state-root /private/coding-ethos-state < event.json +``` + ## Request The request is the existing normalized hook event object. These two fields are @@ -105,8 +116,9 @@ bin/coding-ethos-run agent-hooks capabilities --json The response schema is `coding-ethos.agent-hooks/v1`. `runtime_version` comes from the checkout's `pyproject.toml`. The command is read-only and does not require a policy bundle or code-intelligence store. The report advertises -`mcp_command_flag: "--mcp-command"` alongside the settings and repository root -flags. +`mcp_command_flag: "--mcp-command"` and +`runtime_policy_command: "runtime-policy"` alongside the settings, repository, +and state root flags. ## Kimi Native Semantics @@ -124,31 +136,43 @@ For a settings overlay separate from the repository: ```bash bin/coding-ethos-run agent-hooks sync \ --root /private/settings-overlay \ - --repo-root /path/to/repo + --repo-root /path/to/repo \ + --state-root /private/coding-ethos-state bin/coding-ethos-run agent-hooks verify \ --root /private/settings-overlay \ - --repo-root /path/to/repo + --repo-root /path/to/repo \ + --state-root /private/coding-ethos-state ``` -Provider settings and install state are written under the first path. Skill -checks and runnable hook probes use the second path. +`--root` owns generated provider settings and install metadata. +`--repo-root` is the source checkout used for skill checks, hook probes, and +code-intelligence indexing. `--state-root` owns centralized memories, +code-intelligence databases, runtime-policy artifacts, and hook traces. +`--state-root` defaults to `--root`; omitting all three flags preserves the +existing repository-local behavior. When a provider-neutral supervisor owns hook execution, keep Coding Ethos as the MCP and code-intelligence owner with separate commands: ```bash -bin/coding-ethos-run runtime-policy sync --repo /path/to/repo +bin/coding-ethos-run runtime-policy sync \ + --repo /path/to/repo \ + --state-root /private/coding-ethos-state bin/coding-ethos-run agent-hooks sync \ --root /private/settings-overlay \ --repo-root /path/to/repo \ + --state-root /private/coding-ethos-state \ --hook-command 'env NYAR_HOME=/private/nyar NYAR_CODING_ETHOS_ROOT=/opt/coding-ethos /absolute/path/nyar hook' \ --mcp-command '/opt/coding-ethos/bin/coding-ethos-run mcp' bin/coding-ethos-run agent-hooks verify \ --root /private/settings-overlay \ --repo-root /path/to/repo \ + --state-root /private/coding-ethos-state \ --hook-command 'env NYAR_HOME=/private/nyar NYAR_CODING_ETHOS_ROOT=/opt/coding-ethos /absolute/path/nyar hook' \ --mcp-command '/opt/coding-ethos/bin/coding-ethos-run mcp' -bin/coding-ethos-run runtime-policy check --repo /path/to/repo +bin/coding-ethos-run runtime-policy check \ + --repo /path/to/repo \ + --state-root /private/coding-ethos-state ``` Pass both flags unchanged to `doctor` as well. The external hook form is one @@ -160,6 +184,8 @@ absolute `coding-ethos-run mcp`; omitting it preserves the existing derivation from `coding-ethos-run agent-hook`. Verification sends all provider-native smoke payloads through the external supervisor command, while generated Claude, Codex, Gemini, and Kimi MCP entries continue to invoke Coding Ethos -directly. `runtime-policy sync/check` owns only the consumer-scoped compiled -bundle below Git metadata; it does not generate or rewrite tracked repository -configuration. +directly. For split roots, generated MCP entries append the validated +`--repo-root` and `--state-root` arguments to that exact base command. +`runtime-policy sync/check` owns only the consumer-scoped compiled bundle under +the state root (or below Git metadata when no state root is supplied); it does +not generate or rewrite tracked repository configuration. diff --git a/go/cmd/coding-ethos-run/args.go b/go/cmd/coding-ethos-run/args.go index 85288755..bcd8efed 100644 --- a/go/cmd/coding-ethos-run/args.go +++ b/go/cmd/coding-ethos-run/args.go @@ -32,13 +32,24 @@ func runnerArgs(argv []string) []string { } } -func codeIntelArgs(root string, args []string) []string { - if len(args) == 0 || hasFlag(args, "--root") { +func codeIntelArgs(root, stateRoot string, args []string) []string { + args = withoutFlags(args, "--state-root") + if len(args) == 0 { return args } - next := make([]string, 0, injectedRootArgCount+len(args)) - next = append(next, args[0], "--root", root) + next := []string{args[0]} + if !hasFlag(args, "--root") { + next = append(next, "--root", root) + } + + if !hasFlag(args, "--db") && !sameCleanPath(root, stateRoot) { + next = append( + next, + "--db", + filepath.Join(stateRoot, ".coding-ethos", "code-intel.duckdb"), + ) + } next = append(next, args[1:]...) return next @@ -177,3 +188,86 @@ func flagValue(args []string, name, fallback string) string { return fallback } + +func withoutFlags(args []string, names ...string) []string { + filtered := make([]string, 0, len(args)) + + for index := 0; index < len(args); index++ { + arg := args[index] + removed := false + + for _, name := range names { + if arg == name { + index++ + removed = true + + break + } + + if strings.HasPrefix(arg, name+"=") { + removed = true + + break + } + } + + if !removed { + filtered = append(filtered, arg) + } + } + + return filtered +} + +func (paths runtimePaths) withCommandRoots(args []string) runtimePaths { + if len(args) == 0 { + return paths + } + + var ( + defaultStateRoot = paths.StateRoot + repoRoot string + ) + + switch args[0] { + case "agent-hook", "mcp": + repoRoot = flagValue(args[1:], "--repo-root", paths.Root) + case "agent-hooks": + repoRoot = flagValue(args[1:], "--repo-root", paths.Root) + defaultStateRoot = flagValue(args[1:], "--root", paths.StateRoot) + case "code-intel": + repoRoot = flagValue(args[1:], "--root", paths.Root) + case "runtime-policy": + repoRoot = flagValue(args[1:], "--repo", paths.Root) + default: + return paths + } + + rest := args[1:] + + repoRoot = strings.TrimSpace(repoRoot) + if repoRoot != "" { + paths.Root = filepath.Clean(repoRoot) + } + + stateRoot := strings.TrimSpace(flagValue(rest, "--state-root", defaultStateRoot)) + if stateRoot != "" { + paths.StateRoot = filepath.Clean(stateRoot) + } + + return paths +} + +func shouldLogRuntimeCommand(args []string) bool { + if len(args) == 0 { + return false + } + + if args[0] == "cutover" || args[0] == "lfs-hook" { + return false + } + + return len(args) < 2 || + args[0] != "agent-hooks" || + args[1] != "capabilities" +} diff --git a/go/cmd/coding-ethos-run/dispatch.go b/go/cmd/coding-ethos-run/dispatch.go index 542b9c00..5f527aa6 100644 --- a/go/cmd/coding-ethos-run/dispatch.go +++ b/go/cmd/coding-ethos-run/dispatch.go @@ -813,10 +813,11 @@ func runPolicyHandler(paths runtimePaths, rest []string) error { } func runCodeIntelHandler(paths runtimePaths, rest []string) error { + stateRoot := firstNonEmptyString(paths.StateRoot, paths.Root) runtimeExecTool( paths, "coding-ethos-code-intel", - codeIntelArgs(paths.Root, rest)...) + codeIntelArgs(paths.Root, stateRoot, rest)...) return nil } @@ -973,7 +974,10 @@ func runAgentHook(paths runtimePaths, rest []string) { persistAgentEnvironment(paths) _ = os.Setenv("CODING_ETHOS_GIT_SHIM_DIR", paths.BinDir) paths.executor().execAgentHook( - append([]string{"--bundle", bundlePath, "--json"}, rest...)...) + append( + []string{"--bundle", bundlePath, "--json"}, + withoutFlags(rest, "--repo-root", "--state-root")..., + )...) } func runAgentHooksCommand(paths runtimePaths, rest []string) { @@ -987,6 +991,10 @@ func runAgentHooksCommand(paths runtimePaths, rest []string) { "CODE_ETHOS_CONSUMER_ROOT", flagValue(rest, "--repo-root", settingsRoot), ) + _ = os.Setenv( + envStateRoot, + flagValue(rest, "--state-root", settingsRoot), + ) runtimeExecTool( paths, "coding-ethos-agent-hooks", @@ -1007,11 +1015,16 @@ func runPolicyTool(paths runtimePaths, rest []string) error { func runMCP(paths runtimePaths, rest []string) { bundlePath := hookPolicyBundlePath(paths) requireRuntimeFile(bundlePath, "compiled policy bundle") + + rest = withoutFlags(rest, "--repo-root", "--state-root") + + stateRoot := firstNonEmptyString(paths.StateRoot, paths.Root) runtimeExecTool(paths, "coding-ethos-mcp", append([]string{ "--bundle", bundlePath, "--cerun", filepath.Join(paths.BinDir, "cerun"), "--ethos-root", paths.EthosRoot, "--consumer-root", paths.Root, + "--state-root", stateRoot, "--invocation-cwd", paths.InvocationCWD, }, rest...)...) } diff --git a/go/cmd/coding-ethos-run/hook_policy_paths.go b/go/cmd/coding-ethos-run/hook_policy_paths.go index f2f1958d..4f401191 100644 --- a/go/cmd/coding-ethos-run/hook_policy_paths.go +++ b/go/cmd/coding-ethos-run/hook_policy_paths.go @@ -14,6 +14,16 @@ func hookPolicyMetadataPath(paths runtimePaths) string { } func hookPolicyArtifactPath(paths runtimePaths, name, checkoutPath string) string { + stateRoot := firstNonEmptyString(paths.StateRoot, paths.Root) + if !sameCleanPath(stateRoot, paths.Root) { + return filepath.Join( + stateRoot, + ".coding-ethos", + "policy", + name, + ) + } + if sameCleanPath(paths.Root, paths.EthosRoot) { return checkoutPath } diff --git a/go/cmd/coding-ethos-run/main.go b/go/cmd/coding-ethos-run/main.go index 27bdc84b..d123726f 100644 --- a/go/cmd/coding-ethos-run/main.go +++ b/go/cmd/coding-ethos-run/main.go @@ -24,6 +24,7 @@ const ( envAgentAPIProxyEnabled = "CODE_ETHOS_AGENT_API_PROXY" envAgentAPIProxyURL = "CODE_ETHOS_AGENT_API_PROXY_URL" envAgentAPIProxyIntercept = "CODE_ETHOS_AGENT_PROXY_INTERCEPT" + envStateRoot = "CODE_ETHOS_STATE_ROOT" exitMissing = 127 ) @@ -35,6 +36,7 @@ type runtimePaths struct { GitDir string GitCommonDir string Root string + StateRoot string HooksDir string BinDir string RunBinary string @@ -71,19 +73,18 @@ func mainExitCode() int { exitErr(err) } + args, debug := debugRunnerArgs(runnerArgs(os.Args)) + paths = paths.withCommandRoots(args) paths.export() - args, debug := debugRunnerArgs(runnerArgs(os.Args)) - if len(args) > 0 && - args[0] != "cutover" && - args[0] != "lfs-hook" && + if shouldLogRuntimeCommand(args) && os.Getenv("CODE_ETHOS_HOOK_LOGGING_ACTIVE") == "" { loggedCode, logErr := hooklog.RunInProcess(hooklog.Options{ Stdin: os.Stdin, Stdout: os.Stdout, Stderr: os.Stderr, GitPath: paths.RealGit, - Root: paths.Root, + Root: paths.StateRoot, BundleRoot: paths.BundleRoot, Command: append([]string{paths.RunBinary}, args...), Debug: debug || debuglog.EnabledFromEnv(), @@ -153,6 +154,7 @@ func resolveRuntimePaths() (runtimePaths, error) { } root, localRoot := resolveRuntimeRoot(realGit, invocationCWD) + stateRoot := resolveRuntimeStateRoot(root) hooksDir := resolveRuntimeHooksDir(realGit, root) gitDir := resolveRuntimeGitDir(realGit, root, hooksDir) gitCommonDir := resolveRuntimeGitCommonDir(realGit, root, hooksDir) @@ -180,6 +182,7 @@ func resolveRuntimePaths() (runtimePaths, error) { GitDir: gitDir, GitCommonDir: gitCommonDir, Root: root, + StateRoot: stateRoot, HooksDir: hooksDir, BinDir: binDir, RunBinary: runBinary, @@ -197,6 +200,7 @@ type runtimePathInputs struct { GitDir string GitCommonDir string Root string + StateRoot string HooksDir string BinDir string RunBinary string @@ -221,6 +225,15 @@ func resolveRuntimeRoot(realGit, invocationCWD string) (string, string) { return localRoot, localRoot } +func resolveRuntimeStateRoot(root string) string { + stateRoot := strings.TrimSpace(os.Getenv(envStateRoot)) + if stateRoot == "" { + return root + } + + return filepath.Clean(stateRoot) +} + func resolveRuntimeHooksDir(realGit, root string) string { hooksDir, err := gitOutput( realGit, @@ -380,6 +393,7 @@ func runtimePathSet(inputs runtimePathInputs) runtimePaths { GitDir: gitDir, GitCommonDir: gitCommonDir, Root: inputs.Root, + StateRoot: firstNonEmptyString(inputs.StateRoot, inputs.Root), HooksDir: inputs.HooksDir, BinDir: inputs.BinDir, RunBinary: inputs.RunBinary, @@ -472,6 +486,7 @@ func (paths runtimePaths) export() { "INVOCATION_CWD": paths.InvocationCWD, "CODE_ETHOS_PRECOMMIT_ROOT": paths.BundleRoot, "CODE_ETHOS_CONSUMER_ROOT": paths.Root, + envStateRoot: paths.StateRoot, "CODE_ETHOS_LOCAL_ROOT": paths.LocalRoot, "CODING_ETHOS_RUN_GO_HOOK": paths.RunBinary, "GIT_HOOK_SRC_DIR": filepath.Join( diff --git a/go/cmd/coding-ethos-run/main_test.go b/go/cmd/coding-ethos-run/main_test.go index 9bec2a84..8e44489e 100644 --- a/go/cmd/coding-ethos-run/main_test.go +++ b/go/cmd/coding-ethos-run/main_test.go @@ -183,7 +183,11 @@ func TestPolicyToolLintArgsDoNotExposeSandboxMode(t *testing.T) { func TestCodeIntelArgsInsertRootAfterSubcommand(t *testing.T) { t.Parallel() - args := codeIntelArgs("/repo", []string{"stats", "--db", "/tmp/code-intel.duckdb"}) + args := codeIntelArgs( + "/repo", + "/repo", + []string{"stats", "--db", "/tmp/code-intel.duckdb"}, + ) want := []string{"stats", "--root", "/repo", "--db", "/tmp/code-intel.duckdb"} if !slices.Equal(args, want) { @@ -194,7 +198,7 @@ func TestCodeIntelArgsInsertRootAfterSubcommand(t *testing.T) { func TestCodeIntelArgsKeepExplicitRoot(t *testing.T) { t.Parallel() - args := codeIntelArgs("/repo", []string{"stats", "--root", "/other"}) + args := codeIntelArgs("/repo", "/repo", []string{"stats", "--root", "/other"}) want := []string{"stats", "--root", "/other"} if !slices.Equal(args, want) { @@ -202,6 +206,28 @@ func TestCodeIntelArgsKeepExplicitRoot(t *testing.T) { } } +func TestCodeIntelArgsBindPrivateStateDatabase(t *testing.T) { + t.Parallel() + + args := codeIntelArgs( + "/repo", + "/private/state", + []string{"index-code", "--state-root", "/private/state", "."}, + ) + + want := []string{ + "index-code", + "--root", + "/repo", + "--db", + "/private/state/.coding-ethos/code-intel.duckdb", + ".", + } + if !slices.Equal(args, want) { + t.Fatalf("codeIntelArgs() = %#v, want %#v", args, want) + } +} + func TestAgentHooksArgsInjectCapabilityEthosRootWithoutHookCommand(t *testing.T) { t.Parallel() @@ -239,6 +265,103 @@ func TestFlagValueReadsPrivateOverlayRepoRoot(t *testing.T) { } } +func TestWithCommandRootsSeparatesRepositoryAndState(t *testing.T) { + t.Parallel() + + paths := runtimePaths{Root: "/original", StateRoot: "/original"} + got := paths.withCommandRoots([]string{ + "mcp", + "--repo-root", + "/repo", + "--state-root=/private/state", + }) + + if got.Root != "/repo" || got.StateRoot != "/private/state" { + t.Fatalf("withCommandRoots() = %#v", got) + } +} + +func TestWithCommandRootsUsesCommandSpecificRepositoryFlags(t *testing.T) { + t.Parallel() + + for _, testCase := range []struct { + name string + args []string + }{ + { + name: "agent hooks", + args: []string{ + "agent-hooks", + "sync", + "--repo-root", + "/repo", + "--state-root", + "/private/state", + }, + }, + { + name: "code intel", + args: []string{ + "code-intel", + "index", + "--root", + "/repo", + "--state-root", + "/private/state", + }, + }, + { + name: "runtime policy", + args: []string{ + "runtime-policy", + "sync", + "--repo", + "/repo", + "--state-root", + "/private/state", + }, + }, + } { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + paths := runtimePaths{Root: "/original", StateRoot: "/original"} + got := paths.withCommandRoots(testCase.args) + if got.Root != "/repo" || got.StateRoot != "/private/state" { + t.Fatalf("withCommandRoots() = %#v", got) + } + }) + } +} + +func TestCapabilitiesDiscoveryDoesNotWriteRuntimeLog(t *testing.T) { + t.Parallel() + + if shouldLogRuntimeCommand([]string{"agent-hooks", "capabilities", "--json"}) { + t.Fatal("capabilities discovery should be read-only") + } + if !shouldLogRuntimeCommand([]string{"agent-hooks", "doctor"}) { + t.Fatal("doctor should retain runtime logging") + } +} + +func TestAgentHooksStateDefaultsToPrivateSettingsRoot(t *testing.T) { + t.Parallel() + + paths := runtimePaths{Root: "/original", StateRoot: "/original"} + got := paths.withCommandRoots([]string{ + "agent-hooks", + "sync", + "--root", + "/private/settings", + "--repo-root", + "/repo", + }) + if got.Root != "/repo" || got.StateRoot != "/private/settings" { + t.Fatalf("withCommandRoots() = %#v", got) + } +} + func TestOutputArgsInsertRootAfterSubcommand(t *testing.T) { t.Parallel() @@ -417,6 +540,22 @@ func TestParentLintArgsUseParentRepoAndTOONScope(t *testing.T) { } } +func TestRuntimePolicyBundleUsesPrivateStateRoot(t *testing.T) { + t.Parallel() + + got := parentPolicyBundleDir( + runtimePaths{}, + parentWorkflowOptions{ + Repo: "/repo", + StateRoot: "/private/state", + }, + ) + want := "/private/state/.coding-ethos/policy" + if got != want { + t.Fatalf("parentPolicyBundleDir() = %q, want %q", got, want) + } +} + func TestParentStepStatusFailsOnAnyFailedStep(t *testing.T) { t.Parallel() diff --git a/go/cmd/coding-ethos-run/parent_workflow.go b/go/cmd/coding-ethos-run/parent_workflow.go index e8656fa3..4e8a6760 100644 --- a/go/cmd/coding-ethos-run/parent_workflow.go +++ b/go/cmd/coding-ethos-run/parent_workflow.go @@ -49,6 +49,7 @@ var ( type parentWorkflowOptions struct { Repo string + StateRoot string RepoEthos string RepoConfig string Scope string @@ -160,6 +161,11 @@ func parseParentWorkflowFlags( flags.SetOutput(io.Discard) repo := flags.String("repo", paths.Root, "Parent repository root") + stateRoot := flags.String( + "state-root", + "", + "Private Coding Ethos state root for runtime policy artifacts", + ) repoEthos := flags.String("repo-ethos", "", "Optional parent repo ethos overlay") repoConfig := flags.String("repo-config", "", "Optional parent repo config") scope := flags.String("scope", parentDefaultLintScope, "Parent lint scope") @@ -174,6 +180,11 @@ func parseParentWorkflowFlags( return parentWorkflowOptions{}, err } + resolvedStateRoot, err := cleanOptionalRoot(*stateRoot) + if err != nil { + return parentWorkflowOptions{}, err + } + resolvedRepoEthos, err := firstExistingPath( *repoEthos, parentRepoEthosCandidates(repoRoot), @@ -192,12 +203,26 @@ func parseParentWorkflowFlags( return parentWorkflowOptions{ Repo: repoRoot, + StateRoot: resolvedStateRoot, RepoEthos: resolvedRepoEthos, RepoConfig: resolvedRepoConfig, Scope: strings.TrimSpace(*scope), }, nil } +func cleanOptionalRoot(root string) (string, error) { + if strings.TrimSpace(root) == "" { + return "", nil + } + + cleaned := filepath.Clean(root) + if !filepath.IsAbs(cleaned) { + return "", apperror.StaticError("state root must be absolute") + } + + return cleaned, nil +} + func cleanParentRepoFlag(repo string) (string, error) { if strings.TrimSpace(repo) == "" { return "", apperror.StaticError("parent workflow requires --repo") @@ -355,7 +380,7 @@ func checkParentPolicyBundle(paths runtimePaths, options parentWorkflowOptions) "%w: policy_bundle out of sync in %s checkout; run: %s", errParentArtifactDrift, parentCheckoutLocation(paths, options), - parentInstallCommand(options), + parentPolicySyncCommand(options), ) } @@ -374,6 +399,10 @@ func parentPolicyMetadataPath( } func parentPolicyBundleDir(paths runtimePaths, options parentWorkflowOptions) string { + if options.StateRoot != "" { + return filepath.Join(options.StateRoot, ".coding-ethos", "policy") + } + return filepath.Join( parentGitCommonDir(paths, options.Repo), "coding-ethos-hooks", @@ -921,6 +950,17 @@ func parentInstallCommand(options parentWorkflowOptions) string { shellquote.Arg(options.Repo) } +func parentPolicySyncCommand(options parentWorkflowOptions) string { + if options.StateRoot == "" { + return parentInstallCommand(options) + } + + return "coding-ethos/bin/coding-ethos-run runtime-policy sync --repo " + + shellquote.Arg(options.Repo) + + " --state-root " + + shellquote.Arg(options.StateRoot) +} + func parentCheckoutLocation(paths runtimePaths, options parentWorkflowOptions) string { if sameCleanPath(options.Repo, paths.EthosRoot) { return parentCheckoutCodingEthos diff --git a/go/internal/agenthooks/provider_capabilities.go b/go/internal/agenthooks/provider_capabilities.go index 0e9b46d9..dfb64f20 100644 --- a/go/internal/agenthooks/provider_capabilities.go +++ b/go/internal/agenthooks/provider_capabilities.go @@ -41,6 +41,7 @@ type CapabilityReport struct { RuntimeVersion string `json:"runtime_version"` SettingsRootFlag string `json:"settings_root_flag"` RepositoryRootFlag string `json:"repository_root_flag"` + StateRootFlag string `json:"state_root_flag"` MCPCommandFlag string `json:"mcp_command_flag"` RuntimePolicyCommand string `json:"runtime_policy_command"` @@ -61,6 +62,7 @@ func Capabilities(runtimeVersion string) CapabilityReport { Providers: ProviderCapabilities(), SettingsRootFlag: "--root", RepositoryRootFlag: "--repo-root", + StateRootFlag: "--state-root", MCPCommandFlag: "--mcp-command", RuntimePolicyCommand: "runtime-policy", SupportsPrivateOverlay: true, diff --git a/go/internal/agenthooks/settings.go b/go/internal/agenthooks/settings.go index b902243b..aa2d7df8 100644 --- a/go/internal/agenthooks/settings.go +++ b/go/internal/agenthooks/settings.go @@ -67,6 +67,9 @@ var ( errUnsupportedMCPCommand = apperror.StaticError( "unsupported Coding Ethos MCP command", ) + errPrivateRootAbsolute = apperror.StaticError( + "private repository and state roots must be absolute", + ) errCodexTrustMismatch = apperror.StaticError( "Codex user config does not trust generated project hooks", ) @@ -192,6 +195,61 @@ func mcpServerConfig(hookCommand, mcpCommand string) (mcpServer, error) { }, nil } +func mcpServerConfigForRoots( + hookCommand string, + mcpCommand string, + settingsRoot string, + repoRoot string, + stateRoot string, +) (mcpServer, error) { + server, err := mcpServerConfig(hookCommand, mcpCommand) + if err != nil { + return mcpServer{}, err + } + + if sameRoot(settingsRoot, repoRoot) && sameRoot(settingsRoot, stateRoot) { + return server, nil + } + + repoRoot, err = absolutePrivateRoot("repository", repoRoot) + if err != nil { + return mcpServer{}, err + } + + stateRoot, err = absolutePrivateRoot("state", stateRoot) + if err != nil { + return mcpServer{}, err + } + + server.Args = append( + server.Args, + "--repo-root", + repoRoot, + "--state-root", + stateRoot, + ) + + return server, nil +} + +func sameRoot(left, right string) bool { + leftPath, leftErr := filepath.Abs(left) + rightPath, rightErr := filepath.Abs(right) + + return leftErr == nil && + rightErr == nil && + filepath.Clean(leftPath) == filepath.Clean(rightPath) +} + +func absolutePrivateRoot(kind, root string) (string, error) { + cleaned := filepath.Clean(strings.TrimSpace(root)) + if cleaned == "." || !filepath.IsAbs(cleaned) { + return "", fmt.Errorf("%w: %s root %q", errPrivateRootAbsolute, kind, root) + } + + return cleaned, nil +} + type SettingsPaths struct { Claude string ClaudeMCP string @@ -303,13 +361,38 @@ func SyncSettingsForRepositoryWithMCPCommand( repoRoot string, hookCommand string, mcpCommand string, +) error { + return SyncSettingsForRootsWithMCPCommand( + settingsRoot, + repoRoot, + settingsRoot, + hookCommand, + mcpCommand, + ) +} + +// SyncSettingsForRootsWithMCPCommand writes provider settings under +// settingsRoot and binds generated Coding Ethos MCP entries to repoRoot source +// inspection and stateRoot durable state. +func SyncSettingsForRootsWithMCPCommand( + settingsRoot string, + repoRoot string, + stateRoot string, + hookCommand string, + mcpCommand string, ) error { settings, err := buildAllSettings(hookCommand) if err != nil { return err } - serverConfig, err := mcpServerConfig(hookCommand, mcpCommand) + serverConfig, err := mcpServerConfigForRoots( + hookCommand, + mcpCommand, + settingsRoot, + repoRoot, + stateRoot, + ) if err != nil { return err } @@ -338,7 +421,7 @@ func SyncSettingsForRepositoryWithMCPCommand( return err } - _, err = memories.ImportExisting(repoRoot) + _, err = memories.ImportExistingForRoots(repoRoot, stateRoot) if err != nil { return fmt.Errorf("import existing memories: %w", err) } @@ -812,13 +895,37 @@ func DoctorSettingsForRepositoryWithMCPCommand( repoRoot string, hookCommand string, mcpCommand string, +) error { + return DoctorSettingsForRootsWithMCPCommand( + settingsRoot, + repoRoot, + settingsRoot, + hookCommand, + mcpCommand, + ) +} + +// DoctorSettingsForRootsWithMCPCommand validates separate provider settings, +// repository inspection, and durable-state roots. +func DoctorSettingsForRootsWithMCPCommand( + settingsRoot string, + repoRoot string, + stateRoot string, + hookCommand string, + mcpCommand string, ) error { expected, err := buildAllSettings(hookCommand) if err != nil { return err } - expectedMCP, err := mcpServerConfig(hookCommand, mcpCommand) + expectedMCP, err := mcpServerConfigForRoots( + hookCommand, + mcpCommand, + settingsRoot, + repoRoot, + stateRoot, + ) if err != nil { return err } @@ -835,7 +942,7 @@ func DoctorSettingsForRepositoryWithMCPCommand( return err } - err = memories.Verify(repoRoot) + err = memories.VerifyForRoots(repoRoot, stateRoot) if err != nil { return fmt.Errorf("verify memory surfaces: %w", err) } @@ -995,7 +1102,16 @@ func codexConfigContainsExpectedHooks( func codexConfigContainsExpectedMCPServer(content string, expected mcpServer) bool { return strings.Contains(content, "[mcp_servers."+mcpServerName+"]") && strings.Contains(content, "command = "+tomlString(expected.Command)) && - strings.Contains(content, "args = ["+tomlString(expected.Args[0])+"]") + strings.Contains(content, "args = "+tomlStringArray(expected.Args)) +} + +func tomlStringArray(values []string) string { + encoded := make([]string, 0, len(values)) + for _, value := range values { + encoded = append(encoded, tomlString(value)) + } + + return "[" + strings.Join(encoded, ", ") + "]" } func VerifySettings(root, hookCommand string) (VerifyReport, error) { @@ -1025,9 +1141,28 @@ func VerifySettingsForRepositoryWithMCPCommand( hookCommand string, mcpCommand string, ) (VerifyReport, error) { - err := DoctorSettingsForRepositoryWithMCPCommand( + return VerifySettingsForRootsWithMCPCommand( + settingsRoot, + repoRoot, + settingsRoot, + hookCommand, + mcpCommand, + ) +} + +// VerifySettingsForRootsWithMCPCommand validates separate private roots and +// runs provider probes against repoRoot. +func VerifySettingsForRootsWithMCPCommand( + settingsRoot string, + repoRoot string, + stateRoot string, + hookCommand string, + mcpCommand string, +) (VerifyReport, error) { + err := DoctorSettingsForRootsWithMCPCommand( settingsRoot, repoRoot, + stateRoot, hookCommand, mcpCommand, ) diff --git a/go/internal/agenthooks/settings_test.go b/go/internal/agenthooks/settings_test.go index 742eb73f..601e7e98 100644 --- a/go/internal/agenthooks/settings_test.go +++ b/go/internal/agenthooks/settings_test.go @@ -896,12 +896,14 @@ func TestSyncAndVerifySettingsUsesExternalSupervisorHookAndCodingEthosMCP( settingsRoot := t.TempDir() repoRoot := t.TempDir() + stateRoot := t.TempDir() hookCommand, mcpCommand, mcpRunner := fakeExternalSupervisorCommands(t) writeGeneratedSkillSurfaces(t, repoRoot, "conditional-imports") - err := agenthooks.SyncSettingsForRepositoryWithMCPCommand( + err := agenthooks.SyncSettingsForRootsWithMCPCommand( settingsRoot, repoRoot, + stateRoot, hookCommand, mcpCommand, ) @@ -909,9 +911,10 @@ func TestSyncAndVerifySettingsUsesExternalSupervisorHookAndCodingEthosMCP( t.Fatalf("sync external supervisor overlay: %v", err) } - report, err := agenthooks.VerifySettingsForRepositoryWithMCPCommand( + report, err := agenthooks.VerifySettingsForRootsWithMCPCommand( settingsRoot, repoRoot, + stateRoot, hookCommand, mcpCommand, ) @@ -943,6 +946,12 @@ func TestSyncAndVerifySettingsUsesExternalSupervisorHookAndCodingEthosMCP( {path: paths.ClaudeMCP, text: mcpRunner}, {path: paths.CodexConfig, text: mcpRunner}, {path: paths.KimiMCP, text: mcpRunner}, + {path: paths.ClaudeMCP, text: repoRoot}, + {path: paths.ClaudeMCP, text: stateRoot}, + {path: paths.CodexConfig, text: repoRoot}, + {path: paths.CodexConfig, text: stateRoot}, + {path: paths.KimiMCP, text: repoRoot}, + {path: paths.KimiMCP, text: stateRoot}, } { payload, readErr := os.ReadFile(expectation.path) if readErr != nil { @@ -957,6 +966,18 @@ func TestSyncAndVerifySettingsUsesExternalSupervisorHookAndCodingEthosMCP( ) } } + + if _, statErr := os.Stat( + filepath.Join(stateRoot, ".coding-ethos", "memories", "MEMORY.md"), + ); statErr != nil { + t.Fatalf("private state root lacks centralized memory: %v", statErr) + } + if _, statErr := os.Stat(filepath.Join(repoRoot, ".coding-ethos")); !errors.Is( + statErr, + os.ErrNotExist, + ) { + t.Fatalf("repository root gained durable supervisor state: %v", statErr) + } } func TestSyncSettingsRejectsUnsafeExternalSupervisorHookCommands(t *testing.T) { @@ -1004,6 +1025,43 @@ func TestSyncSettingsRejectsUnsafeOrNonCodingEthosMCPCommands(t *testing.T) { } } +func TestSyncSettingsRejectsRelativePrivateRoots(t *testing.T) { + t.Parallel() + + for _, testCase := range []struct { + name string + repoRoot string + stateRoot string + }{ + { + name: "repository", + repoRoot: "relative-repo", + stateRoot: t.TempDir(), + }, + { + name: "state", + repoRoot: t.TempDir(), + stateRoot: "relative-state", + }, + } { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + err := agenthooks.SyncSettingsForRootsWithMCPCommand( + t.TempDir(), + testCase.repoRoot, + testCase.stateRoot, + testHookCommand, + "/opt/coding-ethos/bin/coding-ethos-run mcp", + ) + if err == nil || + !strings.Contains(err.Error(), "private repository and state roots") { + t.Fatalf("relative private root error = %v", err) + } + }) + } +} + func TestVerifySettingsRejectsInvalidPortableSkillSurface(t *testing.T) { t.Parallel() diff --git a/go/internal/agenthooks/state_artifacts.go b/go/internal/agenthooks/state_artifacts.go index f2454bc9..362f2beb 100644 --- a/go/internal/agenthooks/state_artifacts.go +++ b/go/internal/agenthooks/state_artifacts.go @@ -19,18 +19,64 @@ func StateArtifactsWithMCPCommand( root string, hookCommand string, mcpCommand string, +) ([]syncstate.Artifact, error) { + return StateArtifactsForRootsWithMCPCommand( + root, + root, + root, + hookCommand, + mcpCommand, + ) +} + +// StateArtifactsForRootsWithMCPCommand renders settings that keep generated +// provider configuration, repository inspection, and durable state separate. +func StateArtifactsForRootsWithMCPCommand( + settingsRoot string, + repoRoot string, + stateRoot string, + hookCommand string, + mcpCommand string, ) ([]syncstate.Artifact, error) { settings, err := buildAllSettings(hookCommand) if err != nil { return nil, err } - serverConfig, err := mcpServerConfig(hookCommand, mcpCommand) + serverConfig, err := mcpServerConfigForRoots( + hookCommand, + mcpCommand, + settingsRoot, + repoRoot, + stateRoot, + ) if err != nil { return nil, err } - paths := DefaultSettingsPaths(root) + inputs, err := renderProviderStateArtifactInputs( + settingsRoot, + settings, + serverConfig, + ) + if err != nil { + return nil, err + } + + artifacts, err := syncstate.Artifacts(settingsRoot, inputs) + if err != nil { + return nil, fmt.Errorf("build agent hook state artifacts: %w", err) + } + + return artifacts, nil +} + +func renderProviderStateArtifactInputs( + settingsRoot string, + settings allSettings, + serverConfig mcpServer, +) ([]syncstate.ArtifactInput, error) { + paths := DefaultSettingsPaths(settingsRoot) claude, err := renderSettingsFileContent(paths.Claude, func(payload map[string]any) { payload["hooks"] = settings.Claude.Hooks @@ -73,23 +119,15 @@ func StateArtifactsWithMCPCommand( return nil, err } - artifacts, err := syncstate.Artifacts( - root, - agentHookStateArtifactInputs( - paths, - claude, - claudeMCP, - codex, - gemini, - kimiConfig, - kimiMCP, - ), - ) - if err != nil { - return nil, fmt.Errorf("build agent hook state artifacts: %w", err) - } - - return artifacts, nil + return agentHookStateArtifactInputs( + paths, + claude, + claudeMCP, + codex, + gemini, + kimiConfig, + kimiMCP, + ), nil } func renderKimiStateArtifacts( diff --git a/go/internal/agenthookscli/main.go b/go/internal/agenthookscli/main.go index 9c5a109b..6be14ca2 100644 --- a/go/internal/agenthookscli/main.go +++ b/go/internal/agenthookscli/main.go @@ -137,6 +137,11 @@ func syncSettings(args []string) error { "", "Actual repository root when --root is a private settings overlay", ) + stateRoot := flags.String( + "state-root", + "", + "Private Coding Ethos state root; defaults to --root", + ) ethosRoot := flags.String("ethos-root", ".", "Path to coding-ethos checkout") hookCommand := flags.String("hook-command", "", "Agent hook command") mcpCommand := flags.String( @@ -158,9 +163,12 @@ func syncSettings(args []string) error { resolvedHookCommand := defaultHookCommand(*hookCommand) resolvedRepoRoot := defaultRepoRoot(*root, *repoRoot) + resolvedStateRoot := defaultStateRoot(*root, *stateRoot) - artifacts, err := agenthooks.StateArtifactsWithMCPCommand( + artifacts, err := agenthooks.StateArtifactsForRootsWithMCPCommand( *root, + resolvedRepoRoot, + resolvedStateRoot, resolvedHookCommand, *mcpCommand, ) @@ -175,9 +183,10 @@ func syncSettings(args []string) error { ) } - err = agenthooks.SyncSettingsForRepositoryWithMCPCommand( + err = agenthooks.SyncSettingsForRootsWithMCPCommand( *root, resolvedRepoRoot, + resolvedStateRoot, resolvedHookCommand, *mcpCommand, ) @@ -195,8 +204,10 @@ func syncSettings(args []string) error { } if privateSettingsOverlay(*root, resolvedRepoRoot) { - artifacts, err = agenthooks.StateArtifactsWithMCPCommand( + artifacts, err = agenthooks.StateArtifactsForRootsWithMCPCommand( *root, + resolvedRepoRoot, + resolvedStateRoot, resolvedHookCommand, *mcpCommand, ) @@ -246,6 +257,11 @@ func doctorSettings(args []string) error { "", "Actual repository root when --root is a private settings overlay", ) + stateRoot := flags.String( + "state-root", + "", + "Private Coding Ethos state root; defaults to --root", + ) hookCommand := flags.String("hook-command", "", "Agent hook command") mcpCommand := flags.String( "mcp-command", @@ -258,9 +274,10 @@ func doctorSettings(args []string) error { return fmt.Errorf("parse doctor flags: %w", err) } - err = agenthooks.DoctorSettingsForRepositoryWithMCPCommand( + err = agenthooks.DoctorSettingsForRootsWithMCPCommand( *root, defaultRepoRoot(*root, *repoRoot), + defaultStateRoot(*root, *stateRoot), defaultHookCommand(*hookCommand), *mcpCommand, ) @@ -295,6 +312,11 @@ func verifySettings(args []string) error { "", "Actual repository root when --root is a private settings overlay", ) + stateRoot := flags.String( + "state-root", + "", + "Private Coding Ethos state root; defaults to --root", + ) hookCommand := flags.String("hook-command", "", "Agent hook command") mcpCommand := flags.String( "mcp-command", @@ -307,9 +329,10 @@ func verifySettings(args []string) error { return fmt.Errorf("parse verify flags: %w", err) } - report, err := agenthooks.VerifySettingsForRepositoryWithMCPCommand( + report, err := agenthooks.VerifySettingsForRootsWithMCPCommand( *root, defaultRepoRoot(*root, *repoRoot), + defaultStateRoot(*root, *stateRoot), defaultHookCommand(*hookCommand), *mcpCommand, ) @@ -425,6 +448,14 @@ func defaultRepoRoot(settingsRoot, repoRoot string) string { return settingsRoot } +func defaultStateRoot(settingsRoot, stateRoot string) string { + if strings.TrimSpace(stateRoot) != "" { + return stateRoot + } + + return settingsRoot +} + func privateSettingsOverlay(settingsRoot, repoRoot string) bool { var ( settingsPath, settingsErr = filepath.Abs(settingsRoot) @@ -472,7 +503,8 @@ func usageTo(writer io.Writer) { const text = "Usage: coding-ethos-agent-hooks " + " " + - "[flags]; sync supports --dry-run --format json|toon" + "[flags]; sync supports --repo-root, --state-root, --dry-run, " + + "and --format json|toon" feedback.Emit( writer, diff --git a/go/internal/agenthookscli/main_internal_test.go b/go/internal/agenthookscli/main_internal_test.go index c1946df6..d1c7f565 100644 --- a/go/internal/agenthookscli/main_internal_test.go +++ b/go/internal/agenthookscli/main_internal_test.go @@ -94,6 +94,7 @@ func TestCapabilitiesReportsRuntimeContractAndKimi(t *testing.T) { `"runtime_version": "7.8.9"`, `"contract_version": "coding-ethos.hook/v1"`, `"selector": "neutral-v1"`, + `"state_root_flag": "--state-root"`, `"mcp_command_flag": "--mcp-command"`, `"runtime_policy_command": "runtime-policy"`, `"provider": "kimi"`, @@ -257,6 +258,7 @@ func TestSyncAndDoctorSettingsAcceptPrivateOverlayRepoRoot(t *testing.T) { func TestSyncAndDoctorSettingsAcceptExternalHookAndMCPCommands(t *testing.T) { settingsRoot := t.TempDir() repoRoot := t.TempDir() + stateRoot := t.TempDir() t.Setenv("CODEX_HOME", filepath.Join(t.TempDir(), "must-remain-absent")) hookCommand := "env NYAR_HOME=/private/nyar " + @@ -266,6 +268,7 @@ func TestSyncAndDoctorSettingsAcceptExternalHookAndMCPCommands(t *testing.T) { err := syncSettings([]string{ "--root", settingsRoot, "--repo-root", repoRoot, + "--state-root", stateRoot, "--hook-command", hookCommand, "--mcp-command", mcpCommand, }) @@ -276,6 +279,7 @@ func TestSyncAndDoctorSettingsAcceptExternalHookAndMCPCommands(t *testing.T) { err = doctorSettings([]string{ "--root", settingsRoot, "--repo-root", repoRoot, + "--state-root", stateRoot, "--hook-command", hookCommand, "--mcp-command", mcpCommand, }) @@ -294,6 +298,12 @@ func TestSyncAndDoctorSettingsAcceptExternalHookAndMCPCommands(t *testing.T) { {path: paths.ClaudeMCP, text: "/opt/coding-ethos/bin/coding-ethos-run"}, {path: paths.CodexConfig, text: "/opt/coding-ethos/bin/coding-ethos-run"}, {path: paths.KimiMCP, text: "/opt/coding-ethos/bin/coding-ethos-run"}, + {path: paths.ClaudeMCP, text: repoRoot}, + {path: paths.ClaudeMCP, text: stateRoot}, + {path: paths.CodexConfig, text: repoRoot}, + {path: paths.CodexConfig, text: stateRoot}, + {path: paths.KimiMCP, text: repoRoot}, + {path: paths.KimiMCP, text: stateRoot}, } { payload, readErr := os.ReadFile(expectation.path) if readErr != nil { @@ -303,6 +313,17 @@ func TestSyncAndDoctorSettingsAcceptExternalHookAndMCPCommands(t *testing.T) { t.Fatalf("%s missing %q:\n%s", expectation.path, expectation.text, payload) } } + + if _, statErr := os.Stat( + filepath.Join(stateRoot, ".coding-ethos", "memories", "MEMORY.md"), + ); statErr != nil { + t.Fatalf("private state root lacks centralized memory: %v", statErr) + } + if _, statErr := os.Stat(filepath.Join(repoRoot, ".coding-ethos")); !os.IsNotExist( + statErr, + ) { + t.Fatalf("repository root gained durable supervisor state: %v", statErr) + } } func TestRunCLIDispatchesAgentHookCommands(t *testing.T) { diff --git a/go/internal/managedcapture/capture_test.go b/go/internal/managedcapture/capture_test.go index 70be89ee..a4002d14 100644 --- a/go/internal/managedcapture/capture_test.go +++ b/go/internal/managedcapture/capture_test.go @@ -43,6 +43,7 @@ func TestPrepareManagedWritablePathsCreatesDeclaredCacheDirs(t *testing.T) { ".coding-ethos/cache", managedRuntimePath("lint-runs/"), ".ruff_cache", + "__pycache__", "pkg/app.py", }, }) @@ -54,6 +55,7 @@ func TestPrepareManagedWritablePathsCreatesDeclaredCacheDirs(t *testing.T) { filepath.Join(root, ".coding-ethos", "cache"), filepath.Join(root, ".coding-ethos", "lint-runs"), filepath.Join(root, ".ruff_cache"), + filepath.Join(root, "__pycache__"), } { info, statErr := os.Stat(path) if statErr != nil || !info.IsDir() { diff --git a/go/internal/managedcapture/writable_paths.go b/go/internal/managedcapture/writable_paths.go index edae79e9..58855432 100644 --- a/go/internal/managedcapture/writable_paths.go +++ b/go/internal/managedcapture/writable_paths.go @@ -106,6 +106,7 @@ func managedWritableDir(path string) bool { ".pytest_cache", ".mypy_cache", ".ruff_cache", + "__pycache__", ".uv-cache": return true default: diff --git a/go/internal/mcp/code_intel.go b/go/internal/mcp/code_intel.go index 7cc3ccfe..b9df31d7 100644 --- a/go/internal/mcp/code_intel.go +++ b/go/internal/mcp/code_intel.go @@ -589,13 +589,15 @@ func (server Server) codeIntelIndexCode(args json.RawMessage) (any, error) { return nil, errCodeIntelRootUnavailable } + stateRoot := server.codeIntelStateRoot() + ctx := argsContext() - store, err := codeintel.Open(ctx, codeintel.DefaultDBPath(root)) + store, err := codeintel.Open(ctx, codeintel.DefaultDBPath(stateRoot)) if err != nil { return nil, fmt.Errorf("open code intelligence store: %w", err) } - defer autoPruneCodeIntelDB(root) + defer autoPruneCodeIntelDB(stateRoot) defer store.Close() summary, err := codeintel.NewASTIndexer(store).IndexPaths(ctx, root, input.Paths) @@ -1032,6 +1034,7 @@ func (server Server) loadFreshRepoMap( input codeIntelRepoMapInput, ) (codeintel.RepoMap, string, error) { root := server.codeIntelRoot() + stateRoot := server.codeIntelStateRoot() store, closeStore, err := server.openCodeIntelStore() if err != nil { @@ -1041,7 +1044,7 @@ func (server Server) loadFreshRepoMap( ) } - defer autoPruneCodeIntelDB(root) + defer autoPruneCodeIntelDB(stateRoot) defer closeStore() ctx := argsContext() @@ -1902,6 +1905,31 @@ func (server Server) codeIntelRoot() string { return firstNonEmpty(server.runtime.ConsumerRoot, server.runtime.InvocationCwd) } +func (server Server) codeIntelStateRoot() string { + return firstNonEmpty( + server.runtime.StateRoot, + server.runtime.ConsumerRoot, + server.runtime.InvocationCwd, + ) +} + +func (server Server) codeIntelStoreRoot(repoRoot string) string { + if sameFilesystemPath(repoRoot, server.codeIntelRoot()) { + return server.codeIntelStateRoot() + } + + return repoRoot +} + +func sameFilesystemPath(left, right string) bool { + leftAbsolute, leftErr := filepath.Abs(left) + rightAbsolute, rightErr := filepath.Abs(right) + + return leftErr == nil && + rightErr == nil && + filepath.Clean(leftAbsolute) == filepath.Clean(rightAbsolute) +} + func autoPruneCodeIntelDB(root string) { err := outputsurface.AutoPruneCodeIntelDB(argsContext(), root) if err == nil { diff --git a/go/internal/mcp/code_intel_workspace.go b/go/internal/mcp/code_intel_workspace.go index 3cc578a1..803fd7cd 100644 --- a/go/internal/mcp/code_intel_workspace.go +++ b/go/internal/mcp/code_intel_workspace.go @@ -300,7 +300,7 @@ func (server Server) codeIntelWorkspaceStatus(args json.RawMessage) (any, error) func (server Server) loadCodeIntelWorkspaceStatus( refresh bool, ) (codeintel.WorkspaceStatus, error) { - root := server.codeIntelRoot() + root := server.codeIntelStateRoot() if refresh { status, err := codeintel.RefreshWorkspaceStatus(argsContext(), root) if err != nil { @@ -337,10 +337,11 @@ func (server Server) openCodeIntelForRoot( } ctx := argsContext() + storeRoot := server.codeIntelStoreRoot(root) index, err := codeintel.NewVectorIndex(ctx, codeintel.VectorBackendConfig{ Backend: codeintel.VectorBackendDuckDBVSS, - URI: codeintel.DefaultVectorPath(root), + URI: codeintel.DefaultVectorPath(storeRoot), }) if err != nil { closeStore() @@ -366,7 +367,9 @@ func (server Server) openCodeIntelStoreForRoot( return nil, nil, errCodeIntelRootUnavailable } - store, err := codeintel.Open(argsContext(), codeintel.DefaultDBPath(root)) + storeRoot := server.codeIntelStoreRoot(root) + + store, err := codeintel.Open(argsContext(), codeintel.DefaultDBPath(storeRoot)) if err != nil { return nil, nil, fmt.Errorf("open code intelligence store: %w", err) } @@ -386,7 +389,7 @@ func (server Server) workspaceReposForScope( return nil, apperror.StaticError("workspace repo scope is required") } - registry, err := codeintel.LoadWorkspaceRegistry(server.codeIntelRoot()) + registry, err := codeintel.LoadWorkspaceRegistry(server.codeIntelStateRoot()) if err != nil { return nil, fmt.Errorf("load workspace registry: %w", err) } diff --git a/go/internal/mcp/server.go b/go/internal/mcp/server.go index dc4ef30b..4ef1e0fe 100644 --- a/go/internal/mcp/server.go +++ b/go/internal/mcp/server.go @@ -58,6 +58,7 @@ type Runtime struct { CerunPath string EthosRoot string ConsumerRoot string + StateRoot string InvocationCwd string } diff --git a/go/internal/mcp/server_test.go b/go/internal/mcp/server_test.go index 95ca6f4e..9cc8885b 100644 --- a/go/internal/mcp/server_test.go +++ b/go/internal/mcp/server_test.go @@ -2550,6 +2550,52 @@ func TestServerIndexesAndReturnsCodeChunks(t *testing.T) { } } +func TestServerIndexesRepositoryIntoPrivateStateRoot(t *testing.T) { + t.Parallel() + acquireCodeIntelMCPTestSlot(t) + + repoRoot := t.TempDir() + stateRoot := t.TempDir() + sourcePath := filepath.Join(repoRoot, "pkg", "app.py") + + err := os.MkdirAll(filepath.Dir(sourcePath), 0o700) + if err != nil { + t.Fatalf("create source dir: %v", err) + } + + err = os.WriteFile( + sourcePath, + []byte("def private_index():\n return True\n"), + 0o600, + ) + if err != nil { + t.Fatalf("write source: %v", err) + } + + output := runServerWithRuntime(t, compactJSON(t, `{ + "jsonrpc":"2.0", + "id":47, + "method":"tools/call", + "params":{ + "name":"code_intel_index_code", + "arguments":{"paths":["pkg/app.py"]} + } + }`), mcp.Runtime{ + ConsumerRoot: repoRoot, + StateRoot: stateRoot, + }) + if !strings.Contains(output, `"files_indexed":1`) { + t.Fatalf("private-state index output missing summary:\n%s", output) + } + + if _, statErr := os.Stat(codeintel.DefaultDBPath(stateRoot)); statErr != nil { + t.Fatalf("private state database missing: %v", statErr) + } + if _, statErr := os.Stat(codeintel.DefaultDBPath(repoRoot)); !os.IsNotExist(statErr) { + t.Fatalf("repository-local state database exists: %v", statErr) + } +} + func TestServerChecksCodeSimilarity(t *testing.T) { t.Parallel() acquireCodeIntelMCPTestSlot(t) diff --git a/go/internal/mcpcli/main.go b/go/internal/mcpcli/main.go index 6f9b7b21..f10b7571 100644 --- a/go/internal/mcpcli/main.go +++ b/go/internal/mcpcli/main.go @@ -26,6 +26,11 @@ func runWithIO(args []string, stdin io.Reader, stdout io.Writer) error { cerunPath := flags.String("cerun", "", "Path to repo-local cerun wrapper") ethosRoot := flags.String("ethos-root", "", "coding-ethos checkout root") consumerRoot := flags.String("consumer-root", "", "consumer repository root") + stateRoot := flags.String( + "state-root", + "", + "private Coding Ethos state root; defaults to consumer root", + ) invocationCwd := flags.String( "invocation-cwd", "", @@ -60,6 +65,7 @@ func runWithIO(args []string, stdin io.Reader, stdout io.Writer) error { CerunPath: *cerunPath, EthosRoot: *ethosRoot, ConsumerRoot: *consumerRoot, + StateRoot: *stateRoot, InvocationCwd: *invocationCwd, }).Serve(stdin, stdout) if err != nil { diff --git a/go/internal/memories/memory.go b/go/internal/memories/memory.go index 93c90afb..74356c2b 100644 --- a/go/internal/memories/memory.go +++ b/go/internal/memories/memory.go @@ -21,7 +21,8 @@ import ( ) const ( - // CentralDir is the repo-local provider-agnostic memory directory. + // CentralDir is the provider-agnostic memory directory under the selected + // state root. CentralDir = ".coding-ethos/memories" // PrimaryFile is the Markdown memory surface providers should read and write. PrimaryFile = CentralDir + "/MEMORY.md" @@ -56,7 +57,7 @@ func DeniedReason() string { " and " + CentralDir + "/*.yaml" } -// Settings controls the repo-local memory system. +// Settings controls the root-scoped memory system. type Settings struct { CentralDir string `json:"central_dir"` PrimaryFile string `json:"primary_file"` @@ -187,13 +188,17 @@ func Ensure(root string) error { return err } + return ensureWithSettings(root, settings) +} + +func ensureWithSettings(root string, settings Settings) error { if !settings.Enabled { return nil } primary := filepath.Join(rootOrDot(root), filepath.FromSlash(settings.PrimaryFile)) - err = os.MkdirAll(filepath.Dir(primary), dirMode) + err := os.MkdirAll(filepath.Dir(primary), dirMode) if err != nil { return fmt.Errorf("create memory directory: %w", err) } @@ -224,6 +229,22 @@ func Verify(root string) error { return err } + return verifyWithSettings(root, settings) +} + +// VerifyForRoots checks stateRoot using memory settings loaded from +// settingsRoot. Keeping policy input separate from durable state lets callers +// use private state without copying repository configuration into it. +func VerifyForRoots(settingsRoot, stateRoot string) error { + settings, err := LoadSettings(settingsRoot) + if err != nil { + return err + } + + return verifyWithSettings(stateRoot, settings) +} + +func verifyWithSettings(root string, settings Settings) error { if !settings.Enabled { return nil } @@ -301,19 +322,26 @@ func ClassifyWithSettings(root, rawPath, _ string, settings Settings) Classifica // ImportExisting imports known provider memory files without deleting sources. func ImportExisting(root string) (ImportReport, error) { - settings, err := LoadSettings(root) + return ImportExistingForRoots(root, root) +} + +// ImportExistingForRoots imports provider memory discovered under sourceRoot +// into the central memory store under stateRoot. Memory settings remain +// repository-owned and are therefore loaded from sourceRoot. +func ImportExistingForRoots(sourceRoot, stateRoot string) (ImportReport, error) { + settings, err := LoadSettings(sourceRoot) if err != nil { return ImportReport{}, err } if !settings.Enabled || !settings.ImportExisting { - return ImportReport{Root: rootOrDot(root)}, nil + return ImportReport{Root: rootOrDot(stateRoot)}, nil } - report := ImportReport{Root: rootOrDot(root)} + report := ImportReport{Root: rootOrDot(stateRoot)} - err = withMemoryLock(root, settings, func() error { - return importExistingLocked(root, settings, &report) + err = withMemoryLock(stateRoot, settings, func() error { + return importExistingLocked(sourceRoot, stateRoot, settings, &report) }) if err != nil { return ImportReport{}, err @@ -322,18 +350,26 @@ func ImportExisting(root string) (ImportReport, error) { return report, nil } -func importExistingLocked(root string, settings Settings, report *ImportReport) error { - err := Ensure(root) +func importExistingLocked( + sourceRoot string, + stateRoot string, + settings Settings, + report *ImportReport, +) error { + err := ensureWithSettings(stateRoot, settings) if err != nil { return err } - sources, err := importSourcePaths(root) + sources, err := importSourcePaths(sourceRoot) if err != nil { return err } - primary := filepath.Join(rootOrDot(root), filepath.FromSlash(settings.PrimaryFile)) + primary := filepath.Join( + rootOrDot(stateRoot), + filepath.FromSlash(settings.PrimaryFile), + ) existing, err := os.ReadFile(filepath.Clean(primary)) if err != nil { @@ -345,7 +381,7 @@ func importExistingLocked(root string, settings Settings, report *ImportReport) blocks := strings.Builder{} for _, source := range sources { - record, block, importErr := importOne(root, source, existingText) + record, block, importErr := importOne(sourceRoot, source, existingText) if importErr != nil { return importErr } @@ -364,13 +400,13 @@ func importExistingLocked(root string, settings Settings, report *ImportReport) } } - err = ensureIndex(root, settings, records) + err = ensureIndex(stateRoot, settings, records) if err != nil { return err } *report = ImportReport{ - Root: rootOrDot(root), + Root: rootOrDot(stateRoot), Records: records, Changed: len(records) > 0, } diff --git a/go/internal/memories/memory_test.go b/go/internal/memories/memory_test.go index 01e4bcfa..8540846f 100644 --- a/go/internal/memories/memory_test.go +++ b/go/internal/memories/memory_test.go @@ -4,6 +4,7 @@ package memories_test import ( + "errors" "os" "path/filepath" "strings" @@ -143,6 +144,54 @@ func TestImportExistingIsIdempotent(t *testing.T) { } } +func TestImportExistingForRootsKeepsDurableStateOutOfSourceRoot(t *testing.T) { + t.Parallel() + + sourceRoot := t.TempDir() + stateRoot := t.TempDir() + source := filepath.Join( + sourceRoot, + ".claude", + "projects", + "repo", + "memory", + "project.md", + ) + if err := os.MkdirAll(filepath.Dir(source), 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(source, []byte("private state memory\n"), 0o600); err != nil { + t.Fatalf("write source: %v", err) + } + + report, err := memories.ImportExistingForRoots(sourceRoot, stateRoot) + if err != nil { + t.Fatalf("ImportExistingForRoots: %v", err) + } + if report.Root != stateRoot || len(report.Records) != 1 { + t.Fatalf("report = %#v", report) + } + if err := memories.VerifyForRoots(sourceRoot, stateRoot); err != nil { + t.Fatalf("VerifyForRoots: %v", err) + } + + data, err := os.ReadFile( + filepath.Join(stateRoot, ".coding-ethos", "memories", "MEMORY.md"), + ) + if err != nil { + t.Fatalf("read private central memory: %v", err) + } + if !strings.Contains(string(data), "private state memory") { + t.Fatalf("private central memory lost imported content:\n%s", data) + } + if _, err := os.Stat(filepath.Join(sourceRoot, ".coding-ethos")); !errors.Is( + err, + os.ErrNotExist, + ) { + t.Fatalf("source root gained durable state: %v", err) + } +} + func TestLoadSettingsMergesRepoOverride(t *testing.T) { t.Parallel() From 96ac1859b6e4f80a10dc183d03e32718c3c73918 Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Sun, 26 Jul 2026 19:09:05 -0600 Subject: [PATCH 03/13] fix(runtime): satisfy managed pre-push checks --- go/cmd/coding-ethos-run/args.go | 14 ++++++---- go/cmd/coding-ethos-run/dispatch.go | 2 +- go/internal/agenthookscli/main.go | 43 +++++++++++++++++++++-------- 3 files changed, 41 insertions(+), 18 deletions(-) diff --git a/go/cmd/coding-ethos-run/args.go b/go/cmd/coding-ethos-run/args.go index bcd8efed..2de2f665 100644 --- a/go/cmd/coding-ethos-run/args.go +++ b/go/cmd/coding-ethos-run/args.go @@ -10,10 +10,11 @@ import ( ) const ( - golangciLintAutofixTool = "golangci-lint-autofix" - golangciLintFormatTool = "golangci-lint-format" - golangciLintTool = "golangci-lint" - injectedRootArgCount = 2 + agentHookCapabilitiesCommand = "capabilities" + golangciLintAutofixTool = "golangci-lint-autofix" + golangciLintFormatTool = "golangci-lint-format" + golangciLintTool = "golangci-lint" + injectedRootArgCount = 2 ) func runnerArgs(argv []string) []string { @@ -50,6 +51,7 @@ func codeIntelArgs(root, stateRoot string, args []string) []string { filepath.Join(stateRoot, ".coding-ethos", "code-intel.duckdb"), ) } + next = append(next, args[1:]...) return next @@ -149,7 +151,7 @@ func withDefaultHookCommand(paths runtimePaths, args []string) []string { } func agentHooksArgs(paths runtimePaths, args []string) []string { - if len(args) == 0 || args[0] != "capabilities" { + if len(args) == 0 || args[0] != agentHookCapabilitiesCommand { return withDefaultHookCommand(paths, args) } @@ -269,5 +271,5 @@ func shouldLogRuntimeCommand(args []string) bool { return len(args) < 2 || args[0] != "agent-hooks" || - args[1] != "capabilities" + args[1] != agentHookCapabilitiesCommand } diff --git a/go/cmd/coding-ethos-run/dispatch.go b/go/cmd/coding-ethos-run/dispatch.go index 5f527aa6..1cc86088 100644 --- a/go/cmd/coding-ethos-run/dispatch.go +++ b/go/cmd/coding-ethos-run/dispatch.go @@ -981,7 +981,7 @@ func runAgentHook(paths runtimePaths, rest []string) { } func runAgentHooksCommand(paths runtimePaths, rest []string) { - if len(rest) == 0 || rest[0] != "capabilities" { + if len(rest) == 0 || rest[0] != agentHookCapabilitiesCommand { installGitWrapperShim(paths) installLintToolShims(paths) } diff --git a/go/internal/agenthookscli/main.go b/go/internal/agenthookscli/main.go index 6be14ca2..6732f8de 100644 --- a/go/internal/agenthookscli/main.go +++ b/go/internal/agenthookscli/main.go @@ -183,7 +183,7 @@ func syncSettings(args []string) error { ) } - err = agenthooks.SyncSettingsForRootsWithMCPCommand( + err = applyAgentHookSettings( *root, resolvedRepoRoot, resolvedStateRoot, @@ -191,16 +191,7 @@ func syncSettings(args []string) error { *mcpCommand, ) if err != nil { - return fmt.Errorf("sync agent hook settings: %w", err) - } - - err = agenthooks.SyncCodexTrustState( - *root, - resolvedHookCommand, - codexTrustConfigForRoots(*root, resolvedRepoRoot), - ) - if err != nil { - return fmt.Errorf("sync Codex hook trust: %w", err) + return err } if privateSettingsOverlay(*root, resolvedRepoRoot) { @@ -219,6 +210,36 @@ func syncSettings(args []string) error { return upsertAgentHookSyncState(*root, *ethosRoot, artifacts) } +func applyAgentHookSettings( + root string, + repoRoot string, + stateRoot string, + hookCommand string, + mcpCommand string, +) error { + err := agenthooks.SyncSettingsForRootsWithMCPCommand( + root, + repoRoot, + stateRoot, + hookCommand, + mcpCommand, + ) + if err != nil { + return fmt.Errorf("sync agent hook settings: %w", err) + } + + err = agenthooks.SyncCodexTrustState( + root, + hookCommand, + codexTrustConfigForRoots(root, repoRoot), + ) + if err != nil { + return fmt.Errorf("sync Codex hook trust: %w", err) + } + + return nil +} + func upsertAgentHookSyncState( root string, ethosRoot string, From 00676416dc561481853c6e2df7d4b87f3f3c18d9 Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Sun, 26 Jul 2026 19:43:13 -0600 Subject: [PATCH 04/13] fix(runtime): isolate managed verification context --- Makefile | 2 + go/internal/e2e/mcp_test.go | 1 + go/internal/e2e/sandbox_workflow_test.go | 41 ++++++++++- go/internal/e2e/scenario.go | 69 +++++++++++++++++-- go/internal/e2e/scenario_internal_test.go | 32 +++++++++ go/internal/hookrunnercli/main.go | 7 ++ .../hookrunnercli/main_internal_test.go | 11 +++ go/internal/managedcapture/capture.go | 5 ++ tests/lint_capture_support.py | 24 ++++++- tests/test_lint_capture_lifecycle.py | 23 +++++++ 10 files changed, 207 insertions(+), 8 deletions(-) create mode 100644 go/internal/e2e/scenario_internal_test.go diff --git a/Makefile b/Makefile index 1fb3897d..d61d458d 100644 --- a/Makefile +++ b/Makefile @@ -31,7 +31,9 @@ GO ?= go GOFMT ?= gofmt GO_BUILD_FLAGS ?= -trimpath -buildvcs=false GO_BUILD_CACHE_DIR ?= $(LOCAL_REPO_ROOT)/.coding-ethos/cache/go-build +UV_CACHE_DIR ?= $(LOCAL_REPO_ROOT)/.coding-ethos/cache/uv export GOCACHE := $(GO_BUILD_CACHE_DIR) +export UV_CACHE_DIR empty := space := $(empty) $(empty) diff --git a/go/internal/e2e/mcp_test.go b/go/internal/e2e/mcp_test.go index 03b4cb9c..aa069f44 100644 --- a/go/internal/e2e/mcp_test.go +++ b/go/internal/e2e/mcp_test.go @@ -39,6 +39,7 @@ func StartMCPClient(t *testing.T, ethosRoot, repoRoot string) *MCPClient { ctx, cancel := context.WithTimeout(context.Background(), mcpClientTimeout) cmd := exec.CommandContext(ctx, bin, "mcp") cmd.Dir = repoRoot + cmd.Env = e2e.CommandEnvironment(t, nil) stdin, err := cmd.StdinPipe() if err != nil { diff --git a/go/internal/e2e/sandbox_workflow_test.go b/go/internal/e2e/sandbox_workflow_test.go index e816ba18..594d8344 100644 --- a/go/internal/e2e/sandbox_workflow_test.go +++ b/go/internal/e2e/sandbox_workflow_test.go @@ -102,10 +102,10 @@ func TestSandboxedManagedRuffCaptureProducesSARIFEvidence(t *testing.T) { `"sandbox": {`, `"backend": "native"`, `"profile": "lint-offline"`, - `"network_isolated": true`, } { result.RequireContains(t, want) } + assertSandboxIsolationEvidence(t, result.Combined) } func requiredModeSandboxDenied(t *testing.T, result e2e.CommandResult) bool { @@ -341,8 +341,6 @@ func assertSandboxTraceEvidence(t *testing.T, trace, mode string) { `"enabled": true`, `"git_read_only": true`, `"repo_read_only": true`, - `"network_isolated": true`, - `"process_isolated": true`, `".coding-ethos/cache/"`, `".ruff_cache/"`, `"no-network"`, @@ -352,4 +350,41 @@ func assertSandboxTraceEvidence(t *testing.T, trace, mode string) { t.Fatalf("sandbox trace missing %q:\n%s", want, trace) } } + assertSandboxIsolationEvidence(t, trace) +} + +func assertSandboxIsolationEvidence(t *testing.T, payload string) { + t.Helper() + + // A managed check already inside the agent shell cannot create another + // AppArmor-mediated user namespace. It still executes the native helper's + // Landlock filesystem policy and records the inherited process capability + // explicitly, rather than claiming a fresh PID/network boundary. + if strings.Contains(payload, `"requires_processes": true`) { + for _, unsupported := range []string{ + `"network_isolated": true`, + `"process_isolated": true`, + `"namespace_enforced": true`, + } { + if strings.Contains(payload, unsupported) { + t.Fatalf( + "outer agent-shell reuse overclaimed %q:\n%s", + unsupported, + payload, + ) + } + } + + return + } + + for _, want := range []string{ + `"network_isolated": true`, + `"process_isolated": true`, + `"namespace_enforced": true`, + } { + if !strings.Contains(payload, want) { + t.Fatalf("fresh sandbox evidence missing %q:\n%s", want, payload) + } + } } diff --git a/go/internal/e2e/scenario.go b/go/internal/e2e/scenario.go index 013fcecf..45ba92c0 100644 --- a/go/internal/e2e/scenario.go +++ b/go/internal/e2e/scenario.go @@ -204,13 +204,18 @@ func copyMutableRuntimeBin(t *testing.T, sourceRoot, runtimeRoot string) { continue } + target := filepath.Join(binRoot, filepath.Base(source)) + if filepath.Base(source) == "coding-ethos-sandbox" { + linkManagedSandboxHelper(t, source, target) + + continue + } + payload, readErr := os.ReadFile(source) if readErr != nil { t.Fatalf("read mutable runtime binary %s: %v", source, readErr) } - target := filepath.Join(binRoot, filepath.Base(source)) - writeErr := writeInstrumentedRuntimeFile(target, payload, info.Mode().Perm()) if writeErr != nil { t.Fatalf("write mutable runtime binary %s: %v", target, writeErr) @@ -283,7 +288,6 @@ func buildInstrumentedEthosRoot(t *testing.T, ethosRoot string) string { for _, command := range []string{ "coding-ethos-run", "coding-ethos-lint", - "coding-ethos-sandbox", "coding-ethos-policy", "coding-ethos-hook-log", "coding-ethos-hook-runner", @@ -292,9 +296,33 @@ func buildInstrumentedEthosRoot(t *testing.T, ethosRoot string) string { buildInstrumentedCommand(t, ethosRoot, runtimeRoot, command) } + linkManagedSandboxHelper( + t, + filepath.Join(ethosRoot, "bin", "coding-ethos-sandbox"), + filepath.Join(runtimeRoot, "bin", "coding-ethos-sandbox"), + ) + return runtimeRoot } +// linkManagedSandboxHelper preserves the exact repository-managed executable +// path that host AppArmor policy authorizes for Linux namespace creation. +// Instrumenting or copying the helper changes that path and makes an otherwise +// valid nested sandbox fail closed before it can apply its policy. +func linkManagedSandboxHelper(t *testing.T, source, destination string) { + t.Helper() + + resolved, err := filepath.EvalSymlinks(source) + if err != nil { + t.Fatalf("resolve managed sandbox helper %s: %v", source, err) + } + + err = os.Symlink(resolved, destination) + if err != nil { + t.Fatalf("link managed sandbox helper %s: %v", destination, err) + } +} + func copyOrSymlinkInstrumentedRuntimeEntry(source, destination string) error { info, err := os.Stat(source) if err != nil { @@ -367,7 +395,7 @@ func absoluteCoverageDir(t *testing.T, coverDir string) string { func commandEnvironmentWith(t *testing.T, overrides map[string]string) []string { t.Helper() - env := os.Environ() + env := withoutInheritedRuntimeContext(os.Environ()) for index, entry := range env { value, ok := strings.CutPrefix(entry, "GOCOVERDIR=") if ok && strings.TrimSpace(value) != "" { @@ -383,6 +411,39 @@ func commandEnvironmentWith(t *testing.T, overrides map[string]string) []string return env } +// CommandEnvironment returns a fixture-safe subprocess environment with +// inherited Coding Ethos runtime ownership removed before applying overrides. +func CommandEnvironment(t *testing.T, overrides map[string]string) []string { + t.Helper() + + return commandEnvironmentWith(t, overrides) +} + +func withoutInheritedRuntimeContext(env []string) []string { + for _, name := range []string{ + "CODE_ETHOS_CONSUMER_ROOT", + "CODE_ETHOS_GIT_WRAPPER_AUTHORIZED", + "CODE_ETHOS_GIT_WRAPPER_PID", + "CODE_ETHOS_HOOK_LOGGING_ACTIVE", + "CODE_ETHOS_HOOK_RUN_DIR", + "CODE_ETHOS_LOCAL_ROOT", + "CODE_ETHOS_PRECOMMIT_CONFIG", + "CODE_ETHOS_PRECOMMIT_ROOT", + "CODE_ETHOS_STATE_ROOT", + "CODING_ETHOS_EXEC_STACK", + "CODING_ETHOS_GIT_SHIM_DIR", + "CODING_ETHOS_RUN_GO_HOOK", + "INVOCATION_CWD", + "MANAGED_TOOLCHAIN_MANIFEST", + "POLICY_METADATA", + "TOOLS_SRC_DIR", + } { + env = appendWithoutEnvName(env, name) + } + + return env +} + func appendWithoutEnvName(env []string, name string) []string { prefix := name + "=" out := env[:0] diff --git a/go/internal/e2e/scenario_internal_test.go b/go/internal/e2e/scenario_internal_test.go new file mode 100644 index 00000000..811e92c1 --- /dev/null +++ b/go/internal/e2e/scenario_internal_test.go @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: 2026 Blackcat Informatics Inc. +// SPDX-License-Identifier: AGPL-3.0-only + +package e2e + +import ( + "slices" + "testing" +) + +func TestCommandEnvironmentDropsInheritedRuntimeContext(t *testing.T) { + t.Setenv("CODE_ETHOS_CONSUMER_ROOT", "/outer/repo") + t.Setenv("CODE_ETHOS_HOOK_RUN_DIR", "/outer/hook-run") + t.Setenv("CODE_ETHOS_STATE_ROOT", "/outer/state") + t.Setenv("CODING_ETHOS_AGENT_SHELL_SANDBOX", "1") + + env := commandEnvironmentWith(t, map[string]string{ + "CODE_ETHOS_CONSUMER_ROOT": "/fixture/repo", + }) + + if !slices.Contains(env, "CODE_ETHOS_CONSUMER_ROOT=/fixture/repo") { + t.Fatalf("explicit fixture root missing from environment: %#v", env) + } + if !slices.Contains(env, "CODING_ETHOS_AGENT_SHELL_SANDBOX=1") { + t.Fatalf("outer sandbox provenance missing from environment: %#v", env) + } + if slices.Contains(env, "CODE_ETHOS_CONSUMER_ROOT=/outer/repo") || + slices.Contains(env, "CODE_ETHOS_HOOK_RUN_DIR=/outer/hook-run") || + slices.Contains(env, "CODE_ETHOS_STATE_ROOT=/outer/state") { + t.Fatalf("inherited runtime context leaked into fixture: %#v", env) + } +} diff --git a/go/internal/hookrunnercli/main.go b/go/internal/hookrunnercli/main.go index e20433b6..cbaa76d1 100644 --- a/go/internal/hookrunnercli/main.go +++ b/go/internal/hookrunnercli/main.go @@ -442,6 +442,13 @@ func localRepoRoot() string { } func consumerRoot(ethosRoot string) string { + if root := strings.TrimSpace(os.Getenv(consumerRootEnv)); root != "" { + cwd, err := os.Getwd() + if err == nil && explicitConsumerRootApplies(root, cwd) { + return root + } + } + return resolveConsumerRoot( ethosRoot, os.Getenv(consumerRootEnv), diff --git a/go/internal/hookrunnercli/main_internal_test.go b/go/internal/hookrunnercli/main_internal_test.go index 30b0f9bb..ef4cfc89 100644 --- a/go/internal/hookrunnercli/main_internal_test.go +++ b/go/internal/hookrunnercli/main_internal_test.go @@ -113,6 +113,17 @@ func TestConsumerRootIgnoresUnrelatedExplicitEnvironment(t *testing.T) { } } +func TestConsumerRootHonorsSplitRuntimeWhenCwdBelongsToConsumer(t *testing.T) { + root := t.TempDir() + runtimeRoot := t.TempDir() + t.Setenv(consumerRootEnv, root) + t.Chdir(root) + + if got := consumerRoot(runtimeRoot); got != root { + t.Fatalf("consumerRoot() = %q, want split consumer root %q", got, root) + } +} + func TestConsumerRootIgnoresExplicitRootForIgnoredWorktreeScratch(t *testing.T) { root := setupGitHookTestRepo(t) mustWriteTestFile(t, filepath.Join(root, ".gitignore"), ".coding-ethos/\n") diff --git a/go/internal/managedcapture/capture.go b/go/internal/managedcapture/capture.go index 188698da..5db596f6 100644 --- a/go/internal/managedcapture/capture.go +++ b/go/internal/managedcapture/capture.go @@ -701,6 +701,11 @@ func prepareSandboxCgroup( } cgroup, appliedEvidence, err := sandbox.PrepareCgroupLimits(evidence) + appliedEvidence.Reason = appendEvidenceReason( + evidence.Reason, + appliedEvidence.Reason, + ) + if err != nil { return nil, appliedEvidence, fmt.Errorf( "prepare sandbox cgroup limits: %w", diff --git a/tests/lint_capture_support.py b/tests/lint_capture_support.py index 7d4ab2bd..6a3738b2 100644 --- a/tests/lint_capture_support.py +++ b/tests/lint_capture_support.py @@ -18,12 +18,34 @@ RUNNER = REPO_ROOT / "bin" / "coding-ethos-run" POLICY = REPO_ROOT / "bin" / "coding-ethos-policy" +_RUNTIME_CONTEXT_ENV = frozenset( + { + "CODE_ETHOS_CONSUMER_ROOT", + "CODE_ETHOS_LOCAL_ROOT", + "CODE_ETHOS_PRECOMMIT_ROOT", + "CODE_ETHOS_STATE_ROOT", + "CODING_ETHOS_GIT_SHIM_DIR", + "CODING_ETHOS_REAL_GIT", + "CODING_ETHOS_RUN_GO_HOOK", + "INVOCATION_CWD", + "MANAGED_TOOLCHAIN_MANIFEST", + "POLICY_METADATA", + "TOOLS_SRC_DIR", + } +) + def _clean_subprocess_env(env: dict[str, str] | None) -> dict[str, str]: - clean = dict(os.environ if env is None else env) + inherited = os.environ + clean = dict(inherited if env is None else env) for name in list(clean): if name.startswith("GIT_") or name == "TMPDIR": clean.pop(name, None) + continue + if name in _RUNTIME_CONTEXT_ENV and ( + env is None or env.get(name) == inherited.get(name) + ): + clean.pop(name, None) return clean diff --git a/tests/test_lint_capture_lifecycle.py b/tests/test_lint_capture_lifecycle.py index ba34cc0e..c0da9c38 100644 --- a/tests/test_lint_capture_lifecycle.py +++ b/tests/test_lint_capture_lifecycle.py @@ -10,15 +10,38 @@ import time from pathlib import Path +import pytest + from tests.lint_capture_support import ( REPO_ROOT, RUNNER, + _clean_subprocess_env, _prepare_consumer_repo, _run, _sync_consumer_tool_configs, ) +def test_subprocess_environment_drops_inherited_runtime_roots( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("CODE_ETHOS_CONSUMER_ROOT", "/outer/repo") + monkeypatch.setenv("CODE_ETHOS_STATE_ROOT", "/outer/state") + + clean = _clean_subprocess_env(None) + + assert "CODE_ETHOS_CONSUMER_ROOT" not in clean + assert "CODE_ETHOS_STATE_ROOT" not in clean + + explicit = os.environ.copy() + explicit["CODE_ETHOS_CONSUMER_ROOT"] = "/explicit/consumer" + + assert ( + _clean_subprocess_env(explicit)["CODE_ETHOS_CONSUMER_ROOT"] + == "/explicit/consumer" + ) + + def test_policy_tool_blocks_configured_lint_roots_that_escape_repo( tmp_path: Path, ) -> None: From d7ef2e8e228d7b3dac1217f9c971c99c5f2b813d Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Mon, 27 Jul 2026 00:49:29 -0600 Subject: [PATCH 05/13] fix(deps): update vulnerable managed tools --- .gitattributes | 1 + .../npm-locks/eslint-10.3.0/package-lock.json | 8 ++++---- pyproject.toml | 2 +- uv.lock | 14 +++++++------- 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/.gitattributes b/.gitattributes index bba7ca46..f23fa425 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,5 +4,6 @@ * text=auto eol=lf *.yaml whitespace=trailing-space,space-before-tab *.yml whitespace=trailing-space,space-before-tab +*.json whitespace=trailing-space,space-before-tab *.md whitespace=trailing-space,space-before-tab *.py whitespace=trailing-space,space-before-tab diff --git a/pre-commit/hooks/npm-locks/eslint-10.3.0/package-lock.json b/pre-commit/hooks/npm-locks/eslint-10.3.0/package-lock.json index 2c016550..25564c55 100644 --- a/pre-commit/hooks/npm-locks/eslint-10.3.0/package-lock.json +++ b/pre-commit/hooks/npm-locks/eslint-10.3.0/package-lock.json @@ -236,15 +236,15 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/cross-spawn": { diff --git a/pyproject.toml b/pyproject.toml index 1cacc2eb..8db240b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,7 +87,7 @@ packages = ["coding_ethos"] [tool.uv] exclude-newer = "7 days" -exclude-newer-package = { pip = "2026-04-26T21:00:06Z", sqlfluff = "2026-05-15T00:00:00Z" } +exclude-newer-package = { pip = "2026-05-31T17:33:57Z", sqlfluff = "2026-05-15T00:00:00Z" } managed = true [tool.uv.workspace] diff --git a/uv.lock b/uv.lock index 46461cbb..098a4ea8 100644 --- a/uv.lock +++ b/uv.lock @@ -11,7 +11,7 @@ exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for exclude-newer-span = "P7D" [options.exclude-newer-package] -pip = "2026-04-26T21:00:06Z" +pip = "2026-05-31T17:33:57Z" sqlfluff = "2026-05-15T00:00:00Z" [manifest] @@ -823,11 +823,11 @@ wheels = [ [[package]] name = "pip" -version = "26.1" +version = "26.1.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/73/7e/d2b04004e1068ad4fdfa2f227b839b5d03e602e47cdbbf49de71137c9546/pip-26.1.tar.gz", hash = "sha256:81e13ebcca3ffa8cc85e4deff5c27e1ee26dea0aa7fc2f294a073ac208806ff3", size = 1840316, upload-time = "2026-04-26T21:00:05.406Z" } +sdist = { url = "https://files.pythonhosted.org/packages/01/91/47e7d486260f618783899587af63ccf7980fb60245c3e63dd4571c6b57ad/pip-26.1.2.tar.gz", hash = "sha256:f49cd134c61cf2fd75e0ce2676db03e4054504a5a4986d00f8299ae632dc4605", size = 1840799, upload-time = "2026-05-31T17:33:58.56Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/70/7a/be4bd8bcbb24ea475856dd68159d78b03b2bb53dae369f69c9606b8888f5/pip-26.1-py3-none-any.whl", hash = "sha256:4e8486d821d814b77319acb7b9e8bf5a4ee7590a643e7cb21029f209be8573c1", size = 1812804, upload-time = "2026-04-26T21:00:03.194Z" }, + { url = "https://files.pythonhosted.org/packages/5d/95/6b5cb3461ea5673ba0995989746db58eb18b91b54dbf331e72f569540946/pip-26.1.2-py3-none-any.whl", hash = "sha256:382ff9f685ee3bc25864f820aa50505825f10f5458ffff07e30a6d96e5715cab", size = 1813144, upload-time = "2026-05-31T17:33:56.772Z" }, ] [[package]] @@ -1203,11 +1203,11 @@ wheels = [ [[package]] name = "setuptools" -version = "82.0.1" +version = "83.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, + { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, ] [[package]] From 3f05d06648a5a90f7c7a8b2265f3ac1a56015e17 Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Mon, 27 Jul 2026 11:10:08 -0600 Subject: [PATCH 06/13] feat(agent-hooks): make provider hook deadlines configurable --- README.md | 8 + docs/HOOK_CONTRACT_V1.md | 10 +- go/internal/agenthooks/codex_trust.go | 39 +++- .../agenthooks/provider_capabilities.go | 2 + go/internal/agenthooks/settings.go | 212 +++++++++++++++--- go/internal/agenthooks/settings_probe.go | 2 + go/internal/agenthooks/settings_test.go | 69 ++++++ go/internal/agenthooks/state_artifacts.go | 22 +- go/internal/agenthookscli/main.go | 196 ++++++++-------- .../agenthookscli/main_internal_test.go | 1 + 10 files changed, 435 insertions(+), 126 deletions(-) diff --git a/README.md b/README.md index cb4de2e6..e69ff772 100644 --- a/README.md +++ b/README.md @@ -1619,12 +1619,14 @@ bin/coding-ethos-run agent-hooks sync \ --root /private/settings-overlay \ --repo-root /path/to/repo \ --state-root /private/coding-ethos-state \ + --hook-timeout-seconds 45 \ --hook-command 'env NYAR_HOME=/private/nyar NYAR_CODING_ETHOS_ROOT=/opt/coding-ethos /absolute/path/nyar hook' \ --mcp-command '/opt/coding-ethos/bin/coding-ethos-run mcp' bin/coding-ethos-run agent-hooks verify \ --root /private/settings-overlay \ --repo-root /path/to/repo \ --state-root /private/coding-ethos-state \ + --hook-timeout-seconds 45 \ --hook-command 'env NYAR_HOME=/private/nyar NYAR_CODING_ETHOS_ROOT=/opt/coding-ethos /absolute/path/nyar hook' \ --mcp-command '/opt/coding-ethos/bin/coding-ethos-run mcp' ``` @@ -1638,6 +1640,12 @@ an absolute `coding-ethos-run mcp` command. When `--mcp-command` is omitted, the current MCP command is still derived from `--hook-command`; this preserves existing repo-local behavior. +`--hook-timeout-seconds` accepts 1–3600 seconds and defaults to 30. Pass the +same value to `sync`, `doctor`, and `verify`. Claude, Codex, and Gemini receive +that native hook deadline, and Codex trust hashes bind it. Kimi does not expose +a native per-hook timeout, so an external supervisor must retain its own +bounded command deadline. + For split roots, the generated MCP configuration keeps that statically validated base command and appends `--repo-root` plus `--state-root`. Prepare and verify its compiled consumer policy in the same private state root: diff --git a/docs/HOOK_CONTRACT_V1.md b/docs/HOOK_CONTRACT_V1.md index cc56c91f..8a987a40 100644 --- a/docs/HOOK_CONTRACT_V1.md +++ b/docs/HOOK_CONTRACT_V1.md @@ -116,7 +116,8 @@ bin/coding-ethos-run agent-hooks capabilities --json The response schema is `coding-ethos.agent-hooks/v1`. `runtime_version` comes from the checkout's `pyproject.toml`. The command is read-only and does not require a policy bundle or code-intelligence store. The report advertises -`mcp_command_flag: "--mcp-command"` and +`mcp_command_flag: "--mcp-command"`, +`hook_timeout_flag: "--hook-timeout-seconds"`, and `runtime_policy_command: "runtime-policy"` alongside the settings, repository, and state root flags. @@ -162,12 +163,14 @@ bin/coding-ethos-run agent-hooks sync \ --root /private/settings-overlay \ --repo-root /path/to/repo \ --state-root /private/coding-ethos-state \ + --hook-timeout-seconds 45 \ --hook-command 'env NYAR_HOME=/private/nyar NYAR_CODING_ETHOS_ROOT=/opt/coding-ethos /absolute/path/nyar hook' \ --mcp-command '/opt/coding-ethos/bin/coding-ethos-run mcp' bin/coding-ethos-run agent-hooks verify \ --root /private/settings-overlay \ --repo-root /path/to/repo \ --state-root /private/coding-ethos-state \ + --hook-timeout-seconds 45 \ --hook-command 'env NYAR_HOME=/private/nyar NYAR_CODING_ETHOS_ROOT=/opt/coding-ethos /absolute/path/nyar hook' \ --mcp-command '/opt/coding-ethos/bin/coding-ethos-run mcp' bin/coding-ethos-run runtime-policy check \ @@ -186,6 +189,11 @@ smoke payloads through the external supervisor command, while generated Claude, Codex, Gemini, and Kimi MCP entries continue to invoke Coding Ethos directly. For split roots, generated MCP entries append the validated `--repo-root` and `--state-root` arguments to that exact base command. +`--hook-timeout-seconds` is bounded to 1–3600 seconds and defaults to 30. The +same value must be supplied to `sync`, `doctor`, and `verify`; it is rendered +into Claude, Codex, and Gemini native hook settings and included in Codex +trust hashes. Kimi's native hook schema does not expose a per-hook timeout, so +the supervisor command remains responsible for its own bounded deadline. `runtime-policy sync/check` owns only the consumer-scoped compiled bundle under the state root (or below Git metadata when no state root is supplied); it does not generate or rewrite tracked repository configuration. diff --git a/go/internal/agenthooks/codex_trust.go b/go/internal/agenthooks/codex_trust.go index a1be5b1e..05fe7887 100644 --- a/go/internal/agenthooks/codex_trust.go +++ b/go/internal/agenthooks/codex_trust.go @@ -37,7 +37,23 @@ func DefaultCodexUserConfigPath() (string, error) { // SyncCodexTrustState records trust hashes for generated project Codex hooks. func SyncCodexTrustState(root, hookCommand, userConfigPath string) error { - entries, err := expectedCodexTrustEntries(root, hookCommand) + return SyncCodexTrustStateWithOptions( + root, + hookCommand, + userConfigPath, + DefaultSettingsOptions(), + ) +} + +// SyncCodexTrustStateWithOptions records hashes including the selected hook +// deadline. +func SyncCodexTrustStateWithOptions( + root string, + hookCommand string, + userConfigPath string, + options SettingsOptions, +) error { + entries, err := expectedCodexTrustEntries(root, hookCommand, options) if err != nil { return err } @@ -57,7 +73,23 @@ func SyncCodexTrustState(root, hookCommand, userConfigPath string) error { // VerifyCodexTrustState fails when Codex would treat generated project hooks as // untrusted or modified. func VerifyCodexTrustState(root, hookCommand, userConfigPath string) error { - entries, err := expectedCodexTrustEntries(root, hookCommand) + return VerifyCodexTrustStateWithOptions( + root, + hookCommand, + userConfigPath, + DefaultSettingsOptions(), + ) +} + +// VerifyCodexTrustStateWithOptions verifies hashes including the selected +// provider hook deadline. +func VerifyCodexTrustStateWithOptions( + root string, + hookCommand string, + userConfigPath string, + options SettingsOptions, +) error { + entries, err := expectedCodexTrustEntries(root, hookCommand, options) if err != nil { return err } @@ -152,8 +184,9 @@ func removeCodexTrustEntriesForConfig(content, configPath string) string { func expectedCodexTrustEntries( root, hookCommand string, + options SettingsOptions, ) ([]codexHookTrustEntry, error) { - settings, err := buildAllSettings(hookCommand) + settings, err := buildAllSettings(hookCommand, options) if err != nil { return nil, err } diff --git a/go/internal/agenthooks/provider_capabilities.go b/go/internal/agenthooks/provider_capabilities.go index dfb64f20..f8f4c976 100644 --- a/go/internal/agenthooks/provider_capabilities.go +++ b/go/internal/agenthooks/provider_capabilities.go @@ -43,6 +43,7 @@ type CapabilityReport struct { RepositoryRootFlag string `json:"repository_root_flag"` StateRootFlag string `json:"state_root_flag"` MCPCommandFlag string `json:"mcp_command_flag"` + HookTimeoutFlag string `json:"hook_timeout_flag"` RuntimePolicyCommand string `json:"runtime_policy_command"` HookContracts []hooks.HookContractCapability `json:"hook_contracts"` @@ -64,6 +65,7 @@ func Capabilities(runtimeVersion string) CapabilityReport { RepositoryRootFlag: "--repo-root", StateRootFlag: "--state-root", MCPCommandFlag: "--mcp-command", + HookTimeoutFlag: "--hook-timeout-seconds", RuntimePolicyCommand: "runtime-policy", SupportsPrivateOverlay: true, } diff --git a/go/internal/agenthooks/settings.go b/go/internal/agenthooks/settings.go index aa2d7df8..0e2221cb 100644 --- a/go/internal/agenthooks/settings.go +++ b/go/internal/agenthooks/settings.go @@ -15,6 +15,7 @@ import ( "path/filepath" "regexp" "slices" + "strconv" "strings" "time" @@ -27,13 +28,17 @@ import ( ) const ( - codexConfigGrowth = 2 - kimiBlockExitCode = 2 - mcpServerName = "coding-ethos" - minimumHookArgs = 2 - probeTimeout = 30 * time.Second - settingsDirMode = 0o755 - settingsFileMode = 0o600 + // DefaultHookTimeoutSeconds is the provider-native hook deadline used when + // a caller does not select a stricter integration budget. + DefaultHookTimeoutSeconds = 30 + hookProbeDeadlineMargin = 2 * time.Second + maximumHookTimeoutSeconds = 3600 + codexConfigGrowth = 2 + kimiBlockExitCode = 2 + mcpServerName = "coding-ethos" + minimumHookArgs = 2 + settingsDirMode = 0o755 + settingsFileMode = 0o600 eventPreToolUse = "PreToolUse" eventPostToolUse = "PostToolUse" @@ -73,9 +78,31 @@ var ( errCodexTrustMismatch = apperror.StaticError( "Codex user config does not trust generated project hooks", ) + errHookTimeoutInvalid = apperror.StaticError( + "hook timeout must be between 1 and 3600 seconds", + ) externalEnvironmentName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) ) +// SettingsOptions controls provider-native hook settings. +type SettingsOptions struct { + HookTimeoutSeconds int +} + +// DefaultSettingsOptions returns the bounded provider defaults. +func DefaultSettingsOptions() SettingsOptions { + return SettingsOptions{HookTimeoutSeconds: DefaultHookTimeoutSeconds} +} + +func (options SettingsOptions) validate() error { + if options.HookTimeoutSeconds < 1 || + options.HookTimeoutSeconds > maximumHookTimeoutSeconds { + return errHookTimeoutInvalid + } + + return nil +} + type commandHook struct { Name string `json:"name,omitempty"` Type string `json:"type"` @@ -315,7 +342,21 @@ func DefaultSettingsPaths(root string) SettingsPaths { } func WriteSettings(writer io.Writer, hookCommand string) error { - settings, err := buildAllSettings(hookCommand) + return WriteSettingsWithOptions( + writer, + hookCommand, + DefaultSettingsOptions(), + ) +} + +// WriteSettingsWithOptions renders all provider settings with one validated +// hook deadline. +func WriteSettingsWithOptions( + writer io.Writer, + hookCommand string, + options SettingsOptions, +) error { + settings, err := buildAllSettings(hookCommand, options) if err != nil { return err } @@ -381,7 +422,27 @@ func SyncSettingsForRootsWithMCPCommand( hookCommand string, mcpCommand string, ) error { - settings, err := buildAllSettings(hookCommand) + return SyncSettingsForRootsWithMCPCommandAndOptions( + settingsRoot, + repoRoot, + stateRoot, + hookCommand, + mcpCommand, + DefaultSettingsOptions(), + ) +} + +// SyncSettingsForRootsWithMCPCommandAndOptions writes provider settings with +// separate settings, source, and state roots plus a validated hook deadline. +func SyncSettingsForRootsWithMCPCommandAndOptions( + settingsRoot string, + repoRoot string, + stateRoot string, + hookCommand string, + mcpCommand string, + options SettingsOptions, +) error { + settings, err := buildAllSettings(hookCommand, options) if err != nil { return err } @@ -717,7 +778,9 @@ func renderManagedCodexHooksBlock(settings claudeSettings) string { builder.WriteString(tomlString(matcher.Hooks[0].StatusMessage)) } - builder.WriteString(", timeout = 30 }]") + builder.WriteString(", timeout = ") + builder.WriteString(strconv.Itoa(matcher.Hooks[0].Timeout)) + builder.WriteString(" }]") builder.WriteString(" },\n") } @@ -914,7 +977,27 @@ func DoctorSettingsForRootsWithMCPCommand( hookCommand string, mcpCommand string, ) error { - expected, err := buildAllSettings(hookCommand) + return DoctorSettingsForRootsWithMCPCommandAndOptions( + settingsRoot, + repoRoot, + stateRoot, + hookCommand, + mcpCommand, + DefaultSettingsOptions(), + ) +} + +// DoctorSettingsForRootsWithMCPCommandAndOptions validates provider settings +// against the selected hook deadline. +func DoctorSettingsForRootsWithMCPCommandAndOptions( + settingsRoot string, + repoRoot string, + stateRoot string, + hookCommand string, + mcpCommand string, + options SettingsOptions, +) error { + expected, err := buildAllSettings(hookCommand, options) if err != nil { return err } @@ -1159,12 +1242,33 @@ func VerifySettingsForRootsWithMCPCommand( hookCommand string, mcpCommand string, ) (VerifyReport, error) { - err := DoctorSettingsForRootsWithMCPCommand( + return VerifySettingsForRootsWithMCPCommandAndOptions( + settingsRoot, + repoRoot, + stateRoot, + hookCommand, + mcpCommand, + DefaultSettingsOptions(), + ) +} + +// VerifySettingsForRootsWithMCPCommandAndOptions validates and probes provider +// settings using the selected hook deadline. +func VerifySettingsForRootsWithMCPCommandAndOptions( + settingsRoot string, + repoRoot string, + stateRoot string, + hookCommand string, + mcpCommand string, + options SettingsOptions, +) (VerifyReport, error) { + err := DoctorSettingsForRootsWithMCPCommandAndOptions( settingsRoot, repoRoot, stateRoot, hookCommand, mcpCommand, + options, ) if err != nil { return VerifyReport{ @@ -1190,7 +1294,9 @@ func VerifySettingsForRootsWithMCPCommand( } for _, probe := range hookProbes() { - result, err := runHookProbe(repoRoot, hookCommand, probe) + probeTimeout := time.Duration(options.HookTimeoutSeconds)*time.Second + + hookProbeDeadlineMargin + result, err := runHookProbe(repoRoot, hookCommand, probe, probeTimeout) check := VerifyCheck{ Provider: probe.provider, @@ -1395,20 +1501,40 @@ func parseSkillFrontmatter(content string) (skillFrontmatter, error) { return frontmatter, nil } -func buildAllSettings(hookCommand string) (allSettings, error) { +func buildAllSettings( + hookCommand string, + options SettingsOptions, +) (allSettings, error) { if strings.TrimSpace(hookCommand) == "" { return allSettings{}, errHookCommandRequired } - _, err := hookProbeArgs("", hookCommand) + err := options.validate() + if err != nil { + return allSettings{}, err + } + + _, err = hookProbeArgs("", hookCommand) if err != nil { return allSettings{}, err } return allSettings{ - Claude: buildClaudeSettings(RuntimeHookSpecs(), hookCommand), - Codex: buildCodexSettings(RuntimeHookSpecs(), hookCommand), - Gemini: buildGeminiSettings(RuntimeHookSpecs(), hookCommand), + Claude: buildClaudeSettings( + RuntimeHookSpecs(), + hookCommand, + options.HookTimeoutSeconds, + ), + Codex: buildCodexSettings( + RuntimeHookSpecs(), + hookCommand, + options.HookTimeoutSeconds, + ), + Gemini: buildGeminiSettings( + RuntimeHookSpecs(), + hookCommand, + options.HookTimeoutSeconds, + ), Kimi: buildKimiSettings(RuntimeHookSpecs(), hookCommand), Capabilities: ProviderCapabilities(), }, nil @@ -1552,12 +1678,16 @@ func kimiConfigContainsExpectedHooks( return true } -func buildClaudeSettings(specs []HookSpec, hookCommand string) claudeSettings { +func buildClaudeSettings( + specs []HookSpec, + hookCommand string, + timeoutSeconds int, +) claudeSettings { hooks := make(map[string][]matcherHook) for _, spec := range specs { hooks[spec.Event] = append( hooks[spec.Event], - commandMatcher(spec.Tool, hookCommand), + commandMatcher(spec.Tool, hookCommand, timeoutSeconds), ) } @@ -1565,25 +1695,36 @@ func buildClaudeSettings(specs []HookSpec, hookCommand string) claudeSettings { hooks, toolaliases.ProviderClaude, hookCommand, + timeoutSeconds, commandMatcher, ) return claudeSettings{Hooks: hooks} } -func buildCodexSettings(specs []HookSpec, hookCommand string) claudeSettings { +func buildCodexSettings( + specs []HookSpec, + hookCommand string, + timeoutSeconds int, +) claudeSettings { hooks := make(map[string][]matcherHook) for _, spec := range specs { for _, matcher := range codexHookMatchers(spec) { hooks[spec.Event] = append( hooks[spec.Event], - codexMatcher(matcher, hookCommand), + codexMatcher(matcher, hookCommand, timeoutSeconds), ) } } - addNoopProviderMatchers(hooks, toolaliases.ProviderCodex, hookCommand, codexMatcher) + addNoopProviderMatchers( + hooks, + toolaliases.ProviderCodex, + hookCommand, + timeoutSeconds, + codexMatcher, + ) return claudeSettings{Hooks: hooks} } @@ -1612,7 +1753,11 @@ func codexSupportsTool(tool string) bool { return tool == toolaliases.CanonicalShell || toolaliases.IsWriteLike(tool) } -func buildGeminiSettings(specs []HookSpec, hookCommand string) claudeSettings { +func buildGeminiSettings( + specs []HookSpec, + hookCommand string, + timeoutSeconds int, +) claudeSettings { hooks := make(map[string][]matcherHook) for _, spec := range specs { @@ -1623,7 +1768,7 @@ func buildGeminiSettings(specs []HookSpec, hookCommand string) claudeSettings { hooks[event] = append( hooks[event], - geminiMatcher(matcher, hookCommand), + geminiMatcher(matcher, hookCommand, timeoutSeconds), ) } @@ -1631,6 +1776,7 @@ func buildGeminiSettings(specs []HookSpec, hookCommand string) claudeSettings { hooks, toolaliases.ProviderGemini, hookCommand, + timeoutSeconds, geminiMatcher, ) @@ -1644,18 +1790,19 @@ func addNoopProviderMatchers( hooks map[string][]matcherHook, provider string, hookCommand string, - build func(string, string) matcherHook, + timeoutSeconds int, + build func(string, string, int) matcherHook, ) { aliases := toolaliases.ProviderAliases(provider, toolaliases.CanonicalNoop) for _, alias := range aliases { preEvent, postEvent := providerToolEvents(provider) hooks[preEvent] = append( hooks[preEvent], - build(providerMatcher(alias), hookCommand), + build(providerMatcher(alias), hookCommand, timeoutSeconds), ) hooks[postEvent] = append( hooks[postEvent], - build(providerMatcher(alias), hookCommand), + build(providerMatcher(alias), hookCommand, timeoutSeconds), ) } } @@ -1713,34 +1860,37 @@ func nativePayloadContainsExpectedHooks( return claudePayloadContainsExpectedHooks(payload, expected) } -func commandMatcher(matcher, hookCommand string) matcherHook { +func commandMatcher(matcher, hookCommand string, timeoutSeconds int) matcherHook { return matcherHook{ Matcher: matcher, Hooks: []commandHook{{ Type: "command", Command: hookCommand, + Timeout: timeoutSeconds, }}, } } -func codexMatcher(matcher, hookCommand string) matcherHook { +func codexMatcher(matcher, hookCommand string, timeoutSeconds int) matcherHook { return matcherHook{ Matcher: matcher, Hooks: []commandHook{{ Type: "command", Command: hookCommand, StatusMessage: "coding-ethos policy", + Timeout: timeoutSeconds, }}, } } -func geminiMatcher(matcher, hookCommand string) matcherHook { +func geminiMatcher(matcher, hookCommand string, timeoutSeconds int) matcherHook { return matcherHook{ Matcher: matcher, Hooks: []commandHook{{ Name: "coding-ethos", Type: "command", Command: hookCommand, + Timeout: timeoutSeconds, }}, } } diff --git a/go/internal/agenthooks/settings_probe.go b/go/internal/agenthooks/settings_probe.go index d9604307..c2cae800 100644 --- a/go/internal/agenthooks/settings_probe.go +++ b/go/internal/agenthooks/settings_probe.go @@ -13,6 +13,7 @@ import ( "os/exec" "path/filepath" "strings" + "time" "blackcat.ca/coding-ethos/go/internal/apperror" "blackcat.ca/coding-ethos/go/internal/execguard" @@ -240,6 +241,7 @@ func runHookProbe( root string, hookCommand string, probe hookProbe, + probeTimeout time.Duration, ) (hookProbeResult, error) { var ( stdout bytes.Buffer diff --git a/go/internal/agenthooks/settings_test.go b/go/internal/agenthooks/settings_test.go index 601e7e98..c7115c92 100644 --- a/go/internal/agenthooks/settings_test.go +++ b/go/internal/agenthooks/settings_test.go @@ -74,6 +74,75 @@ func TestWriteSettingsIncludesAllProviders(t *testing.T) { } } +func TestConfiguredHookTimeoutReachesProviderSettings(t *testing.T) { + t.Parallel() + + const timeoutSeconds = 45 + + var buffer bytes.Buffer + + err := agenthooks.WriteSettingsWithOptions( + &buffer, + testHookCommand, + agenthooks.SettingsOptions{HookTimeoutSeconds: timeoutSeconds}, + ) + if err != nil { + t.Fatalf("write settings with timeout: %v", err) + } + + output := buffer.String() + for _, provider := range []string{"claude", "codex", "gemini"} { + settings := providerSettingsSection( + t, + output, + provider, + map[string]string{ + "claude": "codex", + "codex": "gemini", + "gemini": "kimi", + }[provider], + ) + if !strings.Contains(settings, `"timeout": 45`) { + t.Fatalf("%s settings omit configured timeout:\n%s", provider, settings) + } + } + + root := t.TempDir() + err = agenthooks.SyncSettingsForRootsWithMCPCommandAndOptions( + root, + root, + root, + testHookCommand, + "", + agenthooks.SettingsOptions{HookTimeoutSeconds: timeoutSeconds}, + ) + if err != nil { + t.Fatalf("sync settings with timeout: %v", err) + } + codex, err := os.ReadFile(agenthooks.DefaultSettingsPaths(root).CodexConfig) + if err != nil { + t.Fatalf("read Codex settings: %v", err) + } + if !bytes.Contains(codex, []byte("timeout = 45")) { + t.Fatalf("Codex TOML omits configured timeout:\n%s", codex) + } +} + +func TestConfiguredHookTimeoutRejectsUnboundedValues(t *testing.T) { + t.Parallel() + + for _, timeoutSeconds := range []int{0, 3601} { + err := agenthooks.WriteSettingsWithOptions( + &bytes.Buffer{}, + testHookCommand, + agenthooks.SettingsOptions{HookTimeoutSeconds: timeoutSeconds}, + ) + if err == nil { + t.Fatalf("timeout %d unexpectedly accepted", timeoutSeconds) + } + } +} + func TestProviderCapabilitiesDocumentProviderLimits(t *testing.T) { t.Parallel() diff --git a/go/internal/agenthooks/state_artifacts.go b/go/internal/agenthooks/state_artifacts.go index 362f2beb..0ed9fa83 100644 --- a/go/internal/agenthooks/state_artifacts.go +++ b/go/internal/agenthooks/state_artifacts.go @@ -38,7 +38,27 @@ func StateArtifactsForRootsWithMCPCommand( hookCommand string, mcpCommand string, ) ([]syncstate.Artifact, error) { - settings, err := buildAllSettings(hookCommand) + return StateArtifactsForRootsWithMCPCommandAndOptions( + settingsRoot, + repoRoot, + stateRoot, + hookCommand, + mcpCommand, + DefaultSettingsOptions(), + ) +} + +// StateArtifactsForRootsWithMCPCommandAndOptions renders the exact provider +// settings selected by the hook deadline. +func StateArtifactsForRootsWithMCPCommandAndOptions( + settingsRoot string, + repoRoot string, + stateRoot string, + hookCommand string, + mcpCommand string, + options SettingsOptions, +) ([]syncstate.Artifact, error) { + settings, err := buildAllSettings(hookCommand, options) if err != nil { return nil, err } diff --git a/go/internal/agenthookscli/main.go b/go/internal/agenthookscli/main.go index 6732f8de..cb2ea0fd 100644 --- a/go/internal/agenthookscli/main.go +++ b/go/internal/agenthookscli/main.go @@ -32,6 +32,42 @@ var ( errUnknownCommand = apperror.StaticError("unknown agent-hooks command") ) +type settingsCommandFlags struct { + root *string + repoRoot *string + stateRoot *string + hookCommand *string + hookTimeoutSeconds *int + mcpCommand *string +} + +func bindSettingsCommandFlags(flags *flag.FlagSet) settingsCommandFlags { + return settingsCommandFlags{ + root: flags.String("root", ".", "Repository root for agent settings"), + repoRoot: flags.String( + "repo-root", + "", + "Actual repository root when --root is a private settings overlay", + ), + stateRoot: flags.String( + "state-root", + "", + "Private Coding Ethos state root; defaults to --root", + ), + hookCommand: flags.String("hook-command", "", "Agent hook command"), + hookTimeoutSeconds: flags.Int( + "hook-timeout-seconds", + agenthooks.DefaultHookTimeoutSeconds, + "Provider hook timeout in seconds", + ), + mcpCommand: flags.String( + "mcp-command", + "", + "Coding Ethos MCP command; derived from --hook-command when omitted", + ), + } +} + func runCLI(args []string) int { if len(args) == 0 { usage() @@ -104,6 +140,11 @@ func capabilities(args []string) error { func printSettings(args []string) error { flags := flag.NewFlagSet("print", flag.ContinueOnError) hookCommand := flags.String("hook-command", "", "Agent hook command") + hookTimeoutSeconds := flags.Int( + "hook-timeout-seconds", + agenthooks.DefaultHookTimeoutSeconds, + "Provider hook timeout in seconds", + ) err := flags.Parse(args) if err != nil { @@ -112,7 +153,11 @@ func printSettings(args []string) error { var buffer bytes.Buffer - err = agenthooks.WriteSettings(&buffer, defaultHookCommand(*hookCommand)) + err = agenthooks.WriteSettingsWithOptions( + &buffer, + defaultHookCommand(*hookCommand), + settingsOptions(*hookTimeoutSeconds), + ) if err != nil { return fmt.Errorf("write agent hook settings: %w", err) } @@ -131,24 +176,8 @@ func printSettings(args []string) error { func syncSettings(args []string) error { flags := flag.NewFlagSet("sync", flag.ContinueOnError) - root := flags.String("root", ".", "Repository root for agent settings") - repoRoot := flags.String( - "repo-root", - "", - "Actual repository root when --root is a private settings overlay", - ) - stateRoot := flags.String( - "state-root", - "", - "Private Coding Ethos state root; defaults to --root", - ) + settings := bindSettingsCommandFlags(flags) ethosRoot := flags.String("ethos-root", ".", "Path to coding-ethos checkout") - hookCommand := flags.String("hook-command", "", "Agent hook command") - mcpCommand := flags.String( - "mcp-command", - "", - "Coding Ethos MCP command; derived from --hook-command when omitted", - ) dryRun := flags.Bool("dry-run", false, "Report planned writes without mutating files") format := flags.String( "format", @@ -161,16 +190,18 @@ func syncSettings(args []string) error { return fmt.Errorf("parse sync flags: %w", err) } - resolvedHookCommand := defaultHookCommand(*hookCommand) - resolvedRepoRoot := defaultRepoRoot(*root, *repoRoot) - resolvedStateRoot := defaultStateRoot(*root, *stateRoot) + resolvedHookCommand := defaultHookCommand(*settings.hookCommand) + resolvedRepoRoot := defaultRepoRoot(*settings.root, *settings.repoRoot) + resolvedStateRoot := defaultStateRoot(*settings.root, *settings.stateRoot) + options := settingsOptions(*settings.hookTimeoutSeconds) - artifacts, err := agenthooks.StateArtifactsForRootsWithMCPCommand( - *root, + artifacts, err := agenthooks.StateArtifactsForRootsWithMCPCommandAndOptions( + *settings.root, resolvedRepoRoot, resolvedStateRoot, resolvedHookCommand, - *mcpCommand, + *settings.mcpCommand, + options, ) if err != nil { return fmt.Errorf("plan agent hook settings: %w", err) @@ -178,36 +209,38 @@ func syncSettings(args []string) error { if *dryRun { return writeSyncStateReport( - syncstate.Plan(*root, "agent-hooks sync", artifacts), + syncstate.Plan(*settings.root, "agent-hooks sync", artifacts), *format, ) } err = applyAgentHookSettings( - *root, + *settings.root, resolvedRepoRoot, resolvedStateRoot, resolvedHookCommand, - *mcpCommand, + *settings.mcpCommand, + options, ) if err != nil { return err } - if privateSettingsOverlay(*root, resolvedRepoRoot) { - artifacts, err = agenthooks.StateArtifactsForRootsWithMCPCommand( - *root, + if privateSettingsOverlay(*settings.root, resolvedRepoRoot) { + artifacts, err = agenthooks.StateArtifactsForRootsWithMCPCommandAndOptions( + *settings.root, resolvedRepoRoot, resolvedStateRoot, resolvedHookCommand, - *mcpCommand, + *settings.mcpCommand, + options, ) if err != nil { return fmt.Errorf("refresh private overlay state artifacts: %w", err) } } - return upsertAgentHookSyncState(*root, *ethosRoot, artifacts) + return upsertAgentHookSyncState(*settings.root, *ethosRoot, artifacts) } func applyAgentHookSettings( @@ -216,22 +249,25 @@ func applyAgentHookSettings( stateRoot string, hookCommand string, mcpCommand string, + options agenthooks.SettingsOptions, ) error { - err := agenthooks.SyncSettingsForRootsWithMCPCommand( + err := agenthooks.SyncSettingsForRootsWithMCPCommandAndOptions( root, repoRoot, stateRoot, hookCommand, mcpCommand, + options, ) if err != nil { return fmt.Errorf("sync agent hook settings: %w", err) } - err = agenthooks.SyncCodexTrustState( + err = agenthooks.SyncCodexTrustStateWithOptions( root, hookCommand, codexTrustConfigForRoots(root, repoRoot), + options, ) if err != nil { return fmt.Errorf("sync Codex hook trust: %w", err) @@ -272,46 +308,34 @@ func writeSyncStateReport(report syncstate.Report, format string) error { func doctorSettings(args []string) error { flags := flag.NewFlagSet("doctor", flag.ContinueOnError) - root := flags.String("root", ".", "Repository root for agent settings") - repoRoot := flags.String( - "repo-root", - "", - "Actual repository root when --root is a private settings overlay", - ) - stateRoot := flags.String( - "state-root", - "", - "Private Coding Ethos state root; defaults to --root", - ) - hookCommand := flags.String("hook-command", "", "Agent hook command") - mcpCommand := flags.String( - "mcp-command", - "", - "Coding Ethos MCP command; derived from --hook-command when omitted", - ) + settings := bindSettingsCommandFlags(flags) err := flags.Parse(args) if err != nil { return fmt.Errorf("parse doctor flags: %w", err) } - err = agenthooks.DoctorSettingsForRootsWithMCPCommand( - *root, - defaultRepoRoot(*root, *repoRoot), - defaultStateRoot(*root, *stateRoot), - defaultHookCommand(*hookCommand), - *mcpCommand, + options := settingsOptions(*settings.hookTimeoutSeconds) + + err = agenthooks.DoctorSettingsForRootsWithMCPCommandAndOptions( + *settings.root, + defaultRepoRoot(*settings.root, *settings.repoRoot), + defaultStateRoot(*settings.root, *settings.stateRoot), + defaultHookCommand(*settings.hookCommand), + *settings.mcpCommand, + options, ) if err != nil { return fmt.Errorf("doctor agent hook settings: %w", err) } - resolvedRepoRoot := defaultRepoRoot(*root, *repoRoot) + resolvedRepoRoot := defaultRepoRoot(*settings.root, *settings.repoRoot) - err = agenthooks.VerifyCodexTrustState( - *root, - defaultHookCommand(*hookCommand), - codexTrustConfigForRoots(*root, resolvedRepoRoot), + err = agenthooks.VerifyCodexTrustStateWithOptions( + *settings.root, + defaultHookCommand(*settings.hookCommand), + codexTrustConfigForRoots(*settings.root, resolvedRepoRoot), + options, ) if err != nil { return fmt.Errorf("doctor Codex hook trust: %w", err) @@ -327,35 +351,22 @@ func doctorSettings(args []string) error { func verifySettings(args []string) error { flags := flag.NewFlagSet("verify", flag.ContinueOnError) - root := flags.String("root", ".", "Repository root for agent settings") - repoRoot := flags.String( - "repo-root", - "", - "Actual repository root when --root is a private settings overlay", - ) - stateRoot := flags.String( - "state-root", - "", - "Private Coding Ethos state root; defaults to --root", - ) - hookCommand := flags.String("hook-command", "", "Agent hook command") - mcpCommand := flags.String( - "mcp-command", - "", - "Coding Ethos MCP command; derived from --hook-command when omitted", - ) + settings := bindSettingsCommandFlags(flags) err := flags.Parse(args) if err != nil { return fmt.Errorf("parse verify flags: %w", err) } - report, err := agenthooks.VerifySettingsForRootsWithMCPCommand( - *root, - defaultRepoRoot(*root, *repoRoot), - defaultStateRoot(*root, *stateRoot), - defaultHookCommand(*hookCommand), - *mcpCommand, + options := settingsOptions(*settings.hookTimeoutSeconds) + + report, err := agenthooks.VerifySettingsForRootsWithMCPCommandAndOptions( + *settings.root, + defaultRepoRoot(*settings.root, *settings.repoRoot), + defaultStateRoot(*settings.root, *settings.stateRoot), + defaultHookCommand(*settings.hookCommand), + *settings.mcpCommand, + options, ) if err != nil { encodeErr := writeJSONReport(os.Stdout, report) @@ -366,12 +377,13 @@ func verifySettings(args []string) error { return fmt.Errorf("verify agent hook settings: %w", err) } - resolvedRepoRoot := defaultRepoRoot(*root, *repoRoot) + resolvedRepoRoot := defaultRepoRoot(*settings.root, *settings.repoRoot) - err = agenthooks.VerifyCodexTrustState( - *root, - defaultHookCommand(*hookCommand), - codexTrustConfigForRoots(*root, resolvedRepoRoot), + err = agenthooks.VerifyCodexTrustStateWithOptions( + *settings.root, + defaultHookCommand(*settings.hookCommand), + codexTrustConfigForRoots(*settings.root, resolvedRepoRoot), + options, ) if err != nil { report.Status = "invalid" @@ -461,6 +473,10 @@ func defaultHookCommand(hookCommand string) string { return runner + " agent-hook" } +func settingsOptions(hookTimeoutSeconds int) agenthooks.SettingsOptions { + return agenthooks.SettingsOptions{HookTimeoutSeconds: hookTimeoutSeconds} +} + func defaultRepoRoot(settingsRoot, repoRoot string) string { if strings.TrimSpace(repoRoot) != "" { return repoRoot diff --git a/go/internal/agenthookscli/main_internal_test.go b/go/internal/agenthookscli/main_internal_test.go index d1c7f565..db83a0ac 100644 --- a/go/internal/agenthookscli/main_internal_test.go +++ b/go/internal/agenthookscli/main_internal_test.go @@ -96,6 +96,7 @@ func TestCapabilitiesReportsRuntimeContractAndKimi(t *testing.T) { `"selector": "neutral-v1"`, `"state_root_flag": "--state-root"`, `"mcp_command_flag": "--mcp-command"`, + `"hook_timeout_flag": "--hook-timeout-seconds"`, `"runtime_policy_command": "runtime-policy"`, `"provider": "kimi"`, } { From ac198f54a8555c7195c644eb29ffeb9a890ba854 Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Mon, 27 Jul 2026 11:15:05 -0600 Subject: [PATCH 07/13] fix(hooks): exclude managed caches from vulture --- go/internal/hookrunnercli/toolchain_groups.go | 4 ++++ .../toolchain_groups_internal_test.go | 16 ++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/go/internal/hookrunnercli/toolchain_groups.go b/go/internal/hookrunnercli/toolchain_groups.go index 490e0daf..430c7763 100644 --- a/go/internal/hookrunnercli/toolchain_groups.go +++ b/go/internal/hookrunnercli/toolchain_groups.go @@ -896,6 +896,10 @@ func vultureWhitelistArgs() []string { func vultureExcludePatterns() []string { return []string{ + ".coding-ethos", + ".coding-ethos/*", + "*/.coding-ethos", + "*/.coding-ethos/*", ".venv", "*/.venv", "*/.venv/*", diff --git a/go/internal/hookrunnercli/toolchain_groups_internal_test.go b/go/internal/hookrunnercli/toolchain_groups_internal_test.go index cb95319c..7cd364bb 100644 --- a/go/internal/hookrunnercli/toolchain_groups_internal_test.go +++ b/go/internal/hookrunnercli/toolchain_groups_internal_test.go @@ -134,6 +134,22 @@ func TestParsePythonQualityFindings(t *testing.T) { assertVultureFinding(t) } +func TestVultureExcludesManagedRuntimeCache(t *testing.T) { + t.Parallel() + + patterns := vultureExcludePatterns() + for _, expected := range []string{ + ".coding-ethos", + ".coding-ethos/*", + "*/.coding-ethos", + "*/.coding-ethos/*", + } { + if !slices.Contains(patterns, expected) { + t.Fatalf("vultureExcludePatterns() omitted %q", expected) + } + } +} + func TestPythonQualityCommandsRunExternalToolsAndReportFindings(t *testing.T) { nativeSandboxAvailable := nativeSandboxRuntimeAvailable() From 9317c1e7ef3b9b7fc9639ee7a41232533173a0d6 Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Mon, 27 Jul 2026 13:45:28 -0600 Subject: [PATCH 08/13] fix(sandbox): degrade unsupported cgroup start attach Probe delegated cgroup FD start support before managed tool launch and record advisory evidence when the host cannot attach at start. Add Linux sandbox regression coverage for namespace-isolated skips and unavailable start attachment cleanup. --- go/internal/sandbox/cgroup_linux.go | 53 ++++++++++++++++- go/internal/sandbox/cgroup_linux_test.go | 76 ++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 1 deletion(-) diff --git a/go/internal/sandbox/cgroup_linux.go b/go/internal/sandbox/cgroup_linux.go index a9e5b97c..ac8cc09c 100644 --- a/go/internal/sandbox/cgroup_linux.go +++ b/go/internal/sandbox/cgroup_linux.go @@ -4,12 +4,14 @@ package sandbox import ( + "context" "fmt" "os" "os/exec" "path/filepath" "strconv" "syscall" + "time" "golang.org/x/sys/unix" ) @@ -18,6 +20,8 @@ const ( cgroupCPUPeriodMicros = 100_000 cgroupCPUQuotaFactor = 1_000 cgroupFileMode = 0o600 + cgroupStartProbe = "/bin/true" + cgroupStartProbeDelay = 5 * time.Second bytesPerMegabyte = 1024 * 1024 ) @@ -27,11 +31,25 @@ type Cgroup struct { } func PrepareCgroupLimits(evidence Evidence) (*Cgroup, Evidence, error) { + return prepareCgroupLimits(evidence, cgroupRootPath, runCgroupStartCheck) +} + +func prepareCgroupLimits( + evidence Evidence, + rootPath func() string, + startCheck func(context.Context, *Cgroup) error, +) (*Cgroup, Evidence, error) { if !evidence.CgroupRequested { return nil, evidence, nil } - root := cgroupRootPath() + if !cgroupStartAttachmentAllowed(evidence) { + evidence.Reason = "cgroup limits skipped for namespace-isolated tool" + + return nil, evidence, nil + } + + root := rootPath() if root == "" { evidence.Reason = "delegated cgroup v2 filesystem is unavailable" @@ -64,12 +82,45 @@ func PrepareCgroupLimits(evidence Evidence) (*Cgroup, Evidence, error) { } cgroup.fd = descriptor + + ctx, cancel := context.WithTimeout(context.Background(), cgroupStartProbeDelay) + defer cancel() + + err = startCheck(ctx, cgroup) + if err != nil { + _ = cgroup.Close() + evidence.CgroupEnabled = false + evidence.CgroupPath = "" + evidence.Reason = fmt.Sprintf( + "cgroup limits skipped because delegated start attachment is unavailable: %v", + err, + ) + + return nil, evidence, nil + } + evidence.CgroupEnabled = true evidence.CgroupPath = path return cgroup, evidence, nil } +func cgroupStartAttachmentAllowed(evidence Evidence) bool { + return !evidence.Enabled || evidence.RequiresProcesses || !evidence.NamespaceEnforced +} + +func runCgroupStartCheck(ctx context.Context, cgroup *Cgroup) error { + command := exec.CommandContext(ctx, cgroupStartProbe) + command.SysProcAttr = cgroup.SysProcAttr() + + err := command.Run() + if err != nil { + return fmt.Errorf("run cgroup start probe: %w", err) + } + + return nil +} + func (cgroup *Cgroup) ConfigureCommand(command *exec.Cmd) { if command.SysProcAttr == nil { command.SysProcAttr = &syscall.SysProcAttr{} diff --git a/go/internal/sandbox/cgroup_linux_test.go b/go/internal/sandbox/cgroup_linux_test.go index eeba14b6..3c6f52aa 100644 --- a/go/internal/sandbox/cgroup_linux_test.go +++ b/go/internal/sandbox/cgroup_linux_test.go @@ -6,6 +6,11 @@ package sandbox import ( + "context" + "errors" + "os" + "path/filepath" + "strings" "testing" "golang.org/x/sys/unix" @@ -34,3 +39,74 @@ func TestCgroupSysProcAttrUsesCgroupFD(t *testing.T) { t.Fatalf("CgroupFD = %d, want %d", attributes.CgroupFD, descriptor) } } + +func TestCgroupStartAttachmentSkipsNamespaceIsolatedTools(t *testing.T) { + t.Parallel() + + if cgroupStartAttachmentAllowed(Evidence{ + Enabled: true, + NamespaceEnforced: true, + }) { + t.Fatal("namespace-isolated tool requested start-time cgroup attach") + } + + if !cgroupStartAttachmentAllowed(Evidence{ + Enabled: true, + NamespaceEnforced: true, + RequiresProcesses: true, + }) { + t.Fatal("process-enabled tool should keep start-time cgroup attach") + } +} + +func TestPrepareCgroupLimitsSkipsUnavailableStartAttachment(t *testing.T) { + root := t.TempDir() + err := os.WriteFile( + filepath.Join(root, "cgroup.controllers"), + []byte("cpu memory"), + cgroupFileMode, + ) + if err != nil { + t.Fatalf("write cgroup.controllers fixture: %v", err) + } + + rootPath := func() string { + return root + } + startCheck := func(_ context.Context, _ *Cgroup) error { + return errors.New("permission denied") + } + + cgroup, evidence, err := prepareCgroupLimits(Evidence{ + Enabled: true, + CgroupRequested: true, + Tool: "go-test", + RequiresProcesses: true, + }, rootPath, startCheck) + if err != nil { + t.Fatalf("PrepareCgroupLimits returned error: %v", err) + } + + if cgroup != nil { + t.Fatal("PrepareCgroupLimits returned cgroup for unavailable start attachment") + } + if evidence.CgroupEnabled { + t.Fatalf("CgroupEnabled = true: %#v", evidence) + } + if evidence.CgroupPath != "" { + t.Fatalf("CgroupPath = %q, want empty", evidence.CgroupPath) + } + if !strings.Contains(evidence.Reason, "delegated start attachment is unavailable") { + t.Fatalf("Reason = %q", evidence.Reason) + } + + entries, err := os.ReadDir(root) + if err != nil { + t.Fatalf("read cgroup root fixture: %v", err) + } + for _, entry := range entries { + if entry.IsDir() { + t.Fatalf("temporary cgroup directory was not removed: %s", entry.Name()) + } + } +} From 034d37d40e7d816ff59350f332200a50662f6ba4 Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Mon, 27 Jul 2026 14:00:16 -0600 Subject: [PATCH 09/13] fix(deps): clear OSV findings Upgrade vulnerable Go modules reported by the PR OSV scan: github.com/google/cel-go, github.com/klauspost/compress, and golang.org/x/text. Keep transitive x/* module versions consistent with go get and go mod tidy. --- go/go.mod | 16 ++++++++-------- go/go.sum | 32 ++++++++++++++++---------------- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/go/go.mod b/go/go.mod index 4c4fbad7..e6208ea7 100644 --- a/go/go.mod +++ b/go/go.mod @@ -21,7 +21,7 @@ require ( github.com/tree-sitter/tree-sitter-typescript v0.23.2 github.com/yuin/goldmark v1.8.2 go.uber.org/zap v1.28.0 - golang.org/x/sys v0.44.0 + golang.org/x/sys v0.46.0 mvdan.cc/sh/v3 v3.13.1 ) @@ -36,28 +36,28 @@ require ( github.com/goccy/go-json v0.10.5 // indirect github.com/google/flatbuffers v25.12.19+incompatible // indirect github.com/google/uuid v1.6.0 // indirect - github.com/klauspost/compress v1.18.3 // indirect + github.com/klauspost/compress v1.19.1 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/mattn/go-pointer v0.0.1 // indirect github.com/pierrec/lz4/v4 v4.1.25 // indirect github.com/zeebo/xxh3 v1.1.0 // indirect go.uber.org/multierr v1.10.0 // indirect - golang.org/x/mod v0.33.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 // indirect - golang.org/x/tools v0.42.0 // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57 // indirect + golang.org/x/tools v0.47.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect ) require ( github.com/bmatcuk/doublestar v1.3.4 - golang.org/x/text v0.33.0 // indirect + golang.org/x/text v0.40.0 // indirect ) require ( cel.dev/expr v0.25.1 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect - github.com/google/cel-go v0.28.1 + github.com/google/cel-go v0.30.0 golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda // indirect diff --git a/go/go.sum b/go/go.sum index 087ecc04..b4bac55e 100644 --- a/go/go.sum +++ b/go/go.sum @@ -34,8 +34,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/google/cel-go v0.28.1 h1:YWIwi77J4xIsYUwAF/iIuS6haffzIHS8yWI8glSbLWM= -github.com/google/cel-go v0.28.1/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= +github.com/google/cel-go v0.30.0 h1:ll54AkzKunWkBn9wSoiUXbFZXYZTkdJGNXTBXUoolGo= +github.com/google/cel-go v0.30.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= github.com/google/flatbuffers v25.12.19+incompatible h1:haMV2JRRJCe1998HeW/p0X9UaMTK6SDo0ffLn2+DbLs= github.com/google/flatbuffers v25.12.19+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -44,8 +44,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4= github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE= -github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= -github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= +github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -118,18 +118,18 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= -golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= -golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 h1:bTLqdHv7xrGlFbvf5/TXNxy/iUwwdkjhqQTJDjW7aj0= -golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= -golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= -golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57 h1:nwGZBCt+FnXUrGsj5vjzAsEmkcaFvd82BbOjECiFYZc= +golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57/go.mod h1:3AWMyWHS+caVoiEXpiq6+tzKA40J4vQT3MYr80ZtQpc= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= From 4be0ac7e177f661d2f86ab8dd4291dcc35847826 Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Mon, 27 Jul 2026 14:26:19 -0600 Subject: [PATCH 10/13] fix(agent-hooks): address review feedback --- .coding-ethos/.gitignore | 16 +- README.md | 2 +- docs/HOOK_CONTRACT_V1.md | 2 +- go/cmd/coding-ethos-run/dispatch.go | 3 +- go/cmd/coding-ethos-run/main_test.go | 39 +++- go/cmd/coding-ethos-run/parent_workflow.go | 7 +- go/internal/agenthooks/kimi_settings.go | 174 ++++++++++++++++++ go/internal/agenthooks/settings.go | 155 +--------------- go/internal/agenthooks/settings_test.go | 25 ++- go/internal/agenthooks/state_artifacts.go | 42 +++-- go/internal/agenthookscli/main.go | 1 - .../agenthookscli/main_internal_test.go | 2 +- go/internal/hooks/contract_v1.go | 24 ++- go/internal/hooks/contract_v1_test.go | 10 + tests/test_lint_capture_lifecycle.py | 1 + 15 files changed, 310 insertions(+), 193 deletions(-) create mode 100644 go/internal/agenthooks/kimi_settings.go diff --git a/.coding-ethos/.gitignore b/.coding-ethos/.gitignore index 0c2276d8..e569b530 100644 --- a/.coding-ethos/.gitignore +++ b/.coding-ethos/.gitignore @@ -1,12 +1,12 @@ # coding-ethos generated runtime output .claude/settings.local.json -.coding-ethos/cache/ -.coding-ethos/code-intel.duckdb -.coding-ethos/code-intel.duckdb.wal -.coding-ethos/events/ -.coding-ethos/hook-runs/ -.coding-ethos/lint-runs/ -.coding-ethos/prune-runs/ -.coding-ethos/state/ +/cache/ +/code-intel.duckdb +/code-intel.duckdb.wal +/events/ +/hook-runs/ +/lint-runs/ +/prune-runs/ +/state/ .gemini/settings.json .mcp.json diff --git a/README.md b/README.md index e69ff772..7d323200 100644 --- a/README.md +++ b/README.md @@ -1578,7 +1578,7 @@ bin/coding-ethos-run agent-hooks sync bin/coding-ethos-run agent-hooks sync --root /path/to/repo --ethos-root . --dry-run --format toon bin/coding-ethos-run agent-hooks doctor bin/coding-ethos-run agent-hooks verify -bin/coding-ethos-run agent-hooks capabilities --json +bin/coding-ethos-run agent-hooks capabilities ``` Agent hook generation is all-or-nothing. `sync` writes every supported diff --git a/docs/HOOK_CONTRACT_V1.md b/docs/HOOK_CONTRACT_V1.md index 8a987a40..446ee4ad 100644 --- a/docs/HOOK_CONTRACT_V1.md +++ b/docs/HOOK_CONTRACT_V1.md @@ -110,7 +110,7 @@ Discover the runtime version, contract selector, input limit, supported events, effects, provider adapters, and private-overlay flags without loading DuckDB: ```bash -bin/coding-ethos-run agent-hooks capabilities --json +bin/coding-ethos-run agent-hooks capabilities ``` The response schema is `coding-ethos.agent-hooks/v1`. `runtime_version` comes diff --git a/go/cmd/coding-ethos-run/dispatch.go b/go/cmd/coding-ethos-run/dispatch.go index 1cc86088..a37394a9 100644 --- a/go/cmd/coding-ethos-run/dispatch.go +++ b/go/cmd/coding-ethos-run/dispatch.go @@ -987,9 +987,10 @@ func runAgentHooksCommand(paths runtimePaths, rest []string) { } settingsRoot := rootFlagValue(rest, paths.Root) + repoRoot := flagValue(rest, "--repo-root", paths.Root) _ = os.Setenv( "CODE_ETHOS_CONSUMER_ROOT", - flagValue(rest, "--repo-root", settingsRoot), + repoRoot, ) _ = os.Setenv( envStateRoot, diff --git a/go/cmd/coding-ethos-run/main_test.go b/go/cmd/coding-ethos-run/main_test.go index 8e44489e..deca3824 100644 --- a/go/cmd/coding-ethos-run/main_test.go +++ b/go/cmd/coding-ethos-run/main_test.go @@ -236,10 +236,9 @@ func TestAgentHooksArgsInjectCapabilityEthosRootWithoutHookCommand(t *testing.T) RunBinary: "/opt/coding-ethos/bin/coding-ethos-run", } - got := agentHooksArgs(paths, []string{"capabilities", "--json"}) + got := agentHooksArgs(paths, []string{"capabilities"}) want := []string{ "capabilities", - "--json", "--ethos-root", "/opt/coding-ethos", } @@ -337,7 +336,7 @@ func TestWithCommandRootsUsesCommandSpecificRepositoryFlags(t *testing.T) { func TestCapabilitiesDiscoveryDoesNotWriteRuntimeLog(t *testing.T) { t.Parallel() - if shouldLogRuntimeCommand([]string{"agent-hooks", "capabilities", "--json"}) { + if shouldLogRuntimeCommand([]string{"agent-hooks", "capabilities"}) { t.Fatal("capabilities discovery should be read-only") } if !shouldLogRuntimeCommand([]string{"agent-hooks", "doctor"}) { @@ -362,6 +361,40 @@ func TestAgentHooksStateDefaultsToPrivateSettingsRoot(t *testing.T) { } } +func TestRunAgentHooksCommandExportsRepositoryRootBeforeSettingsRoot(t *testing.T) { + testlock.ProcessState(t, "coding-ethos-run-env") + + restoreEnv := captureRuntimeEnvForTest( + "CODE_ETHOS_CONSUMER_ROOT", + envStateRoot, + "CODING_ETHOS_GIT_SHIM_DIR", + ) + t.Cleanup(restoreEnv) + + paths := runtimeTestPaths(t) + var calls []string + paths.Executor = stubRuntimeOps{calls: &calls} + + err := run(paths, []string{ + "agent-hooks", + "sync", + "--root", + "/private/settings", + "--repo-root", + "/repo", + }) + if err != nil { + t.Fatalf("run agent-hooks: %v", err) + } + + if got := os.Getenv("CODE_ETHOS_CONSUMER_ROOT"); got != "/repo" { + t.Fatalf("consumer root = %q, want /repo", got) + } + if got := os.Getenv(envStateRoot); got != "/private/settings" { + t.Fatalf("%s = %q, want /private/settings", envStateRoot, got) + } +} + func TestOutputArgsInsertRootAfterSubcommand(t *testing.T) { t.Parallel() diff --git a/go/cmd/coding-ethos-run/parent_workflow.go b/go/cmd/coding-ethos-run/parent_workflow.go index 4e8a6760..9cdbcdd6 100644 --- a/go/cmd/coding-ethos-run/parent_workflow.go +++ b/go/cmd/coding-ethos-run/parent_workflow.go @@ -45,6 +45,7 @@ var ( errParentGoToolsStale = errors.New("parent Go tools are stale") errParentPathIsDirectory = errors.New("path is a directory, want file") errParentPathIsNotDirectory = errors.New("path is not a directory, want directory") + errParentRootNotAbsolute = errors.New("parent workflow root must be absolute") ) type parentWorkflowOptions struct { @@ -180,7 +181,7 @@ func parseParentWorkflowFlags( return parentWorkflowOptions{}, err } - resolvedStateRoot, err := cleanOptionalRoot(*stateRoot) + resolvedStateRoot, err := cleanOptionalRoot("state-root", *stateRoot) if err != nil { return parentWorkflowOptions{}, err } @@ -210,14 +211,14 @@ func parseParentWorkflowFlags( }, nil } -func cleanOptionalRoot(root string) (string, error) { +func cleanOptionalRoot(label, root string) (string, error) { if strings.TrimSpace(root) == "" { return "", nil } cleaned := filepath.Clean(root) if !filepath.IsAbs(cleaned) { - return "", apperror.StaticError("state root must be absolute") + return "", fmt.Errorf("%w: %s=%s", errParentRootNotAbsolute, label, root) } return cleaned, nil diff --git a/go/internal/agenthooks/kimi_settings.go b/go/internal/agenthooks/kimi_settings.go new file mode 100644 index 00000000..aad88359 --- /dev/null +++ b/go/internal/agenthooks/kimi_settings.go @@ -0,0 +1,174 @@ +// SPDX-FileCopyrightText: 2026 Blackcat Informatics Inc. +// SPDX-License-Identifier: AGPL-3.0-only + +package agenthooks + +import ( + "slices" + "strconv" + "strings" + + "github.com/pelletier/go-toml/v2" + + "blackcat.ca/coding-ethos/go/internal/toolaliases" +) + +type kimiHook struct { + Event string `json:"event" toml:"event"` + Matcher string `json:"matcher,omitempty" toml:"matcher,omitempty"` + Command string `json:"command" toml:"command"` + TimeoutSeconds int `json:"timeout" toml:"timeout"` +} + +type kimiSettings struct { + Hooks []kimiHook `json:"hooks"` +} + +func buildKimiSettings( + specs []HookSpec, + hookCommand string, + timeoutSeconds int, +) kimiSettings { + command := strings.TrimSpace(hookCommand) + " --provider kimi" + settings := kimiSettings{ + Hooks: make([]kimiHook, 0, len(specs)+kimiObservationEventCount), + } + + for _, spec := range specs { + if spec.Event == "PostToolBatch" { + continue + } + + settings.Hooks = append(settings.Hooks, kimiHook{ + Event: spec.Event, + Matcher: spec.Tool, + Command: command, + TimeoutSeconds: timeoutSeconds, + }) + } + + for _, alias := range toolaliases.ProviderAliases( + toolaliases.ProviderKimi, + toolaliases.CanonicalNoop, + ) { + settings.Hooks = append( + settings.Hooks, + kimiHook{ + Event: eventPreToolUse, + Matcher: providerMatcher(alias), + Command: command, + TimeoutSeconds: timeoutSeconds, + }, + kimiHook{ + Event: eventPostToolUse, + Matcher: providerMatcher(alias), + Command: command, + TimeoutSeconds: timeoutSeconds, + }, + ) + } + + for _, event := range kimiObservationEvents() { + settings.Hooks = append(settings.Hooks, kimiHook{ + Event: event, + Command: command, + TimeoutSeconds: timeoutSeconds, + }) + } + + return settings +} + +const kimiObservationEventCount = 7 + +func kimiObservationEvents() []string { + return []string{ + "PostToolUseFailure", + eventPermissionRequest, + "PermissionResult", + "StopFailure", + "Interrupt", + "PostCompact", + "Notification", + } +} + +const ( + kimiManagedHooksStart = "# BEGIN coding-ethos managed Kimi hooks" + kimiManagedHooksEnd = "# END coding-ethos managed Kimi hooks" +) + +func ensureKimiConfig(content string, settings kimiSettings) string { + base := removeKimiManagedHooks(content) + + var builder strings.Builder + + builder.WriteString(strings.TrimRight(base, "\n")) + + if builder.Len() > 0 { + builder.WriteString("\n\n") + } + + builder.WriteString(kimiManagedHooksStart) + builder.WriteByte('\n') + + for _, hook := range settings.Hooks { + builder.WriteString("[[hooks]]\n") + builder.WriteString("event = " + tomlString(hook.Event) + "\n") + + if hook.Matcher != "" { + builder.WriteString("matcher = " + tomlString(hook.Matcher) + "\n") + } + + builder.WriteString("command = " + tomlString(hook.Command) + "\n") + builder.WriteString("timeout = ") + builder.WriteString(strconv.Itoa(hook.TimeoutSeconds)) + builder.WriteString("\n\n") + } + + builder.WriteString(kimiManagedHooksEnd) + builder.WriteByte('\n') + + return builder.String() +} + +func removeKimiManagedHooks(content string) string { + lines := strings.Split(content, "\n") + output := make([]string, 0, len(lines)) + inManagedBlock := false + + for _, line := range lines { + switch strings.TrimSpace(line) { + case kimiManagedHooksStart: + inManagedBlock = true + case kimiManagedHooksEnd: + inManagedBlock = false + default: + if !inManagedBlock { + output = append(output, line) + } + } + } + + return strings.TrimRight(strings.Join(output, "\n"), "\n") +} + +func kimiConfigContainsExpectedHooks( + content string, + expected kimiSettings, +) bool { + var actual kimiSettings + + err := toml.Unmarshal([]byte(content), &actual) + if err != nil { + return false + } + + for _, expectedHook := range expected.Hooks { + if !slices.Contains(actual.Hooks, expectedHook) { + return false + } + } + + return true +} diff --git a/go/internal/agenthooks/settings.go b/go/internal/agenthooks/settings.go index 0e2221cb..1c064a08 100644 --- a/go/internal/agenthooks/settings.go +++ b/go/internal/agenthooks/settings.go @@ -19,7 +19,6 @@ import ( "strings" "time" - "github.com/pelletier/go-toml/v2" "go.yaml.in/yaml/v3" "blackcat.ca/coding-ethos/go/internal/apperror" @@ -121,16 +120,6 @@ type claudeSettings struct { HooksConfig map[string]any `json:"hooksConfig,omitempty"` } -type kimiHook struct { - Event string `json:"event" toml:"event"` - Matcher string `json:"matcher,omitempty" toml:"matcher,omitempty"` - Command string `json:"command" toml:"command"` -} - -type kimiSettings struct { - Hooks []kimiHook `json:"hooks"` -} - type ProviderCapability struct { Provider string `json:"provider"` DisplayName string `json:"display_name"` @@ -1535,149 +1524,15 @@ func buildAllSettings( hookCommand, options.HookTimeoutSeconds, ), - Kimi: buildKimiSettings(RuntimeHookSpecs(), hookCommand), + Kimi: buildKimiSettings( + RuntimeHookSpecs(), + hookCommand, + options.HookTimeoutSeconds, + ), Capabilities: ProviderCapabilities(), }, nil } -func buildKimiSettings(specs []HookSpec, hookCommand string) kimiSettings { - command := strings.TrimSpace(hookCommand) + " --provider kimi" - settings := kimiSettings{ - Hooks: make([]kimiHook, 0, len(specs)+kimiObservationEventCount), - } - - for _, spec := range specs { - if spec.Event == "PostToolBatch" { - continue - } - - settings.Hooks = append(settings.Hooks, kimiHook{ - Event: spec.Event, - Matcher: spec.Tool, - Command: command, - }) - } - - for _, alias := range toolaliases.ProviderAliases( - toolaliases.ProviderKimi, - toolaliases.CanonicalNoop, - ) { - settings.Hooks = append( - settings.Hooks, - kimiHook{ - Event: eventPreToolUse, - Matcher: providerMatcher(alias), - Command: command, - }, - kimiHook{ - Event: eventPostToolUse, - Matcher: providerMatcher(alias), - Command: command, - }, - ) - } - - for _, event := range kimiObservationEvents() { - settings.Hooks = append(settings.Hooks, kimiHook{ - Event: event, - Command: command, - }) - } - - return settings -} - -const kimiObservationEventCount = 7 - -func kimiObservationEvents() []string { - return []string{ - "PostToolUseFailure", - eventPermissionRequest, - "PermissionResult", - "StopFailure", - "Interrupt", - "PostCompact", - "Notification", - } -} - -const ( - kimiManagedHooksStart = "# BEGIN coding-ethos managed Kimi hooks" - kimiManagedHooksEnd = "# END coding-ethos managed Kimi hooks" -) - -func ensureKimiConfig(content string, settings kimiSettings) string { - base := removeKimiManagedHooks(content) - - var builder strings.Builder - - builder.WriteString(strings.TrimRight(base, "\n")) - - if builder.Len() > 0 { - builder.WriteString("\n\n") - } - - builder.WriteString(kimiManagedHooksStart) - builder.WriteByte('\n') - - for _, hook := range settings.Hooks { - builder.WriteString("[[hooks]]\n") - builder.WriteString("event = " + tomlString(hook.Event) + "\n") - - if hook.Matcher != "" { - builder.WriteString("matcher = " + tomlString(hook.Matcher) + "\n") - } - - builder.WriteString("command = " + tomlString(hook.Command) + "\n\n") - } - - builder.WriteString(kimiManagedHooksEnd) - builder.WriteByte('\n') - - return builder.String() -} - -func removeKimiManagedHooks(content string) string { - lines := strings.Split(content, "\n") - output := make([]string, 0, len(lines)) - inManagedBlock := false - - for _, line := range lines { - switch strings.TrimSpace(line) { - case kimiManagedHooksStart: - inManagedBlock = true - case kimiManagedHooksEnd: - inManagedBlock = false - default: - if !inManagedBlock { - output = append(output, line) - } - } - } - - return strings.TrimRight(strings.Join(output, "\n"), "\n") -} - -func kimiConfigContainsExpectedHooks( - content string, - expected kimiSettings, -) bool { - var actual kimiSettings - - err := toml.Unmarshal([]byte(content), &actual) - if err != nil { - return false - } - - for _, expectedHook := range expected.Hooks { - if !slices.Contains(actual.Hooks, expectedHook) { - return false - } - } - - return true -} - func buildClaudeSettings( specs []HookSpec, hookCommand string, diff --git a/go/internal/agenthooks/settings_test.go b/go/internal/agenthooks/settings_test.go index c7115c92..49c0551c 100644 --- a/go/internal/agenthooks/settings_test.go +++ b/go/internal/agenthooks/settings_test.go @@ -91,7 +91,7 @@ func TestConfiguredHookTimeoutReachesProviderSettings(t *testing.T) { } output := buffer.String() - for _, provider := range []string{"claude", "codex", "gemini"} { + for _, provider := range []string{"claude", "codex", "gemini", "kimi"} { settings := providerSettingsSection( t, output, @@ -100,6 +100,7 @@ func TestConfiguredHookTimeoutReachesProviderSettings(t *testing.T) { "claude": "codex", "codex": "gemini", "gemini": "kimi", + "kimi": "capabilities", }[provider], ) if !strings.Contains(settings, `"timeout": 45`) { @@ -126,6 +127,13 @@ func TestConfiguredHookTimeoutReachesProviderSettings(t *testing.T) { if !bytes.Contains(codex, []byte("timeout = 45")) { t.Fatalf("Codex TOML omits configured timeout:\n%s", codex) } + kimi, err := os.ReadFile(agenthooks.DefaultSettingsPaths(root).KimiConfig) + if err != nil { + t.Fatalf("read Kimi settings: %v", err) + } + if !bytes.Contains(kimi, []byte("timeout = 45")) { + t.Fatalf("Kimi TOML omits configured timeout:\n%s", kimi) + } } func TestConfiguredHookTimeoutRejectsUnboundedValues(t *testing.T) { @@ -863,6 +871,8 @@ func TestSyncAndDoctorSettingsWritesAllProviderFiles(t *testing.T) { } } +const expectedProviderVerifyChecks = 17 + func TestSyncAndVerifySettingsRunsProviderSmokePayloads(t *testing.T) { t.Parallel() @@ -884,8 +894,13 @@ func TestSyncAndVerifySettingsRunsProviderSmokePayloads(t *testing.T) { t.Fatalf("status = %q, want valid: %#v", report.Status, report) } - if len(report.Checks) != 17 { - t.Fatalf("check count = %d, want 17: %#v", len(report.Checks), report.Checks) + if len(report.Checks) != expectedProviderVerifyChecks { + t.Fatalf( + "check count = %d, want %d: %#v", + len(report.Checks), + expectedProviderVerifyChecks, + report.Checks, + ) } knownProviders := providerIDsByRegistry() @@ -925,7 +940,7 @@ func TestSyncAndVerifySettingsUsesPrivateOverlayAndRepositoryCWD(t *testing.T) { if err != nil { t.Fatalf("verify overlay settings: %v", err) } - if report.Status != "valid" || len(report.Checks) != 17 { + if report.Status != "valid" || len(report.Checks) != expectedProviderVerifyChecks { t.Fatalf("overlay report = %#v", report) } @@ -990,7 +1005,7 @@ func TestSyncAndVerifySettingsUsesExternalSupervisorHookAndCodingEthosMCP( if err != nil { t.Fatalf("verify external supervisor overlay: %v", err) } - if report.Status != "valid" || len(report.Checks) != 17 { + if report.Status != "valid" || len(report.Checks) != expectedProviderVerifyChecks { t.Fatalf("external supervisor report = %#v", report) } diff --git a/go/internal/agenthooks/state_artifacts.go b/go/internal/agenthooks/state_artifacts.go index 0ed9fa83..881b3394 100644 --- a/go/internal/agenthooks/state_artifacts.go +++ b/go/internal/agenthooks/state_artifacts.go @@ -141,12 +141,14 @@ func renderProviderStateArtifactInputs( return agentHookStateArtifactInputs( paths, - claude, - claudeMCP, - codex, - gemini, - kimiConfig, - kimiMCP, + providerStateContent{ + claude: claude, + claudeMCP: claudeMCP, + codex: codex, + gemini: gemini, + kimiConfig: kimiConfig, + kimiMCP: kimiMCP, + }, ), nil } @@ -175,56 +177,60 @@ func renderKimiStateArtifacts( return config, mcp, nil } +type providerStateContent struct { + claude string + claudeMCP string + codex string + gemini string + kimiConfig string + kimiMCP string +} + func agentHookStateArtifactInputs( paths SettingsPaths, - claude, - claudeMCP, - codex, - gemini, - kimiConfig, - kimiMCP string, + content providerStateContent, ) []syncstate.ArtifactInput { const verifyCommand = "bin/coding-ethos-run agent-hooks doctor" return []syncstate.ArtifactInput{ { RelativePath: paths.Claude, - Content: claude, + Content: content.claude, Provider: "agent-hooks", Surface: "claude-settings", VerificationCommand: verifyCommand, }, { RelativePath: paths.ClaudeMCP, - Content: claudeMCP, + Content: content.claudeMCP, Provider: "agent-hooks", Surface: "claude-mcp", VerificationCommand: verifyCommand, }, { RelativePath: paths.CodexConfig, - Content: codex, + Content: content.codex, Provider: "agent-hooks", Surface: "codex-config", VerificationCommand: verifyCommand, }, { RelativePath: paths.Gemini, - Content: gemini, + Content: content.gemini, Provider: "agent-hooks", Surface: "gemini-settings", VerificationCommand: verifyCommand, }, { RelativePath: paths.KimiConfig, - Content: kimiConfig, + Content: content.kimiConfig, Provider: "agent-hooks", Surface: "kimi-config", VerificationCommand: verifyCommand, }, { RelativePath: paths.KimiMCP, - Content: kimiMCP, + Content: content.kimiMCP, Provider: "agent-hooks", Surface: "kimi-mcp", VerificationCommand: verifyCommand, diff --git a/go/internal/agenthookscli/main.go b/go/internal/agenthookscli/main.go index cb2ea0fd..8dcb9f80 100644 --- a/go/internal/agenthookscli/main.go +++ b/go/internal/agenthookscli/main.go @@ -114,7 +114,6 @@ func runCLI(args []string) int { func capabilities(args []string) error { flags := flag.NewFlagSet("capabilities", flag.ContinueOnError) ethosRoot := flags.String("ethos-root", ".", "Path to coding-ethos checkout") - _ = flags.Bool("json", false, "Emit JSON capability report") err := flags.Parse(args) if err != nil { diff --git a/go/internal/agenthookscli/main_internal_test.go b/go/internal/agenthookscli/main_internal_test.go index db83a0ac..6292037b 100644 --- a/go/internal/agenthookscli/main_internal_test.go +++ b/go/internal/agenthookscli/main_internal_test.go @@ -83,7 +83,7 @@ func TestCapabilitiesReportsRuntimeContractAndKimi(t *testing.T) { var err error output := captureStdout(t, func() { - err = capabilities([]string{"--json", "--ethos-root", ethosRoot}) + err = capabilities([]string{"--ethos-root", ethosRoot}) }) if err != nil { t.Fatalf("capabilities returned error: %v", err) diff --git a/go/internal/hooks/contract_v1.go b/go/internal/hooks/contract_v1.go index 6d5c6f91..d1bbd95b 100644 --- a/go/internal/hooks/contract_v1.go +++ b/go/internal/hooks/contract_v1.go @@ -194,7 +194,9 @@ func validateHookContractProvider(event Event) error { return err } - if !slices.Contains(hookContractV1Providers(), event.Provider()) { + resolvedProvider := hookContractV1ProviderFromHint(event.ProviderHint) + if resolvedProvider == "" || + !slices.Contains(hookContractV1Providers(), resolvedProvider) { return fmt.Errorf( "%w: %s %q", errHookContractProvider, @@ -206,6 +208,26 @@ func validateHookContractProvider(event Event) error { return nil } +func hookContractV1ProviderFromHint(providerHint string) string { + provider := strings.ToLower(strings.TrimSpace(providerHint)) + switch { + case strings.Contains(provider, providerCodingEthos): + return providerCodingEthos + case strings.Contains(provider, providerKimi): + return providerKimi + case strings.Contains(provider, providerGemini): + return providerGemini + case strings.Contains(provider, providerCodex): + return providerCodex + case strings.Contains(provider, providerClaude): + return providerClaude + case provider == "generic": + return provider + default: + return "" + } +} + func validateHookContractOptionalFields(event Event) error { for _, value := range []struct { name string diff --git a/go/internal/hooks/contract_v1_test.go b/go/internal/hooks/contract_v1_test.go index a6b4a2ca..9d49abaf 100644 --- a/go/internal/hooks/contract_v1_test.go +++ b/go/internal/hooks/contract_v1_test.go @@ -114,6 +114,16 @@ func TestDeclaredNeutralHookContractV1RejectsInvalidShape(t *testing.T) { }`, want: "provider is required", }, + { + name: "unknown provider", + payload: `{ + "contract_version": "coding-ethos.hook/v1", + "provider": "unknown-supervisor", + "source": "codex", + "hook_event_name": "Stop" + }`, + want: "unsupported provider", + }, { name: "unknown field", payload: `{ diff --git a/tests/test_lint_capture_lifecycle.py b/tests/test_lint_capture_lifecycle.py index c0da9c38..956e2ae5 100644 --- a/tests/test_lint_capture_lifecycle.py +++ b/tests/test_lint_capture_lifecycle.py @@ -25,6 +25,7 @@ def test_subprocess_environment_drops_inherited_runtime_roots( monkeypatch: pytest.MonkeyPatch, ) -> None: + """Verify inherited managed-runtime roots do not reach subprocesses.""" monkeypatch.setenv("CODE_ETHOS_CONSUMER_ROOT", "/outer/repo") monkeypatch.setenv("CODE_ETHOS_STATE_ROOT", "/outer/state") From 317c6b0cab292553ad4d519dcacca3dde4970152 Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Mon, 27 Jul 2026 14:49:39 -0600 Subject: [PATCH 11/13] test(codeintel): isolate CLI stdout capture --- .../codeintelcli/main_internal_test.go | 95 ++++++++++++++----- 1 file changed, 72 insertions(+), 23 deletions(-) diff --git a/go/internal/codeintelcli/main_internal_test.go b/go/internal/codeintelcli/main_internal_test.go index 8c0cc9f0..83aff104 100644 --- a/go/internal/codeintelcli/main_internal_test.go +++ b/go/internal/codeintelcli/main_internal_test.go @@ -24,7 +24,7 @@ var stdoutCaptureMu sync.Mutex func TestRunRejectsUnknownCommand(t *testing.T) { t.Parallel() - err := run(context.Background(), []string{"unknown"}) + err := runCapturingStdout(t, context.Background(), []string{"unknown"}) if err == nil { t.Fatalf("expected unknown command error") } @@ -36,7 +36,11 @@ func TestStatsCreatesStore(t *testing.T) { root := t.TempDir() dbPath := filepath.Join(root, ".coding-ethos", "code-intel.duckdb") - err := run(context.Background(), []string{"stats", "--root", root, "--db", dbPath}) + err := runCapturingStdout( + t, + context.Background(), + []string{"stats", "--root", root, "--db", dbPath}, + ) if err != nil { t.Fatalf("stats command returned error: %v", err) } @@ -131,7 +135,7 @@ func TestContextAdviceRejectsUnsupportedFormatWithContextError(t *testing.T) { root := t.TempDir() - err := run(context.Background(), []string{ + err := runCapturingStdout(t, context.Background(), []string{ "context-advice", "--root", root, "--format", "xml", @@ -458,7 +462,7 @@ func TestDecisionsListRejectsUnsupportedFormatWithDecisionError(t *testing.T) { root := t.TempDir() dbPath := filepath.Join(root, ".coding-ethos", "code-intel.duckdb") - err := run(context.Background(), []string{ + err := runCapturingStdout(t, context.Background(), []string{ "decisions", "list", "--root", root, @@ -516,7 +520,11 @@ func TestDownstreamAnalysisDoesNotRequireExistingStore(t *testing.T) { root := t.TempDir() - err := run(context.Background(), []string{"downstream-analysis", "--root", root}) + err := runCapturingStdout( + t, + context.Background(), + []string{"downstream-analysis", "--root", root}, + ) if err != nil { t.Fatalf("downstream-analysis command returned error: %v", err) } @@ -566,7 +574,11 @@ func TestVectorStatsCreatesDuckDBVectorStore(t *testing.T) { root := t.TempDir() - err := run(context.Background(), []string{"vector-stats", "--root", root}) + err := runCapturingStdout( + t, + context.Background(), + []string{"vector-stats", "--root", root}, + ) if err != nil { t.Fatalf("vector-stats command returned error: %v", err) } @@ -612,15 +624,25 @@ func captureStdout(t *testing.T, runCommand func()) string { t.Fatalf("create stdout pipe: %v", err) } os.Stdout = writer + writerClosed := false + readerClosed := false defer func() { os.Stdout = original + if !writerClosed { + _ = writer.Close() + } + if !readerClosed { + _ = reader.Close() + } }() runCommand() + os.Stdout = original if err := writer.Close(); err != nil { t.Fatalf("close stdout writer: %v", err) } + writerClosed = true output, err := io.ReadAll(reader) if err != nil { @@ -629,17 +651,29 @@ func captureStdout(t *testing.T, runCommand func()) string { if err := reader.Close(); err != nil { t.Fatalf("close stdout reader: %v", err) } + readerClosed = true return string(output) } +func runCapturingStdout(t *testing.T, ctx context.Context, args []string) error { + t.Helper() + + var runErr error + captureStdout(t, func() { + runErr = run(ctx, args) + }) + + return runErr +} + func TestHealthCommandAcceptsDirectoryPathFilter(t *testing.T) { t.Parallel() root := t.TempDir() dbPath := filepath.Join(root, ".coding-ethos", "code-intel.duckdb") - err := run(context.Background(), []string{ + err := runCapturingStdout(t, context.Background(), []string{ "health", "--root", root, "--db", dbPath, @@ -748,12 +782,16 @@ Use cmd/app.go#runApp as the documented entry point. ctx := context.Background() - err = run(ctx, []string{"index-code", "--root", root, "--db", dbPath, "cmd", "docs"}) + err = runCapturingStdout( + t, + ctx, + []string{"index-code", "--root", root, "--db", dbPath, "cmd", "docs"}, + ) if err != nil { t.Fatalf("index-code command returned error: %v", err) } - err = run(ctx, []string{ + err = runCapturingStdout(t, ctx, []string{ "anatomy-map", "--root", root, "--db", dbPath, "--path", "cmd", "--symbols-per-file", "3", "--format", "toon", }) @@ -767,7 +805,7 @@ Use cmd/app.go#runApp as the documented entry point. t.Fatalf("write listing: %v", err) } - err = run(ctx, []string{ + err = runCapturingStdout(t, ctx, []string{ "enrich-listing", "--root", root, "--db", dbPath, "--command", "ls -la cmd", "--listing-file", listingPath, "--symbols-per-file", "3", @@ -776,7 +814,7 @@ Use cmd/app.go#runApp as the documented entry point. t.Fatalf("enrich-listing command returned error: %v", err) } - err = run(ctx, []string{ + err = runCapturingStdout(t, ctx, []string{ "code-chunks", "--root", root, "--db", dbPath, "--path", "cmd/app.go", "--symbol-name", "runApp", }) @@ -784,7 +822,7 @@ Use cmd/app.go#runApp as the documented entry point. t.Fatalf("code-chunks command returned error: %v", err) } - err = run(ctx, []string{ + err = runCapturingStdout(t, ctx, []string{ "code-context", "--root", root, "--db", dbPath, "--path", "cmd/app.go", "--symbol-path", "runApp", }) @@ -792,7 +830,7 @@ Use cmd/app.go#runApp as the documented entry point. t.Fatalf("code-context by symbol returned error: %v", err) } - err = run(ctx, []string{ + err = runCapturingStdout(t, ctx, []string{ "code-context", "--root", root, "--db", dbPath, "--path", "cmd/app.go", "--line", "3", }) @@ -800,7 +838,7 @@ Use cmd/app.go#runApp as the documented entry point. t.Fatalf("code-context by line returned error: %v", err) } - err = run(ctx, []string{ + err = runCapturingStdout(t, ctx, []string{ "repo-map", "--root", root, "--db", dbPath, "--path", "cmd/app.go", }) @@ -848,7 +886,7 @@ Use cmd/app.go#runApp as the documented entry point. t.Fatalf("graph-report TOON missing expected content:\n%s", graphReportTOON) } - err = run(ctx, []string{ + err = runCapturingStdout(t, ctx, []string{ "centrality", "--root", root, "--db", dbPath, "--path", "cmd", "--format", "json", }) @@ -856,7 +894,7 @@ Use cmd/app.go#runApp as the documented entry point. t.Fatalf("centrality returned error: %v", err) } - err = run(ctx, []string{ + err = runCapturingStdout(t, ctx, []string{ "surprises", "--root", root, "--db", dbPath, "--path", "cmd", "--format", "json", }) @@ -864,7 +902,7 @@ Use cmd/app.go#runApp as the documented entry point. t.Fatalf("surprises returned error: %v", err) } - err = run(ctx, []string{ + err = runCapturingStdout(t, ctx, []string{ "compact-context", "--root", root, "--db", dbPath, "--path", "cmd/app.go", }) @@ -872,7 +910,11 @@ Use cmd/app.go#runApp as the documented entry point. t.Fatalf("compact-context command returned error: %v", err) } - err = run(ctx, []string{"code-context", "--root", root, "--db", dbPath}) + err = runCapturingStdout( + t, + ctx, + []string{"code-context", "--root", root, "--db", dbPath}, + ) if err == nil || !strings.Contains(err.Error(), "--chunk-id") { t.Fatalf("code-context without identifier error = %v", err) @@ -1295,12 +1337,17 @@ func TestSARIFCommands(t *testing.T) { baseArgs := []string{"--root", root, "--db", dbPath} - err = run(ctx, append([]string{"ingest-sarif", "--file", sarifPath}, baseArgs...)) + err = runCapturingStdout( + t, + ctx, + append([]string{"ingest-sarif", "--file", sarifPath}, baseArgs...), + ) if err != nil { t.Fatalf("ingest-sarif returned error: %v", err) } - err = run( + err = runCapturingStdout( + t, ctx, append([]string{"sarif-results", "--policy-id", "policy.one"}, baseArgs...), ) @@ -1308,7 +1355,8 @@ func TestSARIFCommands(t *testing.T) { t.Fatalf("sarif-results returned error: %v", err) } - err = run( + err = runCapturingStdout( + t, ctx, append([]string{"repeated-failures", "--policy-id", "policy.one"}, baseArgs...), ) @@ -1362,12 +1410,13 @@ func TestIngestTracesAndHookUsageCommands(t *testing.T) { baseArgs := []string{"--root", root, "--db", dbPath} - err = run(ctx, append([]string{"ingest-traces"}, baseArgs...)) + err = runCapturingStdout(t, ctx, append([]string{"ingest-traces"}, baseArgs...)) if err != nil { t.Fatalf("ingest-traces returned error: %v", err) } - err = run( + err = runCapturingStdout( + t, ctx, append( []string{"hook-usage", "--provider", "codex", "--status", "denied"}, From 3c721c577367feb0b60ab67daf1e27c6aad90414 Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Mon, 27 Jul 2026 15:07:40 -0600 Subject: [PATCH 12/13] fix(agent-hooks): address final review notes --- README.md | 4 ++-- docs/HOOK_CONTRACT_V1.md | 5 +++-- go/internal/codeintelcli/main_internal_test.go | 16 +++++++++++++--- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 7d323200..d3d129a6 100644 --- a/README.md +++ b/README.md @@ -1701,8 +1701,8 @@ Codex, Gemini, and Kimi payloads. The probes cover: - Codex blocks for raw Git, absolute Git paths, nested shell Git, and Python subprocess Git when rewrite is unavailable - Gemini deny responses for raw shell Git and write-tool policy denial -- Kimi exit-2 policy denial with a stderr reason and structured-deny Stop - continuation +- Kimi policy denial with exit code 2 and a stderr reason +- Kimi structured-deny Stop continuation with exit code 0 - managed hook-binary tampering: `rm ...coding-ethos-git-hook && go build -o ...coding-ethos-git-hook` diff --git a/docs/HOOK_CONTRACT_V1.md b/docs/HOOK_CONTRACT_V1.md index 446ee4ad..27f58e23 100644 --- a/docs/HOOK_CONTRACT_V1.md +++ b/docs/HOOK_CONTRACT_V1.md @@ -192,8 +192,9 @@ directly. For split roots, generated MCP entries append the validated `--hook-timeout-seconds` is bounded to 1–3600 seconds and defaults to 30. The same value must be supplied to `sync`, `doctor`, and `verify`; it is rendered into Claude, Codex, and Gemini native hook settings and included in Codex -trust hashes. Kimi's native hook schema does not expose a per-hook timeout, so -the supervisor command remains responsible for its own bounded deadline. +trust hashes. Generated Kimi TOML also includes the per-hook `timeout` field; +Kimi enforces a 1–600 second native timeout limit after Coding Ethos validates +`--hook-timeout-seconds` within its 1–3600 second rendering range. `runtime-policy sync/check` owns only the consumer-scoped compiled bundle under the state root (or below Git metadata when no state root is supplied); it does not generate or rewrite tracked repository configuration. diff --git a/go/internal/codeintelcli/main_internal_test.go b/go/internal/codeintelcli/main_internal_test.go index 83aff104..f6528cad 100644 --- a/go/internal/codeintelcli/main_internal_test.go +++ b/go/internal/codeintelcli/main_internal_test.go @@ -636,6 +636,16 @@ func captureStdout(t *testing.T, runCommand func()) string { } }() + var ( + output []byte + readErr error + ) + readDone := make(chan struct{}) + go func() { + output, readErr = io.ReadAll(reader) + close(readDone) + }() + runCommand() os.Stdout = original @@ -644,9 +654,9 @@ func captureStdout(t *testing.T, runCommand func()) string { } writerClosed = true - output, err := io.ReadAll(reader) - if err != nil { - t.Fatalf("read stdout pipe: %v", err) + <-readDone + if readErr != nil { + t.Fatalf("read stdout pipe: %v", readErr) } if err := reader.Close(); err != nil { t.Fatalf("close stdout reader: %v", err) From 4dc915711aa9e3461a5be45d62e1e0ae22f3ffd0 Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Mon, 27 Jul 2026 15:25:51 -0600 Subject: [PATCH 13/13] docs(agent-hooks): align Kimi timeout guidance --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d3d129a6..023123cc 100644 --- a/README.md +++ b/README.md @@ -1642,9 +1642,9 @@ existing repo-local behavior. `--hook-timeout-seconds` accepts 1–3600 seconds and defaults to 30. Pass the same value to `sync`, `doctor`, and `verify`. Claude, Codex, and Gemini receive -that native hook deadline, and Codex trust hashes bind it. Kimi does not expose -a native per-hook timeout, so an external supervisor must retain its own -bounded command deadline. +that native hook deadline, and Codex trust hashes bind it. Generated Kimi TOML +also includes a per-hook timeout; Kimi enforces a 1–600 second native timeout +limit after Coding Ethos validates the global 1–3600 second rendering range. For split roots, the generated MCP configuration keeps that statically validated base command and appends `--repo-root` plus `--state-root`. Prepare