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 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
 
@@ -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. A record left behind by the outright
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-claude-supervisor",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "A policy-gated Pi supervisor for observing and verifying Claude Code workers.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
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") return { decision: "deny", reason: `Worker cannot write outside the task working directory: ${violation.path}` };
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
- const rel = relative(root, value);
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(value, root);
93
+ return classifyWritePath(resolvedValue, resolvedRoot);
62
94
  }
63
- let root: string;
64
- try { root = realpathSync(cwd); }
65
- catch { return "git-metadata"; }
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 (typeof event.scratchpad_dir === "string" && isAbsolute(event.scratchpad_dir) && !event.scratchpad_dir.includes("\0")) record.scratchpadDir = event.scratchpad_dir;
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
- ...(record.scratchpadDir ? { writeRoots: [record.scratchpadDir] } : {}),
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}`;