@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.
Files changed (143) hide show
  1. package/README.md +20 -1
  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 +420 -220
  5. package/dist/bundle/claude-runtime.mjs +13215 -8864
  6. package/dist/bundle/codex-runtime.mjs +10771 -6453
  7. package/dist/bundle/cursor-runtime.mjs +10797 -6484
  8. package/dist/cli.js +37 -1
  9. package/dist/commands/approve.js +34 -10
  10. package/dist/commands/doctor.js +51 -0
  11. package/dist/commands/recovery-checkpoints.d.ts +164 -0
  12. package/dist/commands/recovery-checkpoints.js +347 -0
  13. package/dist/commands/revoke.js +19 -7
  14. package/dist/commands/status.js +7 -0
  15. package/dist/config-io.js +49 -18
  16. package/dist/conformance/guarantee-table.js +6 -0
  17. package/dist/conformance/types.d.ts +1 -0
  18. package/dist/core/approval-replay.d.ts +1 -1
  19. package/dist/core/approval-replay.js +4 -1
  20. package/dist/core/approval-service.d.ts +6 -1
  21. package/dist/core/approval-service.js +86 -27
  22. package/dist/core/approval.d.ts +2 -0
  23. package/dist/core/approval.js +6 -3
  24. package/dist/core/audit-types.d.ts +13 -0
  25. package/dist/core/capability/approval-state-mutation.d.ts +19 -0
  26. package/dist/core/capability/approval-state-mutation.js +76 -3
  27. package/dist/core/capability/approval-v3.d.ts +18 -1
  28. package/dist/core/capability/approval-v3.js +63 -23
  29. package/dist/core/capability/attestation.d.ts +3 -0
  30. package/dist/core/capability/attestation.js +7 -0
  31. package/dist/core/capability/boundary-driver-container.d.ts +12 -0
  32. package/dist/core/capability/boundary-driver-container.js +101 -34
  33. package/dist/core/capability/boundary-driver.js +1 -0
  34. package/dist/core/capability/boundary-grant-materialize.d.ts +1 -0
  35. package/dist/core/capability/boundary-grant-materialize.js +15 -0
  36. package/dist/core/capability/boundary-run.d.ts +25 -0
  37. package/dist/core/capability/boundary-run.js +37 -9
  38. package/dist/core/capability/boundary-session.d.ts +4 -0
  39. package/dist/core/capability/boundary-session.js +9 -0
  40. package/dist/core/capability/boundary-workspace-mount.d.ts +5 -0
  41. package/dist/core/capability/boundary-workspace-mount.js +68 -0
  42. package/dist/core/capability/gate-policy-shadow.js +23 -1
  43. package/dist/core/capability/grant-consumption.d.ts +4 -0
  44. package/dist/core/capability/grant-consumption.js +11 -0
  45. package/dist/core/capability/grant-lease.d.ts +36 -1
  46. package/dist/core/capability/grant-lease.js +213 -16
  47. package/dist/core/capability/grant-match.js +3 -0
  48. package/dist/core/capability/index.d.ts +1 -1
  49. package/dist/core/capability/index.js +1 -1
  50. package/dist/core/capability/policy-engine.d.ts +7 -0
  51. package/dist/core/capability/policy-engine.js +202 -15
  52. package/dist/core/capability/request.d.ts +3 -0
  53. package/dist/core/config.d.ts +20 -0
  54. package/dist/core/config.js +62 -0
  55. package/dist/core/effect-ir/audit.d.ts +9 -0
  56. package/dist/core/effect-ir/audit.js +86 -0
  57. package/dist/core/effect-ir/build.d.ts +31 -0
  58. package/dist/core/effect-ir/build.js +277 -0
  59. package/dist/core/effect-ir/index.d.ts +8 -0
  60. package/dist/core/effect-ir/index.js +7 -0
  61. package/dist/core/effect-ir/normalize.d.ts +4 -0
  62. package/dist/core/effect-ir/normalize.js +104 -0
  63. package/dist/core/effect-ir/package-exec-eval.d.ts +8 -0
  64. package/dist/core/effect-ir/package-exec-eval.js +152 -0
  65. package/dist/core/effect-ir/package-exec.d.ts +29 -0
  66. package/dist/core/effect-ir/package-exec.js +292 -0
  67. package/dist/core/effect-ir/policy.d.ts +13 -0
  68. package/dist/core/effect-ir/policy.js +77 -0
  69. package/dist/core/effect-ir/types.d.ts +55 -0
  70. package/dist/core/effect-ir/types.js +1 -0
  71. package/dist/core/egress/allowlist.d.ts +1 -0
  72. package/dist/core/egress/allowlist.js +8 -0
  73. package/dist/core/egress-approval.js +45 -51
  74. package/dist/core/gate-contract.d.ts +2 -0
  75. package/dist/core/gate-contract.js +2 -0
  76. package/dist/core/gate-engine.js +26 -9
  77. package/dist/core/index.d.ts +3 -1
  78. package/dist/core/index.js +2 -1
  79. package/dist/core/judge-cloud-consent.js +29 -15
  80. package/dist/core/network-endpoint.d.ts +10 -0
  81. package/dist/core/network-endpoint.js +51 -0
  82. package/dist/core/process-runner.d.ts +10 -0
  83. package/dist/core/process-runner.js +124 -0
  84. package/dist/core/recover-advice.js +2 -1
  85. package/dist/core/recovery/artifact-store.d.ts +29 -0
  86. package/dist/core/recovery/artifact-store.js +336 -0
  87. package/dist/core/recovery/checkpoint.d.ts +25 -0
  88. package/dist/core/recovery/checkpoint.js +260 -0
  89. package/dist/core/recovery/index.d.ts +3 -1
  90. package/dist/core/recovery/index.js +2 -0
  91. package/dist/core/recovery/operator-guidance.d.ts +7 -0
  92. package/dist/core/recovery/operator-guidance.js +39 -0
  93. package/dist/core/recovery/reconcile.d.ts +3 -0
  94. package/dist/core/recovery/reconcile.js +48 -0
  95. package/dist/core/recovery/resource-identity.d.ts +4 -0
  96. package/dist/core/recovery/resource-identity.js +34 -0
  97. package/dist/core/recovery/restore.d.ts +13 -0
  98. package/dist/core/recovery/restore.js +114 -0
  99. package/dist/core/recovery/snapshot-node.d.ts +27 -0
  100. package/dist/core/recovery/snapshot-node.js +261 -0
  101. package/dist/core/recovery/types.d.ts +85 -1
  102. package/dist/core/transactional/apply-observed-changes.d.ts +13 -0
  103. package/dist/core/transactional/apply-observed-changes.js +275 -0
  104. package/dist/core/transactional/backend-selector.d.ts +8 -0
  105. package/dist/core/transactional/backend-selector.js +118 -0
  106. package/dist/core/transactional/backend.d.ts +55 -0
  107. package/dist/core/transactional/backend.js +1 -0
  108. package/dist/core/transactional/file-checkpoint-backend.d.ts +3 -0
  109. package/dist/core/transactional/file-checkpoint-backend.js +278 -0
  110. package/dist/core/transactional/file-checkpoint-git.d.ts +11 -0
  111. package/dist/core/transactional/file-checkpoint-git.js +217 -0
  112. package/dist/core/transactional/file-checkpoint-staging.d.ts +13 -0
  113. package/dist/core/transactional/file-checkpoint-staging.js +54 -0
  114. package/dist/core/transactional/file-clone.d.ts +15 -0
  115. package/dist/core/transactional/file-clone.js +139 -0
  116. package/dist/core/transactional/file-tree-path.d.ts +7 -0
  117. package/dist/core/transactional/file-tree-path.js +50 -0
  118. package/dist/core/transactional/file-tree.d.ts +41 -0
  119. package/dist/core/transactional/file-tree.js +186 -0
  120. package/dist/core/transactional/git-worktree-backend.d.ts +2 -0
  121. package/dist/core/transactional/git-worktree-backend.js +50 -0
  122. package/dist/core/transactional/git-worktree.d.ts +9 -11
  123. package/dist/core/transactional/git-worktree.js +24 -119
  124. package/dist/core/transactional/index.d.ts +11 -0
  125. package/dist/core/transactional/index.js +10 -0
  126. package/dist/core/transactional/reasons.js +1 -0
  127. package/dist/core/transactional/runner.js +157 -25
  128. package/dist/core/transactional/snapshot-node.d.ts +26 -0
  129. package/dist/core/transactional/snapshot-node.js +87 -0
  130. package/dist/core/transactional/types.d.ts +17 -1
  131. package/dist/core/types.d.ts +10 -1
  132. package/dist/core/verdict/adapter.js +8 -0
  133. package/dist/core/verdict/launcher-resolve.js +2 -20
  134. package/dist/core/verdict/shell-policy.d.ts +1 -0
  135. package/dist/core/verdict/shell-policy.js +1 -1
  136. package/dist/core/verdict/types.d.ts +5 -0
  137. package/dist/core/verdict/verdict.js +37 -20
  138. package/dist/types.d.ts +11 -0
  139. package/dist/version.d.ts +1 -1
  140. package/dist/version.js +1 -1
  141. package/package.json +1 -1
  142. package/skills/belay/SKILL.md +8 -3
  143. package/skills/belay/belay-approve.md +10 -7
@@ -1,23 +1,15 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { compactApprovals, createApprovalRecord } from './approval.js';
3
3
  import { issueApprovalToken } from './approval-token.js';
4
+ import { mutateApprovalStateWithRetry } from './capability/approval-state-mutation.js';
4
5
  import { configuredControlPlaneDir } from './config.js';
5
- import { addDomainToAllowlist, loadEgressAllowlist, saveEgressAllowlist, } from './egress/allowlist.js';
6
+ import { addDomainToAllowlist, mutateEgressAllowlist } from './egress/allowlist.js';
6
7
  import { parseHostFromSummary } from './egress/fingerprint.js';
7
8
  import { notifyDeny } from './notify.js';
8
9
  export async function ensurePendingEgressApproval(params) {
9
10
  const { config, repoRoot, policyResult, store } = params;
10
- const pending = await store.loadPending();
11
- pending.state = compactApprovals(pending.state);
12
- const existing = pending.state.approvals.find((approval) => approval.kind === 'egress' &&
13
- approval.fingerprint === policyResult.fingerprint &&
14
- approval.repoRoot === repoRoot);
15
- if (existing) {
16
- await store.writePending(pending.filePath, pending.state);
17
- return { approvalId: existing.approvalId, approval: existing, created: false };
18
- }
19
11
  const approvalId = `belay_${randomUUID().replaceAll('-', '').slice(0, 12)}`;
20
- const approval = createApprovalRecord({
12
+ const candidate = createApprovalRecord({
21
13
  kind: 'egress',
22
14
  fingerprint: policyResult.fingerprint,
23
15
  repoRoot,
@@ -26,44 +18,47 @@ export async function ensurePendingEgressApproval(params) {
26
18
  approvalTtlMinutes: config.approvalTtlMinutes,
27
19
  approvalId,
28
20
  });
29
- pending.state.approvals.push(approval);
30
- await store.writePending(pending.filePath, pending.state);
31
- return { approvalId, approval, created: true };
32
- }
33
- const consumeLocks = new Map();
34
- async function withConsumeLock(key, fn) {
35
- const previous = consumeLocks.get(key) ?? Promise.resolve();
36
- let release;
37
- const gate = new Promise((resolve) => {
38
- release = resolve;
21
+ const outcome = await mutateApprovalStateWithRetry({
22
+ load: store.loadPending,
23
+ write: store.writePending,
24
+ mutate: (state) => {
25
+ const compacted = compactApprovals(state);
26
+ const existing = compacted.approvals.find((approval) => approval.kind === 'egress' &&
27
+ approval.fingerprint === policyResult.fingerprint &&
28
+ approval.repoRoot === repoRoot);
29
+ if (existing) {
30
+ return {
31
+ state: compacted,
32
+ result: { approvalId: existing.approvalId, approval: existing, created: false },
33
+ };
34
+ }
35
+ compacted.approvals.push(candidate);
36
+ return {
37
+ state: compacted,
38
+ result: { approvalId, approval: candidate, created: true },
39
+ };
40
+ },
39
41
  });
40
- consumeLocks.set(key, previous.then(() => gate));
41
- await previous;
42
- try {
43
- return await fn();
44
- }
45
- finally {
46
- release();
47
- if (consumeLocks.get(key) === gate) {
48
- consumeLocks.delete(key);
49
- }
42
+ if (!outcome) {
43
+ throw new Error('Failed to persist pending egress approval');
50
44
  }
45
+ return outcome;
51
46
  }
52
47
  export async function consumeApprovedEgress(params) {
53
- const lockKey = `${params.repoRoot}:${params.fingerprint}`;
54
- return withConsumeLock(lockKey, async () => {
55
- const approved = await params.store.loadApproved();
56
- approved.state = compactApprovals(approved.state);
57
- const index = approved.state.approvals.findIndex((approval) => approval.kind === 'egress' &&
58
- approval.fingerprint === params.fingerprint &&
59
- approval.repoRoot === params.repoRoot);
60
- if (index === -1) {
61
- await params.store.writeApproved(approved.filePath, approved.state);
62
- return null;
63
- }
64
- const [approval] = approved.state.approvals.splice(index, 1);
65
- await params.store.writeApproved(approved.filePath, approved.state);
66
- return approval;
48
+ return mutateApprovalStateWithRetry({
49
+ load: params.store.loadApproved,
50
+ write: params.store.writeApproved,
51
+ mutate: (state) => {
52
+ const compacted = compactApprovals(state);
53
+ const index = compacted.approvals.findIndex((approval) => approval.kind === 'egress' &&
54
+ approval.fingerprint === params.fingerprint &&
55
+ approval.repoRoot === params.repoRoot);
56
+ if (index === -1) {
57
+ return null;
58
+ }
59
+ const [approval] = compacted.approvals.splice(index, 1);
60
+ return { state: compacted, result: approval ?? null };
61
+ },
67
62
  });
68
63
  }
69
64
  export async function notifyEgressDeny(params) {
@@ -98,8 +93,8 @@ export async function recordEgressApproval(params) {
98
93
  const { recordApproval } = await import('./approval-service.js');
99
94
  const pending = await params.store.loadPending();
100
95
  const match = pending.state.approvals.find((approval) => approval.approvalId === params.approvalId);
101
- const host = match ? parseHostFromSummary(match.summary) : null;
102
- if (params.scope === 'domain' && match && !host) {
96
+ const pendingHost = match ? parseHostFromSummary(match.summary) : null;
97
+ if (params.scope === 'domain' && match && !pendingHost) {
103
98
  return {
104
99
  ok: false,
105
100
  message: `Cannot add domain to egress allowlist: could not parse host from summary "${match.summary}".`,
@@ -112,16 +107,15 @@ export async function recordEgressApproval(params) {
112
107
  requireSignedToken: params.requireSignedToken ?? false,
113
108
  store: params.store,
114
109
  });
110
+ const host = result.approval ? parseHostFromSummary(result.approval.summary) : pendingHost;
115
111
  if (!result.ok || params.scope !== 'domain' || !host) {
116
112
  return { ok: result.ok, message: result.message };
117
113
  }
118
- const allowlist = await loadEgressAllowlist(params.store.allowlistPath);
119
- const updated = addDomainToAllowlist(allowlist, {
114
+ await mutateEgressAllowlist(params.store.allowlistPath, (allowlist) => addDomainToAllowlist(allowlist, {
120
115
  host,
121
116
  approvedAt: new Date().toISOString(),
122
117
  approvalId: params.approvalId,
123
- });
124
- await saveEgressAllowlist(params.store.allowlistPath, updated);
118
+ }));
125
119
  return {
126
120
  ok: true,
127
121
  message: `${result.message} Domain ${host} added to egress allowlist.`,
@@ -31,6 +31,8 @@ export interface GateVerdict extends GatePermissionResponse {
31
31
  axes?: ClassifyResult['axes'];
32
32
  capabilityRequests?: ClassifyResult['capabilityRequests'];
33
33
  authorizationDecision?: ClassifyResult['authorizationDecision'];
34
+ effectPlan?: ClassifyResult['effectPlan'];
35
+ effectPlanPolicyDecisions?: ClassifyResult['effectPlanPolicyDecisions'];
34
36
  boundaryProfile?: string;
35
37
  }
36
38
  export declare function isGatedAction(value: unknown): value is GatedAction;
@@ -28,6 +28,8 @@ export function classifyResultToGateVerdict(params) {
28
28
  axes: result.axes,
29
29
  capabilityRequests: result.capabilityRequests,
30
30
  authorizationDecision: result.authorizationDecision,
31
+ effectPlan: result.effectPlan,
32
+ effectPlanPolicyDecisions: result.effectPlanPolicyDecisions,
31
33
  boundaryProfile: result.boundaryProfile,
32
34
  };
33
35
  }
@@ -4,6 +4,7 @@ import { collectOutsideRepoPaths, collectOutsideRepoPathsFromToolPayload, } from
4
4
  import { classifySubagent } from './classify-subagent.js';
5
5
  import { classifyToolUse } from './classify-tool.js';
6
6
  import { classifierOptionsFromConfig } from './config.js';
7
+ import { buildCapabilityEffectPlan } from './effect-ir/build.js';
7
8
  import { GATE_CONTRACT_VERSION } from './gate-contract.js';
8
9
  import { mergeAgentAssessment } from './judgment.js';
9
10
  import { classifyShell } from './verdict/adapter.js';
@@ -200,7 +201,7 @@ function applyToolSandboxPolicies(action, result, options) {
200
201
  export async function classifyGatedAction(action, config, extraOptions = {}) {
201
202
  const limitResult = checkGatedActionLimits(action);
202
203
  if (limitResult) {
203
- return limitResult;
204
+ return attachEffectPlan(action, limitResult);
204
205
  }
205
206
  const options = { ...classifierOptionsFromConfig(config), ...extraOptions };
206
207
  if (action.kind === 'shell') {
@@ -212,36 +213,52 @@ export async function classifyGatedAction(action, config, extraOptions = {}) {
212
213
  result = applySandboxOutsideBoundary(command, action, result, options);
213
214
  result = applyShellPeripheralPolicy(command, action, result, options);
214
215
  if (!action.agentAssessment) {
215
- return result;
216
+ return attachEffectPlan(action, result);
216
217
  }
217
218
  const merged = mergeAgentAssessment(result.assessment, action.agentAssessment);
218
219
  if (!merged.mismatch) {
219
- return { ...result, assessment: merged.assessment };
220
+ return attachEffectPlan(action, { ...result, assessment: merged.assessment });
220
221
  }
221
- return {
222
+ return attachEffectPlan(action, {
222
223
  ...result,
223
224
  verdict: 'deny_pending_approval',
224
225
  reason: 'agent_assessment_mismatch',
225
226
  assessment: merged.assessment,
226
- };
227
+ });
227
228
  }
228
229
  if (action.kind === 'subagent') {
229
- return classifySubagent(action.payload ?? {}, action.repoRoot, options, config);
230
+ return attachEffectPlan(action, classifySubagent(action.payload ?? {}, action.repoRoot, options, config));
230
231
  }
231
232
  let result = await classifyToolUse(action.payload ?? {}, action.repoRoot, action.cwd, config, options);
232
233
  result = applyToolSandboxPolicies(action, result, options);
233
234
  if (!action.agentAssessment) {
234
- return result;
235
+ return attachEffectPlan(action, result);
235
236
  }
236
237
  const merged = mergeAgentAssessment(result.assessment, action.agentAssessment);
237
238
  if (!merged.mismatch) {
238
- return { ...result, assessment: merged.assessment };
239
+ return attachEffectPlan(action, { ...result, assessment: merged.assessment });
239
240
  }
240
- return {
241
+ return attachEffectPlan(action, {
241
242
  ...result,
242
243
  verdict: 'deny_pending_approval',
243
244
  reason: 'agent_assessment_mismatch',
244
245
  assessment: merged.assessment,
246
+ });
247
+ }
248
+ function attachEffectPlan(action, result) {
249
+ if (result.effectPlan) {
250
+ return result;
251
+ }
252
+ const summary = result.normalizedCommand ?? result.summary ?? action.command ?? action.kind;
253
+ return {
254
+ ...result,
255
+ effectPlan: buildCapabilityEffectPlan({
256
+ actionKind: action.kind,
257
+ summary,
258
+ inputFingerprint: result.fingerprint,
259
+ requests: result.capabilityRequests ?? [],
260
+ effectFree: result.reason === 'read_only',
261
+ }),
245
262
  };
246
263
  }
247
264
  export async function classifyGatedActionAsync(action, config, extraOptions = {}) {
@@ -3,7 +3,7 @@ export type { ApprovalReplayHint, ReplayActionContext, ReplayAdapterId, } from '
3
3
  /** @deprecated Use `buildRetryInstructionForConfig` */
4
4
  export { approvalFlow, buildApprovalRecordedMessage, buildReplayEnvelopeFields, buildReplayHint, buildRetryInstructionForConfig, buildRetryInstructionForConfig as buildRetryInstruction, canAutoReplay, getExecutionLeaseMs, replayShellCommand, validateReplayEnvelope, } from './approval-replay.js';
5
5
  export type { ApprovalStore } from './approval-service.js';
6
- export { consumeApprovedAfterCliReplay, createGateApprovalStore, gateApprovalStoreFromDeps, recordApproval, } from './approval-service.js';
6
+ export { claimApprovedForReplay, consumeApprovedAfterCliReplay, createGateApprovalStore, gateApprovalStoreFromDeps, recordApproval, } from './approval-service.js';
7
7
  export type { AuditMetricsReport } from './audit-metrics.js';
8
8
  export { computeAuditMetrics, parseAuditNdjson } from './audit-metrics.js';
9
9
  export { classifySubagent } from './classify-subagent.js';
@@ -16,6 +16,8 @@ export { classifyResultToGateVerdict, GATE_CONTRACT_VERSION, isGatedAction, unno
16
16
  export { classifyGatedAction, GateNormalizationError, gateEnabledForAction, normalizeGatedAction, } from './gate-engine.js';
17
17
  export { matchesSensitivePath } from './glob.js';
18
18
  export { canonicalPath, hasOutsideRepoPath, normalizeToken, pathWithinRoot, relativeWithinRepo, resolveMutationTarget, } from './path-utils.js';
19
+ export type { RecoveryBackend, RecoveryCheckpointEntry, RecoveryCheckpointEntryV1, RecoveryCheckpointEntryV2, RecoveryCheckpointManifest, RecoveryCheckpointManifestV1, RecoveryCheckpointManifestV2, RecoveryCheckpointState, RecoveryCheckpointSummary, RecoveryFileSnapshotV1, RecoveryFileSnapshotV2, RecoveryProofV1, RecoveryReceiptV1, } from './recovery/index.js';
20
+ export { listRecoveryCheckpoints, RECOVERY_CHECKPOINT_CORRUPT, RECOVERY_CHECKPOINT_QUOTA, RECOVERY_RESTORE_CONFLICT, recoveryCheckpointStorageBytes, recoveryRestoreBinding, restoreRecoveryCheckpoint, showRecoveryCheckpoint, } from './recovery/index.js';
19
21
  export { fingerprintReplayPayload, subagentFingerprintSource } from './replay-scrub.js';
20
22
  export { scrubString, scrubValue } from './scrub.js';
21
23
  export { findCommandSubstitutions, MAX_SUBSTITUTION_DEPTH } from './shell-substitution.js';
@@ -1,7 +1,7 @@
1
1
  export { APPROVAL_EXECUTION_LEASE_MS, approvalCommandMatch, compactApprovals, createApprovalRecord, createApprovalRecordWithEnvelope, isExecutionLeaseExpired, isExpired, mergeApprovalStates, nowIso, } from './approval.js';
2
2
  /** @deprecated Use `buildRetryInstructionForConfig` */
3
3
  export { approvalFlow, buildApprovalRecordedMessage, buildReplayEnvelopeFields, buildReplayHint, buildRetryInstructionForConfig, buildRetryInstructionForConfig as buildRetryInstruction, canAutoReplay, getExecutionLeaseMs, replayShellCommand, validateReplayEnvelope, } from './approval-replay.js';
4
- export { consumeApprovedAfterCliReplay, createGateApprovalStore, gateApprovalStoreFromDeps, recordApproval, } from './approval-service.js';
4
+ export { claimApprovedForReplay, consumeApprovedAfterCliReplay, createGateApprovalStore, gateApprovalStoreFromDeps, recordApproval, } from './approval-service.js';
5
5
  export { computeAuditMetrics, parseAuditNdjson } from './audit-metrics.js';
6
6
  export { classifySubagent } from './classify-subagent.js';
7
7
  export { classifyToolUse } from './classify-tool.js';
@@ -12,6 +12,7 @@ export { classifyResultToGateVerdict, GATE_CONTRACT_VERSION, isGatedAction, unno
12
12
  export { classifyGatedAction, GateNormalizationError, gateEnabledForAction, normalizeGatedAction, } from './gate-engine.js';
13
13
  export { matchesSensitivePath } from './glob.js';
14
14
  export { canonicalPath, hasOutsideRepoPath, normalizeToken, pathWithinRoot, relativeWithinRepo, resolveMutationTarget, } from './path-utils.js';
15
+ export { listRecoveryCheckpoints, RECOVERY_CHECKPOINT_CORRUPT, RECOVERY_CHECKPOINT_QUOTA, RECOVERY_RESTORE_CONFLICT, recoveryCheckpointStorageBytes, recoveryRestoreBinding, restoreRecoveryCheckpoint, showRecoveryCheckpoint, } from './recovery/index.js';
15
16
  export { fingerprintReplayPayload, subagentFingerprintSource } from './replay-scrub.js';
16
17
  export { scrubString, scrubValue } from './scrub.js';
17
18
  export { findCommandSubstitutions, MAX_SUBSTITUTION_DEPTH } from './shell-substitution.js';
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { loadApprovalState, saveApprovalState } from '../config-io.js';
2
+ import { loadApprovalState, pendingApprovalsPath, saveApprovalState } from '../config-io.js';
3
3
  import { compactApprovals, createApprovalRecord } from './approval.js';
4
+ import { mutateApprovalStateWithRetry } from './capability/approval-state-mutation.js';
4
5
  import { JUDGE_CLOUD_CONSENT_REASON } from './capability/reasons.js';
5
6
  export { JUDGE_CLOUD_CONSENT_REASON };
6
7
  export function judgeCloudConsentFingerprint(providerId, endpoint) {
@@ -10,18 +11,8 @@ export async function ensurePendingJudgeCloudConsentApproval(params) {
10
11
  const { repoRoot, config, providerId, endpoint } = params;
11
12
  const normalizedEndpoint = endpoint.trim();
12
13
  const fingerprint = judgeCloudConsentFingerprint(providerId, normalizedEndpoint);
13
- const pending = await loadApprovalState(repoRoot, 'pending-approvals.json', config);
14
- const compacted = compactApprovals(pending);
15
- const existing = compacted.approvals.find((approval) => approval.kind === 'capability' &&
16
- approval.reason === JUDGE_CLOUD_CONSENT_REASON &&
17
- approval.fingerprint === fingerprint &&
18
- approval.repoRoot === repoRoot);
19
- if (existing) {
20
- await saveApprovalState(repoRoot, 'pending-approvals.json', compacted, config);
21
- return { approvalId: existing.approvalId, created: false };
22
- }
23
14
  const approvalId = `belay_${randomUUID().replaceAll('-', '').slice(0, 12)}`;
24
- const approval = createApprovalRecord({
15
+ const candidate = createApprovalRecord({
25
16
  kind: 'capability',
26
17
  fingerprint,
27
18
  repoRoot,
@@ -32,7 +23,30 @@ export async function ensurePendingJudgeCloudConsentApproval(params) {
32
23
  input: `belay judge use ${providerId} --endpoint ${normalizedEndpoint}`,
33
24
  inputKind: 'shell',
34
25
  });
35
- compacted.approvals.push(approval);
36
- await saveApprovalState(repoRoot, 'pending-approvals.json', compacted, config);
37
- return { approvalId, created: true };
26
+ const outcome = await mutateApprovalStateWithRetry({
27
+ load: async () => ({
28
+ filePath: pendingApprovalsPath(repoRoot, config),
29
+ state: await loadApprovalState(repoRoot, 'pending-approvals.json', config),
30
+ }),
31
+ write: async (_filePath, state) => saveApprovalState(repoRoot, 'pending-approvals.json', state, config),
32
+ mutate: (state) => {
33
+ const compacted = compactApprovals(state);
34
+ const existing = compacted.approvals.find((approval) => approval.kind === 'capability' &&
35
+ approval.reason === JUDGE_CLOUD_CONSENT_REASON &&
36
+ approval.fingerprint === fingerprint &&
37
+ approval.repoRoot === repoRoot);
38
+ if (existing) {
39
+ return {
40
+ state: compacted,
41
+ result: { approvalId: existing.approvalId, created: false },
42
+ };
43
+ }
44
+ compacted.approvals.push(candidate);
45
+ return { state: compacted, result: { approvalId, created: true } };
46
+ },
47
+ });
48
+ if (!outcome) {
49
+ throw new Error('Failed to persist cloud judge consent approval');
50
+ }
51
+ return outcome;
38
52
  }
@@ -0,0 +1,10 @@
1
+ export interface NetworkEndpoint {
2
+ host: string;
3
+ port?: number;
4
+ protocol: string;
5
+ }
6
+ export interface NetworkEndpointParseOptions {
7
+ allowHostedGitShorthand?: boolean;
8
+ allowScpStyle?: boolean;
9
+ }
10
+ export declare function parseNetworkEndpoint(spec: string, options?: NetworkEndpointParseOptions): NetworkEndpoint | null;
@@ -0,0 +1,51 @@
1
+ const SUPPORTED_URL_PROTOCOLS = new Set(['http:', 'https:', 'ssh:', 'git:']);
2
+ const NON_NETWORK_COLON_PREFIXES = new Set([
3
+ 'catalog',
4
+ 'exec',
5
+ 'file',
6
+ 'link',
7
+ 'npm',
8
+ 'patch',
9
+ 'portal',
10
+ 'workspace',
11
+ ]);
12
+ const HOSTED_GIT_PREFIXES = {
13
+ bitbucket: 'bitbucket.org',
14
+ github: 'github.com',
15
+ gitlab: 'gitlab.com',
16
+ };
17
+ export function parseNetworkEndpoint(spec, options = {}) {
18
+ const normalized = spec.trim().replace(/^git\+/, '');
19
+ if (options.allowHostedGitShorthand) {
20
+ const hostedGit = normalized.match(/^([a-z]+):(.+)$/i);
21
+ const hostedGitHost = hostedGit?.[1]
22
+ ? HOSTED_GIT_PREFIXES[hostedGit[1].toLowerCase()]
23
+ : undefined;
24
+ if (hostedGitHost && hostedGit?.[2]) {
25
+ return { host: hostedGitHost, protocol: 'git' };
26
+ }
27
+ }
28
+ try {
29
+ const url = new URL(normalized);
30
+ if (SUPPORTED_URL_PROTOCOLS.has(url.protocol) && url.hostname) {
31
+ return {
32
+ host: url.hostname,
33
+ ...(url.port ? { port: Number(url.port) } : {}),
34
+ protocol: url.protocol.slice(0, -1),
35
+ };
36
+ }
37
+ }
38
+ catch { }
39
+ if (!options.allowScpStyle || /^[A-Za-z]:[\\/]/.test(normalized)) {
40
+ return null;
41
+ }
42
+ const scpStyle = normalized.match(/^(?:([^@/\s:]+)@)?([^:/\s]+):(.+)$/);
43
+ if (!scpStyle) {
44
+ return null;
45
+ }
46
+ const [, , host, remotePath] = scpStyle;
47
+ if (!host || !remotePath || NON_NETWORK_COLON_PREFIXES.has(host.toLowerCase())) {
48
+ return null;
49
+ }
50
+ return { host: host.toLowerCase(), protocol: 'ssh' };
51
+ }
@@ -0,0 +1,10 @@
1
+ import { type SpawnOptionsWithoutStdio } from 'node:child_process';
2
+ export interface ShellRunResult {
3
+ exitCode: number | null;
4
+ signal: string | null;
5
+ timedOut: boolean;
6
+ stdout?: string;
7
+ stderr?: string;
8
+ }
9
+ export declare function windowsProcessTreeKillArgs(pid: number): string[];
10
+ export declare function runProcessWithBoundedOutput(file: string, args: string[], options: SpawnOptionsWithoutStdio, timeoutMs: number): Promise<ShellRunResult>;
@@ -0,0 +1,124 @@
1
+ import { spawn } from 'node:child_process';
2
+ const PROCESS_OUTPUT_TAIL_BYTES = 16_384;
3
+ const EXIT_OUTPUT_GRACE_MS = 25;
4
+ const TIMEOUT_KILL_GRACE_MS = 250;
5
+ const WINDOWS_TASKKILL_TIMEOUT_MS = 5_000;
6
+ function appendOutputTail(current, chunk) {
7
+ const next = Buffer.concat([current, Buffer.from(chunk)]);
8
+ return next.length > PROCESS_OUTPUT_TAIL_BYTES ? next.subarray(-PROCESS_OUTPUT_TAIL_BYTES) : next;
9
+ }
10
+ export function windowsProcessTreeKillArgs(pid) {
11
+ return ['/pid', String(pid), '/t', '/f'];
12
+ }
13
+ function forceKillWindowsProcessTree(pid, fallback) {
14
+ return new Promise((resolve) => {
15
+ const taskkill = spawn('taskkill', windowsProcessTreeKillArgs(pid), {
16
+ stdio: 'ignore',
17
+ windowsHide: true,
18
+ });
19
+ let settled = false;
20
+ const finish = (succeeded) => {
21
+ if (settled) {
22
+ return;
23
+ }
24
+ settled = true;
25
+ clearTimeout(timer);
26
+ if (!succeeded) {
27
+ fallback();
28
+ }
29
+ resolve();
30
+ };
31
+ const timer = setTimeout(() => {
32
+ taskkill.kill();
33
+ finish(false);
34
+ }, WINDOWS_TASKKILL_TIMEOUT_MS);
35
+ taskkill.on('error', () => finish(false));
36
+ taskkill.on('close', (code) => finish(code === 0));
37
+ });
38
+ }
39
+ export function runProcessWithBoundedOutput(file, args, options, timeoutMs) {
40
+ return new Promise((resolve) => {
41
+ const detached = process.platform !== 'win32';
42
+ const child = spawn(file, args, {
43
+ ...options,
44
+ detached,
45
+ stdio: ['ignore', 'pipe', 'pipe'],
46
+ });
47
+ let stdout = Buffer.alloc(0);
48
+ let stderr = Buffer.alloc(0);
49
+ let timedOut = false;
50
+ let settled = false;
51
+ let windowsCleanupPending = false;
52
+ let exitTimer;
53
+ child.stdout.on('data', (chunk) => {
54
+ stdout = appendOutputTail(stdout, chunk);
55
+ });
56
+ child.stderr.on('data', (chunk) => {
57
+ stderr = appendOutputTail(stderr, chunk);
58
+ });
59
+ const finish = (exitCode, signal) => {
60
+ if (settled) {
61
+ return;
62
+ }
63
+ settled = true;
64
+ clearTimeout(timeoutTimer);
65
+ if (exitTimer) {
66
+ clearTimeout(exitTimer);
67
+ }
68
+ child.stdout.destroy();
69
+ child.stderr.destroy();
70
+ resolve({
71
+ exitCode,
72
+ signal: signal ? String(signal) : null,
73
+ timedOut,
74
+ stdout: stdout.toString('utf8'),
75
+ stderr: stderr.toString('utf8'),
76
+ });
77
+ };
78
+ const kill = (signal) => {
79
+ try {
80
+ if (detached && child.pid) {
81
+ process.kill(-child.pid, signal);
82
+ return;
83
+ }
84
+ child.kill(signal);
85
+ }
86
+ catch {
87
+ // The process may already have exited between the state check and kill.
88
+ }
89
+ };
90
+ const timeoutTimer = setTimeout(() => {
91
+ timedOut = true;
92
+ if (process.platform === 'win32' && child.pid) {
93
+ windowsCleanupPending = true;
94
+ void forceKillWindowsProcessTree(child.pid, () => child.kill('SIGKILL')).then(() => {
95
+ windowsCleanupPending = false;
96
+ finish(child.exitCode, child.signalCode ?? 'SIGKILL');
97
+ });
98
+ return;
99
+ }
100
+ kill('SIGTERM');
101
+ setTimeout(() => {
102
+ kill('SIGKILL');
103
+ finish(null, 'SIGKILL');
104
+ }, TIMEOUT_KILL_GRACE_MS);
105
+ }, timeoutMs);
106
+ child.on('error', (error) => {
107
+ stderr = appendOutputTail(stderr, error.message);
108
+ finish(1, null);
109
+ });
110
+ child.on('exit', (exitCode, signal) => {
111
+ clearTimeout(timeoutTimer);
112
+ if (windowsCleanupPending) {
113
+ return;
114
+ }
115
+ exitTimer = setTimeout(() => finish(exitCode, signal), EXIT_OUTPUT_GRACE_MS);
116
+ });
117
+ child.on('close', (exitCode, signal) => {
118
+ if (windowsCleanupPending) {
119
+ return;
120
+ }
121
+ finish(exitCode, signal);
122
+ });
123
+ });
124
+ }
@@ -1,5 +1,6 @@
1
1
  export const RECOVER_DISCLAIMER = [
2
- 'Advisory only — belay does not run recovery commands.',
2
+ 'Advisory only — this report is not proof that the selected action executed. Only a checkpoint receipt shown by `belay recover list` is an executable recovery point.',
3
+ '`belay recover advice` does not run recovery commands; checkpoint restore requires a separate one-shot human approval.',
3
4
  'Advice is based on what belay observed through hooks; actions outside hook scope may not be visible.',
4
5
  'Recovery commands themselves pass through belay hooks — destructive undo steps may be blocked again.',
5
6
  ];
@@ -0,0 +1,29 @@
1
+ import type { RecoveryCheckpointManifest, RecoveryCheckpointState, RecoveryCheckpointStateV1, RecoveryReceiptV1 } from './types.js';
2
+ export declare const RECOVERY_CHECKPOINT_CORRUPT = "recovery_checkpoint_corrupt";
3
+ export interface PreparedRecoveryCheckpoint {
4
+ checkpointId: string;
5
+ manifest: RecoveryCheckpointManifest;
6
+ manifestHash: string;
7
+ proofHash: string;
8
+ }
9
+ export interface RecoveryArtifact {
10
+ artifactDir: string;
11
+ manifest: RecoveryCheckpointManifest;
12
+ state: RecoveryCheckpointStateV1;
13
+ manifestHash: string;
14
+ receipt?: RecoveryReceiptV1;
15
+ }
16
+ export declare function checkpointsRoot(stateDir: string): string;
17
+ export declare function checkpointDir(stateDir: string, checkpointId: string): string;
18
+ export declare function fsyncPath(filePath: string): Promise<void>;
19
+ export declare function atomicWriteJson(filePath: string, value: unknown): Promise<void>;
20
+ export declare function writeRecoveryState(artifactDir: string, state: RecoveryCheckpointState, manifestHash: string, detail?: string): Promise<void>;
21
+ export declare function directorySize(root: string): Promise<number>;
22
+ export declare function readRecoveryArtifact(stateDir: string, checkpointId: string): Promise<RecoveryArtifact>;
23
+ export declare function ensureRecoveryReceipt(artifactDir: string, manifest: RecoveryCheckpointManifest, manifestHash: string): Promise<RecoveryReceiptV1>;
24
+ export declare function checkpointIds(stateDir: string): Promise<string[]>;
25
+ export declare function artifactRepoRoot(stateDir: string, checkpointId: string): Promise<string | null>;
26
+ export declare function checkpointIdsForRepo(stateDir: string, repoRoot: string): Promise<string[]>;
27
+ export declare function cleanupOrphanedStaging(stateDir: string): Promise<void>;
28
+ export declare function markRecoveryCheckpointApplying(stateDir: string, checkpoint: PreparedRecoveryCheckpoint): Promise<void>;
29
+ export declare function markRecoveryCheckpointApplied(stateDir: string, checkpoint: PreparedRecoveryCheckpoint): Promise<RecoveryReceiptV1>;