@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
package/src/hooks.ts ADDED
@@ -0,0 +1,425 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join, relative, sep } from 'node:path';
5
+ import type { Store } from './store.js';
6
+ import { ServiceError } from './protocol.js';
7
+
8
+ /** Hooks for a person's own desk sessions (Phase 4b, operator 2026-10-31), in the Claude Code
9
+ * format: `{"hooks": {"PreToolUse": [{"matcher": "Bash", "hooks": [{"type": "command",
10
+ * "command": "...", "timeout": 30}]}]}}`. Yours live in `~/.config/salaam/hooks.json`; a
11
+ * repository's `.salaam/hooks.json` is ignored until you trust that exact file, and any change to it
12
+ * needs trusting again. A hook can block a tool call or a prompt; it can never approve anything. */
13
+ export const HOOK_EVENTS = [
14
+ 'PreToolUse',
15
+ 'PostToolUse',
16
+ 'UserPromptSubmit',
17
+ 'Stop',
18
+ 'Notification',
19
+ ] as const;
20
+ export type HookEvent = (typeof HOOK_EVENTS)[number];
21
+ export interface HookHandler {
22
+ command: string;
23
+ /** Seconds. */
24
+ timeout?: number;
25
+ }
26
+ export interface HookGroup {
27
+ matcher?: string;
28
+ hooks: HookHandler[];
29
+ }
30
+ export type HookConfig = Partial<Record<HookEvent, HookGroup[]>>;
31
+ export interface HookRun {
32
+ code: number | null;
33
+ stdout: string;
34
+ stderr: string;
35
+ timedOut: boolean;
36
+ truncated: boolean;
37
+ /** Set when the hook was not started, e.g. this host refuses unsandboxed commands. */
38
+ refused?: string;
39
+ }
40
+ /** Runs one hook command under the session's sandbox with `input` on stdin. */
41
+ export type HookRunner = (
42
+ session: string,
43
+ command: string,
44
+ input: string,
45
+ timeoutMs: number,
46
+ ) => Promise<HookRun>;
47
+ export interface HookSource {
48
+ scope: 'user' | 'repo';
49
+ path: string;
50
+ digest: string | null;
51
+ trusted: boolean;
52
+ config: HookConfig;
53
+ problems: string[];
54
+ }
55
+ export interface HookOutcome {
56
+ /** The reason a blocking event was stopped. */
57
+ blocked?: string;
58
+ /** UserPromptSubmit output to add to the prompt. */
59
+ context?: string;
60
+ notices: string[];
61
+ }
62
+
63
+ const MAX_FILE = 64 * 1024;
64
+ const MAX_COMMAND = 4096;
65
+ const MAX_HANDLERS = 16;
66
+ const MAX_CONTEXT = 8192;
67
+ const DEFAULT_TIMEOUT = 60;
68
+ const MAX_TIMEOUT = 600;
69
+ const BLOCKING = new Set<HookEvent>(['PreToolUse', 'UserPromptSubmit']);
70
+ const TOOL_EVENTS = new Set<HookEvent>(['PreToolUse', 'PostToolUse']);
71
+
72
+ export function defaultUserHooksFile(env = process.env): string {
73
+ return (
74
+ env.SALAAM_HOOKS_FILE ||
75
+ join(env.XDG_CONFIG_HOME || join(env.HOME || homedir(), '.config'), 'salaam', 'hooks.json')
76
+ );
77
+ }
78
+
79
+ const sha = (data: string | Buffer) => 'sha256:' + createHash('sha256').update(data).digest('hex');
80
+
81
+ export function parseHooks(text: string): { config: HookConfig; problems: string[] } {
82
+ const problems: string[] = [];
83
+ const config: HookConfig = {};
84
+ let raw: unknown;
85
+ try {
86
+ raw = JSON.parse(text);
87
+ } catch (error) {
88
+ return { config, problems: ['not valid JSON: ' + (error as Error).message] };
89
+ }
90
+ const events = (raw as { hooks?: unknown })?.hooks;
91
+ if (!events || typeof events !== 'object' || Array.isArray(events))
92
+ return { config, problems: ['expected {"hooks": {...}}'] };
93
+ let handlers = 0;
94
+ for (const [event, groups] of Object.entries(events)) {
95
+ if (!(HOOK_EVENTS as readonly string[]).includes(event)) {
96
+ problems.push(`${event}: not supported; Salaam runs ${HOOK_EVENTS.join(', ')}`);
97
+ continue;
98
+ }
99
+ if (!Array.isArray(groups)) {
100
+ problems.push(`${event}: expected a list of matcher groups`);
101
+ continue;
102
+ }
103
+ for (const group of groups as unknown[]) {
104
+ const g = group as { matcher?: unknown; hooks?: unknown };
105
+ if (g?.matcher !== undefined && typeof g.matcher !== 'string') {
106
+ problems.push(`${event}: matcher must be a string`);
107
+ continue;
108
+ }
109
+ if (typeof g.matcher === 'string' && !validMatcher(g.matcher)) {
110
+ problems.push(`${event}: matcher ${JSON.stringify(g.matcher)} is not a valid pattern`);
111
+ continue;
112
+ }
113
+ if (!Array.isArray(g.hooks)) {
114
+ problems.push(`${event}: a group needs a hooks list`);
115
+ continue;
116
+ }
117
+ const kept: HookHandler[] = [];
118
+ for (const h of g.hooks as unknown[]) {
119
+ const hook = h as { type?: unknown; command?: unknown; timeout?: unknown };
120
+ if (hook?.type !== 'command') {
121
+ problems.push(
122
+ `${event}: hook type ${JSON.stringify(hook?.type)} is not supported (command only)`,
123
+ );
124
+ continue;
125
+ }
126
+ if (typeof hook.command !== 'string' || !hook.command.trim()) {
127
+ problems.push(`${event}: a command hook needs a command`);
128
+ continue;
129
+ }
130
+ if (Buffer.byteLength(hook.command) > MAX_COMMAND) {
131
+ problems.push(`${event}: a hook command is longer than 4 KiB`);
132
+ continue;
133
+ }
134
+ if (
135
+ hook.timeout !== undefined &&
136
+ (typeof hook.timeout !== 'number' || !(hook.timeout > 0) || hook.timeout > MAX_TIMEOUT)
137
+ ) {
138
+ problems.push(`${event}: timeout must be 1–${MAX_TIMEOUT} seconds`);
139
+ continue;
140
+ }
141
+ if (++handlers > MAX_HANDLERS * HOOK_EVENTS.length) {
142
+ problems.push('too many hooks; the rest are ignored');
143
+ break;
144
+ }
145
+ kept.push({
146
+ command: hook.command,
147
+ ...(hook.timeout !== undefined ? { timeout: hook.timeout as number } : {}),
148
+ });
149
+ }
150
+ if (kept.length)
151
+ (config[event as HookEvent] ??= []).push({
152
+ ...(g.matcher !== undefined ? { matcher: g.matcher } : {}),
153
+ hooks: kept.slice(0, MAX_HANDLERS),
154
+ });
155
+ }
156
+ }
157
+ return { config, problems };
158
+ }
159
+
160
+ function validMatcher(m: string): boolean {
161
+ if (m === '' || m === '*' || /^[A-Za-z0-9_|,-]+$/.test(m)) return true;
162
+ try {
163
+ new RegExp(m);
164
+ return true;
165
+ } catch {
166
+ return false;
167
+ }
168
+ }
169
+
170
+ /** Claude Code's rule: empty or `*` matches everything, plain names (letters, digits, `_`, `-`,
171
+ * separated by `|` or `,`) are exact, anything else is a regular expression. `names` holds the
172
+ * Salaam tool name and its Claude Code alias (`Bash`, `Edit`, …). */
173
+ export function matchesTool(matcher: string | undefined, names: readonly string[]): boolean {
174
+ if (matcher === undefined || matcher === '' || matcher === '*') return true;
175
+ if (/^[A-Za-z0-9_|,-]+$/.test(matcher)) {
176
+ const wanted = matcher.split(/[|,]/);
177
+ return names.some((n) => wanted.includes(n));
178
+ }
179
+ const re = new RegExp('^(?:' + matcher + ')$');
180
+ return names.some((n) => re.test(n));
181
+ }
182
+
183
+ export class Hooks {
184
+ constructor(
185
+ readonly store: Store,
186
+ readonly run: HookRunner,
187
+ readonly userFile = defaultUserHooksFile(),
188
+ ) {
189
+ store.db.exec(
190
+ 'CREATE TABLE IF NOT EXISTS hook_trust(root TEXT NOT NULL,path TEXT NOT NULL,digest TEXT NOT NULL,trusted_at TEXT NOT NULL,PRIMARY KEY(root,path))',
191
+ );
192
+ }
193
+ private repoFile(session: string): string {
194
+ return join(this.store.session(session).cwd, '.salaam', 'hooks.json');
195
+ }
196
+ /** Your hooks and this workspace's, read afresh, each with its trust state. */
197
+ sources(session: string): HookSource[] {
198
+ const root = this.store.session(session).cwd;
199
+ const out: HookSource[] = [];
200
+ for (const [scope, path] of [
201
+ ['user', this.userFile],
202
+ ['repo', this.repoFile(session)],
203
+ ] as const) {
204
+ if (!existsSync(path) && !isLink(path)) continue;
205
+ const problems: string[] = [];
206
+ let digest: string | null = null;
207
+ let config: HookConfig = {};
208
+ try {
209
+ const real = realpathSync(path);
210
+ if (scope === 'repo' && !inside(realpathSync(root), real))
211
+ throw new Error('resolves outside the repository; ignored');
212
+ if (lstatSync(real).size > MAX_FILE) throw new Error('larger than 64 KiB; ignored');
213
+ const bytes = readFileSync(real);
214
+ digest = sha(bytes);
215
+ const parsed = parseHooks(bytes.toString('utf8'));
216
+ config = parsed.config;
217
+ problems.push(...parsed.problems);
218
+ } catch (error) {
219
+ problems.push((error as Error).message);
220
+ digest = null;
221
+ }
222
+ const trusted =
223
+ scope === 'user'
224
+ ? digest !== null
225
+ : digest !== null && this.trustedDigest(root, path) === digest;
226
+ out.push({ scope, path, digest, trusted, config, problems });
227
+ }
228
+ return out;
229
+ }
230
+ private trustedDigest(root: string, path: string): string | undefined {
231
+ const row = this.store.db
232
+ .prepare('SELECT digest FROM hook_trust WHERE root=? AND path=?')
233
+ .get(root, path) as { digest?: string } | undefined;
234
+ return row?.digest;
235
+ }
236
+ /** Trust this workspace's hooks file as reviewed: `digest` must be the content shown to you. */
237
+ trust(session: string, digest: string): HookSource {
238
+ const source = this.sources(session).find((s) => s.scope === 'repo');
239
+ if (!source || !source.digest)
240
+ throw new ServiceError(
241
+ 'hooks_missing',
242
+ 'This workspace has no readable .salaam/hooks.json',
243
+ 404,
244
+ );
245
+ if (source.digest !== digest)
246
+ throw new ServiceError(
247
+ 'hooks_changed',
248
+ 'The hooks file changed since you reviewed it; review it again',
249
+ 409,
250
+ );
251
+ const root = this.store.session(session).cwd;
252
+ this.store.transaction(() => {
253
+ this.store.db
254
+ .prepare(
255
+ 'INSERT INTO hook_trust VALUES (?,?,?,?) ON CONFLICT(root,path) DO UPDATE SET digest=excluded.digest,trusted_at=excluded.trusted_at',
256
+ )
257
+ .run(root, source.path, digest, new Date().toISOString());
258
+ this.store.event(session, 'hooks.trusted', { path: source.path, digest });
259
+ });
260
+ return { ...source, trusted: true };
261
+ }
262
+ revoke(session: string): void {
263
+ const root = this.store.session(session).cwd;
264
+ const path = this.repoFile(session);
265
+ this.store.transaction(() => {
266
+ this.store.db.prepare('DELETE FROM hook_trust WHERE root=? AND path=?').run(root, path);
267
+ this.store.event(session, 'hooks.revoked', { path });
268
+ });
269
+ }
270
+ /** Run every trusted hook for `event`. Blocking events stop at the first block. */
271
+ async fire(
272
+ session: string,
273
+ event: HookEvent,
274
+ payload: Record<string, unknown>,
275
+ names: readonly string[],
276
+ ): Promise<HookOutcome> {
277
+ const notices: string[] = [];
278
+ const context: string[] = [];
279
+ const cwd = this.store.session(session).cwd;
280
+ const input = JSON.stringify({ session_id: session, cwd, hook_event_name: event, ...payload });
281
+ for (const source of this.sources(session)) {
282
+ if (!source.trusted) continue;
283
+ for (const group of source.config[event] ?? []) {
284
+ if (TOOL_EVENTS.has(event) && !matchesTool(group.matcher, names)) continue;
285
+ for (const handler of group.hooks) {
286
+ const started = Date.now();
287
+ const r = await this.run(
288
+ session,
289
+ handler.command,
290
+ input,
291
+ (handler.timeout ?? DEFAULT_TIMEOUT) * 1000,
292
+ );
293
+ const decision = parseDecision(r.stdout);
294
+ let blocked: string | undefined;
295
+ if (BLOCKING.has(event) && !r.refused && !r.timedOut) {
296
+ if (r.code === 2) blocked = r.stderr.trim() || `Blocked by a ${event} hook`;
297
+ else if (r.code === 0 && decision.block)
298
+ blocked = decision.reason || `Blocked by a ${event} hook`;
299
+ }
300
+ if (r.refused) notices.push(`${event} hook refused: ${r.refused}`);
301
+ else if (r.timedOut) notices.push(`${event} hook timed out: ${short(handler.command)}`);
302
+ else if (r.code !== 0 && r.code !== 2)
303
+ notices.push(`${event} hook failed (exit ${r.code}): ${short(handler.command)}`);
304
+ if (event === 'UserPromptSubmit' && r.code === 0 && !blocked) {
305
+ const extra = decision.context ?? (decision.json ? '' : r.stdout.trim());
306
+ if (extra) context.push(extra);
307
+ }
308
+ this.store.transaction(() =>
309
+ this.store.event(session, 'hook.run', {
310
+ event,
311
+ scope: source.scope,
312
+ path: source.path,
313
+ file_digest: source.digest,
314
+ command_digest: sha(handler.command),
315
+ exit_code: r.code,
316
+ timed_out: r.timedOut,
317
+ refused: !!r.refused,
318
+ blocked: !!blocked,
319
+ ...(decision.ignored ? { ignored_decision: decision.ignored } : {}),
320
+ duration_ms: Date.now() - started,
321
+ stdout_digest: sha(r.stdout),
322
+ stderr_digest: sha(r.stderr),
323
+ output_bytes: Buffer.byteLength(r.stdout) + Buffer.byteLength(r.stderr),
324
+ truncated: r.truncated,
325
+ }),
326
+ );
327
+ if (blocked) return { blocked, notices };
328
+ }
329
+ }
330
+ }
331
+ const joined = context.join('\n\n');
332
+ return {
333
+ notices,
334
+ ...(joined ? { context: truncate(joined, MAX_CONTEXT) } : {}),
335
+ };
336
+ }
337
+ }
338
+
339
+ /** A JSON object on stdout, read the Claude Code way. "allow" and "ask" are recorded as ignored:
340
+ * Salaam's own approval flow decides those. */
341
+ function parseDecision(stdout: string): {
342
+ json: boolean;
343
+ block?: boolean;
344
+ reason?: string;
345
+ context?: string;
346
+ ignored?: string;
347
+ } {
348
+ const text = stdout.trim();
349
+ if (!text.startsWith('{')) return { json: false };
350
+ let value: Record<string, any>;
351
+ try {
352
+ value = JSON.parse(text);
353
+ } catch {
354
+ return { json: false };
355
+ }
356
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return { json: false };
357
+ const specific = (value.hookSpecificOutput ?? {}) as Record<string, unknown>;
358
+ const decision = specific.permissionDecision;
359
+ const reason =
360
+ typeof specific.permissionDecisionReason === 'string'
361
+ ? specific.permissionDecisionReason
362
+ : typeof value.reason === 'string'
363
+ ? value.reason
364
+ : undefined;
365
+ const context =
366
+ typeof specific.additionalContext === 'string' ? specific.additionalContext : undefined;
367
+ if (decision === 'deny' || value.decision === 'block')
368
+ return { json: true, block: true, ...(reason ? { reason } : {}) };
369
+ return {
370
+ json: true,
371
+ ...(context ? { context } : {}),
372
+ ...(typeof decision === 'string' ? { ignored: decision } : {}),
373
+ ...(value.decision === 'approve' ? { ignored: 'approve' } : {}),
374
+ };
375
+ }
376
+
377
+ function inside(root: string, path: string): boolean {
378
+ const r = relative(root, path);
379
+ return !r.startsWith('..') && !r.startsWith(sep);
380
+ }
381
+ function isLink(path: string): boolean {
382
+ try {
383
+ return lstatSync(path).isSymbolicLink();
384
+ } catch {
385
+ return false;
386
+ }
387
+ }
388
+ function short(command: string): string {
389
+ const line = command.split('\n')[0]!;
390
+ return line.length > 60 ? line.slice(0, 57) + '…' : line;
391
+ }
392
+ function truncate(text: string, max: number): string {
393
+ const buf = Buffer.from(text);
394
+ return buf.length <= max
395
+ ? text
396
+ : buf.subarray(0, max).toString('utf8') + '\n[hook output truncated]';
397
+ }
398
+
399
+ /** What `/hooks` shows, and what you review before trusting a workspace file: every command. */
400
+ export function describeHooks(sources: readonly HookSource[]): string {
401
+ if (!sources.length)
402
+ return "No hooks. Add your own in ~/.config/salaam/hooks.json, or a workspace's in .salaam/hooks.json (it runs only once you trust it with /hooks trust).";
403
+ return sources
404
+ .map((s) => {
405
+ const state =
406
+ s.scope === 'user'
407
+ ? 'yours, runs'
408
+ : s.trusted
409
+ ? 'workspace, trusted (this exact content)'
410
+ : s.digest
411
+ ? 'workspace, NOT trusted, does not run (/hooks trust to review)'
412
+ : 'workspace, unreadable';
413
+ const lines = [`${s.path} [${state}]`];
414
+ for (const event of HOOK_EVENTS)
415
+ for (const g of s.config[event] ?? [])
416
+ for (const h of g.hooks)
417
+ lines.push(
418
+ ` ${event}${g.matcher ? ` (${g.matcher})` : ''}: ${h.command}${h.timeout ? ` [${h.timeout}s]` : ''}`,
419
+ );
420
+ for (const p of s.problems) lines.push(` ! ${p}`);
421
+ if (s.digest) lines.push(` ${s.digest}`);
422
+ return lines.join('\n');
423
+ })
424
+ .join('\n\n');
425
+ }
package/src/host.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { DEFAULT_COMMAND_NETWORK_SOCKET } from './command-network.js';
2
+ import { loadDangerClassifierOptions } from './danger-classifier.js';
2
3
  import { spawn, execFileSync } from 'node:child_process';
3
4
  import { once } from 'node:events';
4
5
  import {
@@ -32,7 +33,12 @@ import { call } from './transport.js';
32
33
  import { readStationConfig } from './station-config.js';
33
34
  import { readToolkitConfig } from './toolkit-config.js';
34
35
  import { OperatorMcp } from './operator-mcp.js';
35
- import { chooseLocalSandbox, sandboxLabel, type SandboxKind } from './command-sandbox.js';
36
+ import {
37
+ chooseLocalSandbox,
38
+ resolveReadPaths,
39
+ sandboxLabel,
40
+ type SandboxKind,
41
+ } from './command-sandbox.js';
36
42
  import { loadPersona } from './persona.js';
37
43
  interface HostIdentity {
38
44
  node: string;
@@ -102,6 +108,19 @@ export interface HostLocalSandbox {
102
108
  /** Network allowlist (operator rulings 2026-10-30): local commands reach the network only
103
109
  * through stk's local-commands socket, whose egress.hosts the host operator sets. */
104
110
  command_network?: { mode: 'broker'; socket: string; label: string };
111
+ /** Phase 1 (2026-10-31): operator-allowed read-only paths outside the workspace, resolved at host
112
+ * plan (`toolchain` expanded) so the reviewed manifest names every one. */
113
+ read_paths?: string[];
114
+ /** Phase 2b: whether an interactive session on this host may choose full access (no OS sandbox).
115
+ * Hosts allow it only when the operator sets `allow_full_access_sessions: true`. */
116
+ full_access_sessions?: 'allow';
117
+ /** Operator ruling 2026-10-31: whether a desk session on this host may choose YOLO (no sandbox, no
118
+ * approvals). Hosts allow it only when the operator sets `allow_yolo_sessions: true`. */
119
+ yolo_sessions?: 'allow';
120
+ /** Operator 2026-10-31: the command reviewer on this host. Forbidden unless the operator names a
121
+ * reviewed config (its digest pinned here), optionally pinning one of its options. */
122
+ danger_classifier?:
123
+ { mode: 'forbid' } | { mode: 'config'; config: string; digest: string; pin?: string };
105
124
  }
106
125
  export function hostLocalSandbox(
107
126
  platform: NodeJS.Platform = process.platform,
@@ -109,6 +128,10 @@ export function hostLocalSandbox(
109
128
  findSb?: Parameters<typeof chooseLocalSandbox>[3],
110
129
  allowUnsandboxed = false,
111
130
  brokerSocket?: string,
131
+ readPaths: readonly string[] = [],
132
+ allowFullAccessSessions = false,
133
+ allowYoloSessions = false,
134
+ dangerClassifier?: HostLocalSandbox['danger_classifier'],
112
135
  ): HostLocalSandbox {
113
136
  const { kind } = chooseLocalSandbox('workspace-write', platform, find, findSb);
114
137
  return {
@@ -124,8 +147,34 @@ export function hostLocalSandbox(
124
147
  },
125
148
  }
126
149
  : {}),
150
+ ...(readPaths.length ? { read_paths: [...readPaths] } : {}),
151
+ ...(allowFullAccessSessions ? { full_access_sessions: 'allow' as const } : {}),
152
+ ...(allowYoloSessions ? { yolo_sessions: 'allow' as const } : {}),
153
+ ...(dangerClassifier ? { danger_classifier: dangerClassifier } : {}),
127
154
  };
128
155
  }
156
+ /** The read-only paths outside the workspace the reviewed manifest lets local commands read. */
157
+ export function hostReadPaths(m: HostManifest): string[] {
158
+ const r = (m as { local_sandbox?: HostLocalSandbox }).local_sandbox?.read_paths;
159
+ return Array.isArray(r) ? r.filter((p): p is string => typeof p === 'string') : [];
160
+ }
161
+ /** Whether the reviewed manifest lets an interactive session choose full access (Phase 2b). */
162
+ export function hostAllowsFullAccessSessions(m: HostManifest): boolean {
163
+ return (
164
+ (m as { local_sandbox?: HostLocalSandbox }).local_sandbox?.full_access_sessions === 'allow'
165
+ );
166
+ }
167
+ /** Whether the reviewed manifest lets a desk session choose YOLO. */
168
+ export function hostAllowsYoloSessions(m: HostManifest): boolean {
169
+ return (m as { local_sandbox?: HostLocalSandbox }).local_sandbox?.yolo_sessions === 'allow';
170
+ }
171
+ /** The command reviewer a host service may run: a reviewed config and optional pin, or none
172
+ * ({ pin: 'off' }). Absent means forbidden: hosts get no reviewer without the operator. */
173
+ export function hostDangerClassifier(m: HostManifest): { config?: string; pin?: string } {
174
+ const d = (m as { local_sandbox?: HostLocalSandbox }).local_sandbox?.danger_classifier;
175
+ if (!d || d.mode !== 'config') return { pin: 'off' };
176
+ return { config: d.config, ...(d.pin ? { pin: d.pin } : {}) };
177
+ }
129
178
  /** The broker socket local commands reach the network through, or null for no network. */
130
179
  export function hostCommandNetwork(m: HostManifest): { socket: string } | null {
131
180
  const n = (m as { local_sandbox?: HostLocalSandbox }).local_sandbox?.command_network;
@@ -245,6 +294,10 @@ async function planAgentPod(
245
294
  'unsandboxed_commands',
246
295
  'command_network',
247
296
  'command_network_socket',
297
+ 'local_sandbox_read_paths',
298
+ 'allow_full_access_sessions',
299
+ 'allow_yolo_sessions',
300
+ 'danger_classifier',
248
301
  ];
249
302
  if (Object.keys(c).some((k) => !fields.includes(k)))
250
303
  throw Error('Unexpected AgentPod host config field');
@@ -286,6 +339,12 @@ async function planAgentPod(
286
339
  if (c.unsandboxed_commands !== undefined && c.unsandboxed_commands !== 'allow')
287
340
  throw Error('unsandboxed_commands is "allow" or absent');
288
341
  const allowUnsandboxed = c.unsandboxed_commands === 'allow';
342
+ if (c.allow_full_access_sessions !== undefined && c.allow_full_access_sessions !== true)
343
+ throw Error('allow_full_access_sessions is true or absent');
344
+ const allowFullAccessSessions = c.allow_full_access_sessions === true;
345
+ if (c.allow_yolo_sessions !== undefined && c.allow_yolo_sessions !== true)
346
+ throw Error('allow_yolo_sessions is true or absent');
347
+ const allowYoloSessions = c.allow_yolo_sessions === true;
289
348
  if (c.command_network !== undefined && c.command_network !== 'broker')
290
349
  throw Error('command_network is "broker" or absent');
291
350
  if (c.command_network_socket !== undefined) {
@@ -295,6 +354,18 @@ async function planAgentPod(
295
354
  if (typeof sock !== 'string' || !isAbsolute(sock) || sock.includes('\0'))
296
355
  throw Error('command_network_socket must be an absolute path');
297
356
  }
357
+ const dangerClassifier = parseHostDangerClassifier(c.danger_classifier);
358
+ let readPaths: string[] = [];
359
+ if (c.local_sandbox_read_paths !== undefined) {
360
+ const raw = c.local_sandbox_read_paths;
361
+ if (!Array.isArray(raw) || raw.some((p) => typeof p !== 'string'))
362
+ throw Error('local_sandbox_read_paths is a list of absolute paths or "toolchain"');
363
+ try {
364
+ readPaths = resolveReadPaths(raw as string[]);
365
+ } catch (error) {
366
+ throw Error((error as Error).message);
367
+ }
368
+ }
298
369
  const brokerSocket =
299
370
  c.command_network === 'broker'
300
371
  ? String(c.command_network_socket ?? DEFAULT_COMMAND_NETWORK_SOCKET)
@@ -390,9 +461,50 @@ async function planAgentPod(
390
461
  undefined,
391
462
  allowUnsandboxed,
392
463
  brokerSocket,
464
+ readPaths,
465
+ allowFullAccessSessions,
466
+ allowYoloSessions,
467
+ dangerClassifier,
393
468
  ),
394
469
  };
395
470
  }
471
+ function reviewerDigest(path: string): string | null {
472
+ try {
473
+ return digest(readFileSync(path, 'utf8'));
474
+ } catch {
475
+ return null;
476
+ }
477
+ }
478
+ function parseHostDangerClassifier(raw: unknown): HostLocalSandbox['danger_classifier'] {
479
+ if (raw === undefined) return undefined;
480
+ if (raw === 'forbid') return { mode: 'forbid' };
481
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
482
+ throw Error('danger_classifier is "forbid" or { config, pin? }');
483
+ const c = raw as Record<string, unknown>;
484
+ if (Object.keys(c).some((k) => k !== 'config' && k !== 'pin'))
485
+ throw Error('danger_classifier takes only config and pin');
486
+ if (typeof c.config !== 'string' || !isAbsolute(c.config) || c.config.includes('\0'))
487
+ throw Error('danger_classifier.config must be an absolute path');
488
+ let names: string[];
489
+ try {
490
+ // Keys are named by environment variable and checked when the service starts, not here.
491
+ const anyKey = new Proxy({}, { get: () => 'checked-at-start' }) as Record<string, string>;
492
+ names = loadDangerClassifierOptions(
493
+ JSON.parse(readFileSync(c.config, 'utf8')),
494
+ anyKey,
495
+ ).options.map((o) => o.name);
496
+ } catch (error) {
497
+ throw Error('danger_classifier.config: ' + (error as Error).message);
498
+ }
499
+ if (c.pin !== undefined && (typeof c.pin !== 'string' || !names.includes(c.pin)))
500
+ throw Error("danger_classifier.pin must name one of the config's options");
501
+ return {
502
+ mode: 'config',
503
+ config: c.config,
504
+ digest: reviewerDigest(c.config)!,
505
+ ...(typeof c.pin === 'string' ? { pin: c.pin } : {}),
506
+ };
507
+ }
396
508
  export function hostDigests() {
397
509
  const hashDir = (path: string) =>
398
510
  digest(
@@ -553,10 +665,19 @@ export function readHost(path: string): HostManifest {
553
665
  undefined,
554
666
  hostAllowsUnsandboxed(m),
555
667
  hostCommandNetwork(m)?.socket,
668
+ hostReadPaths(m),
669
+ hostAllowsFullAccessSessions(m),
670
+ hostAllowsYoloSessions(m),
671
+ (m as { local_sandbox?: HostLocalSandbox }).local_sandbox?.danger_classifier,
556
672
  ),
557
673
  )
558
674
  )
559
675
  throw Error('Host command sandbox changed; run host plan again');
676
+ {
677
+ const d = (m as { local_sandbox?: HostLocalSandbox }).local_sandbox?.danger_classifier;
678
+ if (d?.mode === 'config' && reviewerDigest(d.config) !== d.digest)
679
+ throw Error('Host command reviewer config changed; run host plan again');
680
+ }
560
681
  const pins = readAgentPodPins(m.agentpod_config, m.state);
561
682
  const managed = hostMcp(m);
562
683
  if (m.schema === 3 || ([4, 5, 6].includes(m.schema) && Object.hasOwn(m, 'managed_mcp'))) {