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.
Files changed (144) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -0
  3. package/bin/faberun.mjs +25 -0
  4. package/integrations/claude-code/statusline-bench.sh +42 -0
  5. package/integrations/claude-code/statusline.sh +80 -0
  6. package/package.json +33 -0
  7. package/skills/faberun/SKILL.md +24 -0
  8. package/skills/faberun/references/contract.md +380 -0
  9. package/skills/faberun/references/engineering.md +29 -0
  10. package/skills/faberun/references/handoffs.md +26 -0
  11. package/skills/faberun/references/operations.md +184 -0
  12. package/skills/faberun/references/rules.md +35 -0
  13. package/skills/faberun/references/workflow.md +23 -0
  14. package/skills/init-agentkit/SKILL.md +108 -0
  15. package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
  16. package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
  17. package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
  18. package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
  19. package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
  20. package/skills/init-agentkit/templates/AGENTS.md +110 -0
  21. package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
  22. package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
  23. package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
  24. package/skills/init-agentkit/templates/docs/VISION.md +33 -0
  25. package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
  26. package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
  27. package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
  28. package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
  29. package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
  30. package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
  31. package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
  32. package/src/campaign/brief.mjs +394 -0
  33. package/src/campaign/chain.mjs +555 -0
  34. package/src/campaign/handoff.mjs +516 -0
  35. package/src/campaign/index.mjs +300 -0
  36. package/src/campaign/journal.mjs +347 -0
  37. package/src/campaign/layout.mjs +51 -0
  38. package/src/campaign/metrics-evals.mjs +25 -0
  39. package/src/campaign/metrics.mjs +517 -0
  40. package/src/campaign/projection.mjs +250 -0
  41. package/src/campaign/record.mjs +102 -0
  42. package/src/campaign/unpark.mjs +56 -0
  43. package/src/cli/brand.mjs +205 -0
  44. package/src/cli/campaign.mjs +730 -0
  45. package/src/cli/contract.mjs +67 -0
  46. package/src/cli/init.mjs +170 -0
  47. package/src/cli/launch.mjs +239 -0
  48. package/src/cli/seat.mjs +139 -0
  49. package/src/cli/setup.mjs +294 -0
  50. package/src/cli/skills.mjs +105 -0
  51. package/src/cli/update.mjs +216 -0
  52. package/src/cli.mjs +525 -0
  53. package/src/contract/articles.mjs +12 -0
  54. package/src/contract/assert.mjs +162 -0
  55. package/src/contract/definition-of-done.mjs +97 -0
  56. package/src/contract/final-verification.mjs +96 -0
  57. package/src/contract/index.mjs +641 -0
  58. package/src/contract/judge-envelope.mjs +25 -0
  59. package/src/contract/review-modes.mjs +151 -0
  60. package/src/contract/runtime.mjs +204 -0
  61. package/src/contract/schema-version.mjs +25 -0
  62. package/src/contract/scope-findings.mjs +77 -0
  63. package/src/contract/snapshot.mjs +639 -0
  64. package/src/contract/task-packet.mjs +495 -0
  65. package/src/contract/untrusted.mjs +75 -0
  66. package/src/contract/verification.mjs +185 -0
  67. package/src/contract/worker-result.mjs +138 -0
  68. package/src/engine/assignment.mjs +63 -0
  69. package/src/engine/backoff.mjs +492 -0
  70. package/src/engine/bulk-read.mjs +361 -0
  71. package/src/engine/cancel.mjs +177 -0
  72. package/src/engine/detach.mjs +101 -0
  73. package/src/engine/dispatch.mjs +752 -0
  74. package/src/engine/failover.mjs +192 -0
  75. package/src/engine/gate.mjs +183 -0
  76. package/src/engine/judge-gate.mjs +517 -0
  77. package/src/engine/lifecycle.mjs +772 -0
  78. package/src/engine/live-preflight.mjs +299 -0
  79. package/src/engine/mutation.mjs +146 -0
  80. package/src/engine/notify-queue.mjs +327 -0
  81. package/src/engine/process-identity.mjs +72 -0
  82. package/src/engine/process.mjs +774 -0
  83. package/src/engine/prompts.mjs +289 -0
  84. package/src/engine/recover.mjs +300 -0
  85. package/src/engine/result-file.mjs +222 -0
  86. package/src/engine/resume.mjs +635 -0
  87. package/src/engine/retry.mjs +334 -0
  88. package/src/engine/review.mjs +228 -0
  89. package/src/engine/run-command.mjs +287 -0
  90. package/src/engine/run-identity.mjs +411 -0
  91. package/src/engine/runtime-discovery.mjs +235 -0
  92. package/src/engine/scheduler.mjs +526 -0
  93. package/src/engine/scope.mjs +378 -0
  94. package/src/engine/settle.mjs +207 -0
  95. package/src/engine/state.mjs +148 -0
  96. package/src/engine/supervise.mjs +713 -0
  97. package/src/engine/verify.mjs +167 -0
  98. package/src/harnesses/agy/index.mjs +62 -0
  99. package/src/harnesses/catalogue.mjs +509 -0
  100. package/src/harnesses/claude/index.mjs +90 -0
  101. package/src/harnesses/codex/index.mjs +87 -0
  102. package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
  103. package/src/harnesses/dsh/index.mjs +210 -0
  104. package/src/harnesses/dsh/runner.mjs +259 -0
  105. package/src/harnesses/exec-jsonl/index.mjs +788 -0
  106. package/src/harnesses/index.mjs +508 -0
  107. package/src/harnesses/protocol.mjs +531 -0
  108. package/src/harnesses/replay/bin.mjs +386 -0
  109. package/src/harnesses/replay/index.mjs +238 -0
  110. package/src/harnesses/zcode/index.mjs +276 -0
  111. package/src/host/config.mjs +87 -0
  112. package/src/host/home.mjs +149 -0
  113. package/src/host/package.mjs +23 -0
  114. package/src/host/preflight.mjs +520 -0
  115. package/src/host/tool-policy-decisions.mjs +341 -0
  116. package/src/host/tool-policy-hook.mjs +270 -0
  117. package/src/notify/index.mjs +359 -0
  118. package/src/notify/os-macos.mjs +81 -0
  119. package/src/repo/declared-paths.mjs +220 -0
  120. package/src/repo/integrate.mjs +546 -0
  121. package/src/repo/scope-closure.mjs +665 -0
  122. package/src/repo/signal-block.mjs +16 -0
  123. package/src/repo/signal.mjs +222 -0
  124. package/src/repo/source-identity.mjs +295 -0
  125. package/src/repo/workspace.mjs +557 -0
  126. package/src/repo/worktree.mjs +352 -0
  127. package/src/report/final.mjs +200 -0
  128. package/src/report/metrics-report.mjs +99 -0
  129. package/src/report/next.mjs +383 -0
  130. package/src/report/render.mjs +716 -0
  131. package/src/run/disk-gc.mjs +251 -0
  132. package/src/run/lock.mjs +329 -0
  133. package/src/run/node-store.mjs +62 -0
  134. package/src/run/operations.mjs +286 -0
  135. package/src/run/store.mjs +187 -0
  136. package/src/run/usage.mjs +337 -0
  137. package/src/seat/harnesses.mjs +83 -0
  138. package/src/seat/index.mjs +239 -0
  139. package/src/seat/tmux.mjs +208 -0
  140. package/src/util.mjs +0 -0
  141. package/src/web/api.mjs +371 -0
  142. package/src/web/boundary.mjs +88 -0
  143. package/src/web/index.html +299 -0
  144. 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
+ }