From cbe5d2a2be44b5f80ac22f43c8b0013478dc0b9f Mon Sep 17 00:00:00 2001 From: Harihara04sudhan Date: Fri, 5 Jun 2026 19:34:32 +0530 Subject: [PATCH 1/4] Restore ArmorCodex entry in Community Plugins The ArmorCodex entry was added in PR #140 (merged 2026-05-20 14:45 UTC) but accidentally dropped 5 minutes later when PR #115 (Add 10 pluginpool plugins, merged 14:50 UTC) was reconciled. The plugin bundle at plugins/armoriq/armorCodex/ remained intact, so the registry has the plugin but README and downstream marketplace artifacts don't list it. Restoring the README entry in its alphabetical slot between Apple Productivity and AxonFlow. Plugin folder + plugin.json are already in the repo from PR #140; no other changes needed. Repo: https://github.com/armoriq/armorCodex Plugin bundle in this repo: plugins/armoriq/armorCodex/ --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index f86d86db..616c42b4 100644 --- a/README.md +++ b/README.md @@ -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. From eeda6ceb02f3eae7b5c111c94e2fc8199c3a6b4c Mon Sep 17 00:00:00 2001 From: Harihara04sudhan Date: Wed, 10 Jun 2026 00:44:54 +0530 Subject: [PATCH 2/4] review fix: backtick bash + apply_patch in ArmorCodex entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per gemini-code-assist review on this PR — keeps formatting consistent with other entries (rg, grep, git blame) and prevents markdown parser ambiguity around the underscore in apply_patch. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 616c42b4..23a18d49 100644 --- a/README.md +++ b/README.md @@ -211,7 +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. +- [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. From 3ec8d056a91f37013928e514243bbc181c936692 Mon Sep 17 00:00:00 2001 From: Harihara04sudhan Date: Wed, 10 Jun 2026 00:52:59 +0530 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20alphabetical=20order=20=E2=80=94=20m?= =?UTF-8?q?ove=20Agent=20Workflow=20System=20after=20Agent=20Harness=20Ski?= =?UTF-8?q?lls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the upstream alphabetical CI check (check-alphabetical.py). Pre-existing ordering issue surfaced by the rebase, not caused by this PR — fixing here since the check is required to pass. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 23a18d49..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. From cc08c6fc4c56e71eaa10a12708084234399f1572 Mon Sep 17 00:00:00 2001 From: Harihara04sudhan Date: Wed, 10 Jun 2026 01:13:18 +0530 Subject: [PATCH 4/4] Complete bundle for armoriq/armorCodex per CONTRIBUTING.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per @internet-dot's review comment requesting the full plugin bundle (not just the README entry). This commit adds the complete ArmorCodex plugin source tree, mirrored from armoriq/armorCodex@main: - README.md, LICENSE, SECURITY.md - .codexignore, .plugin-scanner.toml - hooks/hooks.json (8 hook events declared) - scripts/bootstrap.mjs, scripts/hook-router.mjs, scripts/policy-mcp.mjs - scripts/lib/ (13 lib modules: engine, intent, policy, audit-wal, etc.) - .agents/plugins/marketplace.json (per-plugin marketplace metadata, follows AgiFlow reference shape — name "armoriq", plugin name "armorcodex", category "Tools & Integrations", source URL pointing at the canonical github.com/armoriq/armorCodex repo) Excluded from the bundle: - node_modules/ (installed at runtime via package-lock.json) - tests/ (moved out of the plugin distribution dir per scanner compliance; lives at the repo root in the source repo now) Refs hashgraph-online/awesome-codex-plugins#189 --- .../.agents/plugins/marketplace.json | 20 + .../armorCodex/.codex-plugin/plugin.json | 15 +- plugins/armoriq/armorCodex/.codexignore | 13 + .../armoriq/armorCodex/.plugin-scanner.toml | 3 + plugins/armoriq/armorCodex/LICENSE | 21 + plugins/armoriq/armorCodex/README.md | 43 + plugins/armoriq/armorCodex/SECURITY.md | 37 + plugins/armoriq/armorCodex/assets/README.md | 26 + plugins/armoriq/armorCodex/hooks/hooks.json | 73 ++ .../armoriq/armorCodex/scripts/bootstrap.mjs | 78 ++ .../armorCodex/scripts/hook-router.mjs | 116 +++ .../armorCodex/scripts/lib/audit-wal.mjs | 261 ++++++ .../armoriq/armorCodex/scripts/lib/common.mjs | 415 ++++++++++ .../armoriq/armorCodex/scripts/lib/config.mjs | 161 ++++ .../armorCodex/scripts/lib/crypto-policy.mjs | 244 ++++++ .../armoriq/armorCodex/scripts/lib/engine.mjs | 746 ++++++++++++++++++ .../armorCodex/scripts/lib/fs-store.mjs | 36 + .../armorCodex/scripts/lib/hook-output.mjs | 37 + .../armorCodex/scripts/lib/iap-service.mjs | 278 +++++++ .../armorCodex/scripts/lib/intent-schema.mjs | 70 ++ .../armoriq/armorCodex/scripts/lib/intent.mjs | 642 +++++++++++++++ .../armorCodex/scripts/lib/planner.mjs | 171 ++++ .../armoriq/armorCodex/scripts/lib/policy.mjs | 615 +++++++++++++++ .../armorCodex/scripts/lib/runtime-state.mjs | 80 ++ .../armoriq/armorCodex/scripts/policy-mcp.mjs | 318 ++++++++ 25 files changed, 4510 insertions(+), 9 deletions(-) create mode 100644 plugins/armoriq/armorCodex/.agents/plugins/marketplace.json create mode 100644 plugins/armoriq/armorCodex/.codexignore create mode 100644 plugins/armoriq/armorCodex/.plugin-scanner.toml create mode 100644 plugins/armoriq/armorCodex/LICENSE create mode 100644 plugins/armoriq/armorCodex/README.md create mode 100644 plugins/armoriq/armorCodex/SECURITY.md create mode 100644 plugins/armoriq/armorCodex/assets/README.md create mode 100644 plugins/armoriq/armorCodex/hooks/hooks.json create mode 100644 plugins/armoriq/armorCodex/scripts/bootstrap.mjs create mode 100644 plugins/armoriq/armorCodex/scripts/hook-router.mjs create mode 100644 plugins/armoriq/armorCodex/scripts/lib/audit-wal.mjs create mode 100644 plugins/armoriq/armorCodex/scripts/lib/common.mjs create mode 100644 plugins/armoriq/armorCodex/scripts/lib/config.mjs create mode 100644 plugins/armoriq/armorCodex/scripts/lib/crypto-policy.mjs create mode 100644 plugins/armoriq/armorCodex/scripts/lib/engine.mjs create mode 100644 plugins/armoriq/armorCodex/scripts/lib/fs-store.mjs create mode 100644 plugins/armoriq/armorCodex/scripts/lib/hook-output.mjs create mode 100644 plugins/armoriq/armorCodex/scripts/lib/iap-service.mjs create mode 100644 plugins/armoriq/armorCodex/scripts/lib/intent-schema.mjs create mode 100644 plugins/armoriq/armorCodex/scripts/lib/intent.mjs create mode 100644 plugins/armoriq/armorCodex/scripts/lib/planner.mjs create mode 100644 plugins/armoriq/armorCodex/scripts/lib/policy.mjs create mode 100644 plugins/armoriq/armorCodex/scripts/lib/runtime-state.mjs create mode 100644 plugins/armoriq/armorCodex/scripts/policy-mcp.mjs 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; +});