@guilz-dev/belay 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/README.md +39 -20
  2. package/dist/adapters/claude/runtime-entry.js +54 -25
  3. package/dist/adapters/shared/gate-runtime.d.ts +7 -0
  4. package/dist/adapters/shared/gate-runtime.js +443 -225
  5. package/dist/bundle/claude-runtime.mjs +13027 -7271
  6. package/dist/bundle/codex-runtime.mjs +12700 -6977
  7. package/dist/bundle/cursor-runtime.mjs +12695 -6977
  8. package/dist/cli.js +37 -1
  9. package/dist/commands/approve.js +34 -10
  10. package/dist/commands/doctor.js +58 -0
  11. package/dist/commands/dogfood.js +1 -1
  12. package/dist/commands/explain.js +2 -2
  13. package/dist/commands/harvest.d.ts +0 -1
  14. package/dist/commands/harvest.js +1 -4
  15. package/dist/commands/metrics.js +6 -0
  16. package/dist/commands/quality.js +1 -3
  17. package/dist/commands/recovery-checkpoints.d.ts +164 -0
  18. package/dist/commands/recovery-checkpoints.js +347 -0
  19. package/dist/commands/revoke.js +19 -7
  20. package/dist/commands/status.js +7 -0
  21. package/dist/config-io.js +49 -18
  22. package/dist/conformance/guarantee-table.js +137 -9
  23. package/dist/conformance/types.d.ts +2 -0
  24. package/dist/core/approval-replay.d.ts +1 -1
  25. package/dist/core/approval-replay.js +4 -1
  26. package/dist/core/approval-service.d.ts +6 -1
  27. package/dist/core/approval-service.js +86 -27
  28. package/dist/core/approval.d.ts +2 -0
  29. package/dist/core/approval.js +6 -3
  30. package/dist/core/audit-metrics.d.ts +1 -0
  31. package/dist/core/audit-metrics.js +12 -4
  32. package/dist/core/audit-types.d.ts +16 -0
  33. package/dist/core/capability/approval-state-mutation.d.ts +19 -0
  34. package/dist/core/capability/approval-state-mutation.js +76 -3
  35. package/dist/core/capability/approval-v3.d.ts +18 -1
  36. package/dist/core/capability/approval-v3.js +63 -23
  37. package/dist/core/capability/attestation.d.ts +3 -0
  38. package/dist/core/capability/attestation.js +7 -0
  39. package/dist/core/capability/boundary-driver-container.d.ts +12 -0
  40. package/dist/core/capability/boundary-driver-container.js +101 -34
  41. package/dist/core/capability/boundary-driver.js +1 -0
  42. package/dist/core/capability/boundary-grant-materialize.d.ts +1 -0
  43. package/dist/core/capability/boundary-grant-materialize.js +15 -0
  44. package/dist/core/capability/boundary-run.d.ts +25 -0
  45. package/dist/core/capability/boundary-run.js +37 -9
  46. package/dist/core/capability/boundary-session.d.ts +4 -0
  47. package/dist/core/capability/boundary-session.js +9 -0
  48. package/dist/core/capability/boundary-workspace-mount.d.ts +5 -0
  49. package/dist/core/capability/boundary-workspace-mount.js +68 -0
  50. package/dist/core/capability/gate-policy-shadow.js +23 -1
  51. package/dist/core/capability/grant-consumption.d.ts +4 -0
  52. package/dist/core/capability/grant-consumption.js +11 -0
  53. package/dist/core/capability/grant-lease.d.ts +36 -1
  54. package/dist/core/capability/grant-lease.js +213 -16
  55. package/dist/core/capability/grant-match.js +3 -0
  56. package/dist/core/capability/index.d.ts +2 -2
  57. package/dist/core/capability/index.js +1 -1
  58. package/dist/core/capability/policy-engine.d.ts +38 -1
  59. package/dist/core/capability/policy-engine.js +388 -15
  60. package/dist/core/capability/request.d.ts +12 -0
  61. package/dist/core/capability/resolver.d.ts +0 -1
  62. package/dist/core/capability/resolver.js +0 -1
  63. package/dist/core/config.d.ts +20 -0
  64. package/dist/core/config.js +62 -2
  65. package/dist/core/effect-ir/audit.d.ts +9 -0
  66. package/dist/core/effect-ir/audit.js +87 -0
  67. package/dist/core/effect-ir/build.d.ts +31 -0
  68. package/dist/core/effect-ir/build.js +281 -0
  69. package/dist/core/effect-ir/index.d.ts +13 -0
  70. package/dist/core/effect-ir/index.js +9 -0
  71. package/dist/core/effect-ir/normalize.d.ts +4 -0
  72. package/dist/core/effect-ir/normalize.js +110 -0
  73. package/dist/core/effect-ir/package-exec-eval.d.ts +8 -0
  74. package/dist/core/effect-ir/package-exec-eval.js +152 -0
  75. package/dist/core/effect-ir/package-exec.d.ts +29 -0
  76. package/dist/core/effect-ir/package-exec.js +292 -0
  77. package/dist/core/effect-ir/policy.d.ts +14 -0
  78. package/dist/core/effect-ir/policy.js +164 -0
  79. package/dist/core/effect-ir/shell-build.d.ts +43 -0
  80. package/dist/core/effect-ir/shell-build.js +77 -0
  81. package/dist/core/effect-ir/shell-lower.d.ts +14 -0
  82. package/dist/core/effect-ir/shell-lower.js +1509 -0
  83. package/dist/core/effect-ir/types.d.ts +62 -0
  84. package/dist/core/effect-ir/types.js +1 -0
  85. package/dist/core/egress/allowlist.d.ts +1 -0
  86. package/dist/core/egress/allowlist.js +8 -0
  87. package/dist/core/egress-approval.js +45 -51
  88. package/dist/core/gate-contract.d.ts +2 -0
  89. package/dist/core/gate-contract.js +2 -0
  90. package/dist/core/gate-engine.js +82 -25
  91. package/dist/core/git-resource-identity.d.ts +26 -0
  92. package/dist/core/git-resource-identity.js +284 -0
  93. package/dist/core/harvest.d.ts +3 -7
  94. package/dist/core/harvest.js +4 -27
  95. package/dist/core/index.d.ts +5 -1
  96. package/dist/core/index.js +3 -1
  97. package/dist/core/judge-cloud-consent.js +29 -15
  98. package/dist/core/network-endpoint.d.ts +10 -0
  99. package/dist/core/network-endpoint.js +51 -0
  100. package/dist/core/path-utils.js +42 -23
  101. package/dist/core/process-runner.d.ts +10 -0
  102. package/dist/core/process-runner.js +124 -0
  103. package/dist/core/recover-advice.js +2 -1
  104. package/dist/core/recovery/artifact-store.d.ts +29 -0
  105. package/dist/core/recovery/artifact-store.js +336 -0
  106. package/dist/core/recovery/checkpoint.d.ts +25 -0
  107. package/dist/core/recovery/checkpoint.js +260 -0
  108. package/dist/core/recovery/index.d.ts +3 -1
  109. package/dist/core/recovery/index.js +2 -0
  110. package/dist/core/recovery/operator-guidance.d.ts +7 -0
  111. package/dist/core/recovery/operator-guidance.js +39 -0
  112. package/dist/core/recovery/reconcile.d.ts +3 -0
  113. package/dist/core/recovery/reconcile.js +48 -0
  114. package/dist/core/recovery/resource-identity.d.ts +4 -0
  115. package/dist/core/recovery/resource-identity.js +34 -0
  116. package/dist/core/recovery/restore.d.ts +13 -0
  117. package/dist/core/recovery/restore.js +114 -0
  118. package/dist/core/recovery/snapshot-node.d.ts +27 -0
  119. package/dist/core/recovery/snapshot-node.js +261 -0
  120. package/dist/core/recovery/types.d.ts +85 -1
  121. package/dist/core/shell-substitution.d.ts +1 -0
  122. package/dist/core/shell-substitution.js +61 -0
  123. package/dist/core/standing-allow.d.ts +1 -2
  124. package/dist/core/standing-allow.js +3 -20
  125. package/dist/core/transactional/apply-observed-changes.d.ts +13 -0
  126. package/dist/core/transactional/apply-observed-changes.js +275 -0
  127. package/dist/core/transactional/backend-selector.d.ts +8 -0
  128. package/dist/core/transactional/backend-selector.js +118 -0
  129. package/dist/core/transactional/backend.d.ts +55 -0
  130. package/dist/core/transactional/backend.js +1 -0
  131. package/dist/core/transactional/file-checkpoint-backend.d.ts +3 -0
  132. package/dist/core/transactional/file-checkpoint-backend.js +278 -0
  133. package/dist/core/transactional/file-checkpoint-git.d.ts +11 -0
  134. package/dist/core/transactional/file-checkpoint-git.js +217 -0
  135. package/dist/core/transactional/file-checkpoint-staging.d.ts +13 -0
  136. package/dist/core/transactional/file-checkpoint-staging.js +54 -0
  137. package/dist/core/transactional/file-clone.d.ts +15 -0
  138. package/dist/core/transactional/file-clone.js +139 -0
  139. package/dist/core/transactional/file-tree-path.d.ts +7 -0
  140. package/dist/core/transactional/file-tree-path.js +50 -0
  141. package/dist/core/transactional/file-tree.d.ts +41 -0
  142. package/dist/core/transactional/file-tree.js +186 -0
  143. package/dist/core/transactional/git-worktree-backend.d.ts +2 -0
  144. package/dist/core/transactional/git-worktree-backend.js +50 -0
  145. package/dist/core/transactional/git-worktree.d.ts +9 -11
  146. package/dist/core/transactional/git-worktree.js +24 -119
  147. package/dist/core/transactional/index.d.ts +11 -0
  148. package/dist/core/transactional/index.js +10 -0
  149. package/dist/core/transactional/reasons.js +1 -0
  150. package/dist/core/transactional/runner.js +157 -25
  151. package/dist/core/transactional/snapshot-node.d.ts +26 -0
  152. package/dist/core/transactional/snapshot-node.js +87 -0
  153. package/dist/core/transactional/types.d.ts +17 -1
  154. package/dist/core/types.d.ts +13 -1
  155. package/dist/core/verdict/adapter.js +23 -15
  156. package/dist/core/verdict/containment.js +4 -0
  157. package/dist/core/verdict/egress-classify.d.ts +12 -0
  158. package/dist/core/verdict/egress-classify.js +746 -0
  159. package/dist/core/verdict/git-classifier.d.ts +12 -0
  160. package/dist/core/verdict/git-classifier.js +320 -0
  161. package/dist/core/verdict/launcher-resolve.js +79 -37
  162. package/dist/core/verdict/parser.d.ts +2 -0
  163. package/dist/core/verdict/parser.js +95 -1
  164. package/dist/core/verdict/types.d.ts +6 -2
  165. package/dist/core/verdict/verdict.js +103 -935
  166. package/dist/corpus/must-allow-commands.d.ts +2 -1
  167. package/dist/corpus/must-allow-commands.js +2 -1
  168. package/dist/corpus/runtime-match.d.ts +2 -1
  169. package/dist/corpus/runtime-match.js +2 -1
  170. package/dist/corpus/types.d.ts +2 -2
  171. package/dist/templates.js +7 -2
  172. package/dist/types.d.ts +11 -0
  173. package/dist/version.d.ts +1 -1
  174. package/dist/version.js +1 -1
  175. package/package.json +2 -2
  176. package/skills/belay/SKILL.md +8 -3
  177. package/skills/belay/belay-approve.md +10 -7
  178. package/dist/core/verdict/overrides.d.ts +0 -7
  179. package/dist/core/verdict/overrides.js +0 -37
  180. package/dist/core/verdict/shell-policy.d.ts +0 -25
  181. package/dist/core/verdict/shell-policy.js +0 -40
  182. package/dist/corpus/standing-allow-catalog.generated.d.ts +0 -13
  183. package/dist/corpus/standing-allow-catalog.generated.js +0 -99
@@ -1,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>;
@@ -1,9 +1,33 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
2
3
  import { canonicalPath } from '../path-utils.js';
4
+ import { runProcessWithBoundedOutput } from '../process-runner.js';
3
5
  import { dockerEnvArgs, dockerNetworkArgs, ensureBelayContainerNetwork } from './boundary-egress.js';
4
6
  import { materializeContainerBoundaryGrant } from './boundary-grant-materialize.js';
7
+ import { BoundaryCleanupError, } from './boundary-run.js';
8
+ import { buildWorkspaceMountSpec, resolveGuestWorkdir, workspaceMountEnvArgs, } from './boundary-workspace-mount.js';
5
9
  const ATTESTATION_TTL_MS = 15 * 60_000;
6
10
  const DEFAULT_IMAGE = 'alpine:3.20';
11
+ const CONTAINER_CLEANUP_TIMEOUT_MS = 10_000;
12
+ const CONTAINER_INSPECT_TIMEOUT_MS = 2_000;
13
+ const CONTAINER_ABSENCE_ATTEMPTS = 10;
14
+ const CONTAINER_ABSENCE_RETRY_MS = 100;
15
+ function containerDoesNotExist(result) {
16
+ return /no such container/i.test(`${result.stderr ?? ''}\n${result.stdout ?? ''}`);
17
+ }
18
+ async function confirmContainerAbsent(containerName) {
19
+ for (let attempt = 0; attempt < CONTAINER_ABSENCE_ATTEMPTS; attempt += 1) {
20
+ const inspection = await runProcessWithBoundedOutput('docker', ['inspect', '--type', 'container', containerName], {}, CONTAINER_INSPECT_TIMEOUT_MS);
21
+ if (!inspection.timedOut && inspection.exitCode !== 0 && containerDoesNotExist(inspection)) {
22
+ return true;
23
+ }
24
+ if (inspection.timedOut || (inspection.exitCode !== 0 && !containerDoesNotExist(inspection))) {
25
+ return false;
26
+ }
27
+ await new Promise((resolve) => setTimeout(resolve, CONTAINER_ABSENCE_RETRY_MS));
28
+ }
29
+ return false;
30
+ }
7
31
  export async function isDockerAvailable() {
8
32
  return new Promise((resolve) => {
9
33
  const child = spawn('docker', ['info'], { stdio: 'ignore' });
@@ -13,7 +37,12 @@ export async function isDockerAvailable() {
13
37
  }
14
38
  function containerAttestation(proxyEnv) {
15
39
  const probedAt = new Date().toISOString();
16
- const signals = ['docker', 'container-driver', 'repo-mount-ro-default'];
40
+ const signals = [
41
+ 'docker',
42
+ 'container-driver',
43
+ 'repo-mount-ro-default',
44
+ 'workspace-mount-isolation',
45
+ ];
17
46
  if (Object.keys(proxyEnv).length > 0) {
18
47
  signals.push('egress-proxy-chokepoint');
19
48
  }
@@ -25,9 +54,12 @@ function containerAttestation(proxyEnv) {
25
54
  driver: 'container',
26
55
  probedAt,
27
56
  expiresAt: new Date(Date.now() + ATTESTATION_TTL_MS).toISOString(),
28
- deniesUngrantedEffects: true,
29
- materializesGrants: true,
30
- probeSignals: signals,
57
+ // Mount and network classes are isolated, but exact grants are not yet
58
+ // passed to and enforced by the process runner.
59
+ deniesUngrantedEffects: false,
60
+ materializesGrants: false,
61
+ isolatesWorkspaceMounts: true,
62
+ probeSignals: [...signals, 'exact-grant-enforcement-unavailable'],
31
63
  };
32
64
  }
33
65
  function runDockerProbe(image) {
@@ -39,45 +71,80 @@ function runDockerProbe(image) {
39
71
  child.on('close', (code) => resolve(code === 0));
40
72
  });
41
73
  }
42
- function runInContainer(image, command, cwd, timeoutMs, proxyEnv, mountReadOnly, repoRoot) {
43
- const mount = canonicalPath(cwd);
44
- const mountSpec = mountReadOnly ? `${mount}:${mount}:ro` : `${mount}:${mount}`;
45
- const proxyActive = Object.keys(proxyEnv).length > 0;
46
- const networkArgs = dockerNetworkArgs(proxyActive, repoRoot);
47
- const args = [
74
+ export function buildContainerRunArgs(params) {
75
+ const proxyActive = Object.keys(params.proxyEnv).length > 0;
76
+ const networkArgs = dockerNetworkArgs(proxyActive, params.repoRoot);
77
+ const containerNameArgs = params.containerName ? ['--name', params.containerName] : [];
78
+ const workspaceMount = params.runOptions?.workspaceMount;
79
+ if (workspaceMount) {
80
+ if (!params.repoRoot) {
81
+ throw new Error('boundary_workspace_mount_missing_resource_root');
82
+ }
83
+ if (canonicalPath(workspaceMount.guestTargetRoot) !== canonicalPath(params.repoRoot)) {
84
+ throw new Error('boundary_workspace_mount_target_mismatch');
85
+ }
86
+ const workdir = resolveGuestWorkdir(workspaceMount);
87
+ if (canonicalPath(params.cwd) !== workdir) {
88
+ throw new Error('boundary_workspace_mount_cwd_mismatch');
89
+ }
90
+ return [
91
+ 'run',
92
+ '--rm',
93
+ ...containerNameArgs,
94
+ ...networkArgs,
95
+ '--mount',
96
+ buildWorkspaceMountSpec(workspaceMount),
97
+ '-w',
98
+ workdir,
99
+ ...workspaceMountEnvArgs(workspaceMount),
100
+ ...dockerEnvArgs(params.proxyEnv),
101
+ params.image,
102
+ 'sh',
103
+ '-c',
104
+ params.command,
105
+ ];
106
+ }
107
+ const mount = canonicalPath(params.cwd);
108
+ const mountSpec = params.mountReadOnly ? `${mount}:${mount}:ro` : `${mount}:${mount}`;
109
+ return [
48
110
  'run',
49
111
  '--rm',
112
+ ...containerNameArgs,
50
113
  ...networkArgs,
51
114
  '-v',
52
115
  mountSpec,
53
116
  '-w',
54
117
  mount,
55
- ...dockerEnvArgs(proxyEnv),
56
- image,
118
+ ...dockerEnvArgs(params.proxyEnv),
119
+ params.image,
57
120
  'sh',
58
121
  '-c',
59
- command,
122
+ params.command,
60
123
  ];
61
- return new Promise((resolve) => {
62
- const child = spawn('docker', args, { stdio: 'ignore' });
63
- let timedOut = false;
64
- const timer = setTimeout(() => {
65
- timedOut = true;
66
- child.kill('SIGTERM');
67
- }, timeoutMs);
68
- child.on('error', () => {
69
- clearTimeout(timer);
70
- resolve({ exitCode: 1, signal: null, timedOut });
71
- });
72
- child.on('close', (exitCode, signal) => {
73
- clearTimeout(timer);
74
- resolve({
75
- exitCode,
76
- signal: signal ? String(signal) : null,
77
- timedOut,
78
- });
79
- });
124
+ }
125
+ async function runInContainer(image, command, cwd, timeoutMs, proxyEnv, mountReadOnly, repoRoot, runOptions) {
126
+ const containerName = `belay-run-${randomUUID()}`;
127
+ const args = buildContainerRunArgs({
128
+ image,
129
+ command,
130
+ cwd,
131
+ proxyEnv,
132
+ mountReadOnly,
133
+ repoRoot,
134
+ runOptions,
135
+ containerName,
80
136
  });
137
+ const result = await runProcessWithBoundedOutput('docker', args, {}, timeoutMs);
138
+ if (result.timedOut) {
139
+ const cleanup = await runProcessWithBoundedOutput('docker', ['rm', '-f', containerName], {}, CONTAINER_CLEANUP_TIMEOUT_MS);
140
+ const cleanupFailed = cleanup.timedOut || cleanup.exitCode !== 0;
141
+ const absent = containerDoesNotExist(cleanup) ||
142
+ (cleanupFailed && (await confirmContainerAbsent(containerName)));
143
+ if (cleanupFailed && !absent) {
144
+ throw new BoundaryCleanupError('container', containerName);
145
+ }
146
+ }
147
+ return result;
81
148
  }
82
149
  export function createContainerBoundaryDriver(options = {}) {
83
150
  const image = options.image ?? DEFAULT_IMAGE;
@@ -102,12 +169,12 @@ export function createContainerBoundaryDriver(options = {}) {
102
169
  }
103
170
  },
104
171
  async run(command, cwd, timeoutMs, options) {
105
- const mountReadOnly = options?.mountReadOnly !== false;
172
+ const mountReadOnly = options?.mountReadOnly !== false && !options?.workspaceMount;
106
173
  const proxyActive = Object.keys(proxyEnv).length > 0;
107
174
  if (proxyActive && repoRoot && !preparedNetwork) {
108
175
  await ensureBelayContainerNetwork(repoRoot);
109
176
  }
110
- return runInContainer(image, command, cwd, timeoutMs, proxyEnv, mountReadOnly, repoRoot);
177
+ return runInContainer(image, command, cwd, timeoutMs, proxyEnv, mountReadOnly, repoRoot, options);
111
178
  },
112
179
  materializeGrant(request, context) {
113
180
  return materializeContainerBoundaryGrant(request, {
@@ -11,6 +11,7 @@ function hostIntegrationAttestation(driver) {
11
11
  expiresAt: new Date(Date.now() + ATTESTATION_TTL_MS).toISOString(),
12
12
  deniesUngrantedEffects: false,
13
13
  materializesGrants: false,
14
+ isolatesWorkspaceMounts: false,
14
15
  probeSignals: ['host-integration', 'l3-policy-only'],
15
16
  };
16
17
  }
@@ -11,3 +11,4 @@ export interface MaterializeBoundaryGrantParams {
11
11
  sensitivePaths?: string[];
12
12
  }
13
13
  export declare function materializeContainerBoundaryGrant(request: CapabilityRequestV1, params: MaterializeBoundaryGrantParams): CapabilityGrantV1 | null;
14
+ export declare function materializeContainerBoundaryGrants(requests: readonly CapabilityRequestV1[], params: MaterializeBoundaryGrantParams): CapabilityGrantV1[];
@@ -76,3 +76,18 @@ export function materializeContainerBoundaryGrant(request, params) {
76
76
  }
77
77
  return null;
78
78
  }
79
+ export function materializeContainerBoundaryGrants(requests, params) {
80
+ const grants = [];
81
+ let existing = [...(params.existingGrants ?? [])];
82
+ for (const request of requests) {
83
+ const materialized = materializeContainerBoundaryGrant(request, {
84
+ ...params,
85
+ existingGrants: existing,
86
+ });
87
+ if (materialized) {
88
+ grants.push(materialized);
89
+ existing = [...existing, materialized];
90
+ }
91
+ }
92
+ return grants;
93
+ }
@@ -1,8 +1,33 @@
1
1
  import type { ShellRunResult } from '../transactional/git-worktree.js';
2
2
  import type { ClassifyResult } from '../types.js';
3
+ export interface BoundaryCleanupFailure {
4
+ code: 'BOUNDARY_CLEANUP_UNCONFIRMED';
5
+ resourceKind: 'container';
6
+ resourceId: string;
7
+ executionStarted: true;
8
+ cleanupConfirmed: false;
9
+ }
10
+ export declare class BoundaryCleanupError extends Error implements BoundaryCleanupFailure {
11
+ readonly resourceKind: 'container';
12
+ readonly resourceId: string;
13
+ readonly code: "BOUNDARY_CLEANUP_UNCONFIRMED";
14
+ readonly executionStarted: true;
15
+ readonly cleanupConfirmed: false;
16
+ constructor(resourceKind: 'container', resourceId: string);
17
+ }
18
+ export declare function isBoundaryCleanupError(value: unknown): value is BoundaryCleanupFailure;
19
+ export declare function safeBoundaryCleanupResourceId(value: unknown): string | undefined;
20
+ export interface BoundaryWorkspaceMount {
21
+ hostSourceRoot: string;
22
+ guestTargetRoot: string;
23
+ cwdRelative: string;
24
+ writable: boolean;
25
+ hideHostSourcePath: boolean;
26
+ }
3
27
  export interface BoundaryRunOptions {
4
28
  /** When true, container driver mounts the working directory read-only. */
5
29
  mountReadOnly?: boolean;
30
+ workspaceMount?: BoundaryWorkspaceMount;
6
31
  }
7
32
  export interface BoundaryPrepareContext {
8
33
  repoRoot?: string;
@@ -1,17 +1,45 @@
1
+ const SAFE_CONTAINER_RESOURCE_ID = /^belay-run-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
2
+ export class BoundaryCleanupError extends Error {
3
+ resourceKind;
4
+ resourceId;
5
+ code = 'BOUNDARY_CLEANUP_UNCONFIRMED';
6
+ executionStarted = true;
7
+ cleanupConfirmed = false;
8
+ constructor(resourceKind, resourceId) {
9
+ super('Boundary cleanup could not be confirmed');
10
+ this.resourceKind = resourceKind;
11
+ this.resourceId = resourceId;
12
+ this.name = 'BoundaryCleanupError';
13
+ }
14
+ }
15
+ export function isBoundaryCleanupError(value) {
16
+ if (!value || typeof value !== 'object') {
17
+ return false;
18
+ }
19
+ const candidate = value;
20
+ return (candidate.code === 'BOUNDARY_CLEANUP_UNCONFIRMED' &&
21
+ candidate.resourceKind === 'container' &&
22
+ typeof candidate.resourceId === 'string' &&
23
+ candidate.executionStarted === true &&
24
+ candidate.cleanupConfirmed === false);
25
+ }
26
+ export function safeBoundaryCleanupResourceId(value) {
27
+ if (!isBoundaryCleanupError(value) || !SAFE_CONTAINER_RESOURCE_ID.test(value.resourceId)) {
28
+ return undefined;
29
+ }
30
+ return value.resourceId;
31
+ }
1
32
  export function boundaryMountReadOnlyFromPrediction(predicted) {
2
33
  const effect = predicted.axes?.effect;
3
34
  if (effect === 'local_mutation' || effect === 'remote_mutation') {
4
35
  return false;
5
36
  }
6
- const action = predicted.capabilityRequests?.[0]?.action;
7
- if (action === 'fs.write' || action === 'git.ref.write' || action === 'control_plane.write') {
8
- return false;
9
- }
10
- if (action === 'fs.read') {
11
- return true;
12
- }
13
- if (action === 'network.connect' || action === 'process.exec' || action === 'indeterminate') {
14
- return true;
37
+ for (const request of predicted.capabilityRequests ?? []) {
38
+ if (request.action === 'fs.write' ||
39
+ request.action === 'git.ref.write' ||
40
+ request.action === 'control_plane.write') {
41
+ return false;
42
+ }
15
43
  }
16
44
  return true;
17
45
  }
@@ -12,6 +12,9 @@ export interface ResolvedBoundaryDriverContext {
12
12
  proxyActive: boolean;
13
13
  proxyEnv: Record<string, string>;
14
14
  prepareContext: BoundaryPrepareContext;
15
+ attestationPath: string;
16
+ attestation: BoundaryAttestation | null;
17
+ attestationFresh: boolean;
15
18
  }
16
19
  export declare function boundaryAttestationPath(repoRoot: string, config: BelayConfigV4): string;
17
20
  export declare function loadBoundaryAttestation(filePath: string, expectedRepoRoot?: string, controlPlaneDir?: string): Promise<BoundaryAttestation | null>;
@@ -53,5 +56,6 @@ export declare function runBoundaryAgentCommand(params: {
53
56
  command: string;
54
57
  cwd?: string;
55
58
  timeoutMs?: number;
59
+ runOptions?: BoundaryRunOptions;
56
60
  }): Promise<Awaited<ReturnType<BoundaryDriver['run']>>>;
57
61
  export { dockerNetworkArgs };