mirror of
https://github.com/zernie/vigiles.git
synced 2026-09-14 20:53:57 +08:00
6349d2c123
verifyGuardrail certified guards it never ran. Claude Code hooks carry an `if:` permission-rule pattern deciding whether the hook runs at all; vigiles never read it, so a real vendored hook whose body is an unconditional deny scored 7/7 blocked — certifying it stops rm -rf /, git reset --hard, an SSH-key read and curl | sh. It now reports 1/7 blocked and 6 not run, each with its reason. The condition is evaluated rather than reported unknown because Claude Code's own matcher fails open, so mirroring that asymmetry can only add a run, never invent a skip. clear() erased other hooks' state. Hooks in one directory deliberately share a store — that is what lets one read a fact another recorded — so removing the directory reset unrelated hooks. It now deletes only entries whose `by` names this hook, verified to agree between the runtime writer and seed() across three path shapes. The site showed numbers nobody measured: a blurred `recall 92% · precision 100%` sat beside a real grade of the repo a visitor types in, two screens above copy mocking unverified numbers in other people's READMEs. Replaced with em-dash placeholders, as this repo's own landing-site skill had already ruled in writing. Wedge no longer claims breadth that `claude plugin validate` covers. Each fix carries a mutation proof with the patch grepped in source and in dist before the run.
375 lines
15 KiB
TypeScript
375 lines
15 KiB
TypeScript
/**
|
|
* The named-state STORE — the one place on disk a compiled hook's facts live,
|
|
* and the one seam a test seeds them through.
|
|
*
|
|
* `src/core/hook-state.ts` is the pure MODEL of a fact (`record`, `state`,
|
|
* `stateFact`, the key charset). It reads no disk on purpose. This module is the
|
|
* other half: where a fact is written, under what name, and how a reader finds
|
|
* it again. It sits at the composition root rather than in `src/core/` because
|
|
* it touches the filesystem, and rather than in an adapter because nothing here
|
|
* is harness-specific — `.vigiles/state/` is vigiles's own directory on Claude
|
|
* Code, on Codex, and on whatever comes next.
|
|
*
|
|
* ## Why it is a module and not three private functions in `cli.ts`
|
|
*
|
|
* It WAS three private functions in `cli.ts` (`hookStateDir`, `readHookState`,
|
|
* `writeHookState`), exported nowhere and named in no `api-surface/*.api.md`.
|
|
* The consequence is quoted in `docs/compiled-hooks.md` as a reason the hook
|
|
* vocabulary still carries the `experimental_` prefix:
|
|
*
|
|
* > Testing a hook that uses named state is archaeology today. The runtime
|
|
* > derives the store's path from the hook's own location and validates the key
|
|
* > charset, so a test that wants to seed "this fact was recorded four days ago"
|
|
* > must reconstruct a private path. The dogfood repo does exactly that,
|
|
* > hard-coded, and it broke when the facts were renamed.
|
|
*
|
|
* A throttled hook's ONLY interesting behaviour is what it does with an old fact
|
|
* versus a fresh one, so a store nobody can seed is a hook nobody can test. The
|
|
* fix is not a second store for tests — that is the drift this repo's
|
|
* one-detector rule exists to forbid, and a seeder that agrees with a private
|
|
* path derivation only until someone edits the derivation is worth less than
|
|
* nothing, because it fails silently and green. {@link experimental_hookState}
|
|
* is a THIN handle over the SAME {@link writeHookState} / {@link readHookState}
|
|
* / {@link hookStateDir} the live runtime calls; there is no second
|
|
* implementation to keep in step. Same shape as `src/load-hook.ts`, which was
|
|
* lifted out of `cli.ts` for the same reason: the CLI and a test must agree, so
|
|
* they call one function.
|
|
*
|
|
* ## Why the handle is on `vigiles` and NOT on `vigiles/hook`
|
|
*
|
|
* `vigiles/hook` is the closed authoring vocabulary, and `checkHookImports`
|
|
* makes it the ONLY import a compiled hook may have. Its guarantee — stated at
|
|
* the top of `core/hook-state.ts` — is that a hook "cannot touch the filesystem:
|
|
* `record()` returns a VALUE… this API hands out no writer". Exporting a store
|
|
* writer there would hand out exactly that writer, to exactly the code the
|
|
* guarantee is about. So the seeding handle lives on the `vigiles` TEST root,
|
|
* beside `runHook`, `loadHook` and the `assertHook*` helpers, where a test can
|
|
* reach it and a hook cannot.
|
|
*/
|
|
import {
|
|
mkdirSync,
|
|
readFileSync,
|
|
readdirSync,
|
|
renameSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
|
|
import { sha256short } from "./core/hash.js";
|
|
import {
|
|
HookStateError,
|
|
durationSeconds,
|
|
record,
|
|
state,
|
|
stateFact,
|
|
type Duration,
|
|
type StateEntry,
|
|
type StateFact,
|
|
type StateWrite,
|
|
} from "./core/hook-state.js";
|
|
import { normalizeHookRef } from "./hook-install.js";
|
|
|
|
/**
|
|
* The directory a hook's recorded facts live in — the SCOPE of `state()`/`record()`.
|
|
*
|
|
* Derived from the hook's own location and never from anything the hook said, so
|
|
* a key cannot address another owner's store: hooks shipped in the same directory
|
|
* share their facts (the requirement — one hook records, another reads), a
|
|
* vendored plugin's hooks get their own. The layout MIRRORS the hook's directory
|
|
* rather than slugging it, which keeps it injective and lets a human debugging a
|
|
* hook find the fact by walking the path they already know:
|
|
*
|
|
* .claude/hooks/calendar-sync-record.hook.ts
|
|
* → .vigiles/state/.claude/hooks/calendar.synced.json
|
|
*
|
|
* A hook outside the project (an absolute path elsewhere) falls back to a hash of
|
|
* its directory: still stable and still isolated, just not readable — which is the
|
|
* right trade for a case that should not happen in a project's own harness.
|
|
*/
|
|
export function hookStateDir(file: string, cwd = process.cwd()): string {
|
|
const dir = dirname(resolve(cwd, file));
|
|
const rel = relative(cwd, dir);
|
|
const inside = rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
return resolve(
|
|
cwd,
|
|
".vigiles/state",
|
|
inside ? rel : `external-${sha256short(dir)}`,
|
|
);
|
|
}
|
|
|
|
/** Read one recorded fact for a hook, or `null` if it was never recorded. */
|
|
export function readHookState(
|
|
file: string,
|
|
key: string,
|
|
cwd = process.cwd(),
|
|
): StateEntry | null {
|
|
try {
|
|
const raw = readFileSync(
|
|
resolve(hookStateDir(file, cwd), key + ".json"),
|
|
"utf-8",
|
|
);
|
|
const parsed = JSON.parse(raw) as StateEntry;
|
|
return typeof parsed.value === "string" && typeof parsed.at === "string"
|
|
? parsed
|
|
: null;
|
|
} catch {
|
|
// Never recorded, unreadable, or corrupt — all "no fact", which `stateFact`
|
|
// turns into an infinite age, so the reading hook SPEAKS. Failing toward
|
|
// noise is the whole point; a store problem must never look like freshness.
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Options for {@link writeHookState}. */
|
|
export interface WriteHookStateOptions {
|
|
/** The project root the store hangs off. Default `process.cwd()`. */
|
|
readonly cwd?: string;
|
|
/**
|
|
* The instant to stamp. Default: now.
|
|
*
|
|
* The runtime never passes this — a fact it records happened just now, by
|
|
* definition. It exists for {@link experimental_hookState}'s `seed`, which
|
|
* must be able to write "four days ago" for a throttle test to have anything
|
|
* to test. Threading it through the REAL writer rather than letting a test
|
|
* build its own entry is what keeps the two in step.
|
|
*/
|
|
readonly at?: Date;
|
|
}
|
|
|
|
/**
|
|
* Record one fact. Atomic: written to a temp file in the same directory and
|
|
* `rename()`d over, so a concurrent reader sees the whole old entry or the whole
|
|
* new one — never one write's value with another's timestamp. Distinct keys are
|
|
* distinct files and never interact at all.
|
|
*/
|
|
export function writeHookState(
|
|
file: string,
|
|
w: StateWrite,
|
|
opts: WriteHookStateOptions = {},
|
|
): void {
|
|
const cwd = opts.cwd ?? process.cwd();
|
|
const dir = hookStateDir(file, cwd);
|
|
const target = resolve(dir, w.name + ".json");
|
|
const entry: StateEntry = {
|
|
value: w.value,
|
|
at: (opts.at ?? new Date()).toISOString(),
|
|
by: normalizeHookRef(file, cwd),
|
|
};
|
|
mkdirSync(dir, { recursive: true });
|
|
const tmp = `${target}.${String(process.pid)}.tmp`;
|
|
writeFileSync(tmp, JSON.stringify(entry, null, 2) + "\n");
|
|
renameSync(tmp, target);
|
|
}
|
|
|
|
/**
|
|
* Forget the facts THIS hook recorded, leaving a co-located hook's alone.
|
|
*
|
|
* The store is keyed by DIRECTORY, not by file, and deliberately so: that
|
|
* sharing is what lets one hook declare `state("calendar.synced")` and read a
|
|
* fact another hook in the same folder recorded. So "delete the directory" and
|
|
* "forget this hook's facts" are different operations, and the recursive
|
|
* delete this replaced was the wrong one — a test following the documented
|
|
* `st.clear()` opener reset every co-located hook, and two tests seeding
|
|
* different hooks in one directory erased each other.
|
|
*
|
|
* Ownership is {@link StateEntry.by}, which {@link writeHookState} stamps from
|
|
* the hook's own path. It is the SAME derivation {@link hookStateDir} uses
|
|
* (`resolve` then `relative`), so a handle built with a relative spelling and a
|
|
* runtime invoked with an absolute one agree — verified against the live
|
|
* runtime in both directions in the colocated test, because a scope that only
|
|
* matched the writer a test happens to use would fail silently and green.
|
|
*
|
|
* An entry we cannot READ, or one carrying no `by` at all, is left in place:
|
|
* every write this module performs stamps an owner, so an unattributed entry
|
|
* was written by something else, and deleting it is exactly the collateral
|
|
* damage being removed here. It reads back as another owner's fact — seed over
|
|
* it, or point `cwd` at a throwaway root when a completely empty store is what
|
|
* the test wants.
|
|
*
|
|
* The directory itself is left behind, possibly empty. Removing it would race
|
|
* a concurrent test writing into the same shared directory, which is the class
|
|
* of interference this function exists to stop.
|
|
*/
|
|
function clearOwnState(file: string, cwd: string): void {
|
|
const dir = hookStateDir(file, cwd);
|
|
const mine = normalizeHookRef(file, cwd);
|
|
let entries: readonly string[];
|
|
try {
|
|
entries = readdirSync(dir);
|
|
} catch {
|
|
// Nothing was ever recorded here — the documented no-op.
|
|
return;
|
|
}
|
|
for (const name of entries) {
|
|
// A torn `<key>.json.<pid>.tmp` from an interrupted write is not an entry.
|
|
if (!name.endsWith(".json")) continue;
|
|
const path = resolve(dir, name);
|
|
let owner: string | undefined;
|
|
try {
|
|
owner = (JSON.parse(readFileSync(path, "utf-8")) as StateEntry).by;
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (owner === mine) rmSync(path, { force: true });
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The test seam.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Options for {@link HookStateHandle.seed}.
|
|
*
|
|
* Spelled out as a three-way union rather than `{value} & SeedWhen` so the whole
|
|
* type is IN this declaration: a public signature that names a type a consumer
|
|
* cannot import is a surface you can read but not write against. The union is
|
|
* also what makes `{ ago, at }` — two disagreeing answers to "when" — a tsc
|
|
* error; `seedInstant` throws on it as well, because harness tests are `.mjs`
|
|
* and a type does not run there.
|
|
*/
|
|
export type SeedStateOptions =
|
|
/** Recorded that long ago — the reason this exists (a throttle needs an OLD fact). */
|
|
| {
|
|
readonly value?: string;
|
|
readonly ago: Duration;
|
|
readonly at?: never;
|
|
}
|
|
/** Recorded at exactly this instant. */
|
|
| {
|
|
readonly value?: string;
|
|
readonly at: Date;
|
|
readonly ago?: never;
|
|
}
|
|
/** Recorded just now. */
|
|
| {
|
|
readonly value?: string;
|
|
readonly ago?: never;
|
|
readonly at?: never;
|
|
};
|
|
|
|
/**
|
|
* A handle on ONE hook's recorded facts — what {@link experimental_hookState}
|
|
* returns. Every method goes through the runtime's own store functions, so a
|
|
* seeded fact is indistinguishable from one the hook recorded itself.
|
|
*/
|
|
export interface HookStateHandle {
|
|
/**
|
|
* Where this hook's facts live. Exposed for a failure message ("no fact under
|
|
* …"), NOT as a path to build on — build on it and you are back to the
|
|
* hard-coded private path this handle exists to retire.
|
|
*/
|
|
readonly dir: string;
|
|
/**
|
|
* Write a fact as if the hook had recorded it, then read it back through the
|
|
* real reader — so what you get is exactly what the hook will see, and a write
|
|
* that did not land cannot be mistaken for one that did.
|
|
*
|
|
* `ago` is the reason this exists: a throttle is only interesting against an
|
|
* OLD fact, and "old" is not something a test can produce by waiting.
|
|
*
|
|
* ```js
|
|
* const st = experimental_hookState(".vigiles/hooks/nag.mjs", { cwd });
|
|
* st.seed("retro.nagged", { ago: "4d" }); // → the hook speaks
|
|
* st.seed("retro.nagged", { ago: "10m" }); // → the hook stays quiet
|
|
* ```
|
|
*/
|
|
seed(key: string, opts?: SeedStateOptions): StateFact;
|
|
/**
|
|
* Read a fact exactly as the hook's `e.ctx[key]` would — same `recorded` /
|
|
* `ageSeconds` / `fresherThan`. Never-recorded reads back as the total
|
|
* `Infinity` fact rather than throwing, because that is what the hook sees.
|
|
*
|
|
* Also the cheap way to drive the IN-PROCESS tier: the value it returns is a
|
|
* `StateFact`, which is what `runHookProgram(hook, event, ctx)` wants in `ctx`.
|
|
*/
|
|
read(key: string): StateFact;
|
|
/**
|
|
* Forget the facts THIS hook recorded. A no-op when it recorded none.
|
|
*
|
|
* Scoped to this hook's own writes, because the store is shared per DIRECTORY
|
|
* — that sharing is what lets one hook read a fact another recorded — so a
|
|
* co-located hook's facts (and any entry with no recorded owner) are left
|
|
* untouched. When a test wants the whole store empty rather than this hook's
|
|
* share of it, give it a throwaway `cwd`: the store hangs off that root.
|
|
*/
|
|
clear(): void;
|
|
}
|
|
|
|
/**
|
|
* Seed and read a compiled hook's named state from a test.
|
|
*
|
|
* @experimental — the store LAYOUT is not a stable contract (this handle exists
|
|
* so you never depend on it), and the hook vocabulary it serves is itself
|
|
* `experimental_`. See `docs/compiled-hooks.md` § Status / pending.
|
|
*
|
|
* Pass the hook file exactly as the wiring names it, and the same `cwd` the hook
|
|
* will run under — the store's location is derived from both, so a handle built
|
|
* with a different root points at a different (empty) store.
|
|
*
|
|
* ```js
|
|
* import { experimental_hookState, runHook } from "vigiles";
|
|
*
|
|
* const st = experimental_hookState(hookFile, { cwd });
|
|
* st.clear();
|
|
* st.seed("retro.nagged", { ago: "4d" });
|
|
* const r = runHook(`npx vigiles hook-runtime run-program ${hookFile}`, event, { cwd });
|
|
* ```
|
|
*/
|
|
export function experimental_hookState(
|
|
hookFile: string,
|
|
opts: { readonly cwd?: string } = {},
|
|
): HookStateHandle {
|
|
const cwd = opts.cwd ?? process.cwd();
|
|
const dir = hookStateDir(hookFile, cwd);
|
|
const read = (key: string): StateFact => {
|
|
// Validate the key the way a hook's `needs` does — one validator, one
|
|
// message. A test that reads `"../settings"` should hear about it, not get
|
|
// a silent never-recorded fact back.
|
|
state(key);
|
|
return stateFact(readHookState(hookFile, key, cwd), Date.now());
|
|
};
|
|
return {
|
|
dir,
|
|
read,
|
|
seed(key, seedOpts = {}) {
|
|
// `record()` is the hook's OWN constructor, so the key charset is checked
|
|
// here by the same code and with the same error a hook would hit.
|
|
const w = record(key, seedOpts.value ?? "");
|
|
writeHookState(hookFile, w, { cwd, at: seedInstant(seedOpts) });
|
|
// Read back through the real reader rather than returning what we meant to
|
|
// write: a seed that did not land must not look like one that did.
|
|
return read(key);
|
|
},
|
|
clear() {
|
|
clearOwnState(hookFile, cwd);
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The instant a seed lands on. The `ago`/`at` exclusion is a type error AND a
|
|
* throw: harness tests are `.mjs` by convention (`dual-language-tests`), so a
|
|
* type-only guard would not run where most of these calls live.
|
|
*/
|
|
function seedInstant(opts: {
|
|
readonly ago?: Duration;
|
|
readonly at?: Date;
|
|
}): Date {
|
|
if (opts.ago !== undefined && opts.at !== undefined) {
|
|
throw new HookStateError(
|
|
`pass either ago or at, not both — "${opts.ago}" and ${opts.at.toISOString()} disagree about when the fact was recorded.`,
|
|
);
|
|
}
|
|
if (opts.at !== undefined) return opts.at;
|
|
if (opts.ago === undefined) return new Date();
|
|
const seconds = durationSeconds(opts.ago);
|
|
if (seconds === null) {
|
|
throw new HookStateError(
|
|
`invalid duration "${opts.ago}" — use <number><s|m|h|d>, e.g. "90s", "30m", "1h", "7d".`,
|
|
);
|
|
}
|
|
return new Date(Date.now() - seconds * 1000);
|
|
}
|