diff --git a/.changeset/capture-codex-exec-context.md b/.changeset/capture-codex-exec-context.md new file mode 100644 index 00000000..0b57fc0c --- /dev/null +++ b/.changeset/capture-codex-exec-context.md @@ -0,0 +1,7 @@ +--- +"@donadiosolutions/lcm": patch +--- + +Capture bounded semantic context from Codex `functions.exec` and +`functions.exec_command` PostToolUse events, and validate the installed Codex +hook with structural and no-write functional connector checks. diff --git a/bin/lcm.ts b/bin/lcm.ts index 1ace18ba..1f122b58 100644 --- a/bin/lcm.ts +++ b/bin/lcm.ts @@ -2380,6 +2380,7 @@ export async function runCli( const installed = listConnectors(opts.global ? homedir() : process.cwd()); console.log("\n Connector health:\n"); + let failures = 0; for (const agent of agents) { const agentConnectors = installed.filter((c: any) => c.agentId === (agent as any).id); if ((agentConnectors as any[]).length === 0) { @@ -2389,8 +2390,40 @@ export async function runCli( console.log(` ✓ ${(agent as any).name}: ${c.type} at ${c.path}`); } } + + if ((agent as any).id !== "codex" || agentName === undefined) continue; + + const { + inspectCodexPostToolHook, + resolveCodexHooksPath, + } = await import("../src/connectors/codex-hooks.js"); + const { codexPostToolFunctionalCoverage } = await import("../src/hooks/post-tool-normalization.js"); + const inspection = inspectCodexPostToolHook( + resolveCodexHooksPath(opts.global ? homedir() : process.cwd()), + ); + + if (inspection.state === "installed") { + console.log(" ✓ Codex: PostToolUse hook installed"); + let functional = false; + try { + functional = codexPostToolFunctionalCoverage(); + } catch { + functional = false; + } + if (functional) { + console.log(" ✓ Codex: native exec capture functional"); + } else { + console.log(" ✗ Codex: native exec capture functional"); + failures += 1; + } + } else { + console.log(` ✗ Codex: PostToolUse hook ${inspection.state}`); + console.log(" Codex: native exec capture functional check skipped"); + failures += 1; + } } console.log(); + if (failures > 0) exit(1); }); program.addCommand(connectorsCmd); diff --git a/codecov.yml b/codecov.yml index c2ccb33a..4d20913f 100644 --- a/codecov.yml +++ b/codecov.yml @@ -39,6 +39,7 @@ component_management: - component_id: "unit-hooks" name: "Unit - Hooks" paths: + # Includes native hook adapters under the existing hooks directory ownership. - "src/hooks/" - component_id: "unit-daemon-core" name: "Unit - Daemon Core" diff --git a/docs/hook-protocol.md b/docs/hook-protocol.md index ac9f56b4..2b612fdb 100644 --- a/docs/hook-protocol.md +++ b/docs/hook-protocol.md @@ -162,6 +162,78 @@ The `daemon_port` payload field is ignored. PostToolUse never sends the daemon bearer token or captured event data to a payload-selected listener; queued events are collected by the daemon's bounded background processing instead. +### Codex native PostToolUse capture + +The Codex connector uses the following exact hook entry in the canonical +`~/.codex/hooks.json` file (or the equivalent path selected by the existing +connector install scope): + +```json +{ + "PostToolUse": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "lcm post-tool --client codex" + } + ] + } + ] +} +``` + +The `matcher`, hook `type`, and command are structural contract values. The +installed connector may also retain its timeout and status-message metadata, +but the command must remain exactly `lcm post-tool --client codex`; extra +arguments do not satisfy the contract. Install it with: + +```bash +lcm connectors install codex +lcm connectors doctor codex +``` + +The `--global` option selects the existing global connector scope; no new +configuration option is required for native command capture, and the 2,000- +character adapter bound is fixed rather than configurable. + +Codex sends native tool names `functions.exec` and `functions.exec_command`. +For those names, lcm accepts only the bounded semantic command (`command`, or +`cmd` when `command` is absent) and a direct status projection. `tool_output` is +checked before `tool_response`; status fields are considered in this order: +`isError`, `is_error`, `exit_code`, and `exitCode`. Boolean values are used +directly, while finite numeric exit codes map zero to success and nonzero to an +error. A valid false or zero value is authoritative. Nested or invalid values +are ignored. + +The adapter does not persist raw Codex responses, stdout, stderr, or unknown +fields, and it does not infer file events from shell text or unrecognized +file-like fields. The existing event truncation and scrubbing pipeline still +runs on derived event data. Commands whose trimmed text begins with `lcm +store` are suppressed to prevent LCM's own writes from feeding back into +passive learning. + +`lcm connectors doctor codex` performs two checks for the targeted Codex +connector. It first verifies the exact structural hook contract above. Only +when that check passes does it run the native-exec functional probe. The probe +is pure and in-memory: it exercises normalization and extraction without +invoking the PostToolUse handler, opening an EventsDb, appending sidecar +events, writing hook files, or creating a database. A structurally absent or +incomplete hook never reports functional success; its functional result is +reported as: + +```text +Codex: native exec capture functional check skipped +``` + +When the exact structure and the pure probe both pass, doctor reports: + +```text +✓ Codex: PostToolUse hook installed +✓ Codex: native exec capture functional +``` + ## SessionSnapshot Hook **Command:** `lcm session-snapshot` diff --git a/docs/passive-learning.md b/docs/passive-learning.md index af3caa3c..ace49227 100644 --- a/docs/passive-learning.md +++ b/docs/passive-learning.md @@ -8,7 +8,7 @@ Passive learning captures insights from your Claude Code sessions automatically Two hooks capture events during your session: -- **PostToolUse** — fires after every tool call. Extracts structured metadata (tool name, command, file path) from tool inputs. Never captures raw tool output. +- **PostToolUse** — fires after every tool call. Extracts structured metadata (tool name, command, file path) from tool inputs. Never captures raw tool output. For Codex, the native `functions.exec` and `functions.exec_command` calls are adapted into the existing Bash event semantics. - **UserPromptSubmit** — fires on each user prompt. Detects decisions ("always use X"), role statements ("I'm a data scientist"), and intent patterns. Events are written to a **sidecar SQLite database** @@ -34,6 +34,36 @@ new global sequences during their transactional schema upgrade. ### What Gets Captured +#### Codex native command capture + +The Codex connector recognizes only PostToolUse payloads marked with +`client: "codex"` whose `tool_name` is exactly `functions.exec` or +`functions.exec_command`. The adapter accepts the string `tool_input.command` +first, or the string `tool_input.cmd` when `command` is not a string. Other +command-like, path-like, and file-like fields are ignored; a blank `command` +does not fall back to `cmd`. + +The adapter passes only bounded semantic information into the existing Bash +extractor: + +- A command is limited to 2,000 characters at the adapter boundary; longer + commands are clipped with `...` before event classification. +- The existing event-data truncation and event scrubbing still run after + classification, including the normal sensitive-path redaction rules. +- Status is read only from direct, top-level fields. `tool_output` wins over + `tool_response` when it contains a valid status. Within either object, the + precedence is `isError`, `is_error`, `exit_code`, then `exitCode`; boolean + values are used directly and finite numeric exit codes treat zero as + success and any other value as an error. Invalid, nested, string, `NaN`, and + infinite values are ignored. +- The raw Codex response, stdout, stderr, and unknown output fields are never + copied into the normalized event. Shell text is not parsed into file events. +- A command whose trimmed text begins with `lcm store` is suppressed so LCM's + own storage activity cannot create a passive-learning feedback loop. + +Only events recognized by the existing Bash extractor are queued. The command +itself is not stored as a transcript. + | Category | Examples | Priority | |----------|----------|----------| | Decisions | User answers to AskUserQuestion, "always use TypeScript" | 1 (immediate) | @@ -47,7 +77,9 @@ new global sequences during their transactional schema upgrade. ### What Is NOT Captured -- Raw tool payload contents such as file contents and command stdout/stderr (only tool metadata and brief user answers are stored) +- Raw tool payload contents such as file contents, command stdout/stderr, and + unknown Codex response fields (only bounded semantic metadata and brief user + answers are stored) - Sensitive file paths (`.env`, `.ssh/`, `credentials`, `.npmrc`) - LCM's own `lcm_store` calls (prevents feedback loops) diff --git a/docs/superpowers/plans/2026-08-11-codex-post-tool-capture.md b/docs/superpowers/plans/2026-08-11-codex-post-tool-capture.md index a5cef9cc..8a5bf9b5 100644 --- a/docs/superpowers/plans/2026-08-11-codex-post-tool-capture.md +++ b/docs/superpowers/plans/2026-08-11-codex-post-tool-capture.md @@ -13,8 +13,8 @@ - Start only after the CLI PR for #602/#603 merges. Fetch updated `origin/main`, create a fresh isolated worker workspace at `UPDATED_MAIN=$(git rev-parse --verify 'origin/main^{commit}')`, require `test "$(git rev-parse HEAD)" = "$UPDATED_MAIN"`, persist it with `git update-ref refs/lcm/implementation-bases/issue-604-codex-post-tool "$UPDATED_MAIN"`, and create branch `fix/604-codex-post-tool-capture` from that durable ref; never use a `codex/` prefix. - Do not use, clean, stage, or modify the coordinator worktree or pre-existing files outside this branch. - Add no dependency and preserve exact pins and lockfile integrity. -- Recognize only `client: "codex"` payloads with `functions.exec` or `functions.exec_command` names and explicit bounded command fields. -- Never serialize raw `tool_input`, `tool_response`, stdout, stderr, or unknown output into an event. +- Recognize only `client: "codex"` payloads with `functions.exec` or `functions.exec_command` names and explicit bounded command fields. Copy at most 2,000 command characters plus a literal `...` truncation marker into the canonical in-memory shape; no raw command object survives normalization. +- Never serialize raw `tool_input`, `tool_response`, stdout, stderr, or unknown output into an event. The bounded command is used only by the allowlisted semantic extractor, and extracted event data passes through the existing built-in and project-aware scrubber before enqueue. - Preserve feedback-loop exclusions, built-in/project scrubbing, truncation, and append-before-project-metadata ordering. - Functional doctor is pure/no-write and must not touch the user's event database. - The existing `unit-hooks` directory path already exclusively owns the new production file. Update `codecov.yml` atomically with the count test by documenting that the directory path owns native hook adapters, but do not add a redundant exact-file path; update the literal expected production-file count in `test/codecov-config.test.ts`. @@ -57,7 +57,7 @@ Create table-driven tests for: Normalize each fixture, pass it to `extractPostToolEvents`, and assert the existing event type/category/priority. Add negative fixtures for non-Codex clients, unknown `functions.*` names, non-string `cmd`/`command`, lcm-store feedback-loop names, and response objects containing a sentinel secret. Assert the secret never appears in normalized input or event data. -Add status fixtures with this exact policy: inspect only top-level status fields in record-valued `tool_output` and `tool_response`; prefer `tool_output` over `tool_response`; within one record prefer boolean `isError`, then boolean `is_error`, then finite numeric `exit_code`, then finite numeric `exitCode`. A boolean maps directly, numeric zero maps to false, and every other finite number maps to true. Strings, nested objects, `NaN`, and infinities are ignored. An explicit higher-precedence false overrides a lower-precedence nonzero code. If no recognized field exists, omit canonical `tool_output`. Add conflict fixtures proving every precedence rule. +Add status fixtures with this exact policy: inspect only top-level status fields in record-valued `tool_output` and `tool_response`, consulting `tool_output` first. Within one record, select the first valid field in this precedence order: boolean `isError`, boolean `is_error`, finite numeric `exit_code`, finite numeric `exitCode`. A boolean maps directly, numeric zero maps to false, and every other finite number maps to true. A valid false/zero wins. Strings, nested objects, `NaN`, and infinities are invalid and ignored. Fall back to `tool_response` only when `tool_output` contains no valid recognized field. If neither source contains one, omit canonical `tool_output`. Add conflict and invalid-field fixtures proving every source and field precedence rule. No captured structured Codex file-operation shape is available in #604. Add negative fixtures proving nested/unknown `operation`, `path`, and file-like fields cannot produce file events, and shell text such as `cat`, `sed`, `rm`, or redirects is never parsed into file events. Do not invent a structured mapping without a real captured payload. @@ -93,23 +93,29 @@ export function normalizePostToolInput(input: RawPostToolInput): PostToolInput { } ``` -Keep command selection deterministic: prefer `command` when it is a string, -otherwise `cmd`; trim only to decide whether a command is empty. An empty or -missing command produces an inert canonical input and no event regardless of -status, while the original non-empty command string is retained for existing -bounded extractor semantics. +Keep command selection deterministic: when `command` is a string it wins even +when blank; only if it is not a string may a string `cmd` be selected. Trim only +to decide whether the selected command is empty and to apply the native +feedback-loop matcher; do not fall back from a blank string `command` to `cmd`. +An empty or missing selected command produces an inert canonical input and no +event regardless of status. Bound a non-empty selected command before extraction +to its first 2,000 UTF-16 code units plus `...` when truncated. This mirrors the +existing event-data soft cap while preventing an unbounded raw command from +entering the extractor. Extracted data is then scrubbed by the existing +project-aware event scrubber before any durable write. Implement functional coverage from fixed benign fixtures: ```ts export function codexPostToolFunctionalCoverage(): boolean { - return [ - { tool_name: "functions.exec", tool_input: { command: "git branch" } }, - { tool_name: "functions.exec_command", tool_input: { cmd: "npm install probe" } }, - ].every(fixture => extractPostToolEvents(normalizePostToolInput({ - client: "codex", - ...fixture, - })).length === 1); + const fixtures = [ + { tool_name: "functions.exec", tool_input: { command: "git branch" }, expected: "git_branch" }, + { tool_name: "functions.exec_command", tool_input: { cmd: "npm install probe" }, expected: "env_install" }, + ]; + return fixtures.every(({ expected, ...fixture }) => { + const events = extractPostToolEvents(normalizePostToolInput({ client: "codex", ...fixture })); + return events.length === 1 && events[0]?.type === expected; + }); } ``` @@ -223,7 +229,7 @@ For targeted installed Codex connector doctor, require output that distinguishes ✓ Codex: native exec capture functional ``` -Targeted doctor must inspect the canonical `~/.codex/hooks.json` path using the same default/`--global` resolution as installation even when broad discovery finds only a partial installation. Preserve existing connector path output, then print structural and functional lines. Distinguish absent, incomplete, and installed-but-nonfunctional states; never print functional success when structure is absent/incomplete. Mock functional failure and assert actionable output plus exit 1. +Targeted doctor must inspect the canonical `~/.codex/hooks.json` path using the same default/`--global` resolution as installation even when broad discovery finds only a partial installation. Preserve existing connector path output, then print structural and functional lines. Distinguish absent, incomplete, and installed-but-nonfunctional states; never print functional success when structure is absent/incomplete. Print `Codex: native exec capture functional check skipped` when structure is absent or incomplete. Mock functional failure and assert actionable output plus exit 1. Make the pure probe mockable before module import through an injected dependency or module mock. Assert doctor does not call `appendLocalHookEvents`, instantiate `EventsDb`, write hook files, or create an event database. @@ -239,7 +245,7 @@ Expected: existing broad `hasCodexHooks` accepts partial hook files and doctor o - [ ] **Step 4: Implement exact structural inspection and no-write probe** -Keep broad connector discovery compatibility unchanged. Targeted health calls the exact structural inspector and only then the in-memory functional probe. Aggregate failures and call `exit(1)` only after printing all requested agent results. Non-Codex connector behavior remains unchanged. +Keep broad connector discovery compatibility and its existing output unchanged. Targeted health calls the exact structural inspector and only then the in-memory functional probe. Aggregate failures and call `exit(1)` only after printing all requested agent results. The probe imports only the pure normalizer/extractor path: it must not import or call `handlePostToolUse`, `appendLocalHookEvents`, `EventsDb`, or filesystem setup. Non-Codex connector behavior remains unchanged. - [ ] **Step 5: Run GREEN** diff --git a/docs/vscode-codex.md b/docs/vscode-codex.md index f9bc8a1f..829d24b7 100644 --- a/docs/vscode-codex.md +++ b/docs/vscode-codex.md @@ -64,7 +64,7 @@ The hook connector installs these Codex events: |---|---|---| | `SessionStart` | `lcm restore --client codex` | Restores project context when Codex starts, resumes, or clears a session | | `UserPromptSubmit` | `lcm user-prompt --client codex` | Searches memory and injects prompt-time hints | -| `PostToolUse` | `lcm post-tool --client codex` | Captures passive learning signals from supported tool calls | +| `PostToolUse` | `lcm post-tool --client codex` | Captures bounded semantic signals from `functions.exec` and `functions.exec_command` | | `PreCompact` | `lcm session-snapshot --client codex` | Force-ingests transcript deltas before manual or automatic Codex compaction | | `Stop` | `lcm session-snapshot --client codex` | Ingests transcript deltas and triggers compaction once the configured token threshold is reached | @@ -75,6 +75,41 @@ lcm connectors install codex --global lcm connectors doctor codex --global ``` +### Codex PostToolUse capture boundary + +The installed PostToolUse hook must be an exact command hook under a `*` +matcher: + +```json +{ + "matcher": "*", + "hooks": [ + { "type": "command", "command": "lcm post-tool --client codex" } + ] +} +``` + +`lcm connectors doctor codex` (or the same command with `--global`) verifies +that structure in the canonical Codex hook file. It then runs a pure, +no-write functional check over representative native payloads. The check does +not run the hook handler, write hook files, create an event database, open an +EventsDb, or append sidecar events. If the structure is absent or incomplete, +doctor prints `Codex: native exec capture functional check skipped` and does +not claim functional health. + +Codex capture is deliberately narrow. Only `client: "codex"` payloads with +`tool_name` equal to `functions.exec` or `functions.exec_command` are adapted. +The adapter takes `tool_input.command`, or `tool_input.cmd` when `command` is +absent, and bounds the accepted command to 2,000 characters before passing it +through the existing event-data truncation and sensitive-data scrubbing. It +projects only direct status fields from `tool_output` (preferred) or +`tool_response`, using `isError`, `is_error`, `exit_code`, and `exitCode` in +that order. Raw stdout/stderr, raw responses, nested or unknown output fields, +and file-like fields are not persisted or interpreted as file events. A +trimmed command beginning with `lcm store` is suppressed to avoid a feedback +loop from LCM's own storage calls. There is no new configuration option for +these fixed capture rules. + If you only want the Codex skill or rules instead of the default set: ```bash @@ -122,4 +157,6 @@ lcm import --provider all 1. Add first-class `lcm setup vscode` and `lcm setup codex` commands instead of overloading `lcm install`. 2. Add TOML read/write support so Codex MCP setup can be automated. 3. Add a real VS Code runtime adapter for restore, writeback, and prompt-time recall instead of rules-only guidance. -4. Expand connector diagnostics to validate Codex hook event coverage. +4. Connector diagnostics now validate the exact Codex PostToolUse structure and + its pure native-exec capture path; no database or hook-file writes are part + of the check. diff --git a/src/connectors/codex-hooks.ts b/src/connectors/codex-hooks.ts index 68b6f200..8a99d413 100644 --- a/src/connectors/codex-hooks.ts +++ b/src/connectors/codex-hooks.ts @@ -1,10 +1,19 @@ import { mkdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs"; -import { dirname } from "node:path"; +import { dirname, join } from "node:path"; +import { homedir } from "node:os"; export const CODEX_HOOKS_PATH = "~/.codex/hooks.json"; export const CODEX_CONFIG_PATH = "~/.codex/config.toml"; export const LEGACY_CODEX_HOOKS_PATHS = [".codex/hooks.json"] as const; +export type CodexPostToolHookState = "absent" | "incomplete" | "installed"; + +export interface CodexPostToolHookInspection { + readonly path: string; + readonly state: CodexPostToolHookState; + readonly structural: boolean; +} + type CodexCommandHook = { type?: string; command?: string; @@ -260,3 +269,40 @@ export function hasCodexHooks(hooksPath: string): boolean { ), ); } + +/** Resolve the Codex hooks path using the same `~/` convention as installation. */ +export function resolveCodexHooksPath(cwd: string = process.cwd()): string { + void cwd; + return join(homedir(), CODEX_HOOKS_PATH.slice(2)); +} + +function hasExactPostToolHook(value: unknown): boolean { + if (!isObject(value) || !isObject(value.hooks)) return false; + const postToolUse = value.hooks.PostToolUse; + if (!Array.isArray(postToolUse)) return false; + + return postToolUse.some((group) => { + if (!isObject(group) || group.matcher !== "*" || !Array.isArray(group.hooks)) return false; + return group.hooks.some((hook) => + isObject(hook) + && hook.type === "command" + && hook.command === "lcm post-tool --client codex", + ); + }); +} + +/** Inspect only the exact native Codex PostToolUse contract; this function never writes. */ +export function inspectCodexPostToolHook(hooksPath: string): CodexPostToolHookInspection { + let value: unknown; + try { + value = JSON.parse(readFileSync(hooksPath, "utf-8")); + } catch (error) { + const state: CodexPostToolHookState = (error as NodeJS.ErrnoException).code === "ENOENT" + ? "absent" + : "incomplete"; + return { path: hooksPath, state, structural: false }; + } + + const structural = hasExactPostToolHook(value); + return { path: hooksPath, state: structural ? "installed" : "incomplete", structural }; +} diff --git a/src/hooks/extractors.ts b/src/hooks/extractors.ts index 9956adf7..64654aac 100644 --- a/src/hooks/extractors.ts +++ b/src/hooks/extractors.ts @@ -8,7 +8,7 @@ export interface ExtractedEvent { tags?: string[]; } -interface PostToolInput { +export interface PostToolInput { tool_name: string; tool_input: Record; tool_response?: unknown; diff --git a/src/hooks/post-tool-normalization.ts b/src/hooks/post-tool-normalization.ts new file mode 100644 index 00000000..641adeca --- /dev/null +++ b/src/hooks/post-tool-normalization.ts @@ -0,0 +1,118 @@ +import { + extractPostToolEvents, + type PostToolInput, +} from "./extractors.js"; + +const COMMAND_SOFT_CAP = 2000; +const NATIVE_TOOL_NAMES = new Set(["functions.exec", "functions.exec_command"]); + +export interface RawPostToolInput { + readonly client?: unknown; + readonly tool_name?: unknown; + readonly tool_input?: unknown; + readonly tool_response?: unknown; + readonly tool_output?: unknown; +} + +type StatusRecord = Record; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normalizeToolInput(value: unknown): Record { + return isRecord(value) ? value : {}; +} + +function statusFromRecord(record: StatusRecord): boolean | undefined { + if (typeof record.isError === "boolean") return record.isError; + if (typeof record.is_error === "boolean") return record.is_error; + if (typeof record.exit_code === "number" && Number.isFinite(record.exit_code)) { + return record.exit_code !== 0; + } + if (typeof record.exitCode === "number" && Number.isFinite(record.exitCode)) { + return record.exitCode !== 0; + } + return undefined; +} + +function normalizeStatus(toolOutput: unknown, toolResponse: unknown): { isError?: boolean } | undefined { + const outputStatus = isRecord(toolOutput) ? statusFromRecord(toolOutput) : undefined; + if (outputStatus !== undefined) return { isError: outputStatus }; + + const responseStatus = isRecord(toolResponse) ? statusFromRecord(toolResponse) : undefined; + return responseStatus === undefined ? undefined : { isError: responseStatus }; +} + +function normalizeLegacyToolOutput(value: unknown): { isError?: boolean } | undefined { + if (!isRecord(value)) return undefined; + return typeof value.isError === "boolean" ? { isError: value.isError } : {}; +} + +function boundCommand(command: string): string { + return command.length > COMMAND_SOFT_CAP + ? `${command.slice(0, COMMAND_SOFT_CAP)}...` + : command; +} + +function isNativeFeedbackLoop(command: string): boolean { + return /^lcm\s+store(?:\s|$)/u.test(command.trim()); +} + +function normalizeNonNativeInput(input: RawPostToolInput): PostToolInput { + const toolName = typeof input.tool_name === "string" ? input.tool_name : ""; + const normalized: PostToolInput = { + tool_name: toolName, + tool_input: normalizeToolInput(input.tool_input), + }; + if (input.tool_response !== undefined) normalized.tool_response = input.tool_response; + const toolOutput = normalizeLegacyToolOutput(input.tool_output); + if (toolOutput !== undefined) normalized.tool_output = toolOutput; + return normalized; +} + +export function normalizePostToolInput(input: RawPostToolInput): PostToolInput { + const toolName = typeof input.tool_name === "string" ? input.tool_name : ""; + if (input.client !== "codex" || !NATIVE_TOOL_NAMES.has(toolName)) { + return normalizeNonNativeInput(input); + } + + const toolInput = normalizeToolInput(input.tool_input); + const selectedCommand = typeof toolInput.command === "string" + ? toolInput.command + : typeof toolInput.cmd === "string" + ? toolInput.cmd + : ""; + + if (selectedCommand.trim().length === 0 || isNativeFeedbackLoop(selectedCommand)) { + return { tool_name: "Bash", tool_input: { command: "" } }; + } + + const normalized: PostToolInput = { + tool_name: "Bash", + tool_input: { command: boundCommand(selectedCommand) }, + }; + const status = normalizeStatus(input.tool_output, input.tool_response); + if (status !== undefined) normalized.tool_output = status; + return normalized; +} + +export function codexPostToolFunctionalCoverage(): boolean { + const fixtures = [ + { + tool_name: "functions.exec", + tool_input: { command: "git branch" }, + expected: "git_branch", + }, + { + tool_name: "functions.exec_command", + tool_input: { cmd: "npm install probe" }, + expected: "env_install", + }, + ] as const; + + return fixtures.every(({ expected, ...fixture }) => { + const events = extractPostToolEvents(normalizePostToolInput({ client: "codex", ...fixture })); + return events.length === 1 && events[0]?.type === expected; + }); +} diff --git a/src/hooks/post-tool.ts b/src/hooks/post-tool.ts index a456bd73..6dc613b8 100644 --- a/src/hooks/post-tool.ts +++ b/src/hooks/post-tool.ts @@ -1,5 +1,6 @@ // src/hooks/post-tool.ts import { extractPostToolEvents } from "./extractors.js"; +import { normalizePostToolInput } from "./post-tool-normalization.js"; import { safeLogError } from "./hook-errors.js"; import { ensureProjectDir } from "../daemon/project.js"; import { appendLocalHookEvents } from "./local-enqueue.js"; @@ -10,6 +11,7 @@ import { } from "./publication-fence.js"; interface PostToolHookInput { + client?: unknown; session_id?: unknown; tool_name?: unknown; tool_input?: unknown; @@ -28,15 +30,6 @@ function resolveHookCwd(inputCwd: unknown): string { return process.cwd(); } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function normalizeToolOutput(value: unknown): { isError?: boolean } | undefined { - if (!isRecord(value)) return undefined; - return typeof value.isError === "boolean" ? { isError: value.isError } : {}; -} - export async function handlePostToolUse( stdin: string, _port?: number, @@ -44,16 +37,11 @@ export async function handlePostToolUse( let cwd: string | undefined; try { const input = JSON.parse(stdin) as PostToolHookInput; - const { session_id, tool_name, tool_input, tool_response, tool_output } = input; + const { session_id, tool_name } = input; if (typeof tool_name !== "string" || typeof session_id !== "string") return { exitCode: 0, stdout: "" }; - const extractedEvents = extractPostToolEvents({ - tool_name, - tool_input: isRecord(tool_input) ? tool_input : {}, - tool_response, - tool_output: normalizeToolOutput(tool_output), - }); + const extractedEvents = extractPostToolEvents(normalizePostToolInput(input)); if (extractedEvents.length === 0) return { exitCode: 0, stdout: "" }; const resolvedCwd = resolveHookCwd(input.cwd); diff --git a/test/bin/lcm-run-cli.test.ts b/test/bin/lcm-run-cli.test.ts index 9737b4b8..b60966dc 100644 --- a/test/bin/lcm-run-cli.test.ts +++ b/test/bin/lcm-run-cli.test.ts @@ -74,6 +74,13 @@ const state = vi.hoisted(() => ({ listConnectors: vi.fn(() => state.installed), installConnector: vi.fn(() => state.installResult), removeConnector: vi.fn(() => state.removeResult), + inspectCodexPostToolHook: vi.fn(() => ({ + path: "/home/test/.codex/hooks.json", + state: "installed", + structural: true, + })), + resolveCodexHooksPath: vi.fn(() => "/home/test/.codex/hooks.json"), + codexPostToolFunctionalCoverage: vi.fn(() => true), registerMachine: vi.fn(), showMachine: vi.fn(), recoverMachine: vi.fn(), @@ -193,6 +200,13 @@ vi.mock("../../src/connectors/installer.js", () => ({ installConnector: state.installConnector, removeConnector: state.removeConnector, })); +vi.mock("../../src/connectors/codex-hooks.js", () => ({ + inspectCodexPostToolHook: state.inspectCodexPostToolHook, + resolveCodexHooksPath: state.resolveCodexHooksPath, +})); +vi.mock("../../src/hooks/post-tool-normalization.js", () => ({ + codexPostToolFunctionalCoverage: state.codexPostToolFunctionalCoverage, +})); vi.mock("../../src/identity-service.js", async importOriginal => ({ ...(await importOriginal()), registerMachine: state.registerMachine, @@ -275,6 +289,13 @@ beforeEach(() => { state.installed = []; state.installResult = { path: "/connector", requiresRestart: false }; state.removeResult = true; + state.inspectCodexPostToolHook.mockReturnValue({ + path: "/home/test/.codex/hooks.json", + state: "installed", + structural: true, + }); + state.resolveCodexHooksPath.mockReturnValue("/home/test/.codex/hooks.json"); + state.codexPostToolFunctionalCoverage.mockReturnValue(true); state.provider = "openai"; state.entries = []; state.exists = true; @@ -842,6 +863,88 @@ describe("runCli failure and alternate presentation branches", () => { expect((await invoke(["restore", "--client", "codex"]))?.message).toBe("exit:0"); }); + it("injects and preserves the top-level Codex client for post-tool dispatch", async () => { + fakeStdin.isTTY = false; + fakeStdin.on.mockImplementation((event: string, callback: (chunk?: Buffer) => void) => { + if (event === "data") queueMicrotask(() => callback(Buffer.from(JSON.stringify({ + client: "claude", + session_id: "codex-session", + tool_name: "functions.exec", + })))); + if (event === "end") queueMicrotask(() => callback()); + return fakeStdin; + }); + + expect((await invoke(["post-tool", "--client", "codex"]))?.message).toBe("exit:0"); + expect(JSON.parse(state.dispatchHook.mock.calls.at(-1)![1])).toMatchObject({ + client: "codex", + session_id: "codex-session", + tool_name: "functions.exec", + }); + }); + + it("reports exact Codex structure and functional capture health", async () => { + state.installed = [{ agentId: "codex", type: "hook", path: "/partial/hooks.json" }]; + state.inspectCodexPostToolHook.mockReturnValue({ + path: "/home/test/.codex/hooks.json", + state: "installed", + structural: true, + }); + state.codexPostToolFunctionalCoverage.mockReturnValue(true); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + + expect(await invoke(["connectors", "doctor", "codex", "--global"])).toBeUndefined(); + expect(state.resolveCodexHooksPath).toHaveBeenCalledWith(expect.any(String)); + expect(state.inspectCodexPostToolHook).toHaveBeenCalledWith("/home/test/.codex/hooks.json"); + expect(state.codexPostToolFunctionalCoverage).toHaveBeenCalledOnce(); + expect(log.mock.calls.flat().join("\n")).toContain("✓ Codex: PostToolUse hook installed"); + expect(log.mock.calls.flat().join("\n")).toContain("✓ Codex: native exec capture functional"); + }); + + it("skips the pure functional check when Codex structure is incomplete", async () => { + state.installed = [{ agentId: "codex", type: "hook", path: "/partial/hooks.json" }]; + state.inspectCodexPostToolHook.mockReturnValue({ + path: "/home/test/.codex/hooks.json", + state: "incomplete", + structural: false, + }); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + + expect((await invoke(["connectors", "doctor", "codex"]))?.message).toBe("exit:1"); + expect(state.codexPostToolFunctionalCoverage).not.toHaveBeenCalled(); + expect(log.mock.calls.flat().join("\n")).toContain("Codex: native exec capture functional check skipped"); + }); + + it("aggregates a nonfunctional Codex result and exits after printing the failure", async () => { + state.installed = [{ agentId: "codex", type: "hook", path: "/hooks.json" }]; + state.inspectCodexPostToolHook.mockReturnValue({ + path: "/home/test/.codex/hooks.json", + state: "installed", + structural: true, + }); + state.codexPostToolFunctionalCoverage.mockReturnValue(false); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + + expect((await invoke(["connectors", "doctor", "codex"]))?.message).toBe("exit:1"); + expect(log.mock.calls.flat().join("\n")).toContain("✗ Codex: native exec capture functional"); + }); + + it("fails closed when the pure Codex functional probe throws", async () => { + state.installed = [{ agentId: "codex", type: "hook", path: "/hooks.json" }]; + state.inspectCodexPostToolHook.mockReturnValue({ + path: "/home/test/.codex/hooks.json", + state: "installed", + structural: true, + }); + state.codexPostToolFunctionalCoverage.mockImplementationOnce(() => { + throw new Error("probe failed"); + }); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + + expect((await invoke(["connectors", "doctor", "codex"]))?.message).toBe("exit:1"); + expect(log.mock.calls.flat().join("\n")).toContain("✗ Codex: native exec capture functional"); + }); + it("covers daemon start and restart outcomes", async () => { state.ensureDaemon.mockResolvedValueOnce({ connected: false, spawned: false, restartedForParent: false, pid: undefined, warning: "blocked" }); expect((await invoke(["daemon", "start"]))?.message).toBe("exit:1"); diff --git a/test/codecov-config.test.ts b/test/codecov-config.test.ts index 2832b40d..096e8bcd 100644 --- a/test/codecov-config.test.ts +++ b/test/codecov-config.test.ts @@ -435,7 +435,7 @@ describe("Codecov configuration", () => { expect(isSafeOwnershipPath(path)).toBe(true); } - expect(productionFiles).toHaveLength(190); + expect(productionFiles).toHaveLength(191); for (const component of validateComponents(components)) { expect(filesMatchedByComponent(component, productionFiles).length).toBeGreaterThan(0); @@ -465,7 +465,7 @@ describe("Codecov configuration", () => { expect(unownedFiles).toEqual([]); expect(multiplyOwnedFiles).toEqual([]); - expect(ownershipCounts.size).toBe(190); + expect(ownershipCounts.size).toBe(191); }); test("does not match non-production TypeScript files", () => { diff --git a/test/connectors/codex-hooks.test.ts b/test/connectors/codex-hooks.test.ts index e89b5866..ccf495a0 100644 --- a/test/connectors/codex-hooks.test.ts +++ b/test/connectors/codex-hooks.test.ts @@ -5,8 +5,10 @@ import { tmpdir } from "node:os"; import { enableCodexHooksFeature, hasCodexHooks, + inspectCodexPostToolHook, installCodexHooks, removeCodexHooks, + resolveCodexHooksPath, setCodexHooksFeature, } from "../../src/connectors/codex-hooks.js"; @@ -98,4 +100,57 @@ describe("Codex hook configuration boundaries", () => { expect(removeCodexHooks(hooksPath)).toBe(true); expect(existsSync(hooksPath)).toBe(false); }); + + it("keeps broad discovery permissive while exact inspection requires the native PostToolUse hook", () => { + writeFileSync(hooksPath, JSON.stringify({ + hooks: { + SessionStart: [{ hooks: [{ type: "command", command: "lcm restore --client codex" }] }], + }, + })); + + expect(hasCodexHooks(hooksPath)).toBe(true); + expect(inspectCodexPostToolHook(hooksPath)).toMatchObject({ state: "incomplete" }); + }); + + it.each([ + ["absent file", undefined, "absent"], + ["malformed JSON", "not-json", "incomplete"], + ["parsed JSON is not an object", "null", "incomplete"], + ["hooks is not a record", JSON.stringify({ hooks: [] }), "incomplete"], + ["missing PostToolUse", JSON.stringify({ hooks: { SessionStart: [] } }), "incomplete"], + ["PostToolUse is not an array", JSON.stringify({ hooks: { PostToolUse: {} } }), "incomplete"], + ["wrong matcher", JSON.stringify({ hooks: { PostToolUse: [{ matcher: "tool", hooks: [{ type: "command", command: "lcm post-tool --client codex" }] }] } }), "incomplete"], + ["wrong hook type", JSON.stringify({ hooks: { PostToolUse: [{ matcher: "*", hooks: [{ type: "prompt", command: "lcm post-tool --client codex" }] }] } }), "incomplete"], + ["wrong client", JSON.stringify({ hooks: { PostToolUse: [{ matcher: "*", hooks: [{ type: "command", command: "lcm post-tool" }] }] } }), "incomplete"], + ["extra command arguments", JSON.stringify({ hooks: { PostToolUse: [{ matcher: "*", hooks: [{ type: "command", command: "lcm post-tool --client codex --verbose" }] }] } }), "incomplete"], + ["missing command", JSON.stringify({ hooks: { PostToolUse: [{ matcher: "*", hooks: [{ type: "command" }] }] } }), "incomplete"], + ["exact native hook", JSON.stringify({ hooks: { PostToolUse: [{ matcher: "*", hooks: [{ type: "command", command: "lcm post-tool --client codex" }] }] } }), "installed"], + ] as const)("classifies %s with exact PostToolUse structural rules", (_label, content, expected) => { + if (content !== undefined) writeFileSync(hooksPath, content); + expect(inspectCodexPostToolHook(hooksPath)).toMatchObject({ + state: expected, + structural: expected === "installed", + }); + }); + + it("resolves the same canonical global hooks path used by installation", () => { + expect(resolveCodexHooksPath(dir)).toBe(join(process.env.HOME ?? "", ".codex", "hooks.json")); + }); + + it("does not modify the hook file during structural inspection", () => { + const content = JSON.stringify({ + hooks: { + PostToolUse: [{ matcher: "*", hooks: [{ type: "command", command: "lcm post-tool --client codex" }] }], + }, + }); + writeFileSync(hooksPath, content); + + expect(inspectCodexPostToolHook(hooksPath)).toMatchObject({ state: "installed", structural: true }); + expect(readFileSync(hooksPath, "utf-8")).toBe(content); + }); + + it("treats a readable-path failure other than absence as incomplete", () => { + mkdirSync(hooksPath); + expect(inspectCodexPostToolHook(hooksPath)).toMatchObject({ state: "incomplete", structural: false }); + }); }); diff --git a/test/hooks/dispatch.test.ts b/test/hooks/dispatch.test.ts index 2c075ed4..fd76d90d 100644 --- a/test/hooks/dispatch.test.ts +++ b/test/hooks/dispatch.test.ts @@ -262,6 +262,20 @@ describe("dispatchHook", () => { expect(ensureBootstrapped).not.toHaveBeenCalled(); }); + it("preserves the top-level Codex client for direct post-tool dispatch", async () => { + const payload = JSON.stringify({ + client: "codex", + session_id: "test", + tool_name: "functions.exec", + tool_input: { command: "git branch capture-test" }, + }); + vi.mocked(handlePostToolUse).mockClear(); + + await dispatchHook("post-tool", payload); + + expect(handlePostToolUse).toHaveBeenCalledWith(payload); + }); + it("ignores daemon_port from post-tool payload without loading config", async () => { vi.mocked(handlePostToolUse).mockClear(); vi.mocked(loadHookConfig).mockClear(); diff --git a/test/hooks/post-tool-normalization.test.ts b/test/hooks/post-tool-normalization.test.ts new file mode 100644 index 00000000..b8f1fbfe --- /dev/null +++ b/test/hooks/post-tool-normalization.test.ts @@ -0,0 +1,359 @@ +import { describe, expect, it } from "vitest"; +import { + extractPostToolEvents, + type ExtractedEvent, + type PostToolInput, +} from "../../src/hooks/extractors.js"; +import { + codexPostToolFunctionalCoverage, + normalizePostToolInput, + type RawPostToolInput, +} from "../../src/hooks/post-tool-normalization.js"; + +const SENTINEL = "codex-raw-secret-must-not-cross-the-boundary"; + +function normalize(input: RawPostToolInput): PostToolInput { + return normalizePostToolInput(input); +} + +function extract(input: RawPostToolInput): ExtractedEvent[] { + return extractPostToolEvents(normalize(input)); +} + +describe("normalizePostToolInput", () => { + it.each([ + { + name: "functions.exec command", + tool_name: "functions.exec", + tool_input: { command: "git commit -m 'bounded message'" }, + expectedType: "git_commit", + expectedCategory: "git", + expectedPriority: 2, + }, + { + name: "functions.exec_command cmd", + tool_name: "functions.exec_command", + tool_input: { cmd: "npm install exact-package" }, + expectedType: "env_install", + expectedCategory: "env", + expectedPriority: 2, + }, + ])("maps the allowlisted native $name shape to canonical Bash semantics", (fixture) => { + const normalized = normalize({ client: "codex", ...fixture }); + + expect(normalized.tool_name).toBe("Bash"); + expect(normalized.tool_input).toEqual({ command: fixture.tool_input.command ?? fixture.tool_input.cmd }); + expect(extract({ client: "codex", ...fixture })[0]).toMatchObject({ + type: fixture.expectedType, + category: fixture.expectedCategory, + priority: fixture.expectedPriority, + }); + }); + + it.each([ + { + name: "a non-Codex client", + input: { client: "claude", tool_name: "functions.exec", tool_input: { command: "git branch" } }, + }, + { + name: "an unknown functions tool", + input: { client: "codex", tool_name: "functions.unknown", tool_input: { command: "git branch" } }, + }, + { + name: "a client with the wrong casing", + input: { client: "Codex", tool_name: "functions.exec_command", tool_input: { cmd: "npm install package" } }, + }, + { + name: "a non-string command and cmd", + input: { client: "codex", tool_name: "functions.exec", tool_input: { command: 42, cmd: { value: "npm install" } } }, + }, + { + name: "a missing command", + input: { client: "codex", tool_name: "functions.exec", tool_input: { operation: "read", path: "secret.txt" } }, + }, + { + name: "a malformed tool input", + input: { client: "codex", tool_name: "functions.exec", tool_input: null }, + }, + ])("does not create native events for $name", ({ input }) => { + expect(extract(input)).toEqual([]); + }); + + it("uses an empty canonical name when a raw tool name is not a string", () => { + expect(normalize({ client: "claude", tool_name: 42, tool_input: {} })).toEqual({ + tool_name: "", + tool_input: {}, + }); + expect(normalize({ client: "codex", tool_name: 42, tool_input: {} })).toEqual({ + tool_name: "", + tool_input: {}, + }); + }); + + it("keeps non-Codex canonical tool semantics and only projects bounded status", () => { + const normalized = normalize({ + client: "claude", + tool_name: "AskUserQuestion", + tool_input: { question: "Continue?" }, + tool_response: "yes", + tool_output: { isError: true, stdout: SENTINEL }, + }); + + expect(normalized).toEqual({ + tool_name: "AskUserQuestion", + tool_input: { question: "Continue?" }, + tool_response: "yes", + tool_output: { isError: true }, + }); + expect(extract({ + client: "claude", + tool_name: "AskUserQuestion", + tool_input: { question: "Continue?" }, + tool_response: "yes", + tool_output: { isError: true, stdout: SENTINEL }, + })[0]).toMatchObject({ type: "decision" }); + }); + + it("does not apply native status fields to non-Codex inputs", () => { + expect(normalize({ + client: "claude", + tool_name: "CustomTool", + tool_input: {}, + tool_response: { exitCode: 1 }, + tool_output: { exit_code: 1 }, + })).toEqual({ + tool_name: "CustomTool", + tool_input: {}, + tool_response: { exitCode: 1 }, + tool_output: {}, + }); + }); + + it.each([ + { name: "isError true", output: { isError: true }, expected: true }, + { name: "isError false", output: { isError: false }, expected: false }, + { name: "snake-case error true", output: { is_error: true }, expected: true }, + { name: "snake-case error false", output: { is_error: false }, expected: false }, + { name: "zero exit_code", output: { exit_code: 0 }, expected: false }, + { name: "nonzero exit_code", output: { exit_code: 7 }, expected: true }, + { name: "zero exitCode", output: { exitCode: 0 }, expected: false }, + { name: "nonzero exitCode", output: { exitCode: -1 }, expected: true }, + { + name: "field precedence", + output: { isError: false, is_error: true, exit_code: 4, exitCode: 5 }, + expected: false, + }, + { + name: "invalid high-precedence fields", + output: { isError: "false", is_error: "true", exit_code: 0, exitCode: 9 }, + expected: false, + }, + { + name: "invalid output falls back to response", + output: { isError: "invalid", exit_code: "0" }, + response: { exitCode: 2 }, + expected: true, + }, + { + name: "valid output wins over response", + output: { isError: false }, + response: { isError: true }, + expected: false, + }, + { + name: "empty output falls back to response", + output: {}, + response: { is_error: false }, + expected: false, + }, + { + name: "non-finite output falls back to finite response", + output: { exit_code: Number.NaN, exitCode: Number.POSITIVE_INFINITY }, + response: { exitCode: 0 }, + expected: false, + }, + ])("projects $name using the documented status policy", ({ output, response, expected }) => { + const normalized = normalize({ + client: "codex", + tool_name: "functions.exec", + tool_input: { command: "git branch" }, + tool_output: output, + tool_response: response, + }); + + expect(normalized.tool_output).toEqual({ isError: expected }); + expect(extractPostToolEvents(normalized)[0]?.type).toBe(expected ? "error_tool" : "git_branch"); + }); + + it.each([ + { name: "both sources invalid", output: { isError: "no" }, response: { exitCode: "0" } }, + { name: "nested status fields", output: { result: { isError: true } }, response: { status: { exit_code: 1 } } }, + { name: "non-record sources", output: "isError=true", response: ["exit_code", 1] }, + ])("omits canonical status for $name", ({ output, response }) => { + const normalized = normalize({ + client: "codex", + tool_name: "functions.exec_command", + tool_input: { cmd: "git branch" }, + tool_output: output, + tool_response: response, + }); + + expect(normalized.tool_output).toBeUndefined(); + expect(extractPostToolEvents(normalized)[0]?.type).toBe("git_branch"); + }); + + it.each([ + "lcm store", + " lcm store --tags secret ", + "lcm store\t--tags secret", + ])("suppresses native feedback-loop command %j before status projection", (command) => { + const normalized = normalize({ + client: "codex", + tool_name: "functions.exec", + tool_input: { command }, + tool_output: { isError: true, stdout: SENTINEL }, + tool_response: { is_error: true, stderr: SENTINEL }, + }); + + expect(normalized).toEqual({ tool_name: "Bash", tool_input: { command: "" } }); + expect(extractPostToolEvents(normalized)).toEqual([]); + expect(JSON.stringify(normalized)).not.toContain(SENTINEL); + }); + + it.each([ + { tool_name: "lcm_store", output: undefined }, + { tool_name: "lcm_store", output: { isError: true } }, + { tool_name: "mcp__plugin_lcm_lcm__lcm_store", output: undefined }, + { tool_name: "mcp__plugin_lcm_lcm__lcm_store", output: { is_error: true } }, + ])("preserves existing tool-name feedback-loop suppression for %j", ({ tool_name, output }) => { + expect(extract({ + client: "codex", + tool_name, + tool_input: { text: "stored context" }, + tool_output: output, + })).toEqual([]); + }); + + it("uses the exact feedback-loop boundary", () => { + expect(extract({ + client: "codex", + tool_name: "functions.exec", + tool_input: { command: "lcm storehouse" }, + tool_output: { isError: true }, + })[0]).toMatchObject({ type: "error_tool" }); + }); + + it("gives command precedence over cmd, including an explicitly blank command", () => { + const commandWins = normalize({ + client: "codex", + tool_name: "functions.exec_command", + tool_input: { command: "git branch preferred", cmd: "npm install ignored" }, + }); + expect(commandWins.tool_input).toEqual({ command: "git branch preferred" }); + expect(extractPostToolEvents(commandWins)[0]?.type).toBe("git_branch"); + + const blankWins = normalize({ + client: "codex", + tool_name: "functions.exec_command", + tool_input: { command: "", cmd: "npm install ignored" }, + tool_output: { isError: true }, + }); + expect(blankWins).toEqual({ tool_name: "Bash", tool_input: { command: "" } }); + expect(extractPostToolEvents(blankWins)).toEqual([]); + }); + + it("falls back to cmd only when command is not a string", () => { + const normalized = normalize({ + client: "codex", + tool_name: "functions.exec_command", + tool_input: { command: 42, cmd: "npm install fallback" }, + }); + + expect(normalized.tool_input).toEqual({ command: "npm install fallback" }); + expect(extractPostToolEvents(normalized)[0]?.type).toBe("env_install"); + }); + + it("turns whitespace-only commands into an inert canonical input without trimming commands for extraction", () => { + const whitespace = normalize({ + client: "codex", + tool_name: "functions.exec", + tool_input: { command: " \t\n" }, + tool_output: { isError: true }, + }); + expect(whitespace).toEqual({ tool_name: "Bash", tool_input: { command: "" } }); + expect(extractPostToolEvents(whitespace)).toEqual([]); + + const leadingSpace = normalize({ + client: "codex", + tool_name: "functions.exec", + tool_input: { command: " git branch" }, + }); + expect(leadingSpace.tool_input).toEqual({ command: " git branch" }); + expect(extractPostToolEvents(leadingSpace)).toEqual([]); + }); + + it("bounds the command before it reaches extraction", () => { + const normalized = normalize({ + client: "codex", + tool_name: "functions.exec_command", + tool_input: { cmd: `npm install ${"x".repeat(2500)}` }, + }); + const boundedCommand = normalized.tool_input.command; + + expect(typeof boundedCommand).toBe("string"); + expect(boundedCommand).toHaveLength(2003); + expect(boundedCommand).toMatch(/x\.\.\.$/u); + expect(extractPostToolEvents(normalized)[0]).toMatchObject({ + type: "env_install", + data: boundedCommand, + }); + }); + + it.each([ + "cat /tmp/readme.md", + "sed -n '1p' /tmp/readme.md", + "rm /tmp/readme.md", + "printf 'hello' > /tmp/readme.md", + ])("does not infer file events from shell text %j or unknown file-like fields", (command) => { + const normalized = normalize({ + client: "codex", + tool_name: "functions.exec", + tool_input: { + command, + operation: "read", + path: "/tmp/secret.txt", + file_path: "/tmp/secret.txt", + file: { path: "/tmp/secret.txt" }, + }, + }); + + expect(normalized.tool_input).toEqual({ command }); + expect(extractPostToolEvents(normalized)).toEqual([]); + }); + + it("never copies raw Codex response/output or unknown input fields", () => { + const normalized = normalize({ + client: "codex", + tool_name: "functions.exec", + tool_input: { + command: "git branch", + stdout: SENTINEL, + stderr: SENTINEL, + unknown: SENTINEL, + }, + tool_output: { isError: true, stdout: SENTINEL, stderr: SENTINEL, secret: SENTINEL }, + tool_response: { stdout: SENTINEL, stderr: SENTINEL, secret: SENTINEL }, + }); + + expect(JSON.stringify(normalized)).not.toContain(SENTINEL); + expect(JSON.stringify(extractPostToolEvents(normalized))).not.toContain(SENTINEL); + expect(extractPostToolEvents(normalized)[0]).toMatchObject({ + type: "error_tool", + data: "Bash error: git branch", + }); + }); + + it("proves both fixed benign functional coverage fixtures in memory", () => { + expect(codexPostToolFunctionalCoverage()).toBe(true); + }); +}); diff --git a/test/hooks/post-tool.test.ts b/test/hooks/post-tool.test.ts index 975f6694..f0e728fb 100644 --- a/test/hooks/post-tool.test.ts +++ b/test/hooks/post-tool.test.ts @@ -41,6 +41,32 @@ describe("handlePostToolUse", () => { } } + function readPersistedEvents(inputCwd: string): readonly Record[] { + const db = new EventsDb(eventsDbPath(inputCwd)); + try { + return db.getUnprocessed() as unknown as readonly Record[]; + } finally { + db.close(); + } + } + + async function runNativePersistenceCase( + payload: Record, + includeDefaultClient = true, + ): Promise[]> { + const inputCwd = mkdtempSync(join(tmpdir(), "post-tool-native-cwd-")); + extraDirs.push(inputCwd); + process.env.TEST_EVENTS_DIR = inputCwd; + const request = { + session_id: "native-codex-session", + cwd: inputCwd, + ...payload, + }; + if (includeDefaultClient && !Object.hasOwn(request, "client")) request.client = "codex"; + await handlePostToolUse(JSON.stringify(request)); + return readPersistedEvents(inputCwd); + } + beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "post-tool-test-")); homeDir = mkdtempSync(join(tmpdir(), "post-tool-home-")); @@ -268,6 +294,150 @@ describe("handlePostToolUse", () => { expectPersistedDecision(inputCwd); }); + it.each([ + { + name: "functions.exec command Git event", + tool_name: "functions.exec", + tool_input: { command: "git branch capture-test" }, + expected: { type: "git_branch", category: "git", data: "git branch", priority: 2 }, + }, + { + name: "functions.exec_command cmd environment event", + tool_name: "functions.exec_command", + tool_input: { cmd: "npm install capture-test" }, + expected: { type: "env_install", category: "env", data: "npm install capture-test", priority: 2 }, + }, + { + name: "functions.exec isError error event", + tool_name: "functions.exec", + tool_input: { command: "git branch capture-test" }, + tool_output: { isError: true }, + expected: { type: "error_tool", category: "error", data: "Bash error: git branch capture-test", priority: 1 }, + }, + { + name: "functions.exec_command is_error error event", + tool_name: "functions.exec_command", + tool_input: { cmd: "npm install capture-test" }, + tool_output: { is_error: true }, + expected: { type: "error_tool", category: "error", data: "Bash error: npm install capture-test", priority: 1 }, + }, + { + name: "functions.exec nonzero exit_code error event", + tool_name: "functions.exec", + tool_input: { command: "git branch capture-test" }, + tool_response: { exit_code: 2 }, + expected: { type: "error_tool", category: "error", data: "Bash error: git branch capture-test", priority: 1 }, + }, + { + name: "functions.exec_command nonzero exitCode error event", + tool_name: "functions.exec_command", + tool_input: { cmd: "npm install capture-test" }, + tool_response: { exitCode: 2 }, + expected: { type: "error_tool", category: "error", data: "Bash error: npm install capture-test", priority: 1 }, + }, + { + name: "functions.exec zero exit_code normal event", + tool_name: "functions.exec", + tool_input: { command: "git branch capture-test" }, + tool_output: { exit_code: 0 }, + expected: { type: "git_branch", category: "git", data: "git branch", priority: 2 }, + }, + { + name: "functions.exec_command zero exitCode normal event", + tool_name: "functions.exec_command", + tool_input: { cmd: "npm install capture-test" }, + tool_response: { exitCode: 0 }, + expected: { type: "env_install", category: "env", data: "npm install capture-test", priority: 2 }, + }, + { + name: "functions.exec output status takes precedence over response status", + tool_name: "functions.exec", + tool_input: { command: "git branch capture-test", cmd: "npm install ignored" }, + tool_output: { isError: false, is_error: true, exit_code: 2, exitCode: 3 }, + tool_response: { isError: true }, + expected: { type: "git_branch", category: "git", data: "git branch", priority: 2 }, + }, + { + name: "functions.exec response field precedence uses isError before aliases and codes", + tool_name: "functions.exec", + tool_input: { command: "git branch capture-test" }, + tool_response: { isError: true, is_error: false, exit_code: 0, exitCode: 0 }, + expected: { type: "error_tool", category: "error", data: "Bash error: git branch capture-test", priority: 1 }, + }, + ] as const)("persists native Codex $name through EventsDb", async ({ expected, ...payload }) => { + const rows = await runNativePersistenceCase(payload); + expect(rows).toEqual([ + expect.objectContaining({ + session_id: "native-codex-session", + source_hook: "PostToolUse", + ...expected, + }), + ]); + }); + + it.each([ + { name: "functions.exec success", tool_name: "functions.exec", tool_input: { command: "lcm store capture-test" } }, + { name: "functions.exec error", tool_name: "functions.exec", tool_input: { command: "lcm store capture-test" }, tool_output: { isError: true } }, + { name: "functions.exec_command success", tool_name: "functions.exec_command", tool_input: { cmd: "lcm store capture-test" } }, + { name: "functions.exec_command error", tool_name: "functions.exec_command", tool_input: { cmd: "lcm store capture-test" }, tool_response: { exitCode: 1 } }, + ] as const)("does not persist native Codex lcm-store feedback loop: $name", async (payload) => { + expect(await runNativePersistenceCase(payload)).toEqual([]); + }); + + it("does not persist raw native output, response, or secret sentinels", async () => { + const rows = await runNativePersistenceCase({ + tool_name: "functions.exec_command", + tool_input: { cmd: "npm install capture-test" }, + tool_output: { + isError: true, + stdout: "RAW_STDOUT_SECRET", + stderr: "RAW_STDERR_SECRET", + secret: "RAW_OUTPUT_SECRET", + }, + tool_response: { + isError: true, + stdout: "RAW_RESPONSE_STDOUT_SECRET", + stderr: "RAW_RESPONSE_STDERR_SECRET", + }, + }); + expect(rows).toEqual([ + expect.objectContaining({ + type: "error_tool", + data: "Bash error: npm install capture-test", + }), + ]); + expect(JSON.stringify(rows)).not.toContain("RAW_"); + }); + + it.each([ + { name: "unknown native function", tool_name: "functions.unknown", tool_input: { command: "git branch capture-test" } }, + { name: "malformed command object", tool_name: "functions.exec", tool_input: "not an object" }, + { name: "missing command", tool_name: "functions.exec_command", tool_input: { unrelated: "value" } }, + { name: "non-Codex native function", client: "claude", tool_name: "functions.exec", tool_input: { command: "git branch capture-test" } }, + { name: "missing client native function", tool_name: "functions.exec", tool_input: { command: "git branch capture-test" } }, + ] as const)("does not persist unsupported native input: $name", async ({ client: _client, ...payload }) => { + const rows = await runNativePersistenceCase( + { ...payload, ...(_client === undefined ? {} : { client: _client }) }, + _client !== undefined, + ); + expect(rows).toEqual([]); + }); + + it.each([ + { name: "missing session ID", payload: { client: "codex", tool_name: "functions.exec", tool_input: { command: "git branch capture-test" } } }, + { name: "numeric session ID", payload: { session_id: 42, client: "codex", tool_name: "functions.exec", tool_input: { command: "git branch capture-test" } } }, + { name: "null session ID", payload: { session_id: null, client: "codex", tool_name: "functions.exec", tool_input: { command: "git branch capture-test" } } }, + { name: "malformed JSON", raw: "not json" }, + { name: "null JSON", raw: "null" }, + { name: "array JSON", raw: "[]" }, + ] as const)("does not persist invalid PostToolUse identity or shape: $name", async ({ payload, raw }) => { + const inputCwd = mkdtempSync(join(tmpdir(), "post-tool-invalid-native-cwd-")); + extraDirs.push(inputCwd); + process.env.TEST_EVENTS_DIR = inputCwd; + await handlePostToolUse(raw ?? JSON.stringify({ cwd: inputCwd, ...payload })); + expect(readPersistedEvents(inputCwd)).toEqual([]); + }); + it("uses persisted scrub patterns when PostgreSQL secrets are not staged yet", async () => { const inputCwd = mkdtempSync(join(tmpdir(), "post-tool-postgresql-cwd-")); extraDirs.push(inputCwd);