diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 0000000..56497ed --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,35 @@ +{ + "name": "armorcopilot", + "owner": { + "name": "ArmorIQ", + "email": "license@armoriq.io", + "url": "https://armoriq.ai" + }, + "metadata": { + "description": "ArmorIQ marketplace: intent-based security enforcement for GitHub Copilot CLI (ArmorCopilot) and other agentic harnesses.", + "version": "0.1.0" + }, + "interface": { + "displayName": "ArmorIQ" + }, + "plugins": [ + { + "name": "armorcopilot", + "source": "./plugins/armorcopilot", + "description": "Intent-based security enforcement for GitHub Copilot CLI: declared plans, policy rules, intent-drift blocking, optional CSRG cryptographic proofs, and audit logging.", + "version": "0.1.0", + "category": "Security", + "tags": ["security", "policy", "audit", "intent", "armoriq", "mcp", "hooks", "github-copilot"], + "homepage": "https://armoriq.ai", + "license": "MIT", + "author": { + "name": "ArmorIQ", + "email": "license@armoriq.io" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + } + } + ] +} diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..56497ed --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,35 @@ +{ + "name": "armorcopilot", + "owner": { + "name": "ArmorIQ", + "email": "license@armoriq.io", + "url": "https://armoriq.ai" + }, + "metadata": { + "description": "ArmorIQ marketplace: intent-based security enforcement for GitHub Copilot CLI (ArmorCopilot) and other agentic harnesses.", + "version": "0.1.0" + }, + "interface": { + "displayName": "ArmorIQ" + }, + "plugins": [ + { + "name": "armorcopilot", + "source": "./plugins/armorcopilot", + "description": "Intent-based security enforcement for GitHub Copilot CLI: declared plans, policy rules, intent-drift blocking, optional CSRG cryptographic proofs, and audit logging.", + "version": "0.1.0", + "category": "Security", + "tags": ["security", "policy", "audit", "intent", "armoriq", "mcp", "hooks", "github-copilot"], + "homepage": "https://armoriq.ai", + "license": "MIT", + "author": { + "name": "ArmorIQ", + "email": "license@armoriq.io" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + } + } + ] +} diff --git a/README.md b/README.md index 87d58ec..150554e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,52 @@ # armorCopilot -ArmorIQ enforcement for Microsoft Copilot Studio (and later GitHub Copilot) — intent-based tool-call interception, audit, and policy enforcement. + +ArmorIQ intent-based security enforcement for **GitHub Copilot CLI** — pre-tool guardrails, intent verification, optional cryptographic proofs, audit logging. + +Counterpart of ArmorClaude (Claude Code) and ArmorCodex (OpenAI Codex). Same wedge, different harness. + +## Install + +```bash +copilot plugin install armoriq/armorCopilot +``` + +The plugin runtime auto-discovers `.claude-plugin/plugin.json` inside `plugins/armorcopilot/` and registers hooks + MCP servers. + +## Configure + +After install, paste your ArmorIQ API key into the plugin's userConfig in Copilot CLI. Get one at https://armoriq.ai. Leave blank for local-only mode (no backend audit, policies stored on disk). + +## What it does + +| Surface | Plugin behavior | +|---|---| +| `sessionStart` / `userPromptSubmitted` | Injects directive telling Copilot to register its intent plan first | +| `preToolUse` | Verifies the tool against the registered plan + policy. Blocks via `{"permissionDecision":"deny",...}` if out-of-plan or policy-denied | +| `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), `policy_read` | + +## Layout + +``` +armorCopilot/ +├── .claude-plugin/marketplace.json repo-level marketplace manifest +├── .agents/plugins/marketplace.json mirror (for non-Copilot agent runtimes) +├── plugins/armorcopilot/ the plugin itself +│ ├── .claude-plugin/plugin.json plugin manifest +│ ├── .mcp.json MCP server config +│ ├── hooks/hooks.json 5 hook events wired +│ ├── package.json npm deps +│ ├── README.md plugin-level docs +│ ├── assets/ logo + icons +│ └── scripts/ bootstrap + hook-router + policy-mcp + 12 lib modules +└── README.md this file +``` + +## Refs + +- ArmorClaude: https://github.com/armoriq/armorClaude +- ArmorCodex: https://github.com/armoriq/armorCodex +- GitHub Copilot CLI plugin docs: https://docs.github.com/copilot/concepts/agents/copilot-cli/about-cli-plugins +- GitHub Copilot CLI hooks reference: https://docs.github.com/en/copilot/reference/hooks-configuration +- ArmorIQ platform: https://armoriq.ai diff --git a/install_armorcopilot.sh b/install_armorcopilot.sh new file mode 100755 index 0000000..76c56a8 --- /dev/null +++ b/install_armorcopilot.sh @@ -0,0 +1,397 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ArmorCopilot installer for GitHub Copilot CLI. +# +# Usage: +# curl -fsSL https://armoriq.ai/install_armorcopilot.sh | bash +# +# Works two ways: +# A. curl-pipe (no clone): fetches the plugin into ~/.armoriq/armorCopilot +# B. From an existing checkout: cd armorCopilot && bash install_armorcopilot.sh +# +# What it wires: +# 1. clones the plugin to ~/.armoriq/armorCopilot +# 2. npm install --omit=dev for plugin runtime deps +# 3. installs @armoriq/sdk-dev globally (for the `armoriq-dev` CLI) +# 4. registers the marketplace + installs the plugin in Copilot CLI: +# copilot plugin marketplace add armoriq/armorCopilot +# copilot plugin install armorcopilot@armorcopilot +# 5. runs `armoriq-dev login --product armorcopilot` for device-code auth +# +# Idempotent: re-running pulls the latest, reinstalls deps, refreshes marketplace. +# +# Flags: +# --uninstall remove the plugin + marketplace registration +# --skip-login don't prompt for ArmorIQ login at the end +# +# Non-interactive overrides: +# ARMORCOPILOT_MARKETPLACE_REPO override marketplace source (testing) +# ARMORCOPILOT_GIT_URL override fork source (testing) +# ARMORCOPILOT_GIT_REF branch / tag (default main) +# ARMORCOPILOT_INSTALL_HOME where to clone (default ~/.armoriq/armorCopilot) + +R=$'\033[1;31m' +G=$'\033[32m' +Y=$'\033[33m' +C=$'\033[38;2;0;229;204m' +M=$'\033[38;2;185;112;255m' +B=$'\033[1m' +D=$'\033[0;90m' +N=$'\033[0m' + +MARKETPLACE_REPO="${ARMORCOPILOT_MARKETPLACE_REPO:-armoriq/armorCopilot}" +MARKETPLACE_NAME="armorcopilot" +PLUGIN_NAME="armorcopilot" +PLUGIN_GIT_URL="${ARMORCOPILOT_GIT_URL:-https://github.com/armoriq/armorCopilot.git}" +PLUGIN_GIT_REF="${ARMORCOPILOT_GIT_REF:-main}" +INSTALL_HOME="${ARMORCOPILOT_INSTALL_HOME:-${HOME}/.armoriq/armorCopilot}" +DASHBOARD_URL="https://dev.armoriq.ai" + +# Recover if the caller is running this from a deleted directory (common when +# piping curl into bash from /tmp). +pwd >/dev/null 2>&1 || cd "${HOME:-/}" + +# If invoked via `bash <(curl ...)` BASH_SOURCE may not point at a real file. +SCRIPT_PATH="${BASH_SOURCE[0]:-}" +if [[ -n "${SCRIPT_PATH}" && -f "${SCRIPT_PATH}" ]]; then + SCRIPT_DIR="$(cd "$(dirname "${SCRIPT_PATH}")" && pwd)" +else + SCRIPT_DIR="" +fi + +PLUGIN_SUBDIR="plugins/armorcopilot" +if [[ -n "${SCRIPT_DIR}" && -f "${SCRIPT_DIR}/${PLUGIN_SUBDIR}/scripts/bootstrap.mjs" ]]; then + PLUGIN_ROOT="${SCRIPT_DIR}" +else + PLUGIN_ROOT="${INSTALL_HOME}" +fi +PLUGIN_PATH="${PLUGIN_ROOT}/${PLUGIN_SUBDIR}" +BOOTSTRAP_PATH="${PLUGIN_PATH}/scripts/bootstrap.mjs" + +DO_UNINSTALL=0 +SKIP_LOGIN=0 +for arg in "$@"; do + case "$arg" in + --uninstall) DO_UNINSTALL=1 ;; + --skip-login) SKIP_LOGIN=1 ;; + -h|--help) + sed -n '4,32p' "${SCRIPT_PATH:-$0}" 2>/dev/null || true + exit 0 + ;; + esac +done + +# --------------------------------------------------------------------------- +# UI helpers +# --------------------------------------------------------------------------- + +ok() { printf "${G}✔${N} %s\n" "$*"; } +warn() { printf "${Y}!${N} %s\n" "$*"; } +err() { printf "${R}✘${N} %s\n" "$*" 1>&2; } +info() { printf "${D}·${N} %s\n" "$*"; } +section() { printf "\n${B}${M}┃ %s${N}\n" "$*"; } + +banner() { + cat </dev/null 2>&1; then + err "missing required command: $1" + case "$1" in + copilot) echo " install GitHub Copilot CLI: curl -fsSL https://gh.io/copilot-install | bash" 1>&2 ;; + node) echo " install Node.js >= 20 from https://nodejs.org" 1>&2 ;; + git) echo " install git from https://git-scm.com/downloads" 1>&2 ;; + npm) echo " npm comes bundled with Node.js" 1>&2 ;; + esac + exit 1 + fi +} + +check_node_version() { + local raw major + raw="$(node --version 2>/dev/null || true)" + major="$(printf '%s' "${raw#v}" | cut -d. -f1)" + if [[ -z "${major}" || "${major}" -lt 20 ]]; then + err "Node.js >= 20 required (found ${raw:-none})" + exit 1 + fi +} + +is_promptable() { + [[ -e /dev/tty ]] || return 1 + (: < /dev/tty) 2>/dev/null || return 1 + return 0 +} + +prompt_yes_no() { + local question="$1" default="${2:-Y}" + local hint="(Y/n)" + [[ "$default" == "N" ]] && hint="(y/N)" + if ! is_promptable; then + [[ "$default" == "Y" ]]; return $? + fi + printf "${B}?${N} %s ${D}%s${N} " "$question" "$hint" >&2 + local answer + read -r answer < /dev/tty || answer="" + [[ -z "$answer" ]] && { [[ "$default" == "Y" ]]; return $?; } + [[ "$answer" =~ ^[Yy] ]] +} + +# --------------------------------------------------------------------------- +# Plugin source + Copilot CLI wiring +# --------------------------------------------------------------------------- + +fetch_plugin_source() { + if [[ -f "${BOOTSTRAP_PATH}" ]]; then + info "using existing checkout at ${PLUGIN_ROOT}" + return 0 + fi + + mkdir -p "$(dirname "${INSTALL_HOME}")" + + if [[ -d "${INSTALL_HOME}/.git" ]]; then + info "refreshing ${INSTALL_HOME} (git pull)" + git -C "${INSTALL_HOME}" fetch --quiet origin "${PLUGIN_GIT_REF}" >/dev/null + git -C "${INSTALL_HOME}" reset --hard --quiet "origin/${PLUGIN_GIT_REF}" >/dev/null + ok "updated to ${PLUGIN_GIT_REF}" + else + info "cloning ${PLUGIN_GIT_URL} into ${INSTALL_HOME}" + git clone --quiet --depth 1 --branch "${PLUGIN_GIT_REF}" "${PLUGIN_GIT_URL}" "${INSTALL_HOME}" + ok "cloned to ${INSTALL_HOME}" + fi + + PLUGIN_ROOT="${INSTALL_HOME}" + PLUGIN_PATH="${PLUGIN_ROOT}/${PLUGIN_SUBDIR}" + BOOTSTRAP_PATH="${PLUGIN_PATH}/scripts/bootstrap.mjs" + if [[ ! -f "${BOOTSTRAP_PATH}" ]]; then + err "fetched repo is missing ${PLUGIN_SUBDIR}/scripts/bootstrap.mjs" + exit 1 + fi +} + +install_npm_deps() { + pushd "${PLUGIN_PATH}" >/dev/null + if [[ -d node_modules/@armoriq/sdk-dev && -d node_modules/zod && -d node_modules/@modelcontextprotocol/sdk ]] \ + || [[ -d node_modules/@armoriq/sdk && -d node_modules/zod && -d node_modules/@modelcontextprotocol/sdk ]]; then + info "npm dependencies already present" + else + info "installing npm dependencies (--omit=dev)" + npm install --omit=dev --silent --no-audit --no-fund >/dev/null + ok "npm dependencies installed" + fi + popd >/dev/null +} + +install_armoriq_cli() { + info "installing ArmorIQ CLI ${B}(@armoriq/sdk-dev)${N}" + if npm install -g @armoriq/sdk-dev@latest --silent --no-audit --no-fund >/dev/null 2>&1; then + ok "armoriq-dev CLI ready" + else + warn "couldn't install globally, use ${B}npx @armoriq/sdk-dev${N} instead" + fi +} + +register_marketplace_and_install() { + # Marketplace add accepts owner/repo, URL, or a LOCAL PATH. + # Use the local checkout when available (works without network for the + # marketplace lookup) and otherwise fall back to the GitHub source. + local marketplace_source="${MARKETPLACE_REPO}" + if [[ -f "${PLUGIN_ROOT}/.claude-plugin/marketplace.json" ]]; then + marketplace_source="${PLUGIN_ROOT}" + fi + + info "registering marketplace ${marketplace_source}" + if copilot plugin marketplace add "${marketplace_source}" >/dev/null 2>&1; then + ok "marketplace registered" + else + info "marketplace add skipped (already added)" + fi + + info "installing plugin ${PLUGIN_NAME}@${MARKETPLACE_NAME}" + if copilot plugin install "${PLUGIN_NAME}@${MARKETPLACE_NAME}" >/dev/null 2>&1; then + ok "plugin installed" + else + # On re-run the plugin may already be installed; refresh by uninstall/reinstall. + copilot plugin uninstall "${PLUGIN_NAME}" >/dev/null 2>&1 || true + if copilot plugin install "${PLUGIN_NAME}@${MARKETPLACE_NAME}" >/dev/null 2>&1; then + ok "plugin reinstalled (refreshed)" + else + err "failed to install plugin — try: copilot plugin install ${PLUGIN_NAME}@${MARKETPLACE_NAME}" + exit 1 + fi + fi +} + +verify_install() { + section "Verifying" + local issues=0 + if [[ ! -f "${BOOTSTRAP_PATH}" ]]; then + warn "bootstrap.mjs missing at ${BOOTSTRAP_PATH}" + issues=$((issues+1)) + fi + if ! copilot plugin list 2>&1 | grep -q "${PLUGIN_NAME}@${MARKETPLACE_NAME}"; then + warn "plugin not visible in 'copilot plugin list'" + issues=$((issues+1)) + fi + if [[ "${issues}" -eq 0 ]]; then + ok "armorcopilot is wired up correctly" + else + warn "${issues} verification check(s) failed, see warnings above" + fi +} + +connect_to_armoriq() { + [[ "${SKIP_LOGIN}" -eq 1 ]] && return 0 + + section "Connect to ArmorIQ" + cat </dev/null 2>&1; then + if armoriq-dev login --help 2>&1 | grep -q -- '--product'; then + armoriq-dev login --product "${product}" + else + ARMORIQ_PRODUCT="${product}" armoriq-dev login + fi + elif command -v armoriq >/dev/null 2>&1; then + if armoriq login --help 2>&1 | grep -q -- '--product'; then + armoriq login --product "${product}" + else + ARMORIQ_PRODUCT="${product}" armoriq login + fi + elif command -v npx >/dev/null 2>&1; then + if npx @armoriq/sdk-dev login --help 2>&1 | grep -q -- '--product'; then + npx @armoriq/sdk-dev login --product "${product}" + else + ARMORIQ_PRODUCT="${product}" npx @armoriq/sdk-dev login + fi + else + warn "armoriq CLI not found. Run ${B}npx @armoriq/sdk-dev login${N} manually." + return 0 + fi + + local login_status=$? + if [[ $login_status -eq 0 ]] && [[ -f "$HOME/.armoriq/credentials.json" ]]; then + echo + ok "ArmorIQ connected. Copilot will auto-load the key." + fi +} + +finale() { + echo + printf "${G}${B}ArmorCopilot is installed.${N}\n" + + section "Quick start" + cat < read README.md${N} + ${D}> add a line "this is working" to README.md${N} + + Add policy rules from any prompt (natural language or "Policy ..."): + + ${D}> Policy new: deny webfetch${N} + ${D}> update the policy to not access ~/photos${N} + +EOF + + section "Manage anytime" + cat </dev/null || echo install_armorcopilot.sh) --uninstall${N} + + Plugin: ${C}${PLUGIN_PATH}${N} + Copilot list: ${G}copilot plugin list${N} + Docs: ${C}https://github.com/armoriq/armorCopilot${N} + +EOF +} + +uninstall() { + section "Uninstalling ArmorCopilot" + if copilot plugin uninstall "${PLUGIN_NAME}" >/dev/null 2>&1; then + ok "plugin uninstalled" + else + info "plugin not installed (or already removed)" + fi + if copilot plugin marketplace remove "${MARKETPLACE_NAME}" >/dev/null 2>&1; then + ok "marketplace removed" + else + info "marketplace not registered (or already removed)" + fi + info "Plugin source at ${INSTALL_HOME} left in place. Remove with: rm -rf ${INSTALL_HOME}" +} + +main() { + if [[ "${DO_UNINSTALL}" -eq 1 ]]; then + uninstall + exit 0 + fi + + banner + + section "Checking prerequisites" + require_cmd copilot + require_cmd node + require_cmd npm + require_cmd git + check_node_version + ok "prerequisites OK ($(copilot --version 2>/dev/null | head -1), $(node --version))" + + section "Fetching plugin source" + fetch_plugin_source + + section "Installing dependencies" + install_npm_deps + install_armoriq_cli + + section "Registering Copilot CLI plugin" + register_marketplace_and_install + + verify_install + connect_to_armoriq + finale +} + +main "$@" diff --git a/plugins/armorcopilot/.claude-plugin/plugin.json b/plugins/armorcopilot/.claude-plugin/plugin.json new file mode 100644 index 0000000..61b3950 --- /dev/null +++ b/plugins/armorcopilot/.claude-plugin/plugin.json @@ -0,0 +1,76 @@ +{ + "name": "armorcopilot", + "version": "0.1.0", + "description": "ArmorIQ intent-based security enforcement for GitHub Copilot CLI: pre-tool guardrails with intent verification, optional CSRG cryptographic proofs, and audit logging. Treat as a strong shell guardrail and audit layer — hooks fire on preToolUse / postToolUse / sessionStart / userPromptSubmitted via the official Copilot CLI plugin runtime.", + "author": { + "name": "ArmorIQ", + "email": "license@armoriq.io", + "url": "https://armoriq.ai" + }, + "homepage": "https://armoriq.ai", + "repository": "https://github.com/armoriq/armorCopilot", + "license": "MIT", + "keywords": [ + "security", + "policy", + "audit", + "intent", + "armoriq", + "mcp", + "hooks", + "github-copilot" + ], + "hooks": "./hooks/hooks.json", + "mcpServers": "./.mcp.json", + "interface": { + "displayName": "ArmorCopilot", + "shortDescription": "Intent-based security policy and audit for GitHub Copilot CLI.", + "longDescription": "ArmorIQ intent-based security enforcement for GitHub Copilot CLI. Hooks into preToolUse / postToolUse / sessionStart / userPromptSubmitted events. Provides plan registration through MCP, intent-plan matching, permission gating, and synchronous audit on every tool invocation. Block tools by name, by argument pattern, or by intent drift — all configured from natural language via the policy_update MCP tool.", + "developerName": "ArmorIQ", + "category": "Security", + "capabilities": ["MCP", "Hooks"], + "websiteURL": "https://armoriq.ai", + "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": [ + "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": { + "api_key": { + "type": "string", + "title": "ArmorIQ API Key", + "description": "Your ArmorIQ API key (get one at https://armoriq.ai). Leave blank to run in local-only mode without backend audit/intent.", + "sensitive": true + }, + "mode": { + "type": "string", + "title": "Enforcement Mode", + "description": "enforce = block on policy/intent failures (recommended). monitor = log only, never block.", + "sensitive": false + }, + "intent_required": { + "type": "boolean", + "title": "Require Intent Plan", + "description": "When true, every tool invocation must be backed by a registered intent plan. Disable for advisory-only use.", + "sensitive": false + }, + "crypto_policy_enabled": { + "type": "boolean", + "title": "Enable Crypto Policy Binding", + "description": "Bind policy rules to a Merkle tree so post-issuance tampering is detected.", + "sensitive": false + }, + "use_production": { + "type": "boolean", + "title": "Use Production Endpoints", + "description": "When true, talks to ArmorIQ production. When false, expects a local backend on 127.0.0.1.", + "sensitive": false + } + } +} diff --git a/plugins/armorcopilot/.mcp.json b/plugins/armorcopilot/.mcp.json new file mode 100644 index 0000000..7fd7761 --- /dev/null +++ b/plugins/armorcopilot/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "armorcopilot-policy": { + "command": "node", + "args": ["${CLAUDE_PLUGIN_ROOT}/scripts/bootstrap.mjs", "mcp"] + } + } +} diff --git a/plugins/armorcopilot/README.md b/plugins/armorcopilot/README.md new file mode 100644 index 0000000..b033c9d --- /dev/null +++ b/plugins/armorcopilot/README.md @@ -0,0 +1,76 @@ +# ArmorCopilot for GitHub Copilot CLI + +Intent-based security policy + audit for GitHub Copilot CLI. Ports the same enforcement model that powers ArmorClaude and ArmorCodex to the GitHub Copilot CLI plugin runtime. + +## What it does + +- Hooks into `sessionStart`, `userPromptSubmitted`, `preToolUse`, `postToolUse`, `permissionRequest` +- Registers Copilot's plan via MCP (`register_intent_plan`) +- Verifies every tool call against the registered plan — out-of-plan tools are blocked even if policy would allow them +- Lets you set policies in natural language ("Block any commands that fetch URLs") via the `policy_update` MCP tool +- Optional CSRG cryptographic proofs for tamper detection +- Async batched audit pipeline: each tool call is enqueued to a local write-ahead log (durable on disk), then shipped in batches to the ArmorIQ backend by a background flusher inside the MCP server. Durable enqueue, async ship. + +## Install + +The plugin manifest lives at `plugins/armorcopilot/.claude-plugin/plugin.json` inside the repo. Install via the marketplace flow: + +```bash +copilot plugin marketplace add armoriq/armorCopilot +copilot plugin install armorcopilot@armorcopilot +``` + +The repo's root `.claude-plugin/marketplace.json` declares the plugin source so the marketplace install resolves to the right subdirectory automatically. + +Or use the curl-pipe installer that handles the full wiring (plugin + npm deps + `armoriq-dev` CLI + device-code login): + +```bash +curl -fsSL https://armoriq.ai/install_armorcopilot.sh | bash +``` + +## Configure + +Open the plugin's userConfig in Copilot CLI and paste your ArmorIQ API key. Get one at https://armoriq.ai. Leave blank to run in local-only mode (no backend audit, policies stored on disk). + +## Try in chat + +After install, in any `copilot` session: + +- "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." + +## Architecture + +``` +GitHub Copilot CLI + ↓ preToolUse hook fires + ↓ runs scripts/bootstrap.mjs router + ↓ engine.mjs evaluates: in-plan? policy-allowed? + ↓ returns stdout JSON: { permissionDecision: "allow|deny|ask" } + ↑ Copilot honors the decision +``` + +The MCP server `armorcopilot-policy` exposes three tools: +- `register_intent_plan` — Copilot calls this to declare its plan +- `policy_update` — user updates policy in natural language +- `policy_read` — list current policies + +## Local development + +```bash +git clone https://github.com/armoriq/armorCopilot +cd armorCopilot/plugins/armorcopilot +npm install --omit=dev + +# Then from the repo root, register the marketplace + install: +copilot plugin marketplace add /path/to/armorCopilot +copilot plugin install armorcopilot@armorcopilot +``` + +## Refs + +- ArmorClaude (same model for Claude Code): https://github.com/armoriq/armorClaude +- ArmorCodex (same model for OpenAI Codex): https://github.com/armoriq/armorCodex +- GitHub Copilot CLI plugin docs: https://docs.github.com/copilot/concepts/agents/copilot-cli/about-cli-plugins +- GitHub Copilot CLI hooks reference: https://docs.github.com/en/copilot/reference/hooks-configuration diff --git a/plugins/armorcopilot/assets/armoriq-logo.png b/plugins/armorcopilot/assets/armoriq-logo.png new file mode 100644 index 0000000..52f87f1 Binary files /dev/null and b/plugins/armorcopilot/assets/armoriq-logo.png differ diff --git a/plugins/armorcopilot/hooks/hooks.json b/plugins/armorcopilot/hooks/hooks.json new file mode 100644 index 0000000..fa936d5 --- /dev/null +++ b/plugins/armorcopilot/hooks/hooks.json @@ -0,0 +1,69 @@ +{ + "version": 1, + "hooks": { + "sessionStart": [ + { + "type": "command", + "bash": "node \"$CLAUDE_PLUGIN_ROOT/scripts/bootstrap.mjs\" router", + "env": { "COPILOT_HOOK_EVENT": "SessionStart" }, + "timeoutSec": 30 + } + ], + "userPromptSubmitted": [ + { + "type": "command", + "bash": "node \"$CLAUDE_PLUGIN_ROOT/scripts/bootstrap.mjs\" router", + "env": { "COPILOT_HOOK_EVENT": "UserPromptSubmit" }, + "timeoutSec": 30 + } + ], + "preToolUse": [ + { + "type": "command", + "bash": "node \"$CLAUDE_PLUGIN_ROOT/scripts/bootstrap.mjs\" router", + "env": { "COPILOT_HOOK_EVENT": "PreToolUse" }, + "timeoutSec": 30 + } + ], + "permissionRequest": [ + { + "type": "command", + "bash": "node \"$CLAUDE_PLUGIN_ROOT/scripts/bootstrap.mjs\" router", + "env": { "COPILOT_HOOK_EVENT": "PermissionRequest" }, + "timeoutSec": 30 + } + ], + "postToolUse": [ + { + "type": "command", + "bash": "node \"$CLAUDE_PLUGIN_ROOT/scripts/bootstrap.mjs\" router", + "env": { "COPILOT_HOOK_EVENT": "PostToolUse" }, + "timeoutSec": 30 + } + ], + "postToolUseFailure": [ + { + "type": "command", + "bash": "node \"$CLAUDE_PLUGIN_ROOT/scripts/bootstrap.mjs\" router", + "env": { "COPILOT_HOOK_EVENT": "PostToolUseFailure" }, + "timeoutSec": 30 + } + ], + "agentStop": [ + { + "type": "command", + "bash": "node \"$CLAUDE_PLUGIN_ROOT/scripts/bootstrap.mjs\" router", + "env": { "COPILOT_HOOK_EVENT": "Stop" }, + "timeoutSec": 30 + } + ], + "sessionEnd": [ + { + "type": "command", + "bash": "node \"$CLAUDE_PLUGIN_ROOT/scripts/bootstrap.mjs\" router", + "env": { "COPILOT_HOOK_EVENT": "SessionEnd" }, + "timeoutSec": 30 + } + ] + } +} diff --git a/plugins/armorcopilot/package-lock.json b/plugins/armorcopilot/package-lock.json new file mode 100644 index 0000000..b6adda0 --- /dev/null +++ b/plugins/armorcopilot/package-lock.json @@ -0,0 +1,1354 @@ +{ + "name": "@armoriq/armorcopilot", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@armoriq/armorcopilot", + "version": "0.1.0", + "dependencies": { + "@armoriq/sdk": "^0.3.1", + "@modelcontextprotocol/sdk": "^1.27.1", + "zod": "^3.25.76" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@armoriq/sdk": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@armoriq/sdk/-/sdk-0.3.3.tgz", + "integrity": "sha512-OSBY09wPAHU/8ZESkjC8cUCthbkKId2nSXUVrD2WpZYglpXRxOEnoxS//3CMeVWanXr5nupwsqih5IdgXnfgDQ==", + "license": "MIT", + "dependencies": { + "axios": "^1.7.0", + "js-yaml": "^4.1.1" + }, + "bin": { + "armoriq": "dist/cli/index.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz", + "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.23", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", + "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/plugins/armorcopilot/package.json b/plugins/armorcopilot/package.json new file mode 100644 index 0000000..c41997c --- /dev/null +++ b/plugins/armorcopilot/package.json @@ -0,0 +1,20 @@ +{ + "name": "@armoriq/armorcopilot-gh", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "ArmorIQ intent-based security enforcement for GitHub Copilot CLI: Bash command guardrails with intent verification, optional CSRG cryptographic proofs, and audit logging.", + "scripts": { + "test": "node --test", + "check": "node --test" + }, + "engines": { + "node": ">=20.0.0" + }, + "dependencies": { + "@armoriq/sdk": "^0.3.1", + "@modelcontextprotocol/sdk": "^1.27.1", + "zod": "^3.25.76" + }, + "repository": "https://github.com/armoriq/armorCopilot" +} diff --git a/plugins/armorcopilot/scripts/bootstrap.mjs b/plugins/armorcopilot/scripts/bootstrap.mjs new file mode 100644 index 0000000..8b6f2a3 --- /dev/null +++ b/plugins/armorcopilot/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 +// `copilot 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", ".armorcopilot-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("[armorcopilot] 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("[armorcopilot] 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 Copilot via JSON-RPC / JSON +// over stdio. Any non-JSON write to stdout corrupts the protocol and Copilot +// 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("[armorcopilot] bootstrap: unknown target '" + target + "'\n"); + process.exit(2); +} diff --git a/plugins/armorcopilot/scripts/hook-router.mjs b/plugins/armorcopilot/scripts/hook-router.mjs new file mode 100644 index 0000000..3d81b14 --- /dev/null +++ b/plugins/armorcopilot/scripts/hook-router.mjs @@ -0,0 +1,150 @@ +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(`[armorcopilot] ${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("ArmorCopilot hook payload invalid JSON")); + } + return; + } + // Normalize GitHub Copilot CLI's camelCase payload to the snake_case shape + // the engine expects (matches Claude Code's hook payload format). Copilot + // also doesn't include hook_event_name — it comes from COPILOT_HOOK_EVENT + // set in hooks.json per event. + if (typeof input.sessionId === "string" && !input.session_id) { + input.session_id = input.sessionId; + } + if (typeof input.toolName === "string" && !input.tool_name) { + input.tool_name = input.toolName; + } + if (input.toolArgs !== undefined && input.tool_input === undefined) { + // Copilot serializes tool args as a JSON STRING; parse to object. + if (typeof input.toolArgs === "string") { + try { + input.tool_input = JSON.parse(input.toolArgs); + } catch { + input.tool_input = input.toolArgs; + } + } else { + input.tool_input = input.toolArgs; + } + } + if (typeof input.toolResult !== "undefined" && typeof input.tool_response === "undefined") { + input.tool_response = input.toolResult; + } + if (typeof input.initialPrompt === "string" && !input.prompt) { + input.prompt = input.initialPrompt; + } + const event = + (typeof input.hook_event_name === "string" && input.hook_event_name) || + process.env.COPILOT_HOOK_EVENT || + ""; + if (event && !input.hook_event_name) { + input.hook_event_name = event; + } + 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(`[armorcopilot] error=${message}\n`); + } + if (mode === "enforce") { + if (currentEvent === "PermissionRequest") { + emitJson(denyPermissionRequest(`ArmorCopilot internal error: ${message}`)); + } else { + emitJson(denyPreTool(`ArmorCopilot internal error: ${message}`)); + } + } +}); diff --git a/plugins/armorcopilot/scripts/lib/audit-wal.mjs b/plugins/armorcopilot/scripts/lib/audit-wal.mjs new file mode 100644 index 0000000..dc98ffb --- /dev/null +++ b/plugins/armorcopilot/scripts/lib/audit-wal.mjs @@ -0,0 +1,266 @@ +/** + * 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. + */ + // Cap each read to bound memory if the backend is down + the WAL grows large. + // 4 MiB is enough to comfortably parse `maxRows=100` audit rows (~40KB each + // worst case) while preventing OOM if a partial-line tail keeps growing. + const MAX_READ_BYTES = 4 * 1024 * 1024; + + 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 = Math.min(st.size - offset, MAX_READ_BYTES); + 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/armorcopilot/scripts/lib/common.mjs b/plugins/armorcopilot/scripts/lib/common.mjs new file mode 100644 index 0000000..a0f5411 --- /dev/null +++ b/plugins/armorcopilot/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/armorcopilot/scripts/lib/config.mjs b/plugins/armorcopilot/scripts/lib/config.mjs new file mode 100644 index 0000000..0473181 --- /dev/null +++ b/plugins/armorcopilot/scripts/lib/config.mjs @@ -0,0 +1,172 @@ +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 + * ARMORCOPILOT_* env var used by repo-local hook installs. + */ +function pluginOpt(env, pluginKey, legacyKey) { + const pluginVal = + env[`COPILOT_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", "ARMORCOPILOT_MODE") || "enforce").toLowerCase(); + const envMode = (env.ARMORIQ_ENV || "production").trim().toLowerCase(); + const useProduction = parseBoolean( + pluginOpt(env, "USE_PRODUCTION", "ARMORCOPILOT_USE_PRODUCTION") || undefined, + envMode === "production" + ); + + // Data directory: prefer plugin-injected storage, then + // ARMORCOPILOT_DATA_DIR, then default ~/.copilot/armorcopilot. + const dataDir = + env.COPILOT_PLUGIN_DATA?.trim() || + env.CLAUDE_PLUGIN_DATA?.trim() || + env.ARMORCOPILOT_DATA_DIR?.trim() || + path.join(homedir(), ".copilot", "armorcopilot"); + + const policyFile = + env.ARMORCOPILOT_POLICY_FILE?.trim() || path.join(dataDir, "policy.json"); + const runtimeFile = + env.ARMORCOPILOT_RUNTIME_FILE?.trim() || path.join(dataDir, "runtime.json"); + + const timeoutMs = parseInteger(env.ARMORCOPILOT_TIMEOUT_MS, 8000); + + // 3-way endpoint resolution by envMode (production / staging / local). + // Env var overrides always win; otherwise pick by envMode. + // Previously this was a 2-way ternary that confusingly mapped useProduction + // → staging-api. Reviewers flagged the inversion — fixed by switching on + // envMode explicitly. + const backendEndpoint = + env.ARMORCOPILOT_BACKEND_ENDPOINT?.trim() || + env.BACKEND_ENDPOINT?.trim() || + (envMode === "production" + ? "https://api.armoriq.ai" + : envMode === "staging" + ? "https://staging-api.armoriq.ai" + : "http://127.0.0.1:3000"); + + const iapEndpoint = + env.ARMORCOPILOT_IAP_ENDPOINT?.trim() || + env.IAP_ENDPOINT?.trim() || + (envMode === "production" + ? "https://iap.armoriq.ai" + : envMode === "staging" + ? "https://iap-staging.armoriq.ai" + : "http://127.0.0.1:8000"); + + const proxyEndpoint = + env.ARMORCOPILOT_PROXY_ENDPOINT?.trim() || + env.PROXY_ENDPOINT?.trim() || + (envMode === "production" + ? "https://proxy.armoriq.ai" + : envMode === "staging" + ? "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.ARMORCOPILOT_USE_SDK_INTENT, true), + intentEndpoint: env.ARMORCOPILOT_INTENT_URL?.trim() || "", + verifyStepEndpoint: + env.ARMORCOPILOT_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 ARMORCOPILOT_VALIDITY_SECONDS to tighten. + validitySeconds: parseInteger(env.ARMORCOPILOT_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.ARMORCOPILOT_REFRESH_THRESHOLD_SECONDS, 30), + timeoutMs, + // One attempt per tool call is usually right — a hung backend shouldn't + // stall Copilot for timeout * retries. Users who really want retries can + // opt in via ARMORCOPILOT_MAX_RETRIES. + maxRetries: parseInteger(env.ARMORCOPILOT_MAX_RETRIES, 1), + verifySsl: parseBoolean(env.ARMORCOPILOT_VERIFY_SSL, true), + llmId: env.ARMORCOPILOT_LLM_ID?.trim() || "github-copilot", + mcpName: env.ARMORCOPILOT_MCP_NAME?.trim() || "copilot", + userId: env.ARMORCOPILOT_USER_ID?.trim() || "copilot-user", + agentId: env.ARMORCOPILOT_AGENT_ID?.trim() || "copilot", + contextId: env.ARMORCOPILOT_CONTEXT_ID?.trim() || "default", + + // Intent enforcement — default true (enforce plan mode) + intentRequired: parseBoolean( + pluginOpt(env, "INTENT_REQUIRED", "ARMORCOPILOT_INTENT_REQUIRED") || undefined, + true + ), + // CSRG verification disabled by default until tenant OPA policies are + // configured to allow Copilot tools. The OPA default-deny behavior + // blocks all tools when no matching policy exists. Enable once your + // tenant has allow-rules for the tools Copilot uses. + requireCsrgProofs: parseBoolean(env.REQUIRE_CSRG_PROOFS, false), + csrgVerifyEnabled: parseBoolean(env.CSRG_VERIFY_ENABLED, false), + + // Policy management + policyUpdateEnabled: parseBoolean(env.ARMORCOPILOT_POLICY_UPDATE_ENABLED, true), + policyUpdateAllowList: parseList( + env.ARMORCOPILOT_POLICY_UPDATE_ALLOWLIST || "*" + ), + contextHintsEnabled: parseBoolean( + env.ARMORCOPILOT_CONTEXT_HINTS_ENABLED, + true + ), + + // Crypto policy binding (Merkle tree) + cryptoPolicyEnabled: parseBoolean( + pluginOpt(env, "CRYPTO_POLICY_ENABLED", "ARMORCOPILOT_CRYPTO_POLICY_ENABLED") || undefined, + false + ), + + // Audit logging + auditEnabled: parseBoolean( + env.ARMORCOPILOT_AUDIT_ENABLED, + Boolean(apiKey) + ), + + // Plan directive injection (tells Copilot to register a plan via MCP tool) + planningEnabled: parseBoolean(env.ARMORCOPILOT_PLANNING_ENABLED, true), + + // Param sanitization limits + sanitize: { + maxChars: parseInteger(env.ARMORCOPILOT_MAX_PARAM_CHARS, 2000), + maxDepth: parseInteger(env.ARMORCOPILOT_MAX_PARAM_DEPTH, 4), + maxKeys: parseInteger(env.ARMORCOPILOT_MAX_PARAM_KEYS, 50), + maxItems: parseInteger(env.ARMORCOPILOT_MAX_PARAM_ITEMS, 50) + }, + + debug: parseBoolean(env.ARMORCOPILOT_DEBUG, false) + }; +} diff --git a/plugins/armorcopilot/scripts/lib/crypto-policy.mjs b/plugins/armorcopilot/scripts/lib/crypto-policy.mjs new file mode 100644 index 0000000..586c8e4 --- /dev/null +++ b/plugins/armorcopilot/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 || "copilot-user", + agent_id: identity.agentId || config.agentId || "copilot", + 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/armorcopilot/scripts/lib/engine.mjs b/plugins/armorcopilot/scripts/lib/engine.mjs new file mode 100644 index 0000000..a8ac77a --- /dev/null +++ b/plugins/armorcopilot/scripts/lib/engine.mjs @@ -0,0 +1,748 @@ +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: "ArmorCopilot 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: "ArmorCopilot 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(`[armorcopilot] ${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( + `ArmorCopilot 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 || "ArmorCopilot 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 Copilot to register its intent plan --- + // Copilot 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( + "ArmorCopilot 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, "ArmorCopilot: missing tool_name on PreToolUse"); + } + + // --- Whitelist: ArmorCopilot'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 (armorcopilot-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"]; + // Copilot MCP namespace is `mcp____` and the underlying MCP server name + // can carry hyphens (`armorcopilot-policy`) or be sanitized to underscores + // (`armorcopilot_policy`). Copilot'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__armorcopilot[-_]policy__|armorcopilot[-_]policy[._])/; + if ( + armorTools.some( + (t) => + norm === t || + (norm.endsWith(t) && ARMOR_SERVER_RE.test(norm)) + ) + ) { + return null; + } + + // --- Whitelist: Copilot 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). --- + // Read-only / coordination tools that bypass full enforcement to keep the + // hot path fast. Strictly local-only — anything that performs network + // egress (websearch, webfetch) MUST go through policy + intent enforcement + // and cannot be on this list. + const safeInternalTools = new Set([ + "toolsearch", + "todowrite", + "listmcpresourcestool", + "readmcpresourcetool", + "read", + "grep", + "glob" + ]); + 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 Copilot 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 Copilot 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, `ArmorCopilot 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 || "ArmorCopilot 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: "copilot", 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: "copilot", + 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(`ArmorCopilot 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 || `ArmorCopilot 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, `ArmorCopilot verify-step failed: ${message}`); + if (deny) { + return deny; + } + } + } + + // --- Expiry check --- + if (Number.isFinite(localExpiresAt) && nowEpochSeconds() > localExpiresAt) { + const deny = denyOrAllow( + config, + "ArmorCopilot 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 || "ArmorCopilot intent drift"); + if (deny) { + return deny; + } + } + } + + // --- Enforce intent requirement --- + if (config.intentRequired && !remoteAllowed && !tokenCheckMatched && !localPlanMatched) { + const deny = denyOrAllow(config, "ArmorCopilot 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 || "ArmorCopilot 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/armorcopilot/scripts/lib/fs-store.mjs b/plugins/armorcopilot/scripts/lib/fs-store.mjs new file mode 100644 index 0000000..2d6843e --- /dev/null +++ b/plugins/armorcopilot/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/armorcopilot/scripts/lib/hook-output.mjs b/plugins/armorcopilot/scripts/lib/hook-output.mjs new file mode 100644 index 0000000..9cadc4b --- /dev/null +++ b/plugins/armorcopilot/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/armorcopilot/scripts/lib/iap-service.mjs b/plugins/armorcopilot/scripts/lib/iap-service.mjs new file mode 100644 index 0000000..be8692a --- /dev/null +++ b/plugins/armorcopilot/scripts/lib/iap-service.mjs @@ -0,0 +1,283 @@ +/** + * 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); + // Fail-closed on non-2xx: a JSON body with a partial payload must not + // silently allow downstream code to treat `data.allowed` as true by + // default. The only acceptable success signal is response.ok. + if (!response.ok) { + throw new Error( + (isPlainObject(response.data) && response.data.message) || + 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/armorcopilot/scripts/lib/intent-schema.mjs b/plugins/armorcopilot/scripts/lib/intent-schema.mjs new file mode 100644 index 0000000..6602ce0 --- /dev/null +++ b/plugins/armorcopilot/scripts/lib/intent-schema.mjs @@ -0,0 +1,76 @@ +/** + * Shared intent plan schema — single source of truth used by: + * - register_intent_plan MCP tool (validates Copilot's input) + * - register_intent_plan inputSchema (model sees this when invoking the tool) + * + * Copilot has no ExitPlanMode-equivalent event, so unlike ArmorClaude there is + * no plan-file extraction path on Copilot. + */ + +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"), + session_id: z + .string() + .optional() + .describe( + "Optional — pass the current Copilot session_id so the pending plan is scoped per-session and concurrent sessions don't clobber each other. If omitted, falls back to a shared global pending-plan.json file." + ) +}); + +/** + * Human-readable format string injected into Copilot'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: "copilot", + description: s.description || "", + metadata: s.metadata || {} + })), + metadata: { + goal: parsed.goal, + source: "copilot-registered" + } + }; +} diff --git a/plugins/armorcopilot/scripts/lib/intent.mjs b/plugins/armorcopilot/scripts/lib/intent.mjs new file mode 100644 index 0000000..f613273 --- /dev/null +++ b/plugins/armorcopilot/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 : "ArmorCopilot intent"; + const plan = { steps: [], metadata: { goal, source: "copilot" } }; + if (typeof payload.toolName === "string" && payload.toolName.trim()) { + plan.steps.push({ + action: payload.toolName.trim(), + mcp: payload.mcpName || "copilot", + 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: "ArmorCopilot 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: `ArmorCopilot 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 Copilot'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: `ArmorCopilot 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: "copilot", + 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/armorcopilot/scripts/lib/planner.mjs b/plugins/armorcopilot/scripts/lib/planner.mjs new file mode 100644 index 0000000..bd42647 --- /dev/null +++ b/plugins/armorcopilot/scripts/lib/planner.mjs @@ -0,0 +1,171 @@ +/** + * Plan parsing for ArmorCopilot. + * + * 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: Copilot calls register_intent_plan MCP tool directly + * (handled in policy-mcp.mjs, not here) + * + * This module handles only PARSING — plan generation is done by Copilot'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 Copilot 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 Copilot + * plan-file events; current Copilot 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: "copilot", + 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: "copilot", + 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 Copilot-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}/.copilot/plans/${sessionName}.md`; + } + + return null; +} diff --git a/plugins/armorcopilot/scripts/lib/policy.mjs b/plugins/armorcopilot/scripts/lib/policy.mjs new file mode 100644 index 0000000..b26bf7c --- /dev/null +++ b/plugins/armorcopilot/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: `ArmorCopilot policy deny: ${rule.id}`, + matchedRule: rule, + dataClasses: Array.from(dataClasses), + warnings + }; + } + if (rule.action === "require_approval") { + return { + allowed: false, + reason: `ArmorCopilot 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 Copilot 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_COPILOT_TOOLS = new Set([ + "*", + "bash", "shell", "write", "read", "edit", "apply_patch", "list_dir", "view_image", "mcp_resource", "websearch", "fetch", + "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_COPILOT_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/armorcopilot/scripts/lib/runtime-state.mjs b/plugins/armorcopilot/scripts/lib/runtime-state.mjs new file mode 100644 index 0000000..9667839 --- /dev/null +++ b/plugins/armorcopilot/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/armorcopilot/scripts/policy-mcp.mjs b/plugins/armorcopilot/scripts/policy-mcp.mjs new file mode 100644 index 0000000..cfdee64 --- /dev/null +++ b/plugins/armorcopilot/scripts/policy-mcp.mjs @@ -0,0 +1,337 @@ +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 Copilot 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: "armorcopilot-policy", + version: "0.1.0" + }); + + server.registerTool( + "policy_update", + { + title: "Policy Update", + description: "Manage ArmorCopilot 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("ArmorCopilot 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 ArmorCopilot 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 — Copilot 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 ArmorCopilot 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 Copilot 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) => { + // Copilot 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 + // (or pending-plan.json as a legacy fallback) so PreToolUse has + // something to enforce against. The SDK call (if any) runs entirely + // in the background and updates the same file with the signed token + // when it resolves. + // + // Why per-session: concurrent Copilot sessions would otherwise share + // a single pending-plan.json and clobber each other's plans/tokens. + // PreToolUse in engine.mjs already prefers the session-scoped path + // and falls back to global, so we mirror writes to both paths for + // both pre- and post-fix installs. + // + // Why fire-and-forget: Copilot'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 sessionId = typeof parsed.data.session_id === "string" + ? parsed.data.session_id + : ""; + const globalPath = path.join(config.dataDir, "pending-plan.json"); + const sessionPath = sessionId + ? path.join(config.dataDir, `pending-plan.${sessionId}.json`) + : null; + const pendingPath = sessionPath || globalPath; + const pendingPayload = { + plan, + tokenRaw: "", + allowedActions: Array.from(extractAllowedActions(plan)), + expiresAt: undefined, + registeredAt: Date.now() + }; + await writeJson(pendingPath, pendingPayload); + if (sessionPath) { + // Mirror to the legacy global path so older readers still work. + await writeJson(globalPath, pendingPayload); + } + + 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: "copilot", planning: "copilot-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( + `[armorcopilot] backend token issued, tokenLen=${result.tokenRaw.length}\n` + ); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + process.stderr.write(`[armorcopilot] 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( + `[armorcopilot-policy] flushed ${rows.length} audit rows -> offset=${endOffset}\n` + ); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + process.stderr.write(`[armorcopilot-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(`[armorcopilot-policy] ${message}\n`); + process.exitCode = 1; +});