From bd90a023093ca68cf194adc9683197b26e5bb8dc Mon Sep 17 00:00:00 2001 From: David Crowe Date: Tue, 1 Sep 2026 13:56:55 -0700 Subject: [PATCH] Carry fail-open PreToolUse lapses to the gateway on the next PostToolUse (0.14.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An interactive-tier PreToolUse that cannot reach the gateway fails open (never-brick) and left its only record in ~/.acp/lapse.log — the gateway never learned the call ran ungoverned. Now the lapse is also queued per session under ~/.acp/lapse-pending/.json ({ at, tool, detail }, capped at 20) and the session's next PostToolUse sends it as `pre_lapse` in the /govern/tool-output body; the marker is deleted only after a 2xx, so a failed report is retried on the following call. Every file operation is best-effort and never touches the call path. plugin.json, marketplace.json and PLUGIN_VERSION bumped together to 0.14.0 (shipping govern.mjs without a manifest bump broke attestation, gatewaystack-connect#888). Refs davidcrowe/gatewaystack-connect#902 --- .claude-plugin/marketplace.json | 2 +- bin/govern.mjs | 64 +++++++++- plugin.json | 2 +- test/pre-lapse-report.test.mjs | 199 ++++++++++++++++++++++++++++++++ 4 files changed, 262 insertions(+), 5 deletions(-) create mode 100644 test/pre-lapse-report.test.mjs diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 6b3dc92..adfe3b8 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.13.0", + "version": "0.14.0", "author": { "name": "GatewayStack" }, diff --git a/bin/govern.mjs b/bin/govern.mjs index 5fca9e8..865be32 100644 --- a/bin/govern.mjs +++ b/bin/govern.mjs @@ -33,7 +33,10 @@ // with a loud UNGOVERNED warning + ~/.acp/lapse.log entry (never-brick: // an ACP outage must not freeze every governed session); subagent / // background tiers fail CLOSED (nobody is watching — the block IS the -// safety net). Note: before v0.6.5 this comment claimed fail-open while +// safety net). Since v0.14.0 (gatewaystack-connect#902) an interactive +// lapse is also queued per session under ~/.acp/lapse-pending/ and carried +// on the session's next PostToolUse as `pre_lapse`, so the gateway gets a +// row for the call that ran ungoverned instead of only the local log. Note: before v0.6.5 this comment claimed fail-open while // the code failed closed — the posture is now real, decided, and tested. // Fails OPEN on /api/v1/scoped-tokens errors by default — server-side // per-tenant policy can flip this to fail-closed. The plugin currently @@ -63,7 +66,7 @@ const ACP_GOVERN = process.env.ACP_API_BASE || "https://govern.agenticcontrolplane.com"; -const PLUGIN_VERSION = "0.13.0"; +const PLUGIN_VERSION = "0.14.0"; // Console base for user-facing deep links (session receipt, #606). const ACP_CONSOLE = @@ -73,6 +76,53 @@ const ACP_CONSOLE = // PostToolUse, read + cleared by handleStop. const SESSION_STATS_DIR = join(homedir(), ".acp", "session-stats"); +// Per-session pending lapse markers (gatewaystack-connect#902). Written on +// an interactive fail-open at PreToolUse, carried on the next PostToolUse +// as `pre_lapse`, and deleted once the gateway has acknowledged (2xx). +// Bounded to LAPSE_PENDING_CAP entries per session — lapse.log keeps the +// unbounded record. Every operation is best-effort: a marker failure must +// never touch the call path in either direction. +const LAPSE_PENDING_DIR = join(homedir(), ".acp", "lapse-pending"); +const LAPSE_PENDING_CAP = 20; + +function safeSessionFile(sessionId) { + return String(sessionId).replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 128); +} + +function lapsePendingPath(sessionId) { + return join(LAPSE_PENDING_DIR, `${safeSessionFile(sessionId)}.json`); +} + +function readPendingLapse(sessionId) { + if (!sessionId || sessionId === "unknown") return null; + try { + const raw = JSON.parse(readFileSync(lapsePendingPath(sessionId), "utf8")); + if (!Array.isArray(raw) || raw.length === 0) return null; + return raw.slice(-LAPSE_PENDING_CAP); + } catch { + return null; + } +} + +function recordPendingLapse(sessionId, toolName, detail) { + if (!sessionId || sessionId === "unknown") return; + try { + mkdirSync(LAPSE_PENDING_DIR, { recursive: true }); + const list = readPendingLapse(sessionId) ?? []; + list.push({ + at: new Date().toISOString(), + tool: String(toolName ?? "unknown").slice(0, 120), + detail: String(detail ?? "unknown").slice(0, 200), + }); + writeFileSync(lapsePendingPath(sessionId), JSON.stringify(list.slice(-LAPSE_PENDING_CAP))); + } catch { /* best-effort — lapse.log is the durable record */ } +} + +function clearPendingLapse(sessionId) { + if (!sessionId || sessionId === "unknown") return; + try { unlinkSync(lapsePendingPath(sessionId)); } catch { /* absent is fine */ } +} + // Identifies the calling client to the server (per-client policy routing). // Each client's hooks.json sets this env var at invocation time: // "claude-code-plugin", "cursor", "codex", etc. Falls back to @@ -520,9 +570,12 @@ async function handlePreToolUse() { appendFileSync(join(homedir(), ".acp", "lapse.log"), JSON.stringify({ at: new Date().toISOString(), tool: input.tool_name, tier, detail }) + "\n"); } catch { /* the lapse log is best-effort — never block on it */ } + // Queue the lapse for the session's next PostToolUse (#902) so the + // gateway gets a row for the call it never saw. + recordPendingLapse(input.session_id, input.tool_name, detail); process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow" }, - systemMessage: `[ACP] ⚠ UNGOVERNED: gateway unreachable (${detail}) — call proceeded WITHOUT policy check. Lapse logged to ~/.acp/lapse.log; ACP has no record of this action.`, + systemMessage: `[ACP] ⚠ UNGOVERNED: gateway unreachable (${detail}) — call proceeded WITHOUT policy check. Lapse logged to ~/.acp/lapse.log and queued for this session's next governed call.`, })); process.exit(0); } @@ -770,6 +823,9 @@ async function handlePostToolUse() { if (Buffer.byteLength(outputStr, "utf8") > POST_HOOK_PAYLOAD_CEILING) { outputStr = outputStr.slice(0, POST_HOOK_PAYLOAD_CEILING); } + // Lapses queued by a fail-open PreToolUse in this session (#902) ride + // along; the marker is cleared only after the gateway acknowledged. + const preLapse = readPendingLapse(input.session_id); const body = JSON.stringify({ tool_name: input.tool_name, tool_input: input.tool_input, @@ -780,6 +836,7 @@ async function handlePostToolUse() { hook_event_name: "PostToolUse", agent_tier: resolveAgentTier(), tier_signals: tierSignals(), + ...(preLapse ? { pre_lapse: preLapse } : {}), }); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 4000); @@ -787,6 +844,7 @@ async function handlePostToolUse() { const res = await fetch(`${ACP_GOVERN}/govern/tool-output`, { method: "POST", headers, body, signal: controller.signal }); clearTimeout(timeout); if (!res.ok) { process.exit(0); } + if (preLapse) clearPendingLapse(input.session_id); const data = await res.json(); // Receipt bookkeeping (#606): one governed call, plus what ACP said // about it. Counted only on a real server verdict — a call the diff --git a/plugin.json b/plugin.json index 3d89acc..06cf4c7 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "agentic-control-plane", - "version": "0.13.0", + "version": "0.14.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/pre-lapse-report.test.mjs b/test/pre-lapse-report.test.mjs new file mode 100644 index 0000000..d110b9c --- /dev/null +++ b/test/pre-lapse-report.test.mjs @@ -0,0 +1,199 @@ +// Pending-lapse round trip in bin/govern.mjs (gatewaystack-connect#902). +// +// Run with: node --test test/pre-lapse-report.test.mjs +// +// An interactive-tier PreToolUse that cannot reach the gateway fails open +// (never-brick) and used to leave its only record in ~/.acp/lapse.log. It +// now also queues the lapse per session under ~/.acp/lapse-pending/, and +// the session's next PostToolUse carries it as `pre_lapse` so the gateway +// can write the row for the call it never saw. Invariants: +// 1. fail-open PreToolUse writes lapse-pending/.json with +// [{ at, tool, detail }] and still allows the call. +// 2. PostToolUse for that session sends pre_lapse and, on a 2xx, deletes +// the marker; the following PostToolUse sends no pre_lapse. +// 3. A non-2xx from /govern/tool-output keeps the marker (retry later). +// 4. Repeated lapses append; the marker never exceeds 20 entries. +// 5. A lapse in one session never leaks into another session's report. +// The hook is spawned exactly the way a harness does — JSON on stdin — +// against a throwaway HOME, with ACP_GOVERN_BASE pointed at a local stub +// (or at a closed port for the outage). + +import { test, before, after, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from "node:fs"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const GOVERN = join(ROOT, "bin", "govern.mjs"); + +let HOME; +let server; +let baseUrl; +// Requests the stub saw (parsed bodies), and what it answers next. +let seen = []; +let nextStatus = 200; + +before(async () => { + HOME = mkdtempSync(join(tmpdir(), "acp-prelapse-test-")); + mkdirSync(join(HOME, ".acp"), { recursive: true }); + writeFileSync(join(HOME, ".acp", "credentials"), "gsk_test_deadbeef\n"); + server = createServer((req, res) => { + let raw = ""; + req.on("data", (c) => { raw += c; }); + req.on("end", () => { + let body = null; + try { body = JSON.parse(raw); } catch { /* keep null */ } + seen.push({ url: req.url, body }); + res.statusCode = nextStatus; + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify({ action: "pass" })); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + baseUrl = `http://127.0.0.1:${server.address().port}`; +}); + +after(() => { + server?.close(); + rmSync(HOME, { recursive: true, force: true }); +}); + +beforeEach(() => { + seen = []; + nextStatus = 200; + rmSync(join(HOME, ".acp", "lapse-pending"), { recursive: true, force: true }); +}); + +// Closed port: connection refused, both attempts fail fast → outage posture. +const DEAD = "http://127.0.0.1:1"; + +// Explicit env (never spread process.env): the runner's own CI=true would +// flip the tier to background and the outage posture to fail-closed. +function runHook(input, governBase) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [GOVERN], { + env: { + HOME, + PATH: process.env.PATH, + ACP_GOVERN_BASE: governBase, + CLAUDE_CODE_ENTRYPOINT: "cli", + ACP_FIRST_ATTEMPT_MS: "400", + ACP_RETRY_ATTEMPT_MS: "400", + }, + 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", (code) => { + clearTimeout(killer); + resolve({ code, stdout, stderr, json: stdout.trim() ? JSON.parse(stdout) : null }); + }); + child.stdin.end(JSON.stringify(input)); + }); +} + +const pre = (session_id, command = "ls -la") => ({ + hook_event_name: "PreToolUse", tool_name: "Bash", tool_input: { command }, session_id, +}); +const post = (session_id) => ({ + hook_event_name: "PostToolUse", tool_name: "Bash", tool_input: { command: "ls -la" }, + tool_response: "total 0", session_id, tool_use_id: "call-1", +}); + +function marker(session_id) { + const p = join(HOME, ".acp", "lapse-pending", `${session_id}.json`); + return existsSync(p) ? JSON.parse(readFileSync(p, "utf8")) : null; +} + +test("fail-open PreToolUse allows the call and queues a pending lapse", async () => { + const r = await runHook(pre("sess-a"), DEAD); + assert.equal(r.code, 0, r.stderr); + assert.equal(r.json?.hookSpecificOutput?.permissionDecision, "allow"); + assert.match(r.json?.systemMessage ?? "", /UNGOVERNED/); + const m = marker("sess-a"); + assert.ok(Array.isArray(m) && m.length === 1, `expected one pending entry, got ${JSON.stringify(m)}`); + assert.equal(m[0].tool, "Bash"); + assert.ok(typeof m[0].detail === "string" && m[0].detail.length > 0, "detail is a non-empty string"); + assert.ok(!Number.isNaN(Date.parse(m[0].at)), "at is an ISO timestamp"); + // lapse.log still gets its line — the marker is in addition, not instead. + assert.match(readFileSync(join(HOME, ".acp", "lapse.log"), "utf8"), /"tool":"Bash"/); +}); + +test("next PostToolUse carries pre_lapse, clears the marker on 2xx, and the one after is clean", async () => { + await runHook(pre("sess-b"), DEAD); + assert.equal(marker("sess-b")?.length, 1); + + const r1 = await runHook(post("sess-b"), baseUrl); + assert.equal(r1.code, 0, r1.stderr); + const out1 = seen.find((s) => s.url === "/govern/tool-output"); + assert.ok(out1, "PostToolUse reached the stub"); + assert.ok(Array.isArray(out1.body.pre_lapse), "pre_lapse array present"); + assert.equal(out1.body.pre_lapse.length, 1); + assert.equal(out1.body.pre_lapse[0].tool, "Bash"); + assert.equal(out1.body.session_id, "sess-b"); + assert.equal(marker("sess-b"), null, "marker deleted after 2xx"); + + seen = []; + const r2 = await runHook(post("sess-b"), baseUrl); + assert.equal(r2.code, 0, r2.stderr); + const out2 = seen.find((s) => s.url === "/govern/tool-output"); + assert.ok(out2, "second PostToolUse reached the stub"); + assert.equal(out2.body.pre_lapse, undefined, "no pre_lapse once reported"); +}); + +test("a non-2xx from /govern/tool-output keeps the marker for the next call", async () => { + await runHook(pre("sess-c"), DEAD); + nextStatus = 503; + const r = await runHook(post("sess-c"), baseUrl); + assert.equal(r.code, 0, r.stderr); + assert.equal(seen.find((s) => s.url === "/govern/tool-output")?.body.pre_lapse?.length, 1); + assert.equal(marker("sess-c")?.length, 1, "marker survives a failed report"); + // Gateway still down at PostToolUse too: also survives. + const r2 = await runHook(post("sess-c"), DEAD); + assert.equal(r2.code, 0, r2.stderr); + assert.equal(marker("sess-c")?.length, 1); +}); + +test("repeated lapses append and the marker is capped at 20 entries", async () => { + for (let i = 0; i < 3; i++) await runHook(pre("sess-d", `echo ${i}`), DEAD); + assert.equal(marker("sess-d")?.length, 3); + // Pre-seed 25 entries to prove the cap without 25 spawns. + const dir = join(HOME, ".acp", "lapse-pending"); + writeFileSync(join(dir, "sess-e.json"), JSON.stringify( + Array.from({ length: 25 }, (_, i) => ({ at: new Date().toISOString(), tool: "Bash", detail: `seed ${i}` })), + )); + await runHook(pre("sess-e"), DEAD); + const m = marker("sess-e"); + assert.equal(m.length, 20); + assert.notEqual(m[m.length - 1].detail.slice(0, 4), "seed", "the newest lapse is the last entry"); + const r = await runHook(post("sess-e"), baseUrl); + assert.equal(r.code, 0, r.stderr); + assert.equal(seen.find((s) => s.url === "/govern/tool-output")?.body.pre_lapse?.length, 20); +}); + +test("a lapse in one session never rides on another session's PostToolUse", async () => { + await runHook(pre("sess-f"), DEAD); + const r = await runHook(post("sess-g"), baseUrl); + assert.equal(r.code, 0, r.stderr); + assert.equal(seen.find((s) => s.url === "/govern/tool-output")?.body.pre_lapse, undefined); + assert.equal(marker("sess-f")?.length, 1, "sess-f marker untouched"); +}); + +test("a corrupt marker is ignored, never blocks the call, and is replaced on the next lapse", async () => { + const dir = join(HOME, ".acp", "lapse-pending"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "sess-h.json"), "{not json"); + const r = await runHook(post("sess-h"), baseUrl); + assert.equal(r.code, 0, r.stderr); + assert.equal(seen.find((s) => s.url === "/govern/tool-output")?.body.pre_lapse, undefined); + await runHook(pre("sess-h"), DEAD); + assert.equal(marker("sess-h")?.length, 1); +});