pi-session-mcp 0.12.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/.pi-session-mcp-cli/client-adapters/mcp-verifier.js +2 -1
  2. package/.pi-session-mcp-cli/config.js +15 -1
  3. package/.pi-session-mcp-cli/doctor.js +44 -1
  4. package/.pi-session-mcp-cli/execution-modes.js +10 -2
  5. package/.pi-session-mcp-cli/execution-profile.js +1 -0
  6. package/.pi-session-mcp-cli/sandbox-shell.js +254 -0
  7. package/.pi-session-mcp-cli/session-tools.js +6 -2
  8. package/.pi-session-mcp-cli/writer-boundary.js +27 -0
  9. package/.pi-session-mcp-cli/writer-lifecycle.js +6 -4
  10. package/.pi-session-mcp-cli/writer-limits.js +19 -0
  11. package/.pi-session-mcp-cli/writer-state.js +5 -2
  12. package/CHANGELOG.md +74 -0
  13. package/README.md +2 -1
  14. package/dist/capabilities.d.ts +1 -0
  15. package/dist/capabilities.js +3 -1
  16. package/dist/client-adapters/mcp-verifier.js +2 -1
  17. package/dist/config.d.ts +1 -0
  18. package/dist/config.js +15 -1
  19. package/dist/confined-tools.d.ts +29 -0
  20. package/dist/confined-tools.js +118 -0
  21. package/dist/doctor.d.ts +5 -1
  22. package/dist/doctor.js +44 -1
  23. package/dist/execution-modes.d.ts +13 -1
  24. package/dist/execution-modes.js +10 -2
  25. package/dist/execution-profile.d.ts +4 -0
  26. package/dist/execution-profile.js +1 -0
  27. package/dist/main.js +6 -2
  28. package/dist/pi-adapter.d.ts +1 -1
  29. package/dist/pi-adapter.js +1 -0
  30. package/dist/sandbox-shell.d.ts +71 -0
  31. package/dist/sandbox-shell.js +254 -0
  32. package/dist/sdk-pi-adapter.d.ts +17 -1
  33. package/dist/sdk-pi-adapter.js +64 -10
  34. package/dist/session-tools.d.ts +4 -2
  35. package/dist/session-tools.js +6 -2
  36. package/dist/setup-cli.js +40 -6
  37. package/dist/setup-modes.d.ts +3 -0
  38. package/dist/setup-modes.js +18 -1
  39. package/dist/setup-writers.js +33 -3
  40. package/dist/tool-schemas.d.ts +4 -0
  41. package/dist/tool-schemas.js +1 -0
  42. package/dist/writer-boundary.d.ts +2 -0
  43. package/dist/writer-boundary.js +27 -0
  44. package/dist/writer-lifecycle.d.ts +1 -0
  45. package/dist/writer-lifecycle.js +6 -4
  46. package/dist/writer-limits.d.ts +13 -0
  47. package/dist/writer-limits.js +19 -0
  48. package/dist/writer-relay.js +1 -1
  49. package/dist/writer-state.d.ts +11 -0
  50. package/dist/writer-state.js +5 -2
  51. package/docs/acceptance.md +141 -0
  52. package/docs/adr/0013-hardened-read-only-profiles.md +182 -0
  53. package/docs/doctor-design.md +2 -0
  54. package/docs/execution-modes.md +59 -3
  55. package/docs/installation.md +25 -1
  56. package/docs/isolation-setup.md +31 -2
  57. package/docs/roadmap.md +6 -0
  58. package/docs/session-mcp.md +13 -0
  59. package/docs/threat-model.md +50 -0
  60. package/docs/tool-contracts.md +30 -3
  61. package/package.json +1 -1
  62. package/skills/pi-session-orchestration/references/start-errors.md +1 -1
@@ -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
  }
@@ -146,13 +159,14 @@ export async function loadConfig(path = process.env.PI_SESSION_MCP_CONFIG) {
146
159
  const overrideDirectory = skillOverrideDirectory(absoluteConfig);
147
160
  const workspaces = new ImmutableMap(Object.entries(parsed.workspaces).map(([key, value]) => [key, resolve(base, value)]));
148
161
  const catalog = parsed.skillCatalog ?? {};
149
- const executionProfiles = new ImmutableMap(Object.entries(parsed.executionProfiles).map(([key, { mcpServers, extensions, grantedExtensionTools, skills, ...value }]) => {
162
+ const executionProfiles = new ImmutableMap(Object.entries(parsed.executionProfiles).map(([key, { mcpServers, extensions, grantedExtensionTools, shell, skills, ...value }]) => {
150
163
  const selection = resolveSkillSelection(skills, catalog, base, overrideDirectory);
151
164
  return [key, Object.freeze({
152
165
  alias: key, ...value,
153
166
  ...(mcpServers === undefined ? {} : { mcpServers: snapshotMcpServers(mcpServers) }),
154
167
  ...(extensions === undefined ? {} : { extensions: Object.freeze(extensions.map((spec) => parseExtensionSpec(spec))) }),
155
168
  ...(grantedExtensionTools === undefined ? {} : { grantedExtensionTools: Object.freeze([...grantedExtensionTools]) }),
169
+ ...(shell === undefined ? {} : { shell }),
156
170
  ...(selection === undefined ? {} : { skills: selection }),
157
171
  })];
158
172
  }));
@@ -3,6 +3,7 @@ import { constants } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { homedir } from "node:os";
5
5
  import { createProcessRunner } from "./setup-process.js";
6
+ import { probeSandbox, resolveWorktreeGitDir } from "./sandbox-shell.js";
6
7
  import { podmanInfo, isSafeRefName } from "./writer-boundary.js";
7
8
  import { containedBuiltinSkills, containedProfileRefused } from "./writer-lifecycle.js";
8
9
  import { readWriterState, writerAcceptanceCurrent, writerName, writerPaths } from "./writer-state.js";
@@ -32,7 +33,7 @@ async function readPrefix(path, maxBytes) {
32
33
  }
33
34
  }
34
35
  const defaultFileSystem = { readFile, stat, access };
35
- const CHECK_ORDER = ["node_version_unsupported", "package_metadata_unreadable", "build_missing", "build_stale", "version_mismatch", "config_missing", "config_unreadable", "config_invalid", "workspace_missing", "workspace_not_directory", "workspace_unreadable", "workspace_not_writable", "workspace_gitfile", "workspace_mode_unset", "workspace_base_unset", "workspace_host_writes_accepted", "container_runtime_unavailable", "container_profile_refused", "writer_lease_directory", "writer_not_ready", "writer_not_migrated", "writer_off_default_base", "writer_dirty", "writer_git_identity_missing", "writer_uncollected_bundle", "extension_missing", "extension_version_mismatch", "extension_unpinned", "skill_missing", "skill_unreadable", "skill_duplicate", "skill_override_outdated", "event_spool_root"];
36
+ const CHECK_ORDER = ["node_version_unsupported", "package_metadata_unreadable", "build_missing", "build_stale", "version_mismatch", "config_missing", "config_unreadable", "config_invalid", "workspace_missing", "workspace_not_directory", "workspace_unreadable", "workspace_not_writable", "workspace_gitfile", "workspace_mode_unset", "workspace_base_unset", "workspace_host_writes_accepted", "container_runtime_unavailable", "container_profile_refused", "writer_lease_directory", "writer_not_ready", "writer_not_migrated", "writer_off_default_base", "writer_dirty", "writer_git_identity_missing", "writer_uncollected_bundle", "extension_missing", "extension_version_mismatch", "extension_unpinned", "skill_missing", "skill_unreadable", "skill_duplicate", "skill_override_outdated", "sandbox_unavailable", "workspace_git_unavailable", "event_spool_root"];
36
37
  function failure(id, subject, remediation) { return Object.freeze({ id, severity: "error", subject, remediation }); }
37
38
  function warning(id, subject, remediation) { return Object.freeze({ id, severity: "warning", subject, remediation }); }
38
39
  function ok(id, subject) { return Object.freeze({ id, severity: "ok", subject }); }
@@ -148,6 +149,8 @@ async function hasGitfilePointer(fs, workspace) {
148
149
  return false;
149
150
  }
150
151
  }
152
+ /** The trusted bubblewrap binary plus a probe run through the session's fixed arguments for the workspace, its worktree Git directory included. */
153
+ const defaultSandboxProbe = (workspace) => probeSandbox(workspace);
151
154
  async function defaultRootInfo(path) {
152
155
  try {
153
156
  const info = await lstat(path);
@@ -627,6 +630,46 @@ export async function runDoctor(dependencies = {}) {
627
630
  }
628
631
  }
629
632
  }
633
+ if (config) {
634
+ const sandboxed = [...config.executionProfiles].filter(([, profile]) => profile.shell === "sandboxed").map(([alias]) => alias).sort((a, b) => a.localeCompare(b));
635
+ if (sandboxed.length > 0) {
636
+ // The overlay depends on the workspace's filesystem, so each existing workspace directory is probed.
637
+ for (const [alias, path] of [...config.workspaces].sort(([a], [b]) => a.localeCompare(b))) {
638
+ try {
639
+ if (!(await fs.stat(path)).isDirectory())
640
+ continue;
641
+ }
642
+ catch {
643
+ continue;
644
+ }
645
+ let usable = false;
646
+ try {
647
+ usable = await (dependencies.sandboxProbe ?? defaultSandboxProbe)(path);
648
+ }
649
+ catch {
650
+ usable = false;
651
+ }
652
+ checks.push(usable
653
+ ? ok("sandbox_unavailable", alias)
654
+ : failure("sandbox_unavailable", alias, "install a bubblewrap with --overlay-src, --tmp-overlay and --disable-userns support at /usr/bin/bwrap (root-owned, not group- or world-writable) and allow unprivileged user namespaces, and keep the workspace on a filesystem that can be an overlay lower layer; see docs/installation.md"));
655
+ }
656
+ // Without a usable worktree pointer Git commands fail inside the sandbox; the read tools are unaffected.
657
+ for (const [alias, path] of [...config.workspaces].sort(([a], [b]) => a.localeCompare(b))) {
658
+ if (!(await hasGitfilePointer(fs, path)))
659
+ continue;
660
+ let gitDir;
661
+ try {
662
+ gitDir = await (dependencies.worktreeGitDir ?? resolveWorktreeGitDir)(path);
663
+ }
664
+ catch {
665
+ gitDir = undefined;
666
+ }
667
+ checks.push(gitDir === undefined
668
+ ? warning("workspace_git_unavailable", alias, "the workspace's gitfile does not point to a linked worktree whose main Git directory is intact, so Git commands fail inside the shell sandbox")
669
+ : ok("workspace_git_unavailable", alias));
670
+ }
671
+ }
672
+ }
630
673
  if (config?.eventDelivery === "spool")
631
674
  checks.push(await eventSpoolCheck(dependencies.env ?? process.env, dependencies.eventSpoolRoot ?? defaultRootInfo, dependencies.uid ?? process.getuid?.()));
632
675
  const lease = await writerLeaseCheck(home, dependencies.writerLeaseRoot ?? defaultRootInfo, dependencies.uid ?? process.getuid?.());
@@ -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
+ }
@@ -5,6 +5,7 @@ import { lstat, mkdir, open, readFile, rename, unlink } from "node:fs/promises";
5
5
  import { dirname, join } from "node:path";
6
6
  import { z } from "zod";
7
7
  import { PACKAGE_VERSION } from "./package-metadata.js";
8
+ import { writerLimitsSchema } from "./writer-limits.js";
8
9
  import { ALIAS_PATTERN } from "./execution-profile.js";
9
10
  export const WRITER_NAME_PATTERN = /^[a-z][a-z0-9_-]{0,63}-w([1-9]|1[0-6])$/;
10
11
  export class WriterStateError extends Error {
@@ -31,8 +32,10 @@ export const writerStateSchema = z.object({
31
32
  model: z.string().min(1).max(200),
32
33
  }).strict()).refine((providers) => new Set(providers.map(({ provider }) => provider)).size === providers.length).readonly(),
33
34
  network: z.enum(["pasta", "none"]),
34
- cache: z.enum(["none", "go", "uv"]),
35
+ cache: z.enum(["none", "go", "uv", "npm"]),
35
36
  tags: z.enum(["none", "reachable"]).default("none"),
37
+ clean: z.enum(["untracked", "all"]).default("untracked"),
38
+ limits: writerLimitsSchema.optional(),
36
39
  imageId: hex64,
37
40
  base: z.object({ ref: z.string(), commit: z.string().regex(/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/) }).strict(),
38
41
  acceptance: z.object({
@@ -62,7 +65,7 @@ export function writerPaths(home, name) {
62
65
  const root = join(writersRoot(home), name);
63
66
  return {
64
67
  root, clone: join(root, "clone"), agent: join(root, "agent"), bundles: join(root, "bundles"),
65
- config: join(root, "config"), events: join(root, "events"), go: join(root, "go"), uv: join(root, "uv"), wrapper: join(root, "wrapper.sh"), state: join(root, "state.json"),
68
+ config: join(root, "config"), events: join(root, "events"), go: join(root, "go"), uv: join(root, "uv"), npm: join(root, "npm"), wrapper: join(root, "wrapper.sh"), state: join(root, "state.json"),
66
69
  acceptanceFailure: join(root, "acceptance-failure.json"),
67
70
  };
68
71
  }