@guilz-dev/belay 0.4.0 → 0.6.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 +7 -1
- package/dist/adapters/layouts/scope.js +3 -2
- package/dist/adapters/shared/gate-runtime.d.ts +1 -2
- package/dist/adapters/shared/gate-runtime.js +226 -28
- package/dist/bundle/claude-runtime.mjs +6967 -4078
- package/dist/bundle/codex-runtime.mjs +5951 -3064
- package/dist/bundle/cursor-runtime.mjs +5951 -3064
- package/dist/cli.js +43 -1
- package/dist/commands/classify-for-report.js +1 -1
- package/dist/commands/config.d.ts +8 -0
- package/dist/commands/config.js +134 -11
- package/dist/commands/doctor.js +36 -1
- package/dist/commands/explain.js +11 -1
- package/dist/commands/judge.js +3 -2
- package/dist/commands/session.d.ts +20 -0
- package/dist/commands/session.js +51 -0
- package/dist/config-io.js +49 -12
- package/dist/conformance/guarantee-posture.d.ts +21 -0
- package/dist/conformance/guarantee-posture.js +46 -0
- package/dist/conformance/guarantee-table.d.ts +1 -0
- package/dist/conformance/guarantee-table.js +14 -9
- package/dist/core/approval-service.js +6 -2
- package/dist/core/approval.d.ts +2 -0
- package/dist/core/approval.js +14 -3
- package/dist/core/capability/approval-state-mutation.d.ts +13 -0
- package/dist/core/capability/approval-state-mutation.js +92 -0
- package/dist/core/capability/approval-v3.d.ts +15 -0
- package/dist/core/capability/approval-v3.js +74 -0
- package/dist/core/capability/attestation.d.ts +13 -0
- package/dist/core/capability/attestation.js +55 -0
- package/dist/core/capability/boundary-attestation-sign.d.ts +21 -0
- package/dist/core/capability/boundary-attestation-sign.js +84 -0
- package/dist/core/capability/boundary-driver-container.d.ts +7 -0
- package/dist/core/capability/boundary-driver-container.js +122 -0
- package/dist/core/capability/boundary-driver.d.ts +29 -0
- package/dist/core/capability/boundary-driver.js +43 -0
- package/dist/core/capability/boundary-egress.d.ts +21 -0
- package/dist/core/capability/boundary-egress.js +109 -0
- package/dist/core/capability/boundary-grant-materialize.d.ts +13 -0
- package/dist/core/capability/boundary-grant-materialize.js +78 -0
- package/dist/core/capability/boundary-profile.d.ts +15 -0
- package/dist/core/capability/boundary-profile.js +25 -0
- package/dist/core/capability/boundary-run.d.ts +23 -0
- package/dist/core/capability/boundary-run.js +23 -0
- package/dist/core/capability/boundary-session.d.ts +57 -0
- package/dist/core/capability/boundary-session.js +129 -0
- package/dist/core/capability/capability-request-hash.d.ts +2 -0
- package/dist/core/capability/capability-request-hash.js +8 -0
- package/dist/core/capability/gate-latency-ratchet.d.ts +7 -0
- package/dist/core/capability/gate-latency-ratchet.js +18 -0
- package/dist/core/capability/gate-policy-shadow.d.ts +25 -0
- package/dist/core/capability/gate-policy-shadow.js +108 -0
- package/dist/core/capability/gate-shadow-audit.d.ts +1 -0
- package/dist/core/capability/gate-shadow-audit.js +1 -0
- package/dist/core/capability/gate-shadow-ratchet.d.ts +14 -0
- package/dist/core/capability/gate-shadow-ratchet.js +81 -0
- package/dist/core/capability/grant-lease.d.ts +9 -0
- package/dist/core/capability/grant-lease.js +46 -0
- package/dist/core/capability/grant-loader.d.ts +12 -0
- package/dist/core/capability/grant-loader.js +23 -0
- package/dist/core/capability/grant-match.d.ts +6 -0
- package/dist/core/capability/grant-match.js +93 -0
- package/dist/core/capability/grant.d.ts +16 -0
- package/dist/core/capability/grant.js +16 -0
- package/dist/core/capability/index.d.ts +8 -0
- package/dist/core/capability/index.js +7 -0
- package/dist/core/capability/limits.d.ts +7 -0
- package/dist/core/capability/limits.js +55 -0
- package/dist/core/capability/policy-bridge.d.ts +18 -0
- package/dist/core/capability/policy-bridge.js +51 -0
- package/dist/core/capability/policy-engine.d.ts +70 -0
- package/dist/core/capability/policy-engine.js +476 -0
- package/dist/core/capability/policy-types.d.ts +20 -0
- package/dist/core/capability/policy-types.js +1 -0
- package/dist/core/capability/request.d.ts +45 -0
- package/dist/core/capability/request.js +1 -0
- package/dist/core/capability/resolver.d.ts +4 -0
- package/dist/core/capability/resolver.js +4 -0
- package/dist/core/classify-subagent.d.ts +2 -1
- package/dist/core/classify-subagent.js +38 -2
- package/dist/core/classify-tool.js +126 -62
- package/dist/core/config.d.ts +14 -3
- package/dist/core/config.js +56 -3
- package/dist/core/gate-contract.d.ts +3 -0
- package/dist/core/gate-contract.js +3 -0
- package/dist/core/gate-engine.js +6 -1
- package/dist/core/judge-doctor.js +39 -14
- package/dist/core/judge-fallback-hints.d.ts +13 -0
- package/dist/core/judge-fallback-hints.js +108 -0
- package/dist/core/path-utils.d.ts +1 -0
- package/dist/core/path-utils.js +5 -2
- package/dist/core/recovery/capability.d.ts +3 -0
- package/dist/core/recovery/capability.js +22 -0
- package/dist/core/recovery/fail-closed.d.ts +6 -0
- package/dist/core/recovery/fail-closed.js +25 -0
- package/dist/core/recovery/index.d.ts +3 -0
- package/dist/core/recovery/index.js +2 -0
- package/dist/core/recovery/types.d.ts +20 -0
- package/dist/core/recovery/types.js +1 -0
- package/dist/core/standing-allow.js +3 -0
- package/dist/core/transactional/diff-evaluator.js +4 -1
- package/dist/core/transactional/eligibility.js +4 -0
- package/dist/core/transactional/git-worktree.d.ts +10 -3
- package/dist/core/transactional/git-worktree.js +81 -11
- package/dist/core/transactional/reasons.d.ts +1 -0
- package/dist/core/transactional/reasons.js +4 -0
- package/dist/core/transactional/runner.js +22 -6
- package/dist/core/transactional/types.d.ts +4 -0
- package/dist/core/types.d.ts +21 -3
- package/dist/core/verdict/adapter.d.ts +3 -4
- package/dist/core/verdict/adapter.js +16 -12
- package/dist/core/verdict/cursor-acp-client.d.ts +67 -0
- package/dist/core/verdict/cursor-acp-client.js +345 -0
- package/dist/core/verdict/egress-classify.d.ts +2 -2
- package/dist/core/verdict/egress-classify.js +2 -2
- package/dist/core/verdict/git-classifier.d.ts +26 -0
- package/dist/core/verdict/git-classifier.js +533 -0
- package/dist/core/verdict/judge-audit.js +1 -0
- package/dist/core/verdict/judge-baseline.d.ts +1 -1
- package/dist/core/verdict/judge-baseline.js +1 -0
- package/dist/core/verdict/judge-broker-service.js +12 -0
- package/dist/core/verdict/judge-catalog.js +1 -0
- package/dist/core/verdict/judge-cli-fingerprint.d.ts +1 -0
- package/dist/core/verdict/judge-cli-fingerprint.js +6 -0
- package/dist/core/verdict/judge-cli.js +5 -3
- package/dist/core/verdict/judge-provider-matrix.js +1 -1
- package/dist/core/verdict/judge-runtime-config.d.ts +2 -0
- package/dist/core/verdict/judge-runtime-config.js +10 -6
- package/dist/core/verdict/judge-session-broker.d.ts +1 -1
- package/dist/core/verdict/judge-session-broker.js +10 -5
- package/dist/core/verdict/judge-transport.d.ts +5 -1
- package/dist/core/verdict/judge-transport.js +24 -14
- package/dist/core/verdict/judge.d.ts +2 -15
- package/dist/core/verdict/judge.js +5 -68
- package/dist/core/verdict/launcher-resolve.d.ts +2 -0
- package/dist/core/verdict/launcher-resolve.js +16 -0
- package/dist/core/verdict/prescan.d.ts +15 -0
- package/dist/core/verdict/prescan.js +68 -0
- package/dist/core/verdict/shell-policy.d.ts +25 -0
- package/dist/core/verdict/shell-policy.js +40 -0
- package/dist/core/verdict/shell-semantics.d.ts +24 -0
- package/dist/core/verdict/shell-semantics.js +225 -0
- package/dist/core/verdict/types.d.ts +13 -1
- package/dist/core/verdict/verdict.js +296 -178
- package/dist/corpus/evaluate.js +3 -7
- package/dist/corpus/gate-latency-budget.d.ts +39 -0
- package/dist/corpus/gate-latency-budget.js +33 -0
- package/dist/corpus/mutators.d.ts +1 -1
- package/dist/corpus/mutators.js +11 -0
- package/dist/corpus/runtime-match.js +1 -3
- package/dist/corpus/standing-allow-catalog.generated.js +29 -9
- package/dist/judge-broker-daemon.js +15 -1
- package/dist/services/sandbox-service.d.ts +4 -0
- package/dist/services/sandbox-service.js +28 -2
- package/dist/types.d.ts +2 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { evaluateGuaranteePosture } from './guarantee-posture.js';
|
|
1
2
|
/** Normative rows — keep in sync with docs/guarantee-table.md */
|
|
2
3
|
export const GUARANTEE_TABLE_ROWS = [
|
|
3
4
|
{
|
|
@@ -33,9 +34,10 @@ export const GUARANTEE_SCENARIOS = {
|
|
|
33
34
|
permission: 'allow',
|
|
34
35
|
},
|
|
35
36
|
{
|
|
36
|
-
id: 'l3-
|
|
37
|
+
id: 'l3-deny-read-egress',
|
|
37
38
|
command: 'curl https://example.com',
|
|
38
|
-
permission: '
|
|
39
|
+
permission: 'deny',
|
|
40
|
+
reason: 'external_effect',
|
|
39
41
|
},
|
|
40
42
|
],
|
|
41
43
|
'l1-partial-egress': [
|
|
@@ -45,9 +47,10 @@ export const GUARANTEE_SCENARIOS = {
|
|
|
45
47
|
permission: 'allow',
|
|
46
48
|
},
|
|
47
49
|
{
|
|
48
|
-
id: 'l1p-
|
|
50
|
+
id: 'l1p-deny-read-egress',
|
|
49
51
|
command: 'curl https://example.com',
|
|
50
|
-
permission: '
|
|
52
|
+
permission: 'deny',
|
|
53
|
+
reason: 'external_effect',
|
|
51
54
|
},
|
|
52
55
|
{
|
|
53
56
|
id: 'l1p-deny-write-egress',
|
|
@@ -63,9 +66,10 @@ export const GUARANTEE_SCENARIOS = {
|
|
|
63
66
|
permission: 'allow',
|
|
64
67
|
},
|
|
65
68
|
{
|
|
66
|
-
id: 'l2-
|
|
69
|
+
id: 'l2-deny-read-egress',
|
|
67
70
|
command: 'curl https://example.com',
|
|
68
|
-
permission: '
|
|
71
|
+
permission: 'deny',
|
|
72
|
+
reason: 'external_effect',
|
|
69
73
|
},
|
|
70
74
|
],
|
|
71
75
|
'l1-full': [
|
|
@@ -75,9 +79,10 @@ export const GUARANTEE_SCENARIOS = {
|
|
|
75
79
|
permission: 'allow',
|
|
76
80
|
},
|
|
77
81
|
{
|
|
78
|
-
id: 'l1f-
|
|
82
|
+
id: 'l1f-deny-read-egress',
|
|
79
83
|
command: 'curl https://example.com',
|
|
80
|
-
permission: '
|
|
84
|
+
permission: 'deny',
|
|
85
|
+
reason: 'external_effect',
|
|
81
86
|
},
|
|
82
87
|
{
|
|
83
88
|
id: 'l1f-deny-write-egress',
|
|
@@ -90,7 +95,7 @@ export const GUARANTEE_SCENARIOS = {
|
|
|
90
95
|
id: 'l1f-deny-outside-repo',
|
|
91
96
|
command: 'echo hi > ../../outside.txt',
|
|
92
97
|
permission: 'deny',
|
|
93
|
-
reason: '
|
|
98
|
+
reason: 'outside_repo_mutation',
|
|
94
99
|
},
|
|
95
100
|
{
|
|
96
101
|
id: 'l1f-deny-outside-repo-write',
|
|
@@ -2,6 +2,7 @@ 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 { APPROVAL_STATE_VERSION_V3, mintGrantForApprovedRecord } from './capability/approval-v3.js';
|
|
5
6
|
import { configuredControlPlaneDir } from './config.js';
|
|
6
7
|
export async function recordApproval(params) {
|
|
7
8
|
const { approvalId, config, store, token, requireSignedToken = false, adapter } = params;
|
|
@@ -30,15 +31,18 @@ export async function recordApproval(params) {
|
|
|
30
31
|
await store.writePending(pending.filePath, pending.state);
|
|
31
32
|
const approved = await store.loadApproved();
|
|
32
33
|
approved.state = compactApprovals(approved.state);
|
|
33
|
-
|
|
34
|
+
const approvedRecord = mintGrantForApprovedRecord({
|
|
34
35
|
...approval,
|
|
35
36
|
approvedAt: new Date().toISOString(),
|
|
36
37
|
});
|
|
38
|
+
approved.state.version = APPROVAL_STATE_VERSION_V3;
|
|
39
|
+
approved.state.revision = (approved.state.revision ?? 0) + 1;
|
|
40
|
+
approved.state.approvals.push(approvedRecord);
|
|
37
41
|
await store.writeApproved(approved.filePath, approved.state);
|
|
38
42
|
return {
|
|
39
43
|
ok: true,
|
|
40
44
|
message: buildApprovalRecordedMessage(config, approval, adapter),
|
|
41
|
-
approval,
|
|
45
|
+
approval: approvedRecord,
|
|
42
46
|
};
|
|
43
47
|
}
|
|
44
48
|
/** CLI `--replay` already executed the shell command; drop the one-shot grant. */
|
package/dist/core/approval.d.ts
CHANGED
|
@@ -24,6 +24,7 @@ export declare function createApprovalRecordWithEnvelope(params: {
|
|
|
24
24
|
payload?: Record<string, unknown>;
|
|
25
25
|
};
|
|
26
26
|
scopeHint?: ApprovalRecord['scopeHint'];
|
|
27
|
+
capabilityRequests?: ApprovalRecord['capabilityRequests'];
|
|
27
28
|
}): ApprovalRecord;
|
|
28
29
|
export declare function createApprovalRecord(params: {
|
|
29
30
|
kind: ApprovalRecord['kind'];
|
|
@@ -40,4 +41,5 @@ export declare function createApprovalRecord(params: {
|
|
|
40
41
|
payloadHash?: string;
|
|
41
42
|
payloadJson?: string;
|
|
42
43
|
scopeHint?: ApprovalRecord['scopeHint'];
|
|
44
|
+
capabilityRequests?: ApprovalRecord['capabilityRequests'];
|
|
43
45
|
}): ApprovalRecord;
|
package/dist/core/approval.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { buildReplayEnvelopeFields } from './approval-replay.js';
|
|
2
|
+
import { attachCapabilityEnvelope } from './capability/approval-v3.js';
|
|
2
3
|
import { DEFAULT_APPROVAL_CONFIG } from './config.js';
|
|
3
4
|
/** @deprecated Use `DEFAULT_APPROVAL_CONFIG.executionLeaseMs` */
|
|
4
5
|
export const APPROVAL_EXECUTION_LEASE_MS = DEFAULT_APPROVAL_CONFIG.executionLeaseMs;
|
|
@@ -15,10 +16,14 @@ export function isExecutionLeaseExpired(approval) {
|
|
|
15
16
|
return Date.parse(approval.executionLeaseExpiresAt) <= Date.now();
|
|
16
17
|
}
|
|
17
18
|
export function compactApprovals(state) {
|
|
18
|
-
|
|
19
|
+
const compacted = {
|
|
19
20
|
version: state.version,
|
|
20
21
|
approvals: state.approvals.filter((approval) => !isExpired(approval) && !isExecutionLeaseExpired(approval)),
|
|
21
22
|
};
|
|
23
|
+
if (state.revision !== undefined) {
|
|
24
|
+
compacted.revision = state.revision;
|
|
25
|
+
}
|
|
26
|
+
return compacted;
|
|
22
27
|
}
|
|
23
28
|
export function mergeApprovalStates(target, source) {
|
|
24
29
|
const byId = new Map();
|
|
@@ -31,7 +36,12 @@ export function mergeApprovalStates(target, source) {
|
|
|
31
36
|
}
|
|
32
37
|
}
|
|
33
38
|
return compactApprovals({
|
|
34
|
-
version: target.version ===
|
|
39
|
+
version: target.version === 3 || source.version === 3
|
|
40
|
+
? 3
|
|
41
|
+
: target.version === 2 || source.version === 2
|
|
42
|
+
? 2
|
|
43
|
+
: 1,
|
|
44
|
+
revision: Math.max(target.revision ?? 0, source.revision ?? 0),
|
|
35
45
|
approvals: [...byId.values()],
|
|
36
46
|
});
|
|
37
47
|
}
|
|
@@ -78,6 +88,7 @@ export function createApprovalRecordWithEnvelope(params) {
|
|
|
78
88
|
payloadHash: envelope.payloadHash,
|
|
79
89
|
payloadJson: envelope.payloadJson,
|
|
80
90
|
scopeHint: params.scopeHint,
|
|
91
|
+
capabilityRequests: params.capabilityRequests,
|
|
81
92
|
});
|
|
82
93
|
}
|
|
83
94
|
export function createApprovalRecord(params) {
|
|
@@ -112,5 +123,5 @@ export function createApprovalRecord(params) {
|
|
|
112
123
|
if (params.scopeHint) {
|
|
113
124
|
record.scopeHint = params.scopeHint;
|
|
114
125
|
}
|
|
115
|
-
return record;
|
|
126
|
+
return attachCapabilityEnvelope(record, params.capabilityRequests);
|
|
116
127
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ApprovalStateFile } from '../types.js';
|
|
2
|
+
export declare function mutateApprovalStateWithRetry<T>(params: {
|
|
3
|
+
load: () => Promise<{
|
|
4
|
+
filePath: string;
|
|
5
|
+
state: ApprovalStateFile;
|
|
6
|
+
}>;
|
|
7
|
+
write: (filePath: string, state: ApprovalStateFile) => Promise<void>;
|
|
8
|
+
mutate: (state: ApprovalStateFile) => {
|
|
9
|
+
state: ApprovalStateFile;
|
|
10
|
+
result: T;
|
|
11
|
+
} | null;
|
|
12
|
+
maxRetries?: number;
|
|
13
|
+
}): Promise<T | null>;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { open, readFile, unlink } from 'node:fs/promises';
|
|
3
|
+
import { upgradeApprovalStateToV3 } from './approval-v3.js';
|
|
4
|
+
const DEFAULT_RETRIES = 8;
|
|
5
|
+
const LOCK_STALE_MS = 5 * 60_000;
|
|
6
|
+
async function sleep(ms) {
|
|
7
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
8
|
+
}
|
|
9
|
+
async function isStaleApprovalLock(lockPath) {
|
|
10
|
+
try {
|
|
11
|
+
const raw = await readFile(lockPath, 'utf8');
|
|
12
|
+
const [pidText, timestampText] = raw.trim().split(':');
|
|
13
|
+
const timestamp = Number(timestampText);
|
|
14
|
+
if (Number.isFinite(timestamp) && Date.now() - timestamp > LOCK_STALE_MS) {
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
const pid = Number(pidText);
|
|
18
|
+
if (!Number.isFinite(pid)) {
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
process.kill(pid, 0);
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
async function acquireApprovalStateLock(lockPath, maxRetries) {
|
|
34
|
+
for (let attempt = 0; attempt < maxRetries; attempt += 1) {
|
|
35
|
+
try {
|
|
36
|
+
const handle = await open(lockPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY);
|
|
37
|
+
await handle.writeFile(`${process.pid}:${Date.now()}`);
|
|
38
|
+
await handle.close();
|
|
39
|
+
return async () => {
|
|
40
|
+
await unlink(lockPath).catch(() => { });
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
const code = error.code;
|
|
45
|
+
if (code !== 'EEXIST') {
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
if (await isStaleApprovalLock(lockPath)) {
|
|
49
|
+
await unlink(lockPath).catch(() => { });
|
|
50
|
+
}
|
|
51
|
+
await sleep(5 + attempt * 8);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
throw new Error(`Failed to acquire approval state lock: ${lockPath}`);
|
|
55
|
+
}
|
|
56
|
+
export async function mutateApprovalStateWithRetry(params) {
|
|
57
|
+
const maxRetries = params.maxRetries ?? DEFAULT_RETRIES;
|
|
58
|
+
for (let attempt = 0; attempt < maxRetries; attempt += 1) {
|
|
59
|
+
const loaded = await params.load();
|
|
60
|
+
const lockPath = `${loaded.filePath}.lock`;
|
|
61
|
+
let release = null;
|
|
62
|
+
try {
|
|
63
|
+
release = await acquireApprovalStateLock(lockPath, maxRetries);
|
|
64
|
+
const base = upgradeApprovalStateToV3(loaded.state);
|
|
65
|
+
const revision = base.revision ?? 0;
|
|
66
|
+
const outcome = params.mutate(base);
|
|
67
|
+
if (!outcome) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
const next = {
|
|
71
|
+
...outcome.state,
|
|
72
|
+
version: 3,
|
|
73
|
+
revision: revision + 1,
|
|
74
|
+
};
|
|
75
|
+
const verifyBeforeWrite = upgradeApprovalStateToV3((await params.load()).state);
|
|
76
|
+
if ((verifyBeforeWrite.revision ?? 0) !== revision) {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
await params.write(loaded.filePath, next);
|
|
80
|
+
const verify = upgradeApprovalStateToV3((await params.load()).state);
|
|
81
|
+
if ((verify.revision ?? 0) === revision + 1) {
|
|
82
|
+
return outcome.result;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
if (release) {
|
|
87
|
+
await release();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ApprovalRecord, ApprovalStateFile } from '../types.js';
|
|
2
|
+
import type { CapabilityGrantV1 } from './grant.js';
|
|
3
|
+
import type { CapabilityRequestV1 } from './request.js';
|
|
4
|
+
export declare const APPROVAL_STATE_VERSION_V3: 3;
|
|
5
|
+
export declare function normalizeApprovalStateVersion(state: ApprovalStateFile): ApprovalStateFile;
|
|
6
|
+
export declare function upgradeApprovalStateToV3(state: ApprovalStateFile): ApprovalStateFile;
|
|
7
|
+
export declare function attachCapabilityEnvelope(approval: ApprovalRecord, capabilityRequests?: CapabilityRequestV1[]): ApprovalRecord;
|
|
8
|
+
export declare function mintCapabilityGrant(params: {
|
|
9
|
+
approval: ApprovalRecord;
|
|
10
|
+
capabilityRequests: CapabilityRequestV1[];
|
|
11
|
+
issuer?: string;
|
|
12
|
+
maxUses?: number;
|
|
13
|
+
}): CapabilityGrantV1 | null;
|
|
14
|
+
export declare function mintGrantForApprovedRecord(approval: ApprovalRecord): ApprovalRecord;
|
|
15
|
+
export declare function newGrantId(): string;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { hashCapabilityRequests } from './capability-request-hash.js';
|
|
3
|
+
import { CAPABILITY_GRANT_VERSION } from './grant.js';
|
|
4
|
+
export const APPROVAL_STATE_VERSION_V3 = 3;
|
|
5
|
+
export function normalizeApprovalStateVersion(state) {
|
|
6
|
+
const version = state.version === APPROVAL_STATE_VERSION_V3
|
|
7
|
+
? APPROVAL_STATE_VERSION_V3
|
|
8
|
+
: state.version === 2
|
|
9
|
+
? 2
|
|
10
|
+
: 1;
|
|
11
|
+
return { ...state, version };
|
|
12
|
+
}
|
|
13
|
+
export function upgradeApprovalStateToV3(state) {
|
|
14
|
+
const normalized = normalizeApprovalStateVersion(state);
|
|
15
|
+
if (normalized.version === APPROVAL_STATE_VERSION_V3) {
|
|
16
|
+
return normalized;
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
version: APPROVAL_STATE_VERSION_V3,
|
|
20
|
+
approvals: normalized.approvals.map((approval) => ({
|
|
21
|
+
...approval,
|
|
22
|
+
capabilityRequestHash: approval.capabilityRequestHash ??
|
|
23
|
+
(approval.capabilityRequests?.length
|
|
24
|
+
? hashCapabilityRequests(approval.capabilityRequests)
|
|
25
|
+
: undefined),
|
|
26
|
+
})),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export function attachCapabilityEnvelope(approval, capabilityRequests) {
|
|
30
|
+
if (!capabilityRequests?.length) {
|
|
31
|
+
return approval;
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
...approval,
|
|
35
|
+
capabilityRequests,
|
|
36
|
+
capabilityRequestHash: hashCapabilityRequests(capabilityRequests),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export function mintCapabilityGrant(params) {
|
|
40
|
+
const request = params.capabilityRequests[0];
|
|
41
|
+
if (!request) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
const expiresAt = params.approval.expiresAt;
|
|
45
|
+
return {
|
|
46
|
+
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,
|
|
52
|
+
issuedAt: params.approval.approvedAt ?? new Date().toISOString(),
|
|
53
|
+
expiresAt,
|
|
54
|
+
maxUses: params.maxUses ?? 1,
|
|
55
|
+
usesRemaining: params.maxUses ?? 1,
|
|
56
|
+
issuer: params.issuer ?? 'belay-approval-v3',
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
export function mintGrantForApprovedRecord(approval) {
|
|
60
|
+
if (approval.grant || !approval.capabilityRequests?.length) {
|
|
61
|
+
return approval;
|
|
62
|
+
}
|
|
63
|
+
const grant = mintCapabilityGrant({
|
|
64
|
+
approval,
|
|
65
|
+
capabilityRequests: approval.capabilityRequests,
|
|
66
|
+
});
|
|
67
|
+
if (!grant) {
|
|
68
|
+
return approval;
|
|
69
|
+
}
|
|
70
|
+
return { ...approval, grant };
|
|
71
|
+
}
|
|
72
|
+
export function newGrantId() {
|
|
73
|
+
return `grant_${randomUUID().replaceAll('-', '').slice(0, 16)}`;
|
|
74
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare const BOUNDARY_ATTESTATION_VERSION: 1;
|
|
2
|
+
export type BoundaryDriverId = 'container' | 'cursor-sandbox' | 'seatbelt' | 'landlock' | 'host-integration';
|
|
3
|
+
export interface BoundaryAttestation {
|
|
4
|
+
version: typeof BOUNDARY_ATTESTATION_VERSION;
|
|
5
|
+
driver: BoundaryDriverId;
|
|
6
|
+
probedAt: string;
|
|
7
|
+
expiresAt: string;
|
|
8
|
+
deniesUngrantedEffects: boolean;
|
|
9
|
+
materializesGrants: boolean;
|
|
10
|
+
probeSignals: string[];
|
|
11
|
+
}
|
|
12
|
+
export declare function validateBoundaryAttestation(value: unknown): value is BoundaryAttestation;
|
|
13
|
+
export declare function isAttestationFresh(attestation: BoundaryAttestation, now?: number): boolean;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export const BOUNDARY_ATTESTATION_VERSION = 1;
|
|
2
|
+
const KNOWN_DRIVERS = new Set([
|
|
3
|
+
'container',
|
|
4
|
+
'cursor-sandbox',
|
|
5
|
+
'seatbelt',
|
|
6
|
+
'landlock',
|
|
7
|
+
'host-integration',
|
|
8
|
+
]);
|
|
9
|
+
export function validateBoundaryAttestation(value) {
|
|
10
|
+
if (!value || typeof value !== 'object') {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
const record = value;
|
|
14
|
+
if (record.version !== BOUNDARY_ATTESTATION_VERSION) {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
if (!KNOWN_DRIVERS.has(record.driver)) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
if (typeof record.probedAt !== 'string' || typeof record.expiresAt !== 'string') {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
if (typeof record.deniesUngrantedEffects !== 'boolean') {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
if (typeof record.materializesGrants !== 'boolean') {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
if (!Array.isArray(record.probeSignals) ||
|
|
30
|
+
!record.probeSignals.every((s) => typeof s === 'string')) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
if (record.driver === 'host-integration' && record.materializesGrants) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
if (record.driver === 'container' &&
|
|
37
|
+
record.materializesGrants &&
|
|
38
|
+
!record.deniesUngrantedEffects) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
export function isAttestationFresh(attestation, now = Date.now()) {
|
|
44
|
+
const expires = Date.parse(attestation.expiresAt);
|
|
45
|
+
if (!Number.isFinite(expires)) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
if (expires <= now) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
if (attestation.driver === 'host-integration') {
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
return attestation.deniesUngrantedEffects;
|
|
55
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { BoundaryAttestation } from './attestation.js';
|
|
2
|
+
export type BoundaryAttestationFileFormat = 'signed' | 'legacy' | 'missing' | 'invalid';
|
|
3
|
+
export interface SignedBoundaryAttestationFile {
|
|
4
|
+
version: 1;
|
|
5
|
+
repoRoot: string;
|
|
6
|
+
attestation: BoundaryAttestation;
|
|
7
|
+
signature: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function signBoundaryAttestation(params: {
|
|
10
|
+
repoRoot: string;
|
|
11
|
+
attestation: BoundaryAttestation;
|
|
12
|
+
controlPlaneDir: string;
|
|
13
|
+
}): Promise<SignedBoundaryAttestationFile>;
|
|
14
|
+
export declare function verifySignedBoundaryAttestation(params: {
|
|
15
|
+
file: unknown;
|
|
16
|
+
expectedRepoRoot: string;
|
|
17
|
+
controlPlaneDir: string;
|
|
18
|
+
}): Promise<BoundaryAttestation | null>;
|
|
19
|
+
export declare function readSignedAttestationFile(filePath: string): Promise<unknown>;
|
|
20
|
+
export declare function isLegacyAttestationFormat(file: unknown): file is BoundaryAttestation;
|
|
21
|
+
export declare function inspectBoundaryAttestationFile(filePath: string): Promise<BoundaryAttestationFileFormat>;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { access, readFile } from 'node:fs/promises';
|
|
3
|
+
import { loadOrCreateApprovalSigningKey } from '../approval-token.js';
|
|
4
|
+
import { canonicalStringify } from '../fingerprint.js';
|
|
5
|
+
import { validateBoundaryAttestation } from './attestation.js';
|
|
6
|
+
function signBody(repoRoot, attestation, key) {
|
|
7
|
+
const payload = canonicalStringify({ repoRoot, attestation });
|
|
8
|
+
return createHmac('sha256', key).update(payload).digest('base64url');
|
|
9
|
+
}
|
|
10
|
+
export async function signBoundaryAttestation(params) {
|
|
11
|
+
const key = await loadOrCreateApprovalSigningKey(params.controlPlaneDir);
|
|
12
|
+
const signature = signBody(params.repoRoot, params.attestation, key);
|
|
13
|
+
return {
|
|
14
|
+
version: 1,
|
|
15
|
+
repoRoot: params.repoRoot,
|
|
16
|
+
attestation: params.attestation,
|
|
17
|
+
signature,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export async function verifySignedBoundaryAttestation(params) {
|
|
21
|
+
if (!params.file || typeof params.file !== 'object') {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
const record = params.file;
|
|
25
|
+
if (record.version !== 1 || typeof record.repoRoot !== 'string' || !record.attestation) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
if (record.repoRoot !== params.expectedRepoRoot) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
if (!validateBoundaryAttestation(record.attestation)) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
if (typeof record.signature !== 'string' || !record.signature) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const key = await loadOrCreateApprovalSigningKey(params.controlPlaneDir);
|
|
38
|
+
const expected = signBody(record.repoRoot, record.attestation, key);
|
|
39
|
+
const actualBuffer = Buffer.from(record.signature);
|
|
40
|
+
const expectedBuffer = Buffer.from(expected);
|
|
41
|
+
if (actualBuffer.length !== expectedBuffer.length ||
|
|
42
|
+
!timingSafeEqual(actualBuffer, expectedBuffer)) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
return record.attestation;
|
|
46
|
+
}
|
|
47
|
+
export async function readSignedAttestationFile(filePath) {
|
|
48
|
+
return JSON.parse(await readFile(filePath, 'utf8'));
|
|
49
|
+
}
|
|
50
|
+
export function isLegacyAttestationFormat(file) {
|
|
51
|
+
if (!file || typeof file !== 'object') {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
const record = file;
|
|
55
|
+
return (record.version === 1 &&
|
|
56
|
+
typeof record.driver === 'string' &&
|
|
57
|
+
!('signature' in record) &&
|
|
58
|
+
!('attestation' in record));
|
|
59
|
+
}
|
|
60
|
+
export async function inspectBoundaryAttestationFile(filePath) {
|
|
61
|
+
try {
|
|
62
|
+
await access(filePath);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return 'missing';
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
const raw = await readSignedAttestationFile(filePath);
|
|
69
|
+
if (isLegacyAttestationFormat(raw)) {
|
|
70
|
+
return validateBoundaryAttestation(raw) ? 'legacy' : 'invalid';
|
|
71
|
+
}
|
|
72
|
+
const record = raw;
|
|
73
|
+
if (record.version === 1 &&
|
|
74
|
+
typeof record.repoRoot === 'string' &&
|
|
75
|
+
record.attestation &&
|
|
76
|
+
typeof record.signature === 'string') {
|
|
77
|
+
return 'signed';
|
|
78
|
+
}
|
|
79
|
+
return 'invalid';
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return 'invalid';
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { BoundaryDriver } from './boundary-driver.js';
|
|
2
|
+
export declare function isDockerAvailable(): Promise<boolean>;
|
|
3
|
+
export declare function createContainerBoundaryDriver(options?: {
|
|
4
|
+
image?: string;
|
|
5
|
+
egressProxyEnv?: Record<string, string>;
|
|
6
|
+
repoRoot?: string;
|
|
7
|
+
}): BoundaryDriver;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { canonicalPath } from '../path-utils.js';
|
|
3
|
+
import { dockerEnvArgs, dockerNetworkArgs, ensureBelayContainerNetwork } from './boundary-egress.js';
|
|
4
|
+
import { materializeContainerBoundaryGrant } from './boundary-grant-materialize.js';
|
|
5
|
+
const ATTESTATION_TTL_MS = 15 * 60_000;
|
|
6
|
+
const DEFAULT_IMAGE = 'alpine:3.20';
|
|
7
|
+
export async function isDockerAvailable() {
|
|
8
|
+
return new Promise((resolve) => {
|
|
9
|
+
const child = spawn('docker', ['info'], { stdio: 'ignore' });
|
|
10
|
+
child.on('error', () => resolve(false));
|
|
11
|
+
child.on('close', (code) => resolve(code === 0));
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
function containerAttestation(proxyEnv) {
|
|
15
|
+
const probedAt = new Date().toISOString();
|
|
16
|
+
const signals = ['docker', 'container-driver', 'repo-mount-ro-default'];
|
|
17
|
+
if (Object.keys(proxyEnv).length > 0) {
|
|
18
|
+
signals.push('egress-proxy-chokepoint');
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
signals.push('network-none');
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
version: 1,
|
|
25
|
+
driver: 'container',
|
|
26
|
+
probedAt,
|
|
27
|
+
expiresAt: new Date(Date.now() + ATTESTATION_TTL_MS).toISOString(),
|
|
28
|
+
deniesUngrantedEffects: true,
|
|
29
|
+
materializesGrants: true,
|
|
30
|
+
probeSignals: signals,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function runDockerProbe(image) {
|
|
34
|
+
return new Promise((resolve) => {
|
|
35
|
+
const child = spawn('docker', ['run', '--rm', '--network', 'none', image, 'echo', 'ok'], {
|
|
36
|
+
stdio: 'ignore',
|
|
37
|
+
});
|
|
38
|
+
child.on('error', () => resolve(false));
|
|
39
|
+
child.on('close', (code) => resolve(code === 0));
|
|
40
|
+
});
|
|
41
|
+
}
|
|
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 = [
|
|
48
|
+
'run',
|
|
49
|
+
'--rm',
|
|
50
|
+
...networkArgs,
|
|
51
|
+
'-v',
|
|
52
|
+
mountSpec,
|
|
53
|
+
'-w',
|
|
54
|
+
mount,
|
|
55
|
+
...dockerEnvArgs(proxyEnv),
|
|
56
|
+
image,
|
|
57
|
+
'sh',
|
|
58
|
+
'-c',
|
|
59
|
+
command,
|
|
60
|
+
];
|
|
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
|
+
});
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
export function createContainerBoundaryDriver(options = {}) {
|
|
83
|
+
const image = options.image ?? DEFAULT_IMAGE;
|
|
84
|
+
const proxyEnv = options.egressProxyEnv ?? {};
|
|
85
|
+
const repoRoot = options.repoRoot;
|
|
86
|
+
let preparedNetwork = false;
|
|
87
|
+
return {
|
|
88
|
+
id: 'container',
|
|
89
|
+
async probe() {
|
|
90
|
+
if (!(await isDockerAvailable())) {
|
|
91
|
+
throw new Error('Docker is not available for container boundary driver');
|
|
92
|
+
}
|
|
93
|
+
if (!(await runDockerProbe(image))) {
|
|
94
|
+
throw new Error(`Docker probe failed for image ${image}`);
|
|
95
|
+
}
|
|
96
|
+
return containerAttestation(proxyEnv);
|
|
97
|
+
},
|
|
98
|
+
async prepare(context) {
|
|
99
|
+
if (context.egressProxyActive && context.repoRoot) {
|
|
100
|
+
await ensureBelayContainerNetwork(context.repoRoot);
|
|
101
|
+
preparedNetwork = true;
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
async run(command, cwd, timeoutMs, options) {
|
|
105
|
+
const mountReadOnly = options?.mountReadOnly !== false;
|
|
106
|
+
const proxyActive = Object.keys(proxyEnv).length > 0;
|
|
107
|
+
if (proxyActive && repoRoot && !preparedNetwork) {
|
|
108
|
+
await ensureBelayContainerNetwork(repoRoot);
|
|
109
|
+
}
|
|
110
|
+
return runInContainer(image, command, cwd, timeoutMs, proxyEnv, mountReadOnly, repoRoot);
|
|
111
|
+
},
|
|
112
|
+
materializeGrant(request, context) {
|
|
113
|
+
return materializeContainerBoundaryGrant(request, {
|
|
114
|
+
attestation: context.attestation,
|
|
115
|
+
mountRoot: context.mountRoot,
|
|
116
|
+
egressProxyActive: context.egressProxyActive,
|
|
117
|
+
existingGrants: context.existingGrants,
|
|
118
|
+
sensitivePaths: context.sensitivePaths,
|
|
119
|
+
});
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|