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
@@ -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;
@@ -20,6 +20,7 @@ const profileSchema = z.object({
20
20
  mcpServers: z.custom((value) => isMcpServers(value)).optional(),
21
21
  extensions: z.array(extensionSpec).max(MAX_PROFILE_EXTENSIONS).optional(),
22
22
  grantedExtensionTools: z.array(z.string().refine(isAcceptableExtensionToolName)).max(MAX_GRANTED_EXTENSION_TOOLS).optional(),
23
+ shell: z.literal("sandboxed").optional(),
23
24
  skills: z.array(alias).max(MAX_PROFILE_SKILLS).optional(),
24
25
  }).strict().superRefine((profile, context) => {
25
26
  if (profile.mcpServers !== undefined && !isMcpServers(profile.mcpServers, profile.permissionProfile)) {
@@ -46,6 +47,18 @@ const profileSchema = z.object({
46
47
  context.addIssue({ code: "custom", path: ["grantedExtensionTools"], message: "Extension tool grants must not name built-in tools" });
47
48
  }
48
49
  }
50
+ if (profile.shell !== undefined) {
51
+ // Granted extension tools may write, so the sandbox's read-only guarantee would not hold (ADR 0013).
52
+ if (profile.permissionProfile !== "read-only") {
53
+ context.addIssue({ code: "custom", path: ["shell"], message: "A sandboxed shell requires a read-only profile" });
54
+ }
55
+ if (profile.grantedExtensionTools !== undefined) {
56
+ context.addIssue({ code: "custom", path: ["shell"], message: "A sandboxed shell cannot be combined with granted extension tools" });
57
+ }
58
+ if (process.platform !== "linux") {
59
+ context.addIssue({ code: "custom", path: ["shell"], message: "A sandboxed shell requires Linux" });
60
+ }
61
+ }
49
62
  if (profile.skills !== undefined && new Set(profile.skills).size !== profile.skills.length) {
50
63
  context.addIssue({ code: "custom", path: ["skills"], message: "A skill alias may appear only once per profile" });
51
64
  }
@@ -59,12 +72,31 @@ const CONFIG_ERROR_MESSAGES = {
59
72
  };
60
73
  export class ConfigError extends Error {
61
74
  code;
62
- constructor(code) {
75
+ /** Dotted schema issue locations for Doctor; built by `issueLocations`, never a value, message or path. */
76
+ locations;
77
+ constructor(code, locations = []) {
63
78
  super(CONFIG_ERROR_MESSAGES[code]);
64
79
  this.name = "ConfigError";
65
80
  this.code = code;
81
+ this.locations = Object.freeze([...locations]);
66
82
  }
67
83
  }
84
+ export const MAX_CONFIG_ISSUE_LOCATIONS = 5;
85
+ /**
86
+ * Sorted, deduplicated and bounded issue locations; an issue at the root has the empty location.
87
+ * A segment is kept only if it is a schema field name, or, inside the path, a key that has the alias
88
+ * form (record keys are aliases, which Doctor already reports). Any other key, and every unrecognized
89
+ * key that is not a schema field name, could be a path or a secret and becomes `<key>`.
90
+ */
91
+ export function issueLocations(issues) {
92
+ const fields = new Set([...Object.keys(configSchema.shape), ...Object.keys(profileSchema.shape), ...Object.keys(eventsSchema.shape)]);
93
+ const segment = (value, aliasAllowed) => typeof value === "number" ? String(value) : typeof value === "string" && (fields.has(value) || (aliasAllowed && ALIAS_PATTERN.test(value))) ? value : "<key>";
94
+ const locations = issues.flatMap((issue) => {
95
+ const path = issue.path.map((value) => segment(value, true));
96
+ return issue.code === "unrecognized_keys" ? issue.keys.map((key) => [...path, segment(key, false)]) : [path];
97
+ });
98
+ return [...new Set(locations.map((path) => path.join(".")))].sort().slice(0, MAX_CONFIG_ISSUE_LOCATIONS);
99
+ }
68
100
  const eventsSchema = z.object({ delivery: z.enum(["spool", "off"]) }).strict();
69
101
  export const configSchema = z.object({
70
102
  events: eventsSchema.optional(),
@@ -138,7 +170,7 @@ export async function loadConfig(path = process.env.PI_SESSION_MCP_CONFIG) {
138
170
  const thinkingLevelIssue = error.issues.some((issue) => issue.path.at(-1) === "thinkingLevel");
139
171
  // Only the events field's own issues map to the delivery code; an unknown top-level key stays a profile-config error.
140
172
  const eventsIssue = error.issues.some((issue) => issue.path[0] === "events");
141
- throw new ConfigError(thinkingLevelIssue ? "invalid_thinking_level" : eventsIssue ? "invalid_event_delivery" : "invalid_execution_profile_config");
173
+ throw new ConfigError(thinkingLevelIssue ? "invalid_thinking_level" : eventsIssue ? "invalid_event_delivery" : "invalid_execution_profile_config", issueLocations(error.issues));
142
174
  }
143
175
  throw error;
144
176
  }
@@ -146,13 +178,14 @@ export async function loadConfig(path = process.env.PI_SESSION_MCP_CONFIG) {
146
178
  const overrideDirectory = skillOverrideDirectory(absoluteConfig);
147
179
  const workspaces = new ImmutableMap(Object.entries(parsed.workspaces).map(([key, value]) => [key, resolve(base, value)]));
148
180
  const catalog = parsed.skillCatalog ?? {};
149
- const executionProfiles = new ImmutableMap(Object.entries(parsed.executionProfiles).map(([key, { mcpServers, extensions, grantedExtensionTools, skills, ...value }]) => {
181
+ const executionProfiles = new ImmutableMap(Object.entries(parsed.executionProfiles).map(([key, { mcpServers, extensions, grantedExtensionTools, shell, skills, ...value }]) => {
150
182
  const selection = resolveSkillSelection(skills, catalog, base, overrideDirectory);
151
183
  return [key, Object.freeze({
152
184
  alias: key, ...value,
153
185
  ...(mcpServers === undefined ? {} : { mcpServers: snapshotMcpServers(mcpServers) }),
154
186
  ...(extensions === undefined ? {} : { extensions: Object.freeze(extensions.map((spec) => parseExtensionSpec(spec))) }),
155
187
  ...(grantedExtensionTools === undefined ? {} : { grantedExtensionTools: Object.freeze([...grantedExtensionTools]) }),
188
+ ...(shell === undefined ? {} : { shell }),
156
189
  ...(selection === undefined ? {} : { skills: selection }),
157
190
  })];
158
191
  }));
@@ -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?.());
@@ -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 {
@@ -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
  }
@@ -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
+ }
@@ -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
  }
@@ -35,6 +35,12 @@ export function serveArgs(paths, state, command, releaseRoot, eventsDir, options
35
35
  "-e", "PI_CODING_AGENT_DIR=/pi/agent",
36
36
  "-e", "PI_SESSION_MCP_WRITER_GUARD=/pi/config/writer-guard.json",
37
37
  ];
38
+ if (state.limits?.cpus !== undefined)
39
+ args.push(`--cpus=${state.limits.cpus}`);
40
+ if (state.limits?.cpuShares !== undefined)
41
+ args.push(`--cpu-shares=${state.limits.cpuShares}`);
42
+ if (state.limits?.memory !== undefined)
43
+ args.push(`--memory=${state.limits.memory}`);
38
44
  if (options?.name !== undefined)
39
45
  args.push("--name", writerContainerName(options.name));
40
46
  if (eventsDir !== undefined)
@@ -43,6 +49,8 @@ export function serveArgs(paths, state, command, releaseRoot, eventsDir, options
43
49
  args.push("-v", `${paths.go}:/pi/go:rw`, "-e", "GOPATH=/pi/go", "-e", "GOCACHE=/pi/go/cache", "-e", "GOTOOLCHAIN=local");
44
50
  if (state.cache === "uv")
45
51
  args.push("-v", `${paths.uv}:/pi/uv:rw`, "-e", "UV_CACHE_DIR=/pi/uv/cache", "-e", "npm_config_cache=/pi/uv/npm");
52
+ if (state.cache === "npm")
53
+ args.push("-v", `${paths.npm}:/pi/npm:rw`, "-e", "npm_config_cache=/pi/npm/cache");
46
54
  return [...args, state.imageId, ...command];
47
55
  }
48
56
  export class EventsDirectoryError extends Error {
@@ -261,6 +269,25 @@ export async function podmanInfo(runner) {
261
269
  return { usable: false };
262
270
  }
263
271
  }
272
+ /** A failed or malformed host probe provides no usable controllers. */
273
+ export async function podmanCgroupControllers(runner) {
274
+ try {
275
+ const output = await podman(runner, ["info", "--format", "json"]);
276
+ if (output.exitCode !== 0)
277
+ return [];
278
+ const info = JSON.parse(output.stdout);
279
+ if (!info || typeof info !== "object" || !("host" in info))
280
+ return [];
281
+ const host = info.host;
282
+ if (!host || typeof host !== "object" || !("cgroupControllers" in host))
283
+ return [];
284
+ const controllers = host.cgroupControllers;
285
+ return Array.isArray(controllers) && controllers.every((value) => typeof value === "string") ? controllers : [];
286
+ }
287
+ catch {
288
+ return [];
289
+ }
290
+ }
264
291
  export async function resolveImage(runner, image, pull) {
265
292
  if (image !== "node" && !/^[0-9a-f]{64}$/.test(image))
266
293
  throw new BoundaryError("image_unavailable");
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { constants } from "node:fs";
3
3
  import { chmod, mkdir, open, readdir, rm, writeFile } from "node:fs/promises";
4
4
  import { basename, join } from "node:path";
5
+ import { writerLimits } from "./writer-limits.js";
5
6
  import { configSchema } from "./config.js";
6
7
  import { renderWrapper } from "./writer-wrapper.js";
7
8
  import { builtinSkillName } from "./skill-catalog.js";
@@ -220,7 +221,7 @@ async function storedConfig(paths) {
220
221
  async function sameSettings(paths, existing, settings, now) {
221
222
  const stored = await storedConfig(paths);
222
223
  return stored !== undefined && existing.imageId === now.imageId && existing.lifecycle === settings.lifecycle
223
- && existing.network === settings.network && existing.cache === settings.cache && existing.tags === settings.tags
224
+ && existing.network === settings.network && existing.cache === settings.cache && existing.tags === settings.tags && existing.clean === settings.clean
224
225
  && JSON.stringify(existing.profiles) === JSON.stringify(settings.profiles)
225
226
  && JSON.stringify(existing.providers) === JSON.stringify(now.providers) && canonical(withoutDeliveryOff(stored)) === canonical(withoutDeliveryOff(now.config));
226
227
  }
@@ -295,12 +296,13 @@ export async function provisionWriter(ctx, input) {
295
296
  // With spool delivery the spool directory exists from provisioning on: launch would otherwise add it to the
296
297
  // writer root, a host directory the acceptance harness digests around its own first launch.
297
298
  const spool = config.events?.delivery === "spool" ? [paths.events] : [];
298
- const cache = settings.cache === "go" ? [paths.go] : settings.cache === "uv" ? [paths.uv] : [];
299
+ const cache = settings.cache === "none" ? [] : [paths[settings.cache]];
299
300
  for (const path of [paths.root, paths.clone, paths.config, paths.agent, paths.bundles, ...spool, ...cache])
300
301
  await privateDirectory(path);
301
302
  const state = {
302
303
  schemaVersion: 1, alias: ctx.alias, index, phase: "provisioning", lifecycle: settings.lifecycle,
303
- profiles: [...settings.profiles], providers, network: settings.network, cache: settings.cache, tags: settings.tags, imageId,
304
+ profiles: [...settings.profiles], providers, network: settings.network, cache: settings.cache, tags: settings.tags, clean: settings.clean, imageId,
305
+ ...(writerLimits(settings) ? { limits: writerLimits(settings) } : {}),
304
306
  base: { ref: `refs/heads/${short}`, commit },
305
307
  };
306
308
  await writeWriterState(ctx.home, name, state);
@@ -511,7 +513,7 @@ ${BOUNDARY_GIT} for-each-ref --format='%(refname)' refs/heads | while read -r re
511
513
  if [ "$ref" != "refs/heads/$2" ]; then ${BOUNDARY_GIT} update-ref -d "$ref"; fi
512
514
  done
513
515
  ${BOUNDARY_GIT} stash clear
514
- ${BOUNDARY_GIT} clean -fd
516
+ ${BOUNDARY_GIT} clean ${state.clean === "all" ? "-ffdx" : "-fd"}
515
517
  ${identityScript(4, 5)}`;
516
518
  const result = await ctx.runner.run("podman", throwawayArgs(name, paths, state.imageId, { clone: "rw", bundles: true }, script, [base.ref, base.short, base.commit, ...await operatorIdentity(ctx)]), AbortSignal.timeout(IMAGE_PULL_TIMEOUT_MS));
517
519
  if (result.exitCode !== 0)
@@ -0,0 +1,19 @@
1
+ import { z } from "zod";
2
+ export const cpusSchema = z.number().positive().max(1024).multipleOf(0.01);
3
+ export const cpuSharesSchema = z.number().int().min(2).max(262144);
4
+ export const memorySchema = z.string().regex(/^[1-9][0-9]{0,6}[mg]$/);
5
+ export const writerLimitsSchema = z.object({
6
+ cpus: cpusSchema.optional(),
7
+ cpuShares: cpuSharesSchema.optional(),
8
+ memory: memorySchema.optional(),
9
+ }).strict();
10
+ /** Omitted settings remove previous limits; never persist an empty limits object. */
11
+ export function writerLimits(settings) {
12
+ const { cpus, cpuShares, memory } = settings;
13
+ if (cpus === undefined && cpuShares === undefined && memory === undefined)
14
+ return undefined;
15
+ return { ...(cpus === undefined ? {} : { cpus }), ...(cpuShares === undefined ? {} : { cpuShares }), ...(memory === undefined ? {} : { memory }) };
16
+ }
17
+ export function sameWriterLimits(left, right) {
18
+ return left?.cpus === right?.cpus && left?.cpuShares === right?.cpuShares && left?.memory === right?.memory;
19
+ }