diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index adfe3b8..28a175b 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.14.0", + "version": "0.15.0", "author": { "name": "GatewayStack" }, diff --git a/README.md b/README.md index 8d7e711..8440ac1 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,22 @@ When ACP denies a call, the plugin tells you why with a distinct prefix so you c - `[ACP] Gateway error — tool blocked for safety (HTTP X)` — ACP responded with an error (e.g. auth, server crash) - `[ACP] Gateway unreachable — tool blocked for safety` — ACP didn't respond at all (timeout, network) +### Context guard (v0.15.0+, off by default) + +Whole-file reads are the cheapest thing an agent does and the most expensive thing it puts into a frontier model's context. The hook sizes a read **before** it happens — `Read` (offset/limit-aware) and `cat` / `head` / `tail` / `less` / `more` / `bat` — and sends the line and byte count to the gateway (or the local engine) as `tool_context`. Targeted reads always pass: offset/limit, `head -n 20`, pipes (`cat f | grep x`), redirects, byte ranges, `tail -f`. + +The policy block lives on the governance doc (console → Policies → Context guard) or in `~/.acp/policy.json` for local mode: + +```json +"contextGuard": { "maxLines": 350, "mode": "shadow", "action": "deny" } +``` + +- `shadow` — allow, but every audit row for a read over the ceiling carries `contextGuard.estTokens`: what it would have put into context. Watch this ledger first. +- `enforce` — deny (or `ask`) with a reason that always names the sanctioned path: read the section you need, grep for the symbol, or hand the read to a subagent so it stays out of this context. Codex gets `sed -n 'START,ENDp'` instead of offset/limit. +- No `tool_context` (older hook, unreadable file) — not guarded, never a lapse. The tool surfaces its own error for a missing file. + +The ledger counts tokens the frontier model never saw. It does not count what the agent did instead, so read it next to run cost before enforcing. + ### Cross-architecture credential brokering (v0.5.0+, opt-in) When your workspace has **scoped tokens** enabled (`policies.scopedTokensEnabled: true` in your tenant config), the plugin recognizes calls to known vendors — currently `gh`, `curl api.github.com`, and `git push https://github.com/…` — and: diff --git a/bin/decide.mjs b/bin/decide.mjs index ad7f58b..733c28d 100644 --- a/bin/decide.mjs +++ b/bin/decide.mjs @@ -355,6 +355,153 @@ export function hardlineFloor(toolName, toolInput) { return null; } +// ── Context guard ───────────────────────────────────────────────────── +// Whole-file reads are the cheapest thing an agent does and the most +// expensive thing it puts in a frontier model's context. The guard sizes a +// read BEFORE it happens: Read (offset/limit-aware) and the shell dumpers +// (cat/head/tail/less/more/bat). Piped or redirected dumps pass — those are +// targeted. readIntent() is pure parsing; the dispatcher counts the lines +// (I/O) and hands them back as `context.files`. + +const DUMP_BINS = new Set(["cat", "head", "tail", "less", "more", "bat", "batcat"]); + +/** Split on unquoted | & ; newline, keeping the operator that FOLLOWS each + * segment so a pipe after a dump can be told from a chain before it. */ +function splitSegmentsWithOps(cmd) { + const out = []; + let buf = ""; + let quote = null; + for (const ch of String(cmd)) { + if (quote) { buf += ch; if (ch === quote) quote = null; continue; } + if (ch === '"' || ch === "'") { quote = ch; buf += ch; continue; } + if (ch === "|" || ch === "&" || ch === ";" || ch === "\n") { + if (buf.trim()) out.push({ seg: buf.trim(), op: ch }); + buf = ""; + continue; + } + buf += ch; + } + if (buf.trim()) out.push({ seg: buf.trim(), op: "" }); + return out; +} + +function unquote(t) { return String(t).replace(/^(['"])(.*)\1$/, "$2"); } +function toCount(v) { if (v === null || v === undefined || v === "") return null; const n = Number(v); return Number.isFinite(n) && n >= 0 ? Math.floor(n) : null; } + +/** head/tail line limits: -N, -nN, -n N, --lines=N, --lines N. Byte ranges + * (-c) and follow (-f) are targeted by construction. */ +function dumpArgs(bin, args) { + let limit = null; + let targeted = false; + const paths = []; + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === "--") { for (const rest of args.slice(i + 1)) paths.push(unquote(rest)); break; } + if (!a.startsWith("-") || a === "-") { paths.push(unquote(a)); continue; } + if (bin === "head" || bin === "tail") { + let m; + if ((m = a.match(/^-(\d+)$/))) { limit = toCount(m[1]); continue; } + if ((m = a.match(/^-n\+?(\d+)$/))) { limit = toCount(m[1]); continue; } + if ((m = a.match(/^--lines=\+?(\d+)$/))) { limit = toCount(m[1]); continue; } + if (a === "-n" || a === "--lines") { limit = toCount(String(args[i + 1] || "").replace(/^\+/, "")); i++; continue; } + if (a === "-c" || a === "--bytes") { targeted = true; i++; continue; } + if (/^-c\d+$/.test(a) || /^--bytes=/.test(a)) { targeted = true; continue; } + if (a === "-f" || a === "-F" || a === "--follow") { targeted = true; continue; } + } + if (bin === "bat" || bin === "batcat") { + let m; + if ((m = a.match(/^(?:-r|--line-range)=?(\d+):(\d+)$/))) { limit = Math.max(0, toCount(m[2]) - toCount(m[1]) + 1); continue; } + if (a === "-r" || a === "--line-range") { const r = String(args[i + 1] || "").match(/^(\d+):(\d+)$/); if (r) limit = Math.max(0, toCount(r[2]) - toCount(r[1]) + 1); i++; continue; } + } + } + // head/tail with no explicit count print 10 lines. + if ((bin === "head" || bin === "tail") && limit === null && !targeted) limit = 10; + return { limit, targeted, paths }; +} + +/** + * What would this call pull into context? Returns null when the call is not + * a whole-file read, else { via, paths, offset, limit, targeted }: + * via "Read" | "Bash.cat" | "Bash.head" | … + * paths files the dispatcher should size + * offset 1-based start line (Read) — lines before it are not read + * limit max lines read, or null for "to the end" + * targeted true when the shape is already narrow (byte-ranged, followed); + * the guard never blocks these + */ +export function readIntent(toolName, toolInput) { + const name = String(toolName || ""); + const input = typeof toolInput === "string" ? safeParse(toolInput) : (toolInput || {}); + + if (name === "Read" || name === "read_file") { + const path = input.file_path || input.path || input.target_file || ""; + if (!path) return null; + const offset = toCount(input.offset); + const limit = toCount(input.limit); + return { via: "Read", paths: [String(path)], offset: offset && offset > 0 ? offset : null, limit: limit && limit > 0 ? limit : null, targeted: false }; + } + + if (name === "Bash" || name === "run_terminal_cmd" || name === "shell") { + const raw = input.command || input.cmd || ""; + const cmd = Array.isArray(raw) ? raw.map(String).join(" ") : String(raw); + if (!cmd.trim()) return null; + for (const { seg, op } of splitSegmentsWithOps(cmd)) { + const { bin, args } = parseCommand(seg); + if (!DUMP_BINS.has(bin)) continue; + if (op === "|") continue; // dump feeds a filter — targeted + if (/(^|[^\\])>/.test(seg)) continue; // redirected to a file — never enters context + const { limit, targeted, paths } = dumpArgs(bin, args); + const files = paths.filter((p) => p && p !== "-"); + if (!files.length) continue; // reads stdin + return { via: `Bash.${bin}`, paths: files, offset: null, limit, targeted }; + } + return null; + } + return null; +} + +const GUARD_MODES = new Set(["off", "shadow", "enforce"]); + +/** + * Size a read against the guard. `files` maps path → { lines, bytes } as the + * dispatcher measured them (unmeasured paths are skipped — the tool surfaces + * its own not-found error). Returns null when nothing applies, else + * { mode, action, lines, effectiveLines, estTokens, maxLines, paths, via, guidance }. + */ +export function contextGuard(intent, files, guard) { + if (!intent || !guard || typeof guard !== "object") return null; + const mode = GUARD_MODES.has(guard.mode) ? guard.mode : "off"; + if (mode === "off") return null; + const maxLines = toCount(guard.maxLines); + if (!maxLines) return null; + if (intent.targeted) return null; + const known = intent.paths.map((p) => [p, files && files[p]]).filter(([, f]) => f && Number.isFinite(f.lines)); + if (!known.length) return null; + let lines = 0, effectiveLines = 0, bytes = 0; + for (const [, f] of known) { + lines += f.lines; + let eff = f.lines; + if (intent.offset) eff = Math.max(0, eff - (intent.offset - 1)); + if (intent.limit !== null && intent.limit !== undefined) eff = Math.min(eff, intent.limit); + effectiveLines += eff; + bytes += Number.isFinite(f.bytes) ? f.bytes : 0; + } + if (effectiveLines <= maxLines) return null; + const avgBytesPerLine = lines > 0 && bytes > 0 ? bytes / lines : 40; + const estTokens = Math.round((effectiveLines * avgBytesPerLine) / 4); + const action = guard.action === "ask" ? "ask" : "deny"; + return { mode, action, lines, effectiveLines, estTokens, maxLines, paths: known.map(([p]) => p), via: intent.via, + guidance: typeof guard.guidance === "string" && guard.guidance.trim() ? guard.guidance.trim() : "" }; +} + +/** Default steer, per harness. A block always names the sanctioned path. */ +export function contextGuardSteer(harness) { + if (harness === "codex") { + return "Read just the section you need (sed -n 'START,ENDp' FILE, or grep -n PATTERN FILE), or send the whole-file read to a subagent so it stays out of this context."; + } + return "Read just the section you need (offset/limit, or grep for the symbol), or hand the whole-file read to a subagent so it stays out of this context."; +} + /** * Walk a dotted key from most-specific to least, e.g. * "Bash.curl.api.github.com" → [..., "Bash.curl", "Bash"]. @@ -371,16 +518,30 @@ const SEVERITY = { allow: 0, ask: 1, deny: 2 }; /** * Decide a tool call locally. - * @param policy { default: "allow"|"ask"|"deny", rules: { [key]: "allow"|"ask"|"deny" } } - * @returns { decision, reason, source, classified } + * @param policy { default: "allow"|"ask"|"deny", rules: { [key]: "allow"|"ask"|"deny" }, + * contextGuard?: { maxLines, mode: "off"|"shadow"|"enforce", action?: "deny"|"ask", guidance? } } + * @param context { files?: { [path]: { lines, bytes } }, harness?: string } — measured by the dispatcher + * @returns { decision, reason, source, classified, contextGuard? } */ -export function decide(toolName, toolInput, policy) { +export function decide(toolName, toolInput, policy, context) { const floor = hardlineFloor(toolName, toolInput); if (floor) return { decision: "deny", reason: floor, source: "hardline", classified: classifyTool(toolName, toolInput) }; const key = classifyTool(toolName, toolInput); const rules = (policy && policy.rules) || {}; + // Context guard: a sized read over the line ceiling. Enforce → deny/ask + // with the steer; shadow → decide as usual, but carry what would have + // happened (and the tokens it would have kept out of context) so the + // audit line records it. + const guard = contextGuard(readIntent(toolName, toolInput), context && context.files, policy && policy.contextGuard); + if (guard && guard.mode === "enforce") { + const steer = guard.guidance || contextGuardSteer(context && context.harness); + return { decision: guard.action, source: "context-guard", classified: key, contextGuard: guard, + reason: `whole-file read of ${guard.effectiveLines} lines (ceiling ${guard.maxLines}; ~${guard.estTokens} tokens into context). ${steer}` }; + } + const shadow = guard && guard.mode === "shadow" ? guard : undefined; + // EVERY unit of a compound command is policy-checked, and the strictest // matched rule wins (deny > ask > allow) — so `true && gcloud …` cannot // slip a gcloud rule behind a benign first segment (#18). @@ -396,8 +557,8 @@ export function decide(toolName, toolInput, policy) { } } } - if (hit) return { decision: hit.r, reason: `local policy: ${hit.cand} → ${hit.r}`, source: "policy", classified: key }; + if (hit) return { decision: hit.r, reason: `local policy: ${hit.cand} → ${hit.r}`, source: "policy", classified: key, contextGuard: shadow }; const def = VALID.has(policy && policy.default) ? policy.default : "allow"; - return { decision: def, reason: `local policy: default → ${def}`, source: "default", classified: key }; + return { decision: def, reason: `local policy: default → ${def}`, source: "default", classified: key, contextGuard: shadow }; } diff --git a/bin/govern.mjs b/bin/govern.mjs index 8a565a4..5ca25fd 100644 --- a/bin/govern.mjs +++ b/bin/govern.mjs @@ -45,7 +45,7 @@ import { readFileSync, appendFileSync, existsSync, mkdirSync, writeFileSync, unlinkSync, readdirSync, statSync } from "fs"; import { homedir } from "os"; -import { join } from "path"; +import { join, isAbsolute } from "path"; import { createHash } from "crypto"; import { pathToFileURL, fileURLToPath } from "url"; @@ -66,7 +66,7 @@ const ACP_GOVERN = process.env.ACP_API_BASE || "https://govern.agenticcontrolplane.com"; -const PLUGIN_VERSION = "0.14.0"; +const PLUGIN_VERSION = "0.15.0"; // Console base for user-facing deep links (session receipt, #606). const ACP_CONSOLE = @@ -123,6 +123,47 @@ function clearPendingLapse(sessionId) { try { unlinkSync(lapsePendingPath(sessionId)); } catch { /* absent is fine */ } } +// Context guard (gatewaystack-connect#1072): size a whole-file read before +// it happens. The engine's readIntent() says WHICH files a Read / cat / head +// / tail would pull into context; this measures them (lines + bytes) so the +// decision — local or gateway — can compare against the policy ceiling. Pure +// measurement, bounded (files over MEASURE_CAP_BYTES are estimated from +// size), never throws: an unmeasurable file is simply not guarded. +const MEASURE_CAP_BYTES = 8 * 1024 * 1024; +function measureFiles(paths, cwd) { + const out = {}; + for (const p of paths || []) { + try { + const abs = isAbsolute(p) ? p : join(cwd || process.cwd(), p); + const st = statSync(abs); + if (!st.isFile()) continue; + if (st.size > MEASURE_CAP_BYTES) { out[p] = { lines: Math.ceil(st.size / 40), bytes: st.size, estimated: true }; continue; } + const buf = readFileSync(abs); + let lines = 0; + for (let i = 0; i < buf.length; i++) if (buf[i] === 10) lines++; + if (buf.length && buf[buf.length - 1] !== 10) lines++; + out[p] = { lines, bytes: buf.length }; + } catch { /* missing / unreadable: the tool surfaces its own error */ } + } + return out; +} + +/** { read, files } for the wire and the local engine, or undefined when the + * call is not a whole-file read. One parser (decide.mjs readIntent) sizes + * reads for every harness; the gateway does arithmetic, never shell syntax. */ +async function readContext(input) { + try { + let mod; + try { mod = await import(pathToFileURL(join(ACP_DIR, "decide.mjs")).href); } + catch { mod = await import("./decide.mjs"); } + if (typeof mod.readIntent !== "function") return undefined; + const intent = mod.readIntent(input.tool_name, input.tool_input); + if (!intent || intent.targeted) return undefined; + const files = measureFiles(intent.paths, input.cwd); + return Object.keys(files).length ? { read: intent, files } : undefined; + } catch { return undefined; } +} + // 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 @@ -368,9 +409,15 @@ async function runLocal(input) { return; } } - const d = decide(input.tool_name, input.tool_input, policy); + const ctx = await readContext(input); + const d = decide(input.tool_name, input.tool_input, policy, { ...(ctx || {}), harness: HARNESS }); audit({ ts: new Date().toISOString(), event: "pre", client: ACP_CLIENT, tool: input.tool_name, - classified: d.classified, decision: d.decision, source: d.source, reason: d.reason }); + classified: d.classified, decision: d.decision, source: d.source, reason: d.reason, + // Context guard ledger: what a whole-file read would have put in + // context, whether it was blocked (enforce) or only measured (shadow). + ...(d.contextGuard ? { contextGuard: { mode: d.contextGuard.mode, via: d.contextGuard.via, lines: d.contextGuard.lines, + effectiveLines: d.contextGuard.effectiveLines, estTokens: d.contextGuard.estTokens, + maxLines: d.contextGuard.maxLines } } : {}) }); if (d.decision === "deny") { process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: `[ACP] ${d.reason}` }, @@ -538,6 +585,9 @@ async function requestScopedToken(provider) { /* ------------------------------------------------------------------ */ async function handlePreToolUse() { + // Context guard: the gateway cannot see this machine's files, so the + // hook measures what a whole-file read would pull in and sends it along. + const toolContext = await readContext(input); const body = JSON.stringify({ tool_name: input.tool_name, tool_input: input.tool_input, @@ -548,6 +598,7 @@ async function handlePreToolUse() { agent_tier: resolveAgentTier(), permission_mode: input.permission_mode, tier_signals: tierSignals(), + ...(toolContext ? { tool_context: toolContext } : {}), }); // A deny is a control-flow event, not a full stop (gatewaystack-connect#692). diff --git a/plugin.json b/plugin.json index 06cf4c7..d43b05e 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "agentic-control-plane", - "version": "0.14.0", + "version": "0.15.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/context-guard.test.mjs b/test/context-guard.test.mjs new file mode 100644 index 0000000..ed1e202 --- /dev/null +++ b/test/context-guard.test.mjs @@ -0,0 +1,222 @@ +// Context guard (gatewaystack-connect#1072): whole-file reads over a line +// ceiling are sized BEFORE they happen. Ported from Spotify's shunt plugin +// evals (17 Read cases + 17 Bash cases), then extended where their hook had +// known bypasses (offset 0 / limit 0 pass, `head -100` blocks, `head -n 5` +// passes only by accident) — here the EFFECTIVE read is what's measured. +// +// Run with: node --test test/context-guard.test.mjs +// +// Layer 1 tests the pure engine (readIntent + contextGuard + decide). +// Layer 2 spawns the real hook in LOCAL mode against a fixture HOME, so the +// measurement (line counting) and the wire shape are exercised too. + +import { test, before, after, describe } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, writeFileSync, copyFileSync, readFileSync, rmSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { readIntent, contextGuard, decide } from "../bin/decide.mjs"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const GOVERN = join(ROOT, "bin", "govern.mjs"); +const DECIDE = join(ROOT, "bin", "decide.mjs"); + +const FILES = { "/x/big": { lines: 1200, bytes: 48000 }, "/x/small": { lines: 100, bytes: 4000 }, "/x/edge": { lines: 350, bytes: 14000 }, "/x/over": { lines: 351, bytes: 14040 }, "/x/empty": { lines: 0, bytes: 0 } }; +const ENFORCE = { default: "allow", rules: {}, contextGuard: { maxLines: 350, mode: "enforce" } }; +const SHADOW = { default: "allow", rules: {}, contextGuard: { maxLines: 350, mode: "shadow" } }; +const ctx = { files: FILES }; + +describe("readIntent — Read tool", () => { + test("whole-file Read is an intent; offset/limit are carried, not treated as a bypass", () => { + assert.deepEqual(readIntent("Read", { file_path: "/x/big" }), { via: "Read", paths: ["/x/big"], offset: null, limit: null, targeted: false }); + assert.equal(readIntent("Read", { file_path: "/x/big", offset: 100, limit: 50 }).limit, 50); + assert.equal(readIntent("Read", { file_path: "/x/big", offset: 0, limit: 0 }).offset, null); + }); + test("missing / empty path → not a read", () => { + assert.equal(readIntent("Read", {}), null); + assert.equal(readIntent("Read", { file_path: "" }), null); + }); + test("Cursor read_file alias", () => { + assert.equal(readIntent("read_file", { path: "/x/big" }).via, "Read"); + }); +}); + +describe("readIntent — shell dumpers (Spotify bash-hook-evals, corrected)", () => { + const cases = [ + ["cat /x/big", { via: "Bash.cat", limit: null }], + ["cat -n /x/big", { via: "Bash.cat", limit: null }], + ["head /x/big", { via: "Bash.head", limit: 10 }], + ["head -100 /x/big", { via: "Bash.head", limit: 100 }], + ["head -n 5 /x/big", { via: "Bash.head", limit: 5 }], + ["head -n5 /x/big", { via: "Bash.head", limit: 5 }], + ["head --lines=400 /x/big", { via: "Bash.head", limit: 400 }], + ["tail -n +20 /x/big", { via: "Bash.tail", limit: 20 }], + ["tail /x/big", { via: "Bash.tail", limit: 10 }], + ["less /x/big", { via: "Bash.less", limit: null }], + ["more /x/big", { via: "Bash.more", limit: null }], + ['cat "/x/big"', { via: "Bash.cat", limit: null }], + ["sudo cat /x/big", { via: "Bash.cat", limit: null }], + ["git status && cat /x/big", { via: "Bash.cat", limit: null }], + ["bat -r 10:20 /x/big", { via: "Bash.bat", limit: 11 }], + ]; + for (const [cmd, want] of cases) { + test(cmd, () => { + const got = readIntent("Bash", { command: cmd }); + assert.ok(got, "expected an intent"); + assert.equal(got.via, want.via); + assert.equal(got.limit, want.limit); + assert.equal(got.paths[0], "/x/big"); + }); + } + for (const cmd of ["cat /x/big | grep export", "cat /x/big > /tmp/out.txt", "git status", "grep -n 'export' /x/big", "", "cat", "cat -", "sed -n '1,50p' /x/big", "echo hi | cat"]) { + test(`not a whole-file read: ${JSON.stringify(cmd)}`, () => { + assert.equal(readIntent("Bash", { command: cmd }), null); + }); + } + test("tail -f and byte ranges are targeted", () => { + assert.equal(readIntent("Bash", { command: "tail -f /x/big" }).targeted, true); + assert.equal(readIntent("Bash", { command: "head -c 200 /x/big" }).targeted, true); + }); + test("missing command field → null; Codex array command works", () => { + assert.equal(readIntent("Bash", {}), null); + assert.equal(readIntent("shell", { command: ["cat", "/x/big"] }).via, "Bash.cat"); + }); +}); + +describe("contextGuard — sizing (Spotify hook-evals, corrected)", () => { + const g = (input, policy = ENFORCE.contextGuard, tool = "Read") => contextGuard(readIntent(tool, input), FILES, policy); + test("small / exactly-at-threshold / empty → pass", () => { + assert.equal(g({ file_path: "/x/small" }), null); + assert.equal(g({ file_path: "/x/edge" }), null); + assert.equal(g({ file_path: "/x/empty" }), null); + }); + test("just over / large → trip, with the token estimate", () => { + assert.equal(g({ file_path: "/x/over" }).effectiveLines, 351); + assert.equal(g({ file_path: "/x/big" }).estTokens, 12000); + }); + test("targeted reads pass: offset+limit, limit alone, offset near the end", () => { + assert.equal(g({ file_path: "/x/big", offset: 100, limit: 50 }), null); + assert.equal(g({ file_path: "/x/big", limit: 50 }), null); + assert.equal(g({ file_path: "/x/big", offset: 1000 }), null); + }); + test("offset 0 / limit 0 / offset 1 are NOT a bypass (shunt's known gap)", () => { + assert.ok(g({ file_path: "/x/big", offset: 0 })); + assert.ok(g({ file_path: "/x/big", limit: 0 })); + assert.ok(g({ file_path: "/x/big", offset: 1 })); + }); + test("nonexistent file (unmeasured) → pass, the tool reports its own error", () => { + assert.equal(g({ file_path: "/nope" }), null); + }); + test("head -100 on a big file passes (shunt blocked it); head -n 400 trips", () => { + assert.equal(g({ command: "head -100 /x/big" }, ENFORCE.contextGuard, "Bash"), null); + assert.equal(g({ command: "head -n 400 /x/big" }, ENFORCE.contextGuard, "Bash").effectiveLines, 400); + }); + test("threshold is configurable; junk threshold → guard off", () => { + assert.ok(g({ file_path: "/x/small" }, { maxLines: 50, mode: "enforce" })); + assert.equal(g({ file_path: "/x/over" }, { maxLines: 500, mode: "enforce" }), null); + assert.equal(g({ file_path: "/x/big" }, { maxLines: "abc", mode: "enforce" }), null); + assert.equal(g({ file_path: "/x/big" }, { maxLines: 350, mode: "off" }), null); + assert.equal(g({ file_path: "/x/big" }, { maxLines: 350 }), null); + }); +}); + +describe("decide — enforce denies with a steer, shadow allows and reports", () => { + test("enforce → deny, reason names the size and the sanctioned path", () => { + const d = decide("Read", { file_path: "/x/big" }, ENFORCE, ctx); + assert.equal(d.decision, "deny"); + assert.equal(d.source, "context-guard"); + assert.match(d.reason, /1200 lines \(ceiling 350; ~12000 tokens into context\)\. Read just the section you need \(offset\/limit/); + }); + test("enforce + action ask → ask; operator guidance replaces the default steer", () => { + const d = decide("Read", { file_path: "/x/big" }, { ...ENFORCE, contextGuard: { maxLines: 350, mode: "enforce", action: "ask", guidance: "Use the Explore subagent." } }, ctx); + assert.equal(d.decision, "ask"); + assert.match(d.reason, /\. Use the Explore subagent\.$/); + }); + test("Codex steer names sed -n", () => { + const d = decide("shell", { command: "cat /x/big" }, ENFORCE, { ...ctx, harness: "codex" }); + assert.match(d.reason, /sed -n/); + }); + test("shadow → allow, contextGuard carried for the audit line", () => { + const d = decide("Read", { file_path: "/x/big" }, SHADOW, ctx); + assert.equal(d.decision, "allow"); + assert.equal(d.contextGuard.mode, "shadow"); + assert.equal(d.contextGuard.estTokens, 12000); + }); + test("floor still outranks the guard; a policy deny on Bash.cat still applies under the guard", () => { + assert.equal(decide("Bash", { command: "cat /x/big && rm -rf ~" }, SHADOW, ctx).source, "hardline"); + assert.equal(decide("Bash", { command: "cat /x/small" }, { ...ENFORCE, rules: { "Bash.cat": "deny" } }, ctx).decision, "deny"); + }); + test("no context (older dispatcher) → guard silently off", () => { + assert.equal(decide("Read", { file_path: "/x/big" }, ENFORCE).decision, "allow"); + }); +}); + +// ── Layer 2: the real hook, local mode, real files ───────────────────── +let HOME; +let FIX; +before(() => { + HOME = mkdtempSync(join(tmpdir(), "acp-cg-test-")); + mkdirSync(join(HOME, ".acp"), { recursive: true }); + copyFileSync(DECIDE, join(HOME, ".acp", "decide.mjs")); + FIX = join(HOME, "fixtures"); + mkdirSync(FIX); + writeFileSync(join(FIX, "large.txt"), Array.from({ length: 1200 }, (_, i) => `line ${i + 1}: export const v${i} = ${i};`).join("\n") + "\n"); + writeFileSync(join(FIX, "small.txt"), Array.from({ length: 100 }, (_, i) => `line ${i + 1}`).join("\n") + "\n"); + writeFileSync(join(FIX, "noeol.txt"), Array.from({ length: 351 }, (_, i) => `l${i}`).join("\n")); // no trailing newline: 351 lines +}); +after(() => rmSync(HOME, { recursive: true, force: true })); + +function hook(inputObj, policy, env = {}) { + writeFileSync(join(HOME, ".acp", "policy.json"), JSON.stringify(policy)); + const res = spawnSync(process.execPath, [GOVERN], { input: JSON.stringify(inputObj), encoding: "utf8", env: { HOME, PATH: process.env.PATH, ...env }, timeout: 15000 }); + assert.equal(res.status, 0, `hook exited ${res.status}: ${res.stderr}`); + return res.stdout ? JSON.parse(res.stdout) : null; +} +function lastAudit() { + const p = join(HOME, ".acp", "audit.jsonl"); + if (!existsSync(p)) return null; + const lines = readFileSync(p, "utf8").trim().split("\n").filter(Boolean); + return JSON.parse(lines[lines.length - 1]); +} +const pre = (tool_name, tool_input) => ({ tool_name, tool_input, hook_event_name: "PreToolUse", cwd: FIX }); + +describe("hook (local mode) — measures real files", () => { + test("enforce: Read of a 1200-line file is denied with the steer; audit carries the ledger", () => { + const out = hook(pre("Read", { file_path: join(FIX, "large.txt") }), ENFORCE); + assert.equal(out.hookSpecificOutput.permissionDecision, "deny"); + assert.match(out.hookSpecificOutput.permissionDecisionReason, /whole-file read of 1200 lines/); + const a = lastAudit(); + assert.equal(a.source, "context-guard"); + assert.equal(a.contextGuard.lines, 1200); + assert.ok(a.contextGuard.estTokens > 5000); + }); + test("enforce: small file, targeted read, cat piped → allowed (silent)", () => { + assert.equal(hook(pre("Read", { file_path: join(FIX, "small.txt") }), ENFORCE), null); + assert.equal(hook(pre("Read", { file_path: join(FIX, "large.txt"), offset: 200, limit: 100 }), ENFORCE), null); + assert.equal(hook(pre("Bash", { command: "cat large.txt | grep export" }), ENFORCE), null); + }); + test("relative path resolves against cwd; a file without a trailing newline counts its last line", () => { + const out = hook(pre("Bash", { command: "cat noeol.txt" }), ENFORCE); + assert.equal(out.hookSpecificOutput.permissionDecision, "deny"); + assert.match(out.hookSpecificOutput.permissionDecisionReason, /351 lines/); + }); + test("shadow: allowed, audit line records what would have been blocked", () => { + assert.equal(hook(pre("Bash", { command: "cat large.txt" }), SHADOW), null); + const a = lastAudit(); + assert.equal(a.decision, "allow"); + assert.equal(a.contextGuard.mode, "shadow"); + assert.equal(a.contextGuard.via, "Bash.cat"); + }); + test("codex harness: deny text names sed -n", () => { + const out = hook(pre("shell", { command: "cat large.txt" }), ENFORCE, { ACP_HARNESS: "codex" }); + assert.match(out.hookSpecificOutput.permissionDecisionReason, /sed -n/); + }); + test("missing file → allowed (the tool surfaces its own error)", () => { + assert.equal(hook(pre("Read", { file_path: join(FIX, "does-not-exist.txt") }), ENFORCE), null); + }); + test("no contextGuard in policy → unchanged behavior", () => { + assert.equal(hook(pre("Read", { file_path: join(FIX, "large.txt") }), { default: "allow", rules: {} }), null); + }); +});