@superjackfruitlabs/salaam 0.0.3 → 0.0.4

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 (102) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +12 -11
  3. package/dist/src/acp-outcome.js +177 -17
  4. package/dist/src/acp.js +41 -7
  5. package/dist/src/agent-profiles.js +45 -0
  6. package/dist/src/agentpod-authority.js +169 -11
  7. package/dist/src/agentpod-default-config.js +12 -0
  8. package/dist/src/agentpod-work-decision-protocol.js +40 -6
  9. package/dist/src/agentpod-work-decisions.js +108 -4
  10. package/dist/src/agentpod-work.js +1214 -127
  11. package/dist/src/board-reads.js +106 -0
  12. package/dist/src/children.js +83 -0
  13. package/dist/src/cli-help.js +27 -2
  14. package/dist/src/cli.js +87 -3
  15. package/dist/src/command-network.js +200 -0
  16. package/dist/src/command-sandbox.js +217 -0
  17. package/dist/src/execution-evidence.js +33 -1
  18. package/dist/src/host-bind.js +111 -0
  19. package/dist/src/host-breaker.js +88 -0
  20. package/dist/src/host-start.js +41 -1
  21. package/dist/src/host.js +192 -6
  22. package/dist/src/house-extension.js +162 -57
  23. package/dist/src/local-coding.js +214 -18
  24. package/dist/src/local-edit-helper.js +25 -0
  25. package/dist/src/local-undo.js +25 -16
  26. package/dist/src/managed.js +1 -1
  27. package/dist/src/operator-mcp-cli.js +107 -0
  28. package/dist/src/operator-mcp.js +342 -0
  29. package/dist/src/persona.js +31 -0
  30. package/dist/src/pi.js +22 -1
  31. package/dist/src/protocol.js +5 -2
  32. package/dist/src/rate-limit-retries.js +62 -0
  33. package/dist/src/recurring-work.js +1 -0
  34. package/dist/src/repository-snapshots.js +5 -2
  35. package/dist/src/run-grants.js +62 -0
  36. package/dist/src/service.js +141 -6
  37. package/dist/src/station-checkpoints.js +139 -0
  38. package/dist/src/station-config.js +8 -1
  39. package/dist/src/station-files.js +20 -1
  40. package/dist/src/station-task-verification.js +6 -3
  41. package/dist/src/station-work.js +315 -22
  42. package/dist/src/store.js +19 -0
  43. package/dist/src/supervisor.js +177 -5
  44. package/dist/src/task-budget.js +39 -3
  45. package/dist/src/task-critic.js +115 -0
  46. package/dist/src/task-driver.js +46 -2
  47. package/dist/src/task-policy.js +44 -12
  48. package/dist/src/task-verification.js +42 -9
  49. package/dist/src/task-work.js +47 -0
  50. package/dist/src/tool-identity.js +69 -0
  51. package/dist/src/usage.js +1 -1
  52. package/npm-shrinkwrap.json +2 -2
  53. package/package.json +1 -1
  54. package/src/acp-outcome.ts +169 -19
  55. package/src/acp.ts +53 -7
  56. package/src/agent-profiles.ts +48 -0
  57. package/src/agentpod-authority.ts +234 -15
  58. package/src/agentpod-default-config.ts +16 -0
  59. package/src/agentpod-work-decision-protocol.ts +69 -8
  60. package/src/agentpod-work-decisions.ts +123 -5
  61. package/src/agentpod-work.ts +1455 -152
  62. package/src/board-reads.ts +148 -0
  63. package/src/children.ts +112 -0
  64. package/src/cli-help.ts +27 -2
  65. package/src/cli.ts +109 -3
  66. package/src/command-network.ts +215 -0
  67. package/src/command-sandbox.ts +273 -0
  68. package/src/execution-evidence.ts +31 -1
  69. package/src/host-bind.ts +156 -0
  70. package/src/host-breaker.ts +89 -0
  71. package/src/host-start.ts +51 -1
  72. package/src/host.ts +244 -6
  73. package/src/house-extension.ts +174 -56
  74. package/src/local-coding.ts +270 -18
  75. package/src/local-edit-helper.ts +26 -0
  76. package/src/local-undo.ts +29 -14
  77. package/src/managed.ts +2 -2
  78. package/src/operator-mcp-cli.ts +108 -0
  79. package/src/operator-mcp.ts +420 -0
  80. package/src/persona.ts +37 -0
  81. package/src/pi.ts +23 -1
  82. package/src/protocol.ts +6 -1
  83. package/src/rate-limit-retries.ts +69 -0
  84. package/src/recurring-work.ts +1 -0
  85. package/src/repository-snapshots.ts +5 -2
  86. package/src/run-grants.ts +107 -0
  87. package/src/service.ts +222 -6
  88. package/src/station-checkpoints.ts +183 -0
  89. package/src/station-config.ts +10 -1
  90. package/src/station-files.ts +20 -1
  91. package/src/station-task-verification.ts +6 -3
  92. package/src/station-work.ts +369 -20
  93. package/src/store.ts +18 -0
  94. package/src/supervisor.ts +203 -4
  95. package/src/task-budget.ts +48 -3
  96. package/src/task-critic.ts +171 -0
  97. package/src/task-driver.ts +49 -2
  98. package/src/task-policy.ts +65 -13
  99. package/src/task-verification.ts +53 -9
  100. package/src/task-work.ts +75 -0
  101. package/src/tool-identity.ts +110 -0
  102. package/src/usage.ts +1 -1
@@ -0,0 +1,215 @@
1
+ import { createServer, connect, type Server, type Socket } from 'node:net';
2
+ import { chmodSync, mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ /**
7
+ * Network for local commands through the broker allowlist (operator rulings 2026-10-30). A command
8
+ * has no network of its own; its only route out is stk's `local-commands` socket, where every
9
+ * connection is the `local-commands` identity and only `servers.local-commands.egress.hosts`
10
+ * decides. Salaam puts a relay of its own in between, outside the sandbox, so what a command
11
+ * reached is counted where the command cannot touch it.
12
+ *
13
+ * Linux: the relay listens on a unix socket in a private directory bound into the bubblewrap
14
+ * sandbox; inside, `inSandboxRelaySource` bridges 127.0.0.1:COMMAND_NETWORK_PORT to it and then
15
+ * starts the command. macOS: the relay listens on 127.0.0.1:<ephemeral>, the only address the
16
+ * Seatbelt profile lets the command connect to.
17
+ */
18
+ export const COMMAND_NETWORK_PORT = 3128;
19
+ export const DEFAULT_COMMAND_NETWORK_SOCKET = '/run/stk/local-commands.sock';
20
+ const MAX_TARGETS = 32;
21
+ const MAX_REASON = 300;
22
+
23
+ export interface CommandNetworkSummary {
24
+ broker: 'available' | 'unavailable';
25
+ connections: { target: string; allowed: number; refused: number; reason?: string }[];
26
+ truncated?: boolean;
27
+ }
28
+
29
+ export class BrokerRelay {
30
+ private counts = new Map<string, { allowed: number; refused: number; reason?: string }>();
31
+ private truncated = false;
32
+ private unavailable = false;
33
+ private sockets = new Set<Socket>();
34
+ private constructor(
35
+ private readonly server: Server,
36
+ private readonly broker: string,
37
+ readonly port: number | null,
38
+ readonly path: string | null,
39
+ readonly dir: string | null,
40
+ ) {}
41
+
42
+ /** `tcp`: 127.0.0.1:<ephemeral> (macOS). `unix`: a socket in a new private directory (Linux),
43
+ * readable by the command's user so a tools-user command can reach it. */
44
+ static async start(broker: string, mode: 'tcp' | 'unix'): Promise<BrokerRelay> {
45
+ let relay!: BrokerRelay;
46
+ const server = createServer((client) => relay.handle(client));
47
+ if (mode === 'tcp') {
48
+ await new Promise<void>((resolve, reject) => {
49
+ server.once('error', reject);
50
+ server.listen(0, '127.0.0.1', () => resolve());
51
+ });
52
+ relay = new BrokerRelay(
53
+ server,
54
+ broker,
55
+ (server.address() as { port: number }).port,
56
+ null,
57
+ null,
58
+ );
59
+ } else {
60
+ const dir = mkdtempSync(join(tmpdir(), 'salaam-net-'));
61
+ chmodSync(dir, 0o755);
62
+ const path = join(dir, 'relay.sock');
63
+ await new Promise<void>((resolve, reject) => {
64
+ server.once('error', reject);
65
+ server.listen(path, () => resolve());
66
+ });
67
+ chmodSync(path, 0o666);
68
+ relay = new BrokerRelay(server, broker, null, path, dir);
69
+ }
70
+ return relay;
71
+ }
72
+
73
+ /** A client connection to this relay, for tests. */
74
+ connect(): Socket {
75
+ return this.path ? connect(this.path) : connect(this.port!, '127.0.0.1');
76
+ }
77
+
78
+ private record(target: string, allowed: boolean, reason?: string) {
79
+ let entry = this.counts.get(target);
80
+ if (!entry) {
81
+ if (this.counts.size >= MAX_TARGETS) {
82
+ this.truncated = true;
83
+ return;
84
+ }
85
+ entry = { allowed: 0, refused: 0 };
86
+ this.counts.set(target, entry);
87
+ }
88
+ if (allowed) entry.allowed++;
89
+ else {
90
+ entry.refused++;
91
+ if (reason && !entry.reason) entry.reason = reason.slice(0, MAX_REASON);
92
+ }
93
+ }
94
+
95
+ private handle(client: Socket) {
96
+ this.sockets.add(client);
97
+ client.on('close', () => this.sockets.delete(client));
98
+ client.on('error', () => {});
99
+ let head = Buffer.alloc(0);
100
+ const onHead = (chunk: Buffer) => {
101
+ head = Buffer.concat([head, chunk]);
102
+ const end = head.indexOf('\r\n');
103
+ if (end < 0) {
104
+ if (head.length > 8192) client.destroy();
105
+ return;
106
+ }
107
+ client.off('data', onHead);
108
+ client.pause();
109
+ const line = head.subarray(0, end).toString('latin1');
110
+ const connectLine = /^CONNECT (\S+) HTTP\/1\.[01]$/.exec(line);
111
+ const absolute = /^[A-Z]+ http:\/\/([^/\s]+)/.exec(line);
112
+ const target = connectLine
113
+ ? connectLine[1]!.toLowerCase()
114
+ : absolute
115
+ ? (absolute[1]!.includes(':') ? absolute[1]! : absolute[1] + ':80').toLowerCase()
116
+ : 'invalid-request';
117
+ const up = connect(this.broker);
118
+ this.sockets.add(up);
119
+ up.on('close', () => this.sockets.delete(up));
120
+ up.once('error', () => {
121
+ this.unavailable = true;
122
+ this.record(target, false, 'the broker socket is unavailable');
123
+ const body = 'salaam: the command network broker is unavailable\n';
124
+ client.end(
125
+ `HTTP/1.1 502 Bad Gateway\r\nContent-Length: ${body.length}\r\nConnection: close\r\n\r\n${body}`,
126
+ );
127
+ });
128
+ up.once('connect', () => {
129
+ let reply = Buffer.alloc(0),
130
+ decided = false;
131
+ const onReply = (chunk: Buffer) => {
132
+ client.write(chunk);
133
+ if (decided) return;
134
+ reply = Buffer.concat([reply, chunk]);
135
+ const status = /^HTTP\/1\.[01] (\d{3})/.exec(reply.toString('latin1'));
136
+ if (!status) {
137
+ if (reply.length > 8192) decided = true;
138
+ return;
139
+ }
140
+ const code = Number(status[1]);
141
+ if (code >= 200 && code < 300) {
142
+ decided = true;
143
+ this.record(target, true);
144
+ return;
145
+ }
146
+ // A refusal: wait for the body so the reason names the fix, as stk writes it.
147
+ const text = reply.toString('utf8');
148
+ const bodyAt = text.indexOf('\r\n\r\n');
149
+ if (bodyAt >= 0 && text.length > bodyAt + 4) {
150
+ decided = true;
151
+ this.record(
152
+ target,
153
+ false,
154
+ text
155
+ .slice(bodyAt + 4)
156
+ .trim()
157
+ .split('\n')[0],
158
+ );
159
+ }
160
+ };
161
+ up.on('data', onReply);
162
+ up.on('end', () => {
163
+ if (!decided) {
164
+ decided = true;
165
+ const status = /^HTTP\/1\.[01] (\d{3})[^\r\n]*/.exec(reply.toString('latin1'));
166
+ const code = status ? Number(status[1]) : 0;
167
+ this.record(target, code >= 200 && code < 300, status ? status[0] : 'no reply');
168
+ }
169
+ client.end();
170
+ });
171
+ up.write(head);
172
+ client.on('data', (d) => up.write(d));
173
+ client.on('end', () => up.end());
174
+ client.resume();
175
+ });
176
+ };
177
+ client.on('data', onHead);
178
+ }
179
+
180
+ summary(): CommandNetworkSummary {
181
+ return {
182
+ broker: this.unavailable ? 'unavailable' : 'available',
183
+ connections: [...this.counts].map(([target, c]) => ({ target, ...c })),
184
+ ...(this.truncated ? { truncated: true } : {}),
185
+ };
186
+ }
187
+
188
+ async close(): Promise<void> {
189
+ for (const s of this.sockets) s.destroy();
190
+ await new Promise<void>((resolve) => this.server.close(() => resolve()));
191
+ if (this.dir) rmSync(this.dir, { recursive: true, force: true });
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Runs inside the bubblewrap sandbox as `node -e <this> <unix socket> <port> <command...>`: listens
197
+ * on 127.0.0.1:<port> in the sandbox's own network namespace, pipes each connection to the bound
198
+ * relay socket, then starts the command with its stdio and exits with its status. It decides
199
+ * nothing; the host-side relay and stk do.
200
+ */
201
+ export const inSandboxRelaySource = `
202
+ const net = require('node:net'), { spawn } = require('node:child_process');
203
+ const [sock, port, cmd, ...args] = process.argv.slice(1);
204
+ const server = net.createServer((c) => {
205
+ const u = net.connect(sock);
206
+ c.on('error', () => u.destroy()); u.on('error', () => c.destroy());
207
+ c.pipe(u).pipe(c);
208
+ });
209
+ server.listen(Number(port), '127.0.0.1', () => {
210
+ const child = spawn(cmd, args, { stdio: 'inherit' });
211
+ child.on('exit', (code, signal) => process.exit(code ?? (signal ? 128 : 1)));
212
+ child.on('error', () => process.exit(127));
213
+ });
214
+ server.on('error', () => process.exit(126));
215
+ `.trim();
@@ -0,0 +1,273 @@
1
+ import { COMMAND_NETWORK_PORT, inSandboxRelaySource } from './command-network.js';
2
+ import { existsSync } from 'node:fs';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { isAbsolute } from 'node:path';
5
+ import { ServiceError } from './protocol.js';
6
+
7
+ /**
8
+ * Item 10: the sandbox a local command runs in. `workspace-write` (the default) and `read-only` run
9
+ * the command under the host's OS sandbox, with the system read-only, a private tmp, the workspace
10
+ * writable or read-only, and no network unless declared:
11
+ * - Linux: bubblewrap, with an empty root and user, PID, IPC, UTS and network namespaces unshared,
12
+ * dying with its parent;
13
+ * - macOS: Seatbelt (`sandbox-exec`), with a deny-by-default profile.
14
+ * `full-access` runs the command as is and must be chosen explicitly by the operator. A host with
15
+ * no usable sandbox refuses local commands (operator ruling 2026-10-30, option A) unless the host
16
+ * config sets `unsandboxed_commands: "allow"`; then it runs them unsandboxed and says so:
17
+ * "unsandboxed" in host plan and in every approval, and kind `none` in the approved arguments and
18
+ * the execution result.
19
+ */
20
+ export const SANDBOX_LEVELS = ['read-only', 'workspace-write', 'full-access'] as const;
21
+ export type SandboxLevel = (typeof SANDBOX_LEVELS)[number];
22
+ export interface Bwrap {
23
+ path: string;
24
+ version: string;
25
+ }
26
+ export interface Seatbelt {
27
+ path: string;
28
+ }
29
+ export type SandboxKind = 'bwrap' | 'seatbelt' | 'none';
30
+
31
+ /** The pinned bubblewrap: its absolute path and the version it reports, or null when unusable. */
32
+ export function findBwrap(candidates = ['/usr/bin/bwrap', '/bin/bwrap']): Bwrap | null {
33
+ for (const path of candidates) {
34
+ if (!existsSync(path)) continue;
35
+ try {
36
+ const version = execFileSync(path, ['--version'], { encoding: 'utf8', timeout: 5000 }).trim();
37
+ // Usable means it can actually make the namespaces we ask for, not just that it exists.
38
+ execFileSync(
39
+ path,
40
+ ['--unshare-user', '--unshare-net', '--ro-bind', '/', '/', '--', '/bin/true'],
41
+ {
42
+ timeout: 5000,
43
+ stdio: 'ignore',
44
+ },
45
+ );
46
+ return { path, version };
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+ return null;
52
+ }
53
+
54
+ export function sandboxArgv(
55
+ level: SandboxLevel,
56
+ root: string,
57
+ argv: string[],
58
+ bwrap: Bwrap | null,
59
+ options: {
60
+ network: boolean;
61
+ /** Broker network: the host relay's private directory and the node that runs the in-sandbox
62
+ * relay. The network stays unshared; only the relay socket and node are bound in. */
63
+ broker?: { dir: string; node: string };
64
+ },
65
+ ): string[] {
66
+ if (level === 'full-access') return argv;
67
+ if (!bwrap)
68
+ throw new ServiceError(
69
+ 'sandbox_unavailable',
70
+ 'This host has no usable bubblewrap, so it cannot run a sandboxed command; an operator can choose full access explicitly',
71
+ );
72
+ if (!isAbsolute(root) || root.includes('\0'))
73
+ throw new ServiceError('invalid_field', 'A sandboxed command needs an absolute workspace path');
74
+ // Optional system directories, bound read-only only where the host has them.
75
+ const optional = ['/lib', '/lib64', '/lib32', '/bin', '/sbin', '/etc/alternatives'].flatMap(
76
+ (dir) => ['--ro-bind-try', dir, dir],
77
+ );
78
+ return [
79
+ bwrap.path,
80
+ '--unshare-user',
81
+ '--unshare-pid',
82
+ '--unshare-ipc',
83
+ '--unshare-uts',
84
+ ...(options.network ? [] : ['--unshare-net']),
85
+ '--die-with-parent',
86
+ '--new-session',
87
+ '--tmpfs',
88
+ '/',
89
+ // The system read-only; /usr and /etc/ssl always asked for, the rest only where present.
90
+ '--ro-bind',
91
+ '/usr',
92
+ '/usr',
93
+ '--ro-bind',
94
+ '/etc/ssl',
95
+ '/etc/ssl',
96
+ ...optional,
97
+ '--proc',
98
+ '/proc',
99
+ '--dev',
100
+ '/dev',
101
+ '--tmpfs',
102
+ '/tmp',
103
+ level === 'read-only' ? '--ro-bind' : '--bind',
104
+ root,
105
+ root,
106
+ // Broker network: only the host relay's socket directory and the node that bridges to it.
107
+ ...(options.broker
108
+ ? [
109
+ '--bind',
110
+ options.broker.dir,
111
+ '/run/salaam-net',
112
+ '--ro-bind',
113
+ options.broker.node,
114
+ '/run/salaam-node',
115
+ ]
116
+ : []),
117
+ // The empty root itself is read-only too: only the workspace (and the private /tmp) is writable.
118
+ '--remount-ro',
119
+ '/',
120
+ '--chdir',
121
+ root,
122
+ '--',
123
+ ...(options.broker
124
+ ? [
125
+ '/run/salaam-node',
126
+ '-e',
127
+ inSandboxRelaySource,
128
+ '/run/salaam-net/relay.sock',
129
+ String(COMMAND_NETWORK_PORT),
130
+ ]
131
+ : []),
132
+ ...argv,
133
+ ];
134
+ }
135
+
136
+ function brokerPort(port: number): number {
137
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
138
+ throw new ServiceError('invalid_field', 'A broker relay port is 1–65535');
139
+ return port;
140
+ }
141
+
142
+ /** The pinned Seatbelt launcher, or null when this host cannot apply a deny-by-default profile. */
143
+ export function findSeatbelt(path = '/usr/bin/sandbox-exec'): Seatbelt | null {
144
+ if (!existsSync(path)) return null;
145
+ try {
146
+ // Usable means it applies a deny-by-default profile and the command still runs.
147
+ execFileSync(
148
+ path,
149
+ [
150
+ '-p',
151
+ '(version 1)(deny default)(allow process-exec process-fork)(allow file-read*)(allow sysctl-read)',
152
+ '/usr/bin/true',
153
+ ],
154
+ { timeout: 5000, stdio: 'ignore' },
155
+ );
156
+ return { path };
157
+ } catch {
158
+ return null;
159
+ }
160
+ }
161
+
162
+ /** A path Seatbelt can quote: absolute, with no quote, backslash or control character. */
163
+ function sbplPath(path: string, what: string): string {
164
+ if (!isAbsolute(path) || /["\\\x00-\x1f\x7f]/.test(path))
165
+ throw new ServiceError('invalid_field', `A sandboxed command needs a plain absolute ${what}`);
166
+ return `"${path}"`;
167
+ }
168
+
169
+ /**
170
+ * The deny-by-default Seatbelt profile for a level. Reads: the system, developer tools and
171
+ * package prefixes, the workspace and the private tmp (file metadata anywhere, so paths resolve).
172
+ * Writes: the workspace (workspace-write only), the private tmp and the standard devices. No
173
+ * network unless declared, no mach services, no home directory.
174
+ */
175
+ export function seatbeltProfile(
176
+ level: 'read-only' | 'workspace-write',
177
+ root: string,
178
+ tmp: string,
179
+ /** brokerPort: the host relay's loopback port, the only place a broker-network command may connect. */
180
+ options: { network: boolean; brokerPort?: number },
181
+ ): string {
182
+ const w = sbplPath(root, 'workspace path'),
183
+ t = sbplPath(tmp, 'tmp path');
184
+ const system = [
185
+ '/usr',
186
+ '/bin',
187
+ '/sbin',
188
+ '/System',
189
+ '/Library/Apple',
190
+ '/Library/Developer/CommandLineTools',
191
+ '/Applications/Xcode.app',
192
+ '/opt/homebrew',
193
+ '/usr/local',
194
+ '/private/etc',
195
+ '/private/var/db/dyld',
196
+ '/private/var/db/timezone',
197
+ '/dev',
198
+ ]
199
+ .map((p) => `(subpath "${p}")`)
200
+ .join(' ');
201
+ const devices = ['/dev/null', '/dev/zero', '/dev/stdout', '/dev/stderr', '/dev/tty']
202
+ .map((p) => `(literal "${p}")`)
203
+ .join(' ');
204
+ return [
205
+ '(version 1)',
206
+ '(deny default)',
207
+ '(allow process-exec process-fork)',
208
+ '(allow signal (target same-sandbox))',
209
+ '(allow sysctl-read)',
210
+ '(allow file-read-metadata)',
211
+ `(allow file-read* (literal "/") ${system} (subpath ${w}) (subpath ${t}))`,
212
+ level === 'workspace-write'
213
+ ? `(allow file-write* (subpath ${w}) (subpath ${t}) ${devices} (regex #"^/dev/fd/"))`
214
+ : `(allow file-write* (subpath ${t}) ${devices} (regex #"^/dev/fd/"))`,
215
+ `(allow file-ioctl ${devices} (regex #"^/dev/fd/"))`,
216
+ ...(options.network ? ['(allow network*)'] : []),
217
+ ...(options.brokerPort !== undefined && !options.network
218
+ ? [`(allow network-outbound (remote tcp "localhost:${brokerPort(options.brokerPort)}"))`]
219
+ : []),
220
+ ].join('\n');
221
+ }
222
+
223
+ export function seatbeltArgv(
224
+ level: SandboxLevel,
225
+ root: string,
226
+ tmp: string,
227
+ argv: string[],
228
+ seatbelt: Seatbelt | null,
229
+ options: { network: boolean; brokerPort?: number },
230
+ ): string[] {
231
+ if (level === 'full-access') return argv;
232
+ if (!seatbelt)
233
+ throw new ServiceError(
234
+ 'sandbox_unavailable',
235
+ 'This host has no usable Seatbelt, so it cannot run a sandboxed command; an operator can choose full access explicitly',
236
+ );
237
+ return [
238
+ seatbelt.path,
239
+ '-p',
240
+ seatbeltProfile(level, root, tmp, options),
241
+ '/usr/bin/env',
242
+ `TMPDIR=${tmp}/`,
243
+ ...argv,
244
+ ];
245
+ }
246
+
247
+ /** The sandbox this host gives local commands: bubblewrap on Linux and Seatbelt on macOS when
248
+ * usable, otherwise none. */
249
+ export function chooseLocalSandbox(
250
+ level: SandboxLevel,
251
+ platform: NodeJS.Platform = process.platform,
252
+ find: () => Bwrap | null = findBwrap,
253
+ findSb: () => Seatbelt | null = findSeatbelt,
254
+ ): { kind: SandboxKind; level: SandboxLevel; bwrap: Bwrap | null; seatbelt: Seatbelt | null } {
255
+ const none = { kind: 'none' as const, level, bwrap: null, seatbelt: null };
256
+ if (level === 'full-access') return none;
257
+ if (platform === 'linux') {
258
+ const bwrap = find();
259
+ return bwrap ? { ...none, kind: 'bwrap', bwrap } : none;
260
+ }
261
+ if (platform === 'darwin') {
262
+ const seatbelt = findSb();
263
+ return seatbelt ? { ...none, kind: 'seatbelt', seatbelt } : none;
264
+ }
265
+ return none;
266
+ }
267
+ /** How host plan and approvals name a sandbox kind. */
268
+ export function sandboxLabel(kind: SandboxKind): string {
269
+ return kind === 'bwrap' ? 'bubblewrap' : kind === 'seatbelt' ? 'seatbelt' : 'unsandboxed';
270
+ }
271
+ /** The refusal for a local command on a host with no usable sandbox and no operator opt-in. */
272
+ export const UNSANDBOXED_REFUSAL =
273
+ 'Local commands are refused: this host has no usable command sandbox (bubblewrap on Linux, Seatbelt on macOS). Install or repair bubblewrap, or have the operator set unsandboxed_commands: "allow" in the host config to run them unsandboxed.';
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import type { Store } from './store.js';
2
3
  import type { Artifacts } from './artifacts.js';
3
4
  import type { AgentPodWorkAdmission } from './agentpod-work-protocol.js';
@@ -244,6 +245,32 @@ export function executionEvidence(
244
245
  SELECT id,session_id,digest,bytes FROM artifacts
245
246
  WHERE session_id IN (SELECT id FROM tree) ORDER BY id LIMIT 257`),
246
247
  );
248
+ // A verified completion names each deliverable's artifact with the sha256 of its bytes.
249
+ // Only an artifact whose bytes match that digest counts as produced; dispositions are
250
+ // computed for this session's own artifacts alone, so another session's id names nothing.
251
+ const deliverables = new Set<string>();
252
+ const completed = store.db
253
+ .prepare("SELECT artifact_id FROM task_completions WHERE task_id=? AND state='succeeded'")
254
+ .get(taskId);
255
+ if (completed?.artifact_id) {
256
+ let record: { deliverables?: unknown } = {};
257
+ try {
258
+ record = JSON.parse(artifacts.read(String(completed.artifact_id)));
259
+ } catch {
260
+ record = {};
261
+ }
262
+ for (const d of Array.isArray(record.deliverables) ? record.deliverables : []) {
263
+ const id = (d as { artifact_id?: unknown }).artifact_id,
264
+ sha = (d as { sha256?: unknown }).sha256;
265
+ if (typeof id !== 'string' || typeof sha !== 'string') continue;
266
+ try {
267
+ if (createHash('sha256').update(artifacts.read(id)).digest('hex') === sha)
268
+ deliverables.add(id);
269
+ } catch {
270
+ /* a missing or corrupt artifact is not a deliverable */
271
+ }
272
+ }
273
+ }
247
274
  const artifactEvidence = artifactRows.map((row): Row & { disposition: string } => {
248
275
  // Check actual immutable bytes, not just a database's claimed fingerprint.
249
276
  const content = artifacts.read(String(row.id));
@@ -273,7 +300,10 @@ export function executionEvidence(
273
300
  ? 'integrated'
274
301
  : children.some((child) => child.artifact_id === row.id)
275
302
  ? 'proposed'
276
- : appliedPatch || completion || checks.some((check) => check.artifact_id === row.id)
303
+ : appliedPatch ||
304
+ completion ||
305
+ deliverables.has(String(row.id)) ||
306
+ checks.some((check) => check.artifact_id === row.id)
277
307
  ? 'produced'
278
308
  : 'observed';
279
309
  return { ...row, disposition };
@@ -0,0 +1,156 @@
1
+ import {
2
+ chmodSync,
3
+ closeSync,
4
+ fsyncSync,
5
+ mkdirSync,
6
+ openSync,
7
+ readFileSync,
8
+ realpathSync,
9
+ renameSync,
10
+ writeFileSync,
11
+ existsSync,
12
+ } from 'node:fs';
13
+ import { execFileSync } from 'node:child_process';
14
+ import { createHash } from 'node:crypto';
15
+ import { isAbsolute, join, resolve } from 'node:path';
16
+ import { planHost } from './host.js';
17
+ import { canonical, digest } from './protocol.js';
18
+ import {
19
+ stationHash,
20
+ stationRuntimeFingerprint,
21
+ stationWorkspaceIdentity,
22
+ } from './station-config.js';
23
+
24
+ export interface BindInput {
25
+ organization_id: string;
26
+ node_id: string;
27
+ station_key: string;
28
+ workspace: string;
29
+ tools_user: string;
30
+ files: string;
31
+ runtime_root: string;
32
+ runtime_image_digest: string;
33
+ operator_group?: string;
34
+ dir?: string;
35
+ state?: string;
36
+ provider?: string;
37
+ model?: string;
38
+ mcp_config?: string;
39
+ /** Item 9: the operator's persona file (root-owned, outside the workspace). */
40
+ persona?: string;
41
+ /** AgentPod-managed MCP profile: HTTP mcpServers the node assigns to admitted sessions. */
42
+ managed_mcp_config?: string;
43
+ /** Defaults to /usr/bin/systemd-run and /usr/bin/systemctl. */
44
+ systemd_run?: string;
45
+ systemctl?: string;
46
+ /** Tests only: where the default per-station directories live. */
47
+ roots?: { config: string; state: string };
48
+ }
49
+
50
+ const userName = /^[a-z_][a-z0-9_-]{0,31}$/;
51
+ function pin(path: string) {
52
+ const real = realpathSync(path);
53
+ return { path: real, sha256: stationHash(readFileSync(real)) };
54
+ }
55
+ /** Write a private file only when its content changes; report whether it did. */
56
+ function put(path: string, value: unknown): boolean {
57
+ const text = JSON.stringify(value, null, 2) + '\n';
58
+ if (existsSync(path) && readFileSync(path, 'utf8') === text) return false;
59
+ const tmp = path + '.tmp',
60
+ fd = openSync(tmp, 'w', 0o600);
61
+ try {
62
+ writeFileSync(fd, text);
63
+ fsyncSync(fd);
64
+ } finally {
65
+ closeSync(fd);
66
+ }
67
+ renameSync(tmp, path);
68
+ return true;
69
+ }
70
+
71
+ /** Bind one AgentPod station to a launcher-profile strict work host. Root on Linux.
72
+ * Every output is derived from the inputs; nothing is edited by hand. */
73
+ export async function bindHost(input: BindInput) {
74
+ if (process.platform !== 'linux' || process.getuid?.() !== 0)
75
+ throw Error('host bind runs as root on Linux');
76
+ const workspace = input.workspace;
77
+ if (!isAbsolute(workspace) || realpathSync(workspace) !== workspace)
78
+ throw Error('Use the canonical absolute workspace path');
79
+ const key = 'salaam:' + createHash('sha256').update(workspace).digest('hex').slice(0, 16);
80
+ if (input.station_key !== key)
81
+ throw Error('Station key does not name this workspace (expected ' + key + ')');
82
+ if (!userName.test(input.tools_user)) throw Error('tools user must be a user name');
83
+ try {
84
+ execFileSync('/usr/bin/getent', ['passwd', input.tools_user], { stdio: 'ignore' });
85
+ } catch {
86
+ throw Error('tools user does not exist');
87
+ }
88
+ if (input.tools_user === 'root') throw Error('tools user must not be root');
89
+ const id = key.slice('salaam:'.length),
90
+ roots = input.roots ?? { config: '/etc/salaam/stations', state: '/var/lib/salaam/stations' },
91
+ dir = resolve(input.dir ?? join(roots.config, id)),
92
+ state = resolve(input.state ?? join(roots.state, id, 'state'));
93
+ mkdirSync(dir, { recursive: true, mode: 0o755 });
94
+ // Ancestors of state stay traversable so the operator group can reach operator.sock;
95
+ // state itself is the daemon's (0700, or 0710 for the operator group once served).
96
+ mkdirSync(join(state, '..'), { recursive: true, mode: 0o755 });
97
+ chmodSync(join(state, '..'), 0o755);
98
+ const pinsPath = join(dir, 'agentpod-pins.json'),
99
+ stationPath = join(dir, 'station-config.json'),
100
+ configPath = join(dir, 'host-config.json'),
101
+ manifestPath = join(dir, 'host.json');
102
+ let changed = put(pinsPath, {
103
+ version: 1,
104
+ organization_id: input.organization_id,
105
+ node_id: input.node_id,
106
+ station_key: key,
107
+ workspace,
108
+ });
109
+ const runtime = realpathSync(input.runtime_root);
110
+ changed =
111
+ put(stationPath, {
112
+ version: 1,
113
+ kind: 'linux-systemd-station-v1',
114
+ workspace,
115
+ workspace_identity: stationWorkspaceIdentity(workspace),
116
+ files: pin(input.files),
117
+ systemd_run: pin(input.systemd_run ?? '/usr/bin/systemd-run'),
118
+ systemctl: pin(input.systemctl ?? '/usr/bin/systemctl'),
119
+ runtime_root: runtime,
120
+ runtime_fingerprint: stationRuntimeFingerprint(runtime),
121
+ runtime_image_digest: input.runtime_image_digest,
122
+ tools_user: input.tools_user,
123
+ }) || changed;
124
+ const config = {
125
+ mode: 'agentpod',
126
+ state,
127
+ agentpod_config: pinsPath,
128
+ station_config: stationPath,
129
+ strict_work_version: 1,
130
+ ...(input.operator_group ? { operator_group: input.operator_group } : {}),
131
+ ...(input.provider ? { provider: input.provider } : {}),
132
+ ...(input.model ? { model: input.model } : {}),
133
+ ...(input.mcp_config ? { operator_mcp_config: realpathSync(input.mcp_config) } : {}),
134
+ ...(input.persona ? { persona_config: realpathSync(input.persona) } : {}),
135
+ ...(input.managed_mcp_config ? { mcp_config: realpathSync(input.managed_mcp_config) } : {}),
136
+ };
137
+ changed = put(configPath, config) || changed;
138
+ const manifest = await planHost(config);
139
+ const before = existsSync(manifestPath)
140
+ ? canonical(JSON.parse(readFileSync(manifestPath, 'utf8')))
141
+ : null;
142
+ if (before !== canonical(manifest)) {
143
+ put(manifestPath, manifest);
144
+ changed = true;
145
+ }
146
+ return {
147
+ version: 1,
148
+ station_key: key,
149
+ dir,
150
+ state,
151
+ manifest: manifestPath,
152
+ manifest_digest: digest(manifest),
153
+ unit: 'salaam-host@' + id + '.service',
154
+ changed,
155
+ };
156
+ }