@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
@@ -67,8 +67,15 @@ 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;
73
+ /** When false, path resolution stays fail-closed (opaque cd chains). Default: Boolean(cwd). */
74
+ trustedCwd?: boolean;
75
+ }
76
+ export interface ApprovalScopeHint {
77
+ scope: 'workspace-root';
78
+ path: string;
72
79
  }
73
80
  export interface ApprovalRecord {
74
81
  approvalId: string;
@@ -85,6 +92,16 @@ export interface ApprovalRecord {
85
92
  /** Original gated input for explain-last-ask (ApprovalState v2). */
86
93
  input?: string;
87
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;
88
105
  }
89
106
  export interface ApprovalStateFile {
90
107
  version: 1 | 2;
@@ -1,6 +1,7 @@
1
1
  import type { BelayConfigV4 } from '../config.js';
2
2
  import type { ClassifierOptions, ClassifyResult } from '../types.js';
3
3
  import type { Tier1Judge, VerdictContext, VerdictResult } from './types.js';
4
+ export declare function resolveClassifierTrustedCwd(cwd: string, options?: Pick<ClassifierOptions, 'trustedCwd'>, explicit?: boolean): boolean;
4
5
  export declare function buildVerdictContext(params: {
5
6
  cwd: string;
6
7
  repoRoot: string;
@@ -1,6 +1,13 @@
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';
5
+ export function resolveClassifierTrustedCwd(cwd, options, explicit) {
6
+ if (explicit !== undefined) {
7
+ return explicit;
8
+ }
9
+ return options?.trustedCwd ?? Boolean(cwd);
10
+ }
4
11
  export function buildVerdictContext(params) {
5
12
  const protectedArtifactRoots = [
6
13
  ...(params.options?.protectedArtifactRoots ?? []),
@@ -9,12 +16,15 @@ export function buildVerdictContext(params) {
9
16
  return {
10
17
  cwd: params.cwd,
11
18
  repoRoot: params.repoRoot,
12
- trustedCwd: params.trustedCwd ?? Boolean(params.cwd),
19
+ trustedCwd: resolveClassifierTrustedCwd(params.cwd, params.options, params.trustedCwd),
20
+ trustedWorkspaceRoots: params.options?.trustedWorkspaceRoots ?? [],
13
21
  sensitivePaths: params.options?.sensitivePaths ?? params.config.classifier.sensitivePaths,
14
22
  protectedArtifactRoots: protectedArtifactRoots.length > 0 ? [...new Set(protectedArtifactRoots)] : undefined,
15
23
  customAllowCommands: params.options?.customAllowCommands ?? params.config.overrides.allow,
16
24
  customExternalCommands: params.options?.customExternalCommands ?? params.config.overrides.external,
17
- judge: params.judge ?? params.options?.tier1Judge ?? createJudgeFromConfig(params.config),
25
+ judge: params.judge ??
26
+ params.options?.tier1Judge ??
27
+ createJudgeFromConfig(params.config, { repoRoot: params.repoRoot }),
18
28
  mode: params.config.mode,
19
29
  unknownLocalEffect: params.options?.unknownLocalEffect ?? params.config.policy.unknownLocalEffect,
20
30
  unparseableShell: params.options?.unparseableShell ?? params.config.policy.unparseableShell,
@@ -22,7 +32,12 @@ export function buildVerdictContext(params) {
22
32
  }
23
33
  export async function classifyShell(command, cwd, repoRoot, config, options = {}, judge) {
24
34
  const context = buildVerdictContext({ cwd, repoRoot, config, options, judge });
35
+ const started = Date.now();
25
36
  const result = await verdict(command, context);
37
+ const elapsed = Date.now() - started;
38
+ if (result.confidence !== 'llm') {
39
+ recordJudgeLatency('tier0', elapsed);
40
+ }
26
41
  return verdictToClassifyResult(result);
27
42
  }
28
43
  function mapLegacyReason(result) {
@@ -1,3 +1,4 @@
1
+ export { isOutsideRepoSecretCredentialPath, isPersistentAgentPath } from './persistent-paths.js';
1
2
  import type { VerdictLocation } from './types.js';
2
3
  export interface PathTargetAnalysis {
3
4
  location: VerdictLocation;
@@ -5,9 +6,13 @@ export interface PathTargetAnalysis {
5
6
  signals: string[];
6
7
  }
7
8
  export declare function resolveTrustedPath(token: string, trustedCwd: string, trusted: boolean): string | null;
8
- export declare function locationForPath(resolvedPath: string | null, repoRoot: string): VerdictLocation;
9
+ export declare function locationForPath(resolvedPath: string | null, repoRoot: string, trustedWorkspaceRoots?: string[]): VerdictLocation;
9
10
  export declare function isGitPath(resolvedPath: string, repoRoot: string): boolean;
10
- 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
+ /** ADR-002 M3: destructive shell head on git/sensitive path (not broad category ask). */
13
+ export declare function isDestructiveMutationHead(head: string): boolean;
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[], trustedWorkspaceRoots?: string[]): boolean;
11
16
  export declare function analyzePathTargets(params: {
12
17
  targets: string[];
13
18
  cwd: string;
@@ -15,5 +20,6 @@ export declare function analyzePathTargets(params: {
15
20
  trustedCwd: boolean;
16
21
  sensitivePaths: string[];
17
22
  protectedArtifactRoots?: string[];
23
+ trustedWorkspaceRoots?: string[];
18
24
  }): PathTargetAnalysis;
19
25
  export declare function cwdRelative(repoRoot: string, cwd: string): string;
@@ -1,6 +1,8 @@
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
+ import { isOutsideRepoSecretCredentialPath } from './persistent-paths.js';
5
+ export { isOutsideRepoSecretCredentialPath, isPersistentAgentPath } from './persistent-paths.js';
4
6
  function expandHome(token) {
5
7
  if (token === '~' || token.startsWith('~/')) {
6
8
  const home = process.env.HOME ?? process.env.USERPROFILE ?? '';
@@ -24,11 +26,11 @@ export function resolveTrustedPath(token, trustedCwd, trusted) {
24
26
  }
25
27
  return canonicalPath(path.resolve(trustedCwd, expanded));
26
28
  }
27
- export function locationForPath(resolvedPath, repoRoot) {
29
+ export function locationForPath(resolvedPath, repoRoot, trustedWorkspaceRoots = []) {
28
30
  if (!resolvedPath) {
29
31
  return 'unknown';
30
32
  }
31
- if (pathWithinRoot(repoRoot, resolvedPath)) {
33
+ if (resolveWorkspaceRootMatch(repoRoot, trustedWorkspaceRoots, resolvedPath)) {
32
34
  return 'repo_local';
33
35
  }
34
36
  return 'repo_outside';
@@ -39,19 +41,52 @@ export function isGitPath(resolvedPath, repoRoot) {
39
41
  return false;
40
42
  }
41
43
  const normalized = relative.replaceAll('\\', '/');
42
- return normalized === '.git' || normalized.startsWith('.git/');
44
+ return (normalized === '.git' ||
45
+ normalized.startsWith('.git/') ||
46
+ normalized.endsWith('/.git') ||
47
+ normalized.includes('/.git/'));
43
48
  }
44
- export function isHighStakesPath(resolvedPath, repoRoot, sensitivePaths, protectedRoots = []) {
49
+ export function isHighStakesPath(resolvedPath, repoRoot, sensitivePaths, protectedRoots = [], trustedWorkspaceRoots = []) {
45
50
  if (isGitPath(resolvedPath, repoRoot)) {
46
51
  return true;
47
52
  }
48
- const relative = relativeWithinRepo(repoRoot, resolvedPath);
49
- const checkPath = relative ?? resolvedPath;
50
- if (matchesSensitivePath(checkPath.replaceAll('\\', '/'), sensitivePaths)) {
53
+ const workspaceMatch = resolveWorkspaceRootMatch(repoRoot, trustedWorkspaceRoots, resolvedPath);
54
+ if (workspaceMatch !== null &&
55
+ matchesSensitivePath(workspaceMatch.relativePath.replaceAll('\\', '/'), sensitivePaths)) {
56
+ return true;
57
+ }
58
+ if ((workspaceMatch === null || workspaceMatch.kind === 'trusted') &&
59
+ isOutsideRepoSecretCredentialPath(resolvedPath)) {
51
60
  return true;
52
61
  }
53
62
  return protectedRoots.some((root) => pathWithinRoot(root, resolvedPath));
54
63
  }
64
+ const DESTRUCTIVE_MUTATION_HEADS = new Set([
65
+ 'rm',
66
+ 'shred',
67
+ 'truncate',
68
+ 'dd',
69
+ 'chmod',
70
+ 'chown',
71
+ 'mv',
72
+ 'cp',
73
+ ]);
74
+ /** ADR-002 M3: destructive shell head on git/sensitive path (not broad category ask). */
75
+ export function isDestructiveMutationHead(head) {
76
+ return DESTRUCTIVE_MUTATION_HEADS.has(head);
77
+ }
78
+ export function touchesProtectedRoot(resolvedPath, protectedRoots) {
79
+ return protectedRoots.some((root) => pathWithinRoot(root, resolvedPath) || root === resolvedPath);
80
+ }
81
+ export function isDestructiveHighStakesMutation(head, resolvedPath, repoRoot, sensitivePaths, protectedRoots = [], trustedWorkspaceRoots = []) {
82
+ if (touchesProtectedRoot(resolvedPath, protectedRoots)) {
83
+ return true;
84
+ }
85
+ if (!isDestructiveMutationHead(head)) {
86
+ return false;
87
+ }
88
+ return isHighStakesPath(resolvedPath, repoRoot, sensitivePaths, [], trustedWorkspaceRoots);
89
+ }
55
90
  export function analyzePathTargets(params) {
56
91
  const signals = [];
57
92
  if (!params.trustedCwd || !params.cwd) {
@@ -66,10 +101,10 @@ export function analyzePathTargets(params) {
66
101
  for (const target of params.targets) {
67
102
  const resolved = resolveTrustedPath(target, params.cwd, params.trustedCwd) ??
68
103
  resolveMutationTarget(target, params.cwd);
69
- const location = locationForPath(resolved, params.repoRoot);
104
+ const location = locationForPath(resolved, params.repoRoot, params.trustedWorkspaceRoots);
70
105
  locations.add(location);
71
106
  if (resolved &&
72
- isHighStakesPath(resolved, params.repoRoot, params.sensitivePaths, params.protectedArtifactRoots)) {
107
+ isHighStakesPath(resolved, params.repoRoot, params.sensitivePaths, params.protectedArtifactRoots, params.trustedWorkspaceRoots)) {
73
108
  isHighStakes = true;
74
109
  signals.push('high_stakes_path');
75
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
+ };
@@ -0,0 +1,48 @@
1
+ import { JUDGE_LATENCY_SLO } from './judge-runtime-config.js';
2
+ const samples = [];
3
+ const MAX_SAMPLES = 2_000;
4
+ export function resetJudgeLatencySamples() {
5
+ samples.length = 0;
6
+ }
7
+ export function recordJudgeLatency(phase, latencyMs, at = Date.now()) {
8
+ if (!Number.isFinite(latencyMs) || latencyMs < 0) {
9
+ return;
10
+ }
11
+ samples.push({ phase, latencyMs, at });
12
+ if (samples.length > MAX_SAMPLES) {
13
+ samples.splice(0, samples.length - MAX_SAMPLES);
14
+ }
15
+ }
16
+ function percentile(values, p) {
17
+ if (values.length === 0) {
18
+ return 0;
19
+ }
20
+ const sorted = [...values].sort((a, b) => a - b);
21
+ const index = Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1);
22
+ return sorted[Math.max(0, index)] ?? 0;
23
+ }
24
+ export function judgeLatencyPercentiles(phase) {
25
+ const values = samples
26
+ .filter((sample) => sample.phase === phase)
27
+ .map((sample) => sample.latencyMs);
28
+ return {
29
+ p50: percentile(values, 50),
30
+ p95: percentile(values, 95),
31
+ count: values.length,
32
+ };
33
+ }
34
+ export function judgeLatencySloReport() {
35
+ const measured = {
36
+ tier0: judgeLatencyPercentiles('tier0'),
37
+ tier1_spawn: judgeLatencyPercentiles('tier1_spawn'),
38
+ tier1_session: judgeLatencyPercentiles('tier1_session'),
39
+ tier1_connect: judgeLatencyPercentiles('tier1_connect'),
40
+ tier1_eval: judgeLatencyPercentiles('tier1_eval'),
41
+ tier1_parse: judgeLatencyPercentiles('tier1_parse'),
42
+ };
43
+ const spawnP95 = measured.tier1_spawn.count > 0 ? measured.tier1_spawn.p95 : JUDGE_LATENCY_SLO.spawnBaselineP95Ms;
44
+ const sessionP95 = measured.tier1_session.count > 0 ? measured.tier1_session.p95 : spawnP95;
45
+ const targetP95 = spawnP95 * (1 - JUDGE_LATENCY_SLO.tier1P95ReductionTarget);
46
+ const sessionMeetsTarget = sessionP95 <= targetP95 && sessionP95 <= JUDGE_LATENCY_SLO.sessionMaxP95Ms;
47
+ return { slo: JUDGE_LATENCY_SLO, measured, sessionMeetsTarget };
48
+ }
@@ -0,0 +1,42 @@
1
+ import type { CliJudgeRunCommand } from './judge-cli.js';
2
+ import type { BelayJudgeSessionConfig } from './judge-runtime-config.js';
3
+ import { type BrokerEvaluateRequest, type BrokerEvaluateResult, JudgeSessionBroker, stopRepoJudgeSessionBroker } from './judge-session-broker.js';
4
+ import type { JudgeSessionResetReason } from './judge-session-guard.js';
5
+ export declare const JUDGE_BROKER_SOCKET = "judge-broker.sock";
6
+ export declare const JUDGE_BROKER_STATUS = "judge-broker.json";
7
+ export declare const JUDGE_BROKER_PID = "judge-broker.pid";
8
+ export declare const JUDGE_BROKER_SESSION = "judge-broker-session.json";
9
+ export interface JudgeBrokerStatus {
10
+ pid: number;
11
+ repoRoot: string;
12
+ socketPath: string;
13
+ startedAt: string;
14
+ }
15
+ export interface JudgeBrokerPaths {
16
+ stateDir: string;
17
+ socketPath: string;
18
+ statusPath: string;
19
+ pidPath: string;
20
+ sessionConfigPath: string;
21
+ }
22
+ export declare function judgeBrokerPaths(stateDir: string): JudgeBrokerPaths;
23
+ export declare function ensureJudgeBrokerDaemon(stateDir: string, repoRoot: string, sessionConfig: BelayJudgeSessionConfig): Promise<JudgeBrokerPaths>;
24
+ export declare function cleanupJudgeBrokerArtifacts(paths: JudgeBrokerPaths): Promise<void>;
25
+ export declare function stopJudgeBrokerDaemon(stateDir: string): Promise<number>;
26
+ export declare function evaluateViaJudgeBroker(options: {
27
+ stateDir: string;
28
+ repoRoot: string;
29
+ sessionConfig: BelayJudgeSessionConfig;
30
+ request: BrokerEvaluateRequest;
31
+ timeoutMs: number;
32
+ runCommand?: CliJudgeRunCommand;
33
+ }): Promise<BrokerEvaluateResult>;
34
+ export declare function resetJudgeBrokerForTests(): void;
35
+ export declare function invalidateJudgeSession(options: {
36
+ stateDir: string;
37
+ repoRoot: string;
38
+ sessionConfig: BelayJudgeSessionConfig;
39
+ sessionKey: string;
40
+ reason: JudgeSessionResetReason;
41
+ }): Promise<void>;
42
+ export { JudgeSessionBroker, stopRepoJudgeSessionBroker };
@@ -0,0 +1,279 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { unlink } from 'node:fs/promises';
4
+ import net from 'node:net';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { runCliJsonWithTimeouts } from './judge-cli.js';
8
+ import { normalizeJudgeSessionConfig } from './judge-runtime-config.js';
9
+ import { getRepoJudgeSessionBroker, JudgeSessionBroker, resetJudgeSessionBrokersForTests, stopRepoJudgeSessionBroker, } from './judge-session-broker.js';
10
+ export const JUDGE_BROKER_SOCKET = 'judge-broker.sock';
11
+ export const JUDGE_BROKER_STATUS = 'judge-broker.json';
12
+ export const JUDGE_BROKER_PID = 'judge-broker.pid';
13
+ export const JUDGE_BROKER_SESSION = 'judge-broker-session.json';
14
+ export function judgeBrokerPaths(stateDir) {
15
+ return {
16
+ stateDir,
17
+ socketPath: path.join(stateDir, JUDGE_BROKER_SOCKET),
18
+ statusPath: path.join(stateDir, JUDGE_BROKER_STATUS),
19
+ pidPath: path.join(stateDir, JUDGE_BROKER_PID),
20
+ sessionConfigPath: path.join(stateDir, JUDGE_BROKER_SESSION),
21
+ };
22
+ }
23
+ function daemonScriptPath() {
24
+ return fileURLToPath(new URL('../../judge-broker-daemon.js', import.meta.url));
25
+ }
26
+ function isProcessAlive(pid) {
27
+ try {
28
+ process.kill(pid, 0);
29
+ return true;
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ }
35
+ function useInProcessBroker(env = process.env) {
36
+ return Boolean(env.VITEST || env.VITEST_WORKER_ID || env.BELAY_JUDGE_BROKER_IN_PROCESS === '1');
37
+ }
38
+ async function readBrokerStatus(statusPath) {
39
+ if (!existsSync(statusPath)) {
40
+ return null;
41
+ }
42
+ try {
43
+ const { readFile } = await import('node:fs/promises');
44
+ const raw = JSON.parse(await readFile(statusPath, 'utf8'));
45
+ if (typeof raw.pid !== 'number' || typeof raw.socketPath !== 'string') {
46
+ return null;
47
+ }
48
+ return raw;
49
+ }
50
+ catch {
51
+ return null;
52
+ }
53
+ }
54
+ async function readBrokerSessionConfig(sessionConfigPath) {
55
+ if (!existsSync(sessionConfigPath)) {
56
+ return null;
57
+ }
58
+ try {
59
+ const { readFile } = await import('node:fs/promises');
60
+ const raw = JSON.parse(await readFile(sessionConfigPath, 'utf8'));
61
+ return normalizeJudgeSessionConfig({ ...raw, enabled: true });
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ }
67
+ function brokerSessionConfigPayload(sessionConfig) {
68
+ return JSON.stringify(normalizeJudgeSessionConfig({ ...sessionConfig, enabled: true }));
69
+ }
70
+ async function writeBrokerSessionConfig(paths, sessionConfig) {
71
+ const { writeFile } = await import('node:fs/promises');
72
+ await writeFile(paths.sessionConfigPath, brokerSessionConfigPayload(sessionConfig), {
73
+ encoding: 'utf8',
74
+ mode: 0o600,
75
+ });
76
+ }
77
+ async function connectBrokerSocket(socketPath, timeoutMs) {
78
+ return new Promise((resolve, reject) => {
79
+ const socket = net.createConnection(socketPath);
80
+ const timer = setTimeout(() => {
81
+ socket.destroy();
82
+ reject(new Error('judge broker connect timed out'));
83
+ }, timeoutMs);
84
+ socket.once('connect', () => {
85
+ clearTimeout(timer);
86
+ resolve(socket);
87
+ });
88
+ socket.once('error', (error) => {
89
+ clearTimeout(timer);
90
+ reject(error);
91
+ });
92
+ });
93
+ }
94
+ async function rpcEvaluate(socketPath, request, timeoutMs, connectTimeoutMs) {
95
+ const socket = await connectBrokerSocket(socketPath, connectTimeoutMs);
96
+ const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
97
+ const payload = JSON.stringify({
98
+ type: 'evaluate',
99
+ id,
100
+ request,
101
+ timeoutMs,
102
+ });
103
+ return new Promise((resolve, reject) => {
104
+ let buffer = '';
105
+ const timer = setTimeout(() => {
106
+ socket.destroy();
107
+ reject(new Error('judge broker evaluate timed out'));
108
+ }, timeoutMs + connectTimeoutMs);
109
+ const onData = (chunk) => {
110
+ buffer += String(chunk);
111
+ const lines = buffer.split('\n');
112
+ buffer = lines.pop() ?? '';
113
+ for (const line of lines) {
114
+ if (!line.trim()) {
115
+ continue;
116
+ }
117
+ try {
118
+ const message = JSON.parse(line);
119
+ if (message.id !== id) {
120
+ continue;
121
+ }
122
+ clearTimeout(timer);
123
+ socket.destroy();
124
+ if (message.type === 'error') {
125
+ reject(new Error(message.error ?? 'judge broker error'));
126
+ return;
127
+ }
128
+ if (message.result) {
129
+ resolve(message.result);
130
+ }
131
+ }
132
+ catch {
133
+ // wait for more data
134
+ }
135
+ }
136
+ };
137
+ socket.on('data', onData);
138
+ socket.on('error', (error) => {
139
+ clearTimeout(timer);
140
+ reject(error);
141
+ });
142
+ socket.write(`${payload}\n`);
143
+ });
144
+ }
145
+ export async function ensureJudgeBrokerDaemon(stateDir, repoRoot, sessionConfig) {
146
+ const paths = judgeBrokerPaths(stateDir);
147
+ const brokerSessionConfig = normalizeJudgeSessionConfig({ ...sessionConfig, enabled: true });
148
+ const nextConfigPayload = brokerSessionConfigPayload(brokerSessionConfig);
149
+ const existingConfig = await readBrokerSessionConfig(paths.sessionConfigPath);
150
+ const configChanged = existingConfig !== null && brokerSessionConfigPayload(existingConfig) !== nextConfigPayload;
151
+ const status = await readBrokerStatus(paths.statusPath);
152
+ if (status && isProcessAlive(status.pid) && existsSync(paths.socketPath) && !configChanged) {
153
+ await writeBrokerSessionConfig(paths, brokerSessionConfig);
154
+ return paths;
155
+ }
156
+ if (status?.pid && isProcessAlive(status.pid)) {
157
+ await stopJudgeBrokerDaemon(stateDir);
158
+ }
159
+ else {
160
+ await cleanupJudgeBrokerArtifacts(paths);
161
+ }
162
+ await writeBrokerSessionConfig(paths, brokerSessionConfig);
163
+ const child = spawn(process.execPath, [daemonScriptPath()], {
164
+ detached: true,
165
+ stdio: 'ignore',
166
+ env: {
167
+ ...process.env,
168
+ BELAY_JUDGE_BROKER_REPO_ROOT: repoRoot,
169
+ BELAY_JUDGE_BROKER_STATE_DIR: stateDir,
170
+ },
171
+ });
172
+ child.unref();
173
+ const deadline = Date.now() + sessionConfig.connectTimeoutMs;
174
+ while (Date.now() < deadline) {
175
+ if (existsSync(paths.socketPath)) {
176
+ const live = await readBrokerStatus(paths.statusPath);
177
+ if (live && isProcessAlive(live.pid)) {
178
+ return paths;
179
+ }
180
+ }
181
+ await new Promise((resolve) => setTimeout(resolve, 25));
182
+ }
183
+ throw new Error('judge broker daemon failed to start');
184
+ }
185
+ export async function cleanupJudgeBrokerArtifacts(paths) {
186
+ for (const artifact of [
187
+ paths.socketPath,
188
+ paths.statusPath,
189
+ paths.pidPath,
190
+ paths.sessionConfigPath,
191
+ ]) {
192
+ if (existsSync(artifact)) {
193
+ await unlink(artifact).catch(() => undefined);
194
+ }
195
+ }
196
+ }
197
+ export async function stopJudgeBrokerDaemon(stateDir) {
198
+ const paths = judgeBrokerPaths(stateDir);
199
+ const status = await readBrokerStatus(paths.statusPath);
200
+ if (status?.pid && isProcessAlive(status.pid)) {
201
+ try {
202
+ process.kill(status.pid, 'SIGTERM');
203
+ }
204
+ catch {
205
+ // already stopped
206
+ }
207
+ }
208
+ await cleanupJudgeBrokerArtifacts(paths);
209
+ return status?.pid ? 1 : 0;
210
+ }
211
+ export async function evaluateViaJudgeBroker(options) {
212
+ const runCommand = options.runCommand ??
213
+ ((invocation, timeout) => runCliJsonWithTimeouts(invocation, { evalTimeoutMs: timeout }));
214
+ if (useInProcessBroker()) {
215
+ const broker = getRepoJudgeSessionBroker(options.repoRoot, {
216
+ config: options.sessionConfig,
217
+ runCommand,
218
+ });
219
+ return broker.evaluate(options.request, options.timeoutMs);
220
+ }
221
+ const paths = await ensureJudgeBrokerDaemon(options.stateDir, options.repoRoot, options.sessionConfig);
222
+ return rpcEvaluate(paths.socketPath, options.request, options.timeoutMs, options.sessionConfig.connectTimeoutMs);
223
+ }
224
+ export function resetJudgeBrokerForTests() {
225
+ resetJudgeSessionBrokersForTests();
226
+ }
227
+ async function rpcInvalidate(socketPath, sessionKey, reason, connectTimeoutMs) {
228
+ const socket = await connectBrokerSocket(socketPath, connectTimeoutMs);
229
+ const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
230
+ const payload = JSON.stringify({ type: 'invalidate', id, sessionKey, reason });
231
+ await new Promise((resolve, reject) => {
232
+ let buffer = '';
233
+ const timer = setTimeout(() => {
234
+ socket.destroy();
235
+ reject(new Error('judge broker invalidate timed out'));
236
+ }, connectTimeoutMs);
237
+ const onData = (chunk) => {
238
+ buffer += String(chunk);
239
+ const lines = buffer.split('\n');
240
+ buffer = lines.pop() ?? '';
241
+ for (const line of lines) {
242
+ if (!line.trim()) {
243
+ continue;
244
+ }
245
+ try {
246
+ const message = JSON.parse(line);
247
+ if (message.id !== id) {
248
+ continue;
249
+ }
250
+ clearTimeout(timer);
251
+ socket.destroy();
252
+ resolve();
253
+ }
254
+ catch {
255
+ // wait for more data
256
+ }
257
+ }
258
+ };
259
+ socket.on('data', onData);
260
+ socket.on('error', (error) => {
261
+ clearTimeout(timer);
262
+ reject(error);
263
+ });
264
+ socket.write(`${payload}\n`);
265
+ });
266
+ }
267
+ export async function invalidateJudgeSession(options) {
268
+ if (useInProcessBroker()) {
269
+ const broker = getRepoJudgeSessionBroker(options.repoRoot, {
270
+ config: { ...options.sessionConfig, enabled: true },
271
+ runCommand: (invocation, timeout) => runCliJsonWithTimeouts(invocation, { evalTimeoutMs: timeout }),
272
+ });
273
+ broker.invalidateSession(options.sessionKey, options.reason);
274
+ return;
275
+ }
276
+ const paths = await ensureJudgeBrokerDaemon(options.stateDir, options.repoRoot, options.sessionConfig);
277
+ await rpcInvalidate(paths.socketPath, options.sessionKey, options.reason, options.sessionConfig.connectTimeoutMs);
278
+ }
279
+ export { JudgeSessionBroker, stopRepoJudgeSessionBroker };