Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"name": "agentic-control-plane",
"source": "./",
"description": "Control, audit, and cost-optimize every Claude Code tool call. Governance hook + bundled ACP MCP (cost X-ray, run traces, policy checks) + /cost-xray pre-ship report.",
"version": "0.14.0",
"version": "0.15.0",
"author": {
"name": "GatewayStack"
},
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
171 changes: 166 additions & 5 deletions bin/decide.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"].
Expand All @@ -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).
Expand All @@ -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 };
}
59 changes: 55 additions & 4 deletions bin/govern.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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 =
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}` },
Expand Down Expand Up @@ -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,
Expand All @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "agentic-control-plane",
"version": "0.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",
Expand Down
Loading
Loading