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.19.0",
"version": "0.20.0",
"author": {
"name": "GatewayStack"
},
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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.

### 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`.
Expand Down
114 changes: 110 additions & 4 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.19.0";
const PLUGIN_VERSION = "0.20.0";

// Console base for user-facing deep links (session receipt, #606).
const ACP_CONSOLE =
Expand Down Expand Up @@ -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);
Expand All @@ -632,7 +638,7 @@ if (!token && !LOCAL) {
process.exit(0);
}

if (LOCAL) {
if (LOCAL && EARLY_HOOK_EVENT !== "UserPromptExpansion") {
await runLocal(input);
process.exit(0);
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 <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}`);
}

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();
9 changes: 9 additions & 0 deletions commands/acp-allow.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions commands/acp-apply.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
name: acp-apply
description: "Confirm a rule an agent proposed: /acp-apply <proposal id from /acp-status>. 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.
9 changes: 9 additions & 0 deletions commands/acp-ask.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions commands/acp-audit.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions commands/acp-deny.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions commands/acp-enforce.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions commands/acp-status.md
Original file line number Diff line number Diff line change
@@ -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."
12 changes: 12 additions & 0 deletions hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,18 @@
}
]
}
],
"UserPromptExpansion": [
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "node \"$CLAUDE_PLUGIN_ROOT/bin/govern.mjs\"",
"timeout": 8
}
]
}
]
}
}
6 changes: 5 additions & 1 deletion lib/receipt.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
}
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.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",
Expand Down
2 changes: 2 additions & 0 deletions skills/acp/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <tool>`, `/acp-ask <tool>`, `/acp-deny <tool>`, or `/acp-apply <proposal>` 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:
Expand Down
2 changes: 1 addition & 1 deletion test/receipt.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down
Loading
Loading