@guilz-dev/belay 0.8.0 → 0.9.0

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 (111) hide show
  1. package/README.md +60 -10
  2. package/dist/adapters/shared/gate-runtime.d.ts +5 -0
  3. package/dist/adapters/shared/gate-runtime.js +237 -33
  4. package/dist/bundle/claude-runtime.mjs +4082 -1230
  5. package/dist/bundle/codex-runtime.mjs +4057 -1205
  6. package/dist/bundle/cursor-runtime.mjs +4057 -1205
  7. package/dist/commands/classify-for-report.js +5 -0
  8. package/dist/commands/doctor.js +90 -27
  9. package/dist/commands/explain.js +3 -2
  10. package/dist/commands/metrics.js +75 -4
  11. package/dist/commands/quality.js +2 -6
  12. package/dist/commands/recovery-checkpoints.d.ts +8 -1
  13. package/dist/commands/recovery-checkpoints.js +44 -11
  14. package/dist/commands/session.d.ts +5 -0
  15. package/dist/commands/session.js +8 -1
  16. package/dist/commands/status.js +1 -1
  17. package/dist/config-io.js +2 -2
  18. package/dist/conformance/contained-execution-guarantee.d.ts +121 -0
  19. package/dist/conformance/contained-execution-guarantee.js +121 -0
  20. package/dist/conformance/guarantee-table.js +7 -0
  21. package/dist/core/audit-io.js +8 -0
  22. package/dist/core/audit-metrics.d.ts +34 -0
  23. package/dist/core/audit-metrics.js +87 -15
  24. package/dist/core/audit-recovery-metrics.d.ts +33 -0
  25. package/dist/core/audit-recovery-metrics.js +146 -0
  26. package/dist/core/audit-types.d.ts +7 -1
  27. package/dist/core/audit-types.js +1 -1
  28. package/dist/core/bounded-output.d.ts +11 -0
  29. package/dist/core/bounded-output.js +26 -0
  30. package/dist/core/capability/attestation.d.ts +56 -0
  31. package/dist/core/capability/attestation.js +110 -3
  32. package/dist/core/capability/boundary-session.d.ts +5 -0
  33. package/dist/core/capability/boundary-session.js +38 -1
  34. package/dist/core/capability/index.d.ts +1 -1
  35. package/dist/core/capability/index.js +1 -1
  36. package/dist/core/config.d.ts +18 -0
  37. package/dist/core/config.js +89 -10
  38. package/dist/core/contained-execution/docker-policy.d.ts +18 -0
  39. package/dist/core/contained-execution/docker-policy.js +97 -0
  40. package/dist/core/contained-execution/docker.d.ts +223 -0
  41. package/dist/core/contained-execution/docker.js +846 -0
  42. package/dist/core/contained-execution/eligibility.d.ts +9 -0
  43. package/dist/core/contained-execution/eligibility.js +103 -0
  44. package/dist/core/contained-execution/failure.d.ts +18 -0
  45. package/dist/core/contained-execution/failure.js +105 -0
  46. package/dist/core/contained-execution/mirror.d.ts +54 -0
  47. package/dist/core/contained-execution/mirror.js +587 -0
  48. package/dist/core/contained-execution/policy.d.ts +10 -0
  49. package/dist/core/contained-execution/policy.js +20 -0
  50. package/dist/core/effect-ir/index.d.ts +1 -1
  51. package/dist/core/effect-ir/index.js +1 -1
  52. package/dist/core/effect-ir/normalize.d.ts +5 -0
  53. package/dist/core/effect-ir/normalize.js +17 -7
  54. package/dist/core/effect-ir/shell-build.js +2 -14
  55. package/dist/core/effect-ir/shell-lower.js +100 -25
  56. package/dist/core/gate-contract.d.ts +4 -1
  57. package/dist/core/gate-contract.js +2 -0
  58. package/dist/core/process-runner.d.ts +10 -3
  59. package/dist/core/process-runner.js +66 -10
  60. package/dist/core/recovery/checkpoint.d.ts +3 -0
  61. package/dist/core/recovery/checkpoint.js +33 -4
  62. package/dist/core/recovery/types.d.ts +1 -0
  63. package/dist/core/scrub.d.ts +17 -0
  64. package/dist/core/scrub.js +499 -5
  65. package/dist/core/standing-allow.d.ts +0 -20
  66. package/dist/core/standing-allow.js +0 -71
  67. package/dist/core/transactional/apply-observed-changes.d.ts +1 -0
  68. package/dist/core/transactional/apply-observed-changes.js +11 -0
  69. package/dist/core/transactional/backend-selector.d.ts +2 -1
  70. package/dist/core/transactional/backend-selector.js +32 -29
  71. package/dist/core/transactional/backend.d.ts +3 -0
  72. package/dist/core/transactional/file-checkpoint-backend.js +242 -54
  73. package/dist/core/transactional/file-checkpoint-git.d.ts +3 -0
  74. package/dist/core/transactional/file-checkpoint-git.js +53 -0
  75. package/dist/core/transactional/file-checkpoint-isolation.d.ts +3 -0
  76. package/dist/core/transactional/file-checkpoint-isolation.js +16 -0
  77. package/dist/core/transactional/git-worktree-backend.js +3 -0
  78. package/dist/core/transactional/git-worktree.d.ts +1 -0
  79. package/dist/core/transactional/git-worktree.js +1 -0
  80. package/dist/core/transactional/runner.js +59 -13
  81. package/dist/core/types.d.ts +13 -0
  82. package/dist/core/verdict/parser.d.ts +10 -0
  83. package/dist/core/verdict/parser.js +271 -40
  84. package/dist/core/verdict/shell-semantics.js +21 -0
  85. package/dist/corpus/adversarial-probe.d.ts +1 -1
  86. package/dist/corpus/adversarial-probe.js +3 -3
  87. package/dist/corpus/benign-probe-cores.d.ts +6 -0
  88. package/dist/corpus/{must-allow-commands.js → benign-probe-cores.js} +8 -2
  89. package/dist/corpus/coverage-compare.d.ts +36 -0
  90. package/dist/corpus/coverage-compare.js +197 -0
  91. package/dist/corpus/coverage-contexts.d.ts +17 -0
  92. package/dist/corpus/coverage-contexts.js +88 -0
  93. package/dist/corpus/coverage-matrix.d.ts +37 -0
  94. package/dist/corpus/coverage-matrix.js +159 -0
  95. package/dist/corpus/coverage-probe.d.ts +91 -0
  96. package/dist/corpus/coverage-probe.js +341 -0
  97. package/dist/corpus/evaluate.d.ts +1 -1
  98. package/dist/corpus/evaluate.js +1 -1
  99. package/dist/corpus/structural-fixture-root.d.ts +2 -0
  100. package/dist/corpus/structural-fixture-root.js +5 -0
  101. package/dist/operational-insights.d.ts +3 -0
  102. package/dist/operational-insights.js +7 -3
  103. package/dist/runtime-provenance.d.ts +8 -0
  104. package/dist/runtime-provenance.js +34 -0
  105. package/dist/types.d.ts +3 -0
  106. package/dist/version.d.ts +1 -1
  107. package/dist/version.js +1 -1
  108. package/package.json +3 -1
  109. package/skills/belay/SKILL.md +19 -0
  110. package/skills/belay/belay-status.md +5 -0
  111. package/dist/corpus/must-allow-commands.d.ts +0 -6
@@ -0,0 +1,587 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { constants as fsConstants } from 'node:fs';
3
+ import { chmod, lstat, mkdir, mkdtemp, open, opendir, readlink, realpath, rm, symlink, } from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { inspectGitResourceIdentity } from '../git-resource-identity.js';
7
+ import { canonicalPath } from '../path-utils.js';
8
+ import { computeTreeHash, FILE_CHECKPOINT_HARDLINK_UNSUPPORTED, FILE_CHECKPOINT_PREPARE_TIMEOUT, FILE_CHECKPOINT_QUOTA_EXCEEDED, FILE_CHECKPOINT_UNSUPPORTED_NODE, FileCheckpointDiagnosticError, } from '../transactional/file-tree.js';
9
+ import { compareRelativePathsBytewise, joinRelativePath } from '../transactional/file-tree-path.js';
10
+ import { hashDirectoryNode, hashFileNode, hashSymlinkTarget, } from '../transactional/snapshot-node.js';
11
+ import { ContainedExecutionFailureError } from './failure.js';
12
+ export const CONTAINED_EXECUTION_CLEANUP_UNCONFIRMED = 'contained_execution_cleanup_unconfirmed';
13
+ export const CONTAINED_EXECUTION_SOURCE_CHANGED = 'contained_execution_source_changed';
14
+ export const CONTAINED_EXECUTION_UNSAFE_SYMLINK = 'contained_execution_unsafe_symlink';
15
+ export class ContainedExecutionCleanupUnconfirmedError extends ContainedExecutionFailureError {
16
+ mirrorRoot;
17
+ constructor(mirrorRoot, options) {
18
+ super(CONTAINED_EXECUTION_CLEANUP_UNCONFIRMED, options);
19
+ this.mirrorRoot = mirrorRoot;
20
+ this.name = 'ContainedExecutionCleanupUnconfirmedError';
21
+ }
22
+ }
23
+ function canonicalRootSet(roots) {
24
+ return [...new Set(roots.map(canonicalPath))].sort();
25
+ }
26
+ const mirrorLeases = new WeakMap();
27
+ /** Internal execution-boundary check; the public core index deliberately does not re-export it. */
28
+ export function validateContainedExecutionMirrorLease(handle, expected) {
29
+ const lease = mirrorLeases.get(handle);
30
+ const protectedRoots = canonicalRootSet(expected.protectedRoots);
31
+ return Boolean(lease?.live &&
32
+ lease.sourceRoot === canonicalPath(expected.sourceRoot) &&
33
+ lease.hostMirrorRoot === handle.hostMirrorRoot &&
34
+ lease.guestWorkspacePath === handle.guestWorkspacePath &&
35
+ lease.backend === handle.backend &&
36
+ lease.protectedRoots.length === protectedRoots.length &&
37
+ lease.protectedRoots.every((root, index) => root === protectedRoots[index]));
38
+ }
39
+ const productionDependencies = {
40
+ makeTempRoot: () => mkdtemp(path.join(os.tmpdir(), 'belay-contained-mirror-')),
41
+ removeRoot: (root) => rm(root, { recursive: true, force: true }),
42
+ now: () => Date.now(),
43
+ };
44
+ function dependenciesForTests(overrides) {
45
+ return {
46
+ makeTempRoot: overrides.makeTempRoot ?? productionDependencies.makeTempRoot,
47
+ removeRoot: overrides.removeRoot ?? productionDependencies.removeRoot,
48
+ now: overrides.now ?? productionDependencies.now,
49
+ afterSnapshotCaptured: overrides.afterSnapshotCaptured,
50
+ beforeCopyOpen: overrides.beforeCopyOpen,
51
+ afterCopyRead: overrides.afterCopyRead,
52
+ };
53
+ }
54
+ function isAtOrWithin(root, target) {
55
+ const relative = path.relative(root, target);
56
+ return (relative === '' ||
57
+ (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`)));
58
+ }
59
+ function isGitMetadataRelativePath(relativePath) {
60
+ return relativePath.split(path.sep).some((segment) => segment.toLowerCase() === '.git');
61
+ }
62
+ function identityFromStats(stats) {
63
+ return {
64
+ dev: stats.dev,
65
+ ino: stats.ino,
66
+ mode: stats.mode,
67
+ size: stats.size,
68
+ mtimeNs: stats.mtimeNs,
69
+ ctimeNs: stats.ctimeNs,
70
+ };
71
+ }
72
+ function identitiesEqual(left, right) {
73
+ return (left.dev === right.dev &&
74
+ left.ino === right.ino &&
75
+ left.mode === right.mode &&
76
+ left.size === right.size &&
77
+ left.mtimeNs === right.mtimeNs &&
78
+ left.ctimeNs === right.ctimeNs);
79
+ }
80
+ function safePermissionMode(mode) {
81
+ return Number(mode) & 0o777;
82
+ }
83
+ function assertDeadline(deadlineMs, now) {
84
+ if (now() > deadlineMs) {
85
+ throw new FileCheckpointDiagnosticError(FILE_CHECKPOINT_PREPARE_TIMEOUT, `mirror preparation exceeded deadlineMs=${deadlineMs}`);
86
+ }
87
+ }
88
+ function throwQuota(detail) {
89
+ throw new FileCheckpointDiagnosticError(FILE_CHECKPOINT_QUOTA_EXCEEDED, detail);
90
+ }
91
+ function addSnapshotNode(context) {
92
+ context.fileCount += 1;
93
+ if (context.fileCount > context.limits.maxFiles) {
94
+ throwQuota(`mirror nodeCount=${context.fileCount} exceeds maxFiles=${context.limits.maxFiles}`);
95
+ }
96
+ }
97
+ function addSourceBytes(context, bytes) {
98
+ context.sourceBytes += bytes;
99
+ if (context.sourceBytes > context.limits.maxSourceBytes) {
100
+ throwQuota(`mirror sourceBytes=${context.sourceBytes} exceeds maxSourceBytes=${context.limits.maxSourceBytes}`);
101
+ }
102
+ }
103
+ function addWorkspaceBytes(context, bytes) {
104
+ context.workspaceBytes += bytes;
105
+ if (context.workspaceBytes > context.limits.maxWorkspaceBytes) {
106
+ throwQuota(`mirror workspaceBytes=${context.workspaceBytes} exceeds maxWorkspaceBytes=${context.limits.maxWorkspaceBytes}`);
107
+ }
108
+ }
109
+ function safeReadFlags() {
110
+ if (fsConstants.O_NOFOLLOW === undefined || fsConstants.O_NONBLOCK === undefined) {
111
+ throw new ContainedExecutionFailureError('contained_execution_safe_open_unavailable');
112
+ }
113
+ return fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK;
114
+ }
115
+ function assertRegularSingleLink(stats) {
116
+ if (!stats.isFile()) {
117
+ throw new Error(FILE_CHECKPOINT_UNSUPPORTED_NODE);
118
+ }
119
+ if (stats.nlink > 1) {
120
+ throw new Error(FILE_CHECKPOINT_HARDLINK_UNSUPPORTED);
121
+ }
122
+ }
123
+ function assertRegularSingleLinkBigInt(stats) {
124
+ if (!stats.isFile()) {
125
+ throw new Error(FILE_CHECKPOINT_UNSUPPORTED_NODE);
126
+ }
127
+ if (stats.nlink > 1n) {
128
+ throw new Error(FILE_CHECKPOINT_HARDLINK_UNSUPPORTED);
129
+ }
130
+ }
131
+ async function readStableFile(absolutePath, context) {
132
+ assertDeadline(context.deadlineMs, context.now);
133
+ const before = await lstat(absolutePath, { bigint: true });
134
+ if (!before.isFile()) {
135
+ throw new Error(FILE_CHECKPOINT_UNSUPPORTED_NODE);
136
+ }
137
+ if (before.nlink > 1n) {
138
+ throw new Error(FILE_CHECKPOINT_HARDLINK_UNSUPPORTED);
139
+ }
140
+ const source = await open(absolutePath, safeReadFlags());
141
+ try {
142
+ const opened = await source.stat({ bigint: true });
143
+ assertRegularSingleLinkBigInt(opened);
144
+ const openedIdentity = identityFromStats(opened);
145
+ if (!identitiesEqual(identityFromStats(before), openedIdentity)) {
146
+ throw new ContainedExecutionFailureError(CONTAINED_EXECUTION_SOURCE_CHANGED);
147
+ }
148
+ const hash = createHash('sha256');
149
+ const buffer = Buffer.allocUnsafe(64 * 1024);
150
+ let position = 0;
151
+ while (true) {
152
+ assertDeadline(context.deadlineMs, context.now);
153
+ const { bytesRead } = await source.read(buffer, 0, buffer.length, position);
154
+ if (bytesRead === 0) {
155
+ break;
156
+ }
157
+ addSourceBytes(context, bytesRead);
158
+ hash.update(buffer.subarray(0, bytesRead));
159
+ position += bytesRead;
160
+ }
161
+ const after = await source.stat({ bigint: true });
162
+ assertRegularSingleLinkBigInt(after);
163
+ if (!identitiesEqual(openedIdentity, identityFromStats(after)) ||
164
+ BigInt(position) !== after.size) {
165
+ throw new ContainedExecutionFailureError(CONTAINED_EXECUTION_SOURCE_CHANGED);
166
+ }
167
+ const mode = Number(after.mode);
168
+ return {
169
+ node: {
170
+ kind: 'file',
171
+ mode,
172
+ size: position,
173
+ hash: hashFileNode(mode, hash.digest('hex')),
174
+ },
175
+ identity: openedIdentity,
176
+ };
177
+ }
178
+ finally {
179
+ await source.close();
180
+ }
181
+ }
182
+ function addMetadataRoots(context, directoryPath) {
183
+ const inspection = inspectGitResourceIdentity(directoryPath);
184
+ if (inspection.status === 'invalid') {
185
+ for (const root of inspection.metadataRoots) {
186
+ context.metadataRoots.add(canonicalPath(root));
187
+ }
188
+ return;
189
+ }
190
+ if (inspection.status === 'resolved' &&
191
+ canonicalPath(inspection.identity.repositoryRoot) === canonicalPath(directoryPath)) {
192
+ context.metadataRoots.add(canonicalPath(inspection.identity.gitEntryPath));
193
+ context.metadataRoots.add(canonicalPath(inspection.identity.gitDir));
194
+ context.metadataRoots.add(canonicalPath(inspection.identity.commonDir));
195
+ }
196
+ }
197
+ function pathMatchesRoots(absolutePath, roots) {
198
+ const lexical = path.resolve(absolutePath);
199
+ const canonical = canonicalPath(absolutePath);
200
+ for (const root of roots) {
201
+ if (isAtOrWithin(root, lexical) || isAtOrWithin(root, canonical)) {
202
+ return true;
203
+ }
204
+ }
205
+ return false;
206
+ }
207
+ function pathLexicallyMatchesRoots(absolutePath, roots) {
208
+ const lexical = path.resolve(absolutePath);
209
+ for (const root of roots) {
210
+ if (isAtOrWithin(root, lexical)) {
211
+ return true;
212
+ }
213
+ }
214
+ return false;
215
+ }
216
+ function isLexicallyExcludedLocation(absolutePath, relativePath, context) {
217
+ return (isGitMetadataRelativePath(relativePath) ||
218
+ pathLexicallyMatchesRoots(absolutePath, context.protectedRoots) ||
219
+ pathLexicallyMatchesRoots(absolutePath, context.metadataRoots));
220
+ }
221
+ function isExcludedLocation(absolutePath, relativePath, context) {
222
+ return (isGitMetadataRelativePath(relativePath) ||
223
+ pathMatchesRoots(absolutePath, context.protectedRoots) ||
224
+ pathMatchesRoots(absolutePath, context.metadataRoots));
225
+ }
226
+ async function readSafeSymlink(absolutePath, context) {
227
+ const before = await lstat(absolutePath, { bigint: true });
228
+ if (!before.isSymbolicLink()) {
229
+ throw new ContainedExecutionFailureError(CONTAINED_EXECUTION_SOURCE_CHANGED);
230
+ }
231
+ const identity = identityFromStats(before);
232
+ const target = await readlink(absolutePath);
233
+ if (path.isAbsolute(target)) {
234
+ throw new ContainedExecutionFailureError(CONTAINED_EXECUTION_UNSAFE_SYMLINK);
235
+ }
236
+ const lexicalTarget = path.resolve(path.dirname(absolutePath), target);
237
+ if (!isAtOrWithin(context.sourceRoot, lexicalTarget) ||
238
+ isGitMetadataRelativePath(path.relative(context.sourceRoot, lexicalTarget)) ||
239
+ pathMatchesRoots(lexicalTarget, context.protectedRoots) ||
240
+ pathMatchesRoots(lexicalTarget, context.metadataRoots)) {
241
+ throw new ContainedExecutionFailureError(CONTAINED_EXECUTION_UNSAFE_SYMLINK);
242
+ }
243
+ let resolvedTarget;
244
+ try {
245
+ resolvedTarget = await realpath(absolutePath);
246
+ }
247
+ catch {
248
+ throw new ContainedExecutionFailureError(CONTAINED_EXECUTION_UNSAFE_SYMLINK);
249
+ }
250
+ if (!isAtOrWithin(context.sourceRoot, resolvedTarget) ||
251
+ pathMatchesRoots(resolvedTarget, context.protectedRoots) ||
252
+ pathMatchesRoots(resolvedTarget, context.metadataRoots)) {
253
+ throw new ContainedExecutionFailureError(CONTAINED_EXECUTION_UNSAFE_SYMLINK);
254
+ }
255
+ const after = await lstat(absolutePath, { bigint: true });
256
+ const targetAfter = await readlink(absolutePath);
257
+ if (!after.isSymbolicLink() ||
258
+ !identitiesEqual(identity, identityFromStats(after)) ||
259
+ targetAfter !== target) {
260
+ throw new ContainedExecutionFailureError(CONTAINED_EXECUTION_SOURCE_CHANGED);
261
+ }
262
+ return {
263
+ node: { kind: 'symlink', target, hash: hashSymlinkTarget(target) },
264
+ identity,
265
+ };
266
+ }
267
+ async function walkDirectory(relativeDirectory, entries, context) {
268
+ assertDeadline(context.deadlineMs, context.now);
269
+ const absoluteDirectory = relativeDirectory
270
+ ? joinRelativePath(context.sourceRoot, relativeDirectory)
271
+ : context.sourceRoot;
272
+ addMetadataRoots(context, absoluteDirectory);
273
+ const before = await lstat(absoluteDirectory, { bigint: true });
274
+ if (!before.isDirectory() || before.isSymbolicLink()) {
275
+ throw new ContainedExecutionFailureError(CONTAINED_EXECUTION_SOURCE_CHANGED);
276
+ }
277
+ const beforeIdentity = identityFromStats(before);
278
+ const resolvedDirectory = await realpath(absoluteDirectory);
279
+ if (!isAtOrWithin(context.sourceRoot, resolvedDirectory)) {
280
+ throw new ContainedExecutionFailureError(CONTAINED_EXECUTION_SOURCE_CHANGED);
281
+ }
282
+ const directoryFlags = safeReadFlags() | (fsConstants.O_DIRECTORY ?? 0);
283
+ const directory = await open(absoluteDirectory, directoryFlags);
284
+ try {
285
+ const opened = await directory.stat({ bigint: true });
286
+ if (!opened.isDirectory() || !identitiesEqual(beforeIdentity, identityFromStats(opened))) {
287
+ throw new ContainedExecutionFailureError(CONTAINED_EXECUTION_SOURCE_CHANGED);
288
+ }
289
+ const entriesDirectory = await opendir(absoluteDirectory, { bufferSize: 32 });
290
+ for await (const directoryEntry of entriesDirectory) {
291
+ assertDeadline(context.deadlineMs, context.now);
292
+ const relativePath = relativeDirectory
293
+ ? path.join(relativeDirectory, directoryEntry.name)
294
+ : directoryEntry.name;
295
+ const absolutePath = joinRelativePath(context.sourceRoot, relativePath);
296
+ const info = await lstat(absolutePath);
297
+ if (info.isSymbolicLink()) {
298
+ if (isLexicallyExcludedLocation(absolutePath, relativePath, context)) {
299
+ continue;
300
+ }
301
+ addSnapshotNode(context);
302
+ const captured = await readSafeSymlink(absolutePath, context);
303
+ entries.push({ relativePath, ...captured });
304
+ continue;
305
+ }
306
+ if (isExcludedLocation(absolutePath, relativePath, context)) {
307
+ continue;
308
+ }
309
+ if (info.isDirectory()) {
310
+ addSnapshotNode(context);
311
+ const node = {
312
+ kind: 'directory',
313
+ mode: info.mode,
314
+ hash: hashDirectoryNode(info.mode),
315
+ };
316
+ entries.push({
317
+ relativePath,
318
+ node,
319
+ identity: identityFromStats(await lstat(absolutePath, { bigint: true })),
320
+ });
321
+ await walkDirectory(relativePath, entries, context);
322
+ continue;
323
+ }
324
+ if (info.isFile()) {
325
+ assertRegularSingleLink(info);
326
+ addSnapshotNode(context);
327
+ const captured = await readStableFile(absolutePath, context);
328
+ entries.push({ relativePath, ...captured });
329
+ continue;
330
+ }
331
+ throw new Error(FILE_CHECKPOINT_UNSUPPORTED_NODE);
332
+ }
333
+ const after = await directory.stat({ bigint: true });
334
+ const pathAfter = await lstat(absoluteDirectory, { bigint: true });
335
+ if (!identitiesEqual(beforeIdentity, identityFromStats(after)) ||
336
+ !identitiesEqual(beforeIdentity, identityFromStats(pathAfter))) {
337
+ throw new ContainedExecutionFailureError(CONTAINED_EXECUTION_SOURCE_CHANGED);
338
+ }
339
+ }
340
+ finally {
341
+ await directory.close();
342
+ }
343
+ }
344
+ async function buildSafeMirrorSnapshot(sourceRoot, protectedRoots, limits, deadlineMs, now) {
345
+ const canonicalSourceRoot = await realpath(sourceRoot);
346
+ const rootInfo = await lstat(canonicalSourceRoot);
347
+ if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink()) {
348
+ throw new ContainedExecutionFailureError('contained_execution_source_not_directory');
349
+ }
350
+ const context = {
351
+ sourceRoot: canonicalSourceRoot,
352
+ protectedRoots,
353
+ metadataRoots: new Set(),
354
+ limits,
355
+ deadlineMs,
356
+ now,
357
+ fileCount: 0,
358
+ sourceBytes: 0,
359
+ };
360
+ const entries = [];
361
+ await walkDirectory('', entries, context);
362
+ entries.sort((left, right) => compareRelativePathsBytewise(left.relativePath, right.relativePath));
363
+ return { entries, treeHash: computeTreeHash(entries) };
364
+ }
365
+ function assertEntryIdentity(entry, identity) {
366
+ if (!identitiesEqual(entry.identity, identity)) {
367
+ throw new ContainedExecutionFailureError(CONTAINED_EXECUTION_SOURCE_CHANGED);
368
+ }
369
+ }
370
+ async function copyStableRegularFile(sourcePath, destinationPath, entry, context) {
371
+ const before = await lstat(sourcePath, { bigint: true });
372
+ if (!before.isFile()) {
373
+ throw new Error(FILE_CHECKPOINT_UNSUPPORTED_NODE);
374
+ }
375
+ if (before.nlink > 1n) {
376
+ throw new Error(FILE_CHECKPOINT_HARDLINK_UNSUPPORTED);
377
+ }
378
+ assertEntryIdentity(entry, identityFromStats(before));
379
+ await context.beforeCopyOpen?.(sourcePath);
380
+ const source = await open(sourcePath, safeReadFlags());
381
+ let destination;
382
+ try {
383
+ const opened = await source.stat({ bigint: true });
384
+ assertRegularSingleLinkBigInt(opened);
385
+ const openedIdentity = identityFromStats(opened);
386
+ assertEntryIdentity(entry, openedIdentity);
387
+ if (entry.node.kind !== 'file') {
388
+ throw new ContainedExecutionFailureError(CONTAINED_EXECUTION_SOURCE_CHANGED);
389
+ }
390
+ await mkdir(path.dirname(destinationPath), { recursive: true, mode: 0o700 });
391
+ destination = await open(destinationPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, safePermissionMode(opened.mode));
392
+ const hash = createHash('sha256');
393
+ const buffer = Buffer.allocUnsafe(64 * 1024);
394
+ let position = 0;
395
+ while (true) {
396
+ assertDeadline(context.deadlineMs, context.now);
397
+ const { bytesRead } = await source.read(buffer, 0, buffer.length, position);
398
+ if (bytesRead === 0) {
399
+ break;
400
+ }
401
+ addSourceBytes(context, bytesRead);
402
+ addWorkspaceBytes(context, bytesRead);
403
+ hash.update(buffer.subarray(0, bytesRead));
404
+ let written = 0;
405
+ while (written < bytesRead) {
406
+ const result = await destination.write(buffer, written, bytesRead - written, position + written);
407
+ written += result.bytesWritten;
408
+ }
409
+ position += bytesRead;
410
+ await context.afterCopyRead?.(sourcePath, bytesRead, position);
411
+ }
412
+ const after = await source.stat({ bigint: true });
413
+ assertRegularSingleLinkBigInt(after);
414
+ if (!identitiesEqual(openedIdentity, identityFromStats(after)) ||
415
+ BigInt(position) !== after.size ||
416
+ hashFileNode(Number(after.mode), hash.digest('hex')) !== entry.node.hash) {
417
+ throw new ContainedExecutionFailureError(CONTAINED_EXECUTION_SOURCE_CHANGED);
418
+ }
419
+ await destination.sync();
420
+ }
421
+ finally {
422
+ await Promise.allSettled([source.close(), destination?.close() ?? Promise.resolve()]);
423
+ }
424
+ if (entry.node.kind !== 'file') {
425
+ throw new ContainedExecutionFailureError(CONTAINED_EXECUTION_SOURCE_CHANGED);
426
+ }
427
+ await chmod(destinationPath, safePermissionMode(entry.node.mode));
428
+ }
429
+ async function materializeSnapshot(sourceRoot, destinationRoot, snapshot, context) {
430
+ const directoryEntries = [];
431
+ for (const entry of snapshot.entries) {
432
+ assertDeadline(context.deadlineMs, context.now);
433
+ const sourcePath = joinRelativePath(sourceRoot, entry.relativePath);
434
+ const destinationPath = joinRelativePath(destinationRoot, entry.relativePath);
435
+ if (entry.node.kind === 'directory') {
436
+ await mkdir(destinationPath, { recursive: true, mode: 0o700 });
437
+ directoryEntries.push(entry);
438
+ continue;
439
+ }
440
+ if (entry.node.kind === 'symlink') {
441
+ addWorkspaceBytes(context, Buffer.byteLength(entry.node.target));
442
+ const current = await readlink(sourcePath);
443
+ const currentInfo = await lstat(sourcePath, { bigint: true });
444
+ assertEntryIdentity(entry, identityFromStats(currentInfo));
445
+ if (current !== entry.node.target) {
446
+ throw new ContainedExecutionFailureError(CONTAINED_EXECUTION_SOURCE_CHANGED);
447
+ }
448
+ await mkdir(path.dirname(destinationPath), { recursive: true, mode: 0o700 });
449
+ await symlink(entry.node.target, destinationPath);
450
+ continue;
451
+ }
452
+ await copyStableRegularFile(sourcePath, destinationPath, entry, context);
453
+ }
454
+ for (const entry of directoryEntries.reverse()) {
455
+ if (entry.node.kind !== 'directory') {
456
+ throw new ContainedExecutionFailureError(CONTAINED_EXECUTION_SOURCE_CHANGED);
457
+ }
458
+ await chmod(joinRelativePath(destinationRoot, entry.relativePath), safePermissionMode(entry.node.mode));
459
+ }
460
+ await chmod(destinationRoot, 0o700);
461
+ }
462
+ async function assertStableCopiedSnapshot(sourceRoot, destinationRoot, protectedRoots, limits, deadlineMs, now, before) {
463
+ const [after, copied] = await Promise.all([
464
+ buildSafeMirrorSnapshot(sourceRoot, protectedRoots, limits, deadlineMs, now),
465
+ buildSafeMirrorSnapshot(destinationRoot, [], limits, deadlineMs, now),
466
+ ]);
467
+ if (before.treeHash !== after.treeHash || before.treeHash !== copied.treeHash) {
468
+ throw new ContainedExecutionFailureError(CONTAINED_EXECUTION_SOURCE_CHANGED);
469
+ }
470
+ }
471
+ function validateOptions(options) {
472
+ if (!Array.isArray(options.controlPlaneRoots)) {
473
+ throw new ContainedExecutionFailureError('contained_execution_control_plane_roots_required');
474
+ }
475
+ const values = [
476
+ options.limits?.maxFiles,
477
+ options.limits?.maxSourceBytes,
478
+ options.limits?.maxWorkspaceBytes,
479
+ options.limits?.prepareTimeoutMs,
480
+ ];
481
+ if (values.some((value) => !Number.isSafeInteger(value) || (value ?? 0) <= 0)) {
482
+ throw new ContainedExecutionFailureError('contained_execution_mirror_limits_invalid');
483
+ }
484
+ }
485
+ async function pathIsAbsent(targetPath) {
486
+ try {
487
+ await lstat(targetPath);
488
+ return false;
489
+ }
490
+ catch (error) {
491
+ return error.code === 'ENOENT';
492
+ }
493
+ }
494
+ async function cleanupOwnedRoot(root, dependencies) {
495
+ try {
496
+ await dependencies.removeRoot(root);
497
+ }
498
+ catch (error) {
499
+ throw new ContainedExecutionCleanupUnconfirmedError(root, { cause: error });
500
+ }
501
+ if (!(await pathIsAbsent(root))) {
502
+ throw new ContainedExecutionCleanupUnconfirmedError(root);
503
+ }
504
+ }
505
+ async function prepareWithDependencies(options, dependencies) {
506
+ validateOptions(options);
507
+ const guestWorkspacePath = path.resolve(options.sourceRoot);
508
+ const sourceRoot = canonicalPath(options.sourceRoot);
509
+ const protectedRoots = options.controlPlaneRoots.map((root) => canonicalPath(root));
510
+ if (pathMatchesRoots(sourceRoot, protectedRoots)) {
511
+ throw new ContainedExecutionFailureError('contained_execution_source_is_protected');
512
+ }
513
+ const guestRoot = await dependencies.makeTempRoot();
514
+ try {
515
+ await chmod(guestRoot, 0o700);
516
+ const deadlineMs = dependencies.now() + options.limits.prepareTimeoutMs;
517
+ const before = await buildSafeMirrorSnapshot(sourceRoot, protectedRoots, options.limits, deadlineMs, dependencies.now);
518
+ await dependencies.afterSnapshotCaptured?.();
519
+ await materializeSnapshot(sourceRoot, guestRoot, before, {
520
+ limits: options.limits,
521
+ deadlineMs,
522
+ now: dependencies.now,
523
+ sourceBytes: 0,
524
+ workspaceBytes: 0,
525
+ beforeCopyOpen: dependencies.beforeCopyOpen,
526
+ afterCopyRead: dependencies.afterCopyRead,
527
+ });
528
+ await assertStableCopiedSnapshot(sourceRoot, guestRoot, protectedRoots, options.limits, deadlineMs, dependencies.now, before);
529
+ await chmod(guestRoot, 0o700);
530
+ const lease = {
531
+ sourceRoot,
532
+ hostMirrorRoot: guestRoot,
533
+ guestWorkspacePath,
534
+ backend: 'file_copy',
535
+ protectedRoots: canonicalRootSet(protectedRoots),
536
+ live: true,
537
+ };
538
+ const handle = {
539
+ hostMirrorRoot: guestRoot,
540
+ guestWorkspacePath,
541
+ backend: 'file_copy',
542
+ async cleanup() {
543
+ lease.live = false;
544
+ await cleanupOwnedRoot(guestRoot, dependencies);
545
+ },
546
+ };
547
+ mirrorLeases.set(handle, lease);
548
+ return handle;
549
+ }
550
+ catch (error) {
551
+ try {
552
+ await cleanupOwnedRoot(guestRoot, dependencies);
553
+ }
554
+ catch (cleanupError) {
555
+ throw new ContainedExecutionCleanupUnconfirmedError(guestRoot, { cause: cleanupError });
556
+ }
557
+ throw error;
558
+ }
559
+ }
560
+ /**
561
+ * Non-atomic acceptance rule: the current source tree is read, copied, then read again. A mirror
562
+ * is returned only when both source observations and the copied tree have the same content/mode
563
+ * hash. This detects ordinary concurrent changes but does not claim an atomic filesystem snapshot.
564
+ */
565
+ export function prepareContainedExecutionMirror(options) {
566
+ return prepareWithDependencies(options, productionDependencies);
567
+ }
568
+ /** Narrow test seam for deterministic allocation, race, deadline, and removal failures. */
569
+ export function prepareContainedExecutionMirrorForTests(options, overrides) {
570
+ return prepareWithDependencies(options, dependenciesForTests(overrides));
571
+ }
572
+ async function withMirror(options, operation, dependencies) {
573
+ const mirror = await prepareWithDependencies(options, dependencies);
574
+ try {
575
+ return await operation(mirror);
576
+ }
577
+ finally {
578
+ await mirror.cleanup();
579
+ }
580
+ }
581
+ export function withContainedExecutionMirror(options, operation) {
582
+ return withMirror(options, operation, productionDependencies);
583
+ }
584
+ /** Narrow test seam matching prepareContainedExecutionMirrorForTests. */
585
+ export function withContainedExecutionMirrorForTests(options, operation, overrides) {
586
+ return withMirror(options, operation, dependenciesForTests(overrides));
587
+ }
@@ -0,0 +1,10 @@
1
+ import type { ScrubOptions } from '../types.js';
2
+ /**
3
+ * The only pre-execution failures that may return to the ordinary one-shot approval path.
4
+ * Every other contained-execution failure is terminal and denies the original host command.
5
+ */
6
+ export declare const CONTAINED_EXECUTION_APPROVAL_FALLBACK_REASONS: readonly ["contained_execution_docker_substrate_unavailable", "contained_execution_docker_daemon_unavailable"];
7
+ export type ContainedExecutionApprovalFallbackReason = (typeof CONTAINED_EXECUTION_APPROVAL_FALLBACK_REASONS)[number];
8
+ export declare function isContainedExecutionApprovalFallbackReason(reason: string): reason is ContainedExecutionApprovalFallbackReason;
9
+ /** Contained guest output is a security boundary and cannot inherit user-disableable audit flags. */
10
+ export declare const CONTAINED_EXECUTION_OUTPUT_SCRUB_OPTIONS: Readonly<Required<ScrubOptions>>;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The only pre-execution failures that may return to the ordinary one-shot approval path.
3
+ * Every other contained-execution failure is terminal and denies the original host command.
4
+ */
5
+ export const CONTAINED_EXECUTION_APPROVAL_FALLBACK_REASONS = [
6
+ 'contained_execution_docker_substrate_unavailable',
7
+ 'contained_execution_docker_daemon_unavailable',
8
+ ];
9
+ const approvalFallbackReasons = new Set(CONTAINED_EXECUTION_APPROVAL_FALLBACK_REASONS);
10
+ export function isContainedExecutionApprovalFallbackReason(reason) {
11
+ return approvalFallbackReasons.has(reason);
12
+ }
13
+ /** Contained guest output is a security boundary and cannot inherit user-disableable audit flags. */
14
+ export const CONTAINED_EXECUTION_OUTPUT_SCRUB_OPTIONS = Object.freeze({
15
+ maskApprovalIds: true,
16
+ maskBearerTokens: true,
17
+ maskAuthHeaders: true,
18
+ maskKeyValueSecrets: true,
19
+ maskHighEntropyStrings: true,
20
+ });
@@ -1,7 +1,7 @@
1
1
  export type { GitRefScope, NetworkMode, NetworkPayload, ProcessOperation, } from '../capability/request.js';
2
2
  export { effectPlanAuditFields, hashEffectPlan } from './audit.js';
3
3
  export { buildCapabilityEffectPlan, buildEffectPlan, buildPackageExecEffectNode, collectRequirements, flattenRequirementsToCapabilityRequests, } from './build.js';
4
- export { mergeEffectPlans, mergeRequirements, normalizeEffectTags } from './normalize.js';
4
+ export { joinEffectOpacity, mergeEffectPlans, mergeRequirements, normalizeEffectTags, } from './normalize.js';
5
5
  export { innerRecipeFromPeel, isPackageExecLauncher, peelPackageExecArgv, resolveLocalBin, } from './package-exec.js';
6
6
  export { evaluatePackageExecSegment } from './package-exec-eval.js';
7
7
  export { capabilityRequestsFromEffectPlan, capabilityRequestsToEffectRequirements, effectPlanPolicyLegacyReason, effectPlanPolicyRequiresAsk, evaluateEffectPlanPolicy, } from './policy.js';
@@ -1,6 +1,6 @@
1
1
  export { effectPlanAuditFields, hashEffectPlan } from './audit.js';
2
2
  export { buildCapabilityEffectPlan, buildEffectPlan, buildPackageExecEffectNode, collectRequirements, flattenRequirementsToCapabilityRequests, } from './build.js';
3
- export { mergeEffectPlans, mergeRequirements, normalizeEffectTags } from './normalize.js';
3
+ export { joinEffectOpacity, mergeEffectPlans, mergeRequirements, normalizeEffectTags, } from './normalize.js';
4
4
  export { innerRecipeFromPeel, isPackageExecLauncher, peelPackageExecArgv, resolveLocalBin, } from './package-exec.js';
5
5
  export { evaluatePackageExecSegment } from './package-exec-eval.js';
6
6
  export { capabilityRequestsFromEffectPlan, capabilityRequestsToEffectRequirements, effectPlanPolicyLegacyReason, effectPlanPolicyRequiresAsk, evaluateEffectPlanPolicy, } from './policy.js';
@@ -1,4 +1,9 @@
1
1
  import type { EffectPlan, EffectRequirement, EffectTag } from './types.js';
2
2
  export declare function normalizeEffectTags(tags: Iterable<EffectTag>): readonly EffectTag[];
3
+ /**
4
+ * Joins opacity evidence without allowing a later analysis stage to weaken an
5
+ * earlier uncertainty classification.
6
+ */
7
+ export declare function joinEffectOpacity(...opacities: readonly EffectPlan['opacity'][]): EffectPlan['opacity'];
3
8
  export declare function mergeRequirements(...groups: readonly (readonly EffectRequirement[])[]): EffectRequirement[];
4
9
  export declare function mergeEffectPlans(...plans: readonly (EffectPlan | null | undefined)[]): EffectPlan | undefined;