thinkpool-pair 0.7.109 → 0.7.111
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/account.mjs +31 -5
- package/auth-store.mjs +28 -0
- package/bridge.mjs +8 -0
- package/flow-review-gate.mjs +302 -0
- package/package.json +2 -1
- package/transcript-sanitize.mjs +134 -10
package/account.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import path from 'node:path'
|
|
|
13
13
|
import fs from 'node:fs'
|
|
14
14
|
import { createClient } from '@supabase/supabase-js'
|
|
15
15
|
import os from 'node:os'
|
|
16
|
-
import { saveAuth, loadAuth, loadDirs, bindDir, loadServed, rememberServed } from './auth-store.mjs'
|
|
16
|
+
import { saveAuth, loadAuth, loadDirs, bindDir, loadServed, rememberServed, loadDefaultDir, saveDefaultDir, claudeRecentProjectDir } from './auth-store.mjs'
|
|
17
17
|
import { isSafeToRestart } from './update-gate.mjs'
|
|
18
18
|
import { makeThrottledTrack } from './presence.mjs'
|
|
19
19
|
import { resolveServeDir } from './serve-dir.mjs'
|
|
@@ -226,7 +226,8 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
226
226
|
const svcHint = process.env.THINKPOOL_PAIR_AUTOUPDATE === '1'
|
|
227
227
|
? ' (auto-updating background service — survives reboot, tracks @latest)\n'
|
|
228
228
|
: ' (run hands-off + auto-updating: npx thinkpool-pair install-service)\n'
|
|
229
|
-
|
|
229
|
+
const _effDefault = loadDefaultDir() || claudeRecentProjectDir() || DEFAULT_DIR
|
|
230
|
+
process.stderr.write(`\n ◆ thinkpool-pair — account mode · ${email}\n New sessions auto-serve from ${_effDefault}${loadDefaultDir() ? ' (your default)' : (claudeRecentProjectDir() ? ' (your most-recent Claude Code project)' : '')}\n (change it: npx thinkpool-pair set-default-dir <dir> · one room: npx thinkpool-pair bind <code> <dir>)\n${svcHint}`)
|
|
230
231
|
|
|
231
232
|
const children = new Map() // room -> child process
|
|
232
233
|
const childIdle = new Map() // room -> bool (last idle report: in the quiet window)
|
|
@@ -255,6 +256,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
255
256
|
let applyRequested = false // a user clicked "apply" in some room (Slice 3)
|
|
256
257
|
let pendingUpdate = null // newest published version once the poll sees it
|
|
257
258
|
const warned = new Set()
|
|
259
|
+
const refused = new Map() // room -> reason ('home'|'none'|'dir-gone'): owned but NOT served, announced in presence so the web shows a precise "attach this session" card instead of spinning
|
|
258
260
|
|
|
259
261
|
// Account presence — so the web dashboard can show "● Bridge active" for this
|
|
260
262
|
// account without per-room probing. We TRACK this machine (name + served room
|
|
@@ -262,6 +264,20 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
262
264
|
const machine = os.hostname().replace(/\.local$/, '')
|
|
263
265
|
const acct = sb.channel(`tpacct:${session.user.id}`, { config: { presence: { key: machine }, broadcast: { self: false } } })
|
|
264
266
|
let restarting = false // set by the "restart" broadcast → stop() exits non-zero so a supervisor respawns us
|
|
267
|
+
// Web "attach this session" card → bind an unbound/new room to a local dir (and
|
|
268
|
+
// optionally set it as the account default) so it serves WITHOUT a terminal command.
|
|
269
|
+
// Rides tpacct (keyed to the owner's uid); the dir must exist on THIS machine.
|
|
270
|
+
acct.on('broadcast', { event: 'bind-room' }, ({ payload }) => {
|
|
271
|
+
const code = String(payload?.code || '').toUpperCase().trim()
|
|
272
|
+
let dir = String(payload?.dir || '').trim()
|
|
273
|
+
if (dir.startsWith('~')) dir = path.join(os.homedir(), dir.slice(1))
|
|
274
|
+
if (!code || !dir) return
|
|
275
|
+
if (!fs.existsSync(dir)) { process.stderr.write(`\n ◇ bind ${code}: directory not found (${dir}) — ignored.\n`); return }
|
|
276
|
+
bindDir(code, dir)
|
|
277
|
+
if (payload?.setDefault) { saveDefaultDir(dir); process.stderr.write(`\n ◆ default project dir set → ${dir}\n`) }
|
|
278
|
+
process.stderr.write(`\n ◆ ${code} bound → ${dir} (from the web). Serving…\n`)
|
|
279
|
+
try { tick() } catch { /* the periodic tick will serve it next cycle */ }
|
|
280
|
+
})
|
|
265
281
|
// Dashboard "Disconnect" → broadcast shutdown → clean exit (presence leaves, bar flips live).
|
|
266
282
|
// If we're the auto-restarting background service (launchd KeepAlive / systemd
|
|
267
283
|
// Restart=always sets AUTOUPDATE=1), a bare exit would be respawned ~2s later —
|
|
@@ -324,7 +340,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
324
340
|
// service (always-on, survives reboot) vs a foreground `npx` run that dies with its
|
|
325
341
|
// terminal. THINKPOOL_PAIR_AUTOUPDATE=1 is set ONLY by the install-service plist/unit
|
|
326
342
|
// (service.mjs), so it's the reliable proxy for "running as the managed service".
|
|
327
|
-
const pushPresence = () => trackPresence({ name: machine, version: VERSION, rooms: [...children.keys()], service: process.env.THINKPOOL_PAIR_AUTOUPDATE === '1', ts: Date.now() })
|
|
343
|
+
const pushPresence = () => trackPresence({ name: machine, version: VERSION, rooms: [...children.keys()], refused: [...refused].map(([code, reason]) => ({ code, reason })), service: process.env.THINKPOOL_PAIR_AUTOUPDATE === '1', ts: Date.now() })
|
|
328
344
|
// Re-track on EVERY (re)subscribe, not just the first: a realtime reconnect
|
|
329
345
|
// (network blip, or a token swap mid-flight) rejoins the channel and must
|
|
330
346
|
// re-announce, or the dashboard would read "no bridge" until the next restart.
|
|
@@ -491,18 +507,26 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
491
507
|
// participant's bridge is duplication the UI never even looks at.
|
|
492
508
|
const { data } = await sb.from('code_sessions').select('code,name,last_active_at,owner_id,participants').eq('owner_id', session.user.id).order('last_active_at', { ascending: false }).limit(20)
|
|
493
509
|
rooms = data || []
|
|
510
|
+
// Prune refused entries for rooms we no longer own (only on a SUCCESSFUL fetch,
|
|
511
|
+
// so a transient query error never wipes the set).
|
|
512
|
+
const ownedCodes = new Set(rooms.map((r) => r.code))
|
|
513
|
+
for (const c of [...refused.keys()]) if (!ownedCodes.has(c)) refused.delete(c)
|
|
494
514
|
} catch { /* transient — keep existing children, retry next tick */ }
|
|
495
515
|
const dirs = loadDirs()
|
|
496
516
|
const served = loadServed()
|
|
497
517
|
for (const r of rooms) {
|
|
498
518
|
const room = r.code
|
|
499
|
-
if (!room || children.has(room)) continue
|
|
519
|
+
if (!room || children.has(room)) { if (room) refused.delete(room); continue } // served → not refused
|
|
500
520
|
if (r.owner_id && r.owner_id !== session.user.id) continue // never serve a room we don't own (defense-in-depth behind the .eq above)
|
|
501
521
|
// Resolve the serve dir: explicit bind > last-served memory > launch dir. REFUSE
|
|
502
522
|
// to auto-serve an unbound room from $HOME — that strips the agent's resume context
|
|
503
523
|
// (cwd-keyed SDK sessions) and shows your home folder as the repo (FVIHV1DE).
|
|
504
|
-
|
|
524
|
+
// Default-dir precedence for an unbound room: explicit user default > Claude Code's
|
|
525
|
+
// most-recent project dir (zero-config — where you actually code) > launch cwd ($HOME,
|
|
526
|
+
// which then gets refused). So a new session auto-attaches to your active repo with no setup.
|
|
527
|
+
const choice = resolveServeDir({ bound: dirs[room], remembered: served[room], defaultDir: loadDefaultDir() || claudeRecentProjectDir() || DEFAULT_DIR, home: os.homedir() })
|
|
505
528
|
if (choice.refuse) {
|
|
529
|
+
refused.set(room, choice.reason)
|
|
506
530
|
if (!warned.has(room)) {
|
|
507
531
|
warned.add(room)
|
|
508
532
|
const why = choice.reason === 'home'
|
|
@@ -514,10 +538,12 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
514
538
|
}
|
|
515
539
|
const dir = choice.dir
|
|
516
540
|
if (!fs.existsSync(dir)) {
|
|
541
|
+
refused.set(room, 'dir-gone')
|
|
517
542
|
if (!warned.has(room)) { warned.add(room); process.stderr.write(`\n ◇ ${room}: bound directory is gone (${dir}) — re-bind it.\n`) }
|
|
518
543
|
continue
|
|
519
544
|
}
|
|
520
545
|
warned.delete(room)
|
|
546
|
+
refused.delete(room) // about to serve → clear any prior refusal
|
|
521
547
|
// Learn this room → dir so a later relaunch from $HOME resumes it HERE (the real
|
|
522
548
|
// repo) instead of the home-dir fallback. Only the auto path is remembered; an
|
|
523
549
|
// explicit bind already persists in dirs.json.
|
package/auth-store.mjs
CHANGED
|
@@ -58,3 +58,31 @@ export function rememberServed(room, dir) {
|
|
|
58
58
|
try { fs.writeFileSync(SERVED, JSON.stringify(d, null, 2)) } catch { /* noop */ }
|
|
59
59
|
return d
|
|
60
60
|
}
|
|
61
|
+
|
|
62
|
+
// Account-wide DEFAULT serve directory: new/unbound rooms auto-serve HERE instead of
|
|
63
|
+
// being refused from $HOME. Set once (CLI `set-default-dir <dir>`, or the web attach
|
|
64
|
+
// card). One dir for the common "I mostly work in one repo" case; per-room binds still
|
|
65
|
+
// override it. Stored as a plain path in ~/.thinkpool-pair/default-dir.
|
|
66
|
+
const DEFAULTDIR = path.join(DIR, 'default-dir')
|
|
67
|
+
export function loadDefaultDir() { try { const d = fs.readFileSync(DEFAULTDIR, 'utf8').trim(); return d || null } catch { return null } }
|
|
68
|
+
export function saveDefaultDir(dir) { ensureDir(); try { fs.writeFileSync(DEFAULTDIR, String(dir || '')) } catch { /* noop */ } return dir }
|
|
69
|
+
export function clearDefaultDir() { try { fs.unlinkSync(DEFAULTDIR) } catch { /* noop */ } }
|
|
70
|
+
|
|
71
|
+
// Zero-config default: Claude Code tracks every project directory you've worked in
|
|
72
|
+
// (~/.claude.json `projects`, with per-project session history under
|
|
73
|
+
// ~/.claude/projects/<path-with-slashes-as-dashes>). The MOST-RECENTLY-used real project
|
|
74
|
+
// dir is where you actually code — a great default for a new/unbound room, no setup. Skips
|
|
75
|
+
// $HOME, tmp, and worktree paths; ranks by the session-dir mtime (encode path forward only,
|
|
76
|
+
// never decode — the dash encoding is lossy for paths containing '-').
|
|
77
|
+
export function claudeRecentProjectDir() {
|
|
78
|
+
try {
|
|
79
|
+
const home = os.homedir()
|
|
80
|
+
const conf = JSON.parse(fs.readFileSync(path.join(home, '.claude.json'), 'utf8'))
|
|
81
|
+
const projRoot = path.join(home, '.claude', 'projects')
|
|
82
|
+
const best = Object.keys(conf.projects || {})
|
|
83
|
+
.filter((p) => p && p !== home && !p.startsWith('/private/tmp') && !p.startsWith('/tmp') && !p.includes('/.claude/') && fs.existsSync(p))
|
|
84
|
+
.map((p) => { let mt = 0; try { mt = fs.statSync(path.join(projRoot, p.replace(/\//g, '-'))).mtimeMs } catch { /* no session dir */ } return { p, mt } })
|
|
85
|
+
.sort((a, b) => b.mt - a.mt)[0]
|
|
86
|
+
return best?.p || null
|
|
87
|
+
} catch { return null }
|
|
88
|
+
}
|
package/bridge.mjs
CHANGED
|
@@ -202,6 +202,14 @@ const PROVIDER_INFO = applyProviderEnv()
|
|
|
202
202
|
|
|
203
203
|
if (argv[0] === 'login') { const { runLogin } = await import('./account.mjs'); await runLogin(SUPABASE_URL, SUPABASE_ANON, WEB_BASE) }
|
|
204
204
|
if (argv[0] === 'bind') { const { runBind } = await import('./account.mjs'); runBind(argv[1], argv[2]) }
|
|
205
|
+
if (argv[0] === 'set-default-dir') {
|
|
206
|
+
const { saveDefaultDir } = await import('./auth-store.mjs')
|
|
207
|
+
let d = (argv[1] || '').trim(); if (d.startsWith('~')) d = d.replace(/^~/, os.homedir())
|
|
208
|
+
if (!d) { console.error(' usage: npx thinkpool-pair set-default-dir <dir>'); process.exit(1) }
|
|
209
|
+
saveDefaultDir(d)
|
|
210
|
+
console.log(` ◆ default project dir set → ${d}\n New/unbound sessions auto-serve from here — no per-session bind. Takes effect next serve tick.`)
|
|
211
|
+
process.exit(0)
|
|
212
|
+
}
|
|
205
213
|
if (argv[0] === 'provider') { const { runProvider } = await import('./provider.mjs'); await runProvider(argv.slice(1)); process.exit(0) }
|
|
206
214
|
|
|
207
215
|
// One-command launcher: a bare invocation on a TTY (or the `setup` alias) opens the
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
// Thinkpool Flow — S5 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 } 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 — no symlink possible; allow.
|
|
179
|
+
}
|
|
180
|
+
return { allow: true, reason: `write to verdictFile (${path.basename(verdictFile)}) — allowed` }
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Not the verdictFile. Check whether the write target lands inside the slice.
|
|
185
|
+
|
|
186
|
+
// 1. String-level check — catches '..' traversal and absolute paths into sliceRoot.
|
|
187
|
+
if (_isUnder(resolved, canonicalSliceRoot) || _isUnder(resolved, inputSliceRoot)) {
|
|
188
|
+
return {
|
|
189
|
+
allow: false,
|
|
190
|
+
reason: `${toolName} to "${filePath}" (resolved: "${resolved}") is inside sliceRoot — denied`,
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// 2. Realpath check — catches symlinks whose TARGET is inside sliceRoot.
|
|
195
|
+
try {
|
|
196
|
+
const real = _realpath(filePath)
|
|
197
|
+
if (_isUnder(real, canonicalSliceRoot) || _isUnder(real, inputSliceRoot)) {
|
|
198
|
+
return {
|
|
199
|
+
allow: false,
|
|
200
|
+
reason: `${toolName} to "${filePath}" symlinks to "${real}" inside sliceRoot — denied`,
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
} catch {
|
|
204
|
+
// ENOENT or other — step 1 already handled the in-slice case.
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return { allow: true, reason: `${toolName} to "${filePath}" is outside sliceRoot — allowed` }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── _assessBashCommand ────────────────────────────────────────────────────────
|
|
211
|
+
// HEURISTIC — see BASH LIMITATION comment at top of file.
|
|
212
|
+
|
|
213
|
+
function _assessBashCommand ({ cmd, inputSliceRoot, canonicalSliceRoot }) {
|
|
214
|
+
// Returns true if `str` (a path extracted from the command) resolves under sliceRoot.
|
|
215
|
+
function inSlice (str) {
|
|
216
|
+
if (!str) return false
|
|
217
|
+
const abs = path.isAbsolute(str) ? str : path.resolve(str)
|
|
218
|
+
return _isUnder(abs, canonicalSliceRoot) || _isUnder(abs, inputSliceRoot)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Returns true if either form of sliceRoot appears literally in the command string.
|
|
222
|
+
// Used for commands that embed the target path as a positional argument.
|
|
223
|
+
function cmdContainsSlice () {
|
|
224
|
+
return cmd.includes(canonicalSliceRoot) || cmd.includes(inputSliceRoot)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── Output redirection (> or >>) into sliceRoot ───────────────────────
|
|
228
|
+
// HEURISTIC: misses paths with spaces and shell variable expansion.
|
|
229
|
+
// Negative lookbehind excludes '<' so '<<' (heredoc) and '<>' (r/w) are not
|
|
230
|
+
// misread as write redirections.
|
|
231
|
+
// Group 1: optional surrounding quote char; group 2: the path (stripped of quotes).
|
|
232
|
+
// This catches both `> /slice/f` (unquoted) and `> "/slice/f"` / `> '/slice/f'` (D1/D2).
|
|
233
|
+
const redirRe = /(?<![<])>+\s*(['"]?)([^\s;|&'"]+)\1/g
|
|
234
|
+
let m
|
|
235
|
+
while ((m = redirRe.exec(cmd)) !== null) {
|
|
236
|
+
if (inSlice(m[2])) {
|
|
237
|
+
return {
|
|
238
|
+
allow: false,
|
|
239
|
+
reason: `Bash output redirection to "${m[2]}" targets sliceRoot — denied`,
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── git mutating commands targeting sliceRoot ─────────────────────────
|
|
245
|
+
if (/\bgit\b/.test(cmd) && GIT_MUTATING_RE.test(cmd)) {
|
|
246
|
+
// Explicit -C flag: `git -C <dir> <verb>`
|
|
247
|
+
const cwdMatch = cmd.match(/-C\s+(['"]?)([^\s'"]+)\1/)
|
|
248
|
+
if (cwdMatch) {
|
|
249
|
+
const cwdArg = cwdMatch[2]
|
|
250
|
+
if (inSlice(cwdArg)) {
|
|
251
|
+
return {
|
|
252
|
+
allow: false,
|
|
253
|
+
reason: `Bash "git -C ${cwdArg} ..." targets sliceRoot — denied`,
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
// Fallback: sliceRoot path appears anywhere in a mutating git command.
|
|
258
|
+
if (cmdContainsSlice()) {
|
|
259
|
+
return { allow: false, reason: 'Bash git mutating command references sliceRoot — denied' }
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ── cp/mv: only deny when DESTINATION (last positional arg) is inside sliceRoot ──
|
|
264
|
+
// C5 fix: the old approach (cmdContainsSlice) wrongly denied `cp /slice/f /tmp/x`
|
|
265
|
+
// because sliceRoot appeared as the SOURCE. Only the destination can cause a write.
|
|
266
|
+
if (CP_MV_RE.test(cmd)) {
|
|
267
|
+
const tokens = cmd.trim().split(/\s+/)
|
|
268
|
+
// Skip the verb (i=0) and option flags; remaining tokens are positional path args.
|
|
269
|
+
const pathArgs = tokens.filter((t, i) => i > 0 && !t.startsWith('-'))
|
|
270
|
+
const dest = pathArgs[pathArgs.length - 1]
|
|
271
|
+
if (dest && inSlice(dest)) {
|
|
272
|
+
const verb = (cmd.match(CP_MV_RE) || [''])[0]
|
|
273
|
+
return {
|
|
274
|
+
allow: false,
|
|
275
|
+
reason: `Bash "${verb}" destination "${dest}" targets sliceRoot — denied`,
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ── rm / tee / truncate / dd / install targeting sliceRoot ──
|
|
281
|
+
if (BASH_WRITE_CMD_RE.test(cmd) && cmdContainsSlice()) {
|
|
282
|
+
const verb = (cmd.match(BASH_WRITE_CMD_RE) || [''])[0]
|
|
283
|
+
return {
|
|
284
|
+
allow: false,
|
|
285
|
+
reason: `Bash write command "${verb}" references sliceRoot — denied`,
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ── sed -i targeting sliceRoot ────────────────────────────────────────
|
|
290
|
+
if (SED_INPLACE_RE.test(cmd) && cmdContainsSlice()) {
|
|
291
|
+
return { allow: false, reason: 'Bash "sed -i" references sliceRoot — denied' }
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return { allow: true, reason: 'Bash command allowed (no write-to-slice pattern detected)' }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
// Returns true if `filePath` equals `dir` OR is strictly inside it.
|
|
300
|
+
function _isUnder (filePath, dir) {
|
|
301
|
+
return filePath === dir || filePath.startsWith(dir + path.sep)
|
|
302
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.111",
|
|
4
4
|
"description": "Share a local coding-agent CLI (Claude Code, Codex, Gemini, Aider, …) into a ThinkPool Code room, live.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"flow-task-graph.mjs",
|
|
22
22
|
"flow-preview.mjs",
|
|
23
23
|
"flow-review.mjs",
|
|
24
|
+
"flow-review-gate.mjs",
|
|
24
25
|
"flow-assembly.mjs",
|
|
25
26
|
"flow-budget.mjs",
|
|
26
27
|
"serve-dir.mjs",
|
package/transcript-sanitize.mjs
CHANGED
|
@@ -50,6 +50,7 @@ const ANTHROPIC_SERVER_TOOLS = new Set([
|
|
|
50
50
|
|
|
51
51
|
const STU_MARKER = '"server_tool_use"'
|
|
52
52
|
const TR_MARKER = '"tool_result"'
|
|
53
|
+
const TU_MARKER = '"tool_use"' // matches "type":"tool_use" but NOT "server_tool_use" or "tool_use_id"
|
|
53
54
|
|
|
54
55
|
/**
|
|
55
56
|
* Resolve a session's transcript path the same way Claude Code names it:
|
|
@@ -105,9 +106,107 @@ function fixToolResultInAssistant(content) {
|
|
|
105
106
|
return { removed: before - next.length, content: next }
|
|
106
107
|
}
|
|
107
108
|
|
|
109
|
+
/* ─────────────────────────────────────────────────────────────
|
|
110
|
+
THIRD HEAL — dangling tool_use (S4 clean-redispatch, 2026-06-30):
|
|
111
|
+
a lane force-killed mid-tool-call leaves an assistant message with a
|
|
112
|
+
tool_use block that has NO matching tool_result anywhere in the
|
|
113
|
+
transcript. On the next SDK replay Anthropic 400s:
|
|
114
|
+
"tool_use ids were found without tool_result blocks"
|
|
115
|
+
and the lane cannot resume.
|
|
116
|
+
|
|
117
|
+
THE FIX: cross-row scan — collect every tool_use id and every
|
|
118
|
+
tool_result tool_use_id across the whole transcript; any tool_use id
|
|
119
|
+
with no matching tool_result is "dangling". For each assistant message
|
|
120
|
+
that holds dangling calls, inject a synthetic user message immediately
|
|
121
|
+
after it carrying one tool_result per dangling id:
|
|
122
|
+
{ type:'tool_result', tool_use_id:<id>, content:'[interrupted]', is_error:true }
|
|
123
|
+
This is the minimal valid placeholder that unblocks the next turn.
|
|
124
|
+
|
|
125
|
+
Idempotent: a tool_use already paired with a tool_result is untouched.
|
|
126
|
+
A second pass over the same (now-healed) transcript is a no-op because
|
|
127
|
+
the synthetic results are part of the file and pair the calls.
|
|
128
|
+
───────────────────────────────────────────────────────────── */
|
|
129
|
+
|
|
130
|
+
// Return all tool_use ids from a parsed row (checks .message.content AND top-level .content).
|
|
131
|
+
function getToolUseIds(obj) {
|
|
132
|
+
const ids = []
|
|
133
|
+
const scan = (content) => {
|
|
134
|
+
if (!Array.isArray(content)) return
|
|
135
|
+
for (const b of content) { if (b && b.type === 'tool_use' && b.id) ids.push(b.id) }
|
|
136
|
+
}
|
|
137
|
+
if (obj.message) scan(obj.message.content)
|
|
138
|
+
scan(obj.content)
|
|
139
|
+
return ids
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Collect all tool_result tool_use_ids from a parsed row into a Set.
|
|
143
|
+
function collectToolResultIds(obj, into) {
|
|
144
|
+
const scan = (content) => {
|
|
145
|
+
if (!Array.isArray(content)) return
|
|
146
|
+
for (const b of content) { if (b && b.type === 'tool_result' && b.tool_use_id) into.add(b.tool_use_id) }
|
|
147
|
+
}
|
|
148
|
+
if (obj.message) scan(obj.message.content)
|
|
149
|
+
scan(obj.content)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Return the role of a parsed row ('assistant' | 'user' | null).
|
|
153
|
+
function getRole(obj) {
|
|
154
|
+
if (!obj) return null
|
|
155
|
+
if (obj.message && obj.message.role) return obj.message.role
|
|
156
|
+
if (obj.type === 'assistant') return 'assistant'
|
|
157
|
+
if (obj.type === 'user') return 'user'
|
|
158
|
+
return null
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Heal 3: scan parsed objects for dangling tool_use ids; insert synthetic
|
|
163
|
+
* user rows immediately after the assistant rows that own them.
|
|
164
|
+
* @param {string[]} rows — string rows (already updated by Heals 1+2)
|
|
165
|
+
* @param {(object|null)[]} parsed — objects (mutated in-place by Heals 1+2)
|
|
166
|
+
* @returns {{ rows: string[], injected: number }}
|
|
167
|
+
*/
|
|
168
|
+
function fixDanglingToolUse(rows, parsed) {
|
|
169
|
+
// Full-transcript scan: collect every tool_result tool_use_id (= paired ids).
|
|
170
|
+
const pairedIds = new Set()
|
|
171
|
+
for (const obj of parsed) {
|
|
172
|
+
if (obj) collectToolResultIds(obj, pairedIds)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let injected = 0
|
|
176
|
+
const out = []
|
|
177
|
+
for (let i = 0; i < rows.length; i++) {
|
|
178
|
+
out.push(rows[i])
|
|
179
|
+
const obj = parsed[i]
|
|
180
|
+
if (!obj || getRole(obj) !== 'assistant') continue
|
|
181
|
+
|
|
182
|
+
const dangling = getToolUseIds(obj).filter(id => !pairedIds.has(id))
|
|
183
|
+
if (!dangling.length) continue
|
|
184
|
+
|
|
185
|
+
// Inject synthetic user row with one tool_result per dangling call.
|
|
186
|
+
const synRow = {
|
|
187
|
+
type: 'user',
|
|
188
|
+
message: {
|
|
189
|
+
role: 'user',
|
|
190
|
+
content: dangling.map(id => ({
|
|
191
|
+
type: 'tool_result',
|
|
192
|
+
tool_use_id: id,
|
|
193
|
+
content: '[interrupted]',
|
|
194
|
+
is_error: true,
|
|
195
|
+
})),
|
|
196
|
+
},
|
|
197
|
+
}
|
|
198
|
+
out.push(JSON.stringify(synRow))
|
|
199
|
+
// Mark injected ids as paired so a second pass treats them as clean (idempotency).
|
|
200
|
+
for (const id of dangling) pairedIds.add(id)
|
|
201
|
+
injected += dangling.length
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return { rows: out, injected }
|
|
205
|
+
}
|
|
206
|
+
|
|
108
207
|
/**
|
|
109
208
|
* Sanitize one transcript file in place. Returns { lines, blocks } counting the
|
|
110
|
-
* transcript lines rewritten and the individual blocks
|
|
209
|
+
* transcript lines rewritten and the individual blocks healed/injected. A missing,
|
|
111
210
|
* unreadable, or already-clean file is a silent no-op ({ lines: 0, blocks: 0 }).
|
|
112
211
|
*/
|
|
113
212
|
export function sanitizeTranscript(file) {
|
|
@@ -119,23 +218,29 @@ export function sanitizeTranscript(file) {
|
|
|
119
218
|
}
|
|
120
219
|
const fileHasStu = raw.includes(STU_MARKER)
|
|
121
220
|
const fileHasTr = raw.includes(TR_MARKER)
|
|
122
|
-
|
|
221
|
+
const fileHasTu = raw.includes(TU_MARKER)
|
|
222
|
+
if (!fileHasStu && !fileHasTr && !fileHasTu) return { lines: 0, blocks: 0 } // fast path: nothing illegal possible
|
|
123
223
|
|
|
124
224
|
const rows = raw.split('\n')
|
|
125
225
|
let lines = 0
|
|
126
226
|
let blocks = 0
|
|
227
|
+
|
|
228
|
+
// Pre-parse ALL rows so Heal 3 can do cross-row analysis.
|
|
229
|
+
// Heals 1+2 mutate these objects in-place, so parsed[i] always reflects the
|
|
230
|
+
// post-heal state when Heal 3 runs. Unparseable lines → null (left byte-identical).
|
|
231
|
+
const parsed = rows.map(row => {
|
|
232
|
+
if (!row) return null
|
|
233
|
+
try { return JSON.parse(row) } catch { return null }
|
|
234
|
+
})
|
|
235
|
+
|
|
127
236
|
for (let i = 0; i < rows.length; i++) {
|
|
128
237
|
const row = rows[i]
|
|
129
238
|
if (!row) continue
|
|
130
239
|
const rowHasStu = row.includes(STU_MARKER)
|
|
131
240
|
const rowHasTr = row.includes(TR_MARKER)
|
|
132
241
|
if (!rowHasStu && !rowHasTr) continue
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
obj = JSON.parse(row)
|
|
136
|
-
} catch {
|
|
137
|
-
continue // never touch a line we can't parse — leave it byte-identical
|
|
138
|
-
}
|
|
242
|
+
const obj = parsed[i]
|
|
243
|
+
if (!obj) continue // unparseable — already null, leave byte-identical
|
|
139
244
|
let n = 0
|
|
140
245
|
// Pass 1 — foreign server_tool_use → tool_use (any message).
|
|
141
246
|
if (rowHasStu) {
|
|
@@ -160,11 +265,30 @@ export function sanitizeTranscript(file) {
|
|
|
160
265
|
}
|
|
161
266
|
}
|
|
162
267
|
|
|
163
|
-
|
|
268
|
+
// Heal 3 — dangling tool_use: cross-row scan + synthetic row insertion.
|
|
269
|
+
// Runs after Heals 1+2 so parsed objects already reflect those fixes.
|
|
270
|
+
//
|
|
271
|
+
// Gate: run whenever the fast-path was NOT taken — i.e., always here.
|
|
272
|
+
// The old `if (fileHasTu)` guard was wrong for a heal that depends on Heal 1's
|
|
273
|
+
// output: Heal 1 can create NEW tool_use blocks by flipping server_tool_use, but
|
|
274
|
+
// the raw `"server_tool_use"` string does NOT contain the `"tool_use"` substring
|
|
275
|
+
// (the `server_` prefix + underscore break the literal match), so fileHasTu=false
|
|
276
|
+
// and Heal 3 was skipped — leaving the now-dangling call unhealed (bug C9).
|
|
277
|
+
// The fast-path early-return above is the real no-op gate for truly-clean files.
|
|
278
|
+
let outRows = rows
|
|
279
|
+
{
|
|
280
|
+
const h3 = fixDanglingToolUse(rows, parsed)
|
|
281
|
+
if (h3.injected) {
|
|
282
|
+
outRows = h3.rows
|
|
283
|
+
blocks += h3.injected
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (!blocks) return { lines: 0, blocks: 0 } // nothing was changed by any heal
|
|
164
288
|
|
|
165
289
|
// Atomic write — a crash mid-write must never leave a truncated transcript.
|
|
166
290
|
const tmp = `${file}.tp-sanitize.tmp`
|
|
167
|
-
fs.writeFileSync(tmp,
|
|
291
|
+
fs.writeFileSync(tmp, outRows.join('\n'))
|
|
168
292
|
fs.renameSync(tmp, file)
|
|
169
293
|
return { lines, blocks }
|
|
170
294
|
}
|