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.13.0",
"version": "0.14.0",
"author": {
"name": "GatewayStack"
},
Expand Down
64 changes: 61 additions & 3 deletions bin/govern.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 =
Expand All @@ -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
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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,
Expand All @@ -780,13 +836,15 @@ 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);
try {
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
Expand Down
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.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",
Expand Down
199 changes: 199 additions & 0 deletions test/pre-lapse-report.test.mjs
Original file line number Diff line number Diff line change
@@ -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/<session>.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);
});
Loading