vigiles 5.2.0 → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +99 -48
- package/dist/action-gate.js +1 -1
- package/dist/adapters/claude-code/agent-runtime.d.ts +64 -4
- package/dist/adapters/claude-code/agent-runtime.js +131 -17
- package/dist/adapters/claude-code/dialect.d.ts +34 -0
- package/dist/adapters/claude-code/dialect.js +46 -33
- package/dist/adapters/claude-code/effect-region.js +1 -1
- package/dist/adapters/claude-code/skill-runtime.d.ts +1 -1
- package/dist/adapters/claude-code/skill-runtime.js +1 -9
- package/dist/adapters/claude-code/typed-spec.d.ts +58 -0
- package/dist/adapters/claude-code/typed-spec.js +55 -0
- package/dist/adapters/codex/hook-protocol.js +3 -0
- package/dist/adapters/codex/mock-model.js +1 -1
- package/dist/claude-code.d.ts +1 -0
- package/dist/claude-code.js +8 -1
- package/dist/cli-commands.d.ts +19 -0
- package/dist/cli-commands.js +51 -0
- package/dist/cli.js +735 -76
- package/dist/core/bash-effects.d.ts +12 -0
- package/dist/core/bash-effects.js +31 -0
- package/dist/core/capability-diff.d.ts +46 -0
- package/dist/core/capability-diff.js +97 -0
- package/dist/core/compile.d.ts +1 -1
- package/dist/core/compile.js +14 -0
- package/dist/core/generate-harness.d.ts +187 -0
- package/dist/core/generate-harness.js +337 -0
- package/dist/core/guards.d.ts +126 -0
- package/dist/core/guards.js +309 -0
- package/dist/core/harness-driver.d.ts +1 -1
- package/dist/core/hook-program.d.ts +459 -0
- package/dist/core/hook-program.js +468 -0
- package/dist/core/hook-protocol.d.ts +7 -0
- package/dist/core/hook-providers.d.ts +138 -0
- package/dist/core/hook-providers.js +155 -0
- package/dist/core/hook-spec.d.ts +74 -0
- package/dist/core/hook-spec.js +130 -0
- package/dist/core/inline.js +1 -1
- package/dist/core/mcp-tool.d.ts +12 -0
- package/dist/core/mcp-tool.js +20 -0
- package/dist/core/mcp.d.ts +13 -0
- package/dist/core/mcp.js +67 -0
- package/dist/core/spec.d.ts +290 -8
- package/dist/core/spec.js +118 -3
- package/dist/core/types.d.ts +8 -0
- package/dist/dialect-drift.d.ts +65 -0
- package/dist/dialect-drift.js +216 -0
- package/dist/eval.d.ts +40 -5
- package/dist/eval.js +59 -5
- package/dist/guardrail-check.d.ts +85 -0
- package/dist/guardrail-check.js +152 -0
- package/dist/harness-assert.d.ts +10 -0
- package/dist/harness-assert.js +30 -0
- package/dist/hook-install.d.ts +43 -0
- package/dist/hook-install.js +91 -0
- package/dist/hook.d.ts +52 -0
- package/dist/hook.js +98 -0
- package/dist/leaderboard.d.ts +6 -0
- package/dist/leaderboard.js +43 -1
- package/dist/linting.d.ts +9 -5
- package/dist/linting.js +17 -5
- package/dist/optimize.js +1 -1
- package/dist/scaffold-test.d.ts +28 -0
- package/dist/scaffold-test.js +134 -15
- package/dist/scan-behavioral.d.ts +60 -0
- package/dist/scan-behavioral.js +239 -1
- package/dist/scan.d.ts +14 -0
- package/dist/scan.js +33 -1
- package/dist/score-explainer.js +1 -1
- package/dist/self-command-refs.d.ts +21 -0
- package/dist/self-command-refs.js +125 -0
- package/dist/testing.d.ts +5 -3
- package/dist/testing.js +37 -23
- package/dist/tool-intercept.d.ts +4 -4
- package/dist/tool-intercept.js +5 -5
- package/dist/unit.d.ts +2 -0
- package/dist/unit.js +8 -1
- package/hooks/refs-nudge.sh +1 -1
- package/package.json +5 -3
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.defineReact = exports.nothing = exports.notice = exports.run = exports.defineInject = exports.inject = exports.tools = exports.HookCompileError = exports.tool = exports.ask = exports.deny = exports.allow = void 0;
|
|
4
|
+
exports.gateAction = gateAction;
|
|
5
|
+
exports.commandView = commandView;
|
|
6
|
+
exports.defineHook = defineHook;
|
|
7
|
+
exports.decideProgram = decideProgram;
|
|
8
|
+
exports.decisionExitCode = decisionExitCode;
|
|
9
|
+
exports.checkHookImports = checkHookImports;
|
|
10
|
+
exports.dispatchKind = dispatchKind;
|
|
11
|
+
exports.hookMode = hookMode;
|
|
12
|
+
exports.hookNeeds = hookNeeds;
|
|
13
|
+
exports.hookRouting = hookRouting;
|
|
14
|
+
exports.compileHookProgram = compileHookProgram;
|
|
15
|
+
exports.stampHook = stampHook;
|
|
16
|
+
exports.verifyHookStamp = verifyHookStamp;
|
|
17
|
+
exports.pathView = pathView;
|
|
18
|
+
exports.defineFileGate = defineFileGate;
|
|
19
|
+
exports.decideFileGate = decideFileGate;
|
|
20
|
+
exports.definePromptGate = definePromptGate;
|
|
21
|
+
exports.decidePromptGate = decidePromptGate;
|
|
22
|
+
exports.defineStopGate = defineStopGate;
|
|
23
|
+
exports.decideStopGate = decideStopGate;
|
|
24
|
+
exports.runInject = runInject;
|
|
25
|
+
exports.responseView = responseView;
|
|
26
|
+
exports.runReact = runReact;
|
|
27
|
+
exports.runHookProgram = runHookProgram;
|
|
28
|
+
/**
|
|
29
|
+
* Compiled hooks — a hook as a CONSTRAINED TYPED PROGRAM, not arbitrary shell.
|
|
30
|
+
*
|
|
31
|
+
* The pure core behind the public `vigiles/hook` surface (re-exported in
|
|
32
|
+
* `src/hook.ts`; compiled by `vigiles compile`, run by `vigiles hook-runtime
|
|
33
|
+
* run-program`). A hook today is opaque shell (`bash guard.sh`) —
|
|
34
|
+
* un-analyzable (Rice), and the author hand-writes the fragile parts (exit code,
|
|
35
|
+
* JSON field, a `grep` matcher) that the verified #1 pains come from. Invert it:
|
|
36
|
+
* the author writes a PURE typed function `(event) => Decision` against a CLOSED
|
|
37
|
+
* API; vigiles compiles it. The constraint ELIMINATES whole bug classes by
|
|
38
|
+
* construction and buys testability / safety / matching / portability:
|
|
39
|
+
*
|
|
40
|
+
* - TESTABILITY: `decide` is a pure fn — unit-test in-process, no subprocess, no
|
|
41
|
+
* exit-code/JSON plumbing. The false-confidence bug class (exit 1≠2, wrong field)
|
|
42
|
+
* is UNREPRESENTABLE — the author never writes the protocol; `compile` emits it.
|
|
43
|
+
* - SAFETY: capability = API surface. `checkHookImports` rejects any import outside
|
|
44
|
+
* `vigiles/hook` at compile (so the hook can't reach `child_process`/`net`), and
|
|
45
|
+
* `stampHook`/`verifyHookStamp` make the compiled artifact TAMPER-EVIDENT (the
|
|
46
|
+
* integrity.ts pattern) — a hand-edit that smuggles a capability breaks the stamp.
|
|
47
|
+
* - MATCHING: `command.runs("git push", { force })` is AST-backed (leafCommands),
|
|
48
|
+
* so it catches `cd x && git push -f` that the native `Bash(git:*)` glob (#30519)
|
|
49
|
+
* and a hand-written `grep` both miss.
|
|
50
|
+
* - PORTABILITY: one program → each harness's protocol (CC exit-2 here; Codex /
|
|
51
|
+
* OpenCode via the HookProtocol port later — OpenCode hooks ARE in-process TS).
|
|
52
|
+
*
|
|
53
|
+
* Pure core, harness-neutral. HONEST SCOPE (kept in every doc): compile/verify fix
|
|
54
|
+
* the hook's AUTHORING + LOGIC, not DELIVERY — CC's subagent-bypass (#34692) means
|
|
55
|
+
* a PreToolUse hook does not fire for a subagent's tool calls, so a gate is a strong
|
|
56
|
+
* default, never an unbypassable wall. Limits (buy-in, node-startup latency) +
|
|
57
|
+
* full record in research/hook-pain-points.md.
|
|
58
|
+
*/
|
|
59
|
+
const bash_effects_js_1 = require("./bash-effects.js");
|
|
60
|
+
const hash_js_1 = require("./hash.js");
|
|
61
|
+
const toml_1 = require("@iarna/toml");
|
|
62
|
+
const hook_events_js_1 = require("./hook-events.js");
|
|
63
|
+
const hook_providers_js_1 = require("./hook-providers.js");
|
|
64
|
+
const allow = () => ({ kind: "allow" });
|
|
65
|
+
exports.allow = allow;
|
|
66
|
+
const deny = (reason) => ({ kind: "deny", reason });
|
|
67
|
+
exports.deny = deny;
|
|
68
|
+
const ask = (reason) => ({ kind: "ask", reason });
|
|
69
|
+
exports.ask = ask;
|
|
70
|
+
/**
|
|
71
|
+
* Map a gate's {@link Decision} + {@link HookMode} to what the runtime actually does.
|
|
72
|
+
* `enforce`: deny→block (exit 2), ask→ask, allow→allow. `observe`: a deny/ask is
|
|
73
|
+
* recorded as a no-op `observe` (would-have-blocked) and allowed; allow stays allow.
|
|
74
|
+
* Pure, so a test asserts "in observe mode this deny does NOT block" with no process.
|
|
75
|
+
*/
|
|
76
|
+
function gateAction(decision, mode = "enforce") {
|
|
77
|
+
if (mode === "observe")
|
|
78
|
+
return decision.kind === "allow"
|
|
79
|
+
? { kind: "allow" }
|
|
80
|
+
: { kind: "observe", would: decision.kind, reason: decision.reason };
|
|
81
|
+
switch (decision.kind) {
|
|
82
|
+
case "deny":
|
|
83
|
+
return { kind: "block", reason: decision.reason };
|
|
84
|
+
case "ask":
|
|
85
|
+
return { kind: "ask", reason: decision.reason };
|
|
86
|
+
case "allow":
|
|
87
|
+
return { kind: "allow" };
|
|
88
|
+
default:
|
|
89
|
+
return (0, hash_js_1.assertNever)(decision);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const FORCE_FLAG = /^-(?:-force$|[a-z]*f[a-z]*$)/;
|
|
93
|
+
const hasForce = (argv) => argv.some((a) => FORCE_FLAG.test(a));
|
|
94
|
+
const SHELLS = new Set(["sh", "bash", "zsh", "dash", "ksh"]);
|
|
95
|
+
/** Does `argv` run `tokens` in order (head exact, rest present after it)? */
|
|
96
|
+
function runsSeq(argv, tokens) {
|
|
97
|
+
if (argv[0] !== tokens[0])
|
|
98
|
+
return false;
|
|
99
|
+
let i = 1;
|
|
100
|
+
for (let j = 1; j < argv.length && i < tokens.length; j++) {
|
|
101
|
+
if (argv[j] === tokens[i])
|
|
102
|
+
i++;
|
|
103
|
+
}
|
|
104
|
+
return i === tokens.length;
|
|
105
|
+
}
|
|
106
|
+
/** Does a single token name a path at/under `prefix` (boundary-aware)? */
|
|
107
|
+
function tokenUnder(token, prefix) {
|
|
108
|
+
const t = token.replace(/^\.\//, "");
|
|
109
|
+
return t === prefix || t.startsWith(prefix + "/") || t.endsWith("/" + prefix);
|
|
110
|
+
}
|
|
111
|
+
/** A leaf whose head is a shell reading stdin (no script-file argument). */
|
|
112
|
+
function isBareShellLeaf(argv) {
|
|
113
|
+
return (SHELLS.has(argv[0] ?? "") && argv.slice(1).every((a) => a.startsWith("-")));
|
|
114
|
+
}
|
|
115
|
+
function commandView(raw) {
|
|
116
|
+
const leaves = (0, bash_effects_js_1.leafCommands)(raw);
|
|
117
|
+
return {
|
|
118
|
+
raw,
|
|
119
|
+
runs(program, opts) {
|
|
120
|
+
const tokens = program.split(/\s+/).filter(Boolean);
|
|
121
|
+
return leaves.some((argv) => runsSeq(argv, tokens) && (opts?.force ? hasForce(argv) : true));
|
|
122
|
+
},
|
|
123
|
+
isSideEffecting: () => (0, bash_effects_js_1.classifyBashCommand)(raw) === "side-effecting",
|
|
124
|
+
touches: (prefixes) => leaves.some((argv) => argv.slice(1).some((tok) => prefixes.some((p) => tokenUnder(tok, p)))),
|
|
125
|
+
pipesToShell: () => leaves.some(isBareShellLeaf),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
const tool = (name) => ({ tool: name });
|
|
129
|
+
exports.tool = tool;
|
|
130
|
+
function defineHook(p) {
|
|
131
|
+
return p;
|
|
132
|
+
}
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// Run the program against a raw PreToolUse event (the runtime half)
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
/** Build the typed event from a raw PreToolUse event, then decide. */
|
|
137
|
+
function decideProgram(program, rawEvent, ctx = {}) {
|
|
138
|
+
if (rawEvent.tool_name !== program.match.tool)
|
|
139
|
+
return (0, exports.allow)();
|
|
140
|
+
const command = typeof rawEvent.tool_input?.command === "string"
|
|
141
|
+
? rawEvent.tool_input.command
|
|
142
|
+
: "";
|
|
143
|
+
return program.decide({
|
|
144
|
+
event: program.on,
|
|
145
|
+
tool: "Bash",
|
|
146
|
+
command: commandView(command),
|
|
147
|
+
ctx: ctx,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
/** Map a Decision to the CC hook exit code — the protocol the author never writes. */
|
|
151
|
+
function decisionExitCode(d) {
|
|
152
|
+
return d.kind === "deny" ? 2 : 0;
|
|
153
|
+
}
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// Compile-time safety: capability = the API surface
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
const ALLOWED_IMPORT = "vigiles/hook";
|
|
158
|
+
/**
|
|
159
|
+
* Reject any import/require outside `vigiles/hook` + textual escape hatches
|
|
160
|
+
* (eval / Function / dynamic import). The hook's capabilities are then EXACTLY the
|
|
161
|
+
* sanctioned API — it cannot reach `child_process`, `fs`, or the network. Returns
|
|
162
|
+
* the offending specifiers/constructs (empty = clean). (Probe: a regex scan; a
|
|
163
|
+
* production impl walks the TS AST.)
|
|
164
|
+
*/
|
|
165
|
+
function checkHookImports(source) {
|
|
166
|
+
const out = [];
|
|
167
|
+
const importRe = /\b(?:import|require)\s*(?:\(|[^'"]*from)?\s*['"]([^'"]+)['"]/g;
|
|
168
|
+
for (const m of source.matchAll(importRe)) {
|
|
169
|
+
if (m[1] !== ALLOWED_IMPORT)
|
|
170
|
+
out.push(m[1]);
|
|
171
|
+
}
|
|
172
|
+
if (/\beval\s*\(|\bnew\s+Function\b|\bimport\s*\(/.test(source)) {
|
|
173
|
+
out.push("dynamic-eval");
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
class HookCompileError extends Error {
|
|
178
|
+
}
|
|
179
|
+
exports.HookCompileError = HookCompileError;
|
|
180
|
+
function dispatchKind(hook) {
|
|
181
|
+
if ("role" in hook)
|
|
182
|
+
return hook.role === "gate" ? "file-gate" : hook.role;
|
|
183
|
+
return "bash-gate";
|
|
184
|
+
}
|
|
185
|
+
/** A gate's {@link HookMode} (`enforce` default); non-gate roles report `enforce` too. */
|
|
186
|
+
function hookMode(hook) {
|
|
187
|
+
return "mode" in hook && hook.mode ? hook.mode : "enforce";
|
|
188
|
+
}
|
|
189
|
+
/** The context providers a hook declared via `needs` ([] if none / not a gate). */
|
|
190
|
+
function hookNeeds(hook) {
|
|
191
|
+
return "needs" in hook && Array.isArray(hook.needs)
|
|
192
|
+
? hook.needs
|
|
193
|
+
: [];
|
|
194
|
+
}
|
|
195
|
+
/** Where a hook fires + its tool matcher (undefined for tool-less events). */
|
|
196
|
+
function hookRouting(hook) {
|
|
197
|
+
if ("role" in hook) {
|
|
198
|
+
// inject / prompt-gate / stop-gate fire on a whole EVENT — no tool matcher.
|
|
199
|
+
if (hook.role === "inject" ||
|
|
200
|
+
hook.role === "prompt-gate" ||
|
|
201
|
+
hook.role === "stop-gate")
|
|
202
|
+
return { on: hook.on };
|
|
203
|
+
return { on: hook.on, matcher: hook.match.tools.join("|") };
|
|
204
|
+
}
|
|
205
|
+
return { on: hook.on, matcher: hook.match.tool };
|
|
206
|
+
}
|
|
207
|
+
/** Apply a harness's matcher style to the neutral `A|B` matcher join. */
|
|
208
|
+
function styleMatcher(matcher, protocol) {
|
|
209
|
+
if (matcher === undefined)
|
|
210
|
+
return undefined;
|
|
211
|
+
return protocol?.matcherStyle === "regex" ? `^(${matcher})$` : matcher;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Render the settings block a user pastes into their hooks config. JSON for
|
|
215
|
+
* Claude Code (the nested `{event:[{matcher,hooks:[{type,command}]}]}` shape);
|
|
216
|
+
* TOML `[[hooks.<event>]]` with a flat `command` for Codex.
|
|
217
|
+
*/
|
|
218
|
+
function renderSettingsBlock(on, matcher, gateCommand, format) {
|
|
219
|
+
if (format === "toml") {
|
|
220
|
+
const entry = matcher === undefined
|
|
221
|
+
? { command: gateCommand }
|
|
222
|
+
: { matcher, command: gateCommand };
|
|
223
|
+
return (0, toml_1.stringify)({ hooks: { [on]: [entry] } }).trim();
|
|
224
|
+
}
|
|
225
|
+
const entry = matcher === undefined
|
|
226
|
+
? { hooks: [{ type: "command", command: gateCommand }] }
|
|
227
|
+
: { matcher, hooks: [{ type: "command", command: gateCommand }] };
|
|
228
|
+
return JSON.stringify({ hooks: { [on]: [entry] } }, null, 2);
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Compile a hook program from its source. Runs the capability check FIRST (an
|
|
232
|
+
* out-of-API import does NOT compile), validates the event against the target
|
|
233
|
+
* harness (a typo won't compile), then stamps the source so the shipped artifact
|
|
234
|
+
* is tamper-evident. The hook supplies the routing (event + matcher), which
|
|
235
|
+
* differs per role — a tool gate matches its tool, a react matches its tools, an
|
|
236
|
+
* inject (SessionStart) has no tool matcher at all.
|
|
237
|
+
*
|
|
238
|
+
* Harness-agnostic by injection: with no `opts` it emits the Claude Code block
|
|
239
|
+
* (back-compatible); pass `{ dialect, hookProtocol, settingsFormat }` from a
|
|
240
|
+
* resolved adapter and it emits that harness's block (e.g. Codex TOML + regex
|
|
241
|
+
* matcher). Core never imports an adapter — only the port interfaces.
|
|
242
|
+
*/
|
|
243
|
+
function compileHookProgram(source, hook, opts = {}) {
|
|
244
|
+
const violations = checkHookImports(source);
|
|
245
|
+
if (violations.length > 0) {
|
|
246
|
+
throw new HookCompileError(`hook program uses capabilities outside \`${ALLOWED_IMPORT}\`: ${violations.join(", ")} — only the sanctioned API is allowed (capability = API surface).`);
|
|
247
|
+
}
|
|
248
|
+
const { on, matcher: rawMatcher } = hookRouting(hook);
|
|
249
|
+
// A hook registered under an event the harness never fires is dead — reject it.
|
|
250
|
+
if (opts.dialect) {
|
|
251
|
+
const issues = (0, hook_events_js_1.verifyHookEvents)([on], opts.dialect);
|
|
252
|
+
if (issues.length > 0) {
|
|
253
|
+
throw new HookCompileError(issues[0].message);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
// A `needs` entry that isn't a built-in provider never resolves — reject it
|
|
257
|
+
// (the typo-won't-compile guarantee, for JS authors the type can't reach).
|
|
258
|
+
const needs = hookNeeds(hook);
|
|
259
|
+
const unknownNeeds = (0, hook_providers_js_1.unknownProviders)(needs, opts.registeredProviders);
|
|
260
|
+
if (unknownNeeds.length > 0) {
|
|
261
|
+
throw new HookCompileError(`unknown context provider(s): ${unknownNeeds.join(", ")} — built-ins are ${Object.keys(hook_providers_js_1.BUILTIN_PROVIDERS).join(", ")}; a provider() ref must resolve to a .vigiles/providers/ file, or use provide()/dangerously() inline (see research/hook-context-providers.md).`);
|
|
262
|
+
}
|
|
263
|
+
// An inline provide() whose command isn't provably read-only must be acknowledged.
|
|
264
|
+
const unsafe = (0, hook_providers_js_1.unsafeInlineProviders)(needs);
|
|
265
|
+
if (unsafe.length > 0) {
|
|
266
|
+
throw new HookCompileError(`inline provider(s) not provably read-only: ${unsafe
|
|
267
|
+
.map((u) => `${u.name} ("${u.run}")`)
|
|
268
|
+
.join(", ")} — use dangerously(name, cmd) to acknowledge a side-effecting/undecidable command, or keep provide() only for a read-only one.`);
|
|
269
|
+
}
|
|
270
|
+
const gateCommand = opts.gateCommand ?? "npx vigiles hook-runtime run-program";
|
|
271
|
+
const matcher = styleMatcher(rawMatcher, opts.hookProtocol);
|
|
272
|
+
const entry = matcher === undefined
|
|
273
|
+
? { hooks: [{ type: "command", command: gateCommand }] }
|
|
274
|
+
: {
|
|
275
|
+
matcher,
|
|
276
|
+
hooks: [{ type: "command", command: gateCommand }],
|
|
277
|
+
};
|
|
278
|
+
return {
|
|
279
|
+
hooks: { [on]: [entry] },
|
|
280
|
+
settingsBlock: renderSettingsBlock(on, matcher, gateCommand, opts.settingsFormat ?? "json"),
|
|
281
|
+
stamp: stampHook(source),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
/** Stamp a hook's source (the integrity.ts pattern, applied to a hook artifact). */
|
|
285
|
+
function stampHook(source) {
|
|
286
|
+
return (0, hash_js_1.sha256short)(source);
|
|
287
|
+
}
|
|
288
|
+
/** Verify a shipped hook artifact matches its stamp (tamper → false). */
|
|
289
|
+
function verifyHookStamp(source, stamp) {
|
|
290
|
+
return stampHook(source) === stamp;
|
|
291
|
+
}
|
|
292
|
+
function pathView(raw) {
|
|
293
|
+
const norm = raw.replace(/^\.\//, "");
|
|
294
|
+
return {
|
|
295
|
+
raw,
|
|
296
|
+
under: (prefixes) => prefixes.some((p) => {
|
|
297
|
+
const base = p.replace(/\/?\*+$/, "").replace(/\/$/, "");
|
|
298
|
+
return base === "" || norm === base || norm.startsWith(base + "/");
|
|
299
|
+
}),
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
const tools = (...names) => ({
|
|
303
|
+
tools: names,
|
|
304
|
+
});
|
|
305
|
+
exports.tools = tools;
|
|
306
|
+
function defineFileGate(p) {
|
|
307
|
+
return { role: "gate", ...p };
|
|
308
|
+
}
|
|
309
|
+
/** Run a file-tool gate against a raw PreToolUse event (reads `file_path`). */
|
|
310
|
+
function decideFileGate(hook, raw, ctx = {}) {
|
|
311
|
+
const t = raw.tool_name ?? "";
|
|
312
|
+
if (!hook.match.tools.includes(t))
|
|
313
|
+
return (0, exports.allow)();
|
|
314
|
+
const fp = typeof raw.tool_input?.file_path === "string"
|
|
315
|
+
? raw.tool_input.file_path
|
|
316
|
+
: "";
|
|
317
|
+
return hook.decide({
|
|
318
|
+
event: hook.on,
|
|
319
|
+
tool: t,
|
|
320
|
+
path: pathView(fp),
|
|
321
|
+
ctx: ctx,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
function definePromptGate(p) {
|
|
325
|
+
return { role: "prompt-gate", ...p };
|
|
326
|
+
}
|
|
327
|
+
/** Run a prompt gate against a raw UserPromptSubmit event (reads `prompt`). */
|
|
328
|
+
function decidePromptGate(hook, raw, ctx = {}) {
|
|
329
|
+
const prompt = typeof raw.prompt === "string" ? raw.prompt : "";
|
|
330
|
+
return hook.decide({
|
|
331
|
+
event: hook.on,
|
|
332
|
+
prompt,
|
|
333
|
+
ctx: ctx,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
function defineStopGate(p) {
|
|
337
|
+
return { role: "stop-gate", ...p };
|
|
338
|
+
}
|
|
339
|
+
/** Run a Stop gate against a raw Stop/SubagentStop event (reads `stop_hook_active`). */
|
|
340
|
+
function decideStopGate(hook, raw, ctx = {}) {
|
|
341
|
+
return hook.decide({
|
|
342
|
+
event: hook.on,
|
|
343
|
+
stopHookActive: raw.stop_hook_active === true,
|
|
344
|
+
ctx: ctx,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
const inject = (context) => ({
|
|
348
|
+
kind: "inject",
|
|
349
|
+
context,
|
|
350
|
+
});
|
|
351
|
+
exports.inject = inject;
|
|
352
|
+
const defineInject = (p) => ({
|
|
353
|
+
role: "inject",
|
|
354
|
+
...p,
|
|
355
|
+
});
|
|
356
|
+
exports.defineInject = defineInject;
|
|
357
|
+
/**
|
|
358
|
+
* Run an inject hook → the CC JSON the author never hand-writes. The compiler
|
|
359
|
+
* targets `additionalContext` (the RIGHT field for this event), so the
|
|
360
|
+
* wrong-JSON-field pain can't occur.
|
|
361
|
+
*/
|
|
362
|
+
function runInject(hook, raw) {
|
|
363
|
+
const out = hook.produce({ event: hook.on, source: raw.source ?? "startup" });
|
|
364
|
+
return {
|
|
365
|
+
hookSpecificOutput: {
|
|
366
|
+
hookEventName: hook.on,
|
|
367
|
+
additionalContext: out.context,
|
|
368
|
+
},
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
/** Normalize an arbitrary tool_response payload to text (object → JSON). */
|
|
372
|
+
function responseText(raw) {
|
|
373
|
+
if (typeof raw === "string")
|
|
374
|
+
return raw;
|
|
375
|
+
if (raw == null)
|
|
376
|
+
return "";
|
|
377
|
+
return JSON.stringify(raw);
|
|
378
|
+
}
|
|
379
|
+
function responseView(raw) {
|
|
380
|
+
const text = responseText(raw);
|
|
381
|
+
const flagged = typeof raw === "object" &&
|
|
382
|
+
raw !== null &&
|
|
383
|
+
(Boolean(raw.error) ||
|
|
384
|
+
raw.is_error === true);
|
|
385
|
+
return {
|
|
386
|
+
raw: text,
|
|
387
|
+
isError: () => flagged || /^\s*error[:\s]/i.test(text),
|
|
388
|
+
contains: (needle) => text.includes(needle),
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
/** Run a command in reaction — its effect is classified AT CONSTRUCTION (audit/diff-able). */
|
|
392
|
+
const run = (command) => ({
|
|
393
|
+
kind: "run",
|
|
394
|
+
command,
|
|
395
|
+
effect: (0, bash_effects_js_1.classifyBashCommand)(command),
|
|
396
|
+
});
|
|
397
|
+
exports.run = run;
|
|
398
|
+
/** Surface a non-blocking note (no execution). */
|
|
399
|
+
const notice = (message) => ({
|
|
400
|
+
kind: "notice",
|
|
401
|
+
message,
|
|
402
|
+
});
|
|
403
|
+
exports.notice = notice;
|
|
404
|
+
/** Do nothing. */
|
|
405
|
+
const nothing = () => ({ kind: "none" });
|
|
406
|
+
exports.nothing = nothing;
|
|
407
|
+
const defineReact = (p) => ({
|
|
408
|
+
role: "react",
|
|
409
|
+
...p,
|
|
410
|
+
});
|
|
411
|
+
exports.defineReact = defineReact;
|
|
412
|
+
/** Run a react hook against a raw PostToolUse event → the (classified) Reaction. */
|
|
413
|
+
function runReact(hook, raw) {
|
|
414
|
+
const t = raw.tool_name ?? "";
|
|
415
|
+
if (!hook.match.tools.includes(t))
|
|
416
|
+
return (0, exports.nothing)();
|
|
417
|
+
const fp = typeof raw.tool_input?.file_path === "string"
|
|
418
|
+
? raw.tool_input.file_path
|
|
419
|
+
: "";
|
|
420
|
+
return hook.react({
|
|
421
|
+
event: hook.on,
|
|
422
|
+
tool: t,
|
|
423
|
+
path: pathView(fp),
|
|
424
|
+
response: responseView(raw.tool_response),
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Evaluate a compiled hook against a raw event, in-process, dispatching by role:
|
|
429
|
+
* a gate → its `Decision`, an inject → the injected context text, a react → its
|
|
430
|
+
* (effect-classified) `Reaction`. Pure — no subprocess, no model. The ergonomic
|
|
431
|
+
* base for testing a compiled hook (see `assertHookDenies` / `assertHookAllows`).
|
|
432
|
+
*/
|
|
433
|
+
function runHookProgram(hook, event, ctx = {}) {
|
|
434
|
+
const kind = dispatchKind(hook);
|
|
435
|
+
switch (kind) {
|
|
436
|
+
case "bash-gate":
|
|
437
|
+
return {
|
|
438
|
+
kind: "decision",
|
|
439
|
+
decision: decideProgram(hook, event, ctx),
|
|
440
|
+
};
|
|
441
|
+
case "file-gate":
|
|
442
|
+
return {
|
|
443
|
+
kind: "decision",
|
|
444
|
+
decision: decideFileGate(hook, event, ctx),
|
|
445
|
+
};
|
|
446
|
+
case "prompt-gate":
|
|
447
|
+
return {
|
|
448
|
+
kind: "decision",
|
|
449
|
+
decision: decidePromptGate(hook, event, ctx),
|
|
450
|
+
};
|
|
451
|
+
case "stop-gate":
|
|
452
|
+
return {
|
|
453
|
+
kind: "decision",
|
|
454
|
+
decision: decideStopGate(hook, event, ctx),
|
|
455
|
+
};
|
|
456
|
+
case "inject":
|
|
457
|
+
return {
|
|
458
|
+
kind: "injection",
|
|
459
|
+
context: runInject(hook, event).hookSpecificOutput
|
|
460
|
+
.additionalContext,
|
|
461
|
+
};
|
|
462
|
+
case "react":
|
|
463
|
+
return { kind: "reaction", reaction: runReact(hook, event) };
|
|
464
|
+
default:
|
|
465
|
+
return (0, hash_js_1.assertNever)(kind);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
//# sourceMappingURL=hook-program.js.map
|
|
@@ -24,5 +24,12 @@ export interface HookProtocol {
|
|
|
24
24
|
* Code passes the event on stdin only; Codex adds session_id/cwd/PLUGIN_ROOT/…).
|
|
25
25
|
*/
|
|
26
26
|
readonly eventEnvVars: readonly string[];
|
|
27
|
+
/**
|
|
28
|
+
* How a tool matcher is written in the emitted hooks block — `"exact"` (Claude
|
|
29
|
+
* Code: the tool name / `A|B` alternation) or `"regex"` (Codex: an anchored
|
|
30
|
+
* regex `^(A|B)$`). Optional (additive, non-breaking) — absent ⇒ `"exact"`.
|
|
31
|
+
* Used by `compileHookProgram` when rendering the settings block.
|
|
32
|
+
*/
|
|
33
|
+
readonly matcherStyle?: "exact" | "regex";
|
|
27
34
|
}
|
|
28
35
|
//# sourceMappingURL=hook-protocol.d.ts.map
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/** The closed set of built-in facts a gate may declare via `needs`, with value types. */
|
|
2
|
+
export interface ProviderResults {
|
|
3
|
+
/** The current git branch, or "" outside a git repo / on an unborn HEAD. */
|
|
4
|
+
readonly "git.branch": string;
|
|
5
|
+
/** True iff the working tree has uncommitted changes (false if not a repo). */
|
|
6
|
+
readonly "git.isDirty": boolean;
|
|
7
|
+
/** The repo's top-level directory, or "" outside a git repo. */
|
|
8
|
+
readonly "git.root": string;
|
|
9
|
+
/** The directory the hook runs in. */
|
|
10
|
+
readonly cwd: string;
|
|
11
|
+
/** The OS platform (`process.platform`: "darwin" | "linux" | "win32" | …). */
|
|
12
|
+
readonly "os.platform": NodeJS.Platform;
|
|
13
|
+
/** True iff running on a CI server (detected via the `ci-info` library). */
|
|
14
|
+
readonly "env.isCI": boolean;
|
|
15
|
+
}
|
|
16
|
+
/** A declarable built-in provider name. */
|
|
17
|
+
export type ProviderName = keyof ProviderResults;
|
|
18
|
+
/**
|
|
19
|
+
* An INLINE one-off provider: a command declared right in `needs` (no registered
|
|
20
|
+
* file), run by the trusted runtime, its stdout becoming `e.ctx[name]` (a string).
|
|
21
|
+
* `dangerous` marks the loud escape — a command not provably read-only. Built via
|
|
22
|
+
* {@link provide} (read-only) or {@link dangerously} (acknowledged).
|
|
23
|
+
*/
|
|
24
|
+
export interface InlineProvider<Name extends string = string> {
|
|
25
|
+
readonly kind: "inline";
|
|
26
|
+
readonly name: Name;
|
|
27
|
+
readonly run: string;
|
|
28
|
+
readonly dangerous: boolean;
|
|
29
|
+
}
|
|
30
|
+
/** A `needs` entry — a built-in name, an inline `provide`/`dangerously`, or a `provider()` ref. */
|
|
31
|
+
export type NeedSpec = ProviderName | InlineProvider | RegisteredRef;
|
|
32
|
+
/**
|
|
33
|
+
* Declare an INLINE read-only fact: `provide("k8sCtx", "kubectl config current-context")`.
|
|
34
|
+
* The command MUST be provably read-only (compile rejects it otherwise — use
|
|
35
|
+
* {@link dangerously} to acknowledge a side-effecting/undecidable one). Its stdout
|
|
36
|
+
* is `e.ctx[name]`.
|
|
37
|
+
*/
|
|
38
|
+
export declare const provide: <const Name extends string>(name: Name, run: string) => InlineProvider<Name>;
|
|
39
|
+
/**
|
|
40
|
+
* Declare an INLINE fact whose command ISN'T provably read-only — the loud,
|
|
41
|
+
* greppable escape hatch (the `dangerouslySetInnerHTML` / `unsafe` / `http.send`
|
|
42
|
+
* convention; sibling of `purity:'dangerously-unrestricted'`). A security review
|
|
43
|
+
* searches for this one word.
|
|
44
|
+
*/
|
|
45
|
+
export declare const dangerously: <const Name extends string>(name: Name, run: string) => InlineProvider<Name>;
|
|
46
|
+
/**
|
|
47
|
+
* A REGISTERED provider — a reusable, named fact authored once in
|
|
48
|
+
* `.vigiles/providers/<name>.{mjs,ts}` (default-exported via {@link defineProvider})
|
|
49
|
+
* and referenced from many hooks by {@link provider} name. The reusable sibling of
|
|
50
|
+
* the one-off inline {@link provide}; same read-only-by-default rule.
|
|
51
|
+
*/
|
|
52
|
+
export interface RegisteredProvider<Name extends string = string> {
|
|
53
|
+
readonly kind: "provider-def";
|
|
54
|
+
readonly name: Name;
|
|
55
|
+
readonly run: string;
|
|
56
|
+
readonly dangerous: boolean;
|
|
57
|
+
}
|
|
58
|
+
/** Author a registered provider: `export default defineProvider({ name, run })`. */
|
|
59
|
+
export declare const defineProvider: <const Name extends string>(p: {
|
|
60
|
+
readonly name: Name;
|
|
61
|
+
readonly run: string;
|
|
62
|
+
/** Acknowledge a command that isn't provably read-only (the loud escape). */
|
|
63
|
+
readonly dangerous?: boolean;
|
|
64
|
+
}) => RegisteredProvider<Name>;
|
|
65
|
+
/** A reference to a registered provider, used in a hook's `needs`: `provider("myFact")`. */
|
|
66
|
+
export interface RegisteredRef<Name extends string = string> {
|
|
67
|
+
readonly kind: "provider-ref";
|
|
68
|
+
readonly name: Name;
|
|
69
|
+
}
|
|
70
|
+
/** Reference a registered provider (from `.vigiles/providers/`) by name in `needs`. */
|
|
71
|
+
export declare const provider: <const Name extends string>(name: Name) => RegisteredRef<Name>;
|
|
72
|
+
/** name → its registered provider; the runtime resolves a `provider(name)` ref against this. */
|
|
73
|
+
export type ProviderRegistry = Record<string, RegisteredProvider>;
|
|
74
|
+
type NeedName<E extends NeedSpec> = E extends ProviderName ? E : E extends InlineProvider<infer Nm> ? Nm : E extends RegisteredRef<infer Rn> ? Rn : never;
|
|
75
|
+
type NeedValue<E extends NeedSpec> = E extends ProviderName ? ProviderResults[E] : string;
|
|
76
|
+
/**
|
|
77
|
+
* The typed `e.ctx` for a hook that declared `needs: N` — ONLY the declared facts
|
|
78
|
+
* are present (built-in name → its typed value, inline → string), so reading an
|
|
79
|
+
* undeclared one is a `tsc` error (the typed-purity trick). With `N = readonly []`
|
|
80
|
+
* the ctx is empty. The default `readonly ProviderName[]` is the erased runtime
|
|
81
|
+
* shape (built-ins only — an inline `string` name would force an index signature
|
|
82
|
+
* that clashes with `git.isDirty: boolean`; concrete authored inline names are
|
|
83
|
+
* literals, so they never clash).
|
|
84
|
+
*/
|
|
85
|
+
export type HookCtx<N extends readonly NeedSpec[] = readonly ProviderName[]> = {
|
|
86
|
+
readonly [E in N[number] as NeedName<E>]: NeedValue<E>;
|
|
87
|
+
};
|
|
88
|
+
/** The read-only capabilities a provider may use to gather its fact. */
|
|
89
|
+
export interface ProviderIO {
|
|
90
|
+
/** Run a read-only command, return stdout (throws on non-zero exit). */
|
|
91
|
+
readonly exec: (command: string) => string;
|
|
92
|
+
/** The hook's working directory. */
|
|
93
|
+
readonly cwd: string;
|
|
94
|
+
/** The OS platform (`process.platform`). */
|
|
95
|
+
readonly platform: NodeJS.Platform;
|
|
96
|
+
/** Whether the process is on a CI server (the CLI injects `ci-info`'s verdict). */
|
|
97
|
+
readonly isCI: boolean;
|
|
98
|
+
}
|
|
99
|
+
interface ProviderDef<K extends ProviderName> {
|
|
100
|
+
/**
|
|
101
|
+
* The read-only command the provider runs (for audit + the read-only
|
|
102
|
+
* soundness test). Absent for an ambient fact gathered without a subprocess.
|
|
103
|
+
*/
|
|
104
|
+
readonly run?: string;
|
|
105
|
+
/** Produce the fact; TOTAL — returns a sensible default on failure, never throws. */
|
|
106
|
+
readonly gather: (io: ProviderIO) => ProviderResults[K];
|
|
107
|
+
}
|
|
108
|
+
/** The closed built-in registry. Each `run` is provably read-only (see the test). */
|
|
109
|
+
export declare const BUILTIN_PROVIDERS: {
|
|
110
|
+
readonly [K in ProviderName]: ProviderDef<K>;
|
|
111
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* `needs` entries that don't resolve: a built-in NAME that isn't a built-in (a
|
|
114
|
+
* typo), or a `provider()` ref whose name isn't in `registeredNames` (a dangling
|
|
115
|
+
* ref). Inline `provide`/`dangerously` are always self-defined, so never flagged.
|
|
116
|
+
*/
|
|
117
|
+
export declare function unknownProviders(needs: readonly NeedSpec[], registeredNames?: readonly string[]): string[];
|
|
118
|
+
/**
|
|
119
|
+
* Inline `provide()` entries whose command ISN'T provably read-only — these must
|
|
120
|
+
* switch to `dangerously()` (the acknowledged escape) or compile is rejected. A
|
|
121
|
+
* `dangerously()` entry is never flagged (it already acknowledged the risk).
|
|
122
|
+
*/
|
|
123
|
+
export declare function unsafeInlineProviders(needs: readonly NeedSpec[]): InlineProvider[];
|
|
124
|
+
/**
|
|
125
|
+
* Is a REGISTERED provider's command unsafe (not read-only and not acknowledged
|
|
126
|
+
* `dangerous`)? Reused when compiling a `.vigiles/providers/` file.
|
|
127
|
+
*/
|
|
128
|
+
export declare function unsafeProvider(def: RegisteredProvider): boolean;
|
|
129
|
+
/**
|
|
130
|
+
* Gather the DECLARED facts into a context object (the trusted-host step). Only
|
|
131
|
+
* the names in `needs` are gathered, each at most once; a built-in, inline, or
|
|
132
|
+
* registered provider that can't resolve yields its default ("" / false), never
|
|
133
|
+
* throws. Pure over the injected `io` (CLI passes a real execSync; tests a fake)
|
|
134
|
+
* and the `registry` (the loaded `.vigiles/providers/`, for `provider()` refs).
|
|
135
|
+
*/
|
|
136
|
+
export declare function gatherContext(needs: readonly NeedSpec[], io: ProviderIO, registry?: ProviderRegistry): Record<string, string | boolean>;
|
|
137
|
+
export {};
|
|
138
|
+
//# sourceMappingURL=hook-providers.d.ts.map
|