/** * The LETHAL-TRIFECTA check — the headline Safety detector. Simon Willison's * "lethal trifecta": a single unit (a subagent / model-invocable skill) that * simultaneously holds all THREE capability legs is a prompt-injection * exfiltration path with NO exploit code — attacker-controllable content flows * in, reads your private data, and ships it out, all driven by the model. * * LEG A — PRIVATE-DATA READ — can read local secrets / files / repo * (Read, mcp__filesystem__*, github get_file, * and `Bash`, which can `cat ~/.ssh/*` / `.env`). * LEG B — UNTRUSTED-CONTENT INTAKE — ingests attacker-controllable content * (WebFetch, WebSearch, mcp__fetch__*, MCP * servers reading issues / email / tickets). * LEG C — EXFILTRATION CHANNEL — can send data out (WebFetch, computer_use, * github create_pull_request / add_issue_comment, * any external-write MCP, and `Bash`, which can * `curl`/`wget` to anywhere). * * Meta's "Rule of Two": allow at most two of the three legs in one unit. A unit * holding all three is the hard finding. NO other tool checks the tool-SET for * this — every competitor lints a single tool's effect, never the dangerous * COMBINATION. * * `Bash` is special: it satisfies BOTH leg A (read a secret) AND leg C (curl it * out). So `Bash` + any leg-B tool (e.g. `WebFetch`) is already all three legs. * * HIGH-PRECISION (don't-cry-wolf): only WELL-KNOWN tools map to a leg; an * unknown tool maps to nothing. A `Tool(restriction)` suffix is stripped with the * same `baseTool` shape `effects.ts` uses. * * INHERITS-ALL: a unit with no `tools:` line inherits ALL tools — maximal blast * radius, trivially all three legs. Aligned with the codebase's existing * "inherits-all is ADVISORY" stance (compile/scan treat a missing contract as a * footgun note, not a hard defect), an inherits-all trifecta is reported as * `"advisory"`; an EXPLICIT all-three contract is the `"hard"` flag. * * 🔴 SUBAGENTS ONLY. Everything above is the SUBAGENT reading, where `tools:` is a * documented rail that really does bound the unit. It is NOT how a SKILL works — * a skill's `allowed-tools:` is a PRE-APPROVAL, not a fence. See * {@link skillTrifectaIssue} for the skill path and the measurement behind it. * * Pure + ONE detector (one-detector-no-drift) — intended to be reused by `scan` * (the read-only audit) and a future `lethal-trifecta` lint rule. The dialect is * injected (core ⊄ adapter) so it generalizes across harnesses (Codex's `shell` * plays Bash's dual A+C role — see `LEG_BASH_DUAL` below). The per-leg catalogs * are LOCAL consts here because the `HarnessDialect` interface has no trifecta-leg * fields today; see the "Recommended dialect additions" note at the bottom. */ import type { HarnessDialect } from "./dialect.js"; /** The three capability legs of the lethal trifecta. */ export type TrifectaLeg = "private" | "untrusted" | "exfil"; /** The tools classified into each leg (base names, de-duplicated). */ export interface TrifectaLegs { /** LEG A — tools that can read private data (secrets, files, repo). */ readonly private: readonly string[]; /** LEG B — tools that ingest untrusted / attacker-controllable content. */ readonly untrusted: readonly string[]; /** LEG C — tools that can exfiltrate data out of the trust boundary. */ readonly exfil: readonly string[]; } /** * The severity of a trifecta finding: * - `"hard"`: an EXPLICIT contract that names all three legs — a concrete, * declared exfil path. The flag. * - `"advisory"`: an inherits-all unit (no `tools:` line) that holds all three * legs only because it inherits everything — maximal blast radius, * reported as advisory in line with the inherits-all stance. */ export type TrifectaSeverity = "hard" | "advisory"; /** * How much of a fence a SKILL's `disallowed-tools:` actually is — set only on * findings produced by {@link skillTrifectaIssue}, absent on subagent findings. * * - `"none"` — no `disallowed-tools:` line (or an unreadable block, which yields * the same thing): the skill inherits every tool the session grants. This is * the DEFAULT state of every skill in the ecosystem, which is why the report * aggregates it into one line instead of repeating it per skill. * - `"ineffective"` — a `disallowed-tools:` line exists but does not deny every * built-in supplier of any one leg, so no leg is closed. That one is per-skill: * an author who believed they had fenced and had not. */ export type SkillFenceState = "none" | "ineffective"; /** * A lethal-trifecta finding — emitted ONLY when all three legs are non-empty (or, * for the inherits-all case, when the contract inherits all tools). The `legs` * field names the specific tools that supplied each leg, so the report can show * exactly which capabilities to drop to break the trifecta. */ export interface TrifectaFinding { readonly severity: TrifectaSeverity; /** The tools that supplied each leg (advisory inherits-all carries the wildcard). */ readonly legs: TrifectaLegs; /** A ready-to-show, actionable message. */ readonly message: string; /** * SKILLS ONLY — the fence state behind the finding ({@link SkillFenceState}). * Absent on subagent findings, whose `tools:` rail is a different mechanism. * The report reads it to decide aggregate-vs-per-unit presentation. */ readonly fence?: SkillFenceState; } /** * Does a `Bash(...)` grant still supply the shell's legs — reading a secret and * curling it out — or has it been narrowed to a command that cannot? * * Narrowing the grant is a remedy this tool RECOMMENDS, so the reading has to be able * to see a narrowed grant as narrowed: `Bash(node ./scripts/log.mjs:*)` was once read * as plain `Bash` — the whole shell, in both leg A and leg C — and an author who took * the advice saw the score not move. But the fix for that overshot into the opposite * error, and the opposite error is the one that matters (see {@link EFFECT_FREE}), so * the answer is now: a grant is bounded ONLY when it pins a program from that list. * * The honest consequence, stated rather than hidden: narrowing `Bash` to a script of * your own (`node ./ledger.mjs`) no longer drops a leg, because nothing here has read * that script. The remedy that still works is to drop a leg the checker can SEE — fence * the tool off, or narrow to a command whose effects are enumerable. * * Bash no restriction at all * Bash(*) Bash(:*) the wildcard forms * Bash(node:*) Bash(node x.mjs:*) an interpreter runs whatever it is handed * Bash(sh ...) Bash(curl ...) the shell, and the pinned exfiltrator * Bash(echo ready:*) bounded — and only this shape is * * When in doubt it returns true (keeps the legs): a false "you are exposed" costs the * author an argument, a false "you are safe" costs them the finding. */ export declare function bashGrantIsUnbounded(raw: string): boolean; /** * Classify each tool in a declared contract into the trifecta legs it supplies. * A single tool may land in MULTIPLE legs (`Bash` → A+C, `WebFetch` → B+C). An * unknown tool lands in none (high-precision). De-duplicated per leg. * * NOTE on inherits-all: a wildcard (`""`/`"*"`) entry is NOT classified into a * named leg here (it has no concrete tool name); the inherits-all case is handled * by {@link lethalTrifectaIssues}, which knows it grants every leg. */ export declare function classifyTrifectaLegs(tools: readonly string[], dialect: HarnessDialect): TrifectaLegs; /** Extra facts about HOW the contract was obtained, which change the verdict. */ export interface TrifectaContext { /** * The unit's frontmatter block EXISTS but is not valid YAML. * * 🔴 WHY THE DETECTOR NEEDS TO KNOW. vigiles's frontmatter reader is * deliberately lenient: on a block js-yaml rejects it regex-SALVAGES the * fields, so the live PreToolUse rail still has something to enforce. Right * for a rail, wrong for a score. Measured 2026-08-08: * `readFrontmatter(bad).malformed` is `true` — the tool KNOWS the block is * broken — while `frontmatterList(…, "allowed-tools")` on it still returns * `["Read","Bash"]`, the narrow contract its author MEANT. A unit whose * contract a strict loader rejects was therefore graded as though it had * declared exactly that list, and scored CLEAN. Presence of a declaration is * not enforcement of it — the product's own thesis, turned on the product. * * With this set, `tools` is read as a SALVAGE, not a contract: it can only make * the verdict worse, never better. A salvaged list that names all three legs * still fires `"hard"` (both readings of the file agree the unit holds them); * anything less falls back to what a strict loader actually yields — no * contract at all, i.e. inherits-all, which is the `"advisory"` finding. */ readonly contractUnreadable?: boolean; } /** * Returns a {@link TrifectaFinding} ONLY when a unit holds all three legs, else * `null` (≤ 2 legs = safe by the Rule of Two). * * Three paths: * - INHERITS-ALL (a wildcard `""`/`"*"`, or an EMPTY contract): inherits every * tool → trivially all three legs → an `"advisory"` finding (the inherits-all * stance: a footgun worth surfacing, not a declared exfil path). * - EXPLICIT: classify the named tools; emit a `"hard"` finding iff each of the * three legs is non-empty. * - UNREADABLE ({@link TrifectaContext.contractUnreadable}): the names came from * a salvage of a block a strict loader rejects. They can only make the verdict * WORSE — a salvaged all-three still fires `"hard"` — and anything short of * that falls back to what a strict loader really yields: no contract, i.e. * inherits-all, the `"advisory"` finding. Never the other way round. */ export declare function lethalTrifectaIssues(tools: readonly string[], dialect: HarnessDialect, ctx?: TrifectaContext): TrifectaFinding | null; /** The legs a `disallowed-tools:` list leaves standing, naming the suppliers. */ export declare function skillFenceLegs(disallowed: readonly string[], dialect: HarnessDialect): TrifectaLegs; /** * Whether THIS harness understands a skill-level `disallowed-tools:` at all. * * Read off the dialect record that already exists — `skillFrontmatterKeys` is * exactly "which SKILL.md keys this harness reads", so the question "is there a * fence here?" is the membership of the one key that IS the fence. No new * capability flag: a second field saying the same thing is a second thing to * keep true, and the compiler already emits from this one. * * 🔴 IT USED TO ASK THE WRONG QUESTION. The body was * `dialect.skillFrontmatter === "claude-code"` — a harness name standing in for * a key, which made a harness that read `disallowed-tools:` under any other name * unfenceable by construction. The function already HAD its capability name; * only the fact it read was wrong. */ export declare function dialectSupportsSkillFence(dialect: HarnessDialect): boolean; /** * The lethal-trifecta finding for a MODEL-INVOCABLE SKILL, computed from its * `disallowed-tools:` — the only skill frontmatter field measured to remove a * tool. `allowed-tools:` is not an input here, on purpose: it is a pre-approval, * and treating it as a bound is the defect this function exists to correct (see * the block comment above for the measurement). * * 🔴 AND IT IS A CLAUDE-CODE MECHANISM, which this applied to every harness. On a * Codex repo (`skillFrontmatterKeys: ["name", "description"]` — and our own * compiler drops the tool keys there) every skill was reported as holding all * three legs, scored against Safety, and handed the remedy "add a * `disallowed-tools:` line". That line is INERT in Codex: the author does the * work, the harness ignores the key, the finding returns, and the score never * moves. A remedy the target cannot apply is worse than no finding — it is a floor * dressed up as a defect, and it teaches people that the Safety number is fake. * * So the detector runs only where the fence exists ({@link dialectSupportsSkillFence}). * This is a claim about the MECHANISM, not about the risk: a Codex skill really * does hold every leg, and bounding it is a session/config-level question that * belongs to a Codex-specific detector, not to a Claude Code one applied by * default. Subagent findings are unaffected — `tools:` is a different mechanism, * with its own dialect handling. * * @param disallowed `disallowed-tools:` as declared, or `null` when the line is * absent — the two are NOT the same: an explicit empty list still denies nothing, * but the message should not tell an author who wrote the line that they didn't. * @returns `null` once the fence closes at least one leg (Rule of Two satisfied, * among the built-ins — see {@link FENCE_SUPPLIERS} for the stated limit), and * `null` for any harness with no skill fence to close it with. */ export declare function skillTrifectaIssue(disallowed: readonly string[] | null, dialect: HarnessDialect, ctx?: TrifectaContext): TrifectaFinding | null; //# sourceMappingURL=lethal-trifecta.d.ts.map