@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.
Files changed (104) 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 +73 -6
  9. package/dist/commands/explain.js +3 -2
  10. package/dist/commands/metrics.js +42 -0
  11. package/dist/commands/recovery-checkpoints.d.ts +8 -1
  12. package/dist/commands/recovery-checkpoints.js +44 -11
  13. package/dist/commands/session.d.ts +5 -0
  14. package/dist/commands/session.js +8 -1
  15. package/dist/config-io.js +2 -2
  16. package/dist/conformance/contained-execution-guarantee.d.ts +121 -0
  17. package/dist/conformance/contained-execution-guarantee.js +121 -0
  18. package/dist/conformance/guarantee-table.js +7 -0
  19. package/dist/core/audit-io.js +8 -0
  20. package/dist/core/audit-metrics.d.ts +11 -0
  21. package/dist/core/audit-metrics.js +14 -0
  22. package/dist/core/audit-recovery-metrics.d.ts +33 -0
  23. package/dist/core/audit-recovery-metrics.js +146 -0
  24. package/dist/core/audit-types.d.ts +7 -1
  25. package/dist/core/audit-types.js +1 -1
  26. package/dist/core/bounded-output.d.ts +11 -0
  27. package/dist/core/bounded-output.js +26 -0
  28. package/dist/core/capability/attestation.d.ts +56 -0
  29. package/dist/core/capability/attestation.js +110 -3
  30. package/dist/core/capability/boundary-session.d.ts +5 -0
  31. package/dist/core/capability/boundary-session.js +38 -1
  32. package/dist/core/capability/index.d.ts +1 -1
  33. package/dist/core/capability/index.js +1 -1
  34. package/dist/core/config.d.ts +18 -0
  35. package/dist/core/config.js +89 -10
  36. package/dist/core/contained-execution/docker-policy.d.ts +18 -0
  37. package/dist/core/contained-execution/docker-policy.js +97 -0
  38. package/dist/core/contained-execution/docker.d.ts +223 -0
  39. package/dist/core/contained-execution/docker.js +846 -0
  40. package/dist/core/contained-execution/eligibility.d.ts +9 -0
  41. package/dist/core/contained-execution/eligibility.js +103 -0
  42. package/dist/core/contained-execution/failure.d.ts +18 -0
  43. package/dist/core/contained-execution/failure.js +105 -0
  44. package/dist/core/contained-execution/mirror.d.ts +54 -0
  45. package/dist/core/contained-execution/mirror.js +587 -0
  46. package/dist/core/contained-execution/policy.d.ts +10 -0
  47. package/dist/core/contained-execution/policy.js +20 -0
  48. package/dist/core/effect-ir/index.d.ts +1 -1
  49. package/dist/core/effect-ir/index.js +1 -1
  50. package/dist/core/effect-ir/normalize.d.ts +5 -0
  51. package/dist/core/effect-ir/normalize.js +17 -7
  52. package/dist/core/effect-ir/shell-build.js +2 -14
  53. package/dist/core/effect-ir/shell-lower.js +100 -25
  54. package/dist/core/gate-contract.d.ts +4 -1
  55. package/dist/core/gate-contract.js +2 -0
  56. package/dist/core/process-runner.d.ts +10 -3
  57. package/dist/core/process-runner.js +66 -10
  58. package/dist/core/recovery/checkpoint.d.ts +3 -0
  59. package/dist/core/recovery/checkpoint.js +33 -4
  60. package/dist/core/recovery/types.d.ts +1 -0
  61. package/dist/core/scrub.d.ts +17 -0
  62. package/dist/core/scrub.js +499 -5
  63. package/dist/core/standing-allow.d.ts +0 -20
  64. package/dist/core/standing-allow.js +0 -71
  65. package/dist/core/transactional/apply-observed-changes.d.ts +1 -0
  66. package/dist/core/transactional/apply-observed-changes.js +11 -0
  67. package/dist/core/transactional/backend-selector.d.ts +2 -1
  68. package/dist/core/transactional/backend-selector.js +32 -29
  69. package/dist/core/transactional/backend.d.ts +3 -0
  70. package/dist/core/transactional/file-checkpoint-backend.js +242 -54
  71. package/dist/core/transactional/file-checkpoint-git.d.ts +3 -0
  72. package/dist/core/transactional/file-checkpoint-git.js +53 -0
  73. package/dist/core/transactional/file-checkpoint-isolation.d.ts +3 -0
  74. package/dist/core/transactional/file-checkpoint-isolation.js +16 -0
  75. package/dist/core/transactional/git-worktree-backend.js +3 -0
  76. package/dist/core/transactional/git-worktree.d.ts +1 -0
  77. package/dist/core/transactional/git-worktree.js +1 -0
  78. package/dist/core/transactional/runner.js +59 -13
  79. package/dist/core/types.d.ts +13 -0
  80. package/dist/core/verdict/parser.d.ts +10 -0
  81. package/dist/core/verdict/parser.js +271 -40
  82. package/dist/core/verdict/shell-semantics.js +21 -0
  83. package/dist/corpus/adversarial-probe.d.ts +1 -1
  84. package/dist/corpus/adversarial-probe.js +3 -3
  85. package/dist/corpus/benign-probe-cores.d.ts +6 -0
  86. package/dist/corpus/{must-allow-commands.js → benign-probe-cores.js} +8 -2
  87. package/dist/corpus/coverage-compare.d.ts +36 -0
  88. package/dist/corpus/coverage-compare.js +197 -0
  89. package/dist/corpus/coverage-contexts.d.ts +17 -0
  90. package/dist/corpus/coverage-contexts.js +88 -0
  91. package/dist/corpus/coverage-matrix.d.ts +37 -0
  92. package/dist/corpus/coverage-matrix.js +159 -0
  93. package/dist/corpus/coverage-probe.d.ts +91 -0
  94. package/dist/corpus/coverage-probe.js +341 -0
  95. package/dist/corpus/evaluate.d.ts +1 -1
  96. package/dist/corpus/evaluate.js +1 -1
  97. package/dist/corpus/structural-fixture-root.d.ts +2 -0
  98. package/dist/corpus/structural-fixture-root.js +5 -0
  99. package/dist/version.d.ts +1 -1
  100. package/dist/version.js +1 -1
  101. package/package.json +3 -1
  102. package/skills/belay/SKILL.md +19 -0
  103. package/skills/belay/belay-status.md +5 -0
  104. package/dist/corpus/must-allow-commands.d.ts +0 -6
@@ -3,6 +3,7 @@ import { getAdapter } from '../adapters/registry.js';
3
3
  import { repoShellClassifierOptions } from '../adapters/shared/gate-runtime.js';
4
4
  import { detectAdapterName, loadConfigFile } from '../config-io.js';
5
5
  import { isCapabilityBrokerDemotionActive } from '../core/capability/broker.js';
6
+ import { isContainedUnknownExecutionEligible } from '../core/contained-execution/eligibility.js';
6
7
  import { classifySubagent, classifyToolUse } from '../core/index.js';
7
8
  import { isTransactionalEligible } from '../core/transactional/index.js';
8
9
  import { classifyShell } from '../core/verdict/adapter.js';
@@ -26,6 +27,10 @@ export async function classifyForReport(params) {
26
27
  throw new Error('classify-for-report requires a command for shell classification.');
27
28
  }
28
29
  result = await classifyShell(params.command, cwd, repoRoot, config, classifierOptions);
30
+ result = {
31
+ ...result,
32
+ wouldMediate: isContainedUnknownExecutionEligible(config, { kind: 'shell', repoRoot }, result),
33
+ };
29
34
  input = params.command;
30
35
  }
31
36
  else if (kind === 'subagent') {
@@ -4,20 +4,24 @@ import path from 'node:path';
4
4
  import { getClaudeManagedHookEntries } from '../adapters/claude/hooks.js';
5
5
  import { getCodexManagedHookEntries } from '../adapters/codex/hooks.js';
6
6
  import { getAdapterLayout } from '../adapters/layouts/index.js';
7
+ import { protectedArtifactRoots } from '../adapters/layouts/protected-paths.js';
7
8
  import { resolveScopedPaths } from '../adapters/layouts/scope.js';
8
9
  import { cleanupOrphanApprovalState } from '../cleanup-orphans.js';
9
- import { approvedApprovalsPath, belayStateDir, detectAdapterName, loadLayeredConfig, pendingApprovalsPath, repoLocalStateDirFor, } from '../config-io.js';
10
+ import { approvedApprovalsPath, belayStateDir, detectAdapterName, loadLayeredConfig, pendingApprovalsPath, repoLocalStateDirFor, writeConfigFile, } from '../config-io.js';
10
11
  import { approvalSigningKeyPath } from '../core/approval-token.js';
11
12
  import { detectFenceDrift, summarizeAuditVisibility } from '../core/audit-summary.js';
12
13
  import { inspectBoundaryAttestationFile } from '../core/capability/boundary-attestation-sign.js';
13
14
  import { boundaryAttestationPath, boundarySessionStatus, } from '../core/capability/boundary-session.js';
14
- import { defaultControlPlaneDir } from '../core/config.js';
15
+ import { configuredControlPlaneDir, defaultControlPlaneDir, hasForbiddenShellOverrideLists, stripForbiddenShellOverrideLists, } from '../core/config.js';
15
16
  import { verifyIntegrityManifest } from '../core/integrity.js';
16
17
  import { diagnoseJudge, stopJudgeSessionBrokers } from '../core/judge-doctor.js';
17
18
  import { resolveJudgeTransport } from '../core/judge-runtime-detection.js';
18
19
  import { listRecoveryCheckpoints } from '../core/recovery/checkpoint.js';
19
20
  import { recoveryApprovalSetupNotes, recoveryNotificationConfigured, recoveryNotificationSetupWarning, summarizeRecoveryCheckpointDiagnostics, } from '../core/recovery/operator-guidance.js';
21
+ import { probeFileCheckpointBackend } from '../core/transactional/backend-selector.js';
22
+ import { fileCheckpointIsolationReason } from '../core/transactional/file-checkpoint-isolation.js';
20
23
  import { probeFileCloneStrategy } from '../core/transactional/file-clone.js';
24
+ import { isGitWorktreeAvailable } from '../core/transactional/git-worktree.js';
21
25
  import { getManagedHookEntries } from '../defaults.js';
22
26
  import { resolveNodeBinary } from '../node-resolution.js';
23
27
  import { readInstalledRuntimeProvenance } from '../runtime-provenance.js';
@@ -74,7 +78,7 @@ export async function doctorProject(options = {}) {
74
78
  ...(loadedConfig.overrides.external.length > 0 ? ['overrides.external'] : []),
75
79
  ];
76
80
  if (ignoredShellOverrideLists.length > 0) {
77
- warnings.push(`Legacy shell ${ignoredShellOverrideLists.join(' and ')} ${ignoredShellOverrideLists.length === 1 ? 'is' : 'are'} deprecated and ignored. Remove the command list; inspect the EffectPlan and correct effect semantics or resource scope instead.`);
81
+ issues.push(`Legacy shell ${ignoredShellOverrideLists.join(' and ')} ${ignoredShellOverrideLists.length === 1 ? 'is' : 'are'} forbidden (ADR-005). Remove the command list (or run belay doctor --fix); improve EffectPlan semantics, use one-shot approval, or approve an exact resource scope instead.`);
78
82
  }
79
83
  if (loadedConfig.version !== 4) {
80
84
  warnings.push(`Config version is ${loadedConfig.version}; expected 4. Run belay upgrade to migrate.`);
@@ -233,6 +237,23 @@ export async function doctorProject(options = {}) {
233
237
  }
234
238
  }
235
239
  if (options.fix && loadedConfig) {
240
+ if (hasForbiddenShellOverrideLists(loadedConfig)) {
241
+ if (options.dryRun !== true) {
242
+ const stripped = stripForbiddenShellOverrideLists(loadedConfig);
243
+ await writeConfigFile(repoRoot, stripped, adapterName);
244
+ loadedConfig = stripped;
245
+ notes.push('Removed forbidden legacy shell override lists (overrides.allow / overrides.external).');
246
+ for (let index = issues.length - 1; index >= 0; index -= 1) {
247
+ const issue = issues[index] ?? '';
248
+ if (issue.includes('overrides.allow') || issue.includes('overrides.external')) {
249
+ issues.splice(index, 1);
250
+ }
251
+ }
252
+ }
253
+ else {
254
+ notes.push('Dry run: would remove forbidden legacy shell override lists.');
255
+ }
256
+ }
236
257
  const cleanup = await cleanupOrphanApprovalState(repoRoot, loadedConfig, {
237
258
  dryRun: options.dryRun === true,
238
259
  });
@@ -291,10 +312,27 @@ export async function doctorProject(options = {}) {
291
312
  else if (dogfood.unknownLocalEffect === 'deny' && dogfood.mode !== 'audit') {
292
313
  notes.push('Fail-closed policy is enabled in enforce mode.');
293
314
  }
315
+ const recoveryCohort = metrics.currentCohortRecovery;
316
+ const restoreTotal = recoveryCohort.restore.applied +
317
+ recoveryCohort.restore.conflict +
318
+ recoveryCohort.restore.rejected;
319
+ if (recoveryCohort.snapshot.attempts > 0 || restoreTotal > 0) {
320
+ notes.push(`Recovery cohort metrics: ${recoveryCohort.snapshot.attempts} snapshot attempt(s) (${recoveryCohort.snapshot.applied} applied, ${recoveryCohort.snapshot.skipped} skipped); restore outcomes ${recoveryCohort.restore.applied} applied / ${recoveryCohort.restore.conflict} conflict / ${recoveryCohort.restore.rejected} rejected.`);
321
+ if (recoveryCohort.snapshot.prepareSampleCount > 0) {
322
+ notes.push(`Recovery snapshot prepare latency (cohort): p50 ${recoveryCohort.snapshot.prepareMsP50 ?? 0}ms, p95 ${recoveryCohort.snapshot.prepareMsP95 ?? 0}ms (${recoveryCohort.snapshot.prepareSampleCount} samples).`);
323
+ }
324
+ if (Object.keys(recoveryCohort.snapshot.failuresByReason).length > 0) {
325
+ notes.push(`Recovery snapshot failures (cohort): ${Object.entries(recoveryCohort.snapshot.failuresByReason)
326
+ .map(([reason, count]) => `${reason}=${count}`)
327
+ .join(', ')}.`);
328
+ }
329
+ }
294
330
  if (loadedConfig.policy.transactional.enabled) {
295
- notes.push('Transactional execution: enabled — low-confidence shell mutations run in an isolated git worktree; observed-safe effects are applied once and the hook denies re-execution.');
296
- if (!existsSync(path.join(repoRoot, '.git'))) {
297
- warnings.push('Transactional execution is enabled but this directory is not a git repository. Transactional worktrees will be skipped until git is available.');
331
+ notes.push('Transactional execution: enabled — low-confidence shell mutations run in an isolated git worktree or file-checkpoint mirror; observed-safe effects are applied once and the hook denies re-execution.');
332
+ const fileCheckpointEnabled = loadedConfig.policy.transactional.fileCheckpoint.enabled;
333
+ const allowNonGit = loadedConfig.policy.transactional.fileCheckpoint.allowNonGit;
334
+ if (!existsSync(path.join(repoRoot, '.git')) && !(fileCheckpointEnabled && allowNonGit)) {
335
+ warnings.push('Transactional execution is enabled but this directory is not a git repository. Enable file checkpoint with allowNonGit or initialize git before transactional recovery can run.');
298
336
  }
299
337
  }
300
338
  const fileCheckpoint = loadedConfig.policy.transactional.fileCheckpoint;
@@ -307,6 +345,35 @@ export async function doctorProject(options = {}) {
307
345
  if (!loadedConfig.policy.transactional.checkpoint?.enabled) {
308
346
  warnings.push('File checkpoint is enabled but durable Recovery checkpointing is disabled; backend selection will fail closed.');
309
347
  }
348
+ const checkpointConfig = loadedConfig.policy.transactional.checkpoint;
349
+ const configuredBoundary = loadedConfig.capability?.boundaryDriver ??
350
+ (loadedConfig.sandbox.runtime === 'container' ? 'container' : null);
351
+ let attestation = null;
352
+ if (configuredBoundary) {
353
+ try {
354
+ const session = await boundarySessionStatus({ repoRoot, config: loadedConfig });
355
+ attestation = session.attestation;
356
+ }
357
+ catch {
358
+ attestation = null;
359
+ }
360
+ }
361
+ const gitAvailable = await isGitWorktreeAvailable(repoRoot);
362
+ const backendContext = {
363
+ repoRoot,
364
+ stateDir: belayStateDir(loadedConfig, repoLocalDir),
365
+ cwd: repoRoot,
366
+ dirtyIgnoreRoots: protectedArtifactRoots(activeLayout, repoRoot, loadedConfig.controlPlane.enabled ? configuredControlPlaneDir(loadedConfig) : null),
367
+ fileCheckpoint,
368
+ durableCheckpointEnabled: checkpointConfig?.enabled === true,
369
+ boundaryAttestation: attestation,
370
+ boundaryAttestationFresh: false,
371
+ boundaryDriverId: configuredBoundary ?? undefined,
372
+ };
373
+ const isolationAvailable = fileCheckpointIsolationReason(backendContext) === null;
374
+ const fileCheckpointProbe = await probeFileCheckpointBackend(backendContext);
375
+ const fileCheckpointAvailable = loadedConfig.policy.transactional.enabled && fileCheckpointProbe.eligible;
376
+ notes.push(`File checkpoint eligibility: probe=${fileCheckpointAvailable ? 'available' : 'unavailable'}, workspace=${gitAvailable ? 'git' : 'non-git'}, isolation=${isolationAvailable ? (configuredBoundary ?? 'attested') : 'unavailable'}.`);
310
377
  }
311
378
  if (loadedConfig.policy.transactional.checkpoint?.enabled) {
312
379
  notes.push('Recovery checkpoint: enabled — repo-local pre-images are persisted before observed-safe transactional apply.');
@@ -103,11 +103,12 @@ export function formatExplainReport(report) {
103
103
  : 'Egress proxy: not configured',
104
104
  `Sandbox (L1 broker): ${report.sandbox.enabled ? 'enabled' : 'disabled'} (runtime=${report.sandbox.runtime}, fs broker active=${report.sandboxBrokerActive}, L1-full=${report.l1FullActive})`,
105
105
  `Transactional (L2): ${report.policy.transactional.enabled ? 'enabled' : 'disabled'} (eligible for this command=${report.transactionalEligible})`,
106
+ `Contained unknown execution: ${result.wouldMediate ? 'would mediate' : 'not eligible'}`,
106
107
  report.policy.transactional.enabled
107
108
  ? `Transactional band: [${report.policy.transactional.minConfidence}, ${report.policy.transactional.maxConfidence})`
108
109
  : 'Transactional band: not configured',
109
- `Legacy overrides allow (deprecated, ignored): ${report.overrides.allow.join(', ') || '(none)'}`,
110
- `Legacy overrides external (deprecated, ignored): ${report.overrides.external.join(', ') || '(none)'}`,
110
+ `Legacy overrides allow (forbidden, ignored): ${report.overrides.allow.join(', ') || '(none)'}`,
111
+ `Legacy overrides external (forbidden, ignored): ${report.overrides.external.join(', ') || '(none)'}`,
111
112
  '',
112
113
  `Verdict: ${result.verdict}`,
113
114
  `Reason: ${result.reason}`,
@@ -29,6 +29,42 @@ export async function metricsProject(options = {}) {
29
29
  activeCohort,
30
30
  });
31
31
  }
32
+ function formatRecoveryMetricsSection(label, metrics, excluded) {
33
+ const lines = [
34
+ '',
35
+ label,
36
+ `Snapshot attempts: ${metrics.snapshot.attempts} (${metrics.snapshot.applied} applied, ${metrics.snapshot.skipped} skipped)`,
37
+ ];
38
+ if (excluded) {
39
+ lines.push(`- excluded historical/mismatched snapshot attempts: ${excluded.snapshotAttempts}`, `- excluded historical/mismatched restore events: ${excluded.restoreEvents}`);
40
+ }
41
+ if (metrics.snapshot.prepareSampleCount > 0) {
42
+ lines.push(`Snapshot prepare latency: p50 ${metrics.snapshot.prepareMsP50 ?? 0}ms, p95 ${metrics.snapshot.prepareMsP95 ?? 0}ms (${metrics.snapshot.prepareSampleCount} samples)`);
43
+ }
44
+ if (Object.keys(metrics.snapshot.byBackend).length > 0) {
45
+ lines.push('Snapshot backends:');
46
+ for (const [backend, count] of Object.entries(metrics.snapshot.byBackend).sort((left, right) => right[1] - left[1])) {
47
+ lines.push(`- ${backend}: ${count}`);
48
+ }
49
+ }
50
+ if (Object.keys(metrics.snapshot.byResourceKind).length > 0) {
51
+ lines.push('Snapshot resource kinds:');
52
+ for (const [resourceKind, count] of Object.entries(metrics.snapshot.byResourceKind).sort((left, right) => right[1] - left[1])) {
53
+ lines.push(`- ${resourceKind}: ${count}`);
54
+ }
55
+ }
56
+ if (Object.keys(metrics.snapshot.failuresByReason).length > 0) {
57
+ lines.push('Snapshot failures by reason:');
58
+ for (const [reason, count] of Object.entries(metrics.snapshot.failuresByReason).sort((left, right) => right[1] - left[1])) {
59
+ lines.push(`- ${reason}: ${count}`);
60
+ }
61
+ }
62
+ const restoreTotal = metrics.restore.applied + metrics.restore.conflict + metrics.restore.rejected;
63
+ if (restoreTotal > 0) {
64
+ lines.push(`Restore outcomes: ${metrics.restore.applied} applied, ${metrics.restore.conflict} conflict, ${metrics.restore.rejected} rejected`);
65
+ }
66
+ return lines;
67
+ }
32
68
  export function formatMetricsReport(report) {
33
69
  const lines = [
34
70
  `belay metrics for ${report.auditLogPath}`,
@@ -37,6 +73,7 @@ export function formatMetricsReport(report) {
37
73
  `All-time would-block: ${report.wouldBlockCount} (${(report.wouldBlockRate * 100).toFixed(1)}%)`,
38
74
  `All-time classifier-quality would-block: ${report.classifierWouldBlockCount} (${(report.classifierWouldBlockRate * 100).toFixed(1)}%)`,
39
75
  `All-time approvals recorded during audit: ${report.approvalRecordedCount}`,
76
+ `Contained execution: would mediate ${report.containedExecution.wouldMediate}; complete ${report.containedExecution.complete}; failed ${report.containedExecution.failed}; timed out ${report.containedExecution.timedOut}`,
40
77
  ];
41
78
  lines.push('', 'Current readiness cohort:');
42
79
  if (report.currentCohort.identity) {
@@ -51,6 +88,7 @@ export function formatMetricsReport(report) {
51
88
  lines.push(`- would-block: ${report.currentCohort.wouldBlockCount} (${(report.currentCohort.wouldBlockRate * 100).toFixed(1)}%)`);
52
89
  lines.push(`- classifier-quality would-block: ${report.currentCohort.classifierWouldBlockCount} (${(report.currentCohort.classifierWouldBlockRate * 100).toFixed(1)}%)`);
53
90
  lines.push(`- approvals recorded during audit: ${report.currentCohort.approvalRecordedCount}`);
91
+ lines.push(`- contained execution: would mediate ${report.currentCohort.containedExecution.wouldMediate}; complete ${report.currentCohort.containedExecution.complete}; failed ${report.currentCohort.containedExecution.failed}; timed out ${report.currentCohort.containedExecution.timedOut}`);
54
92
  lines.push(`- availability-caused asks: ${report.currentCohort.availabilityAsks.total}`);
55
93
  if (Object.keys(report.currentCohort.wouldBlockByReason).length > 0) {
56
94
  lines.push('', 'Current-cohort would-block by reason:');
@@ -152,5 +190,9 @@ export function formatMetricsReport(report) {
152
190
  }
153
191
  lines.push('', report.dogfood.readyForEnforce ? 'Ready for enforce: yes' : 'Ready for enforce: not yet');
154
192
  }
193
+ lines.push(...formatRecoveryMetricsSection('All-time recovery metrics:', report.recovery), ...formatRecoveryMetricsSection('Current-cohort recovery metrics:', report.currentCohortRecovery, {
194
+ snapshotAttempts: report.currentCohortRecovery.excludedSnapshotAttempts,
195
+ restoreEvents: report.currentCohortRecovery.excludedRestoreEvents,
196
+ }));
155
197
  return `${lines.join('\n')}\n`;
156
198
  }
@@ -12,10 +12,13 @@ export declare function recoveryCheckpointCommand(options: {
12
12
  backendCounts: {
13
13
  [k: string]: number;
14
14
  };
15
+ resourceKindCounts: {
16
+ [k: string]: number;
17
+ };
15
18
  fileCheckpoint: {
16
19
  enabled: boolean;
17
20
  allowNonGit: boolean;
18
- isolation: string | null;
21
+ isolation: import("../core/capability/attestation.js").BoundaryDriverId | null;
19
22
  copyStrategy: import("../core/transactional/file-clone.js").FileCloneStrategy | undefined;
20
23
  probe: string;
21
24
  };
@@ -55,6 +58,7 @@ export declare function recoveryCheckpointCommand(options: {
55
58
  backend?: undefined;
56
59
  availableBackends?: undefined;
57
60
  backendCounts?: undefined;
61
+ resourceKindCounts?: undefined;
58
62
  fileCheckpoint?: undefined;
59
63
  enabled?: undefined;
60
64
  checkpointCount?: undefined;
@@ -86,6 +90,7 @@ export declare function recoveryCheckpointCommand(options: {
86
90
  backend?: undefined;
87
91
  availableBackends?: undefined;
88
92
  backendCounts?: undefined;
93
+ resourceKindCounts?: undefined;
89
94
  fileCheckpoint?: undefined;
90
95
  enabled?: undefined;
91
96
  checkpointCount?: undefined;
@@ -117,6 +122,7 @@ export declare function recoveryCheckpointCommand(options: {
117
122
  backend?: undefined;
118
123
  availableBackends?: undefined;
119
124
  backendCounts?: undefined;
125
+ resourceKindCounts?: undefined;
120
126
  fileCheckpoint?: undefined;
121
127
  enabled?: undefined;
122
128
  checkpointCount?: undefined;
@@ -144,6 +150,7 @@ export declare function recoveryCheckpointCommand(options: {
144
150
  backend?: undefined;
145
151
  availableBackends?: undefined;
146
152
  backendCounts?: undefined;
153
+ resourceKindCounts?: undefined;
147
154
  fileCheckpoint?: undefined;
148
155
  enabled?: undefined;
149
156
  checkpointCount?: undefined;
@@ -1,5 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import path from 'node:path';
3
+ import { getAdapterLayout } from '../adapters/layouts/index.js';
4
+ import { protectedArtifactRoots } from '../adapters/layouts/protected-paths.js';
3
5
  import { ensureBelayStateDir, loadConfigFile } from '../config-io.js';
4
6
  import { compactApprovals, createApprovalRecordWithEnvelope } from '../core/approval.js';
5
7
  import { createGateApprovalStore } from '../core/approval-service.js';
@@ -12,6 +14,8 @@ import { notifyDeny } from '../core/notify.js';
12
14
  import { canonicalPath } from '../core/path-utils.js';
13
15
  import { listRecoveryCheckpoints, RECOVERY_CHECKPOINT_CORRUPT, RECOVERY_RESTORE_CONFLICT, RECOVERY_RESTORE_REASON, recoveryCheckpointStorageBytes, recoveryRestoreBinding, restoreRecoveryCheckpoint, showRecoveryCheckpoint, } from '../core/recovery/checkpoint.js';
14
16
  import { formatRecoveryStateDiagnostic, recoveryNotificationConfigured, recoveryNotificationSetupWarning, summarizeRecoveryCheckpointDiagnostics, } from '../core/recovery/operator-guidance.js';
17
+ import { probeFileCheckpointBackend } from '../core/transactional/backend-selector.js';
18
+ import { fileCheckpointIsolationReason } from '../core/transactional/file-checkpoint-isolation.js';
15
19
  import { probeFileCloneStrategy } from '../core/transactional/file-clone.js';
16
20
  async function appendRecoveryAudit(repoRoot, config, event) {
17
21
  try {
@@ -116,28 +120,43 @@ export async function recoveryCheckpointCommand(options) {
116
120
  backend,
117
121
  checkpoints.filter((checkpoint) => checkpoint.backend === backend).length,
118
122
  ]));
123
+ const resourceKindCounts = Object.fromEntries([
124
+ ...new Set(checkpoints.map((checkpoint) => checkpoint.resourceKind ?? 'git_repository')),
125
+ ].map((resourceKind) => [
126
+ resourceKind,
127
+ checkpoints.filter((checkpoint) => (checkpoint.resourceKind ?? 'git_repository') === resourceKind).length,
128
+ ]));
119
129
  const checkpointBackends = Object.keys(backendCounts);
120
130
  const fileCheckpointConfig = config.policy.transactional.fileCheckpoint;
121
131
  const configuredBoundary = config.capability?.boundaryDriver ??
122
132
  (config.sandbox.runtime === 'container' ? 'container' : null);
123
- let isolation = configuredBoundary;
124
- let isolationAvailable = false;
133
+ let attestation = null;
125
134
  if (configuredBoundary) {
126
135
  try {
127
136
  const session = await boundarySessionStatus({ repoRoot, config });
128
- isolationAvailable =
129
- session.fresh === true && session.attestation?.isolatesWorkspaceMounts === true;
130
- isolation = isolationAvailable ? (session.attestation?.driver ?? configuredBoundary) : null;
137
+ attestation = session.attestation;
131
138
  }
132
139
  catch {
133
- isolation = null;
140
+ attestation = null;
134
141
  }
135
142
  }
136
143
  const copyStrategy = fileCheckpointConfig.enabled ? await probeFileCloneStrategy() : null;
137
- const fileCheckpointAvailable = fileCheckpointConfig.enabled &&
138
- config.policy.transactional.enabled &&
139
- checkpointConfig.enabled &&
140
- isolationAvailable;
144
+ const adapterLayout = getAdapterLayout(config.adapter);
145
+ const backendContext = {
146
+ repoRoot,
147
+ stateDir,
148
+ cwd: repoRoot,
149
+ dirtyIgnoreRoots: protectedArtifactRoots(adapterLayout, repoRoot, config.controlPlane.enabled ? configuredControlPlaneDir(config) : null),
150
+ fileCheckpoint: fileCheckpointConfig,
151
+ durableCheckpointEnabled: checkpointConfig.enabled,
152
+ boundaryAttestation: attestation,
153
+ boundaryAttestationFresh: false,
154
+ boundaryDriverId: configuredBoundary ?? undefined,
155
+ };
156
+ const isolationAvailable = fileCheckpointIsolationReason(backendContext) === null;
157
+ const fileCheckpointProbe = await probeFileCheckpointBackend(backendContext);
158
+ const fileCheckpointAvailable = config.policy.transactional.enabled && fileCheckpointProbe.eligible;
159
+ const isolation = isolationAvailable ? (attestation?.driver ?? configuredBoundary) : null;
141
160
  return {
142
161
  ok: true,
143
162
  subcommand: 'status',
@@ -149,6 +168,7 @@ export async function recoveryCheckpointCommand(options) {
149
168
  : 'git_worktree',
150
169
  availableBackends: ['git_worktree', 'file_checkpoint'],
151
170
  backendCounts,
171
+ resourceKindCounts,
152
172
  fileCheckpoint: {
153
173
  enabled: fileCheckpointConfig.enabled,
154
174
  allowNonGit: fileCheckpointConfig.allowNonGit,
@@ -304,9 +324,13 @@ export async function recoveryCheckpointCommand(options) {
304
324
  }
305
325
  export function formatRecoveryCheckpointResult(result) {
306
326
  if (result.subcommand === 'status') {
327
+ const fileCheckpoint = result.fileCheckpoint;
328
+ const backendCounts = result.backendCounts;
329
+ const resourceKindCounts = result.resourceKindCounts;
307
330
  const lines = [
308
331
  `belay recover status for ${result.repoRoot}`,
309
332
  `Backend: ${result.backend}`,
333
+ `Available backends: ${result.availableBackends?.join(', ') ?? 'git_worktree, file_checkpoint'}`,
310
334
  `Checkpointing: ${result.enabled ? 'enabled' : 'disabled'}`,
311
335
  `Checkpoints: ${result.checkpointCount} (${result.recoverableCount} recoverable)`,
312
336
  `Needs manual repair: ${result.needsManualRepairCount ?? 0}`,
@@ -314,6 +338,15 @@ export function formatRecoveryCheckpointResult(result) {
314
338
  `Storage: ${result.storageBytes} bytes`,
315
339
  `States: ${JSON.stringify(result.states)}`,
316
340
  ];
341
+ if (backendCounts && Object.keys(backendCounts).length > 0) {
342
+ lines.push(`Backend counts: ${JSON.stringify(backendCounts)}`);
343
+ }
344
+ if (resourceKindCounts && Object.keys(resourceKindCounts).length > 0) {
345
+ lines.push(`Resource kind counts: ${JSON.stringify(resourceKindCounts)}`);
346
+ }
347
+ if (fileCheckpoint) {
348
+ lines.push(`File checkpoint: enabled=${fileCheckpoint.enabled ? 'yes' : 'no'}, allowNonGit=${fileCheckpoint.allowNonGit ? 'yes' : 'no'}, probe=${fileCheckpoint.probe ?? 'unknown'}, isolation=${fileCheckpoint.isolation ?? 'none'}${fileCheckpoint.copyStrategy ? `, copyStrategy=${fileCheckpoint.copyStrategy}` : ''}`);
349
+ }
317
350
  const advisories = result.advisories;
318
351
  if (advisories && advisories.length > 0) {
319
352
  lines.push('', 'Advisories:');
@@ -330,7 +363,7 @@ export function formatRecoveryCheckpointResult(result) {
330
363
  return 'No recovery checkpoints found.\n';
331
364
  const lines = checkpoints.map((item) => {
332
365
  const diagnostic = formatRecoveryStateDiagnostic(item.state, item.stateDetail);
333
- const base = `${item.checkpointId}\t${item.state}\t${item.changeCount} changes\treceipt:${item.receiptHash?.slice(0, 12) ?? '-'}\t${item.createdAt}`;
366
+ const base = `${item.checkpointId}\t${item.state}\t${item.backend}\t${item.resourceKind}\t${item.changeCount} changes\treceipt:${item.receiptHash?.slice(0, 12) ?? '-'}\t${item.createdAt}`;
334
367
  return diagnostic ? `${base}\n ${diagnostic}` : base;
335
368
  });
336
369
  return `${lines.join('\n')}\n`;
@@ -15,6 +15,11 @@ export declare function sessionStatusProject(params: {
15
15
  ok: boolean;
16
16
  attestationPath: string;
17
17
  fresh: boolean;
18
+ containedExecutionFresh?: boolean;
18
19
  attestation: Awaited<ReturnType<typeof boundarySessionStatus>>['attestation'];
19
20
  }>;
21
+ export declare function sessionStatusOk(status: {
22
+ fresh: boolean;
23
+ containedExecutionFresh?: boolean;
24
+ }): boolean;
20
25
  export declare function formatSessionStatusReport(report: Awaited<ReturnType<typeof sessionStatusProject>>): string;
@@ -31,12 +31,16 @@ export async function sessionStatusProject(params) {
31
31
  const config = await loadConfigFile(repoRoot);
32
32
  const status = await boundarySessionStatus({ repoRoot, config });
33
33
  return {
34
- ok: status.fresh,
34
+ ok: sessionStatusOk(status),
35
35
  attestationPath: status.attestationPath,
36
36
  fresh: status.fresh,
37
+ containedExecutionFresh: status.containedExecutionFresh,
37
38
  attestation: status.attestation,
38
39
  };
39
40
  }
41
+ export function sessionStatusOk(status) {
42
+ return status.fresh || status.containedExecutionFresh === true;
43
+ }
40
44
  export function formatSessionStatusReport(report) {
41
45
  if (!report.attestation) {
42
46
  return `No boundary attestation at ${report.attestationPath}. Run belay session start.`;
@@ -45,6 +49,9 @@ export function formatSessionStatusReport(report) {
45
49
  `Attestation: ${report.attestationPath}`,
46
50
  `Driver: ${report.attestation.driver}`,
47
51
  `Fresh: ${report.fresh ? 'yes' : 'no'}`,
52
+ ...(report.containedExecutionFresh === undefined
53
+ ? []
54
+ : [`Contained execution fresh: ${report.containedExecutionFresh ? 'yes' : 'no'}`]),
48
55
  `Expires: ${report.attestation.expiresAt}`,
49
56
  `Materializes grants: ${report.attestation.materializesGrants ? 'yes' : 'no'}`,
50
57
  ].join('\n');
package/dist/config-io.js CHANGED
@@ -5,7 +5,7 @@ import path from 'node:path';
5
5
  import { getAdapterLayout } from './adapters/layouts/index.js';
6
6
  import { compactApprovals, isExpired, mergeApprovalStates } from './core/approval.js';
7
7
  import { mutateApprovalStateWithRetry } from './core/capability/approval-state-mutation.js';
8
- import { approvedApprovalsFile, belayStateDir, configuredControlPlaneDir, mergeConfig, pendingApprovalsFile, } from './core/config.js';
8
+ import { approvedApprovalsFile, belayStateDir, configuredControlPlaneDir, mergeConfig, pendingApprovalsFile, stripForbiddenShellOverrideLists, } from './core/config.js';
9
9
  import { resolveLayeredConfig, teamConfigPath, } from './core/config-layers.js';
10
10
  export function resolveAdapterName(config) {
11
11
  if (config.adapter === 'claude') {
@@ -210,7 +210,7 @@ export async function loadConfigFile(repoRoot, adapter = detectAdapterName(repoR
210
210
  export async function writeConfigFile(repoRoot, config, adapter = resolveAdapterName(config)) {
211
211
  const configPath = configPathFor(repoRoot, adapter);
212
212
  await mkdir(path.dirname(configPath), { recursive: true });
213
- await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
213
+ await writeFile(configPath, `${JSON.stringify(stripForbiddenShellOverrideLists(config), null, 2)}\n`, 'utf8');
214
214
  }
215
215
  export async function mergeAndWriteConfig(repoRoot, adapter = 'cursor') {
216
216
  const layout = getAdapterLayout(adapter);
@@ -0,0 +1,121 @@
1
+ import { CONTAINED_EXECUTION_APPROVAL_FALLBACK_REASONS } from '../core/contained-execution/policy.js';
2
+ /**
3
+ * Normative contained-execution capability contract.
4
+ *
5
+ * This is intentionally not a LayerProfileId: it describes one opt-in mediated execution and
6
+ * never upgrades the surrounding configuration to L1-full.
7
+ */
8
+ export interface ContainedUnknownExecutionGuarantee {
9
+ id: 'contained-unknown-execution-v1';
10
+ optIn: true;
11
+ runtime: 'docker-only';
12
+ l1Full: false;
13
+ materializesGrants: false;
14
+ deniesUngrantedEffects: false;
15
+ authority: {
16
+ shell: 'effect-plan-only';
17
+ commandIdentityEligibility: false;
18
+ forbiddenInputs: readonly [
19
+ 'executable',
20
+ 'prefix',
21
+ 'fingerprint',
22
+ 'corpus-membership',
23
+ 'framework-identity'
24
+ ];
25
+ };
26
+ audit: {
27
+ wouldMediate: true;
28
+ containedRouteExecution: 'none';
29
+ readsAttestation: false;
30
+ preparesMirror: false;
31
+ startsContainer: false;
32
+ gatePermission: 'allow';
33
+ hostExecution: 'delegated-to-host';
34
+ };
35
+ enforce: {
36
+ originalHostCommand: 'deny';
37
+ mirror: 'file_copy';
38
+ startsAtMostOnce: true;
39
+ workspaceChanges: 'discard';
40
+ output: {
41
+ scrub: 'mandatory';
42
+ tailBytes: 16384;
43
+ userRedactionCanDisable: false;
44
+ };
45
+ audit: 'safe-metadata-only';
46
+ };
47
+ attestation: {
48
+ signed: true;
49
+ fresh: true;
50
+ immutableImageId: true;
51
+ configuredDockerBinary: 'absolute-bound';
52
+ configuredDockerSocket: 'local-unix-bound';
53
+ daemonIdentity: true;
54
+ configurationBound: true;
55
+ };
56
+ boundary: {
57
+ network: 'none';
58
+ readOnlyRoot: true;
59
+ sanitizedHostEnvironment: true;
60
+ resourceLimits: true;
61
+ logDriver: 'none';
62
+ mount: 'one-private-mirror-at-original-guest-path';
63
+ excluded: readonly [
64
+ 'host-source',
65
+ 'git-metadata',
66
+ 'control-plane',
67
+ 'docker-socket',
68
+ 'devices',
69
+ 'unrelated-host-paths'
70
+ ];
71
+ cleanupConfirmed: true;
72
+ };
73
+ fallback: {
74
+ approvalOnly: typeof CONTAINED_EXECUTION_APPROVAL_FALLBACK_REASONS;
75
+ };
76
+ failure: {
77
+ /** Every setup failure other than the exact approval fallback set denies before host replay. */
78
+ setup: {
79
+ categories: readonly [
80
+ 'boundary',
81
+ 'capability',
82
+ 'image',
83
+ 'mirror',
84
+ 'lease',
85
+ 'container-lifecycle',
86
+ 'cleanup'
87
+ ];
88
+ outcome: 'deny';
89
+ hostExecution: 'deny';
90
+ approval: 'none';
91
+ approvalStateMutation: 'none';
92
+ };
93
+ /** A started container that times out or exits nonzero is terminal, never an approval. */
94
+ command: {
95
+ timeout: 'contained_execution_failed';
96
+ nonzero: 'contained_execution_failed';
97
+ hostExecution: 'deny';
98
+ approval: 'none';
99
+ };
100
+ };
101
+ outcomes: {
102
+ success: {
103
+ exitCode: 0;
104
+ outcome: 'contained_execution_complete';
105
+ hostExecution: 'deny';
106
+ approval: 'none';
107
+ };
108
+ };
109
+ }
110
+ export type ContainedUnknownExecutionScenario = {
111
+ id: string;
112
+ kind: 'eligible-unknown-local';
113
+ command: string;
114
+ } | {
115
+ id: string;
116
+ kind: 'ineligible-network';
117
+ command: string;
118
+ };
119
+ export declare const CONTAINED_UNKNOWN_EXECUTION_GUARANTEE: ContainedUnknownExecutionGuarantee;
120
+ /** Executable EffectPlan scenarios; routing and output use the gate/executor suites. */
121
+ export declare const CONTAINED_UNKNOWN_EXECUTION_SCENARIOS: readonly ContainedUnknownExecutionScenario[];