@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,38 +1,56 @@
|
|
|
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';
|
|
35
|
+
import { PACKAGE_VERSION } from '../../version.js';
|
|
31
36
|
import { protectedArtifactRoots } from '../layouts/protected-paths.js';
|
|
32
37
|
const EMPTY_APPROVALS = {
|
|
33
38
|
version: 1,
|
|
34
39
|
approvals: [],
|
|
35
40
|
};
|
|
41
|
+
const RUNTIME_PROVENANCE_KEY = Symbol.for('agent-belay.runtime-provenance');
|
|
42
|
+
function auditProvenance(config) {
|
|
43
|
+
const runtime = globalThis[RUNTIME_PROVENANCE_KEY];
|
|
44
|
+
const runtimeVersion = typeof runtime?.runtimeVersion === 'string' ? runtime.runtimeVersion : PACKAGE_VERSION;
|
|
45
|
+
const runtimeBuildStamp = typeof runtime?.runtimeBuildStamp === 'string'
|
|
46
|
+
? runtime.runtimeBuildStamp
|
|
47
|
+
: `${PACKAGE_VERSION}@source`;
|
|
48
|
+
return {
|
|
49
|
+
runtimeVersion,
|
|
50
|
+
runtimeBuildStamp,
|
|
51
|
+
configFingerprint: hashValue(canonicalStringify(config)),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
36
54
|
function adapterIdFromContext(ctx) {
|
|
37
55
|
if (ctx.config.adapter === 'cursor' ||
|
|
38
56
|
ctx.config.adapter === 'claude' ||
|
|
@@ -44,6 +62,16 @@ function adapterIdFromContext(ctx) {
|
|
|
44
62
|
}
|
|
45
63
|
return undefined;
|
|
46
64
|
}
|
|
65
|
+
const REPLAY_AUDIT_FAILURE_NOTE = ' Audit recording failed; inspect audit storage before the next approval.';
|
|
66
|
+
async function appendReplayAuditSafely(ctx, deps, event) {
|
|
67
|
+
try {
|
|
68
|
+
await deps.appendAudit(ctx, event);
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
47
75
|
async function loadJsonFile(filePath, fallback) {
|
|
48
76
|
try {
|
|
49
77
|
const raw = await readFile(filePath, 'utf8');
|
|
@@ -61,11 +89,17 @@ export function createDefaultGateRuntimeDeps() {
|
|
|
61
89
|
async appendAudit(ctx, event) {
|
|
62
90
|
const auditPath = path.join(ctx.repoRoot, ctx.config.audit.logPath);
|
|
63
91
|
await mkdir(path.dirname(auditPath), { recursive: true });
|
|
64
|
-
const
|
|
92
|
+
const provenance = auditProvenance(ctx.config);
|
|
93
|
+
const record = {
|
|
94
|
+
timestamp: new Date().toISOString(),
|
|
95
|
+
...event,
|
|
96
|
+
...provenance,
|
|
97
|
+
};
|
|
65
98
|
if (!ctx.config.audit.includeAssessment) {
|
|
66
99
|
delete record.assessment;
|
|
67
100
|
}
|
|
68
101
|
const scrubbed = scrubValue(record, scrubOptionsFromConfig(ctx.config));
|
|
102
|
+
Object.assign(scrubbed, provenance);
|
|
69
103
|
await writeFile(auditPath, `${JSON.stringify(scrubbed)}\n`, {
|
|
70
104
|
encoding: 'utf8',
|
|
71
105
|
flag: 'a',
|
|
@@ -91,6 +125,16 @@ export function createDefaultGateRuntimeDeps() {
|
|
|
91
125
|
await mkdir(path.dirname(filePath), { recursive: true });
|
|
92
126
|
await writeFile(filePath, `${JSON.stringify(compactApprovals(state), null, 2)}\n`, 'utf8');
|
|
93
127
|
},
|
|
128
|
+
async replayApprovedShell(ctx, command, cwd, timeoutMs) {
|
|
129
|
+
return runBoundaryAgentCommand({
|
|
130
|
+
repoRoot: ctx.repoRoot,
|
|
131
|
+
config: ctx.config,
|
|
132
|
+
command,
|
|
133
|
+
cwd,
|
|
134
|
+
timeoutMs,
|
|
135
|
+
runOptions: { mountReadOnly: false },
|
|
136
|
+
});
|
|
137
|
+
},
|
|
94
138
|
};
|
|
95
139
|
}
|
|
96
140
|
export async function resolveGateConfig(ctx, deps) {
|
|
@@ -139,16 +183,7 @@ function gateAuditEventName(kind) {
|
|
|
139
183
|
return 'subagentGate';
|
|
140
184
|
}
|
|
141
185
|
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({
|
|
186
|
+
const candidate = createApprovalRecordWithEnvelope({
|
|
152
187
|
kind,
|
|
153
188
|
fingerprint: result.fingerprint,
|
|
154
189
|
repoRoot: ctx.repoRoot,
|
|
@@ -159,12 +194,31 @@ async function ensurePendingApproval(ctx, deps, kind, result, approvalInput, sco
|
|
|
159
194
|
approvalInput,
|
|
160
195
|
scopeHint,
|
|
161
196
|
capabilityRequests: result.capabilityRequests,
|
|
197
|
+
effectPlanHash: result.effectPlan ? hashEffectPlan(result.effectPlan) : undefined,
|
|
162
198
|
});
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
199
|
+
const outcome = await mutateApprovalStateWithRetry({
|
|
200
|
+
load: () => deps.loadApprovals(ctx, 'pending-approvals.json'),
|
|
201
|
+
write: (filePath, state) => deps.writeApprovals(filePath, state),
|
|
202
|
+
mutate: (state) => {
|
|
203
|
+
const compacted = compactApprovals(state);
|
|
204
|
+
const existing = compacted.approvals.find((approval) => approval.kind === kind &&
|
|
205
|
+
approval.fingerprint === result.fingerprint &&
|
|
206
|
+
approval.repoRoot === ctx.repoRoot);
|
|
207
|
+
if (existing) {
|
|
208
|
+
return { state: compacted, result: { approval: existing, created: false } };
|
|
209
|
+
}
|
|
210
|
+
compacted.version = APPROVAL_STATE_VERSION_V3;
|
|
211
|
+
compacted.approvals.push(candidate);
|
|
212
|
+
return { state: compacted, result: { approval: candidate, created: true } };
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
if (!outcome) {
|
|
216
|
+
throw new Error('Failed to persist pending approval');
|
|
217
|
+
}
|
|
218
|
+
return outcome;
|
|
219
|
+
}
|
|
220
|
+
function approvalCorrelationId(approvalId) {
|
|
221
|
+
return createHash('sha256').update(approvalId).digest('hex').slice(0, 16);
|
|
168
222
|
}
|
|
169
223
|
function extractShellCommandFromPayload(payload) {
|
|
170
224
|
const toolInput = payload.tool_input;
|
|
@@ -222,21 +276,7 @@ function deriveWorkspaceRootScopeHint(params) {
|
|
|
222
276
|
}
|
|
223
277
|
return { scope: 'workspace-root', path: validation.normalizedPath };
|
|
224
278
|
}
|
|
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
|
-
}
|
|
279
|
+
async function consumeApprovedApproval(ctx, deps, kind, fingerprint, requests) {
|
|
240
280
|
const consumed = await mutateApprovalStateWithRetry({
|
|
241
281
|
load: () => deps.loadApprovals(ctx, 'approved-approvals.json'),
|
|
242
282
|
write: (filePath, state) => deps.writeApprovals(filePath, state),
|
|
@@ -246,30 +286,83 @@ async function consumeApprovedApproval(ctx, deps, kind, fingerprint) {
|
|
|
246
286
|
approval.fingerprint === fingerprint &&
|
|
247
287
|
approval.repoRoot === ctx.repoRoot);
|
|
248
288
|
if (matchIndex === -1) {
|
|
249
|
-
return null;
|
|
289
|
+
return { state: compacted, result: null };
|
|
250
290
|
}
|
|
251
291
|
const approval = compacted.approvals[matchIndex];
|
|
252
|
-
if (approval.
|
|
292
|
+
if (approval.executionLeaseExpiresAt) {
|
|
293
|
+
if (approval.grantBundleVersion === 1) {
|
|
294
|
+
const validated = validateGrantBundleForLeaseReuse(approval, requests);
|
|
295
|
+
if (!validated.ok) {
|
|
296
|
+
compacted.approvals.splice(matchIndex, 1);
|
|
297
|
+
return {
|
|
298
|
+
state: compacted,
|
|
299
|
+
result: { status: 'invalid_bundle', approval, reason: validated.reason },
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return {
|
|
304
|
+
state: compacted,
|
|
305
|
+
result: { status: 'consumed', approval, firstExecution: false },
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
if (approvalGrantBundleExhausted(approval) && !approval.executionLeaseExpiresAt) {
|
|
253
309
|
compacted.approvals.splice(matchIndex, 1);
|
|
254
310
|
return { state: compacted, result: null };
|
|
255
311
|
}
|
|
312
|
+
let updatedApproval = approval;
|
|
313
|
+
const bundle = grantsFromApproval(approval);
|
|
314
|
+
if (approval.grantBundleVersion === 1) {
|
|
315
|
+
const validated = validateAndConsumeGrantBundle(approval, requests);
|
|
316
|
+
if (!validated.ok) {
|
|
317
|
+
compacted.approvals.splice(matchIndex, 1);
|
|
318
|
+
return {
|
|
319
|
+
state: compacted,
|
|
320
|
+
result: { status: 'invalid_bundle', approval, reason: validated.reason },
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
updatedApproval = validated.approval;
|
|
324
|
+
}
|
|
325
|
+
else if (bundle.length > 0) {
|
|
326
|
+
const consumed = consumeApprovedRecordGrantBundle(approval);
|
|
327
|
+
if (!consumed.consumed) {
|
|
328
|
+
return { state: compacted, result: null };
|
|
329
|
+
}
|
|
330
|
+
updatedApproval = consumed.approval;
|
|
331
|
+
}
|
|
332
|
+
else if (approval.grant) {
|
|
333
|
+
updatedApproval = decrementApprovalLegacyGrant(approval);
|
|
334
|
+
}
|
|
256
335
|
compacted.approvals[matchIndex] = {
|
|
257
|
-
...
|
|
336
|
+
...updatedApproval,
|
|
258
337
|
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
338
|
};
|
|
266
|
-
return {
|
|
339
|
+
return {
|
|
340
|
+
state: compacted,
|
|
341
|
+
result: { status: 'consumed', approval: updatedApproval, firstExecution: true },
|
|
342
|
+
};
|
|
267
343
|
},
|
|
268
344
|
});
|
|
269
345
|
if (!consumed) {
|
|
270
346
|
return null;
|
|
271
347
|
}
|
|
272
|
-
return
|
|
348
|
+
return consumed;
|
|
349
|
+
}
|
|
350
|
+
async function discardApprovedApproval(ctx, deps, approvalId) {
|
|
351
|
+
const discarded = await mutateApprovalStateWithRetry({
|
|
352
|
+
load: () => deps.loadApprovals(ctx, 'approved-approvals.json'),
|
|
353
|
+
write: (filePath, state) => deps.writeApprovals(filePath, state),
|
|
354
|
+
mutate: (state) => {
|
|
355
|
+
const compacted = compactApprovals(state);
|
|
356
|
+
const index = compacted.approvals.findIndex((approval) => approval.approvalId === approvalId);
|
|
357
|
+
if (index !== -1) {
|
|
358
|
+
compacted.approvals.splice(index, 1);
|
|
359
|
+
}
|
|
360
|
+
return { state: compacted, result: true };
|
|
361
|
+
},
|
|
362
|
+
});
|
|
363
|
+
if (discarded !== true) {
|
|
364
|
+
throw new Error(`Failed to discard rejected approval ${approvalId}`);
|
|
365
|
+
}
|
|
273
366
|
}
|
|
274
367
|
export async function evaluateGatedAction(ctx, deps, params) {
|
|
275
368
|
let action;
|
|
@@ -285,37 +378,65 @@ export async function evaluateGatedAction(ctx, deps, params) {
|
|
|
285
378
|
});
|
|
286
379
|
}
|
|
287
380
|
catch {
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
381
|
+
const scrubbedInput = scrubValue({
|
|
382
|
+
kind: params.kind,
|
|
383
|
+
cwd: params.cwd,
|
|
384
|
+
command: params.command,
|
|
385
|
+
payload: params.payload,
|
|
386
|
+
toolName: params.toolName,
|
|
387
|
+
}, scrubOptionsFromConfig(ctx.config));
|
|
388
|
+
const inputFingerprint = hashValue(`unnormalized-gated-action:v1:${canonicalStringify(scrubbedInput)}`);
|
|
389
|
+
const verdict = {
|
|
390
|
+
...unnormalizedGateVerdict({
|
|
391
|
+
reason: 'normalization_failed',
|
|
392
|
+
mode: ctx.config.mode,
|
|
393
|
+
user_message: 'belay could not normalize this gated action. Run belay doctor, then retry.',
|
|
394
|
+
agent_message: 'Belay denied this action because the hook payload could not be normalized.',
|
|
395
|
+
}),
|
|
396
|
+
fingerprint: inputFingerprint,
|
|
397
|
+
effectPlan: buildCapabilityEffectPlan({
|
|
398
|
+
actionKind: params.kind,
|
|
399
|
+
summary: params.command ?? params.toolName ?? params.kind,
|
|
400
|
+
inputFingerprint,
|
|
401
|
+
requests: [],
|
|
402
|
+
effectFree: false,
|
|
403
|
+
}),
|
|
404
|
+
};
|
|
294
405
|
await deps.appendAudit(ctx, {
|
|
295
406
|
event: gateAuditEventName(params.kind),
|
|
296
407
|
kind: params.kind,
|
|
408
|
+
fingerprint: verdict.fingerprint,
|
|
297
409
|
verdict: verdict.verdict,
|
|
298
410
|
reason: verdict.reason,
|
|
299
411
|
mode: ctx.config.mode,
|
|
412
|
+
...effectPlanAuditFields(verdict.effectPlan),
|
|
300
413
|
wouldBlock: true,
|
|
301
414
|
permission: 'deny',
|
|
302
415
|
});
|
|
303
416
|
return verdict;
|
|
304
417
|
}
|
|
305
418
|
if (!gateEnabledForAction(ctx.config, action)) {
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
signals: ['gate_disabled'],
|
|
317
|
-
},
|
|
419
|
+
const result = {
|
|
420
|
+
verdict: 'allow',
|
|
421
|
+
reason: 'gate_disabled',
|
|
422
|
+
fingerprint: 'gate_disabled',
|
|
423
|
+
assessment: {
|
|
424
|
+
reversibility: 'reversible',
|
|
425
|
+
external: false,
|
|
426
|
+
blastRadius: 'none',
|
|
427
|
+
confidence: 1,
|
|
428
|
+
signals: ['gate_disabled'],
|
|
318
429
|
},
|
|
430
|
+
effectPlan: buildCapabilityEffectPlan({
|
|
431
|
+
actionKind: action.kind,
|
|
432
|
+
summary: action.command ?? action.toolName ?? action.kind,
|
|
433
|
+
inputFingerprint: 'gate_disabled',
|
|
434
|
+
requests: [],
|
|
435
|
+
effectFree: false,
|
|
436
|
+
}),
|
|
437
|
+
};
|
|
438
|
+
return classifyResultToGateVerdict({
|
|
439
|
+
result,
|
|
319
440
|
mode: ctx.config.mode,
|
|
320
441
|
permission: 'allow',
|
|
321
442
|
wouldBlock: false,
|
|
@@ -362,7 +483,7 @@ export async function evaluateGatedAction(ctx, deps, params) {
|
|
|
362
483
|
command: params.command,
|
|
363
484
|
cwd: params.cwd,
|
|
364
485
|
repoRoot: ctx.repoRoot,
|
|
365
|
-
stateDir:
|
|
486
|
+
stateDir: belayStateDir(ctx.config, ctx.layout.repoLocalStateDir(ctx.repoRoot)),
|
|
366
487
|
timeoutMs: transactional.timeoutMs,
|
|
367
488
|
predicted,
|
|
368
489
|
diffContext: {
|
|
@@ -373,6 +494,8 @@ export async function evaluateGatedAction(ctx, deps, params) {
|
|
|
373
494
|
},
|
|
374
495
|
boundaryContext,
|
|
375
496
|
dirtyIgnoreRoots: protectedArtifactRoots(ctx.layout, ctx.repoRoot, ctx.config.controlPlane.enabled ? configuredControlPlaneDir(ctx.config) : null),
|
|
497
|
+
fileCheckpoint: transactional.fileCheckpoint,
|
|
498
|
+
checkpoint: transactional.checkpoint,
|
|
376
499
|
});
|
|
377
500
|
if (!txResult.skipped && txResult.observed) {
|
|
378
501
|
result = txResult.result;
|
|
@@ -380,20 +503,48 @@ export async function evaluateGatedAction(ctx, deps, params) {
|
|
|
380
503
|
observedAssessment = txResult.observed.assessment;
|
|
381
504
|
transactionalLayer = {
|
|
382
505
|
transactional: true,
|
|
506
|
+
transactionalBackend: txResult.transactionalBackend,
|
|
507
|
+
resourceKind: txResult.resourceKind,
|
|
508
|
+
baselineTreeHash: txResult.baselineTreeHash,
|
|
509
|
+
snapshotFileCount: txResult.snapshotFileCount,
|
|
510
|
+
snapshotSourceBytes: txResult.snapshotSourceBytes,
|
|
511
|
+
snapshotWorkspaceBytes: txResult.snapshotWorkspaceBytes,
|
|
512
|
+
snapshotCopyStrategy: txResult.snapshotCopyStrategy,
|
|
513
|
+
snapshotPrepareMs: txResult.snapshotPrepareMs,
|
|
514
|
+
executionRoute: `boundary:${boundaryContext.driverId}`,
|
|
515
|
+
enforcementStatus: 'mediated_unattested',
|
|
383
516
|
transactionalReason: txResult.observed.reason,
|
|
384
517
|
transactionalCategories: txResult.observed.categories,
|
|
385
518
|
transactionalChangeCount: txResult.observed.changes.length,
|
|
386
519
|
transactionalTimedOut: txResult.timedOut === true,
|
|
520
|
+
...(txResult.recoveryCheckpointId
|
|
521
|
+
? {
|
|
522
|
+
recoveryCheckpointId: txResult.recoveryCheckpointId,
|
|
523
|
+
recoveryBackend: txResult.recoveryBackend,
|
|
524
|
+
recoveryProofHash: txResult.recoveryProofHash,
|
|
525
|
+
recoveryState: txResult.recoveryState,
|
|
526
|
+
}
|
|
527
|
+
: {}),
|
|
387
528
|
};
|
|
388
529
|
}
|
|
389
530
|
else {
|
|
390
531
|
const skipReason = txResult.skipReason ?? 'recovery_observation_failed';
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
532
|
+
const failReason = skipReason === FILE_CHECKPOINT_ISOLATION_UNAVAILABLE
|
|
533
|
+
? FILE_CHECKPOINT_ISOLATION_UNAVAILABLE
|
|
534
|
+
: recoveryFailReasonFromSkip(skipReason);
|
|
535
|
+
result = recoveryFailClosedResult(predicted, failReason, [skipReason]);
|
|
394
536
|
transactionalLayer = {
|
|
395
537
|
transactional: false,
|
|
538
|
+
transactionalBackend: txResult.transactionalBackend,
|
|
539
|
+
resourceKind: txResult.resourceKind,
|
|
540
|
+
baselineTreeHash: txResult.baselineTreeHash,
|
|
541
|
+
snapshotFileCount: txResult.snapshotFileCount,
|
|
542
|
+
snapshotSourceBytes: txResult.snapshotSourceBytes,
|
|
543
|
+
snapshotWorkspaceBytes: txResult.snapshotWorkspaceBytes,
|
|
544
|
+
snapshotCopyStrategy: txResult.snapshotCopyStrategy,
|
|
545
|
+
snapshotPrepareMs: txResult.snapshotPrepareMs,
|
|
396
546
|
transactionalSkipReason: skipReason,
|
|
547
|
+
transactionalSkipDetail: txResult.skipDetail,
|
|
397
548
|
recoveryFailClosed: true,
|
|
398
549
|
};
|
|
399
550
|
}
|
|
@@ -473,20 +624,19 @@ async function consumeCapabilityGrantIfUsed(ctx, deps, result) {
|
|
|
473
624
|
result.authorizationDecision?.matchedRule !== 'grant.exact') {
|
|
474
625
|
return true;
|
|
475
626
|
}
|
|
476
|
-
|
|
477
|
-
if (!request) {
|
|
627
|
+
if (!canConsumeCapabilityGrantLease(result)) {
|
|
478
628
|
return false;
|
|
479
629
|
}
|
|
630
|
+
const requests = result.capabilityRequests ?? [];
|
|
631
|
+
if (!requests.length) {
|
|
632
|
+
return true;
|
|
633
|
+
}
|
|
480
634
|
const consumed = await mutateApprovalStateWithRetry({
|
|
481
635
|
load: () => deps.loadApprovals(ctx, 'approved-approvals.json'),
|
|
482
636
|
write: (filePath, state) => deps.writeApprovals(filePath, state),
|
|
483
637
|
mutate: (state) => {
|
|
484
638
|
const compacted = compactApprovals(state);
|
|
485
|
-
const
|
|
486
|
-
if (!grant) {
|
|
487
|
-
return null;
|
|
488
|
-
}
|
|
489
|
-
const lease = consumeGrantLease(compacted, grant.grantId);
|
|
639
|
+
const lease = consumeGrantLeasesForRequests(compacted, requests);
|
|
490
640
|
if (!lease.consumed) {
|
|
491
641
|
return null;
|
|
492
642
|
}
|
|
@@ -496,6 +646,18 @@ async function consumeCapabilityGrantIfUsed(ctx, deps, result) {
|
|
|
496
646
|
return consumed === true;
|
|
497
647
|
}
|
|
498
648
|
async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {}) {
|
|
649
|
+
if (!result.effectPlan) {
|
|
650
|
+
result = {
|
|
651
|
+
...result,
|
|
652
|
+
effectPlan: buildCapabilityEffectPlan({
|
|
653
|
+
actionKind: kind,
|
|
654
|
+
summary: result.normalizedCommand ?? result.summary ?? kind,
|
|
655
|
+
inputFingerprint: result.fingerprint,
|
|
656
|
+
requests: result.capabilityRequests ?? [],
|
|
657
|
+
effectFree: result.reason === 'read_only',
|
|
658
|
+
}),
|
|
659
|
+
};
|
|
660
|
+
}
|
|
499
661
|
const replayContext = buildAuditReplayContext(kind, result, auditExtras.replayAction);
|
|
500
662
|
const actionSnapshot = buildAuditActionSnapshot(kind, result, auditExtras.replayAction);
|
|
501
663
|
const providerId = String(ctx.config.judge?.providerId ?? 'cursor');
|
|
@@ -520,11 +682,92 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
|
|
|
520
682
|
mode: ctx.config.mode,
|
|
521
683
|
schemaVersion: result.axes ? 2 : 1,
|
|
522
684
|
...(result.axes ?? {}),
|
|
523
|
-
...auditExtras.transactionalLayer,
|
|
524
685
|
...capabilityAudit,
|
|
686
|
+
...auditExtras.transactionalLayer,
|
|
525
687
|
...(replayContext ? { replayContext } : {}),
|
|
526
688
|
...(actionSnapshot ? { actionSnapshot } : {}),
|
|
527
689
|
};
|
|
690
|
+
const denyWithPendingApproval = async (failure) => {
|
|
691
|
+
const { approval, created } = await ensurePendingApproval(ctx, deps, kind, result, auditExtras.approvalInput, deriveWorkspaceRootScopeHint({
|
|
692
|
+
result,
|
|
693
|
+
replayAction: auditExtras.replayAction,
|
|
694
|
+
payloadForScopeHint: auditExtras.scopeHintPayload,
|
|
695
|
+
options: auditExtras.classifierOptions ?? {},
|
|
696
|
+
}));
|
|
697
|
+
if (created) {
|
|
698
|
+
await recordGateApprovalAsk(stateDir, result.reason, false);
|
|
699
|
+
}
|
|
700
|
+
let approvalToken;
|
|
701
|
+
try {
|
|
702
|
+
approvalToken = await issueApprovalToken({
|
|
703
|
+
approvalId: approval.approvalId,
|
|
704
|
+
fingerprint: approval.fingerprint,
|
|
705
|
+
repoRoot: approval.repoRoot,
|
|
706
|
+
issuedAt: approval.createdAt,
|
|
707
|
+
expiresAt: approval.expiresAt,
|
|
708
|
+
}, configuredControlPlaneDir(ctx.config));
|
|
709
|
+
}
|
|
710
|
+
catch {
|
|
711
|
+
approvalToken = undefined;
|
|
712
|
+
}
|
|
713
|
+
const denialReason = failure?.reason ?? result.reason;
|
|
714
|
+
if (ctx.config.notifications.webhookUrl || ctx.config.notifications.commandHook) {
|
|
715
|
+
await notifyDeny(ctx.config.notifications, {
|
|
716
|
+
approvalId: approval.approvalId,
|
|
717
|
+
reason: denialReason,
|
|
718
|
+
summary: result.normalizedCommand ?? result.summary ?? '',
|
|
719
|
+
repoRoot: ctx.repoRoot,
|
|
720
|
+
fingerprint: result.fingerprint,
|
|
721
|
+
approvalToken,
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
const failureAuditFields = typeof failure?.auditFields === 'function'
|
|
725
|
+
? failure.auditFields(approval)
|
|
726
|
+
: failure?.auditFields;
|
|
727
|
+
await deps.appendAudit(ctx, {
|
|
728
|
+
...gateBase,
|
|
729
|
+
verdict: failure ? 'deny_pending_approval' : result.verdict,
|
|
730
|
+
reason: denialReason,
|
|
731
|
+
approvalId: approval.approvalId,
|
|
732
|
+
...(failure?.grantBundleFailureReason
|
|
733
|
+
? { grantBundleFailureReason: failure.grantBundleFailureReason }
|
|
734
|
+
: {}),
|
|
735
|
+
...failureAuditFields,
|
|
736
|
+
wouldBlock: true,
|
|
737
|
+
permission: 'deny',
|
|
738
|
+
});
|
|
739
|
+
const adapter = adapterIdFromContext(ctx);
|
|
740
|
+
const autoReplayShell = kind === 'shell' && canAutoReplay(ctx.config, kind, adapter);
|
|
741
|
+
return classifyResultToGateVerdict({
|
|
742
|
+
result: failure
|
|
743
|
+
? { ...result, verdict: 'deny_pending_approval', reason: denialReason }
|
|
744
|
+
: result,
|
|
745
|
+
mode: ctx.config.mode,
|
|
746
|
+
permission: 'deny',
|
|
747
|
+
wouldBlock: true,
|
|
748
|
+
approvalId: approval.approvalId,
|
|
749
|
+
user_message: failure?.userMessage(approval.approvalId) ??
|
|
750
|
+
`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.`,
|
|
751
|
+
agent_message: failure?.agentMessage ??
|
|
752
|
+
(autoReplayShell
|
|
753
|
+
? `Belay denied this action as ${result.reason}. Wait for approval; Belay will replay the exact shell action automatically. Do not retry unless replay fails.`
|
|
754
|
+
: `Belay denied this action as ${result.reason}. Wait for approval, then retry the exact same action once.`),
|
|
755
|
+
});
|
|
756
|
+
};
|
|
757
|
+
const denyReplayMismatch = async (approval, mismatchKind, messages) => {
|
|
758
|
+
await discardApprovedApproval(ctx, deps, approval.approvalId);
|
|
759
|
+
return denyWithPendingApproval({
|
|
760
|
+
reason: 'approval_replay_mismatch',
|
|
761
|
+
auditFields: (replacement) => ({
|
|
762
|
+
rejectedApprovalId: approval.approvalId,
|
|
763
|
+
rejectedApprovalCorrelationId: approvalCorrelationId(approval.approvalId),
|
|
764
|
+
replacementApprovalCorrelationId: approvalCorrelationId(replacement.approvalId),
|
|
765
|
+
replayMismatchKind: mismatchKind,
|
|
766
|
+
}),
|
|
767
|
+
userMessage: (approvalId) => `${messages.user} New approval ID: ${approvalId}.`,
|
|
768
|
+
agentMessage: `${messages.agent} Wait for the new exact approval, then retry once.`,
|
|
769
|
+
});
|
|
770
|
+
};
|
|
528
771
|
if (result.reason === TRANSACTIONAL_ALREADY_APPLIED) {
|
|
529
772
|
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
773
|
const agentMessage = 'Belay already applied the observed-safe effects of this shell command in isolation. Do not run it again.';
|
|
@@ -544,7 +787,8 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
|
|
|
544
787
|
agent_message: agentMessage,
|
|
545
788
|
});
|
|
546
789
|
}
|
|
547
|
-
if (
|
|
790
|
+
if (kind !== 'shell' &&
|
|
791
|
+
result.verdict === 'deny_pending_approval' &&
|
|
548
792
|
ctx.config.mode === 'enforce' &&
|
|
549
793
|
!TRANSACTIONAL_APPROVAL_BYPASS_REASONS.has(result.reason)) {
|
|
550
794
|
const standingAllowPath = standingAllowFile(ctx.config, ctx.layout.repoLocalStateDir(ctx.repoRoot));
|
|
@@ -561,9 +805,6 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
|
|
|
561
805
|
verdict: 'allow',
|
|
562
806
|
reason: 'standing_allow',
|
|
563
807
|
standingAllowSource: standingMatch.source,
|
|
564
|
-
...(standingMatch.catalogCommand
|
|
565
|
-
? { standingAllowCatalogCommand: standingMatch.catalogCommand }
|
|
566
|
-
: {}),
|
|
567
808
|
wouldBlock: false,
|
|
568
809
|
permission: 'allow',
|
|
569
810
|
});
|
|
@@ -585,61 +826,47 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
|
|
|
585
826
|
entry.fingerprint === result.fingerprint &&
|
|
586
827
|
entry.repoRoot === ctx.repoRoot);
|
|
587
828
|
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',
|
|
829
|
+
if (matchedApproval?.capabilityRequestHash &&
|
|
830
|
+
(!result.capabilityRequests?.length ||
|
|
831
|
+
matchedApproval.capabilityRequestHash !==
|
|
832
|
+
hashCapabilityRequests(result.capabilityRequests))) {
|
|
833
|
+
return denyReplayMismatch(matchedApproval, 'capability_requests', {
|
|
834
|
+
user: 'Belay denied this action because capability requests changed after approval. Re-approve the exact action or run belay explain.',
|
|
835
|
+
agent: 'Belay denied this action because capability requests changed after approval.',
|
|
599
836
|
});
|
|
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.',
|
|
837
|
+
}
|
|
838
|
+
if (matchedApproval?.effectPlanHash &&
|
|
839
|
+
(!result.effectPlan || matchedApproval.effectPlanHash !== hashEffectPlan(result.effectPlan))) {
|
|
840
|
+
return denyReplayMismatch(matchedApproval, 'effect_plan', {
|
|
841
|
+
user: 'Belay denied this action because the effect plan changed after approval. Re-approve the exact action or run belay explain.',
|
|
842
|
+
agent: 'Belay denied this action because the effect plan changed after approval.',
|
|
612
843
|
});
|
|
613
844
|
}
|
|
614
845
|
if (matchedApproval &&
|
|
615
846
|
auditExtras.replayAction &&
|
|
616
847
|
!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.',
|
|
848
|
+
return denyReplayMismatch(matchedApproval, 'replay_envelope', {
|
|
849
|
+
user: 'Belay denied this action because it does not match the approved replay envelope. Re-approve the exact action or run belay explain.',
|
|
850
|
+
agent: 'Belay denied this action because cwd, tool, or payload changed after approval.',
|
|
637
851
|
});
|
|
638
852
|
}
|
|
639
|
-
approved = await consumeApprovedApproval(ctx, deps, kind, result.fingerprint);
|
|
853
|
+
approved = await consumeApprovedApproval(ctx, deps, kind, result.fingerprint, result.capabilityRequests ?? []);
|
|
640
854
|
}
|
|
641
855
|
}
|
|
642
|
-
if (approved) {
|
|
856
|
+
if (approved?.status === 'invalid_bundle') {
|
|
857
|
+
return denyWithPendingApproval({
|
|
858
|
+
reason: 'capability_grant_unavailable',
|
|
859
|
+
grantBundleFailureReason: approved.reason,
|
|
860
|
+
auditFields: (replacement) => ({
|
|
861
|
+
rejectedApprovalId: approved.approval.approvalId,
|
|
862
|
+
rejectedApprovalCorrelationId: approvalCorrelationId(approved.approval.approvalId),
|
|
863
|
+
replacementApprovalCorrelationId: approvalCorrelationId(replacement.approvalId),
|
|
864
|
+
}),
|
|
865
|
+
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.`,
|
|
866
|
+
agentMessage: `Belay denied this action because the approved capability bundle failed exact validation (${approved.reason}). Wait for the new exact approval, then retry once.`,
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
if (approved?.status === 'consumed') {
|
|
643
870
|
if (approved.firstExecution) {
|
|
644
871
|
await recordGateApprovalAsk(stateDir, result.reason, true);
|
|
645
872
|
}
|
|
@@ -648,6 +875,7 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
|
|
|
648
875
|
verdict: 'allow',
|
|
649
876
|
reason: 'approved_once',
|
|
650
877
|
approvalId: approved.approval.approvalId,
|
|
878
|
+
authorizationMode: approved.approval.grantBundleVersion === 1 ? 'exact_bundle' : 'legacy_approval',
|
|
651
879
|
wouldBlock: false,
|
|
652
880
|
permission: 'allow',
|
|
653
881
|
});
|
|
@@ -740,61 +968,33 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
|
|
|
740
968
|
agent_message: denyMessages.agent_message,
|
|
741
969
|
});
|
|
742
970
|
}
|
|
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,
|
|
971
|
+
if (result.reason === FILE_CHECKPOINT_ISOLATION_UNAVAILABLE) {
|
|
972
|
+
const userMessage = 'Belay cannot run file_checkpoint because attested workspace isolation is unavailable. Run `belay session start`, then retry.';
|
|
973
|
+
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.';
|
|
974
|
+
await deps.appendAudit(ctx, {
|
|
975
|
+
...gateBase,
|
|
976
|
+
verdict: result.verdict,
|
|
768
977
|
reason: result.reason,
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
978
|
+
wouldBlock: true,
|
|
979
|
+
permission: 'deny',
|
|
980
|
+
});
|
|
981
|
+
return classifyResultToGateVerdict({
|
|
982
|
+
result,
|
|
983
|
+
mode: ctx.config.mode,
|
|
984
|
+
permission: 'deny',
|
|
985
|
+
wouldBlock: true,
|
|
986
|
+
user_message: userMessage,
|
|
987
|
+
agent_message: agentMessage,
|
|
773
988
|
});
|
|
774
989
|
}
|
|
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
|
-
});
|
|
990
|
+
return denyWithPendingApproval();
|
|
792
991
|
}
|
|
793
992
|
export async function processApprovalPrompt(ctx, deps, prompt) {
|
|
794
993
|
const approvalId = approvalCommandMatch(prompt, ctx.config.tokenPrefix);
|
|
795
994
|
if (!approvalId) {
|
|
796
995
|
return { continue: true };
|
|
797
996
|
}
|
|
997
|
+
const hasFollowupInstruction = prompt.split(/\r?\n/).filter((line) => line.trim().length > 0).length > 1;
|
|
798
998
|
const adapter = adapterIdFromContext(ctx);
|
|
799
999
|
if (ctx.config.approvalSigning.required) {
|
|
800
1000
|
const message = `Signed approval token required for ${approvalId}. Editor prompt approval is disabled in this configuration. ` +
|
|
@@ -837,62 +1037,68 @@ export async function processApprovalPrompt(ctx, deps, prompt) {
|
|
|
837
1037
|
};
|
|
838
1038
|
}
|
|
839
1039
|
const replay = recorded.approval ? buildReplayHint(ctx.config, recorded.approval, adapter) : null;
|
|
840
|
-
const
|
|
841
|
-
replay?.kind === 'shell' &&
|
|
1040
|
+
const shouldReplayApprovedShell = replay?.kind === 'shell' &&
|
|
842
1041
|
replay.autoReplay === true &&
|
|
843
1042
|
Boolean(recorded.approval?.input) &&
|
|
844
1043
|
(!process.env.VITEST || process.env.BELAY_TEST_APPROVAL_REPLAY === '1');
|
|
845
|
-
if (
|
|
1044
|
+
if (shouldReplayApprovedShell && recorded.approval) {
|
|
846
1045
|
const approvalStore = gateApprovalStoreFromDeps({
|
|
847
1046
|
loadApprovals: (fileName) => deps.loadApprovals(ctx, fileName),
|
|
848
1047
|
writeApprovals: (filePath, state) => deps.writeApprovals(filePath, state),
|
|
849
1048
|
});
|
|
1049
|
+
let claimed;
|
|
1050
|
+
try {
|
|
1051
|
+
claimed = await claimApprovedForReplay({ approvalId, store: approvalStore });
|
|
1052
|
+
}
|
|
1053
|
+
catch {
|
|
1054
|
+
return {
|
|
1055
|
+
continue: false,
|
|
1056
|
+
user_message: `Belay approval ${approvalId} could not be claimed for one-step replay. ` +
|
|
1057
|
+
'The shell action was not started.',
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
if (!claimed) {
|
|
1061
|
+
return {
|
|
1062
|
+
continue: false,
|
|
1063
|
+
user_message: `Belay approval ${approvalId} could not be claimed for one-step replay.`,
|
|
1064
|
+
};
|
|
1065
|
+
}
|
|
850
1066
|
let replayResult;
|
|
851
1067
|
try {
|
|
852
|
-
replayResult = await
|
|
1068
|
+
replayResult = await deps.replayApprovedShell(ctx, claimed.input ?? '', claimed.cwd ?? ctx.repoRoot, getExecutionLeaseMs(ctx.config));
|
|
853
1069
|
}
|
|
854
1070
|
catch (error) {
|
|
855
|
-
const
|
|
856
|
-
await
|
|
1071
|
+
const cleanupUnconfirmed = isBoundaryCleanupError(error);
|
|
1072
|
+
const auditRecorded = await appendReplayAuditSafely(ctx, deps, {
|
|
857
1073
|
event: 'approval',
|
|
858
1074
|
kind: 'approval',
|
|
859
1075
|
verdict: 'allow',
|
|
860
1076
|
approvalId,
|
|
861
|
-
reason:
|
|
1077
|
+
reason: cleanupUnconfirmed
|
|
1078
|
+
? 'approval_replay_cleanup_unconfirmed'
|
|
1079
|
+
: 'approval_replay_error',
|
|
862
1080
|
summary: recorded.approval.input ?? recorded.approval.summary ?? prompt,
|
|
863
1081
|
});
|
|
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
|
-
});
|
|
1082
|
+
if (cleanupUnconfirmed) {
|
|
1083
|
+
const resourceId = safeBoundaryCleanupResourceId(error);
|
|
888
1084
|
return {
|
|
889
1085
|
continue: false,
|
|
890
|
-
user_message: `Belay approval recorded for ${approvalId}
|
|
891
|
-
|
|
892
|
-
|
|
1086
|
+
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.` +
|
|
1087
|
+
(resourceId ? ` Container: ${resourceId}.` : '') +
|
|
1088
|
+
' Inspect Docker and remove the container manually if it is still present. ' +
|
|
1089
|
+
'The one-shot approval was consumed and was not re-armed.' +
|
|
1090
|
+
(auditRecorded ? '' : REPLAY_AUDIT_FAILURE_NOTE),
|
|
893
1091
|
};
|
|
894
1092
|
}
|
|
895
|
-
|
|
1093
|
+
return {
|
|
1094
|
+
continue: false,
|
|
1095
|
+
user_message: `Belay approval recorded for ${approvalId}, but one-step shell replay could not start. ` +
|
|
1096
|
+
'The one-shot approval was consumed before replay and was not re-armed.' +
|
|
1097
|
+
(auditRecorded ? '' : REPLAY_AUDIT_FAILURE_NOTE),
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
if (replayResult.exitCode === 0 && !replayResult.timedOut) {
|
|
1101
|
+
const auditRecorded = await appendReplayAuditSafely(ctx, deps, {
|
|
896
1102
|
event: 'approval',
|
|
897
1103
|
kind: 'approval',
|
|
898
1104
|
verdict: 'allow',
|
|
@@ -901,12 +1107,12 @@ export async function processApprovalPrompt(ctx, deps, prompt) {
|
|
|
901
1107
|
summary: recorded.approval.input ?? recorded.approval.summary ?? prompt,
|
|
902
1108
|
});
|
|
903
1109
|
return {
|
|
904
|
-
continue:
|
|
1110
|
+
continue: true,
|
|
905
1111
|
user_message: `Belay approval recorded for ${approvalId}. One-step shell replay succeeded; no manual retry required.` +
|
|
906
|
-
(
|
|
1112
|
+
(auditRecorded ? '' : REPLAY_AUDIT_FAILURE_NOTE),
|
|
907
1113
|
};
|
|
908
1114
|
}
|
|
909
|
-
await
|
|
1115
|
+
const auditRecorded = await appendReplayAuditSafely(ctx, deps, {
|
|
910
1116
|
event: 'approval',
|
|
911
1117
|
kind: 'approval',
|
|
912
1118
|
verdict: 'allow',
|
|
@@ -914,16 +1120,18 @@ export async function processApprovalPrompt(ctx, deps, prompt) {
|
|
|
914
1120
|
reason: 'approval_replay_failed',
|
|
915
1121
|
summary: recorded.approval.input ?? recorded.approval.summary ?? prompt,
|
|
916
1122
|
});
|
|
917
|
-
const
|
|
1123
|
+
const failureSummary = replayResult.timedOut
|
|
1124
|
+
? ' One-step shell replay timed out.'
|
|
1125
|
+
: ` One-step shell replay failed (exit ${replayResult.exitCode}).`;
|
|
918
1126
|
return {
|
|
919
1127
|
continue: false,
|
|
920
|
-
user_message: `Belay approval recorded for ${approvalId}
|
|
921
|
-
|
|
922
|
-
(
|
|
1128
|
+
user_message: `Belay approval recorded for ${approvalId}.${failureSummary} ` +
|
|
1129
|
+
'The one-shot approval was consumed before replay and was not re-armed.' +
|
|
1130
|
+
(auditRecorded ? '' : REPLAY_AUDIT_FAILURE_NOTE),
|
|
923
1131
|
};
|
|
924
1132
|
}
|
|
925
1133
|
return {
|
|
926
|
-
continue:
|
|
1134
|
+
continue: replay !== null || hasFollowupInstruction,
|
|
927
1135
|
user_message: recorded.message,
|
|
928
1136
|
...(replay ? { replay } : {}),
|
|
929
1137
|
};
|
|
@@ -951,15 +1159,18 @@ export function gateVerdictToClaudePreToolUseResponse(verdict) {
|
|
|
951
1159
|
}
|
|
952
1160
|
export function gateVerdictToClaudeUserPromptResponse(verdict) {
|
|
953
1161
|
if (verdict.continue) {
|
|
954
|
-
return
|
|
1162
|
+
return verdict.user_message
|
|
1163
|
+
? {
|
|
1164
|
+
hookSpecificOutput: {
|
|
1165
|
+
hookEventName: 'UserPromptSubmit',
|
|
1166
|
+
additionalContext: verdict.user_message,
|
|
1167
|
+
},
|
|
1168
|
+
}
|
|
1169
|
+
: {};
|
|
955
1170
|
}
|
|
956
1171
|
return {
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
continue: false,
|
|
960
|
-
user_message: verdict.user_message,
|
|
961
|
-
...(verdict.replay ? { replay: verdict.replay } : {}),
|
|
962
|
-
},
|
|
1172
|
+
decision: 'block',
|
|
1173
|
+
reason: verdict.user_message,
|
|
963
1174
|
};
|
|
964
1175
|
}
|
|
965
1176
|
// Codex PreToolUse deny contract is identical to Claude's
|
|
@@ -970,7 +1181,14 @@ export function gateVerdictToCodexPreToolUseResponse(verdict) {
|
|
|
970
1181
|
// Codex UserPromptSubmit blocks via `decision: "block"` (per developers.openai.com/codex/hooks).
|
|
971
1182
|
export function gateVerdictToCodexUserPromptResponse(verdict) {
|
|
972
1183
|
if (verdict.continue) {
|
|
973
|
-
return
|
|
1184
|
+
return verdict.user_message
|
|
1185
|
+
? {
|
|
1186
|
+
hookSpecificOutput: {
|
|
1187
|
+
hookEventName: 'UserPromptSubmit',
|
|
1188
|
+
additionalContext: verdict.user_message,
|
|
1189
|
+
},
|
|
1190
|
+
}
|
|
1191
|
+
: {};
|
|
974
1192
|
}
|
|
975
1193
|
return {
|
|
976
1194
|
decision: 'block',
|