/** * `vigiles audit ` — point vigiles at any plugin/repo and see what it ships * and what's broken, with **no model and no API key**. * * This is the deterministic substrate under the plugin/skill leaderboard * (research/divergent-bets.md #9) and the harness-aware scan * (research/agent-supply-chain-security.md #1): it re-aims the machinery that * already exists — `loadPlugin` (surfaces + dangling-ref/MCP/empty-machine * warnings), `parseAgentTools` (the declared tool contract), and * `findUntestedSurfaces` — into one read-only report. Behavioral checks that * need to RUN the plugin (observed egress under the sandbox, real trigger-rate) * stack on top later; this core stays pure so it runs anywhere in CI for free. */ import { type PluginLayout } from "./core/layout.js"; import type { ExcludeSet } from "./exclude.js"; import type { DeprecatedKeyFilesFinding } from "./deprecated-key-files.js"; import type { HarnessDialect } from "./core/dialect.js"; import type { ToolIssue } from "./core/tool-contract.js"; import { type HookEventIssue } from "./core/hook-events.js"; import { type McpIssue } from "./core/mcp-config.js"; import type { DescriptionOverlap } from "./core/description-overlap.js"; import type { DescriptionBudgetIssue } from "./core/skill-description-budget.js"; import type { McpToolIssue } from "./core/mcp-tool.js"; import { type McpContractToolError } from "./core/mcp-contract-message.js"; import { type McpHookIssue } from "./core/mcp-hook.js"; import type { TrifectaFinding } from "./core/lethal-trifecta.js"; import type { SkillResourceFinding } from "./core/skill-resources.js"; import type { SkillFenceFinding } from "./core/skill-missing-fence.js"; import { type PluginLayoutFinding } from "./core/plugin-dir-layout.js"; import { type UnclaimedSurfaceFinding } from "./core/surface-discovery.js"; import type { DelegationTrifectaFinding } from "./core/delegation-trifecta.js"; import { type HookBlockFinding } from "./core/hook-block-ineffective.js"; import { type HookMatcherFinding } from "./core/hook-matcher.js"; import { type EvidenceCounts } from "./coverage-evidence.js"; import type { PurityLevel, EffectSurface } from "./core/effects.js"; import { type HandWrittenHook, type StaleHook } from "./scan-core.js"; import { type InstructionWeight } from "./core/instruction-weight.js"; import type { FilteredScan } from "./core/apply-rules.js"; export * from "./scan-core.js"; export interface ScanSkill { readonly name: string; readonly path: string; readonly hasDescription: boolean; /** * The skill's effective description (frontmatter `description`, else the first * body paragraph — the same text the selector keys on), trimmed; `undefined` * when neither exists. Feeds the model trigger tier's auto-generated probes. */ readonly description?: string; readonly userInvoked: boolean; /** * SKILL.md body references to a bundled file (`scripts/`/`references/`/`assets/` * or a relative markdown link with an extension) that don't resolve on disk * under the skill dir, each tagged `missing` or `repo-root-only`. Computed by * `skillResourceIssues()` (one detector, no drift); the rule module that * reports them (`src/rules/`) reads them through core's `ScanInput`. */ readonly resourceIssues: readonly SkillResourceFinding[]; /** * Lethal-trifecta finding when a MODEL-INVOCABLE skill's `disallowed-tools:` * fence leaves all three legs standing (read-private + ingest-untrusted + * exfiltrate), else null. Computed by `skillTrifectaIssue()` — NOT from * `allowed-tools:`, which is a pre-approval and bounds nothing (measured * 2026-08-11; see `src/core/lethal-trifecta.ts`). The `fence` field on the * finding says whether the skill declared no fence at all (`"none"` — the * ecosystem default, aggregated in the report) or a fence that closes no leg * (`"ineffective"` — per-skill). A user-invoked skill is excluded (it can't be * selected by attacker content). */ readonly trifecta: TrifectaFinding | null; /** * Set when the SKILL.md opens with frontmatter-looking keys (`name:`, …) but * has NO opening `---` fence, so the whole file loads as body — no name, no * description, no trigger (the skill is invisible). Computed by * `skillMissingFence()` (one detector, no drift). */ readonly fenceIssue: SkillFenceFinding | null; } export interface ScanAgent { readonly name: string; readonly path: string; /** Declared tool contract, or null when the agent ships no `tools:` (inherits all). */ readonly tools: readonly string[] | null; /** Contract entries that don't resolve to a real built-in / MCP tool (typo, never-available). */ readonly toolIssues: readonly ToolIssue[]; /** MCP tool entries naming a server the plugin doesn't declare (can't resolve). */ readonly mcpToolIssues: readonly McpToolIssue[]; /** * ADVISORY tool findings — a real tool withheld only under a condition vigiles * cannot see, or a name not in its capture. Surfaced via `vocabularyNotes`, * never scored. Optional: absence is not a claim of zero. */ readonly toolNotes?: readonly ToolIssue[]; /** `disallowedTools:` block-list entries that are typos of a real tool (block nothing). */ readonly disallowedToolIssues: readonly ToolIssue[]; /** * Static effect-surface purity of the agent's declared tool contract. * - `"pure"` — no side-effecting tools (read-only, deterministically testable). * - `"bounded"` — has side-effecting tools (Edit/Write/…) but no Bash or unknown. * - `"unrestricted"` — has Bash, any MCP/unknown tool, or inherits-all (no contract). * Computed by `effectSurface()` from `src/core/effects.ts` — one detector, no drift. */ readonly purity: PurityLevel; /** * The three tool buckets from `effectSurface()`: read-only, side-effecting, and * unknown-effect (MCP or unrecognized) tool names in the declared contract. */ readonly effectBuckets: Pick; /** * Lethal-trifecta finding when the subagent's declared tools hold all three legs * (read-private + ingest-untrusted + exfiltrate), else null. An inherits-all * agent (no `tools:` line) is the "advisory" case. Computed by * `lethalTrifectaIssues()` (one detector, no drift). */ readonly trifecta: TrifectaFinding | null; } /** A lethal-trifecta finding tagged with the surface (subagent/skill) that holds it. */ /** One advisory vocabulary finding, tagged with the surface that carries it. */ export interface VocabularyNote { /** Where it was found — a hook event key, or an agent's path. */ readonly where: string; readonly message: string; } export interface ScanTrifectaFinding { readonly path: string; readonly kind: "subagent" | "skill"; readonly name: string; readonly finding: TrifectaFinding; } /** A missing-frontmatter-fence finding tagged with the skill path. */ export interface ScanSkillFenceFinding { readonly path: string; readonly name: string; readonly finding: SkillFenceFinding; } /** A delegation-trifecta finding tagged with the subagent path that holds it. */ export interface ScanDelegationFinding { readonly path: string; readonly finding: DelegationTrifectaFinding; } /** A skill/agent whose frontmatter is missing a required field (name / description). */ export interface FrontmatterIssue { readonly path: string; readonly kind: "skill" | "agent"; readonly missing: readonly ("name" | "description")[]; readonly message: string; } /** A skill/agent whose `---` block exists but isn't valid YAML (may not parse as intended). */ export interface FrontmatterParseIssue { readonly path: string; readonly message: string; } /** An agent frontmatter field whose VALUE is invalid (a typo of a real model/color). */ export interface FrontmatterValueIssue { readonly path: string; readonly field: "model" | "color"; readonly value: string; readonly suggestion: string; readonly message: string; } /** ok = file present; missing = referenced but absent; unresolved = path still has an unexpanded var, can't check. */ export type HookStatus = "ok" | "missing" | "unresolved"; export interface ScanHook { /** The hook line that names this file, as the config wrote it. */ readonly command: string; readonly script: string; readonly status: HookStatus; /** * The hook EVENT this script is registered under (`PreToolUse`, `PostToolUse`, * `SessionStart`, …), when it can be determined from the canonical * object-keyed-by-event settings shape; `undefined` for a non-object/array * config. The safety battery uses it to test only the blocking-capable * `PreToolUse` guards — so a `SessionStart`/`PostToolUse` hook isn't unfairly * scored against "does it block rm -rf". */ readonly event?: string; } /** * The repo's top-level instruction file (`CLAUDE.md` / `AGENTS.md`), if present. * Every cc/codex repo has one even when it ships no plugin surface, so `scan` * reports it — otherwise a plain instruction-only repo looks empty. `hasSpec` is * the deterministic fact that a `.spec.ts` sits beside it (spec-managed vs * hand-written); it is informational, NOT the `require-instructions-spec` gate (that's lint). */ export interface ScanInstructions { readonly file: string; readonly hasSpec: boolean; } export interface ScanReport { readonly dir: string; /** The detected instruction file (CLAUDE.md/AGENTS.md), or null if none. */ readonly instructions: ScanInstructions | null; readonly skills: readonly ScanSkill[]; readonly agents: readonly ScanAgent[]; readonly hooks: readonly ScanHook[]; /** Hook entries with no script file (inline shell one-liners) — can't be path-checked. */ readonly inlineHooks: number; /** * The hook lines vigiles did not write (a compiled hook's line is * `hook-runtime …`, read by its own template). Each one is a * `hook-script-exists` finding: a static reader cannot tell what a * hand-written line runs or whether it swallows its own failure. */ readonly handWrittenHooks: readonly HandWrittenHook[]; /** * Compiled lines that are no longer the line `compile` writes (edited, or * compiled by another version). `hook-script-exists` reports them. */ readonly staleHooks: readonly StaleHook[]; readonly commands: number; /** * Output styles found, exempt ones aside, or `"not-supported"` when the * harness has none — the inventory side of `untested-output-style`. Optional * so a hand-built report predating the field still type-checks. */ readonly outputStyles?: number | "not-supported"; readonly mcp: boolean; /** * Intra-plugin file references (hook scripts, skill bodies) pointing at files * that don't exist on disk — the broken-path / partial-vendor class. A * first-class structural finding, not just a free-text warning, so the verdict * and the leaderboard can count it. */ readonly danglingRefs: readonly string[]; /** * Skills naming a sibling skill that does not exist (a near-miss of a real one * — a rename or a typo). Separate from `danglingRefs` because that field holds * PATH tokens from executable sources; these are full sentences about prose. * Both are broken intra-plugin references and score in the same category. * * OPTIONAL because absence is not a claim: a producer that predates this field * (or a browser scan that has not been taught it) reports nothing rather than * reporting zero, the same `undefined`-vs-`[]` distinction the check counter * and `assertNoWrite` already draw. */ readonly skillRefIssues?: readonly string[]; /** Hooks registered under an event name the harness doesn't define (typo / dead). */ readonly hookEventIssues: readonly HookEventIssue[]; /** * ADVISORY vocabulary findings — names vigiles could not confirm, and real * names the platform withholds only under a condition it cannot see. Surfaced * and NEVER scored, which is the point: the two failure modes this replaced * were silence (which reads as approval and hides vigiles's own staleness) and * an error (which blames the user for our gap). Neither is a verdict, so these * get a third channel instead of a grade. * * OPTIONAL because absence is not a claim: a producer predating this field * reports nothing rather than reporting zero. */ readonly vocabularyNotes?: readonly VocabularyNote[]; /** Skills/agents missing a required frontmatter field (name; agents also description). */ readonly frontmatterIssues: readonly FrontmatterIssue[]; /** Agent frontmatter fields with an invalid value (a typo of a real model/color). */ readonly frontmatterValueIssues: readonly FrontmatterValueIssue[]; /** Skills lacking an EXPLICIT name/description — a best-practice recommendation, not a defect. */ readonly skillMetaIssues: readonly FrontmatterIssue[]; /** Declared MCP servers that can't start (no command/url). */ readonly mcpIssues: readonly McpIssue[]; /** `type: mcp_tool` hook actions that are incomplete or target an undeclared server. */ readonly mcpHookIssues: readonly McpHookIssue[]; /** Pairs of model-invocable skills whose descriptions are near-identical (precision collision). */ readonly descriptionOverlaps: readonly DescriptionOverlap[]; /** Model-invocable skills whose description is so long the trigger signal is buried. */ readonly descriptionBudgetIssues: readonly DescriptionBudgetIssue[]; /** * Lethal-trifecta findings across subagents + model-invocable skills — a unit * holding all three legs (read-private + ingest-untrusted + exfiltrate). Each * carries the surface path + kind for reporting/annotations. Shared by `scan` * (the report) and the `lethal-trifecta` lint rule (one detector, no drift). */ readonly trifectaFindings: readonly ScanTrifectaFinding[]; /** * Skills whose frontmatter-looking opening has NO `---` fence, so they load as * pure body (invisible — no name/description/trigger). Shared by `scan` and the * `skill-missing-fence` lint rule (one detector, no drift). */ readonly skillFenceIssues: readonly ScanSkillFenceFinding[]; /** * Functional surface dirs (skills/agents/commands) nested INSIDE the manifest * dir (`.claude-plugin/`) where the harness can't see them. Shared by `scan` * and the `plugin-dir-layout` lint rule (one detector, no drift). */ readonly pluginLayoutIssues: readonly PluginLayoutFinding[]; /** * Surface directories found by SHAPE in the bounded root set that NO * registered harness claims — a harness sitting somewhere vigiles does not * read, reported instead of silently graded around (#240). Computed by * `core/surface-discovery.ts` from paths alone; shared with the browser twin * (one detector, no drift). */ readonly unclaimedSurfaces: readonly UnclaimedSurfaceFinding[]; /** * Lethal trifectas that EMERGE across a delegation edge — a subagent whose * effective (own ∪ delegated-to) capability holds all three legs though no * single unit does. Shared by `scan` and the `delegation-trifecta` lint rule * (one detector, no drift). */ readonly delegationTrifecta: readonly ScanDelegationFinding[]; /** * Hooks that LOOK like they block but silently don't — a block decision on a * non-blocking event, or the legacy `decision` field on a permission-gated * event (#19009, the #1 verified hook pain). Shared by `scan` and the * `hook-block-ineffective` lint rule (one detector, no drift). Empty when the * dialect doesn't declare its blocking-event semantics. */ readonly hookBlockFindings: readonly HookBlockFinding[]; /** * Hook `matcher` strings that don't fire as written — a tool-name typo, a * matcher that doesn't compile, an MCP pattern that matches no tool name or is * too narrow for real server naming, or an undeclared MCP server. Shared by * `scan` and the `hook-matcher` lint rule (one detector, no drift). */ readonly hookMatcherFindings: readonly HookMatcherFinding[]; /** * What the harness loads WITHOUT being asked, weighed in ITS OWN unit — and * what it does when that is too much. `null` when the adapter declares no * budget. Reported, never gated: both corpora this was built against sit near * four times the Claude Code threshold, and a rule that fails every real repo * on day one is switched off on day one. */ readonly instructionWeight: InstructionWeight | null; /** * Instruction specs still using the deprecated `keyFiles` — the * `deprecated-key-files` rule's own detector (`findDeprecatedKeyFiles`), * handed in through `opts` by `audit`, so `lint` and `audit` cannot * disagree. Advisory: printed with its fix, never scored. * * ABSENT when there are none, never `[]`, for the parity reason * `coverageCaveats` gives. ⚠️ The browser twin produces none: it would need the * TypeScript parser in the site bundle, and the demo does not fetch spec * sources. No vendored parity fixture carries a spec, so the gate stays * byte-identical; a spec added to one would show this gap as a parity diff. */ readonly deprecatedKeyFiles?: readonly DeprecatedKeyFilesFinding[]; /** Skills/agents whose `---` block isn't valid YAML — informational (may still load via salvage). */ readonly malformedFrontmatter: readonly FrontmatterParseIssue[]; readonly warnings: readonly string[]; /** Surfaces covered by NEITHER tier — the union count (unchanged). */ readonly untested: number; /** * Surfaces with no DETERMINISTIC harness (`*.harness.*`) — free, * millisecond, every-push. Feeds the `Tested` ring. Optional so a hand-built * report (and any producer predating the split) falls back to `untested`. */ readonly untestedHarness?: number; /** * Surfaces whose FIRING was never measured — no `*.eval.mjs` covers them. Paid, * scheduled, real-model. Feeds the `Evaluated` ring. A DIFFERENT gap from * `untestedHarness`, at a cost three orders of magnitude apart — which is * exactly why it is a different number and not a slash in one finding. */ readonly unevaluated?: number; /** * Surfaces whose firing COULD be measured at all — the `Evaluated` ring's * denominator. 0 → the ring is n/a (nothing to evaluate), never a false 0. */ readonly evaluable?: number; /** * HOW the covered surfaces were decided to be covered — declared / colocated / * name-mentioned. A coverage count with no provenance is what let a one-line * COMMENT confer coverage unnoticed; carrying the derivation makes "28 covered" * distinguishable from "28 names that happen to appear in some file". */ readonly coverageEvidence?: EvidenceCounts; /** * The caveats that QUALIFY the coverage numbers above — "measured, but not * this version", "still carrying a marker that stopped counting". Already * formatted, because both renderers print the same sentence and a second * wording is a second thing to keep true. * * Absent when there are none, never `[]`: byte-parity between the disk and * browser engines is asserted field-for-field, and an empty array on one side * against an absent field on the other is a diff where two absent fields are * not. * * ⚠️ THE BROWSER TWIN PRODUCES NONE OF THESE, and only two of the three have * an excuse: "measured, but not this version" needs `.vigiles/coverage.json` * and the retired-marker note needs to read test files, neither of which a * filesystem-free scan has. The retired-SUFFIX note is derivable from the file * map alone and is simply not wired there yet. The parity test cannot see the * gap either way — no vendored fixture carries any of the three inputs, which * is the same blind spot `scan-files.test.ts` documents for the * single-skill-at-root branch. */ readonly coverageCaveats?: readonly string[]; /** * Whether the repo has its OWN test setup (a real `package.json` `test` script or * a conventional test dir). When true, the `untested` count — which only counts * vigiles-native `.eval.mjs`/`.harness.mjs` — is misleading: the team clearly * tests, they just don't use vigiles's skill-trigger tier. So the `Tested` ring is * contextualized as OPTIONAL rather than reading as a failure (it's advisory / * ungraded either way). Optional — the browser `scanFiles` path leaves it unset * (false); only the fs-based CLI scan detects it. See gate-first-adoption G3. */ readonly ownTestSignal?: boolean; /** * Harness-level purity summary: how many scanned agents fall into each purity * rung. A high `pure` count means more of the harness is statically testable * (deterministic, no mocks); `unrestricted` is the blind-spot count. * Computed by `effectSurface()` (one detector, no drift). */ readonly puritySummary: { pure: number; bounded: number; unrestricted: number; }; } /** * ONE harness a scan reads the repo under — the resolved form of a * `.vigilesrc.json#harnesses` entry (#240). * * All three parts travel together on purpose. The LAYOUT says where this harness * reads and what a surface is there; the DIALECT says what it understands (and * carries its own instruction budget, in its own unit); the ROOTS are the extra * places this repo's owner says that harness's surfaces also live. Splitting * them is how a root came to be read under a layout nobody chose for it. */ export interface ScanHarness { readonly layout: PluginLayout; readonly dialect: HarnessDialect; readonly roots: readonly string[]; } /** * The always-loaded instruction weight of ONE directory under ONE harness — * the bounded read, the harness's chain, and the sum, in one place. * * `scanPlugin` reports it, `lint`'s instruction-weight ratchet gates on it and * `compile` prints it, so all three must arrive at the same number by the same * route; this function is that route. `null` when the harness publishes no * budget (there is no unit to count in). */ export declare function measureInstructionWeight(dir: string, harness: Pick): InstructionWeight | null; /** Scan a plugin/repo directory and report its surfaces + structural issues. */ export declare function scanPlugin(dir: string, layout: PluginLayout, dialect: HarnessDialect, opts?: { sharedDirs?: readonly string[]; sharedDirsRoot?: string; /** * The repo's `.vigilesrc.json#exclude`, so surface DISCOVERY honours it. * * It rides in `opts` rather than as a fifth positional parameter because ~25 * call sites pass the first three and nothing else; a required parameter here * would be twenty-odd mechanical edits for one behavioural change. * * ⚠️ Omitting it is NOT "the repo excludes nothing" — it is "this caller has * no ExcludeSet to give", and the walk then reads everything. `audit` and * every `lint` rule checker supply one (the checkers through the per-bundle * context `overBundles` builds, so a new checker cannot forget it). */ excludes?: ExcludeSet; /** * The `deprecated-key-files` findings for this directory, from the rule's * own detector (`findDeprecatedKeyFiles`), computed by the composition root * — the one place allowed to walk the repository and read specs. `audit` * passes them; every other caller leaves the field off the report. */ deprecatedKeyFiles?: readonly DeprecatedKeyFilesFinding[]; /** * The repo's `.vigilesrc.json#harnesses`, resolved — every declared harness * with its own layout, dialect and roots, IN DECLARATION ORDER. * * 🔴 THE LIST IS READ WHOLE, AND THAT IS THE FIX (#240). The repo is loaded * once per entry, each under its OWN layout, and the file maps merge * (deduplicated by on-disk path, so one tree serving two harnesses is * counted once). Under the flat `harness` array only the first entry's * layout was ever used, so a repo declaring both got its skills OR its * instruction file depending on which name it happened to list first, and no * order gave it both. * * The first entry is the PRIMARY: it supplies the dialect every * dialect-shaped check runs under and the `harness` the report is labelled * with. The INSTRUCTION FILE is the deliberate exception — it comes from the * first declared harness whose instruction file is actually present, because * reporting "no instruction file" for a repo holding the other declared * harness's one is the half of the bug that has nothing to do with skills. * * Rides in `opts` for the same reason `excludes` does: ~25 call sites pass * the first three positionals and nothing else. Omitting it means "this * caller has no declaration to pass" and the scan runs single-harness on the * positional `layout`/`dialect`, exactly as before. */ harnesses?: readonly ScanHarness[]; }): ScanReport; /** * LIVE MCP tool resolution for a scanned plugin — the dynamic check no static * linter can do: it STARTS each declared MCP server and checks every * `mcp__server__tool` the plugin's agents reference actually exists on it * (catching rename/removal rot, e.g. `create_issue`→`issue_write`). Reuses the * already-computed `report` (its agents' tool lists) + the declared server configs; * returns `[]` when the plugin declares no MCP servers (nothing to start). Async + * side-effecting (spawns servers) — so `audit` runs it by default only for the * user's OWN repo (own-repo, like running your own tools); a FOREIGN plugin's * servers are never spawned, and `--fast` opts out. See `verifyMcpContractTools` * (core/mcp.ts). */ export declare function verifyLiveMcpTools(report: ScanReport, layout: PluginLayout, dialect: HarnessDialect, timeoutMs?: number): Promise; /** Render the live MCP tool-check result (human-readable). */ export declare function formatMcpContractReport(errors: readonly McpContractToolError[]): string; /** * A plugin MARKETPLACE (`.claude-plugin/marketplace.json`) decomposed into its * members. A marketplace either VENDORS its plugins in-tree (string `source` * paths, e.g. wshobson/agents — `onDisk` populated) or CURATES external ones * (object `source` with a git/url, e.g. obra/superpowers-marketplace, * anthropics/claude-plugins-community — `external` populated, nothing on disk). * Distinguishing the two lets `scan` report a curated marketplace honestly * instead of mistaking it for an empty repo. */ export interface MarketplaceInfo { readonly name: string; /** Member plugin dirs that exist on disk (string `source` paths). */ readonly onDisk: readonly string[]; /** Members referencing an off-disk source (url/git/github) — can't be scanned here. */ readonly external: number; readonly total: number; } /** * Read a `marketplace.json` beside the layout's plugin manifest and classify its * members into on-disk vs external. Returns `null` when `dir` is not a * marketplace. The source of truth behind {@link expandMarketplace} and the * curated-marketplace report in `vigiles audit`. */ export declare function inspectMarketplace(dir: string, layout: PluginLayout): MarketplaceInfo | null; /** * If `dir` is a plugin MARKETPLACE (a `marketplace.json` beside the layout's * plugin manifest, e.g. `.claude-plugin/marketplace.json`), expand it into the * absolute dirs of its member plugins. Returns `null` when there's no * marketplace, `[]` when it's a marketplace whose members are all external (not * on disk). Used by `vigiles audit` to rank a whole marketplace — wshobson/agents * alone ships 80+ plugins under one `marketplace.json`. See {@link inspectMarketplace}. */ export declare function expandMarketplace(dir: string, layout: PluginLayout): string[] | null; export declare function formatScanReport( /** * Minted by `applyRules`: an off rule's findings are already gone, and an * ungraded group's are in `ungraded` — printed AFTER the verdict line and * never counted in it, from the same data the JSON and the HTML render. */ r: FilteredScan): string; //# sourceMappingURL=scan.d.ts.map