diff --git a/.coding-ethos/.gitignore b/.coding-ethos/.gitignore new file mode 100644 index 00000000..e569b530 --- /dev/null +++ b/.coding-ethos/.gitignore @@ -0,0 +1,12 @@ +# coding-ethos generated runtime output +.claude/settings.local.json +/cache/ +/code-intel.duckdb +/code-intel.duckdb.wal +/events/ +/hook-runs/ +/lint-runs/ +/prune-runs/ +/state/ +.gemini/settings.json +.mcp.json 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/.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/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/README.md b/README.md index 7219bfb6..023123cc 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 @@ -1566,6 +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 ``` Agent hook generation is all-or-nothing. `sync` writes every supported @@ -1574,6 +1587,78 @@ 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 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 \ + --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 +``` + +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 +`sync`, `doctor`, and `verify`: + +```bash +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' +``` + +`--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. + +`--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. 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 +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, @@ -1585,9 +1670,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, @@ -1598,19 +1685,24 @@ 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. - -`agent-hooks verify` runs doctor first, then invokes the configured hook command -with provider-native Claude, Codex, and Gemini payloads. The probes cover: +`.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, +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 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` @@ -1669,10 +1761,29 @@ 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 \ + --repo-root /path/to/repo \ + --state-root /private/coding-ethos-state < 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..27f58e23 --- /dev/null +++ b/docs/HOOK_CONTRACT_V1.md @@ -0,0 +1,200 @@ + + + +# 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. + +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 +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 +``` + +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"`, +`hook_timeout_flag: "--hook-timeout-seconds"`, and +`runtime_policy_command: "runtime-policy"` alongside the settings, repository, +and state 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 \ + --state-root /private/coding-ethos-state +bin/coding-ethos-run agent-hooks verify \ + --root /private/settings-overlay \ + --repo-root /path/to/repo \ + --state-root /private/coding-ethos-state +``` + +`--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 \ + --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-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 \ + --repo /path/to/repo \ + --state-root /private/coding-ethos-state +``` + +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. 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. 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/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..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 { @@ -32,13 +33,25 @@ 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 @@ -137,6 +150,19 @@ func withDefaultHookCommand(paths runtimePaths, args []string) []string { return next } +func agentHooksArgs(paths runtimePaths, args []string) []string { + if len(args) == 0 || args[0] != agentHookCapabilitiesCommand { + 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,15 +174,102 @@ 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 } } 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] != agentHookCapabilitiesCommand +} diff --git a/go/cmd/coding-ethos-run/dispatch.go b/go/cmd/coding-ethos-run/dispatch.go index a5457bd4..a37394a9 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."}, }, ), @@ -808,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 } @@ -968,17 +974,32 @@ 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) { - installGitWrapperShim(paths) - installLintToolShims(paths) - _ = os.Setenv("CODE_ETHOS_CONSUMER_ROOT", rootFlagValue(rest, paths.Root)) + if len(rest) == 0 || rest[0] != agentHookCapabilitiesCommand { + installGitWrapperShim(paths) + installLintToolShims(paths) + } + + settingsRoot := rootFlagValue(rest, paths.Root) + repoRoot := flagValue(rest, "--repo-root", paths.Root) + _ = os.Setenv( + "CODE_ETHOS_CONSUMER_ROOT", + repoRoot, + ) + _ = os.Setenv( + envStateRoot, + flagValue(rest, "--state-root", settingsRoot), + ) runtimeExecTool( paths, "coding-ethos-agent-hooks", - withDefaultHookCommand(paths, rest)...) + agentHooksArgs(paths, rest)...) } func runPolicyTool(paths runtimePaths, rest []string) error { @@ -995,11 +1016,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 4b7bffff..deca3824 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() @@ -171,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) { @@ -182,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) { @@ -190,6 +206,195 @@ 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() + + paths := runtimePaths{ + EthosRoot: "/opt/coding-ethos", + RunBinary: "/opt/coding-ethos/bin/coding-ethos-run", + } + + got := agentHooksArgs(paths, []string{"capabilities"}) + want := []string{ + "capabilities", + "--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 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"}) { + 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 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() @@ -368,6 +573,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 c23dab5f..9cdbcdd6 100644 --- a/go/cmd/coding-ethos-run/parent_workflow.go +++ b/go/cmd/coding-ethos-run/parent_workflow.go @@ -45,10 +45,12 @@ 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 { Repo string + StateRoot string RepoEthos string RepoConfig string Scope string @@ -114,6 +116,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, @@ -123,6 +162,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") @@ -137,6 +181,11 @@ func parseParentWorkflowFlags( return parentWorkflowOptions{}, err } + resolvedStateRoot, err := cleanOptionalRoot("state-root", *stateRoot) + if err != nil { + return parentWorkflowOptions{}, err + } + resolvedRepoEthos, err := firstExistingPath( *repoEthos, parentRepoEthosCandidates(repoRoot), @@ -155,12 +204,26 @@ func parseParentWorkflowFlags( return parentWorkflowOptions{ Repo: repoRoot, + StateRoot: resolvedStateRoot, RepoEthos: resolvedRepoEthos, RepoConfig: resolvedRepoConfig, Scope: strings.TrimSpace(*scope), }, nil } +func cleanOptionalRoot(label, root string) (string, error) { + if strings.TrimSpace(root) == "" { + return "", nil + } + + cleaned := filepath.Clean(root) + if !filepath.IsAbs(cleaned) { + return "", fmt.Errorf("%w: %s=%s", errParentRootNotAbsolute, label, root) + } + + return cleaned, nil +} + func cleanParentRepoFlag(repo string) (string, error) { if strings.TrimSpace(repo) == "" { return "", apperror.StaticError("parent workflow requires --repo") @@ -318,7 +381,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), ) } @@ -337,6 +400,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", @@ -884,6 +951,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/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= 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/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/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/provider_capabilities.go b/go/internal/agenthooks/provider_capabilities.go index 2baa9dc2..f8f4c976 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,50 @@ 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"` + 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"` + 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", + StateRootFlag: "--state-root", + MCPCommandFlag: "--mcp-command", + HookTimeoutFlag: "--hook-timeout-seconds", + RuntimePolicyCommand: "runtime-policy", + SupportsPrivateOverlay: true, + } +} + func claudeProviderCapability() ProviderCapability { return ProviderCapability{ Provider: string(ProviderClaude), @@ -182,6 +224,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..1c064a08 100644 --- a/go/internal/agenthooks/settings.go +++ b/go/internal/agenthooks/settings.go @@ -6,36 +6,38 @@ package agenthooks import ( "bytes" - "context" "encoding/json" "errors" "fmt" "io" "maps" "os" - "os/exec" "path/filepath" "regexp" "slices" + "strconv" "strings" "time" "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 - mcpServerName = "coding-ethos" - 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" @@ -66,11 +68,40 @@ var ( errUnsupportedHookCommand = apperror.StaticError( "unsupported hook command for direct probe", ) + 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", ) + 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"` @@ -113,6 +144,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 +172,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( @@ -156,12 +211,69 @@ func mcpServerConfig(hookCommand 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 CodexConfig string CodexHooks string Gemini string + KimiConfig string + KimiMCP string } // VerifyReport describes the installed native hook surfaces and runnable smoke @@ -213,11 +325,27 @@ 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"), } } 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 } @@ -235,17 +363,91 @@ func WriteSettings(writer io.Writer, hookCommand string) error { } func SyncSettings(root, hookCommand string) error { - settings, err := buildAllSettings(hookCommand) + 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 { + 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 { + 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 } - serverConfig, err := mcpServerConfig(hookCommand) + serverConfig, err := mcpServerConfigForRoots( + hookCommand, + mcpCommand, + settingsRoot, + repoRoot, + stateRoot, + ) 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 +471,7 @@ func SyncSettings(root, hookCommand string) error { return err } - _, err = memories.ImportExisting(root) + _, err = memories.ImportExistingForRoots(repoRoot, stateRoot) if err != nil { return fmt.Errorf("import existing memories: %w", err) } @@ -283,6 +485,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 } @@ -551,7 +767,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") } @@ -704,17 +922,111 @@ func existingSettingsPayload(path string) (map[string]any, error) { } func DoctorSettings(root, hookCommand string) error { - expected, err := buildAllSettings(hookCommand) + 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 { + 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 { + 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 } - expectedMCP, err := mcpServerConfig(hookCommand) + expectedMCP, err := mcpServerConfigForRoots( + hookCommand, + mcpCommand, + settingsRoot, + repoRoot, + stateRoot, + ) + if err != nil { + return err + } + + paths := DefaultSettingsPaths(settingsRoot) + + err = doctorJSONSettings(paths, expected, expectedMCP) if err != nil { return err } - paths := DefaultSettingsPaths(root) + err = doctorTextSettings(paths, expected, expectedMCP) + if err != nil { + return err + } + + err = memories.VerifyForRoots(repoRoot, stateRoot) + 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 +1041,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 +1057,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 +1082,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 @@ -847,11 +1174,91 @@ 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) { - 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) { + 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) { + 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{ Status: verifyStatusInvalid, @@ -870,13 +1277,15 @@ 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) + probeTimeout := time.Duration(options.HookTimeoutSeconds)*time.Second + + hookProbeDeadlineMargin + result, err := runHookProbe(repoRoot, hookCommand, probe, probeTimeout) check := VerifyCheck{ Provider: probe.provider, @@ -1081,25 +1490,59 @@ func parseSkillFrontmatter(content string) (skillFrontmatter, error) { return frontmatter, nil } -func buildAllSettings(hookCommand string) (allSettings, error) { - if hookCommand == "" { +func buildAllSettings( + hookCommand string, + options SettingsOptions, +) (allSettings, error) { + if strings.TrimSpace(hookCommand) == "" { return allSettings{}, errHookCommandRequired } + 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, + options.HookTimeoutSeconds, + ), Capabilities: ProviderCapabilities(), }, nil } -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), ) } @@ -1107,25 +1550,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} } @@ -1154,7 +1608,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 { @@ -1165,7 +1623,7 @@ func buildGeminiSettings(specs []HookSpec, hookCommand string) claudeSettings { hooks[event] = append( hooks[event], - geminiMatcher(matcher, hookCommand), + geminiMatcher(matcher, hookCommand, timeoutSeconds), ) } @@ -1173,6 +1631,7 @@ func buildGeminiSettings(specs []HookSpec, hookCommand string) claudeSettings { hooks, toolaliases.ProviderGemini, hookCommand, + timeoutSeconds, geminiMatcher, ) @@ -1186,18 +1645,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), ) } } @@ -1255,34 +1715,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, }}, } } @@ -1388,560 +1851,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..c2cae800 --- /dev/null +++ b/go/internal/agenthooks/settings_probe.go @@ -0,0 +1,750 @@ +// 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" + "time" + + "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, + probeTimeout time.Duration, +) (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..49c0551c 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"`, @@ -73,11 +74,88 @@ 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", "kimi"} { + settings := providerSettingsSection( + t, + output, + provider, + map[string]string{ + "claude": "codex", + "codex": "gemini", + "gemini": "kimi", + "kimi": "capabilities", + }[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) + } + 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) { + 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() capabilities := agenthooks.ProviderCapabilities() - if len(capabilities) != 4 { + if len(capabilities) != 5 { t.Fatalf("capability count mismatch: %#v", capabilities) } @@ -109,6 +187,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 +228,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 +354,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 +441,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 +637,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 +851,8 @@ func TestSyncAndDoctorSettingsWritesAllProviderFiles(t *testing.T) { paths.ClaudeMCP, paths.CodexConfig, paths.Gemini, + paths.KimiConfig, + paths.KimiMCP, } { _, statErr := os.Stat(path) if statErr != nil { @@ -718,6 +871,8 @@ func TestSyncAndDoctorSettingsWritesAllProviderFiles(t *testing.T) { } } +const expectedProviderVerifyChecks = 17 + func TestSyncAndVerifySettingsRunsProviderSmokePayloads(t *testing.T) { t.Parallel() @@ -739,8 +894,13 @@ 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) != expectedProviderVerifyChecks { + t.Fatalf( + "check count = %d, want %d: %#v", + len(report.Checks), + expectedProviderVerifyChecks, + report.Checks, + ) } knownProviders := providerIDsByRegistry() @@ -755,6 +915,237 @@ 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) != expectedProviderVerifyChecks { + 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() + stateRoot := t.TempDir() + hookCommand, mcpCommand, mcpRunner := fakeExternalSupervisorCommands(t) + writeGeneratedSkillSurfaces(t, repoRoot, "conditional-imports") + + err := agenthooks.SyncSettingsForRootsWithMCPCommand( + settingsRoot, + repoRoot, + stateRoot, + hookCommand, + mcpCommand, + ) + if err != nil { + t.Fatalf("sync external supervisor overlay: %v", err) + } + + report, err := agenthooks.VerifySettingsForRootsWithMCPCommand( + settingsRoot, + repoRoot, + stateRoot, + hookCommand, + mcpCommand, + ) + if err != nil { + t.Fatalf("verify external supervisor overlay: %v", err) + } + if report.Status != "valid" || len(report.Checks) != expectedProviderVerifyChecks { + 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}, + {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 { + 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, + ) + } + } + + 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) { + 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 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() @@ -1228,6 +1619,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 +1659,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 +1702,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..881b3394 100644 --- a/go/internal/agenthooks/state_artifacts.go +++ b/go/internal/agenthooks/state_artifacts.go @@ -10,17 +10,93 @@ import ( ) func StateArtifacts(root, hookCommand string) ([]syncstate.Artifact, error) { - settings, err := buildAllSettings(hookCommand) + 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) { + 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) { + 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 + } + + serverConfig, err := mcpServerConfigForRoots( + hookCommand, + mcpCommand, + settingsRoot, + repoRoot, + stateRoot, + ) if err != nil { return nil, err } - serverConfig, err := mcpServerConfig(hookCommand) + inputs, err := renderProviderStateArtifactInputs( + settingsRoot, + settings, + serverConfig, + ) if err != nil { return nil, err } - paths := DefaultSettingsPaths(root) + 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 @@ -58,54 +134,106 @@ func StateArtifacts(root, hookCommand string) ([]syncstate.Artifact, error) { return nil, err } - artifacts, err := syncstate.Artifacts( - root, - agentHookStateArtifactInputs(paths, claude, claudeMCP, codex, gemini), + kimiConfig, kimiMCP, err := renderKimiStateArtifacts(paths, settings, serverConfig) + if err != nil { + return nil, err + } + + return agentHookStateArtifactInputs( + paths, + providerStateContent{ + claude: claude, + claudeMCP: claudeMCP, + codex: codex, + gemini: gemini, + kimiConfig: kimiConfig, + kimiMCP: kimiMCP, + }, + ), 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 nil, fmt.Errorf("build agent hook state artifacts: %w", err) + return "", "", err } - return artifacts, nil + mcp, err := renderSettingsFileContent(paths.KimiMCP, func(payload map[string]any) { + syncMCPServers(payload, serverConfig.geminiJSON()) + }) + if err != nil { + return "", "", err + } + + 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 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: content.kimiConfig, + Provider: "agent-hooks", + Surface: "kimi-config", + VerificationCommand: verifyCommand, + }, + { + RelativePath: paths.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 85b4d3c6..8dcb9f80 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,9 +26,48 @@ 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") ) +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() @@ -46,6 +86,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,9 +111,39 @@ 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") + + 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") + hookTimeoutSeconds := flags.Int( + "hook-timeout-seconds", + agenthooks.DefaultHookTimeoutSeconds, + "Provider hook timeout in seconds", + ) err := flags.Parse(args) if err != nil { @@ -80,7 +152,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) } @@ -99,9 +175,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") + settings := bindSettingsCommandFlags(flags) ethosRoot := flags.String("ethos-root", ".", "Path to coding-ethos checkout") - hookCommand := flags.String("hook-command", "", "Agent hook command") dryRun := flags.Bool("dry-run", false, "Report planned writes without mutating files") format := flags.String( "format", @@ -114,36 +189,103 @@ func syncSettings(args []string) error { return fmt.Errorf("parse sync flags: %w", err) } - resolvedHookCommand := defaultHookCommand(*hookCommand) - - artifacts, err := agenthooks.StateArtifacts(*root, resolvedHookCommand) + resolvedHookCommand := defaultHookCommand(*settings.hookCommand) + resolvedRepoRoot := defaultRepoRoot(*settings.root, *settings.repoRoot) + resolvedStateRoot := defaultStateRoot(*settings.root, *settings.stateRoot) + options := settingsOptions(*settings.hookTimeoutSeconds) + + artifacts, err := agenthooks.StateArtifactsForRootsWithMCPCommandAndOptions( + *settings.root, + resolvedRepoRoot, + resolvedStateRoot, + resolvedHookCommand, + *settings.mcpCommand, + options, + ) if err != nil { return fmt.Errorf("plan agent hook settings: %w", err) } if *dryRun { return writeSyncStateReport( - syncstate.Plan(*root, "agent-hooks sync", artifacts), + syncstate.Plan(*settings.root, "agent-hooks sync", artifacts), *format, ) } - err = agenthooks.SyncSettings(*root, resolvedHookCommand) + err = applyAgentHookSettings( + *settings.root, + resolvedRepoRoot, + resolvedStateRoot, + resolvedHookCommand, + *settings.mcpCommand, + options, + ) + if err != nil { + return err + } + + if privateSettingsOverlay(*settings.root, resolvedRepoRoot) { + artifacts, err = agenthooks.StateArtifactsForRootsWithMCPCommandAndOptions( + *settings.root, + resolvedRepoRoot, + resolvedStateRoot, + resolvedHookCommand, + *settings.mcpCommand, + options, + ) + if err != nil { + return fmt.Errorf("refresh private overlay state artifacts: %w", err) + } + } + + return upsertAgentHookSyncState(*settings.root, *ethosRoot, artifacts) +} + +func applyAgentHookSettings( + root string, + repoRoot string, + stateRoot string, + hookCommand string, + mcpCommand string, + options agenthooks.SettingsOptions, +) error { + err := agenthooks.SyncSettingsForRootsWithMCPCommandAndOptions( + root, + repoRoot, + stateRoot, + hookCommand, + mcpCommand, + options, + ) if err != nil { return fmt.Errorf("sync agent hook settings: %w", err) } - err = agenthooks.SyncCodexTrustState(*root, resolvedHookCommand, "") + err = agenthooks.SyncCodexTrustStateWithOptions( + root, + hookCommand, + codexTrustConfigForRoots(root, repoRoot), + options, + ) if err != nil { return fmt.Errorf("sync Codex hook trust: %w", err) } - _, err = syncstate.Upsert(syncstate.UpsertOptions{ - RepoRoot: *root, - EthosRoot: *ethosRoot, + return nil +} + +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, }) @@ -165,20 +307,35 @@ 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") - hookCommand := flags.String("hook-command", "", "Agent hook command") + settings := bindSettingsCommandFlags(flags) err := flags.Parse(args) if err != nil { return fmt.Errorf("parse doctor flags: %w", err) } - err = agenthooks.DoctorSettings(*root, defaultHookCommand(*hookCommand)) + 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) } - err = agenthooks.VerifyCodexTrustState(*root, defaultHookCommand(*hookCommand), "") + resolvedRepoRoot := defaultRepoRoot(*settings.root, *settings.repoRoot) + + 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) } @@ -193,15 +350,23 @@ 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") - hookCommand := flags.String("hook-command", "", "Agent hook command") + settings := bindSettingsCommandFlags(flags) err := flags.Parse(args) if err != nil { return fmt.Errorf("parse verify flags: %w", err) } - report, err := agenthooks.VerifySettings(*root, defaultHookCommand(*hookCommand)) + 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) if encodeErr != nil { @@ -211,7 +376,14 @@ func verifySettings(args []string) error { return fmt.Errorf("verify agent hook settings: %w", err) } - err = agenthooks.VerifyCodexTrustState(*root, defaultHookCommand(*hookCommand), "") + resolvedRepoRoot := defaultRepoRoot(*settings.root, *settings.repoRoot) + + err = agenthooks.VerifyCodexTrustStateWithOptions( + *settings.root, + defaultHookCommand(*settings.hookCommand), + codexTrustConfigForRoots(*settings.root, resolvedRepoRoot), + options, + ) if err != nil { report.Status = "invalid" report.Checks = append(report.Checks, agenthooks.VerifyCheck{ @@ -300,6 +472,47 @@ 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 + } + + 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) + 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,8 +537,10 @@ func usage() { 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 05d0600a..6292037b 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,39 @@ 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{"--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"`, + `"state_root_flag": "--state-root"`, + `"mcp_command_flag": "--mcp-command"`, + `"hook_timeout_flag": "--hook-timeout-seconds"`, + `"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 +158,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 +199,134 @@ 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() + stateRoot := 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, + "--state-root", stateRoot, + "--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, + "--state-root", stateRoot, + "--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"}, + {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 { + 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) + } + } + + 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) { root := t.TempDir() t.Setenv("CODEX_HOME", filepath.Join(root, "codex-home")) diff --git a/go/internal/codeintelcli/main_internal_test.go b/go/internal/codeintelcli/main_internal_test.go index 8c0cc9f0..f6528cad 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,34 +624,66 @@ 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() + } + }() + + var ( + output []byte + readErr error + ) + readDone := make(chan struct{}) + go func() { + output, readErr = io.ReadAll(reader) + close(readDone) }() 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 { - 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) } + 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 +792,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 +815,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 +824,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 +832,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 +840,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 +848,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 +896,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 +904,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 +912,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 +920,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 +1347,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 +1365,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 +1420,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"}, 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/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/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/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() diff --git a/go/internal/hooks/contract_v1.go b/go/internal/hooks/contract_v1.go new file mode 100644 index 00000000..d1bbd95b --- /dev/null +++ b/go/internal/hooks/contract_v1.go @@ -0,0 +1,454 @@ +// 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 + } + + resolvedProvider := hookContractV1ProviderFromHint(event.ProviderHint) + if resolvedProvider == "" || + !slices.Contains(hookContractV1Providers(), resolvedProvider) { + return fmt.Errorf( + "%w: %s %q", + errHookContractProvider, + HookContractV1, + event.ProviderHint, + ) + } + + 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 + 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..9d49abaf --- /dev/null +++ b/go/internal/hooks/contract_v1_test.go @@ -0,0 +1,181 @@ +// 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 provider", + payload: `{ + "contract_version": "coding-ethos.hook/v1", + "provider": "unknown-supervisor", + "source": "codex", + "hook_event_name": "Stop" + }`, + want: "unsupported provider", + }, + { + 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/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/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() 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()) + } + } +} 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") } 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/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..956e2ae5 100644 --- a/tests/test_lint_capture_lifecycle.py +++ b/tests/test_lint_capture_lifecycle.py @@ -10,15 +10,39 @@ 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: + """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") + + 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: 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]]