@guilz-dev/belay 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -1
- 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 +420 -220
- package/dist/bundle/claude-runtime.mjs +13215 -8864
- package/dist/bundle/codex-runtime.mjs +10771 -6453
- package/dist/bundle/cursor-runtime.mjs +10797 -6484
- package/dist/cli.js +37 -1
- package/dist/commands/approve.js +34 -10
- package/dist/commands/doctor.js +51 -0
- 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 +6 -0
- package/dist/conformance/types.d.ts +1 -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-types.d.ts +13 -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 +1 -1
- package/dist/core/capability/index.js +1 -1
- package/dist/core/capability/policy-engine.d.ts +7 -0
- package/dist/core/capability/policy-engine.js +202 -15
- package/dist/core/capability/request.d.ts +3 -0
- package/dist/core/config.d.ts +20 -0
- package/dist/core/config.js +62 -0
- package/dist/core/effect-ir/audit.d.ts +9 -0
- package/dist/core/effect-ir/audit.js +86 -0
- package/dist/core/effect-ir/build.d.ts +31 -0
- package/dist/core/effect-ir/build.js +277 -0
- package/dist/core/effect-ir/index.d.ts +8 -0
- package/dist/core/effect-ir/index.js +7 -0
- package/dist/core/effect-ir/normalize.d.ts +4 -0
- package/dist/core/effect-ir/normalize.js +104 -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 +13 -0
- package/dist/core/effect-ir/policy.js +77 -0
- package/dist/core/effect-ir/types.d.ts +55 -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 +26 -9
- package/dist/core/index.d.ts +3 -1
- package/dist/core/index.js +2 -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/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/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 +10 -1
- package/dist/core/verdict/adapter.js +8 -0
- package/dist/core/verdict/launcher-resolve.js +2 -20
- package/dist/core/verdict/shell-policy.d.ts +1 -0
- package/dist/core/verdict/shell-policy.js +1 -1
- package/dist/core/verdict/types.d.ts +5 -0
- package/dist/core/verdict/verdict.js +37 -20
- package/dist/types.d.ts +11 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/skills/belay/SKILL.md +8 -3
- package/skills/belay/belay-approve.md +10 -7
|
@@ -1,32 +1,36 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { compactApprovals, createApprovalRecordWithEnvelope } from '../../core/approval.js';
|
|
6
|
-
import { buildReplayHint, buildRetryInstructionForConfig,
|
|
7
|
-
import {
|
|
6
|
+
import { buildReplayHint, buildRetryInstructionForConfig, canAutoReplay, getExecutionLeaseMs, validateReplayEnvelope, } from '../../core/approval-replay.js';
|
|
7
|
+
import { claimApprovedForReplay, gateApprovalStoreFromDeps, recordApproval, } from '../../core/approval-service.js';
|
|
8
8
|
import { issueApprovalToken } from '../../core/approval-token.js';
|
|
9
9
|
import { buildAuditActionSnapshot, buildAuditReplayContext, } from '../../core/audit-replay-context.js';
|
|
10
10
|
import { mutateApprovalStateWithRetry } from '../../core/capability/approval-state-mutation.js';
|
|
11
11
|
import { APPROVAL_STATE_VERSION_V3 } from '../../core/capability/approval-v3.js';
|
|
12
12
|
import { isEgressProxyActive } from '../../core/capability/boundary-egress.js';
|
|
13
|
-
import {
|
|
13
|
+
import { isBoundaryCleanupError, safeBoundaryCleanupResourceId, } from '../../core/capability/boundary-run.js';
|
|
14
|
+
import { resolveBoundaryDriverContext, runBoundaryAgentCommand, } from '../../core/capability/boundary-session.js';
|
|
14
15
|
import { hashCapabilityRequests } from '../../core/capability/capability-request-hash.js';
|
|
15
16
|
import { recordGateApprovalAsk, scheduleGateShadowAudit, } from '../../core/capability/gate-shadow-audit.js';
|
|
16
|
-
import {
|
|
17
|
+
import { canConsumeCapabilityGrantLease } from '../../core/capability/grant-consumption.js';
|
|
18
|
+
import { approvalGrantBundleExhausted, consumeApprovedRecordGrantBundle, consumeGrantLeasesForRequests, decrementApprovalLegacyGrant, grantsFromApproval, validateAndConsumeGrantBundle, validateGrantBundleForLeaseReuse, } from '../../core/capability/grant-lease.js';
|
|
17
19
|
import { loadClassifierAuthorization } from '../../core/capability/grant-loader.js';
|
|
18
20
|
import { collectOutsideRepoPaths, collectOutsideRepoPathsFromToolPayload, fsScopeAllowlistPath, isCapabilityBrokerDemotionActive, loadFsScopeAllowlistSync, loadTrustedWorkspaceRootsSync, shouldSkipBrokerApprovedOnce, shouldSkipBrokerApprovedRecord, trustedWorkspaceRootsPath, validateTrustedWorkspaceRootCandidate, } from '../../core/capability/index.js';
|
|
19
21
|
import { resolveLayeredConfig, teamConfigPath } from '../../core/config-layers.js';
|
|
22
|
+
import { effectPlanAuditFields, hashEffectPlan } from '../../core/effect-ir/audit.js';
|
|
23
|
+
import { buildCapabilityEffectPlan } from '../../core/effect-ir/build.js';
|
|
20
24
|
import { classifyResultToGateVerdict, unnormalizedGateVerdict, } from '../../core/gate-contract.js';
|
|
21
25
|
import { classifyGatedActionAsync, extractAgentAssessment, GateNormalizationError, gateEnabledForAction, normalizeGatedAction, } from '../../core/gate-engine.js';
|
|
22
|
-
import { approvalCommandMatch, approvedApprovalsFile, belayStateDir, canonicalStringify, classifierOptionsFromConfig, configuredControlPlaneDir, pendingApprovalsFile, resolveControlPlaneDir, scrubOptionsFromConfig, scrubValue, toolFingerprint, } from '../../core/index.js';
|
|
26
|
+
import { approvalCommandMatch, approvedApprovalsFile, belayStateDir, canonicalStringify, classifierOptionsFromConfig, configuredControlPlaneDir, hashValue, pendingApprovalsFile, resolveControlPlaneDir, scrubOptionsFromConfig, scrubValue, toolFingerprint, } from '../../core/index.js';
|
|
23
27
|
import { extractJudgeFallbackReason, formatJudgeInfrastructureDenyMessage, inferProviderIdFromFallbackReason, isJudgeInfrastructureFailure, } from '../../core/judge-fallback-hints.js';
|
|
24
28
|
import { notifyDeny } from '../../core/notify.js';
|
|
25
29
|
import { canonicalPath } from '../../core/path-utils.js';
|
|
26
30
|
import { recoveryFailClosedResult, recoveryFailReasonFromSkip, } from '../../core/recovery/fail-closed.js';
|
|
27
31
|
import { fingerprintReplayPayload } from '../../core/replay-scrub.js';
|
|
28
32
|
import { loadStandingAllow, resolveStandingAllowMatch, standingAllowFile, } from '../../core/standing-allow.js';
|
|
29
|
-
import { isTransactionalEligible, runTransactionalExecution, TRANSACTIONAL_ALREADY_APPLIED, TRANSACTIONAL_APPROVAL_BYPASS_REASONS, } from '../../core/transactional/index.js';
|
|
33
|
+
import { FILE_CHECKPOINT_ISOLATION_UNAVAILABLE, isTransactionalEligible, runTransactionalExecution, TRANSACTIONAL_ALREADY_APPLIED, TRANSACTIONAL_APPROVAL_BYPASS_REASONS, } from '../../core/transactional/index.js';
|
|
30
34
|
import { egressStatus } from '../../services/egress-service.js';
|
|
31
35
|
import { protectedArtifactRoots } from '../layouts/protected-paths.js';
|
|
32
36
|
const EMPTY_APPROVALS = {
|
|
@@ -44,6 +48,16 @@ function adapterIdFromContext(ctx) {
|
|
|
44
48
|
}
|
|
45
49
|
return undefined;
|
|
46
50
|
}
|
|
51
|
+
const REPLAY_AUDIT_FAILURE_NOTE = ' Audit recording failed; inspect audit storage before the next approval.';
|
|
52
|
+
async function appendReplayAuditSafely(ctx, deps, event) {
|
|
53
|
+
try {
|
|
54
|
+
await deps.appendAudit(ctx, event);
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
47
61
|
async function loadJsonFile(filePath, fallback) {
|
|
48
62
|
try {
|
|
49
63
|
const raw = await readFile(filePath, 'utf8');
|
|
@@ -91,6 +105,16 @@ export function createDefaultGateRuntimeDeps() {
|
|
|
91
105
|
await mkdir(path.dirname(filePath), { recursive: true });
|
|
92
106
|
await writeFile(filePath, `${JSON.stringify(compactApprovals(state), null, 2)}\n`, 'utf8');
|
|
93
107
|
},
|
|
108
|
+
async replayApprovedShell(ctx, command, cwd, timeoutMs) {
|
|
109
|
+
return runBoundaryAgentCommand({
|
|
110
|
+
repoRoot: ctx.repoRoot,
|
|
111
|
+
config: ctx.config,
|
|
112
|
+
command,
|
|
113
|
+
cwd,
|
|
114
|
+
timeoutMs,
|
|
115
|
+
runOptions: { mountReadOnly: false },
|
|
116
|
+
});
|
|
117
|
+
},
|
|
94
118
|
};
|
|
95
119
|
}
|
|
96
120
|
export async function resolveGateConfig(ctx, deps) {
|
|
@@ -139,16 +163,7 @@ function gateAuditEventName(kind) {
|
|
|
139
163
|
return 'subagentGate';
|
|
140
164
|
}
|
|
141
165
|
async function ensurePendingApproval(ctx, deps, kind, result, approvalInput, scopeHint) {
|
|
142
|
-
const
|
|
143
|
-
pending.state = compactApprovals(pending.state);
|
|
144
|
-
const existing = pending.state.approvals.find((approval) => approval.kind === kind &&
|
|
145
|
-
approval.fingerprint === result.fingerprint &&
|
|
146
|
-
approval.repoRoot === ctx.repoRoot);
|
|
147
|
-
if (existing) {
|
|
148
|
-
await deps.writeApprovals(pending.filePath, pending.state);
|
|
149
|
-
return { approval: existing, created: false };
|
|
150
|
-
}
|
|
151
|
-
const approval = createApprovalRecordWithEnvelope({
|
|
166
|
+
const candidate = createApprovalRecordWithEnvelope({
|
|
152
167
|
kind,
|
|
153
168
|
fingerprint: result.fingerprint,
|
|
154
169
|
repoRoot: ctx.repoRoot,
|
|
@@ -159,12 +174,31 @@ async function ensurePendingApproval(ctx, deps, kind, result, approvalInput, sco
|
|
|
159
174
|
approvalInput,
|
|
160
175
|
scopeHint,
|
|
161
176
|
capabilityRequests: result.capabilityRequests,
|
|
177
|
+
effectPlanHash: result.effectPlan ? hashEffectPlan(result.effectPlan) : undefined,
|
|
162
178
|
});
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
179
|
+
const outcome = await mutateApprovalStateWithRetry({
|
|
180
|
+
load: () => deps.loadApprovals(ctx, 'pending-approvals.json'),
|
|
181
|
+
write: (filePath, state) => deps.writeApprovals(filePath, state),
|
|
182
|
+
mutate: (state) => {
|
|
183
|
+
const compacted = compactApprovals(state);
|
|
184
|
+
const existing = compacted.approvals.find((approval) => approval.kind === kind &&
|
|
185
|
+
approval.fingerprint === result.fingerprint &&
|
|
186
|
+
approval.repoRoot === ctx.repoRoot);
|
|
187
|
+
if (existing) {
|
|
188
|
+
return { state: compacted, result: { approval: existing, created: false } };
|
|
189
|
+
}
|
|
190
|
+
compacted.version = APPROVAL_STATE_VERSION_V3;
|
|
191
|
+
compacted.approvals.push(candidate);
|
|
192
|
+
return { state: compacted, result: { approval: candidate, created: true } };
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
if (!outcome) {
|
|
196
|
+
throw new Error('Failed to persist pending approval');
|
|
197
|
+
}
|
|
198
|
+
return outcome;
|
|
199
|
+
}
|
|
200
|
+
function approvalCorrelationId(approvalId) {
|
|
201
|
+
return createHash('sha256').update(approvalId).digest('hex').slice(0, 16);
|
|
168
202
|
}
|
|
169
203
|
function extractShellCommandFromPayload(payload) {
|
|
170
204
|
const toolInput = payload.tool_input;
|
|
@@ -222,21 +256,7 @@ function deriveWorkspaceRootScopeHint(params) {
|
|
|
222
256
|
}
|
|
223
257
|
return { scope: 'workspace-root', path: validation.normalizedPath };
|
|
224
258
|
}
|
|
225
|
-
async function consumeApprovedApproval(ctx, deps, kind, fingerprint) {
|
|
226
|
-
const loaded = await deps.loadApprovals(ctx, 'approved-approvals.json');
|
|
227
|
-
loaded.state = compactApprovals(loaded.state);
|
|
228
|
-
const index = loaded.state.approvals.findIndex((approval) => approval.kind === kind &&
|
|
229
|
-
approval.fingerprint === fingerprint &&
|
|
230
|
-
approval.repoRoot === ctx.repoRoot);
|
|
231
|
-
if (index === -1) {
|
|
232
|
-
await deps.writeApprovals(loaded.filePath, loaded.state);
|
|
233
|
-
return null;
|
|
234
|
-
}
|
|
235
|
-
const approval = loaded.state.approvals[index];
|
|
236
|
-
if (approval.executionLeaseExpiresAt) {
|
|
237
|
-
await deps.writeApprovals(loaded.filePath, loaded.state);
|
|
238
|
-
return { approval, firstExecution: false };
|
|
239
|
-
}
|
|
259
|
+
async function consumeApprovedApproval(ctx, deps, kind, fingerprint, requests) {
|
|
240
260
|
const consumed = await mutateApprovalStateWithRetry({
|
|
241
261
|
load: () => deps.loadApprovals(ctx, 'approved-approvals.json'),
|
|
242
262
|
write: (filePath, state) => deps.writeApprovals(filePath, state),
|
|
@@ -246,30 +266,83 @@ async function consumeApprovedApproval(ctx, deps, kind, fingerprint) {
|
|
|
246
266
|
approval.fingerprint === fingerprint &&
|
|
247
267
|
approval.repoRoot === ctx.repoRoot);
|
|
248
268
|
if (matchIndex === -1) {
|
|
249
|
-
return null;
|
|
269
|
+
return { state: compacted, result: null };
|
|
250
270
|
}
|
|
251
271
|
const approval = compacted.approvals[matchIndex];
|
|
252
|
-
if (approval.
|
|
272
|
+
if (approval.executionLeaseExpiresAt) {
|
|
273
|
+
if (approval.grantBundleVersion === 1) {
|
|
274
|
+
const validated = validateGrantBundleForLeaseReuse(approval, requests);
|
|
275
|
+
if (!validated.ok) {
|
|
276
|
+
compacted.approvals.splice(matchIndex, 1);
|
|
277
|
+
return {
|
|
278
|
+
state: compacted,
|
|
279
|
+
result: { status: 'invalid_bundle', approval, reason: validated.reason },
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return {
|
|
284
|
+
state: compacted,
|
|
285
|
+
result: { status: 'consumed', approval, firstExecution: false },
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
if (approvalGrantBundleExhausted(approval) && !approval.executionLeaseExpiresAt) {
|
|
253
289
|
compacted.approvals.splice(matchIndex, 1);
|
|
254
290
|
return { state: compacted, result: null };
|
|
255
291
|
}
|
|
292
|
+
let updatedApproval = approval;
|
|
293
|
+
const bundle = grantsFromApproval(approval);
|
|
294
|
+
if (approval.grantBundleVersion === 1) {
|
|
295
|
+
const validated = validateAndConsumeGrantBundle(approval, requests);
|
|
296
|
+
if (!validated.ok) {
|
|
297
|
+
compacted.approvals.splice(matchIndex, 1);
|
|
298
|
+
return {
|
|
299
|
+
state: compacted,
|
|
300
|
+
result: { status: 'invalid_bundle', approval, reason: validated.reason },
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
updatedApproval = validated.approval;
|
|
304
|
+
}
|
|
305
|
+
else if (bundle.length > 0) {
|
|
306
|
+
const consumed = consumeApprovedRecordGrantBundle(approval);
|
|
307
|
+
if (!consumed.consumed) {
|
|
308
|
+
return { state: compacted, result: null };
|
|
309
|
+
}
|
|
310
|
+
updatedApproval = consumed.approval;
|
|
311
|
+
}
|
|
312
|
+
else if (approval.grant) {
|
|
313
|
+
updatedApproval = decrementApprovalLegacyGrant(approval);
|
|
314
|
+
}
|
|
256
315
|
compacted.approvals[matchIndex] = {
|
|
257
|
-
...
|
|
316
|
+
...updatedApproval,
|
|
258
317
|
executionLeaseExpiresAt: new Date(Date.now() + getExecutionLeaseMs(ctx.config)).toISOString(),
|
|
259
|
-
grant: approval.grant
|
|
260
|
-
? {
|
|
261
|
-
...approval.grant,
|
|
262
|
-
usesRemaining: Math.max(0, approval.grant.usesRemaining - 1),
|
|
263
|
-
}
|
|
264
|
-
: undefined,
|
|
265
318
|
};
|
|
266
|
-
return {
|
|
319
|
+
return {
|
|
320
|
+
state: compacted,
|
|
321
|
+
result: { status: 'consumed', approval: updatedApproval, firstExecution: true },
|
|
322
|
+
};
|
|
267
323
|
},
|
|
268
324
|
});
|
|
269
325
|
if (!consumed) {
|
|
270
326
|
return null;
|
|
271
327
|
}
|
|
272
|
-
return
|
|
328
|
+
return consumed;
|
|
329
|
+
}
|
|
330
|
+
async function discardApprovedApproval(ctx, deps, approvalId) {
|
|
331
|
+
const discarded = await mutateApprovalStateWithRetry({
|
|
332
|
+
load: () => deps.loadApprovals(ctx, 'approved-approvals.json'),
|
|
333
|
+
write: (filePath, state) => deps.writeApprovals(filePath, state),
|
|
334
|
+
mutate: (state) => {
|
|
335
|
+
const compacted = compactApprovals(state);
|
|
336
|
+
const index = compacted.approvals.findIndex((approval) => approval.approvalId === approvalId);
|
|
337
|
+
if (index !== -1) {
|
|
338
|
+
compacted.approvals.splice(index, 1);
|
|
339
|
+
}
|
|
340
|
+
return { state: compacted, result: true };
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
if (discarded !== true) {
|
|
344
|
+
throw new Error(`Failed to discard rejected approval ${approvalId}`);
|
|
345
|
+
}
|
|
273
346
|
}
|
|
274
347
|
export async function evaluateGatedAction(ctx, deps, params) {
|
|
275
348
|
let action;
|
|
@@ -285,37 +358,65 @@ export async function evaluateGatedAction(ctx, deps, params) {
|
|
|
285
358
|
});
|
|
286
359
|
}
|
|
287
360
|
catch {
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
361
|
+
const scrubbedInput = scrubValue({
|
|
362
|
+
kind: params.kind,
|
|
363
|
+
cwd: params.cwd,
|
|
364
|
+
command: params.command,
|
|
365
|
+
payload: params.payload,
|
|
366
|
+
toolName: params.toolName,
|
|
367
|
+
}, scrubOptionsFromConfig(ctx.config));
|
|
368
|
+
const inputFingerprint = hashValue(`unnormalized-gated-action:v1:${canonicalStringify(scrubbedInput)}`);
|
|
369
|
+
const verdict = {
|
|
370
|
+
...unnormalizedGateVerdict({
|
|
371
|
+
reason: 'normalization_failed',
|
|
372
|
+
mode: ctx.config.mode,
|
|
373
|
+
user_message: 'belay could not normalize this gated action. Run belay doctor, then retry.',
|
|
374
|
+
agent_message: 'Belay denied this action because the hook payload could not be normalized.',
|
|
375
|
+
}),
|
|
376
|
+
fingerprint: inputFingerprint,
|
|
377
|
+
effectPlan: buildCapabilityEffectPlan({
|
|
378
|
+
actionKind: params.kind,
|
|
379
|
+
summary: params.command ?? params.toolName ?? params.kind,
|
|
380
|
+
inputFingerprint,
|
|
381
|
+
requests: [],
|
|
382
|
+
effectFree: false,
|
|
383
|
+
}),
|
|
384
|
+
};
|
|
294
385
|
await deps.appendAudit(ctx, {
|
|
295
386
|
event: gateAuditEventName(params.kind),
|
|
296
387
|
kind: params.kind,
|
|
388
|
+
fingerprint: verdict.fingerprint,
|
|
297
389
|
verdict: verdict.verdict,
|
|
298
390
|
reason: verdict.reason,
|
|
299
391
|
mode: ctx.config.mode,
|
|
392
|
+
...effectPlanAuditFields(verdict.effectPlan),
|
|
300
393
|
wouldBlock: true,
|
|
301
394
|
permission: 'deny',
|
|
302
395
|
});
|
|
303
396
|
return verdict;
|
|
304
397
|
}
|
|
305
398
|
if (!gateEnabledForAction(ctx.config, action)) {
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
signals: ['gate_disabled'],
|
|
317
|
-
},
|
|
399
|
+
const result = {
|
|
400
|
+
verdict: 'allow',
|
|
401
|
+
reason: 'gate_disabled',
|
|
402
|
+
fingerprint: 'gate_disabled',
|
|
403
|
+
assessment: {
|
|
404
|
+
reversibility: 'reversible',
|
|
405
|
+
external: false,
|
|
406
|
+
blastRadius: 'none',
|
|
407
|
+
confidence: 1,
|
|
408
|
+
signals: ['gate_disabled'],
|
|
318
409
|
},
|
|
410
|
+
effectPlan: buildCapabilityEffectPlan({
|
|
411
|
+
actionKind: action.kind,
|
|
412
|
+
summary: action.command ?? action.toolName ?? action.kind,
|
|
413
|
+
inputFingerprint: 'gate_disabled',
|
|
414
|
+
requests: [],
|
|
415
|
+
effectFree: false,
|
|
416
|
+
}),
|
|
417
|
+
};
|
|
418
|
+
return classifyResultToGateVerdict({
|
|
419
|
+
result,
|
|
319
420
|
mode: ctx.config.mode,
|
|
320
421
|
permission: 'allow',
|
|
321
422
|
wouldBlock: false,
|
|
@@ -362,7 +463,7 @@ export async function evaluateGatedAction(ctx, deps, params) {
|
|
|
362
463
|
command: params.command,
|
|
363
464
|
cwd: params.cwd,
|
|
364
465
|
repoRoot: ctx.repoRoot,
|
|
365
|
-
stateDir:
|
|
466
|
+
stateDir: belayStateDir(ctx.config, ctx.layout.repoLocalStateDir(ctx.repoRoot)),
|
|
366
467
|
timeoutMs: transactional.timeoutMs,
|
|
367
468
|
predicted,
|
|
368
469
|
diffContext: {
|
|
@@ -373,6 +474,8 @@ export async function evaluateGatedAction(ctx, deps, params) {
|
|
|
373
474
|
},
|
|
374
475
|
boundaryContext,
|
|
375
476
|
dirtyIgnoreRoots: protectedArtifactRoots(ctx.layout, ctx.repoRoot, ctx.config.controlPlane.enabled ? configuredControlPlaneDir(ctx.config) : null),
|
|
477
|
+
fileCheckpoint: transactional.fileCheckpoint,
|
|
478
|
+
checkpoint: transactional.checkpoint,
|
|
376
479
|
});
|
|
377
480
|
if (!txResult.skipped && txResult.observed) {
|
|
378
481
|
result = txResult.result;
|
|
@@ -380,20 +483,48 @@ export async function evaluateGatedAction(ctx, deps, params) {
|
|
|
380
483
|
observedAssessment = txResult.observed.assessment;
|
|
381
484
|
transactionalLayer = {
|
|
382
485
|
transactional: true,
|
|
486
|
+
transactionalBackend: txResult.transactionalBackend,
|
|
487
|
+
resourceKind: txResult.resourceKind,
|
|
488
|
+
baselineTreeHash: txResult.baselineTreeHash,
|
|
489
|
+
snapshotFileCount: txResult.snapshotFileCount,
|
|
490
|
+
snapshotSourceBytes: txResult.snapshotSourceBytes,
|
|
491
|
+
snapshotWorkspaceBytes: txResult.snapshotWorkspaceBytes,
|
|
492
|
+
snapshotCopyStrategy: txResult.snapshotCopyStrategy,
|
|
493
|
+
snapshotPrepareMs: txResult.snapshotPrepareMs,
|
|
494
|
+
executionRoute: `boundary:${boundaryContext.driverId}`,
|
|
495
|
+
enforcementStatus: 'mediated_unattested',
|
|
383
496
|
transactionalReason: txResult.observed.reason,
|
|
384
497
|
transactionalCategories: txResult.observed.categories,
|
|
385
498
|
transactionalChangeCount: txResult.observed.changes.length,
|
|
386
499
|
transactionalTimedOut: txResult.timedOut === true,
|
|
500
|
+
...(txResult.recoveryCheckpointId
|
|
501
|
+
? {
|
|
502
|
+
recoveryCheckpointId: txResult.recoveryCheckpointId,
|
|
503
|
+
recoveryBackend: txResult.recoveryBackend,
|
|
504
|
+
recoveryProofHash: txResult.recoveryProofHash,
|
|
505
|
+
recoveryState: txResult.recoveryState,
|
|
506
|
+
}
|
|
507
|
+
: {}),
|
|
387
508
|
};
|
|
388
509
|
}
|
|
389
510
|
else {
|
|
390
511
|
const skipReason = txResult.skipReason ?? 'recovery_observation_failed';
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
512
|
+
const failReason = skipReason === FILE_CHECKPOINT_ISOLATION_UNAVAILABLE
|
|
513
|
+
? FILE_CHECKPOINT_ISOLATION_UNAVAILABLE
|
|
514
|
+
: recoveryFailReasonFromSkip(skipReason);
|
|
515
|
+
result = recoveryFailClosedResult(predicted, failReason, [skipReason]);
|
|
394
516
|
transactionalLayer = {
|
|
395
517
|
transactional: false,
|
|
518
|
+
transactionalBackend: txResult.transactionalBackend,
|
|
519
|
+
resourceKind: txResult.resourceKind,
|
|
520
|
+
baselineTreeHash: txResult.baselineTreeHash,
|
|
521
|
+
snapshotFileCount: txResult.snapshotFileCount,
|
|
522
|
+
snapshotSourceBytes: txResult.snapshotSourceBytes,
|
|
523
|
+
snapshotWorkspaceBytes: txResult.snapshotWorkspaceBytes,
|
|
524
|
+
snapshotCopyStrategy: txResult.snapshotCopyStrategy,
|
|
525
|
+
snapshotPrepareMs: txResult.snapshotPrepareMs,
|
|
396
526
|
transactionalSkipReason: skipReason,
|
|
527
|
+
transactionalSkipDetail: txResult.skipDetail,
|
|
397
528
|
recoveryFailClosed: true,
|
|
398
529
|
};
|
|
399
530
|
}
|
|
@@ -473,20 +604,19 @@ async function consumeCapabilityGrantIfUsed(ctx, deps, result) {
|
|
|
473
604
|
result.authorizationDecision?.matchedRule !== 'grant.exact') {
|
|
474
605
|
return true;
|
|
475
606
|
}
|
|
476
|
-
|
|
477
|
-
if (!request) {
|
|
607
|
+
if (!canConsumeCapabilityGrantLease(result)) {
|
|
478
608
|
return false;
|
|
479
609
|
}
|
|
610
|
+
const requests = result.capabilityRequests ?? [];
|
|
611
|
+
if (!requests.length) {
|
|
612
|
+
return true;
|
|
613
|
+
}
|
|
480
614
|
const consumed = await mutateApprovalStateWithRetry({
|
|
481
615
|
load: () => deps.loadApprovals(ctx, 'approved-approvals.json'),
|
|
482
616
|
write: (filePath, state) => deps.writeApprovals(filePath, state),
|
|
483
617
|
mutate: (state) => {
|
|
484
618
|
const compacted = compactApprovals(state);
|
|
485
|
-
const
|
|
486
|
-
if (!grant) {
|
|
487
|
-
return null;
|
|
488
|
-
}
|
|
489
|
-
const lease = consumeGrantLease(compacted, grant.grantId);
|
|
619
|
+
const lease = consumeGrantLeasesForRequests(compacted, requests);
|
|
490
620
|
if (!lease.consumed) {
|
|
491
621
|
return null;
|
|
492
622
|
}
|
|
@@ -496,6 +626,18 @@ async function consumeCapabilityGrantIfUsed(ctx, deps, result) {
|
|
|
496
626
|
return consumed === true;
|
|
497
627
|
}
|
|
498
628
|
async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {}) {
|
|
629
|
+
if (!result.effectPlan) {
|
|
630
|
+
result = {
|
|
631
|
+
...result,
|
|
632
|
+
effectPlan: buildCapabilityEffectPlan({
|
|
633
|
+
actionKind: kind,
|
|
634
|
+
summary: result.normalizedCommand ?? result.summary ?? kind,
|
|
635
|
+
inputFingerprint: result.fingerprint,
|
|
636
|
+
requests: result.capabilityRequests ?? [],
|
|
637
|
+
effectFree: result.reason === 'read_only',
|
|
638
|
+
}),
|
|
639
|
+
};
|
|
640
|
+
}
|
|
499
641
|
const replayContext = buildAuditReplayContext(kind, result, auditExtras.replayAction);
|
|
500
642
|
const actionSnapshot = buildAuditActionSnapshot(kind, result, auditExtras.replayAction);
|
|
501
643
|
const providerId = String(ctx.config.judge?.providerId ?? 'cursor');
|
|
@@ -520,11 +662,92 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
|
|
|
520
662
|
mode: ctx.config.mode,
|
|
521
663
|
schemaVersion: result.axes ? 2 : 1,
|
|
522
664
|
...(result.axes ?? {}),
|
|
523
|
-
...auditExtras.transactionalLayer,
|
|
524
665
|
...capabilityAudit,
|
|
666
|
+
...auditExtras.transactionalLayer,
|
|
525
667
|
...(replayContext ? { replayContext } : {}),
|
|
526
668
|
...(actionSnapshot ? { actionSnapshot } : {}),
|
|
527
669
|
};
|
|
670
|
+
const denyWithPendingApproval = async (failure) => {
|
|
671
|
+
const { approval, created } = await ensurePendingApproval(ctx, deps, kind, result, auditExtras.approvalInput, deriveWorkspaceRootScopeHint({
|
|
672
|
+
result,
|
|
673
|
+
replayAction: auditExtras.replayAction,
|
|
674
|
+
payloadForScopeHint: auditExtras.scopeHintPayload,
|
|
675
|
+
options: auditExtras.classifierOptions ?? {},
|
|
676
|
+
}));
|
|
677
|
+
if (created) {
|
|
678
|
+
await recordGateApprovalAsk(stateDir, result.reason, false);
|
|
679
|
+
}
|
|
680
|
+
let approvalToken;
|
|
681
|
+
try {
|
|
682
|
+
approvalToken = await issueApprovalToken({
|
|
683
|
+
approvalId: approval.approvalId,
|
|
684
|
+
fingerprint: approval.fingerprint,
|
|
685
|
+
repoRoot: approval.repoRoot,
|
|
686
|
+
issuedAt: approval.createdAt,
|
|
687
|
+
expiresAt: approval.expiresAt,
|
|
688
|
+
}, configuredControlPlaneDir(ctx.config));
|
|
689
|
+
}
|
|
690
|
+
catch {
|
|
691
|
+
approvalToken = undefined;
|
|
692
|
+
}
|
|
693
|
+
const denialReason = failure?.reason ?? result.reason;
|
|
694
|
+
if (ctx.config.notifications.webhookUrl || ctx.config.notifications.commandHook) {
|
|
695
|
+
await notifyDeny(ctx.config.notifications, {
|
|
696
|
+
approvalId: approval.approvalId,
|
|
697
|
+
reason: denialReason,
|
|
698
|
+
summary: result.normalizedCommand ?? result.summary ?? '',
|
|
699
|
+
repoRoot: ctx.repoRoot,
|
|
700
|
+
fingerprint: result.fingerprint,
|
|
701
|
+
approvalToken,
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
const failureAuditFields = typeof failure?.auditFields === 'function'
|
|
705
|
+
? failure.auditFields(approval)
|
|
706
|
+
: failure?.auditFields;
|
|
707
|
+
await deps.appendAudit(ctx, {
|
|
708
|
+
...gateBase,
|
|
709
|
+
verdict: failure ? 'deny_pending_approval' : result.verdict,
|
|
710
|
+
reason: denialReason,
|
|
711
|
+
approvalId: approval.approvalId,
|
|
712
|
+
...(failure?.grantBundleFailureReason
|
|
713
|
+
? { grantBundleFailureReason: failure.grantBundleFailureReason }
|
|
714
|
+
: {}),
|
|
715
|
+
...failureAuditFields,
|
|
716
|
+
wouldBlock: true,
|
|
717
|
+
permission: 'deny',
|
|
718
|
+
});
|
|
719
|
+
const adapter = adapterIdFromContext(ctx);
|
|
720
|
+
const autoReplayShell = kind === 'shell' && canAutoReplay(ctx.config, kind, adapter);
|
|
721
|
+
return classifyResultToGateVerdict({
|
|
722
|
+
result: failure
|
|
723
|
+
? { ...result, verdict: 'deny_pending_approval', reason: denialReason }
|
|
724
|
+
: result,
|
|
725
|
+
mode: ctx.config.mode,
|
|
726
|
+
permission: 'deny',
|
|
727
|
+
wouldBlock: true,
|
|
728
|
+
approvalId: approval.approvalId,
|
|
729
|
+
user_message: failure?.userMessage(approval.approvalId) ??
|
|
730
|
+
`Belay blocked this high-risk action. Approval ID: ${approval.approvalId}. ${buildRetryInstructionForConfig(ctx.config, ctx.config.tokenPrefix, approval.approvalId, kind, adapter)} For details, run belay explain or /belay why.`,
|
|
731
|
+
agent_message: failure?.agentMessage ??
|
|
732
|
+
(autoReplayShell
|
|
733
|
+
? `Belay denied this action as ${result.reason}. Wait for approval; Belay will replay the exact shell action automatically. Do not retry unless replay fails.`
|
|
734
|
+
: `Belay denied this action as ${result.reason}. Wait for approval, then retry the exact same action once.`),
|
|
735
|
+
});
|
|
736
|
+
};
|
|
737
|
+
const denyReplayMismatch = async (approval, mismatchKind, messages) => {
|
|
738
|
+
await discardApprovedApproval(ctx, deps, approval.approvalId);
|
|
739
|
+
return denyWithPendingApproval({
|
|
740
|
+
reason: 'approval_replay_mismatch',
|
|
741
|
+
auditFields: (replacement) => ({
|
|
742
|
+
rejectedApprovalId: approval.approvalId,
|
|
743
|
+
rejectedApprovalCorrelationId: approvalCorrelationId(approval.approvalId),
|
|
744
|
+
replacementApprovalCorrelationId: approvalCorrelationId(replacement.approvalId),
|
|
745
|
+
replayMismatchKind: mismatchKind,
|
|
746
|
+
}),
|
|
747
|
+
userMessage: (approvalId) => `${messages.user} New approval ID: ${approvalId}.`,
|
|
748
|
+
agentMessage: `${messages.agent} Wait for the new exact approval, then retry once.`,
|
|
749
|
+
});
|
|
750
|
+
};
|
|
528
751
|
if (result.reason === TRANSACTIONAL_ALREADY_APPLIED) {
|
|
529
752
|
const userMessage = 'Belay executed this command safely in an isolated git worktree. Observed-safe file changes are already applied; do not retry the same command.';
|
|
530
753
|
const agentMessage = 'Belay already applied the observed-safe effects of this shell command in isolation. Do not run it again.';
|
|
@@ -585,61 +808,47 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
|
|
|
585
808
|
entry.fingerprint === result.fingerprint &&
|
|
586
809
|
entry.repoRoot === ctx.repoRoot);
|
|
587
810
|
if (!shouldSkipBrokerApprovedRecord(brokerActive, matchedApproval?.reason)) {
|
|
588
|
-
if (matchedApproval &&
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
reason: 'approval_replay_mismatch',
|
|
596
|
-
approvalId: matchedApproval.approvalId,
|
|
597
|
-
wouldBlock: true,
|
|
598
|
-
permission: 'deny',
|
|
811
|
+
if (matchedApproval?.capabilityRequestHash &&
|
|
812
|
+
(!result.capabilityRequests?.length ||
|
|
813
|
+
matchedApproval.capabilityRequestHash !==
|
|
814
|
+
hashCapabilityRequests(result.capabilityRequests))) {
|
|
815
|
+
return denyReplayMismatch(matchedApproval, 'capability_requests', {
|
|
816
|
+
user: 'Belay denied this action because capability requests changed after approval. Re-approve the exact action or run belay explain.',
|
|
817
|
+
agent: 'Belay denied this action because capability requests changed after approval.',
|
|
599
818
|
});
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
mode: ctx.config.mode,
|
|
607
|
-
permission: 'deny',
|
|
608
|
-
wouldBlock: true,
|
|
609
|
-
approvalId: matchedApproval.approvalId,
|
|
610
|
-
user_message: 'Belay denied this action because capability requests changed after approval. Re-approve the exact action or run belay explain.',
|
|
611
|
-
agent_message: 'Belay denied this action because capability requests changed after approval.',
|
|
819
|
+
}
|
|
820
|
+
if (matchedApproval?.effectPlanHash &&
|
|
821
|
+
(!result.effectPlan || matchedApproval.effectPlanHash !== hashEffectPlan(result.effectPlan))) {
|
|
822
|
+
return denyReplayMismatch(matchedApproval, 'effect_plan', {
|
|
823
|
+
user: 'Belay denied this action because the effect plan changed after approval. Re-approve the exact action or run belay explain.',
|
|
824
|
+
agent: 'Belay denied this action because the effect plan changed after approval.',
|
|
612
825
|
});
|
|
613
826
|
}
|
|
614
827
|
if (matchedApproval &&
|
|
615
828
|
auditExtras.replayAction &&
|
|
616
829
|
!validateReplayEnvelope(matchedApproval, auditExtras.replayAction)) {
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
reason: 'approval_replay_mismatch',
|
|
621
|
-
approvalId: matchedApproval.approvalId,
|
|
622
|
-
wouldBlock: true,
|
|
623
|
-
permission: 'deny',
|
|
624
|
-
});
|
|
625
|
-
return classifyResultToGateVerdict({
|
|
626
|
-
result: {
|
|
627
|
-
...result,
|
|
628
|
-
verdict: 'deny_pending_approval',
|
|
629
|
-
reason: 'approval_replay_mismatch',
|
|
630
|
-
},
|
|
631
|
-
mode: ctx.config.mode,
|
|
632
|
-
permission: 'deny',
|
|
633
|
-
wouldBlock: true,
|
|
634
|
-
approvalId: matchedApproval.approvalId,
|
|
635
|
-
user_message: 'Belay denied this action because it does not match the approved replay envelope. Re-approve the exact action or run belay explain.',
|
|
636
|
-
agent_message: 'Belay denied this action because cwd, tool, or payload changed after approval.',
|
|
830
|
+
return denyReplayMismatch(matchedApproval, 'replay_envelope', {
|
|
831
|
+
user: 'Belay denied this action because it does not match the approved replay envelope. Re-approve the exact action or run belay explain.',
|
|
832
|
+
agent: 'Belay denied this action because cwd, tool, or payload changed after approval.',
|
|
637
833
|
});
|
|
638
834
|
}
|
|
639
|
-
approved = await consumeApprovedApproval(ctx, deps, kind, result.fingerprint);
|
|
835
|
+
approved = await consumeApprovedApproval(ctx, deps, kind, result.fingerprint, result.capabilityRequests ?? []);
|
|
640
836
|
}
|
|
641
837
|
}
|
|
642
|
-
if (approved) {
|
|
838
|
+
if (approved?.status === 'invalid_bundle') {
|
|
839
|
+
return denyWithPendingApproval({
|
|
840
|
+
reason: 'capability_grant_unavailable',
|
|
841
|
+
grantBundleFailureReason: approved.reason,
|
|
842
|
+
auditFields: (replacement) => ({
|
|
843
|
+
rejectedApprovalId: approved.approval.approvalId,
|
|
844
|
+
rejectedApprovalCorrelationId: approvalCorrelationId(approved.approval.approvalId),
|
|
845
|
+
replacementApprovalCorrelationId: approvalCorrelationId(replacement.approvalId),
|
|
846
|
+
}),
|
|
847
|
+
userMessage: (approvalId) => `Belay denied this action because its approved capability bundle did not exactly match the current request. New approval ID: ${approvalId}. Re-approve the exact action or run belay explain.`,
|
|
848
|
+
agentMessage: `Belay denied this action because the approved capability bundle failed exact validation (${approved.reason}). Wait for the new exact approval, then retry once.`,
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
if (approved?.status === 'consumed') {
|
|
643
852
|
if (approved.firstExecution) {
|
|
644
853
|
await recordGateApprovalAsk(stateDir, result.reason, true);
|
|
645
854
|
}
|
|
@@ -648,6 +857,7 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
|
|
|
648
857
|
verdict: 'allow',
|
|
649
858
|
reason: 'approved_once',
|
|
650
859
|
approvalId: approved.approval.approvalId,
|
|
860
|
+
authorizationMode: approved.approval.grantBundleVersion === 1 ? 'exact_bundle' : 'legacy_approval',
|
|
651
861
|
wouldBlock: false,
|
|
652
862
|
permission: 'allow',
|
|
653
863
|
});
|
|
@@ -740,61 +950,33 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
|
|
|
740
950
|
agent_message: denyMessages.agent_message,
|
|
741
951
|
});
|
|
742
952
|
}
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
if (created) {
|
|
750
|
-
await recordGateApprovalAsk(stateDir, result.reason, false);
|
|
751
|
-
}
|
|
752
|
-
let approvalToken;
|
|
753
|
-
try {
|
|
754
|
-
approvalToken = await issueApprovalToken({
|
|
755
|
-
approvalId: approval.approvalId,
|
|
756
|
-
fingerprint: approval.fingerprint,
|
|
757
|
-
repoRoot: approval.repoRoot,
|
|
758
|
-
issuedAt: approval.createdAt,
|
|
759
|
-
expiresAt: approval.expiresAt,
|
|
760
|
-
}, configuredControlPlaneDir(ctx.config));
|
|
761
|
-
}
|
|
762
|
-
catch {
|
|
763
|
-
approvalToken = undefined;
|
|
764
|
-
}
|
|
765
|
-
if (ctx.config.notifications.webhookUrl || ctx.config.notifications.commandHook) {
|
|
766
|
-
await notifyDeny(ctx.config.notifications, {
|
|
767
|
-
approvalId: approval.approvalId,
|
|
953
|
+
if (result.reason === FILE_CHECKPOINT_ISOLATION_UNAVAILABLE) {
|
|
954
|
+
const userMessage = 'Belay cannot run file_checkpoint because attested workspace isolation is unavailable. Run `belay session start`, then retry.';
|
|
955
|
+
const agentMessage = 'Belay denied this action because file_checkpoint lacks a fresh matching workspace-isolation attestation. Ask the operator to run `belay session start`, then retry the exact action.';
|
|
956
|
+
await deps.appendAudit(ctx, {
|
|
957
|
+
...gateBase,
|
|
958
|
+
verdict: result.verdict,
|
|
768
959
|
reason: result.reason,
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
960
|
+
wouldBlock: true,
|
|
961
|
+
permission: 'deny',
|
|
962
|
+
});
|
|
963
|
+
return classifyResultToGateVerdict({
|
|
964
|
+
result,
|
|
965
|
+
mode: ctx.config.mode,
|
|
966
|
+
permission: 'deny',
|
|
967
|
+
wouldBlock: true,
|
|
968
|
+
user_message: userMessage,
|
|
969
|
+
agent_message: agentMessage,
|
|
773
970
|
});
|
|
774
971
|
}
|
|
775
|
-
|
|
776
|
-
...gateBase,
|
|
777
|
-
verdict: result.verdict,
|
|
778
|
-
reason: result.reason,
|
|
779
|
-
approvalId: approval.approvalId,
|
|
780
|
-
wouldBlock: true,
|
|
781
|
-
permission: 'deny',
|
|
782
|
-
});
|
|
783
|
-
return classifyResultToGateVerdict({
|
|
784
|
-
result,
|
|
785
|
-
mode: ctx.config.mode,
|
|
786
|
-
permission: 'deny',
|
|
787
|
-
wouldBlock: true,
|
|
788
|
-
approvalId: approval.approvalId,
|
|
789
|
-
user_message: `Belay blocked this high-risk action. Approval ID: ${approval.approvalId}. ${buildRetryInstructionForConfig(ctx.config, ctx.config.tokenPrefix, approval.approvalId)} For details, run belay explain or /belay why.`,
|
|
790
|
-
agent_message: `Belay denied this action as ${result.reason}. Wait for approval, then retry the exact same action once.`,
|
|
791
|
-
});
|
|
972
|
+
return denyWithPendingApproval();
|
|
792
973
|
}
|
|
793
974
|
export async function processApprovalPrompt(ctx, deps, prompt) {
|
|
794
975
|
const approvalId = approvalCommandMatch(prompt, ctx.config.tokenPrefix);
|
|
795
976
|
if (!approvalId) {
|
|
796
977
|
return { continue: true };
|
|
797
978
|
}
|
|
979
|
+
const hasFollowupInstruction = prompt.split(/\r?\n/).filter((line) => line.trim().length > 0).length > 1;
|
|
798
980
|
const adapter = adapterIdFromContext(ctx);
|
|
799
981
|
if (ctx.config.approvalSigning.required) {
|
|
800
982
|
const message = `Signed approval token required for ${approvalId}. Editor prompt approval is disabled in this configuration. ` +
|
|
@@ -837,62 +1019,68 @@ export async function processApprovalPrompt(ctx, deps, prompt) {
|
|
|
837
1019
|
};
|
|
838
1020
|
}
|
|
839
1021
|
const replay = recorded.approval ? buildReplayHint(ctx.config, recorded.approval, adapter) : null;
|
|
840
|
-
const
|
|
841
|
-
replay?.kind === 'shell' &&
|
|
1022
|
+
const shouldReplayApprovedShell = replay?.kind === 'shell' &&
|
|
842
1023
|
replay.autoReplay === true &&
|
|
843
1024
|
Boolean(recorded.approval?.input) &&
|
|
844
1025
|
(!process.env.VITEST || process.env.BELAY_TEST_APPROVAL_REPLAY === '1');
|
|
845
|
-
if (
|
|
1026
|
+
if (shouldReplayApprovedShell && recorded.approval) {
|
|
846
1027
|
const approvalStore = gateApprovalStoreFromDeps({
|
|
847
1028
|
loadApprovals: (fileName) => deps.loadApprovals(ctx, fileName),
|
|
848
1029
|
writeApprovals: (filePath, state) => deps.writeApprovals(filePath, state),
|
|
849
1030
|
});
|
|
1031
|
+
let claimed;
|
|
1032
|
+
try {
|
|
1033
|
+
claimed = await claimApprovedForReplay({ approvalId, store: approvalStore });
|
|
1034
|
+
}
|
|
1035
|
+
catch {
|
|
1036
|
+
return {
|
|
1037
|
+
continue: false,
|
|
1038
|
+
user_message: `Belay approval ${approvalId} could not be claimed for one-step replay. ` +
|
|
1039
|
+
'The shell action was not started.',
|
|
1040
|
+
};
|
|
1041
|
+
}
|
|
1042
|
+
if (!claimed) {
|
|
1043
|
+
return {
|
|
1044
|
+
continue: false,
|
|
1045
|
+
user_message: `Belay approval ${approvalId} could not be claimed for one-step replay.`,
|
|
1046
|
+
};
|
|
1047
|
+
}
|
|
850
1048
|
let replayResult;
|
|
851
1049
|
try {
|
|
852
|
-
replayResult = await
|
|
1050
|
+
replayResult = await deps.replayApprovedShell(ctx, claimed.input ?? '', claimed.cwd ?? ctx.repoRoot, getExecutionLeaseMs(ctx.config));
|
|
853
1051
|
}
|
|
854
1052
|
catch (error) {
|
|
855
|
-
const
|
|
856
|
-
await
|
|
1053
|
+
const cleanupUnconfirmed = isBoundaryCleanupError(error);
|
|
1054
|
+
const auditRecorded = await appendReplayAuditSafely(ctx, deps, {
|
|
857
1055
|
event: 'approval',
|
|
858
1056
|
kind: 'approval',
|
|
859
1057
|
verdict: 'allow',
|
|
860
1058
|
approvalId,
|
|
861
|
-
reason:
|
|
1059
|
+
reason: cleanupUnconfirmed
|
|
1060
|
+
? 'approval_replay_cleanup_unconfirmed'
|
|
1061
|
+
: 'approval_replay_error',
|
|
862
1062
|
summary: recorded.approval.input ?? recorded.approval.summary ?? prompt,
|
|
863
1063
|
});
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
user_message: `Belay approval recorded for ${approvalId}, but one-step shell replay could not start (${detail}). ` +
|
|
867
|
-
'Approval remains active for one hook retry.',
|
|
868
|
-
};
|
|
869
|
-
}
|
|
870
|
-
const output = [replayResult.stdout, replayResult.stderr].filter(Boolean).join('\n').trim();
|
|
871
|
-
if (replayResult.exitCode === 0) {
|
|
872
|
-
try {
|
|
873
|
-
await consumeApprovedAfterCliReplay({
|
|
874
|
-
approvalId: approvalId,
|
|
875
|
-
store: approvalStore,
|
|
876
|
-
});
|
|
877
|
-
}
|
|
878
|
-
catch (error) {
|
|
879
|
-
const detail = error instanceof Error ? error.message : String(error);
|
|
880
|
-
await deps.appendAudit(ctx, {
|
|
881
|
-
event: 'approval',
|
|
882
|
-
kind: 'approval',
|
|
883
|
-
verdict: 'allow',
|
|
884
|
-
approvalId,
|
|
885
|
-
reason: 'approval_replay_consume_error',
|
|
886
|
-
summary: recorded.approval.input ?? recorded.approval.summary ?? prompt,
|
|
887
|
-
});
|
|
1064
|
+
if (cleanupUnconfirmed) {
|
|
1065
|
+
const resourceId = safeBoundaryCleanupResourceId(error);
|
|
888
1066
|
return {
|
|
889
1067
|
continue: false,
|
|
890
|
-
user_message: `Belay approval recorded for ${approvalId}
|
|
891
|
-
|
|
892
|
-
|
|
1068
|
+
user_message: `Belay approval recorded for ${approvalId}. The one-step shell replay started, but cleanup could not be confirmed; the container may still be running.` +
|
|
1069
|
+
(resourceId ? ` Container: ${resourceId}.` : '') +
|
|
1070
|
+
' Inspect Docker and remove the container manually if it is still present. ' +
|
|
1071
|
+
'The one-shot approval was consumed and was not re-armed.' +
|
|
1072
|
+
(auditRecorded ? '' : REPLAY_AUDIT_FAILURE_NOTE),
|
|
893
1073
|
};
|
|
894
1074
|
}
|
|
895
|
-
|
|
1075
|
+
return {
|
|
1076
|
+
continue: false,
|
|
1077
|
+
user_message: `Belay approval recorded for ${approvalId}, but one-step shell replay could not start. ` +
|
|
1078
|
+
'The one-shot approval was consumed before replay and was not re-armed.' +
|
|
1079
|
+
(auditRecorded ? '' : REPLAY_AUDIT_FAILURE_NOTE),
|
|
1080
|
+
};
|
|
1081
|
+
}
|
|
1082
|
+
if (replayResult.exitCode === 0 && !replayResult.timedOut) {
|
|
1083
|
+
const auditRecorded = await appendReplayAuditSafely(ctx, deps, {
|
|
896
1084
|
event: 'approval',
|
|
897
1085
|
kind: 'approval',
|
|
898
1086
|
verdict: 'allow',
|
|
@@ -901,12 +1089,12 @@ export async function processApprovalPrompt(ctx, deps, prompt) {
|
|
|
901
1089
|
summary: recorded.approval.input ?? recorded.approval.summary ?? prompt,
|
|
902
1090
|
});
|
|
903
1091
|
return {
|
|
904
|
-
continue:
|
|
1092
|
+
continue: true,
|
|
905
1093
|
user_message: `Belay approval recorded for ${approvalId}. One-step shell replay succeeded; no manual retry required.` +
|
|
906
|
-
(
|
|
1094
|
+
(auditRecorded ? '' : REPLAY_AUDIT_FAILURE_NOTE),
|
|
907
1095
|
};
|
|
908
1096
|
}
|
|
909
|
-
await
|
|
1097
|
+
const auditRecorded = await appendReplayAuditSafely(ctx, deps, {
|
|
910
1098
|
event: 'approval',
|
|
911
1099
|
kind: 'approval',
|
|
912
1100
|
verdict: 'allow',
|
|
@@ -914,16 +1102,18 @@ export async function processApprovalPrompt(ctx, deps, prompt) {
|
|
|
914
1102
|
reason: 'approval_replay_failed',
|
|
915
1103
|
summary: recorded.approval.input ?? recorded.approval.summary ?? prompt,
|
|
916
1104
|
});
|
|
917
|
-
const
|
|
1105
|
+
const failureSummary = replayResult.timedOut
|
|
1106
|
+
? ' One-step shell replay timed out.'
|
|
1107
|
+
: ` One-step shell replay failed (exit ${replayResult.exitCode}).`;
|
|
918
1108
|
return {
|
|
919
1109
|
continue: false,
|
|
920
|
-
user_message: `Belay approval recorded for ${approvalId}
|
|
921
|
-
|
|
922
|
-
(
|
|
1110
|
+
user_message: `Belay approval recorded for ${approvalId}.${failureSummary} ` +
|
|
1111
|
+
'The one-shot approval was consumed before replay and was not re-armed.' +
|
|
1112
|
+
(auditRecorded ? '' : REPLAY_AUDIT_FAILURE_NOTE),
|
|
923
1113
|
};
|
|
924
1114
|
}
|
|
925
1115
|
return {
|
|
926
|
-
continue:
|
|
1116
|
+
continue: replay !== null || hasFollowupInstruction,
|
|
927
1117
|
user_message: recorded.message,
|
|
928
1118
|
...(replay ? { replay } : {}),
|
|
929
1119
|
};
|
|
@@ -951,15 +1141,18 @@ export function gateVerdictToClaudePreToolUseResponse(verdict) {
|
|
|
951
1141
|
}
|
|
952
1142
|
export function gateVerdictToClaudeUserPromptResponse(verdict) {
|
|
953
1143
|
if (verdict.continue) {
|
|
954
|
-
return
|
|
1144
|
+
return verdict.user_message
|
|
1145
|
+
? {
|
|
1146
|
+
hookSpecificOutput: {
|
|
1147
|
+
hookEventName: 'UserPromptSubmit',
|
|
1148
|
+
additionalContext: verdict.user_message,
|
|
1149
|
+
},
|
|
1150
|
+
}
|
|
1151
|
+
: {};
|
|
955
1152
|
}
|
|
956
1153
|
return {
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
continue: false,
|
|
960
|
-
user_message: verdict.user_message,
|
|
961
|
-
...(verdict.replay ? { replay: verdict.replay } : {}),
|
|
962
|
-
},
|
|
1154
|
+
decision: 'block',
|
|
1155
|
+
reason: verdict.user_message,
|
|
963
1156
|
};
|
|
964
1157
|
}
|
|
965
1158
|
// Codex PreToolUse deny contract is identical to Claude's
|
|
@@ -970,7 +1163,14 @@ export function gateVerdictToCodexPreToolUseResponse(verdict) {
|
|
|
970
1163
|
// Codex UserPromptSubmit blocks via `decision: "block"` (per developers.openai.com/codex/hooks).
|
|
971
1164
|
export function gateVerdictToCodexUserPromptResponse(verdict) {
|
|
972
1165
|
if (verdict.continue) {
|
|
973
|
-
return
|
|
1166
|
+
return verdict.user_message
|
|
1167
|
+
? {
|
|
1168
|
+
hookSpecificOutput: {
|
|
1169
|
+
hookEventName: 'UserPromptSubmit',
|
|
1170
|
+
additionalContext: verdict.user_message,
|
|
1171
|
+
},
|
|
1172
|
+
}
|
|
1173
|
+
: {};
|
|
974
1174
|
}
|
|
975
1175
|
return {
|
|
976
1176
|
decision: 'block',
|