@guilz-dev/belay 0.8.1 → 0.9.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 +60 -10
- package/dist/adapters/shared/gate-runtime.d.ts +5 -0
- package/dist/adapters/shared/gate-runtime.js +237 -33
- package/dist/bundle/claude-runtime.mjs +4082 -1230
- package/dist/bundle/codex-runtime.mjs +4057 -1205
- package/dist/bundle/cursor-runtime.mjs +4057 -1205
- package/dist/commands/classify-for-report.js +5 -0
- package/dist/commands/doctor.js +73 -6
- package/dist/commands/explain.js +3 -2
- package/dist/commands/metrics.js +42 -0
- package/dist/commands/recovery-checkpoints.d.ts +8 -1
- package/dist/commands/recovery-checkpoints.js +44 -11
- package/dist/commands/session.d.ts +5 -0
- package/dist/commands/session.js +8 -1
- package/dist/config-io.js +2 -2
- package/dist/conformance/contained-execution-guarantee.d.ts +121 -0
- package/dist/conformance/contained-execution-guarantee.js +121 -0
- package/dist/conformance/guarantee-table.js +7 -0
- package/dist/core/audit-io.js +8 -0
- package/dist/core/audit-metrics.d.ts +11 -0
- package/dist/core/audit-metrics.js +14 -0
- package/dist/core/audit-recovery-metrics.d.ts +33 -0
- package/dist/core/audit-recovery-metrics.js +146 -0
- package/dist/core/audit-types.d.ts +7 -1
- package/dist/core/audit-types.js +1 -1
- package/dist/core/bounded-output.d.ts +11 -0
- package/dist/core/bounded-output.js +26 -0
- package/dist/core/capability/attestation.d.ts +56 -0
- package/dist/core/capability/attestation.js +110 -3
- package/dist/core/capability/boundary-session.d.ts +5 -0
- package/dist/core/capability/boundary-session.js +38 -1
- package/dist/core/capability/index.d.ts +1 -1
- package/dist/core/capability/index.js +1 -1
- package/dist/core/config.d.ts +18 -0
- package/dist/core/config.js +89 -10
- package/dist/core/contained-execution/docker-policy.d.ts +18 -0
- package/dist/core/contained-execution/docker-policy.js +97 -0
- package/dist/core/contained-execution/docker.d.ts +223 -0
- package/dist/core/contained-execution/docker.js +846 -0
- package/dist/core/contained-execution/eligibility.d.ts +9 -0
- package/dist/core/contained-execution/eligibility.js +103 -0
- package/dist/core/contained-execution/failure.d.ts +18 -0
- package/dist/core/contained-execution/failure.js +105 -0
- package/dist/core/contained-execution/mirror.d.ts +54 -0
- package/dist/core/contained-execution/mirror.js +587 -0
- package/dist/core/contained-execution/policy.d.ts +10 -0
- package/dist/core/contained-execution/policy.js +20 -0
- package/dist/core/effect-ir/index.d.ts +1 -1
- package/dist/core/effect-ir/index.js +1 -1
- package/dist/core/effect-ir/normalize.d.ts +5 -0
- package/dist/core/effect-ir/normalize.js +17 -7
- package/dist/core/effect-ir/shell-build.js +2 -14
- package/dist/core/effect-ir/shell-lower.js +100 -25
- package/dist/core/gate-contract.d.ts +4 -1
- package/dist/core/gate-contract.js +2 -0
- package/dist/core/process-runner.d.ts +10 -3
- package/dist/core/process-runner.js +66 -10
- package/dist/core/recovery/checkpoint.d.ts +3 -0
- package/dist/core/recovery/checkpoint.js +33 -4
- package/dist/core/recovery/types.d.ts +1 -0
- package/dist/core/scrub.d.ts +17 -0
- package/dist/core/scrub.js +499 -5
- package/dist/core/standing-allow.d.ts +0 -20
- package/dist/core/standing-allow.js +0 -71
- package/dist/core/transactional/apply-observed-changes.d.ts +1 -0
- package/dist/core/transactional/apply-observed-changes.js +11 -0
- package/dist/core/transactional/backend-selector.d.ts +2 -1
- package/dist/core/transactional/backend-selector.js +32 -29
- package/dist/core/transactional/backend.d.ts +3 -0
- package/dist/core/transactional/file-checkpoint-backend.js +242 -54
- package/dist/core/transactional/file-checkpoint-git.d.ts +3 -0
- package/dist/core/transactional/file-checkpoint-git.js +53 -0
- package/dist/core/transactional/file-checkpoint-isolation.d.ts +3 -0
- package/dist/core/transactional/file-checkpoint-isolation.js +16 -0
- package/dist/core/transactional/git-worktree-backend.js +3 -0
- package/dist/core/transactional/git-worktree.d.ts +1 -0
- package/dist/core/transactional/git-worktree.js +1 -0
- package/dist/core/transactional/runner.js +59 -13
- package/dist/core/types.d.ts +13 -0
- package/dist/core/verdict/parser.d.ts +10 -0
- package/dist/core/verdict/parser.js +271 -40
- package/dist/core/verdict/shell-semantics.js +21 -0
- package/dist/corpus/adversarial-probe.d.ts +1 -1
- package/dist/corpus/adversarial-probe.js +3 -3
- package/dist/corpus/benign-probe-cores.d.ts +6 -0
- package/dist/corpus/{must-allow-commands.js → benign-probe-cores.js} +8 -2
- package/dist/corpus/coverage-compare.d.ts +36 -0
- package/dist/corpus/coverage-compare.js +197 -0
- package/dist/corpus/coverage-contexts.d.ts +17 -0
- package/dist/corpus/coverage-contexts.js +88 -0
- package/dist/corpus/coverage-matrix.d.ts +37 -0
- package/dist/corpus/coverage-matrix.js +159 -0
- package/dist/corpus/coverage-probe.d.ts +91 -0
- package/dist/corpus/coverage-probe.js +341 -0
- package/dist/corpus/evaluate.d.ts +1 -1
- package/dist/corpus/evaluate.js +1 -1
- package/dist/corpus/structural-fixture-root.d.ts +2 -0
- package/dist/corpus/structural-fixture-root.js +5 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -1
- package/skills/belay/SKILL.md +19 -0
- package/skills/belay/belay-status.md +5 -0
- package/dist/corpus/must-allow-commands.d.ts +0 -6
package/README.md
CHANGED
|
@@ -135,12 +135,13 @@ Belay is a layered hook gate, not a static denylist. Higher layers are opt-in.
|
|
|
135
135
|
| Layer | Role | Enabled by |
|
|
136
136
|
|-------|------|------------|
|
|
137
137
|
| **L1** Containment | Egress proxy, sandbox capability broker | `egress` / `sandbox` config |
|
|
138
|
-
| **L2** Observation | Transactional
|
|
138
|
+
| **L2** Observation | Transactional mirror and durable checkpoint observation | `policy.transactional` |
|
|
139
139
|
| **L3** Prediction | Policy rules + command heuristics | default |
|
|
140
140
|
| **L4** Approval | Human one-shot / scoped approvals | default |
|
|
141
141
|
|
|
142
142
|
- Normalized shell authorization uses only canonical `EffectPlan` requirements.
|
|
143
|
-
Command lists, legacy overrides, corpus labels, and
|
|
143
|
+
Command lists, legacy overrides, corpus labels, and legacy standing-allow records for shell,
|
|
144
|
+
tool, and subagent actions are inert at runtime.
|
|
144
145
|
- Payload-free reads are `allow`; reversible repository-local writes (including implicit
|
|
145
146
|
download output) are `allow_flagged`. Outside-repository writes, external mutation,
|
|
146
147
|
explicit payload/file/secret sends, high-stakes effects, and partial/indeterminate plans
|
|
@@ -148,6 +149,52 @@ Belay is a layered hook gate, not a static denylist. Higher layers are opt-in.
|
|
|
148
149
|
- Adversarial resistance requires the full L1 stack:
|
|
149
150
|
`belay init --preset l1-full-recommended`, verified with `belay sandbox status`.
|
|
150
151
|
|
|
152
|
+
### Contained unknown execution (opt-in)
|
|
153
|
+
|
|
154
|
+
For an eligible repository-local `unknown_local_effect`, an operator may opt into one disposable
|
|
155
|
+
Docker run instead of taking the ordinary approval path. No command allowlist is involved:
|
|
156
|
+
EffectPlan remains the shell authority, and executable names, prefixes, fingerprints, corpus
|
|
157
|
+
membership, Rails, RSpec, and fictional command names all use the same effect-based eligibility
|
|
158
|
+
route. The original host command is blocked after mediation.
|
|
159
|
+
|
|
160
|
+
Enable it only with an operator-provisioned image and explicit local Docker substrate:
|
|
161
|
+
|
|
162
|
+
```json
|
|
163
|
+
{
|
|
164
|
+
"sandbox": {
|
|
165
|
+
"enabled": true,
|
|
166
|
+
"runtime": "container",
|
|
167
|
+
"containedExecution": {
|
|
168
|
+
"enabled": true,
|
|
169
|
+
"image": "registry.example/contained-runner:2026-08-18",
|
|
170
|
+
"dockerExecutable": "/usr/local/bin/docker",
|
|
171
|
+
"dockerHost": "unix:///var/run/docker.sock"
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Run `belay session start` after changing this configuration. It binds a fresh signed capability to
|
|
178
|
+
the configured Docker executable, local Unix daemon, and immutable image ID; image-tag drift,
|
|
179
|
+
tampering, stale proof, or config changes require another session start. v1 never builds or pulls
|
|
180
|
+
an image automatically and always uses network `none`.
|
|
181
|
+
|
|
182
|
+
Belay copies the current workspace to a bounded metadata-free mirror, mounts only that mirror in
|
|
183
|
+
the container at the original guest path, and discards it after one run. Workspace changes are
|
|
184
|
+
discarded; there is no diff/apply or host replay. Guest output is scrubbed and retained only as
|
|
185
|
+
16 KiB tails. This boundary scrub is mandatory even when ordinary audit `redaction.*` options are
|
|
186
|
+
disabled. A nonzero guest exit is reported as contained failure and does not create an approval.
|
|
187
|
+
|
|
188
|
+
In audit mode Belay's contained route reports `wouldMediate` and performs no contained execution:
|
|
189
|
+
it reads no attestation and runs neither a mirror nor a container. The gate returns `allow`, so the
|
|
190
|
+
host hook delegates the original invocation as ordinary audit pass-through; it is not a contained
|
|
191
|
+
route host replay.
|
|
192
|
+
In enforce mode, only pre-execution Docker substrate or daemon unavailability returns to the normal
|
|
193
|
+
approval path. Missing, stale, or tampered attestation/capability; missing or mismatched image;
|
|
194
|
+
mirror/lease failure; create/inspect/start failure; timeout; and cleanup uncertainty fail closed
|
|
195
|
+
without approval. This route is not L1-full;
|
|
196
|
+
see the [guarantee table](./docs/guarantee-table.md) for its exact boundary and residual risks.
|
|
197
|
+
|
|
151
198
|
## Install options
|
|
152
199
|
|
|
153
200
|
```bash
|
|
@@ -242,18 +289,21 @@ Notable settings:
|
|
|
242
289
|
- **`classifier.strictChains: true`** (default) — scans every `&&`, `|`, and `;`
|
|
243
290
|
segment into the EffectPlan and keeps the strictest policy projection. Legacy
|
|
244
291
|
`overrides.allow` / `overrides.external` lists are accepted only for config
|
|
245
|
-
compatibility; shell authorization ignores them
|
|
246
|
-
|
|
292
|
+
compatibility; shell authorization ignores them. **`belay doctor` fails** when either
|
|
293
|
+
list is non-empty (ADR-005). Remove legacy lists; improve EffectPlan semantics or use
|
|
294
|
+
one-shot approval instead.
|
|
247
295
|
- **`controlPlane.enabled: true`** — stores approval state under
|
|
248
296
|
`~/.config/belay/` (or `XDG_CONFIG_HOME/belay`), shared across repos for the
|
|
249
297
|
current OS user. `upgrade` migrates repo-local approvals in; disabling merges
|
|
250
298
|
them back. File-mutation tools and shell redirects cannot write control-plane
|
|
251
299
|
paths while it is enabled.
|
|
252
|
-
- **`policy.transactional.checkpoint.enabled: true`** — after enabling the
|
|
253
|
-
|
|
254
|
-
`
|
|
255
|
-
|
|
256
|
-
|
|
300
|
+
- **`policy.transactional.checkpoint.enabled: true`** — after enabling the transactional
|
|
301
|
+
runner, persist repo-local pre-images and expose them through `belay recover list`. Clean Git
|
|
302
|
+
uses `git_worktree`; dirty Git and non-Git directories use `file_checkpoint` when separately
|
|
303
|
+
enabled (`policy.transactional.fileCheckpoint.enabled` and, for non-Git roots,
|
|
304
|
+
`allowNonGit: true`) with an attested workspace-isolating boundary. Restore is conflict-checked
|
|
305
|
+
and always requires a signed out-of-band, exact one-shot approval. Network, remote Git,
|
|
306
|
+
databases, processes, and repo-external effects are outside this guarantee.
|
|
257
307
|
- **Cloud judge** — configure with `belay config` (interactive) or `belay config set judge.providerId <id>`.
|
|
258
308
|
Providers: `ollama`, `codex`, `claude`, `cursor`. **Provider** is the vendor/service
|
|
259
309
|
(`judge.providerId`); **driver** is the API compatibility layer (`judge.provider`:
|
|
@@ -297,7 +347,7 @@ belay status # show install scope / skill-only state
|
|
|
297
347
|
belay metrics # would-block / verdict summary
|
|
298
348
|
belay report # audit log report
|
|
299
349
|
belay recover [advice] [--command "rm important.ts"] # advisory candidates only
|
|
300
|
-
belay recover status # checkpoint backend and state counts
|
|
350
|
+
belay recover status # checkpoint backend, eligibility, and state counts
|
|
301
351
|
belay recover list # proven repo-local recovery points
|
|
302
352
|
belay recover show <checkpoint-id>
|
|
303
353
|
belay recover apply <checkpoint-id> # signed OOB exact one-shot approval required
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { type ApprovalReplayHint } from '../../core/approval-replay.js';
|
|
2
|
+
import { type ExecuteContainedDockerParams, executeContainedDocker } from '../../core/contained-execution/docker.js';
|
|
3
|
+
import { type ContainedExecutionMirrorHandle, type ContainedExecutionMirrorOptions } from '../../core/contained-execution/mirror.js';
|
|
2
4
|
import { type GatedActionKind, type GatePermissionResponse, type GateVerdict } from '../../core/gate-contract.js';
|
|
3
5
|
import { GateNormalizationError } from '../../core/gate-engine.js';
|
|
4
6
|
import { type BelayConfigV3 } from '../../core/index.js';
|
|
@@ -30,6 +32,9 @@ export interface GateRuntimeDeps {
|
|
|
30
32
|
stdout?: string;
|
|
31
33
|
stderr?: string;
|
|
32
34
|
}>;
|
|
35
|
+
readSignedAttestation: (filePath: string) => Promise<unknown>;
|
|
36
|
+
withContainedExecutionMirror: <T>(options: ContainedExecutionMirrorOptions, operation: (mirror: ContainedExecutionMirrorHandle) => Promise<T>) => Promise<T>;
|
|
37
|
+
executeContainedDocker: (params: ExecuteContainedDockerParams) => ReturnType<typeof executeContainedDocker>;
|
|
33
38
|
}
|
|
34
39
|
export declare function createDefaultGateRuntimeDeps(): GateRuntimeDeps;
|
|
35
40
|
export declare function resolveGateConfig(ctx: Pick<GateRuntimeContext, 'layout' | 'repoRoot' | 'configPath'>, deps: GateRuntimeDeps): Promise<BelayConfigV3>;
|
|
@@ -7,11 +7,13 @@ import { buildReplayHint, buildRetryInstructionForConfig, canAutoReplay, getExec
|
|
|
7
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
|
+
import { boundedUtf8Tail } from '../../core/bounded-output.js';
|
|
10
11
|
import { mutateApprovalStateWithRetry } from '../../core/capability/approval-state-mutation.js';
|
|
11
12
|
import { APPROVAL_STATE_VERSION_V3 } from '../../core/capability/approval-v3.js';
|
|
13
|
+
import { readSignedAttestationFile } from '../../core/capability/boundary-attestation-sign.js';
|
|
12
14
|
import { isEgressProxyActive } from '../../core/capability/boundary-egress.js';
|
|
13
15
|
import { isBoundaryCleanupError, safeBoundaryCleanupResourceId, } from '../../core/capability/boundary-run.js';
|
|
14
|
-
import { resolveBoundaryDriverContext, runBoundaryAgentCommand, } from '../../core/capability/boundary-session.js';
|
|
16
|
+
import { boundaryAttestationPath, resolveBoundaryDriverContext, runBoundaryAgentCommand, } from '../../core/capability/boundary-session.js';
|
|
15
17
|
import { hashCapabilityRequests } from '../../core/capability/capability-request-hash.js';
|
|
16
18
|
import { recordGateApprovalAsk, scheduleGateShadowAudit, } from '../../core/capability/gate-shadow-audit.js';
|
|
17
19
|
import { canConsumeCapabilityGrantLease } from '../../core/capability/grant-consumption.js';
|
|
@@ -19,17 +21,21 @@ import { approvalGrantBundleExhausted, consumeApprovedRecordGrantBundle, consume
|
|
|
19
21
|
import { loadClassifierAuthorization } from '../../core/capability/grant-loader.js';
|
|
20
22
|
import { collectOutsideRepoPaths, collectOutsideRepoPathsFromToolPayload, fsScopeAllowlistPath, isCapabilityBrokerDemotionActive, loadFsScopeAllowlistSync, loadTrustedWorkspaceRootsSync, shouldSkipBrokerApprovedOnce, shouldSkipBrokerApprovedRecord, trustedWorkspaceRootsPath, validateTrustedWorkspaceRootCandidate, } from '../../core/capability/index.js';
|
|
21
23
|
import { resolveLayeredConfig, teamConfigPath } from '../../core/config-layers.js';
|
|
24
|
+
import { ContainedDockerBoundaryUnavailableError, executeContainedDocker, } from '../../core/contained-execution/docker.js';
|
|
25
|
+
import { isContainedUnknownExecutionEligible } from '../../core/contained-execution/eligibility.js';
|
|
26
|
+
import { ContainedExecutionFailureError } from '../../core/contained-execution/failure.js';
|
|
27
|
+
import { withContainedExecutionMirror, } from '../../core/contained-execution/mirror.js';
|
|
28
|
+
import { CONTAINED_EXECUTION_OUTPUT_SCRUB_OPTIONS, isContainedExecutionApprovalFallbackReason, } from '../../core/contained-execution/policy.js';
|
|
22
29
|
import { effectPlanAuditFields, hashEffectPlan } from '../../core/effect-ir/audit.js';
|
|
23
30
|
import { buildCapabilityEffectPlan } from '../../core/effect-ir/build.js';
|
|
24
31
|
import { classifyResultToGateVerdict, unnormalizedGateVerdict, } from '../../core/gate-contract.js';
|
|
25
32
|
import { classifyGatedActionAsync, extractAgentAssessment, GateNormalizationError, gateEnabledForAction, normalizeGatedAction, } from '../../core/gate-engine.js';
|
|
26
|
-
import { approvalCommandMatch, approvedApprovalsFile, belayStateDir, canonicalStringify, classifierOptionsFromConfig, configuredControlPlaneDir, hashValue, pendingApprovalsFile, resolveControlPlaneDir, scrubOptionsFromConfig, scrubValue, toolFingerprint, } from '../../core/index.js';
|
|
33
|
+
import { approvalCommandMatch, approvedApprovalsFile, belayStateDir, canonicalStringify, classifierOptionsFromConfig, configuredControlPlaneDir, hashValue, pendingApprovalsFile, resolveControlPlaneDir, scrubOptionsFromConfig, scrubString, scrubValue, toolFingerprint, } from '../../core/index.js';
|
|
27
34
|
import { extractJudgeFallbackReason, formatJudgeInfrastructureDenyMessage, inferProviderIdFromFallbackReason, isJudgeInfrastructureFailure, } from '../../core/judge-fallback-hints.js';
|
|
28
35
|
import { notifyDeny } from '../../core/notify.js';
|
|
29
36
|
import { canonicalPath } from '../../core/path-utils.js';
|
|
30
37
|
import { recoveryFailClosedResult, recoveryFailReasonFromSkip, } from '../../core/recovery/fail-closed.js';
|
|
31
38
|
import { fingerprintReplayPayload } from '../../core/replay-scrub.js';
|
|
32
|
-
import { loadStandingAllow, resolveStandingAllowMatch, standingAllowFile, } from '../../core/standing-allow.js';
|
|
33
39
|
import { FILE_CHECKPOINT_ISOLATION_UNAVAILABLE, isTransactionalEligible, runTransactionalExecution, TRANSACTIONAL_ALREADY_APPLIED, TRANSACTIONAL_APPROVAL_BYPASS_REASONS, } from '../../core/transactional/index.js';
|
|
34
40
|
import { egressStatus } from '../../services/egress-service.js';
|
|
35
41
|
import { PACKAGE_VERSION } from '../../version.js';
|
|
@@ -51,6 +57,25 @@ function auditProvenance(config) {
|
|
|
51
57
|
configFingerprint: hashValue(canonicalStringify(config)),
|
|
52
58
|
};
|
|
53
59
|
}
|
|
60
|
+
function preservedContainedAuditMetadata(record) {
|
|
61
|
+
const safe = {};
|
|
62
|
+
if (record.wouldMediate === true)
|
|
63
|
+
safe.wouldMediate = true;
|
|
64
|
+
if (typeof record.receiptHash === 'string' && /^[a-f0-9]{64}$/.test(record.receiptHash)) {
|
|
65
|
+
safe.receiptHash = record.receiptHash;
|
|
66
|
+
}
|
|
67
|
+
if (typeof record.imageId === 'string' && /^sha256:[a-f0-9]{64}$/.test(record.imageId)) {
|
|
68
|
+
safe.imageId = record.imageId;
|
|
69
|
+
}
|
|
70
|
+
if (record.mirrorBackend === 'file_copy')
|
|
71
|
+
safe.mirrorBackend = record.mirrorBackend;
|
|
72
|
+
if (record.exitCode === null || Number.isSafeInteger(record.exitCode)) {
|
|
73
|
+
safe.exitCode = record.exitCode;
|
|
74
|
+
}
|
|
75
|
+
if (typeof record.timedOut === 'boolean')
|
|
76
|
+
safe.timedOut = record.timedOut;
|
|
77
|
+
return safe;
|
|
78
|
+
}
|
|
54
79
|
function adapterIdFromContext(ctx) {
|
|
55
80
|
if (ctx.config.adapter === 'cursor' ||
|
|
56
81
|
ctx.config.adapter === 'claude' ||
|
|
@@ -99,7 +124,7 @@ export function createDefaultGateRuntimeDeps() {
|
|
|
99
124
|
delete record.assessment;
|
|
100
125
|
}
|
|
101
126
|
const scrubbed = scrubValue(record, scrubOptionsFromConfig(ctx.config));
|
|
102
|
-
Object.assign(scrubbed, provenance);
|
|
127
|
+
Object.assign(scrubbed, provenance, preservedContainedAuditMetadata(record));
|
|
103
128
|
await writeFile(auditPath, `${JSON.stringify(scrubbed)}\n`, {
|
|
104
129
|
encoding: 'utf8',
|
|
105
130
|
flag: 'a',
|
|
@@ -135,6 +160,9 @@ export function createDefaultGateRuntimeDeps() {
|
|
|
135
160
|
runOptions: { mountReadOnly: false },
|
|
136
161
|
});
|
|
137
162
|
},
|
|
163
|
+
readSignedAttestation: readSignedAttestationFile,
|
|
164
|
+
withContainedExecutionMirror,
|
|
165
|
+
executeContainedDocker,
|
|
138
166
|
};
|
|
139
167
|
}
|
|
140
168
|
export async function resolveGateConfig(ctx, deps) {
|
|
@@ -364,6 +392,197 @@ async function discardApprovedApproval(ctx, deps, approvalId) {
|
|
|
364
392
|
throw new Error(`Failed to discard rejected approval ${approvalId}`);
|
|
365
393
|
}
|
|
366
394
|
}
|
|
395
|
+
function scrubMediatedOutput(value) {
|
|
396
|
+
const source = boundedUtf8Tail(value);
|
|
397
|
+
const scrubbed = boundedUtf8Tail(scrubString(source.value, CONTAINED_EXECUTION_OUTPUT_SCRUB_OPTIONS));
|
|
398
|
+
return { value: scrubbed.value, truncated: source.truncated || scrubbed.truncated };
|
|
399
|
+
}
|
|
400
|
+
function normalizeContainedFailure(error) {
|
|
401
|
+
if (error instanceof ContainedExecutionFailureError) {
|
|
402
|
+
return error;
|
|
403
|
+
}
|
|
404
|
+
if (error?.code === 'ENOENT') {
|
|
405
|
+
return new ContainedExecutionFailureError('contained_execution_capability_invalid', {
|
|
406
|
+
cause: error,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
return new ContainedExecutionFailureError('contained_execution_boundary_failed', { cause: error });
|
|
410
|
+
}
|
|
411
|
+
function containedAuditBase(kind, mode, result) {
|
|
412
|
+
const planFields = effectPlanAuditFields(result.effectPlan);
|
|
413
|
+
return {
|
|
414
|
+
event: gateAuditEventName(kind),
|
|
415
|
+
kind,
|
|
416
|
+
fingerprint: result.fingerprint,
|
|
417
|
+
mode,
|
|
418
|
+
schemaVersion: result.axes ? 2 : 1,
|
|
419
|
+
effectPlanVersion: planFields.effectPlanVersion,
|
|
420
|
+
effectIRHash: planFields.effectIRHash,
|
|
421
|
+
effectPlanSignals: planFields.effectPlanSignals,
|
|
422
|
+
effectPlanOpacity: planFields.effectPlanOpacity,
|
|
423
|
+
effectPlanDisposition: planFields.effectPlanDisposition,
|
|
424
|
+
effectPlanCompleteness: planFields.effectPlanCompleteness,
|
|
425
|
+
wouldMediate: true,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
async function mediateContainedUnknownExecution(params) {
|
|
429
|
+
const { ctx, deps, action, command } = params;
|
|
430
|
+
const result = { ...params.result, wouldMediate: true };
|
|
431
|
+
if (ctx.config.mode === 'audit') {
|
|
432
|
+
await deps.appendAudit(ctx, {
|
|
433
|
+
...containedAuditBase(action.kind, ctx.config.mode, result),
|
|
434
|
+
verdict: result.verdict,
|
|
435
|
+
reason: result.reason,
|
|
436
|
+
wouldBlock: true,
|
|
437
|
+
permission: 'allow',
|
|
438
|
+
});
|
|
439
|
+
return classifyResultToGateVerdict({
|
|
440
|
+
result,
|
|
441
|
+
mode: ctx.config.mode,
|
|
442
|
+
permission: 'allow',
|
|
443
|
+
wouldBlock: true,
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
const controlPlaneDir = configuredControlPlaneDir(ctx.config);
|
|
447
|
+
const attestationPath = boundaryAttestationPath(ctx.repoRoot, ctx.config);
|
|
448
|
+
const protectedRoots = [
|
|
449
|
+
...new Set([
|
|
450
|
+
...protectedArtifactRoots(ctx.layout, ctx.repoRoot, controlPlaneDir),
|
|
451
|
+
attestationPath,
|
|
452
|
+
]),
|
|
453
|
+
];
|
|
454
|
+
const checkpoint = ctx.config.policy.transactional.fileCheckpoint;
|
|
455
|
+
const containedConfig = ctx.config.sandbox.containedExecution;
|
|
456
|
+
if (!containedConfig?.enabled) {
|
|
457
|
+
return null;
|
|
458
|
+
}
|
|
459
|
+
let execution;
|
|
460
|
+
let mirrorBackend;
|
|
461
|
+
try {
|
|
462
|
+
let signedAttestation;
|
|
463
|
+
try {
|
|
464
|
+
signedAttestation = await deps.readSignedAttestation(attestationPath);
|
|
465
|
+
}
|
|
466
|
+
catch (error) {
|
|
467
|
+
throw new ContainedExecutionFailureError('contained_execution_capability_invalid', {
|
|
468
|
+
cause: error,
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
execution = await deps.withContainedExecutionMirror({
|
|
472
|
+
sourceRoot: ctx.repoRoot,
|
|
473
|
+
controlPlaneRoots: protectedRoots,
|
|
474
|
+
limits: {
|
|
475
|
+
maxFiles: checkpoint.maxFiles,
|
|
476
|
+
maxSourceBytes: checkpoint.maxSourceBytes,
|
|
477
|
+
maxWorkspaceBytes: checkpoint.maxWorkspaceBytes,
|
|
478
|
+
prepareTimeoutMs: checkpoint.prepareTimeoutMs,
|
|
479
|
+
},
|
|
480
|
+
}, async (mirror) => {
|
|
481
|
+
mirrorBackend = mirror.backend;
|
|
482
|
+
return deps.executeContainedDocker({
|
|
483
|
+
repoRoot: ctx.repoRoot,
|
|
484
|
+
controlPlaneDir,
|
|
485
|
+
protectedRoots,
|
|
486
|
+
config: containedConfig,
|
|
487
|
+
mirror,
|
|
488
|
+
guestCwd: action.cwd,
|
|
489
|
+
command,
|
|
490
|
+
inputFingerprint: result.fingerprint,
|
|
491
|
+
signedAttestation,
|
|
492
|
+
});
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
catch (error) {
|
|
496
|
+
if (error instanceof ContainedDockerBoundaryUnavailableError &&
|
|
497
|
+
isContainedExecutionApprovalFallbackReason(error.reason)) {
|
|
498
|
+
return null;
|
|
499
|
+
}
|
|
500
|
+
const failure = normalizeContainedFailure(error);
|
|
501
|
+
const reason = failure.code;
|
|
502
|
+
const failedResult = {
|
|
503
|
+
...result,
|
|
504
|
+
verdict: 'deny_pending_approval',
|
|
505
|
+
reason,
|
|
506
|
+
};
|
|
507
|
+
await deps.appendAudit(ctx, {
|
|
508
|
+
...containedAuditBase(action.kind, ctx.config.mode, failedResult),
|
|
509
|
+
verdict: failedResult.verdict,
|
|
510
|
+
reason,
|
|
511
|
+
wouldBlock: true,
|
|
512
|
+
permission: 'deny',
|
|
513
|
+
...(mirrorBackend ? { mirrorBackend } : {}),
|
|
514
|
+
});
|
|
515
|
+
const sessionHint = failure.remediation === 'session-start' ? ' Run `belay session start`, then retry.' : '';
|
|
516
|
+
return classifyResultToGateVerdict({
|
|
517
|
+
result: failedResult,
|
|
518
|
+
mode: ctx.config.mode,
|
|
519
|
+
permission: 'deny',
|
|
520
|
+
wouldBlock: true,
|
|
521
|
+
user_message: `Belay could not confirm safe contained execution (${reason}).${sessionHint}`,
|
|
522
|
+
agent_message: `Belay denied the original host command because contained execution failed closed (${reason}).` +
|
|
523
|
+
sessionHint,
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
const stdout = scrubMediatedOutput(execution.stdout);
|
|
527
|
+
const stderr = scrubMediatedOutput(execution.stderr);
|
|
528
|
+
const mediatedExecution = {
|
|
529
|
+
exitCode: execution.exitCode,
|
|
530
|
+
signal: execution.signal,
|
|
531
|
+
timedOut: execution.timedOut,
|
|
532
|
+
stdout: stdout.value,
|
|
533
|
+
stderr: stderr.value,
|
|
534
|
+
stdoutTruncated: execution.stdoutTruncated || stdout.truncated,
|
|
535
|
+
stderrTruncated: execution.stderrTruncated || stderr.truncated,
|
|
536
|
+
receiptHash: execution.receiptHash,
|
|
537
|
+
workspaceChangesDiscarded: true,
|
|
538
|
+
};
|
|
539
|
+
const reason = execution.exitCode === 0 && !execution.timedOut
|
|
540
|
+
? 'contained_execution_complete'
|
|
541
|
+
: 'contained_execution_failed';
|
|
542
|
+
const mediatedResult = {
|
|
543
|
+
...result,
|
|
544
|
+
verdict: 'allow',
|
|
545
|
+
reason,
|
|
546
|
+
mediatedExecution,
|
|
547
|
+
};
|
|
548
|
+
await deps.appendAudit(ctx, {
|
|
549
|
+
...containedAuditBase(action.kind, ctx.config.mode, mediatedResult),
|
|
550
|
+
verdict: mediatedResult.verdict,
|
|
551
|
+
reason,
|
|
552
|
+
wouldBlock: false,
|
|
553
|
+
permission: 'deny',
|
|
554
|
+
receiptHash: execution.receiptHash,
|
|
555
|
+
imageId: execution.receipt.imageId,
|
|
556
|
+
mirrorBackend,
|
|
557
|
+
exitCode: execution.exitCode,
|
|
558
|
+
timedOut: execution.timedOut,
|
|
559
|
+
});
|
|
560
|
+
const status = execution.timedOut
|
|
561
|
+
? 'timed out'
|
|
562
|
+
: execution.exitCode === 0
|
|
563
|
+
? 'completed successfully'
|
|
564
|
+
: `failed with exit ${execution.exitCode}`;
|
|
565
|
+
const output = [
|
|
566
|
+
mediatedExecution.stdout
|
|
567
|
+
? `stdout tail${mediatedExecution.stdoutTruncated ? ' (truncated)' : ''}:\n${mediatedExecution.stdout}`
|
|
568
|
+
: '',
|
|
569
|
+
mediatedExecution.stderr
|
|
570
|
+
? `stderr tail${mediatedExecution.stderrTruncated ? ' (truncated)' : ''}:\n${mediatedExecution.stderr}`
|
|
571
|
+
: '',
|
|
572
|
+
]
|
|
573
|
+
.filter(Boolean)
|
|
574
|
+
.join('\n');
|
|
575
|
+
return classifyResultToGateVerdict({
|
|
576
|
+
result: mediatedResult,
|
|
577
|
+
mode: ctx.config.mode,
|
|
578
|
+
permission: 'deny',
|
|
579
|
+
wouldBlock: false,
|
|
580
|
+
user_message: `Belay contained execution ${status}; the original host command was not run and workspace changes were discarded. Receipt: ${execution.receiptHash}.` +
|
|
581
|
+
(output ? `\n${output}` : ''),
|
|
582
|
+
agent_message: `Belay already ran this command once inside the contained boundary (${status}). Do not retry it on the host. Receipt: ${execution.receiptHash}.` +
|
|
583
|
+
(output ? `\n${output}` : ''),
|
|
584
|
+
});
|
|
585
|
+
}
|
|
367
586
|
export async function evaluateGatedAction(ctx, deps, params) {
|
|
368
587
|
let action;
|
|
369
588
|
try {
|
|
@@ -462,6 +681,20 @@ export async function evaluateGatedAction(ctx, deps, params) {
|
|
|
462
681
|
egressProxyActive,
|
|
463
682
|
};
|
|
464
683
|
const predicted = await classifyGatedActionAsync(action, ctx.config, enrichedClassifierOptions);
|
|
684
|
+
if (action.kind === 'shell' &&
|
|
685
|
+
action.command &&
|
|
686
|
+
isContainedUnknownExecutionEligible(ctx.config, action, predicted)) {
|
|
687
|
+
const mediated = await mediateContainedUnknownExecution({
|
|
688
|
+
ctx,
|
|
689
|
+
deps,
|
|
690
|
+
action,
|
|
691
|
+
command: action.command,
|
|
692
|
+
result: predicted,
|
|
693
|
+
});
|
|
694
|
+
if (mediated) {
|
|
695
|
+
return mediated;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
465
698
|
let result = predicted;
|
|
466
699
|
let predictedAssessment;
|
|
467
700
|
let observedAssessment;
|
|
@@ -787,35 +1020,6 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
|
|
|
787
1020
|
agent_message: agentMessage,
|
|
788
1021
|
});
|
|
789
1022
|
}
|
|
790
|
-
if (kind !== 'shell' &&
|
|
791
|
-
result.verdict === 'deny_pending_approval' &&
|
|
792
|
-
ctx.config.mode === 'enforce' &&
|
|
793
|
-
!TRANSACTIONAL_APPROVAL_BYPASS_REASONS.has(result.reason)) {
|
|
794
|
-
const standingAllowPath = standingAllowFile(ctx.config, ctx.layout.repoLocalStateDir(ctx.repoRoot));
|
|
795
|
-
const standingState = await loadStandingAllow(standingAllowPath);
|
|
796
|
-
const standingMatch = resolveStandingAllowMatch({
|
|
797
|
-
kind,
|
|
798
|
-
result,
|
|
799
|
-
repoRoot: ctx.repoRoot,
|
|
800
|
-
state: standingState,
|
|
801
|
-
});
|
|
802
|
-
if (standingMatch) {
|
|
803
|
-
await deps.appendAudit(ctx, {
|
|
804
|
-
...gateBase,
|
|
805
|
-
verdict: 'allow',
|
|
806
|
-
reason: 'standing_allow',
|
|
807
|
-
standingAllowSource: standingMatch.source,
|
|
808
|
-
wouldBlock: false,
|
|
809
|
-
permission: 'allow',
|
|
810
|
-
});
|
|
811
|
-
return classifyResultToGateVerdict({
|
|
812
|
-
result: { ...result, verdict: 'allow', reason: 'standing_allow' },
|
|
813
|
-
mode: ctx.config.mode,
|
|
814
|
-
permission: 'allow',
|
|
815
|
-
wouldBlock: false,
|
|
816
|
-
});
|
|
817
|
-
}
|
|
818
|
-
}
|
|
819
1023
|
const brokerActive = isCapabilityBrokerDemotionActive(ctx.config);
|
|
820
1024
|
let approved = null;
|
|
821
1025
|
if (!TRANSACTIONAL_APPROVAL_BYPASS_REASONS.has(result.reason) &&
|