pi-session-mcp 0.12.1 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/.pi-session-mcp-cli/client-adapters/mcp-verifier.js +2 -1
  2. package/.pi-session-mcp-cli/config.js +36 -3
  3. package/.pi-session-mcp-cli/doctor.js +52 -4
  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 +147 -0
  13. package/CONTRIBUTING.md +8 -1
  14. package/README.md +5 -2
  15. package/dist/capabilities.d.ts +1 -0
  16. package/dist/capabilities.js +3 -1
  17. package/dist/client-adapters/mcp-verifier.js +2 -1
  18. package/dist/config.d.ts +12 -1
  19. package/dist/config.js +36 -3
  20. package/dist/confined-tools.d.ts +30 -0
  21. package/dist/confined-tools.js +122 -0
  22. package/dist/doctor.d.ts +5 -1
  23. package/dist/doctor.js +52 -4
  24. package/dist/execution-modes.d.ts +13 -1
  25. package/dist/execution-modes.js +10 -2
  26. package/dist/execution-profile.d.ts +4 -0
  27. package/dist/execution-profile.js +1 -0
  28. package/dist/main.js +10 -2
  29. package/dist/pi-adapter.d.ts +1 -1
  30. package/dist/pi-adapter.js +1 -0
  31. package/dist/sandbox-shell.d.ts +71 -0
  32. package/dist/sandbox-shell.js +254 -0
  33. package/dist/sdk-pi-adapter.d.ts +17 -1
  34. package/dist/sdk-pi-adapter.js +65 -10
  35. package/dist/session-tools.d.ts +4 -2
  36. package/dist/session-tools.js +6 -2
  37. package/dist/setup-cli.d.ts +1 -2
  38. package/dist/setup-cli.js +40 -9
  39. package/dist/setup-modes.d.ts +3 -0
  40. package/dist/setup-modes.js +18 -1
  41. package/dist/setup-writers-common.d.ts +78 -0
  42. package/dist/setup-writers-common.js +177 -0
  43. package/dist/setup-writers-plan.d.ts +34 -0
  44. package/dist/setup-writers-plan.js +105 -0
  45. package/dist/setup-writers-reconcile.d.ts +8 -0
  46. package/dist/setup-writers-reconcile.js +169 -0
  47. package/dist/setup-writers.d.ts +6 -30
  48. package/dist/setup-writers.js +19 -353
  49. package/dist/tool-schemas.d.ts +4 -0
  50. package/dist/tool-schemas.js +1 -0
  51. package/dist/wake-hook.d.ts +11 -0
  52. package/dist/wake-hook.js +51 -8
  53. package/dist/writer-boundary.d.ts +2 -0
  54. package/dist/writer-boundary.js +27 -0
  55. package/dist/writer-lifecycle.d.ts +1 -0
  56. package/dist/writer-lifecycle.js +6 -4
  57. package/dist/writer-limits.d.ts +13 -0
  58. package/dist/writer-limits.js +19 -0
  59. package/dist/writer-relay.js +1 -1
  60. package/dist/writer-state.d.ts +11 -0
  61. package/dist/writer-state.js +5 -2
  62. package/docs/acceptance.md +271 -0
  63. package/docs/adr/0009-execution-modes.md +3 -2
  64. package/docs/adr/0012-leased-writer-pool.md +3 -3
  65. package/docs/adr/0013-hardened-read-only-profiles.md +184 -0
  66. package/docs/architecture.md +1 -1
  67. package/docs/client-setup.md +3 -3
  68. package/docs/doctor-design.md +3 -1
  69. package/docs/execution-modes.md +60 -4
  70. package/docs/installation.md +25 -1
  71. package/docs/isolation-setup.md +31 -2
  72. package/docs/roadmap.md +14 -0
  73. package/docs/session-mcp.md +15 -0
  74. package/docs/threat-model.md +51 -0
  75. package/docs/tool-contracts.md +33 -4
  76. package/package.json +1 -1
  77. package/skills/pi-session-orchestration/SKILL.md +2 -2
  78. package/skills/pi-session-orchestration/references/start-errors.md +1 -1
@@ -5,6 +5,7 @@ import { lstat, mkdir, open, readFile, rename, unlink } from "node:fs/promises";
5
5
  import { dirname, join } from "node:path";
6
6
  import { z } from "zod";
7
7
  import { PACKAGE_VERSION } from "./package-metadata.js";
8
+ import { writerLimitsSchema } from "./writer-limits.js";
8
9
  import { ALIAS_PATTERN } from "./execution-profile.js";
9
10
  export const WRITER_NAME_PATTERN = /^[a-z][a-z0-9_-]{0,63}-w([1-9]|1[0-6])$/;
10
11
  export class WriterStateError extends Error {
@@ -31,8 +32,10 @@ export const writerStateSchema = z.object({
31
32
  model: z.string().min(1).max(200),
32
33
  }).strict()).refine((providers) => new Set(providers.map(({ provider }) => provider)).size === providers.length).readonly(),
33
34
  network: z.enum(["pasta", "none"]),
34
- cache: z.enum(["none", "go", "uv"]),
35
+ cache: z.enum(["none", "go", "uv", "npm"]),
35
36
  tags: z.enum(["none", "reachable"]).default("none"),
37
+ clean: z.enum(["untracked", "all"]).default("untracked"),
38
+ limits: writerLimitsSchema.optional(),
36
39
  imageId: hex64,
37
40
  base: z.object({ ref: z.string(), commit: z.string().regex(/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/) }).strict(),
38
41
  acceptance: z.object({
@@ -62,7 +65,7 @@ export function writerPaths(home, name) {
62
65
  const root = join(writersRoot(home), name);
63
66
  return {
64
67
  root, clone: join(root, "clone"), agent: join(root, "agent"), bundles: join(root, "bundles"),
65
- config: join(root, "config"), events: join(root, "events"), go: join(root, "go"), uv: join(root, "uv"), wrapper: join(root, "wrapper.sh"), state: join(root, "state.json"),
68
+ config: join(root, "config"), events: join(root, "events"), go: join(root, "go"), uv: join(root, "uv"), npm: join(root, "npm"), wrapper: join(root, "wrapper.sh"), state: join(root, "state.json"),
66
69
  acceptanceFailure: join(root, "acceptance-failure.json"),
67
70
  };
68
71
  }
package/CHANGELOG.md CHANGED
@@ -2,6 +2,153 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.13.1 — 2026-10-29
6
+
7
+ Patch release under ADR 0003 with an unchanged MCP contract. It ships the
8
+ fixes after 0.13.0: Doctor names where the configuration schema rejects a
9
+ file ([#173](https://github.com/plagor44-ux/pi-session-mcp/issues/173)),
10
+ read-only confinement of Pi's agent directory
11
+ ([#177](https://github.com/plagor44-ux/pi-session-mcp/issues/177)), a writer
12
+ added without `--base` ([#176](https://github.com/plagor44-ux/pi-session-mcp/issues/176)),
13
+ stale waiter-lock reclamation in the wake hook
14
+ ([#179](https://github.com/plagor44-ux/pi-session-mcp/issues/179)) and
15
+ per-writer reconcile failures
16
+ ([#180](https://github.com/plagor44-ux/pi-session-mcp/issues/180)). Setup
17
+ reports change for a broken kept writer: it now carries its own code on its
18
+ target instead of `writer_reconcile_failed`. `npm audit` still reports
19
+ `undici` 8.10.2, pinned by Pi SDK 1.4.0, the latest SDK release, now with
20
+ eight advisories ([#120](https://github.com/plagor44-ux/pi-session-mcp/issues/120)).
21
+
22
+ Upgrading follows [Installation](docs/installation.md#upgrade); writers must be
23
+ accepted again after the upgrade, as after every release.
24
+
25
+ ### Changed
26
+
27
+ - Doctor's `config_invalid` names where the schema rejects the configuration:
28
+ one check per issue location (at most five) with the subject
29
+ `configuration:<location>`, for example
30
+ `configuration:executionProfiles.review.default`. Locations hold only schema
31
+ field names, aliases and array indexes; an unknown key that is not a schema
32
+ field name, and any other key that is not a valid alias, is shown as
33
+ `<key>`, and values and messages are never reported. An error without a
34
+ location keeps the subject `configuration`
35
+ ([#173](https://github.com/plagor44-ux/pi-session-mcp/issues/173)).
36
+
37
+ ### Added
38
+
39
+ - Issue forms for bug reports, change proposals and maintenance tasks, and a
40
+ pull request template, written so that a person or a coding agent can carry
41
+ an issue out and a reviewer can check it
42
+ ([#178](https://github.com/plagor44-ux/pi-session-mcp/issues/178)).
43
+
44
+ ### Fixed
45
+
46
+ - Security, in the lockfile only, development path: `@hono/node-server`
47
+ 2.1.1 → 2.1.5 (via MCP Inspector; GHSA-rmxm-3fg6-px4f). Still open:
48
+ `undici` 8.10.2 in the production path, pinned exactly by Pi SDK 1.4.0, now
49
+ with eight advisories (GHSA-4qv8-2p9j-2685, GHSA-f72v-6w45-q472,
50
+ GHSA-xjj6-f33q-929c, GHSA-4w7w-5xrc-9h26, GHSA-2m5v-pch6-74hx,
51
+ GHSA-qrxm-fqhp-jwvm, GHSA-9fm3-97px-rjf6, GHSA-55wc-3pg8-jfw6), so
52
+ `npm audit` still reports it
53
+ ([#120](https://github.com/plagor44-ux/pi-session-mcp/issues/120)).
54
+ - Writer reconciliation reports a kept writer that fails, for example with
55
+ a missing wrapper after a crash during provisioning, on that writer's
56
+ target with its own code (`writer_state_invalid`) and still reconciles the
57
+ other writers. Only failures of shared setup state (configuration,
58
+ ownership) end the whole step with `writer_reconcile_failed`
59
+ ([#180](https://github.com/plagor44-ux/pi-session-mcp/issues/180)).
60
+ - Wake hook: reclaiming a stale waiter lock is serialized by an exclusive
61
+ `<lock>.reclaim` guard, and the lock is judged again while holding it. Two
62
+ waiters that both saw the same stale lock can no longer both take the turn
63
+ and send a duplicate wake reminder
64
+ ([#179](https://github.com/plagor44-ux/pi-session-mcp/issues/179)).
65
+ - Read-only confinement protects Pi's agent directory (`~/.pi/agent`, or
66
+ `PI_CODING_AGENT_DIR`) and, for a symlinked configuration file, its target's
67
+ directory: a skill directory that equals or contains either contributes only
68
+ the skill file as a read root. Before, a skill file placed directly in the
69
+ agent directory made `auth.json` readable. The real file of a symlinked skill
70
+ file is now a read root too, so the model can read a skill it was given
71
+ ([#177](https://github.com/plagor44-ux/pi-session-mcp/issues/177)).
72
+ - Setup provisions a writer added without `--base` (for example by raising
73
+ `--writers`) on the workspace's recorded default base, as finish and recreate
74
+ already did. Before, it landed on `origin/HEAD` and default starts never
75
+ leased it. A recorded base that is the repository default still provisions
76
+ from `origin/HEAD` ([#176](https://github.com/plagor44-ux/pi-session-mcp/issues/176)).
77
+
78
+ ## 0.13.0 — 2026-10-28
79
+
80
+ Minor release under ADR 0003 for
81
+ [ADR 0013](docs/adr/0013-hardened-read-only-profiles.md)
82
+ ([#169](https://github.com/plagor44-ux/pi-session-mcp/issues/169)): hardened
83
+ read-only profiles. The MCP contract changes: read-only tools are confined to
84
+ the workspace and the selected skills' directories, profiles gain the optional
85
+ `shell` field, capabilities report `shell`, and `pi_session_start` has the new
86
+ error code `sandbox_unavailable`. It also ships the writer settings changes of
87
+ [#162](https://github.com/plagor44-ux/pi-session-mcp/issues/162),
88
+ [#163](https://github.com/plagor44-ux/pi-session-mcp/issues/163),
89
+ [#165](https://github.com/plagor44-ux/pi-session-mcp/issues/165) and
90
+ [#166](https://github.com/plagor44-ux/pi-session-mcp/issues/166). `npm audit`
91
+ still reports `undici` 8.10.2, pinned by Pi SDK 1.4.0, the latest SDK release
92
+ ([#120](https://github.com/plagor44-ux/pi-session-mcp/issues/120)).
93
+
94
+ Upgrading follows [Installation](docs/installation.md#upgrade); writers must be
95
+ accepted again after the upgrade, as after every release.
96
+
97
+ ### Changed
98
+
99
+ - **Breaking for read-only workflows:** `read`, `grep`, `find` and `ls` of every
100
+ read-only profile now accept only paths inside the session's workspace and the
101
+ directories of the profile's selected skill files. An absolute path, `..`, `~`
102
+ or a symlink leading outside is refused with the tool error `Path is outside
103
+ the allowed read roots`. Read-only workers that read files outside the
104
+ workspace, Pi credentials included, can no longer do so. Coding profiles are
105
+ unchanged ([ADR 0013](docs/adr/0013-hardened-read-only-profiles.md),
106
+ [#169](https://github.com/plagor44-ux/pi-session-mcp/issues/169)).
107
+
108
+ ### Added
109
+
110
+ - Optional profile field `"shell": "sandboxed"` for read-only profiles: a `bash`
111
+ tool whose commands run in a bubblewrap sandbox without network and with the
112
+ workspace as a temporary overlay, so review workers can run `git log`,
113
+ `git blame`, tests and scripts without touching the host tree. Linux only,
114
+ not together with `grantedExtensionTools`. New start error
115
+ `sandbox_unavailable` (class `needs_operator`), new capabilities field
116
+ `shell` (`none` or `sandboxed`) after `grantedExtensionTools`, and the Doctor
117
+ checks `sandbox_unavailable` (error, probed for each configured workspace) and
118
+ `workspace_git_unavailable` (warning). The sandbox's `/tmp` is limited to
119
+ 512 MiB, commands default to a 600 second timeout and are killed with all
120
+ their processes on timeout or abort; the start probe is cancelled with the
121
+ start request. A skill directory that equals or contains the operator's home,
122
+ the configuration directory or `~/.pi` is no longer a read root, only its
123
+ skill file is
124
+ ([#169](https://github.com/plagor44-ux/pi-session-mcp/issues/169)).
125
+
126
+ - Optional container resource settings `--cpus`, `--cpu-shares` and `--memory`,
127
+ with host cgroup-controller preflight and fenced in-place writer updates
128
+ without reprovisioning. A later mode request that omits a limit keeps it;
129
+ `none` removes it
130
+ ([#166](https://github.com/plagor44-ux/pi-session-mcp/issues/166)).
131
+
132
+ - `--cache npm` mounts a private per-writer npm cache at `/pi/npm` with
133
+ `npm_config_cache=/pi/npm/cache`, retained across reset, finish and recreate
134
+ and removed with the writer
135
+ ([#165](https://github.com/plagor44-ux/pi-session-mcp/issues/165)).
136
+
137
+ - Opt-in container setting `--clean all` removes ignored files from writer
138
+ clones on reset and finish while preserving the default dependency-cache
139
+ behavior and per-writer caches outside the clone
140
+ ([#162](https://github.com/plagor44-ux/pi-session-mcp/issues/162)).
141
+
142
+ ### Fixed
143
+
144
+ - An apply of `pi-session-mcp-setup --repo <alias> --mode container ...` with
145
+ drifted settings is now refused before the mode is written: each kept writer
146
+ with a difference reports `writer_settings_changed`, the modes file stays
147
+ unchanged and no kept writer is changed (a surplus writer of a smaller count
148
+ may already have been fenced), so the recorded mode never drifts from
149
+ what the writers run
150
+ ([#163](https://github.com/plagor44-ux/pi-session-mcp/issues/163)).
151
+
5
152
  ## 0.12.1 — 2026-10-28
6
153
 
7
154
  Patch release for
package/CONTRIBUTING.md CHANGED
@@ -42,7 +42,14 @@ ones:
42
42
  Pull requests are limited to collaborators. If you are not a collaborator and
43
43
  want to contribute a fix, open an issue that describes it.
44
44
 
45
- - Keep a pull request to one focused change, and say which issue it addresses.
45
+ Issues use the forms in [`.github/ISSUE_TEMPLATE/`](.github/ISSUE_TEMPLATE/):
46
+ bug report, change proposal or maintenance task. Each asks for acceptance
47
+ criteria and a way to verify them, so that a person or a coding agent can carry
48
+ the issue out and a reviewer can check it. Pull requests use
49
+ [`.github/pull_request_template.md`](.github/pull_request_template.md).
50
+
51
+ - Keep a pull request to one focused change, and name every issue it addresses
52
+ with its own closing keyword (`Closes #12. Closes #13.`).
46
53
  - Run the checks above and `npm audit` before opening it.
47
54
  - Describe what you verified and how. State only what was run: provider-free
48
55
  tests do not establish live client or model behavior.
package/README.md CHANGED
@@ -30,10 +30,12 @@ It is not the right tool when:
30
30
 
31
31
  ## Status
32
32
 
33
- The package version is **0.12.1**.
33
+ The package version is **0.13.1**.
34
34
 
35
35
  [Per-repository execution modes](docs/execution-modes.md) make host writes explicit and block coding on read-only aliases. On container workspaces, the one host `pi-session-mcp` registration leases a rootless-Podman writer per coding session on Linux; no per-writer registrations or tool prefixes are needed ([ADR 0012](docs/adr/0012-leased-writer-pool.md)). Setup provisions, accepts, collects and resets the pool. Starts normally use the workspace default base; fix rounds can pass a short branch `base`. Start/get report `writer` and `base` for collection, and capabilities report configured `writers` and `defaultBase`. Reset, finish and recreate need no reconnect; finish returns the writer to the default base. See the [container upgrade runbook](docs/installation.md#container-writers).
36
36
 
37
+ Every read-only profile reads only inside its workspace and the directories of its selected skills, and a read-only profile on Linux can opt into a bubblewrap shell without network, home or host writes (`shell: "sandboxed"`, [ADR 0013](docs/adr/0013-hardened-read-only-profiles.md)).
38
+
37
39
  - **Pre-1.0.** The public tool contract is stable within a minor line: a change to tools, fields, states or error codes needs a new minor release.
38
40
  - **Linux and macOS**, Node.js 22.19.0 or newer. macOS is tested in CI on macOS 15 and 26 on Apple silicon; Intel Macs run the same code but are untested. Setup acceptance on a real Mac is recorded for Claude Code only; Codex is still pending. See [Acceptance](docs/acceptance.md#status). On other platforms `setup` refuses with `platform_unsupported`.
39
41
  - **Clients:** Codex CLI and Claude Code. The `setup` command does not pin client versions: it accepts a client whose public `mcp` command output matches the expected contract and fails closed otherwise.
@@ -113,7 +115,7 @@ The [installation guide](docs/installation.md) also covers installing from a rel
113
115
  ```
114
116
 
115
117
  - A workspace alias maps to a directory. Clients can select aliases only.
116
- - An execution profile binds a permission profile (`read-only` or `coding`), a provider, a model and a thinking level. Exactly one profile is the default, and it must be `read-only`.
118
+ - An execution profile binds a permission profile (`read-only` or `coding`), a provider, a model and a thinking level. Exactly one profile is the default, and it must be `read-only`. A read-only profile can add `shell: "sandboxed"` on Linux.
117
119
  - A profile can grant individual tools of local stdio MCP servers to its sessions; see [External MCP tools in embedded sessions](docs/session-mcp.md).
118
120
  - An optional top-level `skillCatalog` maps skill aliases to `builtin:<name>` (a skill of the package or of your override directory) or to a `SKILL.md` file, and a profile's optional `skills` selects aliases from it. A profile without `skills` gets no skills. See [Pi skills](docs/skills.md).
119
121
  - An optional top-level `"events": { "delivery": "spool" }` turns on content-free turn events, so that a Claude Code hook can wake the controller when a turn ends instead of polling. It is off by default and changes nothing for Codex. See [Wake the controller](docs/installation.md#wake-the-controller-claude-code).
@@ -174,6 +176,7 @@ Contract and design:
174
176
  - [ADR 0007: execution profiles select Pi skills from an operator catalog](docs/adr/0007-profile-skill-selection.md)
175
177
  - [ADR 0011: turn events and controller wake](docs/adr/0011-turn-events-and-controller-wake.md)
176
178
  - [ADR 0012: one registration with a leased writer pool](docs/adr/0012-leased-writer-pool.md)
179
+ - [ADR 0013: hardened read-only profiles](docs/adr/0013-hardened-read-only-profiles.md)
177
180
 
178
181
  Maintain:
179
182
 
@@ -30,6 +30,7 @@ export declare function projectCapabilities(workspaces: ReadonlyMap<string, stri
30
30
  thinkingLevel: import("./execution-profile.js").ThinkingLevel;
31
31
  extensions: string[];
32
32
  grantedExtensionTools: string[];
33
+ shell: "none" | "sandboxed";
33
34
  skills: string[];
34
35
  }[];
35
36
  };
@@ -18,7 +18,7 @@ export function projectCapabilities(workspaces, executionProfiles, configuration
18
18
  }),
19
19
  executionProfiles: [...executionProfiles.values()]
20
20
  .sort((left, right) => compareAliases(left.alias, right.alias))
21
- .map(({ alias, default: isDefault, permissionProfile, provider, model, thinkingLevel, extensions, grantedExtensionTools, skills }) => ({
21
+ .map(({ alias, default: isDefault, permissionProfile, provider, model, thinkingLevel, extensions, grantedExtensionTools, shell, skills }) => ({
22
22
  alias,
23
23
  default: isDefault,
24
24
  permissionProfile,
@@ -27,6 +27,8 @@ export function projectCapabilities(workspaces, executionProfiles, configuration
27
27
  thinkingLevel,
28
28
  extensions: (extensions ?? []).map((extension) => extension.spec),
29
29
  grantedExtensionTools: [...(grantedExtensionTools ?? [])],
30
+ // Configured intent only: whether the server's probe succeeds is a start-time fact.
31
+ shell: shell ?? "none",
30
32
  skills: (skills ?? []).map((skill) => skill.alias),
31
33
  })),
32
34
  };
@@ -118,7 +118,7 @@ function validCapabilities(value) {
118
118
  if (!profile || typeof profile !== "object")
119
119
  return false;
120
120
  const record = profile;
121
- return exactKeys(record, ["alias", "default", "permissionProfile", "provider", "model", "thinkingLevel", "extensions", "grantedExtensionTools", "skills"])
121
+ return exactKeys(record, ["alias", "default", "permissionProfile", "provider", "model", "thinkingLevel", "extensions", "grantedExtensionTools", "shell", "skills"])
122
122
  && typeof record.alias === "string" && ALIAS_PATTERN.test(record.alias)
123
123
  && typeof record.default === "boolean"
124
124
  && (record.permissionProfile === "read-only" || record.permissionProfile === "coding")
@@ -130,6 +130,7 @@ function validCapabilities(value) {
130
130
  && Array.isArray(record.grantedExtensionTools) && record.grantedExtensionTools.length <= MAX_GRANTED_EXTENSION_TOOLS
131
131
  && record.grantedExtensionTools.every((name) => typeof name === "string" && isAcceptableExtensionToolName(name))
132
132
  && new Set(record.grantedExtensionTools).size === record.grantedExtensionTools.length
133
+ && (record.shell === "none" || record.shell === "sandboxed")
133
134
  && Array.isArray(record.skills) && record.skills.length <= MAX_PROFILE_SKILLS
134
135
  && record.skills.every((skill) => typeof skill === "string" && ALIAS_PATTERN.test(skill))
135
136
  && new Set(record.skills).size === record.skills.length;
package/dist/config.d.ts CHANGED
@@ -4,8 +4,18 @@ import { type WorkspaceModeEntry } from "./execution-modes.js";
4
4
  export type ConfigErrorCode = "config_path_required" | "invalid_execution_profile_config" | "invalid_thinking_level" | "invalid_execution_modes" | "invalid_event_delivery";
5
5
  export declare class ConfigError extends Error {
6
6
  readonly code: ConfigErrorCode;
7
- constructor(code: ConfigErrorCode);
7
+ /** Dotted schema issue locations for Doctor; built by `issueLocations`, never a value, message or path. */
8
+ readonly locations: readonly string[];
9
+ constructor(code: ConfigErrorCode, locations?: readonly string[]);
8
10
  }
11
+ export declare const MAX_CONFIG_ISSUE_LOCATIONS = 5;
12
+ /**
13
+ * Sorted, deduplicated and bounded issue locations; an issue at the root has the empty location.
14
+ * A segment is kept only if it is a schema field name, or, inside the path, a key that has the alias
15
+ * form (record keys are aliases, which Doctor already reports). Any other key, and every unrecognized
16
+ * key that is not a schema field name, could be a path or a secret and becomes `<key>`.
17
+ */
18
+ export declare function issueLocations(issues: readonly z.core.$ZodIssue[]): string[];
9
19
  export declare const configSchema: z.ZodObject<{
10
20
  events: z.ZodOptional<z.ZodObject<{
11
21
  delivery: z.ZodEnum<{
@@ -35,6 +45,7 @@ export declare const configSchema: z.ZodObject<{
35
45
  mcpServers: z.ZodOptional<z.ZodCustom<Readonly<Record<string, import("./mcp-config.js").McpServerConfig>>, Readonly<Record<string, import("./mcp-config.js").McpServerConfig>>>>;
36
46
  extensions: z.ZodOptional<z.ZodArray<z.ZodString>>;
37
47
  grantedExtensionTools: z.ZodOptional<z.ZodArray<z.ZodString>>;
48
+ shell: z.ZodOptional<z.ZodLiteral<"sandboxed">>;
38
49
  skills: z.ZodOptional<z.ZodArray<z.ZodString>>;
39
50
  }, z.core.$strict>>;
40
51
  }, z.core.$strict>;
package/dist/config.js CHANGED
@@ -20,6 +20,7 @@ const profileSchema = z.object({
20
20
  mcpServers: z.custom((value) => isMcpServers(value)).optional(),
21
21
  extensions: z.array(extensionSpec).max(MAX_PROFILE_EXTENSIONS).optional(),
22
22
  grantedExtensionTools: z.array(z.string().refine(isAcceptableExtensionToolName)).max(MAX_GRANTED_EXTENSION_TOOLS).optional(),
23
+ shell: z.literal("sandboxed").optional(),
23
24
  skills: z.array(alias).max(MAX_PROFILE_SKILLS).optional(),
24
25
  }).strict().superRefine((profile, context) => {
25
26
  if (profile.mcpServers !== undefined && !isMcpServers(profile.mcpServers, profile.permissionProfile)) {
@@ -46,6 +47,18 @@ const profileSchema = z.object({
46
47
  context.addIssue({ code: "custom", path: ["grantedExtensionTools"], message: "Extension tool grants must not name built-in tools" });
47
48
  }
48
49
  }
50
+ if (profile.shell !== undefined) {
51
+ // Granted extension tools may write, so the sandbox's read-only guarantee would not hold (ADR 0013).
52
+ if (profile.permissionProfile !== "read-only") {
53
+ context.addIssue({ code: "custom", path: ["shell"], message: "A sandboxed shell requires a read-only profile" });
54
+ }
55
+ if (profile.grantedExtensionTools !== undefined) {
56
+ context.addIssue({ code: "custom", path: ["shell"], message: "A sandboxed shell cannot be combined with granted extension tools" });
57
+ }
58
+ if (process.platform !== "linux") {
59
+ context.addIssue({ code: "custom", path: ["shell"], message: "A sandboxed shell requires Linux" });
60
+ }
61
+ }
49
62
  if (profile.skills !== undefined && new Set(profile.skills).size !== profile.skills.length) {
50
63
  context.addIssue({ code: "custom", path: ["skills"], message: "A skill alias may appear only once per profile" });
51
64
  }
@@ -59,12 +72,31 @@ const CONFIG_ERROR_MESSAGES = {
59
72
  };
60
73
  export class ConfigError extends Error {
61
74
  code;
62
- constructor(code) {
75
+ /** Dotted schema issue locations for Doctor; built by `issueLocations`, never a value, message or path. */
76
+ locations;
77
+ constructor(code, locations = []) {
63
78
  super(CONFIG_ERROR_MESSAGES[code]);
64
79
  this.name = "ConfigError";
65
80
  this.code = code;
81
+ this.locations = Object.freeze([...locations]);
66
82
  }
67
83
  }
84
+ export const MAX_CONFIG_ISSUE_LOCATIONS = 5;
85
+ /**
86
+ * Sorted, deduplicated and bounded issue locations; an issue at the root has the empty location.
87
+ * A segment is kept only if it is a schema field name, or, inside the path, a key that has the alias
88
+ * form (record keys are aliases, which Doctor already reports). Any other key, and every unrecognized
89
+ * key that is not a schema field name, could be a path or a secret and becomes `<key>`.
90
+ */
91
+ export function issueLocations(issues) {
92
+ const fields = new Set([...Object.keys(configSchema.shape), ...Object.keys(profileSchema.shape), ...Object.keys(eventsSchema.shape)]);
93
+ const segment = (value, aliasAllowed) => typeof value === "number" ? String(value) : typeof value === "string" && (fields.has(value) || (aliasAllowed && ALIAS_PATTERN.test(value))) ? value : "<key>";
94
+ const locations = issues.flatMap((issue) => {
95
+ const path = issue.path.map((value) => segment(value, true));
96
+ return issue.code === "unrecognized_keys" ? issue.keys.map((key) => [...path, segment(key, false)]) : [path];
97
+ });
98
+ return [...new Set(locations.map((path) => path.join(".")))].sort().slice(0, MAX_CONFIG_ISSUE_LOCATIONS);
99
+ }
68
100
  const eventsSchema = z.object({ delivery: z.enum(["spool", "off"]) }).strict();
69
101
  export const configSchema = z.object({
70
102
  events: eventsSchema.optional(),
@@ -138,7 +170,7 @@ export async function loadConfig(path = process.env.PI_SESSION_MCP_CONFIG) {
138
170
  const thinkingLevelIssue = error.issues.some((issue) => issue.path.at(-1) === "thinkingLevel");
139
171
  // Only the events field's own issues map to the delivery code; an unknown top-level key stays a profile-config error.
140
172
  const eventsIssue = error.issues.some((issue) => issue.path[0] === "events");
141
- throw new ConfigError(thinkingLevelIssue ? "invalid_thinking_level" : eventsIssue ? "invalid_event_delivery" : "invalid_execution_profile_config");
173
+ throw new ConfigError(thinkingLevelIssue ? "invalid_thinking_level" : eventsIssue ? "invalid_event_delivery" : "invalid_execution_profile_config", issueLocations(error.issues));
142
174
  }
143
175
  throw error;
144
176
  }
@@ -146,13 +178,14 @@ export async function loadConfig(path = process.env.PI_SESSION_MCP_CONFIG) {
146
178
  const overrideDirectory = skillOverrideDirectory(absoluteConfig);
147
179
  const workspaces = new ImmutableMap(Object.entries(parsed.workspaces).map(([key, value]) => [key, resolve(base, value)]));
148
180
  const catalog = parsed.skillCatalog ?? {};
149
- const executionProfiles = new ImmutableMap(Object.entries(parsed.executionProfiles).map(([key, { mcpServers, extensions, grantedExtensionTools, skills, ...value }]) => {
181
+ const executionProfiles = new ImmutableMap(Object.entries(parsed.executionProfiles).map(([key, { mcpServers, extensions, grantedExtensionTools, shell, skills, ...value }]) => {
150
182
  const selection = resolveSkillSelection(skills, catalog, base, overrideDirectory);
151
183
  return [key, Object.freeze({
152
184
  alias: key, ...value,
153
185
  ...(mcpServers === undefined ? {} : { mcpServers: snapshotMcpServers(mcpServers) }),
154
186
  ...(extensions === undefined ? {} : { extensions: Object.freeze(extensions.map((spec) => parseExtensionSpec(spec))) }),
155
187
  ...(grantedExtensionTools === undefined ? {} : { grantedExtensionTools: Object.freeze([...grantedExtensionTools]) }),
188
+ ...(shell === undefined ? {} : { shell }),
156
189
  ...(selection === undefined ? {} : { skills: selection }),
157
190
  })];
158
191
  }));
@@ -0,0 +1,30 @@
1
+ import { type GrepOperations, type LsOperations, type ReadOperations, type Skill, type ToolDefinition } from "@earendil-works/pi-coding-agent";
2
+ /** Fixed refusal text; it never names the refused path. */
3
+ export declare const OUTSIDE_ROOTS_MESSAGE = "Path is outside the allowed read roots";
4
+ /** The read-only built-ins whose definitions this server replaces with confined ones. */
5
+ export declare const CONFINED_TOOL_NAMES: readonly string[];
6
+ /**
7
+ * The allowed read roots: the workspace and the directory of each selected skill file, as real paths.
8
+ * A skill directory that equals or contains a protected path (the operator's home, the configuration
9
+ * directory, the Pi agent directory) would expose it, so only the skill file itself is a root then.
10
+ * A symlinked skill file resolves outside its directory, so its real file is a root as well.
11
+ */
12
+ export declare function allowedReadRoots(workspace: string, skills: readonly Skill[], protectedPaths?: readonly string[]): Promise<string[]>;
13
+ /**
14
+ * Resolves a tool `path` argument the way the SDK will and checks the canonical result against the
15
+ * roots. Arguments the SDK would rewrite before resolving are refused, so both resolve the same path.
16
+ * Returns the canonical path; a refusal throws a fixed message without the path.
17
+ */
18
+ export declare function confineToolPath(input: unknown, cwd: string, roots: readonly string[]): Promise<string>;
19
+ /** The `*Operations` hooks that re-check every absolute path the SDK hands them; exported for tests. */
20
+ export declare function confinedOperations(roots: readonly string[]): {
21
+ read: ReadOperations;
22
+ grep: GrepOperations;
23
+ ls: LsOperations;
24
+ };
25
+ /**
26
+ * Definitions named `read`, `grep`, `find` and `ls` that accept only paths below `roots`. Registered as
27
+ * SDK custom tools they replace the built-ins of the same name. `find` has no operations hook that
28
+ * would still cover `fd`, so it relies on the execute wrapper alone.
29
+ */
30
+ export declare function createConfinedReadOnlyTools(cwd: string, roots: readonly string[]): ToolDefinition[];
@@ -0,0 +1,122 @@
1
+ import { access, readdir, readFile, realpath, stat } from "node:fs/promises";
2
+ import { constants } from "node:fs";
3
+ import { basename, dirname, join, resolve, sep } from "node:path";
4
+ import { createFindToolDefinition, createGrepToolDefinition, createLsToolDefinition, createReadToolDefinition, detectSupportedImageMimeTypeFromFile, } from "@earendil-works/pi-coding-agent";
5
+ /** Fixed refusal text; it never names the refused path. */
6
+ export const OUTSIDE_ROOTS_MESSAGE = "Path is outside the allowed read roots";
7
+ /** The read-only built-ins whose definitions this server replaces with confined ones. */
8
+ export const CONFINED_TOOL_NAMES = ["read", "grep", "find", "ls"];
9
+ /** What the SDK's `normalizePath` would rewrite before resolving (core/tools/path-utils.js, utils/paths.js). */
10
+ const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/;
11
+ /** Real path of an existing path, or the nearest existing ancestor's real path plus the missing rest. */
12
+ async function canonical(path) {
13
+ let current = resolve(path);
14
+ const rest = [];
15
+ for (;;) {
16
+ try {
17
+ return join(await realpath(current), ...rest.reverse());
18
+ }
19
+ catch {
20
+ const parent = dirname(current);
21
+ if (parent === current)
22
+ return resolve(path);
23
+ rest.push(basename(current));
24
+ current = parent;
25
+ }
26
+ }
27
+ }
28
+ function isWithin(path, root) {
29
+ return path === root || path.startsWith(root.endsWith(sep) ? root : `${root}${sep}`);
30
+ }
31
+ /**
32
+ * The allowed read roots: the workspace and the directory of each selected skill file, as real paths.
33
+ * A skill directory that equals or contains a protected path (the operator's home, the configuration
34
+ * directory, the Pi agent directory) would expose it, so only the skill file itself is a root then.
35
+ * A symlinked skill file resolves outside its directory, so its real file is a root as well.
36
+ */
37
+ export async function allowedReadRoots(workspace, skills, protectedPaths = []) {
38
+ const roots = new Set([await realpath(workspace)]);
39
+ const protectedReal = await Promise.all(protectedPaths.map((path) => canonical(path)));
40
+ for (const skill of skills) {
41
+ const directory = await realpath(dirname(skill.filePath)), file = await canonical(skill.filePath);
42
+ const root = protectedReal.some((path) => isWithin(path, directory)) ? file : directory;
43
+ roots.add(root);
44
+ if (!isWithin(file, root))
45
+ roots.add(file);
46
+ }
47
+ return [...roots];
48
+ }
49
+ /**
50
+ * Resolves a tool `path` argument the way the SDK will and checks the canonical result against the
51
+ * roots. Arguments the SDK would rewrite before resolving are refused, so both resolve the same path.
52
+ * Returns the canonical path; a refusal throws a fixed message without the path.
53
+ */
54
+ export async function confineToolPath(input, cwd, roots) {
55
+ const value = input === undefined || input === "" ? "." : input;
56
+ if (typeof value !== "string"
57
+ || value.startsWith("@") || value.startsWith("~") || value.startsWith("file:")
58
+ || value.includes("\0") || UNICODE_SPACES.test(value)) {
59
+ throw new Error(OUTSIDE_ROOTS_MESSAGE);
60
+ }
61
+ const real = await canonical(resolve(cwd, value));
62
+ if (!roots.some((root) => isWithin(real, root)))
63
+ throw new Error(OUTSIDE_ROOTS_MESSAGE);
64
+ return real;
65
+ }
66
+ /** The second layer: the absolute path the SDK hands to an operations hook. */
67
+ async function checkAbsolute(path, roots) {
68
+ const real = await canonical(path);
69
+ if (!roots.some((root) => isWithin(real, root)))
70
+ throw new Error(OUTSIDE_ROOTS_MESSAGE);
71
+ }
72
+ /** The `*Operations` hooks that re-check every absolute path the SDK hands them; exported for tests. */
73
+ export function confinedOperations(roots) {
74
+ const guard = async (path, run) => { await checkAbsolute(path, roots); return run(); };
75
+ return {
76
+ read: {
77
+ access: (path) => guard(path, () => access(path, constants.R_OK)),
78
+ readFile: (path) => guard(path, () => readFile(path)),
79
+ detectImageMimeType: (path) => guard(path, () => detectSupportedImageMimeTypeFromFile(path)),
80
+ },
81
+ grep: {
82
+ isDirectory: (path) => guard(path, async () => (await stat(path)).isDirectory()),
83
+ readFile: (path) => guard(path, () => readFile(path, "utf-8")),
84
+ },
85
+ ls: {
86
+ exists: (path) => guard(path, async () => { try {
87
+ await access(path, constants.F_OK);
88
+ return true;
89
+ }
90
+ catch {
91
+ return false;
92
+ } }),
93
+ stat: (path) => guard(path, () => stat(path)),
94
+ readdir: (path) => guard(path, () => readdir(path)),
95
+ },
96
+ };
97
+ }
98
+ /** Runs the root check on `params.path` before the SDK tool executes. */
99
+ function confine(definition, cwd, roots) {
100
+ return {
101
+ ...definition,
102
+ execute: async (toolCallId, params, signal, onUpdate, context) => {
103
+ // The SDK resolves against `ctx.cwd || cwd`; resolve the same way.
104
+ await confineToolPath(params.path, context?.cwd || cwd, roots);
105
+ return definition.execute(toolCallId, params, signal, onUpdate, context);
106
+ },
107
+ };
108
+ }
109
+ /**
110
+ * Definitions named `read`, `grep`, `find` and `ls` that accept only paths below `roots`. Registered as
111
+ * SDK custom tools they replace the built-ins of the same name. `find` has no operations hook that
112
+ * would still cover `fd`, so it relies on the execute wrapper alone.
113
+ */
114
+ export function createConfinedReadOnlyTools(cwd, roots) {
115
+ const operations = confinedOperations(roots);
116
+ return [
117
+ confine(createReadToolDefinition(cwd, { operations: operations.read }), cwd, roots),
118
+ confine(createGrepToolDefinition(cwd, { operations: operations.grep }), cwd, roots),
119
+ confine(createFindToolDefinition(cwd), cwd, roots),
120
+ confine(createLsToolDefinition(cwd, { operations: operations.ls }), cwd, roots),
121
+ ];
122
+ }
package/dist/doctor.d.ts CHANGED
@@ -3,7 +3,7 @@ import { type ExtensionSpec } from "./extension-spec.js";
3
3
  import { type ReleasedSkillTree } from "./skill-library.js";
4
4
  export type DoctorSeverity = "ok" | "warning" | "error";
5
5
  export type DoctorExitCode = 0 | 1 | 2 | 64;
6
- export type DoctorCheckId = "node_version_unsupported" | "package_metadata_unreadable" | "build_missing" | "build_stale" | "version_mismatch" | "config_missing" | "config_unreadable" | "config_invalid" | "workspace_missing" | "workspace_not_directory" | "workspace_unreadable" | "workspace_not_writable" | "workspace_gitfile" | "workspace_mode_unset" | "workspace_base_unset" | "workspace_host_writes_accepted" | "container_runtime_unavailable" | "container_profile_refused" | "writer_lease_directory" | "writer_not_ready" | "writer_not_migrated" | "writer_off_default_base" | "writer_dirty" | "writer_git_identity_missing" | "writer_uncollected_bundle" | "extension_missing" | "extension_version_mismatch" | "extension_unpinned" | "skill_missing" | "skill_unreadable" | "skill_duplicate" | "skill_override_outdated" | "event_spool_root";
6
+ export type DoctorCheckId = "node_version_unsupported" | "package_metadata_unreadable" | "build_missing" | "build_stale" | "version_mismatch" | "config_missing" | "config_unreadable" | "config_invalid" | "workspace_missing" | "workspace_not_directory" | "workspace_unreadable" | "workspace_not_writable" | "workspace_gitfile" | "workspace_mode_unset" | "workspace_base_unset" | "workspace_host_writes_accepted" | "container_runtime_unavailable" | "container_profile_refused" | "writer_lease_directory" | "writer_not_ready" | "writer_not_migrated" | "writer_off_default_base" | "writer_dirty" | "writer_git_identity_missing" | "writer_uncollected_bundle" | "extension_missing" | "extension_version_mismatch" | "extension_unpinned" | "skill_missing" | "skill_unreadable" | "skill_duplicate" | "skill_override_outdated" | "sandbox_unavailable" | "workspace_git_unavailable" | "event_spool_root";
7
7
  export interface DoctorCheck {
8
8
  readonly id: DoctorCheckId;
9
9
  readonly severity: DoctorSeverity;
@@ -76,6 +76,10 @@ export interface DoctorDependencies {
76
76
  readonly writerLeaseRoot?: (path: string) => Promise<EventSpoolRootInfo | undefined>;
77
77
  /** Effective user id of the current process; defaults to the real one. */
78
78
  readonly uid?: number;
79
+ /** Probes the read-only shell sandbox with the session's fixed arguments for one workspace; called only when a profile selects it. */
80
+ readonly sandboxProbe?: (workspace: string) => Promise<boolean>;
81
+ /** The main Git directory of a linked worktree workspace, or `undefined` when the pointer is unusable. */
82
+ readonly worktreeGitDir?: (workspace: string) => Promise<string | undefined>;
79
83
  }
80
84
  export interface EventSpoolRootInfo {
81
85
  readonly isDirectory: boolean;