pi-session-mcp 0.14.0 → 0.14.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.
@@ -6,7 +6,8 @@ import { CONTAINER_EVENTS_DIR, EVENTS_DIR_ENV, writerEventsDir } from "./turn-ev
6
6
  import { writerContainerName, writerLabel } from "./writer-fence.js";
7
7
  export const NODE_IMAGE_REF = "docker.io/library/node@sha256:c4d5523090a817b7aa86d2111241fdd4f66d1e27782b44160e6aa63b357ecb2d";
8
8
  export const IMAGE_PULL_TIMEOUT_MS = 900_000;
9
- export const BOUNDARY_GIT = "git -c safe.directory=* -c core.fsmonitor=false -c core.hooksPath=/dev/null";
9
+ // Replace refs in a clone are worker-controlled: they could make unexported work look reachable or clean (#193).
10
+ export const BOUNDARY_GIT = "git -c safe.directory=* -c core.fsmonitor=false -c core.hooksPath=/dev/null -c core.useReplaceRefs=false";
10
11
  export class BoundaryError extends Error {
11
12
  code;
12
13
  constructor(code) {
@@ -344,7 +344,7 @@ export async function inspectWork(ctx, name) {
344
344
  });
345
345
  return {
346
346
  baseline: head === state.base.commit && refs.length === 1 && refs[0].name === state.base.ref && refs[0].commit === state.base.commit && tree === baseTree,
347
- dirty: tree !== headTree, branches: refs.filter(({ name }) => name.startsWith("refs/heads/")),
347
+ dirty: tree !== headTree, head: head, branches: refs.filter(({ name }) => name.startsWith("refs/heads/")),
348
348
  digest: `sha256:${createHash("sha256").update(result.stdout).digest("hex")}`,
349
349
  };
350
350
  }
@@ -412,10 +412,16 @@ if [ "$tree" != "$(${BOUNDARY_GIT} rev-parse 'HEAD^{tree}')" ]; then
412
412
  export GIT_COMMITTER_NAME=pi-writer GIT_COMMITTER_EMAIL=pi-writer@example.invalid
413
413
  commit=$(printf '%s\\n' 'WIP collected by pi-session-mcp' | ${BOUNDARY_GIT} commit-tree "$tree" -p HEAD)
414
414
  branch=$(${BOUNDARY_GIT} symbolic-ref --short HEAD || printf '%s' "$4")
415
- if ${BOUNDARY_GIT} --git-dir=/tmp/scratch show-ref --verify --quiet "refs/heads/$branch-wip"; then
415
+ # A taken name, or one that conflicts with an exported branch path, is refused rather than aborting the export.
416
+ if ${BOUNDARY_GIT} --git-dir=/tmp/scratch show-ref --verify --quiet "refs/heads/$branch-wip" \\
417
+ || ! ${BOUNDARY_GIT} --git-dir=/tmp/scratch update-ref "refs/heads/$branch-wip" "$commit" 2>/dev/null; then
418
+ printf '%s\\n' ref_name_refused
419
+ fi
420
+ elif ! ${BOUNDARY_GIT} symbolic-ref -q HEAD >/dev/null 2>&1 && [ -n "$(${BOUNDARY_GIT} rev-list -n 1 HEAD --not --branches)" ]; then
421
+ # Clean detached HEAD that no branch of the clone reaches: export its commit under a derived name (#193).
422
+ if ${BOUNDARY_GIT} --git-dir=/tmp/scratch show-ref --verify --quiet "refs/heads/$4-detached" \\
423
+ || ! ${BOUNDARY_GIT} --git-dir=/tmp/scratch update-ref "refs/heads/$4-detached" "$(${BOUNDARY_GIT} rev-parse --verify HEAD)" 2>/dev/null; then
416
424
  printf '%s\\n' ref_name_refused
417
- else
418
- ${BOUNDARY_GIT} --git-dir=/tmp/scratch update-ref "refs/heads/$branch-wip" "$commit"
419
425
  fi
420
426
  fi
421
427
  if [ -z "$(${BOUNDARY_GIT} --git-dir=/tmp/scratch for-each-ref --format='%(refname)')" ]; then
@@ -450,6 +456,14 @@ fi`;
450
456
  return { status: "failed", code: "writer_collect_incomplete", refs, refused: findings.length, findings };
451
457
  }
452
458
  const work = await inspectWork(ctx, name);
459
+ // Defence in depth (#193): whatever the boundary script exported or reported as nothing to collect,
460
+ // every commit the clone's work refers to must exist as a commit object in the operator checkout
461
+ // before a digest is recorded.
462
+ for (const sha of [work.head, ...work.branches.map((branch) => branch.commit)]) {
463
+ if (sha === undefined || await git(ctx, ["cat-file", "-e", `${sha}^{commit}`], true) === undefined) {
464
+ return { status: "failed", code: "writer_collect_incomplete", refs, refused: findings.length, findings };
465
+ }
466
+ }
453
467
  await writeWriterState(ctx.home, name, { ...state, collected: { digest: work.digest, at: new Date().toISOString() } });
454
468
  return { status: "ok", code: refs.length === 0 ? "nothing_to_collect" : "writer_collected", refs, refused: findings.length, findings };
455
469
  }
package/CHANGELOG.md CHANGED
@@ -2,6 +2,45 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.14.1 — 2026-10-30
6
+
7
+ Patch release under ADR 0003 for Sprint 12. The MCP tool contract, result
8
+ codes and configuration schema are unchanged. `npm audit` still reports
9
+ `undici` 8.10.2, pinned by Pi SDK 1.4.0, the latest SDK release
10
+ ([#120](https://github.com/plagor44-ux/pi-session-mcp/issues/120)).
11
+
12
+ Sprint 12 fixes three defects that an external audit of `9d9cf8c` reported and
13
+ that were reproduced on that commit
14
+ ([Sprint 12](docs/sprints/2026-10-29-s12-audit-fixes.md)). The MCP tool
15
+ contract, result codes and configuration schema are unchanged.
16
+
17
+ - Read-only sessions no longer load Pi context files (`AGENTS.override.md`,
18
+ `AGENTS.md`, `AGENTS.MD`, `CLAUDE.md`, `CLAUDE.MD`) from outside the
19
+ workspace into the model context
20
+ ([#192](https://github.com/plagor44-ux/pi-session-mcp/issues/192)). Every
21
+ resource loader of a read-only profile keeps only files whose real path lies
22
+ inside the workspace's real path, on every reload and before the first
23
+ provider request, and reads them from that verified path. Behaviour change:
24
+ context files of directories above the workspace, including a main
25
+ repository above a linked worktree, are no longer loaded for read-only
26
+ profiles. Coding profiles are unchanged
27
+ ([ADR 0013 amendment](docs/adr/0013-hardened-read-only-profiles.md)).
28
+ - Writer collection exports a clean detached `HEAD` whose commit no branch
29
+ reaches as `<base>-detached`, and records its release digest only when the
30
+ clone's `HEAD` and every branch commit exist in the operator checkout;
31
+ otherwise it reports `writer_collect_incomplete`. Before, such a commit was
32
+ reported as `nothing_to_collect` and finish, reset, recreate and remove
33
+ discarded it without `--discard`
34
+ ([#193](https://github.com/plagor44-ux/pi-session-mcp/issues/193)).
35
+ Clone-side Git, including the writer guard, now ignores replace refs, and a
36
+ derived `-wip` or `-detached` name that conflicts with an exported branch
37
+ path is refused instead of aborting the export.
38
+ - The writer relay compares every public capability field of a writer's
39
+ profile with the host profile before it starts an inner session, including
40
+ skills, extensions, granted extension tools and shell; a mismatch counts as
41
+ `settingsChanged`. Before, a writer with a different skill selection was
42
+ admitted ([#194](https://github.com/plagor44-ux/pi-session-mcp/issues/194)).
43
+
5
44
  ## 0.14.0 — 2026-10-29
6
45
 
7
46
  Minor release under ADR 0003 for
package/README.md CHANGED
@@ -30,7 +30,7 @@ It is not the right tool when:
30
30
 
31
31
  ## Status
32
32
 
33
- The package version is **0.14.0**.
33
+ The package version is **0.14.1**.
34
34
 
35
35
  [Per-repository execution modes](docs/execution-modes.md) make host writes explicit and block coding on read-only aliases. On container workspaces, the one host `pi-session-mcp` registration leases a rootless-Podman writer per coding session on Linux; no per-writer registrations or tool prefixes are needed ([ADR 0012](docs/adr/0012-leased-writer-pool.md)). Setup provisions, accepts, collects and resets the pool. Starts normally use the workspace default base; fix rounds can pass a short branch `base`. Start/get report `writer` and `base` for collection, and capabilities report configured `writers` and `defaultBase`. Reset, finish and recreate need no reconnect; finish returns the writer to the default base. See the [container upgrade runbook](docs/installation.md#container-writers).
36
36
 
@@ -1,6 +1,19 @@
1
1
  import type { ConfigurationMetadata } from "./config.js";
2
2
  import type { WorkspaceModeEntry } from "./execution-modes.js";
3
3
  import type { ConfiguredExecutionProfile } from "./execution-profile.js";
4
+ /** The public capability shape of one profile, exactly as `pi_capabilities_get` publishes it. */
5
+ export declare function projectExecutionProfile({ alias, default: isDefault, permissionProfile, provider, model, thinkingLevel, extensions, grantedExtensionTools, shell, skills }: ConfiguredExecutionProfile): {
6
+ alias: string;
7
+ default: boolean;
8
+ permissionProfile: import("./execution-profile.js").PermissionProfile;
9
+ provider: string;
10
+ model: string;
11
+ thinkingLevel: import("./execution-profile.js").ThinkingLevel;
12
+ extensions: string[];
13
+ grantedExtensionTools: string[];
14
+ shell: "none" | "sandboxed";
15
+ skills: string[];
16
+ };
4
17
  /** Pure projection of the snapshot captured at load time; never reads the configuration file. */
5
18
  export declare function projectCapabilities(workspaces: ReadonlyMap<string, string>, executionProfiles: ReadonlyMap<string, ConfiguredExecutionProfile>, configuration: ConfigurationMetadata, workspaceModes?: ReadonlyMap<string, WorkspaceModeEntry>, eventDelivery?: "spool" | "off"): {
6
19
  ok: true;
@@ -1,5 +1,21 @@
1
1
  import packageMetadata from "../package.json" with { type: "json" };
2
2
  const compareAliases = (left, right) => left < right ? -1 : left > right ? 1 : 0;
3
+ /** The public capability shape of one profile, exactly as `pi_capabilities_get` publishes it. */
4
+ export function projectExecutionProfile({ alias, default: isDefault, permissionProfile, provider, model, thinkingLevel, extensions, grantedExtensionTools, shell, skills }) {
5
+ return {
6
+ alias,
7
+ default: isDefault,
8
+ permissionProfile,
9
+ provider,
10
+ model,
11
+ thinkingLevel,
12
+ extensions: (extensions ?? []).map((extension) => extension.spec),
13
+ grantedExtensionTools: [...(grantedExtensionTools ?? [])],
14
+ // Configured intent only: whether the server's probe succeeds is a start-time fact.
15
+ shell: shell ?? "none",
16
+ skills: (skills ?? []).map((skill) => skill.alias),
17
+ };
18
+ }
3
19
  /** Pure projection of the snapshot captured at load time; never reads the configuration file. */
4
20
  export function projectCapabilities(workspaces, executionProfiles, configuration, workspaceModes = new Map(), eventDelivery = "off") {
5
21
  return {
@@ -18,18 +34,6 @@ export function projectCapabilities(workspaces, executionProfiles, configuration
18
34
  }),
19
35
  executionProfiles: [...executionProfiles.values()]
20
36
  .sort((left, right) => compareAliases(left.alias, right.alias))
21
- .map(({ alias, default: isDefault, permissionProfile, provider, model, thinkingLevel, extensions, grantedExtensionTools, shell, skills }) => ({
22
- alias,
23
- default: isDefault,
24
- permissionProfile,
25
- provider,
26
- model,
27
- thinkingLevel,
28
- extensions: (extensions ?? []).map((extension) => extension.spec),
29
- grantedExtensionTools: [...(grantedExtensionTools ?? [])],
30
- // Configured intent only: whether the server's probe succeeds is a start-time fact.
31
- shell: shell ?? "none",
32
- skills: (skills ?? []).map((skill) => skill.alias),
33
- })),
37
+ .map(projectExecutionProfile),
34
38
  };
35
39
  }
@@ -74,7 +74,7 @@ export type PromptDisposition = Parameters<NonNullable<PromptOptions["preflightR
74
74
  * typecheck (and therefore the Pi SDK canary) instead of being mapped silently.
75
75
  */
76
76
  export declare function admitsTurn(disposition: PromptDisposition): boolean;
77
- export declare function createPhase0ResourceLoader(cwd: string, skills?: readonly Skill[]): Promise<{
77
+ export declare function createPhase0ResourceLoader(cwd: string, skills?: readonly Skill[], permissionProfile?: ResolvedExecutionProfile["permissionProfile"]): Promise<{
78
78
  resourceLoader: DefaultResourceLoader;
79
79
  settingsManager: SettingsManager;
80
80
  }>;
@@ -1,6 +1,7 @@
1
1
  import { createAgentSession, createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createBashToolDefinition, DefaultResourceLoader, getAgentDir, ModelRuntime, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
2
+ import { readFileSync, realpathSync } from "node:fs";
2
3
  import { homedir, userInfo } from "node:os";
3
- import { join } from "node:path";
4
+ import { isAbsolute, join, relative, sep } from "node:path";
4
5
  import { allowedReadRoots, createConfinedReadOnlyTools } from "./confined-tools.js";
5
6
  import { PiExtensionError } from "./extension-spec.js";
6
7
  import { classifyMcpStartupError, connectSessionMcpTools, } from "./mcp-session-tools.js";
@@ -298,7 +299,7 @@ function isTextBlock(block) {
298
299
  return candidate.type === "text" && typeof candidate.text === "string";
299
300
  }
300
301
  /** Independently validated skills replace package and extension discovery on every reload. */
301
- function phase0ResourceLoaderOptions(skills) {
302
+ function phase0ResourceLoaderOptions(skills, confinedCwd) {
302
303
  // Keep the closure's snapshot private: the SDK may annotate or mutate returned objects.
303
304
  const selected = skills.map((skill) => ({ ...skill, sourceInfo: { ...skill.sourceInfo } }));
304
305
  return {
@@ -307,6 +308,24 @@ function phase0ResourceLoaderOptions(skills) {
307
308
  noPromptTemplates: true,
308
309
  noThemes: true,
309
310
  noContextFiles: false,
311
+ ...(confinedCwd === undefined ? {} : {
312
+ agentsFilesOverride: (current) => ({
313
+ agentsFiles: current.agentsFiles.flatMap((file) => {
314
+ try {
315
+ const workspace = realpathSync(confinedCwd), real = realpathSync(file.path);
316
+ const path = relative(workspace, real);
317
+ if (path === ".." || path.startsWith(`..${sep}`) || isAbsolute(path))
318
+ return [];
319
+ // Re-read from the verified real path: the SDK read the link before this check.
320
+ return [{ path: file.path, content: readFileSync(real, "utf-8").replace(/^\uFEFF/, "") }];
321
+ }
322
+ catch {
323
+ // Unresolvable context files never enter a read-only session's model context.
324
+ return [];
325
+ }
326
+ }),
327
+ }),
328
+ }),
310
329
  systemPrompt: "phase-0-system-prompt-disabled",
311
330
  appendSystemPrompt: [],
312
331
  systemPromptOverride: () => undefined,
@@ -316,9 +335,9 @@ function phase0ResourceLoaderOptions(skills) {
316
335
  themesOverride: () => ({ themes: [], diagnostics: [] }),
317
336
  };
318
337
  }
319
- export async function createPhase0ResourceLoader(cwd, skills = []) {
338
+ export async function createPhase0ResourceLoader(cwd, skills = [], permissionProfile) {
320
339
  const settingsManager = SettingsManager.inMemory(undefined, { projectTrusted: false });
321
- const resourceLoader = new DefaultResourceLoader({ cwd, agentDir: cwd, settingsManager, ...phase0ResourceLoaderOptions(skills) });
340
+ const resourceLoader = new DefaultResourceLoader({ cwd, agentDir: cwd, settingsManager, ...phase0ResourceLoaderOptions(skills, permissionProfile === "read-only" ? cwd : undefined) });
322
341
  await resourceLoader.reload();
323
342
  return { resourceLoader, settingsManager };
324
343
  }
@@ -367,7 +386,7 @@ export class SdkPiSessionAdapter {
367
386
  let resourceLoader;
368
387
  let settingsManager;
369
388
  try {
370
- ({ resourceLoader, settingsManager } = await createPhase0ResourceLoader(input.cwd, skills));
389
+ ({ resourceLoader, settingsManager } = await createPhase0ResourceLoader(input.cwd, skills, executionProfile.permissionProfile));
371
390
  }
372
391
  catch {
373
392
  throw this.failure(executionProfile, "resource_loader", "pi_session_creation_failed");
@@ -426,7 +445,7 @@ export class SdkPiSessionAdapter {
426
445
  agentDir: cwd,
427
446
  modelRuntime: runtime,
428
447
  settingsManager: SettingsManager.inMemory(undefined, { projectTrusted: false }),
429
- resourceLoaderOptions: { ...phase0ResourceLoaderOptions(skills), additionalExtensionPaths: directories },
448
+ resourceLoaderOptions: { ...phase0ResourceLoaderOptions(skills, executionProfile.permissionProfile === "read-only" ? cwd : undefined), additionalExtensionPaths: directories },
430
449
  }));
431
450
  this.checkResources(services.resourceLoader, skills, executionProfile, "extension_load");
432
451
  const loaded = services.resourceLoader.getExtensions();
@@ -2,7 +2,7 @@ import type { CommandRunner } from "./client-adapters/types.js";
2
2
  import { writerPaths, type WriterState } from "./writer-state.js";
3
3
  export declare const NODE_IMAGE_REF = "docker.io/library/node@sha256:c4d5523090a817b7aa86d2111241fdd4f66d1e27782b44160e6aa63b357ecb2d";
4
4
  export declare const IMAGE_PULL_TIMEOUT_MS = 900000;
5
- export declare const BOUNDARY_GIT = "git -c safe.directory=* -c core.fsmonitor=false -c core.hooksPath=/dev/null";
5
+ export declare const BOUNDARY_GIT = "git -c safe.directory=* -c core.fsmonitor=false -c core.hooksPath=/dev/null -c core.useReplaceRefs=false";
6
6
  type WriterPaths = ReturnType<typeof writerPaths>;
7
7
  export declare class BoundaryError extends Error {
8
8
  readonly code: "image_unavailable" | "clone_verification_failed" | "writer_fenced";
@@ -6,7 +6,8 @@ import { CONTAINER_EVENTS_DIR, EVENTS_DIR_ENV, writerEventsDir } from "./turn-ev
6
6
  import { writerContainerName, writerLabel } from "./writer-fence.js";
7
7
  export const NODE_IMAGE_REF = "docker.io/library/node@sha256:c4d5523090a817b7aa86d2111241fdd4f66d1e27782b44160e6aa63b357ecb2d";
8
8
  export const IMAGE_PULL_TIMEOUT_MS = 900_000;
9
- export const BOUNDARY_GIT = "git -c safe.directory=* -c core.fsmonitor=false -c core.hooksPath=/dev/null";
9
+ // Replace refs in a clone are worker-controlled: they could make unexported work look reachable or clean (#193).
10
+ export const BOUNDARY_GIT = "git -c safe.directory=* -c core.fsmonitor=false -c core.hooksPath=/dev/null -c core.useReplaceRefs=false";
10
11
  export class BoundaryError extends Error {
11
12
  code;
12
13
  constructor(code) {
@@ -13,7 +13,7 @@ const guardSchema = z.object({
13
13
  }).strict();
14
14
  const run = promisify(execFile);
15
15
  const defaultGit = async (cwd) => {
16
- const git = async (...args) => (await run("git", ["-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", ...args], { cwd, timeout: 10_000, encoding: "utf8" })).stdout;
16
+ const git = async (...args) => (await run("git", ["-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", "-c", "core.useReplaceRefs=false", ...args], { cwd, timeout: 10_000, encoding: "utf8" })).stdout;
17
17
  const [status, head, refs] = await Promise.all([
18
18
  git("status", "--porcelain=v1", "-uall"),
19
19
  git("rev-parse", "HEAD"),
@@ -66,6 +66,7 @@ export declare function provisionWriter(ctx: WriterContext, input: {
66
66
  export declare function inspectWork(ctx: WriterContext, name: string): Promise<{
67
67
  baseline: boolean;
68
68
  digest: string;
69
+ head?: string;
69
70
  branches: readonly {
70
71
  name: string;
71
72
  commit: string;
@@ -344,7 +344,7 @@ export async function inspectWork(ctx, name) {
344
344
  });
345
345
  return {
346
346
  baseline: head === state.base.commit && refs.length === 1 && refs[0].name === state.base.ref && refs[0].commit === state.base.commit && tree === baseTree,
347
- dirty: tree !== headTree, branches: refs.filter(({ name }) => name.startsWith("refs/heads/")),
347
+ dirty: tree !== headTree, head: head, branches: refs.filter(({ name }) => name.startsWith("refs/heads/")),
348
348
  digest: `sha256:${createHash("sha256").update(result.stdout).digest("hex")}`,
349
349
  };
350
350
  }
@@ -412,10 +412,16 @@ if [ "$tree" != "$(${BOUNDARY_GIT} rev-parse 'HEAD^{tree}')" ]; then
412
412
  export GIT_COMMITTER_NAME=pi-writer GIT_COMMITTER_EMAIL=pi-writer@example.invalid
413
413
  commit=$(printf '%s\\n' 'WIP collected by pi-session-mcp' | ${BOUNDARY_GIT} commit-tree "$tree" -p HEAD)
414
414
  branch=$(${BOUNDARY_GIT} symbolic-ref --short HEAD || printf '%s' "$4")
415
- if ${BOUNDARY_GIT} --git-dir=/tmp/scratch show-ref --verify --quiet "refs/heads/$branch-wip"; then
415
+ # A taken name, or one that conflicts with an exported branch path, is refused rather than aborting the export.
416
+ if ${BOUNDARY_GIT} --git-dir=/tmp/scratch show-ref --verify --quiet "refs/heads/$branch-wip" \\
417
+ || ! ${BOUNDARY_GIT} --git-dir=/tmp/scratch update-ref "refs/heads/$branch-wip" "$commit" 2>/dev/null; then
418
+ printf '%s\\n' ref_name_refused
419
+ fi
420
+ elif ! ${BOUNDARY_GIT} symbolic-ref -q HEAD >/dev/null 2>&1 && [ -n "$(${BOUNDARY_GIT} rev-list -n 1 HEAD --not --branches)" ]; then
421
+ # Clean detached HEAD that no branch of the clone reaches: export its commit under a derived name (#193).
422
+ if ${BOUNDARY_GIT} --git-dir=/tmp/scratch show-ref --verify --quiet "refs/heads/$4-detached" \\
423
+ || ! ${BOUNDARY_GIT} --git-dir=/tmp/scratch update-ref "refs/heads/$4-detached" "$(${BOUNDARY_GIT} rev-parse --verify HEAD)" 2>/dev/null; then
416
424
  printf '%s\\n' ref_name_refused
417
- else
418
- ${BOUNDARY_GIT} --git-dir=/tmp/scratch update-ref "refs/heads/$branch-wip" "$commit"
419
425
  fi
420
426
  fi
421
427
  if [ -z "$(${BOUNDARY_GIT} --git-dir=/tmp/scratch for-each-ref --format='%(refname)')" ]; then
@@ -450,6 +456,14 @@ fi`;
450
456
  return { status: "failed", code: "writer_collect_incomplete", refs, refused: findings.length, findings };
451
457
  }
452
458
  const work = await inspectWork(ctx, name);
459
+ // Defence in depth (#193): whatever the boundary script exported or reported as nothing to collect,
460
+ // every commit the clone's work refers to must exist as a commit object in the operator checkout
461
+ // before a digest is recorded.
462
+ for (const sha of [work.head, ...work.branches.map((branch) => branch.commit)]) {
463
+ if (sha === undefined || await git(ctx, ["cat-file", "-e", `${sha}^{commit}`], true) === undefined) {
464
+ return { status: "failed", code: "writer_collect_incomplete", refs, refused: findings.length, findings };
465
+ }
466
+ }
453
467
  await writeWriterState(ctx.home, name, { ...state, collected: { digest: work.digest, at: new Date().toISOString() } });
454
468
  return { status: "ok", code: refs.length === 0 ? "nothing_to_collect" : "writer_collected", refs, refused: findings.length, findings };
455
469
  }
@@ -2,6 +2,7 @@ import { constants } from "node:fs";
2
2
  import { open } from "node:fs/promises";
3
3
  import { Client } from "@modelcontextprotocol/client";
4
4
  import { z } from "zod";
5
+ import { projectExecutionProfile } from "./capabilities.js";
5
6
  import { ChildStdioTransport } from "./child-stdio-transport.js";
6
7
  import { PiSessionCreationError, WriterUnavailableError } from "./pi-adapter.js";
7
8
  import { RegistryError } from "./session-registry.js";
@@ -71,6 +72,16 @@ const creationCodes = new Set([
71
72
  "extension_not_installed", "extension_version_mismatch", "extension_load_failed", "extension_tool_conflict",
72
73
  "extension_tool_missing", "skill_load_failed", "sandbox_unavailable", "pi_session_creation_failed",
73
74
  ]);
75
+ const exactFields = ["permissionProfile", "provider", "model", "thinkingLevel", "shell"];
76
+ const setFields = ["skills", "extensions", "grantedExtensionTools"];
77
+ const sameSet = (left, right) => {
78
+ const a = new Set(left), b = new Set(right);
79
+ return a.size === left.length && b.size === right.length && a.size === b.size && [...a].every(value => b.has(value));
80
+ };
81
+ /** Admission compares every public capability field; a duplicate on either side is a mismatch. */
82
+ function samePublishedProfile(host, inner) {
83
+ return exactFields.every(key => host[key] === inner[key]) && setFields.every(key => sameSet(host[key], inner[key]));
84
+ }
74
85
  class InnerError extends Error {
75
86
  code;
76
87
  constructor(code) {
@@ -306,7 +317,7 @@ export class WriterRelayAdapter {
306
317
  const capabilities = await connection.call("pi_capabilities_get", {}, capabilitiesSchema, remaining(), input.signal);
307
318
  const inner = capabilities.executionProfiles.find(profile => profile.alias === input.executionProfile.alias);
308
319
  const host = this.options.executionProfiles.get(input.executionProfile.alias);
309
- if (!host || !inner || ["permissionProfile", "provider", "model", "thinkingLevel"].some(key => inner[key] !== host[key])) {
320
+ if (!host || !inner || !samePublishedProfile(projectExecutionProfile(host), inner)) {
310
321
  counts.settingsChanged++;
311
322
  continue;
312
323
  }
@@ -2598,6 +2598,195 @@ image) reported `writer_accepted` from 09:48 to 09:51 UTC. Client-target
2598
2598
  all 14 writers. Doctor reported no error, only `workspace_mode_unset` for one
2599
2599
  workspace without a mode.
2600
2600
 
2601
+ ### v0.14.1 pre-release acceptance
2602
+
2603
+ `v0.14.1` was accepted on 2026-10-30 from 06:04 to 06:15 UTC on its release
2604
+ candidate `a239610`, on Linux (`x64`) with Node.js `26.12.1`, npm `12.2.1`,
2605
+ Codex CLI `0.162.1` and Claude Code `2.1.314`. Level 2 passed in the working
2606
+ tree and for the packed tarball, each with delivery off and with delivery on.
2607
+ Level 1 passed except `npm audit`. Later commits on the release pull request
2608
+ change only this record.
2609
+
2610
+ The patch release ships Sprint 12 (#192, #193, #194) from `main` at `d9a47f5`;
2611
+ the candidate adds only the version, the changelog heading and the roadmap
2612
+ line. The MCP tool contract, result codes and configuration schema are
2613
+ unchanged. The source changes were accepted on the branch before the merge, see
2614
+ [Sprint 12 candidate acceptance](#sprint-12-candidate-acceptance-2026-10-30),
2615
+ including Level 3.
2616
+
2617
+ - Level 1 in the checkout of the candidate after `npm ci`: every command exited
2618
+ with `0`, except `npm audit`; `git status --short` printed nothing.
2619
+ - The test run, with `NODE_OPTIONS=--unhandled-rejections=strict`, reported
2620
+ 141 files, 4044 passed and 14 skipped, and no unhandled error.
2621
+ - `npm audit` and `npm audit --omit=dev` exited with `1` and name only the
2622
+ known `undici` 8.10.2 finding through `@earendil-works/pi-coding-agent`
2623
+ 1.4.0, the latest SDK release, with eight advisories
2624
+ ([#120](https://github.com/plagor44-ux/pi-session-mcp/issues/120)).
2625
+ - `npm pack` listed 933 files (tarball shasum
2626
+ `47cf66a3856530112c11095d07cb05b7ab9892f4`, identical in three packs). A
2627
+ scan of the unpacked files for private names, hosts and the maintainer's
2628
+ home path found none.
2629
+ - Doctor reported `packageVersion` `0.14.1`. It exited with `2` because no
2630
+ configuration was set (`config_missing`).
2631
+ - Level 2 in the working tree: 160 of 160 checks passed with delivery off and
2632
+ 228 of 228 with `--events spool`.
2633
+ - Packed-tarball Level 2 from a fresh prefix: 160 of 160 with delivery off and
2634
+ 228 of 228 with `--events spool`. The install smoke from another fresh prefix
2635
+ reported `smoke_passed` with version `0.14.1`.
2636
+ - In every Level 2 run the real client registrations were unchanged, no process
2637
+ remained, the leak check passed and the disposable directories were removed.
2638
+ - Podman-gated writer tests on the candidate (Podman `6.1.4`): 8 of 8 passed
2639
+ (7 lifecycle tests, including the detached-`HEAD` export of #193, and 1
2640
+ boundary test).
2641
+ - Bubblewrap-gated sandbox tests (`test/sandbox-shell.bwrap.test.ts`, bubblewrap
2642
+ `0.13.1`): 12 of 12 passed, none skipped.
2643
+
2644
+ Not established for `0.14.1` beyond the Sprint 12 record: a Level 3 cycle on
2645
+ the release candidate itself, and the published-release Level 2 and upgrades,
2646
+ which follow after publishing.
2647
+
2648
+ ### Sprint 12 candidate acceptance (2026-10-30)
2649
+
2650
+ Recorded on 2026-10-29 and 2026-10-30 for the unreleased branch
2651
+ `fix/s12-audit-findings` ([Sprint 12](sprints/2026-10-29-s12-audit-fixes.md),
2652
+ #192, #193, #194). No release, tag or npm publish.
2653
+
2654
+ - Level 1 on the final source `9426682` in a separate detached worktree with
2655
+ Node.js `26.12.1`: `npm ci`, typecheck, `build:cli`, the test run (530 files,
2656
+ 4044 passed, 14 skipped, 0 failed, no unhandled error, with
2657
+ `NODE_OPTIONS=--unhandled-rejections=strict`), `build` and
2658
+ `git diff --check 9d9cf8c..HEAD` exited with `0`. The same on the earlier
2659
+ candidate `896e29d`: 4043 passed, 0 failed.
2660
+ - Real rootless Podman with the pinned Node image (git `2.39.5`):
2661
+ `test/writer-lifecycle.podman.test.ts` 7 of 7, including the new clean
2662
+ detached-HEAD case, which fails against the source of `9d9cf8c`. In the image,
2663
+ `core.useReplaceRefs=false` makes a graft-hidden detached commit visible
2664
+ again.
2665
+ - Level 2 on `npm pack` of `896e29d`, installed into a fresh prefix below the
2666
+ home directory: 160 of 160 with delivery off and 228 of 228 with
2667
+ `--events spool`; real registrations and built files unchanged, no process
2668
+ left, path-leak scan 0 hits. Clients: Codex CLI `0.162.1` and Claude Code
2669
+ `2.1.312`. The two later commits change the guard's Git arguments and a
2670
+ regular-expression spelling; Level 2 was not repeated for them.
2671
+ - Live profile change (item D), on an expendable test configuration: a
2672
+ candidate with the new read-only profile `review-sonnet`
2673
+ (`anthropic/claude-sonnet-5-5`), Doctor without error, probe dry run, one
2674
+ probe turn (`probe_passed`), timestamped backup and atomic replacement,
2675
+ Doctor, then real use by a fresh server for the R1 and final reviews; rolled
2676
+ back to the backup afterwards, Doctor without error. No production
2677
+ configuration or writer was touched.
2678
+ - Live container writer (items B and C), with the packed candidate, an
2679
+ expendable configuration and a synthetic repository: profile `s12-code`
2680
+ (`deepseek/deepseek-flash`, coding, skill `pi-session-implementation`),
2681
+ one probe turn (`probe_passed`); `--repo s12-probe --mode container
2682
+ --profiles s12-code --writers 1 --apply` reported `writer_provisioned` and
2683
+ `writer_accepted`, `--writer s12-probe-w1 --verify` `mcp_verified`. A real
2684
+ relayed coding session completed its task; `--collect --apply` imported
2685
+ `refs/remotes/writer-s12-probe-w1/s12-live-c`, whose commit passed the
2686
+ oracle (4 of 4 tests), and `--finish --apply` reported `writer_reset`. With
2687
+ the host profile's skills then changed, a start failed with
2688
+ `writer_unavailable`, reason `none_admitted`, counts
2689
+ `settingsChanged: 1`, and collection afterwards found `nothing_to_collect`.
2690
+ After `--writer s12-probe-w1 --remove` and the provisioning request again,
2691
+ the writer was accepted and verified and a start succeeded. Rolled back:
2692
+ writer removed, configuration, repository and prefix deleted.
2693
+ - Level 3, ten-task cycle, on the final source `9426682` (`dist/main.js`
2694
+ SHA-256 `ab93e3a932b1d5147dcbb48bbb85d9442c2214e486101f8d427360b99672cfdb`)
2695
+ from a separate detached checkout, with a temporary credential-free
2696
+ configuration (`l3-read` read-only and `l3-code` coding, both
2697
+ `deepseek/deepseek-flash`), synthetic fixtures, events off, one probe turn
2698
+ per profile as preflight. Claude Code `2.1.312` ran as `claude -p` with a
2699
+ per-invocation `--mcp-config`, `--strict-mcp-config` and
2700
+ `--model claude-sonnet-5-5`; Codex CLI `0.162.1` as `codex exec
2701
+ --ignore-user-config` with `-c mcp_servers.*` overrides. Both clients could
2702
+ wait with `sleep` between polls.
2703
+ - Cycle 1 was not accepted: 5 of 10. The worker ignored a requested
2704
+ ordering in both follow-ups (T2), refused the abort task's essay prompt
2705
+ within about 1.5 seconds so that nothing was left to abort (T5), and the
2706
+ Codex coding snapshot could not be written from the Codex sandbox (T3). A
2707
+ path hit came from the worker's `assistantText`, not from server
2708
+ metadata. No Pi Session MCP defect was found; the cycle is kept unchanged
2709
+ with its own pre-registration.
2710
+ - Cycle 2, with a new pre-registration before its first task (order-free
2711
+ LOW oracle, a long deterministic listing as the abort prompt, snapshots
2712
+ inside the controller directory, the leak scan separating server metadata
2713
+ from `assistantText`): 10 of 10. Read-only fixture digests unchanged; both
2714
+ coding snapshots pass their oracle and the fixture tests and show only the
2715
+ allowed files; both abort tasks show running, abort, `aborted`, close, an
2716
+ empty `pi_session_list` and a fresh `idle` session. Zero leak hits, also in
2717
+ `assistantText`; no server or client process before or 10 seconds after any
2718
+ invocation. Polls per turn: Claude Code 1 to 2, Codex 2 to 4. Deviation:
2719
+ the Codex sandbox refused to spawn `git`, so the Codex snapshots' status
2720
+ lists were derived from the barrier snapshot trees against `HEAD`.
2721
+ The cycle-2 gate holds.
2722
+
2723
+ Not established: a release of these fixes, the operator rollout on either
2724
+ machine, macOS, and #120.
2725
+
2726
+ ### v0.14.0 post-release evidence
2727
+
2728
+ Recorded on 2026-10-29 for the published release `0.14.0`.
2729
+
2730
+ Tag binding: a fresh clone of the annotated tag `v0.14.0` (tag object
2731
+ `f3a8b088a85fd5825c3256f4a7950ba051cbf388`) peels to the merge commit
2732
+ `fd4fdeacf9d02c04b6fd89314f7ec819b5506ed9` of #190, and
2733
+ `git describe --tags --exact-match` reports `v0.14.0`. CI on the merge commit
2734
+ (run 40928709705) and on the tag (run 40929602738) passed on the first attempt.
2735
+ The tag was created after the npm package had been published; the package
2736
+ records `gitHead` `d904fed`, the head of #190, whose tree (`3ae966b`) is
2737
+ identical to the merge commit's.
2738
+
2739
+ - Level 1 on the tag clone after `npm ci`, from 18:18 to 18:20 UTC with
2740
+ Node.js `26.12.1` and npm `12.2.1`: typecheck, `build:cli`, the test run
2741
+ (139 files, 4005 passed, 14 skipped, with
2742
+ `NODE_OPTIONS=--unhandled-rejections=strict`), `build` and
2743
+ `git diff --check` exited with `0`, and the status stayed clean. Doctor
2744
+ reported `packageVersion` `0.14.0` and exited with `2` because no
2745
+ configuration was set (`config_missing`).
2746
+ - `pi-session-mcp@0.14.0` on npm: 932 files, shasum
2747
+ `579cc111a7aa62987b5f35e79b34b69853c8a0e3`. It was published by the
2748
+ maintainer; `npm pack` of the tag clone gives the same shasum and file count.
2749
+ - Published npm package, Level 2 from a fresh prefix, 18:19 to 18:21 UTC:
2750
+ 160 of 160 with delivery off and 228 of 228 with `--events spool`; real
2751
+ registrations and built files unchanged, no process left, leak check passed.
2752
+ Clients: Codex CLI `0.162.1` and Claude Code `2.1.312`. The install smoke on
2753
+ the published package reported `smoke_passed` with version `0.14.0`.
2754
+ - The GitHub release `v0.14.0` was published the same day.
2755
+
2756
+ Operator upgrade of the workstation from `0.13.1` to `0.14.0` on 2026-10-29
2757
+ from 18:33 UTC, following
2758
+ [Installation](installation.md#container-writers): no Pi session of this
2759
+ controller was open; other controller sessions without a coding session stayed
2760
+ open and need a reconnect. Both client targets were removed with `0.13.1`, then
2761
+ `npm install -g pi-session-mcp@0.14.0`. Doctor reported only
2762
+ `writer_not_ready` for the 18 writers. A dry run of the first provisioning
2763
+ command with unchanged settings reported `mode_unchanged` and
2764
+ `writer_planned`; with `--apply`, five container repositories with 18 writers
2765
+ (2 with `--tags reachable`, 4, 4 with `--cache go`, 4 with base `dev`, 4 with
2766
+ `--cache uv`, three of them with operator images) reported `writer_accepted`
2767
+ from 18:34 to 18:37 UTC. Client-target `--apply` and `--verify` passed for
2768
+ `claude-code:user` and `codex:user` (`mcp_verified`, `skill_verified` for both
2769
+ controller skills, and `hook_verified` for the wake hook); `--writer <name>
2770
+ --verify` passed for all 18 writers. Doctor reported no error, only the known
2771
+ `workspace_gitfile` and `workspace_mode_unset` warnings of the worktree
2772
+ aliases.
2773
+
2774
+ Operator upgrade of the second machine (Linux) from `0.13.1` to `0.14.0` on
2775
+ 2026-10-29 from 18:38 UTC, with Node.js `24.22.0` and the same steps; one
2776
+ controller session's server process stayed open and needs a reconnect. Both
2777
+ client targets were removed with `0.13.1`, then
2778
+ `npm install -g pi-session-mcp@0.14.0`; Doctor reported only
2779
+ `writer_not_ready` for the 14 writers. With `--apply`, four container
2780
+ repositories with 14 writers (4, 2, 4 with an operator image and `--cache go`,
2781
+ 4 with an operator image) reported `writer_accepted` from 18:39 to 18:42 UTC.
2782
+ Client-target `--apply` and `--verify` passed for `claude-code:user` and
2783
+ `codex:user` (`mcp_verified`, `skill_verified` for both controller skills), and
2784
+ `--writer <name> --verify` passed for all 14 writers. Doctor reported no
2785
+ error, only `workspace_mode_unset` for one workspace without a mode.
2786
+
2787
+ Not established for `0.14.0` after the upgrade: a coding session through a
2788
+ re-accepted writer, an agent-driven profile change end to end, and Level 3.
2789
+
2601
2790
  ### v0.14.0 pre-release acceptance
2602
2791
 
2603
2792
  `v0.14.0` was accepted on 2026-10-29 from 17:56 to 18:01 UTC on its release
@@ -161,12 +161,16 @@ base.
161
161
  In both cases the host server closes that launcher and tries the next
162
162
  candidate. The inner writer guard stays the authority for baseline admission
163
163
  inside the boundary.
164
- - Before the inner start the relay compares the requested profile's entry in
165
- the writer's validated `pi_capabilities_get` (permission profile, provider,
166
- model, thinking level) with the host's configured profile and moves on with
167
- `writer_settings_changed` on any difference, so a writer never runs an
168
- outdated version of a host profile. The launcher cannot do this check: it
169
- starts before the profile is known.
164
+ - Before the inner start the relay compares every public capability field of
165
+ the requested profile's entry in the writer's validated `pi_capabilities_get`
166
+ with the host's configured profile: `permissionProfile`, `provider`, `model`,
167
+ `thinkingLevel` and `shell` exactly, and `skills`, `extensions` and
168
+ `grantedExtensionTools` as sets. A mismatch or a missing entry counts as
169
+ `settingsChanged`, starts no inner session and moves on to the next
170
+ candidate, so a writer never runs an outdated version of a host profile. The
171
+ launcher cannot do this check: it starts before the profile is known.
172
+ (Amended 2026-10-29, #194: the comparison covers every public capability
173
+ field, not only permission profile, provider, model and thinking level.)
170
174
  - If no candidate is admitted, the start fails with `writer_unavailable`, a
171
175
  content-free reason and content-free counts. `none_ready`: no writer of the
172
176
  workspace is ready, migrated and has a recorded acceptance (whether that
@@ -145,6 +145,36 @@ directly after `grantedExtensionTools`. This is configured intent only.
145
145
  MCP tools and the granted extension tools. A profile with granted extension
146
146
  tools cannot have a sandboxed shell.
147
147
 
148
+ ### Amendment 2026-10-29: context files
149
+
150
+ Issue #192. Previously every resource loader kept the SDK behaviour: Pi
151
+ context files (`AGENTS.override.md`, `AGENTS.md`, `AGENTS.MD`, `CLAUDE.md`,
152
+ `CLAUDE.MD`) were loaded from the session directory and every ancestor
153
+ directory, and a context file reached through a symlink was followed to its
154
+ target, so a read-only session also loaded context from outside the workspace.
155
+
156
+ New rule: for **read-only profiles** every resource loader keeps only context
157
+ files whose real path lies inside the workspace's real path, on every reload
158
+ and before the first provider request. A symlinked context file that resolves
159
+ outside the workspace, a dangling one, and the context files of ancestor
160
+ directories above the workspace (including a main repository above a linked
161
+ worktree) are not loaded for read-only profiles; the linked worktree's own file
162
+ inside the workspace is. Coding profiles keep the SDK behaviour (the working
163
+ directory and its ancestors).
164
+
165
+ The filter runs on the SDK's result, after the SDK has picked the first
166
+ existing name per directory. If that file is dropped, the directory contributes
167
+ no context file: an `AGENTS.override.md` that resolves outside the workspace
168
+ also hides an `AGENTS.md` beside it. This fails closed. A kept file's content
169
+ is read again from its verified real path, so a link swapped after the SDK
170
+ read cannot bring in outside content.
171
+
172
+ Why: the read roots of this ADR. Section 1 confines the read tools of
173
+ read-only profiles to the workspace and the skill directories; context files
174
+ are model input rather than tool reads, and before this amendment they were the
175
+ one channel through which content from outside those roots still reached a
176
+ read-only session.
177
+
148
178
  ## Contract change and versioning
149
179
 
150
180
  The capabilities field `shell`, the start error `sandbox_unavailable` and the
@@ -1,6 +1,6 @@
1
1
  # ADR 0014: Profile probe and offline model catalog
2
2
 
3
- **Status:** Accepted (2026-10-29); unreleased. Issue #139.
3
+ **Status:** Accepted (2026-10-29); released in 0.14.0. Issue #139.
4
4
 
5
5
  ## Context
6
6
 
@@ -182,10 +182,24 @@ pi-session-mcp-setup --writer my-project-w1 --finish --apply
182
182
 
183
183
  Collection exports changed branches via a bundle into the operator's checkout as
184
184
  `refs/remotes/writer-<name>/<branch>`. Uncommitted/untracked work is captured as a
185
- synthetic `<branch>-wip` commit. These refs are **untrusted until reviewed**;
185
+ synthetic `<branch>-wip` commit. A clean detached HEAD whose commit no branch in
186
+ the clone reaches is exported as `<base>-detached` (for example `main-detached`,
187
+ imported as `refs/remotes/writer-<name>/main-detached`); a dirty detached HEAD
188
+ keeps the `<base>-wip` export. A `<base>-detached` or `<branch>-wip` name that
189
+ an exported branch already takes, or that conflicts with an exported branch's
190
+ path (such as a branch `main-detached/topic`), is refused and makes collection
191
+ incomplete (`writer_collect_incomplete`); the other branches are still
192
+ exported. Clone-side Git ignores replace refs. These refs are **untrusted until reviewed**;
186
193
  setup does not merge or cherry-pick them. Collection does not change the clone.
187
- Only a complete export records a digest authorizing later destructive cleanup;
188
- new work invalidates that authorization. Stashes are not exported as branches:
194
+ Only a complete export records a digest authorizing later destructive cleanup,
195
+ and the digest is recorded only when the clone's HEAD commit and every branch
196
+ commit exist in the operator checkout; otherwise collection is incomplete
197
+ (`writer_collect_incomplete`) and no digest is recorded. This includes the base
198
+ commit: if the operator checkout no longer has it (for example after a deleted
199
+ base branch and garbage collection), collect and finish fail this way even on a
200
+ pristine writer, without refused names; fetch the base again or resolve with
201
+ `--reset --discard --apply`. New work invalidates
202
+ that authorization. Stashes are not exported as branches:
189
203
  resolve any needed stash inside the boundary before collection.
190
204
 
191
205
  `--finish` collects, then resets a persistent writer or recreates an ephemeral
@@ -234,6 +248,14 @@ reset updates admission without a server restart. A missing/unusable runtime or
234
248
  stale/unready writer launches a refusal MCP server: all eight tools return the
235
249
  sanitized refusal, with no host fallback. See [Tool contracts](tool-contracts.md).
236
250
 
251
+ Before the relay starts an inner session it compares every public capability
252
+ field of the writer's profile with the host profile: `permissionProfile`,
253
+ `provider`, `model`, `thinkingLevel` and `shell` exactly, and `skills`,
254
+ `extensions` and `grantedExtensionTools` as sets. A mismatch or a missing
255
+ entry counts as `settingsChanged`, starts no inner session and moves on to the
256
+ next writer; if no writer is admitted, the start fails with
257
+ `writer_unavailable` and reason `none_admitted`.
258
+
237
259
  ## Reports and finding codes
238
260
 
239
261
  Reports use sanitized targets `modes/workspace/<alias>` or `writer/<alias>/<name>`
@@ -225,12 +225,18 @@ Git metadata can redirect reads to worker-controlled configuration. Setup checks
225
225
  clone layout and runs clone-side Git only in throwaway no-network containers.
226
226
  Collect with `--writer <name> --collect --apply`, then review
227
227
  `refs/remotes/writer-<name>/<branch>` in your own checkout, with dirty work at
228
- `<branch>-wip`. Imported refs are untrusted until reviewed.
228
+ `<branch>-wip` and a clean detached HEAD at `<base>-detached` (for example
229
+ `main-detached`). Imported refs are untrusted until reviewed.
229
230
 
230
231
  After taking over the result and closing its coding session, run
231
232
  `pi-session-mcp-setup --writer <name> --finish --apply` before another task.
232
233
  It collects then resets (persistent) or recreates (ephemeral). If any branch name
233
- was refused, collection is incomplete and finish stops. Rename inside the writer
234
+ was refused, collection is incomplete and finish stops; a `<base>-detached` or
235
+ `<branch>-wip` name that an exported branch takes or whose path it conflicts
236
+ with is refused the same way. The release digest is
237
+ recorded only when the clone's HEAD commit and every branch commit exist in the
238
+ operator checkout, otherwise collection is `writer_collect_incomplete` and no
239
+ digest is recorded. Rename inside the writer
234
240
  or have the operator explicitly resolve loss with `--reset --discard --apply`.
235
241
  Finish returns persistent and ephemeral writers to the workspace default base.
236
242
  No reconnect is needed after finish, reset or recreate: each session starts a
package/docs/roadmap.md CHANGED
@@ -41,6 +41,15 @@ mean they are implemented or that their gates passed.
41
41
  profile), and the shipped controller skill `pi-session-profiles`, with which a
42
42
  controlling agent creates, changes or retires profiles without a guided
43
43
  dialog. Recorded in [ADR 0014](adr/0014-profile-probe-and-model-catalog.md).
44
+ - 2026-10-30: [Sprint 12](sprints/2026-10-29-s12-audit-fixes.md) fixes three
45
+ audit findings on `9d9cf8c` in one pull request, released in patch release
46
+ 0.14.1: read-only context files confined to the workspace
47
+ ([#192](https://github.com/plagor44-ux/pi-session-mcp/issues/192)), detached
48
+ `HEAD` collection and the release-digest gate
49
+ ([#193](https://github.com/plagor44-ux/pi-session-mcp/issues/193)) and the
50
+ full writer profile comparison in the relay
51
+ ([#194](https://github.com/plagor44-ux/pi-session-mcp/issues/194)). Levels 1
52
+ to 3 passed for the candidate. #120 still waits for a Pi SDK release.
44
53
 
45
54
  The explicit 2026-10-17 user request adds a bounded development-only
46
55
  [token-footprint measurement](sprints/2026-10-17-token-footprint.md), following
@@ -4,9 +4,11 @@ Planning basis: main `47b8d26` (Sprint 7 merged), installed release 0.11.2.
4
4
  Tracking: [#79](https://github.com/plagor44-ux/pi-session-mcp/issues/79),
5
5
  prerequisite [#121](https://github.com/plagor44-ux/pi-session-mcp/issues/121).
6
6
 
7
- Status: **planned**. The maintainer made the choices on 2026-10-25; see
8
- [Decisions](#decisions-2026-10-25). Nothing below has been changed on any
9
- machine yet.
7
+ Status: **in progress** — steps 1–4 are done (pilot `pi-session-mcp`,
8
+ `node-app`, the operator images, go-app) and steps 5–7 are open; see
9
+ [Progress (2026-10-27)](#progress-2026-10-27) and the v0.11.6 and v0.11.8
10
+ post-release evidence in [Acceptance](../acceptance.md). The maintainer made
11
+ the choices on 2026-10-25; see [Decisions](#decisions-2026-10-25).
10
12
 
11
13
  ## Mandate
12
14
 
@@ -0,0 +1,172 @@
1
+ # Sprint 12: audit findings F1–F3
2
+
3
+ Planning basis: main `9d9cf8ca49e6e34dc16be6846b9324ad26ad92b9` (merge of PR
4
+ #191), published 0.14.0, 2026-10-29. Entry: an external audit of that commit
5
+ reported three defects with reproductions. This file plans the work and records
6
+ it as it lands; it claims nothing that its progress log does not state with
7
+ evidence.
8
+
9
+ Branch: `fix/s12-audit-findings`, one pull request, no release.
10
+
11
+ ## Preflight (2026-10-29)
12
+
13
+ - main and `origin/main` at `9d9cf8c`; open issues #79, #120, #144; no open
14
+ pull requests; clean operator checkout; no other worktree in use.
15
+ - Doctor: no errors; only the known `workspace_gitfile` and
16
+ `workspace_mode_unset` warnings of the host worktree aliases.
17
+ - Level 1 on `9d9cf8c` in a clean detached worktree on the workstation:
18
+ `npm ci`, `npm run typecheck`, `npm run build:cli`,
19
+ `NODE_OPTIONS=--unhandled-rejections=strict npm test` (527 files, 4005
20
+ passed, 14 skipped, 0 failed, no unhandled error) and `npm run build` all exit
21
+ 0. The audit's cloud run of the same commit reported 70 failed tests and four
22
+ unhandled errors; they do not reproduce here, so they are not attributed to
23
+ the product. The audit environment is not available to separate them further.
24
+ - Both `pi-session-mcp` writers were at `baseline` on an older main and were
25
+ reset without `--discard` to `9d9cf8c` (`writer_reset`).
26
+
27
+ ## Findings, re-checked on `9d9cf8c`
28
+
29
+ | Id | Issue | Reproduction by the controller | Result |
30
+ | --- | --- | --- | --- |
31
+ | F1 | [#192](https://github.com/plagor44-ux/pi-session-mcp/issues/192) | Synthetic workspace whose `AGENTS.md` links to an outside marker file; `PI_CODING_AGENT_DIR` isolated; faux provider | Confined `read` refuses; `getAgentsFiles()` contains the marker; the real `SdkPiSessionAdapter` sends it as `project_instructions` to the faux provider. Confirmed. |
32
+ | F2 | [#193](https://github.com/plagor44-ux/pi-session-mcp/issues/193) | Real Git; boundary scripts run by an injected runner | Detached `HEAD` commit, clean tree: remove before collect `writer_uncollected`; collect `nothing_to_collect`, 0 refs, digest stored; commit absent from the operator checkout; finish `writer_reset`; remove `writer_removed`. Feature-branch counterproof exports the commit. Confirmed; not yet a Podman result. |
33
+ | F3 | [#194](https://github.com/plagor44-ux/pi-session-mcp/issues/194) | Real relay over stdio, fake writer server fixture | Identical profiles admitted; model drift refused with `settingsChanged`; host `skills: ["pi-session-implementation"]` against inner `skills: []` admitted. Confirmed. |
34
+
35
+ ## Decisions taken at planning
36
+
37
+ - F1: for read-only profiles every resource loader keeps only context files
38
+ whose real path lies inside the workspace's real path. Context files of
39
+ ancestor directories above the workspace, including a main repository above a
40
+ linked worktree, are no longer loaded for read-only profiles: they are outside
41
+ the read roots of ADR 0013. Coding profiles keep the SDK's behaviour.
42
+ - F2: smallest fix inside the existing collection contract; the export name for
43
+ a detached `HEAD` is a documented behaviour addition, no new public code.
44
+ - F3: the relay compares every public capability field a writer reports, with
45
+ set semantics for skills, extensions and granted extension tools.
46
+ - #120: still waiting for an SDK release (see below); no override, no
47
+ `npm audit fix --force`.
48
+ - No release on this branch.
49
+
50
+ ## Roles and routing
51
+
52
+ `pi_capabilities_get` and `--list-models` on 2026-10-29 settle which requested
53
+ models exist as authenticated profiles:
54
+
55
+ | Requested | Profile used | Status |
56
+ | --- | --- | --- |
57
+ | GPT 6.1 Sol | `code` (openai `gpt-6.1-sol`) | available |
58
+ | GPT 6.1 Terra | none | unavailable: the catalog lists no `gpt-6.1-terra`; not substituted with another version |
59
+ | DeepSeek | `code-deepseek` (`deepseek-flash`) | available |
60
+ | GLM 5.3 | `review-glm`, `code-glm` (`zai-glm-5-3`) | available |
61
+ | Mistral Large 4 | `code-mistral` (`mistral-large-4`) | available |
62
+ | Sonnet 5.5 | no profile; provider `anthropic` authenticated | added only in an expendable test configuration (acceptance item D) |
63
+ | Grok 4.7 | `review-grok` (xai `grok-4.7`) | available: the R2 turn completed |
64
+
65
+ agy (Antigravity CLI, Gemini) plans the work packages, dependencies and review
66
+ routing; Pi workers implement and review; the controller integrates, verifies
67
+ and is the only editor of this file, the roadmap, `docs/acceptance.md` and the
68
+ CHANGELOG.
69
+
70
+ ## Tasks
71
+
72
+ | Id | Task | Route | Change area |
73
+ | --- | --- | --- | --- |
74
+ | P1 | Work packages, dependencies, review routing | agy | none |
75
+ | P2 | Independent regression test plan | `review-glm` | none |
76
+ | T1 | F1, #192 | `pi-session-mcp` writer, `code` | `src/sdk-pi-adapter.ts`, `src/confined-tools.ts`, tests |
77
+ | T2 | F2, #193 | `pi-session-mcp` writer, `code-mistral` (agy's fallback for the unavailable Terra) | `src/writer-lifecycle.ts`, tests |
78
+ | T3 | F3, #194 | `pi-session-mcp` writer, `code-deepseek` | `src/writer-relay.ts`, `src/capabilities.ts`, tests, relay fixture |
79
+ | R1 | Security review of T1 and T3 | Sonnet 5.5, read-only | none |
80
+ | R2 | Adversarial review of T2 and the Git lifecycle checks | `review-grok` | none |
81
+ | D1 | Documentation and issue reconciliation (#120, ADR 0014 status, Sprint 8 status, #79, #144) | `pi-session-mcp` writer, `code-mistral`; controller as editor | `docs/`, `skills/pi-session-orchestration/SKILL.md` |
82
+ | A | Live acceptance A–E | controller | evaluation record |
83
+
84
+ ## Progress log
85
+
86
+ Recorded as work lands on the branch.
87
+
88
+ - 2026-10-29: issues #192, #193 and #194 filed; this plan written before any
89
+ implementation.
90
+ - P1: agy (`gemini-3.8-flash-high`, plan mode) proposed the packages,
91
+ `code-mistral` as the F2 author in place of Terra, Sonnet for R1 and Grok
92
+ with a fallback for R2, and at most three review rounds. Adopted.
93
+ - P2: `review-glm` wrote the regression test plan; its edge cases (absent
94
+ against empty set fields, real-Git collection, a Podman case) went into the
95
+ task prompts.
96
+ - T3 (`code-deepseek`, writer w2): collected as
97
+ `writer-pi-session-mcp-w2/fix/f3-relay-profile-drift`; the controller ran the
98
+ new tests against the old source (7 fail) and the fix (all pass); integrated
99
+ as `230e60e`, writer finished (`writer_reset`).
100
+ - T1 (`code`, writer w1): collected as
101
+ `writer-pi-session-mcp-w1/fix/f1-context-files`; new tests against the old
102
+ source 9 of 12 fail, with the fix 148 of 148 pass; integrated as `a0a7d87`,
103
+ writer finished. The container-writer suite showed only the eight known
104
+ lock and hook timeouts (`override-lock`, `skill-customize-concurrency`,
105
+ `wake-hook`).
106
+ - R1 (Sonnet 5.5, read-only, through the test configuration of item D):
107
+ `NON_BLOCKING_ONLY`. Fixed by the controller: kept context files are read
108
+ again from their verified real path (`79e8a7a`), the set comparison is
109
+ symmetric with a duplicated-entry test (`3e14279`); the shadowing case is
110
+ covered by a test and documented. Not changed: synchronous `realpathSync` in
111
+ the loader override (the SDK hook is synchronous) and the trust in the
112
+ writer's own capability report, which is the boundary the relay already
113
+ assumes.
114
+ - T2 (`code-mistral`, writer w2): collected as
115
+ `writer-pi-session-mcp-w2/fix/f2-detached-collect`; the new real-Git tests
116
+ against the old source 3 of 8 fail, with the fix 8 of 8 pass, and the F2
117
+ reproduction now gets `writer_collected`; integrated as `236ca28`, writer
118
+ finished. The controller added the Podman case (`6ee7add`): with real
119
+ rootless Podman and the pinned Node image it passes, against the old source
120
+ it fails.
121
+ - D1 (`code-mistral`, writer w1): collected as
122
+ `writer-pi-session-mcp-w1/docs/s12-reconcile`; split by issue into
123
+ `f5a9712`, `58e0e7b`, `17845ff` and `ebe3c52`. Controller edits: the
124
+ shadowing and re-read rule in the ADR 0013 amendment, and the relay wording
125
+ (a mismatch counts as `settingsChanged`; `writer_unavailable` with
126
+ `none_admitted` only when no writer is admitted). Writer finished.
127
+ - R2 (`review-grok`, Grok 4.7, read-only): `NON_BLOCKING_ONLY`. Verified by
128
+ the controller and fixed in `1dfe4da`: a derived `-wip` or `-detached` name
129
+ that conflicts with an exported branch path aborted the export under
130
+ `set -eu` (reproduced with a branch `main-detached/topic`); a worker-made
131
+ `git replace --graft` made an unexported detached commit look reachable
132
+ (Grok's variant with a plain replacement did not reproduce, the graft did);
133
+ the reachability gate now also runs on `nothing_to_collect`. Recorded as
134
+ limits, not changed: an index-only staged change that the worktree reverted
135
+ (pre-existing), a base name of 192 to 200 characters (fails closed), and a
136
+ runner that reports a fetch it did not do (outside the threat model).
137
+ - Final integration review (Sonnet 5.5, read-only, on `896e29d`):
138
+ `NON_BLOCKING_ONLY`. Fixed: the in-boundary guard probe now ignores replace
139
+ refs too (`9426682`, with a test that fails without it), the BOM pattern is an
140
+ escape (`5d9f15b`), and execution modes names the pruned-base case of the
141
+ digest gate. Not changed: `inspectWork.head` stays optional because required
142
+ would break type-checked mocks outside this change.
143
+ - Acceptance: see
144
+ [Sprint 12 candidate acceptance](../acceptance.md#sprint-12-candidate-acceptance-2026-10-30).
145
+ Level 1 green on `9426682`; Level 2 green on the packed `896e29d`; live items
146
+ B, C and D passed on expendable configurations and were rolled back; the
147
+ Level 3 ten-task cycle 2 passed (10 of 10) after cycle 1 failed on fixture and
148
+ harness design (5 of 10, kept unchanged).
149
+
150
+ ## Results
151
+
152
+ | Finding | Status | Commits | Evidence |
153
+ | --- | --- | --- | --- |
154
+ | F1 #192 | fixed | `a0a7d87`, `79e8a7a`, `5d9f15b`; docs `f5a9712` | `test/read-only-context-files.test.ts` (fails on `9d9cf8c`), faux-provider context check, R1 and final review |
155
+ | F2 #193 | fixed | `236ca28`, `6ee7add`, `1dfe4da`, `9426682`; docs `58e0e7b`, `896e29d` | real-Git collect tests, Podman test, guard test (each fails on the source before), R2 and final review |
156
+ | F3 #194 | fixed | `230e60e`, `3e14279`; docs `17845ff` | `test/writer-relay.test.ts` drift tests (fail on `9d9cf8c`), live refusal with `settingsChanged`, R1 and final review |
157
+
158
+ Remaining risk: #120 is unchanged. `npm audit --omit=dev` on 2026-10-29 still
159
+ reports `undici` 8.10.2 (high) through `@earendil-works/pi-coding-agent` 1.4.0
160
+ (moderate), eight advisories fixed in `undici` >= 8.12.2; the only offered fix
161
+ is a semver-major downgrade. No override and no `npm audit fix --force`.
162
+
163
+ Kept open: #79 (Sprint 8 steps 5 to 7 are not done), #120 and #144 (no macOS
164
+ claim). The sandbox resource limits of ADR 0013 are the follow-up issue
165
+ [#195](https://github.com/plagor44-ux/pi-session-mcp/issues/195), not part of
166
+ this sprint.
167
+
168
+ Cleanup: all controller Pi sessions closed; both `pi-session-mcp` writers
169
+ finished (`writer_reset`); the test writer `s12-probe-w1` removed; expendable
170
+ configurations, the synthetic repository, the pack prefix and the
171
+ controller's worktrees removed.
172
+
@@ -16,7 +16,16 @@ Pi Session MCP trusts the local process that launches and speaks MCP over inheri
16
16
  (arguments the SDK would rewrite, such as a leading `@` or `~`, a `file:`
17
17
  URL, Unicode spaces or NUL, are refused so both resolve the same path), and
18
18
  the `*Operations` hooks of `read`, `grep` and `ls` check the absolute path
19
- again. `rg` and `fd` do not follow symlinks during traversal. A profile with
19
+ again. `rg` and `fd` do not follow symlinks during traversal. For read-only
20
+ profiles the resource loader likewise keeps only Pi context files
21
+ (`AGENTS.override.md`, `AGENTS.md`, `AGENTS.MD`, `CLAUDE.md`, `CLAUDE.MD`)
22
+ whose real path lies inside the workspace's real path, on every reload and
23
+ before the first provider request: a symlinked context file that resolves
24
+ outside the workspace, a dangling one and the context files of ancestor
25
+ directories above the workspace (including a main repository above a linked
26
+ worktree) are not loaded, while the linked worktree's own file inside the
27
+ workspace is. Coding profiles keep the SDK behaviour (the working directory
28
+ and its ancestors). A profile with
20
29
  `shell: "sandboxed"` also gets `bash`, run as `/usr/bin/bwrap` (fixed path,
21
30
  root-owned and not group- or world-writable, no `PATH` lookup) with
22
31
  `--unshare-all`, `--disable-userns`, a cleared environment, no network, `/usr`
@@ -40,7 +40,7 @@ records name the package version and the build they ran on, see
40
40
  ```json
41
41
  {
42
42
  "ok": true,
43
- "server": { "name": "pi-session-mcp", "version": "0.14.0" },
43
+ "server": { "name": "pi-session-mcp", "version": "0.14.1" },
44
44
  "configuration": {
45
45
  "fingerprint": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
46
46
  "reloadPolicy": "restart-required"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-session-mcp",
3
- "version": "0.14.0",
3
+ "version": "0.14.1",
4
4
  "description": "Local stdio MCP server for controlled Pi coding-agent sessions",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -122,7 +122,7 @@ You integrate. Workers never integrate each other's changes. Resolve conflicts b
122
122
 
123
123
  After taking over the result and closing its coding session, before the next task:
124
124
 
125
- 1. Run `pi-session-mcp-setup --writer <name> --collect --apply` from the operator environment. Review the collected refs in the operator's checkout: `refs/remotes/writer-<name>/<branch>`, with uncommitted work as `<branch>-wip`. They are untrusted until reviewed. Integrate only verified work yourself.
125
+ 1. Run `pi-session-mcp-setup --writer <name> --collect --apply` from the operator environment. Review the collected refs in the operator's checkout: `refs/remotes/writer-<name>/<branch>`, with uncommitted work as `<branch>-wip` and a clean detached HEAD as `<base>-detached`. They are untrusted until reviewed. Integrate only verified work yourself.
126
126
  2. Run `pi-session-mcp-setup --writer <name> --finish --apply`. It collects again, then resets a persistent writer or recreates an ephemeral writer. Do not reuse it until this succeeds. A refused branch name makes collection incomplete and finish stops: ask the operator to resolve it by renaming inside the boundary or explicitly discarding with `--reset --discard --apply`. Finish returns it to the workspace default base. No reconnect is needed after reset, finish or recreate. A setup operation on a writer held by a session fails with `writer_busy` after a short wait; close the session and allow launcher cleanup to finish.
127
127
  3. A coding start needs the writer's clone at its baseline, even after a complete collection; otherwise it fails with the controller-action code for uncollected work in [references/start-errors.md](references/start-errors.md). To let a fresh session continue from collected work, for example after a failed turn or for a review fix round, create a local branch at the collected ref and run `pi-session-mcp-setup --writer <name> --reset --base <branch> --apply`, then call `pi_session_start` on `pi-session-mcp` with the coding profile and `base: "<branch>"`. Read `writer` from that result for collection. Default starts cannot use a writer moved to the fix branch.
128
128
  4. Never run Git inside the writer's clone as the host user, including direct fetch from that clone. Use setup's bundle collection and inspect the imported refs in your own checkout. Never discard unreviewed work just to admit the next task.