@guilz-dev/belay 0.8.0 → 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.
Files changed (111) hide show
  1. package/README.md +60 -10
  2. package/dist/adapters/shared/gate-runtime.d.ts +5 -0
  3. package/dist/adapters/shared/gate-runtime.js +237 -33
  4. package/dist/bundle/claude-runtime.mjs +4082 -1230
  5. package/dist/bundle/codex-runtime.mjs +4057 -1205
  6. package/dist/bundle/cursor-runtime.mjs +4057 -1205
  7. package/dist/commands/classify-for-report.js +5 -0
  8. package/dist/commands/doctor.js +90 -27
  9. package/dist/commands/explain.js +3 -2
  10. package/dist/commands/metrics.js +75 -4
  11. package/dist/commands/quality.js +2 -6
  12. package/dist/commands/recovery-checkpoints.d.ts +8 -1
  13. package/dist/commands/recovery-checkpoints.js +44 -11
  14. package/dist/commands/session.d.ts +5 -0
  15. package/dist/commands/session.js +8 -1
  16. package/dist/commands/status.js +1 -1
  17. package/dist/config-io.js +2 -2
  18. package/dist/conformance/contained-execution-guarantee.d.ts +121 -0
  19. package/dist/conformance/contained-execution-guarantee.js +121 -0
  20. package/dist/conformance/guarantee-table.js +7 -0
  21. package/dist/core/audit-io.js +8 -0
  22. package/dist/core/audit-metrics.d.ts +34 -0
  23. package/dist/core/audit-metrics.js +87 -15
  24. package/dist/core/audit-recovery-metrics.d.ts +33 -0
  25. package/dist/core/audit-recovery-metrics.js +146 -0
  26. package/dist/core/audit-types.d.ts +7 -1
  27. package/dist/core/audit-types.js +1 -1
  28. package/dist/core/bounded-output.d.ts +11 -0
  29. package/dist/core/bounded-output.js +26 -0
  30. package/dist/core/capability/attestation.d.ts +56 -0
  31. package/dist/core/capability/attestation.js +110 -3
  32. package/dist/core/capability/boundary-session.d.ts +5 -0
  33. package/dist/core/capability/boundary-session.js +38 -1
  34. package/dist/core/capability/index.d.ts +1 -1
  35. package/dist/core/capability/index.js +1 -1
  36. package/dist/core/config.d.ts +18 -0
  37. package/dist/core/config.js +89 -10
  38. package/dist/core/contained-execution/docker-policy.d.ts +18 -0
  39. package/dist/core/contained-execution/docker-policy.js +97 -0
  40. package/dist/core/contained-execution/docker.d.ts +223 -0
  41. package/dist/core/contained-execution/docker.js +846 -0
  42. package/dist/core/contained-execution/eligibility.d.ts +9 -0
  43. package/dist/core/contained-execution/eligibility.js +103 -0
  44. package/dist/core/contained-execution/failure.d.ts +18 -0
  45. package/dist/core/contained-execution/failure.js +105 -0
  46. package/dist/core/contained-execution/mirror.d.ts +54 -0
  47. package/dist/core/contained-execution/mirror.js +587 -0
  48. package/dist/core/contained-execution/policy.d.ts +10 -0
  49. package/dist/core/contained-execution/policy.js +20 -0
  50. package/dist/core/effect-ir/index.d.ts +1 -1
  51. package/dist/core/effect-ir/index.js +1 -1
  52. package/dist/core/effect-ir/normalize.d.ts +5 -0
  53. package/dist/core/effect-ir/normalize.js +17 -7
  54. package/dist/core/effect-ir/shell-build.js +2 -14
  55. package/dist/core/effect-ir/shell-lower.js +100 -25
  56. package/dist/core/gate-contract.d.ts +4 -1
  57. package/dist/core/gate-contract.js +2 -0
  58. package/dist/core/process-runner.d.ts +10 -3
  59. package/dist/core/process-runner.js +66 -10
  60. package/dist/core/recovery/checkpoint.d.ts +3 -0
  61. package/dist/core/recovery/checkpoint.js +33 -4
  62. package/dist/core/recovery/types.d.ts +1 -0
  63. package/dist/core/scrub.d.ts +17 -0
  64. package/dist/core/scrub.js +499 -5
  65. package/dist/core/standing-allow.d.ts +0 -20
  66. package/dist/core/standing-allow.js +0 -71
  67. package/dist/core/transactional/apply-observed-changes.d.ts +1 -0
  68. package/dist/core/transactional/apply-observed-changes.js +11 -0
  69. package/dist/core/transactional/backend-selector.d.ts +2 -1
  70. package/dist/core/transactional/backend-selector.js +32 -29
  71. package/dist/core/transactional/backend.d.ts +3 -0
  72. package/dist/core/transactional/file-checkpoint-backend.js +242 -54
  73. package/dist/core/transactional/file-checkpoint-git.d.ts +3 -0
  74. package/dist/core/transactional/file-checkpoint-git.js +53 -0
  75. package/dist/core/transactional/file-checkpoint-isolation.d.ts +3 -0
  76. package/dist/core/transactional/file-checkpoint-isolation.js +16 -0
  77. package/dist/core/transactional/git-worktree-backend.js +3 -0
  78. package/dist/core/transactional/git-worktree.d.ts +1 -0
  79. package/dist/core/transactional/git-worktree.js +1 -0
  80. package/dist/core/transactional/runner.js +59 -13
  81. package/dist/core/types.d.ts +13 -0
  82. package/dist/core/verdict/parser.d.ts +10 -0
  83. package/dist/core/verdict/parser.js +271 -40
  84. package/dist/core/verdict/shell-semantics.js +21 -0
  85. package/dist/corpus/adversarial-probe.d.ts +1 -1
  86. package/dist/corpus/adversarial-probe.js +3 -3
  87. package/dist/corpus/benign-probe-cores.d.ts +6 -0
  88. package/dist/corpus/{must-allow-commands.js → benign-probe-cores.js} +8 -2
  89. package/dist/corpus/coverage-compare.d.ts +36 -0
  90. package/dist/corpus/coverage-compare.js +197 -0
  91. package/dist/corpus/coverage-contexts.d.ts +17 -0
  92. package/dist/corpus/coverage-contexts.js +88 -0
  93. package/dist/corpus/coverage-matrix.d.ts +37 -0
  94. package/dist/corpus/coverage-matrix.js +159 -0
  95. package/dist/corpus/coverage-probe.d.ts +91 -0
  96. package/dist/corpus/coverage-probe.js +341 -0
  97. package/dist/corpus/evaluate.d.ts +1 -1
  98. package/dist/corpus/evaluate.js +1 -1
  99. package/dist/corpus/structural-fixture-root.d.ts +2 -0
  100. package/dist/corpus/structural-fixture-root.js +5 -0
  101. package/dist/operational-insights.d.ts +3 -0
  102. package/dist/operational-insights.js +7 -3
  103. package/dist/runtime-provenance.d.ts +8 -0
  104. package/dist/runtime-provenance.js +34 -0
  105. package/dist/types.d.ts +3 -0
  106. package/dist/version.d.ts +1 -1
  107. package/dist/version.js +1 -1
  108. package/package.json +3 -1
  109. package/skills/belay/SKILL.md +19 -0
  110. package/skills/belay/belay-status.md +5 -0
  111. 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 git-worktree diff | `policy.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 shell standing allows are inert.
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 and `belay doctor` warns when
246
- either list is non-empty.
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
- transactional git-worktree runner, persist repo-local pre-images and expose them through
254
- `belay recover list`. Restore is conflict-checked and always requires a signed
255
- out-of-band, exact one-shot approval. Network, remote Git, databases, processes, and
256
- repo-external effects are outside this guarantee.
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) &&