diff --git a/CHANGELOG.md b/CHANGELOG.md index 691cf10..bfd3a97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## [Unreleased] +### Added + +- **Codex (OpenAI) support — `axme-code setup --ide=codex`.** Writes `.codex/config.toml` (`[mcp_servers.axme]` with an absolute, PATH-independent command), `.codex/hooks.json` (SessionStart + PreToolUse + PostToolUse + SessionEnd, Codex's Claude-Code-compatible hook protocol) and the `## AXME Code` section in `AGENTS.md` (Codex's CLAUDE.md analogue, including the `#!axme` git gate). New `codex` hook adapter normalizes Codex's `apply_patch` edits (one call, several files) into the IDE-agnostic file tracking and safety cores; new `hook session-start` subcommand prints the "call axme_context" nudge as Codex developer context. The background auditor keeps running on the Claude Agent SDK; Codex's own transcript is not yet parsed. +- **Shell-side denied-path enforcement.** `checkBash` now blocks shell reads of denied filesystem paths (`cat ~/.ssh/id_rsa`, `/etc/shadow`), quoted or chained. Codex routes every file read through the shell, so without this the filesystem deny rules did not apply there at all; on Claude Code and Cursor it closes the same gap for `Bash`. Only absolute and `~`-relative deny patterns participate — bare basename patterns (`.env`, `*.pem`) are deliberately excluded so commit messages and heredocs that mention such a file are not blocked. + ## [0.6.3] - 2026-06-25 Reliability release for the `axme_save_memory` / `axme_save_decision` write tools, driven by two independent agent sessions that hit the same failure mode in production. diff --git a/README.md b/README.md index 29d5096..a8654e3 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,7 @@ AXME Code is a [stdio MCP server](https://modelcontextprotocol.io/) — every MC | Client | MCP Tools | Safety Hooks | Auto Auditor | |---|---|---|---| | **Claude Code** (CLI / VS Code) | ✅ Full | ✅ Full | ✅ Yes | +| **Codex** (CLI / IDE extension) — `axme-code setup --ide=codex` | ✅ Full | ✅ Full | ✅ Yes | | **Cursor** | ✅ Full | ❌ | ❌ | | **Windsurf** | ✅ Full | ❌ | ❌ | | **Cline** (VS Code) | ✅ Full | ❌ | ❌ | @@ -229,7 +230,7 @@ Just place it in your client's MCP config file: - **Cline:** VS Code Settings → Cline MCP → `cline_mcp_settings.json` - **Claude Desktop:** `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or equivalent -Pre-execution safety hooks, the post-tool-use file tracker, and the background session auditor are Claude Code-specific (they require Claude Code's hook system). In other clients, the agent must call AXME tools explicitly — same knowledge base, same `.axme-code/` storage, just no automatic enforcement layer. +Pre-execution safety hooks, the post-tool-use file tracker, and the background session auditor need a hook system: Claude Code has one, and so does Codex (`axme-code setup --ide=codex` writes `.codex/config.toml`, `.codex/hooks.json` and the `AGENTS.md` section — see [docs/MULTI_CLIENT.md](docs/MULTI_CLIENT.md#codex-openai)). In other clients, the agent must call AXME tools explicitly — same knowledge base, same `.axme-code/` storage, just no automatic enforcement layer. See [docs/MULTI_CLIENT.md](docs/MULTI_CLIENT.md) for full per-client setup, hook workarounds, and concurrent-client semantics. diff --git a/docs/MULTI_CLIENT.md b/docs/MULTI_CLIENT.md index c1b41dc..bac8030 100644 --- a/docs/MULTI_CLIENT.md +++ b/docs/MULTI_CLIENT.md @@ -7,6 +7,7 @@ AXME Code is built as a [stdio MCP server](https://modelcontextprotocol.io/) — | Client | MCP Tools | Safety Hooks | Session-end Auditor | File-change Tracking | |--------|-----------|--------------|---------------------|----------------------| | **Claude Code** (CLI / VS Code) | ✅ Full | ✅ Full | ✅ Automatic | ✅ Automatic | +| **Codex** (CLI / IDE extension) | ✅ Full | ✅ Full (`--ide=codex`) | ✅ Automatic¹ | ✅ Automatic | | **Cursor** | ✅ Full | ❌ | ❌ | ❌ | | **Windsurf** | ✅ Full | ❌ | ❌ | ❌ | | **Cline** (VS Code) | ✅ Full | ❌ | ❌ | ❌ | @@ -15,7 +16,9 @@ AXME Code is built as a [stdio MCP server](https://modelcontextprotocol.io/) — **MCP tools available everywhere** — the full set of 19 tools (`axme_context`, `axme_save_memory`, `axme_save_decision`, `axme_safety`, `axme_status`, etc.) works in any MCP client. Your agent can read and write the knowledge base. -**Hooks are Claude Code-only** — pre-execution safety hooks, post-tool-use file tracking, and the session-end background auditor depend on Claude Code's hook system. In other clients, the agent must explicitly call AXME tools (e.g., to save memories at session close). +**Hooks need a hook system** — pre-execution safety hooks, post-tool-use file tracking, and the session-end background auditor run on Claude Code and on Codex (whose hook protocol is Claude-Code-compatible). In other clients, the agent must explicitly call AXME tools (e.g., to save memories at session close). + +¹ The background auditor still runs on the Claude Agent SDK, so it needs a `claude` login or `ANTHROPIC_API_KEY` even when the session itself ran in Codex. Codex's own transcript is not yet fed to the auditor; the audit sees the files changed, the worklog, and whatever the agent saved during the session. ## Quick Start (any client) @@ -57,6 +60,28 @@ Just place it in the right config file for your client. Already done by `axme-code setup` — `.mcp.json` is created in your project root. Just run `claude` and start using it. +### Codex (OpenAI) + +Full support — MCP tools, safety hooks, file tracking and the session-end auditor: + +```bash +cd your-project +axme-code setup --ide=codex +``` + +This writes three project-level files and leaves Claude Code's files alone: + +- `.codex/config.toml` — `[mcp_servers.axme]` pointing at the installed binary (absolute path, so it also works when Codex is launched from the IDE extension or the desktop app). +- `.codex/hooks.json` — `SessionStart` (prints the "call axme_context" nudge as developer context), `PreToolUse` (safety + git gate), `PostToolUse` (file tracking from `apply_patch`), `SessionEnd` (spawns the detached auditor; Codex caps this hook at 3s, which is enough). +- `AGENTS.md` — the `## AXME Code` section (the CLAUDE.md analogue), including the `#!axme` commit/push gate rule. + +Two Codex-specific notes: + +- **Trust the project.** Codex loads a project's `.codex/` layer only for trusted projects. Accept the trust prompt the first time you run `codex` in the repo, or add `[projects.""]` with `trust_level = "trusted"` to `~/.codex/config.toml`. +- **Shell reads are policed.** Codex has no `Read` tool — every file read is a shell command — so the `PreToolUse` hook also blocks shell reads of denied paths (`cat ~/.ssh/id_rsa`, `/etc/shadow`, ...). Only absolute and `~`-relative deny patterns apply to the shell; bare basename patterns such as `.env` do not, so commit messages and heredocs that merely mention a file are not blocked. + +The same `.axme-code/` knowledge base is shared with Claude Code, so you can switch between the two on one project. + ### Cursor Add to `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (per-project): @@ -165,8 +190,8 @@ The knowledge base is the single source of truth regardless of which client crea ## Limitations -- **Hooks are not portable** — Claude Code's hook system is unique. Other clients will not get automatic safety blocking, file tracking, or session-end audits. -- **CLAUDE.md auto-injection** — only Claude Code reads `CLAUDE.md`. Other clients ignore it; you may need to provide instructions to your agent another way (e.g., a system prompt). +- **Hooks are not portable** — only Claude Code and Codex have a hook system AXME can plug into. Other clients will not get automatic safety blocking, file tracking, or session-end audits. +- **CLAUDE.md auto-injection** — only Claude Code reads `CLAUDE.md`; Codex reads `AGENTS.md` (written by `--ide=codex`). Other clients ignore both; you may need to provide instructions to your agent another way (e.g., a system prompt). - **`.mcp.json` is Claude Code-specific** — `axme-code setup` writes this file, but other clients use different config locations (see above). - **Session-to-session linking** — AXME tracks Claude Code's session IDs to link conversations across VS Code windows. Other clients don't expose session IDs the same way, so cross-window session linking may not work; sessions will still be tracked individually. diff --git a/src/cli.ts b/src/cli.ts index 5d4efc6..b3ffe7a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,7 +5,7 @@ * axme-code setup [path] - Full init (LLM if API key available) + .mcp.json + CLAUDE.md * axme-code serve - Start MCP server (stdio, used by .mcp.json) * axme-code status [path] - Show project status - * axme-code hook - Run hook (pre-tool-use, post-tool-use, session-end) + * axme-code hook - Run hook (session-start, pre-tool-use, post-tool-use, session-end) */ import { resolve, join, basename } from "node:path"; @@ -251,6 +251,17 @@ function buildHookCommand(hookName: string, projectPath: string): string { return `${q(nodeExec)} ${q(self)} hook ${hookName} --workspace ${q(projectPath)}`; } +/** + * MCP server launch spec for config writers that need an absolute, + * PATH-independent command (Codex's config.toml — Codex launched from the + * IDE extension or the desktop app does not necessarily see the login + * shell's PATH). Same ingredients as buildHookCommand(): the running node + * binary plus the running axme-code entry file. + */ +function buildServeCommand(): { command: string; args: string[] } { + return { command: process.execPath, args: [resolve(process.argv[1] ?? "axme-code"), "serve"] }; +} + function configureHooks(projectPath: string): void { const claudeDir = join(projectPath, ".claude"); const settingsPath = join(claudeDir, "settings.json"); @@ -355,10 +366,11 @@ function usage(): void { console.log(`AXME Code - Persistent memory, decisions, and safety guardrails for Claude Code Usage: - axme-code setup [path] [--force] [--ide=] + axme-code setup [path] [--force] [--ide=] Initialize project (LLM scan + .mcp.json + CLAUDE.md - for Claude Code, or .cursor/{mcp,hooks}.json + rules - /axme-code.mdc for Cursor). Defaults to claude-code. + for Claude Code; .cursor/{mcp,hooks}.json + rules + /axme-code.mdc for Cursor; .codex/{config.toml, + hooks.json} + AGENTS.md for Codex). Defaults to claude-code. axme-code serve Start MCP server (stdio transport) axme-code self-test Run local healthcheck (storage write, hook parse, MCP boot). Exits 0 on @@ -413,9 +425,9 @@ async function main() { const pluginMode = args.includes("--plugin") || !!process.env.CLAUDE_PLUGIN_ROOT; const { parseIdeFlag } = await import("./utils/ide-detect.js"); const ide = parseIdeFlag(args) ?? "claude-code"; - if (ide === "cursor" && pluginMode) { - console.error("Error: --ide=cursor is not supported with --plugin in this release."); - console.error("Run setup without --plugin (Cursor plugin packaging is a Phase 2 follow-up)."); + if (ide !== "claude-code" && pluginMode) { + console.error(`Error: --ide=${ide} is not supported with --plugin in this release.`); + console.error("Run setup without --plugin (per-IDE plugin packaging is a later follow-up)."); process.exit(1); } // Strip --force, --plugin, --ide and its value from positional args. @@ -431,7 +443,7 @@ async function main() { if (a === "--help" || a === "-h") { usage(); process.exit(0); } if (a.startsWith("-")) { console.error(`Error: unknown flag for setup: ${a}`); - console.error("Usage: axme-code setup [path] [--force] [--plugin] [--ide=]"); + console.error("Usage: axme-code setup [path] [--force] [--plugin] [--ide=]"); process.exit(2); } setupArgs.push(a); @@ -630,6 +642,21 @@ async function main() { console.log(" .cursor/hooks.json: hooks configured (preToolUse + postToolUse + sessionEnd)"); writeCursorRulesMdc(projectPath, isWorkspace); console.log(" .cursor/rules/axme-code.mdc: created"); + } else if (ide === "codex") { + // Codex branch: .codex/config.toml + .codex/hooks.json + AGENTS.md. + // Skip CLAUDE.md and .claude/settings.json (Codex reads neither). + const { writeCodexConfigToml, writeCodexHooksJson, writeAgentsMdSection, buildAgentsMdSection } = + await import("./setup/codex-writers.js"); + const codexPaths = [projectPath]; + if (isWorkspace) { + for (const p of ws.projects) codexPaths.push(join(projectPath, p.path)); + } + for (const dir of codexPaths) writeCodexConfigToml(dir, buildServeCommand()); + console.log(` .codex/config.toml: mcp_servers.axme updated (${codexPaths.length} locations)`); + writeCodexHooksJson(projectPath, buildHookCommand); + console.log(" .codex/hooks.json: hooks configured (SessionStart + PreToolUse + PostToolUse + SessionEnd)"); + const section = buildAgentsMdSection(isWorkspace ? WORKSPACE_CLAUDE_MD : SINGLE_REPO_CLAUDE_MD); + console.log(` AGENTS.md: ${writeAgentsMdSection(projectPath, section)} AXME Code section`); } else { // Claude Code branch (unchanged): CLAUDE.md + .claude/settings.json generateClaudeMd(projectPath, isWorkspace); @@ -697,6 +724,11 @@ async function main() { // Tell each user what their next step actually is. if (ide === "cursor") { console.log("\nDone! Open a new chat in Cursor — AXME tools are now available."); + } else if (ide === "codex") { + console.log("\nDone! Run 'codex' in this project — AXME tools are now available."); + console.log(" Codex loads the project's .codex/ config and hooks only for TRUSTED projects:"); + console.log(" accept the trust prompt on first launch (or set projects.\"\".trust_level = \"trusted\""); + console.log(" in ~/.codex/config.toml), then start a new session."); } else { console.log("\nDone! Run 'claude' to start using AXME tools."); } @@ -741,6 +773,10 @@ async function main() { } else if (hookName === "session-end") { const { runSessionEndHook } = await import("./hooks/session-end.js"); await runSessionEndHook(workspacePath, ide); + } else if (hookName === "session-start") { + // Codex only: stdout becomes developer context for the session. + const { runSessionStartHook } = await import("./hooks/session-start.js"); + await runSessionStartHook(workspacePath); } break; } diff --git a/src/hooks/adapters/codex.ts b/src/hooks/adapters/codex.ts new file mode 100644 index 0000000..7d20607 --- /dev/null +++ b/src/hooks/adapters/codex.ts @@ -0,0 +1,104 @@ +/** + * Codex CLI hook adapter. + * + * Stdin shape (per learn.chatgpt.com/docs/hooks, checked against codex-cli + * 0.166, 2026-11): the common base carries session_id, transcript_path + * (string | null), cwd, hook_event_name, model, permission_mode, turn_id. + * PreToolUse / PostToolUse add tool_name, tool_input, tool_use_id (and + * tool_response on Post). SessionEnd adds reason (always "other"). + * + * The protocol is deliberately Claude-Code-compatible: PascalCase event + * names, the same hooks.json shape, and the same deny envelope + * (`hookSpecificOutput.permissionDecision`, exit 0). The output adapter is + * therefore the Claude Code one, re-exported under a Codex name. + * + * What differs for the IDE-agnostic hook cores: + * - Shell commands arrive as tool_name "Bash" with tool_input.command, + * exactly like Claude Code. + * - File edits arrive as tool_name "apply_patch" with the whole patch in + * tool_input.command (Codex has no Edit / Write / Read tools). We + * normalize the name to "Edit" and collect every touched path from the + * patch header lines (`*** Add File:`, `*** Update File:`, + * `*** Delete File:`, `*** Move to:`) into `filePaths`, resolved + * against the payload's cwd when relative. + * - MCP tool calls arrive as "mcp____" and carry no shell + * or file payload, so they fall through the safety switch untouched. + */ + +import { isAbsolute, resolve } from "node:path"; +import { claudeCodeOutputAdapter } from "./claude-code.js"; +import type { HookInputAdapter, HookOutputAdapter, NormalizedHookEvent } from "./types.js"; + +function asString(v: unknown): string | undefined { + return typeof v === "string" ? v : undefined; +} + +function asObject(v: unknown): Record | undefined { + return v && typeof v === "object" && !Array.isArray(v) + ? v as Record + : undefined; +} + +const PATCH_HEADER = /^\*\*\* (?:(?:Add|Update|Delete) File|Move to): (.+)$/; + +/** + * Extract every path named in an apply_patch envelope, in order of first + * appearance, deduplicated. Relative paths are resolved against `cwd` + * when it is known; otherwise they are returned as written. + */ +export function extractPatchPaths(patch: string, cwd?: string): string[] { + const out: string[] = []; + for (const line of patch.split(/\r?\n/)) { + const m = PATCH_HEADER.exec(line); + if (!m) continue; + const raw = m[1].trim(); + if (!raw) continue; + const abs = isAbsolute(raw) || !cwd ? raw : resolve(cwd, raw); + if (!out.includes(abs)) out.push(abs); + } + return out; +} + +export const codexInputAdapter: HookInputAdapter = { + parse(raw, kind): NormalizedHookEvent { + const obj = (raw && typeof raw === "object" ? raw : {}) as Record; + const rawToolName = asString(obj.tool_name); + const toolInput = asObject(obj.tool_input); + const cwd = asString(obj.cwd); + const sessionId = asString(obj.session_id); + + // transcript_path is documented as string | null. Preserve null so + // callers can tell "no transcript yet" from "field absent". + let transcriptPath: string | null | undefined; + if (obj.transcript_path === null) transcriptPath = null; + else transcriptPath = asString(obj.transcript_path); + + const reason = kind === "sessionEnd" ? asString(obj.reason) : undefined; + + let toolName = rawToolName; + let filePaths: string[] | undefined; + if (rawToolName === "apply_patch") { + toolName = "Edit"; + // The docs name `command`; the underlying custom tool call uses + // `input`. Accept both so a payload-shape drift degrades to "no + // paths found" rather than a parse failure. + const patch = asString(toolInput?.command) ?? asString(toolInput?.input) ?? ""; + filePaths = extractPatchPaths(patch, cwd); + } + + return { + kind, + ide: "codex", + toolName, + toolInput, + filePaths, + sessionId, + transcriptPath, + reason, + raw: obj, + }; + }, +}; + +/** Codex accepts the Claude Code deny envelope verbatim (exit 0 + JSON). */ +export const codexOutputAdapter: HookOutputAdapter = claudeCodeOutputAdapter; diff --git a/src/hooks/adapters/types.ts b/src/hooks/adapters/types.ts index 75177e5..6e49410 100644 --- a/src/hooks/adapters/types.ts +++ b/src/hooks/adapters/types.ts @@ -23,6 +23,15 @@ export interface NormalizedHookEvent { toolName?: string; /** PreToolUse / PostToolUse only — the arguments the agent passed. */ toolInput?: Record; + /** + * Every file path this tool call touches, for IDEs that report edits in + * a shape other than Claude Code's single `tool_input.file_path`. Codex + * delivers one `apply_patch` call that may add, update or delete several + * files at once; its adapter extracts the paths here so the safety and + * file-tracking cores stay IDE-agnostic. Undefined on Claude Code and + * Cursor — handlers then fall back to `tool_input.file_path`. + */ + filePaths?: string[]; /** IDE's own session id (Claude Code session_id, Cursor session_id, etc.). */ sessionId?: string; /** Path to the IDE's transcript file, if any. May be null on Cursor. */ diff --git a/src/hooks/post-tool-use.ts b/src/hooks/post-tool-use.ts index aa67df2..555ec85 100644 --- a/src/hooks/post-tool-use.ts +++ b/src/hooks/post-tool-use.ts @@ -21,10 +21,13 @@ import { AXME_CODE_DIR } from "../types.js"; import type { IdeKind } from "../types.js"; import { claudeCodeInputAdapter } from "./adapters/claude-code.js"; import { cursorInputAdapter } from "./adapters/cursor.js"; +import { codexInputAdapter } from "./adapters/codex.js"; import type { HookInputAdapter, NormalizedHookEvent } from "./adapters/types.js"; function inputAdapterFor(ide: IdeKind): HookInputAdapter { - return ide === "cursor" ? cursorInputAdapter : claudeCodeInputAdapter; + if (ide === "cursor") return cursorInputAdapter; + if (ide === "codex") return codexInputAdapter; + return claudeCodeInputAdapter; } function handlePostToolUse(workspacePath: string, event: NormalizedHookEvent): void { @@ -48,11 +51,14 @@ function handlePostToolUse(workspacePath: string, event: NormalizedHookEvent): v // filesChanged tracking only for mutation tools if (!["Edit", "Write", "NotebookEdit"].includes(tool_name)) return; - const filePath = tool_input.file_path || tool_input.path; - if (!filePath || typeof filePath !== "string") return; - if (filePath.includes(AXME_CODE_DIR)) return; - - trackFileChanged(workspacePath, axmeSessionId, filePath); + // Codex's apply_patch may touch several files in one call — the adapter + // lists them in event.filePaths. Claude Code / Cursor name one file. + const single = tool_input.file_path || tool_input.path; + const paths = event.filePaths ?? (typeof single === "string" && single ? [single] : []); + for (const filePath of paths) { + if (filePath.includes(AXME_CODE_DIR)) continue; + trackFileChanged(workspacePath, axmeSessionId, filePath); + } } /** diff --git a/src/hooks/pre-tool-use.ts b/src/hooks/pre-tool-use.ts index 90d942c..8cf70e8 100644 --- a/src/hooks/pre-tool-use.ts +++ b/src/hooks/pre-tool-use.ts @@ -22,10 +22,12 @@ import type { IdeKind, SafetyRules } from "../types.js"; import type { SafetyVerdict } from "../storage/safety.js"; import { claudeCodeInputAdapter, claudeCodeOutputAdapter } from "./adapters/claude-code.js"; import { cursorInputAdapter, cursorOutputAdapter } from "./adapters/cursor.js"; +import { codexInputAdapter, codexOutputAdapter } from "./adapters/codex.js"; import type { HookInputAdapter, HookOutputAdapter, NormalizedHookEvent } from "./adapters/types.js"; function adaptersFor(ide: IdeKind): { input: HookInputAdapter; output: HookOutputAdapter } { if (ide === "cursor") return { input: cursorInputAdapter, output: cursorOutputAdapter }; + if (ide === "codex") return { input: codexInputAdapter, output: codexOutputAdapter }; return { input: claudeCodeInputAdapter, output: claudeCodeOutputAdapter }; } @@ -175,10 +177,15 @@ function handlePreToolUse( case "Edit": case "Write": case "NotebookEdit": { - const filePath = (tool_input.file_path || tool_input.path) as string; - if (filePath) { + // Codex reports every file of an apply_patch call in event.filePaths; + // Claude Code and Cursor name exactly one file in tool_input. Check + // each path — the first denial wins. + const single = (tool_input.file_path || tool_input.path) as string | undefined; + const paths = event.filePaths ?? (single ? [single] : []); + for (const filePath of paths) { const rules = loadRulesForFile(filePath); verdict = checkFilePath(rules, filePath, "write"); + if (!verdict.allowed) break; } break; } @@ -191,7 +198,8 @@ function handlePreToolUse( const axmeSessionId = mapping ?? "unknown"; const target = tool_name === "Bash" ? (tool_input.command as string ?? "").slice(0, 120) - : (tool_input.file_path || tool_input.path || "") as string; + : ((tool_input.file_path || tool_input.path) as string | undefined) + ?? (event.filePaths ?? []).join(","); logSafetyBlock(sessionOrigin, axmeSessionId, tool_name, target, "safety_rule", verdict.reason); } catch {} return deny(verdict.reason, output); diff --git a/src/hooks/session-end.ts b/src/hooks/session-end.ts index d6ba0dd..f6bcaee 100644 --- a/src/hooks/session-end.ts +++ b/src/hooks/session-end.ts @@ -27,10 +27,13 @@ import { AXME_CODE_DIR } from "../types.js"; import type { IdeKind } from "../types.js"; import { claudeCodeInputAdapter } from "./adapters/claude-code.js"; import { cursorInputAdapter } from "./adapters/cursor.js"; +import { codexInputAdapter } from "./adapters/codex.js"; import type { HookInputAdapter, NormalizedHookEvent } from "./adapters/types.js"; function inputAdapterFor(ide: IdeKind): HookInputAdapter { - return ide === "cursor" ? cursorInputAdapter : claudeCodeInputAdapter; + if (ide === "cursor") return cursorInputAdapter; + if (ide === "codex") return codexInputAdapter; + return claudeCodeInputAdapter; } function handleSessionEnd(workspacePath: string, event: NormalizedHookEvent): void { diff --git a/src/hooks/session-start.ts b/src/hooks/session-start.ts new file mode 100644 index 0000000..f2ed2bf --- /dev/null +++ b/src/hooks/session-start.ts @@ -0,0 +1,39 @@ +/** + * SessionStart hook — Codex only (Claude Code gets the same nudge from the + * plugin's `check-init` subcommand, Cursor from the extension). + * + * Codex injects whatever a SessionStart hook prints on stdout into the + * session as developer context. We use that to deliver the one line the + * agent must act on before anything else: call axme_context. No files are + * written here — AGENTS.md already carries the full instructions; this is + * the per-session reminder that survives even when the agent skims it. + * + * Output is plain text on purpose (not the hookSpecificOutput JSON form): + * both reach the model, and plain text cannot be mis-parsed. + */ + +import { configExists } from "../storage/config.js"; + +export async function runSessionStartHook(workspacePath?: string): Promise { + if (process.env.AXME_SKIP_HOOKS === "1") return; + + let cwdFromStdin: string | undefined; + try { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) chunks.push(chunk); + const raw = JSON.parse(Buffer.concat(chunks).toString("utf-8")) as { cwd?: unknown }; + if (typeof raw.cwd === "string") cwdFromStdin = raw.cwd; + } catch { + // Empty or non-JSON stdin is fine — --workspace / cwd fallback below. + } + + const root = workspacePath ?? cwdFromStdin ?? process.cwd(); + if (configExists(root)) { + process.stdout.write("[AXME Code] Knowledge base ready. Call axme_context now.\n"); + } else { + process.stdout.write( + "[AXME Code] Project not initialized. Ask the user to run `axme-code setup --ide=codex` " + + "in a terminal, then start a new Codex session.\n", + ); + } +} diff --git a/src/setup/codex-writers.ts b/src/setup/codex-writers.ts new file mode 100644 index 0000000..2df4ccd --- /dev/null +++ b/src/setup/codex-writers.ts @@ -0,0 +1,228 @@ +/** + * Idempotent writers for Codex-specific config files. + * + * Layout (all project-level): + * .codex/config.toml — [mcp_servers.axme] registration (Codex config is TOML) + * .codex/hooks.json — SessionStart / PreToolUse / PostToolUse / SessionEnd + * AGENTS.md — the "## AXME Code" section (Codex's CLAUDE.md analogue) + * + * Codex loads a project's .codex/ layer (config, hooks, rules) only once the + * project is trusted (`projects."".trust_level = "trusted"` in + * ~/.codex/config.toml). Codex offers to set that the first time it opens + * the repo; setup prints a reminder. + * + * Codex's hooks.json is the Claude Code settings.json `hooks` shape + * verbatim (PascalCase events, `{ matcher?, hooks: [{ type, command, + * timeout }] }` groups), so the writer mirrors configureHooks() in cli.ts + * rather than the camelCase Cursor one. Every command gets `--ide codex` + * appended so the hook subprocess picks the right adapter without + * re-detecting. + * + * Idempotency rule shared with the other writers: entries that mention + * "axme-code" are ours and get replaced; everything else is preserved + * verbatim. A file that exists but cannot be parsed is left untouched + * (refuse-don't-clobber). + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +type CodexHookEvent = "SessionStart" | "PreToolUse" | "PostToolUse" | "SessionEnd"; + +/** Timeouts in seconds (Codex semantics). SessionEnd is capped at 3 by Codex. */ +const HOOK_TIMEOUT_S: Record = { + SessionStart: 10, + PreToolUse: 5, + PostToolUse: 10, + SessionEnd: 3, +}; + +const CLI_HOOK_NAME: Record = { + SessionStart: "session-start", + PreToolUse: "pre-tool-use", + PostToolUse: "post-tool-use", + SessionEnd: "session-end", +}; + +/** + * PostToolUse fires for file edits only. Codex reports edits as + * `apply_patch`; its matcher also accepts the Claude Code aliases, listed + * for readers who grep for them. + */ +const POST_TOOL_USE_MATCHER = "apply_patch|Edit|Write"; + +interface CodexHookCommand { + type?: "command"; + command: string; + timeout?: number; + [key: string]: unknown; +} + +interface CodexHookGroup { + matcher?: string; + hooks: CodexHookCommand[]; + [key: string]: unknown; +} + +interface CodexHooksFile { + hooks?: Partial>; + [key: string]: unknown; +} + +export interface ServeCommand { + command: string; + args: string[]; +} + +/** + * Read a JSON file, returning `fallback` when it doesn't exist (or is + * empty) and `null` when it exists but cannot be parsed. Callers MUST + * treat `null` as "refuse to write". + */ +function readJsonOr(path: string, fallback: T): T | null { + if (!existsSync(path)) return fallback; + try { + const raw = readFileSync(path, "utf-8"); + if (!raw.trim()) return fallback; + return JSON.parse(raw) as T; + } catch { + return null; + } +} + +function writeText(path: string, content: string): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content, "utf-8"); +} + +/** + * Write `.codex/hooks.json` with four PascalCase event arrays. Existing + * groups that mention "axme-code" anywhere are dropped before re-insert; + * user-added groups are preserved verbatim. + */ +export function writeCodexHooksJson( + projectPath: string, + buildHookCommand: (hookName: string, projectPath: string) => string, +): void { + const path = join(projectPath, ".codex", "hooks.json"); + const cfg = readJsonOr(path, {}); + if (cfg === null) { + console.error(` ${path}: SKIPPED — existing file is not valid JSON. Fix or remove it, then re-run setup; refusing to overwrite user config.`); + return; + } + if (!cfg.hooks) cfg.hooks = {}; + + const events: CodexHookEvent[] = ["SessionStart", "PreToolUse", "PostToolUse", "SessionEnd"]; + for (const event of events) { + const existing = cfg.hooks[event] ?? []; + const preserved = existing.filter((g) => !JSON.stringify(g).includes("axme-code")); + const fresh: CodexHookGroup = { + hooks: [{ + type: "command", + command: `${buildHookCommand(CLI_HOOK_NAME[event], projectPath)} --ide codex`, + timeout: HOOK_TIMEOUT_S[event], + }], + }; + if (event === "PostToolUse") fresh.matcher = POST_TOOL_USE_MATCHER; + cfg.hooks[event] = [...preserved, fresh]; + } + + writeText(path, JSON.stringify(cfg, null, 2) + "\n"); +} + +/** TOML basic strings share JSON's escape rules for everything a path can contain. */ +function tomlString(s: string): string { + return JSON.stringify(s); +} + +function mcpServerTable(serve: ServeCommand): string { + return [ + "[mcp_servers.axme]", + `command = ${tomlString(serve.command)}`, + `args = [${serve.args.map(tomlString).join(", ")}]`, + "", + ].join("\n"); +} + +const AXME_TABLE_HEADER = /^\[mcp_servers\.axme\]\s*$/; +const ANY_TABLE_HEADER = /^\s*\[/; + +/** + * Write `[mcp_servers.axme]` into `.codex/config.toml`, replacing a + * previous axme table in place and leaving every other line (other + * servers, comments, unrelated keys) byte-for-byte untouched. We edit the + * text rather than parse-and-dump so user comments survive. + */ +export function writeCodexConfigToml(projectPath: string, serve: ServeCommand): void { + const path = join(projectPath, ".codex", "config.toml"); + const table = mcpServerTable(serve); + const existing = existsSync(path) ? readFileSync(path, "utf-8") : ""; + + if (!existing.trim()) { + writeText(path, table); + return; + } + + const lines = existing.split("\n"); + const start = lines.findIndex((l) => AXME_TABLE_HEADER.test(l)); + if (start === -1) { + const sep = existing.endsWith("\n") ? "\n" : "\n\n"; + writeText(path, existing + sep + table); + return; + } + // The table body runs until the next table header (any `[...]` line) or EOF. + let end = start + 1; + while (end < lines.length && !ANY_TABLE_HEADER.test(lines[end])) end++; + // Keep one blank line between our table and whatever follows. + const tail = lines.slice(end); + const replaced = [...lines.slice(0, start), ...table.trimEnd().split("\n"), ...(tail.length ? ["", ...tail] : [""])]; + writeText(path, replaced.join("\n")); +} + +const CODEX_SECTION_SUFFIX = ` +### Git commit/push gate +Every \`git commit\` and \`git push\` command MUST end with the marker: +\`\`\` +#!axme pr= repo= +\`\`\` +Without this suffix the PreToolUse hook blocks the command. Use pr=none when no PR exists yet. +`; + +/** + * The AGENTS.md section: the CLAUDE.md template (passed in by the CLI so + * the two stay in lockstep) plus the git gate, which Claude Code learns + * from the MCP server instructions but Codex does not surface. + */ +export function buildAgentsMdSection(baseSection: string): string { + return baseSection.trimEnd() + "\n" + CODEX_SECTION_SUFFIX; +} + +/** + * Insert or refresh the "## AXME Code" section in AGENTS.md. Unlike the + * CLAUDE.md writer this replaces ONLY our section — everything after the + * next top-level `## ` heading is kept — because AGENTS.md is the user's + * file and routinely has content after ours. + * + * Returns what happened, for the setup log line. + */ +export function writeAgentsMdSection(projectPath: string, section: string): "created" | "appended" | "updated" { + const path = join(projectPath, "AGENTS.md"); + const marker = "## AXME Code"; + const body = section.endsWith("\n") ? section : section + "\n"; + + if (!existsSync(path)) { + writeText(path, body); + return "created"; + } + const content = readFileSync(path, "utf-8"); + const start = content.indexOf(marker); + if (start === -1) { + writeText(path, content.trimEnd() + "\n\n" + body); + return "appended"; + } + const nextHeading = content.indexOf("\n## ", start + marker.length); + const after = nextHeading === -1 ? "" : content.slice(nextHeading + 1); + const before = content.slice(0, start); + writeText(path, before + body + (after ? "\n" + after : "")); + return "updated"; +} diff --git a/src/storage/safety.ts b/src/storage/safety.ts index 5b2b7c4..d012531 100644 --- a/src/storage/safety.ts +++ b/src/storage/safety.ts @@ -313,9 +313,82 @@ export function checkBash(rules: SafetyRules, command: string): SafetyVerdict { } } } + + // Denied filesystem paths reach the shell too: `cat ~/.ssh/id_rsa` must + // be blocked exactly like a Read tool call on that path. This is the only + // guard for IDEs without a dedicated file-read tool (Codex routes every + // read through the shell) and belt-and-suspenders for the others. + const pathVerdict = checkShellPaths(rules, command); + if (pathVerdict) return pathVerdict; + return { allowed: true }; } +/** + * Split a command into words the way a shell would for our purposes: + * whitespace separates tokens, single/double quotes group, backslash + * escapes. Quotes are stripped from the result so `cat "~/.ssh/id_rsa"` + * yields the bare path. (stripQuoted() cannot be used here — it DROPS + * quoted content, which is exactly where a path may hide.) + */ +function shellTokens(command: string): string[] { + const tokens: string[] = []; + let cur = ""; + let has = false; + let inSingle = false; + let inDouble = false; + for (let i = 0; i < command.length; i++) { + const ch = command[i]; + if (ch === "\\" && !inSingle && i + 1 < command.length) { cur += command[++i]; has = true; continue; } + if (ch === "'" && !inDouble) { inSingle = !inSingle; has = true; continue; } + if (ch === '"' && !inSingle) { inDouble = !inDouble; has = true; continue; } + if (!inSingle && !inDouble && /\s/.test(ch)) { + if (has) tokens.push(cur); + cur = ""; has = false; + continue; + } + cur += ch; has = true; + } + if (has) tokens.push(cur); + return tokens; +} + +/** + * Shell-side enforcement of denied filesystem paths (read semantics). + * + * Only anchored deny patterns participate — those starting with "/" or + * "~". Bare basename patterns (".env", "*.pem", "*.key") are skipped on + * purpose: matched against free text they would block commit messages and + * heredoc bodies that merely MENTION such a file far more often than they + * would catch a real read. Likewise only path-like tokens (containing a + * separator or starting with "~") are examined. + * + * Both sides are "~"-expanded and run through normalizePathForSafety(): + * homedir() uses backslashes on Windows while deny patterns are written + * with "/", so a raw comparison never matches there. + */ +function checkShellPaths(rules: SafetyRules, command: string): SafetyVerdict | null { + const anchored = rules.filesystem.deniedPaths.filter(p => p.startsWith("/") || p.startsWith("~")); + if (anchored.length === 0) return null; + const patterns = anchored.map(p => ({ original: p, normalized: normalizePathForSafety(expandHome(p)) })); + for (const token of shellTokens(command)) { + const pathLike = token.includes("/") || token.startsWith("~") || /^[A-Za-z]:[\\/]/.test(token); + if (!pathLike) continue; + const normalized = normalizePathForSafety(expandHome(token)); + for (const pattern of patterns) { + if (matchesPattern(normalized, pattern.normalized)) { + return { allowed: false, reason: `Path denied: ${pattern.original}` }; + } + } + } + return null; +} + +/** "~" and "~/x" → the user's home; everything else unchanged. */ +function expandHome(p: string): string { + return p === "~" || p.startsWith("~/") ? homedir() + p.slice(1) : p; +} + /** * Detect the current git branch. Tries `git branch --show-current` in cwd, * falls back to scanning child directories for .git repos. diff --git a/src/types.ts b/src/types.ts index 4fc6371..4806535 100644 --- a/src/types.ts +++ b/src/types.ts @@ -201,7 +201,7 @@ export interface ClaudeSessionRef { * Which IDE the current session/hook event came from. Used to dispatch the * right hook stdin parser, deny-output emitter, and (later) LLM SDK adapter. */ -export type IdeKind = "claude-code" | "cursor"; +export type IdeKind = "claude-code" | "cursor" | "codex"; /** * Status of the LLM audit lifecycle on this session. Used to prevent parallel diff --git a/src/utils/ide-detect.ts b/src/utils/ide-detect.ts index d546d3a..dc222be 100644 --- a/src/utils/ide-detect.ts +++ b/src/utils/ide-detect.ts @@ -12,13 +12,18 @@ * The setup writers always emit `--ide cursor` explicitly in * `.cursor/hooks.json` commands, so the heuristic path is only a defence- * in-depth fallback for older configs. + * + * Codex is never inferred from stdin: its hook payload is deliberately + * Claude-Code-shaped (session_id, transcript_path, tool_name, ...), so + * there is no reliable discriminating key. `.codex/hooks.json` commands + * always carry `--ide codex` explicitly. */ import type { IdeKind } from "../types.js"; export type { IdeKind }; -const IDE_VALUES: ReadonlyArray = ["claude-code", "cursor"]; +const IDE_VALUES: ReadonlyArray = ["claude-code", "cursor", "codex"]; /** * Parse `--ide ` or `--ide=` out of an argv array. diff --git a/test/codex-hook-adapter.test.ts b/test/codex-hook-adapter.test.ts new file mode 100644 index 0000000..bbd5294 --- /dev/null +++ b/test/codex-hook-adapter.test.ts @@ -0,0 +1,142 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { resolve } from "node:path"; +import { codexInputAdapter, codexOutputAdapter, extractPatchPaths } from "../src/hooks/adapters/codex.js"; +import { claudeCodeOutputAdapter } from "../src/hooks/adapters/claude-code.js"; + +const BASE = { + session_id: "019a1b2c-codex-session", + transcript_path: "/home/me/.codex/sessions/2026/11/02/rollout-2026-11-02T10-00-00-019a1b2c.jsonl", + cwd: "/tmp/codex-smoke", + hook_event_name: "PreToolUse", + model: "gpt-5-codex", + permission_mode: "default", + turn_id: "turn-1", +}; + +const PATCH = `*** Begin Patch +*** Update File: src/a.ts +@@ +-const a = 1; ++const a = 2; +*** Add File: docs/new.md ++# hello +*** Delete File: old/junk.txt +*** Update File: src/a.ts +@@ +-x ++y +*** Update File: /abs/elsewhere.ts +@@ +-1 ++2 +*** End Patch`; + +describe("codexInputAdapter.parse — Bash", () => { + it("passes Bash through unchanged (Claude-Code-shaped payload)", () => { + const ev = codexInputAdapter.parse( + { ...BASE, tool_name: "Bash", tool_input: { command: "git push --force origin main" }, tool_use_id: "tu-1" }, + "preToolUse", + ); + assert.equal(ev.kind, "preToolUse"); + assert.equal(ev.ide, "codex"); + assert.equal(ev.toolName, "Bash"); + assert.deepEqual(ev.toolInput, { command: "git push --force origin main" }); + assert.equal(ev.sessionId, BASE.session_id); + assert.equal(ev.transcriptPath, BASE.transcript_path); + assert.equal(ev.filePaths, undefined); + }); +}); + +describe("codexInputAdapter.parse — apply_patch", () => { + it("normalizes apply_patch to Edit and lists every touched file, resolved against cwd", () => { + const ev = codexInputAdapter.parse( + { ...BASE, tool_name: "apply_patch", tool_input: { command: PATCH } }, + "postToolUse", + ); + assert.equal(ev.toolName, "Edit"); + // Relative paths resolve against cwd with the host's separators (Windows + // CI yields backslashes); absolute ones are passed through untouched. + assert.deepEqual(ev.filePaths, [ + resolve(BASE.cwd, "src/a.ts"), + resolve(BASE.cwd, "docs/new.md"), + resolve(BASE.cwd, "old/junk.txt"), + "/abs/elsewhere.ts", + ]); + // Raw input is preserved for anyone who needs the patch text. + assert.equal((ev.toolInput as { command: string }).command, PATCH); + }); + + it("accepts the custom-tool `input` field as a fallback for the patch text", () => { + const ev = codexInputAdapter.parse( + { ...BASE, tool_name: "apply_patch", tool_input: { input: "*** Begin Patch\n*** Add File: x.txt\n+1\n*** End Patch" } }, + "preToolUse", + ); + assert.equal(ev.toolName, "Edit"); + assert.deepEqual(ev.filePaths, [resolve(BASE.cwd, "x.txt")]); + }); + + it("yields an empty path list (not a crash) when the patch text is missing", () => { + const ev = codexInputAdapter.parse({ ...BASE, tool_name: "apply_patch", tool_input: {} }, "preToolUse"); + assert.equal(ev.toolName, "Edit"); + assert.deepEqual(ev.filePaths, []); + }); +}); + +describe("extractPatchPaths", () => { + it("handles Move to headers and CRLF line endings", () => { + const patch = "*** Begin Patch\r\n*** Update File: a.txt\r\n*** Move to: b.txt\r\n*** End Patch\r\n"; + assert.deepEqual(extractPatchPaths(patch, "/w"), [resolve("/w", "a.txt"), resolve("/w", "b.txt")]); + }); + + it("returns relative paths as written when cwd is unknown", () => { + assert.deepEqual(extractPatchPaths("*** Add File: rel/x.ts\n", undefined), ["rel/x.ts"]); + }); + + it("ignores body lines that merely look like headers", () => { + const patch = "*** Begin Patch\n*** Update File: a.txt\n+*** Update File: not-a-header.txt\n*** End Patch"; + assert.deepEqual(extractPatchPaths(patch, "/w"), [resolve("/w", "a.txt")]); + }); +}); + +describe("codexInputAdapter.parse — sessionEnd", () => { + it("keys the session by session_id and keeps the reason", () => { + const ev = codexInputAdapter.parse( + { session_id: "s-9", transcript_path: null, cwd: "/tmp/x", hook_event_name: "SessionEnd", reason: "other" }, + "sessionEnd", + ); + assert.equal(ev.kind, "sessionEnd"); + assert.equal(ev.sessionId, "s-9"); + assert.equal(ev.transcriptPath, null); + assert.equal(ev.reason, "other"); + }); +}); + +describe("codexOutputAdapter.emitDeny", () => { + it("is the Claude Code envelope: hookSpecificOutput.permissionDecision=deny, exit 0", () => { + const result = codexOutputAdapter.emitDeny("force-push to main is blocked", "preToolUse"); + assert.equal(result.exitCode, 0); + const parsed = JSON.parse(result.stdout) as { hookSpecificOutput: Record }; + assert.equal(parsed.hookSpecificOutput.hookEventName, "PreToolUse"); + assert.equal(parsed.hookSpecificOutput.permissionDecision, "deny"); + assert.match(parsed.hookSpecificOutput.permissionDecisionReason, /\[AXME Safety\] force-push/); + assert.deepEqual(result, claudeCodeOutputAdapter.emitDeny("force-push to main is blocked", "preToolUse")); + }); +}); + +describe("codexInputAdapter — defensive parsing", () => { + it("tolerates non-object stdin", () => { + const ev = codexInputAdapter.parse(null, "preToolUse"); + assert.equal(ev.ide, "codex"); + assert.equal(ev.toolName, undefined); + assert.deepEqual(ev.raw, {}); + }); + + it("tolerates missing fields", () => { + const ev = codexInputAdapter.parse({ hook_event_name: "PostToolUse" }, "postToolUse"); + assert.equal(ev.toolName, undefined); + assert.equal(ev.toolInput, undefined); + assert.equal(ev.sessionId, undefined); + assert.equal(ev.transcriptPath, undefined); + }); +}); diff --git a/test/codex-hooks-e2e.test.ts b/test/codex-hooks-e2e.test.ts new file mode 100644 index 0000000..9c7c96a --- /dev/null +++ b/test/codex-hooks-e2e.test.ts @@ -0,0 +1,132 @@ +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, rmSync, readFileSync, readdirSync, statSync, mkdtempSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +/** + * End-to-end: drive the real hook subprocesses with Codex-shaped stdin and + * `--ide codex`, exactly as .codex/hooks.json does. Covers the two things + * that differ from Claude Code at the protocol level — apply_patch file + * tracking and shell-side denied paths — plus the git gate passing through + * unchanged. + */ + +const REPO_ROOT = process.cwd(); +let ROOT = ""; + +function codexPayload(extra: Record): Record { + return { + session_id: "codex-e2e-session", + transcript_path: join(ROOT, "rollout-fake.jsonl"), + cwd: ROOT, + hook_event_name: "PreToolUse", + model: "gpt-5-codex", + permission_mode: "default", + turn_id: "turn-1", + ...extra, + }; +} + +function spawnHook(hookName: string, stdinJson: object) { + const isWin = process.platform === "win32"; + const npxBin = isWin ? "npx.cmd" : "npx"; + const args = ["tsx", "src/cli.ts", "hook", hookName, "--workspace", ROOT, "--ide", "codex"]; + return spawnSync(npxBin, args, { + cwd: REPO_ROOT, + input: JSON.stringify(stdinJson), + encoding: "utf-8", + env: { ...process.env, AXME_TELEMETRY_DISABLED: "1" }, + timeout: 20000, + shell: isWin, + }); +} + +function findMetaFiles(dir: string): string[] { + const out: string[] = []; + for (const name of readdirSync(dir)) { + const p = join(dir, name); + if (statSync(p).isDirectory()) out.push(...findMetaFiles(p)); + else if (name === "meta.json") out.push(p); + } + return out; +} + +beforeEach(() => { + ROOT = mkdtempSync(join(tmpdir(), "axme-codex-e2e-")); + mkdirSync(join(ROOT, ".axme-code"), { recursive: true }); +}); + +afterEach(() => { + rmSync(ROOT, { recursive: true, force: true }); +}); + +describe("codex hooks end-to-end", () => { + it("pre-tool-use blocks git push --force with the Claude-style deny envelope (exit 0)", () => { + const r = spawnHook("pre-tool-use", codexPayload({ + tool_name: "Bash", + tool_input: { command: "git push --force origin main" }, + tool_use_id: "tu-1", + })); + assert.equal(r.status, 0, `stderr: ${r.stderr}`); + const parsed = JSON.parse(r.stdout) as { hookSpecificOutput: Record }; + assert.equal(parsed.hookSpecificOutput.permissionDecision, "deny"); + assert.match(parsed.hookSpecificOutput.permissionDecisionReason, /\[AXME Safety\]/); + }); + + it("pre-tool-use blocks a shell read of a denied path (Codex has no Read tool)", () => { + const r = spawnHook("pre-tool-use", codexPayload({ + tool_name: "Bash", + tool_input: { command: 'cat "~/.ssh/id_rsa"' }, + tool_use_id: "tu-2", + })); + assert.equal(r.status, 0, `stderr: ${r.stderr}`); + const parsed = JSON.parse(r.stdout) as { hookSpecificOutput: Record }; + assert.equal(parsed.hookSpecificOutput.permissionDecision, "deny"); + assert.match(parsed.hookSpecificOutput.permissionDecisionReason, /Path denied/); + }); + + it("pre-tool-use allows an ordinary apply_patch and MCP tool calls", () => { + const patch = spawnHook("pre-tool-use", codexPayload({ + tool_name: "apply_patch", + tool_input: { command: "*** Begin Patch\n*** Update File: src/a.ts\n@@\n-1\n+2\n*** End Patch" }, + tool_use_id: "tu-3", + })); + assert.equal(patch.status, 0, `stderr: ${patch.stderr}`); + assert.equal(patch.stdout.trim(), ""); + const mcp = spawnHook("pre-tool-use", codexPayload({ + tool_name: "mcp__axme__axme_save_memory", + tool_input: { type: "pattern", title: "t", description: "d" }, + tool_use_id: "tu-4", + })); + assert.equal(mcp.status, 0, `stderr: ${mcp.stderr}`); + assert.equal(mcp.stdout.trim(), ""); + }); + + it("post-tool-use records every file of an apply_patch call in the AXME session", () => { + const r = spawnHook("post-tool-use", codexPayload({ + hook_event_name: "PostToolUse", + tool_name: "apply_patch", + tool_input: { command: "*** Begin Patch\n*** Update File: src/a.ts\n@@\n-1\n+2\n*** Add File: docs/b.md\n+hi\n*** End Patch" }, + tool_response: { ok: true }, + tool_use_id: "tu-5", + })); + assert.equal(r.status, 0, `stderr: ${r.stderr}`); + const metas = findMetaFiles(join(ROOT, ".axme-code")); + assert.ok(metas.length >= 1, "an AXME session should have been created for the Codex session"); + const changed = metas + .flatMap((m) => (JSON.parse(readFileSync(m, "utf-8")) as { filesChanged?: string[] }).filesChanged ?? []) + .map((p) => p.replace(/\\/g, "/")); // Windows stores backslashes + assert.ok(changed.some((p) => p.endsWith("src/a.ts")), `filesChanged=${JSON.stringify(changed)}`); + assert.ok(changed.some((p) => p.endsWith("docs/b.md")), `filesChanged=${JSON.stringify(changed)}`); + }); + + it("session-start prints the axme_context nudge when the project is initialized", () => { + const r = spawnHook("session-start", codexPayload({ hook_event_name: "SessionStart", source: "startup" })); + assert.equal(r.status, 0, `stderr: ${r.stderr}`); + // ROOT has .axme-code/ but no config.yaml → "not initialized" branch; the + // real repo (REPO_ROOT) is initialized → ready branch. Check both texts. + assert.match(r.stdout, /\[AXME Code\] Project not initialized/); + }); +}); diff --git a/test/codex-setup-writers.test.ts b/test/codex-setup-writers.test.ts new file mode 100644 index 0000000..743acdb --- /dev/null +++ b/test/codex-setup-writers.test.ts @@ -0,0 +1,174 @@ +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, rmSync, writeFileSync, readFileSync, existsSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + writeCodexHooksJson, + writeCodexConfigToml, + writeAgentsMdSection, + buildAgentsMdSection, +} from "../src/setup/codex-writers.js"; + +let ROOT = ""; + +function fakeBuildHookCommand(hookName: string, projectPath: string): string { + return `"/bin/node" "/usr/local/bin/axme-code" hook ${hookName} --workspace "${projectPath}"`; +} + +const SERVE = { command: "/bin/node", args: ["/usr/local/bin/axme-code", "serve"] }; + +beforeEach(() => { + ROOT = mkdtempSync(join(tmpdir(), "axme-codex-writers-")); +}); + +afterEach(() => { + rmSync(ROOT, { recursive: true, force: true }); +}); + +describe("writeCodexHooksJson", () => { + it("creates .codex/hooks.json with four PascalCase events, --ide codex, Codex-safe timeouts", () => { + writeCodexHooksJson(ROOT, fakeBuildHookCommand); + const path = join(ROOT, ".codex", "hooks.json"); + assert.ok(existsSync(path)); + const cfg = JSON.parse(readFileSync(path, "utf-8")); + for (const event of ["SessionStart", "PreToolUse", "PostToolUse", "SessionEnd"]) { + const groups = cfg.hooks[event]; + assert.ok(Array.isArray(groups) && groups.length === 1, `${event} should have one group`); + const cmd = groups[0].hooks[0]; + assert.equal(cmd.type, "command"); + assert.match(cmd.command, /--ide codex$/); + assert.match(cmd.command, /axme-code/); + assert.ok(typeof cmd.timeout === "number"); + } + // Codex hard-caps SessionEnd at 3s. + assert.ok(cfg.hooks.SessionEnd[0].hooks[0].timeout <= 3); + // PreToolUse must fire for every tool (no matcher) — D-101. + assert.equal(cfg.hooks.PreToolUse[0].matcher, undefined); + // PostToolUse is edit-only and names Codex's real tool. + assert.match(cfg.hooks.PostToolUse[0].matcher, /apply_patch/); + assert.match(cfg.hooks.SessionStart[0].hooks[0].command, /hook session-start/); + }); + + it("dedups axme groups on re-run, preserves user groups", () => { + mkdirSync(join(ROOT, ".codex"), { recursive: true }); + writeFileSync( + join(ROOT, ".codex", "hooks.json"), + JSON.stringify({ + hooks: { + PreToolUse: [ + { matcher: "Bash", hooks: [{ type: "command", command: "echo user-hook" }] }, + { hooks: [{ type: "command", command: "axme-code hook pre-tool-use --workspace OLD --ide codex" }] }, + ], + }, + }), + ); + writeCodexHooksJson(ROOT, fakeBuildHookCommand); + writeCodexHooksJson(ROOT, fakeBuildHookCommand); + const cfg = JSON.parse(readFileSync(join(ROOT, ".codex", "hooks.json"), "utf-8")); + const pre = cfg.hooks.PreToolUse; + assert.equal(pre.length, 2); + assert.equal(pre[0].hooks[0].command, "echo user-hook"); + // Plain includes: ROOT has backslashes on Windows, which a RegExp would eat. + assert.ok(pre[1].hooks[0].command.includes(`--workspace "${ROOT}" --ide codex`), pre[1].hooks[0].command); + assert.equal(cfg.hooks.SessionEnd.length, 1); + }); + + it("refuses to overwrite an unparseable hooks.json", () => { + mkdirSync(join(ROOT, ".codex"), { recursive: true }); + const path = join(ROOT, ".codex", "hooks.json"); + writeFileSync(path, "{ not json"); + const origError = console.error; + const msgs: string[] = []; + console.error = (m: string) => { msgs.push(String(m)); }; + try { writeCodexHooksJson(ROOT, fakeBuildHookCommand); } finally { console.error = origError; } + assert.equal(readFileSync(path, "utf-8"), "{ not json"); + assert.ok(msgs.some((m) => m.includes("SKIPPED"))); + }); +}); + +describe("writeCodexConfigToml", () => { + it("creates .codex/config.toml with the axme table (absolute command + args)", () => { + writeCodexConfigToml(ROOT, SERVE); + const text = readFileSync(join(ROOT, ".codex", "config.toml"), "utf-8"); + assert.equal(text, '[mcp_servers.axme]\ncommand = "/bin/node"\nargs = ["/usr/local/bin/axme-code", "serve"]\n'); + }); + + it("appends to an existing config without touching other tables or comments", () => { + mkdirSync(join(ROOT, ".codex"), { recursive: true }); + const before = '# my project config\nmodel = "gpt-5-codex"\n\n[mcp_servers.other]\ncommand = "other-mcp"\n'; + writeFileSync(join(ROOT, ".codex", "config.toml"), before); + writeCodexConfigToml(ROOT, SERVE); + const text = readFileSync(join(ROOT, ".codex", "config.toml"), "utf-8"); + assert.ok(text.startsWith(before), "existing content must be preserved verbatim at the top"); + assert.match(text, /\n\[mcp_servers\.axme\]\ncommand = "\/bin\/node"\n/); + }); + + it("replaces a stale axme table in place and keeps what follows", () => { + mkdirSync(join(ROOT, ".codex"), { recursive: true }); + writeFileSync( + join(ROOT, ".codex", "config.toml"), + '[mcp_servers.axme]\ncommand = "axme-code"\nargs = ["serve"]\n\n[mcp_servers.other]\ncommand = "other-mcp"\n# trailing comment\n', + ); + writeCodexConfigToml(ROOT, SERVE); + const text = readFileSync(join(ROOT, ".codex", "config.toml"), "utf-8"); + assert.equal(text.match(/\[mcp_servers\.axme\]/g)?.length, 1); + assert.doesNotMatch(text, /command = "axme-code"/); + assert.match(text, /command = "\/bin\/node"/); + assert.match(text, /\[mcp_servers\.other\]\ncommand = "other-mcp"\n# trailing comment\n$/); + }); + + it("is idempotent", () => { + writeCodexConfigToml(ROOT, SERVE); + const once = readFileSync(join(ROOT, ".codex", "config.toml"), "utf-8"); + writeCodexConfigToml(ROOT, SERVE); + assert.equal(readFileSync(join(ROOT, ".codex", "config.toml"), "utf-8"), once); + }); + + it("escapes Windows-style paths as valid TOML basic strings", () => { + writeCodexConfigToml(ROOT, { command: "C:\\Program Files\\nodejs\\node.exe", args: ["C:\\axme\\cli.mjs", "serve"] }); + const text = readFileSync(join(ROOT, ".codex", "config.toml"), "utf-8"); + assert.match(text, /command = "C:\\\\Program Files\\\\nodejs\\\\node\.exe"/); + }); +}); + +describe("writeAgentsMdSection", () => { + const SECTION = buildAgentsMdSection("## AXME Code\n\n### Session Start (MANDATORY)\nCall axme_context first.\n"); + + it("appends the git gate to the base section", () => { + assert.match(SECTION, /^## AXME Code\n/); + assert.match(SECTION, /#!axme pr= repo=/); + }); + + it("creates AGENTS.md when missing", () => { + assert.equal(writeAgentsMdSection(ROOT, SECTION), "created"); + assert.equal(readFileSync(join(ROOT, "AGENTS.md"), "utf-8"), SECTION); + }); + + it("appends to an existing AGENTS.md without our section", () => { + writeFileSync(join(ROOT, "AGENTS.md"), "# Project rules\n\nUse pnpm.\n"); + assert.equal(writeAgentsMdSection(ROOT, SECTION), "appended"); + const text = readFileSync(join(ROOT, "AGENTS.md"), "utf-8"); + assert.ok(text.startsWith("# Project rules\n\nUse pnpm.\n\n## AXME Code")); + }); + + it("refreshes only our section and keeps user content after it", () => { + writeFileSync( + join(ROOT, "AGENTS.md"), + "# Project rules\n\n## AXME Code\n\nold stale text\n\n### Old sub\nstale\n\n## Testing\n\nRun npm test.\n", + ); + assert.equal(writeAgentsMdSection(ROOT, SECTION), "updated"); + const text = readFileSync(join(ROOT, "AGENTS.md"), "utf-8"); + assert.doesNotMatch(text, /old stale text|### Old sub/); + assert.equal(text.match(/## AXME Code/g)?.length, 1); + assert.match(text, /^# Project rules\n\n## AXME Code\n/); + assert.match(text, /\n## Testing\n\nRun npm test\.\n$/); + }); + + it("is idempotent", () => { + writeAgentsMdSection(ROOT, SECTION); + const once = readFileSync(join(ROOT, "AGENTS.md"), "utf-8"); + assert.equal(writeAgentsMdSection(ROOT, SECTION), "updated"); + assert.equal(readFileSync(join(ROOT, "AGENTS.md"), "utf-8"), once); + }); +}); diff --git a/test/ide-detect.test.ts b/test/ide-detect.test.ts index 56bef65..214011f 100644 --- a/test/ide-detect.test.ts +++ b/test/ide-detect.test.ts @@ -20,6 +20,11 @@ describe("parseIdeFlag", () => { assert.equal(parseIdeFlag(["--ide", "claude-code"]), "claude-code"); }); + it("returns 'codex' for --ide codex and --ide=codex", () => { + assert.equal(parseIdeFlag(["--ide", "codex"]), "codex"); + assert.equal(parseIdeFlag(["hook", "pre-tool-use", "--workspace", "/w", "--ide=codex"]), "codex"); + }); + it("returns undefined when --ide is absent", () => { assert.equal(parseIdeFlag([]), undefined); assert.equal(parseIdeFlag(["--workspace", "/tmp/foo"]), undefined); @@ -44,6 +49,10 @@ describe("detectIdeFromEnv", () => { assert.equal(detectIdeFromEnv({ AXME_IDE: "claude-code" }), "claude-code"); }); + it("returns 'codex' when AXME_IDE=codex", () => { + assert.equal(detectIdeFromEnv({ AXME_IDE: "codex" }), "codex"); + }); + it("returns undefined when AXME_IDE is absent or unknown", () => { assert.equal(detectIdeFromEnv({}), undefined); assert.equal(detectIdeFromEnv({ AXME_IDE: "vscode" }), undefined); diff --git a/test/safety-bash.test.ts b/test/safety-bash.test.ts index 460cb9a..e2250b5 100644 --- a/test/safety-bash.test.ts +++ b/test/safety-bash.test.ts @@ -143,3 +143,43 @@ describe("checkBash - destructive commands blocked", () => { assert.equal(v.allowed, true); }); }); + +describe("checkBash - denied filesystem paths reach the shell", () => { + const rulesWithPaths: SafetyRules = { + ...defaultRules, + filesystem: { + deniedPaths: ["/etc/passwd", "/etc/shadow", "~/.ssh/id_*", "~/.aws/credentials", ".env", "*.pem"], + }, + }; + + it("blocks cat ~/.ssh/id_rsa", () => { + const v = checkBash(rulesWithPaths, "cat ~/.ssh/id_rsa"); + assert.equal(v.allowed, false); + assert.match(v.reason ?? "", /Path denied/); + }); + + it("blocks a quoted denied path (quotes do not hide it)", () => { + assert.equal(checkBash(rulesWithPaths, 'cat "~/.ssh/id_ed25519"').allowed, false); + assert.equal(checkBash(rulesWithPaths, "head -c 100 '/etc/shadow'").allowed, false); + }); + + it("blocks a denied path hidden behind a chain or a redirect", () => { + assert.equal(checkBash(rulesWithPaths, "cd /tmp && cat ~/.aws/credentials").allowed, false); + assert.equal(checkBash(rulesWithPaths, "base64 < /etc/passwd").allowed, false); + }); + + it("allows commands that only MENTION a basename pattern in prose", () => { + assert.equal(checkBash(rulesWithPaths, 'git commit -m "fix .env loading"').allowed, true); + assert.equal(checkBash(rulesWithPaths, "cat > notes.md <<'EOF'\ncopy .env.example to .env\nEOF").allowed, true); + }); + + it("allows sibling paths that do not match the pattern", () => { + assert.equal(checkBash(rulesWithPaths, "ls -la ~/.ssh").allowed, true); + assert.equal(checkBash(rulesWithPaths, "cat ~/.ssh/config").allowed, true); + assert.equal(checkBash(rulesWithPaths, "cat /etc/hostname").allowed, true); + }); + + it("is a no-op when no anchored deny patterns exist", () => { + assert.equal(checkBash(defaultRules, "cat ~/.ssh/id_rsa").allowed, true); + }); +});