/** * The release/deploy tool-call tripwire. * * **Defence in depth, and no longer load-bearing (#126).** This module works by * pattern-matching a tool name or a shell string inside the session, which is * the weakest place a rule can live: the string is the model's, the matching is * a regex, and `bash` hands it an unbounded alphabet to spell `npm publish` in. * It was the only mechanism there was, and it was never the one anybody should * have had to rely on. * * What actually stops a release now is that the daemon performs every one, and * checks it first: the mediated verbs run their checks against the configured * holder (`verbs/server.ts`), on the daemon's side of the socket. A worker that * talks its way past every regex below has still not cut a release — and if it * reaches for `gh` directly instead, the release is not in the ledger, which is * the discrepancy an operator reads. * * It stays installed anyway, for the two things a tripwire is good at and a * boundary is not: it refuses *early*, in the session, with an explanation the * model can act on in the same turn instead of an `EACCES` it may read as * transient; and it leaves a durable record that something tried * ({@link recordReleaseBlock}), which is how drift shows up in a digest rather * than in an incident. Treat a block here as evidence about a session's * intentions, never as proof that the release was prevented — the daemon is * what prevented it. * * {@link releaseRefusal} is the exception to all of the above: it is the shared * grant comparison, and `conductor_release` calls exactly this function, so the * refusal an operator reads is worded once. */ import { appendFileSync, mkdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { stateDir } from "./config.ts"; import type { ReleaseShape, ResolvedGrants, SessionRole } from "./types.ts"; export const RELEASE_POLICY_AUDIT_FILE = "release-policy-blocks.jsonl"; export interface ReleaseBlockContext { tool: string; reason: string; /** * What the shape matcher fired on: the offending command segment for a shell * call, the deciding token for a tool name. Present on every record written * from #491 on, so a fixture hit is visible as one; absent only on older * lines, which the digest still renders. */ matched?: string; /** * The blocked shell command itself, with anything credential-shaped redacted * and the whole string bounded. Only a `bash` call sets it — every other * tool's redacted {@link args} already name the invocation. */ invocation?: string; args: Record; } export interface ReleaseBlock extends Partial { project: string; source: "worker" | "orchestrator"; shape: GateShape; at: string; issue?: number; runId?: string; } export type ReleaseDecision = { block: true; reason: string }; /** * The shared-host heavy-gate shape (#428): whole-package test runs and the * shell suites a worker must be *unable* to run on the operator's shared host, * rather than merely told not to. * * Deliberately **not** a {@link ReleaseShape}: no grant covers it. An operator * is never refused — the operator owns the host, and CI owns the full suite — * and a worker is always, whatever the config says. So it stays out of the * configurable grant vocabulary (`releaseShapeEnum` is drawn straight from * {@link RELEASE_SHAPES}); it only rides the release-policy classifier and the * block ledger so one seam sees it. Its refusals land in the same ledger the * tick reads, but the tick reports them apart from release drift (#562): * a refusal here is the interlock working, never a release-policy violation * and never something a grant could have permitted. */ export const SHARED_HOST_SHAPE = "shared-host-gate" as const; export type SharedHostShape = typeof SHARED_HOST_SHAPE; /** Every shape the classifier reports, grantable or not. */ export type GateShape = ReleaseShape | SharedHostShape; const GIT_PUSH_TAG_SHAPE = /(?:--tags\b|--follow-tags\b|refs\/tags\/|(?:^|\s)(?:tag\s+)?v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?(?=[:\s]|$))/; /** The separators the original quote-blind splitter divides a command on. Kept * for the unterminated-quote fallback, which must reproduce today's behaviour * rather than silently pass a command through. */ const COMMAND_SEPARATORS = /(?:&&|\|\||[;\n|])/; /** * Split a shell command on its operators, ignoring ones inside single or * double quotes. The old splitter was quote-blind, so a `|` inside a quoted * argument (a grep alternation's `\|`, a `--jq` filter, a heredoc) shredded * the command into phantom segments and a gated verb inside a string literal * was refused as a release attempt (#526). A small scanner is enough: no shell * parser, no dependency. An unterminated quote falls back to the original * quote-blind split, because a gated command must not slip through merely * because a quote never closed. */ function splitCommandSegments(command: string): string[] { const segments: string[] = []; let current = ""; let quote: "'" | '"' | null = null; const push = () => { segments.push(current); current = ""; }; for (let i = 0; i < command.length; i++) { const ch = command[i]; // Inside a quote only its closing quote matters — separators are data. A // `\"` inside double quotes is a literal quote and must not close it. if (quote !== null) { current += ch; if (ch === "\\" && quote === '"') { if (i + 1 < command.length) current += command[++i]; continue; } if (ch === quote) quote = null; continue; } // Outside quotes a backslash escapes the next character, so a literal // `\|`, `\;` or `\"` is data, never an operator or the start of a quote. if (ch === "\\") { current += ch; if (i + 1 < command.length) current += command[++i]; continue; } if (ch === "'") { quote = "'"; current += ch; continue; } if (ch === '"') { quote = '"'; current += ch; continue; } if ((ch === "&" && command[i + 1] === "&") || (ch === "|" && command[i + 1] === "|")) { push(); i++; continue; } if (ch === "|" || ch === ";" || ch === "\n") { push(); continue; } current += ch; } if (quote !== null) return command.split(COMMAND_SEPARATORS); push(); return segments; } function commandSegments(command: string): string[] { return splitCommandSegments(command) .map((segment) => segment.trim()) .filter((segment) => segment.length > 0); } /** A leading wrapper command, matched and consumed in a chain (#558). `env` * has its own dedicated stripper below — its option forms need their own * handling — while `timeout`, `nice`, `stdbuf` and a shell `-c` were not * stripped at all, so a whole-package run became allowed the moment it * picked up a wrapper: the incident ran `timeout 300 bun test` and the guard * never fired. Each alternative owns the arguments that belong to it (the * duration, the niceness, the option run), so stripping never swallows the * command's own words. Ordered longest-first so `timeout` is consumed * before `time` and a `VAR=…` chain after `sudo`. */ const COMMAND_WRAPPER_PREFIX = new RegExp( [ "^sudo(?:\\s+-[a-z][a-z0-9-]*)*\\s+", "^(?:[A-Za-z_][A-Za-z0-9_]*=\\S+\\s+)+", "^timeout(?:\\s+--?[a-z][a-z0-9-]*(?:=\\S+|\\s+\\S+)?)*\\s+(?:inf|infinity|\\d+(?:\\.\\d+)?[smhd]?)\\s+", "^nice(?:\\s+-n\\s+-?\\d+|\\s+-\\d+|\\s+--adjustment\\s*=\\s*-?\\d+)?\\s+", "^ionice(?:\\s+--?[a-z][a-z0-9-]*(?:\\s+\\d+)?)*\\s+", "^stdbuf(?:\\s+--?[a-z][a-z0-9-]*(?:=\\S+|[A-Za-z0-9]+|\\s+[A-Za-z0-9])?)*\\s+", "^xargs(?:\\s+--?[A-Za-z][A-Za-z0-9-]*(?:=\\S+|\\s+\\S+)?)*\\s+", "^time\\s+", ].join("|"), ); /** * Consume `env` together with every argument that belongs to it — its * environment assignments and its own option forms — leaving the command the * wrapper would run. The wrapper table's bare `env ` strip read one word and * stopped, so `env -u NAME bun test` survived classification as an * unclassified `-u NAME bun test` while `env FOO=1 bun test` was consumed * through the assignment chain (#789). Every option form below exists to * mutate or choose the *environment*, and that mutation is the wrapper; the * command sits after it, so the whole argument list must go before the * command is classified. * * The parsing mirrors how `env` itself stops: `--` ends env's own argument * list and the rest is the command; a token that is neither an assignment * nor an env option is the command, not a wrapper argument. A short cluster * may end in an operand-taking flag (`-u NAME`, `-iu NAME` — the operand is * the next token) or carry the operand mid-token (`-ui` is `-u i`, `-uNAME` * is `-u NAME` — the rest of the token is the operand), exactly as compiled * option parsing does; a lone `-` is env's implied `-i`. A cluster containing * an unrecognised flag is left behind because `env` itself rejects the whole * command on it — there is no command to gate. * * `-S`/`--split-string` is handled, never unwrapped piecemeal. env *re-splits* * the option's operand as its own command line — assignments, options, then * the command — and then keeps the rest of the line as further arguments to * it (verified against coreutils 9.4, where `env -S --unset=FOO cmd` applies * the unset and runs `cmd` via the remaining argv). The operand is decoded * exactly as the shell and env's split-string parser together leave it and is * re-entered into the argument loop below, so the command env would actually * execute meets ordinary classification — a whole-suite `bun test` behind any * `-S` spelling is refused, a focused `bun test src/x.test.ts` still passes. * The only case a static command line cannot resolve is an operand that * expands a runtime variable (`${VAR}`): it is refused via * {@link UNCLASSIFIABLE_ENV_SPLIT} rather than guessed at. * * Long options are the same, and GNU accepts any unambiguous abbreviation of * one (`--u=NAME` is `--unset=NAME`, `--split 'bun test'` is * `--split-string 'bun test'`), so they are resolved against env's option * table by prefix; a token too ambiguous to be one option is what env itself * rejects — nothing runs — and is left behind, exactly like an unrecognised * short flag. * * Adjacent wrappers compose: env's own command may itself be an env * invocation (`env -i env bun test` runs the inner env on the suite), so the * strip reaches a strict shortening fixpoint rather than stopping at the * first `env`. */ /** The sentinel a strip returns for a segment it must not let through but * cannot classify: an `env -S` operand that expands a runtime variable * (`${VAR}`), which env resolves against its live environment and a static * command line cannot. `releaseCommandMatch` reads it and refuses the whole * segment as `shared-host-gate` rather than risk a whole-package run * slipping through, unclassified, as itself. */ const UNCLASSIFIABLE_ENV_SPLIT = "\u0000env-split-unclassifiable"; /** * Read one shell word at `input`'s head to the first unquoted whitespace (or * end), quoted regions and concatenated fragments included — `'bun test'` and * `'bun'test` are each one word, so the `env -S` operand survives as a unit * while the rest of the segment stays behind. The raw command line still * carries the quotes, because the shell would only remove them as env runs; * they are preserved in `word` for {@link decodeEnvSplitOperand} to resolve * against the split-string parser's own quoting. `undefined` when the head * holds no word at all. */ function readShellWord(input: string): { word: string; rest: string } | undefined { let end = 0; const n = input.length; while (end < n) { const c = input[end]!; if (c === "\\") { end += end + 1 < n ? 2 : 1; continue; } if (c === "'" || c === '"') { const quote = c; end += 1; while (end < n && input[end] !== quote) { end += input[end] === "\\" && end + 1 < n ? 2 : 1; } if (end < n) end += 1; // the matching close continue; } if (/\s/.test(c)) break; end += 1; } if (end === 0) return undefined; return { word: input.slice(0, end), rest: input.slice(end) }; } /** * One backslash escape env's split-string parser accepts, as its character. * `\_` is a word-separating space exactly like a literal one — `bun\_test` * splits into `bun` and `test` — so it cannot decode to an underscore * (verified against coreutils 9.4). `undefined` for anything else: env * rejects `\a`, `\b`, `\e`, `\0` (no octal), `\x`, `\q`, a backslash before * whitespace and any other undeclared escape with "invalid sequence", * running nothing — the caller fails closed rather than guess. */ function splitEscapeChar(e: string): string | undefined { switch (e) { case "n": return "\n"; case "r": return "\r"; case "f": return "\f"; case "t": return "\t"; case "v": return "\v"; case "_": return " "; case "\\": return "\\"; case '"': return '"'; case "'": return "'"; case "$": return "$"; case "#": return "#"; default: return undefined; } } /** * Decode one `'…'`/`"…"` split-string quoting group. env removes the quotes * and the group stands as a single token, so its content is decoded exactly * like the rest of the string. A `\c` inside a group is rejected by env * ("'\c' must not appear in … -S string") and so is an unterminated group * ("no terminating quote in -S string"): both fail closed. */ function decodeSplitGroup( text: string, start: number, quote: string, ): { content: string; end: number } | undefined { let content = ""; let i = start; const n = text.length; while (i < n && text[i] !== quote) { const c = text[i]!; if (c === "\\") { i += 1; if (i >= n) return undefined; if (text[i] === "c") return undefined; // env rejects `\c` in a group const decoded = splitEscapeChar(text[i]!); if (decoded === undefined) return undefined; content += decoded; i += 1; continue; } if (c === "$" || c === "`") return undefined; content += c; i += 1; } if (i >= n) return undefined; // no terminating quote return { content, end: i + 1 }; } /** * Resolve env's split-string decoding of `text`, so the operand re-enters * classification the way env would execute it (verified against coreutils * 9.4). Escapes become their characters (`\_` a word-separating space), * whitespace runs collapse to single spaces, and a `'…'`/`"…"` quoting group * becomes one token with its quotes removed: `'bun' test` reads `bun test`, * exactly as env runs it. A grouped token whose content carries whitespace * keeps quotes around it in the decoded line — that is what a * `bash -c "bun test"` command string looks like to the downstream shell-`-c` * resolver, and the only shape whose token boundary classification would * otherwise lose. An unquoted, unescaped `#` at the start of an argument — a * `#` coreutils' `ss.sep` flag finds at the string head, after whitespace, or * after an outer `\_` — is a comment that ends the split string: coreutils * `goto eos` drops the `#` and everything after it (`env -S '#' bun test` * executes the untouched `bun test` argv via the remaining command line), so * the decode stops there and the caller re-enters only what preceded it. * Escaped (`\#`) or quoted (`'#'`, `"#"`) hashes are that same literal `#` * argument — `\#` passes as-is, and quotes group — never a comment, and a * mid-argument `#` (`bun#test`, `'a'#b`) is the ordinary character it is to * env. A construct env rejects (a `\c` in a group, an unterminated group, an * undeclared escape, a trailing backslash) or cannot resolve statically (a * `${VAR}`/`$VAR`/backtick expansion) is `undefined`: the caller must refuse * the whole segment rather than guess. */ function decodeSplitString(text: string): string | undefined { let out = ""; const n = text.length; let i = 0; // Mirror coreutils' `ss.sep`: true while the next character opens a new // argument — the string head, after a whitespace run, or after an outer // `\_` separator. A `#` seen there is the comment that ends the split; // every other position keeps `#` the literal character it is to env. let sep = true; while (i < n) { const c = text[i]!; if (c === "$" || c === "`") return undefined; if (c === "\\") { i += 1; if (i >= n) return undefined; // a trailing backslash: env rejects it const e = text[i]!; if (e === "c") break; // `\c` stops processing the rest of the string const decoded = splitEscapeChar(e); if (decoded === undefined) return undefined; out += decoded; // `\_` is an argument separator outside quotes, exactly like a space; // any other escape is a character inside the current argument. sep = decoded === " " ? true : false; i += 1; continue; } if (c === "#" && sep) break; // comment: an arg-initial `#` ends the split if (c === "'" || c === '"') { const group = decodeSplitGroup(text, i + 1, c); if (group === undefined) return undefined; const content = group.content; if (/\s/.test(content)) { // One token with an interior space; re-quote it so the shell-`-c` // resolver below still sees a quoted command string. Pick the quote // style the content does not already carry; content with both is not // representable and fails closed. if (!content.includes(c)) { out += c + content + c; } else if (c === "'" && !content.includes('"')) { out += `"${content}"`; } else if (c === '"' && !content.includes("'")) { out += `'${content}'`; } else { return undefined; } } else { out += content; // quotes removed: `'bun' test` is `bun test` } // The group opened an argument, so what follows it — a `#` included — // continues that same argument (`'a'#b` is `a#b`). sep = false; i = group.end; continue; } if (/\s/.test(c)) { out += " "; sep = true; while (i < n && /\s/.test(text[i]!)) i += 1; continue; } out += c; sep = false; i += 1; } return out.replace(/\s+/g, " ").trim(); } /** * Decode the `env -S` operand word for re-entry, the way the shell and env's * split-string parser between them leave it. The operand's own shell quoting * — the `'…'`/`"…"` wrapping the whole word on the raw command line — is the * printable form of the argument; stripping that one level is what the shell * does before env sees it. The quotes *inside* the content are env's own * grouping and are preserved for {@link decodeSplitString}. `undefined` when * the decoded text carries a runtime expansion. */ function decodeEnvSplitOperand(word: string): string | undefined { let content = word; if (word.length >= 2) { const first = word[0]!; const last = word[word.length - 1]!; if (first === "'" && last === "'" && !word.slice(1, -1).includes("'")) { content = word.slice(1, -1); } else if (first === '"' && last === '"') { content = word.slice(1, -1); } } return decodeSplitString(content); } /** * Consume a short `-S` option from the current head, returning its operand * word (raw, quotes included) and the rest of the segment. `operand: null` * means the option had no operand at all — env itself rejects that * invocation, so there is no command to gate. The accepted spellings match * compiled option parsing: `-S`, `-iS`, `-0S`, an operand attached to `S` in * the same token (`-Sbun`, `-S'cmd'`), or the next word (`-S bun`). The long * `--split-string` spelling — exact or an unambiguous abbreviation — is * resolved by {@link parseEnvLongOption} instead. */ function parseEnvSplitOption(rest: string): { operand: string | null; rest: string } | undefined { const short = /^-[i0v]*S/.exec(rest); if (short === null) return undefined; // The operand is attached to S in the same token — `-Sbun`, `-S'cmd'` — // or, after whitespace, the next word: `-S bun`. const after = rest.slice(short[0].length).replace(/^\s+/, ""); const word = readShellWord(after); if (word === undefined) return { operand: null, rest: "" }; return { operand: word.word, rest: word.rest }; } /** env's long options with the operand shape each one declares. */ const ENV_LONG_OPTIONS: ReadonlyArray<{ name: string; /** `required`: an operand via `=` or the next word (empty `=` rejected); * `eq-only`: an optional operand only via `=` (`--block-signal[=SIG]`); * `none`: no operand, and a trailing `=` is rejected. */ operand: "required" | "eq-only" | "none"; }> = [ { name: "split-string", operand: "required" }, { name: "unset", operand: "required" }, { name: "chdir", operand: "required" }, { name: "block-signal", operand: "eq-only" }, { name: "default-signal", operand: "eq-only" }, { name: "ignore-signal", operand: "eq-only" }, { name: "ignore-environment", operand: "none" }, { name: "null", operand: "none" }, { name: "list-signal-handling", operand: "none" }, { name: "debug", operand: "none" }, ]; /** * Consume a `--…` env long option from the current head, exact or an * unambiguous GNU abbreviation. `/usr/bin/env --u=NAME cmd` applies the unset * and runs `cmd`, and `--split 'bun test'` re-splits — verified against * coreutils 9.4 — so a parser that only knew the full spellings left a * leading `--u=…` behind and a whole-package run bypassed classification. * The resolution mirrors getopt_long: the token is matched against env's own * option table as a prefix; exactly one match is the option, consumed with * the operand shape it declares (split-string's operand is returned for the * caller to decode and re-enter, exactly like `-S`). Zero matches (unknown) * or several (ambiguous) are what env itself rejects — verified: * `--frobnicate …` and `--i=…` both error out and run nothing — so the token * is left behind for the caller to return untouched; there is no command to * gate. `undefined` when the head is not a long option at all. */ function parseEnvLongOption( rest: string, ): { next: "leave" } | { next: "continue"; rest: string } | { next: "split"; operand: string | null; rest: string } | undefined { const head = /^--([A-Za-z][A-Za-z0-9-]*)/.exec(rest); if (head === null) return undefined; const token = head[1]!; const matches = ENV_LONG_OPTIONS.filter((option) => option.name.startsWith(token)); if (matches.length !== 1) return { next: "leave" }; const option = matches[0]!; const after = rest.slice(head[0].length); if (option.operand === "none") { if (after.startsWith("=")) return { next: "leave" }; // env rejects `--debug=…` return { next: "continue", rest: after.replace(/^\s+/, "") }; } if (option.operand === "eq-only") { // `--block-signal[=SIG]`: the signal rides only on the `=` and may be // empty (a no-op); without `=`, the token is consumed bare and the next // word is env's command, matching env's own parsing. if (after.startsWith("=")) { const value = readShellWord(after.slice(1)); if (value === undefined) { return { next: "continue", rest: after.slice(1).replace(/^\s+/, "") }; } return { next: "continue", rest: value.rest.replace(/^\s+/, "") }; } return { next: "continue", rest: after.replace(/^\s+/, "") }; } if (option.name === "split-string") { // The operand is split and re-entered as a command line by the caller; // an empty `=` value still splits to nothing, after which the rest of // the line is the command. if (after.startsWith("=")) { const value = readShellWord(after.slice(1)); if (value === undefined) return { next: "split", operand: "", rest: after.slice(1) }; return { next: "split", operand: value.word, rest: value.rest }; } const spaced = after.replace(/^\s+/, ""); if (spaced.length === 0) { // `--split-string` with no operand: env rejects the invocation. return { next: "split", operand: null, rest: "" }; } const word = readShellWord(spaced); if (word === undefined) return { next: "split", operand: null, rest: "" }; return { next: "split", operand: word.word, rest: word.rest }; } // `--unset NAME` / `--chdir DIR` — a required operand via `=` (empty is // rejected by env: "cannot unset ''") or as the next word. if (after.startsWith("=")) { const value = readShellWord(after.slice(1)); if (value === undefined || value.word === "") return { next: "leave" }; return { next: "continue", rest: value.rest.replace(/^\s+/, "") }; } const spaced = after.replace(/^\s+/, ""); const word = readShellWord(spaced); if (word === undefined) { // `--unset` with no operand: env rejects the invocation. return { next: "continue", rest: "" }; } return { next: "continue", rest: word.rest.replace(/^\s+/, "") }; } function stripEnvPrefix(segment: string): string { // env's own command may itself be an env invocation — `env env bun test` // runs the inner env, which runs the whole suite (same for `env -i env …` // and, after a freed wrapper, `sudo env -u NAME env …`) — and the `--` // separator hands env's command to the same strip. So the strip reaches a // strict fixpoint: loop until the remainder no longer opens with `env `. // Each pass consumes at least the `env ` head, so this terminates. let current = segment; while (true) { const head = /^env\s+/.exec(current); if (head === null) return current; let rest = current.slice(head[0].length); const consume = (pattern: RegExp): boolean => { const match = pattern.exec(rest); if (match === null) return false; rest = rest.slice(match[0].length); return true; }; while (true) { // `-S`/`--split-string`: env splits its operand as its own command line // and keeps the rest of the segment as further arguments to it — so the // whole line after the option is still one command env makes the // environment for. Decode the operand and let the argument loop below // consume the line, so the command env would actually run meets ordinary // classification. An operand that expands a runtime variable cannot be // classified statically: refuse the whole segment instead of guessing. const split = parseEnvSplitOption(rest); if (split !== undefined) { if (split.operand === null) { // No operand at all: env itself rejects the invocation, so there is // no command to gate — consume the bare option and move on. rest = split.rest; continue; } const decoded = decodeEnvSplitOperand(split.operand); if (decoded === undefined) return UNCLASSIFIABLE_ENV_SPLIT; rest = `${decoded} ${split.rest}`.trimStart(); continue; } // `--…` env long options, exact or an unambiguous GNU abbreviation: // consumed with the operand shape the option declares. split-string's // operand is decoded and re-entered here, like `-S`; unknown or ambiguous // long options are what env itself rejects (nothing runs), so they are // left behind and the loop stops — there is no command to classify. const long = parseEnvLongOption(rest); if (long !== undefined) { if (long.next === "leave") return rest; if (long.next === "split") { if (long.operand === null) { // `--split-string` with no operand: env rejects the invocation. rest = ""; continue; } const decoded = decodeEnvSplitOperand(long.operand); if (decoded === undefined) return UNCLASSIFIABLE_ENV_SPLIT; rest = `${decoded} ${long.rest}`.trimStart(); continue; } rest = long.rest; continue; } // `env -- cmd`: the separator ends env's argument list; the command // follows — command or not, the outer fixpoint loop re-checks it. if (consume(/^--\s+/)) { current = rest; break; } // An assignment: `env FOO=1 cmd` (`FOO= cmd` — the empty value env also // honours — is an assignment too, because the `=` makes it one). if (consume(/^(?:[A-Za-z_][A-Za-z0-9_]*=\S*)\s+/)) continue; // Short-option cluster: operand-taking flag terminal in the token (the // operand is the next token), operand-taking flag mid-token (the rest of // the token is its operand), a bare cluster of no-operand flags, or the // lone `-` that means `-i` (the cluster is then empty). if ( consume( /^-(?:[i0v]*[uC]\s+\S+|[i0v]*[uC]\S+|[i0v]+)?\s+/, ) ) { continue; } current = rest; break; } } } /** `bash -c`, `sh -c`, `zsh -c` with optional preceding flags — `-lc` and * friends parse as combined short options, so the `c` may ride in the same * token as the flags before it. A `bash -n` parse check is deliberately not * one: it executes nothing. */ const SHELL_DASH_C = /^(?:bash|sh|zsh)\s+(?:-\S+\s+)*?-[A-Za-z]*c\b\s+/; /** * The command a `bash -c ''` / `sh -c ""` segment executes is the * quoted string, not the `bash` invocation, so a wrapper command that reaches * for a shell must be resolved to the string inside the quotes before * classifying. Extra `$0`.. arguments after the closing quote are ignored: * they are not the command. */ function shellCommandInner(segment: string): string | undefined { const shell = SHELL_DASH_C.exec(segment); if (shell === null) return undefined; const rest = segment.slice(shell[0].length); const quote = rest[0]; if (quote !== "'" && quote !== '"') return undefined; const close = rest.indexOf(quote, 1); if (close < 0) return undefined; return rest.slice(1, close); } /** Normalise a segment before classification: consume every leading wrapper * command (#558). The old stripper recognised `env` and `sudo` and nothing * else, so a whole-package `bun test` became allowed the moment a worker * wrapped it in `timeout`/`nice`/`bash -c`. Chains matter — `timeout 300 * nice bun test` is one command with two wrappers, and an `env` with its own * options may sit anywhere in the chain (`sudo env -u NAME timeout 300 bun * test`) — so env is stripped before the table and again after each match, * and the table is matched repeatedly until the leading word belongs to the * command itself. */ function stripCommandPrefix(segment: string): string { let stripped = stripEnvPrefix(segment); let match: RegExpExecArray | null; while ((match = COMMAND_WRAPPER_PREFIX.exec(stripped)) !== null) { stripped = stripped.slice(match[0].length); stripped = stripEnvPrefix(stripped); } return stripped; } /** Recognise the explicit release/deploy command shapes this policy promises to gate. */ export function releaseShapeFromCommand(command: string): GateShape | undefined { return releaseCommandMatch(command)?.shape; } /** * A whole-package `bun test`: `bun test` with no positional naming a specific * test file. Flags alone — or nothing at all — still run the whole project, * which is the gate (#428), and so do the path spellings of "everything": * `bun test .`, `bun test ./`, `bun test src`. A command whose positional * targets are test files — basename carrying the `*.test.*` / `*_test.*` * marker bun itself keys on — is a focused run, a worker's proof path, and * must stay available. A flag's own value is not a focused target: a * `--preload x.ts` run with no file runs the whole package with a preload, * while `--preload ./setup.ts src/daemon.test.ts` is still a test-file run. */ function wholePackageBunTest(segment: string): boolean { const head = /^bun\s+test\b/.exec(segment); if (head === null) return false; const rest = segment.slice(head[0].length); return !rest .split(/\s+/) .filter((token) => token.length > 0) .filter((token) => !token.startsWith("-")) .some((token) => /\.test\.|_test\./.test(token.slice(token.lastIndexOf("/") + 1))); } /** * The shared-host run/shell suites a worker must not execute on the operator's * host (#428): `herdr/test/recover-test.sh`, `test/setup-test.sh` and * `setup.sh`, each under the invocation prefixes a worker actually uses — * plain `bash`/`sh`/`zsh`, POSIX `. ` and `source` (all so the script loads), * and a bare or `./` path. Returns the script path that fired, for the * matched-segment ledger record. * * `bash -n` is deliberately not a match: parsing a script never executes it, * and the pre-push parse gate every worker brief runs is * `bash -n herdr/bin/recover.sh herdr/test/recover-test.sh setup.sh * test/setup-test.sh` — blocking that would make the gate unfollowable without * buying a single byte of protection. A flag beyond `-n` (`bash -x`, `-e`) is * a real execution and is not covered either: the incidents that overloaded * the host were all plain invocations, and refusing the sanctioned parse check * would teach workers to skip it rather than stop the load. */ export const SHARED_HOST_SCRIPTS: readonly string[] = [ "herdr/test/recover-test.sh", "test/setup-test.sh", "setup.sh", ]; const SHARED_HOST_PREFIX = /^(?:(?:bash|sh|zsh|source)\s+|\.\s+)?\.?\/?/; function sharedHostScriptMatch(segment: string): string | undefined { const path = segment.replace(SHARED_HOST_PREFIX, ""); for (const script of SHARED_HOST_SCRIPTS) { if (path === script || path.startsWith(`${script} `) || path.startsWith(`${script}\t`)) { return script; } } return undefined; } /** The `git [-C ] tag` prefix; the tail after it decides read vs write. */ const GIT_TAG_HEAD = /^git(?:\s+-[Cc]\s+\S+)*\s+tag(?=\s|$)/; /** * Whether a segment that opens with `git [-C ] tag` is a write — tag * creation or deletion — rather than a read. The tripwire used to match the * `git tag` prefix alone (#696), so a worker orienting itself with * `git tag | head -5` was refused as an attempted release and the refusal * reached the digest as release-policy drift. Git writes tags in exactly two * shapes, and every other form is a read: * * - creation needs a tagname operand: `git tag ` and the * `-a`/`-s`/`-u`/`-t`/`-m`/`-F`/`-f` forms; * - deletion needs `-d`/`--delete`; * - bare `git tag`, `-l`/`--list` (with an optional pattern), `-n`, * `--contains`, `--points-at`, `--sort`, `--merged`/`--no-merged`, * `--format`, `--column` and `-v`/`--verify` all only read. * * A creation flag with no name is still gated: git rejects the command, but a * create attempt is not a read, and the tripwire errs toward refusing a * release act. */ function gitTagIsWrite(segment: string): boolean { const head = GIT_TAG_HEAD.exec(segment); if (head === null) return false; const tokens = segment.slice(head[0].length).trim().split(/\s+/).filter((token) => token.length > 0); let deletion = false; // -d / --delete let listRead = false; // a read-only list flag let verifyRead = false; // -v / --verify let createSignal = false; // -a / -s / -f / -m / -u / -t / -F let hasOperand = false; // a positional tagname, pattern or commit for (let i = 0; i < tokens.length; i++) { const token = tokens[i]!; // A `--` ends option parsing: whatever follows is a plain operand, the // created or deleted tag's name. if (token === "--") { hasOperand = true; break; } if (token.startsWith("--")) { const flag = token.includes("=") ? token.slice(0, token.indexOf("=")) : token; switch (flag) { case "--delete": deletion = true; break; case "--list": case "--contains": case "--no-contains": case "--points-at": case "--merged": case "--no-merged": case "--sort": case "--format": case "--column": case "--no-column": case "--color": case "--no-color": case "--ignore-case": case "--no-ignore-case": listRead = true; break; case "--verify": verifyRead = true; break; case "--annotate": case "--sign": case "--force": createSignal = true; break; case "--local-user": case "--object": case "--message": case "--file": // The flag's operand is part of the create parameters, not a // tagname; `--flag value` must not be read as `--flag` plus a name. createSignal = true; if (!token.includes("=")) i++; break; default: break; // an unknown flag: no conclusion either way } continue; } if (token.startsWith("-") && token.length > 1) { // Combined short options (`-an5` is `-a -n 5`). An option with a // required operand takes the rest of its token or the next token. let consumeNext = false; for (let j = 1; j < token.length; j++) { const flag = token[j]; switch (flag) { case "d": deletion = true; break; case "a": case "s": case "f": createSignal = true; break; case "l": listRead = true; break; case "v": verifyRead = true; break; case "n": // `-n[]` — the digit run belongs to the flag, so `-n5` // is one token and never an operand. listRead = true; while (j + 1 < token.length && token[j + 1]! >= "0" && token[j + 1]! <= "9") j++; break; case "m": case "u": case "t": case "F": createSignal = true; if (j + 1 < token.length) { j = token.length; } else { consumeNext = true; } break; default: break; // an unknown short flag: no conclusion either way } } if (consumeNext) i++; continue; } // A plain operand: with a list flag it is a pattern or commit for the // read; without one it is the tagname a creation needs. hasOperand = true; } if (deletion) return true; if (listRead || verifyRead) return false; if (hasOperand) return true; return createSignal; } /** * Classify a shell command and the segment that fired. The segment is what a * triager reads: `bun test src/foo.test.ts && npm publish` matches on its * `npm publish` half, and the record says so instead of folding the whole * command into the shape. */ function releaseCommandMatch(command: string): { shape: GateShape; matched: string } | undefined { for (const raw of commandSegments(command)) { const segment = stripCommandPrefix(raw); // A segment whose `env -S` operand expands a runtime variable cannot be // classified from a static command line; refuse the whole command rather // than let an unclassified whole-package run past. if (segment === UNCLASSIFIABLE_ENV_SPLIT) { return { shape: SHARED_HOST_SHAPE, matched: raw.trim() }; } // A `bash -c ''` / `sh -c ""` wrapper executes the quoted // string, so classify that string as a command of its own: a whole // package inside the quotes is refused (#558), a focused run inside // the quotes stays a focused run, and a harmless `-c` script body // (the pre-push `bash -n` parse gate) never unwraps to begin with. const inner = shellCommandInner(segment); if (inner !== undefined) { const nested = releaseCommandMatch(inner); if (nested !== undefined) return nested; continue; } // Patching the running conductor is gated like a release act: `upgrade`, // the detached `upgrade-install`/`upgrade-rollback` executor entries and // any future spelling under the upgrade family all need the install shape, // and a session without that grant is refused before the shell runs. if (/^omp-conductor\s+upgrade(?:\b|$)/.test(segment)) return { shape: "install", matched: segment }; if (gitTagIsWrite(segment)) return { shape: "git-tag", matched: segment }; if ( /^git(?:\s+-[Cc]\s+\S+)*\s+push\b/.test(segment) && GIT_PUSH_TAG_SHAPE.test(segment) ) { return { shape: "git-push-tags", matched: segment }; } if (/^(?:npm|pnpm|bun)\s+(?:publish|stage\s+publish)\b/.test(segment)) { return { shape: "package-publish", matched: segment }; } if (/^yarn\s+npm\s+publish\b/.test(segment)) return { shape: "package-publish", matched: segment }; if (/^gh\s+release\s+create\b/.test(segment)) return { shape: "github-release", matched: segment }; if ( /^(?:kubectl\s+(?:apply|create|replace)|helm\s+(?:install|upgrade)|terraform\s+apply|pulumi\s+up|(?:fly|vercel|wrangler)\s+deploy|docker\s+push)\b/.test( segment, ) ) { return { shape: "deploy", matched: segment }; } // The shared-host gate (#428). Disjoint from every release shape above, so // it is checked last without disturbing any of them. `matched` names the // script path for a suite and the offending segment for a whole-package // `bun test`, so the ledger and the digest say exactly which one fired. if (wholePackageBunTest(segment)) return { shape: SHARED_HOST_SHAPE, matched: segment }; const script = sharedHostScriptMatch(segment); if (script !== undefined) return { shape: SHARED_HOST_SHAPE, matched: script }; } return undefined; } /** * The tokens a tool name or device path is matched on: split on every separator * (`_`, `-`, `.`, `/`, `:`) and on CamelCase boundaries, then lowercased. * `DeployStack` → `[deploy, stack]`; `GetDeploymentStatus` → `[get, deployment, * status]`; `xd://mcp__komodo_DeployStack` → `[xd, mcp, komodo, deploy, stack]`. * * One tokeniser for both branches of {@link releaseShapeFromTool}, because #130 * was two matchers disagreeing about the same tool. The device path tested the * whole string with an unbounded `/deploy/i`, so `xd://mcp__komodo_GetDeploymentStatus` * — a read this fleet's orchestrator makes routinely — was *blocked* as a * release action; the native tool name required a delimiter, so * `mcp__komodo_DeployStack`, the real deploy, matched nothing at all. Wrong in * opposite directions, about the same call. * * Exported for the tests: the negative cases are the whole point of this file, * and they are cheaper to state against tokens than against a decision. */ export function releaseTokens(name: string): string[] { return ( name // `DeployStack` → `Deploy Stack`, then `HTTPServer` → `HTTP Server` so an // acronym prefix cannot swallow the verb that follows it. .replace(/([a-z0-9])([A-Z])/g, "$1 $2") .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") .toLowerCase() .split(/[^a-z0-9]+/) .filter((token) => token.length > 0) ); } /** * Verbs that make a name a query, whatever nouns follow. `GetDeploymentStatus`, * `ListDeployments` and `describeDeployment` are how an orchestrator reads a * deployment's state, and refusing those as release actions is the live block * #130 was filed for. */ const READ_VERBS: Record = { get: true, list: true, describe: true, inspect: true, read: true, watch: true, show: true, search: true, status: true, }; /** * Mutating deploy verbs. The noun `deployment` is deliberately absent: it is * what a read is *about*, never what a call does. * * `apply` and `destroy` are generic enough to catch an unrelated tool * (`ApplyPatch`), and that is the direction this errs on purpose — an * over-refused edit tool is an escalation, a missed IaC call is an incident. */ const DEPLOY_ACTIONS: Record = { deploy: true, redeploy: true, rollout: true, promote: true, apply: true, destroy: true, teardown: true, }; /** Publishing an artifact. `published` is a noun and excluded: it is why * `xd://mcp__x_ListPublishedArtifacts` classified as `package-publish` (#130). */ const PUBLISH_ACTIONS: Record = { publish: true, republish: true }; /** Cutting a GitHub release takes an action *and* the `release` noun, so * `create_repository` and `list_releases` are each missing half of it. */ const RELEASE_ACTIONS: Record = { create: true, draft: true, cut: true, publish: true, republish: true, }; /** * The shape a tokenised tool name or device path names, if any. * * A query name yields nothing whatever its nouns say. The first token drawn from * either vocabulary decides that, because tool names are verb-first * (`ListDeployments`, `DeployStack`) and the leading verb is the act. A trailing * `status` is a query too — `DeployStatus` opens with a mutating verb and still * only asks a question — while a read verb *after* the action verb is not: * `DeployStackAndWatch` deploys. */ function releaseTokenMatch( tokens: readonly string[], ): { shape: ReleaseShape; matched: string } | undefined { if (tokens[tokens.length - 1] === "status") return undefined; let action: string | undefined; for (const token of tokens) { if (READ_VERBS[token] === true) return undefined; if ( DEPLOY_ACTIONS[token] === true || RELEASE_ACTIONS[token] === true || PUBLISH_ACTIONS[token] === true ) { action = token; break; } } if (action === undefined) return undefined; // `release` first: `PublishRelease` cuts a GitHub release, it does not publish // a package, and both vocabularies claim `publish`. `matched` names the token // the shape was read off, so a name that only grazes a vocabulary shows it. if ( tokens.some((t) => t === "release" || t === "releases") && tokens.some((t) => RELEASE_ACTIONS[t] === true) ) { return { shape: "github-release", matched: "release" }; } if (tokens.some((t) => PUBLISH_ACTIONS[t] === true)) return { shape: "package-publish", matched: action }; if (tokens.some((t) => DEPLOY_ACTIONS[t] === true)) return { shape: "deploy", matched: action }; return undefined; } /** * Classify a release-shaped tool call and what matched, like * {@link releaseShapeFromTool} plus the substring the shape was read off. One * classifier sits behind decisions, audit records and tick prompts, so they * cannot disagree about which call was blocked. */ export function releaseToolMatch( toolName: string, input: Record | undefined, ): { shape: GateShape; matched: string } | undefined { if (input === undefined) return undefined; if (toolName === "bash") { return typeof input.command === "string" ? releaseCommandMatch(input.command) : undefined; } // An MCP tool reached through the device path: the path *is* the tool name, so // it goes through the same tokeniser as a native one. Only `xd://` paths — a // `write` to `src/deploy.ts` is source code, not a deploy. const path = typeof input.path === "string" ? input.path : ""; if (toolName === "write" && path.startsWith("xd://")) { const viaDevice = releaseTokenMatch(releaseTokens(path)); if (viaDevice !== undefined) return viaDevice; } return releaseTokenMatch(releaseTokens(toolName)); } /** Release-shaped device/tool invocations that do not pass through a shell. */ export function releaseShapeFromTool( toolName: string, input: Record | undefined, ): GateShape | undefined { return releaseToolMatch(toolName, input)?.shape; } /** * The refusal wording for the shared-host gate (#428). The whole-package * `bun test` form names the focused alternative (a guard that only denies * teaches nothing and gets worked around); a refused shell suite names the * script that fired and the sanctioned `bash -n` parse check (#687), because * there is no focused form of a suite script — the alternative is to drop the * execution segment and keep the allowed prefix, not to run something else. */ function sharedHostRefusalReason(matched: string | undefined): string { if (matched !== undefined && /^bun\s+test\b/.test(matched)) { return ( "Blocked by sharedHostPolicy: the whole-package `bun test` is not a worker's proof path on this shared " + "host (it overloads the 4-core VPS and, before #399, SIGTERMed the production daemon). " + "Run a focused `bun test .test.ts` instead." ); } // The only shared-host matches that still open with `env` are the -S // operands this policy refuses rather than classify (a runtime `${…}` // expansion, or an escape env's split-string parser rejects). Name that, // not the generic suite wording, so a refused command reads like what it is. if (matched !== undefined && /^env\s+/.test(matched)) { return ( "Blocked by sharedHostPolicy: an `env -S`/`--split-string` operand cannot be classified from a static " + "command line — it carries a `${…}`/`$` runtime expansion or an escape env's split-string parser does " + "not accept. Refusing rather than guessing whether it runs a whole-package `bun test`; spell the " + "command literally instead." ); } if (matched !== undefined) { return ( `Blocked by sharedHostPolicy: \`${matched}\` is a shared-host shell suite and not a worker's proof ` + "path on this shared host (it overloads the 4-core VPS that also runs Langfuse and the fleet). " + `\`bash -n ${matched}\` is the sanctioned local check — parsing never executes the script — and CI's ` + "`herdr plugin + installer shell suites` job owns executing it. Drop the segment that fired and keep " + "the `bash -n` parse gate." ); } return ( "Blocked by sharedHostPolicy: this shell suite is not a worker's proof path on this shared host " + "(it overloads the 4-core VPS that also runs Langfuse and the fleet). " + "`bash -n ` is the sanctioned local check; CI's `herdr plugin + installer shell suites` job owns " + "executing it." ); } /** * The worker-brief notice for the shared-host gate (#687): the guarded suite * paths and the sanctioned local check, derived from * {@link SHARED_HOST_SCRIPTS} so the rendered brief and the refusal can never * disagree about what is guarded. One line and non-empty whenever the list is: * the brief's rendered line count must equal its template's, which the * buildBrief tests pin, so the notice must never carry an internal newline. */ export function sharedHostBriefNotice(): string { if (SHARED_HOST_SCRIPTS.length === 0) return ""; const paths = SHARED_HOST_SCRIPTS.map((script) => `\`${script}\``).join(", "); return ( `**Shared-host guard:** ${paths} are never executed on this shared host — \`bash -n\` on them is the ` + "sanctioned local check (parsing never executes), and CI's `herdr plugin + installer shell suites` job " + "owns executing them. A command that executes one is refused, and the refusal names the segment; drop " + "that segment and keep the `bash -n` parse gate." ); } /** * The refusal a `role` session gets for `shape` under `grants`, or `undefined` * when the grant covers it. * * Separate from classification so the external-orchestrator tick can classify * once, consult its own ownership state, and still word the refusal identically. * * The comparison is the whole gate: a grant is an `AuthorityHolder`, so * `"human"` equals no role and refuses every session, and `"worker"` equals no * grant and is refused every shape however permissive the config is (#126). */ export function releaseRefusal( grants: ResolvedGrants, role: SessionRole, shape: GateShape, ): ReleaseDecision | undefined { // The shared-host gate is not a grant anyone holds: a worker is always // refused — the shared operator host cannot absorb a whole-package suite, // which is the exact load that stopped it — and an operator session is // never, because the operator owns the host and CI owns the full suite. if (shape === SHARED_HOST_SHAPE) { if (role !== "worker") return undefined; return { block: true, reason: sharedHostRefusalReason(undefined) }; } const holder = grants[shape]; if (holder === role) return undefined; return { block: true, reason: `Blocked by releasePolicy: ${shape} is granted to "${holder}", and this is a ${role} session. ` + (role === "worker" ? "A worker session never holds a release grant, whatever the config says — report the release " + "as the next step and stop at a green PR." : holder === "human" ? `Only a human may do this. Set releasePolicy.${shape} to "orchestrator" for this project ` + "first, and only once the operator brief carries the procedure to follow." : "Ask the operator which session is meant to hold this grant."), }; } export function releaseDecision( grants: ResolvedGrants, role: SessionRole, toolName: string, input: Record, ): { shape: GateShape; matched: string; decision: ReleaseDecision } | undefined { const match = releaseToolMatch(toolName, input); if (match === undefined) return undefined; const decision = releaseRefusal(grants, role, match.shape); if (decision === undefined) return undefined; // The shared-host gate's wording is matched-specific (name the focused form // for a whole-package `bun test`, name the suite for a script), so it is // read off the offending segment here rather than from `releaseRefusal`. if (match.shape === SHARED_HOST_SHAPE) { return { shape: match.shape, matched: match.matched, decision: { block: true, reason: sharedHostRefusalReason(match.matched) }, }; } return { shape: match.shape, matched: match.matched, decision }; } const RELEASE_ARG_ALLOWLIST = new Set([ "repo", "stack", "artefact", "artifact", "environment", "tag", "version", "image", "prUrl", "branch", "service", ]); /** Keep only explicitly safe release-target strings; redact every other value. */ export function redactReleaseArgs(input: Record): Record { const redacted: Record = {}; for (const [key, value] of Object.entries(input)) { redacted[key] = RELEASE_ARG_ALLOWLIST.has(key) && typeof value === "string" ? value.slice(0, 120) : "(redacted)"; } return redacted; } /** Credential-shaped text a blocked invocation must not carry into the audit * file, each with the replacement that takes its place. */ const CREDENTIAL_PATTERNS: Array = [ // Armoured private keys, in full so a key cannot survive as its own body. [/-----BEGIN [A-Z0-9 ]+PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]+PRIVATE KEY-----/g, "[redacted]"], // GitHub classic and fine-grained PATs. [/gh[pousr]_[A-Za-z0-9]{20,}/g, "[redacted]"], [/github_pat_[A-Za-z0-9_]{20,}/g, "[redacted]"], // Slack tokens. [/xox[baprs]-[A-Za-z0-9-]{10,}/g, "[redacted]"], // AWS access key ids. The secret half is caught by the long-run rule below. [/AKIA[0-9A-Z]{16}/g, "[redacted]"], // OpenAI-style `sk-` secrets. [/sk-[A-Za-z0-9]{16,}/g, "[redacted]"], // `--token x`, `--password=x` and friends. The token word must end at the // separator, so `--author` stays a git flag rather than dying as a // credential. The flag name survives so the record says what kind of // credential rode on the call; only its value is replaced. [ /--([a-z0-9-]*(?:token|secret|password|passwd|api-?key|auth(?:orization)?))(?=[\s=])(?:\s+|=)\S+/gi, "--$1 [redacted]", ], // `TOKEN=…`, `NPM_CONFIG_TOKEN=…` style assignments. [/[A-Z][A-Z0-9_]*(?:TOKEN|PASSWORD|PASSWD|SECRET|API_?KEY|CREDENTIAL)[A-Z0-9_]*=\S+/g, "[redacted]"], // `Bearer …` / `Basic …` header values. [/\b(?:Bearer|Basic)\s+\S+/g, "[redacted]"], // Long unbroken runs are token-shaped whatever scheme they belong to. A // commit sha is a stranger in a blocked command worth keeping; a leaked // token is not worth the triage convenience. [/[A-Za-z0-9_-]{32,}/g, "[redacted]"], ]; /** The bound a recorded invocation is cut to. Long enough to read a heredoc, * short enough that a fixture dump cannot bloat the audit file. */ const RELEASE_INVOCATION_MAX = 400; /** Redact anything credential-shaped in a blocked command, then bound its * length. Redaction runs before truncation so a cut cannot leak a token * fragment past its own pattern. */ export function redactReleaseCommand(command: string): string { let safe = command; for (const [pattern, replacement] of CREDENTIAL_PATTERNS) { safe = safe.replace(pattern, replacement); } return safe.length <= RELEASE_INVOCATION_MAX ? safe : `${safe.slice(0, RELEASE_INVOCATION_MAX)}…`; } /** The invocation a block records: the shell command for a `bash` call, and * nothing for other tools — their redacted args already name the call. */ export function releaseBlockedInvocation( toolName: string, input: Record, ): string | undefined { return toolName === "bash" && typeof input.command === "string" ? redactReleaseCommand(input.command) : undefined; } interface ReleasePolicyPi { on( event: "tool_call", handler: ( event: { toolName: string; input: Record }, ctx: unknown, ) => ReleaseDecision | undefined, ): void; } /** Inline session extension used by workers and the embedded orchestrator. */ export function releasePolicyTripwire( grants: ResolvedGrants, role: SessionRole, onBlocked: (shape: GateShape, context: ReleaseBlockContext) => void = () => {}, ): (pi: ReleasePolicyPi) => void { return (pi) => { pi.on("tool_call", (event) => { const blocked = releaseDecision(grants, role, event.toolName, event.input); if (blocked === undefined) return undefined; try { const invocation = releaseBlockedInvocation(event.toolName, event.input); onBlocked(blocked.shape, { tool: event.toolName, reason: blocked.decision.reason, matched: blocked.matched, ...(invocation === undefined ? {} : { invocation }), args: redactReleaseArgs(event.input), }); } catch { // Audit is evidence, not the gate. A full disk must not turn a deny into allow. } return blocked.decision; }); }; } export function recordReleaseBlock( project: string, source: ReleaseBlock["source"], shape: GateShape, details: Omit = {}, root = stateDir(), now = new Date(), ): void { mkdirSync(root, { recursive: true }); // Redact again at the durable boundary. Callers normally pass the tripwire's // already-safe context, but no alternate recorder path gets to rely on that. const { args, invocation, matched, ...attribution } = details; const record: ReleaseBlock = { project, source, shape, ...attribution, ...(args === undefined ? {} : { args: redactReleaseArgs(args) }), ...(invocation === undefined ? {} : { invocation: redactReleaseCommand(invocation) }), ...(matched === undefined ? {} : { matched: redactReleaseCommand(matched) }), at: now.toISOString(), }; appendFileSync(join(root, RELEASE_POLICY_AUDIT_FILE), `${JSON.stringify(record)}\n`, { mode: 0o600 }); } export interface ReleaseDriftSummary { count: number; latest: ReleaseBlock; } /** * The `release-policy-blocks.jsonl` rows recorded for `project` on `now`'s * UTC day, in file order. One scan backs both day summaries, so the release * drift counter and the shared-host counter can never disagree about which * ledger rows are in play (#562). */ function auditRowsToday(project: string, root: string, now: Date): ReleaseBlock[] { let text: string; try { text = readFileSync(join(root, RELEASE_POLICY_AUDIT_FILE), "utf8"); } catch { return []; } const day = now.toISOString().slice(0, 10); const rows: ReleaseBlock[] = []; for (const line of text.split("\n")) { if (line.length === 0) continue; try { const value = JSON.parse(line) as Partial; if ( value.project === project && typeof value.at === "string" && value.at.startsWith(day) && (value.source === "worker" || value.source === "orchestrator") && typeof value.shape === "string" ) { rows.push(value as ReleaseBlock); } } catch { // One torn line does not hide later valid audit records. } } return rows; } /** * Aggregate today's blocked release/deploy attempts for the orchestrator's * daily digest. The shared-host gate (#428) is deliberately excluded (#562): * a guard refusal is the interlock working, never a divergence from release * policy, and no grant could ever have permitted the command — so counting a * refusal here would report a success as a violation. {@link * sharedHostRefusalsToday} reads the same ledger and reports those rows on * their own line, so nothing is dropped. */ export function releaseDriftToday( project: string, root = stateDir(), now = new Date(), ): ReleaseDriftSummary | undefined { const drift = auditRowsToday(project, root, now).filter((row) => row.shape !== SHARED_HOST_SHAPE); const latest = drift[drift.length - 1]; if (latest === undefined) return undefined; return { count: drift.length, latest }; } export interface SharedHostGuardSummary { count: number; latest: ReleaseBlock; } /** * Aggregate today's shared-host gate refusals (#562), apart from release * drift. Every row records a worker being correctly stopped — the whole * package runs and host shell suites that once SIGTERMed the production * daemon — so the count and the latest {@link ReleaseBlock#matched matched * command} are the operator's signal when a worker keeps pushing at the gate * (tonight's clustering is how #558's wrapper evasion was found), and they * must never ride the release-policy line. */ export function sharedHostRefusalsToday( project: string, root = stateDir(), now = new Date(), ): SharedHostGuardSummary | undefined { const blocked = auditRowsToday(project, root, now).filter((row) => row.shape === SHARED_HOST_SHAPE); const latest = blocked[blocked.length - 1]; if (latest === undefined) return undefined; return { count: blocked.length, latest }; } export function releaseDriftDigestLine( project: string, root = stateDir(), now = new Date(), ): string | undefined { const drift = releaseDriftToday(project, root, now); if (drift === undefined) return undefined; const latest = drift.latest; const attribution = [ `${latest.source} ${latest.shape} at ${latest.at}`, ...(latest.issue === undefined ? [] : [`issue #${latest.issue}`]), ...(latest.runId === undefined ? [] : [`run ${latest.runId}`]), ...(latest.tool === undefined ? [] : [`tool ${latest.tool}`]), ...(latest.invocation === undefined ? [] : [`attempted "${latest.invocation}"`]), ...(latest.matched === undefined ? [] : [`matched "${latest.matched}"`]), ].join(", "); return ( `Release-policy drift today: ${drift.count} release/deploy tool call(s) were blocked ` + `(latest: ${attribution}). ` + "Include this divergence from releasePolicy=none in today's digest." ); } /** * The tick/digest line for the shared-host guard (#562), deliberately apart * from {@link releaseDriftDigestLine}: a shared-host refusal is a worker * being correctly stopped, never a release-policy divergence, and naming a * release grant here would misattribute an event no grant covers (#549). A * working guard reads as a working guard; the guard name and the matched * command are the genuinely useful parts, because a clustering of refusals * is itself a signal. */ export function sharedHostGuardDigestLine( project: string, root = stateDir(), now = new Date(), ): string | undefined { const refusal = sharedHostRefusalsToday(project, root, now); if (refusal === undefined) return undefined; const latest = refusal.latest; const attribution = [ `${latest.source} ${latest.shape} at ${latest.at}`, ...(latest.issue === undefined ? [] : [`issue #${latest.issue}`]), ...(latest.runId === undefined ? [] : [`run ${latest.runId}`]), ...(latest.tool === undefined ? [] : [`tool ${latest.tool}`]), ...(latest.invocation === undefined ? [] : [`attempted "${latest.invocation}"`]), ...(latest.matched === undefined ? [] : [`matched "${latest.matched}"`]), ].join(", "); return ( `The shared-host guard refused ${refusal.count} worker command(s) today ` + `(latest: ${attribution}). ` + "This is the shared-host interlock, not a release-policy divergence: a worker " + "is refused these whatever grants it holds; the operator owns the host and CI owns the full suite." ); }