@guilz-dev/belay 0.6.0 → 0.8.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 (183) hide show
  1. package/README.md +39 -20
  2. package/dist/adapters/claude/runtime-entry.js +54 -25
  3. package/dist/adapters/shared/gate-runtime.d.ts +7 -0
  4. package/dist/adapters/shared/gate-runtime.js +443 -225
  5. package/dist/bundle/claude-runtime.mjs +13027 -7271
  6. package/dist/bundle/codex-runtime.mjs +12700 -6977
  7. package/dist/bundle/cursor-runtime.mjs +12695 -6977
  8. package/dist/cli.js +37 -1
  9. package/dist/commands/approve.js +34 -10
  10. package/dist/commands/doctor.js +58 -0
  11. package/dist/commands/dogfood.js +1 -1
  12. package/dist/commands/explain.js +2 -2
  13. package/dist/commands/harvest.d.ts +0 -1
  14. package/dist/commands/harvest.js +1 -4
  15. package/dist/commands/metrics.js +6 -0
  16. package/dist/commands/quality.js +1 -3
  17. package/dist/commands/recovery-checkpoints.d.ts +164 -0
  18. package/dist/commands/recovery-checkpoints.js +347 -0
  19. package/dist/commands/revoke.js +19 -7
  20. package/dist/commands/status.js +7 -0
  21. package/dist/config-io.js +49 -18
  22. package/dist/conformance/guarantee-table.js +137 -9
  23. package/dist/conformance/types.d.ts +2 -0
  24. package/dist/core/approval-replay.d.ts +1 -1
  25. package/dist/core/approval-replay.js +4 -1
  26. package/dist/core/approval-service.d.ts +6 -1
  27. package/dist/core/approval-service.js +86 -27
  28. package/dist/core/approval.d.ts +2 -0
  29. package/dist/core/approval.js +6 -3
  30. package/dist/core/audit-metrics.d.ts +1 -0
  31. package/dist/core/audit-metrics.js +12 -4
  32. package/dist/core/audit-types.d.ts +16 -0
  33. package/dist/core/capability/approval-state-mutation.d.ts +19 -0
  34. package/dist/core/capability/approval-state-mutation.js +76 -3
  35. package/dist/core/capability/approval-v3.d.ts +18 -1
  36. package/dist/core/capability/approval-v3.js +63 -23
  37. package/dist/core/capability/attestation.d.ts +3 -0
  38. package/dist/core/capability/attestation.js +7 -0
  39. package/dist/core/capability/boundary-driver-container.d.ts +12 -0
  40. package/dist/core/capability/boundary-driver-container.js +101 -34
  41. package/dist/core/capability/boundary-driver.js +1 -0
  42. package/dist/core/capability/boundary-grant-materialize.d.ts +1 -0
  43. package/dist/core/capability/boundary-grant-materialize.js +15 -0
  44. package/dist/core/capability/boundary-run.d.ts +25 -0
  45. package/dist/core/capability/boundary-run.js +37 -9
  46. package/dist/core/capability/boundary-session.d.ts +4 -0
  47. package/dist/core/capability/boundary-session.js +9 -0
  48. package/dist/core/capability/boundary-workspace-mount.d.ts +5 -0
  49. package/dist/core/capability/boundary-workspace-mount.js +68 -0
  50. package/dist/core/capability/gate-policy-shadow.js +23 -1
  51. package/dist/core/capability/grant-consumption.d.ts +4 -0
  52. package/dist/core/capability/grant-consumption.js +11 -0
  53. package/dist/core/capability/grant-lease.d.ts +36 -1
  54. package/dist/core/capability/grant-lease.js +213 -16
  55. package/dist/core/capability/grant-match.js +3 -0
  56. package/dist/core/capability/index.d.ts +2 -2
  57. package/dist/core/capability/index.js +1 -1
  58. package/dist/core/capability/policy-engine.d.ts +38 -1
  59. package/dist/core/capability/policy-engine.js +388 -15
  60. package/dist/core/capability/request.d.ts +12 -0
  61. package/dist/core/capability/resolver.d.ts +0 -1
  62. package/dist/core/capability/resolver.js +0 -1
  63. package/dist/core/config.d.ts +20 -0
  64. package/dist/core/config.js +62 -2
  65. package/dist/core/effect-ir/audit.d.ts +9 -0
  66. package/dist/core/effect-ir/audit.js +87 -0
  67. package/dist/core/effect-ir/build.d.ts +31 -0
  68. package/dist/core/effect-ir/build.js +281 -0
  69. package/dist/core/effect-ir/index.d.ts +13 -0
  70. package/dist/core/effect-ir/index.js +9 -0
  71. package/dist/core/effect-ir/normalize.d.ts +4 -0
  72. package/dist/core/effect-ir/normalize.js +110 -0
  73. package/dist/core/effect-ir/package-exec-eval.d.ts +8 -0
  74. package/dist/core/effect-ir/package-exec-eval.js +152 -0
  75. package/dist/core/effect-ir/package-exec.d.ts +29 -0
  76. package/dist/core/effect-ir/package-exec.js +292 -0
  77. package/dist/core/effect-ir/policy.d.ts +14 -0
  78. package/dist/core/effect-ir/policy.js +164 -0
  79. package/dist/core/effect-ir/shell-build.d.ts +43 -0
  80. package/dist/core/effect-ir/shell-build.js +77 -0
  81. package/dist/core/effect-ir/shell-lower.d.ts +14 -0
  82. package/dist/core/effect-ir/shell-lower.js +1509 -0
  83. package/dist/core/effect-ir/types.d.ts +62 -0
  84. package/dist/core/effect-ir/types.js +1 -0
  85. package/dist/core/egress/allowlist.d.ts +1 -0
  86. package/dist/core/egress/allowlist.js +8 -0
  87. package/dist/core/egress-approval.js +45 -51
  88. package/dist/core/gate-contract.d.ts +2 -0
  89. package/dist/core/gate-contract.js +2 -0
  90. package/dist/core/gate-engine.js +82 -25
  91. package/dist/core/git-resource-identity.d.ts +26 -0
  92. package/dist/core/git-resource-identity.js +284 -0
  93. package/dist/core/harvest.d.ts +3 -7
  94. package/dist/core/harvest.js +4 -27
  95. package/dist/core/index.d.ts +5 -1
  96. package/dist/core/index.js +3 -1
  97. package/dist/core/judge-cloud-consent.js +29 -15
  98. package/dist/core/network-endpoint.d.ts +10 -0
  99. package/dist/core/network-endpoint.js +51 -0
  100. package/dist/core/path-utils.js +42 -23
  101. package/dist/core/process-runner.d.ts +10 -0
  102. package/dist/core/process-runner.js +124 -0
  103. package/dist/core/recover-advice.js +2 -1
  104. package/dist/core/recovery/artifact-store.d.ts +29 -0
  105. package/dist/core/recovery/artifact-store.js +336 -0
  106. package/dist/core/recovery/checkpoint.d.ts +25 -0
  107. package/dist/core/recovery/checkpoint.js +260 -0
  108. package/dist/core/recovery/index.d.ts +3 -1
  109. package/dist/core/recovery/index.js +2 -0
  110. package/dist/core/recovery/operator-guidance.d.ts +7 -0
  111. package/dist/core/recovery/operator-guidance.js +39 -0
  112. package/dist/core/recovery/reconcile.d.ts +3 -0
  113. package/dist/core/recovery/reconcile.js +48 -0
  114. package/dist/core/recovery/resource-identity.d.ts +4 -0
  115. package/dist/core/recovery/resource-identity.js +34 -0
  116. package/dist/core/recovery/restore.d.ts +13 -0
  117. package/dist/core/recovery/restore.js +114 -0
  118. package/dist/core/recovery/snapshot-node.d.ts +27 -0
  119. package/dist/core/recovery/snapshot-node.js +261 -0
  120. package/dist/core/recovery/types.d.ts +85 -1
  121. package/dist/core/shell-substitution.d.ts +1 -0
  122. package/dist/core/shell-substitution.js +61 -0
  123. package/dist/core/standing-allow.d.ts +1 -2
  124. package/dist/core/standing-allow.js +3 -20
  125. package/dist/core/transactional/apply-observed-changes.d.ts +13 -0
  126. package/dist/core/transactional/apply-observed-changes.js +275 -0
  127. package/dist/core/transactional/backend-selector.d.ts +8 -0
  128. package/dist/core/transactional/backend-selector.js +118 -0
  129. package/dist/core/transactional/backend.d.ts +55 -0
  130. package/dist/core/transactional/backend.js +1 -0
  131. package/dist/core/transactional/file-checkpoint-backend.d.ts +3 -0
  132. package/dist/core/transactional/file-checkpoint-backend.js +278 -0
  133. package/dist/core/transactional/file-checkpoint-git.d.ts +11 -0
  134. package/dist/core/transactional/file-checkpoint-git.js +217 -0
  135. package/dist/core/transactional/file-checkpoint-staging.d.ts +13 -0
  136. package/dist/core/transactional/file-checkpoint-staging.js +54 -0
  137. package/dist/core/transactional/file-clone.d.ts +15 -0
  138. package/dist/core/transactional/file-clone.js +139 -0
  139. package/dist/core/transactional/file-tree-path.d.ts +7 -0
  140. package/dist/core/transactional/file-tree-path.js +50 -0
  141. package/dist/core/transactional/file-tree.d.ts +41 -0
  142. package/dist/core/transactional/file-tree.js +186 -0
  143. package/dist/core/transactional/git-worktree-backend.d.ts +2 -0
  144. package/dist/core/transactional/git-worktree-backend.js +50 -0
  145. package/dist/core/transactional/git-worktree.d.ts +9 -11
  146. package/dist/core/transactional/git-worktree.js +24 -119
  147. package/dist/core/transactional/index.d.ts +11 -0
  148. package/dist/core/transactional/index.js +10 -0
  149. package/dist/core/transactional/reasons.js +1 -0
  150. package/dist/core/transactional/runner.js +157 -25
  151. package/dist/core/transactional/snapshot-node.d.ts +26 -0
  152. package/dist/core/transactional/snapshot-node.js +87 -0
  153. package/dist/core/transactional/types.d.ts +17 -1
  154. package/dist/core/types.d.ts +13 -1
  155. package/dist/core/verdict/adapter.js +23 -15
  156. package/dist/core/verdict/containment.js +4 -0
  157. package/dist/core/verdict/egress-classify.d.ts +12 -0
  158. package/dist/core/verdict/egress-classify.js +746 -0
  159. package/dist/core/verdict/git-classifier.d.ts +12 -0
  160. package/dist/core/verdict/git-classifier.js +320 -0
  161. package/dist/core/verdict/launcher-resolve.js +79 -37
  162. package/dist/core/verdict/parser.d.ts +2 -0
  163. package/dist/core/verdict/parser.js +95 -1
  164. package/dist/core/verdict/types.d.ts +6 -2
  165. package/dist/core/verdict/verdict.js +103 -935
  166. package/dist/corpus/must-allow-commands.d.ts +2 -1
  167. package/dist/corpus/must-allow-commands.js +2 -1
  168. package/dist/corpus/runtime-match.d.ts +2 -1
  169. package/dist/corpus/runtime-match.js +2 -1
  170. package/dist/corpus/types.d.ts +2 -2
  171. package/dist/templates.js +7 -2
  172. package/dist/types.d.ts +11 -0
  173. package/dist/version.d.ts +1 -1
  174. package/dist/version.js +1 -1
  175. package/package.json +2 -2
  176. package/skills/belay/SKILL.md +8 -3
  177. package/skills/belay/belay-approve.md +10 -7
  178. package/dist/core/verdict/overrides.d.ts +0 -7
  179. package/dist/core/verdict/overrides.js +0 -37
  180. package/dist/core/verdict/shell-policy.d.ts +0 -25
  181. package/dist/core/verdict/shell-policy.js +0 -40
  182. package/dist/corpus/standing-allow-catalog.generated.d.ts +0 -13
  183. package/dist/corpus/standing-allow-catalog.generated.js +0 -99
@@ -1,11 +1,10 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { createHash } from 'node:crypto';
3
- import { existsSync } from 'node:fs';
4
- import { copyFile, mkdir, mkdtemp, readFile, rm } from 'node:fs/promises';
5
2
  import os from 'node:os';
6
3
  import path from 'node:path';
7
4
  import { canonicalPath, isPathOutsideRoot, pathWithinRoot } from '../path-utils.js';
8
- export const TRANSACTIONAL_APPLY_TOCTOU = 'transactional_apply_toctou';
5
+ import { runProcessWithBoundedOutput } from '../process-runner.js';
6
+ import { applyObservedChanges, buildObservedChangesFromTransactional, TRANSACTIONAL_APPLY_ROLLBACK_FAILED, TRANSACTIONAL_APPLY_TOCTOU, } from './apply-observed-changes.js';
7
+ export { TRANSACTIONAL_APPLY_ROLLBACK_FAILED, TRANSACTIONAL_APPLY_TOCTOU };
9
8
  function execGit(repoRoot, args) {
10
9
  return new Promise((resolve, reject) => {
11
10
  const child = spawn('git', ['-C', repoRoot, ...args], {
@@ -29,7 +28,7 @@ function porcelainRelativePath(line) {
29
28
  if (line.length < 4) {
30
29
  return null;
31
30
  }
32
- const relativePath = line.slice(3).trim();
31
+ const relativePath = line.slice(3);
33
32
  return relativePath || null;
34
33
  }
35
34
  function isIgnoredDirtyPath(repoRoot, relativePath, ignoreRoots) {
@@ -41,22 +40,6 @@ function isIgnoredDirtyPath(repoRoot, relativePath, ignoreRoots) {
41
40
  root === absolutePath ||
42
41
  pathWithinRoot(absolutePath, root));
43
42
  }
44
- export async function hashRepoFile(filePath) {
45
- const content = await readFile(filePath);
46
- return createHash('sha256').update(content).digest('hex');
47
- }
48
- export async function captureRepoFileHashes(repoRoot, changes) {
49
- const hashes = new Map();
50
- for (const change of changes) {
51
- const target = path.join(repoRoot, change.relativePath);
52
- if (!existsSync(target)) {
53
- hashes.set(change.relativePath, null);
54
- continue;
55
- }
56
- hashes.set(change.relativePath, await hashRepoFile(target));
57
- }
58
- return hashes;
59
- }
60
43
  export async function isGitWorktreeAvailable(repoRoot) {
61
44
  try {
62
45
  await execGit(repoRoot, ['rev-parse', '--git-dir']);
@@ -91,6 +74,7 @@ export async function isDirtyWorktree(repoRoot, options) {
91
74
  }
92
75
  }
93
76
  export async function createGitWorktreeSnapshot(repoRoot, _stateDir) {
77
+ const { mkdtemp, rm } = await import('node:fs/promises');
94
78
  const worktreePath = await mkdtemp(path.join(os.tmpdir(), 'belay-tx-'));
95
79
  await execGit(repoRoot, ['worktree', 'add', '--detach', worktreePath, 'HEAD']);
96
80
  return {
@@ -123,38 +107,14 @@ export function resolveWorktreeCwd(repoRoot, worktreePath, cwd) {
123
107
  return path.join(worktreePath, relative);
124
108
  }
125
109
  export function runShellCommand(command, cwd, timeoutMs) {
126
- return new Promise((resolve) => {
127
- const child = spawn(command, {
128
- cwd,
129
- shell: true,
130
- stdio: 'ignore',
131
- env: process.env,
132
- });
133
- let timedOut = false;
134
- const timer = setTimeout(() => {
135
- timedOut = true;
136
- child.kill('SIGTERM');
137
- }, timeoutMs);
138
- child.on('error', () => {
139
- clearTimeout(timer);
140
- resolve({ exitCode: 1, signal: null, timedOut });
141
- });
142
- child.on('close', (exitCode, signal) => {
143
- clearTimeout(timer);
144
- resolve({
145
- exitCode,
146
- signal: signal ? String(signal) : null,
147
- timedOut,
148
- });
149
- });
150
- });
110
+ return runProcessWithBoundedOutput(command, [], { cwd, shell: true, env: process.env }, timeoutMs);
151
111
  }
152
112
  function parseStatusLine(line) {
153
113
  if (line.length < 4) {
154
114
  return null;
155
115
  }
156
116
  const status = line.slice(0, 2);
157
- const relativePath = line.slice(3).trim();
117
+ const relativePath = line.slice(3);
158
118
  if (!relativePath) {
159
119
  return null;
160
120
  }
@@ -170,11 +130,17 @@ function parseStatusLine(line) {
170
130
  return { relativePath, kind: 'modified' };
171
131
  }
172
132
  export async function collectWorktreeChanges(worktreePath) {
173
- const status = await execGit(worktreePath, ['status', '--porcelain']);
133
+ const status = await execGit(worktreePath, [
134
+ 'status',
135
+ '--porcelain=v1',
136
+ '-z',
137
+ '-uall',
138
+ '--no-renames',
139
+ ]);
174
140
  const changes = [];
175
141
  const seen = new Set();
176
- for (const line of status.split('\n')) {
177
- if (!line.trim()) {
142
+ for (const line of status.split('\0')) {
143
+ if (!line) {
178
144
  continue;
179
145
  }
180
146
  const change = parseStatusLine(line);
@@ -186,74 +152,13 @@ export async function collectWorktreeChanges(worktreePath) {
186
152
  }
187
153
  return changes;
188
154
  }
189
- async function rollbackAppliedChanges(actions) {
190
- for (const action of [...actions].reverse()) {
191
- try {
192
- if (action.type === 'restore') {
193
- await mkdir(path.dirname(action.target), { recursive: true });
194
- await copyFile(action.backupPath, action.target);
195
- }
196
- else {
197
- await rm(action.target, { force: true });
198
- }
199
- }
200
- catch {
201
- // best effort
202
- }
203
- }
204
- }
205
- async function assertRepoFilesUnchanged(repoRoot, changes, baseHashes) {
206
- for (const change of changes) {
207
- const target = path.join(repoRoot, change.relativePath);
208
- const expected = baseHashes.get(change.relativePath);
209
- const exists = existsSync(target);
210
- if (expected === null) {
211
- if (exists) {
212
- throw new Error(TRANSACTIONAL_APPLY_TOCTOU);
213
- }
214
- continue;
215
- }
216
- if (!exists) {
217
- throw new Error(TRANSACTIONAL_APPLY_TOCTOU);
218
- }
219
- const current = await hashRepoFile(target);
220
- if (current !== expected) {
221
- throw new Error(TRANSACTIONAL_APPLY_TOCTOU);
222
- }
223
- }
224
- }
225
155
  export async function applyWorktreeChanges(worktreePath, repoRoot, changes, options) {
226
- if (options?.baseHashes) {
227
- await assertRepoFilesUnchanged(repoRoot, changes, options.baseHashes);
228
- }
229
- const backupRoot = await mkdtemp(path.join(os.tmpdir(), 'belay-tx-rollback-'));
230
- const rollbackActions = [];
231
- try {
232
- for (const change of changes) {
233
- const target = path.join(repoRoot, change.relativePath);
234
- if (existsSync(target)) {
235
- const backupPath = path.join(backupRoot, change.relativePath);
236
- await mkdir(path.dirname(backupPath), { recursive: true });
237
- await copyFile(target, backupPath);
238
- rollbackActions.push({ type: 'restore', target, backupPath });
239
- }
240
- else if (change.kind !== 'deleted') {
241
- rollbackActions.push({ type: 'remove', target });
242
- }
243
- if (change.kind === 'deleted') {
244
- await rm(target, { force: true });
245
- continue;
246
- }
247
- const source = path.join(worktreePath, change.relativePath);
248
- await mkdir(path.dirname(target), { recursive: true });
249
- await copyFile(source, target);
250
- }
251
- }
252
- catch (error) {
253
- await rollbackAppliedChanges(rollbackActions);
254
- throw error;
255
- }
256
- finally {
257
- await rm(backupRoot, { recursive: true, force: true });
258
- }
156
+ const observed = options?.observedChanges ??
157
+ (await buildObservedChangesFromTransactional(repoRoot, worktreePath, changes));
158
+ await applyObservedChanges({
159
+ sourceRoot: worktreePath,
160
+ targetRoot: repoRoot,
161
+ changes: observed,
162
+ afterApply: options?.afterApply,
163
+ });
259
164
  }
@@ -1,5 +1,16 @@
1
+ export { applyObservedChanges, buildObservedChangesFromTransactional, TRANSACTIONAL_APPLY_CONFLICT, TRANSACTIONAL_APPLY_ROLLBACK_FAILED, TRANSACTIONAL_APPLY_TOCTOU, } from './apply-observed-changes.js';
2
+ export type { TransactionalBackend, TransactionalBackendContext, TransactionalBackendId, TransactionalBackendProbe, TransactionalBackendSelection, TransactionalSnapshot, } from './backend.js';
3
+ export { FILE_CHECKPOINT_DISABLED, FILE_CHECKPOINT_DURABLE_REQUIRED, FILE_CHECKPOINT_ISOLATION_UNAVAILABLE, FILE_CHECKPOINT_NON_GIT_DISABLED, FILE_CHECKPOINT_NOT_IMPLEMENTED, probeTransactionalBackends, selectTransactionalBackend, } from './backend-selector.js';
1
4
  export { evaluateTransactionalDiff } from './diff-evaluator.js';
2
5
  export { isTransactionalEligible } from './eligibility.js';
6
+ export { FILE_CHECKPOINT_PROTECTED_PATH_CHANGED, fileCheckpointBackend, } from './file-checkpoint-backend.js';
7
+ export { FILE_CHECKPOINT_CWD_OUTSIDE_ROOT, FILE_CHECKPOINT_GIT_METADATA_CHANGED, FILE_CHECKPOINT_SOURCE_CHANGED, FILE_CHECKPOINT_SPLIT_INDEX_UNSUPPORTED, resolveExecutionCwdRelative, } from './file-checkpoint-git.js';
8
+ export { collectDeadOwnerStaging, type FileCheckpointOwnerMarker, isOwnerProcessAlive, removeDeadOwnerStaging, writeOwnerMarker, } from './file-checkpoint-staging.js';
9
+ export { cloneDirectoryTree, type FileCloneResult, type FileCloneStrategy, probeFileCloneStrategy, } from './file-clone.js';
10
+ export { buildFileTreeIndex, diffFileTreeIndices, FileCheckpointDiagnosticError, type FileTreeEntry, type FileTreeIndex, type ObservedFileChange, readObservedChanges, } from './file-tree.js';
11
+ export { compareRelativePathsBytewise, joinRelativePath, validateRelativePath, } from './file-tree-path.js';
12
+ export { gitWorktreeBackend } from './git-worktree-backend.js';
3
13
  export { TRANSACTIONAL_ALREADY_APPLIED, TRANSACTIONAL_APPROVAL_BYPASS_REASONS, TRANSACTIONAL_OBSERVED_RISK, } from './reasons.js';
4
14
  export { runTransactionalExecution } from './runner.js';
15
+ export { hashDirectoryNode, hashFileContent, type PresentSnapshotNode, readSnapshotNode, type SnapshotNode, snapshotNodesEqual, } from './snapshot-node.js';
5
16
  export type { TransactionalDiffEvaluation, TransactionalExecutionResult, TransactionalFileChange, TransactionalRunnerParams, } from './types.js';
@@ -1,4 +1,14 @@
1
+ export { applyObservedChanges, buildObservedChangesFromTransactional, TRANSACTIONAL_APPLY_CONFLICT, TRANSACTIONAL_APPLY_ROLLBACK_FAILED, TRANSACTIONAL_APPLY_TOCTOU, } from './apply-observed-changes.js';
2
+ export { FILE_CHECKPOINT_DISABLED, FILE_CHECKPOINT_DURABLE_REQUIRED, FILE_CHECKPOINT_ISOLATION_UNAVAILABLE, FILE_CHECKPOINT_NON_GIT_DISABLED, FILE_CHECKPOINT_NOT_IMPLEMENTED, probeTransactionalBackends, selectTransactionalBackend, } from './backend-selector.js';
1
3
  export { evaluateTransactionalDiff } from './diff-evaluator.js';
2
4
  export { isTransactionalEligible } from './eligibility.js';
5
+ export { FILE_CHECKPOINT_PROTECTED_PATH_CHANGED, fileCheckpointBackend, } from './file-checkpoint-backend.js';
6
+ export { FILE_CHECKPOINT_CWD_OUTSIDE_ROOT, FILE_CHECKPOINT_GIT_METADATA_CHANGED, FILE_CHECKPOINT_SOURCE_CHANGED, FILE_CHECKPOINT_SPLIT_INDEX_UNSUPPORTED, resolveExecutionCwdRelative, } from './file-checkpoint-git.js';
7
+ export { collectDeadOwnerStaging, isOwnerProcessAlive, removeDeadOwnerStaging, writeOwnerMarker, } from './file-checkpoint-staging.js';
8
+ export { cloneDirectoryTree, probeFileCloneStrategy, } from './file-clone.js';
9
+ export { buildFileTreeIndex, diffFileTreeIndices, FileCheckpointDiagnosticError, readObservedChanges, } from './file-tree.js';
10
+ export { compareRelativePathsBytewise, joinRelativePath, validateRelativePath, } from './file-tree-path.js';
11
+ export { gitWorktreeBackend } from './git-worktree-backend.js';
3
12
  export { TRANSACTIONAL_ALREADY_APPLIED, TRANSACTIONAL_APPROVAL_BYPASS_REASONS, TRANSACTIONAL_OBSERVED_RISK, } from './reasons.js';
4
13
  export { runTransactionalExecution } from './runner.js';
14
+ export { hashDirectoryNode, hashFileContent, readSnapshotNode, snapshotNodesEqual, } from './snapshot-node.js';
@@ -9,4 +9,5 @@ export const TRANSACTIONAL_APPROVAL_BYPASS_REASONS = new Set([
9
9
  'recovery_substrate_unavailable',
10
10
  'recovery_execution_failed',
11
11
  'recovery_dirty_worktree',
12
+ 'file_checkpoint_isolation_unavailable',
12
13
  ]);
@@ -1,41 +1,90 @@
1
1
  import { boundaryMountReadOnlyFromPrediction, runWithBoundaryRunnable, } from '../capability/boundary-run.js';
2
2
  import { hostIntegrationBoundaryContext } from '../capability/boundary-session.js';
3
+ import { resolveGuestWorkdir } from '../capability/boundary-workspace-mount.js';
4
+ import { discardPreparedRecoveryCheckpoint, listRecoveryCheckpoints, markRecoveryCheckpointApplied, markRecoveryCheckpointApplying, prepareRecoveryCheckpoint, reconcileRecoveryCheckpoint, } from '../recovery/checkpoint.js';
5
+ import { buildObservedChangesFromTransactional } from './apply-observed-changes.js';
6
+ import { selectTransactionalBackend } from './backend-selector.js';
3
7
  import { evaluateTransactionalDiff } from './diff-evaluator.js';
4
- import { applyWorktreeChanges, captureRepoFileHashes, collectWorktreeChanges, createGitWorktreeSnapshot, isDirtyWorktree, isGitWorktreeAvailable, resolveWorktreeCwd, TRANSACTIONAL_APPLY_TOCTOU, } from './git-worktree.js';
8
+ import { FileCheckpointDiagnosticError } from './file-tree.js';
9
+ import { applyWorktreeChanges, resolveWorktreeCwd, TRANSACTIONAL_APPLY_ROLLBACK_FAILED, TRANSACTIONAL_APPLY_TOCTOU, } from './git-worktree.js';
5
10
  import { TRANSACTIONAL_ALREADY_APPLIED, TRANSACTIONAL_APPLY_FAILED, TRANSACTIONAL_OBSERVED_RISK, } from './reasons.js';
6
11
  export async function runTransactionalExecution(params) {
7
12
  const { predicted, repoRoot, stateDir, command, cwd, timeoutMs, diffContext } = params;
8
- if (!(await isGitWorktreeAvailable(repoRoot))) {
13
+ const backendContext = {
14
+ repoRoot,
15
+ stateDir,
16
+ cwd,
17
+ dirtyIgnoreRoots: params.dirtyIgnoreRoots,
18
+ fileCheckpoint: params.fileCheckpoint,
19
+ durableCheckpointEnabled: params.checkpoint?.enabled === true,
20
+ boundaryAttestation: params.boundaryContext?.attestation ?? null,
21
+ boundaryAttestationFresh: params.boundaryContext?.attestationFresh ?? false,
22
+ boundaryDriverId: params.boundaryContext?.driverId,
23
+ };
24
+ const selection = await selectTransactionalBackend(backendContext);
25
+ if (!selection.backend) {
26
+ const skipReason = params.fileCheckpoint.enabled
27
+ ? (selection.probe.reason ?? selection.skipReason)
28
+ : (selection.skipReason ?? selection.probe.reason);
9
29
  return {
10
30
  ok: false,
11
31
  skipped: true,
12
- skipReason: 'git_worktree_unavailable',
32
+ skipReason: skipReason ?? 'transactional_execution_failed',
13
33
  predicted,
14
34
  result: predicted,
15
35
  };
16
36
  }
17
- if (await isDirtyWorktree(repoRoot, { ignoreRoots: params.dirtyIgnoreRoots })) {
18
- return {
19
- ok: false,
20
- skipped: true,
21
- skipReason: 'dirty_worktree',
22
- predicted,
23
- result: predicted,
24
- };
37
+ if (params.checkpoint?.enabled) {
38
+ try {
39
+ await listRecoveryCheckpoints(stateDir, repoRoot);
40
+ }
41
+ catch (error) {
42
+ return {
43
+ ok: false,
44
+ skipped: true,
45
+ skipReason: error instanceof Error ? error.message : 'recovery_checkpoint_reconcile_failed',
46
+ predicted,
47
+ result: predicted,
48
+ };
49
+ }
25
50
  }
26
51
  let snapshot = null;
27
52
  try {
28
- snapshot = await createGitWorktreeSnapshot(repoRoot, stateDir);
29
- const execCwd = resolveWorktreeCwd(repoRoot, snapshot.worktreePath, cwd);
53
+ snapshot = await selection.backend.prepare(backendContext);
54
+ const snapshotAudit = {
55
+ transactionalBackend: snapshot.backend,
56
+ resourceKind: snapshot.resourceKind,
57
+ baselineTreeHash: snapshot.baselineTreeHash,
58
+ snapshotFileCount: snapshot.snapshotFileCount,
59
+ snapshotSourceBytes: snapshot.snapshotSourceBytes,
60
+ snapshotWorkspaceBytes: snapshot.snapshotWorkspaceBytes,
61
+ snapshotCopyStrategy: snapshot.copyStrategy,
62
+ snapshotPrepareMs: snapshot.snapshotPrepareMs,
63
+ };
64
+ const runOptions = snapshot.backend === 'file_checkpoint' && snapshot.executionCwdRelative !== undefined
65
+ ? {
66
+ mountReadOnly: boundaryMountReadOnlyFromPrediction(predicted),
67
+ workspaceMount: {
68
+ hostSourceRoot: snapshot.executionRoot,
69
+ guestTargetRoot: snapshot.resourceRoot,
70
+ cwdRelative: snapshot.executionCwdRelative,
71
+ writable: true,
72
+ hideHostSourcePath: true,
73
+ },
74
+ }
75
+ : {
76
+ mountReadOnly: boundaryMountReadOnlyFromPrediction(predicted),
77
+ };
78
+ const execCwd = snapshot.backend === 'file_checkpoint' && runOptions.workspaceMount
79
+ ? resolveGuestWorkdir(runOptions.workspaceMount)
80
+ : resolveWorktreeCwd(repoRoot, snapshot.executionRoot, cwd);
30
81
  const boundaryContext = params.boundaryContext ?? hostIntegrationBoundaryContext(params.repoRoot);
31
82
  const shellResult = await runWithBoundaryRunnable(boundaryContext.driver, {
32
83
  prepareContext: boundaryContext.prepareContext,
33
84
  command,
34
85
  cwd: execCwd,
35
86
  timeoutMs,
36
- runOptions: {
37
- mountReadOnly: boundaryMountReadOnlyFromPrediction(predicted),
38
- },
87
+ runOptions,
39
88
  });
40
89
  if (shellResult.timedOut) {
41
90
  return {
@@ -44,6 +93,7 @@ export async function runTransactionalExecution(params) {
44
93
  skipReason: 'transactional_timed_out',
45
94
  predicted,
46
95
  result: predicted,
96
+ ...snapshotAudit,
47
97
  commandExitCode: shellResult.exitCode,
48
98
  commandSignal: shellResult.signal,
49
99
  timedOut: true,
@@ -56,19 +106,77 @@ export async function runTransactionalExecution(params) {
56
106
  skipReason: 'transactional_command_failed',
57
107
  predicted,
58
108
  result: predicted,
109
+ ...snapshotAudit,
59
110
  commandExitCode: shellResult.exitCode,
60
111
  commandSignal: shellResult.signal,
61
112
  };
62
113
  }
63
- const changes = await collectWorktreeChanges(snapshot.worktreePath);
114
+ const changes = await snapshot.collectChanges();
64
115
  const observed = evaluateTransactionalDiff(changes, diffContext);
65
116
  if (observed.verdict === 'allow') {
66
- const baseHashes = await captureRepoFileHashes(repoRoot, changes);
117
+ await snapshot.validateSourceState?.();
118
+ const observedChanges = await buildObservedChangesFromTransactional(snapshot.baselineRoot ?? repoRoot, snapshot.executionRoot, changes);
119
+ const checkpoint = params.checkpoint?.enabled && changes.length > 0
120
+ ? await prepareRecoveryCheckpoint({
121
+ stateDir,
122
+ repoRoot,
123
+ baselinePath: snapshot.baselineRoot ?? repoRoot,
124
+ worktreePath: snapshot.executionRoot,
125
+ commandFingerprint: predicted.fingerprint,
126
+ changes,
127
+ protectedRoots: diffContext.protectedRoots,
128
+ config: params.checkpoint,
129
+ backend: snapshot.backend,
130
+ })
131
+ : null;
67
132
  try {
68
- await applyWorktreeChanges(snapshot.worktreePath, repoRoot, changes, { baseHashes });
133
+ if (checkpoint) {
134
+ await markRecoveryCheckpointApplying(stateDir, checkpoint);
135
+ }
136
+ let receipt;
137
+ await applyWorktreeChanges(snapshot.executionRoot, repoRoot, changes, {
138
+ observedChanges,
139
+ afterApply: checkpoint
140
+ ? async () => {
141
+ receipt = await markRecoveryCheckpointApplied(stateDir, checkpoint);
142
+ }
143
+ : undefined,
144
+ });
145
+ const result = {
146
+ ...predicted,
147
+ verdict: 'allow',
148
+ reason: TRANSACTIONAL_ALREADY_APPLIED,
149
+ assessment: observed.assessment,
150
+ };
151
+ return {
152
+ ok: true,
153
+ predicted,
154
+ observed,
155
+ result,
156
+ worktreePath: snapshot.executionRoot,
157
+ ...snapshotAudit,
158
+ commandExitCode: shellResult.exitCode,
159
+ commandSignal: shellResult.signal,
160
+ timedOut: shellResult.timedOut,
161
+ ...(checkpoint && receipt
162
+ ? {
163
+ recoveryCheckpointId: checkpoint.checkpointId,
164
+ recoveryBackend: snapshot.backend,
165
+ recoveryProofHash: receipt.proofHash,
166
+ recoveryState: 'applied',
167
+ }
168
+ : {}),
169
+ };
69
170
  }
70
171
  catch (error) {
172
+ if (checkpoint) {
173
+ const recoveryState = await reconcileRecoveryCheckpoint(stateDir, checkpoint.checkpointId);
174
+ if (recoveryState === 'prepared') {
175
+ await discardPreparedRecoveryCheckpoint(stateDir, checkpoint.checkpointId);
176
+ }
177
+ }
71
178
  const toctou = error instanceof Error && error.message === TRANSACTIONAL_APPLY_TOCTOU;
179
+ const rollbackFailed = error instanceof Error && error.message === TRANSACTIONAL_APPLY_ROLLBACK_FAILED;
72
180
  const result = {
73
181
  ...predicted,
74
182
  verdict: 'deny_pending_approval',
@@ -79,7 +187,11 @@ export async function runTransactionalExecution(params) {
79
187
  confidence: 1,
80
188
  signals: [
81
189
  ...observed.assessment.signals,
82
- toctou ? 'transactional_apply_toctou' : 'transactional_apply_failed',
190
+ rollbackFailed
191
+ ? 'transactional_apply_rollback_failed'
192
+ : toctou
193
+ ? 'transactional_apply_toctou'
194
+ : 'transactional_apply_failed',
83
195
  ],
84
196
  },
85
197
  };
@@ -88,7 +200,8 @@ export async function runTransactionalExecution(params) {
88
200
  predicted,
89
201
  observed,
90
202
  result,
91
- worktreePath: snapshot.worktreePath,
203
+ worktreePath: snapshot.executionRoot,
204
+ ...snapshotAudit,
92
205
  commandExitCode: shellResult.exitCode,
93
206
  commandSignal: shellResult.signal,
94
207
  timedOut: shellResult.timedOut,
@@ -97,8 +210,8 @@ export async function runTransactionalExecution(params) {
97
210
  }
98
211
  const result = {
99
212
  ...predicted,
100
- verdict: observed.verdict === 'allow' ? 'allow' : 'deny_pending_approval',
101
- reason: observed.verdict === 'allow' ? TRANSACTIONAL_ALREADY_APPLIED : TRANSACTIONAL_OBSERVED_RISK,
213
+ verdict: 'deny_pending_approval',
214
+ reason: TRANSACTIONAL_OBSERVED_RISK,
102
215
  assessment: observed.assessment,
103
216
  };
104
217
  return {
@@ -106,7 +219,8 @@ export async function runTransactionalExecution(params) {
106
219
  predicted,
107
220
  observed,
108
221
  result,
109
- worktreePath: snapshot.worktreePath,
222
+ worktreePath: snapshot.executionRoot,
223
+ ...snapshotAudit,
110
224
  commandExitCode: shellResult.exitCode,
111
225
  commandSignal: shellResult.signal,
112
226
  timedOut: shellResult.timedOut,
@@ -117,13 +231,31 @@ export async function runTransactionalExecution(params) {
117
231
  ok: false,
118
232
  skipped: true,
119
233
  skipReason: error instanceof Error ? error.message : 'transactional_execution_failed',
234
+ ...(error instanceof FileCheckpointDiagnosticError ? { skipDetail: error.diagnostic } : {}),
120
235
  predicted,
121
236
  result: predicted,
237
+ ...(snapshot
238
+ ? {
239
+ transactionalBackend: snapshot.backend,
240
+ resourceKind: snapshot.resourceKind,
241
+ baselineTreeHash: snapshot.baselineTreeHash,
242
+ snapshotFileCount: snapshot.snapshotFileCount,
243
+ snapshotSourceBytes: snapshot.snapshotSourceBytes,
244
+ snapshotWorkspaceBytes: snapshot.snapshotWorkspaceBytes,
245
+ snapshotCopyStrategy: snapshot.copyStrategy,
246
+ snapshotPrepareMs: snapshot.snapshotPrepareMs,
247
+ }
248
+ : {}),
122
249
  };
123
250
  }
124
251
  finally {
125
252
  if (snapshot) {
126
- await snapshot.cleanup();
253
+ try {
254
+ await snapshot.cleanup();
255
+ }
256
+ catch {
257
+ // Cleanup is best effort and must not overturn a verified apply or observed-risk result.
258
+ }
127
259
  }
128
260
  }
129
261
  }
@@ -0,0 +1,26 @@
1
+ export type SnapshotNode = {
2
+ kind: 'absent';
3
+ } | {
4
+ kind: 'file';
5
+ mode: number;
6
+ size: number;
7
+ hash: string;
8
+ } | {
9
+ kind: 'symlink';
10
+ target: string;
11
+ hash: string;
12
+ } | {
13
+ kind: 'directory';
14
+ mode: number;
15
+ hash: string;
16
+ };
17
+ export type PresentSnapshotNode = Exclude<SnapshotNode, {
18
+ kind: 'absent';
19
+ }>;
20
+ export declare function hashFileContent(filePath: string): Promise<string>;
21
+ export declare function hashSymlinkTarget(target: string): string;
22
+ export declare function hashDirectoryNode(mode: number): string;
23
+ export declare function hashFileNode(mode: number, contentHash: string): string;
24
+ export declare function snapshotNodesEqual(left: SnapshotNode, right: SnapshotNode): boolean;
25
+ export declare function readSnapshotNode(absolutePath: string): Promise<SnapshotNode>;
26
+ export declare function presentNodeKind(node: PresentSnapshotNode): 'file' | 'symlink' | 'directory';
@@ -0,0 +1,87 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { createReadStream } from 'node:fs';
3
+ import { lstat, readlink } from 'node:fs/promises';
4
+ export async function hashFileContent(filePath) {
5
+ const hash = createHash('sha256');
6
+ await new Promise((resolve, reject) => {
7
+ const stream = createReadStream(filePath);
8
+ stream.on('data', (chunk) => hash.update(chunk));
9
+ stream.on('error', reject);
10
+ stream.on('end', () => resolve());
11
+ });
12
+ return hash.digest('hex');
13
+ }
14
+ export function hashSymlinkTarget(target) {
15
+ return createHash('sha256').update(`symlink:${target}`).digest('hex');
16
+ }
17
+ export function hashDirectoryNode(mode) {
18
+ return createHash('sha256')
19
+ .update(`directory:${mode & 0o777}`)
20
+ .digest('hex');
21
+ }
22
+ export function hashFileNode(mode, contentHash) {
23
+ return createHash('sha256')
24
+ .update(`file:${mode & 0o777}:${contentHash}`)
25
+ .digest('hex');
26
+ }
27
+ export function snapshotNodesEqual(left, right) {
28
+ if (left.kind === 'absent' || right.kind === 'absent') {
29
+ return left.kind === right.kind;
30
+ }
31
+ if (left.kind !== right.kind) {
32
+ return false;
33
+ }
34
+ switch (left.kind) {
35
+ case 'file':
36
+ return (right.kind === 'file' &&
37
+ left.mode === right.mode &&
38
+ left.size === right.size &&
39
+ left.hash === right.hash);
40
+ case 'symlink':
41
+ return right.kind === 'symlink' && left.target === right.target && left.hash === right.hash;
42
+ case 'directory':
43
+ return right.kind === 'directory' && left.mode === right.mode && left.hash === right.hash;
44
+ default:
45
+ return false;
46
+ }
47
+ }
48
+ export async function readSnapshotNode(absolutePath) {
49
+ let info;
50
+ try {
51
+ info = await lstat(absolutePath);
52
+ }
53
+ catch (error) {
54
+ if (['ENOENT', 'ENOTDIR'].includes(error.code ?? '')) {
55
+ return { kind: 'absent' };
56
+ }
57
+ throw error;
58
+ }
59
+ if (info.isSymbolicLink()) {
60
+ const target = await readlink(absolutePath);
61
+ return {
62
+ kind: 'symlink',
63
+ target,
64
+ hash: hashSymlinkTarget(target),
65
+ };
66
+ }
67
+ if (info.isFile()) {
68
+ const contentHash = await hashFileContent(absolutePath);
69
+ return {
70
+ kind: 'file',
71
+ mode: info.mode,
72
+ size: info.size,
73
+ hash: hashFileNode(info.mode, contentHash),
74
+ };
75
+ }
76
+ if (info.isDirectory()) {
77
+ return {
78
+ kind: 'directory',
79
+ mode: info.mode,
80
+ hash: hashDirectoryNode(info.mode),
81
+ };
82
+ }
83
+ throw new Error('file_checkpoint_unsupported_node');
84
+ }
85
+ export function presentNodeKind(node) {
86
+ return node.kind;
87
+ }
@@ -1,4 +1,5 @@
1
1
  import type { ResolvedBoundaryDriverContext } from '../capability/boundary-session.js';
2
+ import type { BelayFileCheckpointConfig, BelayTransactionalConfig } from '../config.js';
2
3
  import type { Assessment, ClassifyResult, HookVerdict } from '../types.js';
3
4
  export type TransactionalFileChangeKind = 'added' | 'modified' | 'deleted';
4
5
  export interface TransactionalFileChange {
@@ -18,15 +19,28 @@ export interface TransactionalExecutionResult {
18
19
  ok: boolean;
19
20
  skipped?: boolean;
20
21
  skipReason?: string;
22
+ skipDetail?: string;
21
23
  predicted: ClassifyResult;
22
24
  observed?: TransactionalDiffEvaluation;
23
25
  result: ClassifyResult;
24
26
  worktreePath?: string;
27
+ transactionalBackend?: 'git_worktree' | 'file_checkpoint';
28
+ resourceKind?: 'git_repository' | 'directory';
29
+ baselineTreeHash?: string;
30
+ snapshotFileCount?: number;
31
+ snapshotSourceBytes?: number;
32
+ snapshotWorkspaceBytes?: number;
33
+ snapshotCopyStrategy?: 'clonefile' | 'reflink' | 'copy';
34
+ snapshotPrepareMs?: number;
25
35
  commandExitCode?: number | null;
26
36
  commandSignal?: string | null;
27
37
  timedOut?: boolean;
38
+ recoveryCheckpointId?: string;
39
+ recoveryBackend?: 'git_worktree' | 'file_checkpoint';
40
+ recoveryProofHash?: string;
41
+ recoveryState?: 'applied';
28
42
  }
29
- export interface TransactionalSnapshot {
43
+ export interface GitWorktreeSnapshot {
30
44
  worktreePath: string;
31
45
  cleanup: () => Promise<void>;
32
46
  }
@@ -47,4 +61,6 @@ export interface TransactionalRunnerParams {
47
61
  boundaryContext?: ResolvedBoundaryDriverContext;
48
62
  /** Repo-local Belay paths excluded from dirty-worktree gating (init artifacts). */
49
63
  dirtyIgnoreRoots?: string[];
64
+ fileCheckpoint: BelayFileCheckpointConfig;
65
+ checkpoint?: NonNullable<BelayTransactionalConfig['checkpoint']>;
50
66
  }