@superjackfruitlabs/salaam 0.0.3 → 0.0.5

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 (172) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +12 -11
  3. package/dist/src/acp-desk.js +140 -0
  4. package/dist/src/acp-outcome.js +182 -18
  5. package/dist/src/acp.js +296 -15
  6. package/dist/src/agent-profiles.js +45 -0
  7. package/dist/src/agentpod-authority.js +169 -11
  8. package/dist/src/agentpod-default-config.js +12 -0
  9. package/dist/src/agentpod-work-decision-protocol.js +40 -6
  10. package/dist/src/agentpod-work-decisions.js +108 -4
  11. package/dist/src/agentpod-work.js +1214 -127
  12. package/dist/src/approval-rules.js +287 -0
  13. package/dist/src/attach.js +3 -1
  14. package/dist/src/autonomy.js +80 -9
  15. package/dist/src/board-reads.js +106 -0
  16. package/dist/src/children.js +83 -0
  17. package/dist/src/cli-help.js +61 -6
  18. package/dist/src/cli.js +140 -4
  19. package/dist/src/command-danger.js +797 -0
  20. package/dist/src/command-network.js +200 -0
  21. package/dist/src/command-sandbox.js +327 -0
  22. package/dist/src/custom-commands.js +159 -0
  23. package/dist/src/danger-classifier.js +286 -0
  24. package/dist/src/execution-evidence.js +33 -1
  25. package/dist/src/git-helpers.js +160 -0
  26. package/dist/src/hooks.js +351 -0
  27. package/dist/src/host-bind.js +111 -0
  28. package/dist/src/host-breaker.js +88 -0
  29. package/dist/src/host-start.js +41 -1
  30. package/dist/src/host.js +284 -6
  31. package/dist/src/house-extension.js +292 -61
  32. package/dist/src/local-coding.js +726 -35
  33. package/dist/src/local-edit-helper.js +25 -0
  34. package/dist/src/local-edits.js +168 -6
  35. package/dist/src/local-undo.js +25 -16
  36. package/dist/src/managed.js +1 -1
  37. package/dist/src/mentions.js +277 -0
  38. package/dist/src/operator-mcp-cli.js +107 -0
  39. package/dist/src/operator-mcp.js +342 -0
  40. package/dist/src/persona.js +31 -0
  41. package/dist/src/pi-sessions.js +5 -1
  42. package/dist/src/pi.js +22 -1
  43. package/dist/src/project-instructions.js +195 -0
  44. package/dist/src/protocol.js +9 -3
  45. package/dist/src/rate-limit-retries.js +62 -0
  46. package/dist/src/recurring-work.js +1 -0
  47. package/dist/src/repository-snapshots.js +5 -2
  48. package/dist/src/run-grants.js +62 -0
  49. package/dist/src/service.js +482 -10
  50. package/dist/src/session-pick.js +77 -0
  51. package/dist/src/shell-passthrough.js +39 -0
  52. package/dist/src/station-checkpoints.js +139 -0
  53. package/dist/src/station-config.js +8 -1
  54. package/dist/src/station-files.js +20 -1
  55. package/dist/src/station-task-verification.js +6 -3
  56. package/dist/src/station-work.js +315 -22
  57. package/dist/src/store.js +70 -4
  58. package/dist/src/supervisor.js +408 -25
  59. package/dist/src/task-budget.js +39 -3
  60. package/dist/src/task-context.js +8 -2
  61. package/dist/src/task-critic.js +115 -0
  62. package/dist/src/task-driver.js +46 -2
  63. package/dist/src/task-policy.js +44 -12
  64. package/dist/src/task-verification.js +42 -9
  65. package/dist/src/task-work.js +47 -0
  66. package/dist/src/terminal-status.js +88 -0
  67. package/dist/src/tool-identity.js +69 -0
  68. package/dist/src/tui-activity.js +99 -0
  69. package/dist/src/tui-classifier.js +25 -0
  70. package/dist/src/tui-client.js +186 -5
  71. package/dist/src/tui-commands.js +23 -5
  72. package/dist/src/tui-composer.js +113 -0
  73. package/dist/src/tui-keymap.js +116 -0
  74. package/dist/src/tui-mentions.js +38 -0
  75. package/dist/src/tui-modals.js +157 -34
  76. package/dist/src/tui-notify.js +62 -0
  77. package/dist/src/tui-review.js +55 -0
  78. package/dist/src/tui-turn-keys.js +52 -0
  79. package/dist/src/tui-view.js +243 -69
  80. package/dist/src/tui.js +875 -118
  81. package/dist/src/turn-checkpoints.js +221 -0
  82. package/dist/src/turn-rewind.js +168 -0
  83. package/dist/src/usage.js +1 -1
  84. package/dist/src/web-fetch.js +114 -0
  85. package/dist/src/web-guard.js +163 -0
  86. package/dist/src/web-search.js +302 -0
  87. package/npm-shrinkwrap.json +2 -2
  88. package/package.json +1 -1
  89. package/src/acp-desk.ts +155 -0
  90. package/src/acp-outcome.ts +174 -20
  91. package/src/acp.ts +386 -30
  92. package/src/agent-profiles.ts +48 -0
  93. package/src/agentpod-authority.ts +234 -15
  94. package/src/agentpod-default-config.ts +16 -0
  95. package/src/agentpod-work-decision-protocol.ts +69 -8
  96. package/src/agentpod-work-decisions.ts +123 -5
  97. package/src/agentpod-work.ts +1455 -152
  98. package/src/approval-rules.ts +360 -0
  99. package/src/attach.ts +4 -1
  100. package/src/autonomy.ts +111 -10
  101. package/src/board-reads.ts +148 -0
  102. package/src/children.ts +112 -0
  103. package/src/cli-help.ts +60 -6
  104. package/src/cli.ts +174 -4
  105. package/src/command-danger.ts +886 -0
  106. package/src/command-network.ts +215 -0
  107. package/src/command-sandbox.ts +393 -0
  108. package/src/custom-commands.ts +175 -0
  109. package/src/danger-classifier.ts +321 -0
  110. package/src/execution-evidence.ts +31 -1
  111. package/src/git-helpers.ts +177 -0
  112. package/src/hooks.ts +425 -0
  113. package/src/host-bind.ts +156 -0
  114. package/src/host-breaker.ts +89 -0
  115. package/src/host-start.ts +51 -1
  116. package/src/host.ts +365 -6
  117. package/src/house-extension.ts +313 -60
  118. package/src/local-coding.ts +894 -43
  119. package/src/local-edit-helper.ts +26 -0
  120. package/src/local-edits.ts +191 -8
  121. package/src/local-undo.ts +29 -14
  122. package/src/managed.ts +2 -2
  123. package/src/mentions.ts +304 -0
  124. package/src/operator-mcp-cli.ts +108 -0
  125. package/src/operator-mcp.ts +420 -0
  126. package/src/persona.ts +37 -0
  127. package/src/pi-sessions.ts +9 -1
  128. package/src/pi.ts +23 -1
  129. package/src/project-instructions.ts +229 -0
  130. package/src/protocol.ts +25 -3
  131. package/src/rate-limit-retries.ts +69 -0
  132. package/src/recurring-work.ts +1 -0
  133. package/src/repository-snapshots.ts +5 -2
  134. package/src/run-grants.ts +107 -0
  135. package/src/service.ts +709 -10
  136. package/src/session-pick.ts +102 -0
  137. package/src/shell-passthrough.ts +38 -0
  138. package/src/station-checkpoints.ts +183 -0
  139. package/src/station-config.ts +10 -1
  140. package/src/station-files.ts +20 -1
  141. package/src/station-task-verification.ts +6 -3
  142. package/src/station-work.ts +369 -20
  143. package/src/store.ts +70 -3
  144. package/src/supervisor.ts +463 -26
  145. package/src/task-budget.ts +48 -3
  146. package/src/task-context.ts +9 -2
  147. package/src/task-critic.ts +171 -0
  148. package/src/task-driver.ts +49 -2
  149. package/src/task-policy.ts +65 -13
  150. package/src/task-verification.ts +53 -9
  151. package/src/task-work.ts +75 -0
  152. package/src/terminal-status.ts +103 -0
  153. package/src/tool-identity.ts +110 -0
  154. package/src/tui-activity.ts +133 -0
  155. package/src/tui-classifier.ts +38 -0
  156. package/src/tui-client.ts +239 -8
  157. package/src/tui-commands.ts +49 -10
  158. package/src/tui-composer.ts +124 -0
  159. package/src/tui-keymap.ts +152 -0
  160. package/src/tui-mentions.ts +43 -0
  161. package/src/tui-modals.ts +173 -43
  162. package/src/tui-notify.ts +77 -0
  163. package/src/tui-review.ts +76 -0
  164. package/src/tui-turn-keys.ts +77 -0
  165. package/src/tui-view.ts +341 -88
  166. package/src/tui.ts +1047 -137
  167. package/src/turn-checkpoints.ts +253 -0
  168. package/src/turn-rewind.ts +258 -0
  169. package/src/usage.ts +1 -1
  170. package/src/web-fetch.ts +140 -0
  171. package/src/web-guard.ts +195 -0
  172. package/src/web-search.ts +402 -0
@@ -0,0 +1,215 @@
1
+ import { createServer, connect, type Server, type Socket } from 'node:net';
2
+ import { chmodSync, mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ /**
7
+ * Network for local commands through the broker allowlist (operator rulings 2026-10-30). A command
8
+ * has no network of its own; its only route out is stk's `local-commands` socket, where every
9
+ * connection is the `local-commands` identity and only `servers.local-commands.egress.hosts`
10
+ * decides. Salaam puts a relay of its own in between, outside the sandbox, so what a command
11
+ * reached is counted where the command cannot touch it.
12
+ *
13
+ * Linux: the relay listens on a unix socket in a private directory bound into the bubblewrap
14
+ * sandbox; inside, `inSandboxRelaySource` bridges 127.0.0.1:COMMAND_NETWORK_PORT to it and then
15
+ * starts the command. macOS: the relay listens on 127.0.0.1:<ephemeral>, the only address the
16
+ * Seatbelt profile lets the command connect to.
17
+ */
18
+ export const COMMAND_NETWORK_PORT = 3128;
19
+ export const DEFAULT_COMMAND_NETWORK_SOCKET = '/run/stk/local-commands.sock';
20
+ const MAX_TARGETS = 32;
21
+ const MAX_REASON = 300;
22
+
23
+ export interface CommandNetworkSummary {
24
+ broker: 'available' | 'unavailable';
25
+ connections: { target: string; allowed: number; refused: number; reason?: string }[];
26
+ truncated?: boolean;
27
+ }
28
+
29
+ export class BrokerRelay {
30
+ private counts = new Map<string, { allowed: number; refused: number; reason?: string }>();
31
+ private truncated = false;
32
+ private unavailable = false;
33
+ private sockets = new Set<Socket>();
34
+ private constructor(
35
+ private readonly server: Server,
36
+ private readonly broker: string,
37
+ readonly port: number | null,
38
+ readonly path: string | null,
39
+ readonly dir: string | null,
40
+ ) {}
41
+
42
+ /** `tcp`: 127.0.0.1:<ephemeral> (macOS). `unix`: a socket in a new private directory (Linux),
43
+ * readable by the command's user so a tools-user command can reach it. */
44
+ static async start(broker: string, mode: 'tcp' | 'unix'): Promise<BrokerRelay> {
45
+ let relay!: BrokerRelay;
46
+ const server = createServer((client) => relay.handle(client));
47
+ if (mode === 'tcp') {
48
+ await new Promise<void>((resolve, reject) => {
49
+ server.once('error', reject);
50
+ server.listen(0, '127.0.0.1', () => resolve());
51
+ });
52
+ relay = new BrokerRelay(
53
+ server,
54
+ broker,
55
+ (server.address() as { port: number }).port,
56
+ null,
57
+ null,
58
+ );
59
+ } else {
60
+ const dir = mkdtempSync(join(tmpdir(), 'salaam-net-'));
61
+ chmodSync(dir, 0o755);
62
+ const path = join(dir, 'relay.sock');
63
+ await new Promise<void>((resolve, reject) => {
64
+ server.once('error', reject);
65
+ server.listen(path, () => resolve());
66
+ });
67
+ chmodSync(path, 0o666);
68
+ relay = new BrokerRelay(server, broker, null, path, dir);
69
+ }
70
+ return relay;
71
+ }
72
+
73
+ /** A client connection to this relay, for tests. */
74
+ connect(): Socket {
75
+ return this.path ? connect(this.path) : connect(this.port!, '127.0.0.1');
76
+ }
77
+
78
+ private record(target: string, allowed: boolean, reason?: string) {
79
+ let entry = this.counts.get(target);
80
+ if (!entry) {
81
+ if (this.counts.size >= MAX_TARGETS) {
82
+ this.truncated = true;
83
+ return;
84
+ }
85
+ entry = { allowed: 0, refused: 0 };
86
+ this.counts.set(target, entry);
87
+ }
88
+ if (allowed) entry.allowed++;
89
+ else {
90
+ entry.refused++;
91
+ if (reason && !entry.reason) entry.reason = reason.slice(0, MAX_REASON);
92
+ }
93
+ }
94
+
95
+ private handle(client: Socket) {
96
+ this.sockets.add(client);
97
+ client.on('close', () => this.sockets.delete(client));
98
+ client.on('error', () => {});
99
+ let head = Buffer.alloc(0);
100
+ const onHead = (chunk: Buffer) => {
101
+ head = Buffer.concat([head, chunk]);
102
+ const end = head.indexOf('\r\n');
103
+ if (end < 0) {
104
+ if (head.length > 8192) client.destroy();
105
+ return;
106
+ }
107
+ client.off('data', onHead);
108
+ client.pause();
109
+ const line = head.subarray(0, end).toString('latin1');
110
+ const connectLine = /^CONNECT (\S+) HTTP\/1\.[01]$/.exec(line);
111
+ const absolute = /^[A-Z]+ http:\/\/([^/\s]+)/.exec(line);
112
+ const target = connectLine
113
+ ? connectLine[1]!.toLowerCase()
114
+ : absolute
115
+ ? (absolute[1]!.includes(':') ? absolute[1]! : absolute[1] + ':80').toLowerCase()
116
+ : 'invalid-request';
117
+ const up = connect(this.broker);
118
+ this.sockets.add(up);
119
+ up.on('close', () => this.sockets.delete(up));
120
+ up.once('error', () => {
121
+ this.unavailable = true;
122
+ this.record(target, false, 'the broker socket is unavailable');
123
+ const body = 'salaam: the command network broker is unavailable\n';
124
+ client.end(
125
+ `HTTP/1.1 502 Bad Gateway\r\nContent-Length: ${body.length}\r\nConnection: close\r\n\r\n${body}`,
126
+ );
127
+ });
128
+ up.once('connect', () => {
129
+ let reply = Buffer.alloc(0),
130
+ decided = false;
131
+ const onReply = (chunk: Buffer) => {
132
+ client.write(chunk);
133
+ if (decided) return;
134
+ reply = Buffer.concat([reply, chunk]);
135
+ const status = /^HTTP\/1\.[01] (\d{3})/.exec(reply.toString('latin1'));
136
+ if (!status) {
137
+ if (reply.length > 8192) decided = true;
138
+ return;
139
+ }
140
+ const code = Number(status[1]);
141
+ if (code >= 200 && code < 300) {
142
+ decided = true;
143
+ this.record(target, true);
144
+ return;
145
+ }
146
+ // A refusal: wait for the body so the reason names the fix, as stk writes it.
147
+ const text = reply.toString('utf8');
148
+ const bodyAt = text.indexOf('\r\n\r\n');
149
+ if (bodyAt >= 0 && text.length > bodyAt + 4) {
150
+ decided = true;
151
+ this.record(
152
+ target,
153
+ false,
154
+ text
155
+ .slice(bodyAt + 4)
156
+ .trim()
157
+ .split('\n')[0],
158
+ );
159
+ }
160
+ };
161
+ up.on('data', onReply);
162
+ up.on('end', () => {
163
+ if (!decided) {
164
+ decided = true;
165
+ const status = /^HTTP\/1\.[01] (\d{3})[^\r\n]*/.exec(reply.toString('latin1'));
166
+ const code = status ? Number(status[1]) : 0;
167
+ this.record(target, code >= 200 && code < 300, status ? status[0] : 'no reply');
168
+ }
169
+ client.end();
170
+ });
171
+ up.write(head);
172
+ client.on('data', (d) => up.write(d));
173
+ client.on('end', () => up.end());
174
+ client.resume();
175
+ });
176
+ };
177
+ client.on('data', onHead);
178
+ }
179
+
180
+ summary(): CommandNetworkSummary {
181
+ return {
182
+ broker: this.unavailable ? 'unavailable' : 'available',
183
+ connections: [...this.counts].map(([target, c]) => ({ target, ...c })),
184
+ ...(this.truncated ? { truncated: true } : {}),
185
+ };
186
+ }
187
+
188
+ async close(): Promise<void> {
189
+ for (const s of this.sockets) s.destroy();
190
+ await new Promise<void>((resolve) => this.server.close(() => resolve()));
191
+ if (this.dir) rmSync(this.dir, { recursive: true, force: true });
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Runs inside the bubblewrap sandbox as `node -e <this> <unix socket> <port> <command...>`: listens
197
+ * on 127.0.0.1:<port> in the sandbox's own network namespace, pipes each connection to the bound
198
+ * relay socket, then starts the command with its stdio and exits with its status. It decides
199
+ * nothing; the host-side relay and stk do.
200
+ */
201
+ export const inSandboxRelaySource = `
202
+ const net = require('node:net'), { spawn } = require('node:child_process');
203
+ const [sock, port, cmd, ...args] = process.argv.slice(1);
204
+ const server = net.createServer((c) => {
205
+ const u = net.connect(sock);
206
+ c.on('error', () => u.destroy()); u.on('error', () => c.destroy());
207
+ c.pipe(u).pipe(c);
208
+ });
209
+ server.listen(Number(port), '127.0.0.1', () => {
210
+ const child = spawn(cmd, args, { stdio: 'inherit' });
211
+ child.on('exit', (code, signal) => process.exit(code ?? (signal ? 128 : 1)));
212
+ child.on('error', () => process.exit(127));
213
+ });
214
+ server.on('error', () => process.exit(126));
215
+ `.trim();
@@ -0,0 +1,393 @@
1
+ import { COMMAND_NETWORK_PORT, inSandboxRelaySource } from './command-network.js';
2
+ import { existsSync, realpathSync } from 'node:fs';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { homedir } from 'node:os';
5
+ import { dirname, isAbsolute, join, sep } from 'node:path';
6
+ import { ServiceError } from './protocol.js';
7
+
8
+ /**
9
+ * Item 10: the sandbox a local command runs in. `workspace-write` (the default) and `read-only` run
10
+ * the command under the host's OS sandbox, with the system read-only, a private tmp, the workspace
11
+ * writable or read-only, and no network unless declared:
12
+ * - Linux: bubblewrap, with an empty root and user, PID, IPC, UTS and network namespaces unshared,
13
+ * dying with its parent;
14
+ * - macOS: Seatbelt (`sandbox-exec`), with a deny-by-default profile.
15
+ * `full-access` runs the command as is and must be chosen explicitly by the operator. A host with
16
+ * no usable sandbox refuses local commands (operator ruling 2026-10-30, option A) unless the host
17
+ * config sets `unsandboxed_commands: "allow"`; then it runs them unsandboxed and says so:
18
+ * "unsandboxed" in host plan and in every approval, and kind `none` in the approved arguments and
19
+ * the execution result.
20
+ */
21
+ export const SANDBOX_LEVELS = ['read-only', 'workspace-write', 'full-access'] as const;
22
+ export type SandboxLevel = (typeof SANDBOX_LEVELS)[number];
23
+ export interface Bwrap {
24
+ path: string;
25
+ version: string;
26
+ }
27
+ export interface Seatbelt {
28
+ path: string;
29
+ /** The active Apple developer dir (`xcode-select -p`) when it holds a real git. /usr/bin/git is
30
+ * a shim that needs Xcode's licence preferences, which the profile does not open; commands get
31
+ * the real tools first on PATH instead (step 0, 2026-10-31). */
32
+ developerDir?: string;
33
+ }
34
+ export type SandboxKind = 'bwrap' | 'seatbelt' | 'none';
35
+
36
+ /**
37
+ * Phase 1 (2026-10-31): read-only paths outside the workspace an operator lets local commands use
38
+ * (a toolchain, a package cache, `~/.gitconfig`). Never the root, home itself or an ancestor of
39
+ * it, and never a credential directory or file, or an ancestor of one. `toolchain` expands to the
40
+ * ones that exist here: the prefix of the node running Salaam, and home's package caches and
41
+ * toolchains. Writes stay inside the workspace whatever is listed.
42
+ */
43
+ export const READ_PATHS_TOOLCHAIN = 'toolchain';
44
+ const CREDENTIALS = [
45
+ '.ssh',
46
+ '.gnupg',
47
+ '.aws',
48
+ '.azure',
49
+ '.config/gcloud',
50
+ '.config/gh',
51
+ '.docker',
52
+ '.kube',
53
+ '.netrc',
54
+ '.npmrc',
55
+ '.pypirc',
56
+ '.git-credentials',
57
+ '.claude',
58
+ '.codex',
59
+ '.config/salaam',
60
+ 'Library/Keychains',
61
+ ];
62
+ const TOOLCHAIN = ['.npm', '.nvm', '.cargo', '.rustup', 'go', '.gitconfig', '.config/git'];
63
+ function within(path: string, base: string): boolean {
64
+ return path === base || path.startsWith(base.endsWith(sep) ? base : base + sep);
65
+ }
66
+ export function resolveReadPaths(entries: readonly string[], home = homedir()): string[] {
67
+ const refuse = (path: string, why: string): never => {
68
+ throw new ServiceError('invalid_field', `local_sandbox_read_paths: ${path} ${why}`);
69
+ };
70
+ const out: string[] = [];
71
+ const add = (path: string) => {
72
+ if (!isAbsolute(path)) refuse(path, 'must be an absolute path');
73
+ if (/["\\\x00-\x1f\x7f]/.test(path)) refuse(path, 'has a character Seatbelt cannot quote');
74
+ const p = path.length > 1 ? path.replace(/\/+$/, '') : path;
75
+ if (p === '/') refuse(p, 'is the whole filesystem');
76
+ if (within(home, p)) refuse(p, 'would expose the whole home directory');
77
+ for (const c of CREDENTIALS) {
78
+ const secret = join(home, c);
79
+ if (within(p, secret) || within(secret, p)) refuse(p, `covers credentials (${c})`);
80
+ }
81
+ if (!out.includes(p)) out.push(p);
82
+ };
83
+ for (const entry of entries) {
84
+ if (typeof entry !== 'string') refuse(String(entry), 'must be a string');
85
+ if (entry !== READ_PATHS_TOOLCHAIN) {
86
+ add(entry);
87
+ continue;
88
+ }
89
+ // The node running Salaam, so `node`/`npx`/`npm` resolve inside the sandbox.
90
+ const nodePrefix = dirname(dirname(realpathSync(process.execPath)));
91
+ if (!within(home, nodePrefix)) add(nodePrefix);
92
+ for (const t of TOOLCHAIN) if (existsSync(join(home, t))) add(join(home, t));
93
+ }
94
+ return out;
95
+ }
96
+
97
+ /** The active developer dir when it has a real git, else undefined. */
98
+ function developerDir(): string | undefined {
99
+ try {
100
+ const dir = execFileSync('/usr/bin/xcode-select', ['-p'], {
101
+ timeout: 5000,
102
+ encoding: 'utf8',
103
+ }).trim();
104
+ return isAbsolute(dir) && existsSync(join(dir, 'usr/bin/git')) ? dir : undefined;
105
+ } catch {
106
+ return undefined;
107
+ }
108
+ }
109
+
110
+ /** The pinned bubblewrap: its absolute path and the version it reports, or null when unusable. */
111
+ export function findBwrap(candidates = ['/usr/bin/bwrap', '/bin/bwrap']): Bwrap | null {
112
+ for (const path of candidates) {
113
+ if (!existsSync(path)) continue;
114
+ try {
115
+ const version = execFileSync(path, ['--version'], { encoding: 'utf8', timeout: 5000 }).trim();
116
+ // Usable means it can actually make the namespaces we ask for, not just that it exists.
117
+ execFileSync(
118
+ path,
119
+ ['--unshare-user', '--unshare-net', '--ro-bind', '/', '/', '--', '/bin/true'],
120
+ {
121
+ timeout: 5000,
122
+ stdio: 'ignore',
123
+ },
124
+ );
125
+ return { path, version };
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
130
+ return null;
131
+ }
132
+
133
+ export function sandboxArgv(
134
+ level: SandboxLevel,
135
+ root: string,
136
+ argv: string[],
137
+ bwrap: Bwrap | null,
138
+ options: {
139
+ network: boolean;
140
+ /** Broker network: the host relay's private directory and the node that runs the in-sandbox
141
+ * relay. The network stays unshared; only the relay socket and node are bound in. */
142
+ broker?: { dir: string; node: string };
143
+ /** Operator-allowed read-only paths outside the workspace (resolveReadPaths). */
144
+ readPaths?: readonly string[];
145
+ },
146
+ ): string[] {
147
+ if (level === 'full-access') return argv;
148
+ if (!bwrap)
149
+ throw new ServiceError(
150
+ 'sandbox_unavailable',
151
+ 'This host has no usable bubblewrap, so it cannot run a sandboxed command; an operator can choose full access explicitly',
152
+ );
153
+ if (!isAbsolute(root) || root.includes('\0'))
154
+ throw new ServiceError('invalid_field', 'A sandboxed command needs an absolute workspace path');
155
+ // Optional system directories, bound read-only only where the host has them.
156
+ const optional = ['/lib', '/lib64', '/lib32', '/bin', '/sbin', '/etc/alternatives'].flatMap(
157
+ (dir) => ['--ro-bind-try', dir, dir],
158
+ );
159
+ return [
160
+ bwrap.path,
161
+ '--unshare-user',
162
+ '--unshare-pid',
163
+ '--unshare-ipc',
164
+ '--unshare-uts',
165
+ ...(options.network ? [] : ['--unshare-net']),
166
+ '--die-with-parent',
167
+ '--new-session',
168
+ '--tmpfs',
169
+ '/',
170
+ // The system read-only; /usr and /etc/ssl always asked for, the rest only where present.
171
+ '--ro-bind',
172
+ '/usr',
173
+ '/usr',
174
+ '--ro-bind',
175
+ '/etc/ssl',
176
+ '/etc/ssl',
177
+ ...optional,
178
+ '--proc',
179
+ '/proc',
180
+ '--dev',
181
+ '/dev',
182
+ '--tmpfs',
183
+ '/tmp',
184
+ // Operator-allowed toolchains, read-only, at their own paths (a symlink into one resolves).
185
+ ...(options.readPaths ?? []).flatMap((p) => ['--ro-bind-try', p, p]),
186
+ level === 'read-only' ? '--ro-bind' : '--bind',
187
+ root,
188
+ root,
189
+ // Broker network: only the host relay's socket directory and the node that bridges to it.
190
+ ...(options.broker
191
+ ? [
192
+ '--bind',
193
+ options.broker.dir,
194
+ '/run/salaam-net',
195
+ '--ro-bind',
196
+ options.broker.node,
197
+ '/run/salaam-node',
198
+ ]
199
+ : []),
200
+ // The empty root itself is read-only too: only the workspace (and the private /tmp) is writable.
201
+ '--remount-ro',
202
+ '/',
203
+ '--chdir',
204
+ root,
205
+ '--',
206
+ ...(options.broker
207
+ ? [
208
+ '/run/salaam-node',
209
+ '-e',
210
+ inSandboxRelaySource,
211
+ '/run/salaam-net/relay.sock',
212
+ String(COMMAND_NETWORK_PORT),
213
+ ]
214
+ : []),
215
+ ...argv,
216
+ ];
217
+ }
218
+
219
+ function brokerPort(port: number): number {
220
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
221
+ throw new ServiceError('invalid_field', 'A broker relay port is 1–65535');
222
+ return port;
223
+ }
224
+
225
+ /** The pinned Seatbelt launcher, or null when this host cannot apply a deny-by-default profile. */
226
+ export function findSeatbelt(path = '/usr/bin/sandbox-exec'): Seatbelt | null {
227
+ if (!existsSync(path)) return null;
228
+ try {
229
+ // Usable means it applies a deny-by-default profile and the command still runs.
230
+ execFileSync(
231
+ path,
232
+ [
233
+ '-p',
234
+ '(version 1)(deny default)(allow process-exec process-fork)(allow file-read*)(allow sysctl-read)',
235
+ '/usr/bin/true',
236
+ ],
237
+ { timeout: 5000, stdio: 'ignore' },
238
+ );
239
+ const dev = developerDir();
240
+ return dev ? { path, developerDir: dev } : { path };
241
+ } catch {
242
+ return null;
243
+ }
244
+ }
245
+
246
+ /** A path Seatbelt can quote: absolute, with no quote, backslash or control character. */
247
+ function sbplPath(path: string, what: string): string {
248
+ if (!isAbsolute(path) || /["\\\x00-\x1f\x7f]/.test(path))
249
+ throw new ServiceError('invalid_field', `A sandboxed command needs a plain absolute ${what}`);
250
+ return `"${path}"`;
251
+ }
252
+
253
+ /**
254
+ * The deny-by-default Seatbelt profile for a level. Reads: the system, developer tools and
255
+ * package prefixes, the workspace and the private tmp (file metadata anywhere, so paths resolve).
256
+ * Writes: the workspace (workspace-write only), the private tmp and the standard devices. No
257
+ * network unless declared, no mach services, no home directory.
258
+ */
259
+ export function seatbeltProfile(
260
+ level: 'read-only' | 'workspace-write',
261
+ root: string,
262
+ tmp: string,
263
+ /** brokerPort: the host relay's loopback port, the only place a broker-network command may connect.
264
+ * readPaths: operator-allowed read-only paths outside the workspace. developerDir: the active
265
+ * developer dir, when it lies outside the system paths below. */
266
+ options: {
267
+ network: boolean;
268
+ brokerPort?: number;
269
+ readPaths?: readonly string[];
270
+ developerDir?: string;
271
+ /** A command the person approved to listen (operator ruling 2026-11-01, option C). Seatbelt
272
+ * cannot limit this to loopback: "localhost" in its filters matches every local address. */
273
+ listen?: boolean;
274
+ },
275
+ ): string {
276
+ const w = sbplPath(root, 'workspace path'),
277
+ t = sbplPath(tmp, 'tmp path');
278
+ const extra = [
279
+ ...(options.readPaths ?? []),
280
+ ...(options.developerDir ? [options.developerDir] : []),
281
+ ]
282
+ .map((p) => `(subpath ${sbplPath(p, 'read path')})`)
283
+ .join(' ');
284
+ const system = [
285
+ '/usr',
286
+ '/bin',
287
+ '/sbin',
288
+ '/System',
289
+ '/Library/Apple',
290
+ '/Library/Developer/CommandLineTools',
291
+ '/Applications/Xcode.app',
292
+ '/opt/homebrew',
293
+ '/usr/local',
294
+ '/private/etc',
295
+ '/private/var/db/dyld',
296
+ '/private/var/db/timezone',
297
+ '/dev',
298
+ ]
299
+ .map((p) => `(subpath "${p}")`)
300
+ .join(' ');
301
+ const devices = ['/dev/null', '/dev/zero', '/dev/stdout', '/dev/stderr', '/dev/tty']
302
+ .map((p) => `(literal "${p}")`)
303
+ .join(' ');
304
+ return [
305
+ '(version 1)',
306
+ '(deny default)',
307
+ '(allow process-exec process-fork)',
308
+ '(allow signal (target same-sandbox))',
309
+ '(allow sysctl-read)',
310
+ '(allow file-read-metadata)',
311
+ `(allow file-read* (literal "/") ${system} (subpath ${w}) (subpath ${t})${extra ? ' ' + extra : ''})`,
312
+ level === 'workspace-write'
313
+ ? `(allow file-write* (subpath ${w}) (subpath ${t}) ${devices} (regex #"^/dev/fd/"))`
314
+ : `(allow file-write* (subpath ${t}) ${devices} (regex #"^/dev/fd/"))`,
315
+ `(allow file-ioctl ${devices} (regex #"^/dev/fd/"))`,
316
+ ...(options.network ? ['(allow network*)'] : []),
317
+ ...(options.listen && level === 'workspace-write' && !options.network
318
+ ? [
319
+ '(allow network-bind (local ip "localhost:*"))',
320
+ '(allow network-inbound (local ip "localhost:*"))',
321
+ ]
322
+ : []),
323
+ ...(options.brokerPort !== undefined && !options.network
324
+ ? [`(allow network-outbound (remote tcp "localhost:${brokerPort(options.brokerPort)}"))`]
325
+ : []),
326
+ ].join('\n');
327
+ }
328
+
329
+ export function seatbeltArgv(
330
+ level: SandboxLevel,
331
+ root: string,
332
+ tmp: string,
333
+ argv: string[],
334
+ seatbelt: Seatbelt | null,
335
+ options: {
336
+ network: boolean;
337
+ brokerPort?: number;
338
+ readPaths?: readonly string[];
339
+ listen?: boolean;
340
+ },
341
+ ): string[] {
342
+ if (level === 'full-access') return argv;
343
+ if (!seatbelt)
344
+ throw new ServiceError(
345
+ 'sandbox_unavailable',
346
+ 'This host has no usable Seatbelt, so it cannot run a sandboxed command; an operator can choose full access explicitly',
347
+ );
348
+ const dev = seatbelt.developerDir;
349
+ // git reads ~/.gitconfig and fails hard when it may not; without the operator allowing it, the
350
+ // command runs with no global git config rather than an error.
351
+ const gitConfig = join(homedir(), '.gitconfig');
352
+ const gitConfigAllowed = (options.readPaths ?? []).some((p) => within(gitConfig, p));
353
+ return [
354
+ seatbelt.path,
355
+ '-p',
356
+ seatbeltProfile(level, root, tmp, { ...options, ...(dev ? { developerDir: dev } : {}) }),
357
+ '/usr/bin/env',
358
+ `TMPDIR=${tmp}/`,
359
+ ...(dev
360
+ ? [`PATH=${join(dev, 'usr/bin')}:${process.env.PATH ?? '/usr/bin:/bin:/usr/sbin:/sbin'}`]
361
+ : []),
362
+ ...(gitConfigAllowed ? [] : ['GIT_CONFIG_GLOBAL=/dev/null']),
363
+ ...argv,
364
+ ];
365
+ }
366
+
367
+ /** The sandbox this host gives local commands: bubblewrap on Linux and Seatbelt on macOS when
368
+ * usable, otherwise none. */
369
+ export function chooseLocalSandbox(
370
+ level: SandboxLevel,
371
+ platform: NodeJS.Platform = process.platform,
372
+ find: () => Bwrap | null = findBwrap,
373
+ findSb: () => Seatbelt | null = findSeatbelt,
374
+ ): { kind: SandboxKind; level: SandboxLevel; bwrap: Bwrap | null; seatbelt: Seatbelt | null } {
375
+ const none = { kind: 'none' as const, level, bwrap: null, seatbelt: null };
376
+ if (level === 'full-access') return none;
377
+ if (platform === 'linux') {
378
+ const bwrap = find();
379
+ return bwrap ? { ...none, kind: 'bwrap', bwrap } : none;
380
+ }
381
+ if (platform === 'darwin') {
382
+ const seatbelt = findSb();
383
+ return seatbelt ? { ...none, kind: 'seatbelt', seatbelt } : none;
384
+ }
385
+ return none;
386
+ }
387
+ /** How host plan and approvals name a sandbox kind. */
388
+ export function sandboxLabel(kind: SandboxKind): string {
389
+ return kind === 'bwrap' ? 'bubblewrap' : kind === 'seatbelt' ? 'seatbelt' : 'unsandboxed';
390
+ }
391
+ /** The refusal for a local command on a host with no usable sandbox and no operator opt-in. */
392
+ export const UNSANDBOXED_REFUSAL =
393
+ 'Local commands are refused: this host has no usable command sandbox (bubblewrap on Linux, Seatbelt on macOS). Install or repair bubblewrap, or have the operator set unsandboxed_commands: "allow" in the host config to run them unsandboxed.';