@superjackfruitlabs/salaam 0.0.3 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +12 -11
  3. package/dist/src/acp-desk.js +140 -0
  4. package/dist/src/acp-outcome.js +182 -18
  5. package/dist/src/acp.js +296 -15
  6. package/dist/src/agent-profiles.js +45 -0
  7. package/dist/src/agentpod-authority.js +169 -11
  8. package/dist/src/agentpod-default-config.js +12 -0
  9. package/dist/src/agentpod-work-decision-protocol.js +40 -6
  10. package/dist/src/agentpod-work-decisions.js +108 -4
  11. package/dist/src/agentpod-work.js +1214 -127
  12. package/dist/src/approval-rules.js +287 -0
  13. package/dist/src/attach.js +3 -1
  14. package/dist/src/autonomy.js +80 -9
  15. package/dist/src/board-reads.js +106 -0
  16. package/dist/src/children.js +83 -0
  17. package/dist/src/cli-help.js +61 -6
  18. package/dist/src/cli.js +140 -4
  19. package/dist/src/command-danger.js +797 -0
  20. package/dist/src/command-network.js +200 -0
  21. package/dist/src/command-sandbox.js +327 -0
  22. package/dist/src/custom-commands.js +159 -0
  23. package/dist/src/danger-classifier.js +286 -0
  24. package/dist/src/execution-evidence.js +33 -1
  25. package/dist/src/git-helpers.js +160 -0
  26. package/dist/src/hooks.js +351 -0
  27. package/dist/src/host-bind.js +111 -0
  28. package/dist/src/host-breaker.js +88 -0
  29. package/dist/src/host-start.js +41 -1
  30. package/dist/src/host.js +284 -6
  31. package/dist/src/house-extension.js +292 -61
  32. package/dist/src/local-coding.js +726 -35
  33. package/dist/src/local-edit-helper.js +25 -0
  34. package/dist/src/local-edits.js +168 -6
  35. package/dist/src/local-undo.js +25 -16
  36. package/dist/src/managed.js +1 -1
  37. package/dist/src/mentions.js +277 -0
  38. package/dist/src/operator-mcp-cli.js +107 -0
  39. package/dist/src/operator-mcp.js +342 -0
  40. package/dist/src/persona.js +31 -0
  41. package/dist/src/pi-sessions.js +5 -1
  42. package/dist/src/pi.js +22 -1
  43. package/dist/src/project-instructions.js +195 -0
  44. package/dist/src/protocol.js +9 -3
  45. package/dist/src/rate-limit-retries.js +62 -0
  46. package/dist/src/recurring-work.js +1 -0
  47. package/dist/src/repository-snapshots.js +5 -2
  48. package/dist/src/run-grants.js +62 -0
  49. package/dist/src/service.js +482 -10
  50. package/dist/src/session-pick.js +77 -0
  51. package/dist/src/shell-passthrough.js +39 -0
  52. package/dist/src/station-checkpoints.js +139 -0
  53. package/dist/src/station-config.js +8 -1
  54. package/dist/src/station-files.js +20 -1
  55. package/dist/src/station-task-verification.js +6 -3
  56. package/dist/src/station-work.js +315 -22
  57. package/dist/src/store.js +70 -4
  58. package/dist/src/supervisor.js +408 -25
  59. package/dist/src/task-budget.js +39 -3
  60. package/dist/src/task-context.js +8 -2
  61. package/dist/src/task-critic.js +115 -0
  62. package/dist/src/task-driver.js +46 -2
  63. package/dist/src/task-policy.js +44 -12
  64. package/dist/src/task-verification.js +42 -9
  65. package/dist/src/task-work.js +47 -0
  66. package/dist/src/terminal-status.js +88 -0
  67. package/dist/src/tool-identity.js +69 -0
  68. package/dist/src/tui-activity.js +99 -0
  69. package/dist/src/tui-classifier.js +25 -0
  70. package/dist/src/tui-client.js +186 -5
  71. package/dist/src/tui-commands.js +23 -5
  72. package/dist/src/tui-composer.js +113 -0
  73. package/dist/src/tui-keymap.js +116 -0
  74. package/dist/src/tui-mentions.js +38 -0
  75. package/dist/src/tui-modals.js +157 -34
  76. package/dist/src/tui-notify.js +62 -0
  77. package/dist/src/tui-review.js +55 -0
  78. package/dist/src/tui-turn-keys.js +52 -0
  79. package/dist/src/tui-view.js +243 -69
  80. package/dist/src/tui.js +875 -118
  81. package/dist/src/turn-checkpoints.js +221 -0
  82. package/dist/src/turn-rewind.js +168 -0
  83. package/dist/src/usage.js +1 -1
  84. package/dist/src/web-fetch.js +114 -0
  85. package/dist/src/web-guard.js +163 -0
  86. package/dist/src/web-search.js +302 -0
  87. package/npm-shrinkwrap.json +2 -2
  88. package/package.json +1 -1
  89. package/src/acp-desk.ts +155 -0
  90. package/src/acp-outcome.ts +174 -20
  91. package/src/acp.ts +386 -30
  92. package/src/agent-profiles.ts +48 -0
  93. package/src/agentpod-authority.ts +234 -15
  94. package/src/agentpod-default-config.ts +16 -0
  95. package/src/agentpod-work-decision-protocol.ts +69 -8
  96. package/src/agentpod-work-decisions.ts +123 -5
  97. package/src/agentpod-work.ts +1455 -152
  98. package/src/approval-rules.ts +360 -0
  99. package/src/attach.ts +4 -1
  100. package/src/autonomy.ts +111 -10
  101. package/src/board-reads.ts +148 -0
  102. package/src/children.ts +112 -0
  103. package/src/cli-help.ts +60 -6
  104. package/src/cli.ts +174 -4
  105. package/src/command-danger.ts +886 -0
  106. package/src/command-network.ts +215 -0
  107. package/src/command-sandbox.ts +393 -0
  108. package/src/custom-commands.ts +175 -0
  109. package/src/danger-classifier.ts +321 -0
  110. package/src/execution-evidence.ts +31 -1
  111. package/src/git-helpers.ts +177 -0
  112. package/src/hooks.ts +425 -0
  113. package/src/host-bind.ts +156 -0
  114. package/src/host-breaker.ts +89 -0
  115. package/src/host-start.ts +51 -1
  116. package/src/host.ts +365 -6
  117. package/src/house-extension.ts +313 -60
  118. package/src/local-coding.ts +894 -43
  119. package/src/local-edit-helper.ts +26 -0
  120. package/src/local-edits.ts +191 -8
  121. package/src/local-undo.ts +29 -14
  122. package/src/managed.ts +2 -2
  123. package/src/mentions.ts +304 -0
  124. package/src/operator-mcp-cli.ts +108 -0
  125. package/src/operator-mcp.ts +420 -0
  126. package/src/persona.ts +37 -0
  127. package/src/pi-sessions.ts +9 -1
  128. package/src/pi.ts +23 -1
  129. package/src/project-instructions.ts +229 -0
  130. package/src/protocol.ts +25 -3
  131. package/src/rate-limit-retries.ts +69 -0
  132. package/src/recurring-work.ts +1 -0
  133. package/src/repository-snapshots.ts +5 -2
  134. package/src/run-grants.ts +107 -0
  135. package/src/service.ts +709 -10
  136. package/src/session-pick.ts +102 -0
  137. package/src/shell-passthrough.ts +38 -0
  138. package/src/station-checkpoints.ts +183 -0
  139. package/src/station-config.ts +10 -1
  140. package/src/station-files.ts +20 -1
  141. package/src/station-task-verification.ts +6 -3
  142. package/src/station-work.ts +369 -20
  143. package/src/store.ts +70 -3
  144. package/src/supervisor.ts +463 -26
  145. package/src/task-budget.ts +48 -3
  146. package/src/task-context.ts +9 -2
  147. package/src/task-critic.ts +171 -0
  148. package/src/task-driver.ts +49 -2
  149. package/src/task-policy.ts +65 -13
  150. package/src/task-verification.ts +53 -9
  151. package/src/task-work.ts +75 -0
  152. package/src/terminal-status.ts +103 -0
  153. package/src/tool-identity.ts +110 -0
  154. package/src/tui-activity.ts +133 -0
  155. package/src/tui-classifier.ts +38 -0
  156. package/src/tui-client.ts +239 -8
  157. package/src/tui-commands.ts +49 -10
  158. package/src/tui-composer.ts +124 -0
  159. package/src/tui-keymap.ts +152 -0
  160. package/src/tui-mentions.ts +43 -0
  161. package/src/tui-modals.ts +173 -43
  162. package/src/tui-notify.ts +77 -0
  163. package/src/tui-review.ts +76 -0
  164. package/src/tui-turn-keys.ts +77 -0
  165. package/src/tui-view.ts +341 -88
  166. package/src/tui.ts +1047 -137
  167. package/src/turn-checkpoints.ts +253 -0
  168. package/src/turn-rewind.ts +258 -0
  169. package/src/usage.ts +1 -1
  170. package/src/web-fetch.ts +140 -0
  171. package/src/web-guard.ts +195 -0
  172. package/src/web-search.ts +402 -0
package/src/host.ts CHANGED
@@ -1,4 +1,6 @@
1
- import { spawn } from 'node:child_process';
1
+ import { DEFAULT_COMMAND_NETWORK_SOCKET } from './command-network.js';
2
+ import { loadDangerClassifierOptions } from './danger-classifier.js';
3
+ import { spawn, execFileSync } from 'node:child_process';
2
4
  import { once } from 'node:events';
3
5
  import {
4
6
  readFileSync,
@@ -30,6 +32,14 @@ import { acquireStateLock } from './state-lock.js';
30
32
  import { call } from './transport.js';
31
33
  import { readStationConfig } from './station-config.js';
32
34
  import { readToolkitConfig } from './toolkit-config.js';
35
+ import { OperatorMcp } from './operator-mcp.js';
36
+ import {
37
+ chooseLocalSandbox,
38
+ resolveReadPaths,
39
+ sandboxLabel,
40
+ type SandboxKind,
41
+ } from './command-sandbox.js';
42
+ import { loadPersona } from './persona.js';
33
43
  interface HostIdentity {
34
44
  node: string;
35
45
  node_version: string;
@@ -53,6 +63,7 @@ interface AgentPodHostIdentity extends HostIdentity {
53
63
  pins_digest: string;
54
64
  workspace: string;
55
65
  cli_digest: string;
66
+ local_sandbox?: HostLocalSandbox;
56
67
  }
57
68
  export interface AgentPodHostManifest extends AgentPodHostIdentity {
58
69
  schema: 2;
@@ -78,6 +89,135 @@ export interface AgentPodStrictWorkHostManifest extends Omit<
78
89
  > {
79
90
  schema: 6;
80
91
  strict_work: { version: 1 };
92
+ /** Launcher profile: a root host whose operator socket one unprivileged group may use. */
93
+ operator_access?: OperatorAccess;
94
+ /** Operator-declared MCP servers (docs/mcp.md), pinned by declaration digest. */
95
+ operator_mcp?: { config: string; digest: string };
96
+ /** Item 9: the operator's persona file, pinned by content digest. */
97
+ persona?: { config: string; digest: string };
98
+ /** How long a strict approval offer stays answerable; the daemon default when absent. */
99
+ approval_ttl_ms?: number;
100
+ }
101
+ /** Item 10: the command sandbox this host gives local commands, as reviewed at host plan. With no
102
+ * usable sandbox the host refuses local commands (label "refused") unless the operator set
103
+ * `unsandboxed_commands: "allow"`, which the manifest pins (operator ruling 2026-10-30, option A). */
104
+ export interface HostLocalSandbox {
105
+ kind: SandboxKind;
106
+ label: string;
107
+ unsandboxed_commands?: 'allow';
108
+ /** Network allowlist (operator rulings 2026-10-30): local commands reach the network only
109
+ * through stk's local-commands socket, whose egress.hosts the host operator sets. */
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 };
124
+ }
125
+ export function hostLocalSandbox(
126
+ platform: NodeJS.Platform = process.platform,
127
+ find?: Parameters<typeof chooseLocalSandbox>[2],
128
+ findSb?: Parameters<typeof chooseLocalSandbox>[3],
129
+ allowUnsandboxed = false,
130
+ brokerSocket?: string,
131
+ readPaths: readonly string[] = [],
132
+ allowFullAccessSessions = false,
133
+ allowYoloSessions = false,
134
+ dangerClassifier?: HostLocalSandbox['danger_classifier'],
135
+ ): HostLocalSandbox {
136
+ const { kind } = chooseLocalSandbox('workspace-write', platform, find, findSb);
137
+ return {
138
+ kind,
139
+ label: kind === 'none' && !allowUnsandboxed ? 'refused' : sandboxLabel(kind),
140
+ ...(allowUnsandboxed ? { unsandboxed_commands: 'allow' as const } : {}),
141
+ ...(brokerSocket
142
+ ? {
143
+ command_network: {
144
+ mode: 'broker' as const,
145
+ socket: brokerSocket,
146
+ label: 'broker (hosts set by the stk policy)',
147
+ },
148
+ }
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 } : {}),
154
+ };
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
+ }
178
+ /** The broker socket local commands reach the network through, or null for no network. */
179
+ export function hostCommandNetwork(m: HostManifest): { socket: string } | null {
180
+ const n = (m as { local_sandbox?: HostLocalSandbox }).local_sandbox?.command_network;
181
+ return n?.mode === 'broker' && typeof n.socket === 'string' ? { socket: n.socket } : null;
182
+ }
183
+ /** Whether the reviewed manifest lets local commands run unsandboxed on a host without a sandbox. */
184
+ export function hostAllowsUnsandboxed(m: HostManifest): boolean {
185
+ return (
186
+ (m as { local_sandbox?: HostLocalSandbox }).local_sandbox?.unsandboxed_commands === 'allow'
187
+ );
188
+ }
189
+ export interface OperatorAccess {
190
+ group: string;
191
+ gid: number;
192
+ }
193
+ const groupName = /^[a-z_][a-z0-9_-]{0,31}$/;
194
+ /** Resolve a system group by name. The launcher profile never accepts a numeric id alone. */
195
+ export function resolveOperatorGroup(group: unknown): OperatorAccess {
196
+ if (typeof group !== 'string' || !groupName.test(group))
197
+ throw Error('operator_group must be a system group name');
198
+ let line: string;
199
+ try {
200
+ line = execFileSync('/usr/bin/getent', ['group', group], { encoding: 'utf8' }).trim();
201
+ } catch {
202
+ throw Error('operator_group does not exist');
203
+ }
204
+ const [name, , id] = line.split(':');
205
+ const gid = Number(id);
206
+ if (name !== group || !Number.isInteger(gid) || gid <= 0)
207
+ throw Error('operator_group must name a non-root group');
208
+ return { group, gid };
209
+ }
210
+ /** One minute to one day: long enough for a person to answer, short enough to free the station. */
211
+ function approvalTtl(value: unknown): number {
212
+ if (!Number.isInteger(value) || (value as number) < 60000 || (value as number) > 86400000)
213
+ throw Error('approval_ttl_ms must be an integer from 60000 to 86400000');
214
+ return value as number;
215
+ }
216
+ export function hostApprovalTtlMs(m: HostManifest): number | undefined {
217
+ return m.schema === 6 ? m.approval_ttl_ms : undefined;
218
+ }
219
+ export function hostOperatorAccess(m: HostManifest): OperatorAccess | undefined {
220
+ return m.schema === 6 ? m.operator_access : undefined;
81
221
  }
82
222
  export type HostManifest =
83
223
  | CodingHostManifest
@@ -109,7 +249,7 @@ function privateDocument(path: string): Record<string, unknown> {
109
249
  closeSync(fd);
110
250
  }
111
251
  }
112
- function privateState(path: string): string {
252
+ function privateState(path: string, gid?: number): string {
113
253
  if (!isAbsolute(path) || resolve(path) !== path)
114
254
  throw Error('Use canonical absolute private state paths');
115
255
  // Existing ancestors must resolve to their literal identity; never follow a workspace symlink.
@@ -119,7 +259,9 @@ function privateState(path: string): string {
119
259
  throw Error('Private state path must not contain symlinks');
120
260
  if (existsSync(path)) {
121
261
  const st = lstatSync(path);
122
- if (!st.isDirectory() || st.uid !== process.getuid?.() || st.mode & 0o077)
262
+ // The launcher profile lets exactly one group traverse (x) to the operator socket.
263
+ const shared = gid !== undefined && (st.mode & 0o077) === 0o010 && st.gid === gid;
264
+ if (!st.isDirectory() || st.uid !== process.getuid?.() || (st.mode & 0o077 && !shared))
123
265
  throw Error('Host state must be a private owner-held directory');
124
266
  }
125
267
  return path;
@@ -145,6 +287,17 @@ async function planAgentPod(
145
287
  'toolkit_config',
146
288
  'station_config',
147
289
  'strict_work_version',
290
+ 'operator_group',
291
+ 'operator_mcp_config',
292
+ 'persona_config',
293
+ 'approval_ttl_ms',
294
+ 'unsandboxed_commands',
295
+ 'command_network',
296
+ 'command_network_socket',
297
+ 'local_sandbox_read_paths',
298
+ 'allow_full_access_sessions',
299
+ 'allow_yolo_sessions',
300
+ 'danger_classifier',
148
301
  ];
149
302
  if (Object.keys(c).some((k) => !fields.includes(k)))
150
303
  throw Error('Unexpected AgentPod host config field');
@@ -153,7 +306,71 @@ async function planAgentPod(
153
306
  (c.strict_work_version !== 1 || c.station_config === undefined)
154
307
  )
155
308
  throw Error('Strict work version1 requires reviewed protected station config');
156
- const state = privateState(string(c.state, 'state'));
309
+ let access: OperatorAccess | undefined;
310
+ if (c.operator_group !== undefined) {
311
+ if (c.strict_work_version !== 1)
312
+ throw Error('operator_group is only for the strict work launcher profile');
313
+ if (process.platform !== 'linux' || process.getuid?.() !== 0)
314
+ throw Error('The launcher profile is planned by root on Linux');
315
+ access = resolveOperatorGroup(c.operator_group);
316
+ }
317
+ let operatorMcp: { config: string; digest: string } | undefined;
318
+ if (c.operator_mcp_config !== undefined) {
319
+ if (c.strict_work_version !== 1)
320
+ throw Error('operator_mcp_config is only for the strict work host profile');
321
+ const config = string(c.operator_mcp_config, 'operator_mcp_config');
322
+ if (!isAbsolute(config) || realpathSync(config) !== config)
323
+ throw Error('Use a canonical absolute operator MCP config path');
324
+ operatorMcp = { config, digest: OperatorMcp.load(config).digest };
325
+ }
326
+ let persona: { config: string; digest: string } | undefined;
327
+ if (c.persona_config !== undefined) {
328
+ if (c.strict_work_version !== 1)
329
+ throw Error('persona_config is only for the strict work host profile');
330
+ const config = string(c.persona_config, 'persona_config');
331
+ persona = { config, digest: loadPersona(config).digest };
332
+ }
333
+ let ttl: number | undefined;
334
+ if (c.approval_ttl_ms !== undefined) {
335
+ if (c.strict_work_version !== 1)
336
+ throw Error('approval_ttl_ms is only for the strict work host profile');
337
+ ttl = approvalTtl(c.approval_ttl_ms);
338
+ }
339
+ if (c.unsandboxed_commands !== undefined && c.unsandboxed_commands !== 'allow')
340
+ throw Error('unsandboxed_commands is "allow" or absent');
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;
348
+ if (c.command_network !== undefined && c.command_network !== 'broker')
349
+ throw Error('command_network is "broker" or absent');
350
+ if (c.command_network_socket !== undefined) {
351
+ if (c.command_network !== 'broker')
352
+ throw Error('command_network_socket is only for command_network "broker"');
353
+ const sock = c.command_network_socket;
354
+ if (typeof sock !== 'string' || !isAbsolute(sock) || sock.includes('\0'))
355
+ throw Error('command_network_socket must be an absolute path');
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
+ }
369
+ const brokerSocket =
370
+ c.command_network === 'broker'
371
+ ? String(c.command_network_socket ?? DEFAULT_COMMAND_NETWORK_SOCKET)
372
+ : undefined;
373
+ const state = privateState(string(c.state, 'state'), access?.gid);
157
374
  const config = string(c.agentpod_config, 'agentpod_config');
158
375
  if (!isAbsolute(config) || realpathSync(config) !== config)
159
376
  throw Error('Use a canonical absolute pins path');
@@ -205,7 +422,14 @@ async function planAgentPod(
205
422
  ...(station
206
423
  ? {
207
424
  ...(c.strict_work_version === 1
208
- ? { schema: 6 as const, strict_work: { version: 1 as const } }
425
+ ? {
426
+ schema: 6 as const,
427
+ strict_work: { version: 1 as const },
428
+ ...(access ? { operator_access: access } : {}),
429
+ ...(operatorMcp ? { operator_mcp: operatorMcp } : {}),
430
+ ...(persona ? { persona } : {}),
431
+ ...(ttl !== undefined ? { approval_ttl_ms: ttl } : {}),
432
+ }
209
433
  : { schema: 5 as const }),
210
434
  station_execution: station,
211
435
  ...(toolkit ? { managed_toolkit: toolkit } : {}),
@@ -231,6 +455,54 @@ async function planAgentPod(
231
455
  pins_digest: digest(pins),
232
456
  workspace: pins.workspace,
233
457
  cli_digest: digest(readFileSync(join(ROOT, 'bin/salaam.mjs'), 'utf8')),
458
+ local_sandbox: hostLocalSandbox(
459
+ undefined,
460
+ undefined,
461
+ undefined,
462
+ allowUnsandboxed,
463
+ brokerSocket,
464
+ readPaths,
465
+ allowFullAccessSessions,
466
+ allowYoloSessions,
467
+ dangerClassifier,
468
+ ),
469
+ };
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 } : {}),
234
506
  };
235
507
  }
236
508
  export function hostDigests() {
@@ -335,6 +607,11 @@ export function readHost(path: string): HostManifest {
335
607
  : []),
336
608
  ...([5, 6].includes(m.schema) ? ['station_execution'] : []),
337
609
  ...(m.schema === 6 ? ['strict_work'] : []),
610
+ ...(m.schema === 6 && Object.hasOwn(m, 'operator_access') ? ['operator_access'] : []),
611
+ ...(m.schema === 6 && Object.hasOwn(m, 'operator_mcp') ? ['operator_mcp'] : []),
612
+ ...(m.schema === 6 && Object.hasOwn(m, 'persona') ? ['persona'] : []),
613
+ ...(m.schema === 6 && Object.hasOwn(m, 'approval_ttl_ms') ? ['approval_ttl_ms'] : []),
614
+ ...(Object.hasOwn(m, 'local_sandbox') ? ['local_sandbox'] : []),
338
615
  ];
339
616
  if (m.mode !== 'agentpod' || Object.keys(m).sort().join(',') !== fields.sort().join(','))
340
617
  throw Error('Unsupported AgentPod host manifest fields');
@@ -343,9 +620,64 @@ export function readHost(path: string): HostManifest {
343
620
  (Object.keys(object(m.strict_work)).join(',') !== 'version' || m.strict_work.version !== 1)
344
621
  )
345
622
  throw Error('Unsupported strict work host contract');
346
- privateState(m.state);
623
+ const access = hostOperatorAccess(m);
624
+ if (access !== undefined) {
625
+ const a = object(access);
626
+ if (
627
+ Object.keys(a).sort().join(',') !== 'gid,group' ||
628
+ canonical(resolveOperatorGroup(a.group)) !== canonical({ group: a.group, gid: a.gid })
629
+ )
630
+ throw Error('Host operator group changed');
631
+ }
632
+ privateState(m.state, access?.gid);
633
+ if (m.schema === 6 && Object.hasOwn(m, 'approval_ttl_ms')) approvalTtl(m.approval_ttl_ms);
634
+ if (m.schema === 6 && Object.hasOwn(m, 'operator_mcp')) {
635
+ const o = object(m.operator_mcp);
636
+ if (
637
+ Object.keys(o).sort().join(',') !== 'config,digest' ||
638
+ typeof o.config !== 'string' ||
639
+ !isAbsolute(o.config) ||
640
+ realpathSync(o.config) !== o.config ||
641
+ OperatorMcp.load(o.config).digest !== o.digest
642
+ )
643
+ throw Error('Operator MCP declarations changed');
644
+ }
645
+ if (m.schema === 6 && Object.hasOwn(m, 'persona')) {
646
+ const p = object(m.persona);
647
+ let current: string | null = null;
648
+ try {
649
+ current = loadPersona(String(p.config)).digest;
650
+ } catch {
651
+ current = null;
652
+ }
653
+ if (Object.keys(p).sort().join(',') !== 'config,digest' || current !== p.digest)
654
+ throw Error('Host persona changed; run host plan again');
655
+ }
347
656
  if (m.cli_digest !== digest(readFileSync(join(ROOT, 'bin/salaam.mjs'), 'utf8')))
348
657
  throw Error('Host CLI changed');
658
+ if (
659
+ Object.hasOwn(m, 'local_sandbox') &&
660
+ canonical(m.local_sandbox) !==
661
+ canonical(
662
+ hostLocalSandbox(
663
+ undefined,
664
+ undefined,
665
+ undefined,
666
+ hostAllowsUnsandboxed(m),
667
+ hostCommandNetwork(m)?.socket,
668
+ hostReadPaths(m),
669
+ hostAllowsFullAccessSessions(m),
670
+ hostAllowsYoloSessions(m),
671
+ (m as { local_sandbox?: HostLocalSandbox }).local_sandbox?.danger_classifier,
672
+ ),
673
+ )
674
+ )
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
+ }
349
681
  const pins = readAgentPodPins(m.agentpod_config, m.state);
350
682
  const managed = hostMcp(m);
351
683
  if (m.schema === 3 || ([4, 5, 6].includes(m.schema) && Object.hasOwn(m, 'managed_mcp'))) {
@@ -396,6 +728,33 @@ export function readHost(path: string): HostManifest {
396
728
  }
397
729
  return m;
398
730
  }
731
+ /** What the daemon reports about itself so that a caller which cannot read the
732
+ * manifest (the launcher profile) receives the same receipt as host start. */
733
+ export function hostReceipt(m: HostManifest) {
734
+ if (m.schema === 1) return undefined;
735
+ const pins = readAgentPodPins(m.agentpod_config, m.state);
736
+ return {
737
+ version: 1,
738
+ ready: true,
739
+ manifest_digest: digest(m),
740
+ node: m.node,
741
+ cli: join(m.repo, 'bin/salaam.mjs'),
742
+ state: m.state,
743
+ workspace: m.workspace,
744
+ node_id: pins.node_id,
745
+ pins_digest: m.pins_digest,
746
+ strict_work: m.schema === 6 ? m.strict_work : null,
747
+ };
748
+ }
749
+ /** The tools user a reviewed station config names, for host serve's --tools-user. */
750
+ export function hostToolsUser(m: HostManifest): string | undefined {
751
+ if (m.schema !== 5 && m.schema !== 6) return undefined;
752
+ return readStationConfig(
753
+ m.station_execution.config,
754
+ m.workspace,
755
+ m.station_execution.configuration_digest,
756
+ ).tools_user;
757
+ }
399
758
  export function hostUnit(path: string, platform: string): string {
400
759
  const m = readHost(path),
401
760
  manifest = realpathSync(path),