pi-session-mcp 0.12.1 → 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 +2 -1
  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 +10 -2
  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 +74 -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 +2 -1
  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 +13 -1
  24. package/dist/execution-modes.js +10 -2
  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 +33 -3
  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 +141 -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 +25 -1
  56. package/docs/isolation-setup.md +31 -2
  57. package/docs/roadmap.md +6 -0
  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
@@ -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,
@@ -31,6 +31,14 @@ clone tags, then imports the selected base tags when enabled. Worker-created
31
31
  tags are not collected work and do not affect the collected digest. The setting
32
32
  participates in settings drift, not the acceptance fingerprint.
33
33
 
34
+ Ignored files in a persistent writer's clone survive reset and finish by
35
+ default (`clean: "untracked"`); an ephemeral writer's finish recreates the
36
+ clone. Add `--clean all` to a container mode request to remove
37
+ them with `git clean -ffdx`; the per-writer `/pi/go`, `/pi/uv` and `/pi/npm` caches outside
38
+ the clone stay untouched. Changing this setting requires collecting work,
39
+ removing the writer with `--writer <name> --remove`, and provisioning again;
40
+ it participates in settings drift, not the acceptance fingerprint.
41
+
34
42
  ## Prerequisites
35
43
 
36
44
  - Linux with usable **rootless** Podman, Node.js 22.19.0 or newer and Git.
@@ -60,7 +68,7 @@ participates in settings drift, not the acceptance fingerprint.
60
68
  | Workspace | Separate full clone per writer, created via a base bundle; no hardlinks or shared linked-worktree metadata |
61
69
  | Wrapper | Generated stdio-only launcher bound to the release and writer name; no daemon between client connections |
62
70
  | Boundary | Rootless keep-id, read-only root, capability drop, no-new-privileges, tmpfs home and /tmp; clone read-write with hooks/config overmounted read-only |
63
- | Mounts | Only clone, release read-only, contained config read-only, isolated writable agent directory, optional Go or uv/npm cache; no per-session writer spool mount ([below](#event-spool-mount)) |
71
+ | Mounts | Only clone, release read-only, contained config read-only, isolated writable agent directory, optional Go, uv/npm or npm-only cache; no per-session writer spool mount ([below](#event-spool-mount)) |
64
72
  | Configuration | One workspace, selected coding profiles and a synthetic read-only default; internal acknowledged host mode permits writes only in the contained clone |
65
73
  | Credentials | Selected provider entries only; OAuth refresh token emptied, API keys retained; no host SSH/Git/client credentials intentionally mounted |
66
74
  | Acceptance | Explicit uncontained host control, then provider-free contained harness per writer; fingerprint gates readiness |
@@ -98,7 +106,13 @@ list and providers in its state, and the whole rendered writer configuration
98
106
  with each selected profile's model, thinking level and skills and the event
99
107
  delivery. A difference reports `writer_settings_changed` and leaves the writer
100
108
  as it is
101
- ([#134](https://github.com/plagor44-ux/pi-session-mcp/issues/134)). The same
109
+ ([#134](https://github.com/plagor44-ux/pi-session-mcp/issues/134)). On
110
+ apply the check runs before the mode is written, so a kept writer with a
111
+ difference refuses the mode change itself: the modes file stays unchanged
112
+ and no kept writer is changed; a surplus writer of a smaller count may already
113
+ have been fenced
114
+ ([#163](https://github.com/plagor44-ux/pi-session-mcp/issues/163));
115
+ reconciliation repeats the check under each writer's lease. The same
102
116
  holds for the other findings of this check: `podman_unavailable` when Podman
103
117
  cannot be asked, `image_unavailable` when Podman answers but cannot resolve the
104
118
  image, and `container_profile_unsupported` when a selected coding profile is
@@ -254,5 +268,20 @@ or npm variables are set. The cache belongs to one writer: it is never shared
254
268
  between writers and never a host cache mount, and setup does not install uv
255
269
  or npm for you.
256
270
 
271
+ Optional container settings `--cpus`, `--cpu-shares` and `--memory` bound the
272
+ serving container's CPU or memory use. CPU shares are relative weight under
273
+ contention against sibling cgroups in the operator's user slice only; CPU
274
+ count and memory are hard caps. Setup requires the corresponding host cgroup
275
+ controllers. A later mode request that omits a limit flag keeps the recorded
276
+ limit; `--cpus none`, `--cpu-shares none` and `--memory none` remove it;
277
+ changes update existing writer state without reprovisioning. See
278
+ [Resource limits](execution-modes.md#resource-limits) for ranges and syntax.
279
+
280
+ A Node writer can instead use `--cache npm`, recording `cache: "npm"` and
281
+ mounting its private directory at `/pi/npm` with
282
+ `npm_config_cache=/pi/npm/cache`. No other npm variables are set. Like the Go
283
+ and uv caches, it survives reset, finish and recreate, and is removed with the
284
+ writer. Repeat the cache flag on later mode requests.
285
+
257
286
  See [Execution modes](execution-modes.md#migration-from-manual-writers) for
258
287
  migration limitations and [Doctor](doctor-design.md) for offline writer checks.
package/docs/roadmap.md CHANGED
@@ -17,6 +17,12 @@ mean they are implemented or that their gates passed.
17
17
  pool, base-aware allocation and host-spool events. Prepared as minor release
18
18
  0.12.0 together with #151 and #154, released the same day and installed with
19
19
  a live relayed coding session; 0.12.1 fixes client-target verify (#160).
20
+ Both machines run 0.12.1, and #151 and #154 passed their live checks. The
21
+ opt-in `--clean all` (#162) follows as an unreleased change.
22
+ - 2026-10-28: [ADR 0013](adr/0013-hardened-read-only-profiles.md) (#169),
23
+ implemented and unreleased: workspace-confined read tools for every read-only
24
+ profile and the opt-in bubblewrap shell (`shell: "sandboxed"`). It changes the
25
+ public contract and needs minor release 0.13.0.
20
26
 
21
27
  The explicit 2026-10-17 user request adds a bounded development-only
22
28
  [token-footprint measurement](sprints/2026-10-17-token-footprint.md), following
@@ -137,6 +137,19 @@ made safe by this configuration. Use dedicated credentials and external process
137
137
  isolation where stronger boundaries are required. Do not grant a generic tool that
138
138
  can dispatch arbitrary unreviewed operations and assume that its name is a boundary.
139
139
 
140
+ Granting research tools (search, documentation lookup) to a read-only profile
141
+ uses this same `mcpServers` mechanism with `readOnly: true`. Since every
142
+ read-only profile is confined to its workspace and selected skill directories
143
+ ([ADR 0013](adr/0013-hardened-read-only-profiles.md)), such a tool cannot be fed
144
+ files outside them through the model's read tools. Prefer query-style tools
145
+ (search, documentation lookup) over tools that fetch free URLs: a fetch tool
146
+ with free-form input is still a channel for whatever the workspace contains.
147
+ The MCP server itself is trusted local code and is not confined.
148
+ A skill directory that equals or contains the operator's home, the configuration
149
+ file's directory or `~/.pi` is not a read root; only the skill file itself is,
150
+ so a catalog entry such as `./SKILL.md` next to the configuration does not
151
+ expose the configuration.
152
+
140
153
  Only local stdio tools are covered here. HTTP/SSE/OAuth transports, sampling,
141
154
  elicitation, roots negotiation, separate resource/prompt APIs and global extension
142
155
  loading are not added. MCP tool invocations are marked sequential within Pi's tool
@@ -6,6 +6,54 @@ Pi Session MCP trusts the local process that launches and speaks MCP over inheri
6
6
 
7
7
  ## Threats and mitigations
8
8
 
9
+ - **Read-only file disclosure and the sandboxed shell ([ADR 0013](adr/0013-hardened-read-only-profiles.md)):**
10
+ read-only sessions run in the host server process. `read`, `grep`, `find` and
11
+ `ls` of every read-only profile therefore accept only paths inside the
12
+ workspace and the directories of the selected skill files, so Pi credentials
13
+ and other files the operator can read stay unreachable even for a
14
+ prompt-injected worker. Each call is checked twice: the `path` argument is
15
+ resolved and canonicalized with `realpath` before the SDK tool runs
16
+ (arguments the SDK would rewrite, such as a leading `@` or `~`, a `file:`
17
+ URL, Unicode spaces or NUL, are refused so both resolve the same path), and
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
20
+ `shell: "sandboxed"` also gets `bash`, run as `/usr/bin/bwrap` (fixed path,
21
+ root-owned and not group- or world-writable, no `PATH` lookup) with
22
+ `--unshare-all`, `--disable-userns`, a cleared environment, no network, `/usr`
23
+ and `/etc` read-only, a tmpfs `/tmp`, and the workspace (and, for a linked
24
+ worktree whose pointers are consistent, the main repository's Git directory)
25
+ as a temporary overlay whose writes vanish with the command. The operator's
26
+ home, `PI_*` variables and the server's environment never reach the command.
27
+ The shell fails closed: an untrusted binary or a failing probe is
28
+ `sandbox_unavailable`, and a command whose sandbox cannot start fails; there
29
+ is no unsandboxed fallback. It is not allowed together with granted extension
30
+ tools, which may write.
31
+ - **Residual:** the confinement checks a path when the call starts, so a
32
+ concurrent writer in the same workspace (for example a host-mode coding
33
+ session) could swap a checked directory for a symlink before `rg` or `fd`
34
+ opens it; read-only workers cannot create symlinks themselves. Extension
35
+ hooks and granted extension tools run in the server process with the
36
+ operator's rights and are not confined; the confinement covers the built-in
37
+ tools only. The sandbox exposes `/etc` and the distribution's `/usr` and an
38
+ overlay of the workspace and, for worktrees, of the repository's Git
39
+ directory, including their configuration. Lock-dependent tests that need a
40
+ supported local filesystem can fail on the overlay. macOS, a VM backend and
41
+ network allowlists inside the sandbox are out of scope. The sandbox has no
42
+ memory, process-count or overlay upper-layer limits (only `/tmp` is limited to
43
+ 512 MiB and a command defaults to a 600 second timeout), so a prompt-injected
44
+ worker can exhaust memory or processes of the operator's user until the
45
+ command ends, as a host-mode coding session already can. When `bash`
46
+ truncates long output the SDK names a full-output file in the host's
47
+ temporary directory; confined `read` refuses it and the sandbox's `/tmp` is a
48
+ fresh tmpfs, so only the truncated output is available. `ls` omits entries
49
+ that are symlinks pointing outside the read roots, without a notice. A skill
50
+ directory that equals or contains the operator's home, the configuration
51
+ directory or `~/.pi` contributes only the skill file as a read root. On
52
+ timeout or abort the server kills bubblewrap and the PID namespace takes every
53
+ process of the sandbox with it. The start probe is cancelled with the start
54
+ request. A granted research
55
+ tool with free-form input (search, documentation lookup) is a possible
56
+ outbound channel for workspace content; prefer query-style tools.
9
57
  - **Unsafe diagnostics:** Doctor is a separate offline CLI with no MCP, Pi,
10
58
  provider, credential, or network dependency. Its human/JSON output is
11
59
  sanitized and its stable exit statuses are 0 (healthy), 1 (warning), 2
@@ -127,6 +175,8 @@ Pi Session MCP trusts the local process that launches and speaks MCP over inheri
127
175
  - Setup edits a Claude Code settings file, which is operator configuration. It writes one `PostToolUse` entry under the same dry-run, ownership and divergence rules as a registration: a read-modify-write with a compare-and-swap on the file bytes (a changed file is read again once, a second change fails with `settings_changed`), an atomic rename that keeps the file mode, a pending-before-write ownership record and no replacement or deletion of an entry it does not own. In a project scope it never follows a link below the project root. The entry holds absolute paths of the Node executable and the package, which must be free of shell metacharacters (`hook_path_unsafe`). Ownership records hold a fingerprint, never a path or a command.
128
176
 
129
177
  - **Concurrent writer use and orphaned containers ([ADR 0012](adr/0012-leased-writer-pool.md)):** new assets are the host-only lease directory/files, stable wrappers, relay subprocess/transport and recorded workspace default base. The launcher holds `~/.local/state/pi-session-mcp/leases/<name>.lock`, not the host server. Files are never deleted, opened without following links and checked for type, owner and mode (0700 directory, 0600 file). Every mutating setup operation takes the same lease with a bounded wait (`writer_busy`), including collect and acceptance. Mutating host children inherit its descriptor; nested launchers validate/adopt it. Neither container nor Podman's monitor receives it, so worker code cannot unlock it. Fixed session name `pi-writer-<name>` and label `pi-session-mcp.writer=<name>` supplement the lock. Under the lease the holder confirms absence by label and clone mount, removes orphaned containers and fails closed if absence is uncertain, before any credential or clone change. Stdin end, SIGTERM and SIGHUP trigger refresh join and container cleanup while the launcher still holds the fence; a killed holder's leftovers are removed by the next holder. The trusted same-UID operator must not replace lease files or mutate writers outside setup.
178
+ - **Writer resource fairness:** optional `--cpus` and `--memory` hard caps and `--cpu-shares` relative weight constrain serving containers when the Podman host exposes the required cgroup controllers. Caps are unlimited unless set, kept when a later mode request omits them, and removed only with `none`; maintenance containers are not limited. These are fairness measures for shared hosts, not a security boundary, and do not change acceptance or require reprovisioning.
179
+ - **Ignored writer files:** by default, ignored files in a persistent writer's clone survive reset and finish and reach the next task leased on that writer, invisible to baseline checks and collect. Opt in to `--clean all` to remove them on reset and finish; per-writer Go (`/pi/go`), uv/npm (`/pi/uv`) and npm-only (`/pi/npm`) caches outside the clone remain. These private writable caches survive reset, finish and recreate and are removed with the writer; they are not shared between writers or mounted from host package caches.
130
180
  - **Untrusted relay replies and wrong-base work:** inner replies are schema-validated and size-bounded, never raw-frame passthrough. Host IDs, turn state and spool remain authoritative. Allocation matches the requested/default branch from host writer state, compares profile selection and rechecks placement after inner start; the inner guard still decides baseline admission. Transport loss fails the turn and makes further prompts refuse with `writer_session_lost`; get and close remain available. Branch matching is not commit freshness: results expose the selected base commit for review. Migration requires closed sessions/controllers, removes only owned registrations, rebinds wrappers and re-accepts provider-free; a divergent registration keeps the writer legacy rather than entering the pool.
131
181
 
132
182
  ## Residual risks
@@ -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.12.1" },
43
+ "server": { "name": "pi-session-mcp", "version": "0.13.0" },
44
44
  "configuration": {
45
45
  "fingerprint": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
46
46
  "reloadPolicy": "restart-required"
@@ -56,6 +56,7 @@ records name the package version and the build they ran on, see
56
56
  "thinkingLevel": "medium",
57
57
  "extensions": [],
58
58
  "grantedExtensionTools": [],
59
+ "shell": "none",
59
60
  "skills": []
60
61
  }]
61
62
  }
@@ -63,9 +64,9 @@ records name the package version and the build they ran on, see
63
64
 
64
65
  `events.delivery` is `spool` when the loaded configuration enables turn-event delivery (`"events": { "delivery": "spool" }`) and `off` otherwise. It reports configured intent, except that it is `off` when the server found no spool location at startup (no `PI_SESSION_MCP_EVENTS_DIR` and no absolute `XDG_RUNTIME_DIR`, as under Codex); it makes no claim that the spool directory is writable, and it carries no path.
65
66
 
66
- The server sorts both arrays by alias with a deterministic code-point comparison. It copies only loaded configuration fingerprint/reload policy, the event delivery setting, configured aliases and workspace modes, and the nine documented profile fields. Each workspace has a required `mode`: `unset`, `host`, `container`, or `read-only`; a missing modes entry is reported as `unset`. Container entries also carry `writers` (configured count, not free capacity) and `defaultBase` (short branch name, or `null` if not yet recorded), not writer names or other writers' branches. `extensions` lists the configured extension specs (`npm:<name>@<version>`), empty when a profile loads none; it makes no claim that the package is installed. `grantedExtensionTools`, directly after `extensions`, lists the configured extension tool grants in configured order, `[]` when absent. It is configuration intent only, not proof of registration, activation or declaration to the model. `skills` lists the skill-catalog aliases that the profile selects, in configured order, `[]` when it selects none. It contains no paths, file names, contents or SDK diagnostics, and it is configuration intent only: it makes no claim that a skill file exists, loads, or will be read by the model. See [Pi skills](skills.md). It never copies the configuration path or contents, workspace paths, authentication state, SDK data, session/turn data, prompts, transcripts, or tool arguments/results. The handler reads the in-memory loaded snapshot only and does not call the registry, Pi adapter, provider, filesystem, or network. The fingerprint identifies the exact loaded bytes, not current-file freshness; provider/model fields describe configured intent and make no availability claim.
67
+ The server sorts both arrays by alias with a deterministic code-point comparison. It copies only loaded configuration fingerprint/reload policy, the event delivery setting, configured aliases and workspace modes, and the ten documented profile fields. Each workspace has a required `mode`: `unset`, `host`, `container`, or `read-only`; a missing modes entry is reported as `unset`. Container entries also carry `writers` (configured count, not free capacity) and `defaultBase` (short branch name, or `null` if not yet recorded), not writer names or other writers' branches. `extensions` lists the configured extension specs (`npm:<name>@<version>`), empty when a profile loads none; it makes no claim that the package is installed. `grantedExtensionTools`, directly after `extensions`, lists the configured extension tool grants in configured order, `[]` when absent. It is configuration intent only, not proof of registration, activation or declaration to the model. `shell`, directly after `grantedExtensionTools`, is `none` or `sandboxed` ([ADR 0013](adr/0013-hardened-read-only-profiles.md)). `sandboxed` means the profile is configured to get a `bash` tool that runs each command in a network-less bubblewrap sandbox; it is configuration intent only and no claim that the sandbox works on this host (a failing sandbox surfaces as `sandbox_unavailable` at session start, and Doctor checks it offline). `skills` lists the skill-catalog aliases that the profile selects, in configured order, `[]` when it selects none. It contains no paths, file names, contents or SDK diagnostics, and it is configuration intent only: it makes no claim that a skill file exists, loads, or will be read by the model. See [Pi skills](skills.md). It never copies the configuration path or contents, workspace paths, authentication state, SDK data, session/turn data, prompts, transcripts, or tool arguments/results. The handler reads the in-memory loaded snapshot only and does not call the registry, Pi adapter, provider, filesystem, or network. The fingerprint identifies the exact loaded bytes, not current-file freshness; provider/model fields describe configured intent and make no availability claim.
67
68
 
68
- Setup verify accepts and validates `grantedExtensionTools` in its bounded direct
69
+ Setup verify accepts and validates `grantedExtensionTools` and `shell` in its bounded direct
69
70
  MCP capabilities check, without starting a session or using a provider.
70
71
  **Verifier limit:** at maximum field sizes (8 extension specs of 256 characters,
71
72
  32 grants of 64 characters, provider and model of 256 characters each), at most
@@ -111,6 +112,31 @@ tools: registered-but-inactive tools fail closed too. Lazy registration in
111
112
  decision: third-party tools may write, and workspace modes `container` and
112
113
  `read-only` do not restrict them. See [ADR 0010](adr/0010-read-only-extension-tools.md).
113
114
 
115
+ ### Read-only confinement and the sandboxed shell
116
+
117
+ [ADR 0013](adr/0013-hardened-read-only-profiles.md) confines `read`, `grep`,
118
+ `find` and `ls` of **every** read-only profile to the session's workspace and
119
+ the directories of the profile's selected skill files. A `path` outside them
120
+ (absolute, `..`, `~`, a symlink leading out, a leading `@`, a `file:` URL, a
121
+ Unicode space or NUL) is an ordinary tool error to the model,
122
+ `Path is outside the allowed read roots`, without the path. It is not a start
123
+ or turn error and has no code. Files outside the workspace that earlier
124
+ read-only sessions could read are no longer readable. Coding profiles are
125
+ unchanged.
126
+
127
+ The optional profile field `shell` takes the single value `"sandboxed"`. It is
128
+ valid only on `read-only` profiles, not together with `grantedExtensionTools`,
129
+ and only on Linux; any other combination prevents startup with
130
+ `invalid_execution_profile_config`. Such a profile also has a `bash` tool. Each
131
+ command runs under `/usr/bin/bwrap` with no network, the workspace as a
132
+ temporary copy-on-write overlay (writes vanish when the command ends), `/usr`
133
+ and `/etc` read-only and no other host path. Before the session starts the
134
+ server runs a probe through the same sandbox; if the binary is untrusted or the
135
+ probe fails, start fails with `sandbox_unavailable` and no session exists. There
136
+ is no unsandboxed fallback. For a linked worktree the main repository's Git
137
+ directory is mounted as a second temporary overlay when its pointers are
138
+ consistent, so `git log` and `git blame` work. The sandbox's `/tmp` is limited to 512 MiB, and a command without a `timeout` argument is killed after 600 seconds.
139
+
114
140
  ## Automated writer boundary (Plan B)
115
141
 
116
142
  The host routes container coding through its relay adapter under
@@ -166,6 +192,7 @@ Public MCP errors are stable and sanitized. Their messages do not include inputs
166
192
  | `extension_tool_conflict` | An extension tool name collides with a built-in, MCP or other extension tool, or is unsafe: not 1 to 64 letters, digits, `_` or `-` (not starting with `-`), or reserved for MCP (`mcp__` prefix, `list_mcp_resources`, `list_mcp_resource_templates`, `read_mcp_resource`). |
167
193
  | `extension_tool_missing` | A granted extension tool is not registered at load or is not active and declared to the model at admission. Fixed message: `Granted Pi extension tool is not available`; class `needs_operator` (operator needed; do not retry or switch profiles). |
168
194
  | `skill_load_failed` | A skill that the profile selects could not be loaded: a missing (also a `builtin:` name that matches no override and no shipped skill), unreadable or oversized (over 256 KiB) file, an invalid skill, two selected files with the same Pi skill name, two aliases on the same canonical file, or a skill with `disable-model-invocation: true`. The message is the fixed `Configured Pi skill could not be loaded`; it never contains a path, skill name or SDK text. |
195
+ | `sandbox_unavailable` | A profile with `shell: "sandboxed"` cannot start its shell sandbox: the bubblewrap binary is missing or untrusted, or the probe run for the session's workspace failed or was cancelled with the start request. Fixed message: `Read-only shell sandbox is not available`; class `needs_operator` (operator needed; do not retry or switch profiles). |
169
196
  | `pi_session_creation_failed` | Pi session construction failed without a more specific safe classification. |
170
197
  | `writer_uncollected` | The writer is not at its collected baseline, or its guard/Git state could not be verified; controller must collect and reset it. |
171
198
  | `writer_busy` | Controller action: a session or another operation holds the writer; wait for or close it. Setup waits briefly (10 seconds) for the lease, then fails with this code. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-session-mcp",
3
- "version": "0.12.1",
3
+ "version": "0.13.0",
4
4
  "description": "Local stdio MCP server for controlled Pi coding-agent sessions",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -2,7 +2,7 @@
2
2
 
3
3
  Load this file only after a `pi_session_start` call has failed. Classify the error by its `code` alone. Never decide by the message, provider text or any other free text, and never infer a cause. Each code below is in exactly one class.
4
4
 
5
- - Operator needed (do not retry and do not switch to another profile; report the code and stop that task): `unknown_execution_profile`, `unknown_workspace`, `workspace_mode_forbids_coding`, `unknown_provider`, `unknown_model`, `model_unavailable`, `thinking_level_unavailable`, `local_authentication_missing`, `execution_selection_mismatch`, `skill_load_failed`, `extension_not_installed`, `extension_version_mismatch`, `extension_tool_conflict`, `extension_tool_missing`, `external_mcp_no_tools`, `external_mcp_grant_tool_missing`, `external_mcp_activation_mismatch`, `writer_not_ready`, `container_runtime_unavailable`, `server_stopping`. Writer readiness needs repository setup and acceptance; runtime unavailability needs usable rootless Podman. The last code means the server is shutting down: a new server process is needed, not a retry.
5
+ - Operator needed (do not retry and do not switch to another profile; report the code and stop that task): `unknown_execution_profile`, `unknown_workspace`, `workspace_mode_forbids_coding`, `unknown_provider`, `unknown_model`, `model_unavailable`, `thinking_level_unavailable`, `local_authentication_missing`, `execution_selection_mismatch`, `skill_load_failed`, `sandbox_unavailable`, `extension_not_installed`, `extension_version_mismatch`, `extension_tool_conflict`, `extension_tool_missing`, `external_mcp_no_tools`, `external_mcp_grant_tool_missing`, `external_mcp_activation_mismatch`, `writer_not_ready`, `container_runtime_unavailable`, `server_stopping`. Writer readiness needs repository setup and acceptance; runtime unavailability needs usable rootless Podman. The last code means the server is shutting down: a new server process is needed, not a retry.
6
6
  - Controller action (do not blindly retry or switch profiles; collect and reset the writer for uncollected work, or wait for or close the open coding session for a busy writer; resolve pool state, lost sessions or inapplicable base before a fresh start): `writer_uncollected`, `writer_busy`, `writer_unavailable`, `writer_session_lost`, `base_not_applicable`.
7
7
  - Transient (retry at most once after a pause, then report): `external_mcp_unavailable` (an external MCP server that cannot connect, times out or breaks the protocol), `extension_load_failed` (an invalid package, but also a start timeout), `local_authentication_unavailable` (local authentication could not be resolved within its deadline).
8
8
  - Unknown (report the code; at most one retry in a fresh session): `pi_session_creation_failed`. Any code not listed above is also unknown.