faberun 0.3.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/LICENSE +21 -0
- package/README.md +131 -0
- package/bin/faberun.mjs +25 -0
- package/integrations/claude-code/statusline-bench.sh +42 -0
- package/integrations/claude-code/statusline.sh +80 -0
- package/package.json +33 -0
- package/skills/faberun/SKILL.md +24 -0
- package/skills/faberun/references/contract.md +380 -0
- package/skills/faberun/references/engineering.md +29 -0
- package/skills/faberun/references/handoffs.md +26 -0
- package/skills/faberun/references/operations.md +184 -0
- package/skills/faberun/references/rules.md +35 -0
- package/skills/faberun/references/workflow.md +23 -0
- package/skills/init-agentkit/SKILL.md +108 -0
- package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
- package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
- package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
- package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
- package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
- package/skills/init-agentkit/templates/AGENTS.md +110 -0
- package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
- package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
- package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
- package/skills/init-agentkit/templates/docs/VISION.md +33 -0
- package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
- package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
- package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
- package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
- package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
- package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
- package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
- package/src/campaign/brief.mjs +394 -0
- package/src/campaign/chain.mjs +555 -0
- package/src/campaign/handoff.mjs +516 -0
- package/src/campaign/index.mjs +300 -0
- package/src/campaign/journal.mjs +347 -0
- package/src/campaign/layout.mjs +51 -0
- package/src/campaign/metrics-evals.mjs +25 -0
- package/src/campaign/metrics.mjs +517 -0
- package/src/campaign/projection.mjs +250 -0
- package/src/campaign/record.mjs +102 -0
- package/src/campaign/unpark.mjs +56 -0
- package/src/cli/brand.mjs +205 -0
- package/src/cli/campaign.mjs +730 -0
- package/src/cli/contract.mjs +67 -0
- package/src/cli/init.mjs +170 -0
- package/src/cli/launch.mjs +239 -0
- package/src/cli/seat.mjs +139 -0
- package/src/cli/setup.mjs +294 -0
- package/src/cli/skills.mjs +105 -0
- package/src/cli/update.mjs +216 -0
- package/src/cli.mjs +525 -0
- package/src/contract/articles.mjs +12 -0
- package/src/contract/assert.mjs +162 -0
- package/src/contract/definition-of-done.mjs +97 -0
- package/src/contract/final-verification.mjs +96 -0
- package/src/contract/index.mjs +641 -0
- package/src/contract/judge-envelope.mjs +25 -0
- package/src/contract/review-modes.mjs +151 -0
- package/src/contract/runtime.mjs +204 -0
- package/src/contract/schema-version.mjs +25 -0
- package/src/contract/scope-findings.mjs +77 -0
- package/src/contract/snapshot.mjs +639 -0
- package/src/contract/task-packet.mjs +495 -0
- package/src/contract/untrusted.mjs +75 -0
- package/src/contract/verification.mjs +185 -0
- package/src/contract/worker-result.mjs +138 -0
- package/src/engine/assignment.mjs +63 -0
- package/src/engine/backoff.mjs +492 -0
- package/src/engine/bulk-read.mjs +361 -0
- package/src/engine/cancel.mjs +177 -0
- package/src/engine/detach.mjs +101 -0
- package/src/engine/dispatch.mjs +752 -0
- package/src/engine/failover.mjs +192 -0
- package/src/engine/gate.mjs +183 -0
- package/src/engine/judge-gate.mjs +517 -0
- package/src/engine/lifecycle.mjs +772 -0
- package/src/engine/live-preflight.mjs +299 -0
- package/src/engine/mutation.mjs +146 -0
- package/src/engine/notify-queue.mjs +327 -0
- package/src/engine/process-identity.mjs +72 -0
- package/src/engine/process.mjs +774 -0
- package/src/engine/prompts.mjs +289 -0
- package/src/engine/recover.mjs +300 -0
- package/src/engine/result-file.mjs +222 -0
- package/src/engine/resume.mjs +635 -0
- package/src/engine/retry.mjs +334 -0
- package/src/engine/review.mjs +228 -0
- package/src/engine/run-command.mjs +287 -0
- package/src/engine/run-identity.mjs +411 -0
- package/src/engine/runtime-discovery.mjs +235 -0
- package/src/engine/scheduler.mjs +526 -0
- package/src/engine/scope.mjs +378 -0
- package/src/engine/settle.mjs +207 -0
- package/src/engine/state.mjs +148 -0
- package/src/engine/supervise.mjs +713 -0
- package/src/engine/verify.mjs +167 -0
- package/src/harnesses/agy/index.mjs +62 -0
- package/src/harnesses/catalogue.mjs +509 -0
- package/src/harnesses/claude/index.mjs +90 -0
- package/src/harnesses/codex/index.mjs +87 -0
- package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
- package/src/harnesses/dsh/index.mjs +210 -0
- package/src/harnesses/dsh/runner.mjs +259 -0
- package/src/harnesses/exec-jsonl/index.mjs +788 -0
- package/src/harnesses/index.mjs +508 -0
- package/src/harnesses/protocol.mjs +531 -0
- package/src/harnesses/replay/bin.mjs +386 -0
- package/src/harnesses/replay/index.mjs +238 -0
- package/src/harnesses/zcode/index.mjs +276 -0
- package/src/host/config.mjs +87 -0
- package/src/host/home.mjs +149 -0
- package/src/host/package.mjs +23 -0
- package/src/host/preflight.mjs +520 -0
- package/src/host/tool-policy-decisions.mjs +341 -0
- package/src/host/tool-policy-hook.mjs +270 -0
- package/src/notify/index.mjs +359 -0
- package/src/notify/os-macos.mjs +81 -0
- package/src/repo/declared-paths.mjs +220 -0
- package/src/repo/integrate.mjs +546 -0
- package/src/repo/scope-closure.mjs +665 -0
- package/src/repo/signal-block.mjs +16 -0
- package/src/repo/signal.mjs +222 -0
- package/src/repo/source-identity.mjs +295 -0
- package/src/repo/workspace.mjs +557 -0
- package/src/repo/worktree.mjs +352 -0
- package/src/report/final.mjs +200 -0
- package/src/report/metrics-report.mjs +99 -0
- package/src/report/next.mjs +383 -0
- package/src/report/render.mjs +716 -0
- package/src/run/disk-gc.mjs +251 -0
- package/src/run/lock.mjs +329 -0
- package/src/run/node-store.mjs +62 -0
- package/src/run/operations.mjs +286 -0
- package/src/run/store.mjs +187 -0
- package/src/run/usage.mjs +337 -0
- package/src/seat/harnesses.mjs +83 -0
- package/src/seat/index.mjs +239 -0
- package/src/seat/tmux.mjs +208 -0
- package/src/util.mjs +0 -0
- package/src/web/api.mjs +371 -0
- package/src/web/boundary.mjs +88 -0
- package/src/web/index.html +299 -0
- package/src/web/server.mjs +552 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three PreToolUse decisions the tool policy hook carries but does not
|
|
3
|
+
* yet apply: a write outside the declared scope, a whole-file read above the
|
|
4
|
+
* line threshold, and the same read done through `Bash` (`cat`/`less`/`more`,
|
|
5
|
+
* or `head`/`tail` with no explicit limit). Kept out of tool-policy-hook.mjs
|
|
6
|
+
* so that module stays the wiring (argv, settings, event dispatch) and this
|
|
7
|
+
* one stays the judgment calls, each a pure function of policy and payload.
|
|
8
|
+
*
|
|
9
|
+
* The two read decisions only deny what they can measure: a file that is
|
|
10
|
+
* missing, unreadable, or not a regular file passes through, because the hook
|
|
11
|
+
* must never be the reason a model cannot see the tool's own error. The write
|
|
12
|
+
* decision is different in kind -- scope membership is a fact about the path,
|
|
13
|
+
* not about the disk -- so it judges a target that does not exist yet exactly
|
|
14
|
+
* like one that does. Creating a new file outside the declared scope is the
|
|
15
|
+
* ordinary violation, and skipping it would leave the decision firing only on
|
|
16
|
+
* overwrites.
|
|
17
|
+
*
|
|
18
|
+
* A write made through `Bash` (`>`, `sed -i`, `tee`) is deliberately not
|
|
19
|
+
* caught here: sniffing shell syntax for a write is a race no static read of
|
|
20
|
+
* `command` wins, and the post-hoc scope gate in `engine/scope.mjs` already
|
|
21
|
+
* catches the effect once the attempt completes.
|
|
22
|
+
*/
|
|
23
|
+
import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
24
|
+
import { closeSync, fstatSync, openSync, readSync, realpathSync } from "node:fs";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Tool name to the payload field carrying the path it would write.
|
|
28
|
+
*
|
|
29
|
+
* A tool that is not a key here is not judged at all, and Claude Code reads a
|
|
30
|
+
* hook that says nothing as an allow. That makes the map the enforcement
|
|
31
|
+
* boundary rather than a lookup table, so `test/host/tool-policy.test.mjs`
|
|
32
|
+
* pins it against the tool list the adapter actually offers: adding a
|
|
33
|
+
* write-capable tool to `DEFAULT_CLAUDE_TOOLS` without adding it here fails
|
|
34
|
+
* the suite instead of silently opening the scope.
|
|
35
|
+
*/
|
|
36
|
+
export const WRITE_SCOPE_FIELDS = { Write: "file_path", Edit: "file_path", NotebookEdit: "notebook_path" };
|
|
37
|
+
|
|
38
|
+
/** Tools the adapter offers that cannot write, so their absence above is correct. */
|
|
39
|
+
export const READ_ONLY_TOOLS = new Set(["Read", "Bash", "Glob", "Grep", "WebFetch", "WebSearch", "TodoWrite", "Task"]);
|
|
40
|
+
|
|
41
|
+
/** Bash commands that read a whole file by default. */
|
|
42
|
+
const WHOLE_FILE_READERS = new Set(["cat", "less", "more"]);
|
|
43
|
+
|
|
44
|
+
/** Bash commands that read a whole file only when given no explicit limit. */
|
|
45
|
+
const BOUNDED_BY_DEFAULT_READERS = new Set(["head", "tail"]);
|
|
46
|
+
|
|
47
|
+
/** A command containing any of these is a pipeline, redirection or chain: the named file is a filter's input, not read-tool evidence. */
|
|
48
|
+
const SHELL_COMPOSITION_TOKENS = ["|", ">>", ">", "<", "&&", "||", ";"];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* DECISION 1: deny a `Write`, `Edit`, or `NotebookEdit` call whose target path
|
|
52
|
+
* is neither a declared write file nor beneath a declared write root. An empty
|
|
53
|
+
* scope (no writeFiles and no writeRoots) is the absence of a declared scope,
|
|
54
|
+
* not a closed one, and denies nothing. Existence is not consulted: a path is
|
|
55
|
+
* in the declared scope or it is not, and a file the attempt is about to
|
|
56
|
+
* create is exactly the case worth catching before it exists.
|
|
57
|
+
*
|
|
58
|
+
* @param {{workspace: string|null, writeFiles: string[], writeRoots: string[]}} policy
|
|
59
|
+
* @param {{tool_name?: unknown, tool_input?: unknown}} payload
|
|
60
|
+
* @returns {{hookSpecificOutput: Record<string, unknown>}|null}
|
|
61
|
+
*/
|
|
62
|
+
export function writeScopeDecision(policy, payload) {
|
|
63
|
+
const name = typeof payload?.tool_name === "string" ? payload.tool_name : "";
|
|
64
|
+
const field = /** @type {Record<string, string>} */ (WRITE_SCOPE_FIELDS)[name];
|
|
65
|
+
if (!field) return null;
|
|
66
|
+
const writeFiles = policy.writeFiles ?? [];
|
|
67
|
+
const writeRoots = policy.writeRoots ?? [];
|
|
68
|
+
if (!writeFiles.length && !writeRoots.length) return null;
|
|
69
|
+
const workspace = typeof policy.workspace === "string" ? policy.workspace : "";
|
|
70
|
+
if (!workspace) return null;
|
|
71
|
+
const input = payload?.tool_input;
|
|
72
|
+
const rawPath = input && typeof input === "object" ? /** @type {Record<string, unknown>} */ (input)[field] : undefined;
|
|
73
|
+
if (typeof rawPath !== "string" || !rawPath) return null;
|
|
74
|
+
// Both spellings are offered to the match: the path as declared, and the
|
|
75
|
+
// path the filesystem actually reaches. A symlink pointing elsewhere inside
|
|
76
|
+
// the workspace is a legitimate scope (git reports the target's spelling,
|
|
77
|
+
// which is why `repo/workspace.mjs` accepts both too); one pointing outside
|
|
78
|
+
// is the escape below.
|
|
79
|
+
const spellings = workspaceSpellings(workspace, rawPath);
|
|
80
|
+
if (spellings.reachesWorkspace && spellings.readings.some((rel) => inDeclaredScope(rel, writeFiles, writeRoots))) return null;
|
|
81
|
+
return denyPreTool(writeScopeDenialReason(writeFiles, writeRoots));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Whether one workspace-relative spelling sits in the declared scope.
|
|
86
|
+
* Comparison is Unicode-normalized because a macOS path round-trips between
|
|
87
|
+
* NFC and NFD and a worker that renormalizes a filename it is entitled to
|
|
88
|
+
* write should not meet an opaque denial. A declared root keeps matching with
|
|
89
|
+
* a trailing slash, which is an easy thing to type into a contract and used to
|
|
90
|
+
* break every write under that root.
|
|
91
|
+
*
|
|
92
|
+
* @param {string|null} rel
|
|
93
|
+
* @param {string[]} writeFiles
|
|
94
|
+
* @param {string[]} writeRoots
|
|
95
|
+
* @returns {boolean}
|
|
96
|
+
*/
|
|
97
|
+
function inDeclaredScope(rel, writeFiles, writeRoots) {
|
|
98
|
+
if (rel === null) return false;
|
|
99
|
+
const target = rel.normalize("NFC");
|
|
100
|
+
if (writeFiles.some((file) => file.normalize("NFC") === target)) return true;
|
|
101
|
+
return writeRoots.some((declared) => {
|
|
102
|
+
const root = declared.normalize("NFC").replace(/\/+$/u, "");
|
|
103
|
+
return root !== "" && (target === root || target.startsWith(`${root}/`));
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* @param {string[]} writeFiles
|
|
109
|
+
* @param {string[]} writeRoots
|
|
110
|
+
* @returns {string}
|
|
111
|
+
*/
|
|
112
|
+
function writeScopeDenialReason(writeFiles, writeRoots) {
|
|
113
|
+
const declared = [...writeFiles, ...writeRoots.map((root) => `${root}/`)];
|
|
114
|
+
const shown = declared.slice(0, 12);
|
|
115
|
+
const omitted = declared.length - shown.length;
|
|
116
|
+
const remainder = omitted > 0 ? ` (and ${omitted} more not shown)` : "";
|
|
117
|
+
return `write denied: this path is outside the declared write scope. Declared write paths: ${shown.join(", ")}${remainder}. Write only to one of those.`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* DECISION 2: deny a `Read` call that would read an entire file above
|
|
122
|
+
* `policy.maxReadLines` with neither `offset` nor `limit`. A file that cannot
|
|
123
|
+
* be measured -- missing, unreadable, not a regular file -- passes through.
|
|
124
|
+
*
|
|
125
|
+
* @param {{maxReadLines: number|null}} policy
|
|
126
|
+
* @param {{tool_name?: unknown, tool_input?: unknown}} payload
|
|
127
|
+
* @returns {{hookSpecificOutput: Record<string, unknown>}|null}
|
|
128
|
+
*/
|
|
129
|
+
export function readThresholdDecision(policy, payload) {
|
|
130
|
+
if (typeof payload?.tool_name !== "string" || payload.tool_name !== "Read") return null;
|
|
131
|
+
const maxReadLines = policy.maxReadLines;
|
|
132
|
+
if (typeof maxReadLines !== "number" || maxReadLines <= 0) return null;
|
|
133
|
+
const input = payload?.tool_input;
|
|
134
|
+
if (!input || typeof input !== "object") return null;
|
|
135
|
+
const record = /** @type {Record<string, unknown>} */ (input);
|
|
136
|
+
if (record.offset !== undefined || record.limit !== undefined) return null;
|
|
137
|
+
const rawPath = record.file_path;
|
|
138
|
+
if (typeof rawPath !== "string" || !rawPath) return null;
|
|
139
|
+
const lines = countLines(rawPath);
|
|
140
|
+
if (lines === null || lines <= maxReadLines) return null;
|
|
141
|
+
return denyPreTool(readThresholdDenialReason(lines, maxReadLines));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* DECISION 3: deny the same whole-file read done through `Bash` -- a single
|
|
146
|
+
* `cat`/`less`/`more` invocation, or `head`/`tail` with no explicit limit --
|
|
147
|
+
* over a file above the threshold. A command carrying a pipe, redirection, or
|
|
148
|
+
* chain passes without analysis: the named file is then a filter's input, not
|
|
149
|
+
* evidence entering the model's context.
|
|
150
|
+
*
|
|
151
|
+
* @param {{maxReadLines: number|null}} policy
|
|
152
|
+
* @param {{tool_name?: unknown, tool_input?: unknown}} payload
|
|
153
|
+
* @returns {{hookSpecificOutput: Record<string, unknown>}|null}
|
|
154
|
+
*/
|
|
155
|
+
export function bashReadDecision(policy, payload) {
|
|
156
|
+
if (typeof payload?.tool_name !== "string" || payload.tool_name !== "Bash") return null;
|
|
157
|
+
const maxReadLines = policy.maxReadLines;
|
|
158
|
+
if (typeof maxReadLines !== "number" || maxReadLines <= 0) return null;
|
|
159
|
+
const input = payload?.tool_input;
|
|
160
|
+
const command = input && typeof input === "object" ? /** @type {Record<string, unknown>} */ (input).command : undefined;
|
|
161
|
+
if (typeof command !== "string" || !command.trim()) return null;
|
|
162
|
+
if (SHELL_COMPOSITION_TOKENS.some((token) => command.includes(token))) return null;
|
|
163
|
+
const tokens = command.trim().split(/\s+/u);
|
|
164
|
+
const program = basename(tokens[0] ?? "");
|
|
165
|
+
const args = tokens.slice(1);
|
|
166
|
+
if (WHOLE_FILE_READERS.has(program)) {
|
|
167
|
+
return bashTargetDenial(args, maxReadLines);
|
|
168
|
+
}
|
|
169
|
+
if (BOUNDED_BY_DEFAULT_READERS.has(program) && !hasExplicitLimit(args)) {
|
|
170
|
+
return bashTargetDenial(args, maxReadLines);
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* @param {string[]} args
|
|
177
|
+
* @param {number} maxReadLines
|
|
178
|
+
* @returns {{hookSpecificOutput: Record<string, unknown>}|null}
|
|
179
|
+
*/
|
|
180
|
+
function bashTargetDenial(args, maxReadLines) {
|
|
181
|
+
const target = [...args].reverse().find((token) => !token.startsWith("-"));
|
|
182
|
+
if (!target) return null;
|
|
183
|
+
const lines = countLines(target);
|
|
184
|
+
if (lines === null || lines <= maxReadLines) return null;
|
|
185
|
+
return denyPreTool(readThresholdDenialReason(lines, maxReadLines, true));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* @param {string[]} args
|
|
190
|
+
* @returns {boolean}
|
|
191
|
+
*/
|
|
192
|
+
function hasExplicitLimit(args) {
|
|
193
|
+
return args.some((arg) => arg === "-n" || arg === "-c" || /^-[nc]\d+$/u.test(arg) || /^-\d+$/u.test(arg));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* @param {number} lines
|
|
198
|
+
* @param {number} maxReadLines
|
|
199
|
+
* @param {boolean} [viaBash]
|
|
200
|
+
* @returns {string}
|
|
201
|
+
*/
|
|
202
|
+
function readThresholdDenialReason(lines, maxReadLines, viaBash = false) {
|
|
203
|
+
const retry = viaBash
|
|
204
|
+
? "rerun with an explicit limit (head -n, tail -n) or use the Read tool with an offset and limit"
|
|
205
|
+
: "reread it with an offset and limit instead of the whole file";
|
|
206
|
+
return `read denied: this file has ${lines} lines, above the ${maxReadLines}-line read threshold; ${retry}.`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Where a target path actually lands, and the workspace-relative spellings it
|
|
211
|
+
* can be judged under: the one the caller wrote, and the one the filesystem
|
|
212
|
+
* reaches through any symlink on the way. A path the filesystem does not
|
|
213
|
+
* reach inside the workspace is refused whatever it was spelled as; a path
|
|
214
|
+
* that does is in scope if either spelling is.
|
|
215
|
+
*
|
|
216
|
+
* Lexical containment alone was an escape, reproduced 2026-09-13 against the
|
|
217
|
+
* real provider: declare `writeRoots: ["src/pkg"]` where `src/pkg` is a
|
|
218
|
+
* symlink to a directory outside the repository, and `Write` to
|
|
219
|
+
* `src/pkg/new.txt` passed the prefix test and landed outside the workspace.
|
|
220
|
+
* The provider's own refusal does not cover it either — that one lstats the
|
|
221
|
+
* exact target, so a symlinked *intermediate directory* never trips it.
|
|
222
|
+
*
|
|
223
|
+
* @param {string} workspace
|
|
224
|
+
* @param {string} rawPath
|
|
225
|
+
* @returns {{reachesWorkspace: boolean, readings: (string|null)[]}}
|
|
226
|
+
*/
|
|
227
|
+
function workspaceSpellings(workspace, rawPath) {
|
|
228
|
+
const root = realPath(workspace) ?? workspace;
|
|
229
|
+
const absolute = isAbsolute(rawPath) ? rawPath : resolve(workspace, rawPath);
|
|
230
|
+
const resolved = resolveThroughLinks(absolute);
|
|
231
|
+
const reached = resolved === null ? null : containedRelativePath(root, resolved);
|
|
232
|
+
// The declared spelling is measured against both readings of the workspace
|
|
233
|
+
// root. A caller naming an absolute path uses the root it was handed, which
|
|
234
|
+
// on macOS is routinely a symlink (`/var` to `/private/var`), and measuring
|
|
235
|
+
// that against the resolved root alone reads every path as an escape.
|
|
236
|
+
const declared = containedRelativePath(root, absolute) ?? containedRelativePath(workspace, absolute);
|
|
237
|
+
return { reachesWorkspace: reached !== null, readings: [declared, reached] };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* @param {string} root
|
|
242
|
+
* @param {string} absolute
|
|
243
|
+
* @returns {string|null} the forward-slash relative path, or null when it is not inside the root
|
|
244
|
+
*/
|
|
245
|
+
function containedRelativePath(root, absolute) {
|
|
246
|
+
const rel = relative(root, absolute);
|
|
247
|
+
if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return null;
|
|
248
|
+
return sep === "\\" ? rel.replaceAll("\\", "/") : rel;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Resolve the longest existing prefix of a path through the filesystem and
|
|
253
|
+
* re-append the components that do not exist yet, so a file about to be
|
|
254
|
+
* created is judged at the location it will actually occupy. `null` when
|
|
255
|
+
* containment cannot be proven — an unreadable component denies rather than
|
|
256
|
+
* passes, since the whole point is to refuse what cannot be shown to be
|
|
257
|
+
* inside.
|
|
258
|
+
*
|
|
259
|
+
* @param {string} absolute
|
|
260
|
+
* @returns {string|null}
|
|
261
|
+
*/
|
|
262
|
+
function resolveThroughLinks(absolute) {
|
|
263
|
+
/** @type {string[]} */
|
|
264
|
+
const tail = [];
|
|
265
|
+
let current = absolute;
|
|
266
|
+
for (;;) {
|
|
267
|
+
const real = realPath(current);
|
|
268
|
+
if (real !== null) return tail.length ? resolve(real, ...tail) : real;
|
|
269
|
+
const parent = dirname(current);
|
|
270
|
+
if (parent === current) return null;
|
|
271
|
+
tail.unshift(basename(current));
|
|
272
|
+
current = parent;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** @param {string} path @returns {string|null} */
|
|
277
|
+
function realPath(path) {
|
|
278
|
+
try {
|
|
279
|
+
return realpathSync(path);
|
|
280
|
+
} catch {
|
|
281
|
+
// Missing, unreadable, or a non-directory component: the caller walks up
|
|
282
|
+
// to the nearest existing ancestor, and gives up when there is none.
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Count lines in a file without loading it fully into memory: newline bytes
|
|
289
|
+
* plus one more line when the file does not end on a newline. `null` means
|
|
290
|
+
* the path does not exist, is not a regular file, or cannot be read -- the
|
|
291
|
+
* caller must pass the invocation through rather than fabricate a denial for
|
|
292
|
+
* evidence it cannot prove.
|
|
293
|
+
*
|
|
294
|
+
* @param {string} path
|
|
295
|
+
* @returns {number|null}
|
|
296
|
+
*/
|
|
297
|
+
function countLines(path) {
|
|
298
|
+
let fd;
|
|
299
|
+
try {
|
|
300
|
+
fd = openSync(path, "r");
|
|
301
|
+
} catch {
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
try {
|
|
305
|
+
const stats = fstatSync(fd);
|
|
306
|
+
if (!stats.isFile()) return null;
|
|
307
|
+
if (stats.size === 0) return 0;
|
|
308
|
+
const buffer = Buffer.alloc(64 * 1024);
|
|
309
|
+
let lines = 0;
|
|
310
|
+
let position = 0;
|
|
311
|
+
let lastByte = -1;
|
|
312
|
+
while (position < stats.size) {
|
|
313
|
+
const read = readSync(fd, buffer, 0, buffer.length, position);
|
|
314
|
+
if (read <= 0) break;
|
|
315
|
+
for (let index = 0; index < read; index += 1) {
|
|
316
|
+
if (buffer[index] === 0x0a) lines += 1;
|
|
317
|
+
}
|
|
318
|
+
lastByte = buffer[read - 1];
|
|
319
|
+
position += read;
|
|
320
|
+
}
|
|
321
|
+
return lastByte === 0x0a ? lines : lines + 1;
|
|
322
|
+
} catch {
|
|
323
|
+
return null;
|
|
324
|
+
} finally {
|
|
325
|
+
closeSync(fd);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* @param {string} reason
|
|
331
|
+
* @returns {{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: string}}}
|
|
332
|
+
*/
|
|
333
|
+
function denyPreTool(reason) {
|
|
334
|
+
return {
|
|
335
|
+
hookSpecificOutput: {
|
|
336
|
+
hookEventName: "PreToolUse",
|
|
337
|
+
permissionDecision: "deny",
|
|
338
|
+
permissionDecisionReason: reason,
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Repository-owned enforcement hook for the mechanical worker tool policy at
|
|
4
|
+
* the Claude-compatible CLI boundary (RETROSPECTIVE-2026-08-28 P0.7/P1.1).
|
|
5
|
+
* The runner registers one command for both events through `--settings`:
|
|
6
|
+
*
|
|
7
|
+
* - PreToolUse denies, in order: a `Bash` invocation with `run_in_background:
|
|
8
|
+
* true` and the background-output tools; a `Write`/`Edit`/`NotebookEdit`
|
|
9
|
+
* outside the declared write scope; a whole-file `Read`, or the same read
|
|
10
|
+
* done through `Bash` (`cat`/`less`/`more`, or `head`/`tail` with no
|
|
11
|
+
* explicit limit), above `--max-read-lines`. Every denial reason tells the
|
|
12
|
+
* model how to retry. The write-scope and read-threshold judgment calls
|
|
13
|
+
* live in `tool-policy-decisions.mjs`; this module is the argv wiring and
|
|
14
|
+
* event dispatch, not the decisions themselves.
|
|
15
|
+
* - PostToolUse bounds the textual evidence of a Bash result to at most
|
|
16
|
+
* `--max-tool-output-bytes` UTF-8 bytes, keeping head and tail; small
|
|
17
|
+
* results are emitted unchanged (no output at all leaves them untouched).
|
|
18
|
+
*
|
|
19
|
+
* A write made through `Bash` is not intercepted here: sniffing shell syntax
|
|
20
|
+
* for a write is a race no static read of `command` wins, and the post-hoc
|
|
21
|
+
* scope gate in `engine/scope.mjs` already catches the effect once the
|
|
22
|
+
* attempt completes.
|
|
23
|
+
*
|
|
24
|
+
* The settings that wire this hook are installed inline via `--settings` on
|
|
25
|
+
* every provider invocation; nothing is written to the worktree, so there is
|
|
26
|
+
* no file to remove when the attempt is sealed.
|
|
27
|
+
*
|
|
28
|
+
* Every decision is pure and exported so the settings wiring and the hook
|
|
29
|
+
* behavior stay testable without a live provider.
|
|
30
|
+
*/
|
|
31
|
+
import { realpathSync } from "node:fs";
|
|
32
|
+
import { parseArgs } from "node:util";
|
|
33
|
+
import { fileURLToPath } from "node:url";
|
|
34
|
+
import { TOOL_OUTPUT_LIMIT_BYTES, truncateToolOutput } from "../harnesses/exec-jsonl/index.mjs";
|
|
35
|
+
import { bashReadDecision, readThresholdDecision, writeScopeDecision } from "./tool-policy-decisions.mjs";
|
|
36
|
+
|
|
37
|
+
/** Absolute path of this hook, embedded in generated settings. */
|
|
38
|
+
export const HOOK_PATH = fileURLToPath(import.meta.url);
|
|
39
|
+
|
|
40
|
+
/** Tools whose only purpose is to observe a background invocation. */
|
|
41
|
+
const BACKGROUND_OUTPUT_TOOLS = ["TaskOutput", "BashOutput", "Monitor"];
|
|
42
|
+
|
|
43
|
+
/** PreToolUse matcher covering every tool the policy may deny. */
|
|
44
|
+
export const PRE_TOOL_MATCHER = ["Bash", ...BACKGROUND_OUTPUT_TOOLS, "Write", "Edit", "NotebookEdit", "Read"].join("|");
|
|
45
|
+
|
|
46
|
+
/** Standard foreground-only denial; it tells the model how to retry. */
|
|
47
|
+
export const FOREGROUND_ONLY_DENIAL = "background tool invocation denied by the foreground-only tool policy; rerun the tool in the foreground and wait for it to finish";
|
|
48
|
+
|
|
49
|
+
/** The policy shape as parsed off argv: `workspace` is null when the flag was omitted, unlike the always-populated {@link import("../harnesses/index.mjs").ToolPolicy} the engine builds.
|
|
50
|
+
* @typedef {{foregroundOnly: boolean, maxToolOutputBytes: number, workspace: string|null, writeFiles: string[], writeRoots: string[], maxReadLines: number|null}} ParsedPolicy
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {string[]} argv
|
|
55
|
+
* @returns {ParsedPolicy}
|
|
56
|
+
*/
|
|
57
|
+
function parsePolicy(argv) {
|
|
58
|
+
const flags = parseArgs({ args: argv, options: {
|
|
59
|
+
"foreground-only": { type: "boolean", default: false },
|
|
60
|
+
"max-tool-output-bytes": { type: "string" },
|
|
61
|
+
"workspace": { type: "string" },
|
|
62
|
+
"write-file": { type: "string", multiple: true, default: [] },
|
|
63
|
+
"write-root": { type: "string", multiple: true, default: [] },
|
|
64
|
+
"max-read-lines": { type: "string" },
|
|
65
|
+
} });
|
|
66
|
+
const raw = Number(flags.values["max-tool-output-bytes"]);
|
|
67
|
+
const rawMaxReadLines = Number(flags.values["max-read-lines"]);
|
|
68
|
+
return {
|
|
69
|
+
foregroundOnly: Boolean(flags.values["foreground-only"]),
|
|
70
|
+
maxToolOutputBytes: Number.isInteger(raw) && raw > 0 ? raw : TOOL_OUTPUT_LIMIT_BYTES,
|
|
71
|
+
workspace: typeof flags.values.workspace === "string" ? flags.values.workspace : null,
|
|
72
|
+
writeFiles: /** @type {string[]} */ (flags.values["write-file"] ?? []),
|
|
73
|
+
writeRoots: /** @type {string[]} */ (flags.values["write-root"] ?? []),
|
|
74
|
+
maxReadLines: flags.values["max-read-lines"] === undefined
|
|
75
|
+
? null
|
|
76
|
+
: (Number.isInteger(rawMaxReadLines) && rawMaxReadLines > 0 ? rawMaxReadLines : null),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The hook command string registered in generated settings, carrying the
|
|
82
|
+
* policy it enforces as explicit arguments.
|
|
83
|
+
*
|
|
84
|
+
* @param {import("../harnesses/index.mjs").ToolPolicy} policy
|
|
85
|
+
* @returns {string}
|
|
86
|
+
*/
|
|
87
|
+
export function hookCommand(policy) {
|
|
88
|
+
const argv = [process.execPath, HOOK_PATH];
|
|
89
|
+
if (policy.foregroundOnly) argv.push("--foreground-only");
|
|
90
|
+
if (typeof policy.maxToolOutputBytes === "number" && policy.maxToolOutputBytes > 0) {
|
|
91
|
+
argv.push("--max-tool-output-bytes", String(policy.maxToolOutputBytes));
|
|
92
|
+
}
|
|
93
|
+
if (typeof policy.workspace === "string" && policy.workspace) argv.push("--workspace", policy.workspace);
|
|
94
|
+
for (const file of policy.writeFiles ?? []) argv.push("--write-file", file);
|
|
95
|
+
for (const root of policy.writeRoots ?? []) argv.push("--write-root", root);
|
|
96
|
+
if (typeof policy.maxReadLines === "number" && policy.maxReadLines > 0) {
|
|
97
|
+
argv.push("--max-read-lines", String(policy.maxReadLines));
|
|
98
|
+
}
|
|
99
|
+
return argv.map(shellQuote).join(" ");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The Claude-compatible `--settings` payload wiring this hook for the policy:
|
|
104
|
+
* PreToolUse denial where foreground-only applies, PostToolUse bounding where
|
|
105
|
+
* a byte limit applies.
|
|
106
|
+
*
|
|
107
|
+
* @param {import("../harnesses/index.mjs").ToolPolicy} policy
|
|
108
|
+
* @returns {{hooks: Record<string, {matcher: string, hooks: {type: "command", command: string}[]}[]>}}
|
|
109
|
+
*/
|
|
110
|
+
export function hookSettings(policy) {
|
|
111
|
+
/** @type {Record<string, {matcher: string, hooks: {type: "command", command: string}[]}[]>} */
|
|
112
|
+
const hooks = {};
|
|
113
|
+
const hasWriteScope = Boolean((policy.writeFiles ?? []).length || (policy.writeRoots ?? []).length);
|
|
114
|
+
const hasReadThreshold = typeof policy.maxReadLines === "number" && policy.maxReadLines > 0;
|
|
115
|
+
if (policy.foregroundOnly || hasWriteScope || hasReadThreshold) {
|
|
116
|
+
hooks.PreToolUse = [{ matcher: PRE_TOOL_MATCHER, hooks: [hookEntry(policy)] }];
|
|
117
|
+
}
|
|
118
|
+
if (typeof policy.maxToolOutputBytes === "number" && policy.maxToolOutputBytes > 0) {
|
|
119
|
+
hooks.PostToolUse = [{ matcher: "Bash", hooks: [hookEntry(policy)] }];
|
|
120
|
+
}
|
|
121
|
+
return { hooks };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The mechanical PreToolUse decision: the standard denial when the policy
|
|
126
|
+
* forbids what the invocation asks for, null when it may run.
|
|
127
|
+
*
|
|
128
|
+
* @param {{foregroundOnly: boolean}} policy
|
|
129
|
+
* @param {{tool_name?: unknown, tool_input?: unknown}} payload
|
|
130
|
+
* @returns {{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: string}}|null}
|
|
131
|
+
*/
|
|
132
|
+
function preToolUseDecision(policy, payload) {
|
|
133
|
+
if (policy.foregroundOnly !== true) return null;
|
|
134
|
+
const name = typeof payload?.tool_name === "string" ? payload.tool_name : "";
|
|
135
|
+
const input = payload?.tool_input;
|
|
136
|
+
const backgroundBash = name === "Bash"
|
|
137
|
+
&& Boolean(input) && typeof input === "object"
|
|
138
|
+
&& /** @type {Record<string, unknown>} */ (input).run_in_background === true;
|
|
139
|
+
if (!backgroundBash && !BACKGROUND_OUTPUT_TOOLS.includes(name)) return null;
|
|
140
|
+
return {
|
|
141
|
+
hookSpecificOutput: {
|
|
142
|
+
hookEventName: "PreToolUse",
|
|
143
|
+
permissionDecision: "deny",
|
|
144
|
+
permissionDecisionReason: FOREGROUND_ONLY_DENIAL,
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Bound the textual evidence of one tool response without changing its shape:
|
|
151
|
+
* a string stays a string, an object keeps every key, and the retained text
|
|
152
|
+
* totals at most `maxBytes` UTF-8 bytes. Null means nothing needed bounding.
|
|
153
|
+
*
|
|
154
|
+
* @param {number} maxBytes
|
|
155
|
+
* @param {unknown} toolResponse
|
|
156
|
+
* @returns {string|Record<string, unknown>|null}
|
|
157
|
+
*/
|
|
158
|
+
function boundedToolOutput(maxBytes, toolResponse) {
|
|
159
|
+
if (typeof toolResponse === "string") {
|
|
160
|
+
const bounded = truncateToolOutput(toolResponse, maxBytes);
|
|
161
|
+
return bounded === toolResponse ? null : bounded;
|
|
162
|
+
}
|
|
163
|
+
if (!toolResponse || typeof toolResponse !== "object" || Array.isArray(toolResponse)) return null;
|
|
164
|
+
const record = /** @type {Record<string, unknown>} */ (toolResponse);
|
|
165
|
+
const fields = Object.keys(record).filter((key) => typeof record[key] === "string");
|
|
166
|
+
const sizes = fields.map((key) => Buffer.byteLength(/** @type {string} */ (record[key]), "utf8"));
|
|
167
|
+
if (sizes.reduce((sum, size) => sum + size, 0) <= maxBytes) return null;
|
|
168
|
+
const budgets = allocateTextBudgets(sizes, maxBytes);
|
|
169
|
+
const updated = { ...record };
|
|
170
|
+
fields.forEach((key, index) => {
|
|
171
|
+
updated[key] = truncateToolOutput(/** @type {string} */ (record[key]), budgets[index]);
|
|
172
|
+
});
|
|
173
|
+
return updated;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Deterministic budget split across textual fields: proportional shares, with
|
|
178
|
+
* surplus from fields that fit flowing to the fields that do not.
|
|
179
|
+
*
|
|
180
|
+
* @param {number[]} sizes
|
|
181
|
+
* @param {number} maxBytes
|
|
182
|
+
* @returns {number[]}
|
|
183
|
+
*/
|
|
184
|
+
function allocateTextBudgets(sizes, maxBytes) {
|
|
185
|
+
const total = sizes.reduce((sum, size) => sum + size, 0);
|
|
186
|
+
const budgets = sizes.map((size) => Math.floor((size * maxBytes) / total));
|
|
187
|
+
let surplus = maxBytes - budgets.reduce((sum, budget) => sum + budget, 0);
|
|
188
|
+
for (let index = 0; index < sizes.length; index += 1) {
|
|
189
|
+
if (budgets[index] < sizes[index]) continue;
|
|
190
|
+
surplus += budgets[index] - sizes[index];
|
|
191
|
+
budgets[index] = sizes[index];
|
|
192
|
+
}
|
|
193
|
+
for (let index = 0; index < sizes.length && surplus > 0; index += 1) {
|
|
194
|
+
const room = sizes[index] - budgets[index];
|
|
195
|
+
if (room <= 0) continue;
|
|
196
|
+
budgets[index] += Math.min(room, surplus);
|
|
197
|
+
surplus -= Math.min(room, surplus);
|
|
198
|
+
}
|
|
199
|
+
return budgets;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* One hook decision for one parsed payload, or null when the tool result must
|
|
204
|
+
* pass through untouched. The order among the PreToolUse decisions only
|
|
205
|
+
* matters for the reason the model reads; foregroundOnly stays first because
|
|
206
|
+
* it was the first policy this hook enforced.
|
|
207
|
+
*
|
|
208
|
+
* @param {ParsedPolicy} policy
|
|
209
|
+
* @param {unknown} payload
|
|
210
|
+
* @returns {{hookSpecificOutput: Record<string, unknown>}|null}
|
|
211
|
+
*/
|
|
212
|
+
function hookDecision(policy, payload) {
|
|
213
|
+
if (!payload || typeof payload !== "object") return null;
|
|
214
|
+
const record = /** @type {Record<string, unknown>} */ (payload);
|
|
215
|
+
const event = typeof record.hook_event_name === "string"
|
|
216
|
+
? record.hook_event_name
|
|
217
|
+
: record.tool_response === undefined ? "PreToolUse" : "PostToolUse";
|
|
218
|
+
if (event === "PreToolUse") {
|
|
219
|
+
return preToolUseDecision(policy, record)
|
|
220
|
+
?? writeScopeDecision(policy, record)
|
|
221
|
+
?? readThresholdDecision(policy, record)
|
|
222
|
+
?? bashReadDecision(policy, record);
|
|
223
|
+
}
|
|
224
|
+
if (event === "PostToolUse") {
|
|
225
|
+
if (typeof record.tool_name === "string" && record.tool_name !== "Bash") return null;
|
|
226
|
+
const bounded = boundedToolOutput(policy.maxToolOutputBytes, record.tool_response);
|
|
227
|
+
return bounded === null ? null : { hookSpecificOutput: { hookEventName: "PostToolUse", updatedToolOutput: bounded } };
|
|
228
|
+
}
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* @param {import("../harnesses/index.mjs").ToolPolicy} policy
|
|
234
|
+
* @returns {{type: "command", command: string}}
|
|
235
|
+
*/
|
|
236
|
+
function hookEntry(policy) {
|
|
237
|
+
return { type: "command", command: hookCommand(policy) };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** @param {string} value @returns {string} */
|
|
241
|
+
function shellQuote(value) {
|
|
242
|
+
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** @returns {boolean} */
|
|
246
|
+
function invokedAsScript() {
|
|
247
|
+
try {
|
|
248
|
+
return Boolean(process.argv[1]) && realpathSync(process.argv[1]) === HOOK_PATH;
|
|
249
|
+
} catch {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (invokedAsScript()) {
|
|
255
|
+
const policy = parsePolicy(process.argv.slice(2));
|
|
256
|
+
let input = "";
|
|
257
|
+
process.stdin.setEncoding("utf8");
|
|
258
|
+
process.stdin.on("data", (chunk) => { input += chunk; });
|
|
259
|
+
process.stdin.on("end", () => {
|
|
260
|
+
// An unparsable payload must never break the provider: decide nothing.
|
|
261
|
+
let payload = null;
|
|
262
|
+
try {
|
|
263
|
+
payload = JSON.parse(input);
|
|
264
|
+
} catch {
|
|
265
|
+
// SyntaxError on malformed input leaves payload null; hookDecision then decides nothing, as above.
|
|
266
|
+
}
|
|
267
|
+
const decision = hookDecision(policy, payload);
|
|
268
|
+
if (decision) process.stdout.write(`${JSON.stringify(decision)}\n`);
|
|
269
|
+
});
|
|
270
|
+
}
|