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
package/dist/doctor.js CHANGED
@@ -3,11 +3,12 @@ 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";
9
10
  import { LEASE_DIRECTORY } from "./writer-lease.js";
10
- import { loadConfig } from "./config.js";
11
+ import { ConfigError, loadConfig } from "./config.js";
11
12
  import { containerRuntimeUsable } from "./container-runtime.js";
12
13
  import { PiExtensionError } from "./extension-spec.js";
13
14
  import { readPackageMetadata } from "./package-metadata.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);
@@ -420,8 +423,13 @@ export async function runDoctor(dependencies = {}) {
420
423
  checks.push(failure("config_missing", "configuration", "create PI_SESSION_MCP_CONFIG configuration"));
421
424
  else if (isUnreadable(error))
422
425
  checks.push(failure("config_unreadable", "configuration", "grant configuration read access"));
423
- else
424
- checks.push(failure("config_invalid", "configuration", "fix configuration using the documented schema"));
426
+ else {
427
+ // Locations name the offending field; an error without one, or at the root, keeps the plain subject.
428
+ const locations = error instanceof ConfigError ? error.locations : [];
429
+ for (const location of locations.length ? locations : [""]) {
430
+ checks.push(failure("config_invalid", location ? `configuration:${location}` : "configuration", "fix configuration using the documented schema"));
431
+ }
432
+ }
425
433
  }
426
434
  if (config) {
427
435
  for (const [alias, path] of [...config.workspaces].sort(([a], [b]) => a.localeCompare(b))) {
@@ -627,6 +635,46 @@ export async function runDoctor(dependencies = {}) {
627
635
  }
628
636
  }
629
637
  }
638
+ if (config) {
639
+ const sandboxed = [...config.executionProfiles].filter(([, profile]) => profile.shell === "sandboxed").map(([alias]) => alias).sort((a, b) => a.localeCompare(b));
640
+ if (sandboxed.length > 0) {
641
+ // The overlay depends on the workspace's filesystem, so each existing workspace directory is probed.
642
+ for (const [alias, path] of [...config.workspaces].sort(([a], [b]) => a.localeCompare(b))) {
643
+ try {
644
+ if (!(await fs.stat(path)).isDirectory())
645
+ continue;
646
+ }
647
+ catch {
648
+ continue;
649
+ }
650
+ let usable = false;
651
+ try {
652
+ usable = await (dependencies.sandboxProbe ?? defaultSandboxProbe)(path);
653
+ }
654
+ catch {
655
+ usable = false;
656
+ }
657
+ checks.push(usable
658
+ ? ok("sandbox_unavailable", alias)
659
+ : 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"));
660
+ }
661
+ // Without a usable worktree pointer Git commands fail inside the sandbox; the read tools are unaffected.
662
+ for (const [alias, path] of [...config.workspaces].sort(([a], [b]) => a.localeCompare(b))) {
663
+ if (!(await hasGitfilePointer(fs, path)))
664
+ continue;
665
+ let gitDir;
666
+ try {
667
+ gitDir = await (dependencies.worktreeGitDir ?? resolveWorktreeGitDir)(path);
668
+ }
669
+ catch {
670
+ gitDir = undefined;
671
+ }
672
+ checks.push(gitDir === undefined
673
+ ? 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")
674
+ : ok("workspace_git_unavailable", alias));
675
+ }
676
+ }
677
+ }
630
678
  if (config?.eventDelivery === "spool")
631
679
  checks.push(await eventSpoolCheck(dependencies.env ?? process.env, dependencies.eventSpoolRoot ?? defaultRootInfo, dependencies.uid ?? process.getuid?.()));
632
680
  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,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { stdoutGuard } from "./stdout-guard-install.js";
3
- import { isAbsolute } from "node:path";
3
+ import { realpath } from "node:fs/promises";
4
+ import { dirname, isAbsolute, resolve } from "node:path";
4
5
  import { homedir } from "node:os";
5
6
  import { RoutingPiSessionAdapter, WriterRelayAdapter } from "./writer-relay.js";
6
7
  import { serveStdio, StdioServerTransport } from "@modelcontextprotocol/server/stdio";
@@ -47,7 +48,14 @@ async function main() {
47
48
  spool = TurnEventSpool.open(directory, { diagnostic: writeDiagnostic });
48
49
  }
49
50
  const events = spool;
50
- const hostAdapter = new SdkPiSessionAdapter({ diagnosticSink: writeDiagnostic });
51
+ // The configuration directory must never become readable through a skill directory: the directory
52
+ // named by the variable and, for a symlinked configuration file, the directory of its target.
53
+ // loadConfig has read the file, so the variable is set and the path exists.
54
+ const configFile = resolve(process.env.PI_SESSION_MCP_CONFIG);
55
+ const hostAdapter = new SdkPiSessionAdapter({
56
+ diagnosticSink: writeDiagnostic,
57
+ protectedReadPaths: [dirname(configFile), dirname(await realpath(configFile))],
58
+ });
51
59
  const relay = guardPath === undefined;
52
60
  const adapter = relay ? new RoutingPiSessionAdapter(hostAdapter, new WriterRelayAdapter({
53
61
  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 {
@@ -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. */