@guilz-dev/belay 0.9.0 → 0.9.2

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 (46) hide show
  1. package/README.md +7 -1
  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 +6 -1
  5. package/dist/adapters/cursor/hooks.js +23 -0
  6. package/dist/adapters/cursor/runtime-entry.js +5 -0
  7. package/dist/adapters/shared/gate-runtime.d.ts +1 -0
  8. package/dist/adapters/shared/gate-runtime.js +49 -52
  9. package/dist/bundle/claude-runtime.mjs +4295 -3624
  10. package/dist/bundle/codex-runtime.mjs +4136 -3465
  11. package/dist/bundle/cursor-runtime.mjs +4137 -3463
  12. package/dist/commands/doctor.js +21 -3
  13. package/dist/core/audit-analysis.js +7 -4
  14. package/dist/core/audit-io.d.ts +1 -0
  15. package/dist/core/audit-io.js +8 -6
  16. package/dist/core/audit-legacy-archive.d.ts +7 -0
  17. package/dist/core/audit-legacy-archive.js +56 -0
  18. package/dist/core/audit-metrics.d.ts +4 -0
  19. package/dist/core/audit-metrics.js +2 -2
  20. package/dist/core/audit-query.d.ts +2 -0
  21. package/dist/core/audit-query.js +33 -9
  22. package/dist/core/audit-recovery-metrics.js +2 -2
  23. package/dist/core/audit-serialize.d.ts +9 -0
  24. package/dist/core/audit-serialize.js +179 -0
  25. package/dist/core/audit-types.d.ts +1 -0
  26. package/dist/core/capability/policy-engine.js +32 -13
  27. package/dist/core/decision-config-fingerprint.d.ts +3 -0
  28. package/dist/core/decision-config-fingerprint.js +6 -0
  29. package/dist/core/effect-ir/shell-lower.js +208 -2
  30. package/dist/core/verdict/egress-classify.js +1 -0
  31. package/dist/core/verdict/launcher-resolve.js +51 -10
  32. package/dist/core/verdict/makefile-expand.d.ts +4 -0
  33. package/dist/core/verdict/makefile-expand.js +151 -0
  34. package/dist/core/verdict/parser.d.ts +1 -0
  35. package/dist/core/verdict/parser.js +24 -12
  36. package/dist/corpus/benign-probe-cores.d.ts +1 -1
  37. package/dist/corpus/benign-probe-cores.js +2 -0
  38. package/dist/defaults.js +5 -4
  39. package/dist/egress-daemon.js +20 -4
  40. package/dist/installer.js +3 -0
  41. package/dist/runtime-provenance.d.ts +10 -0
  42. package/dist/runtime-provenance.js +63 -0
  43. package/dist/templates.js +12 -7
  44. package/dist/version.d.ts +1 -1
  45. package/dist/version.js +1 -1
  46. package/package.json +2 -1
@@ -3,12 +3,14 @@ import { readFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { getClaudeManagedHookEntries } from '../adapters/claude/hooks.js';
5
5
  import { getCodexManagedHookEntries } from '../adapters/codex/hooks.js';
6
+ import { hasDuplicateCursorShellGates } from '../adapters/cursor/hooks.js';
6
7
  import { getAdapterLayout } from '../adapters/layouts/index.js';
7
8
  import { protectedArtifactRoots } from '../adapters/layouts/protected-paths.js';
8
9
  import { resolveScopedPaths } from '../adapters/layouts/scope.js';
9
10
  import { cleanupOrphanApprovalState } from '../cleanup-orphans.js';
10
11
  import { approvedApprovalsPath, belayStateDir, detectAdapterName, loadLayeredConfig, pendingApprovalsPath, repoLocalStateDirFor, writeConfigFile, } from '../config-io.js';
11
12
  import { approvalSigningKeyPath } from '../core/approval-token.js';
13
+ import { auditRecordHasLegacyCorrelationPlaceholders } from '../core/audit-legacy-archive.js';
12
14
  import { detectFenceDrift, summarizeAuditVisibility } from '../core/audit-summary.js';
13
15
  import { inspectBoundaryAttestationFile } from '../core/capability/boundary-attestation-sign.js';
14
16
  import { boundaryAttestationPath, boundarySessionStatus, } from '../core/capability/boundary-session.js';
@@ -24,7 +26,7 @@ import { probeFileCloneStrategy } from '../core/transactional/file-clone.js';
24
26
  import { isGitWorktreeAvailable } from '../core/transactional/git-worktree.js';
25
27
  import { getManagedHookEntries } from '../defaults.js';
26
28
  import { resolveNodeBinary } from '../node-resolution.js';
27
- import { readInstalledRuntimeProvenance } from '../runtime-provenance.js';
29
+ import { matchesAuditCohort, readInstalledRuntimeProvenance } from '../runtime-provenance.js';
28
30
  import { egressStatus } from '../services/egress-service.js';
29
31
  import { sandboxStatus } from '../services/sandbox-service.js';
30
32
  import { PACKAGE_VERSION } from '../version.js';
@@ -98,6 +100,14 @@ export async function doctorProject(options = {}) {
98
100
  ? `Install scope: global (hooks/runtime at ${scopedPaths.hooksDir})`
99
101
  : 'Install scope: project');
100
102
  notes.push(`Config mode: ${loadedConfig.mode}`);
103
+ const dogfoodActive = loadedConfig.mode === 'audit' && loadedConfig.policy.unknownLocalEffect === 'deny';
104
+ const allGatesDisabled = !loadedConfig.gates.shell &&
105
+ !loadedConfig.gates.toolShell &&
106
+ !loadedConfig.gates.fileMutation &&
107
+ !loadedConfig.gates.subagent;
108
+ if (dogfoodActive && allGatesDisabled) {
109
+ issues.push('Dogfood audit mode is active but all gates are disabled; gate events will not be recorded. Enable gates.shell, gates.toolShell, gates.fileMutation, and/or gates.subagent.');
110
+ }
101
111
  notes.push('Verdict engine (Tier0 + Tier1; location × opacity × effect × confidence). Audit records include schemaVersion 2 axes when available.');
102
112
  const repoLocalDir = repoLocalStateDirFor(repoRoot, loadedConfig);
103
113
  if (loadedConfig.controlPlane.enabled) {
@@ -181,6 +191,9 @@ export async function doctorProject(options = {}) {
181
191
  issues.push(`Missing managed hook for ${event}: ${definition.command}${matcherSuffix}`);
182
192
  }
183
193
  }
194
+ if (hasDuplicateCursorShellGates(hooksFile, process.platform, hooksDir, repoRoot)) {
195
+ warnings.push('Duplicate Cursor Shell preToolUse gates detected. Run belay upgrade to dedupe managed hooks.');
196
+ }
184
197
  }
185
198
  else if (adapterName === 'codex') {
186
199
  // Codex hooks live in TOML (.codex/config.toml). Verify belay's managed command strings
@@ -280,9 +293,14 @@ export async function doctorProject(options = {}) {
280
293
  const metrics = await metricsProject({ targetDir: repoRoot });
281
294
  const cohortIdentity = metrics.currentCohort.identity;
282
295
  const cohortAuditRecords = cohortIdentity
283
- ? auditRecords.filter((record) => record.runtimeBuildStamp === cohortIdentity.runtimeBuildStamp &&
284
- record.configFingerprint === cohortIdentity.configFingerprint)
296
+ ? auditRecords.filter((record) => matchesAuditCohort(record, cohortIdentity))
285
297
  : [];
298
+ const gateDecisionRecords = cohortAuditRecords.filter((record) => record.kind === 'shell' || record.kind === 'tool' || record.kind === 'subagent');
299
+ if (gateDecisionRecords
300
+ .slice(0, 200)
301
+ .some((record) => auditRecordHasLegacyCorrelationPlaceholders(record))) {
302
+ warnings.push('Gate audit records contain scrub placeholders in correlation fields (<timestamp>, <high-entropy>, <approval-id>). Historical metrics are unreliable until a schema v3 cohort is collected.');
303
+ }
286
304
  const auditVisibility = summarizeAuditVisibility(cohortAuditRecords);
287
305
  const drift = detectFenceDrift(auditVisibility, {
288
306
  threshold: loadedConfig.policy.fenceWarnThreshold,
@@ -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,22 +1,24 @@
1
- import { appendFile, mkdir } from 'node:fs/promises';
2
1
  import path from 'node:path';
3
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';
4
6
  export async function appendCliAuditEvent(repoRoot, config, event) {
5
7
  const auditPath = path.isAbsolute(config.audit.logPath)
6
8
  ? config.audit.logPath
7
9
  : path.join(repoRoot, config.audit.logPath);
8
- await mkdir(path.dirname(auditPath), { recursive: true });
9
10
  const cohort = await resolveActiveAuditCohort(repoRoot, config);
10
- const line = JSON.stringify({
11
- ts: new Date().toISOString(),
11
+ await appendAuditRecord(auditPath, {
12
12
  source: 'belay-cli',
13
13
  ...(cohort
14
14
  ? {
15
15
  runtimeBuildStamp: cohort.runtimeBuildStamp,
16
+ runtimeArtifactHash: cohort.runtimeArtifactHash,
17
+ decisionConfigFingerprint: cohort.decisionConfigFingerprint,
18
+ boundaryProfile: cohort.boundaryProfile,
16
19
  configFingerprint: cohort.configFingerprint,
17
20
  }
18
21
  : {}),
19
22
  ...event,
20
- });
21
- await appendFile(auditPath, `${line}\n`, 'utf8');
23
+ }, scrubOptionsFromConfig(config));
22
24
  }
@@ -0,0 +1,7 @@
1
+ import type { BelayConfigV3 } from './config.js';
2
+ export declare function auditLogHasLegacyScrubPlaceholders(sample: string): boolean;
3
+ export declare function auditRecordHasLegacyCorrelationPlaceholders(record: Record<string, unknown>): boolean;
4
+ export declare function archiveLegacyAuditLogIfNeeded(repoRoot: string, config: BelayConfigV3): Promise<{
5
+ archived: boolean;
6
+ archivedPath?: string;
7
+ }>;
@@ -0,0 +1,56 @@
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
+ export function auditRecordHasLegacyCorrelationPlaceholders(record) {
15
+ return (record.timestamp === '<timestamp>' ||
16
+ record.fingerprint === '<high-entropy>' ||
17
+ record.approvalId === '<approval-id>');
18
+ }
19
+ async function auditFileHasLegacyScrubPlaceholders(auditPath) {
20
+ const handle = await open(auditPath, 'r');
21
+ const buffer = Buffer.allocUnsafe(AUDIT_SCAN_CHUNK_BYTES);
22
+ let carry = '';
23
+ let position = 0;
24
+ try {
25
+ while (true) {
26
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, position);
27
+ if (bytesRead === 0) {
28
+ return false;
29
+ }
30
+ const sample = carry + buffer.toString('utf8', 0, bytesRead);
31
+ if (auditLogHasLegacyScrubPlaceholders(sample)) {
32
+ return true;
33
+ }
34
+ carry = sample.slice(-AUDIT_SCAN_OVERLAP_CHARS);
35
+ position += bytesRead;
36
+ }
37
+ }
38
+ finally {
39
+ await handle.close();
40
+ }
41
+ }
42
+ export async function archiveLegacyAuditLogIfNeeded(repoRoot, config) {
43
+ const auditPath = path.isAbsolute(config.audit.logPath)
44
+ ? config.audit.logPath
45
+ : path.join(repoRoot, config.audit.logPath);
46
+ if (!existsSync(auditPath)) {
47
+ return { archived: false };
48
+ }
49
+ if (!(await auditFileHasLegacyScrubPlaceholders(auditPath))) {
50
+ return { archived: false };
51
+ }
52
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
53
+ const archivePath = `${auditPath}.legacy-${timestamp}.ndjson`;
54
+ await rename(auditPath, archivePath);
55
+ return { archived: true, archivedPath: archivePath };
56
+ }
@@ -4,6 +4,10 @@ import type { AvailabilityAskCounts, ReasonApprovalRatio, RepeatedFingerprintAsk
4
4
  /** Minimum gate events before recommending enforce with zero would-block rate. */
5
5
  export declare const MIN_GATE_EVENTS_FOR_ENFORCE = 20;
6
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. */
7
11
  runtimeBuildStamp: string;
8
12
  configFingerprint: string;
9
13
  }
@@ -1,3 +1,4 @@
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';
3
4
  import { computeRecoveryMetrics, } from './audit-recovery-metrics.js';
@@ -106,8 +107,7 @@ export function computeAuditMetrics(records, options = {}) {
106
107
  .slice(0, 10);
107
108
  const activeCohort = options.activeCohort ?? null;
108
109
  const cohortRecords = activeCohort
109
- ? auditRecords.filter((record) => record.runtimeBuildStamp === activeCohort.runtimeBuildStamp &&
110
- record.configFingerprint === activeCohort.configFingerprint)
110
+ ? auditRecords.filter((record) => matchesAuditCohort(record, activeCohort))
111
111
  : [];
112
112
  const cohortGateRecords = cohortRecords.filter((record) => {
113
113
  const event = typeof record.event === 'string' ? record.event : '';
@@ -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
  }
@@ -1,3 +1,4 @@
1
+ import { matchesAuditCohort } from '../runtime-provenance.js';
1
2
  import { GATE_EVENTS } from './audit-types.js';
2
3
  const RESTORE_EVENTS = new Set(['recoveryApplied', 'recoveryConflict', 'recoveryRejected']);
3
4
  const STABLE_FAILURE_REASON = /^[a-z][a-z0-9_]*$/;
@@ -35,8 +36,7 @@ function isRecoveryRestoreRecord(record) {
35
36
  return RESTORE_EVENTS.has(event);
36
37
  }
37
38
  function matchesCohort(record, cohort) {
38
- return (record.runtimeBuildStamp === cohort.runtimeBuildStamp &&
39
- record.configFingerprint === cohort.configFingerprint);
39
+ return matchesAuditCohort(record, cohort);
40
40
  }
41
41
  function emptySnapshotMetrics() {
42
42
  return {
@@ -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>;
@@ -0,0 +1,179 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { appendFile, mkdir } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { scrubString, scrubValue } from './scrub.js';
5
+ export const AUDIT_SCHEMA_VERSION = 3;
6
+ const ISO8601_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
7
+ const HEX64_PATTERN = /^[a-f0-9]{64}$/;
8
+ const SCRUB_PLACEHOLDERS = new Set(['<timestamp>', '<high-entropy>', '<approval-id>', '<uuid>']);
9
+ const PRESERVED_HASH_FIELDS = new Set([
10
+ 'fingerprint',
11
+ 'commandFingerprint',
12
+ 'effectIRHash',
13
+ 'payloadHash',
14
+ 'configFingerprint',
15
+ 'runtimeArtifactHash',
16
+ 'decisionConfigFingerprint',
17
+ 'receiptHash',
18
+ ]);
19
+ const PRESERVED_LITERAL_FIELDS = new Set([
20
+ 'timestamp',
21
+ 'approvalCorrelationId',
22
+ 'runtimeVersion',
23
+ 'runtimeBuildStamp',
24
+ 'boundaryProfile',
25
+ 'schemaVersion',
26
+ 'imageId',
27
+ 'mirrorBackend',
28
+ 'wouldMediate',
29
+ 'exitCode',
30
+ 'timedOut',
31
+ ]);
32
+ const SCRUBBED_CONTAINER_FIELDS = new Set([
33
+ 'summary',
34
+ 'command',
35
+ 'payload',
36
+ 'replayContext',
37
+ 'actionSnapshot',
38
+ 'assessment',
39
+ 'predictedAssessment',
40
+ 'observedAssessment',
41
+ ]);
42
+ export function approvalCorrelationId(approvalId) {
43
+ return createHash('sha256').update(approvalId).digest('hex').slice(0, 16);
44
+ }
45
+ export function isValidApprovalCorrelationId(value) {
46
+ return /^[a-f0-9]{16}$/.test(value);
47
+ }
48
+ export function isValidAuditTimestamp(value) {
49
+ if (SCRUB_PLACEHOLDERS.has(value) || !ISO8601_PATTERN.test(value)) {
50
+ return false;
51
+ }
52
+ return !Number.isNaN(Date.parse(value));
53
+ }
54
+ export function isValidAuditFingerprint(value) {
55
+ return HEX64_PATTERN.test(value) && !SCRUB_PLACEHOLDERS.has(value);
56
+ }
57
+ function isValidPreservedHashField(field, value) {
58
+ if (field === 'receiptHash') {
59
+ return HEX64_PATTERN.test(value);
60
+ }
61
+ if (field === 'imageId') {
62
+ return /^sha256:[a-f0-9]{64}$/.test(value);
63
+ }
64
+ return isValidAuditFingerprint(value);
65
+ }
66
+ function scrubAuditContainer(value, options) {
67
+ return scrubValue(value, {
68
+ ...options,
69
+ maskHighEntropyStrings: true,
70
+ });
71
+ }
72
+ function serializeAuditField(key, value, options) {
73
+ if (value === undefined) {
74
+ return undefined;
75
+ }
76
+ if (key === 'approvalId') {
77
+ return undefined;
78
+ }
79
+ if (key === 'ts' && typeof value === 'string' && isValidAuditTimestamp(value)) {
80
+ return undefined;
81
+ }
82
+ if (PRESERVED_LITERAL_FIELDS.has(key)) {
83
+ if (key === 'timestamp' && typeof value === 'string' && isValidAuditTimestamp(value)) {
84
+ return value;
85
+ }
86
+ if (key === 'approvalCorrelationId' &&
87
+ typeof value === 'string' &&
88
+ isValidApprovalCorrelationId(value)) {
89
+ return value;
90
+ }
91
+ if ((key === 'runtimeVersion' || key === 'runtimeBuildStamp' || key === 'boundaryProfile') &&
92
+ typeof value === 'string' &&
93
+ value.length > 0) {
94
+ return value;
95
+ }
96
+ if (key === 'schemaVersion' && typeof value === 'number') {
97
+ return value;
98
+ }
99
+ if (key === 'mirrorBackend' && value === 'file_copy') {
100
+ return value;
101
+ }
102
+ if (key === 'wouldMediate' && value === true) {
103
+ return true;
104
+ }
105
+ if (key === 'exitCode' && (value === null || Number.isSafeInteger(value))) {
106
+ return value;
107
+ }
108
+ if (key === 'timedOut' && typeof value === 'boolean') {
109
+ return value;
110
+ }
111
+ if (key === 'imageId' && typeof value === 'string' && /^sha256:[a-f0-9]{64}$/.test(value)) {
112
+ return value;
113
+ }
114
+ return undefined;
115
+ }
116
+ if (PRESERVED_HASH_FIELDS.has(key) && typeof value === 'string') {
117
+ return isValidPreservedHashField(key, value) ? value : undefined;
118
+ }
119
+ if (SCRUBBED_CONTAINER_FIELDS.has(key)) {
120
+ return scrubAuditContainer(value, options);
121
+ }
122
+ if (typeof value === 'string') {
123
+ return scrubString(value, { ...options, maskHighEntropyStrings: true });
124
+ }
125
+ if (value !== null && typeof value === 'object') {
126
+ return scrubAuditContainer(value, options);
127
+ }
128
+ return value;
129
+ }
130
+ export function serializeAuditRecordV3(record, options) {
131
+ const timestamp = typeof record.timestamp === 'string' && isValidAuditTimestamp(record.timestamp)
132
+ ? record.timestamp
133
+ : typeof record.ts === 'string' && isValidAuditTimestamp(record.ts)
134
+ ? record.ts
135
+ : new Date().toISOString();
136
+ const serialized = {
137
+ schemaVersion: AUDIT_SCHEMA_VERSION,
138
+ timestamp,
139
+ };
140
+ if (typeof record.approvalId === 'string' && record.approvalId.length > 0) {
141
+ serialized.approvalCorrelationId = approvalCorrelationId(record.approvalId);
142
+ }
143
+ else if (typeof record.approvalCorrelationId === 'string' &&
144
+ isValidApprovalCorrelationId(record.approvalCorrelationId)) {
145
+ serialized.approvalCorrelationId = record.approvalCorrelationId;
146
+ }
147
+ for (const [key, value] of Object.entries(record)) {
148
+ if (key === 'timestamp' || key === 'ts' || key === 'approvalId' || key === 'schemaVersion') {
149
+ continue;
150
+ }
151
+ const next = serializeAuditField(key, value, options);
152
+ if (next !== undefined) {
153
+ serialized[key] = next;
154
+ }
155
+ }
156
+ return serialized;
157
+ }
158
+ export function parseAuditNdjsonLine(line) {
159
+ const trimmed = line.trim();
160
+ if (!trimmed) {
161
+ return null;
162
+ }
163
+ try {
164
+ const parsed = JSON.parse(trimmed);
165
+ if (typeof parsed.ts === 'string' && parsed.timestamp === undefined) {
166
+ parsed.timestamp = parsed.ts;
167
+ delete parsed.ts;
168
+ }
169
+ return parsed;
170
+ }
171
+ catch {
172
+ return null;
173
+ }
174
+ }
175
+ export async function appendAuditRecord(auditPath, record, options) {
176
+ await mkdir(path.dirname(auditPath), { recursive: true });
177
+ const line = JSON.stringify(serializeAuditRecordV3(record, options));
178
+ await appendFile(auditPath, `${line}\n`, 'utf8');
179
+ }
@@ -69,6 +69,7 @@ export interface ApprovalRoundTrip {
69
69
  approvalTimestamp?: string;
70
70
  executeTimestamp?: string;
71
71
  approvalId?: string;
72
+ approvalCorrelationId?: string;
72
73
  fingerprint: string;
73
74
  reason: string;
74
75
  summary: string;
@@ -40,17 +40,11 @@ function effectPathIsLocal(target, context) {
40
40
  const resolved = resolveCapabilityPath(target, context.cwd);
41
41
  return (resolveWorkspaceRootMatch(context.repoRoot, [...(context.trustedWorkspaceRoots ?? [])], resolved) !== null);
42
42
  }
43
- function effectPathIsHighStakes(target, context) {
43
+ function effectPathIsSecretOrCredentialPath(target, context) {
44
44
  const resolved = resolveCapabilityPath(target, context.cwd);
45
45
  if (HIGH_STAKES_EFFECT_PATHS.some((pattern) => pattern.test(resolved))) {
46
46
  return true;
47
47
  }
48
- if (context.protectedArtifactRoots?.some((root) => pathWithinRoot(canonicalPath(root), resolved))) {
49
- return true;
50
- }
51
- if (isGitMetadataPath(resolved, context.repoRoot)) {
52
- return true;
53
- }
54
48
  const workspaceMatch = resolveWorkspaceRootMatch(context.repoRoot, [...(context.trustedWorkspaceRoots ?? [])], resolved);
55
49
  if (!workspaceMatch) {
56
50
  return false;
@@ -59,6 +53,24 @@ function effectPathIsHighStakes(target, context) {
59
53
  ...(context.sensitivePaths ?? []),
60
54
  ]);
61
55
  }
56
+ function effectPathIsControlPlaneArtifactPath(target, context) {
57
+ const resolved = resolveCapabilityPath(target, context.cwd);
58
+ return (context.protectedArtifactRoots?.some((root) => pathWithinRoot(canonicalPath(root), resolved)) ??
59
+ false);
60
+ }
61
+ function effectPathIsHighStakes(target, context) {
62
+ if (effectPathIsSecretOrCredentialPath(target, context)) {
63
+ return true;
64
+ }
65
+ if (effectPathIsControlPlaneArtifactPath(target, context)) {
66
+ return true;
67
+ }
68
+ const resolved = resolveCapabilityPath(target, context.cwd);
69
+ if (isGitMetadataPath(resolved, context.repoRoot)) {
70
+ return true;
71
+ }
72
+ return false;
73
+ }
62
74
  /**
63
75
  * Policy for a lowered shell effect. It intentionally ignores executable
64
76
  * command names, segment heads, command text, and config allowlists.
@@ -115,12 +127,13 @@ export function evaluateEffectRequirementPolicy(requirement, context) {
115
127
  }
116
128
  switch (requirement.action) {
117
129
  case 'fs.read':
118
- if (requirement.resource.kind === 'path' &&
119
- effectPathIsHighStakes(requirement.resource.path, context)) {
120
- return effectDecision('ask', 'high_stakes_path', 'effect.fs_read_high_stakes', [
121
- ...signals,
122
- 'sensitive_path_read',
123
- ]);
130
+ if (requirement.resource.kind === 'path') {
131
+ if (effectPathIsSecretOrCredentialPath(requirement.resource.path, context)) {
132
+ return effectDecision('ask', 'high_stakes_path', 'effect.fs_read_high_stakes', [
133
+ ...signals,
134
+ 'sensitive_path_read',
135
+ ]);
136
+ }
124
137
  }
125
138
  return effectDecision('allow', 'read_only', 'effect.fs_read', signals);
126
139
  case 'fs.write':
@@ -130,6 +143,12 @@ export function evaluateEffectRequirementPolicy(requirement, context) {
130
143
  if (requirement.resource.kind !== 'path') {
131
144
  return effectDecision('ask', 'indeterminate_effect', 'effect.fs_write_unknown', signals);
132
145
  }
146
+ if (effectPathIsControlPlaneArtifactPath(requirement.resource.path, context)) {
147
+ return effectDecision('ask', 'control_plane_mutation', 'effect.control_plane_write', [
148
+ ...signals,
149
+ 'control_plane_path',
150
+ ]);
151
+ }
133
152
  if (effectPathIsHighStakes(requirement.resource.path, context)) {
134
153
  return effectDecision('ask', 'high_stakes_path', 'effect.fs_write_high_stakes', [
135
154
  ...signals,
@@ -0,0 +1,3 @@
1
+ import type { BelayConfigV3 } from './config.js';
2
+ /** Hash of config fields that affect authorization decisions (excludes mode, audit paths, judge, notifications). */
3
+ export declare function hashDecisionConfig(config: BelayConfigV3): string;
@@ -0,0 +1,6 @@
1
+ import { canonicalStringify, hashValue } from './fingerprint.js';
2
+ /** Hash of config fields that affect authorization decisions (excludes mode, audit paths, judge, notifications). */
3
+ export function hashDecisionConfig(config) {
4
+ const { mode: _mode, notifications: _notifications, audit: _audit, judge: _judge, redaction: _redaction, installScope: _installScope, overrides: _overrides, tokenPrefix: _tokenPrefix, approvalTtlMinutes: _approvalTtlMinutes, ...decisionRelevant } = config;
5
+ return hashValue(canonicalStringify(decisionRelevant));
6
+ }