Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <tool>`, `/acp-ask <tool>`, `/acp-deny <tool>`, or `/acp-apply <proposal>` 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 <tool>`, `/acp-ask <tool>`, `/acp-deny <tool>`, `/acp-apply <proposal>` 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)

Expand Down
84 changes: 79 additions & 5 deletions bin/govern.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 <tier label> policy for <tool>" and
// "step_up by <tier label> policy for <tool>"; 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 {
Expand Down Expand Up @@ -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 }));
Expand All @@ -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.`);
}
Expand All @@ -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 <tool> 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`, {
Expand Down
2 changes: 1 addition & 1 deletion commands/acp-apply.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: acp-apply
description: "Confirm a rule an agent proposed: /acp-apply <proposal id from /acp-status>. You confirm with one tap."
description: "Confirm a proposed rule (/acp-apply <proposal id from /acp-status>) or switch on a cost lever (/acp-apply context-guard). You confirm with one tap."
user-invocable: true
---

Expand Down
9 changes: 9 additions & 0 deletions commands/acp-why.md
Original file line number Diff line number Diff line change
@@ -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."
2 changes: 1 addition & 1 deletion plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
86 changes: 86 additions & 0 deletions test/terminal-intents.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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");
});
Loading