@guilz-dev/belay 0.6.0 → 0.7.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.
- package/README.md +20 -1
- package/dist/adapters/claude/runtime-entry.js +54 -25
- package/dist/adapters/shared/gate-runtime.d.ts +7 -0
- package/dist/adapters/shared/gate-runtime.js +420 -220
- package/dist/bundle/claude-runtime.mjs +13215 -8864
- package/dist/bundle/codex-runtime.mjs +10771 -6453
- package/dist/bundle/cursor-runtime.mjs +10797 -6484
- package/dist/cli.js +37 -1
- package/dist/commands/approve.js +34 -10
- package/dist/commands/doctor.js +51 -0
- package/dist/commands/recovery-checkpoints.d.ts +164 -0
- package/dist/commands/recovery-checkpoints.js +347 -0
- package/dist/commands/revoke.js +19 -7
- package/dist/commands/status.js +7 -0
- package/dist/config-io.js +49 -18
- package/dist/conformance/guarantee-table.js +6 -0
- package/dist/conformance/types.d.ts +1 -0
- package/dist/core/approval-replay.d.ts +1 -1
- package/dist/core/approval-replay.js +4 -1
- package/dist/core/approval-service.d.ts +6 -1
- package/dist/core/approval-service.js +86 -27
- package/dist/core/approval.d.ts +2 -0
- package/dist/core/approval.js +6 -3
- package/dist/core/audit-types.d.ts +13 -0
- package/dist/core/capability/approval-state-mutation.d.ts +19 -0
- package/dist/core/capability/approval-state-mutation.js +76 -3
- package/dist/core/capability/approval-v3.d.ts +18 -1
- package/dist/core/capability/approval-v3.js +63 -23
- package/dist/core/capability/attestation.d.ts +3 -0
- package/dist/core/capability/attestation.js +7 -0
- package/dist/core/capability/boundary-driver-container.d.ts +12 -0
- package/dist/core/capability/boundary-driver-container.js +101 -34
- package/dist/core/capability/boundary-driver.js +1 -0
- package/dist/core/capability/boundary-grant-materialize.d.ts +1 -0
- package/dist/core/capability/boundary-grant-materialize.js +15 -0
- package/dist/core/capability/boundary-run.d.ts +25 -0
- package/dist/core/capability/boundary-run.js +37 -9
- package/dist/core/capability/boundary-session.d.ts +4 -0
- package/dist/core/capability/boundary-session.js +9 -0
- package/dist/core/capability/boundary-workspace-mount.d.ts +5 -0
- package/dist/core/capability/boundary-workspace-mount.js +68 -0
- package/dist/core/capability/gate-policy-shadow.js +23 -1
- package/dist/core/capability/grant-consumption.d.ts +4 -0
- package/dist/core/capability/grant-consumption.js +11 -0
- package/dist/core/capability/grant-lease.d.ts +36 -1
- package/dist/core/capability/grant-lease.js +213 -16
- package/dist/core/capability/grant-match.js +3 -0
- package/dist/core/capability/index.d.ts +1 -1
- package/dist/core/capability/index.js +1 -1
- package/dist/core/capability/policy-engine.d.ts +7 -0
- package/dist/core/capability/policy-engine.js +202 -15
- package/dist/core/capability/request.d.ts +3 -0
- package/dist/core/config.d.ts +20 -0
- package/dist/core/config.js +62 -0
- package/dist/core/effect-ir/audit.d.ts +9 -0
- package/dist/core/effect-ir/audit.js +86 -0
- package/dist/core/effect-ir/build.d.ts +31 -0
- package/dist/core/effect-ir/build.js +277 -0
- package/dist/core/effect-ir/index.d.ts +8 -0
- package/dist/core/effect-ir/index.js +7 -0
- package/dist/core/effect-ir/normalize.d.ts +4 -0
- package/dist/core/effect-ir/normalize.js +104 -0
- package/dist/core/effect-ir/package-exec-eval.d.ts +8 -0
- package/dist/core/effect-ir/package-exec-eval.js +152 -0
- package/dist/core/effect-ir/package-exec.d.ts +29 -0
- package/dist/core/effect-ir/package-exec.js +292 -0
- package/dist/core/effect-ir/policy.d.ts +13 -0
- package/dist/core/effect-ir/policy.js +77 -0
- package/dist/core/effect-ir/types.d.ts +55 -0
- package/dist/core/effect-ir/types.js +1 -0
- package/dist/core/egress/allowlist.d.ts +1 -0
- package/dist/core/egress/allowlist.js +8 -0
- package/dist/core/egress-approval.js +45 -51
- package/dist/core/gate-contract.d.ts +2 -0
- package/dist/core/gate-contract.js +2 -0
- package/dist/core/gate-engine.js +26 -9
- package/dist/core/index.d.ts +3 -1
- package/dist/core/index.js +2 -1
- package/dist/core/judge-cloud-consent.js +29 -15
- package/dist/core/network-endpoint.d.ts +10 -0
- package/dist/core/network-endpoint.js +51 -0
- package/dist/core/process-runner.d.ts +10 -0
- package/dist/core/process-runner.js +124 -0
- package/dist/core/recover-advice.js +2 -1
- package/dist/core/recovery/artifact-store.d.ts +29 -0
- package/dist/core/recovery/artifact-store.js +336 -0
- package/dist/core/recovery/checkpoint.d.ts +25 -0
- package/dist/core/recovery/checkpoint.js +260 -0
- package/dist/core/recovery/index.d.ts +3 -1
- package/dist/core/recovery/index.js +2 -0
- package/dist/core/recovery/operator-guidance.d.ts +7 -0
- package/dist/core/recovery/operator-guidance.js +39 -0
- package/dist/core/recovery/reconcile.d.ts +3 -0
- package/dist/core/recovery/reconcile.js +48 -0
- package/dist/core/recovery/resource-identity.d.ts +4 -0
- package/dist/core/recovery/resource-identity.js +34 -0
- package/dist/core/recovery/restore.d.ts +13 -0
- package/dist/core/recovery/restore.js +114 -0
- package/dist/core/recovery/snapshot-node.d.ts +27 -0
- package/dist/core/recovery/snapshot-node.js +261 -0
- package/dist/core/recovery/types.d.ts +85 -1
- package/dist/core/transactional/apply-observed-changes.d.ts +13 -0
- package/dist/core/transactional/apply-observed-changes.js +275 -0
- package/dist/core/transactional/backend-selector.d.ts +8 -0
- package/dist/core/transactional/backend-selector.js +118 -0
- package/dist/core/transactional/backend.d.ts +55 -0
- package/dist/core/transactional/backend.js +1 -0
- package/dist/core/transactional/file-checkpoint-backend.d.ts +3 -0
- package/dist/core/transactional/file-checkpoint-backend.js +278 -0
- package/dist/core/transactional/file-checkpoint-git.d.ts +11 -0
- package/dist/core/transactional/file-checkpoint-git.js +217 -0
- package/dist/core/transactional/file-checkpoint-staging.d.ts +13 -0
- package/dist/core/transactional/file-checkpoint-staging.js +54 -0
- package/dist/core/transactional/file-clone.d.ts +15 -0
- package/dist/core/transactional/file-clone.js +139 -0
- package/dist/core/transactional/file-tree-path.d.ts +7 -0
- package/dist/core/transactional/file-tree-path.js +50 -0
- package/dist/core/transactional/file-tree.d.ts +41 -0
- package/dist/core/transactional/file-tree.js +186 -0
- package/dist/core/transactional/git-worktree-backend.d.ts +2 -0
- package/dist/core/transactional/git-worktree-backend.js +50 -0
- package/dist/core/transactional/git-worktree.d.ts +9 -11
- package/dist/core/transactional/git-worktree.js +24 -119
- package/dist/core/transactional/index.d.ts +11 -0
- package/dist/core/transactional/index.js +10 -0
- package/dist/core/transactional/reasons.js +1 -0
- package/dist/core/transactional/runner.js +157 -25
- package/dist/core/transactional/snapshot-node.d.ts +26 -0
- package/dist/core/transactional/snapshot-node.js +87 -0
- package/dist/core/transactional/types.d.ts +17 -1
- package/dist/core/types.d.ts +10 -1
- package/dist/core/verdict/adapter.js +8 -0
- package/dist/core/verdict/launcher-resolve.js +2 -20
- package/dist/core/verdict/shell-policy.d.ts +1 -0
- package/dist/core/verdict/shell-policy.js +1 -1
- package/dist/core/verdict/types.d.ts +5 -0
- package/dist/core/verdict/verdict.js +37 -20
- package/dist/types.d.ts +11 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/skills/belay/SKILL.md +8 -3
- package/skills/belay/belay-approve.md +10 -7
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { ensureBelayStateDir, loadConfigFile } from '../config-io.js';
|
|
4
|
+
import { compactApprovals, createApprovalRecordWithEnvelope } from '../core/approval.js';
|
|
5
|
+
import { createGateApprovalStore } from '../core/approval-service.js';
|
|
6
|
+
import { issueApprovalToken } from '../core/approval-token.js';
|
|
7
|
+
import { appendCliAuditEvent } from '../core/audit-io.js';
|
|
8
|
+
import { mutateApprovalStateWithRetry } from '../core/capability/approval-state-mutation.js';
|
|
9
|
+
import { boundarySessionStatus } from '../core/capability/boundary-session.js';
|
|
10
|
+
import { configuredControlPlaneDir, DEFAULT_RECOVERY_CHECKPOINT } from '../core/config.js';
|
|
11
|
+
import { notifyDeny } from '../core/notify.js';
|
|
12
|
+
import { canonicalPath } from '../core/path-utils.js';
|
|
13
|
+
import { listRecoveryCheckpoints, RECOVERY_CHECKPOINT_CORRUPT, RECOVERY_RESTORE_CONFLICT, RECOVERY_RESTORE_REASON, recoveryCheckpointStorageBytes, recoveryRestoreBinding, restoreRecoveryCheckpoint, showRecoveryCheckpoint, } from '../core/recovery/checkpoint.js';
|
|
14
|
+
import { formatRecoveryStateDiagnostic, recoveryNotificationConfigured, recoveryNotificationSetupWarning, summarizeRecoveryCheckpointDiagnostics, } from '../core/recovery/operator-guidance.js';
|
|
15
|
+
import { probeFileCloneStrategy } from '../core/transactional/file-clone.js';
|
|
16
|
+
async function appendRecoveryAudit(repoRoot, config, event) {
|
|
17
|
+
try {
|
|
18
|
+
await appendCliAuditEvent(repoRoot, config, event);
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
async function consumeRecoveryApproval(params) {
|
|
26
|
+
const store = createGateApprovalStore(params.repoRoot, params.config);
|
|
27
|
+
return mutateApprovalStateWithRetry({
|
|
28
|
+
load: store.loadApproved,
|
|
29
|
+
write: store.writeApproved,
|
|
30
|
+
mutate: (state) => {
|
|
31
|
+
const compacted = compactApprovals(state);
|
|
32
|
+
const index = compacted.approvals.findIndex((entry) => entry.kind === 'tool' &&
|
|
33
|
+
entry.reason === RECOVERY_RESTORE_REASON &&
|
|
34
|
+
entry.fingerprint === params.fingerprint &&
|
|
35
|
+
entry.repoRoot === params.repoRoot);
|
|
36
|
+
if (index === -1)
|
|
37
|
+
return null;
|
|
38
|
+
const [approval] = compacted.approvals.splice(index, 1);
|
|
39
|
+
return approval ? { state: compacted, result: approval } : null;
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
async function clearRecoveryPending(params) {
|
|
44
|
+
const store = createGateApprovalStore(params.repoRoot, params.config);
|
|
45
|
+
await mutateApprovalStateWithRetry({
|
|
46
|
+
load: store.loadPending,
|
|
47
|
+
write: store.writePending,
|
|
48
|
+
mutate: (state) => {
|
|
49
|
+
const compacted = compactApprovals(state);
|
|
50
|
+
const remaining = compacted.approvals.filter((entry) => !(entry.kind === 'tool' &&
|
|
51
|
+
entry.reason === RECOVERY_RESTORE_REASON &&
|
|
52
|
+
entry.fingerprint === params.fingerprint &&
|
|
53
|
+
entry.repoRoot === params.repoRoot));
|
|
54
|
+
if (remaining.length === compacted.approvals.length)
|
|
55
|
+
return null;
|
|
56
|
+
compacted.approvals = remaining;
|
|
57
|
+
return { state: compacted, result: undefined };
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
async function ensureRecoveryPending(params) {
|
|
62
|
+
const store = createGateApprovalStore(params.repoRoot, params.config);
|
|
63
|
+
const request = createApprovalRecordWithEnvelope({
|
|
64
|
+
kind: 'tool',
|
|
65
|
+
fingerprint: params.fingerprint,
|
|
66
|
+
repoRoot: params.repoRoot,
|
|
67
|
+
reason: RECOVERY_RESTORE_REASON,
|
|
68
|
+
summary: `Restore recovery checkpoint ${params.checkpointId}`,
|
|
69
|
+
approvalTtlMinutes: params.config.approvalTtlMinutes,
|
|
70
|
+
approvalId: `belay_${randomUUID().replaceAll('-', '').slice(0, 12)}`,
|
|
71
|
+
approvalInput: {
|
|
72
|
+
input: 'recovery.restore',
|
|
73
|
+
inputKind: 'tool',
|
|
74
|
+
cwd: params.repoRoot,
|
|
75
|
+
toolName: 'recovery.restore',
|
|
76
|
+
payload: {
|
|
77
|
+
checkpointId: params.checkpointId,
|
|
78
|
+
manifestHash: params.manifestHash,
|
|
79
|
+
postStateHash: params.postStateHash,
|
|
80
|
+
paths: params.paths,
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
const result = await mutateApprovalStateWithRetry({
|
|
85
|
+
load: store.loadPending,
|
|
86
|
+
write: store.writePending,
|
|
87
|
+
mutate: (state) => {
|
|
88
|
+
const compacted = compactApprovals(state);
|
|
89
|
+
const existing = compacted.approvals.find((entry) => entry.kind === 'tool' &&
|
|
90
|
+
entry.reason === RECOVERY_RESTORE_REASON &&
|
|
91
|
+
entry.fingerprint === params.fingerprint &&
|
|
92
|
+
entry.repoRoot === params.repoRoot);
|
|
93
|
+
if (existing)
|
|
94
|
+
return { state: compacted, result: existing };
|
|
95
|
+
compacted.approvals.push(request);
|
|
96
|
+
return { state: compacted, result: request };
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
if (!result)
|
|
100
|
+
throw new Error('recovery_approval_state_busy');
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
export async function recoveryCheckpointCommand(options) {
|
|
104
|
+
const repoRoot = canonicalPath(path.resolve(options.targetDir ?? process.cwd()));
|
|
105
|
+
const config = await loadConfigFile(repoRoot);
|
|
106
|
+
const stateDir = await ensureBelayStateDir(config, repoRoot);
|
|
107
|
+
const checkpointConfig = config.policy.transactional.checkpoint ?? DEFAULT_RECOVERY_CHECKPOINT;
|
|
108
|
+
if (options.subcommand === 'status') {
|
|
109
|
+
const checkpoints = await listRecoveryCheckpoints(stateDir, repoRoot);
|
|
110
|
+
const states = Object.fromEntries([...new Set(checkpoints.map((checkpoint) => checkpoint.state))].map((state) => [
|
|
111
|
+
state,
|
|
112
|
+
checkpoints.filter((checkpoint) => checkpoint.state === state).length,
|
|
113
|
+
]));
|
|
114
|
+
const advisories = summarizeRecoveryCheckpointDiagnostics(checkpoints);
|
|
115
|
+
const backendCounts = Object.fromEntries([...new Set(checkpoints.map((checkpoint) => checkpoint.backend))].map((backend) => [
|
|
116
|
+
backend,
|
|
117
|
+
checkpoints.filter((checkpoint) => checkpoint.backend === backend).length,
|
|
118
|
+
]));
|
|
119
|
+
const checkpointBackends = Object.keys(backendCounts);
|
|
120
|
+
const fileCheckpointConfig = config.policy.transactional.fileCheckpoint;
|
|
121
|
+
const configuredBoundary = config.capability?.boundaryDriver ??
|
|
122
|
+
(config.sandbox.runtime === 'container' ? 'container' : null);
|
|
123
|
+
let isolation = configuredBoundary;
|
|
124
|
+
let isolationAvailable = false;
|
|
125
|
+
if (configuredBoundary) {
|
|
126
|
+
try {
|
|
127
|
+
const session = await boundarySessionStatus({ repoRoot, config });
|
|
128
|
+
isolationAvailable =
|
|
129
|
+
session.fresh === true && session.attestation?.isolatesWorkspaceMounts === true;
|
|
130
|
+
isolation = isolationAvailable ? (session.attestation?.driver ?? configuredBoundary) : null;
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
isolation = null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const copyStrategy = fileCheckpointConfig.enabled ? await probeFileCloneStrategy() : null;
|
|
137
|
+
const fileCheckpointAvailable = fileCheckpointConfig.enabled &&
|
|
138
|
+
config.policy.transactional.enabled &&
|
|
139
|
+
checkpointConfig.enabled &&
|
|
140
|
+
isolationAvailable;
|
|
141
|
+
return {
|
|
142
|
+
ok: true,
|
|
143
|
+
subcommand: 'status',
|
|
144
|
+
repoRoot,
|
|
145
|
+
backend: checkpointBackends.length === 1
|
|
146
|
+
? checkpointBackends[0]
|
|
147
|
+
: checkpointBackends.length > 1
|
|
148
|
+
? 'mixed'
|
|
149
|
+
: 'git_worktree',
|
|
150
|
+
availableBackends: ['git_worktree', 'file_checkpoint'],
|
|
151
|
+
backendCounts,
|
|
152
|
+
fileCheckpoint: {
|
|
153
|
+
enabled: fileCheckpointConfig.enabled,
|
|
154
|
+
allowNonGit: fileCheckpointConfig.allowNonGit,
|
|
155
|
+
isolation,
|
|
156
|
+
copyStrategy: copyStrategy ?? undefined,
|
|
157
|
+
probe: fileCheckpointAvailable ? 'available' : 'unavailable',
|
|
158
|
+
},
|
|
159
|
+
enabled: checkpointConfig.enabled,
|
|
160
|
+
checkpointCount: checkpoints.length,
|
|
161
|
+
storageBytes: await recoveryCheckpointStorageBytes(stateDir, repoRoot),
|
|
162
|
+
recoverableCount: checkpoints.filter((checkpoint) => checkpoint.state === 'applied').length,
|
|
163
|
+
needsManualRepairCount: checkpoints.filter((checkpoint) => checkpoint.state === 'needs_manual_repair').length,
|
|
164
|
+
verifiedReceiptCount: checkpoints.filter((checkpoint) => checkpoint.receiptHash).length,
|
|
165
|
+
states,
|
|
166
|
+
advisories,
|
|
167
|
+
limits: checkpointConfig,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
if (options.subcommand === 'list') {
|
|
171
|
+
return {
|
|
172
|
+
ok: true,
|
|
173
|
+
subcommand: 'list',
|
|
174
|
+
repoRoot,
|
|
175
|
+
checkpoints: await listRecoveryCheckpoints(stateDir, repoRoot),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
if (!options.checkpointId) {
|
|
179
|
+
throw new Error(`recover ${options.subcommand} requires a checkpoint id`);
|
|
180
|
+
}
|
|
181
|
+
if (options.subcommand === 'show') {
|
|
182
|
+
const loaded = await showRecoveryCheckpoint(stateDir, options.checkpointId, repoRoot);
|
|
183
|
+
return {
|
|
184
|
+
ok: true,
|
|
185
|
+
subcommand: 'show',
|
|
186
|
+
manifest: loaded.manifest,
|
|
187
|
+
state: loaded.state,
|
|
188
|
+
manifestHash: loaded.manifestHash,
|
|
189
|
+
receipt: loaded.receipt,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
let binding;
|
|
193
|
+
try {
|
|
194
|
+
binding = await recoveryRestoreBinding(stateDir, options.checkpointId, repoRoot);
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
const reason = error instanceof Error ? error.message : RECOVERY_CHECKPOINT_CORRUPT;
|
|
198
|
+
await appendRecoveryAudit(repoRoot, config, {
|
|
199
|
+
event: reason === RECOVERY_RESTORE_CONFLICT
|
|
200
|
+
? 'recoveryConflict'
|
|
201
|
+
: reason === RECOVERY_CHECKPOINT_CORRUPT
|
|
202
|
+
? 'recoveryCorrupt'
|
|
203
|
+
: 'recoveryRejected',
|
|
204
|
+
recoveryCheckpointId: options.checkpointId,
|
|
205
|
+
reason,
|
|
206
|
+
});
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
209
|
+
const approval = await consumeRecoveryApproval({
|
|
210
|
+
repoRoot,
|
|
211
|
+
config,
|
|
212
|
+
fingerprint: binding.fingerprint,
|
|
213
|
+
});
|
|
214
|
+
if (!approval) {
|
|
215
|
+
const request = await ensureRecoveryPending({
|
|
216
|
+
repoRoot,
|
|
217
|
+
config,
|
|
218
|
+
fingerprint: binding.fingerprint,
|
|
219
|
+
checkpointId: options.checkpointId,
|
|
220
|
+
manifestHash: binding.manifestHash,
|
|
221
|
+
postStateHash: binding.postStateHash,
|
|
222
|
+
paths: binding.paths,
|
|
223
|
+
});
|
|
224
|
+
try {
|
|
225
|
+
const current = await recoveryRestoreBinding(stateDir, options.checkpointId, repoRoot);
|
|
226
|
+
if (current.fingerprint !== binding.fingerprint) {
|
|
227
|
+
throw new Error(RECOVERY_RESTORE_CONFLICT);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
await clearRecoveryPending({ repoRoot, config, fingerprint: binding.fingerprint }).catch(() => { });
|
|
232
|
+
throw error;
|
|
233
|
+
}
|
|
234
|
+
const notificationConfigured = recoveryNotificationConfigured(config);
|
|
235
|
+
if (notificationConfigured) {
|
|
236
|
+
const approvalToken = await issueApprovalToken({
|
|
237
|
+
approvalId: request.approvalId,
|
|
238
|
+
fingerprint: request.fingerprint,
|
|
239
|
+
repoRoot: request.repoRoot,
|
|
240
|
+
issuedAt: request.createdAt,
|
|
241
|
+
expiresAt: request.expiresAt,
|
|
242
|
+
}, configuredControlPlaneDir(config));
|
|
243
|
+
await notifyDeny(config.notifications, {
|
|
244
|
+
approvalId: request.approvalId,
|
|
245
|
+
reason: request.reason,
|
|
246
|
+
summary: request.summary,
|
|
247
|
+
repoRoot: request.repoRoot,
|
|
248
|
+
fingerprint: request.fingerprint,
|
|
249
|
+
approvalToken,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
const auditRecorded = await appendRecoveryAudit(repoRoot, config, {
|
|
253
|
+
event: 'recoveryApprovalRequested',
|
|
254
|
+
recoveryCheckpointId: options.checkpointId,
|
|
255
|
+
approvalId: request.approvalId,
|
|
256
|
+
manifestHash: binding.manifestHash,
|
|
257
|
+
postStateHash: binding.postStateHash,
|
|
258
|
+
pathCount: binding.paths.length,
|
|
259
|
+
});
|
|
260
|
+
return {
|
|
261
|
+
ok: false,
|
|
262
|
+
subcommand: 'apply',
|
|
263
|
+
verdict: 'deny_pending_approval',
|
|
264
|
+
checkpointId: options.checkpointId,
|
|
265
|
+
approvalId: request.approvalId,
|
|
266
|
+
manifestHash: binding.manifestHash,
|
|
267
|
+
paths: binding.paths,
|
|
268
|
+
auditRecorded,
|
|
269
|
+
message: notificationConfigured
|
|
270
|
+
? `Signed out-of-band approval required for ${request.approvalId}. Use the token delivered through the configured notification channel, then run \`belay approve ${request.approvalId} --token <signed-token>\` and repeat this command.`
|
|
271
|
+
: `${recoveryNotificationSetupWarning()} Pending approval id: ${request.approvalId}.`,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
const result = await restoreRecoveryCheckpoint(stateDir, options.checkpointId, repoRoot);
|
|
276
|
+
await clearRecoveryPending({ repoRoot, config, fingerprint: binding.fingerprint }).catch(() => { });
|
|
277
|
+
const auditRecorded = await appendRecoveryAudit(repoRoot, config, {
|
|
278
|
+
event: 'recoveryApplied',
|
|
279
|
+
recoveryCheckpointId: options.checkpointId,
|
|
280
|
+
approvalId: approval.approvalId,
|
|
281
|
+
manifestHash: result.manifestHash,
|
|
282
|
+
changeCount: result.changeCount,
|
|
283
|
+
});
|
|
284
|
+
return {
|
|
285
|
+
ok: true,
|
|
286
|
+
subcommand: 'apply',
|
|
287
|
+
verdict: 'restored',
|
|
288
|
+
checkpointId: options.checkpointId,
|
|
289
|
+
...result,
|
|
290
|
+
auditRecorded,
|
|
291
|
+
message: `Recovery checkpoint ${options.checkpointId} restored.`,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
catch (error) {
|
|
295
|
+
const reason = error instanceof Error ? error.message : 'recovery_restore_failed';
|
|
296
|
+
await appendRecoveryAudit(repoRoot, config, {
|
|
297
|
+
event: reason === RECOVERY_RESTORE_CONFLICT ? 'recoveryConflict' : 'recoveryRejected',
|
|
298
|
+
recoveryCheckpointId: options.checkpointId,
|
|
299
|
+
approvalId: approval.approvalId,
|
|
300
|
+
reason,
|
|
301
|
+
});
|
|
302
|
+
throw error;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
export function formatRecoveryCheckpointResult(result) {
|
|
306
|
+
if (result.subcommand === 'status') {
|
|
307
|
+
const lines = [
|
|
308
|
+
`belay recover status for ${result.repoRoot}`,
|
|
309
|
+
`Backend: ${result.backend}`,
|
|
310
|
+
`Checkpointing: ${result.enabled ? 'enabled' : 'disabled'}`,
|
|
311
|
+
`Checkpoints: ${result.checkpointCount} (${result.recoverableCount} recoverable)`,
|
|
312
|
+
`Needs manual repair: ${result.needsManualRepairCount ?? 0}`,
|
|
313
|
+
`Verified receipts: ${result.verifiedReceiptCount}`,
|
|
314
|
+
`Storage: ${result.storageBytes} bytes`,
|
|
315
|
+
`States: ${JSON.stringify(result.states)}`,
|
|
316
|
+
];
|
|
317
|
+
const advisories = result.advisories;
|
|
318
|
+
if (advisories && advisories.length > 0) {
|
|
319
|
+
lines.push('', 'Advisories:');
|
|
320
|
+
for (const advisory of advisories) {
|
|
321
|
+
lines.push(`- ${advisory}`);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
lines.push('');
|
|
325
|
+
return `${lines.join('\n')}\n`;
|
|
326
|
+
}
|
|
327
|
+
if (result.subcommand === 'list') {
|
|
328
|
+
const checkpoints = result.checkpoints;
|
|
329
|
+
if (checkpoints.length === 0)
|
|
330
|
+
return 'No recovery checkpoints found.\n';
|
|
331
|
+
const lines = checkpoints.map((item) => {
|
|
332
|
+
const diagnostic = formatRecoveryStateDiagnostic(item.state, item.stateDetail);
|
|
333
|
+
const base = `${item.checkpointId}\t${item.state}\t${item.changeCount} changes\treceipt:${item.receiptHash?.slice(0, 12) ?? '-'}\t${item.createdAt}`;
|
|
334
|
+
return diagnostic ? `${base}\n ${diagnostic}` : base;
|
|
335
|
+
});
|
|
336
|
+
return `${lines.join('\n')}\n`;
|
|
337
|
+
}
|
|
338
|
+
if (result.subcommand === 'show') {
|
|
339
|
+
const state = result.state;
|
|
340
|
+
const diagnostic = state?.state !== undefined
|
|
341
|
+
? formatRecoveryStateDiagnostic(state.state, state.detail)
|
|
342
|
+
: null;
|
|
343
|
+
const payload = diagnostic ? { ...result, diagnostic } : result;
|
|
344
|
+
return `${JSON.stringify(payload, null, 2)}\n`;
|
|
345
|
+
}
|
|
346
|
+
return `${String(result.message ?? 'Recovery command completed.')}\n`;
|
|
347
|
+
}
|
package/dist/commands/revoke.js
CHANGED
|
@@ -1,20 +1,32 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
import { loadApprovalState, loadConfigFile, saveApprovalState } from '../config-io.js';
|
|
2
|
+
import { loadApprovalState, loadConfigFile, pendingApprovalsPath, saveApprovalState, } from '../config-io.js';
|
|
3
3
|
import { compactApprovals } from '../core/approval.js';
|
|
4
|
+
import { mutateApprovalStateWithRetry } from '../core/capability/approval-state-mutation.js';
|
|
4
5
|
export async function revokeApproval(options) {
|
|
5
6
|
const repoRoot = path.resolve(options.targetDir ?? process.cwd());
|
|
6
7
|
const config = await loadConfigFile(repoRoot);
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
const revoked = await mutateApprovalStateWithRetry({
|
|
9
|
+
load: async () => ({
|
|
10
|
+
filePath: pendingApprovalsPath(repoRoot, config),
|
|
11
|
+
state: await loadApprovalState(repoRoot, 'pending-approvals.json', config),
|
|
12
|
+
}),
|
|
13
|
+
write: async (_filePath, state) => saveApprovalState(repoRoot, 'pending-approvals.json', state, config),
|
|
14
|
+
mutate: (state) => {
|
|
15
|
+
const compacted = compactApprovals(state);
|
|
16
|
+
const index = compacted.approvals.findIndex((approval) => approval.approvalId === options.approvalId);
|
|
17
|
+
if (index === -1) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
compacted.approvals.splice(index, 1);
|
|
21
|
+
return { state: compacted, result: true };
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
if (revoked !== true) {
|
|
11
25
|
return {
|
|
12
26
|
ok: false,
|
|
13
27
|
message: `Pending approval ${options.approvalId} not found or already expired.`,
|
|
14
28
|
};
|
|
15
29
|
}
|
|
16
|
-
compacted.approvals.splice(index, 1);
|
|
17
|
-
await saveApprovalState(repoRoot, 'pending-approvals.json', compacted, config);
|
|
18
30
|
return {
|
|
19
31
|
ok: true,
|
|
20
32
|
message: `Revoked pending approval ${options.approvalId}.`,
|
package/dist/commands/status.js
CHANGED
|
@@ -23,6 +23,11 @@ export async function statusProject(options = {}) {
|
|
|
23
23
|
dogfood: operational.dogfood,
|
|
24
24
|
health,
|
|
25
25
|
visibility,
|
|
26
|
+
fileCheckpoint: {
|
|
27
|
+
...config.policy.transactional.fileCheckpoint,
|
|
28
|
+
transactionalEnabled: config.policy.transactional.enabled,
|
|
29
|
+
durableCheckpointEnabled: config.policy.transactional.checkpoint?.enabled === true,
|
|
30
|
+
},
|
|
26
31
|
};
|
|
27
32
|
}
|
|
28
33
|
export function formatStatusReport(report) {
|
|
@@ -51,6 +56,8 @@ export function formatStatusReport(report) {
|
|
|
51
56
|
`Dogfood: ${report.dogfood.active ? 'active' : 'inactive'} (mode=${report.dogfood.mode}, unknownLocalEffect=${report.dogfood.unknownLocalEffect})`,
|
|
52
57
|
`Metrics: ${report.dogfood.gateEvents} gate events, ${report.dogfood.wouldBlockCount} would-block (${(report.dogfood.wouldBlockRate * 100).toFixed(1)}%)`,
|
|
53
58
|
`Ready for enforce: ${report.dogfood.readyForEnforce ? 'yes' : 'not yet'}`,
|
|
59
|
+
`File checkpoint: ${report.fileCheckpoint.enabled ? 'enabled' : 'disabled'} (transactional=${report.fileCheckpoint.transactionalEnabled}, durable=${report.fileCheckpoint.durableCheckpointEnabled}, nonGit=${report.fileCheckpoint.allowNonGit})`,
|
|
60
|
+
`File checkpoint limits: files=${report.fileCheckpoint.maxFiles}, sourceBytes=${report.fileCheckpoint.maxSourceBytes}, workspaceBytes=${report.fileCheckpoint.maxWorkspaceBytes}, prepareTimeoutMs=${report.fileCheckpoint.prepareTimeoutMs}, copyConcurrency=${report.fileCheckpoint.copyConcurrency}`,
|
|
54
61
|
'',
|
|
55
62
|
'Audit visibility:',
|
|
56
63
|
` Gate events: ${report.visibility.gateEvents}`,
|
package/dist/config-io.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
1
2
|
import { existsSync } from 'node:fs';
|
|
2
|
-
import { chmod,
|
|
3
|
+
import { chmod, mkdir, open, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { getAdapterLayout } from './adapters/layouts/index.js';
|
|
5
6
|
import { compactApprovals, isExpired, mergeApprovalStates } from './core/approval.js';
|
|
7
|
+
import { mutateApprovalStateWithRetry } from './core/capability/approval-state-mutation.js';
|
|
6
8
|
import { approvedApprovalsFile, belayStateDir, configuredControlPlaneDir, mergeConfig, pendingApprovalsFile, } from './core/config.js';
|
|
7
9
|
import { resolveLayeredConfig, teamConfigPath, } from './core/config-layers.js';
|
|
8
10
|
export function resolveAdapterName(config) {
|
|
@@ -106,12 +108,32 @@ async function repoLocalApprovalsEmpty(repoRoot, config) {
|
|
|
106
108
|
return true;
|
|
107
109
|
}
|
|
108
110
|
async function writeApprovalStateFile(filePath, state) {
|
|
109
|
-
|
|
110
|
-
await
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
111
|
+
const directory = path.dirname(filePath);
|
|
112
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
113
|
+
const tempPath = path.join(directory, `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`);
|
|
114
|
+
let handle = null;
|
|
115
|
+
try {
|
|
116
|
+
handle = await open(tempPath, 'wx', 0o600);
|
|
117
|
+
await handle.writeFile(`${JSON.stringify(compactApprovals(state), null, 2)}\n`, 'utf8');
|
|
118
|
+
await handle.sync();
|
|
119
|
+
await handle.close();
|
|
120
|
+
handle = null;
|
|
121
|
+
await rename(tempPath, filePath);
|
|
122
|
+
await chmod(filePath, 0o600);
|
|
123
|
+
const directoryHandle = await open(directory, 'r');
|
|
124
|
+
try {
|
|
125
|
+
await directoryHandle.sync().catch(() => undefined);
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
await directoryHandle.close();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
if (handle) {
|
|
133
|
+
await handle.close().catch(() => undefined);
|
|
134
|
+
}
|
|
135
|
+
await unlink(tempPath).catch(() => undefined);
|
|
136
|
+
}
|
|
115
137
|
}
|
|
116
138
|
async function migrateApprovalFilesBetween(sourceDir, targetDir) {
|
|
117
139
|
await mkdir(targetDir, { recursive: true, mode: 0o700 });
|
|
@@ -121,20 +143,29 @@ async function migrateApprovalFilesBetween(sourceDir, targetDir) {
|
|
|
121
143
|
if (!existsSync(from)) {
|
|
122
144
|
continue;
|
|
123
145
|
}
|
|
124
|
-
if (
|
|
125
|
-
|
|
126
|
-
continue;
|
|
127
|
-
}
|
|
128
|
-
await copyFile(from, to);
|
|
129
|
-
await chmod(to, 0o600);
|
|
146
|
+
if ((await isCorruptApprovalStateFile(from)) ||
|
|
147
|
+
(existsSync(to) && (await isCorruptApprovalStateFile(to)))) {
|
|
130
148
|
continue;
|
|
131
149
|
}
|
|
132
|
-
if ((await isCorruptApprovalStateFile(from)) || (await isCorruptApprovalStateFile(to))) {
|
|
133
|
-
continue;
|
|
134
|
-
}
|
|
135
|
-
const targetState = await readApprovalStateFileStrict(to);
|
|
136
150
|
const sourceState = await readApprovalStateFileStrict(from);
|
|
137
|
-
await
|
|
151
|
+
const migrated = await mutateApprovalStateWithRetry({
|
|
152
|
+
load: async () => ({
|
|
153
|
+
filePath: to,
|
|
154
|
+
state: existsSync(to)
|
|
155
|
+
? await readApprovalStateFileStrict(to)
|
|
156
|
+
: { version: 1, approvals: [] },
|
|
157
|
+
}),
|
|
158
|
+
write: writeApprovalStateFile,
|
|
159
|
+
mutate: (targetState) => ({
|
|
160
|
+
state: targetState.approvals.length === 0
|
|
161
|
+
? sourceState
|
|
162
|
+
: mergeApprovalStates(targetState, sourceState),
|
|
163
|
+
result: true,
|
|
164
|
+
}),
|
|
165
|
+
});
|
|
166
|
+
if (migrated !== true) {
|
|
167
|
+
throw new Error(`Failed to migrate approval state to ${to}`);
|
|
168
|
+
}
|
|
138
169
|
}
|
|
139
170
|
}
|
|
140
171
|
export async function migrateRepoLocalApprovalsToControlPlane(repoRoot, config) {
|
|
@@ -65,6 +65,12 @@ export const GUARANTEE_SCENARIOS = {
|
|
|
65
65
|
command: 'git status',
|
|
66
66
|
permission: 'allow',
|
|
67
67
|
},
|
|
68
|
+
{
|
|
69
|
+
id: 'l2-allow-dirty-git-file-checkpoint',
|
|
70
|
+
command: 'touch notes.txt',
|
|
71
|
+
permission: 'allow',
|
|
72
|
+
substrate: 'dirty_git_file_checkpoint',
|
|
73
|
+
},
|
|
68
74
|
{
|
|
69
75
|
id: 'l2-deny-read-egress',
|
|
70
76
|
command: 'curl https://example.com',
|
|
@@ -40,6 +40,6 @@ export declare function buildReplayEnvelopeFields(input: ReplayEnvelopeInput): P
|
|
|
40
40
|
export declare function hasReplayEnvelope(approval: ApprovalRecord): boolean;
|
|
41
41
|
export declare function validateReplayEnvelope(approval: ApprovalRecord, action: ReplayActionContext): boolean;
|
|
42
42
|
export declare function canAutoReplay(config: BelayConfigV4, kind: ApprovalRecord['kind'], adapter?: ReplayAdapterId): boolean;
|
|
43
|
-
export declare function buildRetryInstructionForConfig(config: BelayConfigV4, tokenPrefix: string, approvalId: string): string;
|
|
43
|
+
export declare function buildRetryInstructionForConfig(config: BelayConfigV4, tokenPrefix: string, approvalId: string, kind?: ApprovalRecord['kind'], adapter?: ReplayAdapterId): string;
|
|
44
44
|
export declare function buildApprovalRecordedMessage(config: BelayConfigV4, approval: ApprovalRecord, adapter?: ReplayAdapterId): string;
|
|
45
45
|
export declare function buildReplayHint(config: BelayConfigV4, approval: ApprovalRecord, adapter?: ReplayAdapterId): ApprovalReplayHint | null;
|
|
@@ -103,7 +103,10 @@ export function canAutoReplay(config, kind, adapter) {
|
|
|
103
103
|
}
|
|
104
104
|
return kind === 'shell' && scopes?.shell !== false;
|
|
105
105
|
}
|
|
106
|
-
export function buildRetryInstructionForConfig(config, tokenPrefix, approvalId) {
|
|
106
|
+
export function buildRetryInstructionForConfig(config, tokenPrefix, approvalId, kind, adapter) {
|
|
107
|
+
if (kind === 'shell' && canAutoReplay(config, kind, adapter)) {
|
|
108
|
+
return `To approve and run this exact shell action once, send ${tokenPrefix} ${approvalId}. No follow-up prompt is required.`;
|
|
109
|
+
}
|
|
107
110
|
if (approvalFlow(config) === 'one_step') {
|
|
108
111
|
return `To allow this action once, send ${tokenPrefix} ${approvalId}. After approval, retry the same action unchanged.`;
|
|
109
112
|
}
|
|
@@ -26,11 +26,16 @@ export declare function recordApproval(params: {
|
|
|
26
26
|
message: string;
|
|
27
27
|
approval?: ApprovalStateFile['approvals'][number];
|
|
28
28
|
}>;
|
|
29
|
-
/**
|
|
29
|
+
/** @deprecated Replay callers must claim before execution with `claimApprovedForReplay`. */
|
|
30
30
|
export declare function consumeApprovedAfterCliReplay(params: {
|
|
31
31
|
approvalId: string;
|
|
32
32
|
store: ApprovalStore;
|
|
33
33
|
}): Promise<void>;
|
|
34
|
+
/** Atomically spend and return a one-shot approval before executing its replay. */
|
|
35
|
+
export declare function claimApprovedForReplay(params: {
|
|
36
|
+
approvalId: string;
|
|
37
|
+
store: ApprovalStore;
|
|
38
|
+
}): Promise<ApprovalStateFile['approvals'][number] | null>;
|
|
34
39
|
export declare function createGateApprovalStore(repoRoot: string, config: BelayConfigV3): ApprovalStore;
|
|
35
40
|
export declare function gateApprovalStoreFromDeps(deps: {
|
|
36
41
|
loadApprovals: (fileName: 'pending-approvals.json' | 'approved-approvals.json') => Promise<{
|