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.
- package/README.md +78 -68
- package/dist/action-gate.js +1 -1
- package/dist/adapters/claude-code/agent-runtime.d.ts +46 -11
- package/dist/adapters/claude-code/agent-runtime.js +95 -24
- 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 -1
- package/dist/adapters/codex/hook-protocol.js +3 -0
- package/dist/adapters/codex/mock-model.js +1 -1
- package/dist/cli-commands.d.ts +19 -0
- package/dist/cli-commands.js +51 -0
- package/dist/cli.js +599 -86
- 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/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/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.js +21 -7
- 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
|
@@ -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/
|
|
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 {
|