vigiles 6.0.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.
Files changed (65) hide show
  1. package/README.md +78 -68
  2. package/dist/action-gate.js +1 -1
  3. package/dist/adapters/claude-code/agent-runtime.d.ts +46 -11
  4. package/dist/adapters/claude-code/agent-runtime.js +95 -24
  5. package/dist/adapters/claude-code/effect-region.js +1 -1
  6. package/dist/adapters/claude-code/skill-runtime.d.ts +1 -1
  7. package/dist/adapters/claude-code/skill-runtime.js +1 -1
  8. package/dist/adapters/codex/hook-protocol.js +3 -0
  9. package/dist/adapters/codex/mock-model.js +1 -1
  10. package/dist/cli-commands.d.ts +19 -0
  11. package/dist/cli-commands.js +51 -0
  12. package/dist/cli.js +599 -86
  13. package/dist/core/bash-effects.d.ts +12 -0
  14. package/dist/core/bash-effects.js +31 -0
  15. package/dist/core/capability-diff.d.ts +46 -0
  16. package/dist/core/capability-diff.js +97 -0
  17. package/dist/core/guards.d.ts +126 -0
  18. package/dist/core/guards.js +309 -0
  19. package/dist/core/harness-driver.d.ts +1 -1
  20. package/dist/core/hook-program.d.ts +459 -0
  21. package/dist/core/hook-program.js +468 -0
  22. package/dist/core/hook-protocol.d.ts +7 -0
  23. package/dist/core/hook-providers.d.ts +138 -0
  24. package/dist/core/hook-providers.js +155 -0
  25. package/dist/core/hook-spec.d.ts +74 -0
  26. package/dist/core/hook-spec.js +130 -0
  27. package/dist/core/inline.js +1 -1
  28. package/dist/core/mcp-tool.d.ts +12 -0
  29. package/dist/core/mcp-tool.js +20 -0
  30. package/dist/core/mcp.d.ts +13 -0
  31. package/dist/core/mcp.js +67 -0
  32. package/dist/core/types.d.ts +8 -0
  33. package/dist/dialect-drift.d.ts +65 -0
  34. package/dist/dialect-drift.js +216 -0
  35. package/dist/eval.d.ts +40 -5
  36. package/dist/eval.js +59 -5
  37. package/dist/guardrail-check.d.ts +85 -0
  38. package/dist/guardrail-check.js +152 -0
  39. package/dist/harness-assert.d.ts +10 -0
  40. package/dist/harness-assert.js +30 -0
  41. package/dist/hook-install.d.ts +43 -0
  42. package/dist/hook-install.js +91 -0
  43. package/dist/hook.d.ts +52 -0
  44. package/dist/hook.js +98 -0
  45. package/dist/leaderboard.d.ts +6 -0
  46. package/dist/leaderboard.js +43 -1
  47. package/dist/linting.d.ts +9 -5
  48. package/dist/linting.js +17 -5
  49. package/dist/optimize.js +1 -1
  50. package/dist/scaffold-test.js +21 -7
  51. package/dist/scan-behavioral.d.ts +60 -0
  52. package/dist/scan-behavioral.js +239 -1
  53. package/dist/scan.d.ts +14 -0
  54. package/dist/scan.js +33 -1
  55. package/dist/score-explainer.js +1 -1
  56. package/dist/self-command-refs.d.ts +21 -0
  57. package/dist/self-command-refs.js +125 -0
  58. package/dist/testing.d.ts +5 -3
  59. package/dist/testing.js +37 -23
  60. package/dist/tool-intercept.d.ts +4 -4
  61. package/dist/tool-intercept.js +5 -5
  62. package/dist/unit.d.ts +2 -0
  63. package/dist/unit.js +8 -1
  64. package/hooks/refs-nudge.sh +1 -1
  65. package/package.json +5 -3
@@ -38,4 +38,16 @@ export declare function classifyBashCommand(command: string): BashEffect;
38
38
  * caller uses to decide "this Bash is provably an observation."
39
39
  */
40
40
  export declare function isReadOnlyBash(command: string): boolean;
41
+ /**
42
+ * Extract the static argv of every simple command (CallExpr) in `command`, each
43
+ * as an array of literal words (dynamic / quoted-interpolated words are dropped).
44
+ * AST-backed, so a leaf nested in a pipeline, `&&`/`;`/`|`, a subshell, or a
45
+ * compound command is still found — the structural query a robust matcher needs
46
+ * (a regex over the raw string misses `cd x && git push`). Parse failure → [].
47
+ *
48
+ * This is the matching primitive a typed hook's `command.runs("git push")` is
49
+ * built on: it sees the real `git push` leaf however it's wrapped, which the
50
+ * native `Bash(git:*)` glob (issue #30519) and a hand-written `grep` both miss.
51
+ */
52
+ export declare function leafCommands(command: string): string[][];
41
53
  //# sourceMappingURL=bash-effects.d.ts.map
@@ -26,6 +26,7 @@
26
26
  Object.defineProperty(exports, "__esModule", { value: true });
27
27
  exports.classifyBashCommand = classifyBashCommand;
28
28
  exports.isReadOnlyBash = isReadOnlyBash;
29
+ exports.leafCommands = leafCommands;
29
30
  // mvdan-sh is a CJS package (GopherJS build) with no bundled TypeScript types.
30
31
  // The project compiles to CommonJS (Node16, no "type":"module"), so plain
31
32
  // require() works and is the idiomatic pattern here (see linters.ts).
@@ -402,4 +403,34 @@ function classifyBashCommand(command) {
402
403
  function isReadOnlyBash(command) {
403
404
  return classifyBashCommand(command) === "read-only";
404
405
  }
406
+ /**
407
+ * Extract the static argv of every simple command (CallExpr) in `command`, each
408
+ * as an array of literal words (dynamic / quoted-interpolated words are dropped).
409
+ * AST-backed, so a leaf nested in a pipeline, `&&`/`;`/`|`, a subshell, or a
410
+ * compound command is still found — the structural query a robust matcher needs
411
+ * (a regex over the raw string misses `cd x && git push`). Parse failure → [].
412
+ *
413
+ * This is the matching primitive a typed hook's `command.runs("git push")` is
414
+ * built on: it sees the real `git push` leaf however it's wrapped, which the
415
+ * native `Bash(git:*)` glob (issue #30519) and a hand-written `grep` both miss.
416
+ */
417
+ function leafCommands(command) {
418
+ let file;
419
+ try {
420
+ file = sh.syntax.NewParser().Parse(command, "cmd.sh");
421
+ }
422
+ catch {
423
+ return [];
424
+ }
425
+ const out = [];
426
+ sh.syntax.Walk(file, (node) => {
427
+ if (sh.syntax.NodeType(node) === "CallExpr" && node.Args) {
428
+ const argv = node.Args.map((w) => getLiteral(w)).filter((s) => s !== null);
429
+ if (argv.length > 0)
430
+ out.push(argv);
431
+ }
432
+ return true;
433
+ });
434
+ return out;
435
+ }
405
436
  //# sourceMappingURL=bash-effects.js.map
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Capability-diff — "did this change widen the agent's blast radius?" (moat #2).
3
+ *
4
+ * The whole-harness capability lattice ({@link HarnessCapabilities}, computed by
5
+ * `computeHarnessCapabilities`) is the set of effects an agent/harness can reach:
6
+ * read-only tools, side-effecting tools, unknown/MCP tools, and the loosest purity.
7
+ * Diffing two lattices (a PR's base vs head) yields a deterministic, model-free
8
+ * verdict: a change WIDENED the blast radius iff it adds a side-effecting or
9
+ * unknown/MCP tool, or loosens the purity floor. New read-only tools and removals
10
+ * are reported but are NOT a widening.
11
+ *
12
+ * Honesty / don't-cry-wolf: a widening is INFORMATIONAL by default (a PR comment),
13
+ * not an automatic failure — widening the surface is often intended. The CLI gates
14
+ * a non-zero exit behind an explicit `--fail-on-widen`. Pure + harness-agnostic
15
+ * (operates on the lattice, no dialect needed). See research/typed-spec-moat.md (#2).
16
+ */
17
+ import type { HarnessCapabilities } from "./generate-harness.js";
18
+ import type { PurityLevel } from "./effects.js";
19
+ /** A purity move between two lattices (omitted when unchanged). */
20
+ export interface PurityChange {
21
+ readonly from: PurityLevel;
22
+ readonly to: PurityLevel;
23
+ /** `widened` = loosened (pure→bounded→unrestricted); `narrowed` = tightened. */
24
+ readonly direction: "widened" | "narrowed";
25
+ }
26
+ export interface CapabilityDiff {
27
+ /** Side-effecting tools reachable AFTER but not before — the core blast-radius growth. */
28
+ readonly addedSideEffecting: readonly string[];
29
+ /** Unknown-effect (MCP / unrecognized) tools newly reachable — also widened reach. */
30
+ readonly addedUnknown: readonly string[];
31
+ /** Read-only tools newly reachable — benign (reported, NOT a widening). */
32
+ readonly addedReadOnly: readonly string[];
33
+ /** Tools reachable before but not after — a NARROWING (good; informational). */
34
+ readonly removed: readonly string[];
35
+ /** The purity move, or null when unchanged. */
36
+ readonly purity: PurityChange | null;
37
+ /** The verdict: did the blast radius GROW (new side-effecting/unknown, or purity loosened)? */
38
+ readonly widened: boolean;
39
+ }
40
+ /** Diff two capability lattices → what changed + the widened verdict. Pure. */
41
+ export declare function diffCapabilities(before: HarnessCapabilities, after: HarnessCapabilities): CapabilityDiff;
42
+ /** True when the diff carries no change at all (the common, quiet case). */
43
+ export declare function isNoOpDiff(d: CapabilityDiff): boolean;
44
+ /** Render a capability-diff as a PR-comment-style report (Markdown-friendly). */
45
+ export declare function formatCapabilityDiff(d: CapabilityDiff): string;
46
+ //# sourceMappingURL=capability-diff.d.ts.map
@@ -0,0 +1,97 @@
1
+ "use strict";
2
+ /**
3
+ * Capability-diff — "did this change widen the agent's blast radius?" (moat #2).
4
+ *
5
+ * The whole-harness capability lattice ({@link HarnessCapabilities}, computed by
6
+ * `computeHarnessCapabilities`) is the set of effects an agent/harness can reach:
7
+ * read-only tools, side-effecting tools, unknown/MCP tools, and the loosest purity.
8
+ * Diffing two lattices (a PR's base vs head) yields a deterministic, model-free
9
+ * verdict: a change WIDENED the blast radius iff it adds a side-effecting or
10
+ * unknown/MCP tool, or loosens the purity floor. New read-only tools and removals
11
+ * are reported but are NOT a widening.
12
+ *
13
+ * Honesty / don't-cry-wolf: a widening is INFORMATIONAL by default (a PR comment),
14
+ * not an automatic failure — widening the surface is often intended. The CLI gates
15
+ * a non-zero exit behind an explicit `--fail-on-widen`. Pure + harness-agnostic
16
+ * (operates on the lattice, no dialect needed). See research/typed-spec-moat.md (#2).
17
+ */
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.diffCapabilities = diffCapabilities;
20
+ exports.isNoOpDiff = isNoOpDiff;
21
+ exports.formatCapabilityDiff = formatCapabilityDiff;
22
+ const PURITY_RANK = {
23
+ pure: 0,
24
+ bounded: 1,
25
+ unrestricted: 2,
26
+ };
27
+ const addedIn = (before, after) => after.filter((x) => !before.includes(x));
28
+ /** Diff two capability lattices → what changed + the widened verdict. Pure. */
29
+ function diffCapabilities(before, after) {
30
+ const addedSideEffecting = addedIn(before.sideEffecting, after.sideEffecting);
31
+ const addedUnknown = addedIn(before.unknown, after.unknown);
32
+ const addedReadOnly = addedIn(before.readOnly, after.readOnly);
33
+ // A tool is "removed" if it was reachable in ANY bucket before and in NONE after.
34
+ const afterAll = new Set([
35
+ ...after.readOnly,
36
+ ...after.sideEffecting,
37
+ ...after.unknown,
38
+ ]);
39
+ const removed = [
40
+ ...before.readOnly,
41
+ ...before.sideEffecting,
42
+ ...before.unknown,
43
+ ].filter((x) => !afterAll.has(x));
44
+ const fromRank = PURITY_RANK[before.purity];
45
+ const toRank = PURITY_RANK[after.purity];
46
+ const purity = fromRank === toRank
47
+ ? null
48
+ : {
49
+ from: before.purity,
50
+ to: after.purity,
51
+ direction: toRank > fromRank ? "widened" : "narrowed",
52
+ };
53
+ const widened = addedSideEffecting.length > 0 ||
54
+ addedUnknown.length > 0 ||
55
+ purity?.direction === "widened";
56
+ return {
57
+ addedSideEffecting,
58
+ addedUnknown,
59
+ addedReadOnly,
60
+ removed,
61
+ purity,
62
+ widened,
63
+ };
64
+ }
65
+ /** True when the diff carries no change at all (the common, quiet case). */
66
+ function isNoOpDiff(d) {
67
+ return (d.addedSideEffecting.length === 0 &&
68
+ d.addedUnknown.length === 0 &&
69
+ d.addedReadOnly.length === 0 &&
70
+ d.removed.length === 0 &&
71
+ d.purity === null);
72
+ }
73
+ /** Render a capability-diff as a PR-comment-style report (Markdown-friendly). */
74
+ function formatCapabilityDiff(d) {
75
+ if (isNoOpDiff(d)) {
76
+ return "Capability surface unchanged — no blast-radius change.";
77
+ }
78
+ const lines = [
79
+ d.widened
80
+ ? "⚠️ Capability surface **WIDENED** — this change grows the agent's blast radius:"
81
+ : "Capability surface changed (no widening — narrowing / read-only only):",
82
+ ];
83
+ if (d.addedSideEffecting.length > 0)
84
+ lines.push(` + side-effecting: ${d.addedSideEffecting.join(", ")}`);
85
+ if (d.addedUnknown.length > 0)
86
+ lines.push(` + unknown/MCP: ${d.addedUnknown.join(", ")}`);
87
+ if (d.purity?.direction === "widened")
88
+ lines.push(` + purity loosened: ${d.purity.from} → ${d.purity.to}`);
89
+ if (d.addedReadOnly.length > 0)
90
+ lines.push(` · read-only added (benign): ${d.addedReadOnly.join(", ")}`);
91
+ if (d.removed.length > 0)
92
+ lines.push(` − narrowed (removed): ${d.removed.join(", ")}`);
93
+ if (d.purity?.direction === "narrowed")
94
+ lines.push(` − purity tightened: ${d.purity.from} → ${d.purity.to}`);
95
+ return lines.join("\n");
96
+ }
97
+ //# sourceMappingURL=capability-diff.js.map
@@ -0,0 +1,126 @@
1
+ /**
2
+ * EXPERIMENTAL prototype — typed, safe-by-construction harness GUARDS.
3
+ *
4
+ * Dogfood finding (real OSS hooks: our pre-edit.sh, superpowers/OMC session-start,
5
+ * OMC keyword-detector): a "hook" today is an arbitrary shell command in
6
+ * settings.json — which is BOTH the enforcement vehicle AND the RCE footgun
7
+ * (CVE-2025-59536: a malicious repo's hook runs before the trust dialog). Arbitrary
8
+ * hook safety is UNDECIDABLE (Rice). So this prototype inverts it: you don't WRITE a
9
+ * hook, you DECLARE a guard from a closed, audited vocabulary, and vigiles GENERATES
10
+ * the hooks block — whose command is vigiles's OWN gate (`vigiles hook-runtime guard`), never
11
+ * user shell. Safe-by-construction, not safe-by-analysis.
12
+ *
13
+ * Covers the real PreToolUse patterns + the new ORDER axis:
14
+ * - block: deny a tool call matching args (reproduces pre-edit.sh's intent)
15
+ * - requireBefore: ORDER — deny a tool call until a prerequisite call has fired
16
+ * (`terraform destroy` only after `terraform plan`; the moat)
17
+ * - confine: deny a path-taking tool whose path escapes an allowlist (rm -rf /)
18
+ *
19
+ * EXPERIMENTAL — not on the public API. But the gate now RUNS end-to-end: the
20
+ * `vigiles hook-runtime guard` CLI subcommand reads the live PreToolUse event, loads the
21
+ * guard set (`.vigiles/guards.json`) + the session ledger (`.vigiles/guard-ledger.json`,
22
+ * the reconstructed prior-call list `requireBefore` needs), runs `decideGuards`, and
23
+ * blocks (exit 2 + reason) or records-the-allowed-call (so the next call sees it). The
24
+ * pure `decideGuards` is the decision; `compileGuards` is the generator; the
25
+ * serialization + ledger below are the IO seam the CLI calls.
26
+ * See research/harness-protocol-flow-moat.md.
27
+ */
28
+ import { type ArgMatcher } from "../arg-match.js";
29
+ /** A tool-call shape: a tool name + an optional argument matcher (the `when`). */
30
+ export interface ToolPattern {
31
+ readonly tool: string;
32
+ readonly when?: ArgMatcher;
33
+ }
34
+ export type Guard = {
35
+ readonly kind: "block";
36
+ readonly target: ToolPattern;
37
+ readonly reason: string;
38
+ } | {
39
+ readonly kind: "requireBefore";
40
+ readonly target: ToolPattern;
41
+ /** The prerequisite call that must have fired earlier this session. */
42
+ readonly prerequisite: ToolPattern;
43
+ readonly reason?: string;
44
+ } | {
45
+ readonly kind: "confine";
46
+ readonly tools: readonly string[];
47
+ /** Dot-path to the path argument (default `file_path`). */
48
+ readonly pathKey?: string;
49
+ /** Allowed path prefixes (a call outside ALL of them is denied). */
50
+ readonly allow: readonly string[];
51
+ readonly reason?: string;
52
+ };
53
+ /** Ergonomic builders for the closed vocabulary. */
54
+ export declare const guard: {
55
+ block: (target: ToolPattern, reason: string) => Guard;
56
+ requireBefore: (target: ToolPattern, prerequisite: ToolPattern, reason?: string) => Guard;
57
+ confine: (tools: readonly string[], allow: readonly string[], reason?: string, pathKey?: string) => Guard;
58
+ };
59
+ /** A tool call as seen at PreToolUse (and as recorded in the prior-call ledger). */
60
+ export interface ToolEvent {
61
+ readonly tool: string;
62
+ readonly input: unknown;
63
+ }
64
+ export interface GuardDecision {
65
+ readonly allow: boolean;
66
+ /** Set on a deny — the message surfaced to the agent. */
67
+ readonly reason?: string;
68
+ }
69
+ /**
70
+ * The pure runtime gate: decide allow/deny for `event`, given the guards and the
71
+ * calls that already fired this session (`prior`, oldest-first — the ledger the
72
+ * `guard-hook` CLI reconstructs from the transcript). First match wins a deny.
73
+ */
74
+ export declare function decideGuards(guards: readonly Guard[], event: ToolEvent, prior?: readonly ToolEvent[]): GuardDecision;
75
+ /** Every tool name a guard set gates (the PreToolUse matcher union). */
76
+ export declare function guardedTools(guards: readonly Guard[]): string[];
77
+ /** A Claude Code settings `hooks` block (the generated artifact). */
78
+ export interface HooksConfig {
79
+ readonly hooks: {
80
+ readonly PreToolUse: readonly {
81
+ readonly matcher: string;
82
+ readonly hooks: readonly {
83
+ readonly type: "command";
84
+ readonly command: string;
85
+ }[];
86
+ }[];
87
+ };
88
+ }
89
+ /**
90
+ * Generate the hooks block for a guard set. The command is vigiles's OWN gate
91
+ * (default `npx vigiles hook-runtime guard`), NOT user shell — so the generated
92
+ * enforcement is safe-by-construction and a repo can't smuggle an arbitrary RCE
93
+ * hook. The gate reads the same guard set + the live event and runs `decideGuards`.
94
+ */
95
+ export declare function compileGuards(guards: readonly Guard[], gateCommand?: string): HooksConfig;
96
+ /** Serialize a guard set for `.vigiles/guards.json` (RegExp matchers preserved). */
97
+ export declare function serializeGuards(guards: readonly Guard[]): string;
98
+ /** Parse a guard set from JSON (tolerant — a malformed guard is skipped). */
99
+ export declare function parseGuards(json: string): Guard[];
100
+ /** Load the declared guard set from `.vigiles/guards.json` (absent → none). */
101
+ export declare function loadGuards(cwd: string): Guard[];
102
+ /**
103
+ * The prior-call ledger — the calls already allowed this session, oldest-first.
104
+ * `requireBefore` reads it to know whether a prerequisite ran. Claude Code doesn't
105
+ * surface call history to a hook, so vigiles records each allowed call itself
106
+ * (mirrors `.vigiles/active-agent.json`).
107
+ */
108
+ export declare function readGuardLedger(cwd: string): ToolEvent[];
109
+ /** Append an allowed call to the session ledger. */
110
+ export declare function recordGuardCall(cwd: string, event: ToolEvent): void;
111
+ /** Parse a PreToolUse event JSON (the hook's stdin) into a {@link ToolEvent}. */
112
+ export declare function parseGuardEvent(rawJson: string): ToolEvent | null;
113
+ /** The outcome of running the gate against one event. */
114
+ export interface GuardHookOutcome {
115
+ readonly decision: GuardDecision;
116
+ /** True iff the allowed call was recorded to the ledger. */
117
+ readonly recorded: boolean;
118
+ }
119
+ /**
120
+ * The runnable gate, decoupled from process/exit so it's testable. Decides the
121
+ * event against the loaded guards + the prior-call ledger; on ALLOW it records the
122
+ * call (so a later `requireBefore` sees it) and on DENY it records nothing (a
123
+ * blocked call never happened). Malformed/absent event → allow, record nothing.
124
+ */
125
+ export declare function runGuardHook(cwd: string, rawJson: string): GuardHookOutcome;
126
+ //# sourceMappingURL=guards.d.ts.map
@@ -0,0 +1,309 @@
1
+ "use strict";
2
+ /**
3
+ * EXPERIMENTAL prototype — typed, safe-by-construction harness GUARDS.
4
+ *
5
+ * Dogfood finding (real OSS hooks: our pre-edit.sh, superpowers/OMC session-start,
6
+ * OMC keyword-detector): a "hook" today is an arbitrary shell command in
7
+ * settings.json — which is BOTH the enforcement vehicle AND the RCE footgun
8
+ * (CVE-2025-59536: a malicious repo's hook runs before the trust dialog). Arbitrary
9
+ * hook safety is UNDECIDABLE (Rice). So this prototype inverts it: you don't WRITE a
10
+ * hook, you DECLARE a guard from a closed, audited vocabulary, and vigiles GENERATES
11
+ * the hooks block — whose command is vigiles's OWN gate (`vigiles hook-runtime guard`), never
12
+ * user shell. Safe-by-construction, not safe-by-analysis.
13
+ *
14
+ * Covers the real PreToolUse patterns + the new ORDER axis:
15
+ * - block: deny a tool call matching args (reproduces pre-edit.sh's intent)
16
+ * - requireBefore: ORDER — deny a tool call until a prerequisite call has fired
17
+ * (`terraform destroy` only after `terraform plan`; the moat)
18
+ * - confine: deny a path-taking tool whose path escapes an allowlist (rm -rf /)
19
+ *
20
+ * EXPERIMENTAL — not on the public API. But the gate now RUNS end-to-end: the
21
+ * `vigiles hook-runtime guard` CLI subcommand reads the live PreToolUse event, loads the
22
+ * guard set (`.vigiles/guards.json`) + the session ledger (`.vigiles/guard-ledger.json`,
23
+ * the reconstructed prior-call list `requireBefore` needs), runs `decideGuards`, and
24
+ * blocks (exit 2 + reason) or records-the-allowed-call (so the next call sees it). The
25
+ * pure `decideGuards` is the decision; `compileGuards` is the generator; the
26
+ * serialization + ledger below are the IO seam the CLI calls.
27
+ * See research/harness-protocol-flow-moat.md.
28
+ */
29
+ Object.defineProperty(exports, "__esModule", { value: true });
30
+ exports.guard = void 0;
31
+ exports.decideGuards = decideGuards;
32
+ exports.guardedTools = guardedTools;
33
+ exports.compileGuards = compileGuards;
34
+ exports.serializeGuards = serializeGuards;
35
+ exports.parseGuards = parseGuards;
36
+ exports.loadGuards = loadGuards;
37
+ exports.readGuardLedger = readGuardLedger;
38
+ exports.recordGuardCall = recordGuardCall;
39
+ exports.parseGuardEvent = parseGuardEvent;
40
+ exports.runGuardHook = runGuardHook;
41
+ const node_fs_1 = require("node:fs");
42
+ const node_path_1 = require("node:path");
43
+ const arg_match_js_1 = require("../arg-match.js");
44
+ /** Ergonomic builders for the closed vocabulary. */
45
+ exports.guard = {
46
+ block: (target, reason) => ({
47
+ kind: "block",
48
+ target,
49
+ reason,
50
+ }),
51
+ requireBefore: (target, prerequisite, reason) => ({ kind: "requireBefore", target, prerequisite, reason }),
52
+ confine: (tools, allow, reason, pathKey) => ({ kind: "confine", tools, allow, reason, pathKey }),
53
+ };
54
+ const ALLOW = { allow: true };
55
+ const deny = (reason) => ({ allow: false, reason });
56
+ const matchesPattern = (e, p) => e.tool === p.tool && (p.when === undefined || (0, arg_match_js_1.matchesArgs)(e.input, p.when));
57
+ /** A path is confined if it sits under at least one allowed prefix. */
58
+ function isConfined(path, allow) {
59
+ const norm = path.replace(/^\.\//, "");
60
+ return allow.some((a) => {
61
+ const base = a.replace(/\/?\*+$/, "").replace(/\/$/, "");
62
+ return base === "" || norm === base || norm.startsWith(base + "/");
63
+ });
64
+ }
65
+ /** Decide a single guard against the event (null = this guard doesn't apply). */
66
+ function decideOne(g, event, prior) {
67
+ switch (g.kind) {
68
+ case "block":
69
+ return matchesPattern(event, g.target) ? deny(g.reason) : null;
70
+ case "requireBefore":
71
+ if (!matchesPattern(event, g.target))
72
+ return null;
73
+ if (prior.some((c) => matchesPattern(c, g.prerequisite)))
74
+ return null;
75
+ return deny(g.reason ??
76
+ `${describePattern(g.target)} requires ${describePattern(g.prerequisite)} first`);
77
+ case "confine": {
78
+ if (!g.tools.includes(event.tool))
79
+ return null;
80
+ const path = event.input?.[g.pathKey ?? "file_path"];
81
+ if (typeof path !== "string" || isConfined(path, g.allow))
82
+ return null;
83
+ return deny(g.reason ??
84
+ `${event.tool} path "${path}" is outside the allowed set [${g.allow.join(", ")}]`);
85
+ }
86
+ }
87
+ }
88
+ /**
89
+ * The pure runtime gate: decide allow/deny for `event`, given the guards and the
90
+ * calls that already fired this session (`prior`, oldest-first — the ledger the
91
+ * `guard-hook` CLI reconstructs from the transcript). First match wins a deny.
92
+ */
93
+ function decideGuards(guards, event, prior = []) {
94
+ for (const g of guards) {
95
+ const d = decideOne(g, event, prior);
96
+ if (d)
97
+ return d;
98
+ }
99
+ return ALLOW;
100
+ }
101
+ const describePattern = (p) => p.when ? `${p.tool}(${(0, arg_match_js_1.describeArgs)(p.when)})` : p.tool;
102
+ /** Every tool name a guard set gates (the PreToolUse matcher union). */
103
+ function guardedTools(guards) {
104
+ const tools = new Set();
105
+ for (const g of guards) {
106
+ if (g.kind === "confine")
107
+ for (const t of g.tools)
108
+ tools.add(t);
109
+ else
110
+ tools.add(g.target.tool);
111
+ }
112
+ return [...tools].sort();
113
+ }
114
+ /**
115
+ * Generate the hooks block for a guard set. The command is vigiles's OWN gate
116
+ * (default `npx vigiles hook-runtime guard`), NOT user shell — so the generated
117
+ * enforcement is safe-by-construction and a repo can't smuggle an arbitrary RCE
118
+ * hook. The gate reads the same guard set + the live event and runs `decideGuards`.
119
+ */
120
+ function compileGuards(guards, gateCommand = "npx vigiles hook-runtime guard") {
121
+ const matcher = guardedTools(guards).join("|");
122
+ return {
123
+ hooks: {
124
+ PreToolUse: [
125
+ { matcher, hooks: [{ type: "command", command: gateCommand }] },
126
+ ],
127
+ },
128
+ };
129
+ }
130
+ function isWireRegex(v) {
131
+ return typeof v === "object" && v !== null && "re" in v;
132
+ }
133
+ function encodeMatcher(m) {
134
+ const out = {};
135
+ for (const [k, v] of Object.entries(m)) {
136
+ out[k] = v instanceof RegExp ? { re: v.source, flags: v.flags } : v;
137
+ }
138
+ return out;
139
+ }
140
+ function decodeMatcher(w) {
141
+ const out = {};
142
+ for (const [k, v] of Object.entries(w)) {
143
+ out[k] = isWireRegex(v) ? new RegExp(v.re, v.flags) : v;
144
+ }
145
+ return out;
146
+ }
147
+ function encodePattern(p) {
148
+ return { tool: p.tool, when: p.when ? encodeMatcher(p.when) : undefined };
149
+ }
150
+ function decodePattern(raw) {
151
+ if (raw === null || typeof raw !== "object")
152
+ return null;
153
+ const o = raw;
154
+ if (typeof o.tool !== "string")
155
+ return null;
156
+ const when = o.when !== null && typeof o.when === "object"
157
+ ? decodeMatcher(o.when)
158
+ : undefined;
159
+ return { tool: o.tool, when };
160
+ }
161
+ /** Serialize a guard set for `.vigiles/guards.json` (RegExp matchers preserved). */
162
+ function serializeGuards(guards) {
163
+ const wire = guards.map((g) => {
164
+ if (g.kind === "confine") {
165
+ return {
166
+ kind: g.kind,
167
+ tools: g.tools,
168
+ allow: g.allow,
169
+ pathKey: g.pathKey,
170
+ reason: g.reason,
171
+ };
172
+ }
173
+ if (g.kind === "requireBefore") {
174
+ return {
175
+ kind: g.kind,
176
+ target: encodePattern(g.target),
177
+ prerequisite: encodePattern(g.prerequisite),
178
+ reason: g.reason,
179
+ };
180
+ }
181
+ return { kind: g.kind, target: encodePattern(g.target), reason: g.reason };
182
+ });
183
+ return JSON.stringify({ guards: wire }, null, 2);
184
+ }
185
+ function parseConfine(o) {
186
+ if (!Array.isArray(o.tools) || !Array.isArray(o.allow))
187
+ return null;
188
+ return {
189
+ kind: "confine",
190
+ tools: o.tools.filter((t) => typeof t === "string"),
191
+ allow: o.allow.filter((a) => typeof a === "string"),
192
+ pathKey: typeof o.pathKey === "string" ? o.pathKey : undefined,
193
+ reason: typeof o.reason === "string" ? o.reason : undefined,
194
+ };
195
+ }
196
+ function parseGuard(raw) {
197
+ if (raw === null || typeof raw !== "object")
198
+ return null;
199
+ const o = raw;
200
+ const reason = typeof o.reason === "string" ? o.reason : undefined;
201
+ if (o.kind === "block") {
202
+ const target = decodePattern(o.target);
203
+ return target && reason ? { kind: "block", target, reason } : null;
204
+ }
205
+ if (o.kind === "requireBefore") {
206
+ const target = decodePattern(o.target);
207
+ const prerequisite = decodePattern(o.prerequisite);
208
+ if (!target || !prerequisite)
209
+ return null;
210
+ return { kind: "requireBefore", target, prerequisite, reason };
211
+ }
212
+ if (o.kind === "confine")
213
+ return parseConfine(o);
214
+ return null;
215
+ }
216
+ /** Parse a guard set from JSON (tolerant — a malformed guard is skipped). */
217
+ function parseGuards(json) {
218
+ let data;
219
+ try {
220
+ data = JSON.parse(json);
221
+ }
222
+ catch {
223
+ return [];
224
+ }
225
+ const list = data?.guards;
226
+ if (!Array.isArray(list))
227
+ return [];
228
+ const out = [];
229
+ for (const item of list) {
230
+ const g = parseGuard(item);
231
+ if (g)
232
+ out.push(g);
233
+ }
234
+ return out;
235
+ }
236
+ // ---------------------------------------------------------------------------
237
+ // Runtime IO — load the guard set, read/append the session ledger
238
+ // ---------------------------------------------------------------------------
239
+ const GUARDS_FILE = ".vigiles/guards.json";
240
+ const LEDGER_FILE = ".vigiles/guard-ledger.json";
241
+ /** Load the declared guard set from `.vigiles/guards.json` (absent → none). */
242
+ function loadGuards(cwd) {
243
+ const p = (0, node_path_1.resolve)(cwd, GUARDS_FILE);
244
+ if (!(0, node_fs_1.existsSync)(p))
245
+ return [];
246
+ return parseGuards((0, node_fs_1.readFileSync)(p, "utf-8"));
247
+ }
248
+ /**
249
+ * The prior-call ledger — the calls already allowed this session, oldest-first.
250
+ * `requireBefore` reads it to know whether a prerequisite ran. Claude Code doesn't
251
+ * surface call history to a hook, so vigiles records each allowed call itself
252
+ * (mirrors `.vigiles/active-agent.json`).
253
+ */
254
+ function readGuardLedger(cwd) {
255
+ const p = (0, node_path_1.resolve)(cwd, LEDGER_FILE);
256
+ if (!(0, node_fs_1.existsSync)(p))
257
+ return [];
258
+ try {
259
+ const parsed = JSON.parse((0, node_fs_1.readFileSync)(p, "utf-8"));
260
+ if (!Array.isArray(parsed.calls))
261
+ return [];
262
+ return parsed.calls.filter((c) => c !== null &&
263
+ typeof c === "object" &&
264
+ typeof c.tool === "string");
265
+ }
266
+ catch {
267
+ return [];
268
+ }
269
+ }
270
+ /** Append an allowed call to the session ledger. */
271
+ function recordGuardCall(cwd, event) {
272
+ const p = (0, node_path_1.resolve)(cwd, LEDGER_FILE);
273
+ const calls = readGuardLedger(cwd);
274
+ calls.push({ tool: event.tool, input: event.input });
275
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(p), { recursive: true });
276
+ (0, node_fs_1.writeFileSync)(p, JSON.stringify({ calls }, null, 2));
277
+ }
278
+ /** Parse a PreToolUse event JSON (the hook's stdin) into a {@link ToolEvent}. */
279
+ function parseGuardEvent(rawJson) {
280
+ let parsed;
281
+ try {
282
+ parsed = JSON.parse(rawJson);
283
+ }
284
+ catch {
285
+ return null;
286
+ }
287
+ if (typeof parsed.tool_name !== "string" || !parsed.tool_name)
288
+ return null;
289
+ return { tool: parsed.tool_name, input: parsed.tool_input ?? {} };
290
+ }
291
+ /**
292
+ * The runnable gate, decoupled from process/exit so it's testable. Decides the
293
+ * event against the loaded guards + the prior-call ledger; on ALLOW it records the
294
+ * call (so a later `requireBefore` sees it) and on DENY it records nothing (a
295
+ * blocked call never happened). Malformed/absent event → allow, record nothing.
296
+ */
297
+ function runGuardHook(cwd, rawJson) {
298
+ const event = parseGuardEvent(rawJson);
299
+ if (!event)
300
+ return { decision: ALLOW, recorded: false };
301
+ const guards = loadGuards(cwd);
302
+ const decision = decideGuards(guards, event, readGuardLedger(cwd));
303
+ if (decision.allow) {
304
+ recordGuardCall(cwd, event);
305
+ return { decision, recorded: true };
306
+ }
307
+ return { decision, recorded: false };
308
+ }
309
+ //# sourceMappingURL=guards.js.map
@@ -19,7 +19,7 @@ import type { HarnessRuntime } from "./runtime.js";
19
19
  /**
20
20
  * One scripted assistant turn: a final text answer, or a tool call. The common
21
21
  * shape both harness mocks consume — the Anthropic Messages mock
22
- * (`src/adapters/claude-code/mock-model.ts`) and the OpenAI Responses mock
22
+ * (`src/mock-model.ts`) and the OpenAI Responses mock
23
23
  * (`src/adapters/codex/mock-model.ts`, which uses only `text`).
24
24
  */
25
25
  export interface ModelTurn {