mirror of
https://github.com/zernie/vigiles.git
synced 2026-09-14 20:53:57 +08:00
7afa00f00a
* fix(scan): the reference extractor accused files that were present
Three false-accusation defects found by a 32-repo OSS dogfood sweep, all the
same shape — vigiles telling a maintainer a file they have is missing. For a
tool whose pitch is deterministic precision, a false positive costs more than a
miss: it is the failure mode that gets the tool uninstalled.
Measured on the corpus (91 audit units): 74 reference findings before, 57
after. All 17 removed are verified false; zero true positives lost, zero new
findings anywhere. Eight units change grade, every one upward — rsmdt/the-startup
F(54)→C(70), ayghri/i-have-adhd B(85)→A(100), fcakyon fable-advisor B(82)→A(90),
jjanczur/tyran D(64)→C(72), and Truthfulness 47→100 / 25→100 on two Microsoft
plugins.
One extractor was being run over five grammars. It now reads three, each through
that grammar's parser, and deliberately stops reading the other two:
markdown markdownRefs() in core/markdown.ts — a link's DESTINATION, and code
spans that are NOT inside link text. The old line-scanner reported a
link's LABEL as a missing resource while its destination resolved.
shell commandWords() in core/bash-effects.ts — the words a shell would
resolve, with inline program text (`node -e`, `python -c`) removed
and a nested `sh -c` parsed rather than discarded. The old regex cut
a character run out of a JavaScript payload and reported it as the
hook's script name.
source core/source-refs.ts — the one place a path-token pattern is built,
with both boundaries un-omittable. `js` could claim the head of
`json`; `(?:agents)/` could claim the tail of `claude-agents/`. Both
were live.
NOT READ: frontmatter (already stripped) and full-line comments in every
language, not only shell — both remaining corpus false positives here came out
of JSDoc.
Subtracted: four hand-written extension alternations (two of them duplicated
across the disk detector and its browser twin), a hand-rolled markdown link
regex, a hand-rolled inline-code regex, a whitespace split standing in for shell
words, and `stripShellComments`.
No new CLI verb, no new flag, no public API change (`api:check`: no drift).
Tests: 10 mutations, each proven applied by grep before running and each killed
by its own assertion, across the whole relevant suite. Every new case carries
both halves — fires on the planted defect, silent on the clean shape beside it.
* fix(scan): drop the redundant start-of-content guard in isPluginRooted
`startsAtSeparator` already answers idx <= 0 with true, and the next line
reaches it for idx === 0 (content[-1] is undefined, so never "/"). The guard
was a second copy of a boundary rule this module deliberately does not own —
the same duplication the module comment warns about — and it was the one
statement the 100% gate could not reach.
Also regenerates .vigiles/generated.d.ts for the new source files.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uvk2Zx66BAuxuLGLFL2umd
---------
Co-authored-by: Claude <noreply@anthropic.com>
84 lines
3.2 KiB
Markdown
84 lines
3.2 KiB
Markdown
# hook-script-exists
|
|
|
|
Flag a **hook command that references a script file which doesn't exist** on disk.
|
|
A `PreToolUse`/`SessionStart`/… hook pointing at `${CLAUDE_PLUGIN_ROOT}/hooks/x.sh`
|
|
when that file isn't there silently never runs — the protection or automation the
|
|
author wired up simply doesn't happen, with no error. Same detector `vigiles audit`
|
|
uses (the hook resolver in `src/scan.ts`); one detector, two callers.
|
|
|
|
This matches Anthropic's own `claude plugin validate` — adopting it makes vigiles
|
|
a superset of the official validator on the hook surface.
|
|
|
|
## What it flags
|
|
|
|
```jsonc
|
|
"hooks": {
|
|
"PreToolUse": [{
|
|
"matcher": "Bash",
|
|
"hooks": [{ "type": "command", "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/guard.sh" }]
|
|
}]
|
|
}
|
|
```
|
|
|
|
→ flagged if `hooks/guard.sh` doesn't exist at the plugin root.
|
|
|
|
## FP-safety
|
|
|
|
The shared resolver already excludes the cases that aren't real misses:
|
|
|
|
- **Unresolved `$VAR`** paths (`$CLAUDE_PROJECT_DIR/...`, a runtime var) → reported
|
|
as "unresolved", never "missing" (can't be checked, so not flagged).
|
|
- **Existence-guarded one-liners** (`[ ! -f x ] || x`, `test -f x && x`) → treated
|
|
as optional, not a hard reference.
|
|
- **Inline commands** with no script file (`echo …`, `npx …`) → counted as inline,
|
|
not path-checked.
|
|
|
|
A relative hook path (`./hooks/x.sh`) resolves against the **plugin root**, not the
|
|
scanner's cwd.
|
|
|
|
**The command is parsed as shell, not scanned as text.** Script operands come from
|
|
a real shell parse (mvdan-sh), so the standard portable-plugin idiom
|
|
|
|
```json
|
|
"command": "node -e \"(async()=>{ await import(join(root,'hooks','always-on.mjs')) })()\""
|
|
```
|
|
|
|
is read as what it is — an inline one-liner whose `-e` argument is a _program_,
|
|
not a path. Scanning the raw string instead cut a character run out of that
|
|
JavaScript and reported it as the hook's missing script, under a name the author
|
|
never wrote; that produced nine false "MISSING" findings across a 32-repo sweep
|
|
(2026-08-17) and contributed to two `F/0` grades. A script named on the right of
|
|
`&&` (`cd "$ROOT" && node hooks/x.mjs`) is still checked: it has to exist whether
|
|
or not it runs.
|
|
|
|
## Configuration
|
|
|
|
```json
|
|
{ "rules": { "hook-script-exists": "warn" } }
|
|
```
|
|
|
|
### Severity
|
|
|
|
| Value | Behavior |
|
|
| ------------------ | ---------------------------------------------------------- |
|
|
| `"error"` | `vigiles lint` exits non-zero (2) on a missing hook script |
|
|
| `"warn"` (default) | Prints a warning, exits 0 |
|
|
| `false` | Skip the check |
|
|
|
|
## Scope
|
|
|
|
Hook commands in `.claude/settings.json` and the plugin manifest
|
|
(`.claude-plugin/plugin.json`), across braced/unbraced `${CLAUDE_PLUGIN_ROOT}`.
|
|
|
|
## Why
|
|
|
|
A hook is deterministic harness machinery; a hook whose script is missing is a
|
|
silent no-op — the worst kind of failure, because the author believes the guard is
|
|
in place. Catching it before it ships closes a real gap (the sweep found broken
|
|
hook refs in the wild).
|
|
|
|
## See also
|
|
|
|
- [untested-hook](untested-hook.md) — a hook script that ships without a test/eval
|
|
(a different concern: the script exists but is unverified).
|