@guilz-dev/belay 0.9.0 → 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 (39) 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 +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 +1 -0
  8. package/dist/adapters/shared/gate-runtime.js +49 -52
  9. package/dist/bundle/claude-runtime.mjs +3831 -3581
  10. package/dist/bundle/codex-runtime.mjs +3671 -3421
  11. package/dist/bundle/cursor-runtime.mjs +3677 -3424
  12. package/dist/commands/doctor.js +15 -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 +6 -0
  17. package/dist/core/audit-legacy-archive.js +51 -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 +3 -0
  30. package/dist/core/verdict/egress-classify.js +1 -0
  31. package/dist/defaults.js +0 -8
  32. package/dist/egress-daemon.js +20 -4
  33. package/dist/installer.js +3 -0
  34. package/dist/runtime-provenance.d.ts +10 -0
  35. package/dist/runtime-provenance.js +63 -0
  36. package/dist/templates.js +12 -7
  37. package/dist/version.d.ts +1 -1
  38. package/dist/version.js +1 -1
  39. package/package.json +2 -1
package/README.md CHANGED
@@ -122,7 +122,13 @@ approved shell action first, then lets the remaining prompt continue. CLI replay
122
122
 
123
123
  Approvals are one-shot and expire after 15 minutes by default. Every decision is
124
124
  written to `.cursor/belay/audit.ndjson`, `.claude/belay/audit.ndjson`, or
125
- `.codex/belay/audit.ndjson` (depending on the adapter).
125
+ `.codex/belay/audit.ndjson` (depending on the adapter). Schema v3 preserves ISO
126
+ timestamps, fingerprints, and `approvalCorrelationId` for metrics joins. Dogfood
127
+ readiness counts only the active cohort (runtime bundle + authorization-relevant
128
+ config + boundary profile); legacy placeholder logs should be archived via
129
+ `belay upgrade` before trusting readiness. See
130
+ [config schema — audit log](./docs/config-schema.md#audit-log-ndjson-schema-v3) and
131
+ [dogfood audit remediation](./docs/dogfood-audit-remediation-2026-08-22.ja.md).
126
132
 
127
133
  In **audit mode** (`mode: "audit"`), would-be denials are recorded
128
134
  (`wouldBlock: true`) but execution still continues, and no approval IDs are
@@ -162,6 +162,7 @@ export async function runShellGateHook() {
162
162
  command,
163
163
  payload,
164
164
  toolName: 'Bash',
165
+ sourceEvent: 'PreToolUse',
165
166
  });
166
167
  jsonResponse(gateVerdictToClaudePreToolUseResponse(verdict));
167
168
  }
@@ -261,6 +262,7 @@ export async function runToolGateHook(_eventName) {
261
262
  cwd,
262
263
  payload: normalizedPayload,
263
264
  toolName,
265
+ sourceEvent: 'PreToolUse',
264
266
  });
265
267
  jsonResponse(gateVerdictToClaudePreToolUseResponse(verdict));
266
268
  }
@@ -180,6 +180,7 @@ export async function runToolGateHook(eventName) {
180
180
  command: kind === 'shell' ? extractString(normalizedPayload.tool_input, 'command') : undefined,
181
181
  payload: normalizedPayload,
182
182
  toolName,
183
+ sourceEvent: eventName,
183
184
  });
184
185
  jsonResponse(gateVerdictToCodexPreToolUseResponse(verdict));
185
186
  }
@@ -209,6 +210,7 @@ export async function runShellGateHook() {
209
210
  command,
210
211
  payload,
211
212
  toolName: 'Shell',
213
+ sourceEvent: 'PreToolUse',
212
214
  });
213
215
  jsonResponse(gateVerdictToCodexPreToolUseResponse(verdict));
214
216
  }
@@ -1,2 +1,6 @@
1
- import type { HooksFile } from '../../types.js';
1
+ import type { HookEntry, HooksFile } from '../../types.js';
2
+ /** Legacy Belay-managed Shell preToolUse gate duplicated beforeShellExecution. */
3
+ export declare function legacyManagedShellPreToolUseEntry(platform: NodeJS.Platform, hooksDir: string, repoRoot: string): HookEntry;
4
+ export declare function removeLegacyManagedShellPreToolUse(hooks: HooksFile, platform: NodeJS.Platform, hooksDir: string, repoRoot: string): HooksFile;
2
5
  export declare function mergeCursorHooksFile(current: HooksFile, platform: NodeJS.Platform, hooksDir: string, repoRoot: string): HooksFile;
6
+ export declare function hasDuplicateCursorShellGates(hooks: HooksFile, platform: NodeJS.Platform, hooksDir: string, repoRoot: string): boolean;
@@ -10,10 +10,37 @@ function mergeHookEntry(current, expected, placement) {
10
10
  }
11
11
  return [...filtered, expected];
12
12
  }
13
+ /** Legacy Belay-managed Shell preToolUse gate duplicated beforeShellExecution. */
14
+ export function legacyManagedShellPreToolUseEntry(platform, hooksDir, repoRoot) {
15
+ const managed = getManagedHookEntries(platform, hooksDir, repoRoot);
16
+ const referencePreToolUse = managed.find((entry) => entry.event === 'preToolUse' && entry.definition.matcher === 'Write')?.definition;
17
+ if (!referencePreToolUse) {
18
+ throw new Error('managed hook definitions missing for legacy Shell migration');
19
+ }
20
+ return {
21
+ command: referencePreToolUse.command,
22
+ matcher: 'Shell',
23
+ };
24
+ }
25
+ export function removeLegacyManagedShellPreToolUse(hooks, platform, hooksDir, repoRoot) {
26
+ const legacy = legacyManagedShellPreToolUseEntry(platform, hooksDir, repoRoot);
27
+ const preToolUse = hooks.hooks.preToolUse;
28
+ if (!Array.isArray(preToolUse)) {
29
+ return hooks;
30
+ }
31
+ return {
32
+ ...hooks,
33
+ hooks: {
34
+ ...hooks.hooks,
35
+ preToolUse: preToolUse.filter((entry) => !entryMatches(entry, legacy)),
36
+ },
37
+ };
38
+ }
13
39
  export function mergeCursorHooksFile(current, platform, hooksDir, repoRoot) {
40
+ const withoutLegacyShell = removeLegacyManagedShellPreToolUse(current, platform, hooksDir, repoRoot);
14
41
  const next = {
15
- version: current.version || 1,
16
- hooks: { ...current.hooks },
42
+ version: withoutLegacyShell.version || 1,
43
+ hooks: { ...withoutLegacyShell.hooks },
17
44
  };
18
45
  const managedEntries = getManagedHookEntries(platform, hooksDir, repoRoot);
19
46
  for (const { event, definition } of managedEntries) {
@@ -24,3 +51,11 @@ export function mergeCursorHooksFile(current, platform, hooksDir, repoRoot) {
24
51
  }
25
52
  return next;
26
53
  }
54
+ export function hasDuplicateCursorShellGates(hooks, platform, hooksDir, repoRoot) {
55
+ const legacy = legacyManagedShellPreToolUseEntry(platform, hooksDir, repoRoot);
56
+ const preToolUse = hooks.hooks.preToolUse;
57
+ if (!Array.isArray(preToolUse)) {
58
+ return false;
59
+ }
60
+ return preToolUse.some((entry) => entryMatches(entry, legacy));
61
+ }
@@ -65,6 +65,7 @@ export async function runShellGateHook() {
65
65
  kind: 'shell',
66
66
  cwd,
67
67
  command,
68
+ sourceEvent: 'beforeShellExecution',
68
69
  });
69
70
  jsonResponse(gateVerdictToCursorResponse(verdict));
70
71
  }
@@ -87,6 +88,7 @@ export async function runToolGateHook(eventName) {
87
88
  kind: 'subagent',
88
89
  cwd,
89
90
  payload,
91
+ sourceEvent: eventName,
90
92
  });
91
93
  jsonResponse(gateVerdictToCursorResponse(verdict));
92
94
  return;
@@ -97,6 +99,7 @@ export async function runToolGateHook(eventName) {
97
99
  cwd,
98
100
  payload,
99
101
  toolName,
102
+ sourceEvent: eventName,
100
103
  });
101
104
  jsonResponse(gateVerdictToCursorResponse(verdict));
102
105
  return;
@@ -107,6 +110,7 @@ export async function runToolGateHook(eventName) {
107
110
  cwd,
108
111
  payload,
109
112
  toolName,
113
+ sourceEvent: eventName,
110
114
  });
111
115
  jsonResponse(gateVerdictToCursorResponse(verdict));
112
116
  return;
@@ -116,6 +120,7 @@ export async function runToolGateHook(eventName) {
116
120
  kind: 'subagent',
117
121
  cwd,
118
122
  payload,
123
+ sourceEvent: eventName,
119
124
  });
120
125
  jsonResponse(gateVerdictToCursorResponse(verdict));
121
126
  return;
@@ -46,6 +46,7 @@ export declare function evaluateGatedAction(ctx: GateRuntimeContext, deps: GateR
46
46
  command?: string;
47
47
  payload?: Record<string, unknown>;
48
48
  toolName?: string;
49
+ sourceEvent?: string;
49
50
  }): Promise<GateVerdict>;
50
51
  /** R39: unmapped Codex tools ask via pending approval — not hard deny without approval path. */
51
52
  export declare function gateUnmappedToolVerdict(ctx: GateRuntimeContext, deps: GateRuntimeDeps, toolName: string, payload: Record<string, unknown>): Promise<GateVerdict>;
@@ -1,4 +1,4 @@
1
- import { createHash, randomUUID } from 'node:crypto';
1
+ import { randomUUID } from 'node:crypto';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
4
  import path from 'node:path';
@@ -7,6 +7,7 @@ import { buildReplayHint, buildRetryInstructionForConfig, canAutoReplay, getExec
7
7
  import { claimApprovedForReplay, gateApprovalStoreFromDeps, recordApproval, } from '../../core/approval-service.js';
8
8
  import { issueApprovalToken } from '../../core/approval-token.js';
9
9
  import { buildAuditActionSnapshot, buildAuditReplayContext, } from '../../core/audit-replay-context.js';
10
+ import { approvalCorrelationId, serializeAuditRecordV3 } from '../../core/audit-serialize.js';
10
11
  import { boundedUtf8Tail } from '../../core/bounded-output.js';
11
12
  import { mutateApprovalStateWithRetry } from '../../core/capability/approval-state-mutation.js';
12
13
  import { APPROVAL_STATE_VERSION_V3 } from '../../core/capability/approval-v3.js';
@@ -37,8 +38,8 @@ import { canonicalPath } from '../../core/path-utils.js';
37
38
  import { recoveryFailClosedResult, recoveryFailReasonFromSkip, } from '../../core/recovery/fail-closed.js';
38
39
  import { fingerprintReplayPayload } from '../../core/replay-scrub.js';
39
40
  import { FILE_CHECKPOINT_ISOLATION_UNAVAILABLE, isTransactionalEligible, runTransactionalExecution, TRANSACTIONAL_ALREADY_APPLIED, TRANSACTIONAL_APPROVAL_BYPASS_REASONS, } from '../../core/transactional/index.js';
41
+ import { buildAuditProvenanceFields } from '../../runtime-provenance.js';
40
42
  import { egressStatus } from '../../services/egress-service.js';
41
- import { PACKAGE_VERSION } from '../../version.js';
42
43
  import { protectedArtifactRoots } from '../layouts/protected-paths.js';
43
44
  const EMPTY_APPROVALS = {
44
45
  version: 1,
@@ -47,34 +48,7 @@ const EMPTY_APPROVALS = {
47
48
  const RUNTIME_PROVENANCE_KEY = Symbol.for('agent-belay.runtime-provenance');
48
49
  function auditProvenance(config) {
49
50
  const runtime = globalThis[RUNTIME_PROVENANCE_KEY];
50
- const runtimeVersion = typeof runtime?.runtimeVersion === 'string' ? runtime.runtimeVersion : PACKAGE_VERSION;
51
- const runtimeBuildStamp = typeof runtime?.runtimeBuildStamp === 'string'
52
- ? runtime.runtimeBuildStamp
53
- : `${PACKAGE_VERSION}@source`;
54
- return {
55
- runtimeVersion,
56
- runtimeBuildStamp,
57
- configFingerprint: hashValue(canonicalStringify(config)),
58
- };
59
- }
60
- function preservedContainedAuditMetadata(record) {
61
- const safe = {};
62
- if (record.wouldMediate === true)
63
- safe.wouldMediate = true;
64
- if (typeof record.receiptHash === 'string' && /^[a-f0-9]{64}$/.test(record.receiptHash)) {
65
- safe.receiptHash = record.receiptHash;
66
- }
67
- if (typeof record.imageId === 'string' && /^sha256:[a-f0-9]{64}$/.test(record.imageId)) {
68
- safe.imageId = record.imageId;
69
- }
70
- if (record.mirrorBackend === 'file_copy')
71
- safe.mirrorBackend = record.mirrorBackend;
72
- if (record.exitCode === null || Number.isSafeInteger(record.exitCode)) {
73
- safe.exitCode = record.exitCode;
74
- }
75
- if (typeof record.timedOut === 'boolean')
76
- safe.timedOut = record.timedOut;
77
- return safe;
51
+ return buildAuditProvenanceFields(config, runtime);
78
52
  }
79
53
  function adapterIdFromContext(ctx) {
80
54
  if (ctx.config.adapter === 'cursor' ||
@@ -87,6 +61,14 @@ function adapterIdFromContext(ctx) {
87
61
  }
88
62
  return undefined;
89
63
  }
64
+ function extractShellCommandFromPayload(payload) {
65
+ const toolInput = payload.tool_input;
66
+ if (!toolInput || typeof toolInput !== 'object') {
67
+ return '';
68
+ }
69
+ const input = toolInput;
70
+ return typeof input.command === 'string' ? input.command : '';
71
+ }
90
72
  const REPLAY_AUDIT_FAILURE_NOTE = ' Audit recording failed; inspect audit storage before the next approval.';
91
73
  async function appendReplayAuditSafely(ctx, deps, event) {
92
74
  try {
@@ -117,15 +99,15 @@ export function createDefaultGateRuntimeDeps() {
117
99
  const provenance = auditProvenance(ctx.config);
118
100
  const record = {
119
101
  timestamp: new Date().toISOString(),
102
+ mode: ctx.config.mode,
120
103
  ...event,
121
104
  ...provenance,
122
105
  };
123
106
  if (!ctx.config.audit.includeAssessment) {
124
107
  delete record.assessment;
125
108
  }
126
- const scrubbed = scrubValue(record, scrubOptionsFromConfig(ctx.config));
127
- Object.assign(scrubbed, provenance, preservedContainedAuditMetadata(record));
128
- await writeFile(auditPath, `${JSON.stringify(scrubbed)}\n`, {
109
+ const serialized = serializeAuditRecordV3(record, scrubOptionsFromConfig(ctx.config));
110
+ await writeFile(auditPath, `${JSON.stringify(serialized)}\n`, {
129
111
  encoding: 'utf8',
130
112
  flag: 'a',
131
113
  });
@@ -210,6 +192,22 @@ function gateAuditEventName(kind) {
210
192
  }
211
193
  return 'subagentGate';
212
194
  }
195
+ function resolveGateAuditEvent(sourceEvent, kind) {
196
+ if (!sourceEvent) {
197
+ return gateAuditEventName(kind);
198
+ }
199
+ switch (sourceEvent.toLowerCase()) {
200
+ case 'beforeshellexecution':
201
+ return 'beforeShellExecution';
202
+ case 'pretooluse':
203
+ return 'preToolUse';
204
+ case 'subagentstart':
205
+ case 'subagentgate':
206
+ return 'subagentGate';
207
+ default:
208
+ return gateAuditEventName(kind);
209
+ }
210
+ }
213
211
  async function ensurePendingApproval(ctx, deps, kind, result, approvalInput, scopeHint) {
214
212
  const candidate = createApprovalRecordWithEnvelope({
215
213
  kind,
@@ -245,17 +243,6 @@ async function ensurePendingApproval(ctx, deps, kind, result, approvalInput, sco
245
243
  }
246
244
  return outcome;
247
245
  }
248
- function approvalCorrelationId(approvalId) {
249
- return createHash('sha256').update(approvalId).digest('hex').slice(0, 16);
250
- }
251
- function extractShellCommandFromPayload(payload) {
252
- const toolInput = payload.tool_input;
253
- if (!toolInput || typeof toolInput !== 'object') {
254
- return '';
255
- }
256
- const input = toolInput;
257
- return typeof input.command === 'string' ? input.command : '';
258
- }
259
246
  function deriveWorkspaceRootScopeHint(params) {
260
247
  if (params.result.reason !== 'outside_repo_mutation' &&
261
248
  params.result.reason !== 'outside_repo_redirect') {
@@ -408,10 +395,13 @@ function normalizeContainedFailure(error) {
408
395
  }
409
396
  return new ContainedExecutionFailureError('contained_execution_boundary_failed', { cause: error });
410
397
  }
411
- function containedAuditBase(kind, mode, result) {
398
+ function containedAuditBase(kind, mode, result, sourceEvent) {
412
399
  const planFields = effectPlanAuditFields(result.effectPlan);
400
+ const rawSourceEvent = sourceEvent ?? gateAuditEventName(kind);
401
+ const auditEvent = resolveGateAuditEvent(rawSourceEvent, kind);
413
402
  return {
414
- event: gateAuditEventName(kind),
403
+ event: auditEvent,
404
+ sourceEvent: rawSourceEvent,
415
405
  kind,
416
406
  fingerprint: result.fingerprint,
417
407
  mode,
@@ -426,11 +416,11 @@ function containedAuditBase(kind, mode, result) {
426
416
  };
427
417
  }
428
418
  async function mediateContainedUnknownExecution(params) {
429
- const { ctx, deps, action, command } = params;
419
+ const { ctx, deps, action, command, sourceEvent } = params;
430
420
  const result = { ...params.result, wouldMediate: true };
431
421
  if (ctx.config.mode === 'audit') {
432
422
  await deps.appendAudit(ctx, {
433
- ...containedAuditBase(action.kind, ctx.config.mode, result),
423
+ ...containedAuditBase(action.kind, ctx.config.mode, result, sourceEvent),
434
424
  verdict: result.verdict,
435
425
  reason: result.reason,
436
426
  wouldBlock: true,
@@ -505,7 +495,7 @@ async function mediateContainedUnknownExecution(params) {
505
495
  reason,
506
496
  };
507
497
  await deps.appendAudit(ctx, {
508
- ...containedAuditBase(action.kind, ctx.config.mode, failedResult),
498
+ ...containedAuditBase(action.kind, ctx.config.mode, failedResult, sourceEvent),
509
499
  verdict: failedResult.verdict,
510
500
  reason,
511
501
  wouldBlock: true,
@@ -546,7 +536,7 @@ async function mediateContainedUnknownExecution(params) {
546
536
  mediatedExecution,
547
537
  };
548
538
  await deps.appendAudit(ctx, {
549
- ...containedAuditBase(action.kind, ctx.config.mode, mediatedResult),
539
+ ...containedAuditBase(action.kind, ctx.config.mode, mediatedResult, sourceEvent),
550
540
  verdict: mediatedResult.verdict,
551
541
  reason,
552
542
  wouldBlock: false,
@@ -621,8 +611,10 @@ export async function evaluateGatedAction(ctx, deps, params) {
621
611
  effectFree: false,
622
612
  }),
623
613
  };
614
+ const sourceEvent = params.sourceEvent ?? gateAuditEventName(params.kind);
624
615
  await deps.appendAudit(ctx, {
625
- event: gateAuditEventName(params.kind),
616
+ event: resolveGateAuditEvent(sourceEvent, params.kind),
617
+ sourceEvent,
626
618
  kind: params.kind,
627
619
  fingerprint: verdict.fingerprint,
628
620
  verdict: verdict.verdict,
@@ -690,6 +682,7 @@ export async function evaluateGatedAction(ctx, deps, params) {
690
682
  action,
691
683
  command: action.command,
692
684
  result: predicted,
685
+ sourceEvent: params.sourceEvent,
693
686
  });
694
687
  if (mediated) {
695
688
  return mediated;
@@ -785,6 +778,7 @@ export async function evaluateGatedAction(ctx, deps, params) {
785
778
  const scrubOpts = scrubOptionsFromConfig(ctx.config);
786
779
  const scrubbedPayload = fingerprintReplayPayload(params.kind, params.payload, scrubOpts);
787
780
  return gateDecisionToVerdict(ctx, deps, params.kind, result, {
781
+ sourceEvent: params.sourceEvent,
788
782
  predictedAssessment,
789
783
  observedAssessment,
790
784
  transactionalLayer,
@@ -904,8 +898,11 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
904
898
  (kind === 'shell' ? auditExtras.approvalInput?.input : undefined),
905
899
  stateDir,
906
900
  });
901
+ const sourceEvent = auditExtras.sourceEvent ?? gateAuditEventName(kind);
902
+ const auditEvent = resolveGateAuditEvent(sourceEvent, kind);
907
903
  const gateBase = {
908
- event: gateAuditEventName(kind),
904
+ event: auditEvent,
905
+ sourceEvent,
909
906
  kind,
910
907
  fingerprint: result.fingerprint,
911
908
  summary: result.normalizedCommand ?? result.summary ?? '',