@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
@@ -0,0 +1,260 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { existsSync } from 'node:fs';
3
+ import { mkdir, readdir, readFile, rename, rm } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { canonicalStringify, hashValue } from '../fingerprint.js';
6
+ import { canonicalPath } from '../path-utils.js';
7
+ import { artifactRepoRoot, atomicWriteJson, checkpointDir, checkpointIds, checkpointIdsForRepo, checkpointsRoot, cleanupOrphanedStaging, directorySize, fsyncPath, markRecoveryCheckpointApplied, markRecoveryCheckpointApplying, RECOVERY_CHECKPOINT_CORRUPT, readRecoveryArtifact, writeRecoveryState, } from './artifact-store.js';
8
+ import { matchRecoverySide, reconcileRecoveryCheckpoint } from './reconcile.js';
9
+ import { currentRecoveryResourceIdentity } from './resource-identity.js';
10
+ import { RECOVERY_RESTORE_CONFLICT, RECOVERY_RESTORE_REASON, recoveryRestoreBinding, restoreRecoveryCheckpoint, } from './restore.js';
11
+ import { assertRecoverySafeTarget, captureRecoverySnapshot, withoutRecoveryBlob, } from './snapshot-node.js';
12
+ function recoveryProofProbeSignals(backend) {
13
+ if (backend === 'file_checkpoint') {
14
+ return ['dirty_git_worktree', 'file_checkpoint', 'observed_repo_local_diff'];
15
+ }
16
+ return ['clean_git_worktree', 'observed_repo_local_diff'];
17
+ }
18
+ export const RECOVERY_CHECKPOINT_QUOTA = 'recovery_checkpoint_quota_exceeded';
19
+ async function garbageCollect(stateDir, config, repoRoot) {
20
+ const artifacts = [];
21
+ for (const id of await checkpointIdsForRepo(stateDir, repoRoot)) {
22
+ try {
23
+ const loaded = await readRecoveryArtifact(stateDir, id);
24
+ artifacts.push({
25
+ id,
26
+ state: loaded.state.state,
27
+ updatedAt: Date.parse(loaded.state.updatedAt),
28
+ });
29
+ }
30
+ catch {
31
+ // Corrupt checkpoints are retained for inspection.
32
+ }
33
+ }
34
+ const now = Date.now();
35
+ const removable = artifacts
36
+ .filter((entry) => entry.state === 'applied' || entry.state === 'restored')
37
+ .sort((left, right) => left.updatedAt - right.updatedAt);
38
+ for (const entry of removable) {
39
+ const retentionHours = entry.state === 'restored' ? config.restoredRetentionHours : config.appliedRetentionHours;
40
+ const expired = now - entry.updatedAt >= retentionHours * 60 * 60 * 1000;
41
+ if (!expired)
42
+ continue;
43
+ await rm(checkpointDir(stateDir, entry.id), { recursive: true, force: true });
44
+ }
45
+ }
46
+ export async function prepareRecoveryCheckpoint(params) {
47
+ const backend = params.backend ?? 'git_worktree';
48
+ await mkdir(checkpointsRoot(params.stateDir), { recursive: true, mode: 0o700 });
49
+ await cleanupOrphanedStaging(params.stateDir);
50
+ await garbageCollect(params.stateDir, params.config, params.repoRoot);
51
+ const existing = await checkpointIdsForRepo(params.stateDir, params.repoRoot);
52
+ if (existing.length >= params.config.maxCheckpoints) {
53
+ throw new Error(RECOVERY_CHECKPOINT_QUOTA);
54
+ }
55
+ const checkpointId = `cp_${randomUUID().replaceAll('-', '').slice(0, 24)}`;
56
+ const temporary = path.join(checkpointsRoot(params.stateDir), `.tmp-${checkpointId}`);
57
+ const finalDir = checkpointDir(params.stateDir, checkpointId);
58
+ await mkdir(temporary, { recursive: true, mode: 0o700 });
59
+ await atomicWriteJson(path.join(temporary, 'owner.json'), {
60
+ version: 1,
61
+ pid: process.pid,
62
+ createdAt: new Date().toISOString(),
63
+ repoRoot: canonicalPath(params.repoRoot),
64
+ });
65
+ await mkdir(path.join(temporary, 'blobs'), { recursive: true, mode: 0o700 });
66
+ try {
67
+ const entries = [];
68
+ const protectedRoots = (params.protectedRoots ?? []).map(canonicalPath);
69
+ for (const change of [...params.changes].sort((left, right) => left.relativePath.localeCompare(right.relativePath))) {
70
+ const target = await assertRecoverySafeTarget(params.repoRoot, change.relativePath);
71
+ if (protectedRoots.some((root) => {
72
+ const relative = path.relative(root, target);
73
+ return (relative === '' ||
74
+ (relative !== '..' &&
75
+ !relative.startsWith(`..${path.sep}`) &&
76
+ !path.isAbsolute(relative)));
77
+ })) {
78
+ throw new Error('recovery_protected_path');
79
+ }
80
+ const baseline = await assertRecoverySafeTarget(params.baselinePath ?? params.repoRoot, change.relativePath);
81
+ const source = await assertRecoverySafeTarget(params.worktreePath, change.relativePath);
82
+ entries.push({
83
+ path: change.relativePath,
84
+ before: await captureRecoverySnapshot(baseline, {
85
+ blobDir: path.join(temporary, 'blobs'),
86
+ }),
87
+ after: withoutRecoveryBlob(await captureRecoverySnapshot(source)),
88
+ });
89
+ }
90
+ const createdAt = new Date().toISOString();
91
+ const proof = {
92
+ version: 1,
93
+ backend,
94
+ inputFingerprint: params.commandFingerprint,
95
+ resourceScope: canonicalPath(params.repoRoot),
96
+ baseStateHash: hashValue(canonicalStringify(entries.map((entry) => entry.before))),
97
+ effectClosure: 'repo_local_fs_observed',
98
+ issuedAt: createdAt,
99
+ expiresAt: new Date(Date.now() + params.config.appliedRetentionHours * 60 * 60 * 1000).toISOString(),
100
+ probeSignals: recoveryProofProbeSignals(backend),
101
+ };
102
+ const manifest = {
103
+ version: 2,
104
+ checkpointId,
105
+ backend,
106
+ repoRoot: canonicalPath(params.repoRoot),
107
+ resourceKind: 'git_repository',
108
+ repoIdentity: await currentRecoveryResourceIdentity(params.repoRoot, 'git_repository'),
109
+ commandFingerprint: params.commandFingerprint,
110
+ createdAt,
111
+ expiresAt: proof.expiresAt,
112
+ proof,
113
+ entries,
114
+ };
115
+ const manifestHash = hashValue(canonicalStringify(manifest));
116
+ await atomicWriteJson(path.join(temporary, 'manifest.json'), manifest);
117
+ await writeRecoveryState(temporary, 'prepared', manifestHash);
118
+ await fsyncPath(temporary);
119
+ const projectedBytes = await recoveryCheckpointStorageBytes(params.stateDir, params.repoRoot);
120
+ if (projectedBytes > params.config.maxBytes)
121
+ throw new Error(RECOVERY_CHECKPOINT_QUOTA);
122
+ await rename(temporary, finalDir);
123
+ await fsyncPath(checkpointsRoot(params.stateDir));
124
+ return {
125
+ checkpointId,
126
+ manifest,
127
+ manifestHash,
128
+ proofHash: hashValue(canonicalStringify(proof)),
129
+ };
130
+ }
131
+ catch (error) {
132
+ await rm(temporary, { recursive: true, force: true });
133
+ throw error;
134
+ }
135
+ }
136
+ export async function discardPreparedRecoveryCheckpoint(stateDir, checkpointId) {
137
+ const state = await reconcileRecoveryCheckpoint(stateDir, checkpointId);
138
+ if (state !== 'prepared')
139
+ return false;
140
+ const loaded = await readRecoveryArtifact(stateDir, checkpointId);
141
+ if (!(await matchRecoverySide(loaded.manifest.repoRoot, loaded.manifest.entries, 'before'))) {
142
+ return false;
143
+ }
144
+ await rm(loaded.artifactDir, { recursive: true, force: true });
145
+ return true;
146
+ }
147
+ export async function listRecoveryCheckpoints(stateDir, repoRoot) {
148
+ await cleanupOrphanedStaging(stateDir);
149
+ const summaries = [];
150
+ for (const id of await checkpointIds(stateDir)) {
151
+ if (repoRoot) {
152
+ let rootFromArtifact;
153
+ try {
154
+ rootFromArtifact = (await readRecoveryArtifact(stateDir, id)).manifest.repoRoot;
155
+ }
156
+ catch {
157
+ try {
158
+ const raw = JSON.parse(await readFile(path.join(checkpointDir(stateDir, id), 'manifest.json'), 'utf8'));
159
+ rootFromArtifact =
160
+ typeof raw.repoRoot === 'string' && raw.repoRoot ? raw.repoRoot : undefined;
161
+ }
162
+ catch {
163
+ rootFromArtifact = undefined;
164
+ }
165
+ }
166
+ if (!rootFromArtifact || canonicalPath(rootFromArtifact) !== canonicalPath(repoRoot))
167
+ continue;
168
+ }
169
+ const state = await reconcileRecoveryCheckpoint(stateDir, id);
170
+ try {
171
+ const loaded = await readRecoveryArtifact(stateDir, id);
172
+ if (repoRoot && canonicalPath(loaded.manifest.repoRoot) !== canonicalPath(repoRoot))
173
+ continue;
174
+ summaries.push({
175
+ checkpointId: id,
176
+ state,
177
+ ...(loaded.state.detail ? { stateDetail: loaded.state.detail } : {}),
178
+ backend: loaded.manifest.backend,
179
+ repoRoot: loaded.manifest.repoRoot,
180
+ commandFingerprint: loaded.manifest.commandFingerprint,
181
+ createdAt: loaded.manifest.createdAt,
182
+ expiresAt: loaded.manifest.expiresAt,
183
+ changeCount: loaded.manifest.entries.length,
184
+ manifestHash: loaded.manifestHash,
185
+ ...(loaded.receipt
186
+ ? {
187
+ receiptHash: hashValue(canonicalStringify(loaded.receipt)),
188
+ proofHash: loaded.receipt.proofHash,
189
+ postStateHash: loaded.receipt.postStateHash,
190
+ }
191
+ : {}),
192
+ });
193
+ }
194
+ catch {
195
+ try {
196
+ const manifest = JSON.parse(await readFile(path.join(checkpointDir(stateDir, id), 'manifest.json'), 'utf8'));
197
+ if (manifest.checkpointId !== id || ![1, 2].includes(manifest.version))
198
+ continue;
199
+ if (repoRoot && canonicalPath(manifest.repoRoot) !== canonicalPath(repoRoot))
200
+ continue;
201
+ summaries.push({
202
+ checkpointId: id,
203
+ state: 'corrupt',
204
+ backend: manifest.backend,
205
+ repoRoot: manifest.repoRoot,
206
+ commandFingerprint: manifest.commandFingerprint,
207
+ createdAt: manifest.createdAt,
208
+ expiresAt: manifest.expiresAt,
209
+ changeCount: Array.isArray(manifest.entries) ? manifest.entries.length : 0,
210
+ manifestHash: hashValue(canonicalStringify(manifest)),
211
+ });
212
+ }
213
+ catch {
214
+ // No trustworthy repository identity is available for this artifact.
215
+ }
216
+ }
217
+ }
218
+ return summaries.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
219
+ }
220
+ export async function recoveryCheckpointStorageBytes(stateDir, repoRoot) {
221
+ const root = checkpointsRoot(stateDir);
222
+ if (!repoRoot)
223
+ return directorySize(root);
224
+ if (!existsSync(root))
225
+ return 0;
226
+ const expected = canonicalPath(repoRoot);
227
+ let total = 0;
228
+ for (const entry of await readdir(root, { withFileTypes: true })) {
229
+ if (!entry.isDirectory())
230
+ continue;
231
+ const entryPath = path.join(root, entry.name);
232
+ if (/^cp_[a-f0-9]{24}$/.test(entry.name)) {
233
+ if ((await artifactRepoRoot(stateDir, entry.name)) === expected) {
234
+ total += await directorySize(entryPath);
235
+ }
236
+ continue;
237
+ }
238
+ if (/^\.tmp-cp_[a-f0-9]{24}$/.test(entry.name)) {
239
+ try {
240
+ const owner = JSON.parse(await readFile(path.join(entryPath, 'owner.json'), 'utf8'));
241
+ if (typeof owner.repoRoot === 'string' && canonicalPath(owner.repoRoot) === expected) {
242
+ total += await directorySize(entryPath);
243
+ }
244
+ }
245
+ catch {
246
+ // Unattributable staging data is reconciled once it is stale.
247
+ }
248
+ }
249
+ }
250
+ return total;
251
+ }
252
+ export async function showRecoveryCheckpoint(stateDir, checkpointId, expectedRepoRoot) {
253
+ if (expectedRepoRoot &&
254
+ (await artifactRepoRoot(stateDir, checkpointId)) !== canonicalPath(expectedRepoRoot)) {
255
+ throw new Error('recovery_checkpoint_repo_mismatch');
256
+ }
257
+ await reconcileRecoveryCheckpoint(stateDir, checkpointId);
258
+ return readRecoveryArtifact(stateDir, checkpointId);
259
+ }
260
+ export { markRecoveryCheckpointApplied, markRecoveryCheckpointApplying, RECOVERY_CHECKPOINT_CORRUPT, RECOVERY_RESTORE_CONFLICT, RECOVERY_RESTORE_REASON, reconcileRecoveryCheckpoint, recoveryRestoreBinding, restoreRecoveryCheckpoint, };
@@ -1,3 +1,5 @@
1
1
  export { capabilityRequestsBlockRecovery } from './capability.js';
2
+ export { discardPreparedRecoveryCheckpoint, listRecoveryCheckpoints, markRecoveryCheckpointApplied, markRecoveryCheckpointApplying, prepareRecoveryCheckpoint, RECOVERY_CHECKPOINT_CORRUPT, RECOVERY_CHECKPOINT_QUOTA, RECOVERY_RESTORE_CONFLICT, reconcileRecoveryCheckpoint, recoveryCheckpointStorageBytes, recoveryRestoreBinding, restoreRecoveryCheckpoint, showRecoveryCheckpoint, } from './checkpoint.js';
2
3
  export { RECOVERY_DIRTY_WORKTREE, RECOVERY_EXECUTION_FAILED, RECOVERY_SUBSTRATE_UNAVAILABLE, recoveryFailClosedResult, recoveryFailReasonFromSkip, } from './fail-closed.js';
3
- export type { RecoveryBackend, RecoveryProofContext, RecoveryProofV1 } from './types.js';
4
+ export { formatRecoveryStateDiagnostic, recoveryApprovalSetupNotes, recoveryNotificationConfigured, recoveryNotificationSetupWarning, summarizeRecoveryCheckpointDiagnostics, } from './operator-guidance.js';
5
+ export type { RecoveryBackend, RecoveryCheckpointEntry, RecoveryCheckpointEntryV1, RecoveryCheckpointEntryV2, RecoveryCheckpointManifest, RecoveryCheckpointManifestV1, RecoveryCheckpointManifestV2, RecoveryCheckpointState, RecoveryCheckpointSummary, RecoveryFileSnapshotV1, RecoveryFileSnapshotV2, RecoveryProofContext, RecoveryProofV1, RecoveryReceiptV1, } from './types.js';
@@ -1,2 +1,4 @@
1
1
  export { capabilityRequestsBlockRecovery } from './capability.js';
2
+ export { discardPreparedRecoveryCheckpoint, listRecoveryCheckpoints, markRecoveryCheckpointApplied, markRecoveryCheckpointApplying, prepareRecoveryCheckpoint, RECOVERY_CHECKPOINT_CORRUPT, RECOVERY_CHECKPOINT_QUOTA, RECOVERY_RESTORE_CONFLICT, reconcileRecoveryCheckpoint, recoveryCheckpointStorageBytes, recoveryRestoreBinding, restoreRecoveryCheckpoint, showRecoveryCheckpoint, } from './checkpoint.js';
2
3
  export { RECOVERY_DIRTY_WORKTREE, RECOVERY_EXECUTION_FAILED, RECOVERY_SUBSTRATE_UNAVAILABLE, recoveryFailClosedResult, recoveryFailReasonFromSkip, } from './fail-closed.js';
4
+ export { formatRecoveryStateDiagnostic, recoveryApprovalSetupNotes, recoveryNotificationConfigured, recoveryNotificationSetupWarning, summarizeRecoveryCheckpointDiagnostics, } from './operator-guidance.js';
@@ -0,0 +1,7 @@
1
+ import type { BelayConfigV4 } from '../config.js';
2
+ import type { RecoveryCheckpointState, RecoveryCheckpointSummary } from './types.js';
3
+ export declare function recoveryNotificationConfigured(config: BelayConfigV4): boolean;
4
+ export declare function recoveryApprovalSetupNotes(): string[];
5
+ export declare function recoveryNotificationSetupWarning(): string;
6
+ export declare function formatRecoveryStateDiagnostic(state: RecoveryCheckpointState, detail?: string): string | null;
7
+ export declare function summarizeRecoveryCheckpointDiagnostics(checkpoints: RecoveryCheckpointSummary[]): string[];
@@ -0,0 +1,39 @@
1
+ export function recoveryNotificationConfigured(config) {
2
+ return Boolean(config.notifications.webhookUrl || config.notifications.commandHook);
3
+ }
4
+ export function recoveryApprovalSetupNotes() {
5
+ return [
6
+ 'Recovery restore flow: run `belay recover apply <checkpoint-id>`, approve with `belay approve <approval-id> --token <signed-token>`, then run the same apply command again.',
7
+ 'Recovery restore always requires a signed out-of-band token, even when approvalSigning.required is false for general approvals.',
8
+ ];
9
+ }
10
+ export function recoveryNotificationSetupWarning() {
11
+ return ('Recovery checkpoint restore requires a signed out-of-band approval token, but no notification channel is configured. ' +
12
+ 'Set notifications.webhookUrl or notifications.commandHook (e.g. via `belay config`) so `belay recover apply` can deliver the token.');
13
+ }
14
+ export function formatRecoveryStateDiagnostic(state, detail) {
15
+ switch (state) {
16
+ case 'needs_manual_repair':
17
+ return (detail ??
18
+ 'Repository files no longer match the recorded before/after snapshots after an interrupted apply or restore. ' +
19
+ 'Inspect the working tree manually; automatic restore is blocked until the mismatch is resolved or the checkpoint is removed.');
20
+ case 'conflict':
21
+ return (detail ??
22
+ 'Current repository files do not match the recorded post-state for this checkpoint. Restore was refused to avoid overwriting newer changes.');
23
+ case 'corrupt':
24
+ return (detail ??
25
+ 'Checkpoint artifacts failed integrity checks. Do not restore from this checkpoint; inspect or remove the artifact directory manually.');
26
+ default:
27
+ return detail ?? null;
28
+ }
29
+ }
30
+ export function summarizeRecoveryCheckpointDiagnostics(checkpoints) {
31
+ const advisories = [];
32
+ for (const checkpoint of checkpoints) {
33
+ const diagnostic = formatRecoveryStateDiagnostic(checkpoint.state, checkpoint.stateDetail);
34
+ if (!diagnostic)
35
+ continue;
36
+ advisories.push(`${checkpoint.checkpointId} (${checkpoint.state}): ${diagnostic}`);
37
+ }
38
+ return advisories;
39
+ }
@@ -0,0 +1,3 @@
1
+ import type { RecoveryCheckpointEntry, RecoveryCheckpointState } from './types.js';
2
+ export declare function matchRecoverySide(resourceRoot: string, entries: RecoveryCheckpointEntry[], side: 'before' | 'after'): Promise<boolean>;
3
+ export declare function reconcileRecoveryCheckpoint(stateDir: string, checkpointId: string): Promise<RecoveryCheckpointState>;
@@ -0,0 +1,48 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { hashValue } from '../fingerprint.js';
5
+ import { checkpointDir, ensureRecoveryReceipt, RECOVERY_CHECKPOINT_CORRUPT, readRecoveryArtifact, writeRecoveryState, } from './artifact-store.js';
6
+ import { assertRecoverySafeTarget, recoverySnapshotMatches, withoutRecoveryBlob, } from './snapshot-node.js';
7
+ export async function matchRecoverySide(resourceRoot, entries, side) {
8
+ for (const entry of entries) {
9
+ const target = await assertRecoverySafeTarget(resourceRoot, entry.path);
10
+ if (!(await recoverySnapshotMatches(target, withoutRecoveryBlob(entry[side]))))
11
+ return false;
12
+ }
13
+ return true;
14
+ }
15
+ export async function reconcileRecoveryCheckpoint(stateDir, checkpointId) {
16
+ let loaded;
17
+ try {
18
+ loaded = await readRecoveryArtifact(stateDir, checkpointId);
19
+ }
20
+ catch {
21
+ const artifactDir = checkpointDir(stateDir, checkpointId);
22
+ if (existsSync(artifactDir)) {
23
+ const manifestPath = path.join(artifactDir, 'manifest.json');
24
+ const hash = existsSync(manifestPath)
25
+ ? hashValue(await readFile(manifestPath, 'utf8'))
26
+ : 'unavailable';
27
+ await writeRecoveryState(artifactDir, 'corrupt', hash, RECOVERY_CHECKPOINT_CORRUPT);
28
+ }
29
+ return 'corrupt';
30
+ }
31
+ if (!['prepared', 'applying', 'restoring'].includes(loaded.state.state)) {
32
+ return loaded.state.state;
33
+ }
34
+ const before = await matchRecoverySide(loaded.manifest.repoRoot, loaded.manifest.entries, 'before');
35
+ const after = await matchRecoverySide(loaded.manifest.repoRoot, loaded.manifest.entries, 'after');
36
+ if (before) {
37
+ const state = loaded.state.state === 'restoring' ? 'restored' : 'prepared';
38
+ await writeRecoveryState(loaded.artifactDir, state, loaded.manifestHash);
39
+ return state;
40
+ }
41
+ if (after) {
42
+ await ensureRecoveryReceipt(loaded.artifactDir, loaded.manifest, loaded.manifestHash);
43
+ await writeRecoveryState(loaded.artifactDir, 'applied', loaded.manifestHash);
44
+ return 'applied';
45
+ }
46
+ await writeRecoveryState(loaded.artifactDir, 'needs_manual_repair', loaded.manifestHash, 'mixed checkpoint state after interrupted operation');
47
+ return 'needs_manual_repair';
48
+ }
@@ -0,0 +1,4 @@
1
+ import type { RecoveryCheckpointManifest } from './types.js';
2
+ export type RecoveryResourceKind = 'git_repository' | 'directory';
3
+ export declare function currentRecoveryResourceIdentity(resourceRoot: string, resourceKind: RecoveryResourceKind): Promise<string>;
4
+ export declare function assertRecoveryResourceIdentity(manifest: RecoveryCheckpointManifest): Promise<void>;
@@ -0,0 +1,34 @@
1
+ import { lstat, readFile, realpath } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { hashValue } from '../fingerprint.js';
4
+ export async function currentRecoveryResourceIdentity(resourceRoot, resourceKind) {
5
+ const resolvedRoot = await realpath(resourceRoot);
6
+ if (resourceKind === 'directory') {
7
+ const rootInfo = await lstat(resolvedRoot);
8
+ if (!rootInfo.isDirectory())
9
+ throw new Error('recovery_repo_identity_unavailable');
10
+ return hashValue(`${resolvedRoot}\0${rootInfo.dev}:${rootInfo.ino}:${rootInfo.birthtimeMs}`);
11
+ }
12
+ const dotGit = path.join(resolvedRoot, '.git');
13
+ const gitInfo = await lstat(dotGit);
14
+ let gitMetadataPath = dotGit;
15
+ if (gitInfo.isFile()) {
16
+ const marker = (await readFile(dotGit, 'utf8')).trim();
17
+ if (!marker.startsWith('gitdir:'))
18
+ throw new Error('recovery_repo_identity_unavailable');
19
+ gitMetadataPath = path.resolve(resolvedRoot, marker.slice('gitdir:'.length).trim());
20
+ }
21
+ else if (!gitInfo.isDirectory()) {
22
+ throw new Error('recovery_repo_identity_unavailable');
23
+ }
24
+ const resolvedGitMetadataPath = await realpath(gitMetadataPath);
25
+ const metadata = await lstat(resolvedGitMetadataPath);
26
+ return hashValue(`${resolvedRoot}\0${resolvedGitMetadataPath}\0${metadata.dev}:${metadata.ino}:${metadata.birthtimeMs}`);
27
+ }
28
+ export async function assertRecoveryResourceIdentity(manifest) {
29
+ const resourceKind = manifest.version === 1 ? 'git_repository' : manifest.resourceKind;
30
+ if ((await currentRecoveryResourceIdentity(manifest.repoRoot, resourceKind)) !==
31
+ manifest.repoIdentity) {
32
+ throw new Error('recovery_checkpoint_repo_mismatch');
33
+ }
34
+ }
@@ -0,0 +1,13 @@
1
+ export declare const RECOVERY_RESTORE_CONFLICT = "recovery_restore_conflict";
2
+ export declare const RECOVERY_RESTORE_REASON = "recovery_restore";
3
+ export declare function recoveryRestoreBinding(stateDir: string, checkpointId: string, expectedRepoRoot?: string): Promise<{
4
+ fingerprint: string;
5
+ manifestHash: string;
6
+ postStateHash: string;
7
+ paths: string[];
8
+ repoRoot: string;
9
+ }>;
10
+ export declare function restoreRecoveryCheckpoint(stateDir: string, checkpointId: string, expectedRepoRoot?: string): Promise<{
11
+ manifestHash: string;
12
+ changeCount: number;
13
+ }>;
@@ -0,0 +1,114 @@
1
+ import { mkdtemp, rm } from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { canonicalStringify, hashValue } from '../fingerprint.js';
5
+ import { canonicalPath } from '../path-utils.js';
6
+ import { artifactRepoRoot, RECOVERY_CHECKPOINT_CORRUPT, readRecoveryArtifact, writeRecoveryState, } from './artifact-store.js';
7
+ import { matchRecoverySide } from './reconcile.js';
8
+ import { assertRecoveryResourceIdentity } from './resource-identity.js';
9
+ import { applyRecoverySnapshot, assertRecoverySafeTarget, captureRecoverySnapshot, recoverySnapshotHash, recoveryStateHash, sortRecoveryEntriesForSide, withoutRecoveryBlob, } from './snapshot-node.js';
10
+ export const RECOVERY_RESTORE_CONFLICT = 'recovery_restore_conflict';
11
+ export const RECOVERY_RESTORE_REASON = 'recovery_restore';
12
+ async function assertExpectedRepoRoot(stateDir, checkpointId, expectedRepoRoot) {
13
+ if (expectedRepoRoot &&
14
+ (await artifactRepoRoot(stateDir, checkpointId)) !== canonicalPath(expectedRepoRoot)) {
15
+ throw new Error('recovery_checkpoint_repo_mismatch');
16
+ }
17
+ }
18
+ export async function recoveryRestoreBinding(stateDir, checkpointId, expectedRepoRoot) {
19
+ await assertExpectedRepoRoot(stateDir, checkpointId, expectedRepoRoot);
20
+ const loaded = await readRecoveryArtifact(stateDir, checkpointId);
21
+ await assertRecoveryResourceIdentity(loaded.manifest);
22
+ if (Date.parse(loaded.manifest.expiresAt) <= Date.now()) {
23
+ throw new Error('recovery_checkpoint_expired');
24
+ }
25
+ if (!['applied', 'conflict'].includes(loaded.state.state)) {
26
+ throw new Error(`recovery_checkpoint_not_applied:${loaded.state.state}`);
27
+ }
28
+ if (!(await matchRecoverySide(loaded.manifest.repoRoot, loaded.manifest.entries, 'after'))) {
29
+ await writeRecoveryState(loaded.artifactDir, 'conflict', loaded.manifestHash, RECOVERY_RESTORE_CONFLICT);
30
+ throw new Error(RECOVERY_RESTORE_CONFLICT);
31
+ }
32
+ const paths = loaded.manifest.entries.map((entry) => entry.path).sort();
33
+ const postStateHash = recoveryStateHash(loaded.manifest.entries, 'after');
34
+ return {
35
+ fingerprint: hashValue(canonicalStringify({
36
+ action: 'recovery.restore',
37
+ repoRoot: loaded.manifest.repoRoot,
38
+ checkpointId,
39
+ manifestHash: loaded.manifestHash,
40
+ postStateHash,
41
+ paths,
42
+ })),
43
+ manifestHash: loaded.manifestHash,
44
+ postStateHash,
45
+ paths,
46
+ repoRoot: loaded.manifest.repoRoot,
47
+ };
48
+ }
49
+ export async function restoreRecoveryCheckpoint(stateDir, checkpointId, expectedRepoRoot) {
50
+ await assertExpectedRepoRoot(stateDir, checkpointId, expectedRepoRoot);
51
+ const loaded = await readRecoveryArtifact(stateDir, checkpointId);
52
+ await assertRecoveryResourceIdentity(loaded.manifest);
53
+ if (Date.parse(loaded.manifest.expiresAt) <= Date.now()) {
54
+ throw new Error('recovery_checkpoint_expired');
55
+ }
56
+ if (!['applied', 'conflict'].includes(loaded.state.state)) {
57
+ throw new Error(`recovery_checkpoint_not_applied:${loaded.state.state}`);
58
+ }
59
+ if (!(await matchRecoverySide(loaded.manifest.repoRoot, loaded.manifest.entries, 'after'))) {
60
+ await writeRecoveryState(loaded.artifactDir, 'conflict', loaded.manifestHash, RECOVERY_RESTORE_CONFLICT);
61
+ throw new Error(RECOVERY_RESTORE_CONFLICT);
62
+ }
63
+ const rollbackDir = await mkdtemp(path.join(os.tmpdir(), 'belay-recovery-rollback-'));
64
+ const rollback = [];
65
+ try {
66
+ for (const entry of loaded.manifest.entries) {
67
+ const target = await assertRecoverySafeTarget(loaded.manifest.repoRoot, entry.path);
68
+ const snapshot = await captureRecoverySnapshot(target, {
69
+ blobDir: path.join(rollbackDir, 'blobs'),
70
+ });
71
+ rollback.push({ path: entry.path, target, before: snapshot, after: snapshot });
72
+ }
73
+ const capturedPostStateMatches = rollback.every((item, index) => {
74
+ const expected = loaded.manifest.entries[index]?.after;
75
+ return (expected !== undefined &&
76
+ recoverySnapshotHash(withoutRecoveryBlob(item.before)) ===
77
+ recoverySnapshotHash(withoutRecoveryBlob(expected)));
78
+ });
79
+ if (!capturedPostStateMatches) {
80
+ await writeRecoveryState(loaded.artifactDir, 'conflict', loaded.manifestHash, RECOVERY_RESTORE_CONFLICT);
81
+ throw new Error(RECOVERY_RESTORE_CONFLICT);
82
+ }
83
+ await writeRecoveryState(loaded.artifactDir, 'restoring', loaded.manifestHash);
84
+ try {
85
+ for (const entry of sortRecoveryEntriesForSide(loaded.manifest.entries, 'before')) {
86
+ const target = await assertRecoverySafeTarget(loaded.manifest.repoRoot, entry.path);
87
+ await applyRecoverySnapshot(target, entry.before, loaded.artifactDir, RECOVERY_CHECKPOINT_CORRUPT);
88
+ }
89
+ if (!(await matchRecoverySide(loaded.manifest.repoRoot, loaded.manifest.entries, 'before'))) {
90
+ throw new Error('recovery_restore_verification_failed');
91
+ }
92
+ await writeRecoveryState(loaded.artifactDir, 'restored', loaded.manifestHash);
93
+ return { manifestHash: loaded.manifestHash, changeCount: loaded.manifest.entries.length };
94
+ }
95
+ catch (error) {
96
+ let rollbackFailed = false;
97
+ for (const item of sortRecoveryEntriesForSide(rollback, 'before')) {
98
+ try {
99
+ await applyRecoverySnapshot(item.target, item.before, rollbackDir, RECOVERY_CHECKPOINT_CORRUPT);
100
+ }
101
+ catch {
102
+ rollbackFailed = true;
103
+ }
104
+ }
105
+ const rollbackVerified = !rollbackFailed &&
106
+ (await matchRecoverySide(loaded.manifest.repoRoot, loaded.manifest.entries, 'after'));
107
+ await writeRecoveryState(loaded.artifactDir, rollbackVerified ? 'applied' : 'needs_manual_repair', loaded.manifestHash, error instanceof Error ? error.message : 'recovery_restore_failed');
108
+ throw error;
109
+ }
110
+ }
111
+ finally {
112
+ await rm(rollbackDir, { recursive: true, force: true }).catch(() => { });
113
+ }
114
+ }
@@ -0,0 +1,27 @@
1
+ import type { RecoveryCheckpointEntry, RecoveryFileSnapshotV2 } from './types.js';
2
+ export declare const RECOVERY_UNSUPPORTED_FILE_KIND = "recovery_unsupported_file_kind";
3
+ export declare function validRecoveryRelativePath(relativePath: string): boolean;
4
+ export declare function assertRecoverySafeTarget(resourceRoot: string, relativePath: string): Promise<string>;
5
+ export declare function recoveryDirectoryHash(mode: number): string;
6
+ export declare function recoverySnapshotHash(snapshot: RecoveryFileSnapshotV2): string;
7
+ export declare function withoutRecoveryBlob(snapshot: RecoveryFileSnapshotV2): RecoveryFileSnapshotV2;
8
+ export declare function captureRecoverySnapshot(filePath: string, options?: {
9
+ blobDir?: string;
10
+ }): Promise<RecoveryFileSnapshotV2>;
11
+ export declare function recoverySnapshotMatches(filePath: string, expected: RecoveryFileSnapshotV2): Promise<boolean>;
12
+ export declare function validateRecoverySnapshot(params: {
13
+ snapshot: unknown;
14
+ side: 'before' | 'after';
15
+ manifestVersion: 1 | 2;
16
+ artifactDir: string;
17
+ corruptReason: string;
18
+ }): Promise<void>;
19
+ export declare function applyRecoverySnapshot(target: string, snapshot: RecoveryFileSnapshotV2, artifactDir: string, corruptReason: string): Promise<void>;
20
+ interface RecoveryEntryLike {
21
+ path: string;
22
+ before: RecoveryFileSnapshotV2;
23
+ after: RecoveryFileSnapshotV2;
24
+ }
25
+ export declare function sortRecoveryEntriesForSide<T extends RecoveryEntryLike>(entries: T[], side: 'before' | 'after'): T[];
26
+ export declare function recoveryStateHash(entries: RecoveryCheckpointEntry[], side: 'before' | 'after'): string;
27
+ export {};