From e0e35acef3c619e3ddd2e6a386a5049a431c22e8 Mon Sep 17 00:00:00 2001 From: David Crowe Date: Mon, 14 Sep 2026 15:54:16 -0700 Subject: [PATCH] Terminal commands: /acp-status, /acp-enforce, /acp-audit, /acp-allow|ask|deny, /acp-apply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A command the human types reaches the hook on UserPromptExpansion before it expands; the hook files it with the gateway (POST /plugin/intents), prints the signed confirm link the gateway hands back, and blocks the expansion so the model never sees the command or the link as a prompt. The human's tap on that link is what changes the workspace — the key on this machine never writes policy. /acp-status is read-only. Also: the no-credential and local-mode early exits no longer swallow UserPromptExpansion (the handler owns its own not-connected message); SessionStart prints the gateway's once-a-day offer on the human channel; the session receipt names /acp-status after a shadow notice. 0.18.0. --- .claude-plugin/marketplace.json | 2 +- README.md | 4 + bin/govern.mjs | 114 ++++++++++++++- commands/acp-allow.md | 9 ++ commands/acp-apply.md | 9 ++ commands/acp-ask.md | 9 ++ commands/acp-audit.md | 9 ++ commands/acp-deny.md | 9 ++ commands/acp-enforce.md | 9 ++ commands/acp-status.md | 9 ++ hooks/hooks.json | 12 ++ lib/receipt.mjs | 6 +- plugin.json | 2 +- skills/acp/SKILL.md | 2 + test/receipt.test.mjs | 2 +- test/terminal-intents.test.mjs | 243 ++++++++++++++++++++++++++++++++ 16 files changed, 442 insertions(+), 8 deletions(-) create mode 100644 commands/acp-allow.md create mode 100644 commands/acp-apply.md create mode 100644 commands/acp-ask.md create mode 100644 commands/acp-audit.md create mode 100644 commands/acp-deny.md create mode 100644 commands/acp-enforce.md create mode 100644 commands/acp-status.md create mode 100644 test/terminal-intents.test.mjs diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 29c0e1e..8b769f6 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ "name": "agentic-control-plane", "source": "./", "description": "Control, audit, and cost-optimize every Claude Code tool call. Governance hook + bundled ACP MCP (cost X-ray, run traces, policy checks) + /cost-xray pre-ship report.", - "version": "0.19.0", + "version": "0.20.0", "author": { "name": "GatewayStack" }, diff --git a/README.md b/README.md index ce4da46..43f4af6 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,10 @@ When ACP denies a call, the plugin tells you why with a distinct prefix so you c Not every step_up has to end at a console link. When a workspace rule says ask and you're in an interactive session, this plugin declares that Claude Code can render its own permission prompt, and the policy may answer with a live dialog right there in the terminal instead of a deny. Say yes and the call runs once — the approval is recorded as answered from the terminal, same as any other decision. Say no and the call simply doesn't run; nothing is retried automatically. Unattended tiers and harnesses that can't show a prompt still get the deny + link they always did. +### Commands + +Type `/acp-status`, `/acp-enforce`, `/acp-audit`, `/acp-allow `, `/acp-ask `, `/acp-deny `, or `/acp-apply ` in the terminal to check or change how your workspace is governed without leaving the session. The hook files exactly what you typed and prints a link back — nothing changes until you open it and tap Confirm, so a stray or injected command can't move policy on its own. A confirmed change applies to the whole workspace, the same as making it in the console; if you're not an admin, your request is filed for one to review. + ### Context guard (v0.15.0+, off by default) Whole-file reads are the cheapest thing an agent does and the most expensive thing it puts into a frontier model's context. The hook sizes a read **before** it happens — `Read` (offset/limit-aware) and `cat` / `head` / `tail` / `less` / `more` / `bat` — and sends the line and byte count to the gateway (or the local engine) as `tool_context`. Targeted reads always pass: offset/limit, `head -n 20`, pipes (`cat f | grep x`), redirects, byte ranges, `tail -f`. diff --git a/bin/govern.mjs b/bin/govern.mjs index fe51145..16f867e 100644 --- a/bin/govern.mjs +++ b/bin/govern.mjs @@ -67,7 +67,7 @@ const ACP_GOVERN = process.env.ACP_API_BASE || "https://govern.agenticcontrolplane.com"; -const PLUGIN_VERSION = "0.19.0"; +const PLUGIN_VERSION = "0.20.0"; // Console base for user-facing deep links (session receipt, #606). const ACP_CONSOLE = @@ -613,13 +613,19 @@ try { process.exit(0); } +// UserPromptExpansion (the /acp-* terminal commands) owns its own +// no-credential message — "/acp-connect first", not the tool-call floors +// below, and only when the human actually typed one of our commands. Let it +// fall through to the dispatch at the bottom untouched. +const EARLY_HOOK_EVENT = typeof input?.hook_event_name === "string" ? input.hook_event_name : "PreToolUse"; + // Wired but uncredentialed: the hook runs on every call and has nothing to // authenticate with, so each one proceeds unchecked. Never brick — but NEVER // silently, the same contract the unreachable-gateway and missing-engine // paths already honor. This branch was the exception, and that silence is // what let installs sit ungoverned for weeks while the installer reported // success and the server saw a workspace indistinguishable from unused. -if (!token && !LOCAL) { +if (!token && !LOCAL && EARLY_HOOK_EVENT !== "UserPromptExpansion") { if (REQUIRE_ENROLLMENT) { blockUnenrolled(input); process.exit(0); @@ -632,7 +638,7 @@ if (!token && !LOCAL) { process.exit(0); } -if (LOCAL) { +if (LOCAL && EARLY_HOOK_EVENT !== "UserPromptExpansion") { await runLocal(input); process.exit(0); } @@ -1473,13 +1479,21 @@ async function handleSessionStart() { // session by construction: attest runs at SessionStart only. // Canonical logic in lib/attestation.mjs (attestNoticeOutput). const data = await res.json().catch(() => null); + // The daily offer (gatewaystack-connect govern/terminalOffer.ts): what + // enforcement would have held this week and the command that turns it + // on. Human channel only — it names a command for the HUMAN to type. + // The gateway sends it at most once per day per workspace. + const offer = data && typeof data.offer === "string" && data.offer.trim() ? data.offer.trim() : null; if (data && typeof data.notice === "string" && data.notice.trim()) { process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: data.notice.trim(), }, + ...(offer ? { systemMessage: offer } : {}), })); + } else if (offer) { + process.stdout.write(JSON.stringify({ systemMessage: offer })); } } catch { // silent — absence of attestation is visible server-side by design @@ -1539,7 +1553,11 @@ function buildReceiptLine(stats, sessionId) { const parts = [`${stats.calls} tool call${stats.calls === 1 ? "" : "s"} governed`]; if (stats.flagged > 0) parts.push(`${stats.flagged} flagged`); if (stats.notices > 0) parts.push(`${stats.notices} shadow notice${stats.notices === 1 ? "" : "s"}`); - return `[ACP] Session receipt: ${parts.join(" · ")} — review this session: ${ACP_CONSOLE}/sessions/${encodeURIComponent(String(sessionId))}`; + // A shadow notice only ever fires in audit mode, so notices > 0 means + // enforcement would have held something this session. Name the read-only + // command that shows the list; /acp-status works in every workspace. + const next = stats.notices > 0 ? ` · /acp-status shows what enforcement would have held` : ""; + return `[ACP] Session receipt: ${parts.join(" · ")} — review this session: ${ACP_CONSOLE}/sessions/${encodeURIComponent(String(sessionId))}${next}`; } // One line at session end: what ACP governed, anything it said, and a @@ -1559,8 +1577,96 @@ function handleStop() { process.exit(0); } +/* ------------------------------------------------------------------ */ +/* UserPromptExpansion — /acp-* commands typed by the human */ +/* ------------------------------------------------------------------ */ + +// A slash command the HUMAN types reaches this hook before it expands into +// a prompt; the model cannot author this event. The hook turns the command +// into a pending intent on the gateway (POST /plugin/intents) and prints +// the signed confirm link the gateway hands back — then BLOCKS the +// expansion, so the model never sees the command, its body, or the link +// as a prompt. The human taps the link; that tap is what changes policy, +// as the human, through the same writers the console uses. The workspace +// key on this machine never writes policy (#245). +// +// Note the link is printed on the human channel, which the harness also +// writes to the transcript. That is fine by design: the link executes only +// the intent the human already typed, as that human, once, inside its TTL +// — nothing a reader of the transcript can redirect. +const INTENT_COMMAND_RE = /(?:^|:)acp-(enforce|audit|allow|ask|deny|apply|status)$/; + +function blockExpansion(reason) { + process.stdout.write(JSON.stringify({ decision: "block", reason })); + process.exit(0); +} + +async function handleUserPromptExpansion() { + const name = typeof input.command_name === "string" ? input.command_name : ""; + const m = INTENT_COMMAND_RE.exec(name); + if (!m) process.exit(0); // not ours — let it expand + // Subagents don't get to file workspace changes on the human's behalf. + if (typeof input.agent_id === "string" && input.agent_id) process.exit(0); + const kind = m[1]; + const target = typeof input.command_args === "string" ? input.command_args.trim().split(/\s+/)[0] || "" : ""; + + if (!token) { + blockExpansion(`[ACP] Not connected — /acp-${kind} needs a workspace key. Run /acp-connect first.`); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 6000); + try { + if (kind === "status") { + const res = await fetch(`${ACP_API}/plugin/intents/status`, { method: "GET", headers, signal: controller.signal }); + const s = await res.json().catch(() => null); + if (!res.ok || !s?.ok) { + blockExpansion(`[ACP] Couldn't read workspace status (HTTP ${res.status}). Console: ${ACP_CONSOLE}/policies`); + } + const rules = Array.isArray(s.rules) && s.rules.length + ? s.rules.map((r) => ` ${r.tool}: ${r.interactive === "step_up" ? "ask" : r.interactive ?? "-"}`).join("\n") + : " (no per-tool rules)"; + const proposals = Array.isArray(s.proposals) && s.proposals.length + ? "\nProposals waiting for you:\n" + s.proposals.map((p) => ` ${p.id} ${p.tool} → ${p.permission === "step_up" ? "ask" : p.permission} (${p.source ?? "agent"}) /acp-apply ${p.id}`).join("\n") + : ""; + const asks = Array.isArray(s.pendingApprovals) && s.pendingApprovals.length + ? `\nPending approvals: ${s.pendingApprovals.length} (${ACP_CONSOLE}/approvals)` + : ""; + const next = s.mode === "enforce" + ? "/acp-allow stops the asking for one tool; /acp-audit records only." + : "/acp-enforce turns the starter rules on so they ask first."; + blockExpansion(`[ACP] ${s.workspace} is in ${s.mode} mode.\nInteractive rules:\n${rules}${proposals}${asks}\n${next}`); + } + + const res = await fetch(`${ACP_API}/plugin/intents`, { + method: "POST", + headers, + body: JSON.stringify({ kind, target, session_id: input.session_id, client: ACP_CLIENT }), + signal: controller.signal, + }); + const data = await res.json().catch(() => null); + if (res.status === 404 && data?.error === "not-rolled-out") { + blockExpansion(`[ACP] Terminal commands aren't on for this workspace yet. Console: ${ACP_CONSOLE}/policies`); + } + if (!res.ok || !data?.ok) { + const hint = data?.hint ? ` ${data.hint}` : ""; + blockExpansion(`[ACP] /acp-${kind} not filed (${data?.error ?? `HTTP ${res.status}`}).${hint}`); + } + const mins = Math.max(1, Math.round((data.expiresInSeconds ?? 600) / 60)); + blockExpansion( + `[ACP] ${data.describe}\n\nConfirm (you, not the agent): ${data.confirm}\n` + + `Expires in ${mins} min. Nothing changes until you open that link and tap Confirm.`, + ); + } catch (err) { + blockExpansion(`[ACP] Couldn't reach ACP to file /acp-${kind} (${err?.name === "AbortError" ? "timeout" : err?.message ?? "network error"}). Console: ${ACP_CONSOLE}/policies`); + } finally { + clearTimeout(timeout); + } +} + const hookEvent = typeof input.hook_event_name === "string" ? input.hook_event_name : "PreToolUse"; if (hookEvent === "PostToolUse") handlePostToolUse(); else if (hookEvent === "SessionStart") handleSessionStart(); else if (hookEvent === "Stop") handleStop(); +else if (hookEvent === "UserPromptExpansion") handleUserPromptExpansion(); else handlePreToolUse(); diff --git a/commands/acp-allow.md b/commands/acp-allow.md new file mode 100644 index 0000000..336446e --- /dev/null +++ b/commands/acp-allow.md @@ -0,0 +1,9 @@ +--- +name: acp-allow +description: "Stop asking for one tool in interactive sessions: /acp-allow Bash.git-push. You confirm with one tap." +user-invocable: true +--- + +This command is handled by the ACP hook before it reaches you: the hook files the request and prints a confirm link for the human. If you are reading this, the hook did not intercept it — this Claude Code is older than the UserPromptExpansion hook event. + +Tell the user, in one line: "Your Claude Code is too old for terminal commands — update it, or set the rule at https://cloud.agenticcontrolplane.com/policies." Do not attempt to change the workspace yourself. diff --git a/commands/acp-apply.md b/commands/acp-apply.md new file mode 100644 index 0000000..54003fd --- /dev/null +++ b/commands/acp-apply.md @@ -0,0 +1,9 @@ +--- +name: acp-apply +description: "Confirm a rule an agent proposed: /acp-apply . You confirm with one tap." +user-invocable: true +--- + +This command is handled by the ACP hook before it reaches you: the hook files the request and prints a confirm link for the human. If you are reading this, the hook did not intercept it — this Claude Code is older than the UserPromptExpansion hook event. + +Tell the user, in one line: "Your Claude Code is too old for terminal commands — update it, or confirm the proposal at https://cloud.agenticcontrolplane.com/policies." Do not attempt to change the workspace yourself. diff --git a/commands/acp-ask.md b/commands/acp-ask.md new file mode 100644 index 0000000..0c5bbaa --- /dev/null +++ b/commands/acp-ask.md @@ -0,0 +1,9 @@ +--- +name: acp-ask +description: "Ask before one tool runs in interactive sessions: /acp-ask Bash.rm. You confirm with one tap." +user-invocable: true +--- + +This command is handled by the ACP hook before it reaches you: the hook files the request and prints a confirm link for the human. If you are reading this, the hook did not intercept it — this Claude Code is older than the UserPromptExpansion hook event. + +Tell the user, in one line: "Your Claude Code is too old for terminal commands — update it, or set the rule at https://cloud.agenticcontrolplane.com/policies." Do not attempt to change the workspace yourself. diff --git a/commands/acp-audit.md b/commands/acp-audit.md new file mode 100644 index 0000000..af92471 --- /dev/null +++ b/commands/acp-audit.md @@ -0,0 +1,9 @@ +--- +name: acp-audit +description: Put this workspace back in audit mode — everything recorded, nothing held. You confirm with one tap. +user-invocable: true +--- + +This command is handled by the ACP hook before it reaches you: the hook files the request and prints a confirm link for the human. If you are reading this, the hook did not intercept it — this Claude Code is older than the UserPromptExpansion hook event. + +Tell the user, in one line: "Your Claude Code is too old for terminal commands — update it, or change the mode at https://cloud.agenticcontrolplane.com/policies." Do not attempt to change the workspace yourself. diff --git a/commands/acp-deny.md b/commands/acp-deny.md new file mode 100644 index 0000000..33c420d --- /dev/null +++ b/commands/acp-deny.md @@ -0,0 +1,9 @@ +--- +name: acp-deny +description: "Block one tool in interactive sessions: /acp-deny Bash.curl. You confirm with one tap." +user-invocable: true +--- + +This command is handled by the ACP hook before it reaches you: the hook files the request and prints a confirm link for the human. If you are reading this, the hook did not intercept it — this Claude Code is older than the UserPromptExpansion hook event. + +Tell the user, in one line: "Your Claude Code is too old for terminal commands — update it, or set the rule at https://cloud.agenticcontrolplane.com/policies." Do not attempt to change the workspace yourself. diff --git a/commands/acp-enforce.md b/commands/acp-enforce.md new file mode 100644 index 0000000..89812bf --- /dev/null +++ b/commands/acp-enforce.md @@ -0,0 +1,9 @@ +--- +name: acp-enforce +description: Turn enforcement on for this workspace — the starter rules start asking before risky calls run. You confirm with one tap. +user-invocable: true +--- + +This command is handled by the ACP hook before it reaches you: the hook files the request and prints a confirm link for the human. If you are reading this, the hook did not intercept it — this Claude Code is older than the UserPromptExpansion hook event. + +Tell the user, in one line: "Your Claude Code is too old for terminal commands — update it, or turn enforcement on at https://cloud.agenticcontrolplane.com/policies." Do not attempt to change the workspace yourself; you cannot, and you should not try. diff --git a/commands/acp-status.md b/commands/acp-status.md new file mode 100644 index 0000000..91a4ad5 --- /dev/null +++ b/commands/acp-status.md @@ -0,0 +1,9 @@ +--- +name: acp-status +description: Show this workspace's mode, its interactive rules, and any proposals or approvals waiting for you. +user-invocable: true +--- + +This command is handled by the ACP hook before it reaches you: the hook prints the workspace status for the human. If you are reading this, the hook did not intercept it — this Claude Code is older than the UserPromptExpansion hook event. + +Tell the user, in one line: "Your Claude Code is too old for terminal commands — update it, or see the workspace at https://cloud.agenticcontrolplane.com/policies." diff --git a/hooks/hooks.json b/hooks/hooks.json index 5545292..d1b45b0 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -47,6 +47,18 @@ } ] } + ], + "UserPromptExpansion": [ + { + "matcher": ".*", + "hooks": [ + { + "type": "command", + "command": "node \"$CLAUDE_PLUGIN_ROOT/bin/govern.mjs\"", + "timeout": 8 + } + ] + } ] } } diff --git a/lib/receipt.mjs b/lib/receipt.mjs index d1adede..0166a03 100644 --- a/lib/receipt.mjs +++ b/lib/receipt.mjs @@ -73,5 +73,9 @@ export function buildReceiptMessage(stats, sessionId, consoleBase) { if (stats.flagged > 0) parts.push(`${stats.flagged} flagged`); if (stats.notices > 0) parts.push(`${stats.notices} shadow notice${stats.notices === 1 ? "" : "s"}`); const url = `${consoleBase}/sessions/${encodeURIComponent(String(sessionId))}`; - return `[ACP] Session receipt: ${parts.join(" · ")} — review this session: ${url}`; + // A shadow notice only fires in audit mode, so notices > 0 means + // enforcement would have held something this session. Name the read-only + // command that shows the list; /acp-status works in every workspace. + const next = stats.notices > 0 ? ` · /acp-status shows what enforcement would have held` : ""; + return `[ACP] Session receipt: ${parts.join(" · ")} — review this session: ${url}${next}`; } diff --git a/plugin.json b/plugin.json index 9d74ceb..1f9d734 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "agentic-control-plane", - "version": "0.19.0", + "version": "0.20.0", "description": "Identity, governance, and audit for every Claude Code tool call. Logs all tool usage, enforces policies, and gives teams full visibility \u2014 without changing how you use Claude.", "author": { "name": "GatewayStack", diff --git a/skills/acp/SKILL.md b/skills/acp/SKILL.md index b7d399e..0207ce7 100644 --- a/skills/acp/SKILL.md +++ b/skills/acp/SKILL.md @@ -81,6 +81,8 @@ Tell the user: | Team members | `https://cloud.agenticcontrolplane.com/users` | | Billing & usage | `https://cloud.agenticcontrolplane.com/billing` | +For a mode or rule change, don't only point at the console — the user can type `/acp-status`, `/acp-enforce`, `/acp-audit`, `/acp-allow `, `/acp-ask `, `/acp-deny `, or `/acp-apply ` right in the terminal; the hook files it and hands back a link they confirm themselves, no context switch required. + ## Managing ACP from within Claude Users can ask Claude to help with ACP management. You can assist with: diff --git a/test/receipt.test.mjs b/test/receipt.test.mjs index ee99f9f..faa062b 100644 --- a/test/receipt.test.mjs +++ b/test/receipt.test.mjs @@ -54,7 +54,7 @@ test("receipt message: only what happened, deep link to this session", () => { const msg = buildReceiptMessage({ calls: 214, flagged: 0, notices: 3 }, "sess-9", CONSOLE); assert.equal( msg, - "[ACP] Session receipt: 214 tool calls governed · 3 shadow notices — review this session: https://cloud.agenticcontrolplane.com/sessions/sess-9", + "[ACP] Session receipt: 214 tool calls governed · 3 shadow notices — review this session: https://cloud.agenticcontrolplane.com/sessions/sess-9 · /acp-status shows what enforcement would have held", ); const flaggedMsg = buildReceiptMessage({ calls: 2, flagged: 1, notices: 0 }, "s", CONSOLE); assert.ok(flaggedMsg.includes("2 tool calls governed · 1 flagged")); diff --git a/test/terminal-intents.test.mjs b/test/terminal-intents.test.mjs new file mode 100644 index 0000000..8e44710 --- /dev/null +++ b/test/terminal-intents.test.mjs @@ -0,0 +1,243 @@ +// Tests for the UserPromptExpansion terminal-intents handler in +// bin/govern.mjs (`handleUserPromptExpansion`, dispatched on +// hook_event_name === "UserPromptExpansion"). +// +// Run with: node --test test/terminal-intents.test.mjs +// +// Each test spawns the real hook exactly the way a harness does — JSON on +// stdin — against a throwaway fixture HOME holding a workspace token, with +// ACP_API_BASE pointed at a local stub server that records what it saw and +// serves both POST /plugin/intents and GET /plugin/intents/status. +// Invariants under test: +// a. /acp-enforce with no args → POST {kind:"enforce", target:"", ...} +// with a bearer header; blocked with describe/confirm/expiry text. +// b. /acp-allow [extra] → target is only the first token. +// c. /acp-status → GET .../status; reason starts with the mode line and +// lists a proposal's /acp-apply hint. +// d. A non-ACP command never triggers a request or output. +// e. A subagent (agent_id set) never triggers a request or output. +// f. No credentials in HOME → blocked with an /acp-connect hint, no +// request made. +// g. 404 not-rolled-out → blocked mentioning the workspace isn't on it. +// h. Unreachable stub → blocked mentioning ACP was unreachable. +// The hook always exits 0, in every case above. + +import { test, before, after, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const GOVERN = join(ROOT, "bin", "govern.mjs"); + +let HOME; +let server; +let baseUrl; +let seen = []; +let nextResponse = { + ok: true, + describe: "File /acp-enforce: turn on the starter rules for this workspace.", + confirm: "https://cloud.agenticcontrolplane.com/confirm/abc123", + expiresInSeconds: 600, +}; +let nextStatus = 200; +let nextStatusResponse = { + ok: true, + workspace: "acme", + mode: "audit", + rules: [], + proposals: [], + pendingApprovals: [], +}; + +before(async () => { + HOME = mkdtempSync(join(tmpdir(), "acp-terminal-intents-test-")); + mkdirSync(join(HOME, ".acp"), { recursive: true }); + writeFileSync(join(HOME, ".acp", "credentials"), "gsk_test_deadbeef\n"); + + server = createServer((req, res) => { + let raw = ""; + req.on("data", (c) => { raw += c; }); + req.on("end", () => { + let body = null; + try { body = raw ? JSON.parse(raw) : null; } catch { /* keep null */ } + seen.push({ method: req.method, url: req.url, headers: req.headers, body }); + res.setHeader("content-type", "application/json"); + if (req.url.startsWith("/plugin/intents/status")) { + res.statusCode = nextStatus; + res.end(JSON.stringify(nextStatusResponse)); + } else { + res.statusCode = nextStatus; + res.end(JSON.stringify(nextResponse)); + } + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + baseUrl = `http://127.0.0.1:${server.address().port}`; +}); + +after(() => { + server?.close(); + rmSync(HOME, { recursive: true, force: true }); +}); + +beforeEach(() => { + seen = []; + nextStatus = 200; + nextResponse = { + ok: true, + describe: "File /acp-enforce: turn on the starter rules for this workspace.", + confirm: "https://cloud.agenticcontrolplane.com/confirm/abc123", + expiresInSeconds: 600, + }; + nextStatusResponse = { + ok: true, + workspace: "acme", + mode: "audit", + rules: [], + proposals: [], + pendingApprovals: [], + }; +}); + +// NOTE: async spawn, not spawnSync — the stub server lives in THIS process. +function runHook(input, env = {}, useHome = HOME) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [GOVERN], { + env: { + HOME: useHome, + PATH: process.env.PATH, + ACP_API_BASE: baseUrl, + CLAUDE_CODE_ENTRYPOINT: "cli", + ...env, + }, + stdio: ["pipe", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + const killer = setTimeout(() => child.kill("SIGKILL"), 15000); + child.stdout.on("data", (c) => { stdout += c; }); + child.stderr.on("data", (c) => { stderr += c; }); + child.on("error", reject); + child.on("close", (status) => { + clearTimeout(killer); + try { + assert.equal(status, 0, `hook exited ${status}: ${stderr}`); + resolve(stdout.trim() ? JSON.parse(stdout) : null); + } catch (e) { + reject(e); + } + }); + child.stdin.write(JSON.stringify(input)); + child.stdin.end(); + }); +} + +const expansion = (overrides = {}) => ({ + hook_event_name: "UserPromptExpansion", + command_name: "agentic-control-plane:acp-enforce", + command_args: "", + session_id: "sess-intents-1", + ...overrides, +}); + +test("(a) /acp-enforce with no args files enforce with an empty target and blocks with describe/confirm/expiry", async () => { + const out = await runHook(expansion()); + const req = seen.find((s) => s.url === "/plugin/intents"); + assert.ok(req, "expected a POST to /plugin/intents"); + assert.equal(req.method, "POST"); + assert.deepEqual(req.body, { + kind: "enforce", + target: "", + session_id: "sess-intents-1", + client: req.body.client, + }); + assert.equal(req.headers.authorization, "Bearer gsk_test_deadbeef"); + assert.ok(out, "expected output on stdout"); + assert.equal(out.decision, "block"); + assert.ok(out.reason.includes(nextResponse.describe), "reason should include the describe text"); + assert.ok(out.reason.includes(nextResponse.confirm), "reason should include the confirm URL"); + assert.match(out.reason, /Expires in 10 min/); +}); + +test("(b) /acp-allow target is only the first whitespace-separated token", async () => { + const out = await runHook(expansion({ + command_name: "agentic-control-plane:acp-allow", + command_args: "Bash.git-push --force", + })); + const req = seen.find((s) => s.url === "/plugin/intents"); + assert.ok(req, "expected a POST to /plugin/intents"); + assert.equal(req.body.kind, "allow"); + assert.equal(req.body.target, "Bash.git-push"); + assert.equal(out.decision, "block"); +}); + +test("(c) /acp-status reads GET /plugin/intents/status and lists the mode + a proposal's /acp-apply hint", async () => { + nextStatusResponse = { + ok: true, + workspace: "acme", + mode: "audit", + rules: [], + proposals: [{ id: "p1", tool: "Bash.rm", permission: "step_up", source: "acp_propose_rule" }], + pendingApprovals: [], + }; + const out = await runHook(expansion({ command_name: "agentic-control-plane:acp-status", command_args: "" })); + const req = seen.find((s) => s.url.startsWith("/plugin/intents/status")); + assert.ok(req, "expected a GET to /plugin/intents/status"); + assert.equal(req.method, "GET"); + assert.equal(req.headers.authorization, "Bearer gsk_test_deadbeef"); + assert.ok(out, "expected output on stdout"); + assert.equal(out.decision, "block"); + assert.match(out.reason, /^\[ACP\] acme is in audit mode\./); + assert.ok(out.reason.includes("/acp-apply p1"), "reason should include the apply hint for proposal p1"); +}); + +test("(d) a non-ACP command never triggers a request or any output", async () => { + const out = await runHook(expansion({ command_name: "probe:enforce" })); + assert.equal(out, null); + assert.equal(seen.length, 0); +}); + +test("(e) a subagent (agent_id set) never triggers a request or any output", async () => { + const out = await runHook(expansion({ agent_id: "sub-1" })); + assert.equal(out, null); + assert.equal(seen.length, 0); +}); + +test("(f) no credentials in HOME → blocked with an /acp-connect hint and no request", async () => { + const bareHome = mkdtempSync(join(tmpdir(), "acp-terminal-intents-nocred-")); + try { + const out = await runHook(expansion(), {}, bareHome); + assert.ok(out, "expected output on stdout"); + assert.equal(out.decision, "block"); + assert.ok(out.reason.includes("/acp-connect"), "reason should point at /acp-connect"); + assert.equal(seen.length, 0, "no request should have been made without credentials"); + } finally { + rmSync(bareHome, { recursive: true, force: true }); + } +}); + +test("(g) 404 not-rolled-out → blocked mentioning the workspace isn't on it", async () => { + nextStatus = 404; + nextResponse = { ok: false, error: "not-rolled-out" }; + const out = await runHook(expansion()); + assert.ok(out, "expected output on stdout"); + assert.equal(out.decision, "block"); + assert.match(out.reason, /on for this workspace/); +}); + +test("(h) unreachable stub → blocked mentioning ACP was unreachable", async () => { + const closedServer = createServer(() => {}); + await new Promise((resolve) => closedServer.listen(0, "127.0.0.1", resolve)); + const deadPort = closedServer.address().port; + await new Promise((resolve) => closedServer.close(resolve)); + + const out = await runHook(expansion(), { ACP_API_BASE: `http://127.0.0.1:${deadPort}` }); + assert.ok(out, "expected output on stdout"); + assert.equal(out.decision, "block"); + assert.match(out.reason, /Couldn't reach ACP/); +});