@guilz-dev/belay 0.2.0 → 0.4.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 (180) hide show
  1. package/README.md +39 -12
  2. package/dist/adapters/cursor/runtime-entry.js +1 -0
  3. package/dist/adapters/shared/gate-runtime.d.ts +11 -14
  4. package/dist/adapters/shared/gate-runtime.js +289 -23
  5. package/dist/bundle/claude-runtime.mjs +4435 -708
  6. package/dist/bundle/codex-runtime.mjs +4448 -710
  7. package/dist/bundle/cursor-runtime.mjs +4437 -710
  8. package/dist/cli.js +333 -11
  9. package/dist/commands/approve.d.ts +1 -0
  10. package/dist/commands/approve.js +56 -26
  11. package/dist/commands/config.d.ts +70 -0
  12. package/dist/commands/config.js +577 -0
  13. package/dist/commands/doctor.js +13 -2
  14. package/dist/commands/explain.js +6 -1
  15. package/dist/commands/harvest.d.ts +29 -0
  16. package/dist/commands/harvest.js +72 -0
  17. package/dist/commands/health-snapshot.d.ts +6 -0
  18. package/dist/commands/health-snapshot.js +34 -0
  19. package/dist/commands/judge.d.ts +100 -0
  20. package/dist/commands/judge.js +304 -0
  21. package/dist/commands/metrics.js +34 -2
  22. package/dist/commands/quality.d.ts +38 -0
  23. package/dist/commands/quality.js +105 -0
  24. package/dist/commands/simulate.d.ts +1 -0
  25. package/dist/commands/simulate.js +7 -2
  26. package/dist/commands/standing-allow.d.ts +10 -0
  27. package/dist/commands/standing-allow.js +26 -0
  28. package/dist/commands/status.js +2 -2
  29. package/dist/commands/stdin-key.d.ts +1 -0
  30. package/dist/commands/stdin-key.js +8 -0
  31. package/dist/commands/tui.d.ts +48 -0
  32. package/dist/commands/tui.js +150 -0
  33. package/dist/config-io.js +10 -5
  34. package/dist/conformance/guarantee-table.js +12 -0
  35. package/dist/conformance/types.d.ts +2 -0
  36. package/dist/core/approval-replay-cli.d.ts +7 -0
  37. package/dist/core/approval-replay-cli.js +36 -0
  38. package/dist/core/approval-replay.d.ts +45 -0
  39. package/dist/core/approval-replay.js +141 -0
  40. package/dist/core/approval-service.d.ts +15 -0
  41. package/dist/core/approval-service.js +47 -2
  42. package/dist/core/approval.d.ts +24 -3
  43. package/dist/core/approval.js +47 -4
  44. package/dist/core/audit-analysis.d.ts +7 -1
  45. package/dist/core/audit-analysis.js +111 -0
  46. package/dist/core/audit-io.d.ts +2 -0
  47. package/dist/core/audit-io.js +14 -0
  48. package/dist/core/audit-metrics.d.ts +7 -0
  49. package/dist/core/audit-metrics.js +24 -4
  50. package/dist/core/audit-query.d.ts +1 -0
  51. package/dist/core/audit-query.js +3 -0
  52. package/dist/core/audit-replay-context.d.ts +35 -0
  53. package/dist/core/audit-replay-context.js +88 -0
  54. package/dist/core/audit-types.d.ts +24 -1
  55. package/dist/core/audit-types.js +1 -1
  56. package/dist/core/capability/index.d.ts +4 -3
  57. package/dist/core/capability/index.js +3 -2
  58. package/dist/core/capability/paths.d.ts +2 -1
  59. package/dist/core/capability/paths.js +76 -8
  60. package/dist/core/capability/reasons.d.ts +3 -0
  61. package/dist/core/capability/reasons.js +8 -0
  62. package/dist/core/capability/trusted-workspace-roots.d.ts +25 -0
  63. package/dist/core/capability/trusted-workspace-roots.js +149 -0
  64. package/dist/core/capability/types.d.ts +11 -1
  65. package/dist/core/capability-approval.d.ts +2 -1
  66. package/dist/core/capability-approval.js +100 -2
  67. package/dist/core/classify-subagent.js +2 -20
  68. package/dist/core/classify-tool.js +129 -30
  69. package/dist/core/config-layers.js +2 -1
  70. package/dist/core/config.d.ts +40 -2
  71. package/dist/core/config.js +159 -11
  72. package/dist/core/credential-store.d.ts +11 -0
  73. package/dist/core/credential-store.js +60 -0
  74. package/dist/core/gate-engine.js +104 -13
  75. package/dist/core/harvest.d.ts +53 -0
  76. package/dist/core/harvest.js +276 -0
  77. package/dist/core/index.d.ts +9 -3
  78. package/dist/core/index.js +6 -2
  79. package/dist/core/integrity.d.ts +2 -0
  80. package/dist/core/integrity.js +13 -0
  81. package/dist/core/judge-api-key.d.ts +19 -0
  82. package/dist/core/judge-api-key.js +74 -0
  83. package/dist/core/judge-cloud-consent.d.ts +13 -0
  84. package/dist/core/judge-cloud-consent.js +38 -0
  85. package/dist/core/judge-config.d.ts +41 -4
  86. package/dist/core/judge-config.js +263 -57
  87. package/dist/core/judge-doctor.d.ts +9 -1
  88. package/dist/core/judge-doctor.js +199 -94
  89. package/dist/core/judge-model-discovery.d.ts +24 -0
  90. package/dist/core/judge-model-discovery.js +168 -0
  91. package/dist/core/judge-model-policy.d.ts +5 -0
  92. package/dist/core/judge-model-policy.js +21 -0
  93. package/dist/core/judge-runtime-detection.d.ts +9 -0
  94. package/dist/core/judge-runtime-detection.js +68 -0
  95. package/dist/core/path-utils.d.ts +11 -0
  96. package/dist/core/path-utils.js +56 -6
  97. package/dist/core/reclassify.d.ts +3 -0
  98. package/dist/core/reclassify.js +44 -14
  99. package/dist/core/replay-scrub.d.ts +9 -0
  100. package/dist/core/replay-scrub.js +43 -0
  101. package/dist/core/shell-tokenizer.d.ts +2 -0
  102. package/dist/core/shell-tokenizer.js +82 -23
  103. package/dist/core/standing-allow.d.ts +50 -0
  104. package/dist/core/standing-allow.js +175 -0
  105. package/dist/core/transactional/diff-evaluator.js +1 -19
  106. package/dist/core/types.d.ts +17 -0
  107. package/dist/core/verdict/adapter.d.ts +1 -0
  108. package/dist/core/verdict/adapter.js +17 -2
  109. package/dist/core/verdict/containment.d.ts +8 -2
  110. package/dist/core/verdict/containment.js +45 -10
  111. package/dist/core/verdict/judge-audit.d.ts +1 -0
  112. package/dist/core/verdict/judge-audit.js +32 -1
  113. package/dist/core/verdict/judge-baseline.d.ts +20 -0
  114. package/dist/core/verdict/judge-baseline.js +48 -0
  115. package/dist/core/verdict/judge-broker-service.d.ts +42 -0
  116. package/dist/core/verdict/judge-broker-service.js +279 -0
  117. package/dist/core/verdict/judge-catalog.d.ts +40 -0
  118. package/dist/core/verdict/judge-catalog.js +148 -0
  119. package/dist/core/verdict/judge-cli-fingerprint.d.ts +5 -0
  120. package/dist/core/verdict/judge-cli-fingerprint.js +61 -0
  121. package/dist/core/verdict/judge-cli.d.ts +46 -0
  122. package/dist/core/verdict/judge-cli.js +401 -0
  123. package/dist/core/verdict/judge-factory.d.ts +15 -4
  124. package/dist/core/verdict/judge-factory.js +127 -15
  125. package/dist/core/verdict/judge-provider-matrix.d.ts +20 -0
  126. package/dist/core/verdict/judge-provider-matrix.js +66 -0
  127. package/dist/core/verdict/judge-runtime-config.d.ts +42 -0
  128. package/dist/core/verdict/judge-runtime-config.js +92 -0
  129. package/dist/core/verdict/judge-session-broker.d.ts +48 -0
  130. package/dist/core/verdict/judge-session-broker.js +195 -0
  131. package/dist/core/verdict/judge-session-guard.d.ts +27 -0
  132. package/dist/core/verdict/judge-session-guard.js +91 -0
  133. package/dist/core/verdict/judge-session-kill-switch.d.ts +11 -0
  134. package/dist/core/verdict/judge-session-kill-switch.js +43 -0
  135. package/dist/core/verdict/judge-session-mutex.d.ts +9 -0
  136. package/dist/core/verdict/judge-session-mutex.js +23 -0
  137. package/dist/core/verdict/judge-shadow.d.ts +24 -0
  138. package/dist/core/verdict/judge-shadow.js +116 -0
  139. package/dist/core/verdict/judge-transport.d.ts +40 -0
  140. package/dist/core/verdict/judge-transport.js +258 -0
  141. package/dist/core/verdict/judge.d.ts +33 -1
  142. package/dist/core/verdict/judge.js +84 -14
  143. package/dist/core/verdict/parser.js +6 -1
  144. package/dist/core/verdict/persistent-paths.d.ts +8 -0
  145. package/dist/core/verdict/persistent-paths.js +52 -0
  146. package/dist/core/verdict/types.d.ts +19 -2
  147. package/dist/core/verdict/verdict.js +166 -49
  148. package/dist/corpus/adversarial-probe.d.ts +86 -0
  149. package/dist/corpus/adversarial-probe.js +220 -0
  150. package/dist/corpus/evaluate.d.ts +45 -12
  151. package/dist/corpus/evaluate.js +62 -5
  152. package/dist/corpus/gates.d.ts +35 -0
  153. package/dist/corpus/gates.js +81 -0
  154. package/dist/corpus/judge-accuracy.d.ts +8 -0
  155. package/dist/corpus/judge-accuracy.js +39 -0
  156. package/dist/corpus/must-allow-commands.d.ts +5 -0
  157. package/dist/corpus/must-allow-commands.js +13 -0
  158. package/dist/corpus/mutators.d.ts +28 -0
  159. package/dist/corpus/mutators.js +151 -0
  160. package/dist/corpus/ratchet.d.ts +42 -0
  161. package/dist/corpus/ratchet.js +116 -0
  162. package/dist/corpus/runtime-match.d.ts +19 -0
  163. package/dist/corpus/runtime-match.js +45 -0
  164. package/dist/corpus/standing-allow-catalog.generated.d.ts +13 -0
  165. package/dist/corpus/standing-allow-catalog.generated.js +79 -0
  166. package/dist/corpus/types.d.ts +47 -0
  167. package/dist/corpus/types.js +157 -0
  168. package/dist/installer.js +69 -15
  169. package/dist/judge-broker-daemon.d.ts +1 -0
  170. package/dist/judge-broker-daemon.js +123 -0
  171. package/dist/services/sandbox-service.d.ts +1 -0
  172. package/dist/services/sandbox-service.js +2 -0
  173. package/dist/types.d.ts +7 -0
  174. package/dist/version.d.ts +1 -1
  175. package/dist/version.js +1 -1
  176. package/package.json +7 -3
  177. package/skills/belay/SKILL.md +15 -8
  178. package/skills/belay/belay-approve.md +17 -0
  179. package/dist/commands/init-wizard.d.ts +0 -21
  180. package/dist/commands/init-wizard.js +0 -63
@@ -0,0 +1,141 @@
1
+ import path from 'node:path';
2
+ import { canonicalStringify, hashValue } from './fingerprint.js';
3
+ export { DEFAULT_CLI_REPLAY_TIMEOUT_MS, replayShellCommand } from './approval-replay-cli.js';
4
+ const MAX_PAYLOAD_JSON_BYTES = 16_384;
5
+ export function replayPayloadHash(kind, payload, repoRoot) {
6
+ if (!payload || Object.keys(payload).length === 0) {
7
+ return undefined;
8
+ }
9
+ const json = canonicalStringify(payload);
10
+ return hashValue(`payload:${kind}:${json}:${repoRoot}`);
11
+ }
12
+ const DEFAULT_REPLAY_CAPABILITIES = {
13
+ shell: true,
14
+ tool: false,
15
+ subagent: false,
16
+ };
17
+ const ADAPTER_REPLAY_CAPABLE = {
18
+ cursor: { ...DEFAULT_REPLAY_CAPABILITIES },
19
+ claude: { ...DEFAULT_REPLAY_CAPABILITIES },
20
+ codex: { ...DEFAULT_REPLAY_CAPABILITIES },
21
+ };
22
+ export function getExecutionLeaseMs(config) {
23
+ const ms = config.approval?.executionLeaseMs;
24
+ return typeof ms === 'number' && ms > 0 ? ms : 60_000;
25
+ }
26
+ export function approvalFlow(config) {
27
+ return config.approval?.flow === 'two_step' ? 'two_step' : 'one_step';
28
+ }
29
+ export function buildReplayEnvelopeFields(input) {
30
+ const fields = {};
31
+ const cmd = input.command ?? input.input ?? '';
32
+ if (cmd) {
33
+ fields.input = cmd;
34
+ fields.inputKind = input.inputKind ?? input.kind;
35
+ }
36
+ if (input.cwd) {
37
+ fields.cwd = path.resolve(input.cwd);
38
+ }
39
+ if (input.toolName) {
40
+ fields.toolName = input.toolName;
41
+ }
42
+ if (input.payload && Object.keys(input.payload).length > 0) {
43
+ const json = canonicalStringify(input.payload);
44
+ if (Buffer.byteLength(json, 'utf8') <= MAX_PAYLOAD_JSON_BYTES) {
45
+ fields.payloadJson = json;
46
+ }
47
+ fields.payloadHash = replayPayloadHash(input.kind, input.payload, input.repoRoot);
48
+ }
49
+ return fields;
50
+ }
51
+ export function hasReplayEnvelope(approval) {
52
+ return Boolean(approval.cwd || approval.toolName || approval.payloadHash);
53
+ }
54
+ export function validateReplayEnvelope(approval, action) {
55
+ if (!hasReplayEnvelope(approval)) {
56
+ return true;
57
+ }
58
+ if (approval.kind !== action.kind) {
59
+ return false;
60
+ }
61
+ if (approval.fingerprint !== action.fingerprint) {
62
+ return false;
63
+ }
64
+ if (approval.repoRoot !== action.repoRoot) {
65
+ return false;
66
+ }
67
+ if (approval.cwd && path.resolve(approval.cwd) !== path.resolve(action.cwd ?? '')) {
68
+ return false;
69
+ }
70
+ if (approval.toolName && approval.toolName !== action.toolName) {
71
+ return false;
72
+ }
73
+ if (approval.payloadHash) {
74
+ const hash = replayPayloadHash(action.kind, action.payload, action.repoRoot);
75
+ if (!hash || approval.payloadHash !== hash) {
76
+ return false;
77
+ }
78
+ }
79
+ return true;
80
+ }
81
+ export function canAutoReplay(config, kind, adapter) {
82
+ if (config.approvalSigning?.required) {
83
+ return false;
84
+ }
85
+ if (approvalFlow(config) !== 'one_step') {
86
+ return false;
87
+ }
88
+ if (kind !== 'shell' && kind !== 'tool' && kind !== 'subagent') {
89
+ return false;
90
+ }
91
+ const scopes = config.approval?.autoReplayScopes;
92
+ if (kind === 'shell' && scopes?.shell === false) {
93
+ return false;
94
+ }
95
+ if (kind === 'tool' && scopes?.tool !== true) {
96
+ return false;
97
+ }
98
+ if (kind === 'subagent' && scopes?.subagent !== true) {
99
+ return false;
100
+ }
101
+ if (adapter) {
102
+ return ADAPTER_REPLAY_CAPABLE[adapter][kind] === true;
103
+ }
104
+ return kind === 'shell' && scopes?.shell !== false;
105
+ }
106
+ export function buildRetryInstructionForConfig(config, tokenPrefix, approvalId) {
107
+ if (approvalFlow(config) === 'one_step') {
108
+ return `To allow this action once, send ${tokenPrefix} ${approvalId}. After approval, retry the same action unchanged.`;
109
+ }
110
+ return `To allow the next matching action once, send ${tokenPrefix} ${approvalId} and then retry the original action unchanged.`;
111
+ }
112
+ export function buildApprovalRecordedMessage(config, approval, adapter) {
113
+ if (!canAutoReplay(config, approval.kind, adapter)) {
114
+ return `Belay approval recorded for ${approval.approvalId}. Retry the original action once before it expires.`;
115
+ }
116
+ if (approval.kind === 'shell' && approval.input) {
117
+ return (`Belay approval recorded for ${approval.approvalId}. ` +
118
+ `Retry this shell command unchanged: ${approval.input}`);
119
+ }
120
+ return `Belay approval recorded for ${approval.approvalId}. Retry the original action once before it expires.`;
121
+ }
122
+ export function buildReplayHint(config, approval, adapter) {
123
+ if (approvalFlow(config) !== 'one_step') {
124
+ return null;
125
+ }
126
+ const input = approval.input ?? approval.summary;
127
+ if (!input) {
128
+ return null;
129
+ }
130
+ const autoReplay = canAutoReplay(config, approval.kind, adapter);
131
+ return {
132
+ kind: approval.kind,
133
+ input,
134
+ cwd: approval.cwd,
135
+ toolName: approval.toolName,
136
+ fingerprint: approval.fingerprint,
137
+ approvalId: approval.approvalId,
138
+ autoReplay,
139
+ fallbackToTwoStep: !autoReplay,
140
+ };
141
+ }
@@ -1,3 +1,4 @@
1
+ import { type ReplayAdapterId } from './approval-replay.js';
1
2
  import type { BelayConfigV3 } from './config.js';
2
3
  import type { ApprovalStateFile } from './types.js';
3
4
  export interface ApprovalStore {
@@ -19,8 +20,22 @@ export declare function recordApproval(params: {
19
20
  token?: string;
20
21
  /** When true, require a signed token (out-of-band CLI path). Editor prompts skip this. */
21
22
  requireSignedToken?: boolean;
23
+ adapter?: ReplayAdapterId;
22
24
  }): Promise<{
23
25
  ok: boolean;
24
26
  message: string;
25
27
  approval?: ApprovalStateFile['approvals'][number];
26
28
  }>;
29
+ /** CLI `--replay` already executed the shell command; drop the one-shot grant. */
30
+ export declare function consumeApprovedAfterCliReplay(params: {
31
+ approvalId: string;
32
+ store: ApprovalStore;
33
+ }): Promise<void>;
34
+ export declare function createGateApprovalStore(repoRoot: string, config: BelayConfigV3): ApprovalStore;
35
+ export declare function gateApprovalStoreFromDeps(deps: {
36
+ loadApprovals: (fileName: 'pending-approvals.json' | 'approved-approvals.json') => Promise<{
37
+ filePath: string;
38
+ state: ApprovalStateFile;
39
+ }>;
40
+ writeApprovals: (filePath: string, state: ApprovalStateFile) => Promise<void>;
41
+ }): ApprovalStore;
@@ -1,8 +1,10 @@
1
+ import { approvedApprovalsPath, loadApprovalState, pendingApprovalsPath, saveApprovalState, } from '../config-io.js';
1
2
  import { compactApprovals } from './approval.js';
3
+ import { buildApprovalRecordedMessage } from './approval-replay.js';
2
4
  import { verifyApprovalToken } from './approval-token.js';
3
5
  import { configuredControlPlaneDir } from './config.js';
4
6
  export async function recordApproval(params) {
5
- const { approvalId, config, store, token, requireSignedToken = false } = params;
7
+ const { approvalId, config, store, token, requireSignedToken = false, adapter } = params;
6
8
  const pending = await store.loadPending();
7
9
  pending.state = compactApprovals(pending.state);
8
10
  const index = pending.state.approvals.findIndex((approval) => approval.approvalId === approvalId);
@@ -35,7 +37,50 @@ export async function recordApproval(params) {
35
37
  await store.writeApproved(approved.filePath, approved.state);
36
38
  return {
37
39
  ok: true,
38
- message: `Belay approval recorded for ${approvalId}. Retry the original action once before it expires.`,
40
+ message: buildApprovalRecordedMessage(config, approval, adapter),
39
41
  approval,
40
42
  };
41
43
  }
44
+ /** CLI `--replay` already executed the shell command; drop the one-shot grant. */
45
+ export async function consumeApprovedAfterCliReplay(params) {
46
+ const approved = await params.store.loadApproved();
47
+ approved.state = compactApprovals(approved.state);
48
+ const remaining = approved.state.approvals.filter((approval) => approval.approvalId !== params.approvalId);
49
+ if (remaining.length === approved.state.approvals.length) {
50
+ return;
51
+ }
52
+ approved.state.approvals = remaining;
53
+ await params.store.writeApproved(approved.filePath, approved.state);
54
+ }
55
+ export function createGateApprovalStore(repoRoot, config) {
56
+ return {
57
+ async loadPending() {
58
+ const filePath = pendingApprovalsPath(repoRoot, config);
59
+ return {
60
+ filePath,
61
+ state: await loadApprovalState(repoRoot, 'pending-approvals.json', config),
62
+ };
63
+ },
64
+ async loadApproved() {
65
+ const filePath = approvedApprovalsPath(repoRoot, config);
66
+ return {
67
+ filePath,
68
+ state: await loadApprovalState(repoRoot, 'approved-approvals.json', config),
69
+ };
70
+ },
71
+ async writePending(_filePath, state) {
72
+ await saveApprovalState(repoRoot, 'pending-approvals.json', state, config);
73
+ },
74
+ async writeApproved(_filePath, state) {
75
+ await saveApprovalState(repoRoot, 'approved-approvals.json', state, config);
76
+ },
77
+ };
78
+ }
79
+ export function gateApprovalStoreFromDeps(deps) {
80
+ return {
81
+ loadPending: () => deps.loadApprovals('pending-approvals.json'),
82
+ loadApproved: () => deps.loadApprovals('approved-approvals.json'),
83
+ writePending: (filePath, state) => deps.writeApprovals(filePath, state),
84
+ writeApproved: (filePath, state) => deps.writeApprovals(filePath, state),
85
+ };
86
+ }
@@ -1,6 +1,6 @@
1
1
  import type { ApprovalRecord, ApprovalStateFile } from './types.js';
2
- /** Cursor may invoke the same shell gate more than once per retry; lease covers that window. */
3
- export declare const APPROVAL_EXECUTION_LEASE_MS = 60000;
2
+ /** @deprecated Use `DEFAULT_APPROVAL_CONFIG.executionLeaseMs` */
3
+ export declare const APPROVAL_EXECUTION_LEASE_MS: number;
4
4
  export declare function nowIso(): string;
5
5
  export declare function isExpired(approval: ApprovalRecord): boolean;
6
6
  export declare function isExecutionLeaseExpired(approval: ApprovalRecord): boolean;
@@ -8,7 +8,23 @@ export declare function compactApprovals(state: ApprovalStateFile): ApprovalStat
8
8
  export declare function mergeApprovalStates(target: ApprovalStateFile, source: ApprovalStateFile): ApprovalStateFile;
9
9
  export declare function escapeRegex(value: string): string;
10
10
  export declare function approvalCommandMatch(prompt: string, tokenPrefix: string): string | null;
11
- export declare function buildRetryInstruction(tokenPrefix: string, approvalId: string): string;
11
+ export declare function createApprovalRecordWithEnvelope(params: {
12
+ kind: ApprovalRecord['kind'];
13
+ fingerprint: string;
14
+ repoRoot: string;
15
+ reason: string;
16
+ summary: string;
17
+ approvalTtlMinutes: number;
18
+ approvalId: string;
19
+ approvalInput?: {
20
+ input: string;
21
+ inputKind: 'shell' | 'tool' | 'subagent';
22
+ cwd?: string;
23
+ toolName?: string;
24
+ payload?: Record<string, unknown>;
25
+ };
26
+ scopeHint?: ApprovalRecord['scopeHint'];
27
+ }): ApprovalRecord;
12
28
  export declare function createApprovalRecord(params: {
13
29
  kind: ApprovalRecord['kind'];
14
30
  fingerprint: string;
@@ -19,4 +35,9 @@ export declare function createApprovalRecord(params: {
19
35
  approvalId: string;
20
36
  input?: string;
21
37
  inputKind?: 'shell' | 'tool' | 'subagent';
38
+ cwd?: string;
39
+ toolName?: string;
40
+ payloadHash?: string;
41
+ payloadJson?: string;
42
+ scopeHint?: ApprovalRecord['scopeHint'];
22
43
  }): ApprovalRecord;
@@ -1,5 +1,7 @@
1
- /** Cursor may invoke the same shell gate more than once per retry; lease covers that window. */
2
- export const APPROVAL_EXECUTION_LEASE_MS = 60_000;
1
+ import { buildReplayEnvelopeFields } from './approval-replay.js';
2
+ import { DEFAULT_APPROVAL_CONFIG } from './config.js';
3
+ /** @deprecated Use `DEFAULT_APPROVAL_CONFIG.executionLeaseMs` */
4
+ export const APPROVAL_EXECUTION_LEASE_MS = DEFAULT_APPROVAL_CONFIG.executionLeaseMs;
3
5
  export function nowIso() {
4
6
  return new Date().toISOString();
5
7
  }
@@ -49,8 +51,34 @@ export function approvalCommandMatch(prompt, tokenPrefix) {
49
51
  }
50
52
  return null;
51
53
  }
52
- export function buildRetryInstruction(tokenPrefix, approvalId) {
53
- return `To allow the next matching action once, send ${tokenPrefix} ${approvalId} and then retry the original action unchanged.`;
54
+ export function createApprovalRecordWithEnvelope(params) {
55
+ const envelope = buildReplayEnvelopeFields({
56
+ kind: params.kind,
57
+ command: params.approvalInput?.input,
58
+ input: params.approvalInput?.input,
59
+ inputKind: params.approvalInput?.inputKind,
60
+ cwd: params.approvalInput?.cwd,
61
+ toolName: params.approvalInput?.toolName,
62
+ payload: params.approvalInput?.payload,
63
+ fingerprint: params.fingerprint,
64
+ repoRoot: params.repoRoot,
65
+ });
66
+ return createApprovalRecord({
67
+ kind: params.kind,
68
+ fingerprint: params.fingerprint,
69
+ repoRoot: params.repoRoot,
70
+ reason: params.reason,
71
+ summary: params.summary,
72
+ approvalTtlMinutes: params.approvalTtlMinutes,
73
+ approvalId: params.approvalId,
74
+ input: envelope.input ?? params.approvalInput?.input,
75
+ inputKind: envelope.inputKind ?? params.approvalInput?.inputKind,
76
+ cwd: envelope.cwd,
77
+ toolName: envelope.toolName ?? params.approvalInput?.toolName,
78
+ payloadHash: envelope.payloadHash,
79
+ payloadJson: envelope.payloadJson,
80
+ scopeHint: params.scopeHint,
81
+ });
54
82
  }
55
83
  export function createApprovalRecord(params) {
56
84
  const createdAt = nowIso();
@@ -69,5 +97,20 @@ export function createApprovalRecord(params) {
69
97
  record.input = params.input;
70
98
  record.inputKind = params.inputKind ?? params.kind;
71
99
  }
100
+ if (params.cwd) {
101
+ record.cwd = params.cwd;
102
+ }
103
+ if (params.toolName) {
104
+ record.toolName = params.toolName;
105
+ }
106
+ if (params.payloadHash) {
107
+ record.payloadHash = params.payloadHash;
108
+ }
109
+ if (params.payloadJson) {
110
+ record.payloadJson = params.payloadJson;
111
+ }
112
+ if (params.scopeHint) {
113
+ record.scopeHint = params.scopeHint;
114
+ }
72
115
  return record;
73
116
  }
@@ -1,4 +1,4 @@
1
- import type { ApprovalRoundTrip, AuditRecord, BypassAttempt, NoisyRuleCandidate } from './audit-types.js';
1
+ import type { ApprovalRoundTrip, AuditRecord, AvailabilityAskCounts, BypassAttempt, NoisyRuleCandidate, ReasonApprovalRatio, RepeatedFingerprintAsk } from './audit-types.js';
2
2
  export declare function detectBypassAttempts(records: AuditRecord[], windowMs?: number): BypassAttempt[];
3
3
  export declare function detectNoisyRules(records: AuditRecord[], roundTrips: ApprovalRoundTrip[], minDenies?: number): NoisyRuleCandidate[];
4
4
  export declare function computeApprovalLatencyStats(roundTrips: ApprovalRoundTrip[]): {
@@ -8,3 +8,9 @@ export declare function computeApprovalLatencyStats(roundTrips: ApprovalRoundTri
8
8
  };
9
9
  export declare function bucketGateEventsByDay(records: AuditRecord[]): Record<string, number>;
10
10
  export declare function countVerdicts(records: AuditRecord[]): Record<string, number>;
11
+ export declare function computeWouldBlockByReason(records: AuditRecord[]): Record<string, number>;
12
+ /** Approval ratios are candidate signals from operator behavior — not corpus ground truth. */
13
+ export declare function computeApprovalRatioByReason(records: AuditRecord[], roundTrips: ApprovalRoundTrip[]): ReasonApprovalRatio[];
14
+ export declare function isAvailabilityCausedAsk(record: AuditRecord): boolean;
15
+ export declare function computeAvailabilityAskCounts(records: AuditRecord[]): AvailabilityAskCounts;
16
+ export declare function computeRepeatedFingerprintAsks(records: AuditRecord[], minCount?: number, limit?: number): RepeatedFingerprintAsk[];
@@ -145,3 +145,114 @@ export function countVerdicts(records) {
145
145
  }
146
146
  return counts;
147
147
  }
148
+ export function computeWouldBlockByReason(records) {
149
+ const counts = {};
150
+ for (const record of records) {
151
+ if (!isGateRecord(record) || !inferWouldBlock(record)) {
152
+ continue;
153
+ }
154
+ const reason = record.reason ?? 'unknown';
155
+ counts[reason] = (counts[reason] ?? 0) + 1;
156
+ }
157
+ return counts;
158
+ }
159
+ /** Approval ratios are candidate signals from operator behavior — not corpus ground truth. */
160
+ export function computeApprovalRatioByReason(records, roundTrips) {
161
+ const wouldBlockByReason = new Map();
162
+ const approvedByReason = new Map();
163
+ for (const record of records) {
164
+ if (!isGateRecord(record) || !inferWouldBlock(record)) {
165
+ continue;
166
+ }
167
+ const reason = record.reason ?? 'unknown';
168
+ wouldBlockByReason.set(reason, (wouldBlockByReason.get(reason) ?? 0) + 1);
169
+ }
170
+ for (const trip of roundTrips) {
171
+ if (!trip.approvalTimestamp) {
172
+ continue;
173
+ }
174
+ approvedByReason.set(trip.reason, (approvedByReason.get(trip.reason) ?? 0) + 1);
175
+ }
176
+ return [...wouldBlockByReason.entries()]
177
+ .map(([reason, wouldBlockCount]) => {
178
+ const approvedAfterDenyCount = approvedByReason.get(reason) ?? 0;
179
+ return {
180
+ reason,
181
+ wouldBlockCount,
182
+ approvedAfterDenyCount,
183
+ approvalRate: wouldBlockCount > 0 ? approvedAfterDenyCount / wouldBlockCount : 0,
184
+ };
185
+ })
186
+ .sort((left, right) => right.wouldBlockCount - left.wouldBlockCount);
187
+ }
188
+ function judgeFallbackReason(record) {
189
+ return typeof record.judgeFallbackReason === 'string' ? record.judgeFallbackReason : '';
190
+ }
191
+ function isJudgeTimeoutFallback(fallback) {
192
+ return fallback.includes('timeout');
193
+ }
194
+ export function isAvailabilityCausedAsk(record) {
195
+ if (!isGateRecord(record) || !inferWouldBlock(record)) {
196
+ return false;
197
+ }
198
+ if (record.reason === 'missing_trusted_cwd') {
199
+ return true;
200
+ }
201
+ return judgeFallbackReason(record).length > 0;
202
+ }
203
+ export function computeAvailabilityAskCounts(records) {
204
+ const counts = {
205
+ total: 0,
206
+ missingTrustedCwd: 0,
207
+ judgeTimeout: 0,
208
+ judgeFallback: 0,
209
+ };
210
+ for (const record of records) {
211
+ if (!isGateRecord(record) || !inferWouldBlock(record)) {
212
+ continue;
213
+ }
214
+ if (record.reason === 'missing_trusted_cwd') {
215
+ counts.missingTrustedCwd += 1;
216
+ counts.total += 1;
217
+ continue;
218
+ }
219
+ const fallback = judgeFallbackReason(record);
220
+ if (!fallback) {
221
+ continue;
222
+ }
223
+ if (isJudgeTimeoutFallback(fallback)) {
224
+ counts.judgeTimeout += 1;
225
+ }
226
+ else {
227
+ counts.judgeFallback += 1;
228
+ }
229
+ counts.total += 1;
230
+ }
231
+ return counts;
232
+ }
233
+ export function computeRepeatedFingerprintAsks(records, minCount = 2, limit = 10) {
234
+ const grouped = new Map();
235
+ for (const record of records) {
236
+ if (!isGateRecord(record) || !inferWouldBlock(record) || !record.fingerprint) {
237
+ continue;
238
+ }
239
+ const fingerprint = record.fingerprint;
240
+ const existing = grouped.get(fingerprint);
241
+ if (existing) {
242
+ existing.askCount += 1;
243
+ existing.summary = record.summary ?? existing.summary;
244
+ existing.reason = record.reason ?? existing.reason;
245
+ continue;
246
+ }
247
+ grouped.set(fingerprint, {
248
+ fingerprint,
249
+ summary: record.summary ?? '',
250
+ reason: record.reason ?? 'unknown',
251
+ askCount: 1,
252
+ });
253
+ }
254
+ return [...grouped.values()]
255
+ .filter((entry) => entry.askCount >= minCount)
256
+ .sort((left, right) => right.askCount - left.askCount)
257
+ .slice(0, limit);
258
+ }
@@ -0,0 +1,2 @@
1
+ import type { BelayConfigV4 } from './config.js';
2
+ export declare function appendCliAuditEvent(repoRoot: string, config: BelayConfigV4, event: Record<string, unknown>): Promise<void>;
@@ -0,0 +1,14 @@
1
+ import { appendFile, mkdir } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ export async function appendCliAuditEvent(repoRoot, config, event) {
4
+ const auditPath = path.isAbsolute(config.audit.logPath)
5
+ ? config.audit.logPath
6
+ : 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
+ source: 'belay-cli',
11
+ ...event,
12
+ });
13
+ await appendFile(auditPath, `${line}\n`, 'utf8');
14
+ }
@@ -1,4 +1,5 @@
1
1
  import { buildApprovalRoundTrips, filterAuditRecords, toAuditRecord } from './audit-query.js';
2
+ import type { AvailabilityAskCounts, ReasonApprovalRatio, RepeatedFingerprintAsk } from './audit-types.js';
2
3
  /** Minimum gate events before recommending enforce with zero would-block rate. */
3
4
  export declare const MIN_GATE_EVENTS_FOR_ENFORCE = 20;
4
5
  export interface AuditMetricsReport {
@@ -9,6 +10,12 @@ export interface AuditMetricsReport {
9
10
  gateEvents: number;
10
11
  wouldBlockCount: number;
11
12
  wouldBlockRate: number;
13
+ classifierWouldBlockCount: number;
14
+ classifierWouldBlockRate: number;
15
+ wouldBlockByReason: Record<string, number>;
16
+ approvalRatioByReason: ReasonApprovalRatio[];
17
+ availabilityAsks: AvailabilityAskCounts;
18
+ repeatedFingerprintAsks: RepeatedFingerprintAsk[];
12
19
  byReason: Record<string, number>;
13
20
  byKind: Record<string, number>;
14
21
  byVerdict: Record<string, number>;
@@ -1,4 +1,4 @@
1
- import { bucketGateEventsByDay, computeApprovalLatencyStats, countVerdicts, detectBypassAttempts, detectNoisyRules, } from './audit-analysis.js';
1
+ import { bucketGateEventsByDay, computeApprovalLatencyStats, computeApprovalRatioByReason, computeAvailabilityAskCounts, computeRepeatedFingerprintAsks, computeWouldBlockByReason, countVerdicts, detectBypassAttempts, detectNoisyRules, } from './audit-analysis.js';
2
2
  import { buildApprovalRoundTrips, filterAuditRecords, inferWouldBlock, isApprovalRecorded, toAuditRecord, } from './audit-query.js';
3
3
  import { AUDIT_METRICS_SCHEMA_VERSION, GATE_EVENTS } from './audit-types.js';
4
4
  /** Minimum gate events before recommending enforce with zero would-block rate. */
@@ -78,7 +78,13 @@ export function computeAuditMetrics(records, options = {}) {
78
78
  const approvalLatency = computeApprovalLatencyStats(roundTrips);
79
79
  const bypassAttempts = detectBypassAttempts(auditRecords);
80
80
  const noisyRuleCandidates = detectNoisyRules(auditRecords, roundTrips);
81
+ const wouldBlockByReason = computeWouldBlockByReason(auditRecords);
82
+ const approvalRatioByReason = computeApprovalRatioByReason(auditRecords, roundTrips);
83
+ const availabilityAsks = computeAvailabilityAskCounts(auditRecords);
84
+ const repeatedFingerprintAsks = computeRepeatedFingerprintAsks(auditRecords);
81
85
  const wouldBlockRate = gateEvents > 0 ? wouldBlockCount / gateEvents : 0;
86
+ const classifierWouldBlockCount = Math.max(0, wouldBlockCount - availabilityAsks.total);
87
+ const classifierWouldBlockRate = gateEvents > 0 ? classifierWouldBlockCount / gateEvents : 0;
82
88
  const topWouldBlockSummaries = [...summaryCounts.values()]
83
89
  .sort((left, right) => right.count - left.count)
84
90
  .slice(0, 10);
@@ -101,16 +107,16 @@ export function computeAuditMetrics(records, options = {}) {
101
107
  }
102
108
  }
103
109
  else {
104
- notes.push(`${wouldBlockCount} would-block event(s) (${(wouldBlockRate * 100).toFixed(1)}% of gate traffic). Review top summaries and add overrides.allow where appropriate.`);
110
+ notes.push(`${wouldBlockCount} would-block event(s) (${(wouldBlockRate * 100).toFixed(1)}% of gate traffic; classifier-quality ${(classifierWouldBlockRate * 100).toFixed(1)}%). Review top summaries and add overrides.allow where appropriate.`);
105
111
  if (approvalRecordedCount > 0) {
106
112
  notes.push(`${approvalRecordedCount} approval(s) recorded — these likely indicate actions operators wanted.`);
107
113
  }
108
114
  else {
109
115
  notes.push('Review top would-block summaries and add overrides.allow for legitimate commands before switching to enforce.');
110
116
  }
111
- if (wouldBlockRate < 0.05 && gateEvents >= 20) {
117
+ if (classifierWouldBlockRate < 0.05 && gateEvents >= 20 && availabilityAsks.total === 0) {
112
118
  readyForEnforce = true;
113
- notes.push('Would-block rate is below 5% with sufficient sample size — consider enforce mode.');
119
+ notes.push('Classifier-quality would-block rate is below 5% with sufficient sample size — consider enforce mode.');
114
120
  }
115
121
  }
116
122
  }
@@ -120,6 +126,14 @@ export function computeAuditMetrics(records, options = {}) {
120
126
  else {
121
127
  notes.push('Set policy.unknownLocalEffect to "deny" to dogfood fail-closed defaults.');
122
128
  }
129
+ if (availabilityAsks.total > 0) {
130
+ readyForEnforce = false;
131
+ notes.push(`${availabilityAsks.total} availability-caused ask(s) — tune infrastructure before corpus overrides.`);
132
+ notes.push('Ready for enforce withheld while availability-caused asks are present.');
133
+ }
134
+ if (repeatedFingerprintAsks.length > 0) {
135
+ notes.push(`${repeatedFingerprintAsks.length} repeated fingerprint ask pattern(s) — review standing-allow / cache candidates.`);
136
+ }
123
137
  if (noisyRuleCandidates.length > 0) {
124
138
  notes.push(`${noisyRuleCandidates.length} noisy rule candidate(s) — high deny-then-approve rate.`);
125
139
  }
@@ -131,6 +145,12 @@ export function computeAuditMetrics(records, options = {}) {
131
145
  gateEvents,
132
146
  wouldBlockCount,
133
147
  wouldBlockRate,
148
+ classifierWouldBlockCount,
149
+ classifierWouldBlockRate,
150
+ wouldBlockByReason,
151
+ approvalRatioByReason,
152
+ availabilityAsks,
153
+ repeatedFingerprintAsks,
134
154
  byReason,
135
155
  byKind,
136
156
  byVerdict,
@@ -2,6 +2,7 @@ import type { ApprovalRoundTrip, AuditFilter, AuditRecord } from './audit-types.
2
2
  export declare function toAuditRecord(value: Record<string, unknown>): AuditRecord;
3
3
  export declare function parseTimestamp(value?: string): number | null;
4
4
  export declare function isGateRecord(record: AuditRecord): boolean;
5
+ export declare function isShellGateRecord(record: AuditRecord): boolean;
5
6
  export declare function isApprovalRecorded(record: AuditRecord): boolean;
6
7
  export declare function inferWouldBlock(record: AuditRecord): boolean;
7
8
  export declare function recordStringField(record: AuditRecord, field: 'effect' | 'reason' | 'permission' | 'verdict' | 'mode' | 'summary' | 'fingerprint' | 'location'): string;
@@ -16,6 +16,9 @@ export function parseTimestamp(value) {
16
16
  export function isGateRecord(record) {
17
17
  return typeof record.event === 'string' && GATE_EVENTS.has(record.event);
18
18
  }
19
+ export function isShellGateRecord(record) {
20
+ return (isGateRecord(record) && (record.event === 'beforeShellExecution' || record.kind === 'shell'));
21
+ }
19
22
  export function isApprovalRecorded(record) {
20
23
  return ((record.event === 'approval' ||
21
24
  (record.event === 'beforeSubmitPrompt' && record.reason === 'approval_recorded')) &&
@@ -0,0 +1,35 @@
1
+ import type { GatedActionKind } from './gate-contract.js';
2
+ import type { ClassifyResult } from './types.js';
3
+ /** Preserved action context for simulate triage — not a safety gate. */
4
+ export interface AuditReplayContext {
5
+ cwd: string;
6
+ kind: GatedActionKind;
7
+ command?: string;
8
+ toolName?: string;
9
+ payload?: Record<string, unknown>;
10
+ }
11
+ /** Classifier-facing action snapshot for high-fidelity simulate replay. */
12
+ export interface AuditActionSnapshot {
13
+ schemaVersion: 1;
14
+ kind: GatedActionKind;
15
+ cwd: string;
16
+ normalizedAction: string;
17
+ toolName?: string;
18
+ payloadHash?: string;
19
+ }
20
+ export interface ReplayActionLike {
21
+ cwd?: string;
22
+ kind?: string;
23
+ command?: string;
24
+ toolName?: string;
25
+ payload?: Record<string, unknown>;
26
+ }
27
+ export declare function hashReplayPayload(payload: Record<string, unknown>): string;
28
+ export declare function buildAuditActionSnapshot(kind: GatedActionKind, result: Pick<ClassifyResult, 'normalizedCommand' | 'summary'>, replayAction?: ReplayActionLike): AuditActionSnapshot | undefined;
29
+ export declare function parseAuditActionSnapshot(record: {
30
+ actionSnapshot?: unknown;
31
+ }): AuditActionSnapshot | null;
32
+ export declare function buildAuditReplayContext(kind: GatedActionKind, result: Pick<ClassifyResult, 'normalizedCommand' | 'summary'>, replayAction?: ReplayActionLike): AuditReplayContext | undefined;
33
+ export declare function parseAuditReplayContext(record: {
34
+ replayContext?: unknown;
35
+ }): AuditReplayContext | null;