@guilz-dev/belay 0.8.1 → 0.9.1

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 (127) hide show
  1. package/README.md +67 -11
  2. package/dist/adapters/claude/runtime-entry.js +2 -0
  3. package/dist/adapters/codex/runtime-entry.js +2 -0
  4. package/dist/adapters/cursor/hooks.d.ts +5 -1
  5. package/dist/adapters/cursor/hooks.js +37 -2
  6. package/dist/adapters/cursor/runtime-entry.js +5 -0
  7. package/dist/adapters/shared/gate-runtime.d.ts +6 -0
  8. package/dist/adapters/shared/gate-runtime.js +260 -59
  9. package/dist/bundle/claude-runtime.mjs +7068 -3966
  10. package/dist/bundle/codex-runtime.mjs +7001 -3899
  11. package/dist/bundle/cursor-runtime.mjs +7004 -3899
  12. package/dist/commands/classify-for-report.js +5 -0
  13. package/dist/commands/doctor.js +88 -9
  14. package/dist/commands/explain.js +3 -2
  15. package/dist/commands/metrics.js +42 -0
  16. package/dist/commands/recovery-checkpoints.d.ts +8 -1
  17. package/dist/commands/recovery-checkpoints.js +44 -11
  18. package/dist/commands/session.d.ts +5 -0
  19. package/dist/commands/session.js +8 -1
  20. package/dist/config-io.js +2 -2
  21. package/dist/conformance/contained-execution-guarantee.d.ts +121 -0
  22. package/dist/conformance/contained-execution-guarantee.js +121 -0
  23. package/dist/conformance/guarantee-table.js +7 -0
  24. package/dist/core/audit-analysis.js +7 -4
  25. package/dist/core/audit-io.d.ts +1 -0
  26. package/dist/core/audit-io.js +16 -6
  27. package/dist/core/audit-legacy-archive.d.ts +6 -0
  28. package/dist/core/audit-legacy-archive.js +51 -0
  29. package/dist/core/audit-metrics.d.ts +15 -0
  30. package/dist/core/audit-metrics.js +16 -2
  31. package/dist/core/audit-query.d.ts +2 -0
  32. package/dist/core/audit-query.js +33 -9
  33. package/dist/core/audit-recovery-metrics.d.ts +33 -0
  34. package/dist/core/audit-recovery-metrics.js +146 -0
  35. package/dist/core/audit-serialize.d.ts +9 -0
  36. package/dist/core/audit-serialize.js +179 -0
  37. package/dist/core/audit-types.d.ts +8 -1
  38. package/dist/core/audit-types.js +1 -1
  39. package/dist/core/bounded-output.d.ts +11 -0
  40. package/dist/core/bounded-output.js +26 -0
  41. package/dist/core/capability/attestation.d.ts +56 -0
  42. package/dist/core/capability/attestation.js +110 -3
  43. package/dist/core/capability/boundary-session.d.ts +5 -0
  44. package/dist/core/capability/boundary-session.js +38 -1
  45. package/dist/core/capability/index.d.ts +1 -1
  46. package/dist/core/capability/index.js +1 -1
  47. package/dist/core/capability/policy-engine.js +32 -13
  48. package/dist/core/config.d.ts +18 -0
  49. package/dist/core/config.js +89 -10
  50. package/dist/core/contained-execution/docker-policy.d.ts +18 -0
  51. package/dist/core/contained-execution/docker-policy.js +97 -0
  52. package/dist/core/contained-execution/docker.d.ts +223 -0
  53. package/dist/core/contained-execution/docker.js +846 -0
  54. package/dist/core/contained-execution/eligibility.d.ts +9 -0
  55. package/dist/core/contained-execution/eligibility.js +103 -0
  56. package/dist/core/contained-execution/failure.d.ts +18 -0
  57. package/dist/core/contained-execution/failure.js +105 -0
  58. package/dist/core/contained-execution/mirror.d.ts +54 -0
  59. package/dist/core/contained-execution/mirror.js +587 -0
  60. package/dist/core/contained-execution/policy.d.ts +10 -0
  61. package/dist/core/contained-execution/policy.js +20 -0
  62. package/dist/core/decision-config-fingerprint.d.ts +3 -0
  63. package/dist/core/decision-config-fingerprint.js +6 -0
  64. package/dist/core/effect-ir/index.d.ts +1 -1
  65. package/dist/core/effect-ir/index.js +1 -1
  66. package/dist/core/effect-ir/normalize.d.ts +5 -0
  67. package/dist/core/effect-ir/normalize.js +17 -7
  68. package/dist/core/effect-ir/shell-build.js +2 -14
  69. package/dist/core/effect-ir/shell-lower.js +103 -25
  70. package/dist/core/gate-contract.d.ts +4 -1
  71. package/dist/core/gate-contract.js +2 -0
  72. package/dist/core/process-runner.d.ts +10 -3
  73. package/dist/core/process-runner.js +66 -10
  74. package/dist/core/recovery/checkpoint.d.ts +3 -0
  75. package/dist/core/recovery/checkpoint.js +33 -4
  76. package/dist/core/recovery/types.d.ts +1 -0
  77. package/dist/core/scrub.d.ts +17 -0
  78. package/dist/core/scrub.js +499 -5
  79. package/dist/core/standing-allow.d.ts +0 -20
  80. package/dist/core/standing-allow.js +0 -71
  81. package/dist/core/transactional/apply-observed-changes.d.ts +1 -0
  82. package/dist/core/transactional/apply-observed-changes.js +11 -0
  83. package/dist/core/transactional/backend-selector.d.ts +2 -1
  84. package/dist/core/transactional/backend-selector.js +32 -29
  85. package/dist/core/transactional/backend.d.ts +3 -0
  86. package/dist/core/transactional/file-checkpoint-backend.js +242 -54
  87. package/dist/core/transactional/file-checkpoint-git.d.ts +3 -0
  88. package/dist/core/transactional/file-checkpoint-git.js +53 -0
  89. package/dist/core/transactional/file-checkpoint-isolation.d.ts +3 -0
  90. package/dist/core/transactional/file-checkpoint-isolation.js +16 -0
  91. package/dist/core/transactional/git-worktree-backend.js +3 -0
  92. package/dist/core/transactional/git-worktree.d.ts +1 -0
  93. package/dist/core/transactional/git-worktree.js +1 -0
  94. package/dist/core/transactional/runner.js +59 -13
  95. package/dist/core/types.d.ts +13 -0
  96. package/dist/core/verdict/egress-classify.js +1 -0
  97. package/dist/core/verdict/parser.d.ts +10 -0
  98. package/dist/core/verdict/parser.js +271 -40
  99. package/dist/core/verdict/shell-semantics.js +21 -0
  100. package/dist/corpus/adversarial-probe.d.ts +1 -1
  101. package/dist/corpus/adversarial-probe.js +3 -3
  102. package/dist/corpus/benign-probe-cores.d.ts +6 -0
  103. package/dist/corpus/{must-allow-commands.js → benign-probe-cores.js} +8 -2
  104. package/dist/corpus/coverage-compare.d.ts +36 -0
  105. package/dist/corpus/coverage-compare.js +197 -0
  106. package/dist/corpus/coverage-contexts.d.ts +17 -0
  107. package/dist/corpus/coverage-contexts.js +88 -0
  108. package/dist/corpus/coverage-matrix.d.ts +37 -0
  109. package/dist/corpus/coverage-matrix.js +159 -0
  110. package/dist/corpus/coverage-probe.d.ts +91 -0
  111. package/dist/corpus/coverage-probe.js +341 -0
  112. package/dist/corpus/evaluate.d.ts +1 -1
  113. package/dist/corpus/evaluate.js +1 -1
  114. package/dist/corpus/structural-fixture-root.d.ts +2 -0
  115. package/dist/corpus/structural-fixture-root.js +5 -0
  116. package/dist/defaults.js +0 -8
  117. package/dist/egress-daemon.js +20 -4
  118. package/dist/installer.js +3 -0
  119. package/dist/runtime-provenance.d.ts +10 -0
  120. package/dist/runtime-provenance.js +63 -0
  121. package/dist/templates.js +12 -7
  122. package/dist/version.d.ts +1 -1
  123. package/dist/version.js +1 -1
  124. package/package.json +4 -1
  125. package/skills/belay/SKILL.md +19 -0
  126. package/skills/belay/belay-status.md +5 -0
  127. package/dist/corpus/must-allow-commands.d.ts +0 -6
@@ -0,0 +1,121 @@
1
+ import { OUTPUT_TAIL_LIMIT_BYTES } from '../core/bounded-output.js';
2
+ import { CONTAINED_EXECUTION_APPROVAL_FALLBACK_REASONS } from '../core/contained-execution/policy.js';
3
+ export const CONTAINED_UNKNOWN_EXECUTION_GUARANTEE = {
4
+ id: 'contained-unknown-execution-v1',
5
+ optIn: true,
6
+ runtime: 'docker-only',
7
+ l1Full: false,
8
+ materializesGrants: false,
9
+ deniesUngrantedEffects: false,
10
+ authority: {
11
+ shell: 'effect-plan-only',
12
+ commandIdentityEligibility: false,
13
+ forbiddenInputs: [
14
+ 'executable',
15
+ 'prefix',
16
+ 'fingerprint',
17
+ 'corpus-membership',
18
+ 'framework-identity',
19
+ ],
20
+ },
21
+ audit: {
22
+ wouldMediate: true,
23
+ containedRouteExecution: 'none',
24
+ readsAttestation: false,
25
+ preparesMirror: false,
26
+ startsContainer: false,
27
+ gatePermission: 'allow',
28
+ hostExecution: 'delegated-to-host',
29
+ },
30
+ enforce: {
31
+ originalHostCommand: 'deny',
32
+ mirror: 'file_copy',
33
+ startsAtMostOnce: true,
34
+ workspaceChanges: 'discard',
35
+ output: {
36
+ scrub: 'mandatory',
37
+ tailBytes: OUTPUT_TAIL_LIMIT_BYTES,
38
+ userRedactionCanDisable: false,
39
+ },
40
+ audit: 'safe-metadata-only',
41
+ },
42
+ attestation: {
43
+ signed: true,
44
+ fresh: true,
45
+ immutableImageId: true,
46
+ configuredDockerBinary: 'absolute-bound',
47
+ configuredDockerSocket: 'local-unix-bound',
48
+ daemonIdentity: true,
49
+ configurationBound: true,
50
+ },
51
+ boundary: {
52
+ network: 'none',
53
+ readOnlyRoot: true,
54
+ sanitizedHostEnvironment: true,
55
+ resourceLimits: true,
56
+ logDriver: 'none',
57
+ mount: 'one-private-mirror-at-original-guest-path',
58
+ excluded: [
59
+ 'host-source',
60
+ 'git-metadata',
61
+ 'control-plane',
62
+ 'docker-socket',
63
+ 'devices',
64
+ 'unrelated-host-paths',
65
+ ],
66
+ cleanupConfirmed: true,
67
+ },
68
+ fallback: {
69
+ approvalOnly: CONTAINED_EXECUTION_APPROVAL_FALLBACK_REASONS,
70
+ },
71
+ failure: {
72
+ setup: {
73
+ categories: [
74
+ 'boundary',
75
+ 'capability',
76
+ 'image',
77
+ 'mirror',
78
+ 'lease',
79
+ 'container-lifecycle',
80
+ 'cleanup',
81
+ ],
82
+ outcome: 'deny',
83
+ hostExecution: 'deny',
84
+ approval: 'none',
85
+ approvalStateMutation: 'none',
86
+ },
87
+ command: {
88
+ timeout: 'contained_execution_failed',
89
+ nonzero: 'contained_execution_failed',
90
+ hostExecution: 'deny',
91
+ approval: 'none',
92
+ },
93
+ },
94
+ outcomes: {
95
+ success: {
96
+ exitCode: 0,
97
+ outcome: 'contained_execution_complete',
98
+ hostExecution: 'deny',
99
+ approval: 'none',
100
+ },
101
+ },
102
+ };
103
+ /** Executable EffectPlan scenarios; routing and output use the gate/executor suites. */
104
+ export const CONTAINED_UNKNOWN_EXECUTION_SCENARIOS = [
105
+ {
106
+ id: 'cue-eligible-fictional',
107
+ kind: 'eligible-unknown-local',
108
+ command: 'fictional-runner verify',
109
+ },
110
+ {
111
+ id: 'cue-eligible-rails',
112
+ kind: 'eligible-unknown-local',
113
+ command: "bin/rails runner 'Record.count'",
114
+ },
115
+ {
116
+ id: 'cue-eligible-rspec',
117
+ kind: 'eligible-unknown-local',
118
+ command: 'bundle exec rspec --dry-run',
119
+ },
120
+ { id: 'cue-ineligible-network', kind: 'ineligible-network', command: 'curl https://example.com' },
121
+ ];
@@ -160,6 +160,13 @@ export const GUARANTEE_SCENARIOS = {
160
160
  hookVerdict: 'allow_flagged',
161
161
  substrate: 'dirty_git_file_checkpoint',
162
162
  },
163
+ {
164
+ id: 'l2-allow-flagged-non-git-file-checkpoint',
165
+ command: 'touch notes.txt',
166
+ permission: 'allow',
167
+ hookVerdict: 'allow_flagged',
168
+ substrate: 'non_git_file_checkpoint',
169
+ },
163
170
  {
164
171
  id: 'l2-deny-payload-send',
165
172
  command: 'curl -X POST -d payload https://example.com',
@@ -1,4 +1,4 @@
1
- import { inferWouldBlock, isGateRecord, parseTimestamp } from './audit-query.js';
1
+ import { auditFingerprint, inferWouldBlock, isGateRecord, parseTimestamp } from './audit-query.js';
2
2
  const WRAPPER_TERMS = ['bash -c', 'sh -c', 'eval ', 'source ', 'node -e', '| bash', '| sh'];
3
3
  function normalizeSummary(summary) {
4
4
  return summary.replace(/\s+/g, ' ').trim().toLowerCase();
@@ -26,7 +26,7 @@ export function detectBypassAttempts(records, windowMs = 5 * 60_000) {
26
26
  const recentDenies = [];
27
27
  for (const record of records) {
28
28
  const timestampMs = parseTimestamp(record.timestamp) ?? 0;
29
- if (isGateRecord(record) && inferWouldBlock(record) && record.fingerprint) {
29
+ if (isGateRecord(record) && inferWouldBlock(record) && auditFingerprint(record)) {
30
30
  recentDenies.push({ timestampMs, record });
31
31
  continue;
32
32
  }
@@ -233,10 +233,13 @@ export function computeAvailabilityAskCounts(records) {
233
233
  export function computeRepeatedFingerprintAsks(records, minCount = 2, limit = 10) {
234
234
  const grouped = new Map();
235
235
  for (const record of records) {
236
- if (!isGateRecord(record) || !inferWouldBlock(record) || !record.fingerprint) {
236
+ if (!isGateRecord(record) || !inferWouldBlock(record)) {
237
+ continue;
238
+ }
239
+ const fingerprint = auditFingerprint(record);
240
+ if (!fingerprint) {
237
241
  continue;
238
242
  }
239
- const fingerprint = record.fingerprint;
240
243
  const existing = grouped.get(fingerprint);
241
244
  if (existing) {
242
245
  existing.askCount += 1;
@@ -1,2 +1,3 @@
1
1
  import type { BelayConfigV4 } from './config.js';
2
+ export { AUDIT_SCHEMA_VERSION, appendAuditRecord, approvalCorrelationId, isValidAuditFingerprint, isValidAuditTimestamp, parseAuditNdjsonLine, serializeAuditRecordV3, } from './audit-serialize.js';
2
3
  export declare function appendCliAuditEvent(repoRoot: string, config: BelayConfigV4, event: Record<string, unknown>): Promise<void>;
@@ -1,14 +1,24 @@
1
- import { appendFile, mkdir } from 'node:fs/promises';
2
1
  import path from 'node:path';
2
+ import { resolveActiveAuditCohort } from '../runtime-provenance.js';
3
+ import { appendAuditRecord } from './audit-serialize.js';
4
+ import { scrubOptionsFromConfig } from './config.js';
5
+ export { AUDIT_SCHEMA_VERSION, appendAuditRecord, approvalCorrelationId, isValidAuditFingerprint, isValidAuditTimestamp, parseAuditNdjsonLine, serializeAuditRecordV3, } from './audit-serialize.js';
3
6
  export async function appendCliAuditEvent(repoRoot, config, event) {
4
7
  const auditPath = path.isAbsolute(config.audit.logPath)
5
8
  ? config.audit.logPath
6
9
  : path.join(repoRoot, config.audit.logPath);
7
- await mkdir(path.dirname(auditPath), { recursive: true });
8
- const line = JSON.stringify({
9
- ts: new Date().toISOString(),
10
+ const cohort = await resolveActiveAuditCohort(repoRoot, config);
11
+ await appendAuditRecord(auditPath, {
10
12
  source: 'belay-cli',
13
+ ...(cohort
14
+ ? {
15
+ runtimeBuildStamp: cohort.runtimeBuildStamp,
16
+ runtimeArtifactHash: cohort.runtimeArtifactHash,
17
+ decisionConfigFingerprint: cohort.decisionConfigFingerprint,
18
+ boundaryProfile: cohort.boundaryProfile,
19
+ configFingerprint: cohort.configFingerprint,
20
+ }
21
+ : {}),
11
22
  ...event,
12
- });
13
- await appendFile(auditPath, `${line}\n`, 'utf8');
23
+ }, scrubOptionsFromConfig(config));
14
24
  }
@@ -0,0 +1,6 @@
1
+ import type { BelayConfigV3 } from './config.js';
2
+ export declare function auditLogHasLegacyScrubPlaceholders(sample: string): boolean;
3
+ export declare function archiveLegacyAuditLogIfNeeded(repoRoot: string, config: BelayConfigV3): Promise<{
4
+ archived: boolean;
5
+ archivedPath?: string;
6
+ }>;
@@ -0,0 +1,51 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { open, rename } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ const LEGACY_PLACEHOLDER_PATTERNS = [
5
+ /"(?:timestamp|ts)"\s*:\s*"<timestamp>"/,
6
+ /"(?:fingerprint|commandFingerprint|effectIRHash|payloadHash|configFingerprint|runtimeArtifactHash|decisionConfigFingerprint|receiptHash)"\s*:\s*"<high-entropy>"/,
7
+ /"approvalId"\s*:\s*"<approval-id>"/,
8
+ ];
9
+ const AUDIT_SCAN_CHUNK_BYTES = 64 * 1024;
10
+ const AUDIT_SCAN_OVERLAP_CHARS = 256;
11
+ export function auditLogHasLegacyScrubPlaceholders(sample) {
12
+ return LEGACY_PLACEHOLDER_PATTERNS.some((pattern) => pattern.test(sample));
13
+ }
14
+ async function auditFileHasLegacyScrubPlaceholders(auditPath) {
15
+ const handle = await open(auditPath, 'r');
16
+ const buffer = Buffer.allocUnsafe(AUDIT_SCAN_CHUNK_BYTES);
17
+ let carry = '';
18
+ let position = 0;
19
+ try {
20
+ while (true) {
21
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, position);
22
+ if (bytesRead === 0) {
23
+ return false;
24
+ }
25
+ const sample = carry + buffer.toString('utf8', 0, bytesRead);
26
+ if (auditLogHasLegacyScrubPlaceholders(sample)) {
27
+ return true;
28
+ }
29
+ carry = sample.slice(-AUDIT_SCAN_OVERLAP_CHARS);
30
+ position += bytesRead;
31
+ }
32
+ }
33
+ finally {
34
+ await handle.close();
35
+ }
36
+ }
37
+ export async function archiveLegacyAuditLogIfNeeded(repoRoot, config) {
38
+ const auditPath = path.isAbsolute(config.audit.logPath)
39
+ ? config.audit.logPath
40
+ : path.join(repoRoot, config.audit.logPath);
41
+ if (!existsSync(auditPath)) {
42
+ return { archived: false };
43
+ }
44
+ if (!(await auditFileHasLegacyScrubPlaceholders(auditPath))) {
45
+ return { archived: false };
46
+ }
47
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
48
+ const archivePath = `${auditPath}.legacy-${timestamp}.ndjson`;
49
+ await rename(auditPath, archivePath);
50
+ return { archived: true, archivedPath: archivePath };
51
+ }
@@ -1,8 +1,13 @@
1
1
  import { buildApprovalRoundTrips, filterAuditRecords, toAuditRecord } from './audit-query.js';
2
+ import { type RecoveryMetrics, type RecoveryMetricsCohort } from './audit-recovery-metrics.js';
2
3
  import type { AvailabilityAskCounts, ReasonApprovalRatio, RepeatedFingerprintAsk } from './audit-types.js';
3
4
  /** Minimum gate events before recommending enforce with zero would-block rate. */
4
5
  export declare const MIN_GATE_EVENTS_FOR_ENFORCE = 20;
5
6
  export interface AuditCohortIdentity {
7
+ runtimeArtifactHash: string;
8
+ decisionConfigFingerprint: string;
9
+ boundaryProfile: string;
10
+ /** Display / forensics metadata — not used for v3 cohort matching when artifact hash is present. */
6
11
  runtimeBuildStamp: string;
7
12
  configFingerprint: string;
8
13
  }
@@ -22,6 +27,13 @@ export interface AuditMetricsCohort {
22
27
  reason: string;
23
28
  count: number;
24
29
  }>;
30
+ containedExecution: ContainedExecutionMetrics;
31
+ }
32
+ export interface ContainedExecutionMetrics {
33
+ wouldMediate: number;
34
+ complete: number;
35
+ failed: number;
36
+ timedOut: number;
25
37
  }
26
38
  export interface AuditMetricsReport {
27
39
  schemaVersion: number;
@@ -52,6 +64,7 @@ export interface AuditMetricsReport {
52
64
  reason: string;
53
65
  count: number;
54
66
  }>;
67
+ containedExecution: ContainedExecutionMetrics;
55
68
  approvalLatency: {
56
69
  count: number;
57
70
  medianMs: number | null;
@@ -71,6 +84,8 @@ export interface AuditMetricsReport {
71
84
  readyForEnforce: boolean;
72
85
  notes: string[];
73
86
  };
87
+ recovery: RecoveryMetrics;
88
+ currentCohortRecovery: RecoveryMetricsCohort;
74
89
  }
75
90
  export declare function parseAuditNdjson(raw: string): Record<string, unknown>[];
76
91
  export declare function computeAuditMetrics(records: Record<string, unknown>[], options?: {
@@ -1,8 +1,18 @@
1
+ import { matchesAuditCohort } from '../runtime-provenance.js';
1
2
  import { bucketGateEventsByDay, computeApprovalLatencyStats, computeApprovalRatioByReason, computeAvailabilityAskCounts, computeRepeatedFingerprintAsks, computeWouldBlockByReason, countVerdicts, detectBypassAttempts, detectNoisyRules, } from './audit-analysis.js';
2
3
  import { buildApprovalRoundTrips, filterAuditRecords, inferWouldBlock, isApprovalRecorded, toAuditRecord, } from './audit-query.js';
4
+ import { computeRecoveryMetrics, } from './audit-recovery-metrics.js';
3
5
  import { AUDIT_METRICS_SCHEMA_VERSION, GATE_EVENTS } from './audit-types.js';
4
6
  /** Minimum gate events before recommending enforce with zero would-block rate. */
5
7
  export const MIN_GATE_EVENTS_FOR_ENFORCE = 20;
8
+ function containedExecutionMetrics(records) {
9
+ return {
10
+ wouldMediate: records.filter((record) => record.wouldMediate === true).length,
11
+ complete: records.filter((record) => record.reason === 'contained_execution_complete').length,
12
+ failed: records.filter((record) => record.reason === 'contained_execution_failed').length,
13
+ timedOut: records.filter((record) => record.reason === 'contained_execution_failed' && record.timedOut === true).length,
14
+ };
15
+ }
6
16
  export function parseAuditNdjson(raw) {
7
17
  const records = [];
8
18
  for (const line of raw.split('\n')) {
@@ -97,8 +107,7 @@ export function computeAuditMetrics(records, options = {}) {
97
107
  .slice(0, 10);
98
108
  const activeCohort = options.activeCohort ?? null;
99
109
  const cohortRecords = activeCohort
100
- ? auditRecords.filter((record) => record.runtimeBuildStamp === activeCohort.runtimeBuildStamp &&
101
- record.configFingerprint === activeCohort.configFingerprint)
110
+ ? auditRecords.filter((record) => matchesAuditCohort(record, activeCohort))
102
111
  : [];
103
112
  const cohortGateRecords = cohortRecords.filter((record) => {
104
113
  const event = typeof record.event === 'string' ? record.event : '';
@@ -146,6 +155,7 @@ export function computeAuditMetrics(records, options = {}) {
146
155
  availabilityAsks: cohortAvailabilityAsks,
147
156
  wouldBlockByReason: cohortWouldBlockByReason,
148
157
  topWouldBlockSummaries: cohortTopWouldBlockSummaries,
158
+ containedExecution: containedExecutionMetrics(cohortGateRecords),
149
159
  };
150
160
  const mode = options.mode ?? null;
151
161
  const unknownLocalEffect = options.unknownLocalEffect ?? null;
@@ -201,6 +211,7 @@ export function computeAuditMetrics(records, options = {}) {
201
211
  if (cohortNoisyRuleCandidates.length > 0) {
202
212
  notes.push(`${cohortNoisyRuleCandidates.length} active-cohort noisy rule candidate(s) — high deny-then-approve rate.`);
203
213
  }
214
+ const recoveryMetrics = computeRecoveryMetrics(auditRecords, { activeCohort });
204
215
  return {
205
216
  schemaVersion: AUDIT_METRICS_SCHEMA_VERSION,
206
217
  auditLogPath: options.auditLogPath ?? 'belay/audit.ndjson',
@@ -226,6 +237,7 @@ export function computeAuditMetrics(records, options = {}) {
226
237
  currentCohort,
227
238
  approvalRecordedCount,
228
239
  topWouldBlockSummaries,
240
+ containedExecution: containedExecutionMetrics(auditRecords),
229
241
  approvalLatency,
230
242
  gateEventsByDay: bucketGateEventsByDay(auditRecords),
231
243
  bypassAttemptCount: bypassAttempts.length,
@@ -236,6 +248,8 @@ export function computeAuditMetrics(records, options = {}) {
236
248
  readyForEnforce,
237
249
  notes,
238
250
  },
251
+ recovery: recoveryMetrics.allTime,
252
+ currentCohortRecovery: recoveryMetrics.currentCohort,
239
253
  };
240
254
  }
241
255
  export { buildApprovalRoundTrips, filterAuditRecords, toAuditRecord };
@@ -1,6 +1,8 @@
1
1
  import type { ApprovalRoundTrip, AuditFilter, AuditRecord } from './audit-types.js';
2
2
  export declare function toAuditRecord(value: Record<string, unknown>): AuditRecord;
3
3
  export declare function parseTimestamp(value?: string): number | null;
4
+ export declare function auditFingerprint(record: AuditRecord): string | undefined;
5
+ export declare function auditApprovalCorrelationId(record: AuditRecord): string | undefined;
4
6
  export declare function isGateRecord(record: AuditRecord): boolean;
5
7
  export declare function isShellGateRecord(record: AuditRecord): boolean;
6
8
  export declare function isApprovalRecorded(record: AuditRecord): boolean;
@@ -1,3 +1,4 @@
1
+ import { isValidApprovalCorrelationId, isValidAuditFingerprint, isValidAuditTimestamp, } from './audit-serialize.js';
1
2
  import { GATE_EVENTS } from './audit-types.js';
2
3
  export function toAuditRecord(value) {
3
4
  const record = { ...value };
@@ -7,12 +8,25 @@ export function toAuditRecord(value) {
7
8
  return record;
8
9
  }
9
10
  export function parseTimestamp(value) {
10
- if (!value) {
11
+ if (!value || !isValidAuditTimestamp(value)) {
11
12
  return null;
12
13
  }
13
14
  const parsed = Date.parse(value);
14
15
  return Number.isNaN(parsed) ? null : parsed;
15
16
  }
17
+ export function auditFingerprint(record) {
18
+ if (typeof record.fingerprint !== 'string' || !isValidAuditFingerprint(record.fingerprint)) {
19
+ return undefined;
20
+ }
21
+ return record.fingerprint;
22
+ }
23
+ export function auditApprovalCorrelationId(record) {
24
+ if (typeof record.approvalCorrelationId === 'string' &&
25
+ isValidApprovalCorrelationId(record.approvalCorrelationId)) {
26
+ return record.approvalCorrelationId;
27
+ }
28
+ return undefined;
29
+ }
16
30
  export function isGateRecord(record) {
17
31
  return typeof record.event === 'string' && GATE_EVENTS.has(record.event);
18
32
  }
@@ -89,28 +103,36 @@ export function filterAuditRecords(records, filter = {}) {
89
103
  }
90
104
  export function buildApprovalRoundTrips(records) {
91
105
  const trips = [];
106
+ const pendingByCorrelationId = new Map();
92
107
  const pendingByApprovalId = new Map();
93
108
  const pendingByFingerprint = new Map();
94
109
  for (const record of records) {
95
110
  const timestamp = record.timestamp ?? '';
96
- if (isGateRecord(record) && inferWouldBlock(record) && record.fingerprint) {
111
+ const fingerprint = auditFingerprint(record);
112
+ const correlationId = auditApprovalCorrelationId(record);
113
+ if (isGateRecord(record) && inferWouldBlock(record) && fingerprint) {
97
114
  const trip = {
98
115
  denyTimestamp: timestamp,
99
- fingerprint: record.fingerprint,
116
+ fingerprint,
100
117
  reason: record.reason ?? 'unknown',
101
118
  summary: record.summary ?? '',
102
119
  kind: record.kind ?? 'unknown',
103
120
  approvalId: record.approvalId,
121
+ approvalCorrelationId: correlationId,
104
122
  };
105
123
  trips.push(trip);
106
- if (record.approvalId) {
124
+ if (correlationId) {
125
+ pendingByCorrelationId.set(correlationId, trip);
126
+ }
127
+ if (record.approvalId && !String(record.approvalId).startsWith('<')) {
107
128
  pendingByApprovalId.set(record.approvalId, trip);
108
129
  }
109
- pendingByFingerprint.set(record.fingerprint, trip);
130
+ pendingByFingerprint.set(fingerprint, trip);
110
131
  continue;
111
132
  }
112
- if (isApprovalRecorded(record) && record.approvalId) {
113
- const trip = pendingByApprovalId.get(record.approvalId);
133
+ if (isApprovalRecorded(record)) {
134
+ const trip = (correlationId ? pendingByCorrelationId.get(correlationId) : undefined) ??
135
+ (record.approvalId ? pendingByApprovalId.get(record.approvalId) : undefined);
114
136
  if (trip) {
115
137
  trip.approvalTimestamp = timestamp;
116
138
  const denyMs = parseTimestamp(trip.denyTimestamp);
@@ -123,9 +145,11 @@ export function buildApprovalRoundTrips(records) {
123
145
  }
124
146
  if (isGateRecord(record) &&
125
147
  record.reason === 'approved_once' &&
126
- record.fingerprint &&
148
+ fingerprint &&
127
149
  record.permission === 'allow') {
128
- const trip = pendingByFingerprint.get(record.fingerprint);
150
+ const trip = correlationId
151
+ ? pendingByCorrelationId.get(correlationId)
152
+ : pendingByFingerprint.get(fingerprint);
129
153
  if (trip) {
130
154
  trip.executeTimestamp = timestamp;
131
155
  }
@@ -0,0 +1,33 @@
1
+ import type { AuditCohortIdentity } from './audit-metrics.js';
2
+ import type { AuditRecord } from './audit-types.js';
3
+ export interface RecoverySnapshotMetrics {
4
+ attempts: number;
5
+ applied: number;
6
+ skipped: number;
7
+ byBackend: Record<string, number>;
8
+ byResourceKind: Record<string, number>;
9
+ prepareSampleCount: number;
10
+ prepareMsP50: number | null;
11
+ prepareMsP95: number | null;
12
+ failuresByReason: Record<string, number>;
13
+ }
14
+ export interface RecoveryRestoreMetrics {
15
+ applied: number;
16
+ conflict: number;
17
+ rejected: number;
18
+ }
19
+ export interface RecoveryMetrics {
20
+ snapshot: RecoverySnapshotMetrics;
21
+ restore: RecoveryRestoreMetrics;
22
+ }
23
+ export interface RecoveryMetricsCohort extends RecoveryMetrics {
24
+ excludedSnapshotAttempts: number;
25
+ excludedRestoreEvents: number;
26
+ }
27
+ export declare function sanitizeRecoveryFailureReason(record: AuditRecord): string;
28
+ export declare function computeRecoveryMetrics(records: AuditRecord[], options?: {
29
+ activeCohort?: AuditCohortIdentity | null;
30
+ }): {
31
+ allTime: RecoveryMetrics;
32
+ currentCohort: RecoveryMetricsCohort;
33
+ };
@@ -0,0 +1,146 @@
1
+ import { matchesAuditCohort } from '../runtime-provenance.js';
2
+ import { GATE_EVENTS } from './audit-types.js';
3
+ const RESTORE_EVENTS = new Set(['recoveryApplied', 'recoveryConflict', 'recoveryRejected']);
4
+ const STABLE_FAILURE_REASON = /^[a-z][a-z0-9_]*$/;
5
+ function increment(bucket, key) {
6
+ bucket[key] = (bucket[key] ?? 0) + 1;
7
+ }
8
+ function percentile(sorted, p) {
9
+ if (sorted.length === 0) {
10
+ return null;
11
+ }
12
+ const index = Math.ceil((p / 100) * sorted.length) - 1;
13
+ return sorted[Math.max(0, Math.min(sorted.length - 1, index))] ?? null;
14
+ }
15
+ export function sanitizeRecoveryFailureReason(record) {
16
+ const reason = record.transactionalSkipReason ?? record.reason;
17
+ if (typeof reason === 'string' && STABLE_FAILURE_REASON.test(reason)) {
18
+ return reason;
19
+ }
20
+ return 'transactional_execution_failed';
21
+ }
22
+ function isRecoverySnapshotRecord(record) {
23
+ if (typeof record.transactional === 'boolean') {
24
+ return true;
25
+ }
26
+ if (typeof record.transactionalSkipReason === 'string') {
27
+ return true;
28
+ }
29
+ if (record.recoveryFailClosed === true) {
30
+ return true;
31
+ }
32
+ return false;
33
+ }
34
+ function isRecoveryRestoreRecord(record) {
35
+ const event = typeof record.event === 'string' ? record.event : '';
36
+ return RESTORE_EVENTS.has(event);
37
+ }
38
+ function matchesCohort(record, cohort) {
39
+ return matchesAuditCohort(record, cohort);
40
+ }
41
+ function emptySnapshotMetrics() {
42
+ return {
43
+ attempts: 0,
44
+ applied: 0,
45
+ skipped: 0,
46
+ byBackend: {},
47
+ byResourceKind: {},
48
+ prepareSampleCount: 0,
49
+ prepareMsP50: null,
50
+ prepareMsP95: null,
51
+ failuresByReason: {},
52
+ };
53
+ }
54
+ function emptyRestoreMetrics() {
55
+ return {
56
+ applied: 0,
57
+ conflict: 0,
58
+ rejected: 0,
59
+ };
60
+ }
61
+ function isRecoverySnapshotApplied(record) {
62
+ return (record.transactional === true &&
63
+ (record.reason === 'transactional_already_applied' ||
64
+ typeof record.recoveryCheckpointId === 'string' ||
65
+ record.recoveryState === 'applied'));
66
+ }
67
+ function computeSnapshotMetrics(records) {
68
+ const metrics = emptySnapshotMetrics();
69
+ const prepareSamples = [];
70
+ for (const record of records) {
71
+ const event = typeof record.event === 'string' ? record.event : '';
72
+ if (!GATE_EVENTS.has(event)) {
73
+ continue;
74
+ }
75
+ if (!isRecoverySnapshotRecord(record)) {
76
+ continue;
77
+ }
78
+ metrics.attempts += 1;
79
+ if (isRecoverySnapshotApplied(record)) {
80
+ metrics.applied += 1;
81
+ }
82
+ else {
83
+ metrics.skipped += 1;
84
+ if (record.transactionalReason === 'transactional_observed_risk') {
85
+ increment(metrics.failuresByReason, 'transactional_observed_risk');
86
+ }
87
+ else {
88
+ increment(metrics.failuresByReason, sanitizeRecoveryFailureReason(record));
89
+ }
90
+ }
91
+ const backend = typeof record.transactionalBackend === 'string' ? record.transactionalBackend : 'unknown';
92
+ increment(metrics.byBackend, backend);
93
+ const resourceKind = typeof record.resourceKind === 'string' ? record.resourceKind : 'unknown';
94
+ increment(metrics.byResourceKind, resourceKind);
95
+ if (typeof record.snapshotPrepareMs === 'number' && Number.isFinite(record.snapshotPrepareMs)) {
96
+ prepareSamples.push(record.snapshotPrepareMs);
97
+ }
98
+ }
99
+ prepareSamples.sort((left, right) => left - right);
100
+ metrics.prepareSampleCount = prepareSamples.length;
101
+ metrics.prepareMsP50 = percentile(prepareSamples, 50);
102
+ metrics.prepareMsP95 = percentile(prepareSamples, 95);
103
+ return metrics;
104
+ }
105
+ function computeRestoreMetrics(records) {
106
+ const metrics = emptyRestoreMetrics();
107
+ for (const record of records) {
108
+ const event = typeof record.event === 'string' ? record.event : '';
109
+ if (event === 'recoveryApplied') {
110
+ metrics.applied += 1;
111
+ }
112
+ else if (event === 'recoveryConflict') {
113
+ metrics.conflict += 1;
114
+ }
115
+ else if (event === 'recoveryRejected') {
116
+ metrics.rejected += 1;
117
+ }
118
+ }
119
+ return metrics;
120
+ }
121
+ export function computeRecoveryMetrics(records, options = {}) {
122
+ const gateSnapshotRecords = records.filter((record) => {
123
+ const event = typeof record.event === 'string' ? record.event : '';
124
+ return GATE_EVENTS.has(event) && isRecoverySnapshotRecord(record);
125
+ });
126
+ const restoreRecords = records.filter(isRecoveryRestoreRecord);
127
+ const activeCohort = options.activeCohort ?? null;
128
+ const cohortGateSnapshotRecords = activeCohort
129
+ ? gateSnapshotRecords.filter((record) => matchesCohort(record, activeCohort))
130
+ : [];
131
+ const cohortRestoreRecords = activeCohort
132
+ ? restoreRecords.filter((record) => matchesCohort(record, activeCohort))
133
+ : [];
134
+ return {
135
+ allTime: {
136
+ snapshot: computeSnapshotMetrics(gateSnapshotRecords),
137
+ restore: computeRestoreMetrics(restoreRecords),
138
+ },
139
+ currentCohort: {
140
+ snapshot: computeSnapshotMetrics(cohortGateSnapshotRecords),
141
+ restore: computeRestoreMetrics(cohortRestoreRecords),
142
+ excludedSnapshotAttempts: gateSnapshotRecords.length - cohortGateSnapshotRecords.length,
143
+ excludedRestoreEvents: restoreRecords.length - cohortRestoreRecords.length,
144
+ },
145
+ };
146
+ }
@@ -0,0 +1,9 @@
1
+ import type { ScrubOptions } from './types.js';
2
+ export declare const AUDIT_SCHEMA_VERSION = 3;
3
+ export declare function approvalCorrelationId(approvalId: string): string;
4
+ export declare function isValidApprovalCorrelationId(value: string): boolean;
5
+ export declare function isValidAuditTimestamp(value: string): boolean;
6
+ export declare function isValidAuditFingerprint(value: string): boolean;
7
+ export declare function serializeAuditRecordV3(record: Record<string, unknown>, options: ScrubOptions): Record<string, unknown>;
8
+ export declare function parseAuditNdjsonLine(line: string): Record<string, unknown> | null;
9
+ export declare function appendAuditRecord(auditPath: string, record: Record<string, unknown>, options: ScrubOptions): Promise<void>;