pi-session-mcp 0.12.0 → 0.13.0

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.
Files changed (62) hide show
  1. package/.pi-session-mcp-cli/client-adapters/mcp-verifier.js +8 -3
  2. package/.pi-session-mcp-cli/config.js +15 -1
  3. package/.pi-session-mcp-cli/doctor.js +44 -1
  4. package/.pi-session-mcp-cli/execution-modes.js +13 -3
  5. package/.pi-session-mcp-cli/execution-profile.js +1 -0
  6. package/.pi-session-mcp-cli/sandbox-shell.js +254 -0
  7. package/.pi-session-mcp-cli/session-tools.js +6 -2
  8. package/.pi-session-mcp-cli/writer-boundary.js +27 -0
  9. package/.pi-session-mcp-cli/writer-lifecycle.js +6 -4
  10. package/.pi-session-mcp-cli/writer-limits.js +19 -0
  11. package/.pi-session-mcp-cli/writer-state.js +5 -2
  12. package/CHANGELOG.md +96 -0
  13. package/README.md +2 -1
  14. package/dist/capabilities.d.ts +1 -0
  15. package/dist/capabilities.js +3 -1
  16. package/dist/client-adapters/mcp-verifier.js +8 -3
  17. package/dist/config.d.ts +1 -0
  18. package/dist/config.js +15 -1
  19. package/dist/confined-tools.d.ts +29 -0
  20. package/dist/confined-tools.js +118 -0
  21. package/dist/doctor.d.ts +5 -1
  22. package/dist/doctor.js +44 -1
  23. package/dist/execution-modes.d.ts +15 -1
  24. package/dist/execution-modes.js +13 -3
  25. package/dist/execution-profile.d.ts +4 -0
  26. package/dist/execution-profile.js +1 -0
  27. package/dist/main.js +6 -2
  28. package/dist/pi-adapter.d.ts +1 -1
  29. package/dist/pi-adapter.js +1 -0
  30. package/dist/sandbox-shell.d.ts +71 -0
  31. package/dist/sandbox-shell.js +254 -0
  32. package/dist/sdk-pi-adapter.d.ts +17 -1
  33. package/dist/sdk-pi-adapter.js +64 -10
  34. package/dist/session-tools.d.ts +4 -2
  35. package/dist/session-tools.js +6 -2
  36. package/dist/setup-cli.js +40 -6
  37. package/dist/setup-modes.d.ts +3 -0
  38. package/dist/setup-modes.js +18 -1
  39. package/dist/setup-writers.js +37 -6
  40. package/dist/tool-schemas.d.ts +4 -0
  41. package/dist/tool-schemas.js +1 -0
  42. package/dist/writer-boundary.d.ts +2 -0
  43. package/dist/writer-boundary.js +27 -0
  44. package/dist/writer-lifecycle.d.ts +1 -0
  45. package/dist/writer-lifecycle.js +6 -4
  46. package/dist/writer-limits.d.ts +13 -0
  47. package/dist/writer-limits.js +19 -0
  48. package/dist/writer-relay.js +1 -1
  49. package/dist/writer-state.d.ts +11 -0
  50. package/dist/writer-state.js +5 -2
  51. package/docs/acceptance.md +266 -0
  52. package/docs/adr/0013-hardened-read-only-profiles.md +182 -0
  53. package/docs/doctor-design.md +2 -0
  54. package/docs/execution-modes.md +59 -3
  55. package/docs/installation.md +34 -1
  56. package/docs/isolation-setup.md +31 -2
  57. package/docs/roadmap.md +8 -1
  58. package/docs/session-mcp.md +13 -0
  59. package/docs/threat-model.md +50 -0
  60. package/docs/tool-contracts.md +30 -3
  61. package/package.json +1 -1
  62. package/skills/pi-session-orchestration/references/start-errors.md +1 -1
@@ -2457,6 +2457,272 @@ Not established for `0.11.6`:
2457
2457
  wrapper's periodic credential copy (#136);
2458
2458
  - the VS Code wake; macOS delivery.
2459
2459
 
2460
+ ### v0.13.0 pre-release acceptance
2461
+
2462
+ `v0.13.0` was accepted on 2026-10-28 from 19:30 to 19:36 UTC on its release
2463
+ candidate `f7442b3`, on Linux (`x64`) with Node.js `26.12.1`, npm `12.2.1`,
2464
+ Codex CLI `0.162.1` and Claude Code `2.1.312`. Level 2 passed in the working
2465
+ tree and for the packed tarball, each with delivery off and with delivery on.
2466
+ Level 1 passed except `npm audit`. Later commits on the release pull request
2467
+ change only this record.
2468
+
2469
+ The minor release ships ADR 0013 (#169) and the writer settings changes #162,
2470
+ #163, #165 and #166. It changes the MCP contract (read-only confinement,
2471
+ profile field and capabilities field `shell`, start error
2472
+ `sandbox_unavailable`); the setup report schema is unchanged.
2473
+
2474
+ - Level 1 in a clean worktree of the candidate after `npm ci`: every command
2475
+ exited with `0`, except `npm audit`; `git status --short` printed nothing.
2476
+ - The test run, with `NODE_OPTIONS=--unhandled-rejections=strict`, reported
2477
+ 125 files, 3936 passed and 14 skipped, and no unhandled error.
2478
+ - `npm audit` and `npm audit --omit=dev` exited with `1` and name only the
2479
+ known `undici` finding through `@earendil-works/pi-coding-agent` 1.4.0
2480
+ ([#120](https://github.com/plagor44-ux/pi-session-mcp/issues/120)).
2481
+ - `npm pack` listed 916 files (tarball shasum
2482
+ `1259d57d2d59099ad72e796609ecab3d581e9b06`, identical in two packs). A scan
2483
+ of the packed files for private names, hosts and paths found none; its only
2484
+ matches for `/home/` are the already redacted
2485
+ `<repo>/eval/results/.../home/.pi/...` paths of disposable evaluation homes
2486
+ in six `docs/evaluation/extension-functions-2026-10-17/cursor/` evidence
2487
+ files, which `0.12.1` shipped unchanged.
2488
+ - Doctor reported `packageVersion` `0.13.0`. It exited with `2` because no
2489
+ configuration was set (`config_missing`).
2490
+ - Level 2 in the working tree: 160 of 160 checks passed with delivery off and
2491
+ 228 of 228 with `--events spool`.
2492
+ - Packed-tarball Level 2 from a fresh prefix: 160 of 160 with delivery off and
2493
+ 228 of 228 with `--events spool`. The install smoke from another fresh prefix
2494
+ reported `smoke_passed` with version `0.13.0`.
2495
+ - In every Level 2 run the real client registrations were unchanged, no process
2496
+ remained, the leak check passed and the disposable directories were removed.
2497
+ - Podman-gated writer tests on the candidate: 7 of 7 passed.
2498
+ - Bubblewrap-gated sandbox tests (`test/sandbox-shell.bwrap.test.ts`, bubblewrap
2499
+ `0.13.1`): 12 of 12 passed, none skipped.
2500
+ - Review: #169 had an independent review of its full diff (same model family as
2501
+ the author) that found no confinement or sandbox escape; its follow-ups were
2502
+ fixed before the merge of #170.
2503
+
2504
+ Not established for `0.13.0`: a live session with `shell: "sandboxed"` against
2505
+ a real provider, client-target `--verify` through the installed package, which
2506
+ follows the upgrade; Level 3.
2507
+
2508
+ ### v0.12.1 post-release evidence
2509
+
2510
+ Recorded on 2026-10-28 for the published release `0.12.1`.
2511
+
2512
+ Tag binding: a fresh clone of the annotated tag `v0.12.1` (tag object
2513
+ `eecd6dbf0c220ded997c697f281f6b984d9ebf4e`) peels to the merge commit
2514
+ `91799f2e84b514110f77c24986d59e72c5ed98e5` of #161, and
2515
+ `git describe --tags --exact-match` reports `v0.12.1`. CI on the merge commit
2516
+ and on the tag passed on the first attempt.
2517
+
2518
+ - Level 1 on the tag clone after `npm ci`, from 14:43 to 14:47 UTC with
2519
+ Node.js `26.12.1` and npm `12.2.1`: typecheck, `build:cli`, the test run
2520
+ (120 files, 3708 passed, 14 skipped, with
2521
+ `NODE_OPTIONS=--unhandled-rejections=strict`), `build` and
2522
+ `git diff --check` exited with `0`, and the status stayed clean.
2523
+ - `pi-session-mcp@0.12.1` on npm: 907 files, shasum
2524
+ `58dc7235e0d54d4290d92934d877223e22eec03d`, `gitHead` `cc25693` (the head of
2525
+ #161, whose tree equals the merge commit). `npm pack` of the tag clone gives
2526
+ the same shasum and file count.
2527
+ - Published npm package, Level 2 from a fresh prefix: 160 of 160 with delivery
2528
+ off and 228 of 228 with `--events spool`; real registrations and built files
2529
+ unchanged, no process left, leak check passed. Clients: Codex CLI `0.162.1`
2530
+ and Claude Code `2.1.312`. The install smoke on the published package
2531
+ reported `smoke_passed` with version `0.12.1`.
2532
+ - The GitHub release `v0.12.1` was published the same day.
2533
+
2534
+ Workstation, upgraded from `0.12.0` to `0.12.1` by the release session: Doctor
2535
+ reported no error, client-target `--verify` passed for `claude-code:user` and
2536
+ `codex:user` (`mcp_verified`, `skill_verified`, and `hook_verified` for the
2537
+ wake hook), the open point of the pre-release record, and
2538
+ `--writer <name> --verify` passed for all 18 writers (`mcp_verified`).
2539
+
2540
+ Operator upgrade of the second machine (Linux) from `0.11.8` to `0.12.1` on
2541
+ 2026-10-28 from 14:50 UTC, following
2542
+ [Installation](installation.md#container-writers), with Node.js `24.22.0`:
2543
+
2544
+ - No pi-session-mcp server process and no writer container ran at the start.
2545
+ - Both client targets were removed with `0.11.8`, then
2546
+ `npm install -g pi-session-mcp@0.12.1`. Doctor reported every writer
2547
+ `writer_not_ready`, each container workspace `workspace_base_unset` and each
2548
+ writer `writer_git_identity_missing`.
2549
+ - Four container repositories with 14 writers were migrated by repeating their
2550
+ provisioning commands with unchanged settings, each first as a dry run
2551
+ (`planned`), then with `--apply`: 2, 4, 4 (operator image, `--cache go`) and
2552
+ 4 (operator image) writers. Every writer reported `writer_accepted`.
2553
+ - `--finish --apply` of all 14 writers reported `writer_reset`; no work was
2554
+ collected. Doctor then reported no writer or base finding.
2555
+ - `--apply` and `--verify` of both client targets passed (`mcp_verified`), and
2556
+ `--writer <name> --verify` passed for all 14 writers. No writer registration
2557
+ and no container remained.
2558
+
2559
+ Live checks of #154 and #151 on the workstation, 15:02 to 15:04 UTC, with the
2560
+ coding profile `code-deepseek` through the one host registration and wake
2561
+ events from the host spool:
2562
+
2563
+ - Settings change: a writer whose settings differ is refused with
2564
+ `writer_settings_changed`. Both repositories' writers were collected
2565
+ (`nothing_to_collect`), removed with `--writer <name> --remove` and
2566
+ provisioned again with `--tags reachable` (2 writers) and `--cache uv`
2567
+ (4 writers, operator image with uv `0.13.6`); every writer reported
2568
+ `writer_accepted`.
2569
+ - Tags (#154): the writer with `--tags reachable` held 14 tags, the tags of
2570
+ the operator checkout merged into `main`. In the session,
2571
+ `git describe --tags` reported `v0.12.1` and
2572
+ `git show v0.12.0:package.json` read the file at that tag. The writer without
2573
+ the setting held no tags.
2574
+ - Identity (#154): an empty commit in the session, without `GIT_AUTHOR_*` or
2575
+ `GIT_COMMITTER_*` and without `--author`, recorded the operator's name as
2576
+ author and committer. `git config user.name` inside the writer failed with
2577
+ exit `4` because `.git/config` is mounted read-only. The collected ref showed
2578
+ the same identity. The test ref was removed afterwards.
2579
+ - uv cache (#151): three sessions on the same writer, with `--finish --apply`
2580
+ between the second and the third. In the first, `UV_CACHE_DIR` was
2581
+ `/pi/uv/cache` and empty; `uv sync --frozen` took 1.9 s and left 95,576 KiB
2582
+ in 1,648 files. The second and third sessions found the same size and file
2583
+ count before they ran anything, and `uv sync --frozen --offline` after
2584
+ removing `.venv` took 0.07 s and 0.08 s.
2585
+
2586
+ Findings from these checks:
2587
+
2588
+ - Reset and finish keep Git-ignored files in the clone, as designed: `.venv`
2589
+ from the first session was still present in the third, after the finish.
2590
+ The opt-in `--clean all` removes them
2591
+ ([#162](https://github.com/plagor44-ux/pi-session-mcp/issues/162)); it is
2592
+ not part of the released `0.12.1`.
2593
+ - A provisioning command with changed settings reported `mode_applied` and
2594
+ recorded the new mode although every writer failed with
2595
+ `writer_settings_changed`; Doctor reported no mismatch
2596
+ ([#163](https://github.com/plagor44-ux/pi-session-mcp/issues/163), open).
2597
+
2598
+ Not established for `0.12.1`: a busy pool (`writer_unavailable`) and a `base`
2599
+ fix round in live use; Level 3; the macOS upgrade.
2600
+
2601
+ ### v0.12.1 pre-release acceptance
2602
+
2603
+ `v0.12.1` was accepted on 2026-10-28 from 13:12 to 13:17 UTC on its release
2604
+ candidate `c6b472d`, on Linux (`x64`) with Node.js `26.12.1`, npm `12.2.1`,
2605
+ Codex CLI `0.162.1` and Claude Code `2.1.312`. 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 ships #160 on top of 0.12.0. It does not change the MCP contract or
2611
+ the setup report schema.
2612
+
2613
+ - Level 1 in a clean worktree of the candidate after `npm ci`: every command
2614
+ exited with `0`, except `npm audit`; `git status --short` printed nothing.
2615
+ - The test run, with `NODE_OPTIONS=--unhandled-rejections=strict`, reported
2616
+ 120 files, 3708 passed and 14 skipped, and no unhandled error. It ran on a
2617
+ host with a real writer lease directory, which the Doctor mode-order test
2618
+ no longer reads.
2619
+ - `npm audit` exited with `1` and names only the known `undici` finding
2620
+ through `@earendil-works/pi-coding-agent` 1.4.0
2621
+ ([#120](https://github.com/plagor44-ux/pi-session-mcp/issues/120)).
2622
+ - `npm pack` listed 907 files (tarball shasum
2623
+ `f206e0eb0818342fb9e6a36c2c147fb690c515ce`); a scan of the packed files for
2624
+ private names, hosts and paths found none.
2625
+ - Doctor reported `packageVersion` `0.12.1`. It exited with `2` because no
2626
+ configuration was set (`config_missing`).
2627
+ - Level 2 in the working tree: 160 of 160 checks passed with delivery off and
2628
+ 228 of 228 with `--events spool`.
2629
+ - Packed-tarball Level 2 from a fresh prefix: 160 of 160 with delivery off and
2630
+ 228 of 228 with `--events spool`. The install smoke reported `smoke_passed`
2631
+ with version `0.12.1`.
2632
+ - In every Level 2 run the real client registrations and the built files were
2633
+ unchanged, no process remained, and the leak check passed.
2634
+ - Podman-gated writer tests on the candidate: 4 of 4 passed.
2635
+ - The candidate's setup verifier against its own server with the operator's
2636
+ real configuration, which has five container workspaces, reported
2637
+ `verified`, with initialize, `tools/list` (eight tools) and
2638
+ `pi_capabilities_get`. The same check with the installed 0.12.0 failed at
2639
+ `pi_capabilities_get`.
2640
+ - Review: `review-glm` (another model family than the author) on the fix,
2641
+ verdict `NON_BLOCKING_ONLY`; the shared writer bound and the real-server test
2642
+ with container workspaces were added.
2643
+
2644
+ Not established for `0.12.1`: client-target `--verify` through the installed
2645
+ package, which follows the upgrade; Level 3.
2646
+
2647
+ ### v0.12.0 post-release evidence
2648
+
2649
+ Recorded on 2026-10-28 for the published release `0.12.0`.
2650
+
2651
+ Tag binding: a fresh clone of the annotated tag `v0.12.0` (tag object
2652
+ `bb77dfd8a4e49849d5e9c7fd305fb00baf443044`) peels to the merge commit
2653
+ `dfbef8c5efce38eacc8704dbaf93c534cdad0ef8` of #159, and
2654
+ `git describe --tags --exact-match` reports `v0.12.0`. CI on the merge commit
2655
+ passed on Linux, macOS 15, macOS 26 and `verify` after one rerun: the first
2656
+ macOS 26 run hit a timeout in `test/extension-ablation-deps.test.ts`
2657
+ (5044 ms against 5000 ms), a git-heavy evaluation test that the release did not
2658
+ touch. CI on the tag passed on the first attempt.
2659
+
2660
+ - Level 1 on the tag clone after `npm ci`: typecheck, `build:cli`, the test
2661
+ run (120 files, 3693 passed, 14 skipped, with
2662
+ `NODE_OPTIONS=--unhandled-rejections=strict`), `build` and
2663
+ `git diff --check` exited with `0`, and the status stayed clean. Doctor
2664
+ reported `packageVersion` `0.12.0`.
2665
+ - `pi-session-mcp@0.12.0` on npm: 907 files, shasum
2666
+ `4699b05ef849e88c6d56f5ddae6b06984799cea6`, `gitHead` `1fb8a7e` (the head of
2667
+ #159, whose tree equals the merge commit). A local `npm pack` of the merge
2668
+ commit gives the same shasum. A scan of the published files for private
2669
+ names, hosts and paths found none.
2670
+ - Published npm package, Level 2 from a fresh prefix: 160 of 160 with delivery
2671
+ off and 228 of 228 with `--events spool`; the install smoke reported
2672
+ `smoke_passed` with version `0.12.0`. Clients: Codex CLI `0.162.1` and Claude
2673
+ Code `2.1.312`.
2674
+ - The GitHub release `v0.12.0` was published the same day.
2675
+
2676
+ Operator upgrade of the workstation from `0.11.8` to `0.12.0` on 2026-10-28,
2677
+ following [Installation](installation.md#container-writers):
2678
+
2679
+ - Coding sessions were closed and the two pi-session-mcp writer servers were
2680
+ disabled in the controller; one idle controller of another repository still
2681
+ had four writer containers running.
2682
+ - Both host client targets (`claude-code:user`, `codex:user`) were removed with
2683
+ `0.11.8`, then `npm install -g pi-session-mcp@0.12.0`. Doctor reported
2684
+ `packageVersion` `0.12.0` and, as expected, every writer `writer_not_ready`
2685
+ and `writer_not_migrated`.
2686
+ - Five container repositories with 18 writers were migrated by repeating their
2687
+ provisioning commands with unchanged settings, each first as a dry run
2688
+ (`planned`), then with `--apply`: pi-session-mcp (2 writers), node-app (4),
2689
+ go-app (4, operator image, `--cache go`), pynode-app (4, operator image,
2690
+ base `dev`) and py-app (4, operator image). Every writer reported
2691
+ `writer_accepted` and is `launch: "relay"`, `ready`, verdict `pass`. Each
2692
+ repository recorded its shared default base (`main`, and `dev` for
2693
+ pynode-app). All 36 owned writer registrations and their hook entries were
2694
+ removed; the fence removed the four running legacy containers; no writer
2695
+ container remained.
2696
+ - `--apply` of both client targets passed, but `--verify` failed with
2697
+ `mcp_failed` on both:
2698
+ [#160](https://github.com/plagor44-ux/pi-session-mcp/issues/160). A direct
2699
+ handshake with the installed server returned the eight tools and the
2700
+ capabilities, including `writers` and `defaultBase` for each container
2701
+ workspace; the 0.12.0 verifier rejected them. The fix, run with the real
2702
+ configuration against the candidate's own server, reported `verified`.
2703
+ - `--writer <name> --verify` passed for all 18 writers over the relay path
2704
+ (`mcp_verified`).
2705
+ - Live relayed coding session through the one host registration, after
2706
+ reconnecting `pi-session-mcp`: `pi_capabilities_get` listed the container
2707
+ workspaces with `writers` and `defaultBase`; `pi_session_start` with profile
2708
+ `code` on the pi-session-mcp workspace leased `pi-session-mcp-w1` at
2709
+ `refs/heads/main` and returned `writer` and `base`. The worker implemented
2710
+ the #160 fix in two turns. Both turns woke the controller through the wake
2711
+ hook from the host spool, without polling. Close, collect and finish
2712
+ succeeded without a reconnect. A read-only `review-glm` session on the host
2713
+ checkout reviewed the commit (`NON_BLOCKING_ONLY`).
2714
+ - The first turn could not commit: the migrated clones had no commit identity
2715
+ (Doctor `writer_git_identity_missing` on all 18 writers), because migration
2716
+ keeps the clone and only provisioning and reset copy the identity. After
2717
+ `--finish` of the used writer and `--reset --apply` of the other 17, Doctor
2718
+ reported no writer finding, and all 18 writers stayed `relay`, `ready` and
2719
+ accepted. The upgrade runbook now names this step.
2720
+
2721
+ Not established for `0.12.0`: the upgrade of the second machine; the live
2722
+ checks of #151 (`--cache uv`) and #154 (`--tags reachable`), which need a
2723
+ settings change of an existing writer; a busy-pool (`writer_unavailable`) and a
2724
+ `base` fix round in live use; Level 3.
2725
+
2460
2726
  ### v0.12.0 pre-release acceptance
2461
2727
 
2462
2728
  `v0.12.0` was accepted on 2026-10-28 from 11:40 to 11:45 UTC on its release
@@ -0,0 +1,182 @@
1
+ # ADR 0013: Hardened read-only profiles
2
+
3
+ **Status:** Accepted (2026-10-28). Amends
4
+ [ADR 0006](0006-pi-extension-allowlist.md) and
5
+ [ADR 0010](0010-read-only-extension-tools.md). Issue #169.
6
+
7
+ ## Context
8
+
9
+ Read-only profiles have `read`, `grep`, `find` and `ls` and no shell. Review
10
+ workers cannot run `git log`, `git blame`, a test file or a short script, and
11
+ read more files instead.
12
+
13
+ Read-only sessions run in the host server process. The SDK's read tools accept
14
+ absolute paths, `..`, `~` and symlinks, so a read-only worker can read every
15
+ file the operator can read, including Pi credentials outside the workspace.
16
+ Today only the absence of a network path keeps that content local. A granted
17
+ research tool with free-form input (search, documentation lookup) would give a
18
+ prompt-injected worker a channel to send it out.
19
+
20
+ ## Decision
21
+
22
+ ### 1. Workspace-confined read tools for every read-only profile
23
+
24
+ `read`, `grep`, `find` and `ls` of **every** read-only profile accept only
25
+ paths inside the allowed read roots:
26
+
27
+ - the session's workspace root, and
28
+ - the directory of each selected skill file, because the model loads skills
29
+ with `read` and they live outside the workspace. If that directory equals or
30
+ contains the operator's home directory, the configuration file's directory
31
+ or the Pi agent directory (`~/.pi`), only the skill file itself is a root,
32
+ so a catalog entry such as `./SKILL.md` next to the configuration does not
33
+ expose the configuration.
34
+
35
+ The server replaces the SDK's built-in definitions with definitions built from
36
+ the root exports `createReadToolDefinition`, `createGrepToolDefinition`,
37
+ `createFindToolDefinition` and `createLsToolDefinition`, registered under the
38
+ same names as SDK custom tools. Two layers check each call:
39
+
40
+ 1. Before the SDK tool runs, the `path` argument is resolved against the
41
+ session directory and canonicalized with `realpath` (for a path that does
42
+ not exist yet, the nearest existing ancestor plus the remaining segments).
43
+ It must equal an allowed root or lie below one. Arguments the SDK would
44
+ rewrite before resolving (a leading `@` or `~`, a `file:` URL, Unicode
45
+ spaces, NUL) are refused, so this check and the SDK always resolve the
46
+ same path.
47
+ 2. The `*Operations` hooks of `read`, `grep` and `ls` check the absolute path
48
+ the SDK hands them again. This also covers `read`'s file-name variants and
49
+ symlinks met during reading.
50
+
51
+ `grep` and `find` start `rg` and `fd` on the checked path. Neither follows
52
+ symlinks during traversal by default, so a symlink below the workspace does not
53
+ lead out of it. A refusal is an ordinary tool error,
54
+ `Path is outside the allowed read roots`, without the path.
55
+
56
+ At admission the server checks with `getToolDefinition` that each of these
57
+ names resolves to the server's own definition; otherwise the start fails with
58
+ `pi_session_creation_failed`.
59
+
60
+ The confinement is always on; there is no configuration field. Reading outside
61
+ the workspace has no review use, and a per-profile switch would invite the
62
+ unconfined variant for exactly the profiles that get external tools.
63
+
64
+ ### 2. Sandboxed shell, opt-in per profile
65
+
66
+ The optional profile field `shell` takes the single value `"sandboxed"`. It is
67
+ allowed only on `read-only` profiles, not together with
68
+ `grantedExtensionTools` (granted extension tools may write, so the shell's
69
+ read-only guarantee would not hold), and only on Linux; any other combination
70
+ fails config validation with `invalid_execution_profile_config`.
71
+
72
+ Such a profile also gets `bash`, built with `createBashToolDefinition(cwd,
73
+ { operations, exposeSessionEnvironment: false })` and a custom
74
+ `BashOperations.exec`. Each command runs as
75
+ `/usr/bin/bwrap <fixed arguments> -- /usr/bin/bash -c <command>`:
76
+
77
+ - `--unshare-all` (no network), `--unshare-user` (which bubblewrap requires
78
+ for `--disable-userns`), `--disable-userns`, `--die-with-parent`,
79
+ `--new-session`, `--clearenv` with
80
+ `PATH=/usr/local/bin:/usr/bin:/bin` and `HOME=/tmp/home`;
81
+ - `/usr` and `/etc` read-only; `/bin`, `/lib`, `/lib64` and `/sbin` recreated
82
+ as the host's merged-`/usr` symlinks or bound read-only; `--dev /dev`,
83
+ `--proc /proc`, a tmpfs `/tmp` limited to 512 MiB; no other host path, so the operator's home
84
+ is not visible;
85
+ - the workspace at its host path as an overlay with a temporary upper layer
86
+ (`--overlay-src <workspace> --tmp-overlay <workspace>`): writes succeed
87
+ inside the sandbox and vanish when the command ends;
88
+ - for a linked worktree (a `.git` file instead of a directory), the main
89
+ repository's Git directory as a second temporary overlay at its host path,
90
+ so `git log` and `git blame` work. The server accepts the pointer only if
91
+ `gitdir:` names `<common>/worktrees/<name>`, that directory's `gitdir` file
92
+ points back to the workspace's `.git`, its `commondir` resolves to
93
+ `<common>`, and `<common>` holds `HEAD`, `objects` and `refs`. Otherwise
94
+ nothing extra is bound and Git commands fail inside the sandbox. The read
95
+ tools stay confined to the workspace either way.
96
+
97
+ A command without a `timeout` argument gets a default of 600 seconds; on
98
+ timeout or abort the server kills bubblewrap, and the PID namespace takes every
99
+ process of the sandbox with it.
100
+
101
+ The bubblewrap path is fixed and must be a trusted system binary (root-owned,
102
+ not group- or world-writable); there is no `PATH` lookup. The tool's own
103
+ environment, the controller's environment and `PI_*` session variables never
104
+ reach the command.
105
+
106
+ Fail closed: before a session with `shell: "sandboxed"` starts, the server runs
107
+ `/usr/bin/true` through the same sandbox for the session's workspace, bounded
108
+ and cancelled with the start request. If the binary is untrusted or the probe
109
+ fails, `pi_session_start` fails with the new code `sandbox_unavailable`, class
110
+ `needs_operator`. A command whose sandbox cannot start fails as that command;
111
+ there is never an unsandboxed fallback.
112
+
113
+ ### 3. Research tools
114
+
115
+ There is no new mechanism. Operators grant them through the existing
116
+ per-profile `mcpServers` with `readOnly: true`. The documentation advises
117
+ query-style tools over free URL fetching. Now that every read-only profile is
118
+ confined, granting research tools to read-only profiles no longer exposes files
119
+ outside the workspace and the skill directories.
120
+
121
+ ### Doctor and capabilities
122
+
123
+ Doctor stays offline. When any profile has `shell: "sandboxed"`, it probes
124
+ each configured workspace with the session's fixed arguments, including a
125
+ linked worktree's main Git directory, and reports `sandbox_unavailable` as an
126
+ error when the trusted bubblewrap binary is missing or a probe fails, for
127
+ example because the installed version lacks the overlay options, unprivileged
128
+ user namespaces are disabled or restricted by AppArmor (see
129
+ [ADR 0008](0008-coding-profile-isolation.md)), or the workspace's filesystem
130
+ cannot be an overlay lower layer. For each
131
+ workspace with an unusable gitfile pointer, it warns with
132
+ `workspace_git_unavailable` when any profile has a sandboxed shell.
133
+
134
+ Capabilities expose `shell` (`"none"` or `"sandboxed"`) for every profile,
135
+ directly after `grantedExtensionTools`. This is configured intent only.
136
+
137
+ ## Amendments
138
+
139
+ - ADR 0006: read-only profiles have `read`, `grep`, `find` and `ls`, and
140
+ additionally `bash` with `shell: "sandboxed"`. The read tools are
141
+ workspace-confined.
142
+ - ADR 0010: the active tools of a read-only profile are the above, the granted
143
+ MCP tools and the granted extension tools. A profile with granted extension
144
+ tools cannot have a sandboxed shell.
145
+
146
+ ## Contract change and versioning
147
+
148
+ The capabilities field `shell`, the start error `sandbox_unavailable` and the
149
+ confinement of existing read-only profiles change the public contract and need
150
+ minor release **0.13.0** under
151
+ [ADR 0003](0003-post-mvp-maintenance.md#versioning). The confinement breaks
152
+ read-only workflows that read files outside the workspace. This decision does
153
+ not itself bump the version or authorize publication.
154
+
155
+ ## Consequences
156
+
157
+ - Read-only workers can run Git, tests and scripts without touching the host
158
+ tree or the network.
159
+ - The sandbox shows only `/usr`, `/etc` and the workspace. Toolchains under the
160
+ home directory or `/opt` (nvm, `~/.local`) are not available; Node.js and Git
161
+ from the distribution are.
162
+ - Lock-dependent tests that need a supported local filesystem can fail on the
163
+ overlay (seen with `test/setup-modes.test.ts` in the #169 probe).
164
+ - The sandbox has no memory, process-count or overlay upper-layer limits.
165
+ A prompt-injected worker can exhaust memory or processes of the operator's
166
+ user until the command times out, as a host-mode coding session already
167
+ can. Limits through a cgroup scope are a possible follow-up.
168
+ - When `bash` truncates long output, the SDK stores the full output in the
169
+ host's temporary directory and names that file. Confined `read` refuses it
170
+ and the sandbox's `/tmp` is a fresh tmpfs, so only the truncated output is
171
+ available; workers should narrow their commands instead.
172
+ - `ls` omits entries that are symlinks pointing outside the read roots,
173
+ without a notice.
174
+ - The confinement checks a path when the call starts. A concurrent writer in
175
+ the same workspace, for example a host-mode coding session, could swap a
176
+ checked directory for a symlink before `rg` or `fd` opens it. Read-only
177
+ workers cannot create such symlinks themselves.
178
+ - Extension hooks and granted extension tools keep running in the server
179
+ process with the operator's rights (ADR 0006/0010); confinement covers the
180
+ built-in tools only.
181
+ - macOS, a VM backend and network allowlists inside the sandbox are out of
182
+ scope. Coding profiles are unchanged (ADR 0008/0009).
@@ -52,6 +52,8 @@ and owners are:
52
52
  | `skill_unreadable` | A selected skill path is not a regular file, is larger than 256 KiB, or cannot be read | `<profile>:<skill-alias>` |
53
53
  | `skill_duplicate` | Two selected skill aliases of one profile resolve to the same canonical file | `<profile>:<skill-alias>` |
54
54
  | `skill_override_outdated` | Warning. The current fingerprint of a shipped skill differs from the base recorded in the skill bases file next to the configuration file, or the bases file is corrupt | `skill:<name>`, or `skill:bases` for a corrupt file |
55
+ | `sandbox_unavailable` | Only when some profile has `shell: "sandboxed"` ([ADR 0013](adr/0013-hardened-read-only-profiles.md)). Error: `/usr/bin/bwrap` is missing or not a trusted system binary (root-owned, not group- or world-writable), or a probe run of `/usr/bin/true` through the session's fixed sandbox arguments for a configured workspace (its real path, plus the main Git directory of a linked worktree) fails or exceeds 5 seconds, for example because the installed version lacks the overlay options, unprivileged user namespaces are disabled or restricted by AppArmor, or the workspace's filesystem cannot be an overlay lower layer. Reported once per workspace that exists as a directory, however many profiles select the shell | workspace alias |
56
+ | `workspace_git_unavailable` | Only when some profile has `shell: "sandboxed"`. Warning: a workspace's `.git` is a regular file starting with `gitdir:` that is not a usable linked-worktree pointer (`<common>/worktrees/<name>` with a matching back-pointer and `commondir`, and `HEAD`, `objects` and `refs` in `<common>`), so Git commands fail inside the sandbox; the read tools are unaffected. `ok` for a usable pointer; absent for a workspace without a gitfile | workspace alias |
55
57
  | `event_spool_root` | Only when `events.delivery` is `spool`. Warning: no absolute `XDG_RUNTIME_DIR` is set in Doctor's environment; a server started that way runs with delivery inactive. Error: the `pi-session-mcp` entry in the runtime directory, or its `events` or `claims` entry, cannot be inspected, is not a directory (a link counts), belongs to another user, or has group or other permission bits. An absent entry is `ok`: the server creates it | `event spool` |
56
58
 
57
59
  `workspace_gitfile` is advice, not a boundary. A linked Git worktree shares `hooks/` and `config` with its main repository, so a coding worker could plant code that runs in the operator's later Git commands. A `.git` gitfile alone does not prove a linked worktree, however: submodules and `git init --separate-git-dir` use gitfiles too, and the content of the three is indistinguishable without following the pointer. Doctor therefore never follows, resolves, stats or reads the pointer target and never runs Git. It calls `lstat` on `<workspace>/.git` and, for a regular file of at most 4 KiB, reads at most that many bytes (without following a symbolic link) and checks for a `gitdir:` prefix followed by a non-blank value. Every such file warns, including submodule and separate-git-dir gitfiles, with a fixed path-free remediation that says "possible linked worktree or Git metadata stored outside the workspace" and recommends a separate full clone for coding workers. The check runs only when a coding profile exists and emits one entry per workspace alias: a warning, or `ok` when no gitfile pointer was found. A `.git` directory, an absent `.git`, an empty, oversized or malformed file, a symbolic link named `.git` (not followed, so nothing is known about its target), and any `lstat` or read error (for example `EACCES`) produce `ok`, never a failure and never a crash; an unreadable `.git` therefore means "not detected", not "verified clean". The warning cannot mask an error: the exit code stays 2 whenever any error exists.
@@ -86,7 +86,11 @@ clone.
86
86
  | `--image <value>` | `node` (default, built-in digest pin) or an existing local 64-character lowercase hex image ID; no arbitrary tags |
87
87
  | `--network <value>` | `pasta` (default, not limited) or `none` |
88
88
  | `--tags <value>` | `none` (default), or `reachable` for the tags reachable from the selected base commit |
89
- | `--cache <value>` | `none` (default), `go` for the writer's Go module/build cache, or `uv` for its private uv and npm cache |
89
+ | `--clean <untracked\|all>` | `untracked` (default) keeps ignored files; `all` also removes ignored files on reset and finish |
90
+ | `--cpus <n>` | Hard CPU cap, > 0 through 1024, at most two decimals; `none` removes it; unlimited unless set |
91
+ | `--cpu-shares <n>` | Relative CPU weight under contention, integer 2–262144; `none` removes it; Podman default 1024 |
92
+ | `--memory <size>` | Hard memory cap, 1–9999999 plus lowercase `m` or `g`; `none` removes it; unlimited unless set |
93
+ | `--cache <value>` | `none` (default), `go` for the writer's Go module/build cache, `uv` for its private uv and npm cache, or `npm` for its private npm-only cache |
90
94
  | `--base <ref>` | Named local or remote branch for provisioning; also supported by writer reset; not HEAD or a raw commit hash |
91
95
  | `--client codex` | No writer effect; coding uses the host registration for either client |
92
96
  | `--discard` | Explicitly allow loss of uncollected surplus writers on mode/count changes |
@@ -194,7 +198,7 @@ unexported work. Do not blindly retry or bypass a refusal by changing profiles.
194
198
  | Writer operation | Behavior |
195
199
  | --- | --- |
196
200
  | `--collect --apply` | Bundle export; retain clone |
197
- | `--reset --apply [--base <ref>]` | Reset to current source base branch, delete extra branches/stash and clean untracked files; falls back to recreation if post-reset verification fails |
201
+ | `--reset --apply [--base <ref>]` | Reset to current source base branch, delete extra branches/stash and clean untracked files, and ignored files with `--clean all`; falls back to recreation if post-reset verification fails |
198
202
  | `--recreate --apply` | Rebuild clone and agent copy from the workspace default base, retaining image/settings |
199
203
  | `--finish --apply` | Collect, then reset persistent / recreate ephemeral on the workspace default base |
200
204
  | `--remove` | Explicit mutation: unregister owned registrations before removing writer state |
@@ -255,6 +259,9 @@ listed below; dynamic planned and MCP verification outcomes are listed explicitl
255
259
  | `usage_invalid` | Invalid writer name or argument combination |
256
260
  | `doctor_failed` | Relevant blocking Doctor checks failed; repository steps and mutating writer operations also report each blocking check as a separate failed finding with its Doctor check id and target `client: "doctor"`, `scope: "check"`, and as alias the profile or workspace alias that leads the check's subject (the part before `:`, and for a writer name its alias), or `"invalid"` for a global check or when that is not a valid alias |
257
261
  | `writer_planned` | Writer would be retained/provisioned |
262
+ | `writer_limits_update_planned` | Existing writer limits would be updated without reprovisioning |
263
+ | `writer_limits_updated` | Existing writer limits updated under its fence |
264
+ | `cgroup_controller_unavailable` | Requested CPU or memory controller is unavailable; mode was not written |
258
265
  | `writer_remove_planned` | Surplus writer would be removed |
259
266
  | `writer_collect_planned` | Collection planned only |
260
267
  | `writer_reset_planned` | Reset planned only |
@@ -262,7 +269,7 @@ listed below; dynamic planned and MCP verification outcomes are listed explicitl
262
269
  | `writer_finish_planned` | Finish planned only |
263
270
  | `writer_provisioned` | Clone/config/wrapper prepared, not yet accepted |
264
271
  | `writer_unchanged` | Provisioned settings already match |
265
- | `writer_settings_changed` | Existing writer settings or its rendered configuration differ from the current ones; no silent replacement, no acceptance and no registration change ([Isolation setup](isolation-setup.md)) |
272
+ | `writer_settings_changed` | Existing writer settings or its rendered configuration differ from the current ones; no silent replacement, no acceptance and no registration change, and an apply is refused before the mode is written ([Isolation setup](isolation-setup.md)) |
266
273
  | `registration_divergent` | Writer registration is foreign or differs from its ownership record; it is retained and the writer stays in legacy launch mode |
267
274
  | `writer_reconcile_failed` | Unexpected reconciliation failure |
268
275
  | `writer_operation_failed` | Unclassified lifecycle/orchestrator failure |
@@ -375,6 +382,43 @@ No other uv or npm variables are set, so download and network policy stay the
375
382
  image's. The directory is per writer: it is never shared between writers and
376
383
  never a host cache mount. Setup does not install uv or npm for you.
377
384
 
385
+ ### Resource limits
386
+
387
+ Container writers are unlimited by default. Optional `--cpus <n>` sets a hard
388
+ CPU cap (greater than 0, at most 1024, with at most two decimals).
389
+ `--cpu-shares <n>` sets relative CPU weight under contention (integer 2–262144;
390
+ Podman's default is 1024). The weight only counts against sibling cgroups:
391
+ rootless writers run in the operator's user slice, so `--cpu-shares` makes a
392
+ writer yield to the operator's other containers and processes, not to system
393
+ services such as rootful Docker CI runners in `system.slice`. To protect such
394
+ a service, use the `--cpus` hard cap.
395
+ `--memory <size>` sets a hard memory cap: 1–9999999 followed by lowercase `m`
396
+ or `g`, for example `--memory 512m`.
397
+
398
+ These are fairness measures, not a security boundary. Setup checks that the
399
+ Podman host exposes the `cpu` controller for either CPU option and the
400
+ `memory` controller for memory; missing controllers refuse the mode change
401
+ with `cgroup_controller_unavailable`, including dry-runs. Limits apply to the
402
+ serving container, not throwaway clone/maintenance containers. They update
403
+ existing writer state under its lease without reprovisioning or changing
404
+ boundary acceptance, and take effect at the writer's next launch. Acceptance
405
+ and `--writer <name> --verify` run the serving container under the same
406
+ limits, so a very small cap can make them time out. Unlike the other
407
+ settings, a later mode request that omits a limit flag keeps the recorded
408
+ limit, because limits are outside the settings drift check and would
409
+ otherwise vanish without an error, for example in an upgrade's repeated
410
+ provisioning command. Pass `--cpus none`, `--cpu-shares none` or
411
+ `--memory none` to remove a limit.
412
+
413
+ ### npm cache (opt-in)
414
+
415
+ For a Node writer, pass `--cache npm` to mount a private per-writer directory
416
+ at `/pi/npm` with `npm_config_cache=/pi/npm/cache`. This records `cache: "npm"`;
417
+ no other npm variables are set and setup does not install npm. The directory
418
+ is never shared between writers or mounted from a host package cache. It
419
+ survives reset, finish and recreate, and is removed with the writer. Repeat
420
+ `--cache npm` on later container mode requests to retain it.
421
+
378
422
  ### Base tags (opt-in)
379
423
 
380
424
  Container writers default to `tags: "none"`. Pass `--tags reachable` to bundle
@@ -384,5 +428,17 @@ importing the selected base tags; tags created by workers are never collected
384
428
  and do not change the collected-work digest. Changing the tag setting requires
385
429
  reprovisioning for settings drift, but does not change boundary acceptance.
386
430
 
431
+ ### Ignored files (opt-in)
432
+
433
+ Container writers default to `clean: "untracked"`, keeping ignored files such as
434
+ `.venv`, `node_modules`, `.env` and build output across reset and a persistent
435
+ writer's finish (an ephemeral writer's finish recreates the clone). Pass
436
+ `--clean all` on a container mode request to remove them with `git clean -ffdx`.
437
+ Repeat this flag on later mode requests to retain it. Per-writer caches at
438
+ `/pi/go`, `/pi/uv` and `/pi/npm` live outside the clone and stay untouched.
439
+ Changing the setting requires reprovisioning for settings drift: collect work,
440
+ run `--writer <name> --remove`, then run the provisioning command again with
441
+ the desired `--clean` value. It does not change boundary acceptance.
442
+
387
443
  See [Isolation setup](isolation-setup.md) for boundary prerequisites and what
388
444
  setup automates, and the historical manual acceptance evidence linked there.
@@ -58,6 +58,20 @@ the same arguments.
58
58
  `~/.local/state/pi-session-mcp` on the same filesystem type as `/`, which is
59
59
  APFS. A home directory on HFS+, exFAT, a network volume or any other
60
60
  filesystem fails closed.
61
+ - Optional, Linux only, for the sandboxed shell of a read-only profile
62
+ (`"shell": "sandboxed"`, [ADR 0013](adr/0013-hardened-read-only-profiles.md)):
63
+ [bubblewrap](https://github.com/containers/bubblewrap) at `/usr/bin/bwrap`,
64
+ root-owned and not group- or world-writable, with the options
65
+ `--overlay-src`, `--tmp-overlay` and `--disable-userns`. It runs unprivileged and
66
+ needs unprivileged user namespaces (kernel setting, and on some distributions an
67
+ AppArmor profile that allows the user namespace for `bwrap`; see
68
+ [ADR 0008](adr/0008-coding-profile-isolation.md)). Doctor probes every
69
+ configured workspace and reports `sandbox_unavailable` as an error per
70
+ workspace when it does not work (the workspace's filesystem must be able to be
71
+ an overlay lower layer). The sandbox shows only
72
+ `/usr`, `/etc` and the workspace: toolchains installed under the home
73
+ directory or `/opt` (nvm, `~/.local`) are not available, Node.js and Git from
74
+ the distribution are.
61
75
  - Codex CLI or Claude Code. `setup` does not pin client versions. It accepts a
62
76
  client whose public `mcp` command output matches the expected contract and
63
77
  reports `unsupported` otherwise. The contract was last recorded with Codex CLI
@@ -169,6 +183,15 @@ Rules:
169
183
  `azure`; a profile that names the old key must be changed.
170
184
  - A profile may grant tools from local stdio MCP servers through `mcpServers`.
171
185
  See [External MCP tools in embedded sessions](session-mcp.md).
186
+ - A `read-only` profile may add `"shell": "sandboxed"` to get a `bash` tool
187
+ that runs every command in a bubblewrap sandbox without network, with the
188
+ workspace as a temporary overlay (see the requirements above). It is Linux
189
+ only and cannot be combined with `grantedExtensionTools`; the start fails
190
+ with `sandbox_unavailable` when the sandbox does not work. Without the field
191
+ the profile has no shell.
192
+ - The `read`, `grep`, `find` and `ls` tools of every `read-only` profile accept
193
+ only paths inside the workspace and the directories of the profile's selected
194
+ skill files; there is no field to change that.
172
195
  - A profile may load Pi extensions that Pi has installed, through
173
196
  `extensions`: a list of at most 8 entries of the form
174
197
  `npm:<name>@<exact version>`, each package once. Ranges, tags, `git:`
@@ -747,7 +770,8 @@ repository step between the install and the client targets:
747
770
  more; both clients code through the one host `pi-session-mcp` registration.
748
771
  Use the same
749
772
  settings as before: a writer whose settings or coding profiles changed is
750
- reported as `writer_settings_changed` and left as it is, and one whose
773
+ reported as `writer_settings_changed`, the apply is refused before the
774
+ mode is written and the writer is left as it is, and one whose
751
775
  coding profile selects an overridden or unshipped `builtin:` skill as
752
776
  `container_profile_unsupported`. Such a writer stays `writer_not_ready`
753
777
  until you change the profile or remove the override, or collect it,
@@ -756,6 +780,15 @@ repository step between the install and the client targets:
756
780
  3. `--apply` and `--verify` the client targets, then `--writer <name> --verify`
757
781
  for each writer. Reconnect the host `pi-session-mcp` server after migration
758
782
  and client setup. Later reset, finish and recreate need no reconnect.
783
+ On 0.12.0, client-target verify fails with `mcp_failed` once a container
784
+ workspace exists
785
+ ([#160](https://github.com/plagor44-ux/pi-session-mcp/issues/160)); 0.12.1
786
+ fixes it.
787
+ 4. Migration keeps each clone, so a writer migrated from a release before
788
+ 0.12.0 has no commit identity yet, and Doctor reports
789
+ `writer_git_identity_missing`. Collect any work, then
790
+ `--writer <name> --reset --apply` (or `--finish --apply`) each writer;
791
+ reset copies the operator checkout's identity into the clone.
759
792
 
760
793
  The repository step does not count another repository's writers that are not
761
794
  ready yet, so the repositories can be accepted one after the other. Releases