chati-dev 4.5.15 → 4.5.27

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 (124) hide show
  1. package/README.md +8 -3
  2. package/bin/chati.js +124 -66
  3. package/framework/agents/build/dev.md +5 -5
  4. package/framework/agents/deploy/devops.md +7 -7
  5. package/framework/agents/discover/brief.md +4 -4
  6. package/framework/agents/discover/brownfield-wu.md +3 -3
  7. package/framework/agents/discover/greenfield-wu.md +3 -3
  8. package/framework/agents/plan/architect.md +2 -2
  9. package/framework/agents/plan/detail.md +4 -4
  10. package/framework/agents/plan/phases.md +2 -2
  11. package/framework/agents/plan/tasks.md +2 -2
  12. package/framework/agents/plan/ux.md +2 -2
  13. package/framework/agents/quality/qa-implementation.md +11 -10
  14. package/framework/agents/quality/qa-planning.md +3 -3
  15. package/framework/agents/quality/qa-visual.md +1 -1
  16. package/framework/config.yaml +3 -3
  17. package/framework/constitution.md +18 -18
  18. package/framework/context/protocols.md +2 -2
  19. package/framework/context/quality.md +1 -1
  20. package/framework/context/root.md +4 -4
  21. package/framework/data/entity-registry.yaml +1 -1
  22. package/framework/domains/agents/orchestrator.yaml +2 -2
  23. package/framework/domains/constitution.yaml +1 -1
  24. package/framework/hooks/advance-trigger.js +4 -6
  25. package/framework/hooks/git-push-authority.js +45 -37
  26. package/framework/hooks/mode-governance.js +149 -40
  27. package/framework/hooks/model-governance.js +13 -20
  28. package/framework/hooks/prism-engine.js +74 -92
  29. package/framework/hooks/reasoning-escalator.js +23 -40
  30. package/framework/hooks/session-digest.js +12 -13
  31. package/framework/hooks/session-reader.js +224 -0
  32. package/framework/hooks/session-writer.js +195 -0
  33. package/framework/hooks/team-quality-gate.js +34 -24
  34. package/framework/i18n/en.yaml +2 -2
  35. package/framework/i18n/es.yaml +2 -2
  36. package/framework/i18n/fr.yaml +2 -2
  37. package/framework/i18n/pt.yaml +2 -2
  38. package/framework/intelligence/context-engine.md +4 -4
  39. package/framework/intelligence/memory-layer.md +1 -1
  40. package/framework/manifest.json +129 -119
  41. package/framework/manifest.sig +1 -1
  42. package/framework/orchestrator/chati-router.js +278 -24
  43. package/framework/orchestrator/chati.md +250 -68
  44. package/framework/schemas/session.schema.json +21 -1
  45. package/framework/tasks/orchestrator-deviation.md +1 -1
  46. package/framework/tasks/orchestrator-escalate.md +1 -1
  47. package/framework/tasks/orchestrator-handoff.md +6 -6
  48. package/framework/tasks/orchestrator-health.md +5 -9
  49. package/framework/tasks/orchestrator-mode-switch.md +3 -7
  50. package/framework/tasks/orchestrator-resume.md +10 -14
  51. package/framework/tasks/orchestrator-route.md +3 -3
  52. package/framework/tasks/orchestrator-spawn-terminal.md +1 -1
  53. package/framework/tasks/orchestrator-status.md +9 -9
  54. package/framework/tasks/orchestrator-suggest-mode.md +1 -1
  55. package/framework/tasks/qa-impl-consolidate.md +2 -2
  56. package/framework/tasks/qa-impl-performance-test.md +4 -4
  57. package/framework/tasks/qa-impl-regression-check.md +4 -4
  58. package/framework/tasks/qa-impl-sast-scan.md +1 -1
  59. package/framework/tasks/qa-impl-test-execute.md +1 -1
  60. package/framework/tasks/qa-impl-verdict.md +2 -2
  61. package/framework/tasks/qa-planning-consolidate.md +3 -3
  62. package/framework/tasks/qa-planning-coverage-plan.md +2 -2
  63. package/framework/tasks/qa-planning-gate-define.md +4 -4
  64. package/framework/tasks/qa-planning-risk-matrix.md +4 -4
  65. package/framework/tasks/qa-planning-test-strategy.md +2 -2
  66. package/node_modules/@chati/browser-capability/src/index.js +12 -2
  67. package/node_modules/@chati/planning/src/index.js +24 -4
  68. package/node_modules/@chati/provider-registry/src/index.js +11 -0
  69. package/node_modules/@chati/rail/src/index.js +1967 -83
  70. package/node_modules/@chati/release-lane/README.md +12 -8
  71. package/node_modules/@chati/release-lane/package.json +1 -1
  72. package/node_modules/@chati/release-lane/src/index.js +1426 -58
  73. package/node_modules/@chati/review-council/src/index.js +63 -0
  74. package/node_modules/@chati/tracking-clickup/README.md +13 -0
  75. package/node_modules/@chati/tracking-clickup/src/index.js +690 -30
  76. package/package-artifact-manifest.json +1 -0
  77. package/package-artifact-manifest.sig +1 -0
  78. package/package.json +16 -7
  79. package/scripts/verify-real-harness-e2e.js +1581 -0
  80. package/src/config/framework-adapter.js +4 -4
  81. package/src/installer/core.js +157 -56
  82. package/src/installer/manifest.js +140 -9
  83. package/src/installer/package-artifact.js +249 -0
  84. package/src/installer/templates.js +91 -23
  85. package/src/installer-v2/catalog-client.js +531 -23
  86. package/src/installer-v2/index.js +91 -34
  87. package/src/installer-v2/installation-authority.js +327 -0
  88. package/src/installer-v2/provider-executable.js +235 -0
  89. package/src/installer-v2/wizard-installation.js +1 -1
  90. package/src/intelligence/timeline.js +3 -1
  91. package/src/orchestrator/browser-runtime.js +44 -13
  92. package/src/orchestrator/cli.js +1616 -151
  93. package/src/orchestrator/clickup-projection.js +43 -1
  94. package/src/orchestrator/clickup-runtime.js +355 -39
  95. package/src/orchestrator/doctor.js +87 -4
  96. package/src/orchestrator/index.js +16 -0
  97. package/src/orchestrator/planning-runtime.js +20 -2
  98. package/src/orchestrator/rail-adjudication-evidence.js +234 -0
  99. package/src/orchestrator/rail-evidence-authority.js +147 -0
  100. package/src/orchestrator/rail-execution-evidence.js +45 -0
  101. package/src/orchestrator/rail-runtime.js +836 -56
  102. package/src/orchestrator/release-runtime.js +65 -6
  103. package/src/orchestrator/review-runtime.js +186 -41
  104. package/src/orchestrator/runtime-installation-v2.js +236 -20
  105. package/src/orchestrator/session-manager.js +1249 -52
  106. package/src/terminal/adapters/claude-adapter.js +3 -1
  107. package/src/terminal/adapters/codex-adapter.js +2 -0
  108. package/src/terminal/adapters/grok-adapter.js +7 -4
  109. package/src/terminal/handoff-parser.js +19 -1
  110. package/src/terminal/prompt-builder.js +10 -1
  111. package/src/terminal/provider-preflight.js +36 -3
  112. package/src/terminal/rail-execution-worktree.js +213 -0
  113. package/src/terminal/rail-prompts.js +169 -0
  114. package/src/terminal/rail-readonly-workspace.js +324 -0
  115. package/src/terminal/run-agent.js +434 -16
  116. package/src/terminal/run-parallel.js +5 -0
  117. package/src/terminal/run-rail-adjudication.js +323 -0
  118. package/src/terminal/run-rail-review.js +291 -0
  119. package/src/terminal/run-rail-rework.js +325 -0
  120. package/src/terminal/run-rail-task.js +380 -0
  121. package/src/terminal/run-team.js +5 -0
  122. package/src/terminal/spawner.js +1225 -77
  123. package/src/utils/schema-validator.js +5 -0
  124. package/src/wizard/index.js +3 -2
@@ -7,11 +7,31 @@
7
7
  * (spawnTerminal) that actually calls child_process.spawn.
8
8
  */
9
9
 
10
- import { spawn } from 'child_process';
10
+ import { spawn, spawnSync } from 'child_process';
11
+ import { createHash } from 'node:crypto';
12
+ import { EventEmitter } from 'node:events';
13
+ import {
14
+ closeSync, constants, fstatSync, fsyncSync, lstatSync, mkdirSync, mkdtempSync, openSync,
15
+ readFileSync, readSync, realpathSync, rmSync, writeSync,
16
+ } from 'node:fs';
17
+ import { homedir, hostname, tmpdir } from 'node:os';
18
+ import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
19
+ import { canonicalize, sha256 } from '@chati/core';
11
20
  import { validateWriteScopes, buildIsolationEnv } from './isolation.js';
12
21
  import { getProvider } from './cli-registry.js';
13
22
  import { getRateLimiter } from './rate-limiter.js';
14
23
  import { recordModelSelection } from '../orchestrator/session-manager.js';
24
+ import {
25
+ digestProviderExecutable, openPinnedProviderExecutable, openPinnedProviderSupportingExecutables,
26
+ } from '../installer-v2/provider-executable.js';
27
+ import {
28
+ installationAuthorityStateDirectory, signHostAuthorityPayload, verifyHostAuthorityPayload,
29
+ } from '../installer-v2/installation-authority.js';
30
+ import {
31
+ assertRailExecutionWorktreeBaselineUnchanged,
32
+ captureRailExecutionWorktree,
33
+ verifyRailExecutionWorktreeResult,
34
+ } from './rail-execution-worktree.js';
15
35
 
16
36
  // ---------------------------------------------------------------------------
17
37
  // Constants
@@ -38,6 +58,768 @@ const OVERLOAD_PATTERNS = [/529/, /overloaded/i, /capacity/i, /too many requests
38
58
  // ---------------------------------------------------------------------------
39
59
 
40
60
  let _counter = 0;
61
+ const TERMINAL_AUTHORITY_STATE = new WeakMap();
62
+ const TERMINAL_AUTHORITY_DOMAIN = 'chati-terminal-receipt-v1';
63
+ const TERMINAL_SECURITY_TEST_HOOKS = Symbol.for('chati.terminal.security-test-hooks');
64
+ const READ_ONLY_PROVIDERS = new Set(['claude', 'codex', 'grok']);
65
+ const BUBBLEWRAP_PATH = '/usr/bin/bwrap';
66
+ const PROVIDER_CONTROL_PLANE_DIRECTORIES = Object.freeze([
67
+ '.chati', '.chati.dev', '.claude', '.codex', '.grok', '.agents',
68
+ ]);
69
+ const PROCESS_TREE_SETTLE_WAIT = new Int32Array(new SharedArrayBuffer(4));
70
+ const PRIVATE_PROMPT_MARKER = '<chati-private-prompt-file>';
71
+ const CONTAINED_PROMPT_PATH = '/run/user/chati-private-prompt';
72
+ const CONTAINED_PROVIDER_EXECUTABLE_PATH = '/run/user/chati-provider-executable';
73
+ const AUTHORITY_SYSTEM_PATH = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin';
74
+ const TEAM_CONTEXT_ENV_KEYS = Object.freeze([
75
+ 'CHATI_TEAM_ID', 'CHATI_TEAM_MEMBER', 'CHATI_TEAM_TASK_LIST', 'CHATI_TEAM_MAILBOX',
76
+ ]);
77
+
78
+ function runTerminalSecurityTestHook(name, payload) {
79
+ const hooks = globalThis[TERMINAL_SECURITY_TEST_HOOKS];
80
+ if (hooks && typeof hooks[name] === 'function') hooks[name](payload);
81
+ }
82
+
83
+ function openPrivatePromptFile(prompt) {
84
+ const directory = mkdtempSync(join(tmpdir(), 'chati-terminal-prompt-'));
85
+ const path = join(directory, 'prompt');
86
+ const bytes = Buffer.from(String(prompt), 'utf8');
87
+ let writer;
88
+ let descriptor;
89
+ try {
90
+ writer = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
91
+ let offset = 0;
92
+ while (offset < bytes.length) offset += writeSync(writer, bytes, offset, bytes.length - offset, null);
93
+ fsyncSync(writer);
94
+ const written = fstatSync(writer);
95
+ closeSync(writer);
96
+ writer = undefined;
97
+ descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
98
+ const pinned = fstatSync(descriptor);
99
+ if (!pinned.isFile() || pinned.dev !== written.dev || pinned.ino !== written.ino
100
+ || pinned.nlink !== 1 || pinned.size !== bytes.length || (pinned.mode & 0o777) !== 0o600) {
101
+ return failContainment('private provider prompt file could not be descriptor-pinned');
102
+ }
103
+ const record = Object.freeze({ descriptor, directory, path, size: bytes.length });
104
+ runTerminalSecurityTestHook('afterPrivatePromptFileOpen', { path, directory, size: bytes.length });
105
+ return record;
106
+ } catch (error) {
107
+ if (writer !== undefined) try { closeSync(writer); } catch { /* preserve primary error */ }
108
+ if (descriptor !== undefined) try { closeSync(descriptor); } catch { /* preserve primary error */ }
109
+ rmSync(directory, { recursive: true, force: true });
110
+ throw error;
111
+ }
112
+ }
113
+
114
+ function cleanupPrivatePromptFile(record) {
115
+ if (record?.directory) rmSync(record.directory, { recursive: true, force: true });
116
+ }
117
+
118
+ function materializePrivatePromptArgs(args, promptPath) {
119
+ const matches = args.filter((argument) => argument === PRIVATE_PROMPT_MARKER).length;
120
+ if (matches !== 1) return failContainment('private provider prompt marker is missing or ambiguous');
121
+ return args.map((argument) => argument === PRIVATE_PROMPT_MARKER ? promptPath : argument);
122
+ }
123
+
124
+ function failContainment(message) {
125
+ throw Object.assign(new Error(message), { code: 'TERMINAL_CONTAINMENT_UNAVAILABLE' });
126
+ }
127
+
128
+ function hostRootIsUnmapped(uidMap) {
129
+ return String(uidMap).trim().split(/\n+/).some((line) => {
130
+ const [inside, outside, length] = line.trim().split(/\s+/).map(Number);
131
+ return inside === 0 && outside === 0 && Number.isInteger(length) && length > 0;
132
+ });
133
+ }
134
+
135
+ export function _validateBubblewrapTrust({
136
+ trustedDirectories, pathStat, descriptorStat, uidMap, effectiveUid,
137
+ } = {}) {
138
+ if (!Array.isArray(trustedDirectories) || trustedDirectories.length !== 3
139
+ || !Number.isInteger(effectiveUid) || effectiveUid < 0
140
+ || !hostRootIsUnmapped(uidMap)) {
141
+ return failContainment('Bubblewrap trust requires an unmapped host-root identity');
142
+ }
143
+ if (trustedDirectories.some(({ stat }) => !stat || stat.isSymbolicLink() || !stat.isDirectory()
144
+ || stat.uid !== 0 || (stat.mode & 0o022) !== 0)) {
145
+ return failContainment('Bubblewrap containment ancestors must be host-root-owned, non-writable real directories');
146
+ }
147
+ if (!pathStat || !descriptorStat
148
+ || pathStat.isSymbolicLink() || !pathStat.isFile() || !descriptorStat.isFile()
149
+ || pathStat.dev !== descriptorStat.dev || pathStat.ino !== descriptorStat.ino
150
+ || descriptorStat.uid !== 0 || (descriptorStat.mode & 0o022) !== 0
151
+ || descriptorStat.nlink < 1) {
152
+ return failContainment('Bubblewrap containment binary must be host-root-owned, non-writable and descriptor-pinned');
153
+ }
154
+ return true;
155
+ }
156
+
157
+ function executableSnapshot(stat) {
158
+ return Object.freeze({
159
+ dev: stat.dev, ino: stat.ino, mode: stat.mode, uid: stat.uid,
160
+ gid: stat.gid, nlink: stat.nlink, size: stat.size,
161
+ mtimeMs: stat.mtimeMs, ctimeMs: stat.ctimeMs,
162
+ });
163
+ }
164
+
165
+ function sameExecutableSnapshot(left, right) {
166
+ return Object.keys(left).every((key) => left[key] === right[key]);
167
+ }
168
+
169
+ function digestPinnedExecutable(descriptor, expected = null) {
170
+ const before = executableSnapshot(fstatSync(descriptor));
171
+ if (before.size < 1 || before.size > 16 * 1024 * 1024) {
172
+ return failContainment('Bubblewrap containment binary has an invalid bounded size');
173
+ }
174
+ const hash = createHash('sha256');
175
+ const buffer = Buffer.allocUnsafe(64 * 1024);
176
+ let position = 0;
177
+ while (position < before.size) {
178
+ const bytesRead = readSync(descriptor, buffer, 0, Math.min(buffer.length, before.size - position), position);
179
+ if (bytesRead === 0) return failContainment('Bubblewrap containment binary changed during hashing');
180
+ hash.update(buffer.subarray(0, bytesRead));
181
+ position += bytesRead;
182
+ }
183
+ const after = executableSnapshot(fstatSync(descriptor));
184
+ const digest = hash.digest('hex');
185
+ if (!sameExecutableSnapshot(before, after)
186
+ || (expected && (!sameExecutableSnapshot(after, expected.snapshot) || digest !== expected.digest))) {
187
+ return failContainment('Bubblewrap containment binary changed after trust validation');
188
+ }
189
+ return Object.freeze({ digest, snapshot: after });
190
+ }
191
+
192
+ function currentBubblewrapTrust(descriptor) {
193
+ const trustedDirectories = ['/', '/usr', '/usr/bin'].map((path) => ({ path, stat: lstatSync(path) }));
194
+ const pathStat = lstatSync(BUBBLEWRAP_PATH);
195
+ const descriptorStat = fstatSync(descriptor);
196
+ _validateBubblewrapTrust({
197
+ trustedDirectories, pathStat, descriptorStat,
198
+ uidMap: readFileSync('/proc/self/uid_map', 'utf8'),
199
+ effectiveUid: process.geteuid?.() ?? process.getuid?.() ?? -1,
200
+ });
201
+ }
202
+
203
+ function requireBubblewrapContainment() {
204
+ if (process.platform !== 'linux') {
205
+ throw Object.assign(new Error('authority-bearing RAIL processes require Linux PID-namespace containment'), { code: 'TERMINAL_CONTAINMENT_UNAVAILABLE' });
206
+ }
207
+ let descriptor;
208
+ try { descriptor = openSync(BUBBLEWRAP_PATH, constants.O_RDONLY | (constants.O_NOFOLLOW || 0)); }
209
+ catch (error) {
210
+ throw Object.assign(new Error(`authority-bearing RAIL processes require ${BUBBLEWRAP_PATH}: ${error.message}`), { code: 'TERMINAL_CONTAINMENT_UNAVAILABLE' });
211
+ }
212
+ let probeDirectoryDescriptor;
213
+ try {
214
+ currentBubblewrapTrust(descriptor);
215
+ const executable = digestPinnedExecutable(descriptor);
216
+ probeDirectoryDescriptor = openSync('/', constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
217
+ const probe = spawnSync('/proc/self/fd/3', [
218
+ '--die-with-parent', '--unshare-pid', '--unshare-ipc', '--unshare-cgroup-try',
219
+ '--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--tmpfs', '/run/user',
220
+ '--ro-bind-fd', '4', '/mnt', '--', '/bin/sh', '-c',
221
+ 'test ! -e /proc/self/fd/4 && test -r /mnt/usr/bin/true',
222
+ ], { encoding: 'utf8', timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe', descriptor, probeDirectoryDescriptor] });
223
+ closeSync(probeDirectoryDescriptor);
224
+ probeDirectoryDescriptor = undefined;
225
+ if (probe.status !== 0 || probe.error) {
226
+ throw Object.assign(new Error(`Bubblewrap descriptor-consuming PID-namespace containment is unavailable: ${probe.error?.message || probe.stderr || `status ${probe.status}`}`), { code: 'TERMINAL_CONTAINMENT_UNAVAILABLE' });
227
+ }
228
+ currentBubblewrapTrust(descriptor);
229
+ digestPinnedExecutable(descriptor, executable);
230
+ return Object.freeze({ descriptor, binaryDigest: executable.digest, executable });
231
+ } catch (error) {
232
+ if (probeDirectoryDescriptor !== undefined) closeSync(probeDirectoryDescriptor);
233
+ closeSync(descriptor);
234
+ throw error;
235
+ }
236
+ }
237
+
238
+ function pathContains(root, candidate) {
239
+ const pathFromRoot = relative(resolve(root), resolve(candidate));
240
+ return pathFromRoot === '' || (pathFromRoot !== '..' && !pathFromRoot.startsWith(`..${sep}`) && !isAbsolute(pathFromRoot));
241
+ }
242
+
243
+ function directorySnapshot(stat) {
244
+ return Object.freeze({
245
+ dev: stat.dev, ino: stat.ino, mode: stat.mode, uid: stat.uid,
246
+ gid: stat.gid,
247
+ });
248
+ }
249
+
250
+ function sameDirectorySnapshot(left, right) {
251
+ return Object.keys(left).every((key) => left[key] === right[key]);
252
+ }
253
+
254
+ function closeDirectoryChain(chain) {
255
+ for (const descriptor of [...(chain?.descriptors ?? [])].reverse()) {
256
+ try { closeSync(descriptor); } catch { /* preserve the primary containment result */ }
257
+ }
258
+ }
259
+
260
+ function openPinnedDirectoryChain(path) {
261
+ if (process.platform !== 'linux') {
262
+ return failContainment('authority-bearing RAIL processes require Linux descriptor-pinned project storage');
263
+ }
264
+ const absolute = resolve(path);
265
+ const descriptors = [];
266
+ const snapshots = [];
267
+ let descriptor;
268
+ try {
269
+ descriptor = openSync('/', constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
270
+ descriptors.push(descriptor);
271
+ snapshots.push(directorySnapshot(fstatSync(descriptor)));
272
+ for (const segment of absolute.slice(1).split(sep).filter(Boolean)) {
273
+ descriptor = openSync(
274
+ join(`/proc/self/fd/${descriptor}`, segment),
275
+ constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0),
276
+ );
277
+ const stat = fstatSync(descriptor);
278
+ if (!stat.isDirectory()) return failContainment('authority project chain must contain only real directories');
279
+ descriptors.push(descriptor);
280
+ snapshots.push(directorySnapshot(stat));
281
+ }
282
+ return Object.freeze({
283
+ path: absolute,
284
+ reference: `/proc/self/fd/${descriptors.at(-1)}`,
285
+ descriptors: Object.freeze(descriptors),
286
+ snapshots: Object.freeze(snapshots),
287
+ identity_digest: sha256({ path: absolute, snapshots }),
288
+ });
289
+ } catch (error) {
290
+ closeDirectoryChain({ descriptors });
291
+ if (error?.code === 'TERMINAL_CONTAINMENT_UNAVAILABLE') throw error;
292
+ return failContainment(`authority project chain cannot be pinned: ${error.message}`);
293
+ }
294
+ }
295
+
296
+ function assertPinnedDirectoryChain(chain) {
297
+ const current = openPinnedDirectoryChain(chain.path);
298
+ try {
299
+ if (current.snapshots.length !== chain.snapshots.length
300
+ || current.snapshots.some((snapshot, index) => !sameDirectorySnapshot(snapshot, chain.snapshots[index]))) {
301
+ return failContainment('authority project chain changed before provider launch');
302
+ }
303
+ for (const [index, descriptor] of chain.descriptors.entries()) {
304
+ if (!sameDirectorySnapshot(directorySnapshot(fstatSync(descriptor)), chain.snapshots[index])) {
305
+ return failContainment('pinned authority project directory changed before provider launch');
306
+ }
307
+ }
308
+ return true;
309
+ } finally { closeDirectoryChain(current); }
310
+ }
311
+
312
+ function ensureScopedDirectory(projectChain, relativeDirectory) {
313
+ const descriptors = [];
314
+ const snapshots = [];
315
+ let reference = projectChain.reference;
316
+ try {
317
+ for (const segment of relativeDirectory.split(sep).filter(Boolean)) {
318
+ const target = join(reference, segment);
319
+ try { mkdirSync(target, { mode: 0o700 }); }
320
+ catch (error) { if (error?.code !== 'EEXIST') throw error; }
321
+ const pathStat = lstatSync(target);
322
+ if (pathStat.isSymbolicLink() || !pathStat.isDirectory()) {
323
+ return failContainment('Planning write scope ancestors must be real directories');
324
+ }
325
+ const descriptor = openSync(
326
+ target,
327
+ constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0),
328
+ );
329
+ const pinned = fstatSync(descriptor);
330
+ if (pathStat.dev !== pinned.dev || pathStat.ino !== pinned.ino) {
331
+ closeSync(descriptor);
332
+ return failContainment('Planning write scope changed while it was pinned');
333
+ }
334
+ descriptors.push(descriptor);
335
+ snapshots.push(directorySnapshot(pinned));
336
+ reference = `/proc/self/fd/${descriptor}`;
337
+ }
338
+ return Object.freeze({ descriptors, snapshots, reference });
339
+ } catch (error) {
340
+ descriptors.reverse().forEach((descriptor) => { try { closeSync(descriptor); } catch { /* already closed */ } });
341
+ if (error?.code === 'TERMINAL_CONTAINMENT_UNAVAILABLE') throw error;
342
+ return failContainment(`Planning write scope cannot be pinned: ${error.message}`);
343
+ }
344
+ }
345
+
346
+ function preparePinnedWriteScopes(projectChain, writeScope = []) {
347
+ if (!Array.isArray(writeScope)) return failContainment('terminal write scope must be an array');
348
+ const normalized = [...new Set(writeScope.map((scope) => String(scope || '').trim()).filter(Boolean))];
349
+ const fullProject = normalized.some((scope) => ['.', './'].includes(scope));
350
+ if (fullProject && normalized.length !== 1) return failContainment('full-project write scope cannot be combined with narrower scopes');
351
+ if (fullProject) return Object.freeze({ fullProject: true, entries: Object.freeze([]), descriptors: Object.freeze([]) });
352
+
353
+ const entries = [];
354
+ const descriptors = [];
355
+ try {
356
+ for (const scope of normalized) {
357
+ if (isAbsolute(scope)) return failContainment('terminal write scopes must be project-relative');
358
+ const directoryScope = scope.endsWith('/') || scope.endsWith(sep);
359
+ const absolute = resolve(projectChain.path, scope);
360
+ if (!pathContains(projectChain.path, absolute)) return failContainment('terminal write scope escapes the project');
361
+ for (const protectedPath of ['.chati', '.chati.dev']) {
362
+ const protectedAbsolute = resolve(projectChain.path, protectedPath);
363
+ if (pathContains(protectedAbsolute, absolute) || pathContains(absolute, protectedAbsolute)) {
364
+ return failContainment('terminal write scope overlaps the CHATI control plane');
365
+ }
366
+ }
367
+ const rel = relative(projectChain.path, absolute);
368
+ if (!rel) return failContainment('full-project write authority must use the explicit dot scope');
369
+ const parentRel = directoryScope ? rel : dirname(rel);
370
+ const parent = ensureScopedDirectory(projectChain, parentRel === '.' ? '' : parentRel);
371
+ descriptors.push(...parent.descriptors);
372
+ let targetDescriptor;
373
+ let targetSnapshot;
374
+ if (directoryScope) {
375
+ targetDescriptor = parent.descriptors.at(-1);
376
+ targetSnapshot = parent.snapshots.at(-1);
377
+ } else {
378
+ const name = rel.split(sep).at(-1);
379
+ const target = join(parent.reference, name);
380
+ const pathFlags = constants.O_RDWR | constants.O_CREAT | (constants.O_NOFOLLOW || 0);
381
+ targetDescriptor = openSync(target, pathFlags, 0o600);
382
+ const stat = fstatSync(targetDescriptor);
383
+ if (!stat.isFile() || stat.nlink !== 1) {
384
+ closeSync(targetDescriptor);
385
+ return failContainment('Planning file write scope must be a regular single-link file');
386
+ }
387
+ descriptors.push(targetDescriptor);
388
+ targetSnapshot = executableSnapshot(stat);
389
+ }
390
+ entries.push(Object.freeze({
391
+ target: absolute,
392
+ descriptor: targetDescriptor,
393
+ snapshot: targetSnapshot,
394
+ kind: directoryScope ? 'directory' : 'file',
395
+ }));
396
+ }
397
+ return Object.freeze({
398
+ fullProject: false,
399
+ entries: Object.freeze(entries),
400
+ descriptors: Object.freeze([...new Set(descriptors)]),
401
+ });
402
+ } catch (error) {
403
+ [...new Set(descriptors)].reverse().forEach((descriptor) => { try { closeSync(descriptor); } catch { /* already closed */ } });
404
+ if (error?.code === 'TERMINAL_CONTAINMENT_UNAVAILABLE') throw error;
405
+ return failContainment(`Planning write scope cannot be prepared: ${error.message}`);
406
+ }
407
+ }
408
+
409
+ function assertPinnedWriteScopes(scopes) {
410
+ for (const entry of scopes?.entries ?? []) {
411
+ const current = entry.kind === 'directory'
412
+ ? directorySnapshot(fstatSync(entry.descriptor))
413
+ : executableSnapshot(fstatSync(entry.descriptor));
414
+ const matches = entry.kind === 'directory'
415
+ ? sameDirectorySnapshot(current, entry.snapshot)
416
+ : sameExecutableSnapshot(current, entry.snapshot);
417
+ if (!matches) return failContainment('Planning write scope changed before provider launch');
418
+ }
419
+ return true;
420
+ }
421
+
422
+ function readOnlyTmpPathEntries() {
423
+ const entries = [...new Set(String(process.env.PATH || '').split(delimiter).filter(Boolean).map((entry) => resolve(entry)))];
424
+ return entries.filter((entry) => pathContains('/tmp', entry)).flatMap((entry) => {
425
+ try {
426
+ const stat = lstatSync(entry);
427
+ return !stat.isSymbolicLink() && stat.isDirectory() ? ['--ro-bind', entry, entry] : [];
428
+ } catch { return []; }
429
+ });
430
+ }
431
+
432
+ function hiddenInstallationAuthorityArgs() {
433
+ const directory = installationAuthorityStateDirectory();
434
+ try {
435
+ const stat = lstatSync(directory);
436
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
437
+ return failContainment('installation authority state must be a real directory');
438
+ }
439
+ return ['--tmpfs', directory];
440
+ } catch (error) {
441
+ if (error?.code === 'ENOENT') return [];
442
+ if (error?.code === 'TERMINAL_CONTAINMENT_UNAVAILABLE') throw error;
443
+ return failContainment(`installation authority state cannot be isolated: ${error.message}`);
444
+ }
445
+ }
446
+
447
+ function buildAuthorityContainment({
448
+ args, cwd, readOnly, writeScope, terminalId, provider, providerExecutableBinding,
449
+ promptRecord = null, maskedReadPaths = [],
450
+ }) {
451
+ const trustedBubblewrap = requireBubblewrapContainment();
452
+ if (providerExecutableBinding === undefined) {
453
+ closeSync(trustedBubblewrap.descriptor);
454
+ throw Object.assign(new Error('authority-bearing provider launch requires an executable binding sealed by the installation'), { code: 'PROVIDER_EXECUTABLE_BINDING_REQUIRED' });
455
+ }
456
+ let projectChain;
457
+ const maskedChains = [];
458
+ let trustedProvider;
459
+ let trustedSupportingProviders = [];
460
+ let pinnedWriteScopes;
461
+ const stateDirectory = join(homedir(), provider === 'claude' ? '.claude' : provider === 'codex' ? '.codex' : '.grok');
462
+ let stateDirectoryArgs;
463
+ let stateDirectoryDescriptor;
464
+ const controlPlaneDescriptors = [];
465
+ const controlPlaneArgs = [];
466
+ try {
467
+ trustedProvider = openPinnedProviderExecutable(providerExecutableBinding, { harnessId: provider, projectDir: cwd });
468
+ trustedSupportingProviders = openPinnedProviderSupportingExecutables(
469
+ providerExecutableBinding, { harnessId: provider, projectDir: cwd },
470
+ );
471
+ projectChain = openPinnedDirectoryChain(cwd);
472
+ if (!Array.isArray(maskedReadPaths)) return failContainment('masked read paths must be an array');
473
+ for (const requested of [...new Set(maskedReadPaths.map((entry) => resolve(String(entry))))]) {
474
+ const physical = realpathSync(requested);
475
+ if (pathContains(physical, cwd) || pathContains(cwd, physical)) {
476
+ return failContainment('masked read path must not overlap the authority working directory');
477
+ }
478
+ maskedChains.push(openPinnedDirectoryChain(physical));
479
+ }
480
+ pinnedWriteScopes = preparePinnedWriteScopes(projectChain, readOnly ? [] : writeScope);
481
+ let stateStat;
482
+ try { stateStat = lstatSync(stateDirectory); }
483
+ catch (error) {
484
+ if (error?.code !== 'ENOENT') throw error;
485
+ mkdirSync(stateDirectory, { mode: 0o700 });
486
+ stateStat = lstatSync(stateDirectory);
487
+ }
488
+ if (!stateStat.isDirectory()) {
489
+ throw Object.assign(new Error('provider state path must be a real directory'), { code: 'TERMINAL_CONTAINMENT_UNAVAILABLE' });
490
+ }
491
+ stateDirectoryDescriptor = openSync(stateDirectory, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
492
+ const descriptorStat = fstatSync(stateDirectoryDescriptor);
493
+ if (descriptorStat.dev !== stateStat.dev || descriptorStat.ino !== stateStat.ino) {
494
+ throw Object.assign(new Error('provider state directory changed before containment'), { code: 'TERMINAL_CONTAINMENT_UNAVAILABLE' });
495
+ }
496
+ const physicalProject = realpathSync(projectChain.reference);
497
+ const physicalState = realpathSync(stateDirectory);
498
+ if (pathContains(physicalProject, physicalState) || pathContains(physicalState, physicalProject)) {
499
+ throw Object.assign(new Error('provider state and project directories must not physically overlap inside authority containment'), { code: 'TERMINAL_CONTAINMENT_UNAVAILABLE' });
500
+ }
501
+ // The project bind is applied after the authority key mask. Rejecting an
502
+ // overlap prevents that later bind from remounting the private key into
503
+ // the provider namespace through a caller-selected project path.
504
+ try {
505
+ const physicalAuthority = realpathSync(installationAuthorityStateDirectory());
506
+ if (pathContains(physicalProject, physicalAuthority) || pathContains(physicalAuthority, physicalProject)) {
507
+ throw Object.assign(new Error('installation authority and project directories must not physically overlap inside authority containment'), { code: 'TERMINAL_CONTAINMENT_UNAVAILABLE' });
508
+ }
509
+ } catch (error) {
510
+ if (error?.code !== 'ENOENT') throw error;
511
+ }
512
+ for (const masked of maskedChains) {
513
+ if (pathContains(masked.path, physicalState) || pathContains(physicalState, masked.path)
514
+ || pathContains(masked.path, trustedProvider.binding.executable_path)) {
515
+ throw Object.assign(new Error('masked read path overlaps provider state or executable authority'), { code: 'TERMINAL_CONTAINMENT_UNAVAILABLE' });
516
+ }
517
+ }
518
+ stateDirectoryArgs = ['--bind-fd', '4', stateDirectory];
519
+ // Provider-specific configuration is control-plane material too. A RAIL
520
+ // execution may write its scoped product files, but cannot rewrite a
521
+ // harness hook, skill, rule or config and then rely on that change in the
522
+ // same contained process.
523
+ for (const name of PROVIDER_CONTROL_PLANE_DIRECTORIES) {
524
+ const target = join(projectChain.reference, name);
525
+ let targetStat;
526
+ try { targetStat = lstatSync(target); }
527
+ catch (error) { if (error?.code === 'ENOENT') continue; throw error; }
528
+ if (targetStat.isSymbolicLink() || !targetStat.isDirectory()) {
529
+ throw Object.assign(new Error(`${name} control plane must be a real directory`), { code: 'TERMINAL_CONTAINMENT_UNAVAILABLE' });
530
+ }
531
+ const descriptor = openSync(target, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
532
+ const pinned = fstatSync(descriptor);
533
+ if (pinned.dev !== targetStat.dev || pinned.ino !== targetStat.ino
534
+ || !pathContains(physicalProject, realpathSync(target))) {
535
+ closeSync(descriptor);
536
+ throw Object.assign(new Error(`${name} control plane changed before containment`), { code: 'TERMINAL_CONTAINMENT_UNAVAILABLE' });
537
+ }
538
+ const descriptorNumber = 6 + controlPlaneDescriptors.length;
539
+ controlPlaneDescriptors.push(descriptor);
540
+ controlPlaneArgs.push('--ro-bind-fd', String(descriptorNumber), join(cwd, name));
541
+ }
542
+ } catch (error) {
543
+ controlPlaneDescriptors.forEach((descriptor) => closeSync(descriptor));
544
+ if (stateDirectoryDescriptor !== undefined) closeSync(stateDirectoryDescriptor);
545
+ if (trustedProvider?.descriptor !== undefined) closeSync(trustedProvider.descriptor);
546
+ trustedSupportingProviders.forEach((support) => closeSync(support.descriptor));
547
+ for (const descriptor of pinnedWriteScopes?.descriptors ?? []) try { closeSync(descriptor); } catch { /* already closed */ }
548
+ for (const chain of maskedChains) closeDirectoryChain(chain);
549
+ closeDirectoryChain(projectChain);
550
+ closeSync(trustedBubblewrap.descriptor);
551
+ if (error?.code === 'TERMINAL_CONTAINMENT_UNAVAILABLE' || error?.code?.startsWith('PROVIDER_EXECUTABLE_')) throw error;
552
+ throw Object.assign(new Error(`cannot prepare provider state directory: ${error.message}`), { code: 'TERMINAL_CONTAINMENT_UNAVAILABLE' });
553
+ }
554
+ const writableScopeDescriptors = pinnedWriteScopes.entries.map((entry) => entry.descriptor);
555
+ const writableScopeArgs = pinnedWriteScopes.entries.flatMap((entry, index) => [
556
+ '--bind-fd', String(6 + controlPlaneDescriptors.length + index), entry.target,
557
+ ]);
558
+ const promptDescriptorNumber = 6 + controlPlaneDescriptors.length + writableScopeDescriptors.length;
559
+ const providerDescriptorNumber = promptDescriptorNumber + (promptRecord ? 1 : 0);
560
+ const supportingProviderDescriptorNumber = providerDescriptorNumber + 1;
561
+ const supportingProviderArgs = trustedSupportingProviders.flatMap((support, index) => [
562
+ '--ro-bind-fd', String(supportingProviderDescriptorNumber + index),
563
+ join(dirname(CONTAINED_PROVIDER_EXECUTABLE_PATH), basename(support.binding.executable_path)),
564
+ ]);
565
+ const maskedPathArgs = maskedChains
566
+ .filter((chain) => !pathContains('/tmp', chain.path))
567
+ .flatMap((chain) => ['--tmpfs', chain.path]);
568
+ const containmentArgs = [
569
+ '--die-with-parent', '--unshare-pid', '--unshare-ipc', '--unshare-cgroup-try',
570
+ '--ro-bind', '/', '/',
571
+ '--dev', '/dev', '--proc', '/proc',
572
+ '--tmpfs', '/run/user',
573
+ '--tmpfs', '/tmp',
574
+ ...readOnlyTmpPathEntries(),
575
+ ...maskedPathArgs,
576
+ ...hiddenInstallationAuthorityArgs(),
577
+ ...stateDirectoryArgs,
578
+ pinnedWriteScopes.fullProject ? '--bind-fd' : '--ro-bind-fd', '5', cwd,
579
+ ...controlPlaneArgs,
580
+ ...writableScopeArgs,
581
+ ...(promptRecord ? ['--ro-bind-fd', String(promptDescriptorNumber), CONTAINED_PROMPT_PATH] : []),
582
+ '--ro-bind-fd', String(providerDescriptorNumber), CONTAINED_PROVIDER_EXECUTABLE_PATH,
583
+ ...supportingProviderArgs,
584
+ '--chdir', cwd,
585
+ '--', CONTAINED_PROVIDER_EXECUTABLE_PATH, ...args,
586
+ ];
587
+ return Object.freeze({
588
+ kind: 'bubblewrap_pid_namespace_v1',
589
+ id: `bwrap:${terminalId}`,
590
+ command: '/proc/self/fd/3',
591
+ args: Object.freeze(containmentArgs),
592
+ inheritedDescriptors: Object.freeze([
593
+ trustedBubblewrap.descriptor, stateDirectoryDescriptor, projectChain.descriptors.at(-1),
594
+ ...controlPlaneDescriptors,
595
+ ...writableScopeDescriptors,
596
+ ...(promptRecord ? [promptRecord.descriptor] : []),
597
+ trustedProvider.descriptor,
598
+ ...trustedSupportingProviders.map((support) => support.descriptor),
599
+ ]),
600
+ pinnedProjectChain: projectChain,
601
+ maskedReadChains: Object.freeze(maskedChains),
602
+ pinnedWriteScopes,
603
+ projectReference: projectChain.reference,
604
+ projectIdentityDigest: projectChain.identity_digest,
605
+ trustedExecutable: trustedBubblewrap.executable,
606
+ trustedProviderExecutable: trustedProvider.executable,
607
+ trustedSupportingProviderExecutables: Object.freeze(trustedSupportingProviders.map((support) => support.executable)),
608
+ providerExecutableBinding: trustedProvider.binding,
609
+ providerExecutableDescriptor: trustedProvider.descriptor,
610
+ supportingProviderExecutableBindings: Object.freeze(trustedSupportingProviders.map((support) => support.binding)),
611
+ supportingProviderExecutableDescriptors: Object.freeze(trustedSupportingProviders.map((support) => support.descriptor)),
612
+ digest: sha256({
613
+ binary: BUBBLEWRAP_PATH,
614
+ binary_sha256: trustedBubblewrap.binaryDigest,
615
+ provider_executable: trustedProvider.binding,
616
+ supporting_provider_executables: trustedSupportingProviders.map((support) => support.binding),
617
+ args: containmentArgs.slice(0, -args.length - 1),
618
+ }),
619
+ });
620
+ }
621
+
622
+ function closeAuthorityContainment(containment) {
623
+ const descriptors = new Set([
624
+ ...(containment?.inheritedDescriptors ?? []),
625
+ ...(containment?.pinnedProjectChain?.descriptors ?? []),
626
+ ...(containment?.pinnedWriteScopes?.descriptors ?? []),
627
+ ]);
628
+ for (const descriptor of descriptors) {
629
+ try { closeSync(descriptor); } catch { /* preserve the primary spawn result */ }
630
+ }
631
+ for (const chain of containment?.maskedReadChains ?? []) closeDirectoryChain(chain);
632
+ }
633
+
634
+ function signalProcessTree(child, processGroupId, signal) {
635
+ if (Number.isInteger(processGroupId) && processGroupId > 0 && process.platform !== 'win32') {
636
+ try { process.kill(-processGroupId, signal); return true; }
637
+ catch (error) { if (error?.code !== 'ESRCH') throw error; return false; }
638
+ }
639
+ return child.kill(signal);
640
+ }
641
+
642
+ function processTreeIsRunning(childPid, processGroupId) {
643
+ const target = Number.isInteger(processGroupId) && processGroupId > 0 && process.platform !== 'win32'
644
+ ? -processGroupId
645
+ : childPid;
646
+ try { process.kill(target, 0); return true; }
647
+ catch (error) { if (error?.code === 'ESRCH') return false; throw error; }
648
+ }
649
+
650
+ function processTreeStopsAfterClose(childPid, processGroupId) {
651
+ for (let attempt = 0; attempt < 100; attempt += 1) {
652
+ if (!processTreeIsRunning(childPid, processGroupId)) return true;
653
+ Atomics.wait(PROCESS_TREE_SETTLE_WAIT, 0, 0, 5);
654
+ }
655
+ return false;
656
+ }
657
+
658
+ function terminalAuthorityReceipt(material) {
659
+ const signed = signHostAuthorityPayload(canonicalize(material), {
660
+ domain: TERMINAL_AUTHORITY_DOMAIN,
661
+ });
662
+ return Object.freeze(canonicalize({
663
+ ...material,
664
+ authority_id: signed.authority_id,
665
+ authority_signature: signed.signature,
666
+ }));
667
+ }
668
+
669
+ function parseRailExecutionAuthorityResult(stdout, launchNonce) {
670
+ const matches = [...stdout.matchAll(/<CHATI_RAIL_EXECUTION>\s*([\s\S]*?)\s*<\/CHATI_RAIL_EXECUTION>/g)];
671
+ if (matches.length !== 1) {
672
+ throw Object.assign(new Error('successful RAIL execution must return exactly one result envelope on stdout'), { code: 'RAIL_EXECUTION_OUTPUT_INVALID' });
673
+ }
674
+ let payload;
675
+ try { payload = JSON.parse(matches[0][1]); }
676
+ catch {
677
+ throw Object.assign(new Error('successful RAIL execution returned malformed result JSON'), { code: 'RAIL_EXECUTION_OUTPUT_INVALID' });
678
+ }
679
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)
680
+ || Object.keys(payload).sort().join(',') !== 'launch_nonce,result_commit_ref'
681
+ || !/^[a-f0-9]{40,64}$/.test(payload.result_commit_ref || '')
682
+ || !/^[A-Za-z0-9._-]{16,128}$/.test(payload.launch_nonce || '')) {
683
+ throw Object.assign(new Error('RAIL execution result must bind one full commit id to the exact launch nonce'), { code: 'RAIL_EXECUTION_OUTPUT_INVALID' });
684
+ }
685
+ if (payload.launch_nonce !== launchNonce) {
686
+ throw Object.assign(new Error('RAIL execution output does not echo the canonical launch nonce'), { code: 'RAIL_EXECUTION_LAUNCH_NONCE_MISMATCH' });
687
+ }
688
+ return Object.freeze({ result_commit_ref: payload.result_commit_ref });
689
+ }
690
+
691
+ /**
692
+ * Verifies a receipt minted by the persistent host terminal authority. The
693
+ * private key remains outside provider containment, while persisted receipts
694
+ * remain verifiable after a supervisor restart.
695
+ */
696
+ export function verifyTerminalAuthorityReceipt(receipt, expected = {}) {
697
+ if (!receipt || typeof receipt !== 'object' || Array.isArray(receipt)
698
+ || typeof receipt.authority_id !== 'string'
699
+ || typeof receipt.authority_signature !== 'string') return false;
700
+ const { authority_signature, authority_id, ...material } = receipt;
701
+ if (!verifyHostAuthorityPayload(material, {
702
+ domain: TERMINAL_AUTHORITY_DOMAIN,
703
+ authorityId: authority_id,
704
+ signature: authority_signature,
705
+ })) return false;
706
+ return Object.entries(expected).every(([key, value]) => Object.hasOwn(material, key)
707
+ && sha256(material[key]) === sha256(value));
708
+ }
709
+
710
+ export function getTerminalLaunchAuthorityReceipt(handle) {
711
+ const state = TERMINAL_AUTHORITY_STATE.get(handle);
712
+ if (!state) throw Object.assign(new Error('terminal handle was not minted by spawnTerminal'), { code: 'UNTRUSTED_TERMINAL_HANDLE' });
713
+ return state.launchReceipt;
714
+ }
715
+
716
+ export function getTerminalCompletionAuthorityReceipt(handle) {
717
+ const state = TERMINAL_AUTHORITY_STATE.get(handle);
718
+ if (!state) throw Object.assign(new Error('terminal handle was not minted by spawnTerminal'), { code: 'UNTRUSTED_TERMINAL_HANDLE' });
719
+ const { runtime, identity } = state;
720
+ if (runtime.timeoutCleanupFailure) throw runtime.timeoutCleanupFailure;
721
+ if (runtime.timeoutCleanupPromise && !runtime.timeoutCleanupSettled) {
722
+ throw Object.assign(new Error('terminal timeout cleanup proof is still pending'), { code: 'TERMINAL_CLEANUP_PENDING' });
723
+ }
724
+ if (runtime.status === 'running' || !Number.isInteger(runtime.exitCode)) {
725
+ throw Object.assign(new Error('terminal completion receipt requires an exited process'), { code: 'TERMINAL_STILL_RUNNING' });
726
+ }
727
+ if (runtime.outputOverflow) {
728
+ throw Object.assign(new Error('terminal output exceeded the authority capture limit'), { code: 'TERMINAL_OUTPUT_OVERFLOW' });
729
+ }
730
+ if (!processTreeStopsAfterClose(runtime.child.pid, runtime.processGroupId)) {
731
+ try { signalProcessTree(runtime.child, runtime.processGroupId, 'SIGKILL'); } catch { /* fail closed below */ }
732
+ throw Object.assign(new Error('terminal process group remained active after the provider CLI exited'), { code: 'TERMINAL_PROCESS_GROUP_STILL_RUNNING' });
733
+ }
734
+ if (!state.completionReceipt) {
735
+ const stdout = runtime.stdout.join('');
736
+ let railResult = null;
737
+ let executionWorktreeProof = null;
738
+ let executionResultStatus = null;
739
+ let executionResultErrorCode = null;
740
+ if (state.context.authority_kind === 'rail_execution') {
741
+ if (runtime.exitCode === 0) {
742
+ try {
743
+ railResult = parseRailExecutionAuthorityResult(stdout, state.context.launch_nonce);
744
+ if (state.executionWorktreeBaseline) {
745
+ executionWorktreeProof = verifyRailExecutionWorktreeResult({
746
+ baseline: state.executionWorktreeBaseline,
747
+ resultCommitRef: railResult.result_commit_ref,
748
+ });
749
+ }
750
+ executionResultStatus = 'valid';
751
+ } catch (error) {
752
+ railResult = null;
753
+ executionWorktreeProof = null;
754
+ executionResultStatus = 'invalid_output';
755
+ executionResultErrorCode = error.code || 'RAIL_EXECUTION_OUTPUT_INVALID';
756
+ }
757
+ } else {
758
+ executionResultStatus = 'process_failed';
759
+ }
760
+ }
761
+ state.completionReceipt = terminalAuthorityReceipt({
762
+ schema_version: 1,
763
+ purpose: 'terminal_completion',
764
+ launch_receipt_digest: sha256(state.launchReceipt),
765
+ terminal_id: identity.terminal_id,
766
+ invocation_id: state.context.invocation_id,
767
+ launch_nonce: state.context.launch_nonce,
768
+ authority_kind: state.context.authority_kind,
769
+ provider_id: state.context.provider_id,
770
+ harness_id: identity.harness_id,
771
+ model_id: identity.model_id,
772
+ reasoning_configuration: identity.reasoning_configuration,
773
+ provider_executable_path: identity.provider_executable_path,
774
+ provider_executable_sha256: identity.provider_executable_sha256,
775
+ exit_status: runtime.exitCode,
776
+ stdout_digest: sha256(stdout),
777
+ stderr_digest: sha256(runtime.stderr.join('')),
778
+ ...(executionResultStatus ? { execution_result_status: executionResultStatus } : {}),
779
+ ...(executionResultErrorCode ? { execution_result_error_code: executionResultErrorCode } : {}),
780
+ ...(state.executionWorktreeBaseline ? {
781
+ execution_base_commit_ref: state.executionWorktreeBaseline.base_commit_ref,
782
+ execution_launch_head_ref: state.executionWorktreeBaseline.launch_head_ref,
783
+ execution_repository_identity_digest: state.executionWorktreeBaseline.repository_identity_digest,
784
+ execution_preexisting_commits_digest: state.executionWorktreeBaseline.preexisting_commits_digest,
785
+ } : {}),
786
+ ...(executionWorktreeProof ? {
787
+ execution_result_head_ref: executionWorktreeProof.result_head_ref,
788
+ execution_result_preexisted: executionWorktreeProof.result_preexisted,
789
+ execution_worktree_clean: executionWorktreeProof.worktree_clean,
790
+ } : {}),
791
+ ...(railResult || {}),
792
+ completed_at: runtime.completedAt,
793
+ });
794
+ }
795
+ return state.completionReceipt;
796
+ }
797
+
798
+ /** Wait for an authority terminal and propagate timeout cleanup proof failures. */
799
+ export async function waitForTerminal(handle) {
800
+ const state = TERMINAL_AUTHORITY_STATE.get(handle);
801
+ if (!state) {
802
+ if (!handle?.process || handle.status !== 'running') return handle;
803
+ await new Promise((done) => {
804
+ handle.process.once('exit', done);
805
+ handle.process.once('error', done);
806
+ });
807
+ if (handle.timeoutCleanupFailure) throw handle.timeoutCleanupFailure;
808
+ return handle;
809
+ }
810
+ const { runtime } = state;
811
+ const outcome = await Promise.race([
812
+ runtime.exitPromise.then(() => Object.freeze({ kind: 'exit' })),
813
+ runtime.cleanupFailureSignal.then((error) => Object.freeze({ kind: 'cleanup_failure', error })),
814
+ ]);
815
+ if (outcome.kind === 'cleanup_failure') throw outcome.error;
816
+ if (runtime.timeoutCleanupPromise) {
817
+ const cleanup = await runtime.timeoutCleanupPromise;
818
+ if (!cleanup.ok) throw cleanup.error;
819
+ }
820
+ if (runtime.timeoutCleanupFailure) throw runtime.timeoutCleanupFailure;
821
+ return handle;
822
+ }
41
823
 
42
824
  /**
43
825
  * Generate a unique terminal identifier.
@@ -99,6 +881,24 @@ export function cleanParentEnv(env) {
99
881
  return cleaned;
100
882
  }
101
883
 
884
+ const AUTHORITY_PROVIDER_SECRET_PREFIXES = Object.freeze({
885
+ claude: Object.freeze(['ANTHROPIC_', 'CLAUDE_']),
886
+ codex: Object.freeze(['OPENAI_', 'CODEX_']),
887
+ grok: Object.freeze(['XAI_', 'GROK_']),
888
+ });
889
+ const AUTHORITY_SECRET_NAME = /(API[_-]?KEY|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|PASSWORD|SECRET|TOKEN|CREDENTIAL|AUTH[_-]?SOCK)/i;
890
+
891
+ export function cleanAuthorityEnv(env, provider) {
892
+ const allowedPrefixes = AUTHORITY_PROVIDER_SECRET_PREFIXES[provider] || [];
893
+ const cleaned = {};
894
+ for (const [key, value] of Object.entries(cleanParentEnv(env))) {
895
+ if (AUTHORITY_SECRET_NAME.test(key) && !allowedPrefixes.some((prefix) => key.startsWith(prefix))) continue;
896
+ cleaned[key] = value;
897
+ }
898
+ cleaned.PATH = AUTHORITY_SYSTEM_PATH;
899
+ return cleaned;
900
+ }
901
+
102
902
  // ---------------------------------------------------------------------------
103
903
  // Public API
104
904
  // ---------------------------------------------------------------------------
@@ -112,11 +912,16 @@ export function cleanParentEnv(env) {
112
912
  * @property {string} [providerId] - Vendor provider identifier (anthropic, openai, xai)
113
913
  * @property {string} [reasoningConfiguration] - Requested reasoning effort
114
914
  * @property {string} [catalogSnapshotRef] - Capability catalog snapshot used for routing
115
- * @property {string} [prompt] - Full prompt string (from prompt-builder, piped via stdin)
915
+ * @property {{executable_path:string,executable_sha256:string}} [providerExecutableBinding] - Sealed native provider executable
916
+ * @property {string} [prompt] - Full prompt string transported outside argv by the provider adapter
116
917
  * @property {object} [contextPayload] - Context to inject via env var
117
918
  * @property {string[]} [writeScope] - Override write scope
118
919
  * @property {string} [workingDir] - Working directory for the process
119
920
  * @property {number} [timeout] - Max execution time in ms
921
+ * @property {function} [beforeProcessSpawn] - Read-only preparation after containment is pinned
922
+ * @property {function} [beforeAuthoritySpawn] - Final synchronous authority reservation before spawn
923
+ * @property {string[]} [maskedReadPaths] - Canonical host paths hidden from an authority provider
924
+ * @property {string} [auditProjectDir] - Canonical CHATI project used only for the host-side model audit
120
925
  */
121
926
 
122
927
  /**
@@ -140,7 +945,7 @@ export function cleanParentEnv(env) {
140
945
  * perform any I/O and is therefore fully testable in isolation.
141
946
  *
142
947
  * @param {SpawnConfig} config
143
- * @returns {{ command: string, args: string[], env: Record<string, string>, terminalId: string, prompt: string|null }}
948
+ * @returns {{ command: string, args: string[], env: Record<string, string>, terminalId: string, prompt: string|null, promptFile: string|null }}
144
949
  */
145
950
  export function buildSpawnCommand(config) {
146
951
  if (!config || typeof config !== 'object') {
@@ -157,9 +962,17 @@ export function buildSpawnCommand(config) {
157
962
  failure.code = 'PROVIDER_REQUIRED';
158
963
  throw failure;
159
964
  }
965
+ const readOnly = Array.isArray(config.writeScope) && config.writeScope.length === 0;
966
+ if (readOnly && !READ_ONLY_PROVIDERS.has(config.provider)) {
967
+ const failure = new Error(`Selected provider "${config.provider}" has no enforced read-only terminal mode`);
968
+ failure.code = 'READ_ONLY_PROVIDER_UNSUPPORTED';
969
+ throw failure;
970
+ }
160
971
 
161
972
  const terminalId = generateTerminalId(config.agent);
162
- const isolationEnv = buildIsolationEnv(config.agent);
973
+ const isolationEnv = Array.isArray(config.writeScope)
974
+ ? { CHATI_WRITE_SCOPE: config.writeScope.join(','), CHATI_READ_SCOPE: '*' }
975
+ : buildIsolationEnv(config.agent);
163
976
 
164
977
  const env = {
165
978
  ...isolationEnv,
@@ -167,6 +980,7 @@ export function buildSpawnCommand(config) {
167
980
  CHATI_AGENT: config.agent,
168
981
  CHATI_TASK_ID: config.taskId,
169
982
  CHATI_SPAWNED: 'true',
983
+ ...(readOnly ? { CHATI_EXECUTION_MODE: 'read-only' } : {}),
170
984
  ...(config.reasoningConfiguration ? { CHATI_REASONING_CONFIGURATION: config.reasoningConfiguration } : {}),
171
985
  };
172
986
 
@@ -177,12 +991,16 @@ export function buildSpawnCommand(config) {
177
991
  process.stderr.write(`[chati] spawner context-serialize: ${err.message}\n`);
178
992
  env.CHATI_CONTEXT = '{}';
179
993
  }
994
+ for (const key of TEAM_CONTEXT_ENV_KEYS) {
995
+ const value = config.contextPayload[key];
996
+ if (typeof value === 'string' && value.trim() !== '') env[key] = value;
997
+ }
180
998
  }
181
999
 
182
1000
  // Resolve the exact provider selected by the router. Never substitute a
183
1001
  // different vendor when credentials, catalog data, or a CLI are missing.
184
1002
  const providerName = config.provider;
185
- let command, args, prompt;
1003
+ let command, args, prompt, promptFile;
186
1004
  const providerFallback = null;
187
1005
  const actualProvider = providerName;
188
1006
  let actualModel;
@@ -196,6 +1014,7 @@ export function buildSpawnCommand(config) {
196
1014
  command = adapterResult.command;
197
1015
  args = adapterResult.args;
198
1016
  prompt = adapterResult.stdinPrompt;
1017
+ promptFile = adapterResult.promptFile ?? null;
199
1018
  actualModel = adapterResult.effectiveModel;
200
1019
  actualReasoningConfiguration = adapterResult.effectiveReasoningConfiguration;
201
1020
  } catch (err) {
@@ -205,7 +1024,7 @@ export function buildSpawnCommand(config) {
205
1024
  }
206
1025
 
207
1026
  return {
208
- command, args, env, terminalId, prompt, providerFallback,
1027
+ command, args, env, terminalId, prompt, promptFile, providerFallback,
209
1028
  actualProvider, actualModel, actualReasoningConfiguration,
210
1029
  };
211
1030
  }
@@ -218,100 +1037,394 @@ export function buildSpawnCommand(config) {
218
1037
  */
219
1038
  export function spawnTerminal(config) {
220
1039
  const {
221
- command, args, env, terminalId, prompt, providerFallback,
1040
+ command, args, env, terminalId, prompt, promptFile, providerFallback,
222
1041
  actualProvider, actualModel, actualReasoningConfiguration,
223
1042
  } = buildSpawnCommand(config);
224
1043
 
225
1044
  const cwd = config.workingDir || process.cwd();
226
1045
  const timeout = config.timeout || 300_000; // default 5 minutes
1046
+ const authorityContext = config.authorityContext;
1047
+ if (authorityContext !== undefined) {
1048
+ for (const field of ['authority_kind', 'invocation_id', 'launch_nonce', 'provider_id']) {
1049
+ if (typeof authorityContext?.[field] !== 'string' || authorityContext[field].trim() === '') {
1050
+ throw Object.assign(new Error(`authorityContext.${field} is required`), { code: 'INVALID_TERMINAL_AUTHORITY_CONTEXT' });
1051
+ }
1052
+ }
1053
+ }
227
1054
 
228
- const selectionRecord = recordModelSelection(cwd, {
229
- agent: config.agent,
230
- taskId: config.taskId,
231
- provider: actualProvider,
232
- providerId: config.providerId || null,
233
- model: actualModel,
234
- reasoningConfiguration: actualReasoningConfiguration,
235
- catalogSnapshotRef: config.catalogSnapshotRef || null,
236
- });
237
- if (!selectionRecord.saved) {
238
- const auditError = new Error(selectionRecord.error || 'Failed to persist model selection audit');
239
- auditError.code = 'MODEL_SELECTION_AUDIT_FAILED';
240
- throw auditError;
1055
+ const readOnly = Array.isArray(config.writeScope) && config.writeScope.length === 0;
1056
+ if (config.beforeProcessSpawn !== undefined && typeof config.beforeProcessSpawn !== 'function') {
1057
+ throw Object.assign(new Error('beforeProcessSpawn must be a function'), { code: 'INVALID_TERMINAL_PRESPAWN_HOOK' });
1058
+ }
1059
+ if (config.beforeAuthoritySpawn !== undefined
1060
+ && (authorityContext === undefined || typeof config.beforeAuthoritySpawn !== 'function')) {
1061
+ throw Object.assign(new Error('beforeAuthoritySpawn requires an authority context and callback'), { code: 'INVALID_TERMINAL_PRESPAWN_HOOK' });
241
1062
  }
242
1063
 
243
- const child = spawn(command, args, {
244
- cwd,
245
- env: { ...cleanParentEnv(process.env), ...env },
246
- stdio: ['pipe', 'pipe', 'pipe'],
247
- });
1064
+ const detachedProcessGroup = authorityContext !== undefined && process.platform !== 'win32';
1065
+ let promptRecord;
1066
+ let containment;
1067
+ let sealedProvider;
1068
+ let executionWorktreeBaseline;
1069
+ let spawnArgs = args;
1070
+ let child;
1071
+ try {
1072
+ if (promptFile !== null) {
1073
+ promptRecord = openPrivatePromptFile(promptFile);
1074
+ spawnArgs = materializePrivatePromptArgs(args, authorityContext === undefined
1075
+ ? '/proc/self/fd/3'
1076
+ : CONTAINED_PROMPT_PATH);
1077
+ }
1078
+ containment = authorityContext === undefined ? null : buildAuthorityContainment({
1079
+ args: spawnArgs, cwd: resolve(cwd), readOnly, terminalId, provider: actualProvider,
1080
+ writeScope: config.writeScope, providerExecutableBinding: config.providerExecutableBinding, promptRecord,
1081
+ maskedReadPaths: config.maskedReadPaths,
1082
+ });
1083
+ if (!containment && config.providerExecutableBinding !== undefined) {
1084
+ if (process.platform !== 'linux') {
1085
+ throw Object.assign(
1086
+ new Error('sealed Planning provider execution requires Linux descriptor execution'),
1087
+ { code: 'PROVIDER_EXECUTABLE_UNSUPPORTED' },
1088
+ );
1089
+ }
1090
+ sealedProvider = openPinnedProviderExecutable(config.providerExecutableBinding, {
1091
+ harnessId: actualProvider,
1092
+ projectDir: cwd,
1093
+ });
1094
+ }
1095
+ const auditProjectDir = config.auditProjectDir ? resolve(config.auditProjectDir) : null;
1096
+ if (containment && auditProjectDir && auditProjectDir !== resolve(cwd)
1097
+ && !(config.maskedReadPaths || []).map((entry) => realpathSync(resolve(entry))).includes(realpathSync(auditProjectDir))) {
1098
+ throw Object.assign(new Error('external model audit project must be hidden from the authority provider'), { code: 'TERMINAL_CONTAINMENT_UNAVAILABLE' });
1099
+ }
1100
+ const selectionRecord = recordModelSelection(auditProjectDir ?? containment?.projectReference ?? cwd, {
1101
+ agent: config.agent,
1102
+ taskId: config.taskId,
1103
+ provider: actualProvider,
1104
+ providerId: config.providerId || null,
1105
+ model: actualModel,
1106
+ reasoningConfiguration: actualReasoningConfiguration,
1107
+ catalogSnapshotRef: config.catalogSnapshotRef || null,
1108
+ });
1109
+ if (!selectionRecord.saved) {
1110
+ const auditError = new Error(selectionRecord.error || 'Failed to persist model selection audit');
1111
+ auditError.code = 'MODEL_SELECTION_AUDIT_FAILED';
1112
+ throw auditError;
1113
+ }
248
1114
 
249
- // Pipe prompt via stdin (avoids shell argument length limits)
1115
+ if (authorityContext?.authority_kind === 'rail_execution'
1116
+ && config.executionBaseCommitRef !== undefined) {
1117
+ executionWorktreeBaseline = captureRailExecutionWorktree({
1118
+ projectDir: cwd,
1119
+ baseCommitRef: config.executionBaseCommitRef,
1120
+ });
1121
+ }
1122
+
1123
+ if (config.beforeProcessSpawn) config.beforeProcessSpawn();
1124
+ if (containment) {
1125
+ runTerminalSecurityTestHook('beforeAuthorityExecutableRevalidation', { containment });
1126
+ currentBubblewrapTrust(containment.inheritedDescriptors[0]);
1127
+ digestPinnedExecutable(containment.inheritedDescriptors[0], containment.trustedExecutable);
1128
+ const providerExecutable = digestProviderExecutable(
1129
+ containment.providerExecutableDescriptor,
1130
+ containment.trustedProviderExecutable.snapshot,
1131
+ );
1132
+ if (providerExecutable.sha256 !== containment.providerExecutableBinding.executable_sha256) {
1133
+ throw Object.assign(new Error('provider executable changed after installation binding validation'), { code: 'PROVIDER_EXECUTABLE_DRIFT' });
1134
+ }
1135
+ containment.supportingProviderExecutableDescriptors.forEach((descriptor, index) => {
1136
+ const supportingExecutable = digestProviderExecutable(
1137
+ descriptor, containment.trustedSupportingProviderExecutables[index].snapshot,
1138
+ );
1139
+ if (supportingExecutable.sha256 !== containment.supportingProviderExecutableBindings[index].executable_sha256) {
1140
+ throw Object.assign(new Error('provider supporting executable changed after installation binding validation'), { code: 'PROVIDER_EXECUTABLE_DRIFT' });
1141
+ }
1142
+ });
1143
+ assertPinnedDirectoryChain(containment.pinnedProjectChain);
1144
+ for (const chain of containment.maskedReadChains) assertPinnedDirectoryChain(chain);
1145
+ assertPinnedWriteScopes(containment.pinnedWriteScopes);
1146
+ } else if (sealedProvider) {
1147
+ const providerExecutable = digestProviderExecutable(
1148
+ sealedProvider.descriptor,
1149
+ sealedProvider.executable.snapshot,
1150
+ );
1151
+ if (providerExecutable.sha256 !== sealedProvider.binding.executable_sha256) {
1152
+ throw Object.assign(new Error('provider executable changed after installation binding validation'), { code: 'PROVIDER_EXECUTABLE_DRIFT' });
1153
+ }
1154
+ }
1155
+ if (executionWorktreeBaseline) {
1156
+ executionWorktreeBaseline = assertRailExecutionWorktreeBaselineUnchanged(executionWorktreeBaseline);
1157
+ }
1158
+ if (config.beforeAuthoritySpawn) {
1159
+ config.beforeAuthoritySpawn(Object.freeze({
1160
+ working_dir: resolve(cwd),
1161
+ project_identity_digest: containment.projectIdentityDigest,
1162
+ }));
1163
+ }
1164
+ if (executionWorktreeBaseline) {
1165
+ executionWorktreeBaseline = assertRailExecutionWorktreeBaselineUnchanged(executionWorktreeBaseline);
1166
+ }
1167
+ const sealedProviderDescriptorNumber = 3 + (promptRecord ? 1 : 0);
1168
+ const spawnCommand = containment?.command
1169
+ ?? (sealedProvider ? `/proc/self/fd/${sealedProviderDescriptorNumber}` : command);
1170
+ child = spawn(spawnCommand, containment?.args ?? spawnArgs, {
1171
+ cwd: containment ? '/' : cwd,
1172
+ env: {
1173
+ ...(containment ? cleanAuthorityEnv(process.env, actualProvider) : cleanParentEnv(process.env)), ...env,
1174
+ ...(containment ? { TMPDIR: '/tmp', TMP: '/tmp', TEMP: '/tmp' } : {}),
1175
+ },
1176
+ stdio: containment
1177
+ ? ['pipe', 'pipe', 'pipe', ...containment.inheritedDescriptors]
1178
+ : [
1179
+ 'pipe', 'pipe', 'pipe',
1180
+ ...(promptRecord ? [promptRecord.descriptor] : []),
1181
+ ...(sealedProvider ? [sealedProvider.descriptor] : []),
1182
+ ],
1183
+ detached: detachedProcessGroup,
1184
+ });
1185
+ if (promptRecord) {
1186
+ child.once('exit', () => cleanupPrivatePromptFile(promptRecord));
1187
+ child.once('error', () => cleanupPrivatePromptFile(promptRecord));
1188
+ }
1189
+ if (containment) {
1190
+ assertPinnedDirectoryChain(containment.pinnedProjectChain);
1191
+ }
1192
+ } catch (error) {
1193
+ if (child?.pid) {
1194
+ try { signalProcessTree(child, detachedProcessGroup ? child.pid : null, 'SIGKILL'); } catch { /* reservation remains fail-closed */ }
1195
+ }
1196
+ throw error;
1197
+ } finally {
1198
+ if (containment) closeAuthorityContainment(containment);
1199
+ if (sealedProvider?.descriptor !== undefined) try { closeSync(sealedProvider.descriptor); } catch { /* child inherited its own descriptor */ }
1200
+ if (promptRecord) try { closeSync(promptRecord.descriptor); } catch { /* containment already closed it */ }
1201
+ if (promptRecord && !child) cleanupPrivatePromptFile(promptRecord);
1202
+ }
1203
+ const processGroupId = detachedProcessGroup ? child.pid : null;
1204
+
1205
+ // Stdin-capable adapters transport prompts here. File-capable adapters use
1206
+ // the private descriptor prepared before spawn and leave stdin empty.
250
1207
  if (prompt) {
251
1208
  child.stdin.write(prompt);
252
1209
  }
253
1210
  child.stdin.end();
254
1211
 
1212
+ let resolveAuthorityExit;
1213
+ let signalAuthorityCleanupFailure;
1214
+ const runtime = authorityContext === undefined ? null : {
1215
+ child,
1216
+ processGroupId,
1217
+ status: 'running',
1218
+ exitCode: null,
1219
+ stdout: [],
1220
+ stderr: [],
1221
+ stdoutBytes: 0,
1222
+ stderrBytes: 0,
1223
+ outputOverflow: false,
1224
+ completedAt: null,
1225
+ killRequested: false,
1226
+ finalized: false,
1227
+ exitPromise: new Promise((resolveExit) => { resolveAuthorityExit = resolveExit; }),
1228
+ cleanupFailureSignal: new Promise((resolveFailure) => { signalAuthorityCleanupFailure = resolveFailure; }),
1229
+ timeoutCleanupPromise: null,
1230
+ timeoutCleanupSettled: false,
1231
+ timeoutCleanupFailure: null,
1232
+ };
1233
+ const processFacade = runtime ? new EventEmitter() : child;
1234
+ if (runtime) {
1235
+ Object.defineProperties(processFacade, {
1236
+ pid: { enumerable: true, get: () => child.pid },
1237
+ killed: { enumerable: true, get: () => child.killed },
1238
+ });
1239
+ Object.defineProperty(processFacade, 'kill', {
1240
+ enumerable: true,
1241
+ value: (signal) => signalProcessTree(child, processGroupId, signal),
1242
+ });
1243
+ }
1244
+
255
1245
  /** @type {TerminalHandle} */
256
1246
  const handle = {
257
1247
  id: terminalId,
258
- process: child,
1248
+ process: processFacade,
259
1249
  agent: config.agent,
260
1250
  taskId: config.taskId,
261
1251
  model: actualModel || config.model || 'provider-default',
262
1252
  provider: actualProvider,
263
- reasoningConfiguration: config.reasoningConfiguration || null,
1253
+ reasoningConfiguration: actualReasoningConfiguration || null,
264
1254
  providerFallback,
265
1255
  startedAt: new Date().toISOString(),
266
- status: 'running',
267
- exitCode: null,
268
- stdout: [],
269
- stderr: [],
270
1256
  timeout,
271
1257
  };
272
1258
 
1259
+ if (runtime) {
1260
+ Object.defineProperties(handle, {
1261
+ status: { enumerable: true, configurable: false, get: () => runtime.status },
1262
+ exitCode: { enumerable: true, configurable: false, get: () => runtime.exitCode },
1263
+ stdout: { enumerable: true, configurable: false, get: () => Object.freeze([...runtime.stdout]) },
1264
+ stderr: { enumerable: true, configurable: false, get: () => Object.freeze([...runtime.stderr]) },
1265
+ });
1266
+ } else {
1267
+ Object.assign(handle, { status: 'running', exitCode: null, stdout: [], stderr: [] });
1268
+ }
1269
+
1270
+ if (authorityContext !== undefined) {
1271
+ const context = authorityContext;
1272
+ const identity = Object.freeze({
1273
+ terminal_id: terminalId,
1274
+ harness_id: actualProvider,
1275
+ model_id: actualModel,
1276
+ reasoning_configuration: actualReasoningConfiguration,
1277
+ provider_executable_path: containment.providerExecutableBinding.executable_path,
1278
+ provider_executable_sha256: containment.providerExecutableBinding.executable_sha256,
1279
+ });
1280
+ const launchReceipt = terminalAuthorityReceipt({
1281
+ schema_version: 1,
1282
+ purpose: 'terminal_launch',
1283
+ authority_kind: context.authority_kind,
1284
+ invocation_id: context.invocation_id,
1285
+ launch_nonce: context.launch_nonce,
1286
+ terminal_id: terminalId,
1287
+ process_pid: child.pid,
1288
+ process_group_id: processGroupId,
1289
+ process_hostname: hostname(),
1290
+ process_containment_kind: containment.kind,
1291
+ process_containment_id: containment.id,
1292
+ process_containment_digest: containment.digest,
1293
+ project_identity_digest: containment.projectIdentityDigest,
1294
+ agent: config.agent,
1295
+ task_id: config.taskId,
1296
+ working_dir: resolve(cwd),
1297
+ prompt_digest: sha256(config.prompt ?? ''),
1298
+ provider_id: context.provider_id,
1299
+ harness_id: actualProvider,
1300
+ model_id: actualModel,
1301
+ reasoning_configuration: actualReasoningConfiguration,
1302
+ provider_executable_path: containment.providerExecutableBinding.executable_path,
1303
+ provider_executable_sha256: containment.providerExecutableBinding.executable_sha256,
1304
+ command_digest: sha256({
1305
+ command: containment.providerExecutableBinding.executable_path,
1306
+ command_sha256: containment.providerExecutableBinding.executable_sha256,
1307
+ args,
1308
+ }),
1309
+ ...(executionWorktreeBaseline ? {
1310
+ execution_base_commit_ref: executionWorktreeBaseline.base_commit_ref,
1311
+ execution_launch_head_ref: executionWorktreeBaseline.launch_head_ref,
1312
+ execution_repository_identity_digest: executionWorktreeBaseline.repository_identity_digest,
1313
+ execution_preexisting_commits_digest: executionWorktreeBaseline.preexisting_commits_digest,
1314
+ execution_worktree_clean: true,
1315
+ } : {}),
1316
+ started_at: handle.startedAt,
1317
+ });
1318
+ TERMINAL_AUTHORITY_STATE.set(handle, {
1319
+ context: Object.freeze({ ...context }),
1320
+ identity,
1321
+ runtime,
1322
+ executionWorktreeBaseline,
1323
+ launchReceipt,
1324
+ completionReceipt: null,
1325
+ });
1326
+ }
1327
+
273
1328
  // Record spawn in rate limiter for throttling
274
1329
  const providerForRate = actualProvider;
275
1330
  getRateLimiter(providerForRate).recordSpawn();
276
1331
 
277
1332
  // Capture output (capped at ~10MB to prevent unbounded memory growth)
278
1333
  const MAX_BUFFER_CHUNKS = 10_000;
1334
+ const MAX_AUTHORITY_OUTPUT_BYTES = 10 * 1024 * 1024;
1335
+ const captureAuthorityOutput = (stream, chunk) => {
1336
+ const key = stream === 'stdout' ? 'stdoutBytes' : 'stderrBytes';
1337
+ const bytes = Buffer.byteLength(chunk);
1338
+ if (runtime[key] + bytes > MAX_AUTHORITY_OUTPUT_BYTES) {
1339
+ if (!runtime.outputOverflow) {
1340
+ runtime.outputOverflow = true;
1341
+ runtime.stderr.push(`terminal output exceeded ${MAX_AUTHORITY_OUTPUT_BYTES} byte authority limit`);
1342
+ runtime.killRequested = true;
1343
+ try { signalProcessTree(child, processGroupId, 'SIGKILL'); } catch { /* process already exited */ }
1344
+ }
1345
+ return;
1346
+ }
1347
+ runtime[key] += bytes;
1348
+ runtime[stream].push(chunk);
1349
+ };
279
1350
  if (child.stdout) {
280
1351
  child.stdout.on('data', (chunk) => {
281
- if (handle.stdout.length < MAX_BUFFER_CHUNKS) {
282
- handle.stdout.push(chunk.toString());
1352
+ const text = chunk.toString();
1353
+ if (runtime) { captureAuthorityOutput('stdout', text); return; }
1354
+ const output = handle.stdout;
1355
+ if (output.length < MAX_BUFFER_CHUNKS) {
1356
+ output.push(text);
283
1357
  }
284
1358
  });
285
1359
  }
286
1360
  if (child.stderr) {
287
1361
  child.stderr.on('data', (chunk) => {
288
- if (handle.stderr.length < MAX_BUFFER_CHUNKS) {
289
- handle.stderr.push(chunk.toString());
1362
+ const text = chunk.toString();
1363
+ if (runtime) { captureAuthorityOutput('stderr', text); return; }
1364
+ const output = handle.stderr;
1365
+ if (output.length < MAX_BUFFER_CHUNKS) {
1366
+ output.push(text);
290
1367
  }
291
1368
  });
292
1369
  }
293
1370
 
294
- child.on('exit', (code) => {
295
- handle.status = 'exited';
296
- handle.exitCode = code;
297
- });
1371
+ if (runtime) {
1372
+ const finalize = (code, error = null) => {
1373
+ if (runtime.finalized) return;
1374
+ runtime.finalized = true;
1375
+ runtime.status = runtime.killRequested ? 'killed' : 'exited';
1376
+ runtime.exitCode = Number.isInteger(code) ? code : -1;
1377
+ runtime.completedAt = new Date().toISOString();
1378
+ if (error) runtime.stderr.push(`spawn error: ${error.message}`);
1379
+ if (error && processFacade.listenerCount('error') > 0) processFacade.emit('error', error);
1380
+ processFacade.emit('exit', runtime.exitCode);
1381
+ processFacade.emit('close', runtime.exitCode);
1382
+ resolveAuthorityExit(handle);
1383
+ };
1384
+ // `close` fires only after stdio has drained, so an authority receipt can
1385
+ // never omit output that arrived after the OS process exited.
1386
+ child.once('close', (code) => finalize(code));
1387
+ child.once('error', (error) => finalize(-1, error));
1388
+ } else {
1389
+ child.on('exit', (code) => {
1390
+ handle.status = 'exited';
1391
+ handle.exitCode = code;
1392
+ });
298
1393
 
299
- child.on('error', (err) => {
300
- handle.status = 'exited';
301
- handle.exitCode = -1;
302
- handle.stderr.push(`spawn error: ${err.message}`);
303
- });
1394
+ child.on('error', (err) => {
1395
+ handle.status = 'exited';
1396
+ handle.exitCode = -1;
1397
+ handle.stderr.push(`spawn error: ${err.message}`);
1398
+ });
1399
+ }
304
1400
 
305
1401
  // Enforce timeout — kill process if it exceeds max execution time
306
1402
  const timeoutTimer = setTimeout(() => {
307
1403
  if (handle.status === 'running') {
308
- handle.stderr.push(`timeout: process exceeded ${timeout}ms limit`);
309
- killTerminal(handle);
1404
+ (runtime?.stderr ?? handle.stderr).push(`timeout: process exceeded ${timeout}ms limit`);
1405
+ if (runtime) {
1406
+ runtime.timeoutCleanupPromise = killTerminal(handle).then(
1407
+ (result) => {
1408
+ runtime.timeoutCleanupSettled = true;
1409
+ return Object.freeze({ ok: true, result });
1410
+ },
1411
+ (error) => {
1412
+ runtime.timeoutCleanupSettled = true;
1413
+ runtime.timeoutCleanupFailure = error;
1414
+ signalAuthorityCleanupFailure(error);
1415
+ return Object.freeze({ ok: false, error });
1416
+ },
1417
+ );
1418
+ } else {
1419
+ void killTerminal(handle).catch((error) => {
1420
+ handle.timeoutCleanupFailure = error;
1421
+ });
1422
+ }
310
1423
  }
311
1424
  }, timeout);
312
1425
 
313
1426
  // Clear timer when process exits normally
314
- child.on('exit', () => clearTimeout(timeoutTimer));
1427
+ child.on('close', () => clearTimeout(timeoutTimer));
315
1428
 
316
1429
  return handle;
317
1430
  }
@@ -411,16 +1524,7 @@ export async function spawnParallelGroupAsync(configs, options = {}) {
411
1524
  * when the terminal's process emits 'exit'.
412
1525
  */
413
1526
  function waitForExit(handle) {
414
- if (handle.status !== 'running') {
415
- return Promise.resolve();
416
- }
417
- return new Promise(resolve => {
418
- if (!handle.process) {
419
- resolve();
420
- return;
421
- }
422
- handle.process.once('exit', () => resolve());
423
- });
1527
+ return waitForTerminal(handle).then(() => undefined);
424
1528
  }
425
1529
 
426
1530
  // Fill initial pool
@@ -468,14 +1572,58 @@ export function killTerminal(handle) {
468
1572
  return Promise.resolve({ killed: false, exitCode: handle?.exitCode ?? null });
469
1573
  }
470
1574
 
471
- if (handle.status === 'exited') {
1575
+ const authorityState = TERMINAL_AUTHORITY_STATE.get(handle);
1576
+ const rawProcess = authorityState?.runtime?.child ?? handle.process;
1577
+ const processGroupId = authorityState?.runtime?.processGroupId ?? null;
1578
+ if (authorityState) {
1579
+ authorityState.runtime.killRequested = true;
1580
+ return (async () => {
1581
+ runTerminalSecurityTestHook('beforeAuthorityProcessTreeObservation', { handle });
1582
+ let active;
1583
+ try { active = processTreeIsRunning(rawProcess.pid, processGroupId); }
1584
+ catch (error) {
1585
+ throw Object.assign(new Error(`cannot observe the contained provider process tree: ${error.message}`), { code: 'TERMINAL_PROCESS_TREE_UNRESOLVED' });
1586
+ }
1587
+ if (!active) return { killed: false, exitCode: handle.exitCode };
1588
+ try { signalProcessTree(rawProcess, processGroupId, 'SIGTERM'); }
1589
+ catch { /* the containment may have exited between observation and signal */ }
1590
+ const waitForStop = async (attempts) => {
1591
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
1592
+ try { if (!processTreeIsRunning(rawProcess.pid, processGroupId)) return true; }
1593
+ catch (error) {
1594
+ throw Object.assign(new Error(`cannot observe the contained provider process tree: ${error.message}`), { code: 'TERMINAL_PROCESS_TREE_UNRESOLVED' });
1595
+ }
1596
+ await new Promise((done) => setTimeout(done, 25));
1597
+ }
1598
+ return false;
1599
+ };
1600
+ if (!(await waitForStop(200))) {
1601
+ try { signalProcessTree(rawProcess, processGroupId, 'SIGKILL'); }
1602
+ catch { /* the containment may have exited between observation and signal */ }
1603
+ if (!(await waitForStop(200))) {
1604
+ throw Object.assign(new Error('contained provider process tree survived SIGKILL'), { code: 'TERMINAL_PROCESS_TREE_STILL_RUNNING' });
1605
+ }
1606
+ }
1607
+ for (let attempt = 0; attempt < 80 && handle.status === 'running'; attempt += 1) {
1608
+ await new Promise((done) => setTimeout(done, 25));
1609
+ }
1610
+ return { killed: true, exitCode: handle.exitCode };
1611
+ })();
1612
+ }
1613
+ if (handle.status === 'exited' || handle.status === 'killed') {
472
1614
  return Promise.resolve({ killed: false, exitCode: handle.exitCode });
473
1615
  }
474
1616
 
475
1617
  return new Promise((resolve) => {
1618
+ let settled = false;
1619
+ const settle = (result) => {
1620
+ if (settled) return;
1621
+ settled = true;
1622
+ resolve(result);
1623
+ };
476
1624
  const forceKillTimer = setTimeout(() => {
477
1625
  try {
478
- handle.process.kill('SIGKILL');
1626
+ signalProcessTree(rawProcess, processGroupId, 'SIGKILL');
479
1627
  } catch { /* expected: process may already be dead */
480
1628
  // already dead -- ignore
481
1629
  }
@@ -483,17 +1631,24 @@ export function killTerminal(handle) {
483
1631
 
484
1632
  handle.process.once('exit', (code) => {
485
1633
  clearTimeout(forceKillTimer);
486
- handle.status = 'killed';
487
- handle.exitCode = code;
488
- resolve({ killed: true, exitCode: code });
1634
+ if (!authorityState) {
1635
+ handle.status = 'killed';
1636
+ handle.exitCode = code;
1637
+ }
1638
+ try {
1639
+ if (processTreeIsRunning(rawProcess.pid, processGroupId)) {
1640
+ signalProcessTree(rawProcess, processGroupId, 'SIGKILL');
1641
+ }
1642
+ } catch { /* completion authority remains fail-closed if the tree cannot be observed */ }
1643
+ settle({ killed: true, exitCode: code });
489
1644
  });
490
1645
 
491
1646
  try {
492
- handle.process.kill('SIGTERM');
1647
+ signalProcessTree(rawProcess, processGroupId, 'SIGTERM');
493
1648
  } catch { /* expected: process may already be dead */
494
1649
  clearTimeout(forceKillTimer);
495
- handle.status = 'killed';
496
- resolve({ killed: false, exitCode: handle.exitCode });
1650
+ if (!authorityState) handle.status = 'killed';
1651
+ settle({ killed: false, exitCode: handle.exitCode });
497
1652
  }
498
1653
  });
499
1654
  }
@@ -554,7 +1709,8 @@ export async function spawnTerminalWithRetry(config, retryOptions = {}) {
554
1709
  const maxRetries = retryOptions.maxRetries ?? 2;
555
1710
  const baseDelay = retryOptions.baseDelay ?? 2000;
556
1711
  const shouldRetry = retryOptions.shouldRetry || isTransientFailure;
557
- const enableModelFallback = retryOptions.enableModelFallback ?? true;
1712
+ // Model substitution is authority-sensitive and therefore opt-in only.
1713
+ const enableModelFallback = retryOptions.enableModelFallback === true;
558
1714
 
559
1715
  let lastHandle = null;
560
1716
 
@@ -563,11 +1719,7 @@ export async function spawnTerminalWithRetry(config, retryOptions = {}) {
563
1719
  lastHandle = handle;
564
1720
 
565
1721
  // Wait for process to exit
566
- await new Promise((resolve) => {
567
- if (!handle.process) { resolve(); return; }
568
- if (handle.status !== 'running') { resolve(); return; }
569
- handle.process.once('exit', () => resolve());
570
- });
1722
+ await waitForTerminal(handle);
571
1723
 
572
1724
  // Success — return immediately
573
1725
  if (handle.exitCode === 0) {
@@ -597,11 +1749,7 @@ export async function spawnTerminalWithRetry(config, retryOptions = {}) {
597
1749
  const fallbackConfig = { ...config, model: fallbackModel };
598
1750
  const fallbackHandle = spawnTerminal(fallbackConfig);
599
1751
 
600
- await new Promise((resolve) => {
601
- if (!fallbackHandle.process) { resolve(); return; }
602
- if (fallbackHandle.status !== 'running') { resolve(); return; }
603
- fallbackHandle.process.once('exit', () => resolve());
604
- });
1752
+ await waitForTerminal(fallbackHandle);
605
1753
 
606
1754
  // Record fallback metadata
607
1755
  fallbackHandle.modelFallback = {