vigiles 14.7.0 → 14.9.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/dist/adapters/claude-code/agent-runtime.d.ts +2 -19
- package/dist/adapters/claude-code/agent-runtime.js +5 -30
- package/dist/adapters/claude-code/agent-tools.d.ts +20 -0
- package/dist/adapters/claude-code/agent-tools.js +40 -0
- package/dist/audit-report.d.ts +1 -1
- package/dist/audit-report.template.html +37 -32
- package/dist/audit-score.d.ts +1 -1
- package/dist/audit-score.js +20 -20
- package/dist/audit-verdict.d.ts +1 -1
- package/dist/audit-verdict.js +3 -3
- package/dist/core/assert-never.d.ts +9 -0
- package/dist/core/assert-never.js +14 -0
- package/dist/core/description-overlap.js +2 -2
- package/dist/core/effects.js +3 -3
- package/dist/core/hash.d.ts +1 -2
- package/dist/core/hash.js +6 -4
- package/dist/core/hook-block-ineffective.d.ts +55 -6
- package/dist/core/hook-block-ineffective.js +9 -14
- package/dist/core/mcp-contract-message.d.ts +22 -0
- package/dist/core/mcp-contract-message.js +29 -0
- package/dist/core/mcp.d.ts +4 -12
- package/dist/core/mcp.js +3 -14
- package/dist/core/ncd.d.ts +12 -0
- package/dist/core/ncd.js +50 -0
- package/dist/core/plugin-dir-layout.d.ts +5 -5
- package/dist/core/plugin-dir-layout.js +10 -22
- package/dist/core/proofs.d.ts +2 -11
- package/dist/core/proofs.js +4 -39
- package/dist/core/skill-resources.d.ts +3 -3
- package/dist/core/skill-resources.js +9 -8
- package/dist/leaderboard.d.ts +2 -51
- package/dist/leaderboard.js +20 -225
- package/dist/optimize.d.ts +1 -1
- package/dist/optimize.js +3 -3
- package/dist/posix-path.d.ts +40 -0
- package/dist/posix-path.js +293 -0
- package/dist/scan-core.d.ts +154 -0
- package/dist/scan-core.js +690 -0
- package/dist/scan-files.d.ts +28 -0
- package/dist/scan-files.js +489 -0
- package/dist/scan.d.ts +11 -34
- package/dist/scan.js +55 -668
- package/dist/score-core.d.ts +83 -0
- package/dist/score-core.js +236 -0
- package/dist/test-coverage-files.d.ts +11 -0
- package/dist/test-coverage-files.js +208 -0
- package/package.json +1 -1
package/dist/scan.js
CHANGED
|
@@ -12,9 +12,21 @@
|
|
|
12
12
|
* need to RUN the plugin (observed egress under the sandbox, real trigger-rate)
|
|
13
13
|
* stack on top later; this core stays pure so it runs anywhere in CI for free.
|
|
14
14
|
*/
|
|
15
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
16
|
+
if (k2 === undefined) k2 = k;
|
|
17
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
18
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
19
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
20
|
+
}
|
|
21
|
+
Object.defineProperty(o, k2, desc);
|
|
22
|
+
}) : (function(o, m, k, k2) {
|
|
23
|
+
if (k2 === undefined) k2 = k;
|
|
24
|
+
o[k2] = m[k];
|
|
25
|
+
}));
|
|
26
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
27
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
28
|
+
};
|
|
15
29
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
-
exports.isManagedHookCommand = isManagedHookCommand;
|
|
17
|
-
exports.preferCompiledHooksMessage = preferCompiledHooksMessage;
|
|
18
30
|
exports.scanPlugin = scanPlugin;
|
|
19
31
|
exports.verifyLiveMcpTools = verifyLiveMcpTools;
|
|
20
32
|
exports.formatMcpContractReport = formatMcpContractReport;
|
|
@@ -27,538 +39,25 @@ const plugin_loader_js_1 = require("./adapters/claude-code/plugin-loader.js");
|
|
|
27
39
|
const layout_js_1 = require("./adapters/claude-code/layout.js");
|
|
28
40
|
const dialect_js_1 = require("./adapters/claude-code/dialect.js");
|
|
29
41
|
const plugin_loader_js_2 = require("./plugin-loader.js");
|
|
30
|
-
const tool_contract_js_1 = require("./core/tool-contract.js");
|
|
31
42
|
const hook_events_js_1 = require("./core/hook-events.js");
|
|
32
43
|
const mcp_config_js_1 = require("./core/mcp-config.js");
|
|
33
44
|
const hook_normalize_js_1 = require("./core/hook-normalize.js");
|
|
34
|
-
const linters_js_1 = require("./core/linters.js");
|
|
35
|
-
const frontmatter_read_js_1 = require("./core/frontmatter-read.js");
|
|
36
|
-
const description_overlap_js_1 = require("./core/description-overlap.js");
|
|
37
|
-
const skill_description_budget_js_1 = require("./core/skill-description-budget.js");
|
|
38
|
-
const mcp_tool_js_1 = require("./core/mcp-tool.js");
|
|
39
45
|
const mcp_js_1 = require("./core/mcp.js");
|
|
46
|
+
const mcp_contract_message_js_1 = require("./core/mcp-contract-message.js");
|
|
40
47
|
const mcp_hook_js_1 = require("./core/mcp-hook.js");
|
|
41
|
-
const lethal_trifecta_js_1 = require("./core/lethal-trifecta.js");
|
|
42
|
-
const skill_resources_js_1 = require("./core/skill-resources.js");
|
|
43
|
-
const skill_missing_fence_js_1 = require("./core/skill-missing-fence.js");
|
|
44
48
|
const plugin_dir_layout_js_1 = require("./core/plugin-dir-layout.js");
|
|
45
|
-
const delegation_trifecta_js_1 = require("./core/delegation-trifecta.js");
|
|
46
49
|
const hook_block_ineffective_js_1 = require("./core/hook-block-ineffective.js");
|
|
47
50
|
const hook_matcher_js_1 = require("./core/hook-matcher.js");
|
|
48
|
-
const agent_runtime_js_1 = require("./adapters/claude-code/agent-runtime.js");
|
|
49
51
|
const test_coverage_js_1 = require("./test-coverage.js");
|
|
50
|
-
const
|
|
52
|
+
const scan_core_js_1 = require("./scan-core.js");
|
|
53
|
+
// Re-export the pure detectors (and their public types: SurfaceClassifier,
|
|
54
|
+
// SkillScanContext, isManagedHookCommand, preferCompiledHooksMessage, ...) that
|
|
55
|
+
// moved to the node-free `./scan-core.js`, so every existing consumer of
|
|
56
|
+
// `./scan.js` is unchanged — one detector, no drift.
|
|
57
|
+
__exportStar(require("./scan-core.js"), exports);
|
|
51
58
|
// ---------------------------------------------------------------------------
|
|
52
59
|
// Internals
|
|
53
60
|
// ---------------------------------------------------------------------------
|
|
54
|
-
const SCRIPT_RE = /\S+\.(?:sh|mjs|cjs|js|ts|py|rb)\b/g;
|
|
55
|
-
// The scalar fields scan reads from a skill/agent `---` block, via the shared
|
|
56
|
-
// lenient reader (core/frontmatter-read.ts) — a real YAML parse with a regex
|
|
57
|
-
// salvage on malformed input, so block scalars / multi-line quoted values parse
|
|
58
|
-
// for free and a bad block still yields what it can. One reader, no drift.
|
|
59
|
-
function frontmatter(md) {
|
|
60
|
-
const fm = (0, frontmatter_read_js_1.readFrontmatter)(md);
|
|
61
|
-
return {
|
|
62
|
-
name: (0, frontmatter_read_js_1.frontmatterScalar)(fm, "name"),
|
|
63
|
-
description: (0, frontmatter_read_js_1.frontmatterScalar)(fm, "description"),
|
|
64
|
-
model: (0, frontmatter_read_js_1.frontmatterScalar)(fm, "model"),
|
|
65
|
-
color: (0, frontmatter_read_js_1.frontmatterScalar)(fm, "color"),
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
function escapeRe(s) {
|
|
69
|
-
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
70
|
-
}
|
|
71
|
-
function makeClassifier(layout) {
|
|
72
|
-
// An empty dir means "this harness has no such surface" → never matches.
|
|
73
|
-
const at = (dir) => dir ? `(?:^|/)${escapeRe(dir)}/` : null;
|
|
74
|
-
const skill = at(layout.skillDir);
|
|
75
|
-
const agent = at(layout.agentDir);
|
|
76
|
-
const command = at(layout.commandDir);
|
|
77
|
-
const skillRe = skill ? new RegExp(`${skill}[^/]+/SKILL\\.md$`) : null;
|
|
78
|
-
const agentRe = agent ? new RegExp(`${agent}[^/]+\\.md$`) : null;
|
|
79
|
-
const commandRe = command ? new RegExp(`${command}.+\\.md$`) : null;
|
|
80
|
-
// A subagent lives at the plugin's TOP-LEVEL `agents/` dir (e.g. `agents/foo.md`
|
|
81
|
-
// or `.claude/agents/foo.md`), never recursively under ANOTHER surface dir. Two
|
|
82
|
-
// real-world nesting traps are excluded as false positives:
|
|
83
|
-
// - `skills/<x>/agents/…` — skill-internal worker docs (Anthropic's skill-creator)
|
|
84
|
-
// - `commands/agents/…` — a COMMAND namespaced `/agents:…` (ruvnet/claude-flow),
|
|
85
|
-
// incl. a `README.md`; these are commands, not dispatchable subagents.
|
|
86
|
-
// Flagging either as a subagent missing frontmatter is a false positive (it
|
|
87
|
-
// mis-graded a real plugin F). A genuine top-level `agents/foo.md` still
|
|
88
|
-
// matches. Both excluded dirs are read from the layout (adapter-agnostic). See
|
|
89
|
-
// scan.test.ts for the regressions.
|
|
90
|
-
const nestedUnder = [
|
|
91
|
-
layout.skillDir &&
|
|
92
|
-
`${escapeRe(layout.skillDir)}/.+/${escapeRe(layout.agentDir)}/`,
|
|
93
|
-
layout.commandDir &&
|
|
94
|
-
`${escapeRe(layout.commandDir)}/(?:.+/)?${escapeRe(layout.agentDir)}/`,
|
|
95
|
-
].filter((x) => Boolean(x));
|
|
96
|
-
const nestedAgentRe = layout.agentDir && nestedUnder.length
|
|
97
|
-
? new RegExp(`(?:^|/)(?:${nestedUnder.join("|")})`)
|
|
98
|
-
: null;
|
|
99
|
-
const isAgent = (f) => (agentRe?.test(f) ?? false) &&
|
|
100
|
-
!f.endsWith(".spec.ts") &&
|
|
101
|
-
!(nestedAgentRe?.test(f) ?? false);
|
|
102
|
-
return {
|
|
103
|
-
isSkill: (f) => skillRe?.test(f) ?? false,
|
|
104
|
-
isAgent,
|
|
105
|
-
isCommand: (f) => commandRe?.test(f) ?? false,
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
|
-
function skillName(path) {
|
|
109
|
-
return (path
|
|
110
|
-
.replace(/\/SKILL\.md$/, "")
|
|
111
|
-
.split("/")
|
|
112
|
-
.pop() ?? path);
|
|
113
|
-
}
|
|
114
|
-
/**
|
|
115
|
-
* The first prose paragraph of a SKILL.md body (after the frontmatter and any
|
|
116
|
-
* leading `#` headings) — Claude Code's FALLBACK skill description when the
|
|
117
|
-
* frontmatter omits `description`. Used so the trigger-surface check doesn't
|
|
118
|
-
* overclaim "can't trigger" for a skill that has a usable body paragraph.
|
|
119
|
-
*/
|
|
120
|
-
function firstBodyParagraph(md) {
|
|
121
|
-
const body = md.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "");
|
|
122
|
-
const para = [];
|
|
123
|
-
for (const line of body.split(/\r?\n/)) {
|
|
124
|
-
const t = line.trim();
|
|
125
|
-
if (t === "" || t.startsWith("#")) {
|
|
126
|
-
if (para.length > 0)
|
|
127
|
-
break; // end of the first paragraph
|
|
128
|
-
continue; // skip leading blanks / headings
|
|
129
|
-
}
|
|
130
|
-
para.push(t);
|
|
131
|
-
}
|
|
132
|
-
return para.join(" ").trim() || undefined;
|
|
133
|
-
}
|
|
134
|
-
/** The body of a SKILL.md with the leading `---` frontmatter block stripped. */
|
|
135
|
-
function skillBody(md) {
|
|
136
|
-
return md.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "");
|
|
137
|
-
}
|
|
138
|
-
/**
|
|
139
|
-
* The on-disk path for a materialized file key. `loadPlugin` prefixes each
|
|
140
|
-
* surface file with the layout's `materializeRoot` (e.g. `.claude/`), but the file
|
|
141
|
-
* lives on disk WITHOUT that prefix (under the real surface dir), so a
|
|
142
|
-
* bundled-resource existence check must strip it back off. Mirrors how
|
|
143
|
-
* `resolveScript` resolves a hook path against the real plugin root.
|
|
144
|
-
*/
|
|
145
|
-
function onDiskPath(materializedKey, materializeRoot) {
|
|
146
|
-
if (!materializeRoot)
|
|
147
|
-
return materializedKey;
|
|
148
|
-
const prefix = `${materializeRoot}/`;
|
|
149
|
-
return materializedKey.startsWith(prefix)
|
|
150
|
-
? materializedKey.slice(prefix.length)
|
|
151
|
-
: materializedKey;
|
|
152
|
-
}
|
|
153
|
-
function scanSkills(files, cls, ctx) {
|
|
154
|
-
const { root, materializeRoot, dialect, sharedDirs } = ctx;
|
|
155
|
-
// `sharedDirs` are declared relative to the REPO root (config location), which
|
|
156
|
-
// is `root` for a whole-repo scan but a PARENT when the scan is scoped to a
|
|
157
|
-
// subdir. Resolve them against that, not the scoped subdir.
|
|
158
|
-
const sharedDirsRoot = ctx.sharedDirsRoot ?? root;
|
|
159
|
-
const out = [];
|
|
160
|
-
for (const [path, md] of Object.entries(files)) {
|
|
161
|
-
if (!cls.isSkill(path))
|
|
162
|
-
continue;
|
|
163
|
-
// Prefer the real on-disk dir (a `.claude/skills/…` skill materializes under
|
|
164
|
-
// the same canonical key as a repo-root one, but lives elsewhere on disk).
|
|
165
|
-
const onDiskDir = ctx.sources?.[path];
|
|
166
|
-
const fm = frontmatter(md);
|
|
167
|
-
// A skill's trigger surface is its frontmatter `description` OR — when that's
|
|
168
|
-
// absent — Claude Code's fallback to the first body paragraph. Only when
|
|
169
|
-
// NEITHER exists is the skill genuinely undescribed (can't be selected). The
|
|
170
|
-
// explicit-frontmatter best-practice is the separate `skill-frontmatter` rule.
|
|
171
|
-
const effectiveDesc = fm.description ?? firstBodyParagraph(md);
|
|
172
|
-
const userInvoked = /^\s*disable-model-invocation:\s*true\s*$/m.test(md);
|
|
173
|
-
// Bundled-resource refs resolve against the skill's OWN dir (resources ship
|
|
174
|
-
// beside the SKILL.md), built from the plugin root + the file's ON-DISK dir
|
|
175
|
-
// (the materialize-root prefix the loader added is stripped back off).
|
|
176
|
-
const skillDir = onDiskDir
|
|
177
|
-
? (0, node_path_1.dirname)(onDiskDir)
|
|
178
|
-
: (0, node_path_1.resolve)(root, (0, node_path_1.dirname)(onDiskPath(path, materializeRoot)));
|
|
179
|
-
// Bundled refs resolve against the skill's OWN dir. A repo that shares a
|
|
180
|
-
// top-level tree across skills (`sharedDirs` in .vigilesrc.json) ALSO resolves
|
|
181
|
-
// a ref under one of those declared dirs against the repo root — OPT-IN, so a
|
|
182
|
-
// repo that doesn't set it is byte-identical to before (no masking of a real
|
|
183
|
-
// missing bundled resource). See feedback P1-4.
|
|
184
|
-
const resourceIssues = (0, skill_resources_js_1.skillResourceIssues)(skillBody(md), skillDir, {
|
|
185
|
-
repoRoot: sharedDirsRoot,
|
|
186
|
-
sharedDirs,
|
|
187
|
-
});
|
|
188
|
-
// The lethal trifecta is a property of what a unit CAN do, which for a skill is
|
|
189
|
-
// its declared `allowed-tools` (the CC skill tool contract). Only a model-
|
|
190
|
-
// invocable skill can be hijacked by attacker content, so a user-invoked one is
|
|
191
|
-
// excluded. A skill with no `allowed-tools` line inherits all → advisory.
|
|
192
|
-
const skillTools = (0, agent_runtime_js_1.parseAgentToolList)(md, "allowed-tools");
|
|
193
|
-
// No `allowed-tools:` line (null) → inherits all → wildcard sentinel; an
|
|
194
|
-
// EXPLICIT empty `[]` means zero tools → no trifecta (don't collapse them).
|
|
195
|
-
const trifecta = userInvoked
|
|
196
|
-
? null
|
|
197
|
-
: (0, lethal_trifecta_js_1.lethalTrifectaIssues)(skillTools ?? ["*"], dialect);
|
|
198
|
-
out.push({
|
|
199
|
-
name: fm.name ?? skillName(path),
|
|
200
|
-
path,
|
|
201
|
-
hasDescription: Boolean(effectiveDesc && effectiveDesc.length >= 20),
|
|
202
|
-
description: effectiveDesc?.trim(),
|
|
203
|
-
userInvoked,
|
|
204
|
-
resourceIssues,
|
|
205
|
-
trifecta,
|
|
206
|
-
// A SKILL.md opening with `name:`/`description:` but no `---` fence loads
|
|
207
|
-
// as plain body → the skill is invisible. Inspect the RAW md (not the
|
|
208
|
-
// frontmatter-stripped body) so the unfenced keys are visible.
|
|
209
|
-
fenceIssue: (0, skill_missing_fence_js_1.skillMissingFence)(md),
|
|
210
|
-
});
|
|
211
|
-
}
|
|
212
|
-
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
213
|
-
}
|
|
214
|
-
/**
|
|
215
|
-
* Near-duplicate description pairs among the MODEL-INVOCABLE skills — the ones
|
|
216
|
-
* that actually compete for auto-selection (a user-invoked skill is picked by
|
|
217
|
-
* explicit command, so it can't collide). Uses the same effective-description
|
|
218
|
-
* logic as `scanSkills` (frontmatter `description` ← first body paragraph), then
|
|
219
|
-
* the NCD precision-proxy. See description-overlap.ts.
|
|
220
|
-
*/
|
|
221
|
-
function modelInvocableSkillSurfaces(files, cls) {
|
|
222
|
-
const surfaces = [];
|
|
223
|
-
for (const [path, md] of Object.entries(files)) {
|
|
224
|
-
if (!cls.isSkill(path))
|
|
225
|
-
continue;
|
|
226
|
-
if (/^\s*disable-model-invocation:\s*true\s*$/m.test(md))
|
|
227
|
-
continue;
|
|
228
|
-
const fm = frontmatter(md);
|
|
229
|
-
const description = fm.description ?? firstBodyParagraph(md);
|
|
230
|
-
if (!description || description.length < 20)
|
|
231
|
-
continue;
|
|
232
|
-
surfaces.push({ name: fm.name ?? skillName(path), description });
|
|
233
|
-
}
|
|
234
|
-
return surfaces;
|
|
235
|
-
}
|
|
236
|
-
function descriptionOverlapsFor(files, cls) {
|
|
237
|
-
return (0, description_overlap_js_1.findDescriptionOverlaps)(modelInvocableSkillSurfaces(files, cls));
|
|
238
|
-
}
|
|
239
|
-
/**
|
|
240
|
-
* Model-invocable skills whose description is so long the trigger signal is
|
|
241
|
-
* buried (heuristic proxy; degrades recall + precision). Same surfaces as the
|
|
242
|
-
* overlap check. See skill-description-budget.ts.
|
|
243
|
-
*/
|
|
244
|
-
function descriptionBudgetFor(files, cls) {
|
|
245
|
-
return (0, skill_description_budget_js_1.findDescriptionBudgetIssues)(modelInvocableSkillSurfaces(files, cls));
|
|
246
|
-
}
|
|
247
|
-
function scanAgents(files, dialect, declaredServers, cls) {
|
|
248
|
-
const out = [];
|
|
249
|
-
for (const [path, md] of Object.entries(files)) {
|
|
250
|
-
if (!cls.isAgent(path))
|
|
251
|
-
continue;
|
|
252
|
-
const tools = (0, agent_runtime_js_1.parseAgentTools)(md);
|
|
253
|
-
// An inherits-all agent (no `tools:` line) grants access to every tool
|
|
254
|
-
// including every side-effecting one — pass the wildcard sentinel so
|
|
255
|
-
// effectSurface correctly classifies it as `"unrestricted"`.
|
|
256
|
-
const surface = (0, effects_js_1.effectSurface)(tools ?? ["*"], dialect);
|
|
257
|
-
out.push({
|
|
258
|
-
name: (0, node_path_1.basename)(path, ".md"),
|
|
259
|
-
path,
|
|
260
|
-
tools,
|
|
261
|
-
// Cross-reference the declared rail against the dialect catalog — the moat.
|
|
262
|
-
// Auditing third-party plugins → only the HIGH-CONFIDENCE issues (never-
|
|
263
|
-
// available + close typos); a bare unrecognized tool is likely plugin/MCP-
|
|
264
|
-
// provided, not a defect (the TaskCreate/TaskGet lesson). See tool-contract.ts.
|
|
265
|
-
toolIssues: tools
|
|
266
|
-
? (0, tool_contract_js_1.confidentToolIssues)((0, tool_contract_js_1.verifyToolContract)(tools, dialect))
|
|
267
|
-
: [],
|
|
268
|
-
// The MCP half of the moat: an `mcp__server__tool` whose server isn't in the
|
|
269
|
-
// plugin's declared set can't resolve. High-precision (gated on a declared
|
|
270
|
-
// set, built-ins allowlisted, plugin-namespaced form skipped). See mcp-tool.ts.
|
|
271
|
-
mcpToolIssues: tools
|
|
272
|
-
? (0, mcp_tool_js_1.verifyMcpToolServers)(tools, declaredServers, dialect)
|
|
273
|
-
: [],
|
|
274
|
-
// The block-list mirror: a `disallowedTools:` entry that's a typo of a real
|
|
275
|
-
// tool blocks nothing (close-typo only — high-precision). See tool-contract.ts.
|
|
276
|
-
disallowedToolIssues: (0, tool_contract_js_1.disallowedToolIssues)((0, agent_runtime_js_1.parseAgentToolList)(md, "disallowedTools") ?? [], dialect),
|
|
277
|
-
purity: surface.purity,
|
|
278
|
-
effectBuckets: {
|
|
279
|
-
readOnly: surface.readOnly,
|
|
280
|
-
sideEffecting: surface.sideEffecting,
|
|
281
|
-
unknown: surface.unknown,
|
|
282
|
-
},
|
|
283
|
-
// The lethal trifecta: a subagent whose contract grants all three legs. An
|
|
284
|
-
// inherits-all agent (no `tools:` line → tools === null) is the advisory
|
|
285
|
-
// case — pass the wildcard sentinel so it's distinguished from an EXPLICIT
|
|
286
|
-
// empty `tools: []` (zero tools → no trifecta). One detector, no drift.
|
|
287
|
-
trifecta: (0, lethal_trifecta_js_1.lethalTrifectaIssues)(tools ?? ["*"], dialect),
|
|
288
|
-
});
|
|
289
|
-
}
|
|
290
|
-
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
291
|
-
}
|
|
292
|
-
/**
|
|
293
|
-
* Resolve a hook script token to a checkable path. `loadPlugin` expands the
|
|
294
|
-
* braced plugin-root token (`${CLAUDE_PLUGIN_ROOT}`, Codex `${PLUGIN_ROOT}`, …);
|
|
295
|
-
* the unbraced shell form survives, so resolve BOTH forms of the HARNESS's token
|
|
296
|
-
* (from the layout, not hard-coded) against the plugin root and strip shell
|
|
297
|
-
* quotes. A token that still carries any `$VAR` after that is genuinely
|
|
298
|
-
* uncheckable.
|
|
299
|
-
*/
|
|
300
|
-
function resolveScript(token, root, pluginRootToken, fullCommand) {
|
|
301
|
-
// "${CLAUDE_PLUGIN_ROOT}" → unbraced "$CLAUDE_PLUGIN_ROOT".
|
|
302
|
-
const unbraced = pluginRootToken.replace(/^\$\{(.+)\}$/, "$$$1");
|
|
303
|
-
const cleaned = token
|
|
304
|
-
.replace(/["']/g, "")
|
|
305
|
-
.replaceAll(pluginRootToken, root)
|
|
306
|
-
.replaceAll(unbraced, root);
|
|
307
|
-
if (cleaned.includes("$"))
|
|
308
|
-
return { command: fullCommand, script: token, status: "unresolved" };
|
|
309
|
-
// A relative hook path (`./hooks/x.sh`, `scripts/x.py`) is the plugin's own —
|
|
310
|
-
// resolve it against the PLUGIN ROOT, not the scanner's cwd. Without this, a
|
|
311
|
-
// plugin that references `./hooks/x.sh` (the file IS present) was reported
|
|
312
|
-
// MISSING because existsSync() checked cwd-relative (a false positive caught on
|
|
313
|
-
// ananddtyagi/cc-marketplace). The displayed `script` stays as the author wrote it.
|
|
314
|
-
const abs = (0, node_path_1.isAbsolute)(cleaned) ? cleaned : (0, node_path_1.resolve)(root, cleaned);
|
|
315
|
-
// Resolve the full command the same way we resolve the script token (expand
|
|
316
|
-
// plugin-root, strip outer quotes) so the CLI can pass it to verifyGuardrail.
|
|
317
|
-
const resolvedCommand = fullCommand
|
|
318
|
-
.replaceAll(pluginRootToken, root)
|
|
319
|
-
.replaceAll(unbraced, root);
|
|
320
|
-
return {
|
|
321
|
-
command: resolvedCommand,
|
|
322
|
-
script: cleaned,
|
|
323
|
-
status: (0, node_fs_1.existsSync)(abs) ? "ok" : "missing",
|
|
324
|
-
};
|
|
325
|
-
}
|
|
326
|
-
// A shell existence guard around a command — `[ ! -f x ] || x`, `[ -f x ] && x`,
|
|
327
|
-
// `test -f x && …`. Authors use it to make a hook OPTIONAL (run the script only
|
|
328
|
-
// if present; a no-op otherwise — e.g. a runtime-generated guard), so a missing
|
|
329
|
-
// target is INTENTIONAL, not a broken reference. Don't flag scripts in such a
|
|
330
|
-
// command as MISSING (a false positive caught on gmickel/flow-next's ralph-guard).
|
|
331
|
-
const EXISTENCE_GUARD = /(?:\[\[?\s*!?\s*-[efsx]\s)|(?:\btest\s+!?\s*-[efsx]\s)/;
|
|
332
|
-
/**
|
|
333
|
-
* A compiled `vigiles/hook` artifact runs through the `hook-runtime run-program`
|
|
334
|
-
* runtime entrypoint; any other hook command is hand-written (a shell script or
|
|
335
|
-
* an inline one-liner) the author maintains directly. The basis for the
|
|
336
|
-
* `prefer-compiled-hooks` nudge.
|
|
337
|
-
*/
|
|
338
|
-
function isManagedHookCommand(command) {
|
|
339
|
-
return /\bhook-runtime\b/.test(command);
|
|
340
|
-
}
|
|
341
|
-
/** The `prefer-compiled-hooks` recommendation message (shared by `lint` + `scan`). */
|
|
342
|
-
function preferCompiledHooksMessage(count) {
|
|
343
|
-
return (`${String(count)} hand-written hook command(s) — if any gate the agent ` +
|
|
344
|
-
`(a block/deny decision), compiled hooks (\`vigiles/hook\`) make whole hook ` +
|
|
345
|
-
`bug classes unrepresentable at authoring time, and \`guardrail-check\` proves ` +
|
|
346
|
-
`an existing one blocks. See docs/compiled-hooks.md.`);
|
|
347
|
-
}
|
|
348
|
-
/** Pull script-file hook commands out of the resolved settings; count inline ones. */
|
|
349
|
-
/**
|
|
350
|
-
* Best-effort map of each script token → the hook EVENT it's registered under,
|
|
351
|
-
* by walking the canonical object-keyed-by-event settings shape
|
|
352
|
-
* (`{ PreToolUse: [{ hooks: [{ command }] }], … }`). Lets the safety battery
|
|
353
|
-
* scope itself to `PreToolUse` (the only event that can block a tool call), so a
|
|
354
|
-
* `SessionStart`/`PostToolUse`/`Stop` hook isn't tested against the disaster
|
|
355
|
-
* catalog. Returns an empty map for a non-object/array config (event → unknown).
|
|
356
|
-
*/
|
|
357
|
-
function eventsByScript(regs) {
|
|
358
|
-
const map = new Map();
|
|
359
|
-
for (const reg of regs) {
|
|
360
|
-
for (const tok of reg.command.match(SCRIPT_RE) ?? []) {
|
|
361
|
-
if (!map.has(tok))
|
|
362
|
-
map.set(tok, reg.event);
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
return map;
|
|
366
|
-
}
|
|
367
|
-
function scanHooks(regs, root, pluginRootToken) {
|
|
368
|
-
const commands = regs.map((r) => r.command);
|
|
369
|
-
const evMap = eventsByScript(regs);
|
|
370
|
-
// A hand-written hook is any non-empty command that isn't a vigiles-managed
|
|
371
|
-
// (compiled) hook-runtime invocation — the basis for the prefer-compiled-hooks nudge.
|
|
372
|
-
const manual = commands.filter((c) => {
|
|
373
|
-
const u = c.trim();
|
|
374
|
-
return u !== "" && !isManagedHookCommand(u);
|
|
375
|
-
}).length;
|
|
376
|
-
const byScript = new Map();
|
|
377
|
-
let inline = 0;
|
|
378
|
-
for (const cmd of commands) {
|
|
379
|
-
const found = cmd.match(SCRIPT_RE);
|
|
380
|
-
if (!found || found.length === 0) {
|
|
381
|
-
inline++;
|
|
382
|
-
continue;
|
|
383
|
-
}
|
|
384
|
-
// A guarded command runs its script only if it exists — an optional hook, not
|
|
385
|
-
// a broken one. Treat it as a conditional one-liner (inline), don't path-check.
|
|
386
|
-
if (EXISTENCE_GUARD.test(cmd)) {
|
|
387
|
-
inline++;
|
|
388
|
-
continue;
|
|
389
|
-
}
|
|
390
|
-
for (const tok of found) {
|
|
391
|
-
const hook = resolveScript(tok, root, pluginRootToken, cmd);
|
|
392
|
-
const event = evMap.get(tok);
|
|
393
|
-
byScript.set(hook.script, event ? { ...hook, event } : hook);
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
const hooks = [...byScript.values()].sort((a, b) => a.script.localeCompare(b.script));
|
|
397
|
-
return { hooks, inline, manual };
|
|
398
|
-
}
|
|
399
|
-
// ---------------------------------------------------------------------------
|
|
400
|
-
// Public API
|
|
401
|
-
// ---------------------------------------------------------------------------
|
|
402
|
-
/**
|
|
403
|
-
* Frontmatter-schema check — **subagents only**. Per the Claude Code docs, a
|
|
404
|
-
* subagent (`agents/*.md`) REQUIRES `name` + `description` (no fallback) or it
|
|
405
|
-
* won't register. A SKILL.md requires NOTHING: `name` falls back to the directory
|
|
406
|
-
* name and `description` to the first body paragraph, so a frontmatter-less skill
|
|
407
|
-
* still loads — flagging it would be a false positive (skill description QUALITY
|
|
408
|
-
* is a separate, behavioral concern). See https://code.claude.com/docs/en/skills
|
|
409
|
-
* and …/sub-agents.
|
|
410
|
-
*/
|
|
411
|
-
function frontmatterIssuesFor(files, cls) {
|
|
412
|
-
const out = [];
|
|
413
|
-
for (const [path, md] of Object.entries(files)) {
|
|
414
|
-
if (!cls.isAgent(path))
|
|
415
|
-
continue; // skills require no frontmatter (dir/body fallbacks)
|
|
416
|
-
const fm = frontmatter(md);
|
|
417
|
-
const missing = [];
|
|
418
|
-
if (!fm.name)
|
|
419
|
-
missing.push("name");
|
|
420
|
-
if (!fm.description)
|
|
421
|
-
missing.push("description");
|
|
422
|
-
if (missing.length === 0)
|
|
423
|
-
continue;
|
|
424
|
-
out.push({
|
|
425
|
-
path,
|
|
426
|
-
kind: "agent",
|
|
427
|
-
missing,
|
|
428
|
-
message: `agent ${path} is missing required frontmatter: ${missing.join(", ")} — it won't register.`,
|
|
429
|
-
});
|
|
430
|
-
}
|
|
431
|
-
return out.sort((a, b) => a.path.localeCompare(b.path));
|
|
432
|
-
}
|
|
433
|
-
// The canonical subagent `model:` aliases and `color:` enum (Claude Code). The
|
|
434
|
-
// model check skips a full/dated id (`claude-sonnet-4-5`) — that's a valid
|
|
435
|
-
// explicit form, not a typo — so only an alias misspelling is caught.
|
|
436
|
-
const MODEL_ALIASES = ["inherit", "sonnet", "opus", "haiku"];
|
|
437
|
-
const AGENT_COLORS = [
|
|
438
|
-
"red",
|
|
439
|
-
"blue",
|
|
440
|
-
"green",
|
|
441
|
-
"yellow",
|
|
442
|
-
"purple",
|
|
443
|
-
"orange",
|
|
444
|
-
"pink",
|
|
445
|
-
"cyan",
|
|
446
|
-
];
|
|
447
|
-
/**
|
|
448
|
-
* Closest candidate by edit distance, ONLY when it's a high-confidence typo: the
|
|
449
|
-
* value isn't already a candidate, and the nearest is within 2 edits. Returns
|
|
450
|
-
* null otherwise — a far-off value is more likely an unknown-we-don't-know than a
|
|
451
|
-
* typo (the high-precision discipline), so it's suppressed, not flagged.
|
|
452
|
-
*/
|
|
453
|
-
function closeCandidate(value, candidates) {
|
|
454
|
-
const v = value.toLowerCase();
|
|
455
|
-
if (candidates.includes(v))
|
|
456
|
-
return null;
|
|
457
|
-
let best = null;
|
|
458
|
-
let bestDistance = Infinity;
|
|
459
|
-
for (const c of candidates) {
|
|
460
|
-
const dist = (0, linters_js_1.editDistance)(v, c);
|
|
461
|
-
if (dist < bestDistance) {
|
|
462
|
-
bestDistance = dist;
|
|
463
|
-
best = c;
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
return bestDistance > 0 && bestDistance <= 2 ? best : null;
|
|
467
|
-
}
|
|
468
|
-
/**
|
|
469
|
-
* Agent frontmatter VALUE validity — a `model:` or `color:` that's a close typo
|
|
470
|
-
* of a real one. A bad `model:` silently falls back; a bad `color:` is ignored.
|
|
471
|
-
* High-precision (close-typo only); a full/dated model id is left alone. Folded
|
|
472
|
-
* into the `subagent-frontmatter` rule. Agents only (skills have no model/color).
|
|
473
|
-
*/
|
|
474
|
-
function frontmatterValueIssuesFor(files, cls) {
|
|
475
|
-
const out = [];
|
|
476
|
-
for (const [path, md] of Object.entries(files)) {
|
|
477
|
-
if (!cls.isAgent(path))
|
|
478
|
-
continue;
|
|
479
|
-
const fm = frontmatter(md);
|
|
480
|
-
// A model id with a digit/hyphen is an explicit form, not an alias typo.
|
|
481
|
-
if (fm.model && !/[0-9-]/.test(fm.model)) {
|
|
482
|
-
const near = closeCandidate(fm.model, MODEL_ALIASES);
|
|
483
|
-
if (near) {
|
|
484
|
-
out.push({
|
|
485
|
-
path,
|
|
486
|
-
field: "model",
|
|
487
|
-
value: fm.model,
|
|
488
|
-
suggestion: near,
|
|
489
|
-
message: `agent ${path} has model "${fm.model}", not a known alias — it silently falls back. Did you mean "${near}"?`,
|
|
490
|
-
});
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
if (fm.color) {
|
|
494
|
-
const near = closeCandidate(fm.color, AGENT_COLORS);
|
|
495
|
-
if (near) {
|
|
496
|
-
out.push({
|
|
497
|
-
path,
|
|
498
|
-
field: "color",
|
|
499
|
-
value: fm.color,
|
|
500
|
-
suggestion: near,
|
|
501
|
-
message: `agent ${path} has color "${fm.color}", not a valid color — it's ignored. Did you mean "${near}"?`,
|
|
502
|
-
});
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
return out.sort((a, b) => a.path.localeCompare(b.path));
|
|
507
|
-
}
|
|
508
|
-
/**
|
|
509
|
-
* Frontmatter that EXISTS but isn't valid YAML — the `frontmatter-valid` signal.
|
|
510
|
-
* Reported for skills + agents via the shared reader's `malformed` flag. Honest
|
|
511
|
-
* caveat (see docs/rules/frontmatter-valid.md): js-yaml is stricter than some
|
|
512
|
-
* loaders, so a one-line `description:` containing a `: ` colon or an `<example>`
|
|
513
|
-
* block is flagged even though it may still load — which is why scan surfaces it
|
|
514
|
-
* as an informational note (NOT a structural defect) and the lint rule is a
|
|
515
|
-
* warn, not an error. The file's other fields are still salvaged.
|
|
516
|
-
*/
|
|
517
|
-
function malformedFrontmatterFor(files, cls) {
|
|
518
|
-
const out = [];
|
|
519
|
-
for (const [path, md] of Object.entries(files)) {
|
|
520
|
-
if (!cls.isSkill(path) && !cls.isAgent(path))
|
|
521
|
-
continue;
|
|
522
|
-
if (!(0, frontmatter_read_js_1.readFrontmatter)(md).malformed)
|
|
523
|
-
continue;
|
|
524
|
-
out.push({
|
|
525
|
-
path,
|
|
526
|
-
message: `${path}: frontmatter is not valid YAML — fields may not parse as intended (a colon, quote, or bracket likely needs escaping/quoting).`,
|
|
527
|
-
});
|
|
528
|
-
}
|
|
529
|
-
return out.sort((a, b) => a.path.localeCompare(b.path));
|
|
530
|
-
}
|
|
531
|
-
/**
|
|
532
|
-
* Skill-metadata RECOMMENDATION (not a correctness check): a `SKILL.md` loads
|
|
533
|
-
* fine without frontmatter (`name` ← dir, `description` ← first body paragraph),
|
|
534
|
-
* but relying on those fallbacks is fragile — the dir name may be unclear and the
|
|
535
|
-
* first paragraph is often a heading or boilerplate, making a weak trigger
|
|
536
|
-
* surface. Best practice is an EXPLICIT `name` + `description`. Flags skills
|
|
537
|
-
* missing either; surfaced as a soft note in scan (NOT a structural defect, NOT
|
|
538
|
-
* scored) and gated by the `skill-frontmatter` lint rule (warn by default).
|
|
539
|
-
*/
|
|
540
|
-
function skillMetaIssuesFor(files, cls) {
|
|
541
|
-
const out = [];
|
|
542
|
-
for (const [path, md] of Object.entries(files)) {
|
|
543
|
-
if (!cls.isSkill(path))
|
|
544
|
-
continue;
|
|
545
|
-
const fm = frontmatter(md);
|
|
546
|
-
const missing = [];
|
|
547
|
-
if (!fm.name)
|
|
548
|
-
missing.push("name");
|
|
549
|
-
if (!fm.description)
|
|
550
|
-
missing.push("description");
|
|
551
|
-
if (missing.length === 0)
|
|
552
|
-
continue;
|
|
553
|
-
out.push({
|
|
554
|
-
path,
|
|
555
|
-
kind: "skill",
|
|
556
|
-
missing,
|
|
557
|
-
message: `skill ${path} has no explicit frontmatter ${missing.join(" / ")} — recommended for a reliable trigger surface (it still loads via the dir-name / first-paragraph fallback).`,
|
|
558
|
-
});
|
|
559
|
-
}
|
|
560
|
-
return out.sort((a, b) => a.path.localeCompare(b.path));
|
|
561
|
-
}
|
|
562
61
|
/**
|
|
563
62
|
* Collect declared MCP servers from the JSON sources (`.mcp.json` + the plugin
|
|
564
63
|
* manifest's `mcpServers`). Codex's TOML `[mcp_servers]` isn't parsed here (a
|
|
@@ -586,149 +85,35 @@ function collectMcpServers(root, layout) {
|
|
|
586
85
|
collect(layout.manifestPath);
|
|
587
86
|
return servers;
|
|
588
87
|
}
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
function collectSurfaceFindings(agents, skills) {
|
|
595
|
-
const trifectaFindings = [];
|
|
596
|
-
for (const a of agents) {
|
|
597
|
-
if (a.trifecta) {
|
|
598
|
-
trifectaFindings.push({
|
|
599
|
-
path: a.path,
|
|
600
|
-
kind: "subagent",
|
|
601
|
-
name: a.name,
|
|
602
|
-
finding: a.trifecta,
|
|
603
|
-
});
|
|
604
|
-
}
|
|
88
|
+
// Disk-backed IO the moved detectors take by injection (they never import
|
|
89
|
+
// `node:fs`, so the browser engine can inject a map-backed pair instead).
|
|
90
|
+
const nodeReadFile = (p) => {
|
|
91
|
+
try {
|
|
92
|
+
return (0, node_fs_1.readFileSync)(p, "utf8");
|
|
605
93
|
}
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
trifectaFindings.push({
|
|
609
|
-
path: s.path,
|
|
610
|
-
kind: "skill",
|
|
611
|
-
name: s.name,
|
|
612
|
-
finding: s.trifecta,
|
|
613
|
-
});
|
|
614
|
-
}
|
|
94
|
+
catch {
|
|
95
|
+
return "";
|
|
615
96
|
}
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
})));
|
|
621
|
-
const skillFenceFindings = skills.flatMap((s) => s.fenceIssue ? [{ path: s.path, name: s.name, finding: s.fenceIssue }] : []);
|
|
622
|
-
return { trifectaFindings, skillResourceFindings, skillFenceFindings };
|
|
623
|
-
}
|
|
624
|
-
/**
|
|
625
|
-
* Build the subagent delegation graph and flag a lethal trifecta that EMERGES
|
|
626
|
-
* across an edge (own ∪ delegated-to capability) though no single unit trips it.
|
|
627
|
-
*
|
|
628
|
-
* Edge source (deterministic, audit-available): a subagent that lists the `Task`
|
|
629
|
-
* tool can dispatch any sibling subagent, so it `delegatesTo` every OTHER agent.
|
|
630
|
-
* An inherits-all agent (`tools === null`) carries the wildcard, which the
|
|
631
|
-
* detector's FP-safe guard skips (that maximal-blast case is the per-unit
|
|
632
|
-
* advisory's job). One detector, no drift. (Richer edge sources — a typed
|
|
633
|
-
* railway's `delegate()` chain, a Flue subagent inheritance tree — plug in here.)
|
|
634
|
-
*/
|
|
635
|
-
function collectDelegationTrifecta(agents, dialect) {
|
|
636
|
-
const allNames = agents.map((a) => a.name);
|
|
637
|
-
const nodes = agents.map((a) => {
|
|
638
|
-
const canDispatch = a.tools === null ||
|
|
639
|
-
a.tools.some((t) => t === "Task" || t.startsWith("Task("));
|
|
640
|
-
return {
|
|
641
|
-
name: a.name,
|
|
642
|
-
kind: "agent",
|
|
643
|
-
tools: a.tools ?? ["*"],
|
|
644
|
-
delegatesTo: canDispatch ? allNames.filter((n) => n !== a.name) : [],
|
|
645
|
-
};
|
|
646
|
-
});
|
|
647
|
-
const pathByName = new Map(agents.map((a) => [a.name, a.path]));
|
|
648
|
-
return (0, delegation_trifecta_js_1.delegationTrifectaIssues)(nodes, dialect).map((finding) => ({
|
|
649
|
-
path: pathByName.get(finding.name) ?? "",
|
|
650
|
-
finding,
|
|
651
|
-
}));
|
|
652
|
-
}
|
|
653
|
-
/**
|
|
654
|
-
* Build `hookBlockIssues` entries by walking the canonical object-keyed-by-event
|
|
655
|
-
* settings shape PER REGISTRATION — so a script registered under several events
|
|
656
|
-
* is inspected under EACH (no de-dup by script path), inline one-liners are
|
|
657
|
-
* included (script token → null, inspect the command), and a script token is
|
|
658
|
-
* resolved to its ABSOLUTE on-disk path against the plugin root (not the caller's
|
|
659
|
-
* cwd). Addresses the gaps a de-duplicated `ScanHook[]` would miss.
|
|
660
|
-
*/
|
|
661
|
-
function collectHookBlockEntries(regs, root, pluginRootToken) {
|
|
662
|
-
const out = [];
|
|
663
|
-
for (const { event, command: cmd } of regs) {
|
|
664
|
-
// A wrapper command runs MORE than one script (`node run.cjs guard.mjs`),
|
|
665
|
-
// so resolve EVERY candidate and inspect each — reading only the first
|
|
666
|
-
// (the wrapper) would miss the guard's block logic. Candidates: extensioned
|
|
667
|
-
// script tokens (SCRIPT_RE) PLUS path-like words with NO extension
|
|
668
|
-
// (`bash hooks/guard`, `${ROOT}/hooks/session-start`) that resolve to a file.
|
|
669
|
-
const candidates = new Set(cmd.match(SCRIPT_RE) ?? []);
|
|
670
|
-
for (const word of cmd.split(/\s+/)) {
|
|
671
|
-
const w = word.replace(/^["']+|["']+$/g, "");
|
|
672
|
-
if (w.startsWith("-"))
|
|
673
|
-
continue; // a flag, not a path
|
|
674
|
-
if (w.includes("/") || w.includes(pluginRootToken))
|
|
675
|
-
candidates.add(w);
|
|
676
|
-
}
|
|
677
|
-
const resolvedPaths = [];
|
|
678
|
-
for (const tok of candidates) {
|
|
679
|
-
const r = resolveScript(tok, root, pluginRootToken, cmd);
|
|
680
|
-
if (r.status === "ok") {
|
|
681
|
-
const abs = (0, node_path_1.isAbsolute)(r.script) ? r.script : (0, node_path_1.resolve)(root, r.script);
|
|
682
|
-
if (!resolvedPaths.includes(abs))
|
|
683
|
-
resolvedPaths.push(abs);
|
|
684
|
-
}
|
|
685
|
-
}
|
|
686
|
-
if (resolvedPaths.length > 0) {
|
|
687
|
-
// One entry per resolvable script (each is inspected on its own).
|
|
688
|
-
for (const sp of resolvedPaths) {
|
|
689
|
-
out.push({ event, command: cmd, scriptPath: sp });
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
|
-
else {
|
|
693
|
-
// No script file resolved → inline one-liner; inspect the command text.
|
|
694
|
-
out.push({ event, command: cmd, scriptPath: null });
|
|
695
|
-
}
|
|
97
|
+
};
|
|
98
|
+
const nodeIsDirectory = (p) => {
|
|
99
|
+
try {
|
|
100
|
+
return (0, node_fs_1.statSync)(p).isDirectory();
|
|
696
101
|
}
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
/** Extract (event, matcher) pairs from the normalized hook registrations. */
|
|
700
|
-
function collectHookMatchers(regs) {
|
|
701
|
-
const seen = new Set();
|
|
702
|
-
const out = [];
|
|
703
|
-
for (const { event, matcher } of regs) {
|
|
704
|
-
if (matcher === null)
|
|
705
|
-
continue;
|
|
706
|
-
const key = `${event}${matcher}`;
|
|
707
|
-
if (seen.has(key))
|
|
708
|
-
continue;
|
|
709
|
-
seen.add(key);
|
|
710
|
-
out.push({ event, matcher });
|
|
102
|
+
catch {
|
|
103
|
+
return false;
|
|
711
104
|
}
|
|
712
|
-
|
|
713
|
-
}
|
|
714
|
-
/** Tally how many scanned agents fall into each purity rung (effectSurface). */
|
|
715
|
-
function summarizePurity(agents) {
|
|
716
|
-
return agents.reduce((acc, a) => {
|
|
717
|
-
acc[a.purity]++;
|
|
718
|
-
return acc;
|
|
719
|
-
}, { pure: 0, bounded: 0, unrestricted: 0 });
|
|
720
|
-
}
|
|
105
|
+
};
|
|
721
106
|
/** Scan a plugin/repo directory and report its surfaces + structural issues. */
|
|
722
107
|
function scanPlugin(dir, layout, dialect = dialect_js_1.claudeCodeDialect, opts = {}) {
|
|
723
108
|
const lay = layout ?? layout_js_1.claudeCodeLayout;
|
|
724
|
-
const cls = makeClassifier(lay);
|
|
109
|
+
const cls = (0, scan_core_js_1.makeClassifier)(lay);
|
|
725
110
|
const loaded = (0, plugin_loader_js_1.loadPlugin)(dir, lay);
|
|
726
111
|
// Parse the raw `settings.hooks` ONCE at the boundary (parse-don't-validate):
|
|
727
112
|
// tolerant of the Claude Code nested shape AND the Codex flat shape, so every
|
|
728
113
|
// hook detector below consumes typed `HookRegistration[]` instead of re-walking
|
|
729
114
|
// `unknown`. The single seam where the per-harness hook shape is absorbed.
|
|
730
115
|
const hookRegs = (0, hook_normalize_js_1.normalizeHooks)(loaded.settings.hooks);
|
|
731
|
-
const { hooks, inline, manual } = scanHooks(hookRegs, (0, node_path_1.resolve)(dir), lay.pluginRootToken);
|
|
116
|
+
const { hooks, inline, manual } = (0, scan_core_js_1.scanHooks)(hookRegs, (0, node_path_1.resolve)(dir), lay.pluginRootToken, node_fs_1.existsSync);
|
|
732
117
|
// Hook-event keys are a CLOSED platform set — an unrecognized one is a dead
|
|
733
118
|
// registration (the hook never fires), so flag every unknown (not just typos).
|
|
734
119
|
// ONLY for the canonical object-keyed-by-event shape: a plugin shipping a
|
|
@@ -746,17 +131,18 @@ function scanPlugin(dir, layout, dialect = dialect_js_1.claudeCodeDialect, opts
|
|
|
746
131
|
: null;
|
|
747
132
|
const mcpServers = collectMcpServers((0, node_path_1.resolve)(dir), lay);
|
|
748
133
|
const declaredServers = Object.keys(mcpServers);
|
|
749
|
-
const agents = scanAgents(loaded.files, dialect, declaredServers, cls);
|
|
750
|
-
const skills = scanSkills(loaded.files, cls, {
|
|
134
|
+
const agents = (0, scan_core_js_1.scanAgents)(loaded.files, dialect, declaredServers, cls);
|
|
135
|
+
const skills = (0, scan_core_js_1.scanSkills)(loaded.files, cls, {
|
|
751
136
|
root: (0, node_path_1.resolve)(dir),
|
|
752
137
|
materializeRoot: lay.materializeRoot,
|
|
753
138
|
dialect,
|
|
754
139
|
sources: loaded.sources,
|
|
755
140
|
sharedDirs: opts.sharedDirs,
|
|
756
141
|
sharedDirsRoot: opts.sharedDirsRoot,
|
|
142
|
+
existsSync: node_fs_1.existsSync,
|
|
757
143
|
});
|
|
758
|
-
const puritySummary = summarizePurity(agents);
|
|
759
|
-
const { trifectaFindings, skillResourceFindings, skillFenceFindings } = collectSurfaceFindings(agents, skills);
|
|
144
|
+
const puritySummary = (0, scan_core_js_1.summarizePurity)(agents);
|
|
145
|
+
const { trifectaFindings, skillResourceFindings, skillFenceFindings } = (0, scan_core_js_1.collectSurfaceFindings)(agents, skills);
|
|
760
146
|
return {
|
|
761
147
|
dir,
|
|
762
148
|
instructions,
|
|
@@ -769,13 +155,13 @@ function scanPlugin(dir, layout, dialect = dialect_js_1.claudeCodeDialect, opts
|
|
|
769
155
|
mcp: loaded.warnings.some((w) => w.includes("MCP server")),
|
|
770
156
|
danglingRefs: (0, plugin_loader_js_2.danglingRefs)((0, node_path_1.resolve)(dir), lay),
|
|
771
157
|
hookEventIssues,
|
|
772
|
-
frontmatterIssues: frontmatterIssuesFor(loaded.files, cls),
|
|
773
|
-
frontmatterValueIssues: frontmatterValueIssuesFor(loaded.files, cls),
|
|
774
|
-
skillMetaIssues: skillMetaIssuesFor(loaded.files, cls),
|
|
158
|
+
frontmatterIssues: (0, scan_core_js_1.frontmatterIssuesFor)(loaded.files, cls),
|
|
159
|
+
frontmatterValueIssues: (0, scan_core_js_1.frontmatterValueIssuesFor)(loaded.files, cls),
|
|
160
|
+
skillMetaIssues: (0, scan_core_js_1.skillMetaIssuesFor)(loaded.files, cls),
|
|
775
161
|
mcpIssues: (0, mcp_config_js_1.verifyMcpServers)(mcpServers),
|
|
776
162
|
mcpHookIssues: (0, mcp_hook_js_1.verifyMcpHookTargets)(loaded.settings.hooks, declaredServers, dialect),
|
|
777
|
-
descriptionOverlaps: descriptionOverlapsFor(loaded.files, cls),
|
|
778
|
-
descriptionBudgetIssues: descriptionBudgetFor(loaded.files, cls),
|
|
163
|
+
descriptionOverlaps: (0, scan_core_js_1.descriptionOverlapsFor)(loaded.files, cls),
|
|
164
|
+
descriptionBudgetIssues: (0, scan_core_js_1.descriptionBudgetFor)(loaded.files, cls),
|
|
779
165
|
trifectaFindings,
|
|
780
166
|
skillResourceIssues: skillResourceFindings,
|
|
781
167
|
skillFenceIssues: skillFenceFindings,
|
|
@@ -783,16 +169,17 @@ function scanPlugin(dir, layout, dialect = dialect_js_1.claudeCodeDialect, opts
|
|
|
783
169
|
// The hooks dir is a misplaceable functional surface too, but it lives in
|
|
784
170
|
// the layout as a convention PATH (`hooks/hooks.json`), not in surfaceDirs
|
|
785
171
|
// — derive its first segment and dedupe so the detector watches it as well.
|
|
786
|
-
[...new Set([...lay.surfaceDirs, lay.hooksConventionPath.split("/")[0]])]),
|
|
787
|
-
delegationTrifecta: collectDelegationTrifecta(agents, dialect),
|
|
172
|
+
[...new Set([...lay.surfaceDirs, lay.hooksConventionPath.split("/")[0]])], { existsSync: node_fs_1.existsSync, isDirectory: nodeIsDirectory }),
|
|
173
|
+
delegationTrifecta: (0, scan_core_js_1.collectDelegationTrifecta)(agents, dialect),
|
|
788
174
|
hookBlockFindings: dialect.noEffectHookEvents
|
|
789
|
-
? (0, hook_block_ineffective_js_1.hookBlockIssues)(collectHookBlockEntries(hookRegs, (0, node_path_1.resolve)(dir), lay.pluginRootToken), {
|
|
175
|
+
? (0, hook_block_ineffective_js_1.hookBlockIssues)((0, scan_core_js_1.collectHookBlockEntries)(hookRegs, (0, node_path_1.resolve)(dir), lay.pluginRootToken, node_fs_1.existsSync), {
|
|
790
176
|
noEffectEvents: new Set(dialect.noEffectHookEvents),
|
|
791
177
|
permissionDecisionEvents: new Set(dialect.permissionDecisionHookEvents ?? []),
|
|
178
|
+
readFileSync: nodeReadFile,
|
|
792
179
|
})
|
|
793
180
|
: [],
|
|
794
|
-
hookMatcherFindings: (0, hook_matcher_js_1.hookMatcherIssues)(collectHookMatchers(hookRegs), declaredServers, dialect),
|
|
795
|
-
malformedFrontmatter: malformedFrontmatterFor(loaded.files, cls),
|
|
181
|
+
hookMatcherFindings: (0, hook_matcher_js_1.hookMatcherIssues)((0, scan_core_js_1.collectHookMatchers)(hookRegs), declaredServers, dialect),
|
|
182
|
+
malformedFrontmatter: (0, scan_core_js_1.malformedFrontmatterFor)(loaded.files, cls),
|
|
796
183
|
warnings: loaded.warnings,
|
|
797
184
|
untested: (0, test_coverage_js_1.findUntestedSurfaces)({ basePath: dir, layout: lay }).untested
|
|
798
185
|
.length,
|
|
@@ -827,7 +214,7 @@ function formatMcpContractReport(errors) {
|
|
|
827
214
|
}
|
|
828
215
|
const lines = [`Live MCP tool check — ${String(errors.length)} issue(s):`];
|
|
829
216
|
for (const e of errors)
|
|
830
|
-
lines.push(" ✗ " + (0,
|
|
217
|
+
lines.push(" ✗ " + (0, mcp_contract_message_js_1.mcpContractToolMessage)(e));
|
|
831
218
|
return lines.join("\n");
|
|
832
219
|
}
|
|
833
220
|
/**
|
|
@@ -1023,7 +410,7 @@ function formatScanReport(r) {
|
|
|
1023
410
|
// One discovery nudge toward compiled hooks (never per-hook); the hand-written
|
|
1024
411
|
// shell lane stays first-class, so this is a recommendation, not a defect.
|
|
1025
412
|
if (r.manualHookCount > 0) {
|
|
1026
|
-
out.push(`ℹ ${preferCompiledHooksMessage(r.manualHookCount)}`, "");
|
|
413
|
+
out.push(`ℹ ${(0, scan_core_js_1.preferCompiledHooksMessage)(r.manualHookCount)}`, "");
|
|
1027
414
|
}
|
|
1028
415
|
// Malformed-YAML frontmatter is INFORMATIONAL, not a structural defect: js-yaml
|
|
1029
416
|
// is stricter than some loaders (a colon/quote/<example> in a one-line
|