Repository navigation
Expand file tree
/
Copy pathallow-dispatch.js
More file actions
212 lines (199 loc) · 8.95 KB
/
Copy pathallow-dispatch.js
File metadata and controls
212 lines (199 loc) · 8.95 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
#!/usr/bin/env node
// allow-dispatch.js — PreToolUse hook (matcher: Bash)
//
// PURPOSE
// The single registered auto-allow hook for this plugin. It asks the required
// decision modules — `blueprint-allow.js` (provenance: this exact command
// text is in a reviewed, versioned plugin file), `sentinel-read-allow.js`
// (shape: this command is a known read-only idiom) — and, when shipped,
// the optional Foundry module `github-read-allow.js`
// (gh read: every command is an allowlisted gh read; no grant or record) —
// in rank order, emits the first allow, and
// appends one audit record describing what all of them said. The modules keep
// their own logic, their own standalone drivers and their own test suites;
// this file adds no policy of its own.
//
// HOW IT WORKS
// 1. Read stdin once.
// 2. For each lane in LANES order, inside its own protected block, require
// the module and call `evaluate(raw)`.
// 3. The first lane that allowed has its payload written to stdout
// immediately; a later allow is recorded but never printed.
// 4. Append one audit record carrying the effective verdict and every lane's
// raw verdict — unless every lane returned `none`, in which case there is
// no opinion to record and nothing is written.
//
// ALLOW-ONLY — WHY IT EXITS 0 ON EVERY PATH
// The only decision this hook can ever emit is `allow`. A crash therefore
// grants nothing and the call falls back to normal permission handling, which
// is the safe direction; exiting 2 would block a call this hook was never
// empowered to refuse. A module that throws contributes `none` /
// `module-error` and never suppresses the other module. Audit failure is
// invisible to the decision: it cannot change stdout, the exit code, or which
// payload was chosen.
//
// EXIT CODES
// 0 always — see ALLOW-ONLY above. This hook has no blocking path.
//
// ACCEPTED BEHAVIOUR DELTAS versus registering the modules separately
// 1. When several modules allow, the host receives ONE allow payload instead
// of several. The decision is identical and the reason string is the
// highest-ranked lane's; rank order makes that deterministic where
// parallel hooks were not.
// 2. A crash in this file loses every verdict, where a crash in one of
// several separately registered hooks lost only one. The per-module
// protected blocks confine that to this file's own stdin handling.
// 3. The evaluations share one process, one timeout and one failure
// boundary. A stall anywhere can push the hook past its timeout, and the
// host then discards its ENTIRE output — printing early does not save an
// already-written allow. What step 3 does buy is that a slow shape
// evaluation cannot delay a blueprint allow that already completed, and
// that audit I/O sits after every decision.
//
// ENVIRONMENT
// RIG_AUDIT=0 disables audit writing. The hook still evaluates every module
// and still prints its payload: the switch disables logging,
// never permission behaviour. Checked before the audit library is
// required, so a disabled audit costs nothing and cannot fail
// during module load.
"use strict";
const path = require("path");
const fs = require("fs");
/**
* Rank order is the contract: blueprint outranks shape, shape outranks the gh-read lane, so the first allow and its
* reason string win.
*/
const LANES = [
{ name: "blueprint", module: "blueprint-allow.js" },
{ name: "shape", module: "sentinel-read-allow.js" },
{ name: "gh-read", module: "github-read-allow.js", optional: true },
];
/** This hook's short name, used to build `agent_id`. */
const HOOK_NAME = "allow-dispatch";
/**
* Evaluate one decision module without letting its failure reach the other.
* A module that cannot be required, or that throws, is reported as `none` / `module-error`: the absence of an
* opinion, never an abstention and never a reason to suppress the other lane.
*/
function evaluateLane(lane, raw) {
try {
const mod = require(path.join(__dirname, lane.module));
const result = mod.evaluate(raw);
if (!result || typeof result !== "object") {
return { lane: lane.name, decision: "none", why: "module-error", payload: null };
}
return result;
} catch (_error) {
return { lane: lane.name, decision: "none", why: "module-error", payload: null };
}
}
/** Return the compact per-lane verdict that goes into `action_detail.verdicts`. */
function verdictOf(result) {
const verdict = { lane: result.lane, decision: result.decision };
if (typeof result.why === "string") verdict.why = result.why;
if (typeof result.src === "string") verdict.src = result.src;
return verdict;
}
/**
* Return the `action_detail` of the before-row, or null when no lane reached a decision.
* The effective decision is the winning lane's when one allowed, `passthrough` when at least one lane examined the
* command and declined, and nothing at all when all returned `none`.
*/
function beforeDetail(results) {
// The winner is the first lane that both said allow AND produced a payload, which is exactly the test `dispatch`
// uses to decide what to print. A lane claiming an allow with no payload would otherwise put an allow in the record
// that the host was never told about.
const winner = results.find((result) => result.decision === "allow" && result.payload) || null;
const decided = results.filter((result) => result.decision !== "none");
if (winner === null && decided.length === 0) return null;
const detail = winner
? { decision: "allow", lane: winner.lane, rank: winner.rank }
: { decision: "passthrough", lane: "none" };
if (winner && typeof winner.src === "string") detail.src = winner.src;
// The digest is the blueprint normalizer's, the only one that exists, so it is present exactly when the blueprint
// lane normalized the command. A shape-only allow therefore carries no digest, which is correct rather than a gap.
// Found by lane name rather than by position, so reordering LANES cannot attribute it to a lane that never
// normalized anything.
const normalizer = results.find((result) => result.lane === "blueprint") || {};
if (typeof normalizer.digest === "string") detail.digest = normalizer.digest;
detail.verdicts = results.map(verdictOf);
return detail;
}
/** Return the stdin fields the record needs, tolerating any malformed payload. */
function context(raw) {
try {
const data = JSON.parse(raw);
if (!data || typeof data !== "object") return {};
return { session_id: data.session_id, tool_use_id: data.tool_use_id, project: data.cwd };
} catch (_error) {
return {};
}
}
/**
* Append the before-row. Every failure here is swallowed: the decision has already been printed, and no audit
* outcome may change stdout, the exit code, or what the host was told.
*/
function recordDecision(raw, results) {
if (process.env.RIG_AUDIT === "0") return;
try {
const detail = beforeDetail(results);
if (detail === null) return;
const audit = require(path.join(__dirname, "lib", "audit-log.js"));
const { session_id: sessionId, tool_use_id: toolUseId, project } = context(raw);
audit.appendRecord({
agent_id: `${audit.logicalPlugin()}/${HOOK_NAME}`,
agent_version: audit.pluginVersion(),
session_id: sessionId ?? null,
tool_use_id: toolUseId ?? null,
project: project ?? null,
action_type: "tool.bash",
action_detail: detail,
outcome: "pending",
trust_level: detail.decision === "allow" ? "plugin" : "unknown",
parent_record_id: null,
prev_hash: null,
record_phase: "pre_execution",
});
} catch (_error) {
// Logging must never change the decision that was already emitted.
}
}
/**
* Run every lane over `raw`, write at most one payload, and record what happened.
* Returns the payload that was written, or null — the stdout oracle is `payload ? JSON.stringify(payload) : <empty>`,
* and never the string "null".
*/
function dispatch(raw, write) {
const results = [];
let printed = null;
for (const lane of LANES) {
// Siblings ship only the required lanes; a present but broken optional module still reports module-error.
if (lane.optional && !fs.existsSync(path.join(__dirname, lane.module))) continue;
const result = evaluateLane(lane, raw);
results.push(result);
if (printed === null && result.decision === "allow" && result.payload) {
printed = result.payload;
write(JSON.stringify(printed));
}
}
recordDecision(raw, results);
return printed;
}
if (require.main === module) {
let raw = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => (raw += chunk));
process.stdin.on("end", () => {
try {
dispatch(raw, (text) => process.stdout.write(text));
} catch (_error) {
// Never crash or block Claude due to a hook bug.
}
process.exit(0);
});
}
module.exports = {
dispatch,
beforeDetail,
evaluateLane,
};