pi-session-mcp 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/.pi-session-mcp-cli/client-adapters/mcp-verifier.js +8 -3
  2. package/.pi-session-mcp-cli/config.js +15 -1
  3. package/.pi-session-mcp-cli/doctor.js +44 -1
  4. package/.pi-session-mcp-cli/execution-modes.js +13 -3
  5. package/.pi-session-mcp-cli/execution-profile.js +1 -0
  6. package/.pi-session-mcp-cli/sandbox-shell.js +254 -0
  7. package/.pi-session-mcp-cli/session-tools.js +6 -2
  8. package/.pi-session-mcp-cli/writer-boundary.js +27 -0
  9. package/.pi-session-mcp-cli/writer-lifecycle.js +6 -4
  10. package/.pi-session-mcp-cli/writer-limits.js +19 -0
  11. package/.pi-session-mcp-cli/writer-state.js +5 -2
  12. package/CHANGELOG.md +96 -0
  13. package/README.md +2 -1
  14. package/dist/capabilities.d.ts +1 -0
  15. package/dist/capabilities.js +3 -1
  16. package/dist/client-adapters/mcp-verifier.js +8 -3
  17. package/dist/config.d.ts +1 -0
  18. package/dist/config.js +15 -1
  19. package/dist/confined-tools.d.ts +29 -0
  20. package/dist/confined-tools.js +118 -0
  21. package/dist/doctor.d.ts +5 -1
  22. package/dist/doctor.js +44 -1
  23. package/dist/execution-modes.d.ts +15 -1
  24. package/dist/execution-modes.js +13 -3
  25. package/dist/execution-profile.d.ts +4 -0
  26. package/dist/execution-profile.js +1 -0
  27. package/dist/main.js +6 -2
  28. package/dist/pi-adapter.d.ts +1 -1
  29. package/dist/pi-adapter.js +1 -0
  30. package/dist/sandbox-shell.d.ts +71 -0
  31. package/dist/sandbox-shell.js +254 -0
  32. package/dist/sdk-pi-adapter.d.ts +17 -1
  33. package/dist/sdk-pi-adapter.js +64 -10
  34. package/dist/session-tools.d.ts +4 -2
  35. package/dist/session-tools.js +6 -2
  36. package/dist/setup-cli.js +40 -6
  37. package/dist/setup-modes.d.ts +3 -0
  38. package/dist/setup-modes.js +18 -1
  39. package/dist/setup-writers.js +37 -6
  40. package/dist/tool-schemas.d.ts +4 -0
  41. package/dist/tool-schemas.js +1 -0
  42. package/dist/writer-boundary.d.ts +2 -0
  43. package/dist/writer-boundary.js +27 -0
  44. package/dist/writer-lifecycle.d.ts +1 -0
  45. package/dist/writer-lifecycle.js +6 -4
  46. package/dist/writer-limits.d.ts +13 -0
  47. package/dist/writer-limits.js +19 -0
  48. package/dist/writer-relay.js +1 -1
  49. package/dist/writer-state.d.ts +11 -0
  50. package/dist/writer-state.js +5 -2
  51. package/docs/acceptance.md +266 -0
  52. package/docs/adr/0013-hardened-read-only-profiles.md +182 -0
  53. package/docs/doctor-design.md +2 -0
  54. package/docs/execution-modes.md +59 -3
  55. package/docs/installation.md +34 -1
  56. package/docs/isolation-setup.md +31 -2
  57. package/docs/roadmap.md +8 -1
  58. package/docs/session-mcp.md +13 -0
  59. package/docs/threat-model.md +50 -0
  60. package/docs/tool-contracts.md +30 -3
  61. package/package.json +1 -1
  62. package/skills/pi-session-orchestration/references/start-errors.md +1 -1
@@ -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 {
@@ -0,0 +1,71 @@
1
+ import type { BashOperations } from "@earendil-works/pi-coding-agent";
2
+ /** The only bubblewrap binary the shell uses: a fixed path, never a `PATH` lookup. */
3
+ export declare const BWRAP_PATH = "/usr/bin/bwrap";
4
+ export declare const SANDBOX_BASH_PATH = "/usr/bin/bash";
5
+ export declare const PROBE_TIMEOUT_MS = 5000;
6
+ /** Seconds a command may run when the tool call names no `timeout`. */
7
+ export declare const DEFAULT_COMMAND_TIMEOUT_SECONDS = 600;
8
+ /** Size limit of the sandbox's tmpfs `/tmp`, in bytes (512 MiB). */
9
+ export declare const SANDBOX_TMP_BYTES = 536870912;
10
+ export declare const SANDBOX_UNAVAILABLE_MESSAGE = "Read-only shell sandbox is not available";
11
+ export type RootEntry = {
12
+ readonly path: string;
13
+ readonly kind: "symlink";
14
+ readonly target: string;
15
+ } | {
16
+ readonly path: string;
17
+ readonly kind: "directory";
18
+ };
19
+ export interface SandboxArgsInput {
20
+ /** Real path of the workspace, mounted at the same path as an overlay with a temporary upper layer. */
21
+ readonly workspace: string;
22
+ /** Real path of a linked worktree's main Git directory, mounted the same way. */
23
+ readonly gitCommonDir?: string | undefined;
24
+ readonly cwd: string;
25
+ readonly rootEntries: readonly RootEntry[];
26
+ /** The program and its arguments, run after `--`. */
27
+ readonly argv: readonly string[];
28
+ }
29
+ /** The complete, fixed bubblewrap argument list of ADR 0013. Pure: host facts come in as `rootEntries`. */
30
+ export declare function sandboxArgs(input: SandboxArgsInput): string[];
31
+ /** The host's `/bin`, `/lib`, `/lib64` and `/sbin`: a symlink is recreated, a directory bound, an absent entry skipped. */
32
+ export declare function hostRootEntries(): RootEntry[];
33
+ /**
34
+ * The main repository's Git directory of a linked worktree, or `undefined`. Only a `.git` regular file
35
+ * whose `gitdir:` names `<common>/worktrees/<name>` is accepted, and only if that directory points
36
+ * back at this workspace's `.git`, its `commondir` resolves to `<common>`, and `<common>` holds `HEAD`,
37
+ * `objects` and `refs`. Never throws on bad content.
38
+ */
39
+ export declare function resolveWorktreeGitDir(workspace: string): Promise<string | undefined>;
40
+ /** The part of a child process the sandbox uses; the default is `node:child_process`. */
41
+ export interface SandboxChild {
42
+ readonly stdout: NodeJS.ReadableStream | null;
43
+ readonly stderr: NodeJS.ReadableStream | null;
44
+ once(event: "error", listener: (error: Error) => void): unknown;
45
+ once(event: "close", listener: (code: number | null, signal: NodeJS.Signals | null) => void): unknown;
46
+ kill(signal?: NodeJS.Signals): unknown;
47
+ }
48
+ export type SandboxSpawn = (file: string, args: readonly string[], options: {
49
+ env: Record<string, never>;
50
+ stdio: ["ignore", "pipe", "pipe"];
51
+ }) => SandboxChild;
52
+ export interface SandboxDependencies {
53
+ readonly spawn?: SandboxSpawn;
54
+ /** Whether the fixed bubblewrap binary may be used; defaults to a trusted-system-binary check. */
55
+ readonly trusted?: () => boolean;
56
+ readonly rootEntries?: () => readonly RootEntry[];
57
+ readonly gitCommonDir?: (workspace: string) => Promise<string | undefined>;
58
+ }
59
+ /** Whether the fixed bubblewrap binary is a trusted system binary. */
60
+ export declare function sandboxBinaryTrusted(dependencies?: SandboxDependencies): boolean;
61
+ /**
62
+ * Runs `/usr/bin/true` through the same sandbox as a command, bounded by `PROBE_TIMEOUT_MS`.
63
+ * False for an untrusted binary, a spawn failure, a non-zero exit or a timeout.
64
+ */
65
+ export declare function probeSandbox(workspace: string, dependencies?: SandboxDependencies, signal?: AbortSignal): Promise<boolean>;
66
+ /**
67
+ * `BashOperations` that run each command as `bwrap <fixed arguments> -- bash -c <command>`. The passed
68
+ * `env` is ignored, a working directory outside the workspace is refused and there is no unsandboxed
69
+ * fallback: a sandbox that cannot start fails the command.
70
+ */
71
+ export declare function createSandboxedBashOperations(workspace: string, dependencies?: SandboxDependencies): BashOperations;
@@ -0,0 +1,254 @@
1
+ import { spawn } from "node:child_process";
2
+ import { constants as osConstants } from "node:os";
3
+ import { lstatSync, readlinkSync } from "node:fs";
4
+ import { lstat, readFile, realpath, stat } from "node:fs/promises";
5
+ import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
6
+ import { isTrustedSystemBinary } from "./setup-platform.js";
7
+ /** The only bubblewrap binary the shell uses: a fixed path, never a `PATH` lookup. */
8
+ export const BWRAP_PATH = "/usr/bin/bwrap";
9
+ export const SANDBOX_BASH_PATH = "/usr/bin/bash";
10
+ const SANDBOX_TRUE_PATH = "/usr/bin/true";
11
+ const SANDBOX_PATH = "/usr/local/bin:/usr/bin:/bin";
12
+ const SANDBOX_HOME = "/tmp/home";
13
+ /** Directories of a merged-`/usr` host that the sandbox recreates as symlinks or binds read-only. */
14
+ const ROOT_ENTRIES = ["/bin", "/lib", "/lib64", "/sbin"];
15
+ export const PROBE_TIMEOUT_MS = 5_000;
16
+ /** Seconds a command may run when the tool call names no `timeout`. */
17
+ export const DEFAULT_COMMAND_TIMEOUT_SECONDS = 600;
18
+ /** Size limit of the sandbox's tmpfs `/tmp`, in bytes (512 MiB). */
19
+ export const SANDBOX_TMP_BYTES = 536_870_912;
20
+ export const SANDBOX_UNAVAILABLE_MESSAGE = "Read-only shell sandbox is not available";
21
+ const GITFILE_LIMIT_BYTES = 4096;
22
+ /** The complete, fixed bubblewrap argument list of ADR 0013. Pure: host facts come in as `rootEntries`. */
23
+ export function sandboxArgs(input) {
24
+ const args = [
25
+ "--unshare-all", "--unshare-user", "--disable-userns", "--die-with-parent", "--new-session", "--clearenv",
26
+ "--setenv", "PATH", SANDBOX_PATH, "--setenv", "HOME", SANDBOX_HOME,
27
+ "--ro-bind", "/usr", "/usr",
28
+ ];
29
+ for (const entry of input.rootEntries) {
30
+ if (entry.kind === "symlink")
31
+ args.push("--symlink", entry.target, entry.path);
32
+ else
33
+ args.push("--ro-bind", entry.path, entry.path);
34
+ }
35
+ args.push("--ro-bind", "/etc", "/etc", "--dev", "/dev", "--proc", "/proc", "--size", String(SANDBOX_TMP_BYTES), "--tmpfs", "/tmp", "--dir", SANDBOX_HOME, "--overlay-src", input.workspace, "--tmp-overlay", input.workspace);
36
+ if (input.gitCommonDir !== undefined)
37
+ args.push("--overlay-src", input.gitCommonDir, "--tmp-overlay", input.gitCommonDir);
38
+ args.push("--chdir", input.cwd, "--", ...input.argv);
39
+ return args;
40
+ }
41
+ /** The host's `/bin`, `/lib`, `/lib64` and `/sbin`: a symlink is recreated, a directory bound, an absent entry skipped. */
42
+ export function hostRootEntries() {
43
+ const entries = [];
44
+ for (const path of ROOT_ENTRIES) {
45
+ try {
46
+ const info = lstatSync(path);
47
+ if (info.isSymbolicLink())
48
+ entries.push({ path, kind: "symlink", target: readlinkSync(path) });
49
+ else if (info.isDirectory())
50
+ entries.push({ path, kind: "directory" });
51
+ }
52
+ catch { /* absent */ }
53
+ }
54
+ return entries;
55
+ }
56
+ const within = (path, root) => path === root || path.startsWith(root.endsWith(sep) ? root : `${root}${sep}`);
57
+ async function readSmall(path) {
58
+ try {
59
+ const info = await lstat(path);
60
+ if (!info.isFile() || info.size > GITFILE_LIMIT_BYTES)
61
+ return undefined;
62
+ return (await readFile(path, "utf8")).trim();
63
+ }
64
+ catch {
65
+ return undefined;
66
+ }
67
+ }
68
+ const real = async (path) => { try {
69
+ return await realpath(path);
70
+ }
71
+ catch {
72
+ return undefined;
73
+ } };
74
+ const isDirectory = async (path) => { try {
75
+ return (await stat(path)).isDirectory();
76
+ }
77
+ catch {
78
+ return false;
79
+ } };
80
+ const isFile = async (path) => { try {
81
+ return (await stat(path)).isFile();
82
+ }
83
+ catch {
84
+ return false;
85
+ } };
86
+ /**
87
+ * The main repository's Git directory of a linked worktree, or `undefined`. Only a `.git` regular file
88
+ * whose `gitdir:` names `<common>/worktrees/<name>` is accepted, and only if that directory points
89
+ * back at this workspace's `.git`, its `commondir` resolves to `<common>`, and `<common>` holds `HEAD`,
90
+ * `objects` and `refs`. Never throws on bad content.
91
+ */
92
+ export async function resolveWorktreeGitDir(workspace) {
93
+ try {
94
+ const gitFile = join(workspace, ".git");
95
+ const content = await readSmall(gitFile);
96
+ const match = content === undefined ? null : /^gitdir: (.+)$/.exec(content);
97
+ if (!match)
98
+ return undefined;
99
+ const gitdir = await real(resolve(workspace, match[1]));
100
+ if (gitdir === undefined || !(await isDirectory(gitdir)))
101
+ return undefined;
102
+ const worktrees = dirname(gitdir);
103
+ if (basename(worktrees) !== "worktrees")
104
+ return undefined;
105
+ const common = dirname(worktrees);
106
+ const back = await readSmall(join(gitdir, "gitdir"));
107
+ if (back === undefined || (await real(resolve(gitdir, back))) !== (await real(gitFile)))
108
+ return undefined;
109
+ const commondir = await readSmall(join(gitdir, "commondir"));
110
+ if (commondir === undefined || (await real(resolve(gitdir, commondir))) !== common)
111
+ return undefined;
112
+ if (!(await isFile(join(common, "HEAD"))) || !(await isDirectory(join(common, "objects"))) || !(await isDirectory(join(common, "refs"))))
113
+ return undefined;
114
+ return common;
115
+ }
116
+ catch {
117
+ return undefined;
118
+ }
119
+ }
120
+ const defaultSpawn = (file, args, options) => spawn(file, [...args], options);
121
+ const defaultTrusted = () => isTrustedSystemBinary(BWRAP_PATH);
122
+ /** Whether the fixed bubblewrap binary is a trusted system binary. */
123
+ export function sandboxBinaryTrusted(dependencies = {}) {
124
+ try {
125
+ return (dependencies.trusted ?? defaultTrusted)();
126
+ }
127
+ catch {
128
+ return false;
129
+ }
130
+ }
131
+ /** The complete argument list for one run; refuses when the fixed binary is not trusted. */
132
+ async function prepare(workspace, cwd, argv, dependencies) {
133
+ if (!sandboxBinaryTrusted(dependencies))
134
+ throw new Error(SANDBOX_UNAVAILABLE_MESSAGE);
135
+ const gitCommonDir = await (dependencies.gitCommonDir ?? resolveWorktreeGitDir)(workspace);
136
+ return sandboxArgs({ workspace, gitCommonDir, cwd, rootEntries: (dependencies.rootEntries ?? hostRootEntries)(), argv });
137
+ }
138
+ /** Spawns bubblewrap and attaches the termination listeners in the same tick, so no event is missed. */
139
+ function start(args, dependencies) {
140
+ // An empty environment: neither the server's nor the tool's variables reach the sandbox.
141
+ const child = (dependencies.spawn ?? defaultSpawn)(BWRAP_PATH, args, { env: {}, stdio: ["ignore", "pipe", "pipe"] });
142
+ const closed = new Promise((resolvePromise, reject) => {
143
+ child.once("error", () => reject(new Error(SANDBOX_UNAVAILABLE_MESSAGE)));
144
+ child.once("close", (code, signal) => resolvePromise({ code, signal }));
145
+ });
146
+ closed.catch(() => undefined);
147
+ return { child, closed };
148
+ }
149
+ /**
150
+ * Runs `/usr/bin/true` through the same sandbox as a command, bounded by `PROBE_TIMEOUT_MS`.
151
+ * False for an untrusted binary, a spawn failure, a non-zero exit or a timeout.
152
+ */
153
+ export async function probeSandbox(workspace, dependencies = {}, signal) {
154
+ let running;
155
+ let timer;
156
+ let onAbort;
157
+ try {
158
+ if (signal?.aborted)
159
+ return false;
160
+ const workspaceReal = await realpath(workspace);
161
+ running = start(await prepare(workspaceReal, workspaceReal, [SANDBOX_TRUE_PATH], dependencies), dependencies);
162
+ const { child, closed } = running;
163
+ child.stdout?.resume();
164
+ child.stderr?.resume();
165
+ const deadline = new Promise((resolvePromise) => {
166
+ timer = setTimeout(() => resolvePromise(null), PROBE_TIMEOUT_MS);
167
+ onAbort = () => resolvePromise(null);
168
+ signal?.addEventListener("abort", onAbort, { once: true });
169
+ if (signal?.aborted)
170
+ resolvePromise(null);
171
+ });
172
+ const outcome = await Promise.race([closed, deadline]);
173
+ if (outcome === null)
174
+ child.kill("SIGKILL");
175
+ return outcome !== null && outcome.code === 0 && signal?.aborted !== true;
176
+ }
177
+ catch {
178
+ try {
179
+ running?.child.kill("SIGKILL");
180
+ }
181
+ catch { /* already gone */ }
182
+ return false;
183
+ }
184
+ finally {
185
+ if (timer)
186
+ clearTimeout(timer);
187
+ if (onAbort)
188
+ signal?.removeEventListener("abort", onAbort);
189
+ }
190
+ }
191
+ /**
192
+ * `BashOperations` that run each command as `bwrap <fixed arguments> -- bash -c <command>`. The passed
193
+ * `env` is ignored, a working directory outside the workspace is refused and there is no unsandboxed
194
+ * fallback: a sandbox that cannot start fails the command.
195
+ */
196
+ export function createSandboxedBashOperations(workspace, dependencies = {}) {
197
+ return {
198
+ exec: async (command, cwd, { onData, signal, timeout }) => {
199
+ if (signal?.aborted)
200
+ throw new Error("aborted");
201
+ let workspaceReal;
202
+ let cwdReal;
203
+ try {
204
+ workspaceReal = await realpath(workspace);
205
+ cwdReal = await realpath(isAbsolute(cwd) ? cwd : resolve(workspaceReal, cwd));
206
+ }
207
+ catch {
208
+ throw new Error("Working directory is not available");
209
+ }
210
+ if (!within(cwdReal, workspaceReal))
211
+ throw new Error("Working directory is outside the workspace");
212
+ let timeoutMs = DEFAULT_COMMAND_TIMEOUT_SECONDS * 1000;
213
+ if (timeout !== undefined) {
214
+ if (!Number.isFinite(timeout) || timeout <= 0)
215
+ throw new Error("Invalid timeout: must be a finite number of seconds");
216
+ timeoutMs = Math.min(timeout * 1000, 2_147_483_647);
217
+ }
218
+ let running;
219
+ try {
220
+ running = start(await prepare(workspaceReal, cwdReal, [SANDBOX_BASH_PATH, "-c", command], dependencies), dependencies);
221
+ }
222
+ catch {
223
+ throw new Error(SANDBOX_UNAVAILABLE_MESSAGE);
224
+ }
225
+ const { child, closed } = running;
226
+ child.stdout?.on("data", onData);
227
+ child.stderr?.on("data", onData);
228
+ let timedOut = false;
229
+ let timer;
230
+ const kill = () => { try {
231
+ child.kill("SIGKILL");
232
+ }
233
+ catch { /* already gone */ } };
234
+ const onAbort = () => kill();
235
+ try {
236
+ timer = setTimeout(() => { timedOut = true; kill(); }, timeoutMs);
237
+ signal?.addEventListener("abort", onAbort, { once: true });
238
+ if (signal?.aborted)
239
+ kill();
240
+ const outcome = await closed;
241
+ if (signal?.aborted)
242
+ throw new Error("aborted");
243
+ if (timedOut)
244
+ throw new Error(`timeout:${timeout ?? DEFAULT_COMMAND_TIMEOUT_SECONDS}`);
245
+ return { exitCode: outcome.code ?? (outcome.signal ? 128 + (osConstants.signals[outcome.signal] ?? 0) : 1) };
246
+ }
247
+ finally {
248
+ if (timer)
249
+ clearTimeout(timer);
250
+ signal?.removeEventListener("abort", onAbort);
251
+ }
252
+ },
253
+ };
254
+ }
@@ -3,6 +3,7 @@ import { type ExtensionSpec } from "./extension-spec.js";
3
3
  import type { ResolvedExecutionProfile, ThinkingLevel } from "./execution-profile.js";
4
4
  import { type McpConnectionFactory } from "./mcp-session-tools.js";
5
5
  import { type PiSessionAdapter, type PiSessionCreationErrorCode, type PiSessionHandle, type PiTurnOutcome } from "./pi-adapter.js";
6
+ import { type SandboxDependencies } from "./sandbox-shell.js";
6
7
  export { PROFILE_TOOLS } from "./session-tools.js";
7
8
  type CreateSession = (options: CreateAgentSessionOptions) => Promise<CreateAgentSessionResult>;
8
9
  export declare const AUTH_OPERATION_TIMEOUT_MS = 5000;
@@ -11,7 +12,7 @@ export declare const SESSION_CREATION_TIMEOUT_MS = 10000;
11
12
  export interface SdkPiAdapterDiagnostic {
12
13
  readonly level: "error";
13
14
  readonly event: "pi_session_creation_failed";
14
- readonly stage: "runtime" | "provider" | "model" | "check_auth" | "get_auth" | "availability" | "resource_loader" | "mcp_tools" | "create_session" | "postcheck" | "extension_resolve" | "extension_load" | "extension_bind" | "skill_load";
15
+ readonly stage: "runtime" | "provider" | "model" | "check_auth" | "get_auth" | "availability" | "resource_loader" | "mcp_tools" | "create_session" | "postcheck" | "extension_resolve" | "extension_load" | "extension_bind" | "skill_load" | "sandbox";
15
16
  readonly code: PiSessionCreationErrorCode;
16
17
  readonly executionProfile: string;
17
18
  readonly provider: string;
@@ -39,6 +40,13 @@ export interface SdkPiSessionAdapterOptions {
39
40
  /** Resolves a pinned extension to its installed package directory. */
40
41
  readonly resolveExtension?: (extension: ExtensionSpec) => Promise<string>;
41
42
  readonly extensionSdk?: ExtensionSdk;
43
+ /** Host seams of the read-only shell sandbox; tests inject a fake runner. */
44
+ readonly sandbox?: SandboxDependencies;
45
+ /**
46
+ * Directories a skill directory must not expose through the read tools, in addition to the
47
+ * operator's home and the Pi agent directory (`~/.pi`). The server passes the configuration directory.
48
+ */
49
+ readonly protectedReadPaths?: readonly string[];
42
50
  }
43
51
  type SdkAssistantMessage = Extract<Extract<AgentSessionEvent, {
44
52
  type: "message_end";
@@ -78,6 +86,8 @@ export declare class SdkPiSessionAdapter implements PiSessionAdapter {
78
86
  private readonly mcpConnectionFactory;
79
87
  private readonly resolveExtension;
80
88
  private readonly extensionSdk;
89
+ private readonly sandbox;
90
+ private readonly protectedReadPaths;
81
91
  constructor(options?: SdkPiSessionAdapterOptions);
82
92
  create(input: {
83
93
  cwd: string;
@@ -85,6 +95,12 @@ export declare class SdkPiSessionAdapter implements PiSessionAdapter {
85
95
  signal?: AbortSignal;
86
96
  }): Promise<PiSessionHandle>;
87
97
  private createWithExtensions;
98
+ /**
99
+ * The server's own tool definitions of a read-only session (ADR 0013); none for coding. The workspace
100
+ * root is the session directory: a root that cannot be resolved fails the start. With a sandboxed shell
101
+ * the sandbox is probed first, and a profile whose sandbox cannot start never gets a session.
102
+ */
103
+ private confinedTools;
88
104
  private loadSkills;
89
105
  private checkResources;
90
106
  /** Provider, model, authentication and availability, exactly as configured. */
@@ -1,11 +1,15 @@
1
- import { createAgentSession, createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, DefaultResourceLoader, ModelRuntime, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
1
+ import { createAgentSession, createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createBashToolDefinition, DefaultResourceLoader, ModelRuntime, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
2
+ import { homedir, userInfo } from "node:os";
3
+ import { join } from "node:path";
4
+ import { allowedReadRoots, createConfinedReadOnlyTools } from "./confined-tools.js";
2
5
  import { PiExtensionError } from "./extension-spec.js";
3
6
  import { classifyMcpStartupError, connectSessionMcpTools, } from "./mcp-session-tools.js";
4
7
  import { createStdioMcpConnection } from "./mcp-stdio-client.js";
5
8
  import { PiSessionCreationError, } from "./pi-adapter.js";
6
9
  import { resolvePiExtension } from "./pi-extensions.js";
7
10
  import { loadSelectedSkills, PiSkillError } from "./pi-skills.js";
8
- import { composeSessionTools, PROFILE_TOOLS } from "./session-tools.js";
11
+ import { createSandboxedBashOperations, probeSandbox } from "./sandbox-shell.js";
12
+ import { composeSessionTools, profileTools } from "./session-tools.js";
9
13
  export { PROFILE_TOOLS } from "./session-tools.js";
10
14
  export const AUTH_OPERATION_TIMEOUT_MS = 5_000;
11
15
  /** Server-side bound for SDK session construction. The SDK does not document cancellation. */
@@ -326,6 +330,8 @@ export class SdkPiSessionAdapter {
326
330
  mcpConnectionFactory;
327
331
  resolveExtension;
328
332
  extensionSdk;
333
+ sandbox;
334
+ protectedReadPaths;
329
335
  constructor(options = {}) {
330
336
  this.modelRuntimeFactory = options.modelRuntimeFactory ?? (() => ModelRuntime.create({ refreshOnCreate: false }));
331
337
  this.createSession = options.createSession ?? createAgentSession;
@@ -333,6 +339,15 @@ export class SdkPiSessionAdapter {
333
339
  this.mcpConnectionFactory = options.mcpConnectionFactory ?? createStdioMcpConnection;
334
340
  this.resolveExtension = options.resolveExtension ?? ((extension) => resolvePiExtension(extension));
335
341
  this.extensionSdk = options.extensionSdk ?? DEFAULT_EXTENSION_SDK;
342
+ this.sandbox = options.sandbox ?? {};
343
+ // The account database's home, not `$HOME`, which a caller's environment could point elsewhere.
344
+ const home = (() => { try {
345
+ return userInfo().homedir;
346
+ }
347
+ catch {
348
+ return homedir();
349
+ } })();
350
+ this.protectedReadPaths = [home, join(home, ".pi"), ...(options.protectedReadPaths ?? [])];
336
351
  }
337
352
  async create(input) {
338
353
  const extensions = input.executionProfile.extensions ?? [];
@@ -356,8 +371,10 @@ export class SdkPiSessionAdapter {
356
371
  catch {
357
372
  throw this.failure(executionProfile, "resource_loader", "pi_session_creation_failed");
358
373
  }
374
+ const confined = await this.confinedTools(input, skills);
359
375
  const mcpTools = await this.connectMcpTools(input);
360
- const tools = [...PROFILE_TOOLS[executionProfile.permissionProfile], ...mcpTools.tools.map((tool) => tool.name)];
376
+ const tools = [...profileTools(executionProfile.permissionProfile, executionProfile.shell), ...mcpTools.tools.map((tool) => tool.name)];
377
+ const customTools = [...confined, ...mcpTools.tools];
361
378
  let session;
362
379
  try {
363
380
  ({ session } = await withCreationDeadline(() => this.createSession({
@@ -370,14 +387,14 @@ export class SdkPiSessionAdapter {
370
387
  resourceLoader,
371
388
  // With an explicit tools list, Pi also needs custom tool names here to activate them.
372
389
  tools,
373
- ...(mcpTools.tools.length === 0 ? {} : { customTools: mcpTools.tools }),
390
+ ...(customTools.length === 0 ? {} : { customTools }),
374
391
  }), (late) => disposeSdkSession(late.session)));
375
392
  }
376
393
  catch {
377
394
  await mcpTools.close();
378
395
  throw this.failure(executionProfile, "create_session", "pi_session_creation_failed");
379
396
  }
380
- return this.admit(session, executionProfile, tools, mcpTools, () => disposeSdkSession(session), mcpTools.tools.length > 0 ? "external_mcp_activation_mismatch" : undefined, "exact", skills);
397
+ return this.admit(session, executionProfile, tools, mcpTools, () => disposeSdkSession(session), mcpTools.tools.length > 0 ? "external_mcp_activation_mismatch" : undefined, "exact", skills, [], confined);
381
398
  }
382
399
  async createWithExtensions(input, extensions) {
383
400
  const { cwd, executionProfile } = input;
@@ -423,9 +440,11 @@ export class SdkPiSessionAdapter {
423
440
  throw this.failure(executionProfile, "extension_load", "extension_load_failed");
424
441
  }
425
442
  const model = await this.resolveVerifiedModel(services.modelRuntime, executionProfile);
443
+ const confined = await this.confinedTools(input, skills);
426
444
  const mcpTools = await this.connectMcpTools(input);
445
+ const customTools = [...confined, ...mcpTools.tools];
427
446
  const grantedTools = executionProfile.grantedExtensionTools ?? [];
428
- const composed = composeSessionTools(executionProfile.permissionProfile, mcpTools.tools.map((tool) => tool.name), extensionTools, grantedTools);
447
+ const composed = composeSessionTools(executionProfile.permissionProfile, mcpTools.tools.map((tool) => tool.name), extensionTools, grantedTools, executionProfile.shell);
429
448
  if (!composed.ok) {
430
449
  await mcpTools.close();
431
450
  throw this.failure(executionProfile, "extension_load", composed.code);
@@ -440,7 +459,7 @@ export class SdkPiSessionAdapter {
440
459
  model,
441
460
  thinkingLevel: executionProfile.thinkingLevel,
442
461
  tools,
443
- ...(mcpTools.tools.length === 0 ? {} : { customTools: mcpTools.tools }),
462
+ ...(customTools.length === 0 ? {} : { customTools }),
444
463
  })),
445
464
  services,
446
465
  diagnostics: services.diagnostics,
@@ -474,7 +493,7 @@ export class SdkPiSessionAdapter {
474
493
  // A `session_start` handler may register a tool that replaces a built-in or granted tool.
475
494
  // Tools an extension registers later, during a turn, are not re-checked: allowlisted
476
495
  // extensions are trusted code.
477
- const recheck = composeSessionTools(executionProfile.permissionProfile, mcpTools.tools.map((tool) => tool.name), registeredExtensionTools(services), grantedTools);
496
+ const recheck = composeSessionTools(executionProfile.permissionProfile, mcpTools.tools.map((tool) => tool.name), registeredExtensionTools(services), grantedTools, executionProfile.shell);
478
497
  if (!recheck.ok)
479
498
  throw this.failure(executionProfile, "extension_bind", recheck.code);
480
499
  if (recheck.tools.length !== tools.length || recheck.tools.some((name, index) => name !== tools[index]))
@@ -490,7 +509,35 @@ export class SdkPiSessionAdapter {
490
509
  await cleanUp();
491
510
  throw this.failure(executionProfile, "extension_bind", "extension_tool_conflict");
492
511
  }
493
- return this.admit(sessionRuntime.session, executionProfile, tools, mcpTools, disposeRuntime, mcpTools.tools.length > 0 ? "external_mcp_activation_mismatch" : "execution_selection_mismatch", "subset", skills, grantedTools);
512
+ return this.admit(sessionRuntime.session, executionProfile, tools, mcpTools, disposeRuntime, mcpTools.tools.length > 0 ? "external_mcp_activation_mismatch" : "execution_selection_mismatch", "subset", skills, grantedTools, confined);
513
+ }
514
+ /**
515
+ * The server's own tool definitions of a read-only session (ADR 0013); none for coding. The workspace
516
+ * root is the session directory: a root that cannot be resolved fails the start. With a sandboxed shell
517
+ * the sandbox is probed first, and a profile whose sandbox cannot start never gets a session.
518
+ */
519
+ async confinedTools(input, skills) {
520
+ const { executionProfile } = input;
521
+ if (executionProfile.permissionProfile !== "read-only")
522
+ return [];
523
+ let roots;
524
+ try {
525
+ roots = await allowedReadRoots(input.cwd, skills, this.protectedReadPaths);
526
+ }
527
+ catch {
528
+ throw this.failure(executionProfile, "create_session", "pi_session_creation_failed");
529
+ }
530
+ const tools = createConfinedReadOnlyTools(input.cwd, roots);
531
+ if (executionProfile.shell === "sandboxed") {
532
+ // The first root is the real path of the workspace.
533
+ if (!(await probeSandbox(roots[0], this.sandbox, input.signal)))
534
+ throw this.failure(executionProfile, "sandbox", "sandbox_unavailable");
535
+ tools.push(createBashToolDefinition(input.cwd, {
536
+ operations: createSandboxedBashOperations(roots[0], this.sandbox),
537
+ exposeSessionEnvironment: false,
538
+ }));
539
+ }
540
+ return tools;
494
541
  }
495
542
  async loadSkills(input) {
496
543
  try {
@@ -581,9 +628,16 @@ export class SdkPiSessionAdapter {
581
628
  /** Post-verifies a created session and wraps it; a mismatch disposes it before reporting. */
582
629
  async admit(session, executionProfile, tools, mcpTools, disposeSession, toolMismatch,
583
630
  // `subset` lets an extension narrow its own active tools; nothing outside the allowlist may be active.
584
- toolCheck = "exact", skills = [], requiredTools = []) {
631
+ toolCheck = "exact", skills = [], requiredTools = [],
632
+ // The server's own definitions must be the ones the session resolves for their names.
633
+ ownDefinitions = []) {
585
634
  try {
586
635
  this.checkResources(session.resourceLoader, skills, executionProfile, "postcheck");
636
+ for (const definition of ownDefinitions) {
637
+ if (session.getToolDefinition(definition.name) !== definition) {
638
+ throw this.failure(executionProfile, "postcheck", "pi_session_creation_failed");
639
+ }
640
+ }
587
641
  if (session.model?.provider !== executionProfile.provider || session.model.id !== executionProfile.model) {
588
642
  throw this.failure(executionProfile, "postcheck", "execution_selection_mismatch");
589
643
  }
@@ -1,5 +1,7 @@
1
- import type { PermissionProfile } from "./execution-profile.js";
1
+ import type { PermissionProfile, ShellMode } from "./execution-profile.js";
2
2
  export declare const PROFILE_TOOLS: Readonly<Record<PermissionProfile, readonly string[]>>;
3
+ /** The built-in tools of a profile: a read-only profile with a sandboxed shell also has `bash`. */
4
+ export declare function profileTools(permissionProfile: PermissionProfile, shell?: ShellMode): readonly string[];
3
5
  export declare const MAX_GRANTED_EXTENSION_TOOLS = 32;
4
6
  export declare function isAcceptableExtensionToolName(name: string): boolean;
5
7
  export type ComposedSessionTools = {
@@ -15,4 +17,4 @@ export type ComposedSessionTools = {
15
17
  * in both profiles so an extension can never shadow a built-in or granted MCP tool.
16
18
  * Unsafe names and names the SDK treats as MCP tools also fail closed.
17
19
  */
18
- export declare function composeSessionTools(permissionProfile: PermissionProfile, mcpToolNames: readonly string[], extensionToolNames: readonly (readonly string[])[], grantedExtensionTools?: readonly string[]): ComposedSessionTools;
20
+ export declare function composeSessionTools(permissionProfile: PermissionProfile, mcpToolNames: readonly string[], extensionToolNames: readonly (readonly string[])[], grantedExtensionTools?: readonly string[], shell?: ShellMode): ComposedSessionTools;
@@ -2,6 +2,10 @@ export const PROFILE_TOOLS = {
2
2
  "read-only": ["read", "grep", "find", "ls"],
3
3
  coding: ["read", "bash", "edit", "write", "grep", "find", "ls"],
4
4
  };
5
+ /** The built-in tools of a profile: a read-only profile with a sandboxed shell also has `bash`. */
6
+ export function profileTools(permissionProfile, shell) {
7
+ return permissionProfile === "read-only" && shell === "sandboxed" ? [...PROFILE_TOOLS["read-only"], "bash"] : PROFILE_TOOLS[permissionProfile];
8
+ }
5
9
  export const MAX_GRANTED_EXTENSION_TOOLS = 32;
6
10
  /**
7
11
  * Letters, digits, `_` and `-`, at most 64 characters, which fits the common provider
@@ -27,7 +31,7 @@ export function isAcceptableExtensionToolName(name) {
27
31
  * in both profiles so an extension can never shadow a built-in or granted MCP tool.
28
32
  * Unsafe names and names the SDK treats as MCP tools also fail closed.
29
33
  */
30
- export function composeSessionTools(permissionProfile, mcpToolNames, extensionToolNames, grantedExtensionTools = []) {
34
+ export function composeSessionTools(permissionProfile, mcpToolNames, extensionToolNames, grantedExtensionTools = [], shell) {
31
35
  const reserved = new Set([...PROFILE_TOOLS.coding, ...PROFILE_TOOLS["read-only"], ...mcpToolNames]);
32
36
  const extensionTools = [];
33
37
  for (const names of extensionToolNames) {
@@ -41,5 +45,5 @@ export function composeSessionTools(permissionProfile, mcpToolNames, extensionTo
41
45
  if (permissionProfile === "read-only" && grantedExtensionTools.some((name) => !extensionTools.includes(name))) {
42
46
  return { ok: false, code: "extension_tool_missing" };
43
47
  }
44
- return { ok: true, tools: [...PROFILE_TOOLS[permissionProfile], ...mcpToolNames, ...(permissionProfile === "coding" ? extensionTools : grantedExtensionTools)] };
48
+ return { ok: true, tools: [...profileTools(permissionProfile, shell), ...mcpToolNames, ...(permissionProfile === "coding" ? extensionTools : grantedExtensionTools)] };
45
49
  }