@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
@@ -1,6 +1,22 @@
1
+ import { BrokerRelay, COMMAND_NETWORK_PORT } from './command-network.js';
2
+ import {
3
+ sandboxArgv,
4
+ seatbeltArgv,
5
+ UNSANDBOXED_REFUSAL,
6
+ type Bwrap,
7
+ type SandboxKind,
8
+ type SandboxLevel,
9
+ type Seatbelt,
10
+ } from './command-sandbox.js';
1
11
  import { spawn } from 'node:child_process';
2
12
  import { randomUUID } from 'node:crypto';
3
- import { realpathSync } from 'node:fs';
13
+ import { mkdtempSync, realpathSync, rmSync } from 'node:fs';
14
+ import { homedir, tmpdir } from 'node:os';
15
+ import { classifyCommand, type DangerVerdict } from './command-danger.js';
16
+ import { gitIdentityEnv, publishesToRemote } from './git-helpers.js';
17
+ import { boundShellCommand } from './shell-passthrough.js';
18
+ import type { ClassifierReview } from './danger-classifier.js';
19
+ import { join } from 'node:path';
4
20
  import type { Store } from './store.js';
5
21
  import type { ManagedWork } from './managed.js';
6
22
  import { digest, object, string, ServiceError, type Operation } from './protocol.js';
@@ -8,16 +24,56 @@ import {
8
24
  prepareLocalEdits,
9
25
  verifyLocalEdits,
10
26
  applyLocalEdits,
27
+ reviewLocalEdits,
11
28
  type LocalEdit,
12
29
  } from './local-edits.js';
13
30
  import {
14
31
  permissionPolicy,
32
+ autoApprovesEdits,
33
+ autoApprovesCommands,
15
34
  permissionDigest,
16
- matchesEditScope,
17
35
  assertPermissionBinding,
18
36
  } from './autonomy.js';
37
+ import {
38
+ commandArgv,
39
+ evaluateRules,
40
+ type ApprovalRules,
41
+ type RuleRequest,
42
+ } from './approval-rules.js';
19
43
  import { assertUndoSettled } from './local-undo.js';
20
- import { processIdentity } from './supervisor.js';
44
+ import type { HookRun } from './hooks.js';
45
+ import { processIdentity, processStart } from './supervisor.js';
46
+
47
+ const TASK_TAIL = 65536;
48
+ const TASK_LIMIT = 4;
49
+ const BACKGROUND_LIFETIME = 2 * 3600 * 1000;
50
+ type TaskState = 'running' | 'exited' | 'stopped' | 'timed_out' | 'lost';
51
+ interface TaskRow {
52
+ id: string;
53
+ session_id: string;
54
+ call_id: string;
55
+ command: string;
56
+ state: TaskState;
57
+ pid: number | null;
58
+ identity: string | null;
59
+ exit_code: number | null;
60
+ started_at: string;
61
+ ended_at: string | null;
62
+ }
63
+ interface BackgroundTask {
64
+ id: string;
65
+ session: string;
66
+ done: Promise<void>;
67
+ output: () => { output: string; bytes: number; truncated: boolean };
68
+ stop: (why: TaskState) => void;
69
+ }
70
+ import {
71
+ applyEditsAsTools,
72
+ killToolGroup,
73
+ serviceToolIdentity,
74
+ toolArgv,
75
+ type ToolIdentity,
76
+ } from './tool-identity.js';
21
77
 
22
78
  interface LocalCall {
23
79
  id: string;
@@ -34,14 +90,63 @@ interface LocalCall {
34
90
  pid: number | null;
35
91
  identity: string | null;
36
92
  }
93
+ /**
94
+ * Item 10: how this host runs local commands. `bwrap` (Linux) or `seatbelt` (macOS): under the OS
95
+ * sandbox at `level` (full-access only by explicit operator setting). `none`: a host with no usable
96
+ * sandbox, which refuses local commands unless the operator allowed them unsandboxed
97
+ * (`allowUnsandboxed`); then it runs them and says so in every approval.
98
+ */
99
+ export interface LocalSandbox {
100
+ kind: SandboxKind;
101
+ level: SandboxLevel;
102
+ bwrap: Bwrap | null;
103
+ seatbelt?: Seatbelt | null;
104
+ /** The host config's `unsandboxed_commands: "allow"` (operator ruling 2026-10-30, option A). */
105
+ allowUnsandboxed?: boolean;
106
+ /** The host config's `command_network: "broker"`: a sandboxed command reaches the network only
107
+ * through this stk local-commands socket (operator rulings 2026-10-30). */
108
+ network?: { socket: string };
109
+ /** Operator-allowed read-only paths outside the workspace (Phase 1, 2026-10-31). */
110
+ readPaths?: string[];
111
+ }
112
+ /** The proxy a broker-network command uses: only the relay on loopback, nothing direct. */
113
+ function proxyEnv(port: number): Record<string, string> {
114
+ const url = `http://127.0.0.1:${port}`;
115
+ return {
116
+ HTTP_PROXY: url,
117
+ HTTPS_PROXY: url,
118
+ http_proxy: url,
119
+ https_proxy: url,
120
+ NO_PROXY: 'localhost,127.0.0.1',
121
+ no_proxy: 'localhost,127.0.0.1',
122
+ };
123
+ }
37
124
  export class LocalCoding {
38
125
  private jobs = new Map<string, { stop: () => void; done: Promise<void> }>();
126
+ /** Background tasks this controller owns; they belong to the session, not to a turn. */
127
+ private live = new Map<string, BackgroundTask>();
128
+ /** Output of recently ended tasks, so the model can read why one stopped. */
129
+ private kept = new Map<string, { output: string; bytes: number; truncated: boolean }>();
130
+ /** Background commands are for a person's own desk session only (Phase 3c). */
131
+ public interactive: (session: string) => boolean = () => false;
39
132
  private closed = false;
40
133
  private unregister: () => void;
134
+ /** Desk-session approval rules (allow/ask/deny), set by the service; absent means every request asks. */
135
+ public rules?: ApprovalRules;
136
+ /** Operator ruling 2026-11-01 (option 1): one command may ask to run outside the OS sandbox.
137
+ * Off where the host forbids full access, since it is full access for that one command. */
138
+ public allowOutsideSandbox = true;
139
+ /** The optional small-model command reviewer (Phase 4c); absent means the rules alone decide. */
140
+ public classifier?: {
141
+ review(session: string, command: string, cwd: string): Promise<ClassifierReview | null>;
142
+ };
41
143
  constructor(
42
144
  readonly store: Store,
43
145
  readonly managed: ManagedWork,
44
146
  readonly active: (s: string, e: number, a: string) => void,
147
+ readonly tools: ToolIdentity = serviceToolIdentity(),
148
+ /** Chosen by the service from the host and the operator's setting. */
149
+ public sandbox: LocalSandbox = { kind: 'none', level: 'full-access', bwrap: null },
45
150
  ) {
46
151
  this.unregister = managed.registerMcpController('local-coding-v1', {
47
152
  owns: (op) => !!store.db.prepare('SELECT 1 FROM local_calls WHERE operation_id=?').get(op.id),
@@ -54,6 +159,20 @@ export class LocalCoding {
54
159
  throw new ServiceError('local_busy', 'Command resources are still active');
55
160
  },
56
161
  });
162
+ store.db.exec(
163
+ 'CREATE TABLE IF NOT EXISTS local_tasks(id TEXT PRIMARY KEY,session_id TEXT NOT NULL,call_id TEXT NOT NULL,command TEXT NOT NULL,state TEXT NOT NULL,pid INTEGER,identity TEXT,exit_code INTEGER,started_at TEXT NOT NULL,ended_at TEXT)',
164
+ );
165
+ // A background task never outlives its controller: kill what a restart left and say so.
166
+ for (const raw of store.db.prepare("SELECT * FROM local_tasks WHERE state='running'").all()) {
167
+ const t = raw as unknown as TaskRow;
168
+ if (t.pid && t.identity && processStart(t.pid) === t.identity)
169
+ try {
170
+ process.kill(-t.pid, 'SIGKILL');
171
+ } catch {
172
+ /* already gone */
173
+ }
174
+ this.endTask(t, 'lost', null);
175
+ }
57
176
  // A lost controller must never retry a possibly executed edit or command.
58
177
  for (const raw of store.db.prepare("SELECT * FROM local_calls WHERE state='running'").all()) {
59
178
  const c = raw as unknown as LocalCall;
@@ -64,6 +183,8 @@ export class LocalCoding {
64
183
  /* outcome remains uncertain */
65
184
  }
66
185
  }
186
+ const pgid = c.result ? Number(JSON.parse(c.result).tool_pgid) : NaN;
187
+ if (Number.isSafeInteger(pgid)) killToolGroup(this.tools, pgid);
67
188
  store.transaction(() => {
68
189
  this.change(c, 'outcome_unknown', {
69
190
  error:
@@ -73,6 +194,26 @@ export class LocalCoding {
73
194
  });
74
195
  }
75
196
  }
197
+ /** The exact edits an edit request carries, or none for a command; files over 256 KiB are left out. */
198
+ edits(
199
+ session: string,
200
+ by: { operation: string } | { toolCall: string },
201
+ ): { root: string; edits: LocalEdit[] } | null {
202
+ const row = this.store.db
203
+ .prepare(
204
+ `SELECT root,args FROM local_calls WHERE session_id=? AND ${'operation' in by ? 'operation_id' : 'tool_call'}=? ORDER BY rowid DESC LIMIT 1`,
205
+ )
206
+ .get(session, 'operation' in by ? by.operation : by.toolCall) as
207
+ { root: string; args: string } | undefined;
208
+ if (!row) return null;
209
+ const args = object(JSON.parse(row.args));
210
+ if (args.action !== 'edit') return null;
211
+ const small = (text: string | null) => text === null || Buffer.byteLength(text) <= 256 * 1024;
212
+ return {
213
+ root: row.root,
214
+ edits: (args.edits as LocalEdit[]).filter((e) => small(e.before) && small(e.after)),
215
+ };
216
+ }
76
217
  get(id: string): LocalCall {
77
218
  const row = this.store.db.prepare('SELECT * FROM local_calls WHERE id=?').get(id);
78
219
  if (!row) throw new ServiceError('not_found', 'Local operation not found', 404);
@@ -121,12 +262,70 @@ export class LocalCoding {
121
262
  'Another operation or uncertain outcome owns this workspace',
122
263
  );
123
264
  }
265
+ /** prepare, after the optional reviewer has looked at a command the session would run unasked. */
266
+ async prepareReviewed(
267
+ session: string,
268
+ epoch: number,
269
+ attempt: string,
270
+ toolCall: string,
271
+ input: Record<string, unknown>,
272
+ ) {
273
+ let review: ClassifierReview | null = null;
274
+ if (this.classifier && input.action === 'command' && typeof input.command === 'string') {
275
+ const p = permissionPolicy(this.store, session);
276
+ // Only where the mode would run the command without asking; review mode asks anyway.
277
+ if (p.access || p.mode === 'auto') {
278
+ const root = this.store.session(session).cwd;
279
+ if (!classifyCommand(input.command, this.dangerContext(root)).dangerous)
280
+ review = await this.classifier.review(session, input.command, root);
281
+ }
282
+ }
283
+ return this.prepare(session, epoch, attempt, toolCall, input, review);
284
+ }
285
+ private dangerContext(root: string) {
286
+ return {
287
+ root,
288
+ home: homedir(),
289
+ tmp: [...new Set(['/tmp', '/private/tmp', tmpdir(), realpathSync(tmpdir())])],
290
+ };
291
+ }
292
+ /** Has this tool call already been prepared? */
293
+ hasCall(session: string, epoch: number, toolCall: string): boolean {
294
+ return !!this.store.db
295
+ .prepare('SELECT 1 FROM local_calls WHERE session_id=? AND epoch=? AND tool_call=?')
296
+ .get(session, epoch, toolCall);
297
+ }
298
+ /** A `!command` turn runs only what was typed, once (trial polish #12): enforced here, not by
299
+ * the model following its instruction, so it holds in auto, full access and YOLO alike. */
300
+ private assertShellBinding(session: string, attempt: string, input: Record<string, unknown>) {
301
+ const row = this.store.db
302
+ .prepare(
303
+ 'SELECT s.text FROM attempts a JOIN submissions s ON s.id=a.submission_id WHERE a.id=? AND a.session_id=?',
304
+ )
305
+ .get(attempt, session) as { text: string } | undefined;
306
+ const bound = row ? boundShellCommand(row.text) : undefined;
307
+ if (bound === undefined) return;
308
+ if (input.action !== 'command' || input.command !== bound || input.background)
309
+ throw new ServiceError(
310
+ 'shell_binding',
311
+ `This turn runs only the command you typed: ${bound}. Run exactly that, unchanged.`,
312
+ );
313
+ const ran = this.store.db
314
+ .prepare('SELECT 1 FROM local_calls WHERE session_id=? AND attempt_id=?')
315
+ .get(session, attempt);
316
+ if (ran)
317
+ throw new ServiceError(
318
+ 'shell_binding',
319
+ 'This turn already ran the command you typed. Report its output; do not run anything else.',
320
+ );
321
+ }
124
322
  prepare(
125
323
  session: string,
126
324
  epoch: number,
127
325
  attempt: string,
128
326
  toolCall: string,
129
327
  input: Record<string, unknown>,
328
+ review: ClassifierReview | null = null,
130
329
  ) {
131
330
  const prior = this.store.db
132
331
  .prepare(
@@ -138,6 +337,7 @@ export class LocalCoding {
138
337
  throw new ServiceError('request_conflict', 'Tool call already binds different arguments');
139
338
  return this.view(String(prior.id));
140
339
  }
340
+ this.assertShellBinding(session, attempt, input);
141
341
  const root = this.store.session(session).cwd;
142
342
  const c: LocalCall = {
143
343
  id: randomUUID(),
@@ -158,26 +358,102 @@ export class LocalCoding {
158
358
  const kind = string(input.action, 'action');
159
359
  let args:
160
360
  | { action: 'edit'; edits: LocalEdit[] }
161
- | { action: 'command'; command: string; timeout_ms: number };
361
+ | {
362
+ action: 'command';
363
+ command: string;
364
+ timeout_ms: number;
365
+ background?: true;
366
+ listen?: true;
367
+ /** Why this one command asked to run without the OS sandbox. */
368
+ outside_sandbox?: string;
369
+ sandbox: { kind: string; level: string; bwrap: string | null; network?: 'broker' };
370
+ };
162
371
  if (kind === 'edit') args = { action: 'edit', edits: prepareLocalEdits(root, input.edits) };
163
372
  else if (kind === 'command') {
164
373
  const timeout = input.timeout_ms ?? 30000;
165
374
  if (!Number.isInteger(timeout) || Number(timeout) < 100 || Number(timeout) > 120000)
166
375
  throw new ServiceError('invalid_field', 'Command timeout must be 100–120000 ms');
376
+ const background = input.background === true;
377
+ if (input.background !== undefined && typeof input.background !== 'boolean')
378
+ throw new ServiceError('invalid_field', 'background must be true or false');
379
+ if (background) this.assertBackground(session);
380
+ if (input.listen !== undefined && typeof input.listen !== 'boolean')
381
+ throw new ServiceError('invalid_field', 'listen must be true or false');
382
+ if (
383
+ input.outside_sandbox !== undefined &&
384
+ (typeof input.outside_sandbox !== 'string' || !input.outside_sandbox.trim())
385
+ )
386
+ throw new ServiceError(
387
+ 'invalid_field',
388
+ 'outside_sandbox must say why the command needs to run without the sandbox',
389
+ );
390
+ const session_sb = this.sandboxFor(session);
391
+ // Only a sandboxed session has anything to step out of; under full access it changes nothing.
392
+ const outside =
393
+ typeof input.outside_sandbox === 'string' && this.sandboxed(session_sb)
394
+ ? input.outside_sandbox.trim().slice(0, 300)
395
+ : undefined;
396
+ if (outside !== undefined && (!this.allowOutsideSandbox || this.tools.mode === 'tools-user'))
397
+ throw new ServiceError(
398
+ 'outside_sandbox_denied',
399
+ 'The operator does not allow commands outside the OS sandbox on this host. Ask the person to run it themselves.',
400
+ );
401
+ const sb: LocalSandbox =
402
+ outside !== undefined ? { ...session_sb, level: 'full-access' } : session_sb;
403
+ // Operator ruling 2026-11-01 (option C): a sandboxed command listens only when a person
404
+ // approves that exact command to. Linux's sandbox has its own network, so it cannot serve.
405
+ const listen = input.listen === true && this.sandboxed(sb);
406
+ if (listen && sb.kind === 'bwrap')
407
+ throw new ServiceError(
408
+ 'listen_unreachable',
409
+ 'On Linux the command sandbox has its own network namespace, so a server it starts cannot be reached from the host. Ask for full access for this command, or run the server yourself.',
410
+ );
411
+ if (sb.kind === 'bwrap' && sb.level !== 'full-access' && !sb.bwrap)
412
+ throw new ServiceError(
413
+ 'sandbox_unavailable',
414
+ 'This host has no usable bubblewrap, so it cannot run a sandboxed command; an operator can choose full access explicitly',
415
+ );
416
+ if (sb.kind === 'none' && sb.level !== 'full-access' && !sb.allowUnsandboxed)
417
+ throw new ServiceError('sandbox_unavailable', UNSANDBOXED_REFUSAL);
167
418
  args = {
168
419
  action: 'command',
169
420
  command: string(input.command, 'command', 8192),
170
- timeout_ms: Number(timeout),
421
+ timeout_ms: background ? BACKGROUND_LIFETIME : Number(timeout),
422
+ ...(background ? { background: true as const } : {}),
423
+ ...(listen ? { listen: true as const } : {}),
424
+ ...(outside !== undefined ? { outside_sandbox: outside } : {}),
425
+ // Bound into the approval and the operation digest: the sandbox is part of what is approved.
426
+ sandbox: {
427
+ kind: sb.kind,
428
+ level: sb.level,
429
+ bwrap: sb.bwrap?.version ?? null,
430
+ ...(this.brokerNetwork(sb) ? { network: 'broker' as const } : {}),
431
+ },
171
432
  };
172
433
  } else throw new ServiceError('invalid_field', 'Choose edit or command');
173
434
  c.args = JSON.stringify(args);
435
+ // Operator ruling 2026-10-31: full access asks only for commands this check calls dangerous.
436
+ const rulesDanger: DangerVerdict =
437
+ args.action === 'command'
438
+ ? classifyCommand(args.command, this.dangerContext(root))
439
+ : { dangerous: false };
440
+ // Phase 4c: the reviewer can only add a reason to ask, never remove one.
441
+ const escalated = !rulesDanger.dangerous && args.action === 'command' && !!review?.escalate;
442
+ const danger: DangerVerdict = escalated
443
+ ? {
444
+ dangerous: true,
445
+ category: 'classifier',
446
+ reason: `${review!.reason} (reviewed by ${review!.model})`,
447
+ segment: (args as { command: string }).command,
448
+ }
449
+ : rulesDanger;
450
+ // Pushing and opening a pull request ask in every mode but YOLO (operator ruling 2026-10-31).
451
+ const publishes = args.action === 'command' ? publishesToRemote(args.command) : null;
174
452
  this.store.transaction(() => {
175
453
  const reviewed =
176
454
  args.action === 'edit'
177
- ? args.edits
178
- .map((e) => `--- ${e.path}\n${e.before ?? '(new file)'}\n+++ ${e.path}\n${e.after}`)
179
- .join('\n\n')
180
- : `Runs as your local user, outside an OS sandbox. May access files and network beyond this workspace.\nDirectory: ${root}\nTimeout: ${args.timeout_ms} ms\nCommand:\n${args.command}`;
455
+ ? reviewLocalEdits(args.edits)
456
+ : `Runs as ${this.tools.mode === 'tools-user' ? `tools user ${this.tools.user} (uid ${this.tools.uid})` : 'your local user'}, ${this.sandboxText(this.bound(args), !!args.outside_sandbox)}\nDirectory: ${root}${args.background ? `\nRuns in the background until stopped (at most ${BACKGROUND_LIFETIME / 3600000} h); the last ${TASK_TAIL / 1024} KiB of output is kept` : `\nTimeout: ${args.timeout_ms} ms`}\n${danger.dangerous ? `Dangerous (${danger.category}): ${danger.reason}\n` : ''}${publishes ? `Publishes: ${publishes}; this always asks, except in YOLO\n` : ''}${args.outside_sandbox ? `Outside the sandbox: ${args.outside_sandbox}. This one command runs without the OS sandbox; the session stays sandboxed. This always asks, except in YOLO or your own rule\n` : ''}${args.listen ? 'May listen: this command may accept network connections. macOS cannot limit listening to localhost, so a server bound to 0.0.0.0 is reachable from your network. This always asks, except in YOLO or your own rule\n' : ''}Command:\n${args.command}`;
181
457
  const permission = this.managed.prepareWorkspaceInTransaction(
182
458
  session,
183
459
  epoch,
@@ -210,14 +486,100 @@ export class LocalCoding {
210
486
  action: kind,
211
487
  });
212
488
  });
489
+ if (review)
490
+ this.store.transaction(() =>
491
+ this.store.event(session, 'permission.classifier', {
492
+ call_id: c.id,
493
+ operation_id: c.operation_id,
494
+ model: review.model,
495
+ verdict: review.verdict,
496
+ escalated,
497
+ ...(review.reason ? { reason: review.reason } : {}),
498
+ }),
499
+ );
213
500
  const policy = permissionPolicy(this.store, session);
214
- if (
215
- args.action === 'edit' &&
216
- matchesEditScope(
217
- policy,
218
- args.edits.map((e) => e.path),
219
- )
220
- ) {
501
+ // Desk-session rules: a matching deny refuses, a matching allow approves and is recorded with the
502
+ // rule that decided it; anything else falls through to the scoped-edit policy and then asks.
503
+ if (this.rules && policy.mode !== 'inspect' && this.rules.applies(session)) {
504
+ const request: RuleRequest =
505
+ args.action === 'command'
506
+ ? { tool: 'command', argv: commandArgv(args.command) }
507
+ : { tool: 'edit', paths: args.edits.map((e) => e.path) };
508
+ // A repo's allow rules cannot approve a dangerous or publishing command: only the person's own
509
+ // rules can.
510
+ const usable = (all: ReturnType<ApprovalRules['list']>) =>
511
+ danger.dangerous ||
512
+ publishes ||
513
+ (args.action === 'command' && (args.listen || args.outside_sandbox))
514
+ ? all.filter((r) => !(r.source === 'repo' && r.effect === 'allow'))
515
+ : all;
516
+ const decided = evaluateRules(usable(this.rules.list(session)), request);
517
+ const approval = this.managed.forOperation(c.operation_id);
518
+ const rule = decided.rule && {
519
+ id: decided.rule.id,
520
+ source: decided.rule.source,
521
+ effect: decided.rule.effect,
522
+ tool: decided.rule.tool,
523
+ ...(decided.rule.command ? { command: decided.rule.command } : {}),
524
+ ...(decided.rule.paths ? { paths: decided.rule.paths } : {}),
525
+ };
526
+ if (decided.effect === 'deny' && rule) {
527
+ this.managed.decide(
528
+ approval.id,
529
+ 'rule-deny:' + c.id,
530
+ approval.revision,
531
+ approval.operation_digest,
532
+ 'deny',
533
+ );
534
+ this.store.transaction(() =>
535
+ this.store.event(session, 'permission.rule-denied', {
536
+ rule,
537
+ call_id: c.id,
538
+ operation_id: c.operation_id,
539
+ }),
540
+ );
541
+ } else if (decided.effect === 'allow' && rule) {
542
+ const rules = this.rules;
543
+ this.managed.decide(
544
+ approval.id,
545
+ 'rule:' + c.id,
546
+ approval.revision,
547
+ approval.operation_digest,
548
+ 'approve',
549
+ {
550
+ receipt: {
551
+ policy_digest: permissionDigest(policy),
552
+ policy_revision: policy.revision,
553
+ rule,
554
+ root,
555
+ call_id: c.id,
556
+ },
557
+ decidedBy: 'rule:' + rule.source,
558
+ authorize: () => {
559
+ this.guard(c);
560
+ const again = evaluateRules(usable(rules.list(session)), request);
561
+ if (again.effect !== 'allow' || again.rule?.id !== rule.id)
562
+ throw new ServiceError('permission_changed', 'Approval rules changed');
563
+ },
564
+ },
565
+ );
566
+ }
567
+ }
568
+ // The session's mode approves by itself only what it covers (Phase 2b): scoped or ordinary
569
+ // project edits, and, in auto, commands that stay inside the OS sandbox. Re-checked at decision.
570
+ const covered = (p: typeof policy) =>
571
+ args.action === 'edit'
572
+ ? autoApprovesEdits(
573
+ p,
574
+ args.edits.map((e) => e.path),
575
+ )
576
+ : autoApprovesCommands(
577
+ p,
578
+ this.sandboxed(this.bound(args)),
579
+ danger.dangerous,
580
+ publishes !== null || args.listen === true || !!args.outside_sandbox,
581
+ ) && !(escalated && p.access !== 'yolo');
582
+ if (this.managed.forOperation(c.operation_id).state === 'pending' && covered(policy)) {
221
583
  const approval = this.managed.forOperation(c.operation_id);
222
584
  this.managed.decide(
223
585
  approval.id,
@@ -235,19 +597,27 @@ export class LocalCoding {
235
597
  authorize: () => {
236
598
  this.guard(c);
237
599
  const fresh = permissionPolicy(this.store, session);
238
- if (
239
- permissionDigest(fresh) !== permissionDigest(policy) ||
240
- args.action !== 'edit' ||
241
- !matchesEditScope(
242
- fresh,
243
- args.edits.map((e) => e.path),
244
- )
245
- )
600
+ if (permissionDigest(fresh) !== permissionDigest(policy) || !covered(fresh))
246
601
  throw new ServiceError('permission_changed', 'Scoped edit policy changed');
247
- verifyLocalEdits(root, args.edits);
602
+ if (args.action === 'edit') verifyLocalEdits(root, args.edits);
248
603
  },
249
604
  },
250
605
  );
606
+ // YOLO approves everything; each approval is recorded with what full access would have asked.
607
+ if (policy.access === 'yolo')
608
+ this.store.transaction(() =>
609
+ this.store.event(session, 'permission.yolo-approved', {
610
+ call_id: c.id,
611
+ operation_id: c.operation_id,
612
+ action: args.action,
613
+ ...(args.action === 'command' ? { command: args.command } : {}),
614
+ ...(danger.dangerous
615
+ ? { would_have_asked: { category: danger.category, reason: danger.reason } }
616
+ : publishes
617
+ ? { would_have_asked: { category: 'publish', reason: publishes } }
618
+ : {}),
619
+ }),
620
+ );
251
621
  }
252
622
  return this.view(c.id);
253
623
  }
@@ -287,16 +657,19 @@ export class LocalCoding {
287
657
  try {
288
658
  verifyLocalEdits(c.root, args.edits as LocalEdit[]);
289
659
  entered = true;
290
- applyLocalEdits(c.root, args.edits as LocalEdit[], (path) => {
660
+ const progress = (path: string) => {
291
661
  applied.push(path);
292
662
  this.store.transaction(() => this.change(c, 'running', { applied }));
293
- });
663
+ };
664
+ if (this.tools.mode === 'tools-user')
665
+ applyEditsAsTools(this.tools, c.root, args.edits as LocalEdit[], progress);
666
+ else applyLocalEdits(c.root, args.edits as LocalEdit[], progress);
294
667
  verifyLocalEdits(
295
668
  c.root,
296
669
  (args.edits as LocalEdit[]).map((edit) => ({ ...edit, before: edit.after })),
297
670
  );
298
671
  this.store.transaction(() => {
299
- const result = { applied, verified: true };
672
+ const result = { applied, verified: true, tool_uid: this.tools.uid };
300
673
  this.change(c, 'succeeded', result);
301
674
  this.managed.workspaceResultInTransaction(c.operation_id, result);
302
675
  });
@@ -313,20 +686,240 @@ export class LocalCoding {
313
686
  else this.managed.workspaceDefiniteFailureInTransaction(c.operation_id);
314
687
  });
315
688
  }
316
- } else this.command(c, String(args.command), Number(args.timeout_ms));
689
+ } else if (args.background === true) this.background(c, String(args.command), this.bound(args));
690
+ else this.command(c, String(args.command), Number(args.timeout_ms), this.bound(args));
317
691
  return this.view(id);
318
692
  }
319
- private command(c: LocalCall, command: string, timeout: number): void {
693
+ /** The sandbox for a new command in this session: the host's, or full access if the person chose
694
+ * it for this session (Phase 2b). Bound into the command's approval and digest. */
695
+ private sandboxFor(session: string): LocalSandbox {
696
+ return permissionPolicy(this.store, session).access
697
+ ? { ...this.sandbox, level: 'full-access' }
698
+ : this.sandbox;
699
+ }
700
+ /** The sandbox a prepared command was approved under. */
701
+ private bound(args: { sandbox?: { level?: string } }): LocalSandbox {
702
+ return args.sandbox?.level === 'full-access'
703
+ ? { ...this.sandbox, level: 'full-access' }
704
+ : this.sandbox;
705
+ }
706
+ private sandboxed(sb: LocalSandbox): boolean {
707
+ return sb.kind !== 'none' && sb.level !== 'full-access';
708
+ }
709
+ /** Where this session's next command runs, in a word or two for the status and side panels. */
710
+ commandSandbox(session: string): string {
711
+ const access = permissionPolicy(this.store, session).access;
712
+ if (access) return access === 'yolo' ? 'YOLO' : 'full access';
713
+ if (this.sandbox.level === 'full-access') return 'no sandbox';
714
+ if (this.sandbox.kind === 'none')
715
+ return this.sandbox.allowUnsandboxed ? 'no sandbox' : 'refused';
716
+ return this.sandbox.kind === 'seatbelt' ? 'Seatbelt' : 'bubblewrap';
717
+ }
718
+ private sandboxText(sb: LocalSandbox = this.sandbox, outside = false): string {
719
+ const { kind, level } = sb;
720
+ if (outside)
721
+ return 'unsandboxed for this one command, at its own request: it can read and change any file your user can and reach the network.';
722
+ if (level === 'full-access')
723
+ return this.sandbox.level === 'full-access'
724
+ ? 'unsandboxed: the operator chose full access. May access files and network beyond this workspace.'
725
+ : 'unsandboxed: you turned on full access for this session. May access files and network beyond this workspace.';
726
+ if (kind === 'none')
727
+ return 'unsandboxed: this host has no usable command sandbox, so the command may access files and network beyond this workspace.';
728
+ const name = kind === 'seatbelt' ? 'Seatbelt' : 'bubblewrap';
729
+ const net = this.brokerNetwork(sb)
730
+ ? 'network: through the broker allowlist (hosts set by the stk policy), nothing direct.'
731
+ : 'has no network.';
732
+ const reads = this.sandbox.readPaths?.length
733
+ ? ` It can also read ${this.sandbox.readPaths.length} operator-allowed path${this.sandbox.readPaths.length === 1 ? '' : 's'} outside the workspace.`
734
+ : '';
735
+ return level === 'read-only'
736
+ ? `in a ${name} sandbox (read-only): it can read this workspace but write nothing, and ${net}${reads}`
737
+ : `in a ${name} sandbox (workspace-write): it can write only inside this workspace, and ${net}${reads}`;
738
+ }
739
+ /** Broker network applies only to a sandboxed command: unsandboxed ones are not confined anyway. */
740
+ private brokerNetwork(sb: LocalSandbox = this.sandbox): { socket: string } | null {
741
+ return sb.network && this.sandboxed(sb) ? sb.network : null;
742
+ }
743
+ /** Run a hook (Phase 4b) as one of this session's sandboxed commands would run: the same sandbox
744
+ * and level, no network, a minimal environment, `input` on stdin, bounded output and a timeout.
745
+ * Never as the tools user, whose process accounting is for approved operations only. */
746
+ runHook(session: string, command: string, input: string, timeoutMs: number): Promise<HookRun> {
747
+ const refused = (why: string): Promise<HookRun> =>
748
+ Promise.resolve({
749
+ code: null,
750
+ stdout: '',
751
+ stderr: '',
752
+ timedOut: false,
753
+ truncated: false,
754
+ refused: why,
755
+ });
756
+ if (this.tools.mode === 'tools-user')
757
+ return refused('Hooks do not run when commands run as a separate tools user');
758
+ const sb = this.sandboxFor(session);
759
+ if (sb.kind === 'bwrap' && sb.level !== 'full-access' && !sb.bwrap)
760
+ return refused('This host has no usable bubblewrap, so it cannot run a sandboxed hook');
761
+ if (sb.kind === 'none' && sb.level !== 'full-access' && !sb.allowUnsandboxed)
762
+ return refused(UNSANDBOXED_REFUSAL);
763
+ const root = this.store.session(session).cwd;
764
+ const tmp =
765
+ sb.kind === 'seatbelt' && sb.level !== 'full-access'
766
+ ? realpathSync(mkdtempSync(join(tmpdir(), 'salaam-hook-')))
767
+ : undefined;
768
+ const argv = this.commandArgv(root, command, tmp, null, sb);
769
+ const cap = 16 * 1024;
770
+ return new Promise<HookRun>((resolve) => {
771
+ let stdout = '',
772
+ stderr = '',
773
+ truncated = false,
774
+ timedOut = false;
775
+ const child = spawn(argv[0]!, argv.slice(1), {
776
+ cwd: root,
777
+ detached: true,
778
+ env: {
779
+ PATH: '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin',
780
+ HOME: process.env.HOME ?? root,
781
+ LANG: 'en_US.UTF-8',
782
+ TMPDIR: '/tmp',
783
+ SALAAM_PROJECT_DIR: root,
784
+ CLAUDE_PROJECT_DIR: root,
785
+ },
786
+ stdio: ['pipe', 'pipe', 'pipe'],
787
+ });
788
+ const take = (current: string, chunk: Buffer) => {
789
+ const room = cap - Buffer.byteLength(current);
790
+ if (chunk.length > room) truncated = true;
791
+ return room > 0 ? current + chunk.subarray(0, room).toString('utf8') : current;
792
+ };
793
+ child.stdout.on('data', (c: Buffer) => (stdout = take(stdout, c)));
794
+ child.stderr.on('data', (c: Buffer) => (stderr = take(stderr, c)));
795
+ child.stdin.on('error', () => {});
796
+ child.stdin.end(input);
797
+ const timer = setTimeout(() => {
798
+ timedOut = true;
799
+ try {
800
+ process.kill(-child.pid!, 'SIGKILL');
801
+ } catch {
802
+ /* already gone */
803
+ }
804
+ }, timeoutMs);
805
+ const finish = (code: number | null) => {
806
+ clearTimeout(timer);
807
+ if (tmp) rmSync(tmp, { recursive: true, force: true });
808
+ resolve({ code: timedOut ? null : code, stdout, stderr, timedOut, truncated });
809
+ };
810
+ child.once('error', (error) => {
811
+ stderr = take(stderr, Buffer.from(error.message));
812
+ finish(null);
813
+ });
814
+ child.once('close', (code) => finish(code));
815
+ });
816
+ }
817
+ /** The process a command runs as. Under the tools user, the host-side shell reports its own
818
+ * process group (the one a stop kills) before exec-ing bubblewrap, whose PID namespace would
819
+ * otherwise report a number that means nothing on the host. */
820
+ private commandArgv(
821
+ root: string,
822
+ script: string,
823
+ tmp?: string,
824
+ relay?: BrokerRelay | null,
825
+ sb: LocalSandbox = this.sandbox,
826
+ listen = false,
827
+ ): string[] {
828
+ const sandboxed = this.sandboxed(sb);
829
+ const inner = (s: string) =>
830
+ !sandboxed
831
+ ? ['/bin/sh', '-c', s]
832
+ : sb.kind === 'seatbelt'
833
+ ? seatbeltArgv(sb.level, root, tmp ?? '', ['/bin/sh', '-c', s], sb.seatbelt ?? null, {
834
+ network: false,
835
+ ...(relay?.port ? { brokerPort: relay.port } : {}),
836
+ readPaths: sb.readPaths ?? [],
837
+ ...(listen ? { listen: true } : {}),
838
+ })
839
+ : sandboxArgv(sb.level, root, ['/bin/sh', '-c', s], sb.bwrap, {
840
+ network: false,
841
+ ...(relay?.dir ? { broker: { dir: relay.dir, node: process.execPath } } : {}),
842
+ readPaths: sb.readPaths ?? [],
843
+ });
844
+ if (this.tools.mode !== 'tools-user') return inner(script);
845
+ const marker = 'umask 0002\nprintf \'salaam-tool-pgid:%s\\n\' "$$"\n';
846
+ return sandboxed
847
+ ? toolArgv(this.tools, [
848
+ '/usr/bin/setsid',
849
+ '-w',
850
+ '/bin/sh',
851
+ '-c',
852
+ marker + 'exec "$@"',
853
+ 'sh',
854
+ ...inner(script),
855
+ ])
856
+ : toolArgv(this.tools, ['/usr/bin/setsid', '-w', '/bin/sh', '-c', marker + script]);
857
+ }
858
+ private command(c: LocalCall, command: string, timeout: number, sb: LocalSandbox): void {
859
+ const broker = this.brokerNetwork(sb);
860
+ // The relay is Salaam's, outside the sandbox: it forwards to stk and counts what was reached.
861
+ const relayStarting = broker
862
+ ? BrokerRelay.start(broker.socket, sb.kind === 'seatbelt' ? 'tcp' : 'unix')
863
+ : Promise.resolve(null);
864
+ // A stop that arrives while the relay starts still cancels the command once it is spawned.
865
+ let early = false;
866
+ let stop = () => {
867
+ early = true;
868
+ };
869
+ // Seatbelt has no tmpfs: each command gets its own private tmp, removed when it ends.
870
+ const tmp =
871
+ sb.kind === 'seatbelt' && sb.level !== 'full-access'
872
+ ? realpathSync(mkdtempSync(join(tmpdir(), 'salaam-cmd-')))
873
+ : undefined;
874
+ const set = (s: () => void) => {
875
+ stop = s;
876
+ if (early) s();
877
+ };
878
+ const done = relayStarting.then(
879
+ (relay) => this.runCommand(c, command, timeout, tmp, relay, set, false, sb),
880
+ () => this.runCommand(c, command, timeout, tmp, null, set, !!broker, sb),
881
+ );
882
+ this.jobs.set(c.id, { stop: () => stop(), done });
883
+ if (tmp) void done.finally(() => rmSync(tmp, { recursive: true, force: true }));
884
+ }
885
+ private runCommand(
886
+ c: LocalCall,
887
+ command: string,
888
+ timeout: number,
889
+ tmp: string | undefined,
890
+ relay: BrokerRelay | null,
891
+ setStop: (stop: () => void) => void,
892
+ relayFailed = false,
893
+ sb: LocalSandbox = this.sandbox,
894
+ ): Promise<void> {
320
895
  let stop = () => {};
321
- const done = new Promise<void>((resolve) => {
896
+ return new Promise<void>((resolve) => {
322
897
  let output = '',
323
898
  bytes = 0,
324
899
  reason: string | null = null,
325
900
  finished = false;
326
901
  // Deliberately do not inherit the daemon's provider credentials or service tokens.
327
902
  // Hold the shell before any user instruction until its identity is durably owned.
903
+ const tools = this.tools.mode === 'tools-user';
328
904
  const script = 'IFS= read -r salaam_start_gate || exit 125\nexec 0</dev/null\n' + command;
329
- const child = spawn('/bin/sh', ['-c', script], {
905
+ // As the tools user, setsid makes the shell its own group leader so it can be killed by group.
906
+ const argv = this.commandArgv(
907
+ c.root,
908
+ script,
909
+ tmp,
910
+ relay,
911
+ sb,
912
+ JSON.parse(c.args).listen === true,
913
+ );
914
+ let toolGroup: number | null = null,
915
+ marker = tools ? '' : null,
916
+ spawned = false;
917
+ // Release the gate only once the process is owned and, as the tools user, its group is
918
+ // known: otherwise a kill before the marker could miss a command already running.
919
+ const release = () => {
920
+ if (spawned && (!tools || toolGroup) && !reason && !finished) child.stdin.end('start\n');
921
+ };
922
+ const child = spawn(argv[0]!, argv.slice(1), {
330
923
  cwd: c.root,
331
924
  detached: true,
332
925
  env: {
@@ -334,11 +927,15 @@ export class LocalCoding {
334
927
  HOME: process.env.HOME ?? c.root,
335
928
  LANG: 'en_US.UTF-8',
336
929
  TMPDIR: '/tmp',
930
+ ...(this.sandboxed(sb) ? gitIdentityEnv(c.root) : {}),
931
+ ...(relay ? proxyEnv(relay.port ?? COMMAND_NETWORK_PORT) : {}),
337
932
  },
338
933
  stdio: ['pipe', 'pipe', 'pipe'],
339
934
  });
340
935
  let reap: NodeJS.Timeout | undefined;
341
936
  const kill = () => {
937
+ if (toolGroup) killToolGroup(this.tools, toolGroup);
938
+ if (tools) child.stdin.destroy();
342
939
  if (child.pid)
343
940
  try {
344
941
  process.kill(-child.pid, 'SIGKILL');
@@ -347,17 +944,24 @@ export class LocalCoding {
347
944
  }
348
945
  if (!finished && !reap)
349
946
  reap = setTimeout(() => {
350
- reason = 'outcome_unknown';
351
- child.stdout.destroy();
352
- child.stderr.destroy();
353
- child.unref();
354
- finish(null);
947
+ if (toolGroup) killToolGroup(this.tools, toolGroup);
948
+ // A busy event loop runs timers before pending I/O: let an already delivered close
949
+ // settle the outcome before declaring it unknown.
950
+ setImmediate(() => {
951
+ if (finished) return;
952
+ reason = 'outcome_unknown';
953
+ child.stdout.destroy();
954
+ child.stderr.destroy();
955
+ child.unref();
956
+ finish(null);
957
+ });
355
958
  }, 1500);
356
959
  };
357
960
  stop = () => {
358
961
  reason ??= 'cancelled';
359
962
  kill();
360
963
  };
964
+ setStop(stop);
361
965
  const deadline = setTimeout(() => {
362
966
  reason = 'timed_out';
363
967
  kill();
@@ -382,7 +986,35 @@ export class LocalCoding {
382
986
  reason ??= 'outcome_unknown';
383
987
  kill();
384
988
  });
385
- child.stdout.on('data', collect);
989
+ child.stdout.on('data', (chunk: Buffer) => {
990
+ if (marker === null) return collect(chunk);
991
+ marker += chunk.toString('latin1');
992
+ const end = marker.indexOf('\n');
993
+ if (end < 0) {
994
+ if (marker.length > 64) {
995
+ reason = 'outcome_unknown';
996
+ kill();
997
+ }
998
+ return;
999
+ }
1000
+ const found = /^salaam-tool-pgid:(\d+)$/.exec(marker.slice(0, end));
1001
+ const rest = Buffer.from(marker.slice(end + 1), 'latin1');
1002
+ marker = null;
1003
+ if (!found) {
1004
+ reason = 'outcome_unknown';
1005
+ kill();
1006
+ return;
1007
+ }
1008
+ toolGroup = Number(found[1]);
1009
+ try {
1010
+ this.store.transaction(() => this.change(c, 'running', { tool_pgid: toolGroup }));
1011
+ } catch {
1012
+ reason = 'outcome_unknown';
1013
+ kill();
1014
+ }
1015
+ if (rest.length) collect(rest);
1016
+ release();
1017
+ });
386
1018
  child.stderr.on('data', collect);
387
1019
  child.once('spawn', () => {
388
1020
  try {
@@ -399,7 +1031,8 @@ export class LocalCoding {
399
1031
  stop();
400
1032
  return;
401
1033
  }
402
- child.stdin.end('start\n');
1034
+ spawned = true;
1035
+ release();
403
1036
  } catch {
404
1037
  reason = 'outcome_unknown';
405
1038
  kill();
@@ -414,12 +1047,30 @@ export class LocalCoding {
414
1047
  kill();
415
1048
  this.jobs.delete(c.id);
416
1049
  const state = reason ?? (error ? 'failed' : code === 0 ? 'succeeded' : 'failed');
1050
+ const network = relay
1051
+ ? relay.summary()
1052
+ : relayFailed
1053
+ ? { broker: 'unavailable' as const, connections: [] }
1054
+ : null;
1055
+ void relay?.close();
1056
+ // What the broker refused, in its own words, so the reason reaches the card.
1057
+ const refusals = (network?.connections ?? [])
1058
+ .filter((n) => n.refused && n.reason)
1059
+ .map((n) => `[network] refused ${n.target}: ${n.reason}`)
1060
+ .join('\n');
417
1061
  const result = {
418
1062
  exit_code: code,
419
- output,
1063
+ output: refusals
1064
+ ? output + (output.endsWith('\n') || !output ? '' : '\n') + refusals + '\n'
1065
+ : output,
420
1066
  state,
421
1067
  error: error ? 'Command could not start' : null,
422
- isolation: 'local-user-no-os-sandbox',
1068
+ isolation:
1069
+ (tools ? 'tools-user-' : 'local-user-') +
1070
+ (this.sandboxed(sb) ? sb.kind + '-' + sb.level : 'no-os-sandbox'),
1071
+ sandbox: this.sandboxed(sb) ? sb.kind : 'none',
1072
+ tool_uid: this.tools.uid,
1073
+ ...(network ? { network } : {}),
423
1074
  };
424
1075
  this.store.transaction(() => {
425
1076
  this.change(c, state, result);
@@ -437,13 +1088,213 @@ export class LocalCoding {
437
1088
  child.once('error', (e) => finish(null, e));
438
1089
  child.once('close', (code) => finish(code));
439
1090
  });
440
- this.jobs.set(c.id, { stop: () => stop(), done });
1091
+ }
1092
+ private assertBackground(session: string): void {
1093
+ if (!this.interactive(session))
1094
+ throw new ServiceError(
1095
+ 'background_denied',
1096
+ 'Background commands are only for an interactive local coding session',
1097
+ );
1098
+ if (this.tools.mode === 'tools-user')
1099
+ throw new ServiceError(
1100
+ 'background_denied',
1101
+ 'Background commands do not yet run as a separate tools user',
1102
+ );
1103
+ if ([...this.live.values()].filter((t) => t.session === session).length >= TASK_LIMIT)
1104
+ throw new ServiceError(
1105
+ 'background_limit',
1106
+ `A session runs at most ${TASK_LIMIT} background commands; stop one first`,
1107
+ );
1108
+ }
1109
+ /** Launch an approved background command. Its operation settles once the process is owned;
1110
+ * the process then lives as a session task until it exits, is stopped, or the session ends. */
1111
+ private background(c: LocalCall, command: string, sb: LocalSandbox): void {
1112
+ const settle = (ok: boolean, result: Record<string, unknown>) =>
1113
+ this.store.transaction(() => {
1114
+ this.change(c, ok ? 'succeeded' : 'failed', result);
1115
+ this.managed.localExecutionResultInTransaction(c.operation_id, ok, result);
1116
+ });
1117
+ try {
1118
+ this.assertBackground(c.session_id);
1119
+ } catch (error) {
1120
+ settle(false, { error: (error as Error).message, background: true });
1121
+ return;
1122
+ }
1123
+ const id = 'task_' + randomUUID().slice(0, 8);
1124
+ const tmp =
1125
+ sb.kind === 'seatbelt' && sb.level !== 'full-access'
1126
+ ? realpathSync(mkdtempSync(join(tmpdir(), 'salaam-cmd-')))
1127
+ : undefined;
1128
+ // Network for a long-lived task needs a relay that lives as long; keep it simple and honest.
1129
+ const script = 'IFS= read -r salaam_start_gate || exit 125\nexec 0</dev/null\n' + command;
1130
+ const argv = this.commandArgv(
1131
+ c.root,
1132
+ script,
1133
+ tmp,
1134
+ null,
1135
+ sb,
1136
+ JSON.parse(c.args).listen === true,
1137
+ );
1138
+ const child = spawn(argv[0]!, argv.slice(1), {
1139
+ cwd: c.root,
1140
+ detached: true,
1141
+ env: {
1142
+ PATH: '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin',
1143
+ HOME: process.env.HOME ?? c.root,
1144
+ LANG: 'en_US.UTF-8',
1145
+ TMPDIR: '/tmp',
1146
+ ...(this.sandboxed(sb) ? gitIdentityEnv(c.root) : {}),
1147
+ },
1148
+ stdio: ['pipe', 'pipe', 'pipe'],
1149
+ });
1150
+ let tail = Buffer.alloc(0),
1151
+ bytes = 0,
1152
+ ended: TaskState | null = null,
1153
+ resolve = () => {};
1154
+ const done = new Promise<void>((r) => (resolve = r));
1155
+ const kill = () => {
1156
+ if (child.pid)
1157
+ try {
1158
+ process.kill(-child.pid, 'SIGKILL');
1159
+ } catch {
1160
+ /* already gone */
1161
+ }
1162
+ };
1163
+ const task: BackgroundTask = {
1164
+ id,
1165
+ session: c.session_id,
1166
+ done,
1167
+ output: () => ({ output: tail.toString('utf8'), bytes, truncated: bytes > tail.length }),
1168
+ stop: (why) => {
1169
+ ended ??= why;
1170
+ kill();
1171
+ },
1172
+ };
1173
+ const collect = (chunk: Buffer) => {
1174
+ bytes += chunk.length;
1175
+ tail = Buffer.concat([tail, chunk]);
1176
+ if (tail.length > TASK_TAIL) tail = tail.subarray(tail.length - TASK_TAIL);
1177
+ };
1178
+ child.stdout.on('data', collect);
1179
+ child.stderr.on('data', collect);
1180
+ child.stdin.on('error', () => {});
1181
+ const lifetime = setTimeout(() => task.stop('timed_out'), BACKGROUND_LIFETIME);
1182
+ // The session, not the turn, owns the task: it ends when local coding or its root goes away.
1183
+ const watch = setInterval(() => {
1184
+ try {
1185
+ if (this.closed || !this.store.localCoding(c.session_id)) throw Error('closed');
1186
+ if (this.store.session(c.session_id).cwd !== c.root) throw Error('moved');
1187
+ } catch {
1188
+ task.stop('stopped');
1189
+ }
1190
+ }, 1000);
1191
+ const finish = (code: number | null) => {
1192
+ clearTimeout(lifetime);
1193
+ clearInterval(watch);
1194
+ kill();
1195
+ this.live.delete(id);
1196
+ this.kept.set(id, task.output());
1197
+ if (this.kept.size > 32) this.kept.delete(this.kept.keys().next().value!);
1198
+ if (tmp) rmSync(tmp, { recursive: true, force: true });
1199
+ const row = this.store.db.prepare('SELECT * FROM local_tasks WHERE id=?').get(id) as
1200
+ TaskRow | undefined;
1201
+ if (row) this.endTask(row, ended ?? 'exited', code);
1202
+ else settle(false, { error: 'Command could not start', background: true });
1203
+ resolve();
1204
+ };
1205
+ child.once('error', () => finish(null));
1206
+ child.once('close', (code) => finish(code));
1207
+ child.once('spawn', () => {
1208
+ try {
1209
+ // The start time, not processIdentity: the gated shell execs into the command.
1210
+ const identity = processStart(child.pid!);
1211
+ if (!identity) throw Error('No process identity');
1212
+ this.store.transaction(() =>
1213
+ this.store.db
1214
+ .prepare(
1215
+ "INSERT INTO local_tasks(id,session_id,call_id,command,state,pid,identity,started_at) VALUES (?,?,?,?,'running',?,?,?)",
1216
+ )
1217
+ .run(id, c.session_id, c.id, command, child.pid!, identity, new Date().toISOString()),
1218
+ );
1219
+ this.live.set(id, task);
1220
+ settle(true, {
1221
+ background: true,
1222
+ task_id: id,
1223
+ state: 'running',
1224
+ isolation:
1225
+ 'local-user-' + (this.sandboxed(sb) ? sb.kind + '-' + sb.level : 'no-os-sandbox'),
1226
+ sandbox: this.sandboxed(sb) ? sb.kind : 'none',
1227
+ note: 'Read its output with salaam_tasks; it keeps running after this turn.',
1228
+ });
1229
+ child.stdin.end('start\n');
1230
+ } catch {
1231
+ ended = 'lost';
1232
+ kill();
1233
+ }
1234
+ });
1235
+ }
1236
+ private endTask(t: TaskRow, state: TaskState, code: number | null): void {
1237
+ this.store.transaction(() => {
1238
+ this.store.db
1239
+ .prepare(
1240
+ "UPDATE local_tasks SET state=?,exit_code=?,ended_at=? WHERE id=? AND state='running'",
1241
+ )
1242
+ .run(state, code, new Date().toISOString(), t.id);
1243
+ this.store.event(t.session_id, 'local.task_finished', {
1244
+ task_id: t.id,
1245
+ command: t.command.slice(0, 200),
1246
+ state,
1247
+ exit_code: code,
1248
+ });
1249
+ });
1250
+ }
1251
+ /** The session's background tasks, newest first. */
1252
+ tasks(session: string) {
1253
+ return (
1254
+ this.store.db
1255
+ .prepare(
1256
+ 'SELECT * FROM local_tasks WHERE session_id=? ORDER BY started_at DESC, rowid DESC',
1257
+ )
1258
+ .all(session) as unknown as TaskRow[]
1259
+ ).map((t) => ({
1260
+ id: t.id,
1261
+ command: t.command,
1262
+ state: t.state,
1263
+ exit_code: t.exit_code,
1264
+ pid: t.pid,
1265
+ started_at: t.started_at,
1266
+ ended_at: t.ended_at,
1267
+ bytes: this.live.get(t.id)?.output().bytes ?? null,
1268
+ }));
1269
+ }
1270
+ private task(session: string, id: string): TaskRow {
1271
+ const row = this.store.db
1272
+ .prepare('SELECT * FROM local_tasks WHERE id=? AND session_id=?')
1273
+ .get(id, session) as TaskRow | undefined;
1274
+ if (!row) throw new ServiceError('not_found', 'No such background task in this session', 404);
1275
+ return row;
1276
+ }
1277
+ /** The kept tail of a task's output. Output of a task this controller no longer owns is gone. */
1278
+ taskOutput(session: string, id: string) {
1279
+ const row = this.task(session, id);
1280
+ const live = this.live.get(id);
1281
+ return {
1282
+ task: { id: row.id, state: row.state, exit_code: row.exit_code },
1283
+ ...(live?.output() ?? this.kept.get(id) ?? { output: '', bytes: 0, truncated: false }),
1284
+ };
1285
+ }
1286
+ stopTask(session: string, id: string) {
1287
+ this.task(session, id);
1288
+ this.live.get(id)?.stop('stopped');
1289
+ return { stopping: this.live.has(id) };
441
1290
  }
442
1291
  async close(): Promise<void> {
443
1292
  this.closed = true;
444
1293
  const jobs = [...this.jobs.values()];
445
1294
  for (const job of jobs) job.stop();
446
- await Promise.all(jobs.map((j) => j.done));
1295
+ const tasks = [...this.live.values()];
1296
+ for (const t of tasks) t.stop('stopped');
1297
+ await Promise.all([...jobs.map((j) => j.done), ...tasks.map((t) => t.done)]);
447
1298
  this.unregister();
448
1299
  }
449
1300
  }