@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.
- package/README.md +39 -20
- package/dist/adapters/claude/runtime-entry.js +54 -25
- package/dist/adapters/shared/gate-runtime.d.ts +7 -0
- package/dist/adapters/shared/gate-runtime.js +443 -225
- package/dist/bundle/claude-runtime.mjs +13027 -7271
- package/dist/bundle/codex-runtime.mjs +12700 -6977
- package/dist/bundle/cursor-runtime.mjs +12695 -6977
- package/dist/cli.js +37 -1
- package/dist/commands/approve.js +34 -10
- package/dist/commands/doctor.js +58 -0
- package/dist/commands/dogfood.js +1 -1
- package/dist/commands/explain.js +2 -2
- package/dist/commands/harvest.d.ts +0 -1
- package/dist/commands/harvest.js +1 -4
- package/dist/commands/metrics.js +6 -0
- package/dist/commands/quality.js +1 -3
- package/dist/commands/recovery-checkpoints.d.ts +164 -0
- package/dist/commands/recovery-checkpoints.js +347 -0
- package/dist/commands/revoke.js +19 -7
- package/dist/commands/status.js +7 -0
- package/dist/config-io.js +49 -18
- package/dist/conformance/guarantee-table.js +137 -9
- package/dist/conformance/types.d.ts +2 -0
- package/dist/core/approval-replay.d.ts +1 -1
- package/dist/core/approval-replay.js +4 -1
- package/dist/core/approval-service.d.ts +6 -1
- package/dist/core/approval-service.js +86 -27
- package/dist/core/approval.d.ts +2 -0
- package/dist/core/approval.js +6 -3
- package/dist/core/audit-metrics.d.ts +1 -0
- package/dist/core/audit-metrics.js +12 -4
- package/dist/core/audit-types.d.ts +16 -0
- package/dist/core/capability/approval-state-mutation.d.ts +19 -0
- package/dist/core/capability/approval-state-mutation.js +76 -3
- package/dist/core/capability/approval-v3.d.ts +18 -1
- package/dist/core/capability/approval-v3.js +63 -23
- package/dist/core/capability/attestation.d.ts +3 -0
- package/dist/core/capability/attestation.js +7 -0
- package/dist/core/capability/boundary-driver-container.d.ts +12 -0
- package/dist/core/capability/boundary-driver-container.js +101 -34
- package/dist/core/capability/boundary-driver.js +1 -0
- package/dist/core/capability/boundary-grant-materialize.d.ts +1 -0
- package/dist/core/capability/boundary-grant-materialize.js +15 -0
- package/dist/core/capability/boundary-run.d.ts +25 -0
- package/dist/core/capability/boundary-run.js +37 -9
- package/dist/core/capability/boundary-session.d.ts +4 -0
- package/dist/core/capability/boundary-session.js +9 -0
- package/dist/core/capability/boundary-workspace-mount.d.ts +5 -0
- package/dist/core/capability/boundary-workspace-mount.js +68 -0
- package/dist/core/capability/gate-policy-shadow.js +23 -1
- package/dist/core/capability/grant-consumption.d.ts +4 -0
- package/dist/core/capability/grant-consumption.js +11 -0
- package/dist/core/capability/grant-lease.d.ts +36 -1
- package/dist/core/capability/grant-lease.js +213 -16
- package/dist/core/capability/grant-match.js +3 -0
- package/dist/core/capability/index.d.ts +2 -2
- package/dist/core/capability/index.js +1 -1
- package/dist/core/capability/policy-engine.d.ts +38 -1
- package/dist/core/capability/policy-engine.js +388 -15
- package/dist/core/capability/request.d.ts +12 -0
- package/dist/core/capability/resolver.d.ts +0 -1
- package/dist/core/capability/resolver.js +0 -1
- package/dist/core/config.d.ts +20 -0
- package/dist/core/config.js +62 -2
- package/dist/core/effect-ir/audit.d.ts +9 -0
- package/dist/core/effect-ir/audit.js +87 -0
- package/dist/core/effect-ir/build.d.ts +31 -0
- package/dist/core/effect-ir/build.js +281 -0
- package/dist/core/effect-ir/index.d.ts +13 -0
- package/dist/core/effect-ir/index.js +9 -0
- package/dist/core/effect-ir/normalize.d.ts +4 -0
- package/dist/core/effect-ir/normalize.js +110 -0
- package/dist/core/effect-ir/package-exec-eval.d.ts +8 -0
- package/dist/core/effect-ir/package-exec-eval.js +152 -0
- package/dist/core/effect-ir/package-exec.d.ts +29 -0
- package/dist/core/effect-ir/package-exec.js +292 -0
- package/dist/core/effect-ir/policy.d.ts +14 -0
- package/dist/core/effect-ir/policy.js +164 -0
- package/dist/core/effect-ir/shell-build.d.ts +43 -0
- package/dist/core/effect-ir/shell-build.js +77 -0
- package/dist/core/effect-ir/shell-lower.d.ts +14 -0
- package/dist/core/effect-ir/shell-lower.js +1509 -0
- package/dist/core/effect-ir/types.d.ts +62 -0
- package/dist/core/effect-ir/types.js +1 -0
- package/dist/core/egress/allowlist.d.ts +1 -0
- package/dist/core/egress/allowlist.js +8 -0
- package/dist/core/egress-approval.js +45 -51
- package/dist/core/gate-contract.d.ts +2 -0
- package/dist/core/gate-contract.js +2 -0
- package/dist/core/gate-engine.js +82 -25
- package/dist/core/git-resource-identity.d.ts +26 -0
- package/dist/core/git-resource-identity.js +284 -0
- package/dist/core/harvest.d.ts +3 -7
- package/dist/core/harvest.js +4 -27
- package/dist/core/index.d.ts +5 -1
- package/dist/core/index.js +3 -1
- package/dist/core/judge-cloud-consent.js +29 -15
- package/dist/core/network-endpoint.d.ts +10 -0
- package/dist/core/network-endpoint.js +51 -0
- package/dist/core/path-utils.js +42 -23
- package/dist/core/process-runner.d.ts +10 -0
- package/dist/core/process-runner.js +124 -0
- package/dist/core/recover-advice.js +2 -1
- package/dist/core/recovery/artifact-store.d.ts +29 -0
- package/dist/core/recovery/artifact-store.js +336 -0
- package/dist/core/recovery/checkpoint.d.ts +25 -0
- package/dist/core/recovery/checkpoint.js +260 -0
- package/dist/core/recovery/index.d.ts +3 -1
- package/dist/core/recovery/index.js +2 -0
- package/dist/core/recovery/operator-guidance.d.ts +7 -0
- package/dist/core/recovery/operator-guidance.js +39 -0
- package/dist/core/recovery/reconcile.d.ts +3 -0
- package/dist/core/recovery/reconcile.js +48 -0
- package/dist/core/recovery/resource-identity.d.ts +4 -0
- package/dist/core/recovery/resource-identity.js +34 -0
- package/dist/core/recovery/restore.d.ts +13 -0
- package/dist/core/recovery/restore.js +114 -0
- package/dist/core/recovery/snapshot-node.d.ts +27 -0
- package/dist/core/recovery/snapshot-node.js +261 -0
- package/dist/core/recovery/types.d.ts +85 -1
- package/dist/core/shell-substitution.d.ts +1 -0
- package/dist/core/shell-substitution.js +61 -0
- package/dist/core/standing-allow.d.ts +1 -2
- package/dist/core/standing-allow.js +3 -20
- package/dist/core/transactional/apply-observed-changes.d.ts +13 -0
- package/dist/core/transactional/apply-observed-changes.js +275 -0
- package/dist/core/transactional/backend-selector.d.ts +8 -0
- package/dist/core/transactional/backend-selector.js +118 -0
- package/dist/core/transactional/backend.d.ts +55 -0
- package/dist/core/transactional/backend.js +1 -0
- package/dist/core/transactional/file-checkpoint-backend.d.ts +3 -0
- package/dist/core/transactional/file-checkpoint-backend.js +278 -0
- package/dist/core/transactional/file-checkpoint-git.d.ts +11 -0
- package/dist/core/transactional/file-checkpoint-git.js +217 -0
- package/dist/core/transactional/file-checkpoint-staging.d.ts +13 -0
- package/dist/core/transactional/file-checkpoint-staging.js +54 -0
- package/dist/core/transactional/file-clone.d.ts +15 -0
- package/dist/core/transactional/file-clone.js +139 -0
- package/dist/core/transactional/file-tree-path.d.ts +7 -0
- package/dist/core/transactional/file-tree-path.js +50 -0
- package/dist/core/transactional/file-tree.d.ts +41 -0
- package/dist/core/transactional/file-tree.js +186 -0
- package/dist/core/transactional/git-worktree-backend.d.ts +2 -0
- package/dist/core/transactional/git-worktree-backend.js +50 -0
- package/dist/core/transactional/git-worktree.d.ts +9 -11
- package/dist/core/transactional/git-worktree.js +24 -119
- package/dist/core/transactional/index.d.ts +11 -0
- package/dist/core/transactional/index.js +10 -0
- package/dist/core/transactional/reasons.js +1 -0
- package/dist/core/transactional/runner.js +157 -25
- package/dist/core/transactional/snapshot-node.d.ts +26 -0
- package/dist/core/transactional/snapshot-node.js +87 -0
- package/dist/core/transactional/types.d.ts +17 -1
- package/dist/core/types.d.ts +13 -1
- package/dist/core/verdict/adapter.js +23 -15
- package/dist/core/verdict/containment.js +4 -0
- package/dist/core/verdict/egress-classify.d.ts +12 -0
- package/dist/core/verdict/egress-classify.js +746 -0
- package/dist/core/verdict/git-classifier.d.ts +12 -0
- package/dist/core/verdict/git-classifier.js +320 -0
- package/dist/core/verdict/launcher-resolve.js +79 -37
- package/dist/core/verdict/parser.d.ts +2 -0
- package/dist/core/verdict/parser.js +95 -1
- package/dist/core/verdict/types.d.ts +6 -2
- package/dist/core/verdict/verdict.js +103 -935
- package/dist/corpus/must-allow-commands.d.ts +2 -1
- package/dist/corpus/must-allow-commands.js +2 -1
- package/dist/corpus/runtime-match.d.ts +2 -1
- package/dist/corpus/runtime-match.js +2 -1
- package/dist/corpus/types.d.ts +2 -2
- package/dist/templates.js +7 -2
- package/dist/types.d.ts +11 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
- package/skills/belay/SKILL.md +8 -3
- package/skills/belay/belay-approve.md +10 -7
- package/dist/core/verdict/overrides.d.ts +0 -7
- package/dist/core/verdict/overrides.js +0 -37
- package/dist/core/verdict/shell-policy.d.ts +0 -25
- package/dist/core/verdict/shell-policy.js +0 -40
- package/dist/corpus/standing-allow-catalog.generated.d.ts +0 -13
- 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
|
|
60
|
-
const lockPath = `${
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
...
|
|
35
|
-
|
|
36
|
-
capabilityRequestHash: hashCapabilityRequests(capabilityRequests),
|
|
42
|
+
...withRequests,
|
|
43
|
+
effectPlanHash,
|
|
37
44
|
};
|
|
38
45
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
if (
|
|
42
|
-
return
|
|
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
|
|
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
|
-
|
|
61
|
-
|
|
94
|
+
const normalized = normalizeApprovalGrants(approval);
|
|
95
|
+
if ((normalized.grants?.length ?? 0) > 0 || !normalized.capabilityRequests?.length) {
|
|
96
|
+
return normalized;
|
|
62
97
|
}
|
|
63
|
-
const
|
|
64
|
-
approval,
|
|
65
|
-
capabilityRequests:
|
|
98
|
+
const grants = mintCapabilityGrantBundle({
|
|
99
|
+
approval: normalized,
|
|
100
|
+
capabilityRequests: normalized.capabilityRequests,
|
|
66
101
|
});
|
|
67
|
-
if (!
|
|
68
|
-
return
|
|
102
|
+
if (!grants.length) {
|
|
103
|
+
return normalized;
|
|
69
104
|
}
|
|
70
|
-
return {
|
|
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 = [
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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 };
|