@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.
- package/CHANGELOG.md +41 -0
- package/dist/src/acp-desk.js +140 -0
- package/dist/src/acp-outcome.js +23 -19
- package/dist/src/acp.js +258 -11
- package/dist/src/approval-rules.js +287 -0
- package/dist/src/attach.js +3 -1
- package/dist/src/autonomy.js +80 -9
- package/dist/src/cli-help.js +34 -4
- package/dist/src/cli.js +55 -3
- package/dist/src/command-danger.js +797 -0
- package/dist/src/command-sandbox.js +116 -6
- package/dist/src/custom-commands.js +159 -0
- package/dist/src/danger-classifier.js +286 -0
- package/dist/src/git-helpers.js +160 -0
- package/dist/src/hooks.js +351 -0
- package/dist/src/host.js +96 -4
- package/dist/src/house-extension.js +133 -7
- package/dist/src/local-coding.js +553 -58
- package/dist/src/local-edits.js +168 -6
- package/dist/src/mentions.js +277 -0
- package/dist/src/pi-sessions.js +5 -1
- package/dist/src/project-instructions.js +195 -0
- package/dist/src/protocol.js +4 -1
- package/dist/src/service.js +342 -5
- package/dist/src/session-pick.js +77 -0
- package/dist/src/shell-passthrough.js +39 -0
- package/dist/src/store.js +51 -4
- package/dist/src/supervisor.js +233 -22
- package/dist/src/task-context.js +8 -2
- package/dist/src/terminal-status.js +88 -0
- package/dist/src/tui-activity.js +99 -0
- package/dist/src/tui-classifier.js +25 -0
- package/dist/src/tui-client.js +186 -5
- package/dist/src/tui-commands.js +23 -5
- package/dist/src/tui-composer.js +113 -0
- package/dist/src/tui-keymap.js +116 -0
- package/dist/src/tui-mentions.js +38 -0
- package/dist/src/tui-modals.js +157 -34
- package/dist/src/tui-notify.js +62 -0
- package/dist/src/tui-review.js +55 -0
- package/dist/src/tui-turn-keys.js +52 -0
- package/dist/src/tui-view.js +243 -69
- package/dist/src/tui.js +875 -118
- package/dist/src/turn-checkpoints.js +221 -0
- package/dist/src/turn-rewind.js +168 -0
- package/dist/src/web-fetch.js +114 -0
- package/dist/src/web-guard.js +163 -0
- package/dist/src/web-search.js +302 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/src/acp-desk.ts +155 -0
- package/src/acp-outcome.ts +25 -21
- package/src/acp.ts +338 -28
- package/src/approval-rules.ts +360 -0
- package/src/attach.ts +4 -1
- package/src/autonomy.ts +111 -10
- package/src/cli-help.ts +33 -4
- package/src/cli.ts +66 -2
- package/src/command-danger.ts +886 -0
- package/src/command-sandbox.ts +128 -8
- package/src/custom-commands.ts +175 -0
- package/src/danger-classifier.ts +321 -0
- package/src/git-helpers.ts +177 -0
- package/src/hooks.ts +425 -0
- package/src/host.ts +122 -1
- package/src/house-extension.ts +142 -7
- package/src/local-coding.ts +674 -75
- package/src/local-edits.ts +191 -8
- package/src/mentions.ts +304 -0
- package/src/pi-sessions.ts +9 -1
- package/src/project-instructions.ts +229 -0
- package/src/protocol.ts +19 -2
- package/src/service.ts +488 -5
- package/src/session-pick.ts +102 -0
- package/src/shell-passthrough.ts +38 -0
- package/src/store.ts +52 -3
- package/src/supervisor.ts +262 -24
- package/src/task-context.ts +9 -2
- package/src/terminal-status.ts +103 -0
- package/src/tui-activity.ts +133 -0
- package/src/tui-classifier.ts +38 -0
- package/src/tui-client.ts +239 -8
- package/src/tui-commands.ts +49 -10
- package/src/tui-composer.ts +124 -0
- package/src/tui-keymap.ts +152 -0
- package/src/tui-mentions.ts +43 -0
- package/src/tui-modals.ts +173 -43
- package/src/tui-notify.ts +77 -0
- package/src/tui-review.ts +76 -0
- package/src/tui-turn-keys.ts +77 -0
- package/src/tui-view.ts +341 -88
- package/src/tui.ts +1047 -137
- package/src/turn-checkpoints.ts +253 -0
- package/src/turn-rewind.ts +258 -0
- package/src/web-fetch.ts +140 -0
- package/src/web-guard.ts +195 -0
- package/src/web-search.ts +402 -0
package/src/local-coding.ts
CHANGED
|
@@ -11,7 +11,11 @@ import {
|
|
|
11
11
|
import { spawn } from 'node:child_process';
|
|
12
12
|
import { randomUUID } from 'node:crypto';
|
|
13
13
|
import { mkdtempSync, realpathSync, rmSync } from 'node:fs';
|
|
14
|
-
import { tmpdir } from 'node:os';
|
|
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';
|
|
15
19
|
import { join } from 'node:path';
|
|
16
20
|
import type { Store } from './store.js';
|
|
17
21
|
import type { ManagedWork } from './managed.js';
|
|
@@ -20,16 +24,49 @@ import {
|
|
|
20
24
|
prepareLocalEdits,
|
|
21
25
|
verifyLocalEdits,
|
|
22
26
|
applyLocalEdits,
|
|
27
|
+
reviewLocalEdits,
|
|
23
28
|
type LocalEdit,
|
|
24
29
|
} from './local-edits.js';
|
|
25
30
|
import {
|
|
26
31
|
permissionPolicy,
|
|
32
|
+
autoApprovesEdits,
|
|
33
|
+
autoApprovesCommands,
|
|
27
34
|
permissionDigest,
|
|
28
|
-
matchesEditScope,
|
|
29
35
|
assertPermissionBinding,
|
|
30
36
|
} from './autonomy.js';
|
|
37
|
+
import {
|
|
38
|
+
commandArgv,
|
|
39
|
+
evaluateRules,
|
|
40
|
+
type ApprovalRules,
|
|
41
|
+
type RuleRequest,
|
|
42
|
+
} from './approval-rules.js';
|
|
31
43
|
import { assertUndoSettled } from './local-undo.js';
|
|
32
|
-
import {
|
|
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
|
+
}
|
|
33
70
|
import {
|
|
34
71
|
applyEditsAsTools,
|
|
35
72
|
killToolGroup,
|
|
@@ -69,6 +106,8 @@ export interface LocalSandbox {
|
|
|
69
106
|
/** The host config's `command_network: "broker"`: a sandboxed command reaches the network only
|
|
70
107
|
* through this stk local-commands socket (operator rulings 2026-10-30). */
|
|
71
108
|
network?: { socket: string };
|
|
109
|
+
/** Operator-allowed read-only paths outside the workspace (Phase 1, 2026-10-31). */
|
|
110
|
+
readPaths?: string[];
|
|
72
111
|
}
|
|
73
112
|
/** The proxy a broker-network command uses: only the relay on loopback, nothing direct. */
|
|
74
113
|
function proxyEnv(port: number): Record<string, string> {
|
|
@@ -84,8 +123,23 @@ function proxyEnv(port: number): Record<string, string> {
|
|
|
84
123
|
}
|
|
85
124
|
export class LocalCoding {
|
|
86
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;
|
|
87
132
|
private closed = false;
|
|
88
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
|
+
};
|
|
89
143
|
constructor(
|
|
90
144
|
readonly store: Store,
|
|
91
145
|
readonly managed: ManagedWork,
|
|
@@ -105,6 +159,20 @@ export class LocalCoding {
|
|
|
105
159
|
throw new ServiceError('local_busy', 'Command resources are still active');
|
|
106
160
|
},
|
|
107
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
|
+
}
|
|
108
176
|
// A lost controller must never retry a possibly executed edit or command.
|
|
109
177
|
for (const raw of store.db.prepare("SELECT * FROM local_calls WHERE state='running'").all()) {
|
|
110
178
|
const c = raw as unknown as LocalCall;
|
|
@@ -126,6 +194,26 @@ export class LocalCoding {
|
|
|
126
194
|
});
|
|
127
195
|
}
|
|
128
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
|
+
}
|
|
129
217
|
get(id: string): LocalCall {
|
|
130
218
|
const row = this.store.db.prepare('SELECT * FROM local_calls WHERE id=?').get(id);
|
|
131
219
|
if (!row) throw new ServiceError('not_found', 'Local operation not found', 404);
|
|
@@ -174,12 +262,70 @@ export class LocalCoding {
|
|
|
174
262
|
'Another operation or uncertain outcome owns this workspace',
|
|
175
263
|
);
|
|
176
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
|
+
}
|
|
177
322
|
prepare(
|
|
178
323
|
session: string,
|
|
179
324
|
epoch: number,
|
|
180
325
|
attempt: string,
|
|
181
326
|
toolCall: string,
|
|
182
327
|
input: Record<string, unknown>,
|
|
328
|
+
review: ClassifierReview | null = null,
|
|
183
329
|
) {
|
|
184
330
|
const prior = this.store.db
|
|
185
331
|
.prepare(
|
|
@@ -191,6 +337,7 @@ export class LocalCoding {
|
|
|
191
337
|
throw new ServiceError('request_conflict', 'Tool call already binds different arguments');
|
|
192
338
|
return this.view(String(prior.id));
|
|
193
339
|
}
|
|
340
|
+
this.assertShellBinding(session, attempt, input);
|
|
194
341
|
const root = this.store.session(session).cwd;
|
|
195
342
|
const c: LocalCall = {
|
|
196
343
|
id: randomUUID(),
|
|
@@ -215,6 +362,10 @@ export class LocalCoding {
|
|
|
215
362
|
action: 'command';
|
|
216
363
|
command: string;
|
|
217
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;
|
|
218
369
|
sandbox: { kind: string; level: string; bwrap: string | null; network?: 'broker' };
|
|
219
370
|
};
|
|
220
371
|
if (kind === 'edit') args = { action: 'edit', edits: prepareLocalEdits(root, input.edits) };
|
|
@@ -222,42 +373,87 @@ export class LocalCoding {
|
|
|
222
373
|
const timeout = input.timeout_ms ?? 30000;
|
|
223
374
|
if (!Number.isInteger(timeout) || Number(timeout) < 100 || Number(timeout) > 120000)
|
|
224
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');
|
|
225
382
|
if (
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
!this.sandbox.bwrap
|
|
383
|
+
input.outside_sandbox !== undefined &&
|
|
384
|
+
(typeof input.outside_sandbox !== 'string' || !input.outside_sandbox.trim())
|
|
229
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)
|
|
230
412
|
throw new ServiceError(
|
|
231
413
|
'sandbox_unavailable',
|
|
232
414
|
'This host has no usable bubblewrap, so it cannot run a sandboxed command; an operator can choose full access explicitly',
|
|
233
415
|
);
|
|
234
|
-
if (
|
|
235
|
-
this.sandbox.kind === 'none' &&
|
|
236
|
-
this.sandbox.level !== 'full-access' &&
|
|
237
|
-
!this.sandbox.allowUnsandboxed
|
|
238
|
-
)
|
|
416
|
+
if (sb.kind === 'none' && sb.level !== 'full-access' && !sb.allowUnsandboxed)
|
|
239
417
|
throw new ServiceError('sandbox_unavailable', UNSANDBOXED_REFUSAL);
|
|
240
418
|
args = {
|
|
241
419
|
action: 'command',
|
|
242
420
|
command: string(input.command, 'command', 8192),
|
|
243
|
-
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 } : {}),
|
|
244
425
|
// Bound into the approval and the operation digest: the sandbox is part of what is approved.
|
|
245
426
|
sandbox: {
|
|
246
|
-
kind:
|
|
247
|
-
level:
|
|
248
|
-
bwrap:
|
|
249
|
-
...(this.brokerNetwork() ? { network: 'broker' as const } : {}),
|
|
427
|
+
kind: sb.kind,
|
|
428
|
+
level: sb.level,
|
|
429
|
+
bwrap: sb.bwrap?.version ?? null,
|
|
430
|
+
...(this.brokerNetwork(sb) ? { network: 'broker' as const } : {}),
|
|
250
431
|
},
|
|
251
432
|
};
|
|
252
433
|
} else throw new ServiceError('invalid_field', 'Choose edit or command');
|
|
253
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;
|
|
254
452
|
this.store.transaction(() => {
|
|
255
453
|
const reviewed =
|
|
256
454
|
args.action === 'edit'
|
|
257
|
-
? args.edits
|
|
258
|
-
|
|
259
|
-
.join('\n\n')
|
|
260
|
-
: `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}`;
|
|
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}`;
|
|
261
457
|
const permission = this.managed.prepareWorkspaceInTransaction(
|
|
262
458
|
session,
|
|
263
459
|
epoch,
|
|
@@ -290,14 +486,100 @@ export class LocalCoding {
|
|
|
290
486
|
action: kind,
|
|
291
487
|
});
|
|
292
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
|
+
);
|
|
293
500
|
const policy = permissionPolicy(this.store, session);
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
args.
|
|
299
|
-
|
|
300
|
-
|
|
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)) {
|
|
301
583
|
const approval = this.managed.forOperation(c.operation_id);
|
|
302
584
|
this.managed.decide(
|
|
303
585
|
approval.id,
|
|
@@ -315,19 +597,27 @@ export class LocalCoding {
|
|
|
315
597
|
authorize: () => {
|
|
316
598
|
this.guard(c);
|
|
317
599
|
const fresh = permissionPolicy(this.store, session);
|
|
318
|
-
if (
|
|
319
|
-
permissionDigest(fresh) !== permissionDigest(policy) ||
|
|
320
|
-
args.action !== 'edit' ||
|
|
321
|
-
!matchesEditScope(
|
|
322
|
-
fresh,
|
|
323
|
-
args.edits.map((e) => e.path),
|
|
324
|
-
)
|
|
325
|
-
)
|
|
600
|
+
if (permissionDigest(fresh) !== permissionDigest(policy) || !covered(fresh))
|
|
326
601
|
throw new ServiceError('permission_changed', 'Scoped edit policy changed');
|
|
327
|
-
verifyLocalEdits(root, args.edits);
|
|
602
|
+
if (args.action === 'edit') verifyLocalEdits(root, args.edits);
|
|
328
603
|
},
|
|
329
604
|
},
|
|
330
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
|
+
);
|
|
331
621
|
}
|
|
332
622
|
return this.view(c.id);
|
|
333
623
|
}
|
|
@@ -396,30 +686,133 @@ export class LocalCoding {
|
|
|
396
686
|
else this.managed.workspaceDefiniteFailureInTransaction(c.operation_id);
|
|
397
687
|
});
|
|
398
688
|
}
|
|
399
|
-
} else this.
|
|
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));
|
|
400
691
|
return this.view(id);
|
|
401
692
|
}
|
|
402
|
-
|
|
403
|
-
|
|
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.';
|
|
404
722
|
if (level === 'full-access')
|
|
405
|
-
return
|
|
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.';
|
|
406
726
|
if (kind === 'none')
|
|
407
727
|
return 'unsandboxed: this host has no usable command sandbox, so the command may access files and network beyond this workspace.';
|
|
408
728
|
const name = kind === 'seatbelt' ? 'Seatbelt' : 'bubblewrap';
|
|
409
|
-
const net = this.brokerNetwork()
|
|
729
|
+
const net = this.brokerNetwork(sb)
|
|
410
730
|
? 'network: through the broker allowlist (hosts set by the stk policy), nothing direct.'
|
|
411
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
|
+
: '';
|
|
412
735
|
return level === 'read-only'
|
|
413
|
-
? `in a ${name} sandbox (read-only): it can read this workspace but write nothing, and ${net}`
|
|
414
|
-
: `in a ${name} sandbox (workspace-write): it can write only inside this workspace, and ${net}`;
|
|
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}`;
|
|
415
738
|
}
|
|
416
739
|
/** Broker network applies only to a sandboxed command: unsandboxed ones are not confined anyway. */
|
|
417
|
-
private brokerNetwork(): { socket: string } | null {
|
|
418
|
-
return this.
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
+
});
|
|
423
816
|
}
|
|
424
817
|
/** The process a command runs as. Under the tools user, the host-side shell reports its own
|
|
425
818
|
* process group (the one a stop kills) before exec-ing bubblewrap, whose PID namespace would
|
|
@@ -429,23 +822,24 @@ export class LocalCoding {
|
|
|
429
822
|
script: string,
|
|
430
823
|
tmp?: string,
|
|
431
824
|
relay?: BrokerRelay | null,
|
|
825
|
+
sb: LocalSandbox = this.sandbox,
|
|
826
|
+
listen = false,
|
|
432
827
|
): string[] {
|
|
433
|
-
const sandboxed = this.
|
|
828
|
+
const sandboxed = this.sandboxed(sb);
|
|
434
829
|
const inner = (s: string) =>
|
|
435
830
|
!sandboxed
|
|
436
831
|
? ['/bin/sh', '-c', s]
|
|
437
|
-
:
|
|
438
|
-
? seatbeltArgv(
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
)
|
|
446
|
-
: sandboxArgv(this.sandbox.level, root, ['/bin/sh', '-c', s], this.sandbox.bwrap, {
|
|
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, {
|
|
447
840
|
network: false,
|
|
448
841
|
...(relay?.dir ? { broker: { dir: relay.dir, node: process.execPath } } : {}),
|
|
842
|
+
readPaths: sb.readPaths ?? [],
|
|
449
843
|
});
|
|
450
844
|
if (this.tools.mode !== 'tools-user') return inner(script);
|
|
451
845
|
const marker = 'umask 0002\nprintf \'salaam-tool-pgid:%s\\n\' "$$"\n';
|
|
@@ -461,11 +855,11 @@ export class LocalCoding {
|
|
|
461
855
|
])
|
|
462
856
|
: toolArgv(this.tools, ['/usr/bin/setsid', '-w', '/bin/sh', '-c', marker + script]);
|
|
463
857
|
}
|
|
464
|
-
private command(c: LocalCall, command: string, timeout: number): void {
|
|
465
|
-
const broker = this.brokerNetwork();
|
|
858
|
+
private command(c: LocalCall, command: string, timeout: number, sb: LocalSandbox): void {
|
|
859
|
+
const broker = this.brokerNetwork(sb);
|
|
466
860
|
// The relay is Salaam's, outside the sandbox: it forwards to stk and counts what was reached.
|
|
467
861
|
const relayStarting = broker
|
|
468
|
-
? BrokerRelay.start(broker.socket,
|
|
862
|
+
? BrokerRelay.start(broker.socket, sb.kind === 'seatbelt' ? 'tcp' : 'unix')
|
|
469
863
|
: Promise.resolve(null);
|
|
470
864
|
// A stop that arrives while the relay starts still cancels the command once it is spawned.
|
|
471
865
|
let early = false;
|
|
@@ -474,7 +868,7 @@ export class LocalCoding {
|
|
|
474
868
|
};
|
|
475
869
|
// Seatbelt has no tmpfs: each command gets its own private tmp, removed when it ends.
|
|
476
870
|
const tmp =
|
|
477
|
-
|
|
871
|
+
sb.kind === 'seatbelt' && sb.level !== 'full-access'
|
|
478
872
|
? realpathSync(mkdtempSync(join(tmpdir(), 'salaam-cmd-')))
|
|
479
873
|
: undefined;
|
|
480
874
|
const set = (s: () => void) => {
|
|
@@ -482,8 +876,8 @@ export class LocalCoding {
|
|
|
482
876
|
if (early) s();
|
|
483
877
|
};
|
|
484
878
|
const done = relayStarting.then(
|
|
485
|
-
(relay) => this.runCommand(c, command, timeout, tmp, relay, set),
|
|
486
|
-
() => this.runCommand(c, command, timeout, tmp, null, set, !!broker),
|
|
879
|
+
(relay) => this.runCommand(c, command, timeout, tmp, relay, set, false, sb),
|
|
880
|
+
() => this.runCommand(c, command, timeout, tmp, null, set, !!broker, sb),
|
|
487
881
|
);
|
|
488
882
|
this.jobs.set(c.id, { stop: () => stop(), done });
|
|
489
883
|
if (tmp) void done.finally(() => rmSync(tmp, { recursive: true, force: true }));
|
|
@@ -496,6 +890,7 @@ export class LocalCoding {
|
|
|
496
890
|
relay: BrokerRelay | null,
|
|
497
891
|
setStop: (stop: () => void) => void,
|
|
498
892
|
relayFailed = false,
|
|
893
|
+
sb: LocalSandbox = this.sandbox,
|
|
499
894
|
): Promise<void> {
|
|
500
895
|
let stop = () => {};
|
|
501
896
|
return new Promise<void>((resolve) => {
|
|
@@ -508,7 +903,14 @@ export class LocalCoding {
|
|
|
508
903
|
const tools = this.tools.mode === 'tools-user';
|
|
509
904
|
const script = 'IFS= read -r salaam_start_gate || exit 125\nexec 0</dev/null\n' + command;
|
|
510
905
|
// As the tools user, setsid makes the shell its own group leader so it can be killed by group.
|
|
511
|
-
const argv = this.commandArgv(
|
|
906
|
+
const argv = this.commandArgv(
|
|
907
|
+
c.root,
|
|
908
|
+
script,
|
|
909
|
+
tmp,
|
|
910
|
+
relay,
|
|
911
|
+
sb,
|
|
912
|
+
JSON.parse(c.args).listen === true,
|
|
913
|
+
);
|
|
512
914
|
let toolGroup: number | null = null,
|
|
513
915
|
marker = tools ? '' : null,
|
|
514
916
|
spawned = false;
|
|
@@ -525,6 +927,7 @@ export class LocalCoding {
|
|
|
525
927
|
HOME: process.env.HOME ?? c.root,
|
|
526
928
|
LANG: 'en_US.UTF-8',
|
|
527
929
|
TMPDIR: '/tmp',
|
|
930
|
+
...(this.sandboxed(sb) ? gitIdentityEnv(c.root) : {}),
|
|
528
931
|
...(relay ? proxyEnv(relay.port ?? COMMAND_NETWORK_PORT) : {}),
|
|
529
932
|
},
|
|
530
933
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
@@ -664,13 +1067,8 @@ export class LocalCoding {
|
|
|
664
1067
|
error: error ? 'Command could not start' : null,
|
|
665
1068
|
isolation:
|
|
666
1069
|
(tools ? 'tools-user-' : 'local-user-') +
|
|
667
|
-
(this.
|
|
668
|
-
|
|
669
|
-
: 'no-os-sandbox'),
|
|
670
|
-
sandbox:
|
|
671
|
-
this.sandbox.kind !== 'none' && this.sandbox.level !== 'full-access'
|
|
672
|
-
? this.sandbox.kind
|
|
673
|
-
: 'none',
|
|
1070
|
+
(this.sandboxed(sb) ? sb.kind + '-' + sb.level : 'no-os-sandbox'),
|
|
1071
|
+
sandbox: this.sandboxed(sb) ? sb.kind : 'none',
|
|
674
1072
|
tool_uid: this.tools.uid,
|
|
675
1073
|
...(network ? { network } : {}),
|
|
676
1074
|
};
|
|
@@ -691,11 +1089,212 @@ export class LocalCoding {
|
|
|
691
1089
|
child.once('close', (code) => finish(code));
|
|
692
1090
|
});
|
|
693
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) };
|
|
1290
|
+
}
|
|
694
1291
|
async close(): Promise<void> {
|
|
695
1292
|
this.closed = true;
|
|
696
1293
|
const jobs = [...this.jobs.values()];
|
|
697
1294
|
for (const job of jobs) job.stop();
|
|
698
|
-
|
|
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)]);
|
|
699
1298
|
this.unregister();
|
|
700
1299
|
}
|
|
701
1300
|
}
|