diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 47d542a..3449e16 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.28.0", + "version": "0.29.0", "author": { "name": "GatewayStack" }, diff --git a/bin/govern.mjs b/bin/govern.mjs index a357fca..3ff7170 100644 --- a/bin/govern.mjs +++ b/bin/govern.mjs @@ -122,7 +122,7 @@ const ACP_GOVERN = ACP_DEV_BASE ?? resolveAcpBase( process.env.ACP_GOVERN_BASE || process.env.ACP_API_BASE, "https://govern.agenticcontrolplane.com", acpBaseWarn); -const PLUGIN_VERSION = "0.28.0"; +const PLUGIN_VERSION = "0.29.0"; // Console base for user-facing deep links (session receipt, #606; enrolment // hint). Validated like the gateway bases: a poisoned value would otherwise @@ -325,6 +325,39 @@ function normalizeCopilotInput(input) { return true; } +// Receipt chokepoint (#1348): every verdict this hook emits leaves through +// stdout, so held (ask) and denied (deny) verdicts are counted HERE, +// whichever path produced them — gateway verdict, local policy engine, +// offline destructive floor, fail-closed outage or key rejection. The Stop +// turn line and the SessionEnd receipt read these counters, so a deny the +// human must see cannot slip through a path that forgot to bump one. +// A gateway hold reaches Codex-style harnesses as a "deny" that carries an +// approval_id; the emitting path sets receiptVerdictHint = "held" so it is +// counted as a hold, not a refusal. +let receiptVerdictHint = null; +{ + const rawWrite = process.stdout.write.bind(process.stdout); + process.stdout.write = (chunk, ...rest) => { + try { + const o = JSON.parse(String(chunk)); + const d = o?.hookSpecificOutput?.permissionDecision ?? o?.permissionDecision; + const kind = receiptVerdictHint ?? (d === "ask" ? "held" : d === "deny" ? "denied" : null); + receiptVerdictHint = null; + if (kind === "held") recordVerdictForReceipt({ held: 1 }); + else if (kind === "denied") recordVerdictForReceipt({ denied: 1 }); + } catch { /* not one of our JSON verdicts */ } + return rawWrite(chunk, ...rest); + }; +} + +function recordVerdictForReceipt(delta) { + try { + const sid = typeof input === "object" && input ? input.session_id : undefined; + bumpTurnStats(sid, delta); + bumpReceiptStats(sid, delta); + } catch { /* bookkeeping must never touch the call path */ } +} + if (HARNESS === "copilot") { const rawWrite = process.stdout.write.bind(process.stdout); process.stdout.write = (chunk, ...rest) => { @@ -345,6 +378,15 @@ if (HARNESS === "copilot") { // the backend's scan ceiling. const POST_HOOK_PAYLOAD_CEILING = 200 * 1024; +// Strip control/bidi-override characters from any gateway-supplied free +// text before it reaches a terminal (approval_summary, agent_notice, +// catch_up, upgrade_notice, …). Same character class the existing /acp-why +// and /acp-status renderers already use inline — centralized here for new +// code so it is applied consistently. +function stripControl(v) { + return String(v ?? "").replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f‪-‮⁦-⁩]/g, " "); +} + // Hostname-only form of ACP_API for env-var injection (e.g. GH_HOST). // gh CLI accepts hostnames, not URLs — strip protocol + trailing slash. const ACP_HOST = ACP_API.replace(/^https?:\/\//, "").replace(/\/$/, ""); @@ -1011,6 +1053,10 @@ function firstTierNoticeThisSession() { * 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; +// #1351 upgrade_notice: same "at most once per TTL" marker pattern, its own +// file so it dedupes independently of the stale-hook-version notice above. +const UPGRADE_NOTICE_MARKER = join(ACP_DIR, ".upgrade-notice"); +const UPGRADE_NOTICE_TTL_MS = 24 * 60 * 60 * 1000; function parseSemver(v) { if (typeof v !== "string") return null; @@ -1209,9 +1255,12 @@ async function handlePreToolUse() { // "hand it over" steer would contradict that, so this one wins. const WAIT_STEER = "This call is decided in the ACP console or from the approval email. Do what the reason says: call acp_wait_approval with that approval_id, retry the identical call only after it reports approved, and stop if it reports denied or timeout. Keep working on anything that does not need this call while you wait."; - function denyByPolicy(reason, kind) { + function denyByPolicy(reason, kind, verdict) { rememberHeld(reason, kind); - const steer = /\bacp_wait_approval\b/.test(reason) ? WAIT_STEER : STEER_BY_KIND[kind] + const held = approvalMeta(verdict); + rememberPendingApproval(held); + if (held) receiptVerdictHint = "held"; + const steer =/\bacp_wait_approval\b/.test(reason) ? WAIT_STEER : STEER_BY_KIND[kind] || (UNPROPOSABLE.test(reason) ? STEER_BY_KIND.terminal : STEER_BY_KIND.reformulate); process.stdout.write(JSON.stringify({ hookSpecificOutput: { @@ -1219,7 +1268,7 @@ async function handlePreToolUse() { permissionDecision: "deny", permissionDecisionReason: `[ACP] Denied by policy: ${reason}\n\n${steer}`, }, - systemMessage: `[ACP] Denied by policy: ${reason}`, + systemMessage: [recoveryLine, `[ACP] Denied by policy: ${reason}`].filter(Boolean).join("\n"), })); process.exit(0); } @@ -1246,9 +1295,20 @@ async function handlePreToolUse() { // 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); + // UNGOVERNED banner (#1352 part 3): loud once at the START of an + // outage, silent for every call while it continues — the recovery + // line (below, keyed off the same local file) says how many ran + // unchecked when the gateway answers again. Scoped to the + // interactive fail-open branch only: the unattended fail-closed path + // below already blocks and already logs, so there is nothing to + // dedupe there. + const outageState = markOutageCall(); + const firstOfOutage = !outageState || outageState.calls <= 1; 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 and queued for this session's next governed call.`, + ...(firstOfOutage + ? { 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); } @@ -1325,8 +1385,9 @@ async function handlePreToolUse() { function denyUnreachable(detail) { failPostureOnOutage(detail || "network error"); } - function ask(reason) { + function ask(reason, verdict) { rememberHeld(reason, "ask"); + rememberPendingApproval(approvalMeta(verdict)); // Codex has no ask semantic on the wire (see HARNESS note): emit deny // with the approval deep link so the human approves out-of-band and // the re-run passes under the grant. @@ -1348,7 +1409,7 @@ async function handlePreToolUse() { permissionDecision: decision, permissionDecisionReason: `${headline}\n\n${steer}`, }, - systemMessage: headline, + systemMessage: [recoveryLine, headline].filter(Boolean).join("\n"), })); process.exit(0); } @@ -1385,6 +1446,11 @@ async function handlePreToolUse() { let policyAllowed = true; let tierNotice = null; + // Recovery line (#1352 part 3): set the moment the gateway answers with a + // real verdict (2xx, or a 4xx that carries one) after an outage was in + // progress; threaded into whichever of denyByPolicy/ask/exitAllow fires + // next so a hook run still writes exactly one stdout object. + let recoveryLine = null; // Stale-hook line (see staleHookNotice): rides the allow funnel below, // and reaches stderr on every decision including denies. let staleNotice = null; @@ -1416,12 +1482,15 @@ async function handlePreToolUse() { // 4xx (e.g. the auth guard's 401) keep the outage posture. if (res.status >= 400 && res.status < 500) { const verdict = await res.json().catch(() => null); + if (verdict && (verdict.decision === "deny" || verdict.decision === "ask")) { + recoveryLine = checkOutageRecovery(); + } if (verdict && verdict.decision === "deny") { - denyByPolicy(verdict.reason || `denied (HTTP ${res.status})`, verdict.kind); + denyByPolicy(verdict.reason || `denied (HTTP ${res.status})`, verdict.kind, verdict); return; } if (verdict && verdict.decision === "ask") { - ask(verdict.reason || "approval required"); + ask(verdict.reason || "approval required", verdict); return; } } @@ -1430,12 +1499,13 @@ async function handlePreToolUse() { } const data = await res.json(); staleNotice = staleHookNotice(res, data); + recoveryLine = checkOutageRecovery(); if (data.decision === "deny") { - denyByPolicy(data.reason || "policy did not return a reason", data.kind); + denyByPolicy(data.reason || "policy did not return a reason", data.kind, data); return; } if (data.decision === "ask") { - ask(data.reason || "approval required"); + ask(data.reason || "approval required", data); return; } // decision is allow (or unspecified) — continue to step 2. @@ -1466,7 +1536,7 @@ async function handlePreToolUse() { // session's marker), so the result is memoized for any second read. let unpricedNotice; function allowSystemMessage() { - const parts = [tierNotice, wireWarning, staleNotice].filter((s) => typeof s === "string" && s.trim()); + const parts = [recoveryLine, 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; @@ -1842,31 +1912,61 @@ async function handlePostToolUse() { // Receipt bookkeeping (#606): one governed call, plus what ACP said // about it. Counted only on a real server verdict — a call the // gateway never saw is not claimed as governed. - const noticed = !SHADOW_OFF && typeof data.notice === "string" && data.notice.trim() !== ""; + const flagged = data.action === "redact" || data.action === "block"; + const isShadow = !SHADOW_OFF && typeof data.notice === "string" && data.notice.trim() !== ""; + // notice_lever marks a "cost" lever notice (#1349/#1350 part 1: + // expensive_run, context_threshold, tool_output_bloat, repeat_approval, + // ...); a notice with no lever is the older shadow-mode counterfactual. + // Both use the same `notice` text on the human channel — this only + // decides which per-turn bucket it counts against for the Stop line. + const hasLever = typeof data.notice_lever === "string" && data.notice_lever.trim() !== ""; + const noticed = isShadow; bumpReceiptStats(input.session_id, { calls: 1, - flagged: data.action === "redact" || data.action === "block" ? 1 : 0, + flagged: flagged ? 1 : 0, notices: noticed ? 1 : 0, }); + bumpTurnStats(input.session_id, { + flagged: flagged ? 1 : 0, + shadow: noticed && !hasLever ? 1 : 0, + cost: noticed && hasLever ? 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: 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 - // sends a fully-formed "[ACP shadow] …" line for audit-mode tenants — - // what enforcement WOULD have done to the call that just ran. It is - // 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: withStale(data.notice) })); + const sysParts = []; + if (flagged) { + sysParts.push(withStale(`[ACP] ${data.action === "block" ? "Blocked" : "Flagged"}: ${data.reason || "governance policy"}`)); + } else if (isShadow) { + // Shadow-mode counterfactual (gatewaystack-connect#607) / lever notice + // (#1349): the server sends a fully-formed human line for audit-mode + // tenants or a specific cost/behavior lever. Advisory only; frequency + // caps are server-side. ACP_SHADOW=off is the client-side belt to the + // server's suspenders (the tenant-level shadowNotices:false disable). + sysParts.push(withStale(data.notice)); } else if (staleNotice) { - process.stdout.write(JSON.stringify({ systemMessage: staleNotice })); + sysParts.push(staleNotice); } + // agent_notice (#1349): model-facing only, for levers the model can act + // on itself. `notice` (above) stays human-only and unchanged. + const agentNotice = typeof data.agent_notice === "string" && data.agent_notice.trim() + ? stripControl(data.agent_notice).trim() + : null; + const out = {}; + if (sysParts.length) out.systemMessage = sysParts.join(" "); + if (agentNotice) { + if (HARNESS === "claude-code") { + out.hookSpecificOutput = { hookEventName: "PostToolUse", additionalContext: agentNotice }; + } else { + // Codex only honors additionalContext on SessionStart; Copilot's + // support for it here is unconfirmed. Fold into the human channel + // instead of dropping it, joined onto whatever systemMessage this + // call already has. + out.systemMessage = out.systemMessage ? `${out.systemMessage} ${agentNotice}` : agentNotice; + } + } + if (out.systemMessage || out.hookSpecificOutput) process.stdout.write(JSON.stringify(out)); } catch { // silent pass-through for the verdict; the usage batch is retried next // time (a 2xx whose body failed to parse was already acked above). @@ -1926,8 +2026,13 @@ async function handleSessionStart() { if (flushNotice) maybeSpawnFlush(); let context = null; let offer = null; + let catchUp = null; + let upgradeOnce = null; const finish = () => { - const systemMessage = [flushNotice, offer].filter(Boolean).join("\n"); + // Human channel, in order: ledger flush, #1351 catch_up, the human-worded + // upgrade line (at most once per 24h), then the daily offer. One JSON + // object however many apply (#51). + const systemMessage = [flushNotice, catchUp, upgradeOnce, offer].filter(Boolean).join("\n"); if (context || systemMessage) { process.stdout.write(JSON.stringify({ ...(context ? { hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: context } } : {}), @@ -1974,8 +2079,26 @@ async function handleSessionStart() { // 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; + // #1351's upgrade_notice (human-worded, ends "/acp-upgrade") supersedes + // the older model-facing `data.notice` upgrade text per CONTRACT.md §4: + // when present, it is shown to the human only, at most once per 24h + // per machine, and the older text is NOT also forwarded to the model. + const newUpgrade = data && typeof data.upgrade_notice === "string" && data.upgrade_notice.trim() + ? data.upgrade_notice.trim() + : null; + const upgradeNotice = !newUpgrade && data && typeof data.notice === "string" && data.notice.trim() + ? data.notice.trim() + : null; context = [upgradeNotice, staleNotice].filter(Boolean).join("\n") || null; + // catch_up (#1351): one human line since this user's previous session + // start. Shown every SessionStart it's present on — the gateway itself + // bounds how often it has anything to say (cap 7 days, ≥60s cache). + catchUp = data && typeof data.catch_up === "string" && data.catch_up.trim() + ? stripControl(data.catch_up).trim() + : null; + upgradeOnce = newUpgrade && shouldNoticeNow({ markerPath: UPGRADE_NOTICE_MARKER, ttlMs: UPGRADE_NOTICE_TTL_MS }) + ? newUpgrade + : null; } catch { // silent — absence of attestation is visible server-side by design } finally { @@ -1999,9 +2122,15 @@ function receiptStatsPath(sessionId) { function readReceiptStats(sessionId) { try { const raw = JSON.parse(readFileSync(receiptStatsPath(sessionId), "utf8")); - return { calls: Number(raw.calls) || 0, flagged: Number(raw.flagged) || 0, notices: Number(raw.notices) || 0 }; + return { + calls: Number(raw.calls) || 0, + flagged: Number(raw.flagged) || 0, + notices: Number(raw.notices) || 0, + held: Number(raw.held) || 0, + denied: Number(raw.denied) || 0, + }; } catch { - return { calls: 0, flagged: 0, notices: 0 }; + return { calls: 0, flagged: 0, notices: 0, held: 0, denied: 0 }; } } @@ -2014,6 +2143,8 @@ function bumpReceiptStats(sessionId, delta) { calls: cur.calls + (delta.calls ?? 0), flagged: cur.flagged + (delta.flagged ?? 0), notices: cur.notices + (delta.notices ?? 0), + held: cur.held + (delta.held ?? 0), + denied: (cur.denied ?? 0) + (delta.denied ?? 0), })); } catch { /* bookkeeping must never touch the call path */ } } @@ -2068,6 +2199,235 @@ function explainHeld(held) { return lines.join("\n"); } +/* ── Pending approvals list (#1346/#1348/#1350): the local half of the + * held-call bookkeeping /acp-approve and UserPromptSubmit read. Separate + * from heldPath/rememberHeld above (which /acp-why owns and which only + * ever remembers the LAST held call) because a session can have several + * approval-workflow holds outstanding at once. Best-effort, local only, + * pruned by clearReceiptStats's directory sweep at SessionEnd like every + * other file under SESSION_STATS_DIR. ── */ +function pendingPath(sessionId) { + const safe = String(sessionId).replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 128); + return join(SESSION_STATS_DIR, `${safe}.pending.json`); +} + +function readPending(sessionId) { + try { + const raw = JSON.parse(readFileSync(pendingPath(sessionId), "utf8")); + return Array.isArray(raw) ? raw : []; + } catch { + return []; + } +} + +function writePending(sessionId, list) { + if (!sessionId || sessionId === "unknown") return; + try { + mkdirSync(SESSION_STATS_DIR, { recursive: true }); + writeFileSync(pendingPath(sessionId), JSON.stringify(Array.isArray(list) ? list.slice(-20) : [])); + } catch { /* bookkeeping must never touch the call path */ } +} + +// Pulls the new optional approval_* fields (CONTRACT.md §1) off a deny/ask +// verdict. Returns null when the verdict carries no approval_id — a +// fatigue-cap deny, a plain rule deny, or a gateway older than this field +// all fall through here silently. +function approvalMeta(verdict) { + if (!verdict || typeof verdict !== "object") return null; + const id = typeof verdict.approval_id === "string" && verdict.approval_id ? verdict.approval_id : null; + if (!id) return null; + const code = typeof verdict.approval_code === "string" && verdict.approval_code + ? verdict.approval_code + : id.replace(/[^0-9a-fA-F]/g, "").slice(0, 4).toUpperCase(); + return { + id, + code, + url: typeof verdict.approval_url === "string" && verdict.approval_url ? verdict.approval_url : null, + summary: typeof verdict.approval_summary === "string" && verdict.approval_summary + ? stripControl(verdict.approval_summary).slice(0, 90) + : null, + }; +} + +// Records (or refreshes) one pending approval for this session. Called from +// both denyByPolicy and ask — coalescing on the gateway side means repeat +// holds for the same request arrive with the same approval_id, so this +// keeps one row per id rather than growing without bound. +function rememberPendingApproval(meta) { + if (!meta) return; + const sid = input.session_id; + if (!sid || sid === "unknown") return; + try { + const list = readPending(sid).filter((e) => e && e.id !== meta.id); + list.push({ + id: meta.id, + code: meta.code, + url: meta.url, + summary: meta.summary, + status: "pending", + notified: false, + at: new Date().toISOString(), + }); + writePending(sid, list); + } catch { /* bookkeeping must never touch the call path */ } +} + +// ≤10 ids, ≤ timeoutMs, silent on any failure — the shared fetch behind +// both UserPromptSubmit's poll and /acp-approve's refresh. Returns a Map +// keyed by approval id; empty on any failure (network, timeout, non-2xx, +// malformed body). +async function refreshApprovals(ids, timeoutMs) { + const list = Array.isArray(ids) ? ids.filter((x) => typeof x === "string" && x).slice(0, 10) : []; + if (!list.length || !token) return new Map(); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(`${ACP_API}/plugin/intents/approvals?ids=${encodeURIComponent(list.join(","))}`, { + method: "GET", + headers, + signal: controller.signal, + }); + if (!res.ok) return new Map(); + const body = await res.json().catch(() => null); + const approvals = Array.isArray(body?.approvals) ? body.approvals : []; + return new Map(approvals.filter((a) => a && typeof a.id === "string").map((a) => [a.id, a])); + } catch { + return new Map(); + } finally { + clearTimeout(timer); + } +} + +// Merges a refreshApprovals() Map onto locally-held pending entries. Marks +// `notified` on any entry that has left "pending" so a later /acp-approve +// read (or the next UserPromptSubmit poll) does not re-announce it. +function mergeApprovalUpdates(entries, fresh) { + return entries.map((e) => { + const a = fresh.get(e.id); + if (!a) return e; + const status = typeof a.status === "string" ? a.status : e.status; + if (status === e.status) return e; + return { ...e, status, code: typeof a.code === "string" && a.code ? a.code : e.code, notified: status !== "pending" ? true : e.notified }; + }); +} + +function ageString(iso) { + const ms = Date.now() - new Date(iso).getTime(); + if (!Number.isFinite(ms) || ms < 0) return ""; + const mins = Math.round(ms / 60000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m`; + return `${Math.round(mins / 60)}h`; +} + +function describeApproval(e) { + const age = e.at ? ageString(e.at) : ""; + const summary = e.summary ? stripControl(e.summary) : (e.tool || "a tool call"); + const url = e.url || `${ACP_CONSOLE}/approvals`; + return `ACP-${e.code} ${summary}${age ? ` (${age})` : ""} ${url}`; +} + +/* ── Per-turn receipt counters (#1348): reset every Stop, unlike the + * cumulative session totals in receiptStatsPath (reset only at + * SessionEnd). `held` is bumped directly from denyByPolicy/ask, since a + * held call never reaches PostToolUse. ── */ +function turnStatsPath(sessionId) { + const safe = String(sessionId).replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 128); + return join(SESSION_STATS_DIR, `${safe}.turn.json`); +} + +function readTurnStats(sessionId) { + try { + const raw = JSON.parse(readFileSync(turnStatsPath(sessionId), "utf8")); + return { + calls: Number(raw.calls) || 0, + flagged: Number(raw.flagged) || 0, + shadow: Number(raw.shadow) || 0, + cost: Number(raw.cost) || 0, + held: Number(raw.held) || 0, + denied: Number(raw.denied) || 0, + }; + } catch { + return { calls: 0, flagged: 0, shadow: 0, cost: 0, held: 0, denied: 0 }; + } +} + +function bumpTurnStats(sessionId, delta) { + if (!sessionId || sessionId === "unknown") return; + try { + mkdirSync(SESSION_STATS_DIR, { recursive: true }); + const cur = readTurnStats(sessionId); + writeFileSync(turnStatsPath(sessionId), JSON.stringify({ + calls: cur.calls + (delta.calls ?? 0), + flagged: cur.flagged + (delta.flagged ?? 0), + shadow: cur.shadow + (delta.shadow ?? 0), + cost: cur.cost + (delta.cost ?? 0), + held: cur.held + (delta.held ?? 0), + denied: (cur.denied ?? 0) + (delta.denied ?? 0), + })); + } catch { /* bookkeeping must never touch the call path */ } +} + +function clearTurnStats(sessionId) { + try { unlinkSync(turnStatsPath(sessionId)); } catch { /* absent is fine */ } +} + +/* ── UNGOVERNED outage dedup (#1352 part 3) ── + * Scoped only to failPostureOnOutage's interactive fail-open branch: the + * unattended fail-closed branch already blocks and already logs every + * call, and the separate no-credentials / key-rejected banners are a + * different failure category with their own once-per-session dedupe. */ +const OUTAGE_STATE_PATH = join(ACP_DIR, "outage-state.json"); + +function readOutageState() { + try { + const raw = JSON.parse(readFileSync(OUTAGE_STATE_PATH, "utf8")); + return raw && typeof raw === "object" ? raw : null; + } catch { + return null; + } +} + +function writeOutageState(state) { + try { + mkdirSync(ACP_DIR, { recursive: true }); + writeFileSync(OUTAGE_STATE_PATH, JSON.stringify(state)); + } catch { /* bookkeeping must never touch the call path */ } +} + +function clearOutageState() { + try { unlinkSync(OUTAGE_STATE_PATH); } catch { /* absent is fine */ } +} + +// Called on every interactive fail-open lapse; returns the updated state +// (or null on a write failure, treated as "first of outage" by the caller +// so the banner still fires rather than going silently missing). +function markOutageCall() { + try { + const cur = readOutageState() || { since: new Date().toISOString(), calls: 0 }; + cur.calls = (Number(cur.calls) || 0) + 1; + writeOutageState(cur); + return cur; + } catch { + return null; + } +} + +// Called whenever the gateway answers with a real verdict. If an outage was +// recorded, clears it and returns the human-facing recovery line; else +// null. Never throws, never blocks. +function checkOutageRecovery() { + try { + const state = readOutageState(); + if (!state || !state.calls) return null; + clearOutageState(); + const n = Number(state.calls) || 0; + return `[ACP] Governed again; ${n} call${n === 1 ? "" : "s"} ran unchecked, logged to ~/.acp/lapse.log.`; + } catch { + return null; + } +} + function clearReceiptStats(sessionId) { try { unlinkSync(receiptStatsPath(sessionId)); } catch { /* absent is fine */ } try { @@ -2080,8 +2440,14 @@ function clearReceiptStats(sessionId) { } function buildReceiptLine(stats, sessionId) { - if (!stats || stats.calls <= 0) return null; + if (!stats) return null; + // Held and denied calls never reach PostToolUse, so a session made only + // of them has calls == 0 and still earns a receipt: those are exactly the + // lines a human must see. + if (stats.calls <= 0 && !(stats.held > 0) && !(stats.denied > 0)) return null; const parts = [`${stats.calls} tool call${stats.calls === 1 ? "" : "s"} governed`]; + if (stats.held > 0) parts.push(`${stats.held} held for approval`); + if (stats.denied > 0) parts.push(`${stats.denied} denied`); if (stats.flagged > 0) parts.push(`${stats.flagged} flagged`); if (stats.notices > 0) parts.push(`${stats.notices} shadow notice${stats.notices === 1 ? "" : "s"}`); // A shadow notice only ever fires in audit mode, so notices > 0 means @@ -2091,20 +2457,57 @@ function buildReceiptLine(stats, sessionId) { 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 -// deep link to THIS session's timeline. Purely local (reads the counters -// PostToolUse kept) — no network, no latency, silent when the session -// used no tools. The counters are cleared so a resumed session starts a -// fresh receipt, and stale files from crashed sessions are pruned. +// Per-turn line (#1348): fires on Stop, which in Claude Code is every +// assistant turn — NOT session end. Silent unless this turn actually did +// something (a held call, a flag, a shadow notice or a cost notice) or +// there are held calls still waiting from an earlier turn. Only the turn +// window is cleared here; the cumulative session totals (receiptStatsPath) +// now live until handleSessionEnd, so a long session's real receipt is not +// lost to the very first Stop. function handleStop() { // stop_hook_active means WE are inside a stop-hook continuation — // never loop or double-print the receipt. if (input.stop_hook_active) process.exit(0); + const sid = input.session_id ?? "unknown"; + try { + const turn = readTurnStats(sid); + const pending = readPending(sid).filter((e) => e && e.status === "pending"); + const parts = []; + if (turn.held || turn.denied || turn.flagged || turn.shadow || turn.cost) { + const bits = []; + if (turn.held) bits.push(`${turn.held} held`); + if (turn.denied) bits.push(`${turn.denied} denied`); + if (turn.flagged) bits.push(`${turn.flagged} flagged`); + if (turn.shadow) bits.push(`${turn.shadow} shadow notice${turn.shadow === 1 ? "" : "s"}`); + if (turn.cost) bits.push(`${turn.cost} cost notice${turn.cost === 1 ? "" : "s"}`); + parts.push(`[ACP] This turn: ${bits.join(", ")}.`); + } + if (pending.length) { + const lines = pending.slice(0, 5).map((e) => ` ${describeApproval(e)}`); + parts.push(`[ACP] Still pending approval (/acp-approve):\n${lines.join("\n")}`); + } + if (parts.length) process.stdout.write(JSON.stringify({ systemMessage: parts.join("\n") })); + } catch { /* a receipt failure must never disturb the turn */ } + clearTurnStats(sid); + process.exit(0); +} + +// Real totals receipt on the harness session-end event (#1348): what ACP +// governed this session, anything it said, and a deep link to THIS +// session's timeline. Purely local (reads the counters PostToolUse kept) +// — no network, no latency, silent when the session used no tools. +// Cumulative counters, the turn window and the pending-approvals list are +// all cleared here so a resumed session starts a fresh receipt; stale +// files from crashed sessions are pruned by the same sweep. +function handleSessionEnd() { + const sid = input.session_id ?? "unknown"; try { - const msg = buildReceiptLine(readReceiptStats(input.session_id ?? "unknown"), input.session_id ?? "unknown"); + const msg = buildReceiptLine(readReceiptStats(sid), sid); if (msg) process.stdout.write(JSON.stringify({ systemMessage: msg })); } catch { /* a receipt failure must never disturb session end */ } - clearReceiptStats(input.session_id ?? "unknown"); + clearReceiptStats(sid); + clearTurnStats(sid); + try { unlinkSync(pendingPath(sid)); } catch { /* absent is fine */ } process.exit(0); } @@ -2125,7 +2528,7 @@ function handleStop() { // 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|why)$/; +const INTENT_COMMAND_RE = /(?:^|:)acp-(enforce|audit|allow|ask|deny|apply|status|why|approve)$/; function blockExpansion(reason) { process.stdout.write(JSON.stringify({ decision: "block", reason })); @@ -2146,6 +2549,36 @@ async function handleUserPromptExpansion() { blockExpansion(explainHeld(readHeld(input.session_id ?? "unknown"))); } + // /acp-approve [code]: local-first, like /acp-why — the pending list was + // already recorded on this machine when the call was held. With a token + // it also takes one best-effort refresh so a code approved from the + // console shows as approved here without waiting for the next prompt's + // UserPromptSubmit poll. Never approves anything itself (#1346 §1) — the + // console page does that, under its own auth. + if (kind === "approve") { + const sid = input.session_id ?? "unknown"; + let list = readPending(sid); + if (token) { + const ids = list.filter((e) => e.status === "pending").map((e) => e.id); + if (ids.length) { + const fresh = await refreshApprovals(ids, 4000); + if (fresh.size) { + list = mergeApprovalUpdates(list, fresh); + writePending(sid, list); + } + } + } + const pending = list.filter((e) => e.status === "pending"); + const wanted = target.replace(/^ACP-/i, "").toLowerCase(); + const filtered = target ? pending.filter((e) => String(e.code).toLowerCase() === wanted) : pending; + if (!filtered.length) { + blockExpansion(target + ? `[ACP] No pending held call matches "${target}" in this session.` + : `[ACP] Nothing held for approval in this session.`); + } + blockExpansion(`[ACP] Held, waiting for approval:\n${filtered.map((e) => ` ${describeApproval(e)}`).join("\n")}`); + } + if (!token) { blockExpansion(`[ACP] Not connected — /acp-${kind} needs a workspace key. Run /acp-connect first.`); } @@ -2226,9 +2659,51 @@ async function handleUserPromptExpansion() { } } +/* ------------------------------------------------------------------ */ +/* UserPromptSubmit — approval poll on each human prompt (#1350 part 2) */ +/* ------------------------------------------------------------------ */ + +// Runs on every prompt the human sends. If this session has calls waiting +// on the local pending-approvals list, take one bounded (≤1.5s), silent- +// on-failure look at their live status; a code that just turned "approved" +// gets a model-facing line telling it to retry the identical call now. +// Never blocks the prompt — absent, timed out, or nothing new, this hook +// simply passes through. +async function handleUserPromptSubmit() { + const sid = input.session_id ?? "unknown"; + try { + // Subagents don't drive the human's prompt stream. + if (typeof input.agent_id === "string" && input.agent_id) process.exit(0); + if (!token) process.exit(0); + const list = readPending(sid); + const pendingIds = list.filter((e) => e && e.status === "pending").map((e) => e.id); + if (!pendingIds.length) process.exit(0); + const fresh = await refreshApprovals(pendingIds, 1500); + if (!fresh.size) process.exit(0); + const merged = mergeApprovalUpdates(list, fresh); + const newlyApproved = merged.filter((e, i) => e.status === "approved" && list[i]?.status === "pending"); + writePending(sid, merged); + if (!newlyApproved.length) process.exit(0); + const context = newlyApproved.map((e) => `ACP-${e.code} was approved — retry the identical call now.`).join("\n"); + // Codex only honors additionalContext on SessionStart; Copilot's + // dialect is unconfirmed for this field outside it. Fall back to the + // human channel on both rather than send a field the harness ignores. + process.stdout.write(JSON.stringify( + HARNESS === "claude-code" + ? { hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: context } } + : { systemMessage: context }, + )); + } catch { + // silent — an approval-status miss must never block the prompt + } + process.exit(0); +} + 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 === "SessionEnd") handleSessionEnd(); +else if (hookEvent === "UserPromptSubmit") handleUserPromptSubmit(); else if (hookEvent === "UserPromptExpansion") handleUserPromptExpansion(); else handlePreToolUse(); diff --git a/commands/acp-approve.md b/commands/acp-approve.md new file mode 100644 index 0000000..1e00ac0 --- /dev/null +++ b/commands/acp-approve.md @@ -0,0 +1,9 @@ +--- +name: acp-approve +description: List this session's held calls waiting for approval (optionally one code), each with its console link. +user-invocable: true +--- + +This command is handled by the ACP hook before it reaches you: the hook prints, for the human, what is waiting for approval in this session — not the model. 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." diff --git a/hooks/hooks.json b/hooks/hooks.json index d1b45b0..1eac147 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -59,6 +59,30 @@ } ] } + ], + "SessionEnd": [ + { + "matcher": ".*", + "hooks": [ + { + "type": "command", + "command": "node \"$CLAUDE_PLUGIN_ROOT/bin/govern.mjs\"", + "timeout": 5 + } + ] + } + ], + "UserPromptSubmit": [ + { + "matcher": ".*", + "hooks": [ + { + "type": "command", + "command": "node \"$CLAUDE_PLUGIN_ROOT/bin/govern.mjs\"", + "timeout": 5 + } + ] + } ] } } diff --git a/plugin.json b/plugin.json index eef7f67..e642fba 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "agentic-control-plane", - "version": "0.28.0", + "version": "0.29.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/approvals-and-notices.test.mjs b/test/approvals-and-notices.test.mjs new file mode 100644 index 0000000..92cf6c2 --- /dev/null +++ b/test/approvals-and-notices.test.mjs @@ -0,0 +1,229 @@ +// Tests for the #1346/#1350 approval-refresh loop and the #1349 +// agent_notice channel in bin/govern.mjs: +// - UserPromptSubmit polls GET /plugin/intents/approvals for this +// session's pending held calls and, for any that just turned +// "approved", adds a model-facing retry line. +// - /acp-approve (UserPromptExpansion) lists the session's pending held +// calls from local state, same pattern as /acp-status. +// - agent_notice on a PostToolUse response reaches the model via +// hookSpecificOutput.additionalContext under Claude Code, and falls +// back to systemMessage under Codex/Copilot (neither honors +// additionalContext outside SessionStart). +// +// Run with: node --test test/approvals-and-notices.test.mjs + +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 } from "node:fs"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +// Loopback stubs are reached via ~/.acp/dev_base_override (#50); the shim +// writes it into the temp HOME when ACP_TEST_DEV_BASE=1 is in the spawn env. +import "./_dev-base.mjs"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const GOVERN = join(ROOT, "bin", "govern.mjs"); +const LOOPBACK = [127, 0, 0, 1].join("."); + +let HOME; +let server; +let baseUrl; +let seen = []; +let nextToolUse = { decision: "allow" }; +let nextToolOutput = { action: "pass" }; +let nextApprovals = { approvals: [] }; + +before(async () => { + HOME = mkdtempSync(join(tmpdir(), "acp-approvals-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", () => { + seen.push({ method: req.method, url: req.url }); + res.setHeader("content-type", "application/json"); + if (req.url.startsWith("/plugin/intents/approvals")) { + res.end(JSON.stringify(nextApprovals)); + } else if (req.url.endsWith("/govern/tool-use")) { + res.end(JSON.stringify(nextToolUse)); + } else if (req.url.endsWith("/govern/tool-output")) { + res.end(JSON.stringify(nextToolOutput)); + } else { + res.end(JSON.stringify({ ok: true })); + } + }); + }); + 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(() => { + seen = []; + nextToolUse = { decision: "allow" }; + nextToolOutput = { action: "pass" }; + nextApprovals = { approvals: [] }; +}); + +function runHook(input, env = {}) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [GOVERN], { + env: { HOME, PATH: process.env.PATH, ACP_TEST_DEV_BASE: "1", ACP_GOVERN_BASE: baseUrl, ACP_API_BASE: baseUrl, ...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", (status) => { + clearTimeout(killer); + try { + assert.equal(status, 0, `hook exited ${status}: ${stderr}`); + resolve(stdout.trim() ? JSON.parse(stdout) : null); + } catch (e) { + reject(e); + } + }); + child.stdin.write(JSON.stringify(input)); + child.stdin.end(); + }); +} + +const preToolUse = (sid, overrides = {}) => ({ + hook_event_name: "PreToolUse", tool_name: "Bash", tool_input: { command: "rm -rf build/" }, + session_id: sid, permission_mode: "default", ...overrides, +}); +const promptSubmit = (sid, overrides = {}) => ({ + hook_event_name: "UserPromptSubmit", session_id: sid, ...overrides, +}); +const expansion = (sid, overrides = {}) => ({ + hook_event_name: "UserPromptExpansion", + command_name: "agentic-control-plane:acp-approve", + command_args: "", session_id: sid, ...overrides, +}); + +const APPROVAL_ID = "5f00abcd-0000-4000-8000-000000000000"; +async function holdOneCall(sid) { + nextToolUse = { + decision: "ask", reason: "approval required", kind: "delegate", + approval_id: APPROVAL_ID, approval_code: "5F00", + approval_url: `https://cloud.agenticcontrolplane.com/approvals?id=${APPROVAL_ID}`, + approval_summary: "Bash · rm -rf build/", + }; + await runHook(preToolUse(sid)); +} + +test("UserPromptSubmit polls approvals for pending ids and stays silent when nothing changed", async () => { + const sid = "sess-poll-nochange"; + await holdOneCall(sid); + nextApprovals = { approvals: [{ id: APPROVAL_ID, code: "5F00", status: "pending", summary: "x", created_at: new Date().toISOString() }] }; + const out = await runHook(promptSubmit(sid)); + const req = seen.find((s) => s.url.startsWith("/plugin/intents/approvals")); + assert.ok(req, "expected a GET to /plugin/intents/approvals"); + assert.ok(req.url.includes(APPROVAL_ID), "the pending id should be in the query"); + assert.equal(out, null, "still pending — no new context"); +}); + +test("UserPromptSubmit adds a model-facing retry line when a held call was just approved", async () => { + const sid = "sess-poll-approved"; + await holdOneCall(sid); + nextApprovals = { approvals: [{ id: APPROVAL_ID, code: "5F00", status: "approved", summary: "x", created_at: new Date().toISOString() }] }; + const out = await runHook(promptSubmit(sid)); + assert.ok(out, "expected output on stdout"); + assert.equal(out.hookSpecificOutput?.hookEventName, "UserPromptSubmit"); + assert.match(out.hookSpecificOutput.additionalContext, /ACP-5F00 was approved — retry the identical call now\./); + + // The same approval must not be re-announced on the next prompt. + const out2 = await runHook(promptSubmit(sid)); + assert.equal(out2, null, "an already-notified approval must not repeat"); +}); + +test("UserPromptSubmit under Codex/Copilot also writes the retry line to systemMessage", async () => { + const sid = "sess-poll-codex"; + await holdOneCall(sid); + nextApprovals = { approvals: [{ id: APPROVAL_ID, code: "5F00", status: "approved", summary: "x", created_at: new Date().toISOString() }] }; + const out = await runHook(promptSubmit(sid), { ACP_HARNESS: "codex" }); + assert.equal(out.hookSpecificOutput, undefined); + assert.match(out.systemMessage, /ACP-5F00 was approved/); +}); + +test("UserPromptSubmit with no pending calls makes no network request", async () => { + const out = await runHook(promptSubmit("sess-poll-empty")); + assert.equal(out, null); + assert.equal(seen.length, 0); +}); + +test("UserPromptSubmit from a subagent never polls or prints", async () => { + const sid = "sess-poll-subagent"; + await holdOneCall(sid); + const out = await runHook(promptSubmit(sid, { agent_id: "sub-1" })); + assert.equal(out, null); + assert.equal(seen.length, 1, "only the preceding holdOneCall's tool-use request should be seen"); +}); + +test("/acp-approve lists this session's pending held calls with code, summary and link", async () => { + const sid = "sess-approve-list"; + await holdOneCall(sid); + const out = await runHook(expansion(sid)); + assert.equal(out.decision, "block"); + assert.match(out.reason, /ACP-5F00/); + assert.match(out.reason, /Bash · rm -rf build\//); + assert.match(out.reason, new RegExp(`approvals\\?id=${APPROVAL_ID}`)); +}); + +test("/acp-approve filters to the one matching call", async () => { + const sid = "sess-approve-filter"; + await holdOneCall(sid); + const out = await runHook(expansion(sid, { command_args: "ACP-5F00" })); + assert.equal(out.decision, "block"); + assert.match(out.reason, /ACP-5F00/); + + const miss = await runHook(expansion(sid, { command_args: "ACP-0000" })); + assert.match(miss.reason, /No pending held call matches/); +}); + +test("/acp-approve with nothing held says so", async () => { + const out = await runHook(expansion("sess-approve-empty")); + assert.match(out.reason, /Nothing held for approval/); +}); + +test("agent_notice on PostToolUse reaches the model via additionalContext under Claude Code", async () => { + nextToolOutput = { action: "pass", agent_notice: "Context is large — consider /acp-apply compact-earlier.", notice_lever: "context_threshold" }; + const out = await runHook({ hook_event_name: "PostToolUse", tool_name: "Read", tool_input: {}, tool_response: "x", session_id: "sess-agent-notice-cc" }); + assert.equal(out.hookSpecificOutput?.hookEventName, "PostToolUse"); + assert.match(out.hookSpecificOutput.additionalContext, /consider \/acp-apply compact-earlier/); +}); + +test("agent_notice falls back to systemMessage under Codex (no additionalContext outside SessionStart)", async () => { + nextToolOutput = { action: "pass", agent_notice: "Context is large — consider /acp-apply compact-earlier.", notice_lever: "context_threshold" }; + const out = await runHook( + { hook_event_name: "PostToolUse", tool_name: "Read", tool_input: {}, tool_response: "x", session_id: "sess-agent-notice-codex" }, + { ACP_HARNESS: "codex" }, + ); + assert.equal(out.hookSpecificOutput, undefined); + assert.match(out.systemMessage, /consider \/acp-apply compact-earlier/); +}); + +test("agent_notice control characters are stripped before reaching the model", async () => { + nextToolOutput = { action: "pass", agent_notice: "line one\u001b[31m\u0007 line two" }; + const out = await runHook({ hook_event_name: "PostToolUse", tool_name: "Read", tool_input: {}, tool_response: "x", session_id: "sess-agent-notice-clean" }); + assert.ok(!out.hookSpecificOutput.additionalContext.includes("\u001b")); + assert.ok(!out.hookSpecificOutput.additionalContext.includes("\u0007")); +}); + +test("no agent_notice → no hookSpecificOutput on PostToolUse", async () => { + nextToolOutput = { action: "pass" }; + const out = await runHook({ hook_event_name: "PostToolUse", tool_name: "Read", tool_input: {}, tool_response: "x", session_id: "sess-agent-notice-none" }); + assert.equal(out, null); +}); diff --git a/test/notices-and-outage.test.mjs b/test/notices-and-outage.test.mjs new file mode 100644 index 0000000..ee4bc0d --- /dev/null +++ b/test/notices-and-outage.test.mjs @@ -0,0 +1,156 @@ +// Tests for the #1351 SessionStart notices (upgrade_notice / catch_up) and +// the #1352 part 3 UNGOVERNED-outage dedup in bin/govern.mjs: +// - upgrade_notice (human-worded, ends "/acp-upgrade") is shown to the +// HUMAN at most once per 24h per machine, via systemMessage, and +// suppresses the older model-facing data.notice upgrade text. +// - catch_up is one human line, shown as systemMessage at SessionStart. +// - an interactive session that hits an unreachable gateway prints the +// UNGOVERNED banner once at the START of the outage, stays silent on +// later calls during the same outage, and prints a "governed again" +// recovery line exactly once when the gateway answers again. +// +// Run with: node --test test/notices-and-outage.test.mjs + +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 } from "node:fs"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +// Loopback stubs are reached via ~/.acp/dev_base_override (#50); the shim +// writes it into the temp HOME when ACP_TEST_DEV_BASE=1 is in the spawn env. +import "./_dev-base.mjs"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const GOVERN = join(ROOT, "bin", "govern.mjs"); +const LOOPBACK = [127, 0, 0, 1].join("."); + +let HOME; +let server; +let baseUrl; +let nextAttest = { ok: true }; +let nextToolUse = { decision: "allow" }; + +before(async () => { + HOME = mkdtempSync(join(tmpdir(), "acp-notices-outage-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", () => { + res.setHeader("content-type", "application/json"); + if (req.url.endsWith("/govern/attest")) { + res.end(JSON.stringify(nextAttest)); + } else if (req.url.endsWith("/govern/tool-use")) { + res.end(JSON.stringify(nextToolUse)); + } else { + res.end(JSON.stringify({ ok: true })); + } + }); + }); + 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(() => { + nextAttest = { ok: true }; + nextToolUse = { decision: "allow" }; +}); + +function runHook(input, env = {}) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [GOVERN], { + env: { HOME, PATH: process.env.PATH, ACP_TEST_DEV_BASE: "1", ACP_GOVERN_BASE: baseUrl, ACP_API_BASE: baseUrl, ...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", (status) => { + clearTimeout(killer); + try { + assert.equal(status, 0, `hook exited ${status}: ${stderr}`); + resolve(stdout.trim() ? JSON.parse(stdout) : null); + } catch (e) { + reject(e); + } + }); + child.stdin.write(JSON.stringify(input)); + child.stdin.end(); + }); +} + +const sessionStart = (sid, overrides = {}) => ({ + hook_event_name: "SessionStart", session_id: sid, cwd: "/tmp", ...overrides, +}); +const preToolUse = (sid, overrides = {}) => ({ + hook_event_name: "PreToolUse", tool_name: "Bash", tool_input: { command: "echo hi" }, + session_id: sid, permission_mode: "default", ...overrides, +}); + +test("upgrade_notice is shown to the human once per 24h, and suppresses the old model-facing notice", async () => { + const sid = "sess-upgrade-1"; + nextAttest = { + ok: true, + notice: "[ACP] old model-facing upgrade text", + upgrade_notice: "Your ACP plugin is out of date — type /acp-upgrade to update.", + }; + const out1 = await runHook(sessionStart(sid)); + assert.match(out1.systemMessage, /type \/acp-upgrade/); + // The old data.notice must NOT also reach the model when upgrade_notice + // is present (CONTRACT §4). + assert.equal(out1.hookSpecificOutput, undefined, "old notice must not be forwarded as additionalContext"); + + // A second SessionStart within the 24h window must not repeat it. + const out2 = await runHook(sessionStart("sess-upgrade-2")); + assert.equal(out2, null, "upgrade_notice must not repeat inside the 24h window"); +}); + +test("catch_up is shown as a human systemMessage at SessionStart", async () => { + nextAttest = { ok: true, catch_up: "While you were away: 3 calls were approved." }; + const out = await runHook(sessionStart("sess-catchup-1")); + assert.match(out.systemMessage, /3 calls were approved/); +}); + +test("old data.notice still reaches the model via additionalContext when no upgrade_notice is present", async () => { + nextAttest = { ok: true, notice: "[ACP] old model-facing upgrade text" }; + const out = await runHook(sessionStart("sess-old-notice-only")); + assert.equal(out.hookSpecificOutput?.hookEventName, "SessionStart"); + assert.match(out.hookSpecificOutput.additionalContext, /old model-facing upgrade text/); +}); + +test("interactive session: UNGOVERNED fires once at the start of an outage, silent while it continues, then a single recovery line on the first call after the gateway answers again", async () => { + const sid = "sess-outage-1"; + const deadServer = createServer((req) => req.destroy()); + await new Promise((resolve) => deadServer.listen(0, LOOPBACK, resolve)); + const deadBase = `http://${LOOPBACK}:${deadServer.address().port}`; + const env = { CLAUDE_CODE_ENTRYPOINT: "cli", ACP_FIRST_ATTEMPT_MS: "300", ACP_RETRY_ATTEMPT_MS: "300" }; + + const out1 = await runHook(preToolUse(sid), { ...env, ACP_GOVERN_BASE: deadBase, ACP_API_BASE: deadBase }); + assert.equal(out1.hookSpecificOutput?.permissionDecision, "allow"); + assert.match(out1.systemMessage, /UNGOVERNED: gateway unreachable/); + + const out2 = await runHook(preToolUse(sid), { ...env, ACP_GOVERN_BASE: deadBase, ACP_API_BASE: deadBase }); + assert.equal(out2.hookSpecificOutput?.permissionDecision, "allow"); + assert.equal(out2.systemMessage, undefined, "no repeat banner while the outage continues"); + + deadServer.close(); + + const out3 = await runHook(preToolUse(sid), env); + assert.match(out3.systemMessage, /Governed again; 2 calls ran unchecked/); + + const out4 = await runHook(preToolUse(sid), env); + assert.equal(out4, null, "recovery line must not repeat on the next healthy call"); +}); diff --git a/test/turn-and-session-receipt.test.mjs b/test/turn-and-session-receipt.test.mjs new file mode 100644 index 0000000..89b3087 --- /dev/null +++ b/test/turn-and-session-receipt.test.mjs @@ -0,0 +1,187 @@ +// Tests for the #1348 receipt split in bin/govern.mjs: a per-turn line on +// Stop (silent unless the turn did something, or a held call is still +// pending) versus the real cumulative-totals receipt on SessionEnd. Before +// this change, handleStop cleared the cumulative counters on every Stop — +// which in Claude Code fires every assistant turn, not once per session — +// so a multi-turn session's receipt only ever reported the last turn. +// +// Run with: node --test test/turn-and-session-receipt.test.mjs +// +// Each test spawns the real hook against a throwaway fixture HOME, with a +// local stub standing in for /govern/tool-use, /govern/tool-output and +// /govern/attest. State (turn stats, cumulative stats, pending approvals) +// lives in files under HOME/.acp/session-stats, so it persists across the +// separate child processes each hook event spawns, exactly as it would +// across real harness events in one session. + +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 } from "node:fs"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +// Loopback stubs are reached via ~/.acp/dev_base_override (#50); the shim +// writes it into the temp HOME when ACP_TEST_DEV_BASE=1 is in the spawn env. +import "./_dev-base.mjs"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const GOVERN = join(ROOT, "bin", "govern.mjs"); +const LOOPBACK = [127, 0, 0, 1].join("."); + +let HOME; +let server; +let baseUrl; +let nextToolUse = { decision: "allow" }; +let nextToolOutput = { action: "pass" }; + +before(async () => { + HOME = mkdtempSync(join(tmpdir(), "acp-turn-receipt-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", () => { + res.setHeader("content-type", "application/json"); + if (req.url.endsWith("/govern/tool-use")) { + res.end(JSON.stringify(nextToolUse)); + } else if (req.url.endsWith("/govern/tool-output")) { + res.end(JSON.stringify(nextToolOutput)); + } else { + res.end(JSON.stringify({ ok: true })); + } + }); + }); + 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(() => { + nextToolUse = { decision: "allow" }; + nextToolOutput = { action: "pass" }; +}); + +function runHook(input, env = {}) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [GOVERN], { + env: { HOME, PATH: process.env.PATH, ACP_TEST_DEV_BASE: "1", ACP_GOVERN_BASE: baseUrl, ACP_API_BASE: baseUrl, ...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", (status) => { + clearTimeout(killer); + try { + assert.equal(status, 0, `hook exited ${status}: ${stderr}`); + resolve(stdout.trim() ? JSON.parse(stdout) : null); + } catch (e) { + reject(e); + } + }); + child.stdin.write(JSON.stringify(input)); + child.stdin.end(); + }); +} + +const preToolUse = (sid, overrides = {}) => ({ + hook_event_name: "PreToolUse", tool_name: "Bash", tool_input: { command: "ls" }, + session_id: sid, permission_mode: "default", ...overrides, +}); +const postToolUse = (sid, overrides = {}) => ({ + hook_event_name: "PostToolUse", tool_name: "Bash", tool_input: { command: "ls" }, + tool_response: "ok", session_id: sid, ...overrides, +}); +const stop = (sid, overrides = {}) => ({ hook_event_name: "Stop", session_id: sid, ...overrides }); +const sessionEnd = (sid, overrides = {}) => ({ hook_event_name: "SessionEnd", session_id: sid, ...overrides }); + +test("Stop is silent when the turn had no held call, flag, shadow or cost notice", async () => { + const sid = "sess-turn-silent"; + nextToolOutput = { action: "pass" }; + await runHook(postToolUse(sid)); + const out = await runHook(stop(sid)); + assert.equal(out, null); +}); + +test("Stop reports a flagged call this turn, then goes silent again next turn", async () => { + const sid = "sess-turn-flag"; + nextToolOutput = { action: "redact", reason: "redacted PII" }; + await runHook(postToolUse(sid)); + const out1 = await runHook(stop(sid)); + assert.match(out1.systemMessage, /This turn: 1 flagged/); + + nextToolOutput = { action: "pass" }; + await runHook(postToolUse(sid)); + const out2 = await runHook(stop(sid)); + assert.equal(out2, null, "turn counters must reset after Stop"); +}); + +test("a held call lists still-pending approvals on Stop, across turns, until resolved", async () => { + const sid = "sess-turn-held"; + nextToolUse = { + decision: "deny", reason: "ask a human", kind: "delegate", + approval_id: "1234abcd-0000-4000-8000-000000000000", + approval_code: "1234", + approval_url: "https://cloud.agenticcontrolplane.com/approvals?id=1234abcd", + approval_summary: "Bash · rm -rf build/", + }; + await runHook(preToolUse(sid)); + const out1 = await runHook(stop(sid)); + assert.match(out1.systemMessage, /This turn: 1 held/); + assert.match(out1.systemMessage, /Still pending approval/); + assert.match(out1.systemMessage, /ACP-1234/); + assert.match(out1.systemMessage, /Bash · rm -rf build\/|https:\/\/cloud\.agenticcontrolplane\.com\/approvals\?id=1234abcd/); + + // Next turn: the "this turn" bucket is empty again, but the call is + // still pending, so the reminder must persist. + const out2 = await runHook(stop(sid)); + assert.ok(!out2.systemMessage.includes("This turn:"), "no new activity this turn"); + assert.match(out2.systemMessage, /ACP-1234/); +}); + +test("SessionEnd reports cumulative totals across every turn, not just the last one, then clears", async () => { + const sid = "sess-end-totals"; + nextToolOutput = { action: "pass" }; + await runHook(postToolUse(sid)); + await runHook(stop(sid)); + await runHook(postToolUse(sid)); + await runHook(stop(sid)); + await runHook(postToolUse(sid)); + const end1 = await runHook(sessionEnd(sid)); + assert.match(end1.systemMessage, /3 tool calls governed/, "totals must survive the intermediate Stops"); + + // A fresh call after SessionEnd starts a new receipt from zero. + await runHook(postToolUse(sid)); + const end2 = await runHook(sessionEnd(sid)); + assert.match(end2.systemMessage, /1 tool call governed/, "SessionEnd must clear the cumulative counters"); +}); + +test("SessionEnd is silent when the session made no governed calls", async () => { + const out = await runHook(sessionEnd("sess-end-empty")); + assert.equal(out, null); +}); + +// A denied call never reaches PostToolUse, so a session made only of denies +// has calls == 0. The human must still see it: on the turn line and on the +// end-of-session receipt (review finding on #48). +test("a policy deny is counted on the turn line and earns a receipt even with no allowed calls", async () => { + const sid = "sess-deny-only"; + nextToolUse = { decision: "deny", reason: "no rm -rf here", kind: "terminal" }; + await runHook(preToolUse(sid)); + const out1 = await runHook(stop(sid)); + assert.match(out1.systemMessage, /This turn: 1 denied/); + assert.ok(!out1.systemMessage.includes("held"), "a plain deny is not a hold"); + const out2 = await runHook(sessionEnd(sid)); + assert.match(out2.systemMessage, /Session receipt: 0 tool calls governed · 1 denied/); +});