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.
- package/LICENSE +18 -0
- package/README.md +20 -0
- package/abort-turn-barrier.mjs +1 -23
- package/account.mjs +1 -1437
- package/acp-client.mjs +1 -140
- package/agent-detect.mjs +1 -28
- package/agent-notify.mjs +1 -142
- package/agent-visibility.mjs +1 -67
- package/auth-store.mjs +1 -120
- package/bridge.mjs +1 -6266
- package/byok-detect.mjs +1 -126
- package/claude-command-catalog.mjs +1 -91
- package/claude-session.mjs +1 -1519
- package/code-event-contract.mjs +1 -118
- package/codex-app-server.mjs +1 -340
- package/codex-commands.mjs +1 -83
- package/codex-event-mapper.mjs +1 -232
- package/codex-images.mjs +1 -69
- package/codex-mcp-http.mjs +1 -131
- package/codex-session.mjs +1 -1295
- package/command-catalog.mjs +1 -118
- package/command-guidance.mjs +1 -8
- package/context-contract.mjs +1 -95
- package/context-windows.mjs +1 -107
- package/cross-terminal.mjs +1 -789
- package/cumulative-event-relay.mjs +1 -53
- package/design-edit.mjs +1 -424
- package/design-source-contract.mjs +1 -4
- package/direct-pair-room.mjs +1 -57
- package/dispatch-lease.mjs +1 -37
- package/dispatch-permission-cleanup.mjs +1 -86
- package/edit-diff.mjs +1 -136
- package/error-recovery.mjs +1 -50
- package/event-bounds.mjs +1 -121
- package/event-delivery-queue.mjs +1 -60
- package/event-id.mjs +1 -549
- package/evidence-citations.mjs +1 -50
- package/evidence-compact.mjs +1 -11
- package/flow-assembly.mjs +1 -196
- package/flow-budget.mjs +1 -84
- package/flow-conductor.mjs +1 -259
- package/flow-context-store.mjs +1 -387
- package/flow-host-revert.mjs +1 -42
- package/flow-models.mjs +1 -139
- package/flow-preview.mjs +1 -148
- package/flow-receipt.mjs +1 -122
- package/flow-redispatch.mjs +1 -72
- package/flow-review-gate.mjs +1 -402
- package/flow-review-reflect.mjs +1 -115
- package/flow-review.mjs +1 -152
- package/flow-scope-evidence.mjs +1 -117
- package/flow-skill-registry.mjs +1 -140
- package/flow-task-graph.mjs +1 -562
- package/flow-worktree.mjs +1 -71
- package/git-diff-report.mjs +1 -121
- package/hermes-delegation-guard.mjs +1 -14
- package/hermes-event-mapper.mjs +1 -194
- package/hermes-isolation.mjs +1 -61
- package/hermes-model-cache.mjs +1 -54
- package/hermes-policy.mjs +1 -92
- package/hermes-probe.mjs +1 -57
- package/hermes-session.mjs +1 -683
- package/hermes-setup.mjs +1 -167
- package/host-memory.mjs +1 -116
- package/interrupted-resume.mjs +1 -95
- package/keep-awake.mjs +1 -148
- package/key-shape.mjs +1 -49
- package/lane-continuation.mjs +1 -83
- package/lane-lifecycle.mjs +1 -189
- package/lane-worktree.mjs +1 -77
- package/launcher.mjs +1 -354
- package/mcp-flight-recorder.mjs +1 -79
- package/mockup-delivery.mjs +1 -57
- package/model-prices.mjs +1 -113
- package/package.json +13 -4
- package/pair-bus.mjs +1 -98
- package/pair-control-authority.mjs +1 -89
- package/past-work-search.mjs +1 -105
- package/plan-meters.mjs +1 -144
- package/presence.mjs +1 -191
- package/privacy-report.mjs +1 -108
- package/provider-resilience.mjs +1 -356
- package/provider.mjs +1 -133
- package/providers.mjs +1 -491
- package/publish-guard.mjs +2 -0
- package/publish-manifest.json +129 -0
- package/question-response.mjs +1 -58
- package/reap-terminal.mjs +1 -68
- package/recap.mjs +1 -297
- package/replay-transport.mjs +1 -64
- package/repo-search.mjs +1 -190
- package/review-check.mjs +1 -182
- package/runtime-contract.mjs +1 -93
- package/runtime-registry.mjs +1 -64
- package/runtime-session.mjs +1 -20
- package/scheduled-run-admission.mjs +1 -364
- package/scheduled-runs.mjs +1 -268
- package/sdk-admission.mjs +1 -9
- package/sdk-smoke.mjs +1 -61
- package/serve-consent.mjs +1 -118
- package/serve-dir.mjs +1 -40
- package/service.mjs +1 -877
- package/session-store.mjs +1 -484
- package/side-lane.mjs +1 -63
- package/supabase-key.mjs +1 -176
- package/supervisor-ready.mjs +1 -57
- package/switch-provider.mjs +1 -84
- package/terminal-name.mjs +1 -359
- package/terminal-row-reconcile.mjs +1 -70
- package/thinkpool-prompt-contracts.mjs +1 -85
- package/thinkpool-room-prompt.mjs +1 -187
- package/transcript-sanitize.mjs +1 -332
- package/turn-stall.mjs +1 -61
- package/update-gate.mjs +1 -53
- package/viewport.mjs +1 -810
- package/worker-completion.mjs +1 -57
package/flow-review-gate.mjs
CHANGED
|
@@ -1,402 +1 @@
|
|
|
1
|
-
|
|
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)}
|
package/flow-review-reflect.mjs
CHANGED
|
@@ -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.`}
|