pi-claude-supervisor 0.8.0 → 0.8.1
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/CHANGELOG.md +9 -0
- package/docs/architecture.md +17 -1
- package/package.json +1 -1
- package/src/policy.ts +41 -8
- package/src/worker/tmux-adapter.ts +52 -4
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented here.
|
|
4
4
|
|
|
5
|
+
## [0.8.1](https://github.com/btnalit/pi-claude-supervisor/compare/v0.8.0...v0.8.1) (2026-09-20)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **policy:** let a Worker keep its own memory, and make denials actionable ([605fb58](https://github.com/btnalit/pi-claude-supervisor/commit/605fb5860b38ee9023ad980b3101708cadd39fb2))
|
|
11
|
+
* **policy:** make the memory write root actually reachable, and drop the cwd-identity relaxation ([8a1bfc5](https://github.com/btnalit/pi-claude-supervisor/commit/8a1bfc55f97421997b0a8c87f0b1ae9cd7c94ba9))
|
|
12
|
+
* **tmux:** accept an adopted pane whose cwd path went stale but is the same directory ([207219c](https://github.com/btnalit/pi-claude-supervisor/commit/207219c1fec218d1eecf1920c5ba9a2806935a8f))
|
|
13
|
+
|
|
5
14
|
## [0.8.0](https://github.com/btnalit/pi-claude-supervisor/compare/v0.7.3...v0.8.0) (2026-09-19)
|
|
6
15
|
|
|
7
16
|
|
package/docs/architecture.md
CHANGED
|
@@ -424,7 +424,23 @@ stay on a blocked candidate instead of being cut short by the stop. For an adopt
|
|
|
424
424
|
interactive session the outright stop is a release (the adapter never kills a
|
|
425
425
|
session it does not own), so the Claude process keeps running unsupervised; the
|
|
426
426
|
close-out exists so that a task which merely ran long still ends with a verified
|
|
427
|
-
candidate instead of a silent hand-back.
|
|
427
|
+
candidate instead of a silent hand-back.
|
|
428
|
+
|
|
429
|
+
A denial is a capability boundary the Worker has to route around by itself, so
|
|
430
|
+
each one names a remedy it can act on: a dynamic argument says to substitute the
|
|
431
|
+
literal value so the command can be read, and an outside-cwd write names the
|
|
432
|
+
write roots this Worker actually holds (and nothing when it holds none). Those
|
|
433
|
+
roots are what the permission policy accepts beside the task cwd: the scratchpad
|
|
434
|
+
Claude reports at SessionStart, and its per-project memory directory, derived
|
|
435
|
+
from the session's own `transcript_path`. The transcript path is captured from
|
|
436
|
+
any hook event, since an adopted session never replays SessionStart, and it is
|
|
437
|
+
untrusted input, so the shape is verified rather than trusted — it must be
|
|
438
|
+
`<…>/.claude/projects/<slug>/<session>.jsonl` whose slug is the one Claude
|
|
439
|
+
derives from this task's cwd, which rejects a subagent transcript and any path
|
|
440
|
+
naming another project. A root is honored before it exists (Claude creates the
|
|
441
|
+
memory directory on first write) and through a symlinked ancestor.
|
|
442
|
+
|
|
443
|
+
A record left behind by the outright
|
|
428
444
|
stop (`recoverable_failure`, so `active/interrupted`) is not a dead end either:
|
|
429
445
|
`recover --extend <duration>` re-persists a deadline measured from now
|
|
430
446
|
(`extendedDeadlineMs`), `--extend 0` recovers straight into the close-out, and
|
package/package.json
CHANGED
package/src/policy.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { lstatSync, realpathSync, statSync } from "node:fs";
|
|
2
|
-
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
2
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
3
|
|
|
4
4
|
export type PolicyDecision = "allow" | "review" | "deny";
|
|
5
5
|
|
|
@@ -26,7 +26,12 @@ export function evaluatePermission(toolName: string, input: unknown, cwd = proce
|
|
|
26
26
|
const paths = fileToolPaths(input);
|
|
27
27
|
if (paths.length === 0) return { decision: "deny", reason: `${toolName} request has no recognizable file path` };
|
|
28
28
|
const violation = paths.map((path) => ({ path, classification: classifyWritePath(path, cwd, options.writeRoots) })).find((entry) => entry.classification !== undefined);
|
|
29
|
-
if (violation?.classification === "outside-cwd")
|
|
29
|
+
if (violation?.classification === "outside-cwd") {
|
|
30
|
+
// Only name an alternative the Worker actually has: writeRoots is empty
|
|
31
|
+
// for a bridge Worker and before an adopted session's first hook event.
|
|
32
|
+
const alternatives = (options.writeRoots ?? []).length > 0 ? `; scratch work may go under ${(options.writeRoots ?? []).join(", ")}` : "";
|
|
33
|
+
return { decision: "deny", reason: `Worker cannot write outside the task working directory: ${violation.path}${alternatives}` };
|
|
34
|
+
}
|
|
30
35
|
if (violation?.classification === "git-metadata") return { decision: "deny", reason: "Worker cannot write Git metadata or protected branch refs" };
|
|
31
36
|
return { decision: "allow", reason: `local Claude file tool is allowed by the task policy: ${toolName}` };
|
|
32
37
|
}
|
|
@@ -50,19 +55,47 @@ function fileToolPaths(input: unknown): string[] {
|
|
|
50
55
|
|
|
51
56
|
type WritePathViolation = "outside-cwd" | "git-metadata";
|
|
52
57
|
|
|
58
|
+
/**
|
|
59
|
+
* `realpath` of the deepest ancestor that exists, with the not-yet-created tail
|
|
60
|
+
* re-appended. Plain `realpathSync` throws for a directory the Worker is about
|
|
61
|
+
* to create, and the caller cannot tell that apart from a hostile path.
|
|
62
|
+
*/
|
|
63
|
+
function resolveExistingPath(path: string): string {
|
|
64
|
+
let current = resolve(path);
|
|
65
|
+
const missing: string[] = [];
|
|
66
|
+
for (;;) {
|
|
67
|
+
try { return join(realpathSync(current), ...[...missing].reverse()); }
|
|
68
|
+
catch (error) {
|
|
69
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
70
|
+
if (code !== "ENOENT" && code !== "ENOTDIR") return resolve(path);
|
|
71
|
+
const parent = dirname(current);
|
|
72
|
+
if (parent === current) return resolve(path);
|
|
73
|
+
missing.push(basename(current));
|
|
74
|
+
current = parent;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
53
79
|
function classifyWritePath(value: string, cwd: string, writeRoots: readonly string[] = []): WritePathViolation | undefined {
|
|
54
80
|
if (value.replaceAll("\\", "/").split("/").some((segment) => segment.toLowerCase() === ".git")) return "git-metadata";
|
|
55
81
|
// A path inside an extra write root (Claude's own scratchpad) is judged
|
|
56
82
|
// against that root instead of the cwd, with the same symlink/metadata rules.
|
|
57
83
|
for (const root of writeRoots) {
|
|
58
84
|
if (!isAbsolute(root) || !isAbsolute(value)) continue;
|
|
59
|
-
|
|
85
|
+
// Resolve both sides before comparing: a write root reached through a
|
|
86
|
+
// symlinked ancestor (a dotfile-managed ~/.claude, /var on macOS) would
|
|
87
|
+
// otherwise be judged outside itself. The final component stays unresolved
|
|
88
|
+
// so the per-segment symlink and `.git` checks below still see it.
|
|
89
|
+
const resolvedRoot = resolveExistingPath(root);
|
|
90
|
+
const resolvedValue = join(resolveExistingPath(dirname(value)), basename(value));
|
|
91
|
+
const rel = relative(resolvedRoot, resolvedValue);
|
|
60
92
|
if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) continue;
|
|
61
|
-
return classifyWritePath(
|
|
93
|
+
return classifyWritePath(resolvedValue, resolvedRoot);
|
|
62
94
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
95
|
+
// A write root need not exist yet: Claude creates its memory directory on
|
|
96
|
+
// the first write, and failing closed there denied the very write the
|
|
97
|
+
// outside-cwd message points at.
|
|
98
|
+
const root = resolveExistingPath(cwd);
|
|
66
99
|
const raw = value.replaceAll("\\", "/");
|
|
67
100
|
const canonicalRoot = root.replaceAll("\\", "/").replace(/\/+$/u, "") || "/";
|
|
68
101
|
let components: string[];
|
|
@@ -212,7 +245,7 @@ function evaluateRepositoryBoundary(tokens: readonly ShellToken[], canonical: st
|
|
|
212
245
|
// Dynamic text in an ordinary local command (`for f in …; echo "$f"`) or in
|
|
213
246
|
// another statement (`npm test; echo "exit $?"`) is Claude's own business.
|
|
214
247
|
if ((hasDynamicArgument && hasDynamicCommandName(tokens)) || segmentsOf(tokens).some((segment) => hasDynamicSensitiveArgument(segment))) {
|
|
215
|
-
return { decision: "deny", reason: "a repository, package, network or shell command with a dynamic argument cannot be capability-checked" };
|
|
248
|
+
return { decision: "deny", reason: "a repository, package, network or shell command with a dynamic argument cannot be capability-checked; substitute the literal value for the shell variable so the command can be read, or use the Write/Edit tools when the intent is to change a file" };
|
|
216
249
|
}
|
|
217
250
|
if (/\bgit\b[\s\S]*\b(?:push|merge(?!-)|send-pack|receive-pack|update-ref)\b/iu.test(canonical)
|
|
218
251
|
|| /\bgit-(?:send|receive|upload)-pack\b/iu.test(canonical)
|
|
@@ -3,7 +3,7 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
3
3
|
import { constants as fsConstants, lstatSync, readdirSync, unlinkSync } from "node:fs";
|
|
4
4
|
import { access, chmod, lstat, mkdir, open, readdir, readFile, realpath, rm, rmdir, stat, truncate, writeFile } from "node:fs/promises";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
|
-
import { delimiter, dirname, isAbsolute, join } from "node:path";
|
|
6
|
+
import { basename, delimiter, dirname, isAbsolute, join } from "node:path";
|
|
7
7
|
import type {
|
|
8
8
|
PermissionDecision,
|
|
9
9
|
WorkerAdapter,
|
|
@@ -1344,6 +1344,11 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
|
|
|
1344
1344
|
if (expected?.pid !== undefined && pane.pid !== expected.pid) throw new Error("tmux pane pid changed; refusing identity-unverified handoff");
|
|
1345
1345
|
if (pane.dead) throw new Error("cannot adopt a dead tmux pane");
|
|
1346
1346
|
record.handle.pid = pane.pid;
|
|
1347
|
+
// Compared as reported, not by inode identity: the hook relay routes events
|
|
1348
|
+
// by the SHA-256 of realpath(cwd), so a pane whose cwd merely *resolves* to
|
|
1349
|
+
// the same directory under a different spelling would be adopted and then
|
|
1350
|
+
// never deliver a single hook event -- an unsupervised Worker that looks
|
|
1351
|
+
// supervised. A mismatch means the pane cannot be governed, so it is refused.
|
|
1347
1352
|
const currentPath = await this.#run(record, ["display-message", "-p", "-t", record.target, "#{pane_current_path}"]);
|
|
1348
1353
|
if (currentPath.stdout.trim() !== cwd) throw new Error(`tmux session cwd mismatch: expected ${cwd}, got ${currentPath.stdout.trim()}`);
|
|
1349
1354
|
const command = (await this.#run(record, ["display-message", "-p", "-t", record.target, "#{pane_current_command}"])).stdout.trim();
|
|
@@ -1989,12 +1994,16 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
|
|
|
1989
1994
|
}
|
|
1990
1995
|
record.lastOutputAt = new Date().toISOString();
|
|
1991
1996
|
const event = request.event;
|
|
1997
|
+
// Every hook event carries `transcript_path`, and an adopted session never
|
|
1998
|
+
// replays SessionStart, so capture it here rather than only at startup:
|
|
1999
|
+
// it is what locates Claude's own per-project memory directory below.
|
|
2000
|
+
if (!record.transcriptPath && isSafeAbsolutePath(event.transcript_path)) record.transcriptPath = event.transcript_path;
|
|
1992
2001
|
switch (event.hook_event_name) {
|
|
1993
2002
|
case "SessionStart": {
|
|
1994
2003
|
record.claudeSessionId ??= event.session_id;
|
|
1995
2004
|
record.handle.sessionId = event.session_id;
|
|
1996
|
-
if (event.transcript_path) record.transcriptPath = event.transcript_path;
|
|
1997
|
-
if (
|
|
2005
|
+
if (isSafeAbsolutePath(event.transcript_path)) record.transcriptPath = event.transcript_path;
|
|
2006
|
+
if (isSafeAbsolutePath(event.scratchpad_dir)) record.scratchpadDir = event.scratchpad_dir;
|
|
1998
2007
|
record.sessionStartReceived = true;
|
|
1999
2008
|
return {};
|
|
2000
2009
|
}
|
|
@@ -2068,6 +2077,7 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
|
|
|
2068
2077
|
// itself away in respondPermission and hang the relay.
|
|
2069
2078
|
record.permissionResponses.delete(requestId);
|
|
2070
2079
|
const toolUseId = event.tool_use_id ?? requestId;
|
|
2080
|
+
const writeRoots = writeRootsOf(record);
|
|
2071
2081
|
this.#emit(record, {
|
|
2072
2082
|
type: "permission_request",
|
|
2073
2083
|
handle: record.handle,
|
|
@@ -2078,7 +2088,7 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
|
|
|
2078
2088
|
input: event.tool_input,
|
|
2079
2089
|
raw: event as unknown as Record<string, unknown>,
|
|
2080
2090
|
phase,
|
|
2081
|
-
...(
|
|
2091
|
+
...(writeRoots.length > 0 ? { writeRoots } : {}),
|
|
2082
2092
|
},
|
|
2083
2093
|
});
|
|
2084
2094
|
return new Promise<HookRelayReply>((resolve) => {
|
|
@@ -2151,6 +2161,44 @@ function bridgeEnvironment(env: NodeJS.ProcessEnv, cwd: string, command: string,
|
|
|
2151
2161
|
return result;
|
|
2152
2162
|
}
|
|
2153
2163
|
|
|
2164
|
+
/** Same shape the decision-session registry requires of an untrusted absolute path. */
|
|
2165
|
+
function isSafeAbsolutePath(value: unknown): value is string {
|
|
2166
|
+
return typeof value === "string" && value.length > 0 && value.length <= 4_096
|
|
2167
|
+
&& isAbsolute(value) && String(redactSensitiveText(value)) === value;
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
/**
|
|
2171
|
+
* Claude's per-project memory directory for this task, or undefined when the
|
|
2172
|
+
* reported transcript path is not this project's session transcript.
|
|
2173
|
+
*
|
|
2174
|
+
* `transcript_path` is untrusted hook input, so the shape is checked rather
|
|
2175
|
+
* than trusted: it must be `<…>/.claude/projects/<slug>/<session>.jsonl` whose
|
|
2176
|
+
* `<slug>` is the one Claude derives from this task's cwd. That rejects a
|
|
2177
|
+
* subagent transcript (`<slug>/<session>/subagents/agent-*.jsonl`, which would
|
|
2178
|
+
* otherwise freeze a bogus root) and any path naming another project, a home
|
|
2179
|
+
* directory, or somewhere inside the repository.
|
|
2180
|
+
*/
|
|
2181
|
+
export function memoryRootFor(transcriptPath: string | undefined, cwd: string): string | undefined {
|
|
2182
|
+
if (!transcriptPath || !cwd) return undefined;
|
|
2183
|
+
const sessionDir = dirname(transcriptPath);
|
|
2184
|
+
const projectsDir = dirname(sessionDir);
|
|
2185
|
+
if (basename(projectsDir) !== "projects" || basename(dirname(projectsDir)) !== ".claude") return undefined;
|
|
2186
|
+
if (basename(sessionDir) !== cwd.replaceAll("/", "-")) return undefined;
|
|
2187
|
+
return join(sessionDir, "memory");
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
/**
|
|
2191
|
+
* The directories outside the task cwd that the Worker may still write: its own
|
|
2192
|
+
* per-session scratchpad, and Claude's per-project memory directory.
|
|
2193
|
+
*/
|
|
2194
|
+
export function writeRootsOf(record: { scratchpadDir?: string; transcriptPath?: string; handle?: { cwd: string } }): string[] {
|
|
2195
|
+
const roots: string[] = [];
|
|
2196
|
+
if (record.scratchpadDir) roots.push(record.scratchpadDir);
|
|
2197
|
+
const memory = memoryRootFor(record.transcriptPath, record.handle?.cwd ?? "");
|
|
2198
|
+
if (memory) roots.push(memory);
|
|
2199
|
+
return roots;
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2154
2202
|
export function attachCommand(handle: Pick<WorkerHandle, "tmuxSocket" | "sessionName">): string {
|
|
2155
2203
|
const target = shellQuote(handle.sessionName ?? "");
|
|
2156
2204
|
return handle.tmuxSocket ? `tmux -S ${shellQuote(handle.tmuxSocket)} attach -t ${target}` : `tmux attach -t ${target}`;
|