From 653e072c3dfdd58f51d1bcab6d24af55dd8bb6af Mon Sep 17 00:00:00 2001 From: David Crowe Date: Mon, 14 Sep 2026 11:38:30 -0700 Subject: [PATCH 1/2] Stale-hook notice from gateway version headers (0.17.0) A production tenant ran hook 0.4.0 while main was 0.16.0 and two hardline/tamper denies ran anyway (hook.enforcement_diverged). The hook now reads X-ACP-Latest-Version / X-ACP-Min-Good-Version on every hook response (and latestVersion / minGoodVersion in the attest body) and, when it is behind, says so once per 24h: one stderr line plus the same text on the event's existing notice channel (PreToolUse allow funnel, PostToolUse systemMessage, SessionStart additionalContext), appended to any existing notice. Advisory only: no download, no execution, no await before the verdict, no file touched except ~/.acp/.stale-notice. --- bin/govern.mjs | 119 ++++++++++++++++++++++++++++-- lib/staleNotice.mjs | 88 ++++++++++++++++++++++ test/stale-notice.test.mjs | 145 +++++++++++++++++++++++++++++++++++++ 3 files changed, 347 insertions(+), 5 deletions(-) create mode 100644 lib/staleNotice.mjs create mode 100644 test/stale-notice.test.mjs diff --git a/bin/govern.mjs b/bin/govern.mjs index 16f867e..58b6d22 100644 --- a/bin/govern.mjs +++ b/bin/govern.mjs @@ -807,6 +807,99 @@ function firstTierNoticeThisSession() { return true; } +/* ── Stale-hook notice ── + * + * A production tenant ran hook 0.4.0 while main was 0.16.0, and two + * hardline/tamper denies RAN ANYWAY on that stale hook + * (hook.enforcement_diverged). The gateway sends X-ACP-Latest-Version and + * X-ACP-Min-Good-Version on every hook response (and latestVersion / + * minGoodVersion in the attest body); this hook compares them with its own + * version and says so — once per 24h across all events, on stderr and on + * the event's existing notice channel. Advisory only: it never downloads, + * never executes, never blocks, adds no await before the verdict, and + * touches nothing but ~/.acp/.stale-notice. Canonical copy of the logic + * lives in lib/staleNotice.mjs for the test suite — keep both in sync. */ +const STALE_NOTICE_MARKER = join(ACP_DIR, ".stale-notice"); +const STALE_NOTICE_TTL_MS = 24 * 60 * 60 * 1000; + +function parseSemver(v) { + if (typeof v !== "string") return null; + const m = v.trim().match(/^v?(\d+)\.(\d+)\.(\d+)/); + if (!m) return null; + return [Number(m[1]), Number(m[2]), Number(m[3])]; +} + +function compareSemver(a, b) { + const pa = parseSemver(a); + const pb = parseSemver(b); + if (!pa || !pb) return 0; + for (let i = 0; i < 3; i++) { + if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1; + } + return 0; +} + +function readVersionHeaders(res) { + const get = (name) => { + try { + const v = res?.headers?.get?.(name); + return typeof v === "string" && v.trim() ? v.trim() : null; + } catch { + return null; + } + }; + return { + latest: get("x-acp-latest-version"), + minGood: get("x-acp-min-good-version"), + }; +} + +function staleNoticeText({ current, latest, minGood, updateCmd = "acp-update" }) { + if (minGood && compareSemver(current, minGood) < 0) { + return `[ACP] governance hook v${current} is below the minimum supported v${minGood} — denies may not be enforced. Run: ${updateCmd}`; + } + if (latest && compareSemver(current, latest) < 0) { + return `[ACP] governance hook v${current} is outdated (v${latest} current). Run: ${updateCmd}`; + } + return null; +} + +// True at most once per TTL; the marker is written BEFORE returning true. +// Every failure → false so an unwritable ~/.acp never becomes per-call noise. +function shouldNoticeNow({ markerPath, now = Date.now(), ttlMs = STALE_NOTICE_TTL_MS }) { + try { + let last = 0; + try { + last = Number(readFileSync(markerPath, "utf8").trim()) || 0; + } catch { /* no marker yet */ } + if (last && now - last < ttlMs) return false; + mkdirSync(ACP_DIR, { recursive: true }); + writeFileSync(markerPath, String(now)); + return true; + } catch { + return false; + } +} + +/** Headers (any hook response) and/or the attest body fields → the notice + * line, already written to stderr, or null. Sync, never throws. */ +function staleHookNotice(res, data) { + try { + const fromHeaders = readVersionHeaders(res); + const latest = fromHeaders.latest + ?? (typeof data?.latestVersion === "string" && data.latestVersion.trim() ? data.latestVersion.trim() : null); + const minGood = fromHeaders.minGood + ?? (typeof data?.minGoodVersion === "string" && data.minGoodVersion.trim() ? data.minGoodVersion.trim() : null); + const text = staleNoticeText({ current: PLUGIN_VERSION, latest, minGood, updateCmd: "acp-update" }); + if (!text) return null; + if (!shouldNoticeNow({ markerPath: STALE_NOTICE_MARKER })) return null; + process.stderr.write(text + "\n"); + return text; + } catch { + return null; + } +} + /* ------------------------------------------------------------------ */ /* Scoped-token request (Phase 1 cross-arch broker) */ /* ------------------------------------------------------------------ */ @@ -1089,6 +1182,9 @@ async function handlePreToolUse() { let policyAllowed = true; let tierNotice = null; + // Stale-hook line (see staleHookNotice): rides the allow funnel below, + // and reaches stderr on every decision including denies. + let staleNotice = null; // The gateway's human-facing line for THIS call (gatewaystack-connect#429): // the billing grace nag, or a fail-open — "policy could not be read; this // call ran fail-open (not policy-checked)". The field has existed since @@ -1130,6 +1226,7 @@ async function handlePreToolUse() { return; } const data = await res.json(); + staleNotice = staleHookNotice(res, data); if (data.decision === "deny") { denyByPolicy(data.reason || "policy did not return a reason", data.kind); return; @@ -1166,7 +1263,7 @@ async function handlePreToolUse() { // 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()); + const parts = [tierNotice, wireWarning, staleNotice].filter((s) => typeof s === "string" && s.trim()); if (unpricedNotice === undefined) unpricedNotice = claimUnpricedNotice(); if (unpricedNotice) parts.push(unpricedNotice); return parts.length ? parts.join(" ") : null; @@ -1401,9 +1498,13 @@ async function handlePostToolUse() { flagged: data.action === "redact" || data.action === "block" ? 1 : 0, notices: noticed ? 1 : 0, }); + // Stale-hook line: appended to whatever this event already says, never + // replacing it; a hook run writes at most ONE stdout JSON object. + const staleNotice = staleHookNotice(res, data); + const withStale = (msg) => (staleNotice ? `${msg} ${staleNotice}` : msg); if (data.action === "redact" || data.action === "block") { process.stdout.write(JSON.stringify({ - systemMessage: `[ACP] ${data.action === "block" ? "Blocked" : "Flagged"}: ${data.reason || "governance policy"}`, + systemMessage: withStale(`[ACP] ${data.action === "block" ? "Blocked" : "Flagged"}: ${data.reason || "governance policy"}`), })); } else if (!SHADOW_OFF && typeof data.notice === "string" && data.notice.trim()) { // Shadow-mode counterfactual (gatewaystack-connect#607): the server @@ -1412,7 +1513,9 @@ async function handlePostToolUse() { // advisory only and arrives with action "pass"; frequency caps are // server-side. ACP_SHADOW=off is the client-side belt to the server's // suspenders (the tenant-level shadowNotices:false disable). - process.stdout.write(JSON.stringify({ systemMessage: data.notice })); + process.stdout.write(JSON.stringify({ systemMessage: withStale(data.notice) })); + } else if (staleNotice) { + process.stdout.write(JSON.stringify({ systemMessage: staleNotice })); } } catch { // silent pass-through @@ -1484,11 +1587,17 @@ async function handleSessionStart() { // 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()) { + // Stale-hook line: headers first, then the attest body's + // latestVersion / minGoodVersion. Appended to the upgrade notice when + // both are present, never replacing it. + const staleNotice = staleHookNotice(res, data); + const upgradeNotice = data && typeof data.notice === "string" && data.notice.trim() ? data.notice.trim() : null; + const context = [upgradeNotice, staleNotice].filter(Boolean).join("\n"); + if (context) { process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "SessionStart", - additionalContext: data.notice.trim(), + additionalContext: context, }, ...(offer ? { systemMessage: offer } : {}), })); diff --git a/lib/staleNotice.mjs b/lib/staleNotice.mjs new file mode 100644 index 0000000..a35ede4 --- /dev/null +++ b/lib/staleNotice.mjs @@ -0,0 +1,88 @@ +// Canonical copy of the stale-hook notice logic. bin/govern.mjs carries the +// same logic inline (it is deliberately self-contained, like +// vendor-patterns and receipt); this module exists so tests can pin the +// contract. +// +// Why: a production tenant ran hook 0.4.0 while main was 0.16.0, and two +// hardline/tamper denies RAN ANYWAY on that stale hook +// (hook.enforcement_diverged). The gateway sends X-ACP-Latest-Version and +// X-ACP-Min-Good-Version on every hook response (and latestVersion / +// minGoodVersion in the attest body); the hook compares them with its own +// version and says so — once per 24h, advisory only. It never downloads, +// never executes, never blocks, and touches nothing but a marker under +// ~/.acp. + +import { dirname } from "path"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** "1.2.3" / "v1.2.3" / "1.2.3-rc1" → [1,2,3]; anything else → null. */ +export function parseSemver(v) { + if (typeof v !== "string") return null; + const m = v.trim().match(/^v?(\d+)\.(\d+)\.(\d+)/); + if (!m) return null; + return [Number(m[1]), Number(m[2]), Number(m[3])]; +} + +/** -1 / 0 / 1. Missing or garbage on either side → 0 (never stale on a + * value we can't read). */ +export function compareSemver(a, b) { + const pa = parseSemver(a); + const pb = parseSemver(b); + if (!pa || !pb) return 0; + for (let i = 0; i < 3; i++) { + if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1; + } + return 0; +} + +/** {latest, minGood} from the response headers, nulls when absent or + * unreadable. Works on any object with a case-insensitive headers.get. */ +export function readVersionHeaders(res) { + const get = (name) => { + try { + const v = res?.headers?.get?.(name); + return typeof v === "string" && v.trim() ? v.trim() : null; + } catch { + return null; + } + }; + return { + latest: get("x-acp-latest-version"), + minGood: get("x-acp-min-good-version"), + }; +} + +/** The one line the human sees, or null when the hook is current (or the + * server sent nothing usable). Below-minimum wins over merely-outdated. */ +export function staleNoticeText({ current, latest, minGood, updateCmd = "acp-update" }) { + if (minGood && compareSemver(current, minGood) < 0) { + return `[ACP] governance hook v${current} is below the minimum supported v${minGood} — denies may not be enforced. Run: ${updateCmd}`; + } + if (latest && compareSemver(current, latest) < 0) { + return `[ACP] governance hook v${current} is outdated (v${latest} current). Run: ${updateCmd}`; + } + return null; +} + +/** True at most once per ttlMs. The marker (epoch ms of the last notice) + * is written BEFORE returning true so a crash mid-notice cannot cause a + * repeat. Every failure → false: an unwritable ~/.acp must never turn an + * advisory line into per-call noise. Sync by design — no awaits before + * the verdict. */ +export function shouldNoticeNow({ markerPath, now = Date.now(), ttlMs = DAY_MS, fs }) { + try { + let last = 0; + try { + last = Number(fs.readFileSync(markerPath, "utf8").trim()) || 0; + } catch { + /* no marker yet */ + } + if (last && now - last < ttlMs) return false; + fs.mkdirSync(dirname(markerPath), { recursive: true }); + fs.writeFileSync(markerPath, String(now)); + return true; + } catch { + return false; + } +} diff --git a/test/stale-notice.test.mjs b/test/stale-notice.test.mjs new file mode 100644 index 0000000..5309995 --- /dev/null +++ b/test/stale-notice.test.mjs @@ -0,0 +1,145 @@ +// Pins the stale-hook notice contract. Evidence: hook.enforcement_diverged +// on a 0.4.0 hook while main was 0.16.0 — two denies ran anyway. This +// notice is advisory (stderr + the event's existing notice channel), fires +// at most once per 24h, and must never throw into the verdict path. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, writeFileSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + parseSemver, + compareSemver, + readVersionHeaders, + staleNoticeText, + shouldNoticeNow, +} from "../lib/staleNotice.mjs"; + +const realFs = { readFileSync, writeFileSync, mkdirSync }; +const DAY = 24 * 60 * 60 * 1000; + +test("compareSemver orders numerically and tolerates prefixes", () => { + assert.equal(compareSemver("0.4.0", "0.16.0"), -1); + assert.equal(compareSemver("0.16.0", "0.4.0"), 1); + assert.equal(compareSemver("0.16.0", "0.16.0"), 0); + assert.equal(compareSemver("1.0.0", "0.99.99"), 1); + assert.equal(compareSemver("v0.17.0", "0.17.0"), 0); + assert.equal(compareSemver("0.17.0-rc1", "0.17.0"), 0); + assert.equal(compareSemver(" 0.9.0 ", "0.10.0"), -1); +}); + +test("compareSemver is 0 on missing or garbage input", () => { + assert.equal(compareSemver(undefined, "0.16.0"), 0); + assert.equal(compareSemver("0.16.0", null), 0); + assert.equal(compareSemver("", ""), 0); + assert.equal(compareSemver("latest", "0.16.0"), 0); + assert.equal(compareSemver("0.16", "0.16.0"), 0); + assert.equal(compareSemver(16, "0.16.0"), 0); + assert.equal(compareSemver({}, []), 0); + assert.equal(parseSemver("garbage"), null); + assert.deepEqual(parseSemver("v1.2.3"), [1, 2, 3]); +}); + +test("readVersionHeaders reads both headers case-insensitively", () => { + const res = { headers: new Headers({ "X-ACP-Latest-Version": "0.17.0", "X-ACP-Min-Good-Version": " 0.12.0 " }) }; + assert.deepEqual(readVersionHeaders(res), { latest: "0.17.0", minGood: "0.12.0" }); +}); + +test("readVersionHeaders is nulls when headers are absent, empty, or unreadable", () => { + assert.deepEqual(readVersionHeaders({ headers: new Headers() }), { latest: null, minGood: null }); + assert.deepEqual(readVersionHeaders({ headers: new Headers({ "x-acp-latest-version": " " }) }), { latest: null, minGood: null }); + assert.deepEqual(readVersionHeaders({}), { latest: null, minGood: null }); + assert.deepEqual(readVersionHeaders(null), { latest: null, minGood: null }); + assert.deepEqual(readVersionHeaders({ headers: { get() { throw new Error("boom"); } } }), { latest: null, minGood: null }); +}); + +test("staleNoticeText: outdated", () => { + assert.equal( + staleNoticeText({ current: "0.4.0", latest: "0.16.0", minGood: "0.1.0", updateCmd: "acp-update" }), + "[ACP] governance hook v0.4.0 is outdated (v0.16.0 current). Run: acp-update", + ); +}); + +test("staleNoticeText: below minimum supported wins over outdated", () => { + assert.equal( + staleNoticeText({ current: "0.4.0", latest: "0.16.0", minGood: "0.12.0", updateCmd: "acp-update" }), + "[ACP] governance hook v0.4.0 is below the minimum supported v0.12.0 — denies may not be enforced. Run: acp-update", + ); + assert.equal( + staleNoticeText({ current: "0.4.0", latest: null, minGood: "0.12.0" }), + "[ACP] governance hook v0.4.0 is below the minimum supported v0.12.0 — denies may not be enforced. Run: acp-update", + ); +}); + +test("staleNoticeText: current, ahead, or nothing usable → null", () => { + assert.equal(staleNoticeText({ current: "0.16.0", latest: "0.16.0", minGood: "0.12.0" }), null); + assert.equal(staleNoticeText({ current: "0.17.0", latest: "0.16.0", minGood: "0.12.0" }), null); + assert.equal(staleNoticeText({ current: "0.16.0", latest: null, minGood: null }), null); + assert.equal(staleNoticeText({ current: "0.16.0", latest: "garbage", minGood: "also-garbage" }), null); + assert.equal(staleNoticeText({ current: undefined, latest: "0.16.0", minGood: "0.12.0" }), null); +}); + +test("shouldNoticeNow: no marker → true and marker written with now", () => { + const dir = mkdtempSync(join(tmpdir(), "acp-stale-")); + try { + const markerPath = join(dir, "nested", ".stale-notice"); + const now = 1_700_000_000_000; + assert.equal(shouldNoticeNow({ markerPath, now, fs: realFs }), true); + assert.equal(readFileSync(markerPath, "utf8"), String(now)); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("shouldNoticeNow: fresh marker → false and marker untouched", () => { + const dir = mkdtempSync(join(tmpdir(), "acp-stale-")); + try { + const markerPath = join(dir, ".stale-notice"); + const then = 1_700_000_000_000; + writeFileSync(markerPath, String(then)); + assert.equal(shouldNoticeNow({ markerPath, now: then + DAY - 1, fs: realFs }), false); + assert.equal(readFileSync(markerPath, "utf8"), String(then)); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("shouldNoticeNow: stale marker → true and marker rewritten", () => { + const dir = mkdtempSync(join(tmpdir(), "acp-stale-")); + try { + const markerPath = join(dir, ".stale-notice"); + const then = 1_700_000_000_000; + writeFileSync(markerPath, String(then)); + const now = then + DAY; + assert.equal(shouldNoticeNow({ markerPath, now, fs: realFs }), true); + assert.equal(readFileSync(markerPath, "utf8"), String(now)); + // Second call in the same window is silent. + assert.equal(shouldNoticeNow({ markerPath, now: now + 1000, fs: realFs }), false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("shouldNoticeNow: garbage marker is treated as absent", () => { + const dir = mkdtempSync(join(tmpdir(), "acp-stale-")); + try { + const markerPath = join(dir, ".stale-notice"); + writeFileSync(markerPath, "not-a-number"); + assert.equal(shouldNoticeNow({ markerPath, now: 5, fs: realFs }), true); + assert.equal(readFileSync(markerPath, "utf8"), "5"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("shouldNoticeNow: fs write throws → false, never throws", () => { + const throwingFs = { + readFileSync() { throw new Error("ENOENT"); }, + mkdirSync() { throw new Error("EACCES"); }, + writeFileSync() { throw new Error("EACCES"); }, + }; + assert.equal(shouldNoticeNow({ markerPath: "/nope/.stale-notice", now: 1, fs: throwingFs }), false); + assert.equal(shouldNoticeNow({ markerPath: "/nope/.stale-notice", now: 1, fs: null }), false); + assert.equal(shouldNoticeNow({ markerPath: undefined, now: 1, fs: realFs }), false); +}); From 7eb16d7a266c49c769de78b229016140bb13bce5 Mon Sep 17 00:00:00 2001 From: David Crowe Date: Wed, 16 Sep 2026 16:50:05 -0700 Subject: [PATCH 2/2] Bump to 0.21.0 after rebase onto the terminal-commands release --- .claude-plugin/marketplace.json | 2 +- bin/govern.mjs | 2 +- plugin.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 8b769f6..8491ee8 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.20.0", + "version": "0.21.0", "author": { "name": "GatewayStack" }, diff --git a/bin/govern.mjs b/bin/govern.mjs index 58b6d22..00eab14 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.20.0"; +const PLUGIN_VERSION = "0.21.0"; // Console base for user-facing deep links (session receipt, #606). const ACP_CONSOLE = diff --git a/plugin.json b/plugin.json index 1f9d734..9aeec0e 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "agentic-control-plane", - "version": "0.20.0", + "version": "0.21.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",