@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
@@ -2,18 +2,25 @@ import { approvedApprovalsPath, loadApprovalState, pendingApprovalsPath, saveApp
2
2
  import { compactApprovals } from './approval.js';
3
3
  import { buildApprovalRecordedMessage } from './approval-replay.js';
4
4
  import { verifyApprovalToken } from './approval-token.js';
5
+ import { mutateApprovalStateWithRetry, mutatePendingAndApprovedWithRetry, } from './capability/approval-state-mutation.js';
5
6
  import { APPROVAL_STATE_VERSION_V3, mintGrantForApprovedRecord } from './capability/approval-v3.js';
6
7
  import { configuredControlPlaneDir } from './config.js';
7
8
  export async function recordApproval(params) {
8
9
  const { approvalId, config, store, token, requireSignedToken = false, adapter } = params;
9
- const pending = await store.loadPending();
10
+ const [pending, approved] = await Promise.all([store.loadPending(), store.loadApproved()]);
10
11
  pending.state = compactApprovals(pending.state);
11
- const index = pending.state.approvals.findIndex((approval) => approval.approvalId === approvalId);
12
- if (index === -1) {
13
- await store.writePending(pending.filePath, pending.state);
12
+ approved.state = compactApprovals(approved.state);
13
+ const pendingApproval = pending.state.approvals.find((entry) => entry.approvalId === approvalId);
14
+ const approvedApproval = approved.state.approvals.find((entry) => entry.approvalId === approvalId);
15
+ const approval = pendingApproval ?? approvedApproval;
16
+ if (!approval) {
17
+ await mutateApprovalStateWithRetry({
18
+ load: store.loadPending,
19
+ write: store.writePending,
20
+ mutate: (state) => ({ state: compactApprovals(state), result: true }),
21
+ });
14
22
  return { ok: false, message: 'Belay approval not found or expired.' };
15
23
  }
16
- const [approval] = pending.state.approvals.slice(index, index + 1);
17
24
  if (requireSignedToken) {
18
25
  if (!token) {
19
26
  return { ok: false, message: 'Signed approval token required for out-of-band approval.' };
@@ -27,34 +34,86 @@ export async function recordApproval(params) {
27
34
  return { ok: false, message: 'Signed approval token does not match the pending approval.' };
28
35
  }
29
36
  }
30
- pending.state.approvals.splice(index, 1);
31
- await store.writePending(pending.filePath, pending.state);
32
- const approved = await store.loadApproved();
33
- approved.state = compactApprovals(approved.state);
34
- const approvedRecord = mintGrantForApprovedRecord({
35
- ...approval,
36
- approvedAt: new Date().toISOString(),
37
+ if (!pendingApproval && approvedApproval) {
38
+ return {
39
+ ok: true,
40
+ message: buildApprovalRecordedMessage(config, approvedApproval, adapter),
41
+ approval: approvedApproval,
42
+ };
43
+ }
44
+ const recorded = await mutatePendingAndApprovedWithRetry({
45
+ loadPending: store.loadPending,
46
+ loadApproved: store.loadApproved,
47
+ writePending: store.writePending,
48
+ writeApproved: store.writeApproved,
49
+ mutate: (pendingState, approvedState) => {
50
+ const compactedPending = compactApprovals(pendingState);
51
+ const compactedApproved = compactApprovals(approvedState);
52
+ const existing = compactedApproved.approvals.find((entry) => entry.approvalId === approvalId);
53
+ if (existing &&
54
+ (existing.fingerprint !== approval.fingerprint || existing.repoRoot !== approval.repoRoot)) {
55
+ return null;
56
+ }
57
+ const pendingIndex = compactedPending.approvals.findIndex((entry) => entry.approvalId === approvalId &&
58
+ entry.fingerprint === approval.fingerprint &&
59
+ entry.repoRoot === approval.repoRoot);
60
+ if (pendingIndex === -1) {
61
+ return existing
62
+ ? {
63
+ pending: compactedPending,
64
+ approved: compactedApproved,
65
+ result: existing,
66
+ }
67
+ : null;
68
+ }
69
+ const [claimed] = compactedPending.approvals.splice(pendingIndex, 1);
70
+ if (!claimed) {
71
+ return null;
72
+ }
73
+ const approvedRecord = existing ??
74
+ mintGrantForApprovedRecord({
75
+ ...claimed,
76
+ approvedAt: new Date().toISOString(),
77
+ });
78
+ if (!existing) {
79
+ compactedApproved.version = APPROVAL_STATE_VERSION_V3;
80
+ compactedApproved.approvals.push(approvedRecord);
81
+ }
82
+ return {
83
+ pending: compactedPending,
84
+ approved: compactedApproved,
85
+ result: approvedRecord,
86
+ };
87
+ },
37
88
  });
38
- approved.state.version = APPROVAL_STATE_VERSION_V3;
39
- approved.state.revision = (approved.state.revision ?? 0) + 1;
40
- approved.state.approvals.push(approvedRecord);
41
- await store.writeApproved(approved.filePath, approved.state);
89
+ if (!recorded) {
90
+ return { ok: false, message: 'Belay approval not found, expired, or already claimed.' };
91
+ }
42
92
  return {
43
93
  ok: true,
44
- message: buildApprovalRecordedMessage(config, approval, adapter),
45
- approval: approvedRecord,
94
+ message: buildApprovalRecordedMessage(config, recorded, adapter),
95
+ approval: recorded,
46
96
  };
47
97
  }
48
- /** CLI `--replay` already executed the shell command; drop the one-shot grant. */
98
+ /** @deprecated Replay callers must claim before execution with `claimApprovedForReplay`. */
49
99
  export async function consumeApprovedAfterCliReplay(params) {
50
- const approved = await params.store.loadApproved();
51
- approved.state = compactApprovals(approved.state);
52
- const remaining = approved.state.approvals.filter((approval) => approval.approvalId !== params.approvalId);
53
- if (remaining.length === approved.state.approvals.length) {
54
- return;
55
- }
56
- approved.state.approvals = remaining;
57
- await params.store.writeApproved(approved.filePath, approved.state);
100
+ await claimApprovedForReplay(params);
101
+ }
102
+ /** Atomically spend and return a one-shot approval before executing its replay. */
103
+ export async function claimApprovedForReplay(params) {
104
+ return mutateApprovalStateWithRetry({
105
+ load: params.store.loadApproved,
106
+ write: params.store.writeApproved,
107
+ mutate: (state) => {
108
+ const compacted = compactApprovals(state);
109
+ const index = compacted.approvals.findIndex((approval) => approval.approvalId === params.approvalId);
110
+ if (index === -1) {
111
+ return null;
112
+ }
113
+ const [claimed] = compacted.approvals.splice(index, 1);
114
+ return { state: compacted, result: claimed ?? null };
115
+ },
116
+ });
58
117
  }
59
118
  export function createGateApprovalStore(repoRoot, config) {
60
119
  return {
@@ -25,6 +25,7 @@ export declare function createApprovalRecordWithEnvelope(params: {
25
25
  };
26
26
  scopeHint?: ApprovalRecord['scopeHint'];
27
27
  capabilityRequests?: ApprovalRecord['capabilityRequests'];
28
+ effectPlanHash?: string;
28
29
  }): ApprovalRecord;
29
30
  export declare function createApprovalRecord(params: {
30
31
  kind: ApprovalRecord['kind'];
@@ -42,4 +43,5 @@ export declare function createApprovalRecord(params: {
42
43
  payloadJson?: string;
43
44
  scopeHint?: ApprovalRecord['scopeHint'];
44
45
  capabilityRequests?: ApprovalRecord['capabilityRequests'];
46
+ effectPlanHash?: string;
45
47
  }): ApprovalRecord;
@@ -7,13 +7,15 @@ export function nowIso() {
7
7
  return new Date().toISOString();
8
8
  }
9
9
  export function isExpired(approval) {
10
- return Date.parse(approval.expiresAt) <= Date.now();
10
+ const expiresAt = Date.parse(approval.expiresAt);
11
+ return !Number.isFinite(expiresAt) || expiresAt <= Date.now();
11
12
  }
12
13
  export function isExecutionLeaseExpired(approval) {
13
14
  if (!approval.executionLeaseExpiresAt) {
14
15
  return false;
15
16
  }
16
- return Date.parse(approval.executionLeaseExpiresAt) <= Date.now();
17
+ const expiresAt = Date.parse(approval.executionLeaseExpiresAt);
18
+ return !Number.isFinite(expiresAt) || expiresAt <= Date.now();
17
19
  }
18
20
  export function compactApprovals(state) {
19
21
  const compacted = {
@@ -89,6 +91,7 @@ export function createApprovalRecordWithEnvelope(params) {
89
91
  payloadJson: envelope.payloadJson,
90
92
  scopeHint: params.scopeHint,
91
93
  capabilityRequests: params.capabilityRequests,
94
+ effectPlanHash: params.effectPlanHash,
92
95
  });
93
96
  }
94
97
  export function createApprovalRecord(params) {
@@ -123,5 +126,5 @@ export function createApprovalRecord(params) {
123
126
  if (params.scopeHint) {
124
127
  record.scopeHint = params.scopeHint;
125
128
  }
126
- return attachCapabilityEnvelope(record, params.capabilityRequests);
129
+ return attachCapabilityEnvelope(record, params.capabilityRequests, params.effectPlanHash);
127
130
  }
@@ -20,10 +20,23 @@ export interface AuditRecord {
20
20
  predictedAssessment?: Assessment;
21
21
  observedAssessment?: Assessment;
22
22
  transactional?: boolean;
23
+ transactionalBackend?: 'git_worktree' | 'file_checkpoint';
24
+ resourceKind?: 'git_repository' | 'directory';
25
+ baselineTreeHash?: string;
26
+ snapshotFileCount?: number;
27
+ snapshotSourceBytes?: number;
28
+ snapshotWorkspaceBytes?: number;
29
+ snapshotCopyStrategy?: 'clonefile' | 'reflink' | 'copy';
30
+ snapshotPrepareMs?: number;
23
31
  transactionalReason?: string;
24
32
  transactionalCategories?: string[];
25
33
  transactionalChangeCount?: number;
26
34
  transactionalSkipReason?: string;
35
+ transactionalSkipDetail?: string;
36
+ recoveryCheckpointId?: string;
37
+ recoveryBackend?: string;
38
+ recoveryProofHash?: string;
39
+ recoveryState?: string;
27
40
  replayContext?: AuditReplayContext;
28
41
  actionSnapshot?: AuditActionSnapshot;
29
42
  [key: string]: unknown;
@@ -1,4 +1,5 @@
1
1
  import type { ApprovalStateFile } from '../types.js';
2
+ export declare function withStateFileLock<T>(filePath: string, operation: () => Promise<T>, maxRetries?: number): Promise<T>;
2
3
  export declare function mutateApprovalStateWithRetry<T>(params: {
3
4
  load: () => Promise<{
4
5
  filePath: string;
@@ -11,3 +12,21 @@ export declare function mutateApprovalStateWithRetry<T>(params: {
11
12
  } | null;
12
13
  maxRetries?: number;
13
14
  }): Promise<T | null>;
15
+ export declare function mutatePendingAndApprovedWithRetry<T>(params: {
16
+ loadPending: () => Promise<{
17
+ filePath: string;
18
+ state: ApprovalStateFile;
19
+ }>;
20
+ loadApproved: () => Promise<{
21
+ filePath: string;
22
+ state: ApprovalStateFile;
23
+ }>;
24
+ writePending: (filePath: string, state: ApprovalStateFile) => Promise<void>;
25
+ writeApproved: (filePath: string, state: ApprovalStateFile) => Promise<void>;
26
+ mutate: (pending: ApprovalStateFile, approved: ApprovalStateFile) => {
27
+ pending: ApprovalStateFile;
28
+ approved: ApprovalStateFile;
29
+ result: T;
30
+ } | null;
31
+ maxRetries?: number;
32
+ }): Promise<T | null>;
@@ -1,5 +1,6 @@
1
1
  import { constants } from 'node:fs';
2
- import { open, readFile, unlink } from 'node:fs/promises';
2
+ import { mkdir, open, readFile, unlink } from 'node:fs/promises';
3
+ import path from 'node:path';
3
4
  import { upgradeApprovalStateToV3 } from './approval-v3.js';
4
5
  const DEFAULT_RETRIES = 8;
5
6
  const LOCK_STALE_MS = 5 * 60_000;
@@ -31,6 +32,7 @@ async function isStaleApprovalLock(lockPath) {
31
32
  }
32
33
  }
33
34
  async function acquireApprovalStateLock(lockPath, maxRetries) {
35
+ await mkdir(path.dirname(lockPath), { recursive: true });
34
36
  for (let attempt = 0; attempt < maxRetries; attempt += 1) {
35
37
  try {
36
38
  const handle = await open(lockPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY);
@@ -53,14 +55,27 @@ async function acquireApprovalStateLock(lockPath, maxRetries) {
53
55
  }
54
56
  throw new Error(`Failed to acquire approval state lock: ${lockPath}`);
55
57
  }
58
+ export async function withStateFileLock(filePath, operation, maxRetries = DEFAULT_RETRIES) {
59
+ const release = await acquireApprovalStateLock(`${filePath}.lock`, maxRetries);
60
+ try {
61
+ return await operation();
62
+ }
63
+ finally {
64
+ await release();
65
+ }
66
+ }
56
67
  export async function mutateApprovalStateWithRetry(params) {
57
68
  const maxRetries = params.maxRetries ?? DEFAULT_RETRIES;
58
69
  for (let attempt = 0; attempt < maxRetries; attempt += 1) {
59
- const loaded = await params.load();
60
- const lockPath = `${loaded.filePath}.lock`;
70
+ const initial = await params.load();
71
+ const lockPath = `${initial.filePath}.lock`;
61
72
  let release = null;
62
73
  try {
63
74
  release = await acquireApprovalStateLock(lockPath, maxRetries);
75
+ const loaded = await params.load();
76
+ if (loaded.filePath !== initial.filePath) {
77
+ throw new Error('Approval state path changed while acquiring lock');
78
+ }
64
79
  const base = upgradeApprovalStateToV3(loaded.state);
65
80
  const revision = base.revision ?? 0;
66
81
  const outcome = params.mutate(base);
@@ -90,3 +105,61 @@ export async function mutateApprovalStateWithRetry(params) {
90
105
  }
91
106
  return null;
92
107
  }
108
+ export async function mutatePendingAndApprovedWithRetry(params) {
109
+ const maxRetries = params.maxRetries ?? DEFAULT_RETRIES;
110
+ const [initialPending, initialApproved] = await Promise.all([
111
+ params.loadPending(),
112
+ params.loadApproved(),
113
+ ]);
114
+ const lockPaths = [`${initialPending.filePath}.lock`, `${initialApproved.filePath}.lock`].sort();
115
+ if (lockPaths[0] === lockPaths[1]) {
116
+ throw new Error('Pending and approved state must use different files');
117
+ }
118
+ const releases = [];
119
+ try {
120
+ for (const lockPath of lockPaths) {
121
+ releases.push(await acquireApprovalStateLock(lockPath, maxRetries));
122
+ }
123
+ const [pendingLoaded, approvedLoaded] = await Promise.all([
124
+ params.loadPending(),
125
+ params.loadApproved(),
126
+ ]);
127
+ if (pendingLoaded.filePath !== initialPending.filePath ||
128
+ approvedLoaded.filePath !== initialApproved.filePath) {
129
+ throw new Error('Approval state paths changed while acquiring locks');
130
+ }
131
+ const pending = upgradeApprovalStateToV3(pendingLoaded.state);
132
+ const approved = upgradeApprovalStateToV3(approvedLoaded.state);
133
+ const outcome = params.mutate(pending, approved);
134
+ if (!outcome) {
135
+ return null;
136
+ }
137
+ const nextApproved = {
138
+ ...outcome.approved,
139
+ version: 3,
140
+ revision: (approved.revision ?? 0) + 1,
141
+ };
142
+ const nextPending = {
143
+ ...outcome.pending,
144
+ version: 3,
145
+ revision: (pending.revision ?? 0) + 1,
146
+ };
147
+ // Claim first: interruption may require a fresh approval, but can never duplicate a grant.
148
+ await params.writePending(pendingLoaded.filePath, nextPending);
149
+ await params.writeApproved(approvedLoaded.filePath, nextApproved);
150
+ const [verifiedPending, verifiedApproved] = await Promise.all([
151
+ params.loadPending(),
152
+ params.loadApproved(),
153
+ ]);
154
+ if ((upgradeApprovalStateToV3(verifiedPending.state).revision ?? 0) !== nextPending.revision ||
155
+ (upgradeApprovalStateToV3(verifiedApproved.state).revision ?? 0) !== nextApproved.revision) {
156
+ return null;
157
+ }
158
+ return outcome.result;
159
+ }
160
+ finally {
161
+ for (const release of releases.reverse()) {
162
+ await release();
163
+ }
164
+ }
165
+ }
@@ -2,14 +2,31 @@ import type { ApprovalRecord, ApprovalStateFile } from '../types.js';
2
2
  import type { CapabilityGrantV1 } from './grant.js';
3
3
  import type { CapabilityRequestV1 } from './request.js';
4
4
  export declare const APPROVAL_STATE_VERSION_V3: 3;
5
+ export declare const EFFECT_PLAN_HASH_VERSION: "effect-plan:v1";
5
6
  export declare function normalizeApprovalStateVersion(state: ApprovalStateFile): ApprovalStateFile;
6
7
  export declare function upgradeApprovalStateToV3(state: ApprovalStateFile): ApprovalStateFile;
7
- export declare function attachCapabilityEnvelope(approval: ApprovalRecord, capabilityRequests?: CapabilityRequestV1[]): ApprovalRecord;
8
+ export declare function attachCapabilityEnvelope(approval: ApprovalRecord, capabilityRequests?: CapabilityRequestV1[], effectPlanHash?: string): ApprovalRecord;
9
+ /** Dual-read: expose legacy single grant as a one-element bundle without mutating storage. */
10
+ export declare function normalizeApprovalGrants(approval: ApprovalRecord): ApprovalRecord;
11
+ export declare function mintCapabilityGrantForRequest(params: {
12
+ approval: ApprovalRecord;
13
+ request: CapabilityRequestV1;
14
+ grantSuffix?: string;
15
+ issuer?: string;
16
+ maxUses?: number;
17
+ }): CapabilityGrantV1;
18
+ /** @deprecated Use mintCapabilityGrantBundle; returns first grant only for legacy callers. */
8
19
  export declare function mintCapabilityGrant(params: {
9
20
  approval: ApprovalRecord;
10
21
  capabilityRequests: CapabilityRequestV1[];
11
22
  issuer?: string;
12
23
  maxUses?: number;
13
24
  }): CapabilityGrantV1 | null;
25
+ export declare function mintCapabilityGrantBundle(params: {
26
+ approval: ApprovalRecord;
27
+ capabilityRequests: CapabilityRequestV1[];
28
+ issuer?: string;
29
+ maxUses?: number;
30
+ }): CapabilityGrantV1[];
14
31
  export declare function mintGrantForApprovedRecord(approval: ApprovalRecord): ApprovalRecord;
15
32
  export declare function newGrantId(): string;
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { hashCapabilityRequests } from './capability-request-hash.js';
3
3
  import { CAPABILITY_GRANT_VERSION } from './grant.js';
4
4
  export const APPROVAL_STATE_VERSION_V3 = 3;
5
+ export const EFFECT_PLAN_HASH_VERSION = 'effect-plan:v1';
5
6
  export function normalizeApprovalStateVersion(state) {
6
7
  const version = state.version === APPROVAL_STATE_VERSION_V3
7
8
  ? APPROVAL_STATE_VERSION_V3
@@ -26,29 +27,45 @@ export function upgradeApprovalStateToV3(state) {
26
27
  })),
27
28
  };
28
29
  }
29
- export function attachCapabilityEnvelope(approval, capabilityRequests) {
30
- if (!capabilityRequests?.length) {
31
- return approval;
30
+ export function attachCapabilityEnvelope(approval, capabilityRequests, effectPlanHash) {
31
+ const withRequests = !capabilityRequests?.length
32
+ ? approval
33
+ : {
34
+ ...approval,
35
+ capabilityRequests,
36
+ capabilityRequestHash: hashCapabilityRequests(capabilityRequests),
37
+ };
38
+ if (!effectPlanHash) {
39
+ return withRequests;
32
40
  }
33
41
  return {
34
- ...approval,
35
- capabilityRequests,
36
- capabilityRequestHash: hashCapabilityRequests(capabilityRequests),
42
+ ...withRequests,
43
+ effectPlanHash,
37
44
  };
38
45
  }
39
- export function mintCapabilityGrant(params) {
40
- const request = params.capabilityRequests[0];
41
- if (!request) {
42
- return null;
46
+ /** Dual-read: expose legacy single grant as a one-element bundle without mutating storage. */
47
+ export function normalizeApprovalGrants(approval) {
48
+ if (approval.grants?.length) {
49
+ return approval;
43
50
  }
51
+ if (approval.grant) {
52
+ return { ...approval, grants: [approval.grant] };
53
+ }
54
+ return approval;
55
+ }
56
+ export function mintCapabilityGrantForRequest(params) {
44
57
  const expiresAt = params.approval.expiresAt;
58
+ const suffix = params.grantSuffix ?? '';
59
+ const grantId = suffix === '' || suffix === '0'
60
+ ? `grant_${params.approval.approvalId}${suffix === '0' && params.grantSuffix !== undefined ? '_0' : ''}`
61
+ : `grant_${params.approval.approvalId}_${suffix}`;
45
62
  return {
46
63
  version: CAPABILITY_GRANT_VERSION,
47
- grantId: `grant_${params.approval.approvalId}`,
48
- principal: request.principal,
49
- action: request.action,
50
- resource: request.resource,
51
- inputFingerprint: request.context.inputFingerprint,
64
+ grantId,
65
+ principal: params.request.principal,
66
+ action: params.request.action,
67
+ resource: params.request.resource,
68
+ inputFingerprint: params.request.context.inputFingerprint,
52
69
  issuedAt: params.approval.approvedAt ?? new Date().toISOString(),
53
70
  expiresAt,
54
71
  maxUses: params.maxUses ?? 1,
@@ -56,18 +73,41 @@ export function mintCapabilityGrant(params) {
56
73
  issuer: params.issuer ?? 'belay-approval-v3',
57
74
  };
58
75
  }
76
+ /** @deprecated Use mintCapabilityGrantBundle; returns first grant only for legacy callers. */
77
+ export function mintCapabilityGrant(params) {
78
+ const bundle = mintCapabilityGrantBundle(params);
79
+ return bundle[0] ?? null;
80
+ }
81
+ export function mintCapabilityGrantBundle(params) {
82
+ if (!params.capabilityRequests.length) {
83
+ return [];
84
+ }
85
+ return params.capabilityRequests.map((request, index) => mintCapabilityGrantForRequest({
86
+ approval: params.approval,
87
+ request,
88
+ grantSuffix: params.capabilityRequests.length === 1 ? undefined : String(index),
89
+ issuer: params.issuer,
90
+ maxUses: params.maxUses,
91
+ }));
92
+ }
59
93
  export function mintGrantForApprovedRecord(approval) {
60
- if (approval.grant || !approval.capabilityRequests?.length) {
61
- return approval;
94
+ const normalized = normalizeApprovalGrants(approval);
95
+ if ((normalized.grants?.length ?? 0) > 0 || !normalized.capabilityRequests?.length) {
96
+ return normalized;
62
97
  }
63
- const grant = mintCapabilityGrant({
64
- approval,
65
- capabilityRequests: approval.capabilityRequests,
98
+ const grants = mintCapabilityGrantBundle({
99
+ approval: normalized,
100
+ capabilityRequests: normalized.capabilityRequests,
66
101
  });
67
- if (!grant) {
68
- return approval;
102
+ if (!grants.length) {
103
+ return normalized;
69
104
  }
70
- return { ...approval, grant };
105
+ return {
106
+ ...normalized,
107
+ grants,
108
+ grant: grants[0],
109
+ grantBundleVersion: 1,
110
+ };
71
111
  }
72
112
  export function newGrantId() {
73
113
  return `grant_${randomUUID().replaceAll('-', '').slice(0, 16)}`;
@@ -8,6 +8,9 @@ export interface BoundaryAttestation {
8
8
  deniesUngrantedEffects: boolean;
9
9
  materializesGrants: boolean;
10
10
  probeSignals: string[];
11
+ /** When true, the driver can mount an execution mirror at the original workspace path. */
12
+ isolatesWorkspaceMounts?: boolean;
11
13
  }
12
14
  export declare function validateBoundaryAttestation(value: unknown): value is BoundaryAttestation;
13
15
  export declare function isAttestationFresh(attestation: BoundaryAttestation, now?: number): boolean;
16
+ export declare function attestsWorkspaceMountIsolation(attestation: BoundaryAttestation | null | undefined): boolean;
@@ -30,6 +30,10 @@ export function validateBoundaryAttestation(value) {
30
30
  !record.probeSignals.every((s) => typeof s === 'string')) {
31
31
  return false;
32
32
  }
33
+ if (record.isolatesWorkspaceMounts !== undefined &&
34
+ typeof record.isolatesWorkspaceMounts !== 'boolean') {
35
+ return false;
36
+ }
33
37
  if (record.driver === 'host-integration' && record.materializesGrants) {
34
38
  return false;
35
39
  }
@@ -53,3 +57,6 @@ export function isAttestationFresh(attestation, now = Date.now()) {
53
57
  }
54
58
  return attestation.deniesUngrantedEffects;
55
59
  }
60
+ export function attestsWorkspaceMountIsolation(attestation) {
61
+ return attestation?.isolatesWorkspaceMounts === true;
62
+ }
@@ -1,5 +1,17 @@
1
1
  import type { BoundaryDriver } from './boundary-driver.js';
2
+ import { type BoundaryRunOptions } from './boundary-run.js';
2
3
  export declare function isDockerAvailable(): Promise<boolean>;
4
+ export interface ContainerRunParams {
5
+ image: string;
6
+ command: string;
7
+ cwd: string;
8
+ proxyEnv: Record<string, string>;
9
+ mountReadOnly: boolean;
10
+ repoRoot?: string;
11
+ runOptions?: BoundaryRunOptions;
12
+ containerName?: string;
13
+ }
14
+ export declare function buildContainerRunArgs(params: ContainerRunParams): string[];
3
15
  export declare function createContainerBoundaryDriver(options?: {
4
16
  image?: string;
5
17
  egressProxyEnv?: Record<string, string>;