From 135776e686e3307742ccd3812b68b472f3611f27 Mon Sep 17 00:00:00 2001 From: David Crowe Date: Wed, 16 Sep 2026 14:41:06 -0700 Subject: [PATCH 1/2] Tell plain-launch sessions their model calls are not priced or policy-checked (0.19.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production 2026-09-16: 0 of 69 external tenants have a single llm.proxy.* row, and every external harness tenant has zero costed runs. Everyone runs the plain binary; nobody runs -acp, and nothing tells them — the only surface that mentions cost is the launcher's own exit line, which only prints after you already used the launcher. The hook runs under the plain binary, so the hook says it. Port of the installer's bundled-engine change (agenticcontrolplane.com PR #178) into the govern.mjs that online installs fetch and the marketplace plugin runs — #178 alone reaches nobody in today's data. One systemMessage, once per session, on the first ALLOWED call: [ACP] Tool calls in this session are checked and logged. Model calls are not: plain `claude` sends them straight to the provider, so they are neither priced nor policy-checked (tool-result redaction, model routing). For the cost X-ray and model-call policy, launch with `claude-acp` (~/.acp/bin/claude-acp). Shown once per session. - Detection: every launcher is generated from one template whose first act is `export ACP_KEY`; the hook inherits that env. No ACP_KEY, and no provider base URL at the ACP proxy, means the session was not started by a launcher. Bias to silence on doubt. - Once per session: marker ~/.acp/session-notices/unpriced- (id sanitised to [A-Za-z0-9._-], capped at 120), created with an exclusive flag so parallel hook processes for the same session cannot both fire. No session_id in the payload means silence, no marker. Markers older than 7 days are pruned; the directory is capped at 200. - Every harness with a launcher is named by ACP_CLIENT (claude-acp, codex-acp for both "codex" and "codex-plugin", qwen-acp, opencode-acp, pi-acp, hermes-acp, prime-acp, grok-acp, dsh-acp). Cursor has no launcher and gets nothing. - Marketplace-only installs (`claude plugin install`) have this hook but no launcher on disk; for them the notice names the installer instead of a path that does not exist. One deviation from #178, in favour of saying something true. - Cloud mode only: the local path never reaches it. No-credential and managed-unenrolled paths never reach it either. - Purely additive: rides the allow path as one JSON object (merged with the tier notice and the wire warning when they apply — the updatedInput branch now reads the message once instead of twice), never a deny or an ask, and the whole thing is wrapped so any failure is silence. - Version 0.19.0 so the attestation hash is compared against a fresh version. Branched off main independently of the transcript-usage PR (0.18.0); the two conflict in bin/govern.mjs and this one rebases. Tests (test/unpriced-session-notice.test.mjs, real hook against a stub gateway): notice on a plain launch with nothing that could change the call; silent on the 2nd/3rd call of the same session; fires once for a new session; nothing without a session id; nothing with ACP_KEY in env or a provider base URL at the proxy; deny and ask byte-identical with no notice, then the notice rides the session's first allowed call; wire warning + notice as ONE object; codex names codex-acp, cursor gets nothing; six parallel processes yield exactly one notice; stale markers pruned and the directory capped; hostile session ids sanitised; launcher absent names the installer; --local silent. wire-warning.test.mjs now runs as a launched session so its assertions stay about the warning. --- .claude-plugin/marketplace.json | 2 +- README.md | 10 + bin/govern.mjs | 88 ++++++++- plugin.json | 2 +- test/unpriced-session-notice.test.mjs | 259 ++++++++++++++++++++++++++ test/wire-warning.test.mjs | 5 +- 6 files changed, 358 insertions(+), 8 deletions(-) create mode 100644 test/unpriced-session-notice.test.mjs diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index ebe0f7f..29c0e1e 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.17.0", + "version": "0.19.0", "author": { "name": "GatewayStack" }, diff --git a/README.md b/README.md index 4fc1a57..ecce82e 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,16 @@ The plugin registers a **PreToolUse hook** that fires before every tool call: The hook **fails open** on network errors in an interactive session — ACP outages never block Claude Code — but never silently, and never below the floors (see next section). +### Plain-launch notice (v0.19.0+) + +This hook sees every tool call whether you start `claude` or `claude-acp`. It never sees a model call: only `claude-acp` (`~/.acp/bin/claude-acp`, written by the installer) routes model traffic through the ACP proxy, which is where the cost X-ray and model-call policy (tool-result redaction, model routing) run. Under plain `claude`, the first allowed call of each session says so, once: + +``` +[ACP] Tool calls in this session are checked and logged. Model calls are not: plain `claude` sends them straight to the provider, so they are neither priced nor policy-checked (tool-result redaction, model routing). For the cost X-ray and model-call policy, launch with `claude-acp` (~/.acp/bin/claude-acp). Shown once per session. +``` + +Nothing else changes: the notice rides an allow, never a deny or an ask, and a session started by the launcher (which exports `ACP_KEY`) or with `ANTHROPIC_BASE_URL` already at the ACP proxy never sees it. Local mode never shows it. Once per session is enforced with a marker under `~/.acp/session-notices/` (pruned after 7 days, capped at 200). + ### Offline floor and local ledger (v0.16.0+) Whenever the gateway cannot see a call — no key on this machine yet, key present but the gateway unreachable, or `--local` mode — the hook still does two things: diff --git a/bin/govern.mjs b/bin/govern.mjs index b173e5c..48deb1b 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.17.0"; +const PLUGIN_VERSION = "0.19.0"; // Console base for user-facing deep links (session receipt, #606). const ACP_CONSOLE = @@ -242,6 +242,78 @@ function detectVendor(toolName, toolInput) { return null; } +// Unpriced-session notice (cloud mode only). This hook runs under the plain +// harness binary too, where it sees tool calls but never model calls: only +// the -acp launchers route model traffic through the proxy, which +// is where pricing and model-call policy (tool-result redaction, denied +// tools stripped from the request, model routing) run. Production +// 2026-09-16: 0 of 69 external tenants had ever launched via a launcher, +// and nothing told them — the only surface that mentions cost is the +// launcher's own exit line. So the hook says it, once per session, on an +// allowed call. Detection: every launcher is generated from ONE template +// whose first act is `export ACP_KEY` (install.sh acp_write_launcher); +// the hook inherits the launcher's environment, so no ACP_KEY means this +// session was not started by a launcher. Clients without a launcher +// (Cursor) get nothing. Purely additive: never blocks, never changes a +// decision, never throws — any failure here means silence, not a nag. +// Same logic as the installer's bundled engine (agenticcontrolplane.com +// install.sh) — keep the two in step. +const LAUNCHER_BY_CLIENT = { + "claude-code-plugin": ["claude", "claude-acp"], + codex: ["codex", "codex-acp"], + "codex-plugin": ["codex", "codex-acp"], + "qwen-code": ["qwen", "qwen-acp"], + opencode: ["opencode", "opencode-acp"], + pi: ["pi", "pi-acp"], + hermes: ["hermes", "hermes-acp"], + "prime-agent": ["prime-agent", "prime-acp"], + "grok-build": ["grok", "grok-acp"], + dsh: ["dsh", "dsh-acp"], +}; +const NOTICE_DIR = join(homedir(), ".acp", "session-notices"); +const NOTICE_TTL_MS = 7 * 24 * 60 * 60 * 1000; +const NOTICE_MAX_FILES = 200; +function launchedViaLauncher() { + if (process.env.ACP_KEY) return true; + // Hand-rolled proxy env (ANTHROPIC_BASE_URL / OPENAI_BASE_URL at the ACP + // proxy) is priced too; do not tell those users to switch. + return /agenticcontrolplane\.com/.test(`${process.env.ANTHROPIC_BASE_URL || ""} ${process.env.OPENAI_BASE_URL || ""}`); +} +// Returns the notice text the FIRST time it is called for this session, null +// every time after — including from a parallel hook process for the same +// session: the marker is created with an exclusive flag, so exactly one +// caller wins. No session id → no marker → no notice (bias to silence). +function claimUnpricedNotice() { + try { + if (launchedViaLauncher()) return null; + const pair = LAUNCHER_BY_CLIENT[ACP_CLIENT]; + if (!pair) return null; + const id = String(input?.session_id ?? "").replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 120); + if (!id) return null; + mkdirSync(NOTICE_DIR, { recursive: true }); + try { writeFileSync(join(NOTICE_DIR, `unpriced-${id}`), new Date().toISOString(), { flag: "wx" }); } + catch { return null; } + // Prune so the directory cannot grow without bound: drop markers past + // the TTL, and never keep more than NOTICE_MAX_FILES (newest win). + try { + const cutoff = Date.now() - NOTICE_TTL_MS; + const files = readdirSync(NOTICE_DIR).map((n) => { + const f = join(NOTICE_DIR, n); let m = 0; + try { m = statSync(f).mtimeMs; } catch { /* vanished */ } + return { f, m }; + }).sort((a, b) => b.m - a.m); + files.forEach((x, i) => { if (x.m < cutoff || i >= NOTICE_MAX_FILES) { try { unlinkSync(x.f); } catch { /* raced */ } } }); + } catch { /* pruning is opportunistic */ } + const [bin, launcher] = pair; + // A marketplace-only install (`claude plugin install`) has this hook but + // no launcher yet: point at the installer, not at a path that is absent. + const how = existsSync(join(homedir(), ".acp", "bin", launcher)) + ? `launch with \`${launcher}\` (~/.acp/bin/${launcher})` + : `install the \`${launcher}\` launcher: curl -sf https://agenticcontrolplane.com/install.sh | bash`; + return `[ACP] Tool calls in this session are checked and logged. Model calls are not: plain \`${bin}\` sends them straight to the provider, so they are neither priced nor policy-checked (tool-result redaction, model routing). For the cost X-ray and model-call policy, ${how}. Shown once per session.`; + } catch { return null; } +} + function readToken() { if (process.env.ACP_BEARER_TOKEN) return process.env.ACP_BEARER_TOKEN; // Both credential paths, in the same order as bin/mcp-auth-headers.sh — @@ -1072,11 +1144,16 @@ async function handlePreToolUse() { } // A hook run may write exactly ONE stdout JSON object — every allow-path - // exit funnels through here so the tier-divergence notice and the wire - // warning never produce a second one. Both may fire on one call; they - // share the single systemMessage. + // exit funnels through here so the tier-divergence notice, the wire + // warning and the unpriced-session notice never produce a second one. + // All may fire on one call; they share the single systemMessage. The + // unpriced claim is made at most once per process (it consumes the + // session's marker), so the result is memoized for any second read. + let unpricedNotice; function allowSystemMessage() { const parts = [tierNotice, wireWarning].filter((s) => typeof s === "string" && s.trim()); + if (unpricedNotice === undefined) unpricedNotice = claimUnpricedNotice(); + if (unpricedNotice) parts.push(unpricedNotice); return parts.length ? parts.join(" ") : null; } function exitAllow() { @@ -1156,13 +1233,14 @@ async function handlePreToolUse() { } envParts.push(`${vendor.envVar}=${tokenResult.token}`); const updated = `${envParts.join(" ")} ${original}`; + const msg = allowSystemMessage(); process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...input.tool_input, command: updated }, }, - ...(allowSystemMessage() ? { systemMessage: allowSystemMessage() } : {}), + ...(msg ? { systemMessage: msg } : {}), })); process.exit(0); } diff --git a/plugin.json b/plugin.json index c2f2aaa..9d74ceb 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "agentic-control-plane", - "version": "0.17.0", + "version": "0.19.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/unpriced-session-notice.test.mjs b/test/unpriced-session-notice.test.mjs new file mode 100644 index 0000000..b94eb76 --- /dev/null +++ b/test/unpriced-session-notice.test.mjs @@ -0,0 +1,259 @@ +// Plain-launch sessions are told their model calls are not priced or +// policy-checked — once per session, on an allowed call. +// +// Run with: node --test test/unpriced-session-notice.test.mjs +// +// This hook runs under plain `claude` too, where it sees tool calls but +// never model calls: only the -acp launchers route model traffic +// through the proxy, which is where pricing and model-call policy run. +// Production 2026-09-16: 0 of 69 external tenants had ever used a launcher, +// and nothing told them. Same logic as the installer's bundled engine +// (agenticcontrolplane.com PR #178); this is the copy that online installs +// and the marketplace plugin actually run. +// +// Spawn the real hook against a stub gateway, assert on the one stdout +// JSON object a hook run may write. Invariants: +// 1. Plain claude, first allowed call → the notice, as systemMessage, +// with nothing that could change the call (no hookSpecificOutput). +// 2. Same session, later calls → silent. New session → fires once more. +// 3. No session_id → silence and no marker. Bias to silence. +// 4. Launched via a launcher (ACP_KEY in env) → silence, no marker; a +// hand-rolled provider base URL at the ACP proxy counts as priced too. +// 5. A deny is byte-identical to today and carries no notice; the notice +// then rides the session's first ALLOWED call. +// 6. Wire warning + notice on one call → ONE JSON object carrying both. +// 7. Clients without a launcher (cursor) get nothing; codex names +// codex-acp. +// 8. Concurrency: N parallel hook processes for one session → exactly one +// notice (exclusive-create marker). +// 9. Markers older than 7 days are pruned; the directory is capped. +// 10. Launcher absent on disk (marketplace-only install) → the notice +// names the installer, not a path that does not exist. +// 11. --local mode never shows it. + +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, readdirSync, existsSync, utimesSync, copyFileSync } 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"); +const DECIDE = join(ROOT, "bin", "decide.mjs"); +const LOOPBACK = [127, 0, 0, 1].join("."); + +const NOTICE_HEAD = "[ACP] Tool calls in this session are checked and logged. Model calls are not: plain `claude` sends them straight to the provider, so they are neither priced nor policy-checked (tool-result redaction, model routing)."; +const WITH_LAUNCHER = `${NOTICE_HEAD} For the cost X-ray and model-call policy, launch with \`claude-acp\` (~/.acp/bin/claude-acp). Shown once per session.`; +const WITHOUT_LAUNCHER = `${NOTICE_HEAD} For the cost X-ray and model-call policy, install the \`claude-acp\` launcher: curl -sf https://agenticcontrolplane.com/install.sh | bash. Shown once per session.`; +const WARNING = "[ACP billing] grace period: 3 days left"; + +let HOME; +let NOTICES; +let server; +let baseUrl; +let nextResponse = { decision: "allow" }; + +before(async () => { + HOME = mkdtempSync(join(tmpdir(), "acp-unpriced-notice-test-")); + NOTICES = join(HOME, ".acp", "session-notices"); + mkdirSync(join(HOME, ".acp", "bin"), { recursive: true }); + // A workspace token routes govern.mjs down the cloud path (never LOCAL). + writeFileSync(join(HOME, ".acp", "credentials"), "gsk_test_deadbeef\n"); + // The launcher the installer writes; its presence picks the wording. + writeFileSync(join(HOME, ".acp", "bin", "claude-acp"), "#!/bin/sh\n"); + server = createServer((req, res) => { + let raw = ""; + req.on("data", (c) => { raw += c; }); + req.on("end", () => { + res.setHeader("content-type", "application/json"); + if (req.url !== "/govern/tool-use") { res.end(JSON.stringify({ action: "pass" })); return; } + res.end(JSON.stringify(raw.includes('"sandbox.deny"') ? { decision: "deny", reason: "sandbox deny" } : nextResponse)); + }); + }); + await new Promise((resolve) => server.listen(0, LOOPBACK, resolve)); + baseUrl = `http://${LOOPBACK}:${server.address().port}`; +}); + +after(() => { + server?.close(); + rmSync(HOME, { recursive: true, force: true }); +}); + +beforeEach(() => { + nextResponse = { decision: "allow" }; +}); + +// Explicit env (never spread process.env): a launcher's ACP_KEY, or the +// runner's own provider base URLs, would silence the notice from outside. +function preHook(sessionId, { env = {}, tool = "Bash", home = HOME } = {}) { + const input = { + hook_event_name: "PreToolUse", + tool_name: tool, + tool_input: { command: "ls -la" }, + cwd: "/tmp", + ...(sessionId === undefined ? {} : { session_id: sessionId }), + }; + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [GOVERN], { + env: { HOME: home, PATH: process.env.PATH, ACP_GOVERN_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", () => { + clearTimeout(killer); + try { + resolve(stdout.trim() ? JSON.parse(stdout) : null); + } catch (e) { + reject(new Error(`unparseable stdout: ${stdout}\nstderr: ${stderr}\n${e}`)); + } + }); + child.stdin.write(JSON.stringify(input)); + child.stdin.end(); + }); +} + +const marker = (id) => join(NOTICES, `unpriced-${id}`); + +test("plain claude: the first allowed call carries the notice, naming claude-acp, and nothing else", async () => { + const out = await preHook("sess-plain-1"); + assert.ok(out, "expected output on stdout"); + assert.equal(out.systemMessage, WITH_LAUNCHER); + assert.equal(out.hookSpecificOutput, undefined); + assert.ok(!/governance/i.test(out.systemMessage), "never says governance"); + assert.ok(existsSync(marker("sess-plain-1"))); +}); + +test("same session: the second and third calls are silent (once per session, not per tool call)", async () => { + assert.equal(await preHook("sess-plain-1"), null); + assert.equal(await preHook("sess-plain-1", { tool: "Read" }), null); +}); + +test("new session: the notice fires again, exactly once", async () => { + const out = await preHook("sess-plain-2"); + assert.equal(out?.systemMessage, WITH_LAUNCHER); + assert.equal(await preHook("sess-plain-2"), null); +}); + +test("no session id in the payload: silence, and no marker", async () => { + const before = readdirSync(NOTICES).length; + assert.equal(await preHook(undefined), null); + assert.equal(readdirSync(NOTICES).length, before); + assert.ok(!existsSync(marker(""))); +}); + +test("launched via claude-acp (ACP_KEY in env): no notice, no marker", async () => { + assert.equal(await preHook("sess-launched-1", { env: { ACP_KEY: "gsk_test_deadbeef" } }), null); + assert.ok(!existsSync(marker("sess-launched-1"))); +}); + +test("a hand-rolled provider base URL at the ACP proxy is priced too: silence", async () => { + assert.equal(await preHook("sess-byo-1", { env: { ANTHROPIC_BASE_URL: "https://api.agenticcontrolplane.com/anthropic" } }), null); + assert.equal(await preHook("sess-byo-2", { env: { OPENAI_BASE_URL: "https://api.agenticcontrolplane.com/v1" } }), null); + assert.ok(!existsSync(marker("sess-byo-1")) && !existsSync(marker("sess-byo-2"))); +}); + +test("a deny is unchanged and carries no notice; the notice then rides the session's first allowed call", async () => { + const denied = await preHook("sess-deny-1", { tool: "sandbox.deny" }); + assert.equal(denied.hookSpecificOutput?.permissionDecision, "deny"); + assert.match(denied.systemMessage, /^\[ACP\] Denied by policy: sandbox deny$/); + assert.ok(!denied.hookSpecificOutput.permissionDecisionReason.includes("claude-acp")); + assert.ok(!existsSync(marker("sess-deny-1")), "a deny must not consume the session's notice"); + const allowed = await preHook("sess-deny-1"); + assert.equal(allowed?.systemMessage, WITH_LAUNCHER); + assert.equal(await preHook("sess-deny-1"), null); +}); + +test("an ask is unchanged and carries no notice", async () => { + nextResponse = { decision: "ask", reason: "outward-facing" }; + const asked = await preHook("sess-ask-1"); + assert.equal(asked.hookSpecificOutput?.permissionDecision, "ask"); + assert.equal(asked.systemMessage, "[ACP] Approval required: outward-facing"); + assert.ok(!existsSync(marker("sess-ask-1"))); +}); + +test("wire warning + notice on one call → ONE JSON object carrying both", async () => { + nextResponse = { decision: "allow", warning: WARNING }; + const out = await preHook("sess-warn-1"); + assert.equal(out.systemMessage, `${WARNING} ${WITH_LAUNCHER}`); + assert.equal(out.hookSpecificOutput, undefined); + // And the warning alone on the next call, exactly as before. + const again = await preHook("sess-warn-1"); + assert.equal(again.systemMessage, WARNING); +}); + +test("codex names codex-acp (both client spellings); cursor has no launcher and gets nothing", async () => { + const codex = await preHook("sess-codex-1", { env: { ACP_CLIENT: "codex", ACP_HARNESS: "codex" } }); + assert.match(codex?.systemMessage ?? "", /plain `codex` .* install the `codex-acp` launcher/); + const plugin = await preHook("sess-codex-2", { env: { ACP_CLIENT: "codex-plugin", ACP_HARNESS: "codex" } }); + assert.match(plugin?.systemMessage ?? "", /`codex-acp`/); + assert.equal(await preHook("sess-cursor-1", { env: { ACP_CLIENT: "cursor" } }), null); + assert.ok(!existsSync(marker("sess-cursor-1"))); +}); + +test("concurrency: six parallel hook processes for one session yield exactly one notice", async () => { + const outs = await Promise.all(Array.from({ length: 6 }, () => preHook("sess-parallel-1"))); + const notices = outs.filter((o) => o && o.systemMessage === WITH_LAUNCHER); + assert.equal(notices.length, 1); + assert.equal(outs.filter((o) => o === null).length, 5); +}); + +test("markers older than 7 days are pruned; live ones are kept; the directory is capped", async () => { + const eightDaysAgo = (Date.now() - 8 * 24 * 3600 * 1000) / 1000; + writeFileSync(marker("stale"), "x"); + utimesSync(marker("stale"), eightDaysAgo, eightDaysAgo); + for (let i = 0; i < 150; i++) writeFileSync(marker(`bulk-${String(i).padStart(3, "0")}`), "x"); + await preHook("sess-prune-1"); + assert.ok(!existsSync(marker("stale")), "stale marker pruned"); + assert.ok(existsSync(marker("sess-prune-1")), "the marker just claimed survives"); + assert.ok(existsSync(marker("sess-plain-1")), "recent markers survive while under the cap"); + // Past the cap the newest win; the marker just claimed is among them. + for (let i = 150; i < 400; i++) writeFileSync(marker(`bulk-${String(i).padStart(3, "0")}`), "x"); + await preHook("sess-prune-2"); + assert.ok(existsSync(marker("sess-prune-2"))); + assert.ok(readdirSync(NOTICES).length <= 200, `capped at 200, got ${readdirSync(NOTICES).length}`); +}); + +test("session ids are sanitised for the marker name and capped in length", async () => { + const wild = "../../evil/../id with spaces/" + "x".repeat(300); + const out = await preHook(wild); + assert.equal(out?.systemMessage, WITH_LAUNCHER); + const names = readdirSync(NOTICES).filter((n) => n.startsWith("unpriced-.._.._evil")); + assert.equal(names.length, 1); + assert.ok(names[0].length <= "unpriced-".length + 120); + assert.ok(!existsSync(join(HOME, "evil"))); +}); + +test("launcher absent on disk (marketplace-only install): the notice names the installer, not a missing path", async () => { + const home = mkdtempSync(join(tmpdir(), "acp-unpriced-nolauncher-")); + mkdirSync(join(home, ".acp"), { recursive: true }); + writeFileSync(join(home, ".acp", "credentials"), "gsk_test_deadbeef\n"); + try { + const out = await preHook("sess-nolauncher-1", { home }); + assert.equal(out?.systemMessage, WITHOUT_LAUNCHER); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); + +test("--local mode: no notice, no marker directory, the call is still audited locally", async () => { + const home = mkdtempSync(join(tmpdir(), "acp-unpriced-local-")); + mkdirSync(join(home, ".acp"), { recursive: true }); + copyFileSync(DECIDE, join(home, ".acp", "decide.mjs")); + writeFileSync(join(home, ".acp", "policy.json"), JSON.stringify({ default: "allow", rules: {} })); + try { + const out = await preHook("sess-local-1", { home, env: { ACP_LOCAL: "1" } }); + assert.equal(out, null); + assert.ok(!existsSync(join(home, ".acp", "session-notices"))); + assert.ok(existsSync(join(home, ".acp", "audit.jsonl"))); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); diff --git a/test/wire-warning.test.mjs b/test/wire-warning.test.mjs index 9a81ebf..fb00f53 100644 --- a/test/wire-warning.test.mjs +++ b/test/wire-warning.test.mjs @@ -68,8 +68,11 @@ function preHook(sessionId, env = {}) { cwd: "/tmp", }; return new Promise((resolve, reject) => { + // ACP_KEY: a session started by claude-acp. Keeps the once-per-session + // unpriced-session notice (unpriced-session-notice.test.mjs) out of + // these assertions, which are about the wire warning alone. const child = spawn(process.execPath, [GOVERN], { - env: { HOME, PATH: process.env.PATH, ACP_GOVERN_BASE: baseUrl, ...env }, + env: { HOME, PATH: process.env.PATH, ACP_GOVERN_BASE: baseUrl, ACP_KEY: "gsk_test_deadbeef", ...env }, stdio: ["pipe", "pipe", "pipe"], }); let stdout = ""; From ab4a34522fa01da556b4971799469b2448a10654 Mon Sep 17 00:00:00 2001 From: David Crowe Date: Wed, 16 Sep 2026 14:58:23 -0700 Subject: [PATCH 2/2] Notice: say what a plain-launch session HAS after 0.18.0, then the policy half it lacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #36 made plain `claude` priced — from the transcript, at API-rate equivalents — so "neither priced nor policy-checked" became false the moment it merged. Rewritten to lead with what the session has and name only what still needs the launcher: [ACP] Tool calls in this session are checked and logged, and model-call cost is estimated from the session transcript (API-rate equivalent, not a metered charge). Model calls are not policy-checked: plain `claude` sends them straight to the provider, so tool-result redaction and model routing are off. For those, launch with `claude-acp` (~/.acp/bin/claude-acp). Shown once per session. The estimate-vs-metered distinction is stated up front so it does not arrive later as a support question. The wording is chosen by the same condition that makes the session priced — a readable transcript_path on the payload — so a harness that hands over no transcript still gets the plainer "neither priced nor policy-checked" form rather than a claim the hook cannot back. Every mechanical guarantee is unchanged: allow path only, once per session via the exclusive marker, silence with no session_id, never in --local, never with ACP_KEY or a proxy base URL. Tests: wording assertions updated; new case for a payload with no (or a missing) transcript; transcript-model-usage.test.mjs runs as a launched session so its PreToolUse assertion stays about the body. 220/220. --- README.md | 6 ++--- bin/govern.mjs | 9 ++++++++ test/transcript-model-usage.test.mjs | 5 +++- test/unpriced-session-notice.test.mjs | 33 ++++++++++++++++++++++----- 4 files changed, 43 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index b813c4a..ce4da46 100644 --- a/README.md +++ b/README.md @@ -42,13 +42,13 @@ Plain `claude` sends model calls straight to Anthropic, so the hook never sees t ### Plain-launch notice (v0.19.0+) -This hook sees every tool call whether you start `claude` or `claude-acp`. It never sees a model call: only `claude-acp` (`~/.acp/bin/claude-acp`, written by the installer) routes model traffic through the ACP proxy, which is where the cost X-ray and model-call policy (tool-result redaction, model routing) run. Under plain `claude`, the first allowed call of each session says so, once: +This hook sees every tool call whether you start `claude` or `claude-acp`, and since 0.18.0 it prices model calls from the transcript either way. What it cannot do under plain `claude` is policy-check the model calls themselves: only `claude-acp` (`~/.acp/bin/claude-acp`, written by the installer) routes them through the ACP proxy, where tool-result redaction and model routing run and where cost is metered rather than estimated. Under plain `claude`, the first allowed call of each session says so, once: ``` -[ACP] Tool calls in this session are checked and logged. Model calls are not: plain `claude` sends them straight to the provider, so they are neither priced nor policy-checked (tool-result redaction, model routing). For the cost X-ray and model-call policy, launch with `claude-acp` (~/.acp/bin/claude-acp). Shown once per session. +[ACP] Tool calls in this session are checked and logged, and model-call cost is estimated from the session transcript (API-rate equivalent, not a metered charge). Model calls are not policy-checked: plain `claude` sends them straight to the provider, so tool-result redaction and model routing are off. For those, launch with `claude-acp` (~/.acp/bin/claude-acp). Shown once per session. ``` -Nothing else changes: the notice rides an allow, never a deny or an ask, and a session started by the launcher (which exports `ACP_KEY`) or with `ANTHROPIC_BASE_URL` already at the ACP proxy never sees it. Local mode never shows it. Once per session is enforced with a marker under `~/.acp/session-notices/` (pruned after 7 days, capped at 200). +A harness whose hook payload carries no transcript (so nothing is priced) gets the plainer form: "…neither priced nor policy-checked… For the cost X-ray and model-call policy, launch with…". Nothing else changes: the notice rides an allow, never a deny or an ask, and a session started by the launcher (which exports `ACP_KEY`) or with `ANTHROPIC_BASE_URL` already at the ACP proxy never sees it. Local mode never shows it. Once per session is enforced with a marker under `~/.acp/session-notices/` (pruned after 7 days, capped at 200). ### Offline floor and local ledger (v0.16.0+) diff --git a/bin/govern.mjs b/bin/govern.mjs index baa5087..fe51145 100644 --- a/bin/govern.mjs +++ b/bin/govern.mjs @@ -310,6 +310,15 @@ function claimUnpricedNotice() { const how = existsSync(join(homedir(), ".acp", "bin", launcher)) ? `launch with \`${launcher}\` (~/.acp/bin/${launcher})` : `install the \`${launcher}\` launcher: curl -sf https://agenticcontrolplane.com/install.sh | bash`; + // Since 0.18.0 a session whose payload names a readable transcript IS + // priced — from the transcript, at API-rate equivalents (see + // collectTranscriptUsage). Say what they have, then what still needs + // the launcher: the policy half. A harness whose payload carries no + // transcript gets nothing priced, and the notice says so. + const costed = typeof input?.transcript_path === "string" && input.transcript_path && existsSync(input.transcript_path); + if (costed) { + return `[ACP] Tool calls in this session are checked and logged, and model-call cost is estimated from the session transcript (API-rate equivalent, not a metered charge). Model calls are not policy-checked: plain \`${bin}\` sends them straight to the provider, so tool-result redaction and model routing are off. For those, ${how}. Shown once per session.`; + } return `[ACP] Tool calls in this session are checked and logged. Model calls are not: plain \`${bin}\` sends them straight to the provider, so they are neither priced nor policy-checked (tool-result redaction, model routing). For the cost X-ray and model-call policy, ${how}. Shown once per session.`; } catch { return null; } } diff --git a/test/transcript-model-usage.test.mjs b/test/transcript-model-usage.test.mjs index ff4d8d6..9201609 100644 --- a/test/transcript-model-usage.test.mjs +++ b/test/transcript-model-usage.test.mjs @@ -76,8 +76,11 @@ beforeEach(() => { // flip the tier to background. function runHook(input) { return new Promise((resolve, reject) => { + // ACP_KEY: a session started by claude-acp. Keeps the once-per-session + // plain-launch notice (unpriced-session-notice.test.mjs) out of these + // assertions, which are about the PostToolUse body alone. const child = spawn(process.execPath, [GOVERN], { - env: { HOME, PATH: process.env.PATH, ACP_GOVERN_BASE: baseUrl, CLAUDE_CODE_ENTRYPOINT: "cli" }, + env: { HOME, PATH: process.env.PATH, ACP_GOVERN_BASE: baseUrl, CLAUDE_CODE_ENTRYPOINT: "cli", ACP_KEY: "gsk_test_deadbeef" }, stdio: ["pipe", "pipe", "pipe"], }); let stdout = ""; diff --git a/test/unpriced-session-notice.test.mjs b/test/unpriced-session-notice.test.mjs index b94eb76..1b0bfc1 100644 --- a/test/unpriced-session-notice.test.mjs +++ b/test/unpriced-session-notice.test.mjs @@ -45,13 +45,20 @@ const GOVERN = join(ROOT, "bin", "govern.mjs"); const DECIDE = join(ROOT, "bin", "decide.mjs"); const LOOPBACK = [127, 0, 0, 1].join("."); -const NOTICE_HEAD = "[ACP] Tool calls in this session are checked and logged. Model calls are not: plain `claude` sends them straight to the provider, so they are neither priced nor policy-checked (tool-result redaction, model routing)."; -const WITH_LAUNCHER = `${NOTICE_HEAD} For the cost X-ray and model-call policy, launch with \`claude-acp\` (~/.acp/bin/claude-acp). Shown once per session.`; -const WITHOUT_LAUNCHER = `${NOTICE_HEAD} For the cost X-ray and model-call policy, install the \`claude-acp\` launcher: curl -sf https://agenticcontrolplane.com/install.sh | bash. Shown once per session.`; +// Since 0.18.0 a session with a readable transcript is priced from it +// (transcript-model-usage.test.mjs), so the notice leads with what the +// session HAS and names only the policy half as missing. +const NOTICE_HEAD = "[ACP] Tool calls in this session are checked and logged, and model-call cost is estimated from the session transcript (API-rate equivalent, not a metered charge). Model calls are not policy-checked: plain `claude` sends them straight to the provider, so tool-result redaction and model routing are off."; +const WITH_LAUNCHER = `${NOTICE_HEAD} For those, launch with \`claude-acp\` (~/.acp/bin/claude-acp). Shown once per session.`; +const WITHOUT_LAUNCHER = `${NOTICE_HEAD} For those, install the \`claude-acp\` launcher: curl -sf https://agenticcontrolplane.com/install.sh | bash. Shown once per session.`; +// A payload with no transcript (a harness that does not hand one over) +// prices nothing, and the notice must not claim otherwise. +const UNCOSTED = "[ACP] Tool calls in this session are checked and logged. Model calls are not: plain `claude` sends them straight to the provider, so they are neither priced nor policy-checked (tool-result redaction, model routing). For the cost X-ray and model-call policy, launch with `claude-acp` (~/.acp/bin/claude-acp). Shown once per session."; const WARNING = "[ACP billing] grace period: 3 days left"; let HOME; let NOTICES; +let TRANSCRIPT; let server; let baseUrl; let nextResponse = { decision: "allow" }; @@ -64,6 +71,10 @@ before(async () => { writeFileSync(join(HOME, ".acp", "credentials"), "gsk_test_deadbeef\n"); // The launcher the installer writes; its presence picks the wording. writeFileSync(join(HOME, ".acp", "bin", "claude-acp"), "#!/bin/sh\n"); + // A session transcript as Claude Code keeps one; its presence on the + // payload is what makes the session priced. + TRANSCRIPT = join(HOME, "transcript.jsonl"); + writeFileSync(TRANSCRIPT, JSON.stringify({ type: "user", message: { role: "user", content: "hi" } }) + "\n"); server = createServer((req, res) => { let raw = ""; req.on("data", (c) => { raw += c; }); @@ -88,13 +99,16 @@ beforeEach(() => { // Explicit env (never spread process.env): a launcher's ACP_KEY, or the // runner's own provider base URLs, would silence the notice from outside. -function preHook(sessionId, { env = {}, tool = "Bash", home = HOME } = {}) { +function preHook(sessionId, { env = {}, tool = "Bash", home = HOME, transcript = TRANSCRIPT } = {}) { const input = { hook_event_name: "PreToolUse", tool_name: tool, tool_input: { command: "ls -la" }, cwd: "/tmp", ...(sessionId === undefined ? {} : { session_id: sessionId }), + // Claude Code names the session transcript on every payload; that is + // what makes the session priced (0.18.0). Pass null to leave it off. + ...(transcript ? { transcript_path: transcript } : {}), }; return new Promise((resolve, reject) => { const child = spawn(process.execPath, [GOVERN], { @@ -189,9 +203,16 @@ test("wire warning + notice on one call → ONE JSON object carrying both", asyn assert.equal(again.systemMessage, WARNING); }); +test("no transcript on the payload: nothing is priced, and the notice says so (never claims an estimate it cannot make)", async () => { + const out = await preHook("sess-uncosted-1", { transcript: null }); + assert.equal(out?.systemMessage, UNCOSTED); + const missing = await preHook("sess-uncosted-2", { transcript: join(HOME, "no-such-transcript.jsonl") }); + assert.equal(missing?.systemMessage, UNCOSTED); +}); + test("codex names codex-acp (both client spellings); cursor has no launcher and gets nothing", async () => { - const codex = await preHook("sess-codex-1", { env: { ACP_CLIENT: "codex", ACP_HARNESS: "codex" } }); - assert.match(codex?.systemMessage ?? "", /plain `codex` .* install the `codex-acp` launcher/); + const codex = await preHook("sess-codex-1", { env: { ACP_CLIENT: "codex", ACP_HARNESS: "codex" }, transcript: null }); + assert.match(codex?.systemMessage ?? "", /plain `codex` .*neither priced nor policy-checked.* install the `codex-acp` launcher/); const plugin = await preHook("sess-codex-2", { env: { ACP_CLIENT: "codex-plugin", ACP_HARNESS: "codex" } }); assert.match(plugin?.systemMessage ?? "", /`codex-acp`/); assert.equal(await preHook("sess-cursor-1", { env: { ACP_CLIENT: "cursor" } }), null);