@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
@@ -0,0 +1,43 @@
1
+ import { scrubValue } from './scrub.js';
2
+ /** Subagent fingerprint input — must match classify-subagent `fingerprintSource`. */
3
+ export function subagentFingerprintSource(payload, scrubOptions) {
4
+ const toolInput = payload.tool_input;
5
+ if (toolInput && typeof toolInput === 'object') {
6
+ const input = toolInput;
7
+ return scrubValue({
8
+ description: input.description ?? '',
9
+ prompt: input.prompt ?? '',
10
+ }, scrubOptions);
11
+ }
12
+ const task = payload.task;
13
+ if (typeof task === 'string') {
14
+ return scrubValue({ task }, scrubOptions);
15
+ }
16
+ if (task && typeof task === 'object') {
17
+ const taskObj = task;
18
+ return scrubValue({
19
+ description: taskObj.description ?? '',
20
+ prompt: taskObj.prompt ?? '',
21
+ }, scrubOptions);
22
+ }
23
+ return scrubValue(payload, scrubOptions);
24
+ }
25
+ /**
26
+ * Scrubbed payload used for replay envelope hashing — aligned with classifier fingerprints.
27
+ * Tool: scrubbed `tool_input`. Subagent: description/prompt subset. Other: full payload.
28
+ */
29
+ export function fingerprintReplayPayload(kind, payload, scrubOptions) {
30
+ if (!payload) {
31
+ return undefined;
32
+ }
33
+ if (kind === 'tool') {
34
+ const toolInput = payload.tool_input;
35
+ if (toolInput && typeof toolInput === 'object') {
36
+ return scrubValue(toolInput, scrubOptions);
37
+ }
38
+ }
39
+ if (kind === 'subagent') {
40
+ return subagentFingerprintSource(payload, scrubOptions);
41
+ }
42
+ return scrubValue(payload, scrubOptions);
43
+ }
@@ -1,3 +1,5 @@
1
+ export declare function isRedirectOperator(token: string): boolean;
2
+ export declare function isFdDuplication(token: string): boolean;
1
3
  export declare function tokenizeShell(input: string): string[];
2
4
  export declare function normalizeShellCommand(command: string, repoRoot: string, normalizeToken: (t: string, r: string) => string): string;
3
5
  export declare function splitTopLevelSegments(tokens: string[]): string[][];
@@ -1,4 +1,72 @@
1
1
  const ENV_PREFIX_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*=(?:'[^']*'|"[^"]*"|\S+)$/;
2
+ const FD_DUPLICATION_PATTERN = /^\d+[<>]&(?:\d+|-)$/;
3
+ const FD_REDIRECT_PATTERN = /^\d+(?:>>?|<)$/;
4
+ function readDigits(input, index) {
5
+ let end = index;
6
+ while (end < input.length && /[0-9]/.test(input[end] ?? '')) {
7
+ end += 1;
8
+ }
9
+ return input.slice(index, end);
10
+ }
11
+ // This tokenizer only recognizes FD redirects when the digit run is immediately
12
+ // followed by `>` or `<` (for example `2>&1`, `3>file`, `12>>log`, `3<file`,
13
+ // `3<&1`). It does not try to recover bash's full word-boundary rules for cases
14
+ // like `foo12>>bar` or `foo12<bar`.
15
+ function readShellOperator(input, index) {
16
+ const char = input[index];
17
+ const next = input[index + 1] ?? '';
18
+ const digits = /[0-9]/.test(char) ? readDigits(input, index) : '';
19
+ const digitsLength = digits.length;
20
+ const afterDigits = digitsLength > 0 ? (input[index + digitsLength] ?? '') : '';
21
+ const afterDigitsNext = digitsLength > 0 ? (input[index + digitsLength + 1] ?? '') : '';
22
+ if (char === '&' && next === '&') {
23
+ return { token: '&&', length: 2 };
24
+ }
25
+ if (char === '|' && next === '|') {
26
+ return { token: '||', length: 2 };
27
+ }
28
+ if (char === '|' && next === '&') {
29
+ return { token: '|&', length: 2 };
30
+ }
31
+ if (char === '&' && next === '>') {
32
+ return { token: '&>', length: 2 };
33
+ }
34
+ if (digitsLength > 0 && (afterDigits === '>' || afterDigits === '<')) {
35
+ if (afterDigitsNext === '&') {
36
+ let end = index + digitsLength + 2;
37
+ while (end < input.length && /[0-9-]/.test(input[end] ?? '')) {
38
+ end += 1;
39
+ }
40
+ if (end > index + digitsLength + 2) {
41
+ return { token: input.slice(index, end), length: end - index };
42
+ }
43
+ }
44
+ if (afterDigits === '>' && afterDigitsNext === '>') {
45
+ return { token: `${digits}>>`, length: digitsLength + 2 };
46
+ }
47
+ return { token: `${digits}${afterDigits}`, length: digitsLength + 1 };
48
+ }
49
+ if (char === '>' && next === '>') {
50
+ return { token: '>>', length: 2 };
51
+ }
52
+ if (char === '&') {
53
+ return { token: '&', length: 1 };
54
+ }
55
+ if (char === '|' || char === ';' || char === '>' || char === '<') {
56
+ return { token: char, length: 1 };
57
+ }
58
+ return null;
59
+ }
60
+ export function isRedirectOperator(token) {
61
+ return (token === '>' ||
62
+ token === '>>' ||
63
+ token === '<' ||
64
+ token === '&>' ||
65
+ FD_REDIRECT_PATTERN.test(token));
66
+ }
67
+ export function isFdDuplication(token) {
68
+ return FD_DUPLICATION_PATTERN.test(token);
69
+ }
2
70
  export function tokenizeShell(input) {
3
71
  const tokens = [];
4
72
  let buffer = '';
@@ -12,7 +80,6 @@ export function tokenizeShell(input) {
12
80
  };
13
81
  for (let index = 0; index < input.length; index += 1) {
14
82
  const char = input[index];
15
- const next = input[index + 1] ?? '';
16
83
  if (escaping) {
17
84
  buffer += char;
18
85
  escaping = false;
@@ -35,27 +102,11 @@ export function tokenizeShell(input) {
35
102
  quote = char;
36
103
  continue;
37
104
  }
38
- if (char === '&' && next === '&') {
39
- flush();
40
- tokens.push('&&');
41
- index += 1;
42
- continue;
43
- }
44
- if (char === '|' && next === '|') {
45
- flush();
46
- tokens.push('||');
47
- index += 1;
48
- continue;
49
- }
50
- if (char === '>' && next === '>') {
105
+ const operator = readShellOperator(input, index);
106
+ if (operator) {
51
107
  flush();
52
- tokens.push('>>');
53
- index += 1;
54
- continue;
55
- }
56
- if (char === '|' || char === ';' || char === '>' || char === '<') {
57
- flush();
58
- tokens.push(char);
108
+ tokens.push(operator.token);
109
+ index += operator.length - 1;
59
110
  continue;
60
111
  }
61
112
  if (char === '\n' || char === '\r') {
@@ -84,7 +135,12 @@ export function splitTopLevelSegments(tokens) {
84
135
  const segments = [];
85
136
  let current = [];
86
137
  for (const token of tokens) {
87
- if (token === '&&' || token === '||' || token === ';' || token === '|') {
138
+ if (token === '&&' ||
139
+ token === '||' ||
140
+ token === ';' ||
141
+ token === '|' ||
142
+ token === '&' ||
143
+ token === '|&') {
88
144
  if (current.length > 0) {
89
145
  segments.push(current);
90
146
  }
@@ -118,7 +174,10 @@ export function extractRedirectTargets(tokens) {
118
174
  const targets = [];
119
175
  for (let index = 0; index < tokens.length; index += 1) {
120
176
  const token = tokens[index];
121
- if (token === '>' || token === '>>' || token === '<') {
177
+ if (isFdDuplication(token)) {
178
+ continue;
179
+ }
180
+ if (isRedirectOperator(token)) {
122
181
  const next = tokens[index + 1];
123
182
  if (next) {
124
183
  targets.push(next);
@@ -0,0 +1,50 @@
1
+ import type { BelayConfigV4 } from './config.js';
2
+ import type { GatedActionKind } from './gate-contract.js';
3
+ import type { ClassifyResult } from './types.js';
4
+ export type StandingAllowSource = 'provably-benign-corpus' | 'must-allow-catalog' | 'operator' | 'availability-reconfirmed';
5
+ export interface StandingAllowEntry {
6
+ kind: GatedActionKind;
7
+ fingerprint: string;
8
+ source: StandingAllowSource;
9
+ reason: string;
10
+ summary?: string;
11
+ createdAt: string;
12
+ expiresAt: string;
13
+ repoRoot?: string;
14
+ }
15
+ export interface StandingAllowFile {
16
+ version: 1;
17
+ entries: StandingAllowEntry[];
18
+ }
19
+ export interface StandingAllowMatch {
20
+ source: StandingAllowSource;
21
+ catalogCommand?: string;
22
+ entryId?: string;
23
+ }
24
+ /** Default TTL for operator / availability-reconfirmed standing-allow entries. */
25
+ export declare const DEFAULT_STANDING_ALLOW_TTL_MS: number;
26
+ export declare function standingAllowFile(config: BelayConfigV4, repoLocalStateDir: string): string;
27
+ export declare function isTier0StandingAllowBlocked(result: ClassifyResult): boolean;
28
+ export declare function compactStandingAllow(state: StandingAllowFile, now?: number): StandingAllowFile;
29
+ export declare function loadStandingAllow(filePath: string): Promise<StandingAllowFile>;
30
+ export declare function saveStandingAllow(filePath: string, state: StandingAllowFile): Promise<void>;
31
+ export declare function resolveStandingAllowMatch(params: {
32
+ kind: GatedActionKind;
33
+ result: ClassifyResult;
34
+ repoRoot: string;
35
+ state: StandingAllowFile;
36
+ now?: number;
37
+ }): StandingAllowMatch | null;
38
+ export declare function addStandingAllowEntry(state: StandingAllowFile, entry: Omit<StandingAllowEntry, 'createdAt' | 'expiresAt'> & {
39
+ createdAt?: string;
40
+ expiresAt?: string;
41
+ ttlMs?: number;
42
+ }): StandingAllowFile;
43
+ export declare function revokeStandingAllowEntry(state: StandingAllowFile, params: {
44
+ kind: GatedActionKind;
45
+ fingerprint: string;
46
+ repoRoot?: string;
47
+ }): {
48
+ state: StandingAllowFile;
49
+ removed: boolean;
50
+ };
@@ -0,0 +1,175 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { STANDING_ALLOW_CATALOG } from '../corpus/standing-allow-catalog.generated.js';
5
+ import { belayStateDir } from './config.js';
6
+ const EMPTY_STANDING_ALLOW = {
7
+ version: 1,
8
+ entries: [],
9
+ };
10
+ /** Default TTL for operator / availability-reconfirmed standing-allow entries. */
11
+ export const DEFAULT_STANDING_ALLOW_TTL_MS = 30 * 24 * 60 * 60 * 1000;
12
+ const PROVABLY_BENIGN_COMMANDS = new Set(STANDING_ALLOW_CATALOG.shell.provablyBenign.map((entry) => entry.normalizedCommand));
13
+ const MUST_ALLOW_COMMANDS = new Set(STANDING_ALLOW_CATALOG.shell.mustAllow.map((entry) => entry.normalizedCommand));
14
+ /** Reasons that must never be silenced via standing-allow (defense in depth with signal checks). */
15
+ const STANDING_ALLOW_BLOCKED_REASONS = new Set([
16
+ 'external_effect',
17
+ 'tier1_catastrophic',
18
+ 'protected_artifact',
19
+ 'pipe_to_shell',
20
+ 'command_substitution',
21
+ ]);
22
+ export function standingAllowFile(config, repoLocalStateDir) {
23
+ return `${belayStateDir(config, repoLocalStateDir)}/standing-allow.json`;
24
+ }
25
+ export function isTier0StandingAllowBlocked(result) {
26
+ if (result.reason.startsWith('tier0_') || STANDING_ALLOW_BLOCKED_REASONS.has(result.reason)) {
27
+ return true;
28
+ }
29
+ const signals = result.assessment?.signals ?? [];
30
+ return signals.some((signal) => signal === 'tier0_external' || signal === 'tier1_catastrophic');
31
+ }
32
+ function isExpired(iso, now = Date.now()) {
33
+ const parsed = Date.parse(iso);
34
+ return Number.isNaN(parsed) || parsed <= now;
35
+ }
36
+ export function compactStandingAllow(state, now = Date.now()) {
37
+ return {
38
+ version: 1,
39
+ entries: state.entries.filter((entry) => !isExpired(entry.expiresAt, now)),
40
+ };
41
+ }
42
+ function sanitizeStandingAllowEntries(input) {
43
+ if (!Array.isArray(input)) {
44
+ return [];
45
+ }
46
+ return input.flatMap((entry) => {
47
+ if (!entry || typeof entry !== 'object') {
48
+ return [];
49
+ }
50
+ const record = entry;
51
+ if ((record.kind !== 'shell' && record.kind !== 'tool' && record.kind !== 'subagent') ||
52
+ typeof record.fingerprint !== 'string' ||
53
+ !record.fingerprint.trim() ||
54
+ typeof record.expiresAt !== 'string' ||
55
+ typeof record.createdAt !== 'string' ||
56
+ typeof record.reason !== 'string') {
57
+ return [];
58
+ }
59
+ const source = record.source;
60
+ if (source !== 'operator' && source !== 'availability-reconfirmed') {
61
+ return [];
62
+ }
63
+ return [
64
+ {
65
+ kind: record.kind,
66
+ fingerprint: record.fingerprint,
67
+ source,
68
+ reason: record.reason,
69
+ createdAt: record.createdAt,
70
+ expiresAt: record.expiresAt,
71
+ ...(typeof record.summary === 'string' ? { summary: record.summary } : {}),
72
+ ...(typeof record.repoRoot === 'string' ? { repoRoot: record.repoRoot } : {}),
73
+ },
74
+ ];
75
+ });
76
+ }
77
+ export async function loadStandingAllow(filePath) {
78
+ if (!existsSync(filePath)) {
79
+ return { ...EMPTY_STANDING_ALLOW };
80
+ }
81
+ const raw = JSON.parse(await readFile(filePath, 'utf8'));
82
+ const rawCount = Array.isArray(raw.entries) ? raw.entries.length : 0;
83
+ const entries = sanitizeStandingAllowEntries(raw.entries);
84
+ const compacted = compactStandingAllow({ version: 1, entries });
85
+ if (rawCount !== compacted.entries.length || entries.length !== compacted.entries.length) {
86
+ await saveStandingAllow(filePath, compacted);
87
+ }
88
+ return compacted;
89
+ }
90
+ export async function saveStandingAllow(filePath, state) {
91
+ await mkdir(path.dirname(filePath), { recursive: true });
92
+ const compacted = compactStandingAllow(state);
93
+ await writeFile(filePath, `${JSON.stringify(compacted, null, 2)}\n`, 'utf8');
94
+ }
95
+ function matchBundledCatalog(kind, normalizedCommand) {
96
+ if (kind !== 'shell' || !normalizedCommand) {
97
+ return null;
98
+ }
99
+ if (PROVABLY_BENIGN_COMMANDS.has(normalizedCommand)) {
100
+ return { source: 'provably-benign-corpus', catalogCommand: normalizedCommand };
101
+ }
102
+ if (MUST_ALLOW_COMMANDS.has(normalizedCommand)) {
103
+ return { source: 'must-allow-catalog', catalogCommand: normalizedCommand };
104
+ }
105
+ return null;
106
+ }
107
+ function matchStateEntry(params) {
108
+ const now = params.now ?? Date.now();
109
+ const match = params.state.entries.find((entry) => entry.kind === params.kind &&
110
+ entry.fingerprint === params.fingerprint &&
111
+ !isExpired(entry.expiresAt, now) &&
112
+ (entry.repoRoot === undefined || entry.repoRoot === params.repoRoot));
113
+ if (!match) {
114
+ return null;
115
+ }
116
+ return { source: match.source, entryId: match.fingerprint };
117
+ }
118
+ export function resolveStandingAllowMatch(params) {
119
+ if (params.result.verdict !== 'deny_pending_approval') {
120
+ return null;
121
+ }
122
+ if (isTier0StandingAllowBlocked(params.result)) {
123
+ return null;
124
+ }
125
+ const normalizedCommand = params.result.normalizedCommand ?? params.result.summary ?? '';
126
+ const catalogMatch = matchBundledCatalog(params.kind, normalizedCommand);
127
+ if (catalogMatch) {
128
+ return catalogMatch;
129
+ }
130
+ return matchStateEntry({
131
+ kind: params.kind,
132
+ fingerprint: params.result.fingerprint,
133
+ repoRoot: params.repoRoot,
134
+ state: params.state,
135
+ now: params.now,
136
+ });
137
+ }
138
+ export function addStandingAllowEntry(state, entry) {
139
+ const createdAt = entry.createdAt ?? new Date().toISOString();
140
+ const ttlMs = entry.ttlMs ?? DEFAULT_STANDING_ALLOW_TTL_MS;
141
+ const expiresAt = entry.expiresAt ?? new Date(Date.parse(createdAt) + ttlMs).toISOString();
142
+ const next = {
143
+ kind: entry.kind,
144
+ fingerprint: entry.fingerprint,
145
+ source: entry.source,
146
+ reason: entry.reason,
147
+ createdAt,
148
+ expiresAt,
149
+ ...(entry.summary ? { summary: entry.summary } : {}),
150
+ ...(entry.repoRoot ? { repoRoot: entry.repoRoot } : {}),
151
+ };
152
+ const filtered = state.entries.filter((existing) => !(existing.kind === next.kind &&
153
+ existing.fingerprint === next.fingerprint &&
154
+ existing.repoRoot === next.repoRoot));
155
+ return compactStandingAllow({
156
+ version: 1,
157
+ entries: [...filtered, next],
158
+ });
159
+ }
160
+ export function revokeStandingAllowEntry(state, params) {
161
+ const before = state.entries.length;
162
+ const entries = state.entries.filter((entry) => {
163
+ if (entry.kind !== params.kind || entry.fingerprint !== params.fingerprint) {
164
+ return true;
165
+ }
166
+ if (params.repoRoot && entry.repoRoot && entry.repoRoot !== params.repoRoot) {
167
+ return true;
168
+ }
169
+ return false;
170
+ });
171
+ return {
172
+ state: { version: 1, entries },
173
+ removed: entries.length < before,
174
+ };
175
+ }
@@ -67,11 +67,16 @@ export interface ClassifierOptions {
67
67
  /** When true with sandbox enabled, outside-repo rules defer to fs-scope allowlist. */
68
68
  brokerFsScope?: boolean;
69
69
  fsScopeAllowlist?: FsScopeAllowlistFile;
70
+ trustedWorkspaceRoots?: string[];
70
71
  /** Test override: inject Tier1 judge without changing config.judge. */
71
72
  tier1Judge?: import('./verdict/types.js').Tier1Judge;
72
73
  /** When false, path resolution stays fail-closed (opaque cd chains). Default: Boolean(cwd). */
73
74
  trustedCwd?: boolean;
74
75
  }
76
+ export interface ApprovalScopeHint {
77
+ scope: 'workspace-root';
78
+ path: string;
79
+ }
75
80
  export interface ApprovalRecord {
76
81
  approvalId: string;
77
82
  kind: 'shell' | 'subagent' | 'tool' | 'egress' | 'capability';
@@ -87,6 +92,16 @@ export interface ApprovalRecord {
87
92
  /** Original gated input for explain-last-ask (ApprovalState v2). */
88
93
  input?: string;
89
94
  inputKind?: 'shell' | 'tool' | 'subagent';
95
+ /** Replay envelope: working directory when the action was gated. */
96
+ cwd?: string;
97
+ /** Replay envelope: tool name for tool/subagent actions. */
98
+ toolName?: string;
99
+ /** Replay envelope: canonical payload hash for strict replay validation. */
100
+ payloadHash?: string;
101
+ /** Replay envelope: scrubbed payload JSON for explain (size-capped at write time). */
102
+ payloadJson?: string;
103
+ /** Optional scope hint for structured capability approvals. */
104
+ scopeHint?: ApprovalScopeHint;
90
105
  }
91
106
  export interface ApprovalStateFile {
92
107
  version: 1 | 2;
@@ -1,4 +1,5 @@
1
1
  import { judgeTraceAuditFields } from './judge-audit.js';
2
+ import { recordJudgeLatency } from './judge-baseline.js';
2
3
  import { createJudgeFromConfig } from './judge-factory.js';
3
4
  import { verdict } from './verdict.js';
4
5
  export function resolveClassifierTrustedCwd(cwd, options, explicit) {
@@ -16,11 +17,14 @@ export function buildVerdictContext(params) {
16
17
  cwd: params.cwd,
17
18
  repoRoot: params.repoRoot,
18
19
  trustedCwd: resolveClassifierTrustedCwd(params.cwd, params.options, params.trustedCwd),
20
+ trustedWorkspaceRoots: params.options?.trustedWorkspaceRoots ?? [],
19
21
  sensitivePaths: params.options?.sensitivePaths ?? params.config.classifier.sensitivePaths,
20
22
  protectedArtifactRoots: protectedArtifactRoots.length > 0 ? [...new Set(protectedArtifactRoots)] : undefined,
21
23
  customAllowCommands: params.options?.customAllowCommands ?? params.config.overrides.allow,
22
24
  customExternalCommands: params.options?.customExternalCommands ?? params.config.overrides.external,
23
- judge: params.judge ?? params.options?.tier1Judge ?? createJudgeFromConfig(params.config),
25
+ judge: params.judge ??
26
+ params.options?.tier1Judge ??
27
+ createJudgeFromConfig(params.config, { repoRoot: params.repoRoot }),
24
28
  mode: params.config.mode,
25
29
  unknownLocalEffect: params.options?.unknownLocalEffect ?? params.config.policy.unknownLocalEffect,
26
30
  unparseableShell: params.options?.unparseableShell ?? params.config.policy.unparseableShell,
@@ -28,7 +32,12 @@ export function buildVerdictContext(params) {
28
32
  }
29
33
  export async function classifyShell(command, cwd, repoRoot, config, options = {}, judge) {
30
34
  const context = buildVerdictContext({ cwd, repoRoot, config, options, judge });
35
+ const started = Date.now();
31
36
  const result = await verdict(command, context);
37
+ const elapsed = Date.now() - started;
38
+ if (result.confidence !== 'llm') {
39
+ recordJudgeLatency('tier0', elapsed);
40
+ }
32
41
  return verdictToClassifyResult(result);
33
42
  }
34
43
  function mapLegacyReason(result) {
@@ -6,13 +6,13 @@ export interface PathTargetAnalysis {
6
6
  signals: string[];
7
7
  }
8
8
  export declare function resolveTrustedPath(token: string, trustedCwd: string, trusted: boolean): string | null;
9
- export declare function locationForPath(resolvedPath: string | null, repoRoot: string): VerdictLocation;
9
+ export declare function locationForPath(resolvedPath: string | null, repoRoot: string, trustedWorkspaceRoots?: string[]): VerdictLocation;
10
10
  export declare function isGitPath(resolvedPath: string, repoRoot: string): boolean;
11
- export declare function isHighStakesPath(resolvedPath: string, repoRoot: string, sensitivePaths: string[], protectedRoots?: string[]): boolean;
11
+ export declare function isHighStakesPath(resolvedPath: string, repoRoot: string, sensitivePaths: string[], protectedRoots?: string[], trustedWorkspaceRoots?: string[]): boolean;
12
12
  /** ADR-002 M3: destructive shell head on git/sensitive path (not broad category ask). */
13
13
  export declare function isDestructiveMutationHead(head: string): boolean;
14
14
  export declare function touchesProtectedRoot(resolvedPath: string, protectedRoots: string[]): boolean;
15
- export declare function isDestructiveHighStakesMutation(head: string, resolvedPath: string, repoRoot: string, sensitivePaths: string[], protectedRoots?: string[]): boolean;
15
+ export declare function isDestructiveHighStakesMutation(head: string, resolvedPath: string, repoRoot: string, sensitivePaths: string[], protectedRoots?: string[], trustedWorkspaceRoots?: string[]): boolean;
16
16
  export declare function analyzePathTargets(params: {
17
17
  targets: string[];
18
18
  cwd: string;
@@ -20,5 +20,6 @@ export declare function analyzePathTargets(params: {
20
20
  trustedCwd: boolean;
21
21
  sensitivePaths: string[];
22
22
  protectedArtifactRoots?: string[];
23
+ trustedWorkspaceRoots?: string[];
23
24
  }): PathTargetAnalysis;
24
25
  export declare function cwdRelative(repoRoot: string, cwd: string): string;
@@ -1,6 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import { matchesSensitivePath } from '../glob.js';
3
- import { canonicalPath, pathWithinRoot, relativeWithinRepo, resolveMutationTarget, } from '../path-utils.js';
3
+ import { canonicalPath, pathWithinRoot, relativeWithinRepo, resolveMutationTarget, resolveWorkspaceRootMatch, } from '../path-utils.js';
4
4
  import { isOutsideRepoSecretCredentialPath } from './persistent-paths.js';
5
5
  export { isOutsideRepoSecretCredentialPath, isPersistentAgentPath } from './persistent-paths.js';
6
6
  function expandHome(token) {
@@ -26,11 +26,11 @@ export function resolveTrustedPath(token, trustedCwd, trusted) {
26
26
  }
27
27
  return canonicalPath(path.resolve(trustedCwd, expanded));
28
28
  }
29
- export function locationForPath(resolvedPath, repoRoot) {
29
+ export function locationForPath(resolvedPath, repoRoot, trustedWorkspaceRoots = []) {
30
30
  if (!resolvedPath) {
31
31
  return 'unknown';
32
32
  }
33
- if (pathWithinRoot(repoRoot, resolvedPath)) {
33
+ if (resolveWorkspaceRootMatch(repoRoot, trustedWorkspaceRoots, resolvedPath)) {
34
34
  return 'repo_local';
35
35
  }
36
36
  return 'repo_outside';
@@ -41,17 +41,22 @@ export function isGitPath(resolvedPath, repoRoot) {
41
41
  return false;
42
42
  }
43
43
  const normalized = relative.replaceAll('\\', '/');
44
- return normalized === '.git' || normalized.startsWith('.git/');
44
+ return (normalized === '.git' ||
45
+ normalized.startsWith('.git/') ||
46
+ normalized.endsWith('/.git') ||
47
+ normalized.includes('/.git/'));
45
48
  }
46
- export function isHighStakesPath(resolvedPath, repoRoot, sensitivePaths, protectedRoots = []) {
49
+ export function isHighStakesPath(resolvedPath, repoRoot, sensitivePaths, protectedRoots = [], trustedWorkspaceRoots = []) {
47
50
  if (isGitPath(resolvedPath, repoRoot)) {
48
51
  return true;
49
52
  }
50
- const relative = relativeWithinRepo(repoRoot, resolvedPath);
51
- if (relative !== null && matchesSensitivePath(relative.replaceAll('\\', '/'), sensitivePaths)) {
53
+ const workspaceMatch = resolveWorkspaceRootMatch(repoRoot, trustedWorkspaceRoots, resolvedPath);
54
+ if (workspaceMatch !== null &&
55
+ matchesSensitivePath(workspaceMatch.relativePath.replaceAll('\\', '/'), sensitivePaths)) {
52
56
  return true;
53
57
  }
54
- if (relative === null && isOutsideRepoSecretCredentialPath(resolvedPath)) {
58
+ if ((workspaceMatch === null || workspaceMatch.kind === 'trusted') &&
59
+ isOutsideRepoSecretCredentialPath(resolvedPath)) {
55
60
  return true;
56
61
  }
57
62
  return protectedRoots.some((root) => pathWithinRoot(root, resolvedPath));
@@ -73,14 +78,14 @@ export function isDestructiveMutationHead(head) {
73
78
  export function touchesProtectedRoot(resolvedPath, protectedRoots) {
74
79
  return protectedRoots.some((root) => pathWithinRoot(root, resolvedPath) || root === resolvedPath);
75
80
  }
76
- export function isDestructiveHighStakesMutation(head, resolvedPath, repoRoot, sensitivePaths, protectedRoots = []) {
81
+ export function isDestructiveHighStakesMutation(head, resolvedPath, repoRoot, sensitivePaths, protectedRoots = [], trustedWorkspaceRoots = []) {
77
82
  if (touchesProtectedRoot(resolvedPath, protectedRoots)) {
78
83
  return true;
79
84
  }
80
85
  if (!isDestructiveMutationHead(head)) {
81
86
  return false;
82
87
  }
83
- return isHighStakesPath(resolvedPath, repoRoot, sensitivePaths, []);
88
+ return isHighStakesPath(resolvedPath, repoRoot, sensitivePaths, [], trustedWorkspaceRoots);
84
89
  }
85
90
  export function analyzePathTargets(params) {
86
91
  const signals = [];
@@ -96,10 +101,10 @@ export function analyzePathTargets(params) {
96
101
  for (const target of params.targets) {
97
102
  const resolved = resolveTrustedPath(target, params.cwd, params.trustedCwd) ??
98
103
  resolveMutationTarget(target, params.cwd);
99
- const location = locationForPath(resolved, params.repoRoot);
104
+ const location = locationForPath(resolved, params.repoRoot, params.trustedWorkspaceRoots);
100
105
  locations.add(location);
101
106
  if (resolved &&
102
- isHighStakesPath(resolved, params.repoRoot, params.sensitivePaths, params.protectedArtifactRoots)) {
107
+ isHighStakesPath(resolved, params.repoRoot, params.sensitivePaths, params.protectedArtifactRoots, params.trustedWorkspaceRoots)) {
103
108
  isHighStakes = true;
104
109
  signals.push('high_stakes_path');
105
110
  }
@@ -1,2 +1,3 @@
1
1
  import type { JudgeTrace } from './types.js';
2
+ export declare function hashJudgeSessionRef(value: string): string;
2
3
  export declare function judgeTraceAuditFields(trace?: JudgeTrace): Record<string, unknown>;
@@ -1,3 +1,7 @@
1
+ import { createHash } from 'node:crypto';
2
+ export function hashJudgeSessionRef(value) {
3
+ return createHash('sha256').update(value).digest('hex').slice(0, 16);
4
+ }
1
5
  export function judgeTraceAuditFields(trace) {
2
6
  if (!trace) {
3
7
  return {};
@@ -10,6 +14,33 @@ export function judgeTraceAuditFields(trace) {
10
14
  ...(trace.outboundRedacted !== undefined
11
15
  ? { judgeOutboundRedacted: trace.outboundRedacted }
12
16
  : {}),
13
- ...(trace.fallbackReason ? { judgeFallbackReason: trace.fallbackReason } : {}),
17
+ ...(trace.judgeFallbackReason
18
+ ? { judgeFallbackReason: trace.judgeFallbackReason }
19
+ : trace.fallbackReason
20
+ ? { judgeFallbackReason: trace.fallbackReason }
21
+ : {}),
22
+ ...(trace.judgeSessionUsed !== undefined ? { judgeSessionUsed: trace.judgeSessionUsed } : {}),
23
+ ...(trace.judgeSessionReused !== undefined
24
+ ? { judgeSessionReused: trace.judgeSessionReused }
25
+ : {}),
26
+ ...(trace.judgeSessionRefHash ? { judgeSessionRefHash: trace.judgeSessionRefHash } : {}),
27
+ ...(trace.judgeSessionResetReason
28
+ ? { judgeSessionResetReason: trace.judgeSessionResetReason }
29
+ : {}),
30
+ ...(trace.judgeConnectMs !== undefined ? { judgeConnectMs: trace.judgeConnectMs } : {}),
31
+ ...(trace.judgeEvalMs !== undefined ? { judgeEvalMs: trace.judgeEvalMs } : {}),
32
+ ...(trace.judgeParseMs !== undefined ? { judgeParseMs: trace.judgeParseMs } : {}),
33
+ ...(trace.judgeShadowCompared !== undefined
34
+ ? { judgeShadowCompared: trace.judgeShadowCompared }
35
+ : {}),
36
+ ...(trace.judgeShadowMismatch !== undefined
37
+ ? { judgeShadowMismatch: trace.judgeShadowMismatch }
38
+ : {}),
39
+ ...(trace.judgeShadowMismatchRateWindow !== undefined
40
+ ? { judgeShadowMismatchRateWindow: trace.judgeShadowMismatchRateWindow }
41
+ : {}),
42
+ ...(trace.judgeKillSwitchTriggered !== undefined
43
+ ? { judgeKillSwitchTriggered: trace.judgeKillSwitchTriggered }
44
+ : {}),
14
45
  };
15
46
  }
@@ -0,0 +1,20 @@
1
+ import { JUDGE_LATENCY_SLO } from './judge-runtime-config.js';
2
+ export type JudgeLatencyPhase = 'tier0' | 'tier1_spawn' | 'tier1_session' | 'tier1_connect' | 'tier1_eval' | 'tier1_parse';
3
+ export interface JudgeLatencySample {
4
+ phase: JudgeLatencyPhase;
5
+ latencyMs: number;
6
+ at: number;
7
+ }
8
+ export interface JudgeLatencyPercentiles {
9
+ p50: number;
10
+ p95: number;
11
+ count: number;
12
+ }
13
+ export declare function resetJudgeLatencySamples(): void;
14
+ export declare function recordJudgeLatency(phase: JudgeLatencyPhase, latencyMs: number, at?: number): void;
15
+ export declare function judgeLatencyPercentiles(phase: JudgeLatencyPhase): JudgeLatencyPercentiles;
16
+ export declare function judgeLatencySloReport(): {
17
+ slo: typeof JUDGE_LATENCY_SLO;
18
+ measured: Record<JudgeLatencyPhase, JudgeLatencyPercentiles>;
19
+ sessionMeetsTarget: boolean;
20
+ };