diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 5658f72..3014c37 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.26.0", + "version": "0.27.0", "author": { "name": "GatewayStack" }, diff --git a/README.md b/README.md index 43f4af6..df0df90 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ Not every step_up has to end at a console link. When a workspace rule says ask a ### 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. +Type `/acp-status`, `/acp-enforce`, `/acp-audit`, `/acp-allow `, `/acp-ask `, `/acp-deny `, `/acp-apply ` or `/acp-apply context-guard` in the terminal to check or change how your workspace is governed without leaving the session. `/acp-status` also lists suggestions from your own usage and every approval waiting on someone, each with its link. `/acp-why` explains the last call ACP held in this session, locally, with no network call. 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) diff --git a/bin/govern.mjs b/bin/govern.mjs index d53f389..e01008b 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.26.0"; +const PLUGIN_VERSION = "0.27.0"; // Console base for user-facing deep links (session receipt, #606). const ACP_CONSOLE = @@ -1147,8 +1147,14 @@ async function handlePreToolUse() { reformulate: "This refused ONE operation, not your task — continue with everything else. If you believe you should have this capability, call acp_propose_rule (tool, tier, rationale) to draft a rule for a human to approve; it is never applied by you.", }; + // A human decides this hold outside the terminal, and the gateway told the agent how + // to wait for the answer (gatewaystack-connect#1326). The generic + // "hand it over" steer would contradict that, so this one wins. + const WAIT_STEER = + "This call is decided in the ACP console or from the approval email. Do what the reason says: call acp_wait_approval with that approval_id, retry the identical call only after it reports approved, and stop if it reports denied or timeout. Keep working on anything that does not need this call while you wait."; function denyByPolicy(reason, kind) { - const steer = STEER_BY_KIND[kind] + rememberHeld(reason, kind); + const steer = /\bacp_wait_approval\b/.test(reason) ? WAIT_STEER : STEER_BY_KIND[kind] || (UNPROPOSABLE.test(reason) ? STEER_BY_KIND.terminal : STEER_BY_KIND.reformulate); process.stdout.write(JSON.stringify({ hookSpecificOutput: { @@ -1263,6 +1269,7 @@ async function handlePreToolUse() { failPostureOnOutage(detail || "network error"); } function ask(reason) { + rememberHeld(reason, "ask"); // Codex has no ask semantic on the wire (see HARNESS note): emit deny // with the approval deep link so the human approves out-of-band and // the re-run passes under the grant. @@ -1941,6 +1948,56 @@ function bumpReceiptStats(sessionId, delta) { } catch { /* bookkeeping must never touch the call path */ } } +// Last held call, for /acp-why (gatewaystack-connect#1327). Kept beside the +// receipt counters but in its own file: the Stop hook clears the counters +// every turn, and "why was that blocked?" is usually asked a turn later. +// Local only, never sent anywhere; pruned with the rest after 7 days. +function heldPath(sessionId) { + const safe = String(sessionId).replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 128); + return join(SESSION_STATS_DIR, `${safe}.held.json`); +} + +function rememberHeld(reason, kind) { + const sid = input.session_id; + if (!sid || sid === "unknown") return; + try { + mkdirSync(SESSION_STATS_DIR, { recursive: true }); + writeFileSync(heldPath(sid), JSON.stringify({ + tool: typeof input.tool_name === "string" ? input.tool_name.slice(0, 80) : "", + reason: String(reason ?? "").slice(0, 2000), + kind: typeof kind === "string" ? kind : null, + at: new Date().toISOString(), + })); + } catch { /* bookkeeping must never touch the call path */ } +} + +function readHeld(sessionId) { + try { return JSON.parse(readFileSync(heldPath(sessionId), "utf8")); } catch { return null; } +} + +/** The lines /acp-why prints: what was held, why, and what changes it. */ +function explainHeld(held) { + if (!held || !held.reason) { + return "[ACP] Nothing has been held in this session."; + } + const clean = (v) => String(v ?? "").replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, " "); + const lines = [`[ACP] Last held call${held.tool ? ` (${clean(held.tool)})` : ""} at ${clean(held.at)}:`, ` ${clean(held.reason)}`]; + // Gateway wording: "denied by policy for " and + // "step_up by policy for "; the label can hold spaces + // and parentheses ("api tier (scoped API key)"). + const rule = /\b(?:denied|step_up|defer) by [^\n]{1,80}? policy for ([A-Za-z][A-Za-z0-9_.:-]{0,79})/.exec(held.reason); + const review = /Review: (https:\/\/\S+)/.exec(held.reason); + if (review) lines.push(`Approval: ${review[1]}`); + if (rule) { + lines.push(`A workspace rule on ${rule[1]} held it. /acp-allow ${rule[1]} stops holding it; /acp-status lists every rule.`); + } else if (/^\s*(hardline floor|governance surface|uninstall floor|destructive floor)\b/i.test(held.reason) || held.kind === "terminal") { + lines.push("This is a safety floor, not a workspace rule; no command loosens it."); + } else { + lines.push("/acp-status shows the workspace mode and rules."); + } + return lines.join("\n"); +} + function clearReceiptStats(sessionId) { try { unlinkSync(receiptStatsPath(sessionId)); } catch { /* absent is fine */ } try { @@ -1998,7 +2055,7 @@ function handleStop() { // 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)$/; +const INTENT_COMMAND_RE = /(?:^|:)acp-(enforce|audit|allow|ask|deny|apply|status|why)$/; function blockExpansion(reason) { process.stdout.write(JSON.stringify({ decision: "block", reason })); @@ -2014,6 +2071,11 @@ async function handleUserPromptExpansion() { const kind = m[1]; const target = typeof input.command_args === "string" ? input.command_args.trim().split(/\s+/)[0] || "" : ""; + // /acp-why: local, read-only, no network and no key needed. + if (kind === "why") { + blockExpansion(explainHeld(readHeld(input.session_id ?? "unknown"))); + } + if (!token) { blockExpansion(`[ACP] Not connected — /acp-${kind} needs a workspace key. Run /acp-connect first.`); } @@ -2033,13 +2095,25 @@ async function handleUserPromptExpansion() { 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 link = (v) => (typeof v === "string" && /^https:\/\/\S+$/.test(v) ? v : ""); const asks = Array.isArray(s.pendingApprovals) && s.pendingApprovals.length - ? `\nPending approvals: ${s.pendingApprovals.length} (${ACP_CONSOLE}/approvals)` + ? `\nPending approvals (${s.pendingApprovals.length}):\n` + s.pendingApprovals.slice(0, 10).map((a) => + ` ${String(a?.tool ?? "a tool call").replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, " ").slice(0, 80)} ${link(a?.review) || `${ACP_CONSOLE}/approvals`}`).join("\n") + : ""; + // Advice feed (gatewaystack-connect#1325): the top suggestions from + // your own usage, each with the command that acts on it. Absent when + // the workspace isn't rolled out or the feed wasn't ready in time. + const clean = (v) => String(v ?? "").replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, " ").slice(0, 300); + const advice = Array.isArray(s.advice) && s.advice.length + ? "\nSuggestions from your usage:\n" + s.advice.slice(0, 3).map((a) => { + const cmd = a && typeof a.command === "string" && a.command ? `\n → ${clean(a.command)}` : ""; + return ` • ${clean(a?.line)}${cmd}`; + }).join("\n") : ""; 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}`); + blockExpansion(`[ACP] ${s.workspace} is in ${s.mode} mode.\nInteractive rules:\n${rules}${proposals}${asks}${advice}\n${next}`); } const res = await fetch(`${ACP_API}/plugin/intents`, { diff --git a/commands/acp-apply.md b/commands/acp-apply.md index 54003fd..5890d1c 100644 --- a/commands/acp-apply.md +++ b/commands/acp-apply.md @@ -1,6 +1,6 @@ --- name: acp-apply -description: "Confirm a rule an agent proposed: /acp-apply . You confirm with one tap." +description: "Confirm a proposed rule (/acp-apply ) or switch on a cost lever (/acp-apply context-guard). You confirm with one tap." user-invocable: true --- diff --git a/commands/acp-why.md b/commands/acp-why.md new file mode 100644 index 0000000..c5a7f32 --- /dev/null +++ b/commands/acp-why.md @@ -0,0 +1,9 @@ +--- +name: acp-why +description: Explain the last tool call ACP held in this session, and the command that would change it. +user-invocable: true +--- + +This command is handled by the ACP hook before it reaches you: the hook prints the explanation 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 this session in the ACP console." diff --git a/plugin.json b/plugin.json index 8fc853b..6e49417 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "agentic-control-plane", - "version": "0.26.0", + "version": "0.27.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/test/terminal-intents.test.mjs b/test/terminal-intents.test.mjs index 8e44710..e6dd99e 100644 --- a/test/terminal-intents.test.mjs +++ b/test/terminal-intents.test.mjs @@ -196,6 +196,30 @@ test("(c) /acp-status reads GET /plugin/intents/status and lists the mode + a pr assert.ok(out.reason.includes("/acp-apply p1"), "reason should include the apply hint for proposal p1"); }); +test("(c2) /acp-status prints the advice feed with each item's command, and omits the section when absent", async () => { + nextStatusResponse = { + ok: true, + workspace: "acme", + mode: "enforce", + rules: [], + proposals: [], + pendingApprovals: [], + advice: [ + { id: "policy:auto_allow:Bash.git-push", line: "You approved Bash.git-push 6 times this month and never said no.", command: "/acp-allow Bash.git-push", commandKind: "acp", impactUsd: null }, + { id: "cost:cache_economics:x", line: "x pays full price for most of its input.\u001b[31m", command: null, commandKind: null, impactUsd: 90 }, + ], + }; + const out = await runHook(expansion({ command_name: "agentic-control-plane:acp-status", command_args: "" })); + assert.ok(out.reason.includes("Suggestions from your usage:")); + assert.ok(out.reason.includes("→ /acp-allow Bash.git-push")); + assert.ok(out.reason.includes("pays full price")); + assert.ok(!out.reason.includes("\u001b"), "control characters from the gateway are stripped"); + + nextStatusResponse = { ok: true, workspace: "acme", mode: "audit", rules: [], proposals: [], pendingApprovals: [] }; + const plain = await runHook(expansion({ command_name: "agentic-control-plane:acp-status", command_args: "" })); + assert.ok(!plain.reason.includes("Suggestions")); +}); + 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); @@ -241,3 +265,65 @@ test("(h) unreachable stub → blocked mentioning ACP was unreachable", async () assert.equal(out.decision, "block"); assert.match(out.reason, /Couldn't reach ACP/); }); + +// ── #1326 / #1327: held calls, /acp-why, approvals in /acp-status ────── + +const HELD_REASON = + 'approval-requested:0b3c7a52-1111-4000-8000-000000000000 — approvers notified; action blocked until approved. ' + + "Review: https://cloud.agenticcontrolplane.com/approvals?id=0b3c7a52-1111-4000-8000-000000000000 " + + 'A workspace admin decides this one. To keep going, call the acp_wait_approval tool with approval_id "0b3c7a52-1111-4000-8000-000000000000".'; + +const preToolUse = (overrides = {}) => ({ + hook_event_name: "PreToolUse", + tool_name: "Read", + tool_input: { file_path: "/tmp/acp-why-probe.txt" }, + session_id: "sess-why-1", + permission_mode: "default", + ...overrides, +}); + +test("(g) an admin-decided hold tells the agent to wait, not to hand the step over", async () => { + nextResponse = { decision: "deny", reason: HELD_REASON, kind: "delegate" }; + const out = await runHook(preToolUse()); + const reason = out?.hookSpecificOutput?.permissionDecisionReason ?? ""; + assert.equal(out?.hookSpecificOutput?.permissionDecision, "deny"); + assert.match(reason, /call acp_wait_approval with that approval_id/); + assert.ok(!reason.includes("Hand that one step over"), "the generic delegate steer must not contradict the wait"); +}); + +test("(h) /acp-why explains the last held call from local state, with no request", async () => { + nextResponse = { decision: "deny", reason: "denied by interactive tier policy for Bash.rm", kind: "reformulate" }; + await runHook(preToolUse({ session_id: "sess-why-2", tool_name: "Bash", tool_input: { command: "ls" } })); + seen = []; + const out = await runHook(expansion({ command_name: "agentic-control-plane:acp-why", session_id: "sess-why-2" })); + assert.equal(seen.length, 0, "/acp-why makes no network call"); + assert.equal(out.decision, "block"); + assert.match(out.reason, /Last held call \(Bash\)/); + assert.match(out.reason, /\/acp-allow Bash\.rm stops holding it/); +}); + +test("(i) /acp-why with an approval hold prints the approval link", async () => { + nextResponse = { decision: "deny", reason: HELD_REASON, kind: "delegate" }; + await runHook(preToolUse({ session_id: "sess-why-3" })); + const out = await runHook(expansion({ command_name: "agentic-control-plane:acp-why", session_id: "sess-why-3" })); + assert.match(out.reason, /Approval: https:\/\/cloud\.agenticcontrolplane\.com\/approvals\?id=0b3c7a52/); +}); + +test("(j) /acp-why with nothing held says so", async () => { + const out = await runHook(expansion({ command_name: "agentic-control-plane:acp-why", session_id: "sess-never-held" })); + assert.match(out.reason, /Nothing has been held in this session/); +}); + +test("(k) /acp-status lists each pending approval with its own link", async () => { + nextStatusResponse = { + ok: true, workspace: "acme", mode: "enforce", rules: [], proposals: [], + pendingApprovals: [ + { id: "a1", tool: "Bash.git-push", channel: "console", review: "https://cloud.agenticcontrolplane.com/approvals?id=a1" }, + { id: "a2", tool: "Bash.rm", channel: "console", review: "javascript:alert(1)" }, + ], + }; + const out = await runHook(expansion({ command_name: "agentic-control-plane:acp-status", command_args: "" })); + assert.match(out.reason, /Pending approvals \(2\):/); + assert.match(out.reason, /Bash\.git-push {2}https:\/\/cloud\.agenticcontrolplane\.com\/approvals\?id=a1/); + assert.ok(!out.reason.includes("javascript:"), "only https links are printed"); +});