import { relative, resolve } from "node:path"; import { projectConfigPath, projectStateDir } from "../../platform/paths.ts"; import { normalizeSeparators } from "../../platform/sanitize.ts"; import { isInside, isPolicySurface, resolveTarget } from "./floor.paths.ts"; import { type HeredocChunk, heredocChunks, type ShellSegment, type ShellWord, tokenizeShell, } from "./floor.tokenize.ts"; import { firstOperand, verbOf } from "./floor.verb.ts"; /** * `remedy` is the way out for *this* denial. * * hazard: every policy-surface refusal used to end with the same sentence — set a gate command, make policy changes * from your own terminal. That is advice about *writing* policy, handed to an agent that was trying to *read* the * handoff the harness had just told it to read. A refusal a model cannot plan around is the opaque-refusal failure * the 2026 tool-use literature names, and it is what confused a colleague's agent * ([/decisions/ad-047.md](/decisions/ad-047.md)). */ export type PolicySurfaceVerdict = | { kind: "allow" } | { kind: "deny"; detail: string; note: string; remedy?: string }; const ALLOW: PolicySurfaceVerdict = { kind: "allow" }; // invariant: this is an allowlist on purpose. The set of ways a shell can write a file is unbounded — // `python3 -c`, `perl -pi`, `ex`, any interpreter — so enumerating writers guarantees a hole. Enumerating // the readers instead means an unknown verb lands on the deny side without anyone having to predict it. // hazard: `awk` and `sort` look like readers and are not — `awk '{print > f}'` and `sort -o f` both write a // file the head verb never reveals. They are left out deliberately. const PROVEN_READERS = new Set([ "cat", "cmp", "diff", "echo", "file", "grep", "head", "jq", "less", "ls", "md5sum", "more", "od", "printf", "rg", "sha256sum", "stat", "strings", "tail", // why: `test` and `[` evaluate a predicate and produce an exit code. They have no way to write a file at all — // no output flag, no redirection of their own — so they are strictly safer than `echo`, which is already here. // Their absence was an incomplete allowlist rather than a decision: the harness tells an agent to read the // handoff, and `test -f handoff.json && head -c 2000 handoff.json` — the obvious way to do it — was denied // ([/decisions/ad-047.md](/decisions/ad-047.md)). "test", "[", "wc", "xxd", ]); // hazard: `git checkout -- `, `git restore` and `git apply` overwrite the working tree, so `git` as a // whole cannot be a reader. Only the subcommands that provably do not write are allowed. const GIT_READERS = new Set(["show", "diff", "log", "status", "ls-files", "cat-file", "blame"]); // hazard: a heredoc is only a program when the verb it feeds executes what it reads. `git commit -F -` and // `cat < word.text).join(" ")); return text.includes(harnessPrefix(projectDir)); } // why: the surface and the target overlap when either contains the other. Containment in the second // direction is what catches `rm -rf .tlc/harness/state`, which removes the flags without ever naming one. // hazard: the project root also contains the surface. Counting it would deny `find .` and `grep -r x .`, // so the root is excluded and destruction of the whole project stays the concern of the existing rules. function overlapsSurface( projectDir: string, resolved: string, extraSurfacePaths: readonly string[], ): boolean { if (resolved === resolve(projectDir)) { return false; } if (isPolicySurface(projectDir, resolved, extraSurfacePaths)) { return true; } return [projectConfigPath(projectDir), projectStateDir(projectDir)].some( (surface) => isInside(surface, resolved) || isInside(resolved, surface), ); } function referencesSurface( projectDir: string, word: ShellWord, extraSurfacePaths: readonly string[], ): boolean { if (word.text === "") { return false; } // hazard: an unresolved word carries a `$var` or `$(...)`, so its value is unknowable here. Matching the // literal portion catches `> .tlc/harness/$f`; a fully computed path stays out of reach and is what the // per-session integrity baseline exists to catch. if (word.unresolved) { const normalized = normalizeSeparators(word.text); if (normalized.includes(harnessPrefix(projectDir))) { return true; } return extraSurfacePaths.some((path) => normalized.includes(normalizeSeparators(path))); } return overlapsSurface(projectDir, resolveTarget(projectDir, word.text), extraSurfacePaths); } // why: a redirect target is not an argument of the head verb, so argument scanning alone would allow // `cat x > config.json` — the head verb there is a proven reader. Both the spaced and the attached forms // have to be read, because `>f`, `>>f` and `x>f` all tokenize as a single word. function redirectTargets(words: ShellWord[]): ShellWord[] { const targets: ShellWord[] = []; for (let index = 0; index < words.length; index += 1) { const word = words[index]; // hazard: a `>` inside a quoted argument is literal text, not a redirect. Scanning quoted words denied // commands that merely carry shell-looking data — a JSON hook payload, a fixture, a doc example. if (!word || word.quotedStart) { continue; } const match = /^(.*?)>{1,2}\|?(.*)$/s.exec(word.text); if (!match) { continue; } const attached = match[2] ?? ""; if (attached !== "") { targets.push({ text: attached, unresolved: word.unresolved, quotedStart: false }); continue; } const next = words[index + 1]; if (next) { targets.push(next); index += 1; } } return targets; } function harnessSubcommand(args: ShellWord[]): string | null { const operands = args.filter((word) => !word.text.startsWith("-") && word.text !== ""); if (operands[0]?.text.toLowerCase() !== "harness") { return null; } // why: `route()` defaults a missing subcommand to `status`, so bare `tlc harness` reads state and is // not a mutation. return (operands[1]?.text ?? "status").toLowerCase(); } function checkSegment( projectDir: string, segment: ShellSegment, extraSurfacePaths: readonly string[], ): PolicySurfaceVerdict { for (const target of redirectTargets(segment.words)) { if (referencesSurface(projectDir, target, extraSurfacePaths)) { return deny( "a redirect in this command writes into the harness policy surface.", "redirect into the policy surface", ); } } const head = verbOf(segment.words); if (head && HARNESS_BINS.has(head.verb)) { const subcommand = harnessSubcommand(head.args); if (subcommand !== null && MUTATING_SUBCOMMANDS.has(subcommand)) { return deny( `\`tlc harness ${subcommand}\` changes harness policy, and policy is the operator's to change.`, `tlc harness ${subcommand}`, ); } } const references = segment.words.filter((word) => referencesSurface(projectDir, word, extraSurfacePaths)); if (references.length === 0 && !namesSurface(projectDir, segment)) { return ALLOW; } // hazard: an unreliable split means the head verb is not established, so a reader-looking head proves // nothing. Unknown resolves to denied, as it does for the destruction rules. if (segment.opaque) { return deny( "this command names the harness policy surface inside a segment this gate cannot split, so what it does to it cannot be established.", "unprovable policy-surface access", ); } if (!head) { return deny( "the harness policy surface is named in a command with no resolvable verb.", "policy-surface access with no verb", ); } // why: a proven reader is cleared before any other question, because reading the policy is ordinary work // and the bootstrap asks for it by name. Nothing in this set can write a file on its own. if (PROVEN_READERS.has(head.verb)) { return ALLOW; } if (head.verb === "git") { const subcommand = firstOperand(head.args)?.text.toLowerCase() ?? ""; return GIT_READERS.has(subcommand) ? ALLOW : deny( `\`git ${subcommand}\` can write the working tree, so it cannot be proven to only read the harness policy surface.`, `git ${subcommand} on the policy surface`, READ_REMEDY, ); } if (references.some((word) => word.unresolved)) { return deny( "this command builds a harness policy path at runtime, so the file it would touch cannot be established.", "unresolvable policy-surface path", ); } return deny( `\`${head.verb}\` is not a proven reader, so this command cannot be shown to only read the harness policy surface.`, `${head.verb} on the policy surface`, READ_REMEDY, ); } // hazard: `python3 - <> f <