@guilz-dev/belay 0.3.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 (140) hide show
  1. package/dist/adapters/cursor/runtime-entry.js +1 -0
  2. package/dist/adapters/shared/gate-runtime.d.ts +11 -14
  3. package/dist/adapters/shared/gate-runtime.js +277 -19
  4. package/dist/bundle/claude-runtime.mjs +3138 -699
  5. package/dist/bundle/codex-runtime.mjs +3162 -711
  6. package/dist/bundle/cursor-runtime.mjs +3140 -701
  7. package/dist/cli.js +137 -5
  8. package/dist/commands/approve.d.ts +1 -0
  9. package/dist/commands/approve.js +45 -26
  10. package/dist/commands/config.js +135 -52
  11. package/dist/commands/doctor.js +11 -1
  12. package/dist/commands/explain.js +6 -1
  13. package/dist/commands/harvest.d.ts +29 -0
  14. package/dist/commands/harvest.js +72 -0
  15. package/dist/commands/judge.d.ts +11 -1
  16. package/dist/commands/judge.js +21 -2
  17. package/dist/commands/metrics.js +34 -2
  18. package/dist/commands/quality.d.ts +38 -0
  19. package/dist/commands/quality.js +105 -0
  20. package/dist/commands/simulate.d.ts +1 -0
  21. package/dist/commands/simulate.js +7 -2
  22. package/dist/commands/standing-allow.d.ts +10 -0
  23. package/dist/commands/standing-allow.js +26 -0
  24. package/dist/commands/tui.d.ts +48 -0
  25. package/dist/commands/tui.js +150 -0
  26. package/dist/config-io.js +10 -5
  27. package/dist/core/approval-replay-cli.d.ts +7 -0
  28. package/dist/core/approval-replay-cli.js +36 -0
  29. package/dist/core/approval-replay.d.ts +45 -0
  30. package/dist/core/approval-replay.js +141 -0
  31. package/dist/core/approval-service.d.ts +15 -0
  32. package/dist/core/approval-service.js +47 -2
  33. package/dist/core/approval.d.ts +24 -3
  34. package/dist/core/approval.js +47 -4
  35. package/dist/core/audit-analysis.d.ts +7 -1
  36. package/dist/core/audit-analysis.js +111 -0
  37. package/dist/core/audit-metrics.d.ts +7 -0
  38. package/dist/core/audit-metrics.js +24 -4
  39. package/dist/core/audit-query.d.ts +1 -0
  40. package/dist/core/audit-query.js +3 -0
  41. package/dist/core/audit-replay-context.d.ts +35 -0
  42. package/dist/core/audit-replay-context.js +88 -0
  43. package/dist/core/audit-types.d.ts +24 -1
  44. package/dist/core/audit-types.js +1 -1
  45. package/dist/core/capability/index.d.ts +2 -1
  46. package/dist/core/capability/index.js +1 -0
  47. package/dist/core/capability/paths.d.ts +2 -2
  48. package/dist/core/capability/paths.js +9 -9
  49. package/dist/core/capability/trusted-workspace-roots.d.ts +25 -0
  50. package/dist/core/capability/trusted-workspace-roots.js +149 -0
  51. package/dist/core/capability/types.d.ts +11 -1
  52. package/dist/core/capability-approval.d.ts +2 -1
  53. package/dist/core/capability-approval.js +100 -2
  54. package/dist/core/classify-subagent.js +2 -20
  55. package/dist/core/classify-tool.js +32 -3
  56. package/dist/core/config.d.ts +18 -0
  57. package/dist/core/config.js +49 -0
  58. package/dist/core/gate-engine.js +6 -6
  59. package/dist/core/harvest.d.ts +53 -0
  60. package/dist/core/harvest.js +276 -0
  61. package/dist/core/index.d.ts +9 -3
  62. package/dist/core/index.js +6 -2
  63. package/dist/core/judge-doctor.d.ts +3 -0
  64. package/dist/core/judge-doctor.js +54 -0
  65. package/dist/core/path-utils.d.ts +11 -0
  66. package/dist/core/path-utils.js +56 -6
  67. package/dist/core/reclassify.d.ts +3 -0
  68. package/dist/core/reclassify.js +44 -14
  69. package/dist/core/replay-scrub.d.ts +9 -0
  70. package/dist/core/replay-scrub.js +43 -0
  71. package/dist/core/shell-tokenizer.d.ts +2 -0
  72. package/dist/core/shell-tokenizer.js +82 -23
  73. package/dist/core/standing-allow.d.ts +50 -0
  74. package/dist/core/standing-allow.js +175 -0
  75. package/dist/core/types.d.ts +15 -0
  76. package/dist/core/verdict/adapter.js +10 -1
  77. package/dist/core/verdict/containment.d.ts +4 -3
  78. package/dist/core/verdict/containment.js +17 -12
  79. package/dist/core/verdict/judge-audit.d.ts +1 -0
  80. package/dist/core/verdict/judge-audit.js +32 -1
  81. package/dist/core/verdict/judge-baseline.d.ts +20 -0
  82. package/dist/core/verdict/judge-baseline.js +48 -0
  83. package/dist/core/verdict/judge-broker-service.d.ts +42 -0
  84. package/dist/core/verdict/judge-broker-service.js +279 -0
  85. package/dist/core/verdict/judge-cli-fingerprint.d.ts +5 -0
  86. package/dist/core/verdict/judge-cli-fingerprint.js +61 -0
  87. package/dist/core/verdict/judge-cli.d.ts +23 -0
  88. package/dist/core/verdict/judge-cli.js +137 -16
  89. package/dist/core/verdict/judge-factory.js +13 -4
  90. package/dist/core/verdict/judge-provider-matrix.d.ts +20 -0
  91. package/dist/core/verdict/judge-provider-matrix.js +66 -0
  92. package/dist/core/verdict/judge-runtime-config.d.ts +42 -0
  93. package/dist/core/verdict/judge-runtime-config.js +92 -0
  94. package/dist/core/verdict/judge-session-broker.d.ts +48 -0
  95. package/dist/core/verdict/judge-session-broker.js +195 -0
  96. package/dist/core/verdict/judge-session-guard.d.ts +27 -0
  97. package/dist/core/verdict/judge-session-guard.js +91 -0
  98. package/dist/core/verdict/judge-session-kill-switch.d.ts +11 -0
  99. package/dist/core/verdict/judge-session-kill-switch.js +43 -0
  100. package/dist/core/verdict/judge-session-mutex.d.ts +9 -0
  101. package/dist/core/verdict/judge-session-mutex.js +23 -0
  102. package/dist/core/verdict/judge-shadow.d.ts +24 -0
  103. package/dist/core/verdict/judge-shadow.js +116 -0
  104. package/dist/core/verdict/judge-transport.d.ts +40 -0
  105. package/dist/core/verdict/judge-transport.js +258 -0
  106. package/dist/core/verdict/judge.d.ts +14 -1
  107. package/dist/core/verdict/judge.js +6 -5
  108. package/dist/core/verdict/parser.js +6 -1
  109. package/dist/core/verdict/types.d.ts +13 -0
  110. package/dist/core/verdict/verdict.js +7 -4
  111. package/dist/corpus/adversarial-probe.d.ts +86 -0
  112. package/dist/corpus/adversarial-probe.js +220 -0
  113. package/dist/corpus/evaluate.d.ts +45 -12
  114. package/dist/corpus/evaluate.js +62 -5
  115. package/dist/corpus/gates.d.ts +35 -0
  116. package/dist/corpus/gates.js +81 -0
  117. package/dist/corpus/judge-accuracy.d.ts +8 -0
  118. package/dist/corpus/judge-accuracy.js +39 -0
  119. package/dist/corpus/must-allow-commands.d.ts +5 -0
  120. package/dist/corpus/must-allow-commands.js +13 -0
  121. package/dist/corpus/mutators.d.ts +28 -0
  122. package/dist/corpus/mutators.js +151 -0
  123. package/dist/corpus/ratchet.d.ts +42 -0
  124. package/dist/corpus/ratchet.js +116 -0
  125. package/dist/corpus/runtime-match.d.ts +19 -0
  126. package/dist/corpus/runtime-match.js +45 -0
  127. package/dist/corpus/standing-allow-catalog.generated.d.ts +13 -0
  128. package/dist/corpus/standing-allow-catalog.generated.js +79 -0
  129. package/dist/corpus/types.d.ts +47 -0
  130. package/dist/corpus/types.js +157 -0
  131. package/dist/installer.js +4 -1
  132. package/dist/judge-broker-daemon.d.ts +1 -0
  133. package/dist/judge-broker-daemon.js +123 -0
  134. package/dist/services/sandbox-service.d.ts +1 -0
  135. package/dist/services/sandbox-service.js +2 -0
  136. package/dist/version.d.ts +1 -1
  137. package/dist/version.js +1 -1
  138. package/package.json +7 -3
  139. package/skills/belay/SKILL.md +7 -1
  140. package/skills/belay/belay-approve.md +17 -0
@@ -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
+ }
@@ -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;
@@ -0,0 +1,88 @@
1
+ import { createHash } from 'node:crypto';
2
+ export function hashReplayPayload(payload) {
3
+ return createHash('sha256').update(JSON.stringify(payload)).digest('hex');
4
+ }
5
+ export function buildAuditActionSnapshot(kind, result, replayAction) {
6
+ if (!replayAction?.cwd) {
7
+ return undefined;
8
+ }
9
+ const replayKind = replayAction.kind;
10
+ const resolvedKind = replayKind === 'shell' || replayKind === 'tool' || replayKind === 'subagent' ? replayKind : kind;
11
+ const normalizedAction = result.normalizedCommand ?? replayAction.command ?? result.summary ?? '';
12
+ if (!normalizedAction.trim()) {
13
+ return undefined;
14
+ }
15
+ return {
16
+ schemaVersion: 1,
17
+ kind: resolvedKind,
18
+ cwd: replayAction.cwd,
19
+ normalizedAction,
20
+ ...(replayAction.toolName ? { toolName: replayAction.toolName } : {}),
21
+ ...(replayAction.payload ? { payloadHash: hashReplayPayload(replayAction.payload) } : {}),
22
+ };
23
+ }
24
+ export function parseAuditActionSnapshot(record) {
25
+ const raw = record.actionSnapshot;
26
+ if (!raw || typeof raw !== 'object') {
27
+ return null;
28
+ }
29
+ const snapshot = raw;
30
+ if (snapshot.schemaVersion !== 1) {
31
+ return null;
32
+ }
33
+ if (typeof snapshot.cwd !== 'string' || !snapshot.cwd.trim()) {
34
+ return null;
35
+ }
36
+ const kind = snapshot.kind;
37
+ if (kind !== 'shell' && kind !== 'tool' && kind !== 'subagent') {
38
+ return null;
39
+ }
40
+ if (typeof snapshot.normalizedAction !== 'string' || !snapshot.normalizedAction.trim()) {
41
+ return null;
42
+ }
43
+ return {
44
+ schemaVersion: 1,
45
+ kind,
46
+ cwd: snapshot.cwd,
47
+ normalizedAction: snapshot.normalizedAction,
48
+ ...(typeof snapshot.toolName === 'string' ? { toolName: snapshot.toolName } : {}),
49
+ ...(typeof snapshot.payloadHash === 'string' ? { payloadHash: snapshot.payloadHash } : {}),
50
+ };
51
+ }
52
+ export function buildAuditReplayContext(kind, result, replayAction) {
53
+ if (!replayAction?.cwd) {
54
+ return undefined;
55
+ }
56
+ const replayKind = replayAction.kind;
57
+ const resolvedKind = replayKind === 'shell' || replayKind === 'tool' || replayKind === 'subagent' ? replayKind : kind;
58
+ return {
59
+ cwd: replayAction.cwd,
60
+ kind: resolvedKind,
61
+ command: replayAction.command ?? result.normalizedCommand ?? result.summary,
62
+ ...(replayAction.toolName ? { toolName: replayAction.toolName } : {}),
63
+ ...(replayAction.payload ? { payload: replayAction.payload } : {}),
64
+ };
65
+ }
66
+ export function parseAuditReplayContext(record) {
67
+ const raw = record.replayContext;
68
+ if (!raw || typeof raw !== 'object') {
69
+ return null;
70
+ }
71
+ const ctx = raw;
72
+ if (typeof ctx.cwd !== 'string' || !ctx.cwd.trim()) {
73
+ return null;
74
+ }
75
+ const kind = ctx.kind;
76
+ if (kind !== 'shell' && kind !== 'tool' && kind !== 'subagent') {
77
+ return null;
78
+ }
79
+ return {
80
+ cwd: ctx.cwd,
81
+ kind,
82
+ ...(typeof ctx.command === 'string' ? { command: ctx.command } : {}),
83
+ ...(typeof ctx.toolName === 'string' ? { toolName: ctx.toolName } : {}),
84
+ ...(ctx.payload && typeof ctx.payload === 'object' && !Array.isArray(ctx.payload)
85
+ ? { payload: ctx.payload }
86
+ : {}),
87
+ };
88
+ }
@@ -1,5 +1,7 @@
1
+ import type { AuditActionSnapshot, AuditReplayContext } from './audit-replay-context.js';
1
2
  import type { Assessment } from './types.js';
2
- export declare const AUDIT_METRICS_SCHEMA_VERSION = 2;
3
+ export type { AuditActionSnapshot, AuditReplayContext } from './audit-replay-context.js';
4
+ export declare const AUDIT_METRICS_SCHEMA_VERSION = 3;
3
5
  export declare const GATE_EVENTS: Set<string>;
4
6
  export interface AuditRecord {
5
7
  timestamp?: string;
@@ -11,6 +13,7 @@ export interface AuditRecord {
11
13
  summary?: string;
12
14
  approvalId?: string;
13
15
  wouldBlock?: boolean;
16
+ judgeFallbackReason?: string;
14
17
  permission?: string;
15
18
  mode?: string;
16
19
  assessment?: Assessment;
@@ -21,6 +24,8 @@ export interface AuditRecord {
21
24
  transactionalCategories?: string[];
22
25
  transactionalChangeCount?: number;
23
26
  transactionalSkipReason?: string;
27
+ replayContext?: AuditReplayContext;
28
+ actionSnapshot?: AuditActionSnapshot;
24
29
  [key: string]: unknown;
25
30
  }
26
31
  export interface AuditFilter {
@@ -63,3 +68,21 @@ export interface NoisyRuleCandidate {
63
68
  approvedCount: number;
64
69
  approvalRate: number;
65
70
  }
71
+ export interface ReasonApprovalRatio {
72
+ reason: string;
73
+ wouldBlockCount: number;
74
+ approvedAfterDenyCount: number;
75
+ approvalRate: number;
76
+ }
77
+ export interface RepeatedFingerprintAsk {
78
+ fingerprint: string;
79
+ summary: string;
80
+ reason: string;
81
+ askCount: number;
82
+ }
83
+ export interface AvailabilityAskCounts {
84
+ total: number;
85
+ missingTrustedCwd: number;
86
+ judgeTimeout: number;
87
+ judgeFallback: number;
88
+ }
@@ -1,2 +1,2 @@
1
- export const AUDIT_METRICS_SCHEMA_VERSION = 2;
1
+ export const AUDIT_METRICS_SCHEMA_VERSION = 3;
2
2
  export const GATE_EVENTS = new Set(['beforeShellExecution', 'preToolUse', 'subagentGate']);
@@ -2,4 +2,5 @@ export { addPathToAllowlist, allPathsAllowlisted, fsScopeAllowlistPath, isPathAl
2
2
  export { evaluateL1FullStatus, hasSandboxRuntime, isCapabilityBrokerDemotionActive, isSandboxBrokerEnabled, } from './broker.js';
3
3
  export { collectOutsideRepoPaths, collectOutsideRepoPathsFromToolPayload } from './paths.js';
4
4
  export { FS_SCOPE_REASONS, shouldSkipBrokerApprovedOnce, shouldSkipBrokerApprovedRecord, } from './reasons.js';
5
- export type { CapabilityApprovalScope, FsScopeAllowlistEntry, FsScopeAllowlistFile, } from './types.js';
5
+ export { addTrustedWorkspaceRoot, isBroadTrustedWorkspaceRoot, isHighStakesTrustedWorkspaceRoot, isPathWithinTrustedWorkspaceRoots, loadTrustedWorkspaceRoots, loadTrustedWorkspaceRootsSync, normalizeTrustedWorkspaceRootPath, saveTrustedWorkspaceRoots, trustedWorkspaceRootsPath, validateTrustedWorkspaceRootCandidate, } from './trusted-workspace-roots.js';
6
+ export type { CapabilityApprovalScope, FsScopeAllowlistEntry, FsScopeAllowlistFile, TrustedWorkspaceRootEntry, TrustedWorkspaceRootsFile, } from './types.js';
@@ -2,3 +2,4 @@ export { addPathToAllowlist, allPathsAllowlisted, fsScopeAllowlistPath, isPathAl
2
2
  export { evaluateL1FullStatus, hasSandboxRuntime, isCapabilityBrokerDemotionActive, isSandboxBrokerEnabled, } from './broker.js';
3
3
  export { collectOutsideRepoPaths, collectOutsideRepoPathsFromToolPayload } from './paths.js';
4
4
  export { FS_SCOPE_REASONS, shouldSkipBrokerApprovedOnce, shouldSkipBrokerApprovedRecord, } from './reasons.js';
5
+ export { addTrustedWorkspaceRoot, isBroadTrustedWorkspaceRoot, isHighStakesTrustedWorkspaceRoot, isPathWithinTrustedWorkspaceRoots, loadTrustedWorkspaceRoots, loadTrustedWorkspaceRootsSync, normalizeTrustedWorkspaceRootPath, saveTrustedWorkspaceRoots, trustedWorkspaceRootsPath, validateTrustedWorkspaceRootCandidate, } from './trusted-workspace-roots.js';
@@ -1,2 +1,2 @@
1
- export declare function collectOutsideRepoPaths(command: string, cwd: string, repoRoot: string): string[];
2
- export declare function collectOutsideRepoPathsFromToolPayload(payload: Record<string, unknown>, cwd: string, repoRoot: string): string[];
1
+ export declare function collectOutsideRepoPaths(command: string, cwd: string, repoRoot: string, trustedWorkspaceRoots?: string[]): string[];
2
+ export declare function collectOutsideRepoPathsFromToolPayload(payload: Record<string, unknown>, cwd: string, repoRoot: string, trustedWorkspaceRoots?: string[]): string[];
@@ -1,5 +1,5 @@
1
1
  import path from 'node:path';
2
- import { relativeWithinRepo, resolveMutationTarget } from '../path-utils.js';
2
+ import { resolveMutationTarget, resolveWorkspaceRootMatch } from '../path-utils.js';
3
3
  import { extractRedirectTargets, tokenizeShell } from '../shell-tokenizer.js';
4
4
  function applyPatchTargets(patch) {
5
5
  const targets = [];
@@ -42,25 +42,25 @@ function extractToolPatch(payload) {
42
42
  }
43
43
  return null;
44
44
  }
45
- function addOutsideRepoPath(paths, token, cwd, repoRoot) {
45
+ function addOutsideRepoPath(paths, token, cwd, repoRoot, trustedWorkspaceRoots = []) {
46
46
  const resolved = resolveMutationTarget(token, cwd);
47
- if (resolved && relativeWithinRepo(repoRoot, resolved) === null) {
47
+ if (resolved && resolveWorkspaceRootMatch(repoRoot, trustedWorkspaceRoots, resolved) === null) {
48
48
  paths.add(resolved);
49
49
  }
50
50
  }
51
- export function collectOutsideRepoPaths(command, cwd, repoRoot) {
51
+ export function collectOutsideRepoPaths(command, cwd, repoRoot, trustedWorkspaceRoots = []) {
52
52
  const tokens = tokenizeShell(command);
53
53
  const redirects = extractRedirectTargets(tokens);
54
54
  const paths = new Set();
55
55
  for (const token of tokens.slice(1)) {
56
- addOutsideRepoPath(paths, token, cwd, repoRoot);
56
+ addOutsideRepoPath(paths, token, cwd, repoRoot, trustedWorkspaceRoots);
57
57
  }
58
58
  for (const redirect of redirects) {
59
- addOutsideRepoPath(paths, redirect, cwd, repoRoot);
59
+ addOutsideRepoPath(paths, redirect, cwd, repoRoot, trustedWorkspaceRoots);
60
60
  }
61
61
  return [...paths];
62
62
  }
63
- export function collectOutsideRepoPathsFromToolPayload(payload, cwd, repoRoot) {
63
+ export function collectOutsideRepoPathsFromToolPayload(payload, cwd, repoRoot, trustedWorkspaceRoots = []) {
64
64
  const toolKind = String(payload.tool_name ?? '')
65
65
  .trim()
66
66
  .toLowerCase();
@@ -68,7 +68,7 @@ export function collectOutsideRepoPathsFromToolPayload(payload, cwd, repoRoot) {
68
68
  const filePath = extractToolFilePath(payload);
69
69
  if (filePath) {
70
70
  const resolved = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
71
- if (relativeWithinRepo(repoRoot, resolved) === null) {
71
+ if (resolveWorkspaceRootMatch(repoRoot, trustedWorkspaceRoots, resolved) === null) {
72
72
  paths.add(resolved);
73
73
  }
74
74
  return [...paths];
@@ -78,7 +78,7 @@ export function collectOutsideRepoPathsFromToolPayload(payload, cwd, repoRoot) {
78
78
  if (patch) {
79
79
  for (const target of applyPatchTargets(patch)) {
80
80
  const resolved = path.isAbsolute(target) ? target : path.resolve(cwd, target);
81
- if (relativeWithinRepo(repoRoot, resolved) === null) {
81
+ if (resolveWorkspaceRootMatch(repoRoot, trustedWorkspaceRoots, resolved) === null) {
82
82
  paths.add(resolved);
83
83
  }
84
84
  }
@@ -0,0 +1,25 @@
1
+ import type { BelayConfigV3 } from '../config.js';
2
+ import type { TrustedWorkspaceRootEntry, TrustedWorkspaceRootsFile } from './types.js';
3
+ export declare function trustedWorkspaceRootsPath(config: BelayConfigV3, repoLocalStateDir: string): string;
4
+ export declare function loadTrustedWorkspaceRoots(filePath: string): Promise<TrustedWorkspaceRootsFile>;
5
+ export declare function loadTrustedWorkspaceRootsSync(filePath: string): TrustedWorkspaceRootsFile;
6
+ export declare function saveTrustedWorkspaceRoots(filePath: string, state: TrustedWorkspaceRootsFile): Promise<void>;
7
+ export declare function normalizeTrustedWorkspaceRootPath(targetPath: string): string;
8
+ export interface TrustedWorkspaceRootValidationParams {
9
+ candidatePath: string;
10
+ repoRoot: string;
11
+ controlPlaneDir?: string | null;
12
+ protectedRoots?: string[];
13
+ requireExistingDirectory?: boolean;
14
+ requireNonGit?: boolean;
15
+ }
16
+ export interface TrustedWorkspaceRootValidationResult {
17
+ ok: boolean;
18
+ normalizedPath: string;
19
+ reason?: 'not_directory' | 'broad_root' | 'high_stakes' | 'inside_repo' | 'inside_git_repo' | 'control_plane_overlap' | 'protected_root_overlap';
20
+ }
21
+ export declare function isBroadTrustedWorkspaceRoot(targetPath: string): boolean;
22
+ export declare function isHighStakesTrustedWorkspaceRoot(targetPath: string): boolean;
23
+ export declare function validateTrustedWorkspaceRootCandidate(params: TrustedWorkspaceRootValidationParams): TrustedWorkspaceRootValidationResult;
24
+ export declare function isPathWithinTrustedWorkspaceRoots(absolutePath: string, roots: TrustedWorkspaceRootsFile): boolean;
25
+ export declare function addTrustedWorkspaceRoot(roots: TrustedWorkspaceRootsFile, entry: TrustedWorkspaceRootEntry): TrustedWorkspaceRootsFile;
@@ -0,0 +1,149 @@
1
+ import { existsSync, readFileSync, statSync } from 'node:fs';
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { belayStateDir } from '../config.js';
5
+ import { canonicalPath, containingGitRoot, pathWithinRoot, resolveWorkspaceRootMatch, } from '../path-utils.js';
6
+ export function trustedWorkspaceRootsPath(config, repoLocalStateDir) {
7
+ return path.join(belayStateDir(config, repoLocalStateDir), 'trusted-workspace-roots.json');
8
+ }
9
+ export async function loadTrustedWorkspaceRoots(filePath) {
10
+ if (!existsSync(filePath)) {
11
+ return { version: 1, roots: [] };
12
+ }
13
+ const raw = JSON.parse(await readFile(filePath, 'utf8'));
14
+ return { version: 1, roots: sanitizeTrustedWorkspaceRootEntries(raw.roots) };
15
+ }
16
+ export function loadTrustedWorkspaceRootsSync(filePath) {
17
+ if (!existsSync(filePath)) {
18
+ return { version: 1, roots: [] };
19
+ }
20
+ const raw = JSON.parse(readFileSync(filePath, 'utf8'));
21
+ return { version: 1, roots: sanitizeTrustedWorkspaceRootEntries(raw.roots) };
22
+ }
23
+ export async function saveTrustedWorkspaceRoots(filePath, state) {
24
+ await mkdir(path.dirname(filePath), { recursive: true });
25
+ await writeFile(filePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
26
+ }
27
+ export function normalizeTrustedWorkspaceRootPath(targetPath) {
28
+ return canonicalPath(targetPath);
29
+ }
30
+ function sanitizeTrustedWorkspaceRootEntries(input) {
31
+ if (!Array.isArray(input)) {
32
+ return [];
33
+ }
34
+ return input.flatMap((entry) => {
35
+ if (!entry || typeof entry !== 'object') {
36
+ return [];
37
+ }
38
+ const record = entry;
39
+ if (typeof record.path !== 'string' || !record.path.trim()) {
40
+ return [];
41
+ }
42
+ const approvedAt = typeof record.approvedAt === 'string' ? record.approvedAt : new Date(0).toISOString();
43
+ const approvalId = typeof record.approvalId === 'string' ? record.approvalId : 'unknown';
44
+ const source = record.source === 'approval' ? 'approval' : undefined;
45
+ return [
46
+ {
47
+ path: path.resolve(record.path),
48
+ approvedAt,
49
+ approvalId,
50
+ ...(source ? { source } : {}),
51
+ },
52
+ ];
53
+ });
54
+ }
55
+ const SYSTEM_HIGH_STAKES_PREFIXES = [
56
+ '/etc',
57
+ '/private/etc',
58
+ '/bin',
59
+ '/sbin',
60
+ '/usr',
61
+ '/opt',
62
+ '/System',
63
+ '/Library',
64
+ ];
65
+ const HOME_HIGH_STAKES_SEGMENTS = ['.ssh', '.gnupg', '.aws', '.kube', '.docker', '.config'];
66
+ function isDirectoryPath(targetPath) {
67
+ try {
68
+ return statSync(targetPath).isDirectory();
69
+ }
70
+ catch {
71
+ return false;
72
+ }
73
+ }
74
+ export function isBroadTrustedWorkspaceRoot(targetPath) {
75
+ const home = process.env.HOME ?? process.env.USERPROFILE;
76
+ const root = normalizeTrustedWorkspaceRootPath(targetPath);
77
+ if (root === normalizeTrustedWorkspaceRootPath(path.parse(root).root)) {
78
+ return true;
79
+ }
80
+ if (home && normalizeTrustedWorkspaceRootPath(home) === root) {
81
+ return true;
82
+ }
83
+ return false;
84
+ }
85
+ export function isHighStakesTrustedWorkspaceRoot(targetPath) {
86
+ const normalized = normalizeTrustedWorkspaceRootPath(targetPath);
87
+ if (SYSTEM_HIGH_STAKES_PREFIXES.some((prefix) => normalized === normalizeTrustedWorkspaceRootPath(prefix) ||
88
+ pathWithinRoot(normalizeTrustedWorkspaceRootPath(prefix), normalized))) {
89
+ return true;
90
+ }
91
+ const home = process.env.HOME ?? process.env.USERPROFILE;
92
+ if (!home) {
93
+ return false;
94
+ }
95
+ const homeRoot = normalizeTrustedWorkspaceRootPath(home);
96
+ if (!pathWithinRoot(homeRoot, normalized)) {
97
+ return false;
98
+ }
99
+ return HOME_HIGH_STAKES_SEGMENTS.some((segment) => pathWithinRoot(path.join(homeRoot, segment), normalized));
100
+ }
101
+ export function validateTrustedWorkspaceRootCandidate(params) {
102
+ const normalizedPath = normalizeTrustedWorkspaceRootPath(params.candidatePath);
103
+ if (params.requireExistingDirectory !== false && !isDirectoryPath(normalizedPath)) {
104
+ return { ok: false, normalizedPath, reason: 'not_directory' };
105
+ }
106
+ if (isBroadTrustedWorkspaceRoot(normalizedPath)) {
107
+ return { ok: false, normalizedPath, reason: 'broad_root' };
108
+ }
109
+ if (isHighStakesTrustedWorkspaceRoot(normalizedPath)) {
110
+ return { ok: false, normalizedPath, reason: 'high_stakes' };
111
+ }
112
+ if (resolveWorkspaceRootMatch(params.repoRoot, [], normalizedPath) !== null) {
113
+ return { ok: false, normalizedPath, reason: 'inside_repo' };
114
+ }
115
+ if (params.requireNonGit !== false && containingGitRoot(normalizedPath)) {
116
+ return { ok: false, normalizedPath, reason: 'inside_git_repo' };
117
+ }
118
+ const normalizedControlPlane = params.controlPlaneDir?.trim()
119
+ ? normalizeTrustedWorkspaceRootPath(params.controlPlaneDir)
120
+ : null;
121
+ if (normalizedControlPlane &&
122
+ (pathWithinRoot(normalizedControlPlane, normalizedPath) ||
123
+ pathWithinRoot(normalizedPath, normalizedControlPlane))) {
124
+ return { ok: false, normalizedPath, reason: 'control_plane_overlap' };
125
+ }
126
+ if ((params.protectedRoots ?? []).some((root) => {
127
+ const normalizedRoot = normalizeTrustedWorkspaceRootPath(root);
128
+ return (pathWithinRoot(normalizedRoot, normalizedPath) ||
129
+ pathWithinRoot(normalizedPath, normalizedRoot));
130
+ })) {
131
+ return { ok: false, normalizedPath, reason: 'protected_root_overlap' };
132
+ }
133
+ return { ok: true, normalizedPath };
134
+ }
135
+ export function isPathWithinTrustedWorkspaceRoots(absolutePath, roots) {
136
+ const resolved = normalizeTrustedWorkspaceRootPath(absolutePath);
137
+ return roots.roots.some((entry) => {
138
+ const root = path.resolve(entry.path);
139
+ return resolved === root || pathWithinRoot(root, resolved);
140
+ });
141
+ }
142
+ export function addTrustedWorkspaceRoot(roots, entry) {
143
+ const normalized = normalizeTrustedWorkspaceRootPath(entry.path);
144
+ const filtered = roots.roots.filter((existing) => normalizeTrustedWorkspaceRootPath(existing.path) !== normalized);
145
+ return {
146
+ version: 1,
147
+ roots: [...filtered, { ...entry, path: normalized }],
148
+ };
149
+ }