thinkpool-pair 0.7.364 → 0.7.366

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 (116) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +20 -0
  3. package/abort-turn-barrier.mjs +1 -23
  4. package/account.mjs +1 -1437
  5. package/acp-client.mjs +1 -140
  6. package/agent-detect.mjs +1 -28
  7. package/agent-notify.mjs +1 -142
  8. package/agent-visibility.mjs +1 -67
  9. package/auth-store.mjs +1 -120
  10. package/bridge.mjs +1 -6266
  11. package/byok-detect.mjs +1 -126
  12. package/claude-command-catalog.mjs +1 -91
  13. package/claude-session.mjs +1 -1519
  14. package/code-event-contract.mjs +1 -118
  15. package/codex-app-server.mjs +1 -340
  16. package/codex-commands.mjs +1 -83
  17. package/codex-event-mapper.mjs +1 -232
  18. package/codex-images.mjs +1 -69
  19. package/codex-mcp-http.mjs +1 -131
  20. package/codex-session.mjs +1 -1295
  21. package/command-catalog.mjs +1 -118
  22. package/command-guidance.mjs +1 -8
  23. package/context-contract.mjs +1 -95
  24. package/context-windows.mjs +1 -107
  25. package/cross-terminal.mjs +1 -789
  26. package/cumulative-event-relay.mjs +1 -53
  27. package/design-edit.mjs +1 -424
  28. package/design-source-contract.mjs +1 -4
  29. package/direct-pair-room.mjs +1 -57
  30. package/dispatch-lease.mjs +1 -37
  31. package/dispatch-permission-cleanup.mjs +1 -86
  32. package/edit-diff.mjs +1 -136
  33. package/error-recovery.mjs +1 -50
  34. package/event-bounds.mjs +1 -121
  35. package/event-delivery-queue.mjs +1 -60
  36. package/event-id.mjs +1 -549
  37. package/evidence-citations.mjs +1 -50
  38. package/evidence-compact.mjs +1 -11
  39. package/flow-assembly.mjs +1 -196
  40. package/flow-budget.mjs +1 -84
  41. package/flow-conductor.mjs +1 -259
  42. package/flow-context-store.mjs +1 -387
  43. package/flow-host-revert.mjs +1 -42
  44. package/flow-models.mjs +1 -139
  45. package/flow-preview.mjs +1 -148
  46. package/flow-receipt.mjs +1 -122
  47. package/flow-redispatch.mjs +1 -72
  48. package/flow-review-gate.mjs +1 -402
  49. package/flow-review-reflect.mjs +1 -115
  50. package/flow-review.mjs +1 -152
  51. package/flow-scope-evidence.mjs +1 -117
  52. package/flow-skill-registry.mjs +1 -140
  53. package/flow-task-graph.mjs +1 -562
  54. package/flow-worktree.mjs +1 -71
  55. package/git-diff-report.mjs +1 -121
  56. package/hermes-delegation-guard.mjs +1 -14
  57. package/hermes-event-mapper.mjs +1 -194
  58. package/hermes-isolation.mjs +1 -61
  59. package/hermes-model-cache.mjs +1 -54
  60. package/hermes-policy.mjs +1 -92
  61. package/hermes-probe.mjs +1 -57
  62. package/hermes-session.mjs +1 -683
  63. package/hermes-setup.mjs +1 -167
  64. package/host-memory.mjs +1 -116
  65. package/interrupted-resume.mjs +1 -95
  66. package/keep-awake.mjs +1 -148
  67. package/key-shape.mjs +1 -49
  68. package/lane-continuation.mjs +1 -83
  69. package/lane-lifecycle.mjs +1 -189
  70. package/lane-worktree.mjs +1 -77
  71. package/launcher.mjs +1 -354
  72. package/mcp-flight-recorder.mjs +1 -79
  73. package/mockup-delivery.mjs +1 -57
  74. package/model-prices.mjs +1 -113
  75. package/package.json +13 -4
  76. package/pair-bus.mjs +1 -98
  77. package/pair-control-authority.mjs +1 -89
  78. package/past-work-search.mjs +1 -105
  79. package/plan-meters.mjs +1 -144
  80. package/presence.mjs +1 -191
  81. package/privacy-report.mjs +1 -108
  82. package/provider-resilience.mjs +1 -356
  83. package/provider.mjs +1 -133
  84. package/providers.mjs +1 -491
  85. package/publish-guard.mjs +2 -0
  86. package/publish-manifest.json +129 -0
  87. package/question-response.mjs +1 -58
  88. package/reap-terminal.mjs +1 -68
  89. package/recap.mjs +1 -297
  90. package/replay-transport.mjs +1 -64
  91. package/repo-search.mjs +1 -190
  92. package/review-check.mjs +1 -182
  93. package/runtime-contract.mjs +1 -93
  94. package/runtime-registry.mjs +1 -64
  95. package/runtime-session.mjs +1 -20
  96. package/scheduled-run-admission.mjs +1 -364
  97. package/scheduled-runs.mjs +1 -268
  98. package/sdk-admission.mjs +1 -9
  99. package/sdk-smoke.mjs +1 -61
  100. package/serve-consent.mjs +1 -118
  101. package/serve-dir.mjs +1 -40
  102. package/service.mjs +1 -877
  103. package/session-store.mjs +1 -484
  104. package/side-lane.mjs +1 -63
  105. package/supabase-key.mjs +1 -176
  106. package/supervisor-ready.mjs +1 -57
  107. package/switch-provider.mjs +1 -84
  108. package/terminal-name.mjs +1 -359
  109. package/terminal-row-reconcile.mjs +1 -70
  110. package/thinkpool-prompt-contracts.mjs +1 -85
  111. package/thinkpool-room-prompt.mjs +1 -187
  112. package/transcript-sanitize.mjs +1 -332
  113. package/turn-stall.mjs +1 -61
  114. package/update-gate.mjs +1 -53
  115. package/viewport.mjs +1 -810
  116. package/worker-completion.mjs +1 -57
@@ -1,402 +1 @@
1
- // Thinkpool FlowS5 adversarial reviewer tool-call gate.
2
- //
3
- // Pure decision function: given a reviewer lane's tool call, decide whether to allow or
4
- // deny it. No fs side-effects except realpathSync (injectable via _realpath for tests).
5
- //
6
- // Spec: docs/specs/2026-06-30-flow-build-s5-reviewer-writeblock.md (S5, slice 1).
7
- //
8
- // Rules:
9
- // READ-ONLY tools (Read, Grep, Glob, LS, …) → ALLOW (always)
10
- // Edit / Write / NotebookEdit → structural path check against sliceRoot:
11
- // • file_path resolves under sliceRoot AND is NOT verdictFile → DENY
12
- // • file_path IS verdictFile (FLOW_REVIEW.json) → ALLOW (unless it symlinks into slice)
13
- // Bash → ALLOW by default (reviewer MUST run tests); DENY on write-to-slice heuristics
14
- // Unknown tools → DENY (conservative; add to READ_ONLY_TOOLS if truly non-mutating)
15
- //
16
- // Path-robustness:
17
- // • '..' traversal: caught by path.resolve() normalization.
18
- // • Absolute-escape: caught by path.resolve() normalization.
19
- // • Symlink escape: caught by realpathSync() (injectable for tests). A write whose REAL
20
- // resolved path lands under sliceRoot is denied even through a symlink.
21
- // • Symlinked verdictFile pointing into the slice is denied.
22
- // • Both canonical (realpathSync) and input (path.resolve) forms of sliceRoot are checked
23
- // to handle cases where sliceRoot itself is a symlink (e.g. macOS /tmp → /private/tmp).
24
- //
25
- // BASH LIMITATION: Bash write-detection is HEURISTIC (best-effort pattern matching), unlike
26
- // Edit/Write/NotebookEdit which have a structural file_path field. Shell quoting, variable
27
- // expansion, heredocs, subshells, and aliases can bypass these patterns. The reviewer prompt
28
- // already instructs it not to modify the slice; this gate is a structural backstop for the
29
- // common cases, not a complete shell parser. If precise Bash blocking is required, use a
30
- // wrapper that translates shell commands into structured tool calls.
31
- //
32
- // ACCEPTED LIMITATIONS (do not add fragile parsers for these):
33
- // • python3 -c '...' / node -e '...' inline scripts: scripted writes embedded in string
34
- // literals are not parsed — detecting them reliably requires a full language parser.
35
- // • bare `git commit` / `git add .` with no explicit slice path: the gate detects git
36
- // mutations only when sliceRoot appears literally in the command (via -C flag or path
37
- // arg). A bare git command that relies on cwd is NOT caught by this heuristic.
38
- // REAL MITIGATION: the reviewer lane runs in its OWN git worktree (not the slice's).
39
- // Bare cwd-relative git mutations and inline scripted writes therefore affect the
40
- // reviewer's worktree, not the slice. The wiring layer that provisions the reviewer lane
41
- // MUST guarantee this worktree isolation — this gate cannot substitute for it.
42
-
43
- import { realpathSync as nodeRealpathSync, readlinkSync, lstatSync } from 'node:fs'
44
- import path from 'node:path'
45
-
46
- // ── Tool classification ───────────────────────────────────────────────────────
47
-
48
- // Tools that cannot mutate files — always allowed for a reviewer.
49
- const READ_ONLY_TOOLS = new Set([
50
- 'Read', 'Grep', 'Glob', 'LS',
51
- // Other non-mutating Claude Code tools:
52
- 'WebFetch', 'WebSearch', 'Search', 'TodoRead', 'Explore',
53
- ])
54
-
55
- // Tools that write files — must have file_path checked before allowing.
56
- const FILE_WRITE_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
57
-
58
- // Git subcommands that mutate the working tree.
59
- const GIT_MUTATING_RE = /\b(?:add|commit|apply|checkout|reset|revert|rm|clean|merge|rebase|cherry-pick|stash)\b/
60
-
61
- // Shell commands that write to a path positional argument (HEURISTIC — see BASH LIMITATION).
62
- // cp and mv are handled separately via CP_MV_RE — only their DESTINATION is checked,
63
- // so that reading FROM the slice (e.g. cp /slice/f /tmp/x) is allowed.
64
- const BASH_WRITE_CMD_RE = /\b(?:rm|tee|truncate|dd|install)\b/
65
-
66
- // cp/mv require destination-aware analysis — see _assessBashCommand.
67
- const CP_MV_RE = /\b(?:cp|mv)\b/
68
-
69
- // sed in-place edit.
70
- const SED_INPLACE_RE = /\bsed\b[^|;]*-i\b/
71
-
72
- // ── reviewGateDecision ────────────────────────────────────────────────────────
73
-
74
- /**
75
- * Decide whether a reviewer lane's tool call is allowed.
76
- *
77
- * @param {object} opts
78
- * @param {string} opts.toolName Tool name ('Read', 'Write', 'Bash', …).
79
- * @param {object} [opts.input] Tool input (e.g. { file_path } or { command }).
80
- * @param {string} opts.sliceRoot Absolute path of the directory tree under review.
81
- * @param {string} [opts.verdictFile] Absolute path where reviewer writes FLOW_REVIEW.json.
82
- * Writes to this exact path are ALLOWED unless it
83
- * symlinks back into sliceRoot.
84
- * @param {function}[opts._realpath] Injectable realpathSync (default: node:fs realpathSync).
85
- * @returns {{ allow: boolean, reason: string }}
86
- */
87
- export function reviewGateDecision ({
88
- toolName,
89
- input = {},
90
- sliceRoot,
91
- verdictFile,
92
- _realpath = nodeRealpathSync,
93
- }) {
94
- if (!sliceRoot) {
95
- return { allow: false, reason: 'sliceRoot not provided — gate cannot assess containment; denied' }
96
- }
97
-
98
- // Resolve both forms of sliceRoot so comparisons work whether or not sliceRoot is
99
- // itself a symlink (e.g. macOS /tmp → /private/tmp).
100
- const inputSliceRoot = path.resolve(sliceRoot)
101
- let canonicalSliceRoot
102
- try {
103
- canonicalSliceRoot = _realpath(sliceRoot)
104
- } catch {
105
- canonicalSliceRoot = inputSliceRoot
106
- }
107
-
108
- // ── READ-ONLY TOOLS ───────────────────────────────────────────────────
109
- if (READ_ONLY_TOOLS.has(toolName)) {
110
- return { allow: true, reason: `${toolName} is a read-only tool — allowed` }
111
- }
112
-
113
- // ── FILE WRITE TOOLS ──────────────────────────────────────────────────
114
- if (FILE_WRITE_TOOLS.has(toolName)) {
115
- const filePath = input.file_path
116
- if (!filePath) {
117
- return {
118
- allow: false,
119
- reason: `${toolName} has no file_path — cannot assess containment; denied conservatively`,
120
- }
121
- }
122
- return _assessFilePath({
123
- filePath, toolName,
124
- inputSliceRoot, canonicalSliceRoot,
125
- verdictFile, _realpath,
126
- })
127
- }
128
-
129
- // ── BASH ──────────────────────────────────────────────────────────────
130
- if (toolName === 'Bash') {
131
- const cmd = typeof input.command === 'string' ? input.command : ''
132
- return _assessBashCommand({ cmd, inputSliceRoot, canonicalSliceRoot })
133
- }
134
-
135
- // ── UNKNOWN TOOLS ─────────────────────────────────────────────────────
136
- // Conservative default: deny unknown tools — they may be mutating.
137
- // To allow a new read-only tool, add it to READ_ONLY_TOOLS above.
138
- return {
139
- allow: false,
140
- reason: `Unknown tool "${toolName}" — denied by default (not in READ_ONLY_TOOLS; add if truly read-only)`,
141
- }
142
- }
143
-
144
- // ── _assessFilePath ───────────────────────────────────────────────────────────
145
-
146
- function _assessFilePath ({
147
- filePath, toolName,
148
- inputSliceRoot, canonicalSliceRoot,
149
- verdictFile, _realpath,
150
- }) {
151
- const resolved = path.resolve(filePath)
152
-
153
- // Is this a write to the verdictFile (FLOW_REVIEW.json)?
154
- if (verdictFile) {
155
- const resolvedVerdict = path.resolve(verdictFile)
156
- if (resolved === resolvedVerdict) {
157
- // B4 fix: check resolved path FIRST — before any symlink/realpath logic.
158
- // If verdictFile's resolved path is inside sliceRoot, deny regardless of whether
159
- // the file exists yet. The old ENOENT catch path ("no symlink possible; allow")
160
- // was the hole: a non-existent verdictFile inside sliceRoot slipped through.
161
- if (_isUnder(resolvedVerdict, canonicalSliceRoot) || _isUnder(resolvedVerdict, inputSliceRoot)) {
162
- return {
163
- allow: false,
164
- reason: `verdictFile "${verdictFile}" resolves inside sliceRoot — denied`,
165
- }
166
- }
167
- // Secondary check: deny if an existing verdictFile is a symlink INTO the slice.
168
- // (Handles indirect containment via symlinks that point inside sliceRoot.)
169
- try {
170
- const realVerdict = _realpath(verdictFile)
171
- if (_isUnder(realVerdict, canonicalSliceRoot) || _isUnder(realVerdict, inputSliceRoot)) {
172
- return {
173
- allow: false,
174
- reason: `verdictFile "${verdictFile}" is a symlink resolving to "${realVerdict}" inside sliceRoot — denied`,
175
- }
176
- }
177
- } catch {
178
- // verdictFile doesn't exist yet — realpathSync(verdictFile) threw ENOENT.
179
- // That does NOT prove "no symlink possible": a symlinked ANCESTOR directory (or a
180
- // dangling symlink named FLOW_REVIEW.json) can still resolve the write into the slice.
181
- // Realpath the nearest EXISTING ancestor and re-check containment of the full target.
182
- const realAncestor = _realpathNearestAncestor(resolvedVerdict, _realpath)
183
- if (realAncestor && (_isUnder(realAncestor, canonicalSliceRoot) || _isUnder(realAncestor, inputSliceRoot))) {
184
- return {
185
- allow: false,
186
- reason: `verdictFile "${verdictFile}" resolves (via a symlinked path component) to "${realAncestor}" inside sliceRoot — denied`,
187
- }
188
- }
189
- }
190
- return { allow: true, reason: `write to verdictFile (${path.basename(verdictFile)}) — allowed` }
191
- }
192
- }
193
-
194
- // Not the verdictFile. Check whether the write target lands inside the slice.
195
-
196
- // 1. String-level check — catches '..' traversal and absolute paths into sliceRoot.
197
- if (_isUnder(resolved, canonicalSliceRoot) || _isUnder(resolved, inputSliceRoot)) {
198
- return {
199
- allow: false,
200
- reason: `${toolName} to "${filePath}" (resolved: "${resolved}") is inside sliceRoot — denied`,
201
- }
202
- }
203
-
204
- // 2. Realpath check — catches symlinks whose TARGET is inside sliceRoot.
205
- try {
206
- const real = _realpath(filePath)
207
- if (_isUnder(real, canonicalSliceRoot) || _isUnder(real, inputSliceRoot)) {
208
- return {
209
- allow: false,
210
- reason: `${toolName} to "${filePath}" symlinks to "${real}" inside sliceRoot — denied`,
211
- }
212
- }
213
- } catch {
214
- // ENOENT — the file (or a component of it) doesn't exist yet, so realpathSync(filePath)
215
- // threw before it could follow any symlinked ANCESTOR directory. Step 1 only normalized
216
- // '..' — it did NOT resolve a symlinked path component (e.g. scratch/link → sliceRoot,
217
- // then Write scratch/link/PWNED.js lands in the slice). Realpath the nearest EXISTING
218
- // ancestor, re-join the un-created tail, and re-check containment.
219
- const realAncestor = _realpathNearestAncestor(resolved, _realpath)
220
- if (realAncestor && (_isUnder(realAncestor, canonicalSliceRoot) || _isUnder(realAncestor, inputSliceRoot))) {
221
- return {
222
- allow: false,
223
- reason: `${toolName} to "${filePath}" resolves (via a symlinked path component) to "${realAncestor}" inside sliceRoot — denied`,
224
- }
225
- }
226
- }
227
-
228
- return { allow: true, reason: `${toolName} to "${filePath}" is outside sliceRoot — allowed` }
229
- }
230
-
231
- // ── _realpathNearestAncestor ────────────────────────────────────────────────────
232
- // For a target path that does NOT fully exist (realpathSync threw ENOENT), walk UP to the
233
- // nearest existing ancestor directory, realpath-resolve THAT (following any symlinked
234
- // component), then re-join the remaining un-created tail. The returned absolute path is what
235
- // the write would ACTUALLY land on once created — which is what containment must be tested
236
- // against. Returns null only if even the filesystem root can't be resolved (never expected).
237
- function _realpathNearestAncestor (resolved, _realpath) {
238
- // If the LEAF itself is a symlink (existing but DANGLING — its target doesn't exist, so
239
- // realpathSync(resolved) threw), follow the link one hop and resolve from THERE. This is the
240
- // dangling-verdict-symlink case: scratch/FLOW_REVIEW.json → <sliceRoot>/evil.js. path.resolve
241
- // does NOT follow symlinks, so without this the write's true landing spot is invisible.
242
- try {
243
- if (lstatSync(resolved).isSymbolicLink()) {
244
- const linkTarget = readlinkSync(resolved)
245
- const absTarget = path.isAbsolute(linkTarget)
246
- ? linkTarget
247
- : path.resolve(path.dirname(resolved), linkTarget)
248
- // Recurse: the target may itself be non-existent / chained through more symlinks.
249
- try {
250
- return _realpath(absTarget)
251
- } catch {
252
- return _realpathNearestAncestor(absTarget, _realpath)
253
- }
254
- }
255
- } catch {
256
- // lstat failed — leaf doesn't exist at all; fall through to ancestor walk below.
257
- }
258
-
259
- let ancestor = path.dirname(resolved)
260
- const tail = [path.basename(resolved)]
261
- // Walk up until an ancestor resolves, or we hit the root (dirname is a fixed point).
262
- while (true) {
263
- try {
264
- const realAncestor = _realpath(ancestor)
265
- return path.join(realAncestor, ...tail.reverse())
266
- } catch {
267
- const parent = path.dirname(ancestor)
268
- if (parent === ancestor) return null // reached root without resolving — give up
269
- tail.push(path.basename(ancestor))
270
- ancestor = parent
271
- }
272
- }
273
- }
274
-
275
- // ── _assessBashCommand ────────────────────────────────────────────────────────
276
- // HEURISTIC — see BASH LIMITATION comment at top of file.
277
-
278
- function _assessBashCommand ({ cmd, inputSliceRoot, canonicalSliceRoot }) {
279
- // Returns true if `str` (a path extracted from the command) resolves under sliceRoot.
280
- function inSlice (str) {
281
- if (!str) return false
282
- const abs = path.isAbsolute(str) ? str : path.resolve(str)
283
- return _isUnder(abs, canonicalSliceRoot) || _isUnder(abs, inputSliceRoot)
284
- }
285
-
286
- // Returns true if either form of sliceRoot appears literally in the command string.
287
- // Used for commands that embed the target path as a positional argument.
288
- function cmdContainsSlice () {
289
- return cmd.includes(canonicalSliceRoot) || cmd.includes(inputSliceRoot)
290
- }
291
-
292
- // ── Output redirection (> or >>) into sliceRoot ───────────────────────
293
- // HEURISTIC: misses paths with spaces and shell variable expansion.
294
- // Negative lookbehind excludes '<' so '<<' (heredoc) and '<>' (r/w) are not
295
- // misread as write redirections.
296
- // Group 1: optional surrounding quote char; group 2: the path (stripped of quotes).
297
- // This catches both `> /slice/f` (unquoted) and `> "/slice/f"` / `> '/slice/f'` (D1/D2).
298
- const redirRe = /(?<![<])>+\s*(['"]?)([^\s;|&'"]+)\1/g
299
- let m
300
- while ((m = redirRe.exec(cmd)) !== null) {
301
- if (inSlice(m[2])) {
302
- return {
303
- allow: false,
304
- reason: `Bash output redirection to "${m[2]}" targets sliceRoot — denied`,
305
- }
306
- }
307
- }
308
-
309
- // ── git mutating commands targeting sliceRoot ─────────────────────────
310
- if (/\bgit\b/.test(cmd) && GIT_MUTATING_RE.test(cmd)) {
311
- // Explicit -C flag: `git -C <dir> <verb>`
312
- const cwdMatch = cmd.match(/-C\s+(['"]?)([^\s'"]+)\1/)
313
- if (cwdMatch) {
314
- const cwdArg = cwdMatch[2]
315
- if (inSlice(cwdArg)) {
316
- return {
317
- allow: false,
318
- reason: `Bash "git -C ${cwdArg} ..." targets sliceRoot — denied`,
319
- }
320
- }
321
- }
322
- // Fallback: sliceRoot path appears anywhere in a mutating git command.
323
- if (cmdContainsSlice()) {
324
- return { allow: false, reason: 'Bash git mutating command references sliceRoot — denied' }
325
- }
326
- }
327
-
328
- // ── cp/mv: only deny when DESTINATION (last positional arg) is inside sliceRoot ──
329
- // C5 fix: the old approach (cmdContainsSlice) wrongly denied `cp /slice/f /tmp/x`
330
- // because sliceRoot appeared as the SOURCE. Only the destination can cause a write.
331
- if (CP_MV_RE.test(cmd)) {
332
- const tokens = cmd.trim().split(/\s+/)
333
- // Skip the verb (i=0) and option flags; remaining tokens are positional path args.
334
- const pathArgs = tokens.filter((t, i) => i > 0 && !t.startsWith('-'))
335
- const dest = pathArgs[pathArgs.length - 1]
336
- if (dest && inSlice(dest)) {
337
- const verb = (cmd.match(CP_MV_RE) || [''])[0]
338
- return {
339
- allow: false,
340
- reason: `Bash "${verb}" destination "${dest}" targets sliceRoot — denied`,
341
- }
342
- }
343
- }
344
-
345
- // ── rm / tee / truncate / dd / install targeting sliceRoot ──
346
- if (BASH_WRITE_CMD_RE.test(cmd) && cmdContainsSlice()) {
347
- const verb = (cmd.match(BASH_WRITE_CMD_RE) || [''])[0]
348
- return {
349
- allow: false,
350
- reason: `Bash write command "${verb}" references sliceRoot — denied`,
351
- }
352
- }
353
-
354
- // ── sed -i targeting sliceRoot ────────────────────────────────────────
355
- if (SED_INPLACE_RE.test(cmd) && cmdContainsSlice()) {
356
- return { allow: false, reason: 'Bash "sed -i" references sliceRoot — denied' }
357
- }
358
-
359
- return { allow: true, reason: 'Bash command allowed (no write-to-slice pattern detected)' }
360
- }
361
-
362
- // ── reviewGatePreToolDecision ───────────────────────────────────────────────────
363
- // S5 (slice 1b) — the LIVE PreToolUse wiring seam. Lives HERE (not in claude-session.mjs)
364
- // so it stays SDK-free and unit-testable: claude-session.mjs pulls the Claude Agent SDK at
365
- // module load, which isn't resolvable in the test env. The PreToolUse hook imports and calls
366
- // this; the integration test imports it directly.
367
- //
368
- // `reviewGate` is a per-review-lane closure (bridge.mjs folds reviewGateDecision over the
369
- // reviewed slices' worktree roots); it is NULL on builder lanes. Returns:
370
- // • null → fall through to the normal permission path (read / run-test / verdict).
371
- // • deny payload → a terminal PreToolUse `permissionDecision: 'deny'` the hook returns
372
- // immediately — BEFORE the auto-allow/bypass path, so the block is
373
- // structural even in the lane's bypassPermissions mode.
374
- // A throwing reviewGate fails CLOSED (deny) — never a silent allow. Builder lanes
375
- // (reviewGate=null) always return null here → their toolset is UNCHANGED.
376
- export function reviewGatePreToolDecision ({ reviewGate, toolName, toolInput }) {
377
- if (!reviewGate) return null
378
- let g = null
379
- try {
380
- g = reviewGate({ toolName, input: toolInput })
381
- } catch (e) {
382
- g = { allow: false, reason: `review gate error — denied: ${e?.message || e}` }
383
- }
384
- if (g && g.allow === false) {
385
- return {
386
- continue: true,
387
- hookSpecificOutput: {
388
- hookEventName: 'PreToolUse',
389
- permissionDecision: 'deny',
390
- permissionDecisionReason: `Blocked (Flow review lane): ${g.reason} You REVIEW the slice — you do not edit it. Read it, run it to reproduce acceptance, then emit your verdict by Writing FLOW_REVIEW.json.`,
391
- },
392
- }
393
- }
394
- return null
395
- }
396
-
397
- // ── helpers ───────────────────────────────────────────────────────────────────
398
-
399
- // Returns true if `filePath` equals `dir` OR is strictly inside it.
400
- function _isUnder (filePath, dir) {
401
- return filePath === dir || filePath.startsWith(dir + path.sep)
402
- }
1
+ import{realpathSync as e,readlinkSync as t,lstatSync as o}from"node:fs";import n from"node:path";const i=new Set(["Read","Grep","Glob","LS","WebFetch","WebSearch","Search","TodoRead","Explore"]),r=new Set(["Edit","Write","NotebookEdit"]),s=/\b(?:add|commit|apply|checkout|reset|revert|rm|clean|merge|rebase|cherry-pick|stash)\b/,a=/\b(?:rm|tee|truncate|dd|install)\b/,l=/\b(?:cp|mv)\b/,c=/\bsed\b[^|;]*-i\b/;export function reviewGateDecision({toolName:t,input:o={},sliceRoot:m,verdictFile:f,_realpath:h=e}){if(!m)return{allow:!1,reason:"sliceRoot not provided gate cannot assess containment; denied"};const p=n.resolve(m);let w;try{w=h(m)}catch{w=p}if(i.has(t))return{allow:!0,reason:`${t} is a read-only tool — allowed`};if(r.has(t)){const e=o.file_path;return e?function({filePath:e,toolName:t,inputSliceRoot:o,canonicalSliceRoot:i,verdictFile:r,_realpath:s}){const a=n.resolve(e);if(r){const e=n.resolve(r);if(a===e){if(u(e,i)||u(e,o))return{allow:!1,reason:`verdictFile "${r}" resolves inside sliceRoot — denied`};try{const e=s(r);if(u(e,i)||u(e,o))return{allow:!1,reason:`verdictFile "${r}" is a symlink resolving to "${e}" inside sliceRoot — denied`}}catch{const t=d(e,s);if(t&&(u(t,i)||u(t,o)))return{allow:!1,reason:`verdictFile "${r}" resolves (via a symlinked path component) to "${t}" inside sliceRoot — denied`}}return{allow:!0,reason:`write to verdictFile (${n.basename(r)}) — allowed`}}}if(u(a,i)||u(a,o))return{allow:!1,reason:`${t} to "${e}" (resolved: "${a}") is inside sliceRoot — denied`};try{const n=s(e);if(u(n,i)||u(n,o))return{allow:!1,reason:`${t} to "${e}" symlinks to "${n}" inside sliceRoot — denied`}}catch{const n=d(a,s);if(n&&(u(n,i)||u(n,o)))return{allow:!1,reason:`${t} to "${e}" resolves (via a symlinked path component) to "${n}" inside sliceRoot — denied`}}return{allow:!0,reason:`${t} to "${e}" is outside sliceRoot — allowed`}}({filePath:e,toolName:t,inputSliceRoot:p,canonicalSliceRoot:w,verdictFile:f,_realpath:h}):{allow:!1,reason:`${t} has no file_path — cannot assess containment; denied conservatively`}}return"Bash"===t?function({cmd:e,inputSliceRoot:t,canonicalSliceRoot:o}){function i(e){if(!e)return!1;const i=n.isAbsolute(e)?e:n.resolve(e);return u(i,o)||u(i,t)}function r(){return e.includes(o)||e.includes(t)}const d=/(?<![<])>+\s*(['"]?)([^\s;|&'"]+)\1/g;let m;for(;null!==(m=d.exec(e));)if(i(m[2]))return{allow:!1,reason:`Bash output redirection to "${m[2]}" targets sliceRoot — denied`};if(/\bgit\b/.test(e)&&s.test(e)){const t=e.match(/-C\s+(['"]?)([^\s'"]+)\1/);if(t){const e=t[2];if(i(e))return{allow:!1,reason:`Bash "git -C ${e} ..." targets sliceRoot — denied`}}if(r())return{allow:!1,reason:"Bash git mutating command references sliceRoot — denied"}}if(l.test(e)){const t=e.trim().split(/\s+/).filter((e,t)=>t>0&&!e.startsWith("-")),o=t[t.length-1];if(o&&i(o))return{allow:!1,reason:`Bash "${(e.match(l)||[""])[0]}" destination "${o}" targets sliceRoot — denied`}}return a.test(e)&&r()?{allow:!1,reason:`Bash write command "${(e.match(a)||[""])[0]}" references sliceRoot — denied`}:c.test(e)&&r()?{allow:!1,reason:'Bash "sed -i" references sliceRoot — denied'}:{allow:!0,reason:"Bash command allowed (no write-to-slice pattern detected)"}}({cmd:"string"==typeof o.command?o.command:"",inputSliceRoot:p,canonicalSliceRoot:w}):{allow:!1,reason:`Unknown tool "${t}" — denied by default (not in READ_ONLY_TOOLS; add if truly read-only)`}}function d(e,i){try{if(o(e).isSymbolicLink()){const o=t(e),r=n.isAbsolute(o)?o:n.resolve(n.dirname(e),o);try{return i(r)}catch{return d(r,i)}}}catch{}let r=n.dirname(e);const s=[n.basename(e)];for(;;)try{const e=i(r);return n.join(e,...s.reverse())}catch{const e=n.dirname(r);if(e===r)return null;s.push(n.basename(r)),r=e}}export function reviewGatePreToolDecision({reviewGate:e,toolName:t,toolInput:o}){if(!e)return null;let n=null;try{n=e({toolName:t,input:o})}catch(e){n={allow:!1,reason:`review gate error — denied: ${e?.message||e}`}}return n&&!1===n.allow?{continue:!0,hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:`Blocked (Flow review lane): ${n.reason} You REVIEW the slice — you do not edit it. Read it, run it to reproduce acceptance, then emit your verdict by Writing FLOW_REVIEW.json.`}}:null}function u(e,t){return e===t||e.startsWith(t+n.sep)}
@@ -1,115 +1 @@
1
- /* ─────────────────────────────────────────────────────────────
2
- flow-review-reflect.mjs — the BOUNDED reflection governor for the
3
- verifying reviewer lane (heist E1 + P3).
4
- Spec: docs/specs/2026-07-02-e1p3-verifying-reviewer.md
5
- Parents: Emergent steal E1 (self-test loop) + Cursor steal P3 (Bugbot).
6
-
7
- The existing adversarial reviewer (bridge/flow-review.mjs) runs the slice
8
- once and emits a verdict. E1 makes that a bounded, budget-capped SELF-TEST
9
- loop: the reviewer may dig across several rounds (re-run, hit edge cases,
10
- reproduce the acceptance proof harder) — but it MUST stop at a hard ceiling
11
- and hand off to the humans, never loop unbounded. That unbounded self-heal
12
- loop is exactly Emergent's most-hated wound (billing a runaway fix-loop back
13
- to the user); this governor is the structural guarantee we never have it.
14
-
15
- Two independent stop conditions, both enforced here (not prompted):
16
- • ROUND ceiling — never more than maxRounds hunt rounds (anti-Emergent).
17
- • BUDGET ceiling — never spend past the room/lane cap (nexos steal #1 /
18
- cost-guard; the review lane SPENDS inference, so it inherits N1).
19
-
20
- Pure + deterministic — the whole loop-control decision in one function so it
21
- is unit-testable without a live Flow room (the reviewable seam; the live
22
- wiring into flow-review.mjs + bridge.mjs is the held integration slice,
23
- same split that shipped S5's reviewGateDecision).
24
-
25
- P3 (pair co-adjudication): when the loop stops, the outcome is not the end —
26
- `surfaceToPair` says the verdict/findings must go to the ROOM for the two
27
- humans + Pool to challenge, rather than silently auto-driving the gate. A
28
- solo Bugbot's verdict is final; ours is a prompt for the pair to argue.
29
- ───────────────────────────────────────────────────────────── */
30
-
31
- export const REVIEW_DEFAULTS = Object.freeze({
32
- maxRounds: 3, // hard hunt ceiling — bounded, never unbounded (anti-Emergent)
33
- })
34
-
35
- /**
36
- * Decide whether the reviewer's self-test loop continues or stops, and why.
37
- *
38
- * @param {object} s
39
- * @param {number} s.round 1-based current hunt round (1 = first pass)
40
- * @param {number} [s.maxRounds] hard ceiling (default REVIEW_DEFAULTS.maxRounds)
41
- * @param {number} [s.spentTokens] tokens the review lane has spent so far
42
- * @param {number|null} [s.budgetCap] the room/lane token cap (null = uncapped)
43
- * @param {boolean} s.foundFailure this round produced a CONCRETE, reproduced failure
44
- * @param {boolean} [s.exhausted] the reviewer reports it has nothing left to check
45
- * @returns {{ stop: boolean, action: 'reject'|'pass'|'surface'|'continue',
46
- * surfaceToPair: boolean, reason: string }}
47
- * action:
48
- * 'reject' — a concrete failure was found; stop, verdict = REJECT
49
- * 'pass' — checks exhausted with no failure; stop, verdict = PASS
50
- * 'surface' — a ceiling (budget or rounds) was hit inconclusively; stop,
51
- * hand the partial findings to the humans (no guess, no loop)
52
- * 'continue' — keep hunting (still under both ceilings, not yet concluded)
53
- * surfaceToPair — true whenever the loop STOPS (P3: every terminal outcome is
54
- * a prompt for the pair, not an automatic gate action).
55
- */
56
- export function reviewReflectionDecision ({
57
- round,
58
- maxRounds = REVIEW_DEFAULTS.maxRounds,
59
- spentTokens = 0,
60
- budgetCap = null,
61
- foundFailure = false,
62
- exhausted = false,
63
- } = {}) {
64
- const r = Number.isFinite(round) ? round : 1
65
- const cap = Number.isFinite(maxRounds) && maxRounds > 0 ? maxRounds : REVIEW_DEFAULTS.maxRounds
66
-
67
- // 1. A concrete, reproduced failure ends the hunt immediately — default-to-
68
- // reject is the whole point; no value in more rounds once it's broken.
69
- if (foundFailure) {
70
- return { stop: true, action: 'reject', surfaceToPair: true,
71
- reason: 'concrete failure reproduced — verdict REJECT' }
72
- }
73
-
74
- // 2. BUDGET ceiling (nexos #1 / cost-guard). Fail-closed: the moment spend
75
- // reaches the cap, STOP and hand off — never spend the next unit. Checked
76
- // before the round ceiling because money is the harder limit.
77
- if (budgetCap != null && Number.isFinite(budgetCap) && spentTokens >= budgetCap) {
78
- return { stop: true, action: 'surface', surfaceToPair: true,
79
- reason: `review budget cap reached (${spentTokens}/${budgetCap} tokens) — surfacing partial findings to the pair` }
80
- }
81
-
82
- // 3. Reviewer says it has checked everything and found nothing — verdict PASS.
83
- if (exhausted) {
84
- return { stop: true, action: 'pass', surfaceToPair: true,
85
- reason: 'checks exhausted, no failure reproduced — verdict PASS' }
86
- }
87
-
88
- // 4. ROUND ceiling (anti-Emergent). Reached the last allowed round without a
89
- // conclusion → surface to the humans rather than guess or loop forever.
90
- if (r >= cap) {
91
- return { stop: true, action: 'surface', surfaceToPair: true,
92
- reason: `reached the ${cap}-round hunt ceiling without a conclusion — surfacing to the pair (not looping)` }
93
- }
94
-
95
- // 5. Under both ceilings, not yet concluded → dig another round.
96
- return { stop: false, action: 'continue', surfaceToPair: false,
97
- reason: `round ${r}/${cap}, under budget — continue hunting` }
98
- }
99
-
100
- /**
101
- * Format a stopped review outcome as the room-facing co-adjudication prompt
102
- * (P3). Not the gate action itself — a message that invites the two humans +
103
- * Pool to challenge the reviewer before the verdict is treated as final.
104
- */
105
- export function pairAdjudicationPrompt ({ action, reason, findings = [], taskKey = '' } = {}) {
106
- const head = {
107
- reject: `The reviewer REJECTED ${taskKey || 'this slice'}.`,
108
- pass: `The reviewer PASSED ${taskKey || 'this slice'}.`,
109
- surface: `The reviewer could not conclude on ${taskKey || 'this slice'}.`,
110
- }[action] || `Review outcome for ${taskKey || 'this slice'}.`
111
- const body = findings.length
112
- ? '\n' + findings.map((f) => ` • ${f}`).join('\n')
113
- : (reason ? `\n ${reason}` : '')
114
- return `${head}${body}\n\nYou two decide — do you agree? Challenge it, or accept.`
115
- }
1
+ export const REVIEW_DEFAULTS=Object.freeze({maxRounds:3});export function reviewReflectionDecision({round:e,maxRounds:n=REVIEW_DEFAULTS.maxRounds,spentTokens:o=0,budgetCap:i=null,foundFailure:r=!1,exhausted:t=!1}={}){const a=Number.isFinite(e)?e:1,c=Number.isFinite(n)&&n>0?n:REVIEW_DEFAULTS.maxRounds;return r?{stop:!0,action:"reject",surfaceToPair:!0,reason:"concrete failure reproduced — verdict REJECT"}:null!=i&&Number.isFinite(i)&&o>=i?{stop:!0,action:"surface",surfaceToPair:!0,reason:`review budget cap reached (${o}/${i} tokens) — surfacing partial findings to the pair`}:t?{stop:!0,action:"pass",surfaceToPair:!0,reason:"checks exhausted, no failure reproduced — verdict PASS"}:a>=c?{stop:!0,action:"surface",surfaceToPair:!0,reason:`reached the ${c}-round hunt ceiling without a conclusion — surfacing to the pair (not looping)`}:{stop:!1,action:"continue",surfaceToPair:!1,reason:`round ${a}/${c}, under budget — continue hunting`}}export function pairAdjudicationPrompt({action:e,reason:n,findings:o=[],taskKey:i=""}={}){return`${{reject:`The reviewer REJECTED ${i||"this slice"}.`,pass:`The reviewer PASSED ${i||"this slice"}.`,surface:`The reviewer could not conclude on ${i||"this slice"}.`}[e]||`Review outcome for ${i||"this slice"}.`}${o.length?"\n"+o.map(e=>` • ${e}`).join("\n"):n?`\n ${n}`:""}\n\nYou two decide — do you agree? Challenge it, or accept.`}