@superjackfruitlabs/salaam 0.0.4 → 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 (97) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/src/acp-desk.js +140 -0
  3. package/dist/src/acp-outcome.js +23 -19
  4. package/dist/src/acp.js +258 -11
  5. package/dist/src/approval-rules.js +287 -0
  6. package/dist/src/attach.js +3 -1
  7. package/dist/src/autonomy.js +80 -9
  8. package/dist/src/cli-help.js +34 -4
  9. package/dist/src/cli.js +55 -3
  10. package/dist/src/command-danger.js +797 -0
  11. package/dist/src/command-sandbox.js +116 -6
  12. package/dist/src/custom-commands.js +159 -0
  13. package/dist/src/danger-classifier.js +286 -0
  14. package/dist/src/git-helpers.js +160 -0
  15. package/dist/src/hooks.js +351 -0
  16. package/dist/src/host.js +96 -4
  17. package/dist/src/house-extension.js +133 -7
  18. package/dist/src/local-coding.js +553 -58
  19. package/dist/src/local-edits.js +168 -6
  20. package/dist/src/mentions.js +277 -0
  21. package/dist/src/pi-sessions.js +5 -1
  22. package/dist/src/project-instructions.js +195 -0
  23. package/dist/src/protocol.js +4 -1
  24. package/dist/src/service.js +342 -5
  25. package/dist/src/session-pick.js +77 -0
  26. package/dist/src/shell-passthrough.js +39 -0
  27. package/dist/src/store.js +51 -4
  28. package/dist/src/supervisor.js +233 -22
  29. package/dist/src/task-context.js +8 -2
  30. package/dist/src/terminal-status.js +88 -0
  31. package/dist/src/tui-activity.js +99 -0
  32. package/dist/src/tui-classifier.js +25 -0
  33. package/dist/src/tui-client.js +186 -5
  34. package/dist/src/tui-commands.js +23 -5
  35. package/dist/src/tui-composer.js +113 -0
  36. package/dist/src/tui-keymap.js +116 -0
  37. package/dist/src/tui-mentions.js +38 -0
  38. package/dist/src/tui-modals.js +157 -34
  39. package/dist/src/tui-notify.js +62 -0
  40. package/dist/src/tui-review.js +55 -0
  41. package/dist/src/tui-turn-keys.js +52 -0
  42. package/dist/src/tui-view.js +243 -69
  43. package/dist/src/tui.js +875 -118
  44. package/dist/src/turn-checkpoints.js +221 -0
  45. package/dist/src/turn-rewind.js +168 -0
  46. package/dist/src/web-fetch.js +114 -0
  47. package/dist/src/web-guard.js +163 -0
  48. package/dist/src/web-search.js +302 -0
  49. package/npm-shrinkwrap.json +2 -2
  50. package/package.json +1 -1
  51. package/src/acp-desk.ts +155 -0
  52. package/src/acp-outcome.ts +25 -21
  53. package/src/acp.ts +338 -28
  54. package/src/approval-rules.ts +360 -0
  55. package/src/attach.ts +4 -1
  56. package/src/autonomy.ts +111 -10
  57. package/src/cli-help.ts +33 -4
  58. package/src/cli.ts +66 -2
  59. package/src/command-danger.ts +886 -0
  60. package/src/command-sandbox.ts +128 -8
  61. package/src/custom-commands.ts +175 -0
  62. package/src/danger-classifier.ts +321 -0
  63. package/src/git-helpers.ts +177 -0
  64. package/src/hooks.ts +425 -0
  65. package/src/host.ts +122 -1
  66. package/src/house-extension.ts +142 -7
  67. package/src/local-coding.ts +674 -75
  68. package/src/local-edits.ts +191 -8
  69. package/src/mentions.ts +304 -0
  70. package/src/pi-sessions.ts +9 -1
  71. package/src/project-instructions.ts +229 -0
  72. package/src/protocol.ts +19 -2
  73. package/src/service.ts +488 -5
  74. package/src/session-pick.ts +102 -0
  75. package/src/shell-passthrough.ts +38 -0
  76. package/src/store.ts +52 -3
  77. package/src/supervisor.ts +262 -24
  78. package/src/task-context.ts +9 -2
  79. package/src/terminal-status.ts +103 -0
  80. package/src/tui-activity.ts +133 -0
  81. package/src/tui-classifier.ts +38 -0
  82. package/src/tui-client.ts +239 -8
  83. package/src/tui-commands.ts +49 -10
  84. package/src/tui-composer.ts +124 -0
  85. package/src/tui-keymap.ts +152 -0
  86. package/src/tui-mentions.ts +43 -0
  87. package/src/tui-modals.ts +173 -43
  88. package/src/tui-notify.ts +77 -0
  89. package/src/tui-review.ts +76 -0
  90. package/src/tui-turn-keys.ts +77 -0
  91. package/src/tui-view.ts +341 -88
  92. package/src/tui.ts +1047 -137
  93. package/src/turn-checkpoints.ts +253 -0
  94. package/src/turn-rewind.ts +258 -0
  95. package/src/web-fetch.ts +140 -0
  96. package/src/web-guard.ts +195 -0
  97. package/src/web-search.ts +402 -0
@@ -3,13 +3,20 @@ import { sandboxArgv, seatbeltArgv, UNSANDBOXED_REFUSAL, } from './command-sandb
3
3
  import { spawn } from 'node:child_process';
4
4
  import { randomUUID } from 'node:crypto';
5
5
  import { mkdtempSync, realpathSync, rmSync } from 'node:fs';
6
- import { tmpdir } from 'node:os';
6
+ import { homedir, tmpdir } from 'node:os';
7
+ import { classifyCommand } from './command-danger.js';
8
+ import { gitIdentityEnv, publishesToRemote } from './git-helpers.js';
9
+ import { boundShellCommand } from './shell-passthrough.js';
7
10
  import { join } from 'node:path';
8
11
  import { digest, object, string, ServiceError } from './protocol.js';
9
- import { prepareLocalEdits, verifyLocalEdits, applyLocalEdits, } from './local-edits.js';
10
- import { permissionPolicy, permissionDigest, matchesEditScope, assertPermissionBinding, } from './autonomy.js';
12
+ import { prepareLocalEdits, verifyLocalEdits, applyLocalEdits, reviewLocalEdits, } from './local-edits.js';
13
+ import { permissionPolicy, autoApprovesEdits, autoApprovesCommands, permissionDigest, assertPermissionBinding, } from './autonomy.js';
14
+ import { commandArgv, evaluateRules, } from './approval-rules.js';
11
15
  import { assertUndoSettled } from './local-undo.js';
12
- import { processIdentity } from './supervisor.js';
16
+ import { processIdentity, processStart } from './supervisor.js';
17
+ const TASK_TAIL = 65536;
18
+ const TASK_LIMIT = 4;
19
+ const BACKGROUND_LIFETIME = 2 * 3600 * 1000;
13
20
  import { applyEditsAsTools, killToolGroup, serviceToolIdentity, toolArgv, } from './tool-identity.js';
14
21
  /** The proxy a broker-network command uses: only the relay on loopback, nothing direct. */
15
22
  function proxyEnv(port) {
@@ -30,8 +37,21 @@ export class LocalCoding {
30
37
  tools;
31
38
  sandbox;
32
39
  jobs = new Map();
40
+ /** Background tasks this controller owns; they belong to the session, not to a turn. */
41
+ live = new Map();
42
+ /** Output of recently ended tasks, so the model can read why one stopped. */
43
+ kept = new Map();
44
+ /** Background commands are for a person's own desk session only (Phase 3c). */
45
+ interactive = () => false;
33
46
  closed = false;
34
47
  unregister;
48
+ /** Desk-session approval rules (allow/ask/deny), set by the service; absent means every request asks. */
49
+ rules;
50
+ /** Operator ruling 2026-11-01 (option 1): one command may ask to run outside the OS sandbox.
51
+ * Off where the host forbids full access, since it is full access for that one command. */
52
+ allowOutsideSandbox = true;
53
+ /** The optional small-model command reviewer (Phase 4c); absent means the rules alone decide. */
54
+ classifier;
35
55
  constructor(store, managed, active, tools = serviceToolIdentity(),
36
56
  /** Chosen by the service from the host and the operator's setting. */
37
57
  sandbox = { kind: 'none', level: 'full-access', bwrap: null }) {
@@ -51,6 +71,19 @@ export class LocalCoding {
51
71
  throw new ServiceError('local_busy', 'Command resources are still active');
52
72
  },
53
73
  });
74
+ store.db.exec('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)');
75
+ // A background task never outlives its controller: kill what a restart left and say so.
76
+ for (const raw of store.db.prepare("SELECT * FROM local_tasks WHERE state='running'").all()) {
77
+ const t = raw;
78
+ if (t.pid && t.identity && processStart(t.pid) === t.identity)
79
+ try {
80
+ process.kill(-t.pid, 'SIGKILL');
81
+ }
82
+ catch {
83
+ /* already gone */
84
+ }
85
+ this.endTask(t, 'lost', null);
86
+ }
54
87
  // A lost controller must never retry a possibly executed edit or command.
55
88
  for (const raw of store.db.prepare("SELECT * FROM local_calls WHERE state='running'").all()) {
56
89
  const c = raw;
@@ -73,6 +106,22 @@ export class LocalCoding {
73
106
  });
74
107
  }
75
108
  }
109
+ /** The exact edits an edit request carries, or none for a command; files over 256 KiB are left out. */
110
+ edits(session, by) {
111
+ const row = this.store.db
112
+ .prepare(`SELECT root,args FROM local_calls WHERE session_id=? AND ${'operation' in by ? 'operation_id' : 'tool_call'}=? ORDER BY rowid DESC LIMIT 1`)
113
+ .get(session, 'operation' in by ? by.operation : by.toolCall);
114
+ if (!row)
115
+ return null;
116
+ const args = object(JSON.parse(row.args));
117
+ if (args.action !== 'edit')
118
+ return null;
119
+ const small = (text) => text === null || Buffer.byteLength(text) <= 256 * 1024;
120
+ return {
121
+ root: row.root,
122
+ edits: args.edits.filter((e) => small(e.before) && small(e.after)),
123
+ };
124
+ }
76
125
  get(id) {
77
126
  const row = this.store.db.prepare('SELECT * FROM local_calls WHERE id=?').get(id);
78
127
  if (!row)
@@ -114,7 +163,51 @@ export class LocalCoding {
114
163
  .get(c.root, c.id))
115
164
  throw new ServiceError('local_busy', 'Another operation or uncertain outcome owns this workspace');
116
165
  }
117
- prepare(session, epoch, attempt, toolCall, input) {
166
+ /** prepare, after the optional reviewer has looked at a command the session would run unasked. */
167
+ async prepareReviewed(session, epoch, attempt, toolCall, input) {
168
+ let review = null;
169
+ if (this.classifier && input.action === 'command' && typeof input.command === 'string') {
170
+ const p = permissionPolicy(this.store, session);
171
+ // Only where the mode would run the command without asking; review mode asks anyway.
172
+ if (p.access || p.mode === 'auto') {
173
+ const root = this.store.session(session).cwd;
174
+ if (!classifyCommand(input.command, this.dangerContext(root)).dangerous)
175
+ review = await this.classifier.review(session, input.command, root);
176
+ }
177
+ }
178
+ return this.prepare(session, epoch, attempt, toolCall, input, review);
179
+ }
180
+ dangerContext(root) {
181
+ return {
182
+ root,
183
+ home: homedir(),
184
+ tmp: [...new Set(['/tmp', '/private/tmp', tmpdir(), realpathSync(tmpdir())])],
185
+ };
186
+ }
187
+ /** Has this tool call already been prepared? */
188
+ hasCall(session, epoch, toolCall) {
189
+ return !!this.store.db
190
+ .prepare('SELECT 1 FROM local_calls WHERE session_id=? AND epoch=? AND tool_call=?')
191
+ .get(session, epoch, toolCall);
192
+ }
193
+ /** A `!command` turn runs only what was typed, once (trial polish #12): enforced here, not by
194
+ * the model following its instruction, so it holds in auto, full access and YOLO alike. */
195
+ assertShellBinding(session, attempt, input) {
196
+ const row = this.store.db
197
+ .prepare('SELECT s.text FROM attempts a JOIN submissions s ON s.id=a.submission_id WHERE a.id=? AND a.session_id=?')
198
+ .get(attempt, session);
199
+ const bound = row ? boundShellCommand(row.text) : undefined;
200
+ if (bound === undefined)
201
+ return;
202
+ if (input.action !== 'command' || input.command !== bound || input.background)
203
+ throw new ServiceError('shell_binding', `This turn runs only the command you typed: ${bound}. Run exactly that, unchanged.`);
204
+ const ran = this.store.db
205
+ .prepare('SELECT 1 FROM local_calls WHERE session_id=? AND attempt_id=?')
206
+ .get(session, attempt);
207
+ if (ran)
208
+ throw new ServiceError('shell_binding', 'This turn already ran the command you typed. Report its output; do not run anything else.');
209
+ }
210
+ prepare(session, epoch, attempt, toolCall, input, review = null) {
118
211
  const prior = this.store.db
119
212
  .prepare('SELECT id,input_digest FROM local_calls WHERE session_id=? AND epoch=? AND tool_call=?')
120
213
  .get(session, epoch, toolCall);
@@ -123,6 +216,7 @@ export class LocalCoding {
123
216
  throw new ServiceError('request_conflict', 'Tool call already binds different arguments');
124
217
  return this.view(String(prior.id));
125
218
  }
219
+ this.assertShellBinding(session, attempt, input);
126
220
  const root = this.store.session(session).cwd;
127
221
  const c = {
128
222
  id: randomUUID(),
@@ -148,36 +242,72 @@ export class LocalCoding {
148
242
  const timeout = input.timeout_ms ?? 30000;
149
243
  if (!Number.isInteger(timeout) || Number(timeout) < 100 || Number(timeout) > 120000)
150
244
  throw new ServiceError('invalid_field', 'Command timeout must be 100–120000 ms');
151
- if (this.sandbox.kind === 'bwrap' &&
152
- this.sandbox.level !== 'full-access' &&
153
- !this.sandbox.bwrap)
245
+ const background = input.background === true;
246
+ if (input.background !== undefined && typeof input.background !== 'boolean')
247
+ throw new ServiceError('invalid_field', 'background must be true or false');
248
+ if (background)
249
+ this.assertBackground(session);
250
+ if (input.listen !== undefined && typeof input.listen !== 'boolean')
251
+ throw new ServiceError('invalid_field', 'listen must be true or false');
252
+ if (input.outside_sandbox !== undefined &&
253
+ (typeof input.outside_sandbox !== 'string' || !input.outside_sandbox.trim()))
254
+ throw new ServiceError('invalid_field', 'outside_sandbox must say why the command needs to run without the sandbox');
255
+ const session_sb = this.sandboxFor(session);
256
+ // Only a sandboxed session has anything to step out of; under full access it changes nothing.
257
+ const outside = typeof input.outside_sandbox === 'string' && this.sandboxed(session_sb)
258
+ ? input.outside_sandbox.trim().slice(0, 300)
259
+ : undefined;
260
+ if (outside !== undefined && (!this.allowOutsideSandbox || this.tools.mode === 'tools-user'))
261
+ throw new ServiceError('outside_sandbox_denied', 'The operator does not allow commands outside the OS sandbox on this host. Ask the person to run it themselves.');
262
+ const sb = outside !== undefined ? { ...session_sb, level: 'full-access' } : session_sb;
263
+ // Operator ruling 2026-11-01 (option C): a sandboxed command listens only when a person
264
+ // approves that exact command to. Linux's sandbox has its own network, so it cannot serve.
265
+ const listen = input.listen === true && this.sandboxed(sb);
266
+ if (listen && sb.kind === 'bwrap')
267
+ throw new ServiceError('listen_unreachable', '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.');
268
+ if (sb.kind === 'bwrap' && sb.level !== 'full-access' && !sb.bwrap)
154
269
  throw new ServiceError('sandbox_unavailable', 'This host has no usable bubblewrap, so it cannot run a sandboxed command; an operator can choose full access explicitly');
155
- if (this.sandbox.kind === 'none' &&
156
- this.sandbox.level !== 'full-access' &&
157
- !this.sandbox.allowUnsandboxed)
270
+ if (sb.kind === 'none' && sb.level !== 'full-access' && !sb.allowUnsandboxed)
158
271
  throw new ServiceError('sandbox_unavailable', UNSANDBOXED_REFUSAL);
159
272
  args = {
160
273
  action: 'command',
161
274
  command: string(input.command, 'command', 8192),
162
- timeout_ms: Number(timeout),
275
+ timeout_ms: background ? BACKGROUND_LIFETIME : Number(timeout),
276
+ ...(background ? { background: true } : {}),
277
+ ...(listen ? { listen: true } : {}),
278
+ ...(outside !== undefined ? { outside_sandbox: outside } : {}),
163
279
  // Bound into the approval and the operation digest: the sandbox is part of what is approved.
164
280
  sandbox: {
165
- kind: this.sandbox.kind,
166
- level: this.sandbox.level,
167
- bwrap: this.sandbox.bwrap?.version ?? null,
168
- ...(this.brokerNetwork() ? { network: 'broker' } : {}),
281
+ kind: sb.kind,
282
+ level: sb.level,
283
+ bwrap: sb.bwrap?.version ?? null,
284
+ ...(this.brokerNetwork(sb) ? { network: 'broker' } : {}),
169
285
  },
170
286
  };
171
287
  }
172
288
  else
173
289
  throw new ServiceError('invalid_field', 'Choose edit or command');
174
290
  c.args = JSON.stringify(args);
291
+ // Operator ruling 2026-10-31: full access asks only for commands this check calls dangerous.
292
+ const rulesDanger = args.action === 'command'
293
+ ? classifyCommand(args.command, this.dangerContext(root))
294
+ : { dangerous: false };
295
+ // Phase 4c: the reviewer can only add a reason to ask, never remove one.
296
+ const escalated = !rulesDanger.dangerous && args.action === 'command' && !!review?.escalate;
297
+ const danger = escalated
298
+ ? {
299
+ dangerous: true,
300
+ category: 'classifier',
301
+ reason: `${review.reason} (reviewed by ${review.model})`,
302
+ segment: args.command,
303
+ }
304
+ : rulesDanger;
305
+ // Pushing and opening a pull request ask in every mode but YOLO (operator ruling 2026-10-31).
306
+ const publishes = args.action === 'command' ? publishesToRemote(args.command) : null;
175
307
  this.store.transaction(() => {
176
308
  const reviewed = 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 ${this.tools.mode === 'tools-user' ? `tools user ${this.tools.user} (uid ${this.tools.uid})` : 'your local user'}, ${this.sandboxText()}\nDirectory: ${root}\nTimeout: ${args.timeout_ms} ms\nCommand:\n${args.command}`;
309
+ ? reviewLocalEdits(args.edits)
310
+ : `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
311
  const permission = this.managed.prepareWorkspaceInTransaction(session, epoch, attempt, toolCall, `Local ${kind}: ${root}`, reviewed, digest({ root, args }));
182
312
  c.operation_id = permission.operation.id;
183
313
  this.store.db
@@ -189,9 +319,73 @@ export class LocalCoding {
189
319
  action: kind,
190
320
  });
191
321
  });
322
+ if (review)
323
+ this.store.transaction(() => this.store.event(session, 'permission.classifier', {
324
+ call_id: c.id,
325
+ operation_id: c.operation_id,
326
+ model: review.model,
327
+ verdict: review.verdict,
328
+ escalated,
329
+ ...(review.reason ? { reason: review.reason } : {}),
330
+ }));
192
331
  const policy = permissionPolicy(this.store, session);
193
- if (args.action === 'edit' &&
194
- matchesEditScope(policy, args.edits.map((e) => e.path))) {
332
+ // Desk-session rules: a matching deny refuses, a matching allow approves and is recorded with the
333
+ // rule that decided it; anything else falls through to the scoped-edit policy and then asks.
334
+ if (this.rules && policy.mode !== 'inspect' && this.rules.applies(session)) {
335
+ const request = args.action === 'command'
336
+ ? { tool: 'command', argv: commandArgv(args.command) }
337
+ : { tool: 'edit', paths: args.edits.map((e) => e.path) };
338
+ // A repo's allow rules cannot approve a dangerous or publishing command: only the person's own
339
+ // rules can.
340
+ const usable = (all) => danger.dangerous ||
341
+ publishes ||
342
+ (args.action === 'command' && (args.listen || args.outside_sandbox))
343
+ ? all.filter((r) => !(r.source === 'repo' && r.effect === 'allow'))
344
+ : all;
345
+ const decided = evaluateRules(usable(this.rules.list(session)), request);
346
+ const approval = this.managed.forOperation(c.operation_id);
347
+ const rule = decided.rule && {
348
+ id: decided.rule.id,
349
+ source: decided.rule.source,
350
+ effect: decided.rule.effect,
351
+ tool: decided.rule.tool,
352
+ ...(decided.rule.command ? { command: decided.rule.command } : {}),
353
+ ...(decided.rule.paths ? { paths: decided.rule.paths } : {}),
354
+ };
355
+ if (decided.effect === 'deny' && rule) {
356
+ this.managed.decide(approval.id, 'rule-deny:' + c.id, approval.revision, approval.operation_digest, 'deny');
357
+ this.store.transaction(() => this.store.event(session, 'permission.rule-denied', {
358
+ rule,
359
+ call_id: c.id,
360
+ operation_id: c.operation_id,
361
+ }));
362
+ }
363
+ else if (decided.effect === 'allow' && rule) {
364
+ const rules = this.rules;
365
+ this.managed.decide(approval.id, 'rule:' + c.id, approval.revision, approval.operation_digest, 'approve', {
366
+ receipt: {
367
+ policy_digest: permissionDigest(policy),
368
+ policy_revision: policy.revision,
369
+ rule,
370
+ root,
371
+ call_id: c.id,
372
+ },
373
+ decidedBy: 'rule:' + rule.source,
374
+ authorize: () => {
375
+ this.guard(c);
376
+ const again = evaluateRules(usable(rules.list(session)), request);
377
+ if (again.effect !== 'allow' || again.rule?.id !== rule.id)
378
+ throw new ServiceError('permission_changed', 'Approval rules changed');
379
+ },
380
+ });
381
+ }
382
+ }
383
+ // The session's mode approves by itself only what it covers (Phase 2b): scoped or ordinary
384
+ // project edits, and, in auto, commands that stay inside the OS sandbox. Re-checked at decision.
385
+ const covered = (p) => args.action === 'edit'
386
+ ? autoApprovesEdits(p, args.edits.map((e) => e.path))
387
+ : autoApprovesCommands(p, this.sandboxed(this.bound(args)), danger.dangerous, publishes !== null || args.listen === true || !!args.outside_sandbox) && !(escalated && p.access !== 'yolo');
388
+ if (this.managed.forOperation(c.operation_id).state === 'pending' && covered(policy)) {
195
389
  const approval = this.managed.forOperation(c.operation_id);
196
390
  this.managed.decide(approval.id, 'scoped:' + c.id, approval.revision, approval.operation_digest, 'approve', {
197
391
  receipt: {
@@ -203,13 +397,25 @@ export class LocalCoding {
203
397
  authorize: () => {
204
398
  this.guard(c);
205
399
  const fresh = permissionPolicy(this.store, session);
206
- if (permissionDigest(fresh) !== permissionDigest(policy) ||
207
- args.action !== 'edit' ||
208
- !matchesEditScope(fresh, args.edits.map((e) => e.path)))
400
+ if (permissionDigest(fresh) !== permissionDigest(policy) || !covered(fresh))
209
401
  throw new ServiceError('permission_changed', 'Scoped edit policy changed');
210
- verifyLocalEdits(root, args.edits);
402
+ if (args.action === 'edit')
403
+ verifyLocalEdits(root, args.edits);
211
404
  },
212
405
  });
406
+ // YOLO approves everything; each approval is recorded with what full access would have asked.
407
+ if (policy.access === 'yolo')
408
+ this.store.transaction(() => this.store.event(session, 'permission.yolo-approved', {
409
+ call_id: c.id,
410
+ operation_id: c.operation_id,
411
+ action: args.action,
412
+ ...(args.action === 'command' ? { command: args.command } : {}),
413
+ ...(danger.dangerous
414
+ ? { would_have_asked: { category: danger.category, reason: danger.reason } }
415
+ : publishes
416
+ ? { would_have_asked: { category: 'publish', reason: publishes } }
417
+ : {}),
418
+ }));
213
419
  }
214
420
  return this.view(c.id);
215
421
  }
@@ -278,44 +484,154 @@ export class LocalCoding {
278
484
  });
279
485
  }
280
486
  }
487
+ else if (args.background === true)
488
+ this.background(c, String(args.command), this.bound(args));
281
489
  else
282
- this.command(c, String(args.command), Number(args.timeout_ms));
490
+ this.command(c, String(args.command), Number(args.timeout_ms), this.bound(args));
283
491
  return this.view(id);
284
492
  }
285
- sandboxText() {
286
- const { kind, level } = this.sandbox;
493
+ /** The sandbox for a new command in this session: the host's, or full access if the person chose
494
+ * it for this session (Phase 2b). Bound into the command's approval and digest. */
495
+ sandboxFor(session) {
496
+ return permissionPolicy(this.store, session).access
497
+ ? { ...this.sandbox, level: 'full-access' }
498
+ : this.sandbox;
499
+ }
500
+ /** The sandbox a prepared command was approved under. */
501
+ bound(args) {
502
+ return args.sandbox?.level === 'full-access'
503
+ ? { ...this.sandbox, level: 'full-access' }
504
+ : this.sandbox;
505
+ }
506
+ sandboxed(sb) {
507
+ return sb.kind !== 'none' && sb.level !== 'full-access';
508
+ }
509
+ /** Where this session's next command runs, in a word or two for the status and side panels. */
510
+ commandSandbox(session) {
511
+ const access = permissionPolicy(this.store, session).access;
512
+ if (access)
513
+ return access === 'yolo' ? 'YOLO' : 'full access';
514
+ if (this.sandbox.level === 'full-access')
515
+ return 'no sandbox';
516
+ if (this.sandbox.kind === 'none')
517
+ return this.sandbox.allowUnsandboxed ? 'no sandbox' : 'refused';
518
+ return this.sandbox.kind === 'seatbelt' ? 'Seatbelt' : 'bubblewrap';
519
+ }
520
+ sandboxText(sb = this.sandbox, outside = false) {
521
+ const { kind, level } = sb;
522
+ if (outside)
523
+ return 'unsandboxed for this one command, at its own request: it can read and change any file your user can and reach the network.';
287
524
  if (level === 'full-access')
288
- return 'unsandboxed: the operator chose full access. May access files and network beyond this workspace.';
525
+ return this.sandbox.level === 'full-access'
526
+ ? 'unsandboxed: the operator chose full access. May access files and network beyond this workspace.'
527
+ : 'unsandboxed: you turned on full access for this session. May access files and network beyond this workspace.';
289
528
  if (kind === 'none')
290
529
  return 'unsandboxed: this host has no usable command sandbox, so the command may access files and network beyond this workspace.';
291
530
  const name = kind === 'seatbelt' ? 'Seatbelt' : 'bubblewrap';
292
- const net = this.brokerNetwork()
531
+ const net = this.brokerNetwork(sb)
293
532
  ? 'network: through the broker allowlist (hosts set by the stk policy), nothing direct.'
294
533
  : 'has no network.';
534
+ const reads = this.sandbox.readPaths?.length
535
+ ? ` It can also read ${this.sandbox.readPaths.length} operator-allowed path${this.sandbox.readPaths.length === 1 ? '' : 's'} outside the workspace.`
536
+ : '';
295
537
  return level === 'read-only'
296
- ? `in a ${name} sandbox (read-only): it can read this workspace but write nothing, and ${net}`
297
- : `in a ${name} sandbox (workspace-write): it can write only inside this workspace, and ${net}`;
538
+ ? `in a ${name} sandbox (read-only): it can read this workspace but write nothing, and ${net}${reads}`
539
+ : `in a ${name} sandbox (workspace-write): it can write only inside this workspace, and ${net}${reads}`;
298
540
  }
299
541
  /** Broker network applies only to a sandboxed command: unsandboxed ones are not confined anyway. */
300
- brokerNetwork() {
301
- return this.sandbox.network &&
302
- this.sandbox.kind !== 'none' &&
303
- this.sandbox.level !== 'full-access'
304
- ? this.sandbox.network
305
- : null;
542
+ brokerNetwork(sb = this.sandbox) {
543
+ return sb.network && this.sandboxed(sb) ? sb.network : null;
544
+ }
545
+ /** Run a hook (Phase 4b) as one of this session's sandboxed commands would run: the same sandbox
546
+ * and level, no network, a minimal environment, `input` on stdin, bounded output and a timeout.
547
+ * Never as the tools user, whose process accounting is for approved operations only. */
548
+ runHook(session, command, input, timeoutMs) {
549
+ const refused = (why) => Promise.resolve({
550
+ code: null,
551
+ stdout: '',
552
+ stderr: '',
553
+ timedOut: false,
554
+ truncated: false,
555
+ refused: why,
556
+ });
557
+ if (this.tools.mode === 'tools-user')
558
+ return refused('Hooks do not run when commands run as a separate tools user');
559
+ const sb = this.sandboxFor(session);
560
+ if (sb.kind === 'bwrap' && sb.level !== 'full-access' && !sb.bwrap)
561
+ return refused('This host has no usable bubblewrap, so it cannot run a sandboxed hook');
562
+ if (sb.kind === 'none' && sb.level !== 'full-access' && !sb.allowUnsandboxed)
563
+ return refused(UNSANDBOXED_REFUSAL);
564
+ const root = this.store.session(session).cwd;
565
+ const tmp = sb.kind === 'seatbelt' && sb.level !== 'full-access'
566
+ ? realpathSync(mkdtempSync(join(tmpdir(), 'salaam-hook-')))
567
+ : undefined;
568
+ const argv = this.commandArgv(root, command, tmp, null, sb);
569
+ const cap = 16 * 1024;
570
+ return new Promise((resolve) => {
571
+ let stdout = '', stderr = '', truncated = false, timedOut = false;
572
+ const child = spawn(argv[0], argv.slice(1), {
573
+ cwd: root,
574
+ detached: true,
575
+ env: {
576
+ PATH: '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin',
577
+ HOME: process.env.HOME ?? root,
578
+ LANG: 'en_US.UTF-8',
579
+ TMPDIR: '/tmp',
580
+ SALAAM_PROJECT_DIR: root,
581
+ CLAUDE_PROJECT_DIR: root,
582
+ },
583
+ stdio: ['pipe', 'pipe', 'pipe'],
584
+ });
585
+ const take = (current, chunk) => {
586
+ const room = cap - Buffer.byteLength(current);
587
+ if (chunk.length > room)
588
+ truncated = true;
589
+ return room > 0 ? current + chunk.subarray(0, room).toString('utf8') : current;
590
+ };
591
+ child.stdout.on('data', (c) => (stdout = take(stdout, c)));
592
+ child.stderr.on('data', (c) => (stderr = take(stderr, c)));
593
+ child.stdin.on('error', () => { });
594
+ child.stdin.end(input);
595
+ const timer = setTimeout(() => {
596
+ timedOut = true;
597
+ try {
598
+ process.kill(-child.pid, 'SIGKILL');
599
+ }
600
+ catch {
601
+ /* already gone */
602
+ }
603
+ }, timeoutMs);
604
+ const finish = (code) => {
605
+ clearTimeout(timer);
606
+ if (tmp)
607
+ rmSync(tmp, { recursive: true, force: true });
608
+ resolve({ code: timedOut ? null : code, stdout, stderr, timedOut, truncated });
609
+ };
610
+ child.once('error', (error) => {
611
+ stderr = take(stderr, Buffer.from(error.message));
612
+ finish(null);
613
+ });
614
+ child.once('close', (code) => finish(code));
615
+ });
306
616
  }
307
617
  /** The process a command runs as. Under the tools user, the host-side shell reports its own
308
618
  * process group (the one a stop kills) before exec-ing bubblewrap, whose PID namespace would
309
619
  * otherwise report a number that means nothing on the host. */
310
- commandArgv(root, script, tmp, relay) {
311
- const sandboxed = this.sandbox.kind !== 'none' && this.sandbox.level !== 'full-access';
620
+ commandArgv(root, script, tmp, relay, sb = this.sandbox, listen = false) {
621
+ const sandboxed = this.sandboxed(sb);
312
622
  const inner = (s) => !sandboxed
313
623
  ? ['/bin/sh', '-c', s]
314
- : this.sandbox.kind === 'seatbelt'
315
- ? seatbeltArgv(this.sandbox.level, root, tmp ?? '', ['/bin/sh', '-c', s], this.sandbox.seatbelt ?? null, { network: false, ...(relay?.port ? { brokerPort: relay.port } : {}) })
316
- : sandboxArgv(this.sandbox.level, root, ['/bin/sh', '-c', s], this.sandbox.bwrap, {
624
+ : sb.kind === 'seatbelt'
625
+ ? seatbeltArgv(sb.level, root, tmp ?? '', ['/bin/sh', '-c', s], sb.seatbelt ?? null, {
626
+ network: false,
627
+ ...(relay?.port ? { brokerPort: relay.port } : {}),
628
+ readPaths: sb.readPaths ?? [],
629
+ ...(listen ? { listen: true } : {}),
630
+ })
631
+ : sandboxArgv(sb.level, root, ['/bin/sh', '-c', s], sb.bwrap, {
317
632
  network: false,
318
633
  ...(relay?.dir ? { broker: { dir: relay.dir, node: process.execPath } } : {}),
634
+ readPaths: sb.readPaths ?? [],
319
635
  });
320
636
  if (this.tools.mode !== 'tools-user')
321
637
  return inner(script);
@@ -332,11 +648,11 @@ export class LocalCoding {
332
648
  ])
333
649
  : toolArgv(this.tools, ['/usr/bin/setsid', '-w', '/bin/sh', '-c', marker + script]);
334
650
  }
335
- command(c, command, timeout) {
336
- const broker = this.brokerNetwork();
651
+ command(c, command, timeout, sb) {
652
+ const broker = this.brokerNetwork(sb);
337
653
  // The relay is Salaam's, outside the sandbox: it forwards to stk and counts what was reached.
338
654
  const relayStarting = broker
339
- ? BrokerRelay.start(broker.socket, this.sandbox.kind === 'seatbelt' ? 'tcp' : 'unix')
655
+ ? BrokerRelay.start(broker.socket, sb.kind === 'seatbelt' ? 'tcp' : 'unix')
340
656
  : Promise.resolve(null);
341
657
  // A stop that arrives while the relay starts still cancels the command once it is spawned.
342
658
  let early = false;
@@ -344,7 +660,7 @@ export class LocalCoding {
344
660
  early = true;
345
661
  };
346
662
  // Seatbelt has no tmpfs: each command gets its own private tmp, removed when it ends.
347
- const tmp = this.sandbox.kind === 'seatbelt' && this.sandbox.level !== 'full-access'
663
+ const tmp = sb.kind === 'seatbelt' && sb.level !== 'full-access'
348
664
  ? realpathSync(mkdtempSync(join(tmpdir(), 'salaam-cmd-')))
349
665
  : undefined;
350
666
  const set = (s) => {
@@ -352,12 +668,12 @@ export class LocalCoding {
352
668
  if (early)
353
669
  s();
354
670
  };
355
- const done = relayStarting.then((relay) => this.runCommand(c, command, timeout, tmp, relay, set), () => this.runCommand(c, command, timeout, tmp, null, set, !!broker));
671
+ const done = relayStarting.then((relay) => this.runCommand(c, command, timeout, tmp, relay, set, false, sb), () => this.runCommand(c, command, timeout, tmp, null, set, !!broker, sb));
356
672
  this.jobs.set(c.id, { stop: () => stop(), done });
357
673
  if (tmp)
358
674
  void done.finally(() => rmSync(tmp, { recursive: true, force: true }));
359
675
  }
360
- runCommand(c, command, timeout, tmp, relay, setStop, relayFailed = false) {
676
+ runCommand(c, command, timeout, tmp, relay, setStop, relayFailed = false, sb = this.sandbox) {
361
677
  let stop = () => { };
362
678
  return new Promise((resolve) => {
363
679
  let output = '', bytes = 0, reason = null, finished = false;
@@ -366,7 +682,7 @@ export class LocalCoding {
366
682
  const tools = this.tools.mode === 'tools-user';
367
683
  const script = 'IFS= read -r salaam_start_gate || exit 125\nexec 0</dev/null\n' + command;
368
684
  // As the tools user, setsid makes the shell its own group leader so it can be killed by group.
369
- const argv = this.commandArgv(c.root, script, tmp, relay);
685
+ const argv = this.commandArgv(c.root, script, tmp, relay, sb, JSON.parse(c.args).listen === true);
370
686
  let toolGroup = null, marker = tools ? '' : null, spawned = false;
371
687
  // Release the gate only once the process is owned and, as the tools user, its group is
372
688
  // known: otherwise a kill before the marker could miss a command already running.
@@ -382,6 +698,7 @@ export class LocalCoding {
382
698
  HOME: process.env.HOME ?? c.root,
383
699
  LANG: 'en_US.UTF-8',
384
700
  TMPDIR: '/tmp',
701
+ ...(this.sandboxed(sb) ? gitIdentityEnv(c.root) : {}),
385
702
  ...(relay ? proxyEnv(relay.port ?? COMMAND_NETWORK_PORT) : {}),
386
703
  },
387
704
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -532,12 +849,8 @@ export class LocalCoding {
532
849
  state,
533
850
  error: error ? 'Command could not start' : null,
534
851
  isolation: (tools ? 'tools-user-' : 'local-user-') +
535
- (this.sandbox.kind !== 'none' && this.sandbox.level !== 'full-access'
536
- ? this.sandbox.kind + '-' + this.sandbox.level
537
- : 'no-os-sandbox'),
538
- sandbox: this.sandbox.kind !== 'none' && this.sandbox.level !== 'full-access'
539
- ? this.sandbox.kind
540
- : 'none',
852
+ (this.sandboxed(sb) ? sb.kind + '-' + sb.level : 'no-os-sandbox'),
853
+ sandbox: this.sandboxed(sb) ? sb.kind : 'none',
541
854
  tool_uid: this.tools.uid,
542
855
  ...(network ? { network } : {}),
543
856
  };
@@ -554,12 +867,194 @@ export class LocalCoding {
554
867
  child.once('close', (code) => finish(code));
555
868
  });
556
869
  }
870
+ assertBackground(session) {
871
+ if (!this.interactive(session))
872
+ throw new ServiceError('background_denied', 'Background commands are only for an interactive local coding session');
873
+ if (this.tools.mode === 'tools-user')
874
+ throw new ServiceError('background_denied', 'Background commands do not yet run as a separate tools user');
875
+ if ([...this.live.values()].filter((t) => t.session === session).length >= TASK_LIMIT)
876
+ throw new ServiceError('background_limit', `A session runs at most ${TASK_LIMIT} background commands; stop one first`);
877
+ }
878
+ /** Launch an approved background command. Its operation settles once the process is owned;
879
+ * the process then lives as a session task until it exits, is stopped, or the session ends. */
880
+ background(c, command, sb) {
881
+ const settle = (ok, result) => this.store.transaction(() => {
882
+ this.change(c, ok ? 'succeeded' : 'failed', result);
883
+ this.managed.localExecutionResultInTransaction(c.operation_id, ok, result);
884
+ });
885
+ try {
886
+ this.assertBackground(c.session_id);
887
+ }
888
+ catch (error) {
889
+ settle(false, { error: error.message, background: true });
890
+ return;
891
+ }
892
+ const id = 'task_' + randomUUID().slice(0, 8);
893
+ const tmp = sb.kind === 'seatbelt' && sb.level !== 'full-access'
894
+ ? realpathSync(mkdtempSync(join(tmpdir(), 'salaam-cmd-')))
895
+ : undefined;
896
+ // Network for a long-lived task needs a relay that lives as long; keep it simple and honest.
897
+ const script = 'IFS= read -r salaam_start_gate || exit 125\nexec 0</dev/null\n' + command;
898
+ const argv = this.commandArgv(c.root, script, tmp, null, sb, JSON.parse(c.args).listen === true);
899
+ const child = spawn(argv[0], argv.slice(1), {
900
+ cwd: c.root,
901
+ detached: true,
902
+ env: {
903
+ PATH: '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin',
904
+ HOME: process.env.HOME ?? c.root,
905
+ LANG: 'en_US.UTF-8',
906
+ TMPDIR: '/tmp',
907
+ ...(this.sandboxed(sb) ? gitIdentityEnv(c.root) : {}),
908
+ },
909
+ stdio: ['pipe', 'pipe', 'pipe'],
910
+ });
911
+ let tail = Buffer.alloc(0), bytes = 0, ended = null, resolve = () => { };
912
+ const done = new Promise((r) => (resolve = r));
913
+ const kill = () => {
914
+ if (child.pid)
915
+ try {
916
+ process.kill(-child.pid, 'SIGKILL');
917
+ }
918
+ catch {
919
+ /* already gone */
920
+ }
921
+ };
922
+ const task = {
923
+ id,
924
+ session: c.session_id,
925
+ done,
926
+ output: () => ({ output: tail.toString('utf8'), bytes, truncated: bytes > tail.length }),
927
+ stop: (why) => {
928
+ ended ??= why;
929
+ kill();
930
+ },
931
+ };
932
+ const collect = (chunk) => {
933
+ bytes += chunk.length;
934
+ tail = Buffer.concat([tail, chunk]);
935
+ if (tail.length > TASK_TAIL)
936
+ tail = tail.subarray(tail.length - TASK_TAIL);
937
+ };
938
+ child.stdout.on('data', collect);
939
+ child.stderr.on('data', collect);
940
+ child.stdin.on('error', () => { });
941
+ const lifetime = setTimeout(() => task.stop('timed_out'), BACKGROUND_LIFETIME);
942
+ // The session, not the turn, owns the task: it ends when local coding or its root goes away.
943
+ const watch = setInterval(() => {
944
+ try {
945
+ if (this.closed || !this.store.localCoding(c.session_id))
946
+ throw Error('closed');
947
+ if (this.store.session(c.session_id).cwd !== c.root)
948
+ throw Error('moved');
949
+ }
950
+ catch {
951
+ task.stop('stopped');
952
+ }
953
+ }, 1000);
954
+ const finish = (code) => {
955
+ clearTimeout(lifetime);
956
+ clearInterval(watch);
957
+ kill();
958
+ this.live.delete(id);
959
+ this.kept.set(id, task.output());
960
+ if (this.kept.size > 32)
961
+ this.kept.delete(this.kept.keys().next().value);
962
+ if (tmp)
963
+ rmSync(tmp, { recursive: true, force: true });
964
+ const row = this.store.db.prepare('SELECT * FROM local_tasks WHERE id=?').get(id);
965
+ if (row)
966
+ this.endTask(row, ended ?? 'exited', code);
967
+ else
968
+ settle(false, { error: 'Command could not start', background: true });
969
+ resolve();
970
+ };
971
+ child.once('error', () => finish(null));
972
+ child.once('close', (code) => finish(code));
973
+ child.once('spawn', () => {
974
+ try {
975
+ // The start time, not processIdentity: the gated shell execs into the command.
976
+ const identity = processStart(child.pid);
977
+ if (!identity)
978
+ throw Error('No process identity');
979
+ this.store.transaction(() => this.store.db
980
+ .prepare("INSERT INTO local_tasks(id,session_id,call_id,command,state,pid,identity,started_at) VALUES (?,?,?,?,'running',?,?,?)")
981
+ .run(id, c.session_id, c.id, command, child.pid, identity, new Date().toISOString()));
982
+ this.live.set(id, task);
983
+ settle(true, {
984
+ background: true,
985
+ task_id: id,
986
+ state: 'running',
987
+ isolation: 'local-user-' + (this.sandboxed(sb) ? sb.kind + '-' + sb.level : 'no-os-sandbox'),
988
+ sandbox: this.sandboxed(sb) ? sb.kind : 'none',
989
+ note: 'Read its output with salaam_tasks; it keeps running after this turn.',
990
+ });
991
+ child.stdin.end('start\n');
992
+ }
993
+ catch {
994
+ ended = 'lost';
995
+ kill();
996
+ }
997
+ });
998
+ }
999
+ endTask(t, state, code) {
1000
+ this.store.transaction(() => {
1001
+ this.store.db
1002
+ .prepare("UPDATE local_tasks SET state=?,exit_code=?,ended_at=? WHERE id=? AND state='running'")
1003
+ .run(state, code, new Date().toISOString(), t.id);
1004
+ this.store.event(t.session_id, 'local.task_finished', {
1005
+ task_id: t.id,
1006
+ command: t.command.slice(0, 200),
1007
+ state,
1008
+ exit_code: code,
1009
+ });
1010
+ });
1011
+ }
1012
+ /** The session's background tasks, newest first. */
1013
+ tasks(session) {
1014
+ return this.store.db
1015
+ .prepare('SELECT * FROM local_tasks WHERE session_id=? ORDER BY started_at DESC, rowid DESC')
1016
+ .all(session).map((t) => ({
1017
+ id: t.id,
1018
+ command: t.command,
1019
+ state: t.state,
1020
+ exit_code: t.exit_code,
1021
+ pid: t.pid,
1022
+ started_at: t.started_at,
1023
+ ended_at: t.ended_at,
1024
+ bytes: this.live.get(t.id)?.output().bytes ?? null,
1025
+ }));
1026
+ }
1027
+ task(session, id) {
1028
+ const row = this.store.db
1029
+ .prepare('SELECT * FROM local_tasks WHERE id=? AND session_id=?')
1030
+ .get(id, session);
1031
+ if (!row)
1032
+ throw new ServiceError('not_found', 'No such background task in this session', 404);
1033
+ return row;
1034
+ }
1035
+ /** The kept tail of a task's output. Output of a task this controller no longer owns is gone. */
1036
+ taskOutput(session, id) {
1037
+ const row = this.task(session, id);
1038
+ const live = this.live.get(id);
1039
+ return {
1040
+ task: { id: row.id, state: row.state, exit_code: row.exit_code },
1041
+ ...(live?.output() ?? this.kept.get(id) ?? { output: '', bytes: 0, truncated: false }),
1042
+ };
1043
+ }
1044
+ stopTask(session, id) {
1045
+ this.task(session, id);
1046
+ this.live.get(id)?.stop('stopped');
1047
+ return { stopping: this.live.has(id) };
1048
+ }
557
1049
  async close() {
558
1050
  this.closed = true;
559
1051
  const jobs = [...this.jobs.values()];
560
1052
  for (const job of jobs)
561
1053
  job.stop();
562
- await Promise.all(jobs.map((j) => j.done));
1054
+ const tasks = [...this.live.values()];
1055
+ for (const t of tasks)
1056
+ t.stop('stopped');
1057
+ await Promise.all([...jobs.map((j) => j.done), ...tasks.map((t) => t.done)]);
563
1058
  this.unregister();
564
1059
  }
565
1060
  }