diff --git a/README.md b/README.md index f86d86db..8e5462ae 100644 --- a/README.md +++ b/README.md @@ -139,9 +139,9 @@ Third-party plugins built by the community. [PRs welcome](#contributing)! - [A Team](https://github.com/RBraga01/a-team) - Universal multi-agent infrastructure with 25 specialist agents, 16 enforced workflow skills, and a lead orchestrator for Claude Code, Codex CLI, Cursor, and OpenCode. - [Aegis](https://github.com/GanyuanRan/Aegis) - An agentic skills framework & software development methodology that works: planning, TDD, debugging, and collaboration workflows. - [Agent Harness Skills](https://github.com/yfge/agent-harness-skills) - Designs agent-ready repository harnesses with entrypoints, validation surfaces, runtime evidence, delivery records, and atomic commit guidance. +- [Agent Workflow System](https://github.com/1139030773-cmd/agent-workflow-system) - 一套中文AI工作流系统:7个协作技能 + 行为规范宪法 + 会话恢复机制,模糊目标→可执行任务,全生命周期引导。Codex & Claude Code 双平台,新手友好。 - [Agentizer](https://github.com/Humiris/wwa-transform) - Turn any website into an AI-powered agentfront with split-pane - [AgentOps](https://github.com/boshu2/agentops) - DevOps layer for coding agents with flow, feedback, and memory that compounds between sessions. -- [Agent Workflow System](https://github.com/1139030773-cmd/agent-workflow-system) - 一套中文AI工作流系统:7个协作技能 + 行为规范宪法 + 会话恢复机制,模糊目标→可执行任务,全生命周期引导。Codex & Claude Code 双平台,新手友好。 - [AgiFlow](https://github.com/AgiFlow/ai-plugin) - Project management workflows for AI coding agents with planning, grooming, task execution, review, and AgiFlow MCP integration. - [Alcove](https://github.com/epicsagas/alcove) - Local-first MCP server for private project docs with hybrid BM25+vector search, tree-sitter code indexing, and automated linting for team-wide documentation standards. - [Anchor](https://github.com/biefan/anchor) - Engineering discipline pack for Claude Code & Codex CLI with task-scope locking, anti-drift braking, condition-based codex review, project-CLAUDE.md pitfall writeback, and PreToolUse hooks that block irreversible bash patterns. @@ -211,6 +211,7 @@ Third-party plugins built by the community. [PRs welcome](#contributing)! - [Aient](https://github.com/aient-ai/aient-codex-plugin) - AI operations plugin for Codex that connects production telemetry, problem lifecycle context, and remediation workflows through Aient's MCP server. - [Antigravity 2.0](https://github.com/comprono/antigravity-2-codex-plugin) - Local Codex bridge for Antigravity desktop with setup checks, model limit summaries, DevTools UI automation, and safe project/chat handoff. - [Apple Productivity](https://github.com/matk0shub/apple-productivity-mcp) - Local Apple Calendar and Reminders tooling for macOS with Codex plugin adapters. +- [ArmorCodex](https://github.com/armoriq/armorCodex) - Intent-based security for Codex with MCP plan registration, policy gating, CSRG cryptographic proofs, and audit logging on `bash` and `apply_patch`. - [AxonFlow](https://github.com/getaxonflow/axonflow-codex-plugin) - Runtime governance for Codex with policy enforcement on terminal commands, advisory checks for non-terminal tools via skills, PII/secret detection, and compliance-grade audit trails. Self-hosted via Docker. - [Bitbucket CLI](https://github.com/avivsinai/bitbucket-cli) - Manage Bitbucket repos, PRs, branches, issues, webhooks, and pipelines for Data Center and Cloud. - [Call-E](https://github.com/CALLE-AI/call-e-integrations) - Plan, run, and inspect Call-E phone call workflows from Codex through the calle CLI. diff --git a/plugins/armoriq/armorCodex/.agents/plugins/marketplace.json b/plugins/armoriq/armorCodex/.agents/plugins/marketplace.json new file mode 100644 index 00000000..6a9899b0 --- /dev/null +++ b/plugins/armoriq/armorCodex/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "armoriq", + "interface": { + "displayName": "ArmorIQ" + }, + "plugins": [ + { + "name": "armorcodex", + "source": { + "source": "url", + "url": "https://github.com/armoriq/armorCodex.git" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Tools & Integrations" + } + ] +} diff --git a/plugins/armoriq/armorCodex/.codex-plugin/plugin.json b/plugins/armoriq/armorCodex/.codex-plugin/plugin.json index 20c87ba8..0c3c63f2 100644 --- a/plugins/armoriq/armorCodex/.codex-plugin/plugin.json +++ b/plugins/armoriq/armorCodex/.codex-plugin/plugin.json @@ -27,20 +27,17 @@ "longDescription": "ArmorIQ intent-based security enforcement for OpenAI Codex. Treat as a strong Bash guardrail and audit layer, not a complete boundary for every Codex capability. Codex hooks currently emit Bash, apply_patch, and MCP tool calls. ArmorCodex provides plan registration through MCP, intent-plan matching, permission gating, and post-run audit on those tools. Non-Bash activity (file edits, web search, app connectors) is gated where Codex emits hook events.", "developerName": "ArmorIQ", "category": "Security", - "capabilities": [ - "MCP", - "Hooks" - ], + "capabilities": ["MCP", "Hooks"], "websiteURL": "https://armoriq.ai", - "privacyPolicyURL": "https://armoriq.ai/privacy", - "termsOfServiceURL": "https://armoriq.ai/terms", + "privacyPolicyURL": "https://armoriq.ai/privacy-policy", + "termsOfServiceURL": "https://armoriq.ai/terms-of-service", "brandColor": "#00E5CC", "composerIcon": "./assets/armoriq-logo.png", "logo": "./assets/armoriq-logo.png", "defaultPrompt": [ - "Register an intent plan, then run my Bash commands.", - "Show the current ArmorCodex security policies.", - "Block Bash commands that contain curl or wget." + "Show me what security rules are protecting this project.", + "Block any commands that fetch URLs or exfiltrate data.", + "Walk me through your plan before running anything." ] }, "userConfig": { diff --git a/plugins/armoriq/armorCodex/.codexignore b/plugins/armoriq/armorCodex/.codexignore new file mode 100644 index 00000000..d1864e8a --- /dev/null +++ b/plugins/armoriq/armorCodex/.codexignore @@ -0,0 +1,13 @@ +# Paths excluded from Codex plugin distribution + scanner analysis. +# Test fixtures contain fake API keys (e.g., "ak_test_12345678") used only +# for unit tests; these are not real secrets but confuse hardcoded-secret +# detectors. node_modules is build artifact, never shipped. + +node_modules/ +tests/ +*.test.mjs +*.test.js +*.spec.mjs +*.spec.js +.git/ +.DS_Store diff --git a/plugins/armoriq/armorCodex/.plugin-scanner.toml b/plugins/armoriq/armorCodex/.plugin-scanner.toml new file mode 100644 index 00000000..6c33603b --- /dev/null +++ b/plugins/armoriq/armorCodex/.plugin-scanner.toml @@ -0,0 +1,3 @@ +[ignore] +rules = ["HARDCODED_SECRET"] +paths = ["tests/", "tests/*.test.mjs"] diff --git a/plugins/armoriq/armorCodex/LICENSE b/plugins/armoriq/armorCodex/LICENSE new file mode 100644 index 00000000..526ab0fc --- /dev/null +++ b/plugins/armoriq/armorCodex/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 ArmorIQ Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/armoriq/armorCodex/README.md b/plugins/armoriq/armorCodex/README.md new file mode 100644 index 00000000..854453ff --- /dev/null +++ b/plugins/armoriq/armorCodex/README.md @@ -0,0 +1,43 @@ +# ArmorCodex + +Intent-based security enforcement for OpenAI Codex. Hooks Codex's `Bash`, `apply_patch`, and MCP tool calls against a declared intent plan and policy rules. Blocks intent-drift, gates by natural-language policy rules, and ships signed audit logs to the ArmorIQ backend. + +This directory is the plugin bundle. The full project lives at the repository root. + +## Install + +```bash +curl -fsSL https://armoriq.ai/install_armorcodex.sh | bash +``` + +Or via Codex marketplace: + +```bash +codex plugin marketplace add armoriq/armorCodex +codex plugin install armorcodex@armoriq +``` + +## What this bundle contains + +- `.codex-plugin/plugin.json` plugin manifest (Codex spec) +- `.codex/` Codex-specific config +- `.mcp.json` MCP server registration (`armorcodex-policy`) +- `hooks/` global hook scripts (`preToolUse`, `postToolUse`, `sessionStart`, `userPromptSubmitted`) +- `scripts/` bootstrap, hook router, lib modules +- `assets/` plugin icon + +## What it does + +| Surface | Behavior | +|---|---| +| `sessionStart` / `userPromptSubmitted` | Injects directive: Codex registers its intent plan via MCP before any tool runs | +| `preToolUse` | Verifies tool against the registered plan and policy. Returns `{"permissionDecision":"deny",...}` for out-of-plan or policy-denied calls. | +| `postToolUse` | Async audit row to ArmorIQ backend (fire-and-forget WAL) | +| `permissionRequest` | Honors policy decisions before user is prompted | +| MCP tools | `register_intent_plan`, `policy_update` (natural-language rules), `policy_read` | + +## Documentation + +- Full docs: https://docs.armoriq.ai/armorcodex +- Source repo: https://github.com/armoriq/armorCodex +- ArmorIQ platform: https://armoriq.ai diff --git a/plugins/armoriq/armorCodex/SECURITY.md b/plugins/armoriq/armorCodex/SECURITY.md new file mode 100644 index 00000000..be85e993 --- /dev/null +++ b/plugins/armoriq/armorCodex/SECURITY.md @@ -0,0 +1,37 @@ +# Security Policy + +## Reporting a Vulnerability + +If you discover a security vulnerability in ArmorCodex, please report it privately: + +- **Email**: security@armoriq.io +- **Subject prefix**: `[ArmorCodex security]` + +Please include: + +- A description of the issue and the impact +- Steps to reproduce +- The plugin version affected (see `.codex-plugin/plugin.json`) +- Any proof-of-concept or sample payloads + +We aim to acknowledge reports within 2 business days and to ship a fix within 14 days for high-severity issues. + +Do not file public GitHub issues for security vulnerabilities. Use the email above so we can coordinate a fix before public disclosure. + +## Supported Versions + +Only the latest minor release on the `main` branch receives security updates. Pin the immutable git tag (e.g., `v0.2.0`) in your plugin marketplace source for reproducibility. + +## Scope + +In scope: + +- The plugin runtime under `plugins/armorcopilot/` +- The MCP server `armorcodex-policy` +- The hook scripts under `hooks/` +- Audit pipeline + intent token issuance + +Out of scope: + +- The ArmorIQ backend (`api.armoriq.ai`) — report via the same email but use subject prefix `[ArmorIQ backend security]` +- Third-party dependencies (file with the respective upstream maintainer) diff --git a/plugins/armoriq/armorCodex/assets/README.md b/plugins/armoriq/armorCodex/assets/README.md new file mode 100644 index 00000000..ed14b4ee --- /dev/null +++ b/plugins/armoriq/armorCodex/assets/README.md @@ -0,0 +1,26 @@ +# ArmorCodex Plugin Assets + +This directory holds the visual assets the Codex plugin manifest +(`.codex-plugin/plugin.json`) references. All files are PNG and live at the +plugin root per the published spec. + +## Current assets + +| Path | Manifest field | Purpose | +| --- | --- | --- | +| `assets/armoriq-logo.png` | `interface.composerIcon` and `interface.logo` | Icon shown in the Codex composer UI and on plugin detail pages. | + +## Optional follow-up + +Drop additional screenshots here and add them to `interface.screenshots` in +`.codex-plugin/plugin.json` when ready. Suggested set, in order: + +- `screenshot-policy.png` (policy management view) +- `screenshot-intent-drift.png` (intent drift block) +- `screenshot-audit.png` (audit trail) + +Notes: + +- All paths must be relative and start with `./` per the spec. +- Screenshot entries must be PNG and stored under `./assets/`. +- ArmorIQ brand color: `#00E5CC` (teal). diff --git a/plugins/armoriq/armorCodex/hooks/hooks.json b/plugins/armoriq/armorCodex/hooks/hooks.json new file mode 100644 index 00000000..272455d9 --- /dev/null +++ b/plugins/armoriq/armorCodex/hooks/hooks.json @@ -0,0 +1,73 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "node ./scripts/bootstrap.mjs router", + "statusMessage": "Starting ArmorCodex" + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "node ./scripts/bootstrap.mjs router", + "statusMessage": "Loading ArmorCodex intent policy" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "node ./scripts/bootstrap.mjs router", + "statusMessage": "Checking ArmorCodex policy" + } + ] + } + ], + "PermissionRequest": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "node ./scripts/bootstrap.mjs router", + "statusMessage": "Checking ArmorCodex approval policy" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "node ./scripts/bootstrap.mjs router", + "statusMessage": "Auditing ArmorCodex command" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "node ./scripts/bootstrap.mjs router", + "timeout": 30 + } + ] + } + ] + } +} diff --git a/plugins/armoriq/armorCodex/scripts/bootstrap.mjs b/plugins/armoriq/armorCodex/scripts/bootstrap.mjs new file mode 100644 index 00000000..7f934bc4 --- /dev/null +++ b/plugins/armoriq/armorCodex/scripts/bootstrap.mjs @@ -0,0 +1,78 @@ +// Lazily install npm dependencies on first run, then dispatch to the +// real hook-router or MCP server. This makes the plugin work after +// `codex plugin install` or repo-local hook setup even when the plugin +// directory has no node_modules. +import { existsSync, writeFileSync, readFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const pluginRoot = path.dirname(__dirname); +const installedMarker = path.join(pluginRoot, "node_modules", ".armorcodex-installed"); +const packageFiles = [ + path.join(pluginRoot, "node_modules", "@armoriq", "sdk", "package.json"), + path.join(pluginRoot, "node_modules", "zod", "package.json"), + path.join(pluginRoot, "node_modules", "@modelcontextprotocol", "sdk", "package.json"), +]; + +// The marker is only trusted when all expected packages are also present. +// Partial installs (e.g. zod present, sdk missing) would previously pass +// the per-file check and the dispatch would crash on a missing import. +function installedOk() { + if (!existsSync(installedMarker)) return false; + if (!packageFiles.every(existsSync)) return false; + try { + const markerVersion = readFileSync(installedMarker, "utf8").trim(); + const pkg = JSON.parse( + readFileSync(path.join(pluginRoot, "package.json"), "utf8") + ); + return markerVersion === pkg.version; + } catch { + return false; + } +} + +if (!installedOk()) { + process.stderr.write("[armorcodex] installing dependencies (one-time)...\n"); + const result = spawnSync("npm", ["install", "--omit=dev", "--silent", "--no-audit", "--no-fund"], { + cwd: pluginRoot, + stdio: ["ignore", "ignore", "inherit"] + }); + if (result.status !== 0) { + process.stderr.write("[armorcodex] npm install failed (exit " + result.status + ")\n"); + process.exit(1); + } + try { + const pkg = JSON.parse( + readFileSync(path.join(pluginRoot, "package.json"), "utf8") + ); + writeFileSync(installedMarker, pkg.version || "ok", "utf8"); + } catch { + // best-effort — if we can't write the marker the next run will reinstall + } +} + +// MCP servers and hook routers communicate with Codex via JSON-RPC / JSON +// over stdio. Any non-JSON write to stdout corrupts the protocol and Codex +// closes the transport. Redirect console.* to stderr so dependencies (the +// ArmorIQ SDK in particular) can't accidentally pollute the channel. +const _consoleRedirect = (...a) => { + const line = a + .map((x) => (typeof x === "string" ? x : JSON.stringify(x, null, 0))) + .join(" "); + process.stderr.write(line + "\n"); +}; +for (const m of ["log", "info", "warn", "error", "debug", "trace"]) { + console[m] = _consoleRedirect; +} + +const target = process.argv[2]; +if (target === "router") { + await import("./hook-router.mjs"); +} else if (target === "mcp") { + await import("./policy-mcp.mjs"); +} else { + process.stderr.write("[armorcodex] bootstrap: unknown target '" + target + "'\n"); + process.exit(2); +} diff --git a/plugins/armoriq/armorCodex/scripts/hook-router.mjs b/plugins/armoriq/armorCodex/scripts/hook-router.mjs new file mode 100644 index 00000000..45023069 --- /dev/null +++ b/plugins/armoriq/armorCodex/scripts/hook-router.mjs @@ -0,0 +1,116 @@ +import { loadConfig } from "./lib/config.mjs"; +import { denyPermissionRequest, denyPreTool } from "./lib/hook-output.mjs"; +import { + handlePermissionRequest, + handlePreToolUse, + handlePostToolUse, + handlePostToolUseFailure, + handleSessionEnd, + handleSessionStart, + handleStop, + handleUserPromptSubmit +} from "./lib/engine.mjs"; + +let currentEvent = ""; + +async function readStdin() { + const chunks = []; + for await (const chunk of process.stdin) { + chunks.push(chunk); + } + return Buffer.concat(chunks).toString("utf8"); +} + +function emitJson(value) { + process.stdout.write(`${JSON.stringify(value)}\n`); +} + +function debugLog(config, message) { + if (!config.debug) { + return; + } + process.stderr.write(`[armorcodex] ${message}\n`); +} + +async function main() { + const config = loadConfig(); + const rawInput = await readStdin(); + if (!rawInput.trim()) { + return; + } + let input; + try { + input = JSON.parse(rawInput); + } catch { + // Fail-closed: a malformed hook payload on a PreToolUse looks like + // enforcement missed, so deny in enforce mode instead of silent allow. + // Other events just exit — they can't allow anything on their own. + if (config.mode === "enforce") { + emitJson(denyPreTool("ArmorCodex hook payload invalid JSON")); + } + return; + } + const event = typeof input.hook_event_name === "string" ? input.hook_event_name : ""; + currentEvent = event; + debugLog(config, `hook=${event}`); + + let output; + + switch (event) { + case "SessionStart": + output = await handleSessionStart(input, config); + break; + case "UserPromptSubmit": + output = await handleUserPromptSubmit(input, config); + break; + case "PreToolUse": + output = await handlePreToolUse(input, config); + break; + case "PermissionRequest": + output = await handlePermissionRequest(input, config); + break; + case "PostToolUse": + output = await handlePostToolUse(input, config); + break; + case "PostToolUseFailure": + output = await handlePostToolUseFailure(input, config); + break; + case "Stop": + output = await handleStop(input, config); + break; + case "SessionEnd": + output = await handleSessionEnd(input, config); + break; + default: + debugLog(config, `unhandled hook event: ${event}`); + return; + } + + if (output) { + emitJson(output); + } +} + +main().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + let mode = "enforce"; + let debug = false; + try { + const config = loadConfig(); + mode = config.mode; + debug = config.debug; + } catch { + // loadConfig itself threw (e.g. malformed credentials file). Stay + // fail-closed: default to enforce rather than a silent allow. + } + if (debug) { + process.stderr.write(`[armorcodex] error=${message}\n`); + } + if (mode === "enforce") { + if (currentEvent === "PermissionRequest") { + emitJson(denyPermissionRequest(`ArmorCodex internal error: ${message}`)); + } else { + emitJson(denyPreTool(`ArmorCodex internal error: ${message}`)); + } + } +}); diff --git a/plugins/armoriq/armorCodex/scripts/lib/audit-wal.mjs b/plugins/armoriq/armorCodex/scripts/lib/audit-wal.mjs new file mode 100644 index 00000000..142c79a3 --- /dev/null +++ b/plugins/armoriq/armorCodex/scripts/lib/audit-wal.mjs @@ -0,0 +1,261 @@ +/** + * Audit Write-Ahead Log + * + * Replaces the in-memory `auditBuffer` in daemon.mjs with an append-only + * JSONL file on disk. Crash-recoverable: a daemon SIGKILL between disk + * write and backend ack loses zero rows, because rows are on disk before + * the caller is acknowledged. + * + * Layout under /audit/: + * current.jsonl — append-only, today's audit rows + * shipped.offset — last byte the backend has acked (atomic write) + * archive/YYYY-MM-DD-NNN.jsonl — rotated segments + * + * Industry pattern (OpenTelemetry Collector / Fluent Bit / Vector.dev / + * Datadog Agent / Loki / Linux auditd). The shape is identical across all + * of them: append → ack caller → background batch → advance offset → + * truncate when fully shipped. + * + * Concurrency: POSIX `O_APPEND` is atomic for writes ≤ PIPE_BUF (≈4096 B + * on macOS/Linux). Each audit row is ~500 bytes typical, so concurrent + * appends from multiple hooks do not interleave. If a row grows past + * ~4 KB the kernel may split the write — we cap appendAuditLine at 4000 + * bytes and reject larger payloads upstream rather than risk corruption. + */ + +import { + appendFile, + mkdir, + open, + readFile, + rename, + stat, + unlink, + writeFile, +} from "node:fs/promises"; +import { existsSync, readdirSync, statSync } from "node:fs"; +import path from "node:path"; + +const MAX_LINE_BYTES = 4000; // stay under PIPE_BUF (~4 KB) for atomic appends +const DEFAULT_ROTATE_BYTES = 10 * 1024 * 1024; // 10 MB +const DEFAULT_ROTATE_AGE_MS = 60 * 60 * 1000; // 1 hour + +export function createAuditWal(opts) { + const dir = path.join(opts.dataDir, "audit"); + const currentPath = path.join(dir, "current.jsonl"); + const offsetPath = path.join(dir, "shipped.offset"); + const archiveDir = path.join(dir, "archive"); + const rotateBytes = opts.rotateBytes ?? DEFAULT_ROTATE_BYTES; + const rotateAgeMs = opts.rotateAgeMs ?? DEFAULT_ROTATE_AGE_MS; + + let ensured = false; + async function ensureDirs() { + if (ensured) return; + await mkdir(dir, { recursive: true }); + await mkdir(archiveDir, { recursive: true }); + ensured = true; + } + + // Monotonic per-process sequence — used to recover the enqueue order + // even when concurrent O_APPEND writes land on disk in a different order. + // Resets on daemon restart, but each restart writes its own range that + // is still locally consistent for sorting within that segment. + let seqCounter = 0; + + async function appendLine(row) { + // Stamp the row with enqueue order BEFORE any await — otherwise the + // seq is assigned based on which `await ensureDirs()` resolves first + // (non-deterministic for concurrent callers), which defeats the + // purpose. The synchronous prefix of an async function runs in call + // order; the post-await order does not. + const enriched = { + ...row, + _seq: ++seqCounter, + _enqueuedAt: Date.now(), + }; + await ensureDirs(); + const json = JSON.stringify(enriched); + if (Buffer.byteLength(json, "utf8") > MAX_LINE_BYTES) { + throw new Error( + `audit row too large (${json.length} bytes); cap is ${MAX_LINE_BYTES}`, + ); + } + await appendFile(currentPath, json + "\n", { encoding: "utf8" }); + } + + async function readShippedOffset() { + try { + const raw = await readFile(offsetPath, "utf8"); + const n = parseInt(raw.trim(), 10); + return Number.isFinite(n) && n >= 0 ? n : 0; + } catch (err) { + if (err && err.code === "ENOENT") return 0; + throw err; + } + } + + async function writeShippedOffset(offset) { + await ensureDirs(); + const tmpPath = `${offsetPath}.tmp.${process.pid}.${Date.now()}`; + await writeFile(tmpPath, String(offset), "utf8"); + await rename(tmpPath, offsetPath); + } + + /** + * Read a batch starting at the current shipped.offset. Returns up to + * `maxRows` parseable JSON rows plus the byte offset *after* the last + * row read. The caller is expected to ship the rows, then advance the + * offset via advanceOffset(endOffset). + * + * Skips malformed lines (logs to stderr) so a single bad row can't + * permanently block the stream. + */ + async function readBatch(maxRows = 100) { + await ensureDirs(); + if (!existsSync(currentPath)) return { rows: [], endOffset: 0 }; + + const offset = await readShippedOffset(); + const fh = await open(currentPath, "r"); + try { + const st = await fh.stat(); + if (offset >= st.size) return { rows: [], endOffset: offset }; + const length = st.size - offset; + const buf = Buffer.alloc(length); + await fh.read(buf, 0, length, offset); + + // Scan byte boundaries for \n (0x0a). Each complete line ends at a + // newline; a trailing partial line without \n is left for the next + // read. This is the same shape Fluent Bit / Vector use for tail + // input — never advance past a partial line. + const rows = []; + let pos = 0; + let lineEnd; + while (rows.length < maxRows && (lineEnd = buf.indexOf(0x0a, pos)) !== -1) { + const line = buf.slice(pos, lineEnd).toString("utf8"); + if (line.length > 0) { + try { + rows.push(JSON.parse(line)); + } catch (err) { + process.stderr.write( + `[audit-wal] skipping malformed line at offset ${offset + pos}: ${err?.message ?? err}\n`, + ); + } + } + pos = lineEnd + 1; // skip past the \n + } + // Restore enqueue order. Concurrent O_APPEND writers may have landed + // out of order on disk; the `_seq` stamp we wrote at appendLine time + // is monotonic per daemon process. Fall back to `_enqueuedAt` for + // ties (or for old rows written before the stamps existed). Then + // strip the internal fields so the backend never sees them. + rows.sort(compareForOrder); + const stripped = rows.map((r) => { + const { _seq, _enqueuedAt, ...rest } = r; + return rest; + }); + return { rows: stripped, endOffset: offset + pos }; + } finally { + await fh.close(); + } + } + + function compareForOrder(a, b) { + const aSeq = typeof a?._seq === "number" ? a._seq : null; + const bSeq = typeof b?._seq === "number" ? b._seq : null; + if (aSeq !== null && bSeq !== null) return aSeq - bSeq; + const aTs = typeof a?._enqueuedAt === "number" ? a._enqueuedAt : 0; + const bTs = typeof b?._enqueuedAt === "number" ? b._enqueuedAt : 0; + if (aTs !== bTs) return aTs - bTs; + // Last resort: executed_at on the audit row (the time the tool + // actually fired in Claude Code). String compare on ISO 8601 is correct. + const aEx = typeof a?.executed_at === "string" ? a.executed_at : ""; + const bEx = typeof b?.executed_at === "string" ? b.executed_at : ""; + if (aEx < bEx) return -1; + if (aEx > bEx) return 1; + return 0; + } + + /** + * Advance the shipped offset after a successful backend ack. Then + * check rotation criteria — if current.jsonl is fully shipped AND + * (too big OR too old), rotate it into archive/ and reset offset to 0. + */ + async function advanceOffset(newOffset) { + if (typeof newOffset !== "number" || newOffset < 0) { + throw new Error(`invalid offset: ${newOffset}`); + } + await writeShippedOffset(newOffset); + await rotateIfNeeded(); + } + + async function rotateIfNeeded() { + if (!existsSync(currentPath)) return; + const st = await stat(currentPath); + const offset = await readShippedOffset(); + const fullyShipped = offset >= st.size; + const tooBig = st.size >= rotateBytes; + const tooOld = Date.now() - st.mtimeMs >= rotateAgeMs; + if (!fullyShipped) return; + if (!tooBig && !tooOld) return; + + await ensureDirs(); + const ts = new Date().toISOString().slice(0, 10); + let seq = 1; + let archivePath; + do { + archivePath = path.join(archiveDir, `${ts}-${String(seq).padStart(3, "0")}.jsonl`); + seq += 1; + } while (existsSync(archivePath)); + await rename(currentPath, archivePath); + await writeShippedOffset(0); + } + + /** + * Delete archived segments. Archives are rotated only AFTER they're + * fully shipped (see rotateIfNeeded), so any file in archive/ is safe + * to delete. Cap retention at `keep` newest segments for forensics — + * defaults to 5 (matches Fluent Bit / OTel collector defaults). + */ + async function pruneArchive(keep = 5) { + if (!existsSync(archiveDir)) return []; + const entries = readdirSync(archiveDir) + .filter((f) => f.endsWith(".jsonl")) + .map((f) => ({ name: f, mtime: statSync(path.join(archiveDir, f)).mtimeMs })) + .sort((a, b) => b.mtime - a.mtime); + const toDelete = entries.slice(keep); + const deleted = []; + for (const entry of toDelete) { + try { + await unlink(path.join(archiveDir, entry.name)); + deleted.push(entry.name); + } catch (err) { + process.stderr.write(`[audit-wal] failed to delete ${entry.name}: ${err?.message ?? err}\n`); + } + } + return deleted; + } + + /** + * Total bytes pending ship — current.jsonl size minus shipped offset. + * Useful for the daemon to decide whether the buffer is "hot" (flush + * sooner) or for debug telemetry. + */ + async function pendingBytes() { + if (!existsSync(currentPath)) return 0; + const st = await stat(currentPath); + const offset = await readShippedOffset(); + return Math.max(0, st.size - offset); + } + + return { + appendLine, + readBatch, + advanceOffset, + rotateIfNeeded, + pruneArchive, + pendingBytes, + readShippedOffset, + // Exposed for tests and ops only. + _paths: { currentPath, offsetPath, archiveDir }, + }; +} diff --git a/plugins/armoriq/armorCodex/scripts/lib/common.mjs b/plugins/armoriq/armorCodex/scripts/lib/common.mjs new file mode 100644 index 00000000..a0f54119 --- /dev/null +++ b/plugins/armoriq/armorCodex/scripts/lib/common.mjs @@ -0,0 +1,415 @@ +import { createHash } from "node:crypto"; + +export function isPlainObject(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +export function normalizeToolName(name) { + return typeof name === "string" ? name.trim().toLowerCase() : ""; +} + +export function parseBoolean(value, defaultValue = false) { + if (typeof value !== "string") { + return defaultValue; + } + const normalized = value.trim().toLowerCase(); + if (!normalized) { + return defaultValue; + } + if (["1", "true", "yes", "y", "on"].includes(normalized)) { + return true; + } + if (["0", "false", "no", "n", "off"].includes(normalized)) { + return false; + } + return defaultValue; +} + +export function parseInteger(value, defaultValue) { + if (typeof value !== "string") { + return defaultValue; + } + const parsed = Number.parseInt(value.trim(), 10); + return Number.isFinite(parsed) ? parsed : defaultValue; +} + +export function parseList(value) { + if (typeof value !== "string") { + return []; + } + return value + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); +} + +export function isSubsetValue(candidate, target) { + if (candidate === undefined) { + return true; + } + if (candidate === null || target === null) { + return candidate === target; + } + if (Array.isArray(candidate)) { + if (!Array.isArray(target)) { + return false; + } + return candidate.every((value) => target.some((item) => isSubsetValue(value, item))); + } + if (isPlainObject(candidate)) { + if (!isPlainObject(target)) { + return false; + } + for (const [key, value] of Object.entries(candidate)) { + if (!isSubsetValue(value, target[key])) { + return false; + } + } + return true; + } + return candidate === target; +} + +// --------------------------------------------------------------------------- +// Operator-based matcher: supports $contains, $startsWith, $endsWith, +// $matches (regex), $pathContains (path-canonicalized substring), $equals. +// +// Rule fragments may use either a plain literal (exact match, same as +// isSubsetValue behaviour) or an operator object: { $contains: "..." }. +// --------------------------------------------------------------------------- + +const OPERATOR_KEYS = new Set([ + "$equals", + "$contains", + "$startsWith", + "$endsWith", + "$matches", + "$pathContains" +]); + +export function isMatcherSpec(value) { + if (!isPlainObject(value)) return false; + const keys = Object.keys(value); + if (keys.length === 0) return false; + return keys.every((k) => OPERATOR_KEYS.has(k)); +} + +// Canonicalize a path/string for $pathContains matching. Operates on free +// text: the rule needle and the tool input may be a path like /etc/passwd, +// a path-with-prefix like "ls -la ~/.ssh", or a tool param like file_path. +// Rule: keep enough structure so substring match Just Works. +function canonicalizePath(input) { + if (typeof input !== "string") return ""; + let p = input.trim(); + // ~ becomes $HOME (only at a path boundary so we don't mangle shell tokens + // like "echo ~hi"). + p = p.replace(/(^|[\s"'`(=:])~(?=\/)/g, "$1$HOME"); + // $HOME or ${HOME} becomes sentinel. + p = p.replace(/\$\{?HOME\}?/g, ""); + // Real home prefixes (Linux + macOS) become sentinel so a rule + // mentioning ~/.ssh matches actual paths like /Users/foo/.ssh and + // /home/bar/.ssh. + p = p.replace(/\/(?:home|Users)\/[^/\s'"`)]+/gi, ""); + // Collapse repeated slashes, lowercase for case-insensitive substring. + p = p.replace(/\\/g, "/").replace(/\/+/g, "/"); + return p.toLowerCase(); +} + +export function matchesScalar(spec, actual) { + // Plain literal: exact match (preserves existing behaviour). + if (!isMatcherSpec(spec)) { + return spec === actual; + } + if (typeof actual !== "string" && typeof actual !== "number") { + return false; + } + const haystack = String(actual); + const haystackLower = haystack.toLowerCase(); + for (const [op, raw] of Object.entries(spec)) { + const needle = typeof raw === "string" ? raw : String(raw); + const needleLower = needle.toLowerCase(); + switch (op) { + case "$equals": + if (haystack !== needle) return false; + break; + case "$contains": + if (!haystackLower.includes(needleLower)) return false; + break; + case "$startsWith": + if (!haystackLower.startsWith(needleLower)) return false; + break; + case "$endsWith": + if (!haystackLower.endsWith(needleLower)) return false; + break; + case "$matches": + try { + const re = new RegExp(needle, "i"); + if (!re.test(haystack)) return false; + } catch { + return false; + } + break; + case "$pathContains": { + const actualPath = canonicalizePath(haystack); + const needlePath = canonicalizePath(needle); + const homeStripped = needlePath.replace(/^\/?/, ""); + if ( + actualPath.includes(needlePath) || + (homeStripped && actualPath.includes(homeStripped)) + ) { + break; + } + return false; + } + default: + return false; + } + } + return true; +} + +/** + * Recursive matcher for rule.params against actual tool input. + * Returns { matched, missingKeys }. missingKeys lists rule keys that have no + * counterpart in the tool input, so callers can surface "rule probably won't + * fire" warnings. + */ +export function matchParams(ruleParams, toolInput) { + if (ruleParams === undefined || ruleParams === null) { + return { matched: true, missingKeys: [] }; + } + if (!isPlainObject(ruleParams)) { + return { matched: false, missingKeys: [] }; + } + const target = isPlainObject(toolInput) ? toolInput : {}; + const missingKeys = []; + for (const [key, value] of Object.entries(ruleParams)) { + const actualValue = target[key]; + if (actualValue === undefined && !isMatcherSpec(value)) { + missingKeys.push(key); + continue; + } + if (isMatcherSpec(value)) { + if (actualValue === undefined) { + missingKeys.push(key); + continue; + } + if (!matchesScalar(value, actualValue)) { + return { matched: false, missingKeys }; + } + continue; + } + if (isPlainObject(value)) { + const sub = matchParams(value, actualValue); + missingKeys.push(...sub.missingKeys.map((k) => `${key}.${k}`)); + if (!sub.matched) { + return { matched: false, missingKeys }; + } + continue; + } + if (Array.isArray(value)) { + if (!Array.isArray(actualValue)) { + return { matched: false, missingKeys }; + } + const allFound = value.every((needle) => + actualValue.some((item) => matchesScalar(needle, item) || isSubsetValue(needle, item)) + ); + if (!allFound) { + return { matched: false, missingKeys }; + } + continue; + } + if (value !== actualValue) { + return { matched: false, missingKeys }; + } + } + if (missingKeys.length > 0) { + return { matched: false, missingKeys }; + } + return { matched: true, missingKeys: [] }; +} + +/** + * Apply a single matcher spec across ANY string field in a tool input. + * Used for rules like "deny anything mentioning ~/.ssh" where the user + * doesn't know which parameter key the tool uses. + */ +export function matchesAnyStringField(spec, toolInput, depth = 0) { + if (depth > 4) return false; + if (toolInput === null || toolInput === undefined) return false; + if (typeof toolInput === "string") { + return matchesScalar(spec, toolInput); + } + if (Array.isArray(toolInput)) { + return toolInput.some((entry) => matchesAnyStringField(spec, entry, depth + 1)); + } + if (isPlainObject(toolInput)) { + for (const value of Object.values(toolInput)) { + if (matchesAnyStringField(spec, value, depth + 1)) return true; + } + } + return false; +} + +function sanitizeValue(value, limits, depth) { + if (depth > limits.maxDepth) { + return ""; + } + if (value == null) { + return value; + } + if (typeof value === "string") { + return value.length > limits.maxChars ? `${value.slice(0, limits.maxChars)}...` : value; + } + if (typeof value === "number" || typeof value === "boolean") { + return value; + } + if (typeof value === "bigint") { + return value.toString(); + } + if (typeof value === "symbol") { + return value.toString(); + } + if (typeof value === "function") { + return ""; + } + if (value instanceof Uint8Array) { + return ``; + } + if (Array.isArray(value)) { + return value.slice(0, limits.maxItems).map((entry) => sanitizeValue(entry, limits, depth + 1)); + } + if (isPlainObject(value)) { + const out = {}; + for (const [key, item] of Object.entries(value).slice(0, limits.maxKeys)) { + out[key] = sanitizeValue(item, limits, depth + 1); + } + return out; + } + try { + return JSON.parse(JSON.stringify(value)); + } catch { + return ""; + } +} + +export function sanitizeParams(params, limits) { + const input = isPlainObject(params) ? params : {}; + const sanitized = sanitizeValue(input, limits, 0); + return isPlainObject(sanitized) ? sanitized : {}; +} + +// --------------------------------------------------------------------------- +// Secret redaction — applied to audit payloads before they leave the host. +// Kept deliberately cheap: a handful of regexes run against strings only, +// no deep rebuild when nothing matches. +// --------------------------------------------------------------------------- + +const SECRET_PATTERNS = [ + // Bearer / Authorization tokens in free text + /\b(Bearer\s+)[A-Za-z0-9._\-+/=]{12,}/gi, + // AWS access keys + /\bAKIA[0-9A-Z]{16}\b/g, + // Generic long hex / base64 tokens prefixed by common secret field names + /\b((?:api[_-]?key|secret|token|password|passwd|pwd|authorization)\s*[:=]\s*)["']?[A-Za-z0-9._\-+/=]{12,}["']?/gi, + // GitHub personal access tokens + /\bghp_[A-Za-z0-9]{30,}\b/g, + // JWT-ish three-part tokens + /\beyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\b/g, + // Private key blocks + /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g +]; + +function redactString(text) { + let out = text; + for (const pattern of SECRET_PATTERNS) { + out = out.replace(pattern, (match, prefix) => `${prefix || ""}`); + } + return out; +} + +function redactValue(value, depth = 0) { + if (depth > 8) return value; + if (typeof value === "string") { + return redactString(value); + } + if (Array.isArray(value)) { + return value.map((entry) => redactValue(entry, depth + 1)); + } + if (isPlainObject(value)) { + const out = {}; + for (const [key, entry] of Object.entries(value)) { + out[key] = redactValue(entry, depth + 1); + } + return out; + } + return value; +} + +export function redactSecrets(value) { + return redactValue(value, 0); +} + +export function nowEpochSeconds() { + return Math.floor(Date.now() / 1000); +} + +export function readString(value) { + return typeof value === "string" ? value.trim() || undefined : undefined; +} + +export function parseStepIndex(value) { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string") { + const parsed = Number.parseInt(value.trim(), 10); + if (Number.isFinite(parsed)) { + return parsed; + } + } + return null; +} + +export function sha256Hex(value) { + return createHash("sha256").update(value).digest("hex"); +} + +// --------------------------------------------------------------------------- +// HTTP helpers (shared by intent.mjs and iap-service.mjs) +// --------------------------------------------------------------------------- + +export async function postJson(url, payload, headers, timeoutMs) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify(payload), + signal: controller.signal + }); + const text = await response.text(); + let data = null; + if (text) { + try { + data = JSON.parse(text); + } catch { + data = null; + } + } + return { ok: response.ok, status: response.status, text, data }; + } finally { + clearTimeout(timeout); + } +} + +export function buildAuthHeaders(config) { + const headers = { "Content-Type": "application/json" }; + if (config.apiKey) { + headers.Authorization = `Bearer ${config.apiKey}`; + headers["X-API-Key"] = config.apiKey; + headers["x-api-key"] = config.apiKey; + } + return headers; +} diff --git a/plugins/armoriq/armorCodex/scripts/lib/config.mjs b/plugins/armoriq/armorCodex/scripts/lib/config.mjs new file mode 100644 index 00000000..d197af81 --- /dev/null +++ b/plugins/armoriq/armorCodex/scripts/lib/config.mjs @@ -0,0 +1,161 @@ +import { homedir } from "node:os"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { parseBoolean, parseInteger, parseList } from "./common.mjs"; + +/** + * Read a config value from plugin userConfig env, falling back to the + * ARMORCODEX_* env var used by repo-local hook installs. + */ +function pluginOpt(env, pluginKey, legacyKey) { + const pluginVal = + env[`CODEX_PLUGIN_OPTION_${pluginKey}`]?.trim() || + env[`CLAUDE_PLUGIN_OPTION_${pluginKey}`]?.trim(); + if (pluginVal) return pluginVal; + if (legacyKey) return env[legacyKey]?.trim() || ""; + return ""; +} + +export function loadConfig(env = process.env) { + const mode = (pluginOpt(env, "MODE", "ARMORCODEX_MODE") || "enforce").toLowerCase(); + const envMode = (env.ARMORIQ_ENV || "production").trim().toLowerCase(); + const useProduction = parseBoolean( + pluginOpt(env, "USE_PRODUCTION", "ARMORCODEX_USE_PRODUCTION") || undefined, + envMode === "production" + ); + + // Data directory: prefer plugin-injected storage, then + // ARMORCODEX_DATA_DIR, then default ~/.codex/armorcodex. + const dataDir = + env.CODEX_PLUGIN_DATA?.trim() || + env.CLAUDE_PLUGIN_DATA?.trim() || + env.ARMORCODEX_DATA_DIR?.trim() || + path.join(homedir(), ".codex", "armorcodex"); + + const policyFile = + env.ARMORCODEX_POLICY_FILE?.trim() || path.join(dataDir, "policy.json"); + const runtimeFile = + env.ARMORCODEX_RUNTIME_FILE?.trim() || path.join(dataDir, "runtime.json"); + + const timeoutMs = parseInteger(env.ARMORCODEX_TIMEOUT_MS, 8000); + + const backendEndpoint = + env.ARMORCODEX_BACKEND_ENDPOINT?.trim() || + env.BACKEND_ENDPOINT?.trim() || + (useProduction + ? "https://api.armoriq.ai" + : "http://127.0.0.1:3000"); + + const iapEndpoint = + env.ARMORCODEX_IAP_ENDPOINT?.trim() || + env.IAP_ENDPOINT?.trim() || + (useProduction + ? "https://iap.armoriq.ai" + : "http://127.0.0.1:8000"); + + const proxyEndpoint = + env.ARMORCODEX_PROXY_ENDPOINT?.trim() || + env.PROXY_ENDPOINT?.trim() || + (useProduction + ? "https://cloud-run-proxy.armoriq.io" + : "http://127.0.0.1:3001"); + + const csrgEndpoint = + pluginOpt(env, "CSRG_ENDPOINT", "CSRG_URL") || iapEndpoint; + + // API key resolution: plugin config → env var → ~/.armoriq/credentials.json + let apiKey = pluginOpt(env, "API_KEY", "ARMORIQ_API_KEY"); + if (!apiKey) { + try { + const credPath = path.join(homedir(), ".armoriq", "credentials.json"); + const creds = JSON.parse(readFileSync(credPath, "utf-8")); + if (creds?.apiKey && typeof creds.apiKey === "string") { + apiKey = creds.apiKey; + } + } catch { + // no credentials file — local-only mode + } + } + + return { + mode: mode === "monitor" ? "monitor" : "enforce", + dataDir, + policyFile, + runtimeFile, + useProduction, + backendEndpoint, + iapEndpoint, + proxyEndpoint, + csrgEndpoint, + apiKey, + useSdkIntent: parseBoolean(env.ARMORCODEX_USE_SDK_INTENT, true), + intentEndpoint: env.ARMORCODEX_INTENT_URL?.trim() || "", + verifyStepEndpoint: + env.ARMORCODEX_VERIFY_STEP_URL?.trim() || + `${backendEndpoint}/iap/verify-step`, + // 10 minutes is long enough for multi-step agentic work without forcing + // a replan mid-turn. Set ARMORCODEX_VALIDITY_SECONDS to tighten. + validitySeconds: parseInteger(env.ARMORCODEX_VALIDITY_SECONDS, 600), + // Proactively refresh the intent token when it has less than this many + // seconds of life left, so tool calls don't hit the expiry boundary. + refreshThresholdSeconds: parseInteger(env.ARMORCODEX_REFRESH_THRESHOLD_SECONDS, 30), + timeoutMs, + // One attempt per tool call is usually right — a hung backend shouldn't + // stall Codex for timeout * retries. Users who really want retries can + // opt in via ARMORCODEX_MAX_RETRIES. + maxRetries: parseInteger(env.ARMORCODEX_MAX_RETRIES, 1), + verifySsl: parseBoolean(env.ARMORCODEX_VERIFY_SSL, true), + llmId: env.ARMORCODEX_LLM_ID?.trim() || "openai-codex", + mcpName: env.ARMORCODEX_MCP_NAME?.trim() || "codex", + userId: env.ARMORCODEX_USER_ID?.trim() || "codex-user", + agentId: env.ARMORCODEX_AGENT_ID?.trim() || "codex", + contextId: env.ARMORCODEX_CONTEXT_ID?.trim() || "default", + + // Intent enforcement — default true (enforce plan mode) + intentRequired: parseBoolean( + pluginOpt(env, "INTENT_REQUIRED", "ARMORCODEX_INTENT_REQUIRED") || undefined, + true + ), + // CSRG verification disabled by default until tenant OPA policies are + // configured to allow Codex tools. The OPA default-deny behavior + // blocks all tools when no matching policy exists. Enable once your + // tenant has allow-rules for the tools Codex uses. + requireCsrgProofs: parseBoolean(env.REQUIRE_CSRG_PROOFS, false), + csrgVerifyEnabled: parseBoolean(env.CSRG_VERIFY_ENABLED, false), + + // Policy management + policyUpdateEnabled: parseBoolean(env.ARMORCODEX_POLICY_UPDATE_ENABLED, true), + policyUpdateAllowList: parseList( + env.ARMORCODEX_POLICY_UPDATE_ALLOWLIST || "*" + ), + contextHintsEnabled: parseBoolean( + env.ARMORCODEX_CONTEXT_HINTS_ENABLED, + true + ), + + // Crypto policy binding (Merkle tree) + cryptoPolicyEnabled: parseBoolean( + pluginOpt(env, "CRYPTO_POLICY_ENABLED", "ARMORCODEX_CRYPTO_POLICY_ENABLED") || undefined, + false + ), + + // Audit logging + auditEnabled: parseBoolean( + env.ARMORCODEX_AUDIT_ENABLED, + Boolean(apiKey) + ), + + // Plan directive injection (tells Codex to register a plan via MCP tool) + planningEnabled: parseBoolean(env.ARMORCODEX_PLANNING_ENABLED, true), + + // Param sanitization limits + sanitize: { + maxChars: parseInteger(env.ARMORCODEX_MAX_PARAM_CHARS, 2000), + maxDepth: parseInteger(env.ARMORCODEX_MAX_PARAM_DEPTH, 4), + maxKeys: parseInteger(env.ARMORCODEX_MAX_PARAM_KEYS, 50), + maxItems: parseInteger(env.ARMORCODEX_MAX_PARAM_ITEMS, 50) + }, + + debug: parseBoolean(env.ARMORCODEX_DEBUG, false) + }; +} diff --git a/plugins/armoriq/armorCodex/scripts/lib/crypto-policy.mjs b/plugins/armoriq/armorCodex/scripts/lib/crypto-policy.mjs new file mode 100644 index 00000000..747a16d8 --- /dev/null +++ b/plugins/armoriq/armorCodex/scripts/lib/crypto-policy.mjs @@ -0,0 +1,244 @@ +/** + * Crypto-Bound Policy Service + * + * Embeds policy rules into CSRG tokens with cryptographic (Merkle tree) proofs. + * Ported from ArmorClaw's CryptoPolicyService (crypto-policy.service.ts). + * + * Flow: + * 1. Policy update -> build policy metadata -> call CSRG /intent + * 2. CSRG hashes policy into Merkle tree -> signs with Ed25519 + * 3. Tool execution -> verify policy digest matches token + * + * State is persisted to disk because hooks are stateless short-lived processes. + */ + +import { isPlainObject, postJson, sha256Hex } from "./common.mjs"; +import { readJson, writeJson } from "./fs-store.mjs"; +import path from "node:path"; + +// --------------------------------------------------------------------------- +// Policy digest computation +// --------------------------------------------------------------------------- + +/** + * Compute a canonical SHA-256 digest of policy rules. + * Must match ArmorClaw's computePolicyDigest exactly. + */ +export function computePolicyDigest(rules) { + if (!Array.isArray(rules)) return sha256Hex("policy|[]"); + const canonical = JSON.stringify( + rules.map((r) => ({ + id: r.id, + action: r.action, + tool: r.tool, + dataClass: r.dataClass, + params: r.params, + scope: r.scope + })), + null, + 0 + ); + return sha256Hex(`policy|${canonical}`); +} + +// --------------------------------------------------------------------------- +// Service factory +// --------------------------------------------------------------------------- + +/** + * Create a CryptoPolicyService instance. + * Adapted for stateless hook execution with file-based persistence. + */ +export function createCryptoPolicyService(config) { + const csrgEndpoint = config.csrgEndpoint || config.iapEndpoint || ""; + const timeoutMs = config.timeoutMs || 30000; + const stateFilePath = path.join(config.dataDir, "crypto-policy-state.json"); + + return { + /** + * Issue a new CSRG policy token with policy embedded in Merkle tree. + */ + async issuePolicyToken(policyState, identity, validitySeconds = 3600) { + const digest = computePolicyDigest(policyState.policy?.rules || []); + + const policyMetadata = { + rules: policyState.policy?.rules || [], + version: policyState.version || 0, + updated_at: policyState.updatedAt || new Date().toISOString(), + updated_by: policyState.updatedBy, + policy_digest: digest + }; + + const plan = buildPolicyPlan(policyState.policy); + + const request = { + plan, + policy: { + global: { + metadata: policyMetadata + } + }, + identity: { + user_id: identity.userId || config.userId || "codex-user", + agent_id: identity.agentId || config.agentId || "codex", + context_id: identity.contextId || config.contextId || "default" + }, + validity_seconds: validitySeconds + }; + + const response = await postJson( + `${csrgEndpoint}/intent`, + request, + { "Content-Type": "application/json" }, + timeoutMs + ); + + if (!response.ok || !response.data) { + const msg = response.text || `CSRG /intent failed with status ${response.status}`; + throw new Error(`Policy token issuance failed: ${msg}`); + } + + const token = { + ...response.data, + policy_digest: digest + }; + + // Persist to disk + await writeJson(stateFilePath, { + token, + policyDigest: digest, + issuedAt: Date.now() + }); + + return token; + }, + + /** + * Verify that the current policy digest matches the cached token digest. + * Returns { valid, reason }. + */ + verifyPolicyDigest(currentDigest, tokenDigest) { + if (!tokenDigest) { + return { + valid: false, + reason: "No policy token - policy not cryptographically bound" + }; + } + if (currentDigest !== tokenDigest) { + return { + valid: false, + reason: `Policy mismatch: current=${currentDigest.slice(0, 16)}... token=${tokenDigest.slice(0, 16)}...` + }; + } + return { valid: true, reason: "Policy digest verified" }; + }, + + /** + * Verify a policy rule is included in the token using CSRG /verify/action. + */ + async verifyPolicyRule(ruleId, toolName) { + const cached = await this.loadCachedState(); + if (!cached?.token) { + return { allowed: false, reason: "No policy token cached" }; + } + + const ruleProof = cached.token.step_proofs?.find( + (p) => p.path?.includes(ruleId) || p.path?.includes(toolName) + ); + + if (!ruleProof) { + return { allowed: true, reason: "No specific proof required" }; + } + + const verifyRequest = { + path: ruleProof.path, + value: { tool: toolName, rule_id: ruleId }, + proof: ruleProof.proof, + token: cached.token.token + }; + + const response = await postJson( + `${csrgEndpoint}/verify/action`, + verifyRequest, + { "Content-Type": "application/json" }, + Math.min(timeoutMs, 15000) + ); + + if (!response.ok || !response.data) { + return { + allowed: false, + reason: response.text || "CSRG verification failed" + }; + } + + return response.data; + }, + + /** + * Load persisted crypto policy state from disk. + */ + async loadCachedState() { + return await readJson(stateFilePath, null); + }, + + /** + * Clear persisted crypto policy state. + */ + async clearCache() { + try { + await writeJson(stateFilePath, null); + } catch { /* ignore */ } + } + }; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** + * Convert policy rules into a plan structure for CSRG hashing. + * Each rule becomes a step with action "policy_rule:". + * Matches ArmorClaw's CryptoPolicyService.buildPolicyPlan(). + */ +function buildPolicyPlan(policy) { + const rules = Array.isArray(policy?.rules) ? policy.rules : []; + + const steps = rules.map((rule) => ({ + action: `policy_rule:${rule.id}`, + mcp: "armoriq-policy", + description: `Rule: ${rule.action} ${rule.tool}${rule.dataClass ? ` for ${rule.dataClass}` : ""}`, + metadata: { + rule_id: rule.id, + rule_action: rule.action, + rule_tool: rule.tool, + rule_data_class: rule.dataClass, + rule_params: rule.params, + rule_scope: rule.scope + } + })); + + if (steps.length === 0) { + steps.push({ + action: "policy_rule:allow-all", + mcp: "armoriq-policy", + description: "Default: allow all", + metadata: { + rule_id: "allow-all", + rule_action: "allow", + rule_tool: "*", + rule_data_class: undefined, + rule_params: undefined, + rule_scope: undefined + } + }); + } + + return { + steps, + metadata: { + goal: "ArmorIQ policy enforcement", + policy_type: "crypto-bound" + } + }; +} diff --git a/plugins/armoriq/armorCodex/scripts/lib/engine.mjs b/plugins/armoriq/armorCodex/scripts/lib/engine.mjs new file mode 100644 index 00000000..c5fb2354 --- /dev/null +++ b/plugins/armoriq/armorCodex/scripts/lib/engine.mjs @@ -0,0 +1,746 @@ +import { isPlainObject, normalizeToolName, nowEpochSeconds, redactSecrets, sanitizeParams } from "./common.mjs"; +import { addPromptContext, blockPrompt, denyPermissionRequest, denyPreTool } from "./hook-output.mjs"; +import { + checkIntentTokenPlan, + checkToolAgainstPlan, + extractAllowedActions, + findPlanStepIndices, + getSessionTokenUsedStepIndices, + parseCsrgProofHeaders, + recordSessionTokenUsedStepIndices, + requestIntent, + resolveCsrgProofsFromToken, + validateCsrgProofHeaders +} from "./intent.mjs"; +import { createIapService } from "./iap-service.mjs"; +import { + applyPolicyCommand, + computePolicyHash, + evaluatePolicy, + loadPolicyState, + parsePolicyTextCommand +} from "./policy.mjs"; +import { readJson } from "./fs-store.mjs"; +import { unlink } from "node:fs/promises"; +import path from "node:path"; +import { + getSession, + loadRuntimeState, + saveRuntimeState, + upsertDiscoveredTool, + upsertSession +} from "./runtime-state.mjs"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function shouldDeny(config) { + return config.mode === "enforce"; +} + +function buildPolicyContextHints() { + return "For policy changes call `policy_update` (mode: replace rewrites the full ruleset; empty rules clears policy)."; +} + +function actorCandidates(input) { + const out = []; + for (const key of ["session_id", "user_id", "actor_id", "cwd"]) { + const value = input && typeof input[key] === "string" ? input[key].trim() : ""; + if (value) { + out.push(value); + } + } + return out; +} + +function policyCommandLooksLikePrompt(prompt) { + return typeof prompt === "string" && /^\s*policy\b/i.test(prompt); +} + +function isPolicyUpdateAllowed(config, input) { + if (!config.policyUpdateEnabled) { + return { allowed: false, reason: "ArmorCodex policy updates disabled" }; + } + const allowList = config.policyUpdateAllowList; + if (!Array.isArray(allowList) || allowList.length === 0 || allowList.includes("*")) { + return { allowed: true }; + } + const candidates = actorCandidates(input); + const allowed = candidates.some((entry) => allowList.includes(entry)); + return allowed + ? { allowed: true } + : { + allowed: false, + reason: "ArmorCodex policy update denied", + candidates + }; +} + +function mergeIntentIntoSession(session, intentResponse) { + if (!intentResponse || intentResponse.skipped) { + return session; + } + const next = { ...session }; + if (typeof intentResponse.tokenRaw === "string") { + next.intentTokenRaw = intentResponse.tokenRaw; + } + if (intentResponse.plan && typeof intentResponse.plan === "object") { + next.plan = intentResponse.plan; + next.allowedActions = Array.from(extractAllowedActions(intentResponse.plan)); + } + if (Number.isFinite(intentResponse.expiresAt)) { + next.expiresAt = intentResponse.expiresAt; + } + return next; +} + +function readIntentTokenRaw(input, session) { + const candidates = [ + input.intentTokenRaw, + input.intent_token_raw, + input.intent_token, + input.intentToken, + session.intentTokenRaw + ]; + for (const value of candidates) { + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + } + return ""; +} + +function denyOrAllow(config, reason) { + if (shouldDeny(config)) { + return denyPreTool(reason); + } + return null; +} + +function debugLog(config, message) { + if (config.debug) { + process.stderr.write(`[armorcodex] ${message}\n`); + } +} + +/** + * Pick the best matching step index in the plan for a given tool call. + * Prefers a step that matches BOTH tool name and parameters, falls back to + * tool name only, then to step 0. Used to populate audit log step_index so + * the backend can advance plan execution state to 'completed'. + */ +function pickStepIndex(plan, toolName, toolInput) { + if (!plan || typeof plan !== "object") return 0; + const { matches, paramMatches } = findPlanStepIndices(plan, toolName, toolInput); + if (paramMatches.length > 0) return paramMatches[0]; + if (matches.length > 0) return matches[0]; + return 0; +} + +// --------------------------------------------------------------------------- +// SessionStart +// --------------------------------------------------------------------------- + +export async function handleSessionStart(input, config) { + const sessionId = typeof input.session_id === "string" ? input.session_id : ""; + if (!sessionId) return null; + + const runtimeState = await loadRuntimeState(config.runtimeFile); + upsertSession(runtimeState, sessionId, { + startedAt: nowEpochSeconds(), + discoveredTools: [] + }); + await saveRuntimeState(config.runtimeFile, runtimeState); + + debugLog(config, `session started: ${sessionId}, mode=${config.mode}`); + + const modeLabel = config.mode === "enforce" ? "ENFORCING" : "MONITORING"; + const intentLabel = config.intentRequired ? "required" : "optional"; + return addPromptContext( + `ArmorCodex active (${modeLabel}, intent=${intentLabel})`, + "SessionStart" + ); +} + +// --------------------------------------------------------------------------- +// UserPromptSubmit +// --------------------------------------------------------------------------- + +export async function handleUserPromptSubmit(input, config) { + const prompt = typeof input.prompt === "string" ? input.prompt : ""; + const sessionId = typeof input.session_id === "string" ? input.session_id : ""; + if (!prompt || !sessionId) { + return null; + } + + // --- Policy command handling --- + if (policyCommandLooksLikePrompt(prompt)) { + const allowed = isPolicyUpdateAllowed(config, input); + if (!allowed.allowed) { + return blockPrompt(allowed.reason || "ArmorCodex policy update denied"); + } + const policyState = await loadPolicyState(config.policyFile); + const command = parsePolicyTextCommand(prompt, policyState); + const actor = actorCandidates(input)[0] || "unknown"; + const result = await applyPolicyCommand({ + policyFilePath: config.policyFile, + state: policyState, + command, + actor + }); + return blockPrompt(result.message); + } + + // --- Store prompt in session --- + const runtimeState = await loadRuntimeState(config.runtimeFile); + upsertSession(runtimeState, sessionId, { + lastPrompt: prompt, + lastPromptAt: nowEpochSeconds() + }); + await saveRuntimeState(config.runtimeFile, runtimeState); + + // --- Inject directive: tell Codex to register its intent plan --- + // Codex will call the `register_intent_plan` MCP tool as its first action. + // The MCP tool's inputSchema already describes the JSON shape, so we don't + // duplicate it here — keeps the visible prompt context short. + const parts = []; + if (config.planningEnabled) { + parts.push( + "ArmorCodex active. Call `register_intent_plan` first; step `action` = tool name, `metadata.inputs` = `{}` matches by name only." + ); + } + if (config.contextHintsEnabled && config.policyUpdateEnabled) { + parts.push(buildPolicyContextHints()); + } + if (parts.length > 0) { + return addPromptContext(parts.join("\n\n")); + } + return null; +} + +// --------------------------------------------------------------------------- +// PreToolUse +// --------------------------------------------------------------------------- + +export async function handlePreToolUse(input, config) { + const sessionId = typeof input.session_id === "string" ? input.session_id : ""; + const toolName = typeof input.tool_name === "string" ? input.tool_name : ""; + const toolInput = sanitizeParams(input.tool_input, config.sanitize); + if (!toolName) { + // Missing tool_name on a PreToolUse event means the payload shape is + // unexpected. Fail-closed in enforce mode instead of silently allowing. + return denyOrAllow(config, "ArmorCodex: missing tool_name on PreToolUse"); + } + + // --- Whitelist: ArmorCodex's own MCP tools must never be blocked, + // otherwise the agent can't register a plan or read/update policy. + // Match the exact MCP prefix from .mcp.json (armorcodex-policy), + // not any suffix — an evil server called evil__policy_update would + // previously have been whitelisted. --- + const norm = normalizeToolName(toolName); + const armorTools = ["register_intent_plan", "policy_read", "policy_update"]; + // Codex MCP namespace is `mcp____` and the underlying MCP server name + // can carry hyphens (`armorcodex-policy`) or be sanitized to underscores + // (`armorcodex_policy`). Codex's TUI display also surfaces `.` + // in user-facing strings. Match all reasonable forms — but only accept names + // anchored to our own server identifier so this can't whitelist a malicious + // MCP server that happens to expose a same-named tool. + const ARMOR_SERVER_RE = /(mcp__armorcodex[-_]policy__|armorcodex[-_]policy[._])/; + if ( + armorTools.some( + (t) => + norm === t || + (norm.endsWith(t) && ARMOR_SERVER_RE.test(norm)) + ) + ) { + return null; + } + + // --- Whitelist: Codex introspection / coordination tools that have + // no side effects on user files or systems. Blocking these makes the + // agent fight itself (e.g. ToolSearch is needed to fetch deferred MCP + // tool schemas before they can be called). --- + const safeInternalTools = new Set([ + "toolsearch", + "todowrite", + "listmcpresourcestool", + "readmcpresourcetool", + "read", + "grep", + "glob", + "websearch", + "webfetch" + ]); + if (safeInternalTools.has(norm)) { + return null; + } + + // --- Consume pending plan from register_intent_plan MCP tool --- + // Always consume if a pending file exists — the MCP handler only writes + // it when Codex has registered a NEW plan, and stale plans must be + // overwritten so each prompt gets its own plan boundary. + // This load is reused for the rest of the PreToolUse handler instead of + // reloading from disk below (fewer disk reads on the hot path). + const runtimeState = await loadRuntimeState(config.runtimeFile); + // Per-session plan file so concurrent Codex windows don't clobber each + // other. Fall back to the legacy global path for installs that still have + // a write from a pre-upgrade MCP server. + const sessionPendingPath = sessionId + ? path.join(config.dataDir, `pending-plan.${sessionId}.json`) + : null; + const legacyPendingPath = path.join(config.dataDir, "pending-plan.json"); + let pendingPath = sessionPendingPath; + let pending = sessionPendingPath ? await readJson(sessionPendingPath, null) : null; + if (!pending) { + pending = await readJson(legacyPendingPath, null); + if (pending) pendingPath = legacyPendingPath; + } + if (pending && (pending.tokenRaw || pending.plan)) { + upsertSession(runtimeState, sessionId, { + intentTokenRaw: pending.tokenRaw || "", + plan: pending.plan, + allowedActions: Array.isArray(pending.allowedActions) ? pending.allowedActions : [], + expiresAt: pending.expiresAt, + // Reset per-token execution tracking when a new plan replaces the old. + intentExecution: undefined + }); + await saveRuntimeState(config.runtimeFile, runtimeState); + if (pendingPath) await unlink(pendingPath).catch(() => {}); + debugLog(config, "consumed pending plan from register_intent_plan"); + } + + // --- Static policy evaluation --- + const policyState = await loadPolicyState(config.policyFile); + + // Crypto policy digest check (Phase 4 integration point) + if (config.cryptoPolicyEnabled) { + try { + const { createCryptoPolicyService } = await import("./crypto-policy.mjs"); + const cryptoService = createCryptoPolicyService(config); + const currentDigest = computePolicyHash(policyState.policy); + const cachedState = await cryptoService.loadCachedState(); + if (cachedState?.policyDigest) { + const check = cryptoService.verifyPolicyDigest(currentDigest, cachedState.policyDigest); + if (!check.valid) { + return denyOrAllow(config, `ArmorCodex crypto policy mismatch: ${check.reason}`); + } + } + } catch (error) { + debugLog(config, `crypto policy check error: ${error}`); + } + } + + const policyDecision = evaluatePolicy({ + policy: policyState.policy, + toolName, + toolParams: toolInput + }); + if (!policyDecision.allowed) { + return denyPreTool(policyDecision.reason || "ArmorCodex policy denied"); + } + + // --- Intent token verification --- + // Reuse the runtimeState loaded above instead of re-reading from disk. + const session = getSession(runtimeState, sessionId) || {}; + let intentTokenRaw = readIntentTokenRaw(input, session); + let localPlan = session.plan; + let localExpiresAt = session.expiresAt; + let remoteAllowed = false; + let tokenCheckMatched = false; + let usedStepIndices = + intentTokenRaw && localPlan + ? getSessionTokenUsedStepIndices(session, intentTokenRaw) + : undefined; + + // Proactive refresh: if the token is about to expire and we still have the + // plan, re-issue silently so the user never sees a "token expired" deny in + // the middle of a multi-step turn. If the refresh fails, flow falls through + // to the existing expiry check below. + const refreshThreshold = Number.isFinite(config.refreshThresholdSeconds) + ? config.refreshThresholdSeconds + : 30; + if ( + intentTokenRaw && + isPlainObject(localPlan) && + Number.isFinite(localExpiresAt) && + localExpiresAt - nowEpochSeconds() < refreshThreshold && + (config.intentEndpoint || (config.useSdkIntent && config.apiKey)) + ) { + try { + const policyHash = computePolicyHash(policyState.policy); + const refreshed = await requestIntent(config, { + prompt: session.lastPrompt || `Refresh intent for ${toolName}`, + plan: localPlan, + session_id: sessionId, + toolName, + toolInput, + policy_hash: policyHash, + policy: policyState.policy, + validitySeconds: config.validitySeconds, + metadata: { source: "codex", trigger: "auto_refresh" } + }); + if (!refreshed.skipped) { + const merged = mergeIntentIntoSession(session, refreshed); + upsertSession(runtimeState, sessionId, merged); + intentTokenRaw = + typeof merged.intentTokenRaw === "string" + ? merged.intentTokenRaw + : intentTokenRaw; + localPlan = merged.plan || localPlan; + localExpiresAt = merged.expiresAt || localExpiresAt; + debugLog(config, "intent token auto-refreshed near expiry"); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + debugLog(config, `auto-refresh failed: ${message}`); + } + } + + // If no token, try to acquire one + if (!intentTokenRaw && (config.intentEndpoint || (config.useSdkIntent && config.apiKey))) { + try { + const policyHash = computePolicyHash(policyState.policy); + const intentResponse = await requestIntent(config, { + prompt: session.lastPrompt || `Use tool ${toolName}`, + session_id: sessionId, + toolName, + toolInput, + policy_hash: policyHash, + policy: policyState.policy, + validitySeconds: config.validitySeconds, + metadata: { + source: "codex", + trigger: "pre_tool_use" + } + }); + const merged = mergeIntentIntoSession(session, intentResponse); + upsertSession(runtimeState, sessionId, merged); + intentTokenRaw = + typeof merged.intentTokenRaw === "string" ? merged.intentTokenRaw : ""; + localPlan = merged.plan || localPlan; + localExpiresAt = merged.expiresAt || localExpiresAt; + usedStepIndices = + intentTokenRaw && localPlan + ? getSessionTokenUsedStepIndices(merged, intentTokenRaw) + : undefined; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (config.intentRequired && shouldDeny(config)) { + return denyPreTool(`ArmorCodex intent planning failed: ${message}`); + } + } + } + + // Validate tool against intent token plan + if (intentTokenRaw) { + const tokenCheck = checkIntentTokenPlan({ + intentTokenRaw, + toolName, + toolParams: toolInput + }); + if (tokenCheck.matched) { + tokenCheckMatched = true; + if (tokenCheck.blockReason) { + return denyOrAllow(config, tokenCheck.blockReason); + } + localPlan = tokenCheck.plan || localPlan; + remoteAllowed = true; + } + } + + // --- CSRG proof handling --- + const parsedProofs = parseCsrgProofHeaders(input); + if (parsedProofs.error) { + return denyOrAllow(config, parsedProofs.error); + } + let csrgProofs = parsedProofs.proofs; + if (!csrgProofs && intentTokenRaw && localPlan && typeof localPlan === "object") { + const resolved = resolveCsrgProofsFromToken({ + intentTokenRaw, + plan: localPlan, + toolName, + toolParams: toolInput, + usedStepIndices + }); + if (resolved) { + csrgProofs = resolved; + } + } + const proofError = validateCsrgProofHeaders( + csrgProofs, + config.requireCsrgProofs && + config.csrgVerifyEnabled && + Boolean(config.verifyStepEndpoint) && + Boolean(intentTokenRaw) + ); + if (proofError) { + return denyOrAllow(config, proofError); + } + + // --- Remote step verification --- + if (intentTokenRaw && config.verifyStepEndpoint && config.csrgVerifyEnabled) { + try { + const iapService = createIapService(config); + const verifyResult = await iapService.verifyStep(intentTokenRaw, csrgProofs, toolName); + if (!verifyResult.skipped) { + remoteAllowed = verifyResult.allowed === true; + } + if (verifyResult.allowed === false) { + return denyOrAllow( + config, + verifyResult.reason || `ArmorCodex intent verification denied for ${toolName}` + ); + } + const merged = mergeIntentIntoSession(session, verifyResult); + upsertSession(runtimeState, sessionId, merged); + localPlan = merged.plan || localPlan; + localExpiresAt = merged.expiresAt || localExpiresAt; + if (typeof verifyResult.stepIndex === "number") { + const indices = usedStepIndices || new Set(); + indices.add(verifyResult.stepIndex); + recordSessionTokenUsedStepIndices(merged, intentTokenRaw, indices); + } else if (usedStepIndices && intentTokenRaw) { + recordSessionTokenUsedStepIndices(merged, intentTokenRaw, usedStepIndices); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const deny = denyOrAllow(config, `ArmorCodex verify-step failed: ${message}`); + if (deny) { + return deny; + } + } + } + + // --- Expiry check --- + if (Number.isFinite(localExpiresAt) && nowEpochSeconds() > localExpiresAt) { + const deny = denyOrAllow( + config, + "ArmorCodex intent token expired — call register_intent_plan with your current plan to refresh, then retry the tool" + ); + if (deny) { + return deny; + } + } + + // --- Local plan enforcement (no backend / no token) --- + // When a plan was registered via register_intent_plan but ArmorIQ is not + // configured, enforce the plan locally: tool must be in plan, and params + // (if declared in step.metadata.inputs) must match. + let localPlanMatched = false; + if (!intentTokenRaw && localPlan && typeof localPlan === "object") { + const localCheck = checkToolAgainstPlan({ + plan: localPlan, + toolName, + toolInput + }); + if (localCheck.allowed) { + localPlanMatched = true; + } else { + const deny = denyOrAllow(config, localCheck.reason || "ArmorCodex intent drift"); + if (deny) { + return deny; + } + } + } + + // --- Enforce intent requirement --- + if (config.intentRequired && !remoteAllowed && !tokenCheckMatched && !localPlanMatched) { + const deny = denyOrAllow(config, "ArmorCodex intent plan missing for this session"); + if (deny) { + return deny; + } + } + + // --- Record tool for discovery --- + upsertDiscoveredTool(runtimeState, toolName); + await saveRuntimeState(config.runtimeFile, runtimeState); + return null; +} + +// --------------------------------------------------------------------------- +// PermissionRequest +// --------------------------------------------------------------------------- + +export async function handlePermissionRequest(input, config) { + const toolName = typeof input.tool_name === "string" ? input.tool_name : ""; + const toolInput = sanitizeParams(input.tool_input, config.sanitize); + if (!toolName) { + return null; + } + + const policyState = await loadPolicyState(config.policyFile); + const policyDecision = evaluatePolicy({ + policy: policyState.policy, + toolName, + toolParams: toolInput + }); + if (!policyDecision.allowed && shouldDeny(config)) { + return denyPermissionRequest(policyDecision.reason || "ArmorCodex policy denied approval request"); + } + + return null; +} + +// --------------------------------------------------------------------------- +// PostToolUse — audit logging +// --------------------------------------------------------------------------- + +export async function handlePostToolUse(input, config) { + if (!config.auditEnabled || !config.apiKey) { + return null; + } + + const sessionId = typeof input.session_id === "string" ? input.session_id : ""; + const toolName = typeof input.tool_name === "string" ? input.tool_name : ""; + if (!toolName) return null; + + try { + const runtimeState = await loadRuntimeState(config.runtimeFile); + const session = getSession(runtimeState, sessionId) || {}; + const iapService = createIapService(config); + + const intentTokenRaw = session.intentTokenRaw || ""; + let token = intentTokenRaw; + // Extract JWT if embedded in JSON envelope + if (intentTokenRaw.startsWith("{")) { + try { + const parsed = JSON.parse(intentTokenRaw); + token = parsed.jwtToken || parsed.jwt_token || intentTokenRaw; + } catch { /* use raw */ } + } + + // Compute the real step index from the registered plan so the backend's + // updateExecutionProgress can advance plan status to 'completed'. + const inputs = sanitizeParams(input.tool_input, config.sanitize); + const stepIdx = pickStepIndex(session.plan, toolName, inputs); + + const dto = { + token, + step_index: stepIdx, + action: toolName, + tool: toolName, + input: redactSecrets(inputs), + output: redactSecrets(sanitizeParams(input.tool_response, config.sanitize)), + status: "success", + executed_at: new Date().toISOString(), + duration_ms: 0 + }; + + // Await the WAL disk write (~1-2ms) so the row is durable before the + // hook returns. Without the await a crash between read and write loses + // the audit row even though the WAL exists for exactly this reason. + // The slow HTTP ship to /iap/audit/batch still happens async via the + // embedded flusher in policy-mcp.mjs. Mirrors armorClaude#46 fix #5. + try { + await iapService.enqueueAudit(dto); + } catch (error) { + debugLog(config, `audit enqueue failed: ${error}`); + } + debugLog(config, `audit log enqueued for ${toolName} step=${stepIdx}`); + } catch (error) { + // Audit is best-effort — don't block + debugLog(config, `audit log failed: ${error}`); + } + + return null; +} + +// --------------------------------------------------------------------------- +// PostToolUseFailure — audit logging for failed tool calls +// --------------------------------------------------------------------------- + +export async function handlePostToolUseFailure(input, config) { + if (!config.auditEnabled || !config.apiKey) { + return null; + } + + const sessionId = typeof input.session_id === "string" ? input.session_id : ""; + const toolName = typeof input.tool_name === "string" ? input.tool_name : ""; + if (!toolName) return null; + + try { + const runtimeState = await loadRuntimeState(config.runtimeFile); + const session = getSession(runtimeState, sessionId) || {}; + const iapService = createIapService(config); + + const intentTokenRaw = session.intentTokenRaw || ""; + let token = intentTokenRaw; + if (intentTokenRaw.startsWith("{")) { + try { + const parsed = JSON.parse(intentTokenRaw); + token = parsed.jwtToken || parsed.jwt_token || intentTokenRaw; + } catch { /* use raw */ } + } + + const inputs = sanitizeParams(input.tool_input, config.sanitize); + const stepIdx = pickStepIndex(session.plan, toolName, inputs); + const dto = { + token, + step_index: stepIdx, + action: toolName, + tool: toolName, + input: redactSecrets(inputs), + output: null, + status: "failed", + error_message: typeof input.error === "string" ? redactSecrets(input.error) : "Unknown error", + executed_at: new Date().toISOString(), + duration_ms: 0 + }; + + // Same await rationale as the success path above — see armorClaude#46 fix #5. + try { + await iapService.enqueueAudit(dto); + } catch (error) { + debugLog(config, `audit enqueue (failure) failed: ${error}`); + } + debugLog(config, `audit log (failure) enqueued for ${toolName}`); + } catch (error) { + debugLog(config, `audit log (failure) failed: ${error}`); + } + + return null; +} + +// --------------------------------------------------------------------------- +// Stop — end of turn +// --------------------------------------------------------------------------- + +export async function handleStop(input, config) { + const sessionId = typeof input.session_id === "string" ? input.session_id : ""; + if (!sessionId) return null; + + const runtimeState = await loadRuntimeState(config.runtimeFile); + const session = getSession(runtimeState, sessionId); + if (!session) return null; + + // Check if token expired mid-turn + if (Number.isFinite(session.expiresAt) && nowEpochSeconds() > session.expiresAt) { + debugLog(config, "intent token expired during turn"); + } + + upsertSession(runtimeState, sessionId, { + lastStopAt: nowEpochSeconds() + }); + await saveRuntimeState(config.runtimeFile, runtimeState); + return null; +} + +// --------------------------------------------------------------------------- +// SessionEnd — cleanup +// --------------------------------------------------------------------------- + +export async function handleSessionEnd(input, config) { + const sessionId = typeof input.session_id === "string" ? input.session_id : ""; + if (!sessionId) return null; + + const runtimeState = await loadRuntimeState(config.runtimeFile); + // Remove the session entirely + if (runtimeState.sessions && runtimeState.sessions[sessionId]) { + delete runtimeState.sessions[sessionId]; + } + await saveRuntimeState(config.runtimeFile, runtimeState); + + debugLog(config, `session ended: ${sessionId}`); + return null; +} diff --git a/plugins/armoriq/armorCodex/scripts/lib/fs-store.mjs b/plugins/armoriq/armorCodex/scripts/lib/fs-store.mjs new file mode 100644 index 00000000..2d6843eb --- /dev/null +++ b/plugins/armoriq/armorCodex/scripts/lib/fs-store.mjs @@ -0,0 +1,36 @@ +import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises"; +import path from "node:path"; + +export async function readJson(filePath, fallbackValue) { + try { + const raw = await readFile(filePath, "utf8"); + return JSON.parse(raw); + } catch (error) { + if (error && typeof error === "object" && error.code === "ENOENT") { + return fallbackValue; + } + // Corrupted JSON (e.g. interrupted write from an older non-atomic build) + // falls back to the default rather than breaking the whole session. + if (error instanceof SyntaxError) { + return fallbackValue; + } + throw error; + } +} + +// Atomic write: write to a sibling tmp file then rename into place. Prevents +// partial/torn JSON when two hooks (PreToolUse + PostToolUse) race or when the +// process is killed mid-write. +export async function writeJson(filePath, value) { + await mkdir(path.dirname(filePath), { recursive: true }); + const tmpPath = `${filePath}.tmp.${process.pid}.${Date.now()}`; + const payload = JSON.stringify(value, null, 2); + try { + await writeFile(tmpPath, payload, "utf8"); + await rename(tmpPath, filePath); + } catch (error) { + await unlink(tmpPath).catch(() => {}); + throw error; + } +} + diff --git a/plugins/armoriq/armorCodex/scripts/lib/hook-output.mjs b/plugins/armoriq/armorCodex/scripts/lib/hook-output.mjs new file mode 100644 index 00000000..9cadc4bd --- /dev/null +++ b/plugins/armoriq/armorCodex/scripts/lib/hook-output.mjs @@ -0,0 +1,37 @@ +export function denyPreTool(reason) { + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: reason + } + }; +} + +export function denyPermissionRequest(reason) { + return { + hookSpecificOutput: { + hookEventName: "PermissionRequest", + decision: { + behavior: "deny", + message: reason + } + } + }; +} + +export function blockPrompt(reason) { + return { + decision: "block", + reason + }; +} + +export function addPromptContext(context, hookEventName = "UserPromptSubmit") { + return { + hookSpecificOutput: { + hookEventName, + additionalContext: context + } + }; +} diff --git a/plugins/armoriq/armorCodex/scripts/lib/iap-service.mjs b/plugins/armoriq/armorCodex/scripts/lib/iap-service.mjs new file mode 100644 index 00000000..9b6d53c2 --- /dev/null +++ b/plugins/armoriq/armorCodex/scripts/lib/iap-service.mjs @@ -0,0 +1,278 @@ +/** + * IAP Verification Service + * + * Abstraction over ArmorIQ IAP backend operations: + * - verifyStep: POST /iap/verify-step + * - verifyWithCsrg: POST /verify/action (CSRG Merkle proof) + * - createAuditLog: POST /iap/audit + * + * Ported from ArmorClaw's IAPVerificationService (iap-verfication.service.ts). + */ + +import { + buildAuthHeaders, + isPlainObject, + parseStepIndex, + postJson, + readString +} from "./common.mjs"; +import { createAuditWal } from "./audit-wal.mjs"; + +// Shared WAL instance per dataDir. The MCP server, hook handlers, and any +// fire-and-forget background flusher all enqueue to the same on-disk JSONL +// so the audit pipeline is crash-safe and concurrent-safe. +const walCache = new Map(); +function getAuditWal(config) { + const key = config.dataDir; + let wal = walCache.get(key); + if (!wal) { + wal = createAuditWal({ dataDir: config.dataDir }); + walCache.set(key, wal); + } + return wal; +} + +/** + * Create an IAP service instance from config. + */ +export function createIapService(config) { + const backendEndpoint = config.backendEndpoint || config.verifyStepEndpoint?.replace(/\/iap\/verify-step$/, "") || ""; + const csrgEndpoint = config.csrgEndpoint || config.iapEndpoint || ""; + const timeoutMs = config.timeoutMs || 8000; + const headers = buildAuthHeaders(config); + + return { + /** + * Verify a tool execution step with the IAP backend. + * Equivalent to ArmorClaw IAPVerificationService.verifyStep() + */ + async verifyStep(intentTokenRaw, csrgProofs, toolName) { + const endpoint = config.verifyStepEndpoint; + if (!endpoint || !config.csrgVerifyEnabled) { + return { skipped: true }; + } + + const { token, tokenObj } = getTokenForVerification(intentTokenRaw); + if (!token) { + return { skipped: false, allowed: false, reason: "ArmorIQ intent token missing" }; + } + + const payload = { token }; + if (csrgProofs?.path) { + payload.path = csrgProofs.path; + const stepMatch = csrgProofs.path.match(/\/steps\/\[(\d+)\]/); + if (stepMatch) { + payload.step_index = Number.parseInt(stepMatch[1] || "0", 10); + } + } + if (toolName) { + payload.tool_name = toolName; + } + if (Array.isArray(csrgProofs?.proof)) { + payload.proof = csrgProofs.proof; + } + if (csrgProofs?.valueDigest) { + payload.context = { + csrg_value_digest: csrgProofs.valueDigest, + proof_source: "client" + }; + } + + const response = await postJson(endpoint, payload, headers, timeoutMs); + if (!response.ok && !isPlainObject(response.data)) { + throw new Error( + response.text || `IAP verify-step failed with status ${response.status}` + ); + } + + const data = isPlainObject(response.data) ? response.data : {}; + const tokenRaw = + typeof data.intentTokenRaw === "string" + ? data.intentTokenRaw + : typeof data.tokenRaw === "string" + ? data.tokenRaw + : isPlainObject(data.token) + ? JSON.stringify(data.token) + : undefined; + const parsedFromResponse = tokenRaw ? extractPlanFromResponse(tokenRaw) : null; + const fallbackPlan = isPlainObject(tokenObj?.plan) + ? tokenObj.plan + : isPlainObject(tokenObj?.rawToken?.plan) + ? tokenObj.rawToken.plan + : undefined; + const stepIndex = + parseStepIndex(data?.step?.step_index) ?? + parseStepIndex(data?.execution_state?.current_step) ?? + parseStepIndexFromPath(csrgProofs?.path) ?? + undefined; + + return { + skipped: false, + allowed: data.allowed !== false, + reason: typeof data.reason === "string" ? data.reason : "", + tokenRaw, + plan: isPlainObject(data.plan) ? data.plan : parsedFromResponse?.plan || fallbackPlan, + expiresAt: Number.isFinite(data.expiresAt) ? data.expiresAt : parsedFromResponse?.expiresAt, + stepIndex + }; + }, + + /** + * Verify action directly with CSRG service using Merkle proof. + * Equivalent to ArmorClaw IAPVerificationService.verifyWithCsrg() + */ + async verifyWithCsrg(path, value, proof, token, context) { + if (!config.csrgVerifyEnabled) { + throw new Error("CSRG verification is disabled"); + } + + const payload = { path, value, proof, token, context }; + const response = await postJson( + `${csrgEndpoint}/verify/action`, + payload, + { "Content-Type": "application/json" }, + Math.min(timeoutMs, 15000) + ); + + if (response.ok && response.data) { + return response.data; + } + + if (response.data) { + return { + allowed: false, + reason: + response.data.reason || + `CSRG verification failed: ${response.text || "unknown error"}` + }; + } + + return { + allowed: false, + reason: response.text + ? `CSRG verification failed: ${response.text}` + : `CSRG verification failed with status ${response.status}` + }; + }, + + /** + * Create an audit log entry in the IAP service. + * Equivalent to ArmorClaw IAPVerificationService.createAuditLog() + */ + async createAuditLog(dto) { + const response = await postJson( + `${backendEndpoint}/iap/audit`, + dto, + headers, + timeoutMs + ); + + if (!response.ok || !response.data) { + const message = response.text + ? `IAP audit creation failed: ${response.text}` + : `IAP audit creation failed with status ${response.status}`; + throw new Error(message); + } + + return response.data; + }, + + /** + * Enqueue an audit DTO to the local WAL. Returns immediately after the + * disk append (~1-2ms). A background flusher in policy-mcp.mjs drains + * the WAL in batches and POSTs to /iap/audit. Fire-and-forget callers + * use this to keep hook latency low. + */ + async enqueueAudit(dto) { + const wal = getAuditWal(config); + await wal.appendLine(dto); + }, + + /** + * Ship a batch of audit rows via POST /iap/audit/batch (one HTTP call + * for N rows, ~N× faster than per-row POSTs). Matches armorClaude's + * createAuditLogBatch — same backend endpoint, same payload shape. + * + * Failures throw — caller should NOT advance the WAL offset on failure + * so the next tick retries the same rows. Backend idempotency + * (planId, to_hash unique) keeps retries safe. + */ + async shipAuditBatch(rows) { + if (!Array.isArray(rows) || rows.length === 0) { + return { written: 0, failures: [] }; + } + const response = await postJson( + `${backendEndpoint}/iap/audit/batch`, + { rows }, + headers, + timeoutMs + ); + if (!response.ok || !response.data) { + const message = response.text + ? `IAP audit batch failed: ${response.text}` + : `IAP audit batch failed with status ${response.status}`; + throw new Error(message); + } + return response.data; + }, + + csrgProofsRequired() { + return Boolean(config.requireCsrgProofs); + }, + + csrgVerifyIsEnabled() { + return Boolean(config.csrgVerifyEnabled); + } + }; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +function getTokenForVerification(intentTokenRaw) { + if (typeof intentTokenRaw !== "string") { + return { token: "", tokenObj: null }; + } + try { + const parsed = JSON.parse(intentTokenRaw); + if (isPlainObject(parsed)) { + const jwtToken = readString(parsed.jwtToken) || readString(parsed.jwt_token); + if (jwtToken) { + return { token: jwtToken, tokenObj: parsed }; + } + return { token: intentTokenRaw, tokenObj: parsed }; + } + return { token: intentTokenRaw, tokenObj: null }; + } catch { + return { token: intentTokenRaw, tokenObj: null }; + } +} + +function extractPlanFromResponse(tokenRaw) { + try { + const parsed = JSON.parse(tokenRaw); + if (!isPlainObject(parsed)) return null; + const plan = + isPlainObject(parsed.plan) + ? parsed.plan + : isPlainObject(parsed.rawToken?.plan) + ? parsed.rawToken.plan + : null; + const expiresAt = + Number.isFinite(parsed.expiresAt) ? parsed.expiresAt : + Number.isFinite(parsed.token?.expires_at) ? parsed.token.expires_at : + undefined; + return plan ? { plan, expiresAt } : null; + } catch { + return null; + } +} + +function parseStepIndexFromPath(path) { + if (!path) return null; + const match = path.match(/\/steps\/\[(\d+)\]/); + if (!match) return null; + const index = Number.parseInt(match[1] || "", 10); + return Number.isFinite(index) ? index : null; +} diff --git a/plugins/armoriq/armorCodex/scripts/lib/intent-schema.mjs b/plugins/armoriq/armorCodex/scripts/lib/intent-schema.mjs new file mode 100644 index 00000000..67880410 --- /dev/null +++ b/plugins/armoriq/armorCodex/scripts/lib/intent-schema.mjs @@ -0,0 +1,70 @@ +/** + * Shared intent plan schema — single source of truth used by: + * - register_intent_plan MCP tool (validates Codex's input) + * - register_intent_plan inputSchema (model sees this when invoking the tool) + * + * Codex has no ExitPlanMode-equivalent event, so unlike ArmorClaude there is + * no plan-file extraction path on Codex. + */ + +import { z } from "zod"; + +export const PLAN_STEP_SCHEMA = z.object({ + action: z.string().min(1).describe("Tool name (e.g. Read, Edit, Bash, mcp__server__tool)"), + description: z.string().optional().describe("Why this step is needed"), + metadata: z + .object({ + inputs: z + .record(z.string(), z.unknown()) + .optional() + .describe("Expected tool parameters for enforcement") + }) + .optional() +}); + +export const INTENT_PLAN_ZOD = z.object({ + goal: z.string().min(1).describe("One-line summary of what the plan accomplishes"), + steps: z + .array(PLAN_STEP_SCHEMA) + .min(1) + .describe("Ordered list of tool calls the agent intends to make") +}); + +/** + * Human-readable format string injected into Codex's context so it knows + * exactly what shape to produce. + */ +export const INTENT_PLAN_FORMAT = `{ + "goal": "", + "steps": [ + { + "action": "", + "description": "", + "metadata": { "inputs": { /* expected tool parameters, optional */ } } + } + ] +}`; + +/** + * Normalize a validated plan into the internal format used by requestIntent() + * and the plan enforcement pipeline. + */ +export function normalizeIntentPlan(parsed) { + return { + steps: parsed.steps.map((s) => ({ + // Both `action` and `tool` are populated to match the backend's + // CSRG/policy enforcer expectations: the SDK's invoke() does the + // same (sets tool: action). The backend hashes `step.tool` for + // policy paths like /steps/[i]/tool. + action: s.action, + tool: s.action, + mcp: "codex", + description: s.description || "", + metadata: s.metadata || {} + })), + metadata: { + goal: parsed.goal, + source: "codex-registered" + } + }; +} diff --git a/plugins/armoriq/armorCodex/scripts/lib/intent.mjs b/plugins/armoriq/armorCodex/scripts/lib/intent.mjs new file mode 100644 index 00000000..432088ab --- /dev/null +++ b/plugins/armoriq/armorCodex/scripts/lib/intent.mjs @@ -0,0 +1,642 @@ +import armoriqSdk from "@armoriq/sdk"; +import { + buildAuthHeaders, + isPlainObject, + isSubsetValue, + normalizeToolName, + parseStepIndex, + postJson, + readString, + sha256Hex +} from "./common.mjs"; + +const { ArmorIQClient } = armoriqSdk; +const sdkClientCache = new Map(); + +function buildSdkClientKey(config) { + return [ + config.apiKey, + config.userId, + config.agentId, + config.contextId, + config.iapEndpoint, + config.proxyEndpoint, + config.backendEndpoint, + config.useProduction ? "prod" : "dev" + ].join("|"); +} + +function getSdkClient(config) { + const key = buildSdkClientKey(config); + const cached = sdkClientCache.get(key); + if (cached) { + return cached; + } + const client = new ArmorIQClient({ + apiKey: config.apiKey, + userId: config.userId, + agentId: config.agentId, + contextId: config.contextId, + useProduction: config.useProduction, + iapEndpoint: config.iapEndpoint, + proxyEndpoint: config.proxyEndpoint, + backendEndpoint: config.backendEndpoint, + timeout: config.timeoutMs, + maxRetries: config.maxRetries, + verifySsl: config.verifySsl + }); + sdkClientCache.set(key, client); + return client; +} + +function buildFallbackPlan(payload) { + const goal = typeof payload.prompt === "string" ? payload.prompt : "ArmorCodex intent"; + const plan = { steps: [], metadata: { goal, source: "codex" } }; + if (typeof payload.toolName === "string" && payload.toolName.trim()) { + plan.steps.push({ + action: payload.toolName.trim(), + mcp: payload.mcpName || "codex", + metadata: isPlainObject(payload.toolInput) ? { inputs: payload.toolInput } : {} + }); + } + return plan; +} + +function resolvePlan(payload) { + if (isPlainObject(payload.plan)) { + return payload.plan; + } + return buildFallbackPlan(payload); +} + +export function extractPlanFromIntentToken(raw) { + if (typeof raw !== "string" || !raw.trim()) { + return null; + } + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if (!isPlainObject(parsed)) { + return null; + } + const rawToken = isPlainObject(parsed.rawToken) ? parsed.rawToken : undefined; + const planCandidate = + (rawToken && isPlainObject(rawToken.plan) ? rawToken.plan : undefined) || + (isPlainObject(parsed.plan) ? parsed.plan : undefined) || + (isPlainObject(parsed.token) && isPlainObject(parsed.token.plan) ? parsed.token.plan : undefined); + if (!planCandidate) { + return null; + } + const expiresAt = + Number.isFinite(parsed.expiresAt) + ? parsed.expiresAt + : isPlainObject(parsed.token) && Number.isFinite(parsed.token.expires_at) + ? parsed.token.expires_at + : undefined; + return { plan: planCandidate, expiresAt }; +} + +export function extractAllowedActions(plan) { + const allowed = new Set(); + const steps = Array.isArray(plan?.steps) ? plan.steps : []; + for (const step of steps) { + if (!isPlainObject(step)) { + continue; + } + const action = + typeof step.action === "string" + ? step.action + : typeof step.tool === "string" + ? step.tool + : ""; + if (action.trim()) { + allowed.add(normalizeToolName(action)); + } + } + return allowed; +} + +function findPlanStep(plan, toolName) { + const steps = Array.isArray(plan?.steps) ? plan.steps : []; + const normalizedTool = normalizeToolName(toolName); + for (const step of steps) { + if (!isPlainObject(step)) { + continue; + } + const action = + typeof step.action === "string" + ? step.action + : typeof step.tool === "string" + ? step.tool + : ""; + if (normalizeToolName(action) === normalizedTool) { + return step; + } + } + return null; +} + +function getStepInputCandidates(step) { + const candidates = []; + if (isPlainObject(step.metadata) && isPlainObject(step.metadata.inputs)) { + candidates.push(step.metadata.inputs); + } + if (isPlainObject(step.params)) { + candidates.push(step.params); + } + if (isPlainObject(step.arguments)) { + candidates.push(step.arguments); + } + return candidates; +} + +export function findPlanStepIndices(plan, toolName, toolParams) { + const steps = Array.isArray(plan?.steps) ? plan.steps : []; + const normalizedTool = normalizeToolName(toolName); + const matches = []; + const paramMatches = []; + for (let idx = 0; idx < steps.length; idx += 1) { + const step = steps[idx]; + if (!isPlainObject(step)) { + continue; + } + const action = + typeof step.action === "string" + ? step.action + : typeof step.tool === "string" + ? step.tool + : ""; + if (normalizeToolName(action) !== normalizedTool) { + continue; + } + matches.push(idx); + if (toolParams) { + const inputCandidates = getStepInputCandidates(step); + if (inputCandidates.some((inputs) => isSubsetValue(inputs, toolParams))) { + paramMatches.push(idx); + } + } + } + return { matches, paramMatches }; +} + +export function checkToolAgainstPlan({ plan, toolName, toolInput }) { + const normalizedTool = normalizeToolName(toolName); + const steps = Array.isArray(plan?.steps) ? plan.steps : []; + if (!steps.length) { + return { allowed: false, reason: "ArmorCodex intent plan is empty" }; + } + const matches = []; + for (const step of steps) { + if (!isPlainObject(step)) { + continue; + } + const action = + typeof step.action === "string" + ? step.action + : typeof step.tool === "string" + ? step.tool + : ""; + if (normalizeToolName(action) === normalizedTool) { + matches.push(step); + } + } + if (!matches.length) { + return { allowed: false, reason: `ArmorCodex intent drift: tool not in plan (${toolName})` }; + } + if (!isPlainObject(toolInput)) { + return { allowed: true }; + } + let sawConstrainedMatch = false; + for (const step of matches) { + const inputCandidates = getStepInputCandidates(step); + if (inputCandidates.length === 0) { + return { allowed: true }; + } + sawConstrainedMatch = true; + for (const candidate of inputCandidates) { + // Strict subset: every key in declared candidate matches actual input. + if (isSubsetValue(candidate, toolInput)) { + return { allowed: true }; + } + // Lenient fallback: agents (especially gpt-5.4) often declare inputs + // with field names that don't match the real tool (e.g. `cmd` instead + // of Codex's `command`). If NONE of the declared keys exist on the + // real input, treat it as an over-eager declaration and allow. + // The tool name itself was already matched; the parameter declaration + // was simply wrong-fielded, not a security violation. + if (isPlainObject(candidate) && isPlainObject(toolInput)) { + const declaredKeys = Object.keys(candidate); + if (declaredKeys.length > 0) { + const overlappingKeys = declaredKeys.filter((k) => k in toolInput); + if (overlappingKeys.length === 0) { + return { allowed: true }; + } + } + } + } + } + if (sawConstrainedMatch) { + return { + allowed: false, + reason: `ArmorCodex intent mismatch: parameters not allowed for ${toolName}` + }; + } + return { allowed: true }; +} + +export function checkIntentTokenPlan({ intentTokenRaw, toolName, toolParams }) { + const parsed = extractPlanFromIntentToken(intentTokenRaw); + if (!parsed) { + return { matched: false }; + } + if (parsed.expiresAt && Date.now() / 1000 > parsed.expiresAt) { + return { + matched: true, + blockReason: + "ArmorIQ intent token expired — call register_intent_plan to refresh, then retry", + plan: parsed.plan + }; + } + const allowedActions = extractAllowedActions(parsed.plan); + if (!allowedActions.has(normalizeToolName(toolName))) { + return { + matched: true, + blockReason: `ArmorIQ intent drift: tool not in plan (${toolName})`, + plan: parsed.plan + }; + } + + // Parameter-level enforcement: check tool params against plan step constraints + if (isPlainObject(toolParams)) { + const paramCheck = checkToolAgainstPlan({ + plan: parsed.plan, + toolName, + toolInput: toolParams + }); + if (!paramCheck.allowed) { + return { + matched: true, + blockReason: paramCheck.reason, + plan: parsed.plan + }; + } + } + + return { + matched: true, + params: isPlainObject(toolParams) ? toolParams : undefined, + plan: parsed.plan + }; +} + +export function parseStepIndexFromPath(path) { + if (!path) { + return null; + } + const match = path.match(/\/steps\/\[(\d+)\]/); + if (!match) { + return null; + } + const index = Number.parseInt(match[1] || "", 10); + return Number.isFinite(index) ? index : null; +} + +function readStepProofsFromToken(tokenObj) { + if (Array.isArray(tokenObj.stepProofs)) { + return tokenObj.stepProofs; + } + if (Array.isArray(tokenObj.step_proofs)) { + return tokenObj.step_proofs; + } + if (isPlainObject(tokenObj.rawToken)) { + if (Array.isArray(tokenObj.rawToken.stepProofs)) { + return tokenObj.rawToken.stepProofs; + } + if (Array.isArray(tokenObj.rawToken.step_proofs)) { + return tokenObj.rawToken.step_proofs; + } + } + return null; +} + +function resolveStepProofEntry(stepProofs, stepIndex) { + const entry = stepProofs[stepIndex]; + if (!entry) { + return null; + } + if (Array.isArray(entry)) { + return { proof: entry, stepIndex }; + } + if (!isPlainObject(entry)) { + return null; + } + const proof = Array.isArray(entry.proof) ? entry.proof : undefined; + const path = + readString(entry.path) || + readString(entry.step_path) || + readString(entry.csrg_path) || + undefined; + const indexFromField = parseStepIndex(entry.step_index) ?? parseStepIndex(entry.stepIndex); + const indexFromPath = parseStepIndexFromPath(path); + const resolvedStepIndex = indexFromField ?? indexFromPath ?? stepIndex; + const valueDigest = + readString(entry.value_digest) || + readString(entry.valueDigest) || + readString(entry.csrg_value_digest) || + undefined; + return { proof, path, valueDigest, stepIndex: resolvedStepIndex }; +} + +function scoreProofPath(path) { + if (!path) { + return 0; + } + if (/\/(action|tool)$/i.test(path)) { + return 3; + } + if (/\/(arguments|params|metadata)$/i.test(path)) { + return 1; + } + return 2; +} + +function chooseProofEntry(entries, usedStepIndices) { + if (!entries.length) { + return null; + } + const stepGroups = new Map(); + for (const entry of entries) { + const list = stepGroups.get(entry.stepIndex) || []; + list.push(entry); + stepGroups.set(entry.stepIndex, list); + } + const orderedStepIndices = Array.from(stepGroups.keys()).sort((a, b) => { + const aUsed = usedStepIndices?.has(a) ? 1 : 0; + const bUsed = usedStepIndices?.has(b) ? 1 : 0; + if (aUsed !== bUsed) { + return aUsed - bUsed; + } + return a - b; + }); + const selectedStepIndex = orderedStepIndices[0]; + if (selectedStepIndex === undefined) { + return null; + } + const candidates = stepGroups.get(selectedStepIndex) || []; + candidates.sort((a, b) => { + const pathScore = scoreProofPath(b.path) - scoreProofPath(a.path); + if (pathScore !== 0) { + return pathScore; + } + const digestScore = Number(Boolean(b.valueDigest)) - Number(Boolean(a.valueDigest)); + if (digestScore !== 0) { + return digestScore; + } + return 0; + }); + return candidates[0] || null; +} + +export function resolveCsrgProofsFromToken({ + intentTokenRaw, + plan, + toolName, + toolParams, + usedStepIndices +}) { + let parsed; + try { + parsed = JSON.parse(intentTokenRaw); + } catch { + return null; + } + if (!isPlainObject(parsed)) { + return null; + } + const stepProofs = readStepProofsFromToken(parsed); + if (!stepProofs || stepProofs.length === 0) { + return null; + } + const normalizedParams = isPlainObject(toolParams) ? toolParams : undefined; + const { matches, paramMatches } = findPlanStepIndices(plan, toolName, normalizedParams); + if (matches.length === 0) { + return null; + } + const resolvedEntries = []; + for (let idx = 0; idx < stepProofs.length; idx += 1) { + const entry = resolveStepProofEntry(stepProofs, idx); + if (!entry?.proof || !Array.isArray(entry.proof)) { + continue; + } + resolvedEntries.push(entry); + } + const entriesMatchingTool = resolvedEntries.filter((entry) => matches.includes(entry.stepIndex)); + if (!entriesMatchingTool.length) { + return null; + } + const entriesMatchingParams = + paramMatches.length > 0 + ? entriesMatchingTool.filter((entry) => paramMatches.includes(entry.stepIndex)) + : []; + const selected = chooseProofEntry( + entriesMatchingParams.length > 0 ? entriesMatchingParams : entriesMatchingTool, + usedStepIndices + ); + if (!selected || !Array.isArray(selected.proof)) { + return null; + } + const steps = Array.isArray(plan?.steps) ? plan.steps : []; + const stepIndex = selected.stepIndex; + const stepObj = steps[stepIndex]; + const action = + isPlainObject(stepObj) && typeof stepObj.action === "string" + ? stepObj.action + : isPlainObject(stepObj) && typeof stepObj.tool === "string" + ? stepObj.tool + : toolName; + return { + path: selected.path || `/steps/[${stepIndex}]/action`, + proof: selected.proof, + valueDigest: selected.valueDigest || sha256Hex(JSON.stringify(action)), + stepIndex + }; +} + +function parseProofValue(raw) { + if (Array.isArray(raw)) { + return raw; + } + if (typeof raw === "string") { + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + return parsed; + } + return { error: "ArmorIQ CSRG proof header must be a JSON array" }; + } catch { + return { error: "ArmorIQ CSRG proof header invalid JSON" }; + } + } + return undefined; +} + +function readFromHeaderMap(headers, keys) { + if (!isPlainObject(headers)) { + return undefined; + } + for (const key of keys) { + const value = readString(headers[key]); + if (value) { + return value; + } + } + return undefined; +} + +export function parseCsrgProofHeaders(input) { + const headers = isPlainObject(input.headers) ? input.headers : undefined; + const path = + readString(input.csrgPath) || + readString(input.csrg_path) || + readString(input["x-csrg-path"]) || + readFromHeaderMap(headers, ["x-csrg-path", "X-CSRG-Path"]) || + undefined; + const valueDigest = + readString(input.csrgValueDigest) || + readString(input.csrg_value_digest) || + readString(input["x-csrg-value-digest"]) || + readFromHeaderMap(headers, ["x-csrg-value-digest", "X-CSRG-Value-Digest"]) || + undefined; + const proofRaw = + input.csrgProofRaw ?? + input.csrg_proof ?? + input["x-csrg-proof"] ?? + (headers ? headers["x-csrg-proof"] ?? headers["X-CSRG-Proof"] : undefined); + + if (!path && !valueDigest && proofRaw === undefined) { + return {}; + } + const parsedProof = parseProofValue(proofRaw); + if (isPlainObject(parsedProof) && parsedProof.error) { + return { error: parsedProof.error }; + } + return { + proofs: { + path, + valueDigest, + proof: parsedProof + } + }; +} + +export function validateCsrgProofHeaders(proofs, required) { + if (!required) { + return null; + } + if (!proofs) { + return "ArmorIQ CSRG proof headers missing"; + } + if (!proofs.path) { + return "ArmorIQ CSRG path header missing"; + } + if (!proofs.valueDigest) { + return "ArmorIQ CSRG value digest header missing"; + } + if (!proofs.proof || !Array.isArray(proofs.proof)) { + return "ArmorIQ CSRG proof header missing"; + } + return null; +} + +export async function requestIntent(config, payload) { + if (config.intentEndpoint) { + const response = await postJson( + config.intentEndpoint, + payload, + buildAuthHeaders(config), + config.timeoutMs + ); + if (!response.ok) { + throw new Error(response.text || `Intent request failed: ${response.status}`); + } + const data = isPlainObject(response.data) ? response.data : {}; + const tokenRaw = + typeof data.intentTokenRaw === "string" + ? data.intentTokenRaw + : typeof data.tokenRaw === "string" + ? data.tokenRaw + : isPlainObject(data.token) + ? JSON.stringify(data.token) + : undefined; + const parsedFromToken = tokenRaw ? extractPlanFromIntentToken(tokenRaw) : null; + const plan = isPlainObject(data.plan) ? data.plan : parsedFromToken?.plan; + const expiresAt = + Number.isFinite(data.expiresAt) + ? data.expiresAt + : Number.isFinite(data.expires_at) + ? data.expires_at + : parsedFromToken?.expiresAt; + return { + skipped: false, + source: "custom-endpoint", + tokenRaw, + plan, + expiresAt + }; + } + + if (!config.useSdkIntent || !config.apiKey) { + return { skipped: true }; + } + const client = getSdkClient(config); + const plan = resolvePlan({ ...payload, mcpName: config.mcpName }); + const metadata = { + source: "codex", + session_id: payload.session_id, + policy_hash: payload.policy_hash, + ...payload.metadata + }; + const capture = client.capturePlan(config.llmId, payload.prompt || "", plan, metadata); + const token = await client.getIntentToken(capture, payload.policy, payload.validitySeconds); + const tokenRaw = JSON.stringify(token); + const parsedFromToken = extractPlanFromIntentToken(tokenRaw); + return { + skipped: false, + source: "armoriq-sdk", + tokenRaw, + plan: parsedFromToken?.plan || plan, + expiresAt: Number.isFinite(token.expiresAt) ? token.expiresAt : parsedFromToken?.expiresAt + }; +} + +export function getSessionTokenUsedStepIndices(session, intentTokenRaw) { + if (!session || typeof intentTokenRaw !== "string" || !intentTokenRaw.trim()) { + return undefined; + } + const tokenHash = sha256Hex(intentTokenRaw); + const tracker = isPlainObject(session.intentExecution) ? session.intentExecution : {}; + if (tracker.tokenHash !== tokenHash) { + tracker.tokenHash = tokenHash; + tracker.usedStepIndices = []; + session.intentExecution = tracker; + } + const used = Array.isArray(tracker.usedStepIndices) ? tracker.usedStepIndices : []; + tracker.usedStepIndices = used.filter((value) => Number.isFinite(value)); + session.intentExecution = tracker; + return new Set(tracker.usedStepIndices); +} + +export function recordSessionTokenUsedStepIndices(session, intentTokenRaw, usedStepIndices) { + if (!session || typeof intentTokenRaw !== "string" || !intentTokenRaw.trim()) { + return; + } + const tokenHash = sha256Hex(intentTokenRaw); + session.intentExecution = { + tokenHash, + usedStepIndices: Array.from(usedStepIndices || []).filter((value) => Number.isFinite(value)) + }; +} diff --git a/plugins/armoriq/armorCodex/scripts/lib/planner.mjs b/plugins/armoriq/armorCodex/scripts/lib/planner.mjs new file mode 100644 index 00000000..472b8ce2 --- /dev/null +++ b/plugins/armoriq/armorCodex/scripts/lib/planner.mjs @@ -0,0 +1,171 @@ +/** + * Plan parsing for ArmorCodex. + * + * Two capture paths, one schema: + * 1. Plan mode: parse the plan file for a fenced ```json block (preferred) + * or heuristic markdown extraction (fallback) + * 2. No plan mode: Codex calls register_intent_plan MCP tool directly + * (handled in policy-mcp.mjs, not here) + * + * This module handles only PARSING — plan generation is done by Codex's own + * LLM via the directive injected in UserPromptSubmit. + */ + +import { readFile } from "node:fs/promises"; +import { normalizeToolName } from "./common.mjs"; + +// --------------------------------------------------------------------------- +// JSON block extraction (preferred — matches the directive's format) +// --------------------------------------------------------------------------- + +/** + * Extract a fenced ```json block from markdown content. + * The UserPromptSubmit directive tells Codex to include the plan as a + * fenced JSON block in plan mode. + * + * Strategy: scan all ```json blocks and return the LAST one that parses + * cleanly AND looks like an intent plan (has a `steps` array). This avoids + * picking up an example/illustration block earlier in the file. + */ +export function extractPlanJsonBlock(markdown) { + if (!markdown) return null; + const matches = Array.from(markdown.matchAll(/```json\s*([\s\S]*?)```/g)); + if (matches.length === 0) return null; + for (let i = matches.length - 1; i >= 0; i -= 1) { + const raw = matches[i][1]?.trim(); + if (!raw) continue; + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + continue; + } + if (parsed && typeof parsed === "object" && Array.isArray(parsed.steps)) { + return parsed; + } + } + return null; +} + +// --------------------------------------------------------------------------- +// Plan file parsing (heuristic fallback) +// --------------------------------------------------------------------------- + +/** + * Parse a plan markdown file into a structured plan. + * This is retained for compatibility with imported tests and future Codex + * plan-file events; current Codex hooks do not expose ExitPlanMode. + */ +export async function parsePlanFile(planFilePath) { + if (!planFilePath) return null; + let content; + try { + content = await readFile(planFilePath, "utf8"); + } catch { + return null; + } + if (!content.trim()) return null; + return parsePlanMarkdown(content); +} + +/** + * Heuristic: extract tool intentions from markdown content. + * Looks for backtick-wrapped tool names and numbered/bulleted steps. + */ +export function parsePlanMarkdown(markdown) { + const steps = []; + const seenTools = new Set(); + + // Backtick-wrapped identifiers: `Read`, `mcp__server__tool` + const backtickPattern = /`([A-Za-z][A-Za-z0-9_]*(?:__[A-Za-z0-9_]+)*)`/g; + for (const match of markdown.matchAll(backtickPattern)) { + const name = match[1]?.trim(); + if (name && name.length > 1 && name.length < 80) { + seenTools.add(normalizeToolName(name)); + } + } + + // Numbered / bulleted steps + const stepPattern = /^[\s]*(?:\d+[.)]\s+|[-*]\s+)(.+)/gm; + for (const match of markdown.matchAll(stepPattern)) { + const text = match[1]?.trim(); + if (!text || text.length < 3) continue; + const toolRef = extractToolFromStepText(text); + if (toolRef) { + seenTools.add(normalizeToolName(toolRef)); + steps.push({ + action: toolRef, + mcp: "codex", + description: text, + metadata: {} + }); + } + } + + // If no steps from list parsing, create steps from discovered tool names + if (steps.length === 0) { + for (const toolName of seenTools) { + steps.push({ + action: toolName, + mcp: "codex", + description: `Use ${toolName}`, + metadata: {} + }); + } + } + + const headingMatch = markdown.match(/^#+\s+(.+)/m); + const goal = headingMatch ? headingMatch[1].trim() : markdown.split("\n")[0]?.trim() || "Plan"; + + return { + steps, + metadata: { goal, source: "plan-file-heuristic" } + }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const KNOWN_TOOLS = new Set([ + "read", "write", "edit", "bash", "glob", "grep", "agent", + "webfetch", "websearch", "notebookedit", "askuserquestion", + "taskcreate", "taskupdate", "skill" +]); + +function extractToolFromStepText(text) { + const backtickMatch = text.match(/`([A-Za-z][A-Za-z0-9_]*(?:__[A-Za-z0-9_]+)*)`/); + if (backtickMatch) return backtickMatch[1]; + + const mcpMatch = text.match(/\b(mcp__[a-z0-9_]+__[a-z0-9_]+)\b/i); + if (mcpMatch) return mcpMatch[1]; + + const words = text.split(/\s+/); + const firstWord = words[0]?.toLowerCase().replace(/[^a-z]/g, ""); + if (KNOWN_TOOLS.has(firstWord)) { + return firstWord.charAt(0).toUpperCase() + firstWord.slice(1); + } + + return null; +} + +/** + * Resolve the plan file path for the current session. + * Resolve a best-effort Codex-scoped plan path. + */ +export function resolvePlanFilePath(input) { + const transcriptPath = + typeof input?.transcript_path === "string" ? input.transcript_path : ""; + + const sessionMatch = transcriptPath.match( + /sessions\/([^/]+?)(?:\.jsonl)?$/ + ); + const sessionName = sessionMatch ? sessionMatch[1] : null; + + if (sessionName) { + const homeDir = process.env.HOME || process.env.USERPROFILE || "/tmp"; + return `${homeDir}/.codex/plans/${sessionName}.md`; + } + + return null; +} diff --git a/plugins/armoriq/armorCodex/scripts/lib/policy.mjs b/plugins/armoriq/armorCodex/scripts/lib/policy.mjs new file mode 100644 index 00000000..db6d2c93 --- /dev/null +++ b/plugins/armoriq/armorCodex/scripts/lib/policy.mjs @@ -0,0 +1,615 @@ +import { createHash } from "node:crypto"; +import { + isMatcherSpec, + isPlainObject, + isSubsetValue, + matchParams, + matchesAnyStringField, + matchesScalar, + normalizeToolName +} from "./common.mjs"; +import { readJson, writeJson } from "./fs-store.mjs"; + +const POLICY_ACTIONS = new Set(["allow", "deny", "require_approval"]); +const POLICY_DATA_CLASSES = new Set(["PCI", "PAYMENT", "PHI", "PII"]); + +function normalizeRule(rule) { + if (!isPlainObject(rule)) { + return null; + } + const id = typeof rule.id === "string" ? rule.id.trim() : ""; + const action = typeof rule.action === "string" ? rule.action.trim() : ""; + const tool = typeof rule.tool === "string" ? rule.tool.trim() : ""; + if (!id || !tool || !POLICY_ACTIONS.has(action)) { + return null; + } + const normalized = { + id, + action, + tool + }; + if (typeof rule.dataClass === "string" && POLICY_DATA_CLASSES.has(rule.dataClass.trim())) { + normalized.dataClass = rule.dataClass.trim(); + } + if (isPlainObject(rule.params)) { + normalized.params = rule.params; + } + // anyParam: matcher applied across any string field in the tool input. + // Useful for free-text intents like "deny ~/.ssh" where we don't know + // which key the tool will store the path under. + if (isMatcherSpec(rule.anyParam) || typeof rule.anyParam === "string") { + normalized.anyParam = + typeof rule.anyParam === "string" + ? { $contains: rule.anyParam } + : rule.anyParam; + } + return normalized; +} + +function normalizePolicy(policyLike) { + const input = isPlainObject(policyLike) ? policyLike : {}; + const rulesInput = Array.isArray(input.rules) ? input.rules : []; + const rules = rulesInput.map((rule) => normalizeRule(rule)).filter(Boolean); + return { rules }; +} + +export async function loadPolicyState(policyFilePath) { + const initial = { + version: 0, + updatedAt: new Date().toISOString(), + policy: { rules: [] }, + history: [] + }; + const raw = await readJson(policyFilePath, initial); + const state = isPlainObject(raw) ? raw : initial; + return { + version: Number.isFinite(state.version) ? state.version : 0, + updatedAt: typeof state.updatedAt === "string" ? state.updatedAt : new Date().toISOString(), + updatedBy: typeof state.updatedBy === "string" ? state.updatedBy : undefined, + policy: normalizePolicy(state.policy || state), + history: Array.isArray(state.history) ? state.history : [] + }; +} + +export async function savePolicyState(policyFilePath, state) { + await writeJson(policyFilePath, state); +} + +export function computePolicyHash(policy) { + return createHash("sha256").update(JSON.stringify(normalizePolicy(policy))).digest("hex"); +} + +function toolMatches(ruleTool, toolName) { + if (ruleTool === "*") { + return true; + } + return normalizeToolName(ruleTool) === normalizeToolName(toolName); +} + +function extractStrings(value, depth, texts, keys) { + if (depth > 4) { + return; + } + if (typeof value === "string") { + texts.push(value); + return; + } + if (Array.isArray(value)) { + value.forEach((entry) => extractStrings(entry, depth + 1, texts, keys)); + return; + } + if (isPlainObject(value)) { + for (const [key, entry] of Object.entries(value)) { + keys.push(key); + extractStrings(entry, depth + 1, texts, keys); + } + } +} + +function luhnCheck(value) { + let sum = 0; + let doubleDigit = false; + for (let i = value.length - 1; i >= 0; i -= 1) { + let digit = Number.parseInt(value[i] || "", 10); + if (!Number.isFinite(digit)) { + return false; + } + if (doubleDigit) { + digit *= 2; + if (digit > 9) { + digit -= 9; + } + } + sum += digit; + doubleDigit = !doubleDigit; + } + return sum % 10 === 0; +} + +function hasCardNumber(texts) { + const regex = /\b(?:\d[ -]*?){13,19}\b/g; + for (const text of texts) { + const matches = text.match(regex); + if (!matches) { + continue; + } + for (const match of matches) { + const digits = match.replace(/[^\d]/g, ""); + if (digits.length >= 13 && digits.length <= 19 && luhnCheck(digits)) { + return true; + } + } + } + return false; +} + +function hasPaymentKeywords(texts, keys) { + const keywords = ["card", "credit", "payment", "cvv", "iban", "swift", "bank", "routing"]; + const haystack = [...texts, ...keys].join(" ").toLowerCase(); + return keywords.some((keyword) => haystack.includes(keyword)); +} + +function isPaymentTool(toolName) { + return /pay|payment|transfer|charge|crypto|bank|card|stripe|billing/i.test(toolName); +} + +export function detectDataClasses(toolName, toolParams) { + const texts = []; + const keys = []; + extractStrings(toolParams || {}, 0, texts, keys); + const classes = new Set(); + if (hasCardNumber(texts) || hasPaymentKeywords(texts, keys)) { + classes.add("PCI"); + } + if (isPaymentTool(toolName) || hasPaymentKeywords(texts, keys)) { + classes.add("PAYMENT"); + } + return classes; +} + +export function evaluatePolicy({ policy, toolName, toolParams }) { + const rules = normalizePolicy(policy).rules; + const dataClasses = detectDataClasses(toolName, toolParams); + const warnings = []; + + for (const rule of rules) { + if (!toolMatches(rule.tool, toolName)) { + continue; + } + if (rule.dataClass && !dataClasses.has(rule.dataClass)) { + continue; + } + let paramsMatched = true; + if (rule.params) { + const result = matchParams(rule.params, toolParams || {}); + paramsMatched = result.matched; + // Surface "rule probably won't fire": rule references keys absent from + // this tool's input, which usually means the user's intent isn't + // expressible as-is. + if (!result.matched && result.missingKeys.length > 0) { + warnings.push({ + ruleId: rule.id, + tool: rule.tool, + missingKeys: result.missingKeys, + message: `Rule ${rule.id} references keys absent from ${toolName} input: ${result.missingKeys.join(", ")}. Consider using anyParam or operator-based matchers.` + }); + } + } + if (!paramsMatched) { + continue; + } + // anyParam matches if ANY string field in the tool input satisfies the + // matcher. Useful when the user doesn't know which key holds the path. + if (rule.anyParam) { + if (!matchesAnyStringField(rule.anyParam, toolParams || {})) { + continue; + } + } + if (rule.action === "allow") { + return { allowed: true, matchedRule: rule, dataClasses: Array.from(dataClasses), warnings }; + } + if (rule.action === "deny") { + return { + allowed: false, + reason: `ArmorCodex policy deny: ${rule.id}`, + matchedRule: rule, + dataClasses: Array.from(dataClasses), + warnings + }; + } + if (rule.action === "require_approval") { + return { + allowed: false, + reason: `ArmorCodex policy requires approval: ${rule.id}`, + matchedRule: rule, + dataClasses: Array.from(dataClasses), + warnings + }; + } + } + + return { allowed: true, dataClasses: Array.from(dataClasses), warnings }; +} + +function truncateReason(text, max = 160) { + const trimmed = text.trim(); + if (trimmed.length <= max) { + return trimmed; + } + return `${trimmed.slice(0, max)}...`; +} + +function formatRule(rule) { + const parts = [`id=${rule.id}`, `action=${rule.action}`, `tool=${rule.tool}`]; + if (rule.dataClass) { + parts.push(`dataClass=${rule.dataClass}`); + } + if (rule.anyParam) { + const op = Object.keys(rule.anyParam)[0]; + const val = rule.anyParam[op]; + parts.push(`match=${op}:${val}`); + } + if (rule.params) { + parts.push(`params=${JSON.stringify(rule.params)}`); + } + return parts.join(" "); +} + +function nextPolicyId(state) { + const ids = state.policy.rules + .map((rule) => rule.id) + .map((id) => { + const match = id.match(/^policy(\d+)$/i); + return match ? Number.parseInt(match[1] || "", 10) : null; + }) + .filter((value) => Number.isFinite(value)); + const max = ids.length ? Math.max(...ids) : 0; + return `policy${max + 1}`; +} + +function inferPolicyAction(text) { + const lower = text.toLowerCase(); + if (/(require\s+approval|needs\s+approval|approval\s+required)/i.test(lower)) { + return "require_approval"; + } + if (/(allow|permit|enable|whitelist)/i.test(lower)) { + return "allow"; + } + if (/(deny|block|disallow|prevent|prohibit|stop)/i.test(lower)) { + return "deny"; + } + return "deny"; +} + +function inferPolicyDataClass(text) { + const lower = text.toLowerCase(); + if (/(credit\s*card|card\s*number|pci)/i.test(lower)) { + return "PCI"; + } + if (/(payment|billing|bank|iban|swift|routing)/i.test(lower)) { + return "PAYMENT"; + } + if (/(phi|health|patient|medical)/i.test(lower)) { + return "PHI"; + } + if (/(pii|ssn|personal\s+data|identity)/i.test(lower)) { + return "PII"; + } + return undefined; +} + +// A tool name must look like a real identifier — letters, digits, underscore, +// hyphen, dot, colon — OR exactly "*". Anything else is rejected so free-text +// like "all tools" or regex fragments can't become rule matchers. +const VALID_TOOL_NAME = /^(?:\*|[A-Za-z][\w.:\-]{0,80})$/; + +function sanitizeToolName(candidate) { + if (typeof candidate !== "string") return null; + const trimmed = candidate.trim(); + if (!trimmed) return null; + return VALID_TOOL_NAME.test(trimmed) ? trimmed : null; +} + +// Detect a path or substring the user wants to block. Looks for things like +// ~/.ssh, /etc/passwd, or quoted/backticked snippets after "block"/"deny". +function inferAnyParamMatcher(text) { + // Quoted snippets first: most explicit. + const quoted = + text.match(/"([^"\n]{2,80})"/) || + text.match(/'([^'\n]{2,80})'/); + if (quoted && quoted[1]) { + return inferMatcherForPhrase(quoted[1]); + } + // Path-like tokens: ~/..., /xxx/yyy, $HOME/... + const pathMatch = text.match(/((?:~|\$\{?HOME\}?|\/)[\w./@\-+~]{2,120})/); + if (pathMatch && pathMatch[1]) { + const candidate = pathMatch[1].replace(/[.,;:)\]}]+$/, ""); + if (candidate.length >= 2) { + return { $pathContains: candidate }; + } + } + return null; +} + +function inferMatcherForPhrase(phrase) { + const trimmed = phrase.trim(); + if (!trimmed) return null; + if (/^(?:~|\$\{?HOME\}?|\/)/.test(trimmed)) { + return { $pathContains: trimmed }; + } + // Looks like a regex: leave operator-based match. + if (/[\\^$+?(){}[\]|]/.test(trimmed)) { + return { $matches: trimmed }; + } + return { $contains: trimmed }; +} + +// Real Codex tools we recognize. Used to disambiguate "block X for Y" where X +// may or may not be a tool name. Falls back to "*" when X isn't here. +const KNOWN_CODEX_TOOLS = new Set([ + "*", + "bash", "apply_patch", "list_dir", "view_image", "mcp_resource", + "update_plan", "create_goal", "update_goal", "get_goal", + "spawn_agents_on_csv", "tool_search", "tool_suggest", + "register_intent_plan", "policy_read", "policy_update" +]); + +function inferPolicyTool(text) { + const lower = text.toLowerCase(); + if (/(all\s+tools|any\s+tool|\*\b)/i.test(lower)) { + return "*"; + } + const backtickMatch = text.match(/`([A-Za-z][\w.:\-]{0,80})`/); + const backtickName = sanitizeToolName(backtickMatch?.[1]); + if (backtickName) { + return backtickName; + } + const toolMatch = text.match(/\btool\s*[:=]?\s*([A-Za-z][\w.:\-]{0,80})/i); + const toolName = sanitizeToolName(toolMatch?.[1]); + if (toolName) { + return toolName; + } + const actionMatch = text.match(/\b(?:block|deny|allow|disallow|permit|require)\s+([A-Za-z][\w.:\-]{0,80})/i); + const actionName = sanitizeToolName(actionMatch?.[1]); + if (actionName) { + return actionName; + } + return "*"; +} + +function buildPolicyUpdateFromText(text, state, forceNewId = false) { + const explicitIdMatch = text.match(/\bpolicy[-_]?(\d+)\b/i); + const explicitId = explicitIdMatch && explicitIdMatch[1] ? `policy${explicitIdMatch[1]}` : ""; + const id = forceNewId ? nextPolicyId(state) : explicitId || nextPolicyId(state); + const inferredTool = inferPolicyTool(text); + const anyParam = inferAnyParamMatcher(text); + + // If we found a path/phrase to match AND the inferred tool is a verb like + // "access" or any unknown name, the user means "block this content across + // all tools": promote tool to "*". A real tool name (Bash, apply_patch...) + // stays as-is so users can scope rules to a specific tool when they want. + let tool = inferredTool; + if (anyParam && tool !== "*") { + const normalized = tool.toLowerCase(); + if (!KNOWN_CODEX_TOOLS.has(normalized)) { + tool = "*"; + } + } + + const rule = { + id, + action: inferPolicyAction(text), + tool, + dataClass: inferPolicyDataClass(text) + }; + if (anyParam) { + rule.anyParam = anyParam; + } + return { + reason: truncateReason(`User policy update: ${text}`), + mode: /replace/i.test(text) ? "replace" : "merge", + rules: [rule] + }; +} + +export function parsePolicyTextCommand(text, state) { + const trimmed = text.trim(); + const lower = trimmed.toLowerCase(); + + if (!/^policy\b/i.test(trimmed)) { + return { kind: "none" }; + } + + // Only the bare "Policy help" / "Policy commands" form triggers help. + // Otherwise "Bash commands containing curl" inside a rule body would + // wrongly route here. + if (/^\s*policy\s+(help|commands)\s*$/i.test(trimmed)) { + return { kind: "help" }; + } + if (/^\s*policy\s+(list|show|view)\s*$/i.test(trimmed)) { + return { kind: "list" }; + } + if (/\breset|clear\s+all|wipe\b/i.test(lower)) { + return { kind: "reset", reason: truncateReason(`Policy reset: ${trimmed}`) }; + } + const reorderMatch = trimmed.match( + /\bpolicy\s*(?:priorit(?:y|ize|ise)|reorder|move)\s+(policy\d+|[a-z0-9][\w.-]*)\s+(?:to\s+)?(\d+)\b/i + ); + if (reorderMatch && reorderMatch[1] && reorderMatch[2]) { + return { + kind: "reorder", + id: reorderMatch[1], + position: Number.parseInt(reorderMatch[2], 10), + reason: truncateReason(`Policy reorder: ${trimmed}`) + }; + } + const deleteMatch = trimmed.match(/\bpolicy\s+delete\s+([a-z0-9][\w.-]*)\b/i); + if (deleteMatch && deleteMatch[1]) { + return { + kind: "delete", + id: deleteMatch[1], + reason: truncateReason(`Policy delete: ${trimmed}`) + }; + } + const getMatch = trimmed.match(/\bpolicy\s+get\s+([a-z0-9][\w.-]*)\b/i); + if (getMatch && getMatch[1]) { + return { kind: "get", id: getMatch[1] }; + } + const newMatch = trimmed.match(/\bpolicy\s+new\s*:\s*(.+)$/i); + if (newMatch && newMatch[1]) { + return { kind: "update", update: buildPolicyUpdateFromText(newMatch[1], state, true) }; + } + const updateMatch = trimmed.match(/\bpolicy\s+update(?:\s+([a-z0-9][\w.-]*))?\s*:\s*(.+)$/i); + if (updateMatch && updateMatch[2]) { + const [_, maybeId, body] = updateMatch; + const full = maybeId ? `${maybeId} ${body}` : body; + return { kind: "update", update: buildPolicyUpdateFromText(full, state, false), hasId: Boolean(maybeId) }; + } + + return { kind: "help" }; +} + +function mergeRules(existing, updates) { + const byId = new Map(); + for (const rule of existing) { + byId.set(rule.id, rule); + } + const newRules = []; + for (const rule of updates) { + if (byId.has(rule.id)) { + byId.set(rule.id, rule); + } else { + newRules.push(rule); + } + } + return [...newRules, ...Array.from(byId.values())]; +} + +async function persistNextState(policyFilePath, oldState, nextPolicy, actor, reason) { + const version = oldState.version + 1; + const updatedAt = new Date().toISOString(); + const entry = { + version, + updatedAt, + updatedBy: actor, + reason, + policy: nextPolicy + }; + const nextState = { + version, + updatedAt, + updatedBy: actor, + policy: nextPolicy, + history: [...oldState.history, entry] + }; + await savePolicyState(policyFilePath, nextState); + return nextState; +} + +function formatPolicyHelp() { + return [ + "Policy commands:", + "1. Policy list", + "2. Policy get policy1", + "3. Policy delete policy1", + "4. Policy reset", + "5. Policy update policy1: block send_email for payment data", + "6. Policy new: block web_fetch for PII", + "7. Policy prioritize policy2 1" + ].join("\n"); +} + +export async function applyPolicyCommand({ policyFilePath, state, command, actor }) { + if (command.kind === "none") { + return { state, message: "" }; + } + if (command.kind === "help") { + return { state, message: formatPolicyHelp() }; + } + if (command.kind === "list") { + if (!state.policy.rules.length) { + return { state, message: `Policy version ${state.version}. No explicit rules.` }; + } + const lines = state.policy.rules.map((rule, idx) => `${idx + 1}. ${formatRule(rule)}`); + return { state, message: `Policy version ${state.version}:\n${lines.join("\n")}` }; + } + if (command.kind === "get") { + const rule = state.policy.rules.find((entry) => entry.id === command.id); + return { + state, + message: rule ? `Policy rule:\n- ${formatRule(rule)}` : `Policy rule not found: ${command.id}` + }; + } + if (command.kind === "reset") { + const nextState = await persistNextState( + policyFilePath, + state, + { rules: [] }, + actor, + command.reason || "Policy reset" + ); + return { state: nextState, message: `Policy reset. Version ${nextState.version}.` }; + } + if (command.kind === "delete") { + const rules = state.policy.rules.filter((rule) => rule.id !== command.id); + const nextState = await persistNextState( + policyFilePath, + state, + { rules }, + actor, + command.reason || `Policy delete: ${command.id}` + ); + return { + state: nextState, + message: + rules.length === state.policy.rules.length + ? `No matching rule removed (${command.id}).` + : `Policy rule removed: ${command.id}. Version ${nextState.version}.` + }; + } + if (command.kind === "reorder") { + const rules = [...state.policy.rules]; + const index = rules.findIndex((rule) => rule.id === command.id); + if (index === -1) { + return { state, message: `Policy rule not found: ${command.id}` }; + } + const clamped = Math.min(Math.max(command.position, 1), rules.length); + const [rule] = rules.splice(index, 1); + rules.splice(clamped - 1, 0, rule); + const nextState = await persistNextState( + policyFilePath, + state, + { rules }, + actor, + command.reason || `Policy reorder: ${command.id}` + ); + return { state: nextState, message: `Policy ${command.id} moved to position ${clamped}.` }; + } + if (command.kind === "update") { + if (!isPlainObject(command.update)) { + return { state, message: "Policy update rejected: invalid payload." }; + } + const mode = command.update.mode === "replace" ? "replace" : "merge"; + const updates = Array.isArray(command.update.rules) + ? command.update.rules.map((rule) => normalizeRule(rule)).filter(Boolean) + : []; + // Allow empty rules in `replace` mode: this is how callers clear all + // policy rules atomically. Reject only when merge-mode update has nothing + // to add, since that would be a no-op. + if (!updates.length && mode !== "replace") { + return { state, message: "Policy update rejected: no valid rules." }; + } + const nextRules = mode === "replace" ? updates : mergeRules(state.policy.rules, updates); + const action = mode === "replace" && updates.length === 0 ? "cleared" : "updated"; + const nextState = await persistNextState( + policyFilePath, + state, + { rules: nextRules }, + actor, + command.update.reason || "Policy update" + ); + return { state: nextState, message: `Policy ${action}. Version ${nextState.version}.` }; + } + return { state, message: "No policy changes applied." }; +} + diff --git a/plugins/armoriq/armorCodex/scripts/lib/runtime-state.mjs b/plugins/armoriq/armorCodex/scripts/lib/runtime-state.mjs new file mode 100644 index 00000000..96678393 --- /dev/null +++ b/plugins/armoriq/armorCodex/scripts/lib/runtime-state.mjs @@ -0,0 +1,80 @@ +import { nowEpochSeconds } from "./common.mjs"; +import { readJson, writeJson } from "./fs-store.mjs"; + +const MAX_SESSION_AGE_SECONDS = 60 * 60 * 24; + +export async function loadRuntimeState(runtimeFilePath) { + const initial = { sessions: {}, discoveredTools: [] }; + const raw = await readJson(runtimeFilePath, initial); + const sessions = raw && typeof raw === "object" && raw.sessions && typeof raw.sessions === "object" + ? raw.sessions + : {}; + const discoveredTools = Array.isArray(raw?.discoveredTools) + ? raw.discoveredTools + : []; + return { sessions, discoveredTools }; +} + +export function getSession(runtimeState, sessionId) { + if (!sessionId) { + return undefined; + } + return runtimeState.sessions[sessionId]; +} + +export function upsertSession(runtimeState, sessionId, patch) { + const prev = getSession(runtimeState, sessionId) || {}; + runtimeState.sessions[sessionId] = { + ...prev, + ...patch, + updatedAt: nowEpochSeconds() + }; + return runtimeState.sessions[sessionId]; +} + +const POST_EXPIRY_GRACE_SECONDS = 60 * 60; + +export function pruneSessions(runtimeState) { + const now = nowEpochSeconds(); + for (const [sessionId, session] of Object.entries(runtimeState.sessions)) { + const updatedAt = Number.isFinite(session.updatedAt) ? session.updatedAt : 0; + if (now - updatedAt > MAX_SESSION_AGE_SECONDS) { + delete runtimeState.sessions[sessionId]; + continue; + } + const expiresAt = Number.isFinite(session.expiresAt) ? session.expiresAt : 0; + if (expiresAt > 0 && now - expiresAt > POST_EXPIRY_GRACE_SECONDS) { + delete runtimeState.sessions[sessionId]; + } + } +} + +export async function saveRuntimeState(runtimeFilePath, runtimeState) { + pruneSessions(runtimeState); + await writeJson(runtimeFilePath, runtimeState); +} + +// --------------------------------------------------------------------------- +// Tool discovery — accumulate known tools across PreToolUse calls +// --------------------------------------------------------------------------- + +export function upsertDiscoveredTool(runtimeState, toolName) { + if (!toolName || typeof toolName !== "string") return; + const name = toolName.trim(); + if (!name) return; + if (!Array.isArray(runtimeState.discoveredTools)) { + runtimeState.discoveredTools = []; + } + const normalized = name.toLowerCase(); + const existing = runtimeState.discoveredTools.map((t) => t.toLowerCase()); + if (!existing.includes(normalized)) { + runtimeState.discoveredTools.push(name); + } +} + +export function getDiscoveredTools(runtimeState) { + return Array.isArray(runtimeState?.discoveredTools) + ? runtimeState.discoveredTools + : []; +} + diff --git a/plugins/armoriq/armorCodex/scripts/policy-mcp.mjs b/plugins/armoriq/armorCodex/scripts/policy-mcp.mjs new file mode 100644 index 00000000..7e1ba269 --- /dev/null +++ b/plugins/armoriq/armorCodex/scripts/policy-mcp.mjs @@ -0,0 +1,318 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import path from "node:path"; +import { z } from "zod"; +import { loadConfig } from "./lib/config.mjs"; +import { writeJson } from "./lib/fs-store.mjs"; +import { extractAllowedActions, requestIntent } from "./lib/intent.mjs"; +import { INTENT_PLAN_ZOD, PLAN_STEP_SCHEMA, normalizeIntentPlan } from "./lib/intent-schema.mjs"; +import { applyPolicyCommand, computePolicyHash, loadPolicyState, parsePolicyTextCommand } from "./lib/policy.mjs"; +import { createAuditWal } from "./lib/audit-wal.mjs"; +import { createIapService } from "./lib/iap-service.mjs"; + +const MATCHER_OPERATORS = z + .object({ + $equals: z.string().optional(), + $contains: z.string().optional(), + $startsWith: z.string().optional(), + $endsWith: z.string().optional(), + $matches: z.string().optional(), + $pathContains: z.string().optional() + }) + .strict(); + +const POLICY_RULE_SCHEMA = z.object({ + id: z.string().min(1), + action: z.enum(["allow", "deny", "require_approval"]), + tool: z.string().min(1), + dataClass: z.enum(["PCI", "PAYMENT", "PHI", "PII"]).optional(), + params: z.record(z.string(), z.unknown()).optional(), + // anyParam: matches a substring or operator spec across any string field + // in the tool input. Plain string is sugar for { $contains: }. + anyParam: z.union([z.string().min(1), MATCHER_OPERATORS]).optional() +}); + +const POLICY_UPDATE_SCHEMA = z.object({ + reason: z.string().min(1), + mode: z.enum(["replace", "merge"]).optional(), + rules: z.array(POLICY_RULE_SCHEMA) +}); + +function toTextResult(text, extra = {}) { + return { + content: [{ type: "text", text }], + structuredContent: { + message: text, + ...extra + } + }; +} + +/** + * Some MCP clients (and Codex itself) sometimes pass complex tool arguments + * as JSON-encoded strings instead of structured objects. Accept either form. + * + * { goal: "...", steps: "[{...}]" } → parse steps as JSON + * { plan: "{\"goal\":...}" } → parse plan envelope as JSON + * { goal: "...", steps: [{...}] } → pass through + */ +function coercePlanArgs(args) { + if (!args || typeof args !== "object") { + return args; + } + // If caller wrapped the entire plan in a `plan` field (string or object), + // unwrap it. + if (args.plan !== undefined) { + let unwrapped = args.plan; + if (typeof unwrapped === "string") { + try { unwrapped = JSON.parse(unwrapped); } catch { /* fall through */ } + } + if (unwrapped && typeof unwrapped === "object") { + args = { ...unwrapped, ...args }; + delete args.plan; + } + } + // Coerce stringified arrays/objects on known fields. + if (typeof args.steps === "string") { + try { args = { ...args, steps: JSON.parse(args.steps) }; } catch { /* leave as-is */ } + } + return args; +} + +async function loadStateAndConfig() { + const config = loadConfig(); + const state = await loadPolicyState(config.policyFile); + return { config, state }; +} + +async function run() { + const server = new McpServer({ + name: "armorcodex-policy", + version: "0.1.0" + }); + + server.registerTool( + "policy_update", + { + title: "Policy Update", + description: "Manage ArmorCodex policy rules (update/list/delete/reset)", + inputSchema: { + text: z.string().optional(), + update: POLICY_UPDATE_SCHEMA.optional() + } + }, + async (args) => { + const { config, state } = await loadStateAndConfig(); + if (!config.policyUpdateEnabled) { + return toTextResult("ArmorCodex policy updates are disabled."); + } + + if (typeof args.text === "string" && args.text.trim()) { + const command = parsePolicyTextCommand(args.text, state); + const result = await applyPolicyCommand({ + policyFilePath: config.policyFile, + state, + command, + actor: "mcp" + }); + return toTextResult(result.message, { version: result.state.version }); + } + + if (args.update) { + // Tolerate JSON-string update payloads (some clients stringify objects). + let updateInput = args.update; + if (typeof updateInput === "string") { + try { updateInput = JSON.parse(updateInput); } catch { /* let validator complain */ } + } + const parsed = POLICY_UPDATE_SCHEMA.safeParse(updateInput); + if (!parsed.success) { + return toTextResult(`Policy update rejected: ${parsed.error.message}`); + } + const result = await applyPolicyCommand({ + policyFilePath: config.policyFile, + state, + command: { + kind: "update", + update: parsed.data + }, + actor: "mcp" + }); + return toTextResult(result.message, { version: result.state.version }); + } + + return toTextResult("Policy update rejected: missing `text` or `update`."); + } + ); + + server.registerTool( + "policy_read", + { + title: "Policy Read", + description: "Read current ArmorCodex policy state", + inputSchema: { + id: z.string().optional() + } + }, + async (args) => { + const { state } = await loadStateAndConfig(); + if (typeof args.id === "string" && args.id.trim()) { + const rule = state.policy.rules.find((entry) => entry.id === args.id.trim()); + if (!rule) { + return toTextResult(`Policy rule not found: ${args.id}`); + } + return toTextResult(JSON.stringify(rule, null, 2), { rule }); + } + return toTextResult(JSON.stringify(state, null, 2), { + version: state.version, + rules: state.policy.rules + }); + } + ); + + // ----------------------------------------------------------------- + // register_intent_plan — Codex calls this to declare its plan + // ----------------------------------------------------------------- + server.registerTool( + "register_intent_plan", + { + title: "Register Intent Plan", + description: + "Declare the tools you intend to use for this task. " + + "Required by ArmorCodex before any other tool call. " + + "Without a registered plan, all tool calls will be blocked.", + // Accept the canonical {goal, steps} shape AND the string-serialized + // variants Codex sometimes emits (steps as a JSON string, or the + // whole plan wrapped in a `plan` field). The handler below coerces + // them to the canonical shape before validating with INTENT_PLAN_ZOD. + inputSchema: { + goal: z.string().min(1).optional() + .describe("One-line summary of what the plan accomplishes"), + steps: z.union([ + z.array(PLAN_STEP_SCHEMA).min(1), + z.string().min(1) + ]).optional() + .describe("Ordered list of tool calls (array, or JSON-stringified array)"), + plan: z.union([INTENT_PLAN_ZOD, z.string().min(1)]).optional() + .describe("Alternative: pass the whole plan as an object or JSON string") + } + }, + async (args) => { + // Codex sometimes serializes complex tool arguments as JSON strings + // (e.g. steps: "[{...}]" instead of steps: [{...}]). Tolerate both. + const coerced = coercePlanArgs(args); + const parsed = INTENT_PLAN_ZOD.safeParse(coerced); + if (!parsed.success) { + return toTextResult(`Plan rejected: ${parsed.error.message}`); + } + + const config = loadConfig(); + const plan = normalizeIntentPlan(parsed.data); + + // Write the local plan to pending-plan.json IMMEDIATELY so PreToolUse + // has something to enforce against. The SDK call (if any) runs entirely + // in the background and updates pending-plan.json with the signed + // token when it resolves. + // + // Why fire-and-forget: Codex's MCP transport closes its stdio pipe + // around the ~1s mark. Any await we do here (loadPolicyState, the + // SDK round-trip, even cold-start latency) eats into that budget. + // Awaiting nothing on the network path keeps the MCP response under + // ~100ms regardless of backend conditions. + const pendingPath = path.join(config.dataDir, "pending-plan.json"); + await writeJson(pendingPath, { + plan, + tokenRaw: "", + allowedActions: Array.from(extractAllowedActions(plan)), + expiresAt: undefined, + registeredAt: Date.now() + }); + + let backendWillIssue = false; + if (config.intentEndpoint || (config.useSdkIntent && config.apiKey)) { + backendWillIssue = true; + // Kick off the SDK call. When it resolves with a signed token, update + // pending-plan.json so PreToolUse picks up the token on subsequent + // calls. Errors are logged to stderr and otherwise swallowed. + (async () => { + try { + const policyState = await loadPolicyState(config.policyFile); + const result = await requestIntent(config, { + prompt: parsed.data.goal, + plan, + session_id: "mcp", + policy_hash: computePolicyHash(policyState.policy), + policy: policyState.policy, + validitySeconds: config.validitySeconds, + metadata: { source: "codex", planning: "codex-registered" } + }); + if (result?.tokenRaw) { + await writeJson(pendingPath, { + plan: result.plan || plan, + tokenRaw: result.tokenRaw, + allowedActions: Array.from(extractAllowedActions(result.plan || plan)), + expiresAt: result.expiresAt, + registeredAt: Date.now() + }); + process.stderr.write( + `[armorcodex] backend token issued, tokenLen=${result.tokenRaw.length}\n` + ); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + process.stderr.write(`[armorcodex] intent capture failed: ${msg}\n`); + } + })(); + } + + const tokenInfo = backendWillIssue + ? `Plan registered; ArmorIQ token issuing in background.` + : "Plan stored locally (no ArmorIQ backend configured)."; + + return toTextResult( + `Intent registered: ${plan.steps.length} steps. ${tokenInfo}`, + { steps: plan.steps.length, goal: parsed.data.goal } + ); + } + ); + + // Background WAL flusher — drains queued audit rows in batches and ships + // to /iap/audit. Embedded here because the MCP server is already a + // long-lived stdio process; no need for a separate daemon binary the way + // armorClaude needs one (Claude Code spawns a fresh node per hook). + // + // Tuning mirrors armorClaude#44 daemon for cross-product parity: + // - 5s interval (AUDIT_FLUSH_INTERVAL_MS) + // - 100-row batch (AUDIT_FLUSH_THRESHOLD) + // Errors are logged + retried on the next tick (offset isn't advanced + // on failure). + const flusher = setInterval(async () => { + try { + const config = loadConfig(); + if (!config.apiKey) return; // no backend configured; WAL just accumulates locally + const wal = createAuditWal({ dataDir: config.dataDir }); + const { rows, endOffset } = await wal.readBatch(100); + if (rows.length === 0) return; + const iapService = createIapService(config); + await iapService.shipAuditBatch(rows); + await wal.advanceOffset(endOffset); + process.stderr.write( + `[armorcodex-policy] flushed ${rows.length} audit rows -> offset=${endOffset}\n` + ); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + process.stderr.write(`[armorcodex-policy] flusher: ${msg}\n`); + } + }, 5000); + flusher.unref?.(); + process.on("SIGTERM", () => clearInterval(flusher)); + process.on("SIGINT", () => clearInterval(flusher)); + + const transport = new StdioServerTransport(); + await server.connect(transport); +} + +run().catch((error) => { + const message = error instanceof Error ? error.stack || error.message : String(error); + process.stderr.write(`[armorcodex-policy] ${message}\n`); + process.exitCode = 1; +});