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
package/CHANGELOG.md CHANGED
@@ -2,6 +2,80 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.13.0 — 2026-10-28
6
+
7
+ Minor release under ADR 0003 for
8
+ [ADR 0013](docs/adr/0013-hardened-read-only-profiles.md)
9
+ ([#169](https://github.com/plagor44-ux/pi-session-mcp/issues/169)): hardened
10
+ read-only profiles. The MCP contract changes: read-only tools are confined to
11
+ the workspace and the selected skills' directories, profiles gain the optional
12
+ `shell` field, capabilities report `shell`, and `pi_session_start` has the new
13
+ error code `sandbox_unavailable`. It also ships the writer settings changes of
14
+ [#162](https://github.com/plagor44-ux/pi-session-mcp/issues/162),
15
+ [#163](https://github.com/plagor44-ux/pi-session-mcp/issues/163),
16
+ [#165](https://github.com/plagor44-ux/pi-session-mcp/issues/165) and
17
+ [#166](https://github.com/plagor44-ux/pi-session-mcp/issues/166). `npm audit`
18
+ still reports `undici` 8.10.2, pinned by Pi SDK 1.4.0, the latest SDK release
19
+ ([#120](https://github.com/plagor44-ux/pi-session-mcp/issues/120)).
20
+
21
+ Upgrading follows [Installation](docs/installation.md#upgrade); writers must be
22
+ accepted again after the upgrade, as after every release.
23
+
24
+ ### Changed
25
+
26
+ - **Breaking for read-only workflows:** `read`, `grep`, `find` and `ls` of every
27
+ read-only profile now accept only paths inside the session's workspace and the
28
+ directories of the profile's selected skill files. An absolute path, `..`, `~`
29
+ or a symlink leading outside is refused with the tool error `Path is outside
30
+ the allowed read roots`. Read-only workers that read files outside the
31
+ workspace, Pi credentials included, can no longer do so. Coding profiles are
32
+ unchanged ([ADR 0013](docs/adr/0013-hardened-read-only-profiles.md),
33
+ [#169](https://github.com/plagor44-ux/pi-session-mcp/issues/169)).
34
+
35
+ ### Added
36
+
37
+ - Optional profile field `"shell": "sandboxed"` for read-only profiles: a `bash`
38
+ tool whose commands run in a bubblewrap sandbox without network and with the
39
+ workspace as a temporary overlay, so review workers can run `git log`,
40
+ `git blame`, tests and scripts without touching the host tree. Linux only,
41
+ not together with `grantedExtensionTools`. New start error
42
+ `sandbox_unavailable` (class `needs_operator`), new capabilities field
43
+ `shell` (`none` or `sandboxed`) after `grantedExtensionTools`, and the Doctor
44
+ checks `sandbox_unavailable` (error, probed for each configured workspace) and
45
+ `workspace_git_unavailable` (warning). The sandbox's `/tmp` is limited to
46
+ 512 MiB, commands default to a 600 second timeout and are killed with all
47
+ their processes on timeout or abort; the start probe is cancelled with the
48
+ start request. A skill directory that equals or contains the operator's home,
49
+ the configuration directory or `~/.pi` is no longer a read root, only its
50
+ skill file is
51
+ ([#169](https://github.com/plagor44-ux/pi-session-mcp/issues/169)).
52
+
53
+ - Optional container resource settings `--cpus`, `--cpu-shares` and `--memory`,
54
+ with host cgroup-controller preflight and fenced in-place writer updates
55
+ without reprovisioning. A later mode request that omits a limit keeps it;
56
+ `none` removes it
57
+ ([#166](https://github.com/plagor44-ux/pi-session-mcp/issues/166)).
58
+
59
+ - `--cache npm` mounts a private per-writer npm cache at `/pi/npm` with
60
+ `npm_config_cache=/pi/npm/cache`, retained across reset, finish and recreate
61
+ and removed with the writer
62
+ ([#165](https://github.com/plagor44-ux/pi-session-mcp/issues/165)).
63
+
64
+ - Opt-in container setting `--clean all` removes ignored files from writer
65
+ clones on reset and finish while preserving the default dependency-cache
66
+ behavior and per-writer caches outside the clone
67
+ ([#162](https://github.com/plagor44-ux/pi-session-mcp/issues/162)).
68
+
69
+ ### Fixed
70
+
71
+ - An apply of `pi-session-mcp-setup --repo <alias> --mode container ...` with
72
+ drifted settings is now refused before the mode is written: each kept writer
73
+ with a difference reports `writer_settings_changed`, the modes file stays
74
+ unchanged and no kept writer is changed (a surplus writer of a smaller count
75
+ may already have been fenced), so the recorded mode never drifts from
76
+ what the writers run
77
+ ([#163](https://github.com/plagor44-ux/pi-session-mcp/issues/163)).
78
+
5
79
  ## 0.12.1 — 2026-10-28
6
80
 
7
81
  Patch release for
package/README.md CHANGED
@@ -30,7 +30,7 @@ It is not the right tool when:
30
30
 
31
31
  ## Status
32
32
 
33
- The package version is **0.12.1**.
33
+ The package version is **0.13.0**.
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
 
@@ -174,6 +174,7 @@ Contract and design:
174
174
  - [ADR 0007: execution profiles select Pi skills from an operator catalog](docs/adr/0007-profile-skill-selection.md)
175
175
  - [ADR 0011: turn events and controller wake](docs/adr/0011-turn-events-and-controller-wake.md)
176
176
  - [ADR 0012: one registration with a leased writer pool](docs/adr/0012-leased-writer-pool.md)
177
+ - [ADR 0013: hardened read-only profiles](docs/adr/0013-hardened-read-only-profiles.md)
177
178
 
178
179
  Maintain:
179
180
 
@@ -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
@@ -35,6 +35,7 @@ export declare const configSchema: z.ZodObject<{
35
35
  mcpServers: z.ZodOptional<z.ZodCustom<Readonly<Record<string, import("./mcp-config.js").McpServerConfig>>, Readonly<Record<string, import("./mcp-config.js").McpServerConfig>>>>;
36
36
  extensions: z.ZodOptional<z.ZodArray<z.ZodString>>;
37
37
  grantedExtensionTools: z.ZodOptional<z.ZodArray<z.ZodString>>;
38
+ shell: z.ZodOptional<z.ZodLiteral<"sandboxed">>;
38
39
  skills: z.ZodOptional<z.ZodArray<z.ZodString>>;
39
40
  }, z.core.$strict>>;
40
41
  }, 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
  }
@@ -146,13 +159,14 @@ export async function loadConfig(path = process.env.PI_SESSION_MCP_CONFIG) {
146
159
  const overrideDirectory = skillOverrideDirectory(absoluteConfig);
147
160
  const workspaces = new ImmutableMap(Object.entries(parsed.workspaces).map(([key, value]) => [key, resolve(base, value)]));
148
161
  const catalog = parsed.skillCatalog ?? {};
149
- const executionProfiles = new ImmutableMap(Object.entries(parsed.executionProfiles).map(([key, { mcpServers, extensions, grantedExtensionTools, skills, ...value }]) => {
162
+ const executionProfiles = new ImmutableMap(Object.entries(parsed.executionProfiles).map(([key, { mcpServers, extensions, grantedExtensionTools, shell, skills, ...value }]) => {
150
163
  const selection = resolveSkillSelection(skills, catalog, base, overrideDirectory);
151
164
  return [key, Object.freeze({
152
165
  alias: key, ...value,
153
166
  ...(mcpServers === undefined ? {} : { mcpServers: snapshotMcpServers(mcpServers) }),
154
167
  ...(extensions === undefined ? {} : { extensions: Object.freeze(extensions.map((spec) => parseExtensionSpec(spec))) }),
155
168
  ...(grantedExtensionTools === undefined ? {} : { grantedExtensionTools: Object.freeze([...grantedExtensionTools]) }),
169
+ ...(shell === undefined ? {} : { shell }),
156
170
  ...(selection === undefined ? {} : { skills: selection }),
157
171
  })];
158
172
  }));
@@ -0,0 +1,29 @@
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
+ */
11
+ export declare function allowedReadRoots(workspace: string, skills: readonly Skill[], protectedPaths?: readonly string[]): Promise<string[]>;
12
+ /**
13
+ * Resolves a tool `path` argument the way the SDK will and checks the canonical result against the
14
+ * roots. Arguments the SDK would rewrite before resolving are refused, so both resolve the same path.
15
+ * Returns the canonical path; a refusal throws a fixed message without the path.
16
+ */
17
+ export declare function confineToolPath(input: unknown, cwd: string, roots: readonly string[]): Promise<string>;
18
+ /** The `*Operations` hooks that re-check every absolute path the SDK hands them; exported for tests. */
19
+ export declare function confinedOperations(roots: readonly string[]): {
20
+ read: ReadOperations;
21
+ grep: GrepOperations;
22
+ ls: LsOperations;
23
+ };
24
+ /**
25
+ * Definitions named `read`, `grep`, `find` and `ls` that accept only paths below `roots`. Registered as
26
+ * SDK custom tools they replace the built-ins of the same name. `find` has no operations hook that
27
+ * would still cover `fd`, so it relies on the execute wrapper alone.
28
+ */
29
+ export declare function createConfinedReadOnlyTools(cwd: string, roots: readonly string[]): ToolDefinition[];
@@ -0,0 +1,118 @@
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
+ */
36
+ export async function allowedReadRoots(workspace, skills, protectedPaths = []) {
37
+ const roots = new Set([await realpath(workspace)]);
38
+ const protectedReal = await Promise.all(protectedPaths.map((path) => canonical(path)));
39
+ for (const skill of skills) {
40
+ const directory = await realpath(dirname(skill.filePath));
41
+ roots.add(protectedReal.some((path) => isWithin(path, directory)) ? await realpath(skill.filePath) : directory);
42
+ }
43
+ return [...roots];
44
+ }
45
+ /**
46
+ * Resolves a tool `path` argument the way the SDK will and checks the canonical result against the
47
+ * roots. Arguments the SDK would rewrite before resolving are refused, so both resolve the same path.
48
+ * Returns the canonical path; a refusal throws a fixed message without the path.
49
+ */
50
+ export async function confineToolPath(input, cwd, roots) {
51
+ const value = input === undefined || input === "" ? "." : input;
52
+ if (typeof value !== "string"
53
+ || value.startsWith("@") || value.startsWith("~") || value.startsWith("file:")
54
+ || value.includes("\0") || UNICODE_SPACES.test(value)) {
55
+ throw new Error(OUTSIDE_ROOTS_MESSAGE);
56
+ }
57
+ const real = await canonical(resolve(cwd, value));
58
+ if (!roots.some((root) => isWithin(real, root)))
59
+ throw new Error(OUTSIDE_ROOTS_MESSAGE);
60
+ return real;
61
+ }
62
+ /** The second layer: the absolute path the SDK hands to an operations hook. */
63
+ async function checkAbsolute(path, roots) {
64
+ const real = await canonical(path);
65
+ if (!roots.some((root) => isWithin(real, root)))
66
+ throw new Error(OUTSIDE_ROOTS_MESSAGE);
67
+ }
68
+ /** The `*Operations` hooks that re-check every absolute path the SDK hands them; exported for tests. */
69
+ export function confinedOperations(roots) {
70
+ const guard = async (path, run) => { await checkAbsolute(path, roots); return run(); };
71
+ return {
72
+ read: {
73
+ access: (path) => guard(path, () => access(path, constants.R_OK)),
74
+ readFile: (path) => guard(path, () => readFile(path)),
75
+ detectImageMimeType: (path) => guard(path, () => detectSupportedImageMimeTypeFromFile(path)),
76
+ },
77
+ grep: {
78
+ isDirectory: (path) => guard(path, async () => (await stat(path)).isDirectory()),
79
+ readFile: (path) => guard(path, () => readFile(path, "utf-8")),
80
+ },
81
+ ls: {
82
+ exists: (path) => guard(path, async () => { try {
83
+ await access(path, constants.F_OK);
84
+ return true;
85
+ }
86
+ catch {
87
+ return false;
88
+ } }),
89
+ stat: (path) => guard(path, () => stat(path)),
90
+ readdir: (path) => guard(path, () => readdir(path)),
91
+ },
92
+ };
93
+ }
94
+ /** Runs the root check on `params.path` before the SDK tool executes. */
95
+ function confine(definition, cwd, roots) {
96
+ return {
97
+ ...definition,
98
+ execute: async (toolCallId, params, signal, onUpdate, context) => {
99
+ // The SDK resolves against `ctx.cwd || cwd`; resolve the same way.
100
+ await confineToolPath(params.path, context?.cwd || cwd, roots);
101
+ return definition.execute(toolCallId, params, signal, onUpdate, context);
102
+ },
103
+ };
104
+ }
105
+ /**
106
+ * Definitions named `read`, `grep`, `find` and `ls` that accept only paths below `roots`. Registered as
107
+ * SDK custom tools they replace the built-ins of the same name. `find` has no operations hook that
108
+ * would still cover `fd`, so it relies on the execute wrapper alone.
109
+ */
110
+ export function createConfinedReadOnlyTools(cwd, roots) {
111
+ const operations = confinedOperations(roots);
112
+ return [
113
+ confine(createReadToolDefinition(cwd, { operations: operations.read }), cwd, roots),
114
+ confine(createGrepToolDefinition(cwd, { operations: operations.grep }), cwd, roots),
115
+ confine(createFindToolDefinition(cwd), cwd, roots),
116
+ confine(createLsToolDefinition(cwd, { operations: operations.ls }), cwd, roots),
117
+ ];
118
+ }
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;
package/dist/doctor.js CHANGED
@@ -3,6 +3,7 @@ import { constants } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { homedir } from "node:os";
5
5
  import { createProcessRunner } from "./setup-process.js";
6
+ import { probeSandbox, resolveWorktreeGitDir } from "./sandbox-shell.js";
6
7
  import { podmanInfo, isSafeRefName } from "./writer-boundary.js";
7
8
  import { containedBuiltinSkills, containedProfileRefused } from "./writer-lifecycle.js";
8
9
  import { readWriterState, writerAcceptanceCurrent, writerName, writerPaths } from "./writer-state.js";
@@ -32,7 +33,7 @@ async function readPrefix(path, maxBytes) {
32
33
  }
33
34
  }
34
35
  const defaultFileSystem = { readFile, stat, access };
35
- const CHECK_ORDER = ["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"];
36
+ const CHECK_ORDER = ["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"];
36
37
  function failure(id, subject, remediation) { return Object.freeze({ id, severity: "error", subject, remediation }); }
37
38
  function warning(id, subject, remediation) { return Object.freeze({ id, severity: "warning", subject, remediation }); }
38
39
  function ok(id, subject) { return Object.freeze({ id, severity: "ok", subject }); }
@@ -148,6 +149,8 @@ async function hasGitfilePointer(fs, workspace) {
148
149
  return false;
149
150
  }
150
151
  }
152
+ /** The trusted bubblewrap binary plus a probe run through the session's fixed arguments for the workspace, its worktree Git directory included. */
153
+ const defaultSandboxProbe = (workspace) => probeSandbox(workspace);
151
154
  async function defaultRootInfo(path) {
152
155
  try {
153
156
  const info = await lstat(path);
@@ -627,6 +630,46 @@ export async function runDoctor(dependencies = {}) {
627
630
  }
628
631
  }
629
632
  }
633
+ if (config) {
634
+ const sandboxed = [...config.executionProfiles].filter(([, profile]) => profile.shell === "sandboxed").map(([alias]) => alias).sort((a, b) => a.localeCompare(b));
635
+ if (sandboxed.length > 0) {
636
+ // The overlay depends on the workspace's filesystem, so each existing workspace directory is probed.
637
+ for (const [alias, path] of [...config.workspaces].sort(([a], [b]) => a.localeCompare(b))) {
638
+ try {
639
+ if (!(await fs.stat(path)).isDirectory())
640
+ continue;
641
+ }
642
+ catch {
643
+ continue;
644
+ }
645
+ let usable = false;
646
+ try {
647
+ usable = await (dependencies.sandboxProbe ?? defaultSandboxProbe)(path);
648
+ }
649
+ catch {
650
+ usable = false;
651
+ }
652
+ checks.push(usable
653
+ ? ok("sandbox_unavailable", alias)
654
+ : failure("sandbox_unavailable", alias, "install a bubblewrap with --overlay-src, --tmp-overlay and --disable-userns support at /usr/bin/bwrap (root-owned, not group- or world-writable) and allow unprivileged user namespaces, and keep the workspace on a filesystem that can be an overlay lower layer; see docs/installation.md"));
655
+ }
656
+ // Without a usable worktree pointer Git commands fail inside the sandbox; the read tools are unaffected.
657
+ for (const [alias, path] of [...config.workspaces].sort(([a], [b]) => a.localeCompare(b))) {
658
+ if (!(await hasGitfilePointer(fs, path)))
659
+ continue;
660
+ let gitDir;
661
+ try {
662
+ gitDir = await (dependencies.worktreeGitDir ?? resolveWorktreeGitDir)(path);
663
+ }
664
+ catch {
665
+ gitDir = undefined;
666
+ }
667
+ checks.push(gitDir === undefined
668
+ ? warning("workspace_git_unavailable", alias, "the workspace's gitfile does not point to a linked worktree whose main Git directory is intact, so Git commands fail inside the shell sandbox")
669
+ : ok("workspace_git_unavailable", alias));
670
+ }
671
+ }
672
+ }
630
673
  if (config?.eventDelivery === "spool")
631
674
  checks.push(await eventSpoolCheck(dependencies.env ?? process.env, dependencies.eventSpoolRoot ?? defaultRootInfo, dependencies.uid ?? process.getuid?.()));
632
675
  const lease = await writerLeaseCheck(home, dependencies.writerLeaseRoot ?? defaultRootInfo, dependencies.uid ?? process.getuid?.());
@@ -6,9 +6,13 @@ export interface ContainerSettings {
6
6
  readonly profiles: readonly string[];
7
7
  readonly image: string;
8
8
  readonly network: "pasta" | "none";
9
- readonly cache: "none" | "go" | "uv";
9
+ readonly cache: "none" | "go" | "uv" | "npm";
10
10
  readonly tags: "none" | "reachable";
11
+ readonly clean: "untracked" | "all";
11
12
  readonly base?: string;
13
+ readonly cpus?: number;
14
+ readonly cpuShares?: number;
15
+ readonly memory?: string;
12
16
  }
13
17
  export interface WorkspaceModeEntry {
14
18
  readonly mode: WorkspaceMode;
@@ -43,13 +47,21 @@ export declare const executionModesSchema: z.ZodObject<{
43
47
  cache: z.ZodDefault<z.ZodEnum<{
44
48
  go: "go";
45
49
  none: "none";
50
+ npm: "npm";
46
51
  uv: "uv";
47
52
  }>>;
48
53
  tags: z.ZodDefault<z.ZodEnum<{
49
54
  none: "none";
50
55
  reachable: "reachable";
51
56
  }>>;
57
+ clean: z.ZodDefault<z.ZodEnum<{
58
+ all: "all";
59
+ untracked: "untracked";
60
+ }>>;
52
61
  base: z.ZodOptional<z.ZodString>;
62
+ cpus: z.ZodOptional<z.ZodNumber>;
63
+ cpuShares: z.ZodOptional<z.ZodNumber>;
64
+ memory: z.ZodOptional<z.ZodString>;
53
65
  }, z.core.$strict>], "mode">>;
54
66
  }, z.core.$strict>;
55
67
  type ModeProfile = {
@@ -4,6 +4,7 @@ import { z } from "zod";
4
4
  import { ConfigError } from "./config.js";
5
5
  import { ImmutableMap } from "./execution-profile.js";
6
6
  import { isSafeRefName } from "./writer-boundary.js";
7
+ import { cpusSchema, cpuSharesSchema, memorySchema } from "./writer-limits.js";
7
8
  export const EXECUTION_MODES_FILE = "execution-modes.json";
8
9
  /** Writers per container workspace; capabilities report it and setup verify checks it. */
9
10
  export const MAX_WRITERS = 16;
@@ -28,9 +29,13 @@ const entrySchema = z.discriminatedUnion("mode", [
28
29
  profiles: z.array(z.string()).min(1),
29
30
  image: z.union([z.literal("node"), z.string().regex(/^[0-9a-f]{64}$/)]).default("node"),
30
31
  network: z.enum(["pasta", "none"]).default("pasta"),
31
- cache: z.enum(["none", "go", "uv"]).default("none"),
32
+ cache: z.enum(["none", "go", "uv", "npm"]).default("none"),
32
33
  tags: z.enum(["none", "reachable"]).default("none"),
34
+ clean: z.enum(["untracked", "all"]).default("untracked"),
33
35
  base: z.string().refine(isShortBranchName).optional(),
36
+ cpus: cpusSchema.optional(),
37
+ cpuShares: cpuSharesSchema.optional(),
38
+ memory: memorySchema.optional(),
34
39
  }).strict(),
35
40
  ]);
36
41
  export const executionModesSchema = z.object({
@@ -52,10 +57,13 @@ export function resolveExecutionModes(raw, workspaceAliases, profiles) {
52
57
  throw new ConfigError("invalid_execution_modes");
53
58
  }
54
59
  }
55
- const { mode, base, ...settings } = entry;
60
+ const { mode, base, cpus, cpuShares, memory, ...settings } = entry;
56
61
  entries.set(alias, Object.freeze({ mode, container: Object.freeze({
57
62
  ...settings, profiles: Object.freeze([...settings.profiles]),
58
63
  ...(base === undefined ? {} : { base }),
64
+ ...(cpus === undefined ? {} : { cpus }),
65
+ ...(cpuShares === undefined ? {} : { cpuShares }),
66
+ ...(memory === undefined ? {} : { memory }),
59
67
  }) }));
60
68
  }
61
69
  else {
@@ -3,6 +3,7 @@ import { type McpServers } from "./mcp-config.js";
3
3
  import type { SkillSelection } from "./skill-catalog.js";
4
4
  export declare const ALIAS_PATTERN: RegExp;
5
5
  export type PermissionProfile = "read-only" | "coding";
6
+ export type ShellMode = "sandboxed";
6
7
  export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
7
8
  /** A profile as it appears in the strictly validated application config. */
8
9
  export interface ConfiguredExecutionProfile {
@@ -15,6 +16,8 @@ export interface ConfiguredExecutionProfile {
15
16
  readonly mcpServers?: McpServers;
16
17
  readonly extensions?: readonly ExtensionSpec[];
17
18
  readonly grantedExtensionTools?: readonly string[];
19
+ /** `sandboxed` adds a bubblewrap-confined `bash` to a read-only profile (ADR 0013); absent means no shell. */
20
+ readonly shell?: ShellMode;
18
21
  /** The selected catalog skills in configured order; absent when the profile selects none. */
19
22
  readonly skills?: readonly SkillSelection[];
20
23
  }
@@ -28,6 +31,7 @@ export interface ResolvedExecutionProfile {
28
31
  readonly mcpServers?: McpServers;
29
32
  readonly extensions?: readonly ExtensionSpec[];
30
33
  readonly grantedExtensionTools?: readonly string[];
34
+ readonly shell?: ShellMode;
31
35
  readonly skills?: readonly SkillSelection[];
32
36
  }
33
37
  export declare function toResolvedExecutionProfile(profile: ConfiguredExecutionProfile): ResolvedExecutionProfile;
@@ -10,6 +10,7 @@ export function toResolvedExecutionProfile(profile) {
10
10
  ...(profile.mcpServers === undefined ? {} : { mcpServers: snapshotMcpServers(profile.mcpServers) }),
11
11
  ...(profile.extensions === undefined ? {} : { extensions: profile.extensions }),
12
12
  ...(profile.grantedExtensionTools === undefined ? {} : { grantedExtensionTools: Object.freeze([...profile.grantedExtensionTools]) }),
13
+ ...(profile.shell === undefined ? {} : { shell: profile.shell }),
13
14
  ...(profile.skills === undefined ? {} : { skills: profile.skills }),
14
15
  });
15
16
  }
package/dist/main.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { stdoutGuard } from "./stdout-guard-install.js";
3
- import { isAbsolute } from "node:path";
3
+ import { dirname, isAbsolute, resolve } from "node:path";
4
4
  import { homedir } from "node:os";
5
5
  import { RoutingPiSessionAdapter, WriterRelayAdapter } from "./writer-relay.js";
6
6
  import { serveStdio, StdioServerTransport } from "@modelcontextprotocol/server/stdio";
@@ -47,7 +47,11 @@ async function main() {
47
47
  spool = TurnEventSpool.open(directory, { diagnostic: writeDiagnostic });
48
48
  }
49
49
  const events = spool;
50
- const hostAdapter = new SdkPiSessionAdapter({ diagnosticSink: writeDiagnostic });
50
+ const hostAdapter = new SdkPiSessionAdapter({
51
+ diagnosticSink: writeDiagnostic,
52
+ // The configuration directory must never become readable through a skill directory.
53
+ ...(process.env.PI_SESSION_MCP_CONFIG === undefined || process.env.PI_SESSION_MCP_CONFIG === "" ? {} : { protectedReadPaths: [dirname(resolve(process.env.PI_SESSION_MCP_CONFIG))] }),
54
+ });
51
55
  const relay = guardPath === undefined;
52
56
  const adapter = relay ? new RoutingPiSessionAdapter(hostAdapter, new WriterRelayAdapter({
53
57
  home: homedir(), workspaceModes: config.workspaceModes, executionProfiles: config.executionProfiles,
@@ -14,7 +14,7 @@ export interface PiAppliedSelection {
14
14
  readonly model: string;
15
15
  readonly thinkingLevel: ThinkingLevel;
16
16
  }
17
- export type PiSessionCreationErrorCode = "unknown_provider" | "unknown_model" | "local_authentication_missing" | "local_authentication_unavailable" | "model_unavailable" | "thinking_level_unavailable" | "execution_selection_mismatch" | "external_mcp_unavailable" | "external_mcp_no_tools" | "external_mcp_grant_tool_missing" | "external_mcp_activation_mismatch" | "extension_not_installed" | "extension_version_mismatch" | "extension_load_failed" | "extension_tool_conflict" | "extension_tool_missing" | "skill_load_failed" | "pi_session_creation_failed";
17
+ export type PiSessionCreationErrorCode = "unknown_provider" | "unknown_model" | "local_authentication_missing" | "local_authentication_unavailable" | "model_unavailable" | "thinking_level_unavailable" | "execution_selection_mismatch" | "external_mcp_unavailable" | "external_mcp_no_tools" | "external_mcp_grant_tool_missing" | "external_mcp_activation_mismatch" | "extension_not_installed" | "extension_version_mismatch" | "extension_load_failed" | "extension_tool_conflict" | "extension_tool_missing" | "skill_load_failed" | "sandbox_unavailable" | "pi_session_creation_failed";
18
18
  export declare class PiSessionCreationError extends Error {
19
19
  readonly code: PiSessionCreationErrorCode;
20
20
  constructor(code: PiSessionCreationErrorCode);
@@ -16,6 +16,7 @@ const PI_SESSION_CREATION_MESSAGES = {
16
16
  extension_tool_conflict: "Configured Pi extension tools conflict",
17
17
  extension_tool_missing: "Granted Pi extension tool is not available",
18
18
  skill_load_failed: "Configured Pi skill could not be loaded",
19
+ sandbox_unavailable: "Read-only shell sandbox is not available",
19
20
  pi_session_creation_failed: "Pi session creation failed",
20
21
  };
21
22
  export class PiSessionCreationError extends Error {