@guilz-dev/belay 0.1.1 → 0.3.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 (106) hide show
  1. package/README.md +55 -15
  2. package/dist/adapters/shared/gate-runtime.js +25 -9
  3. package/dist/bundle/claude-runtime.mjs +1786 -379
  4. package/dist/bundle/codex-runtime.mjs +1785 -379
  5. package/dist/bundle/cursor-runtime.mjs +1786 -379
  6. package/dist/cli.js +201 -11
  7. package/dist/commands/approve.js +11 -0
  8. package/dist/commands/classify-for-report.js +3 -3
  9. package/dist/commands/config.d.ts +70 -0
  10. package/dist/commands/config.js +494 -0
  11. package/dist/commands/doctor.js +3 -2
  12. package/dist/commands/explain.js +14 -14
  13. package/dist/commands/health-snapshot.d.ts +6 -0
  14. package/dist/commands/health-snapshot.js +34 -0
  15. package/dist/commands/judge.d.ts +90 -0
  16. package/dist/commands/judge.js +285 -0
  17. package/dist/commands/recover.js +2 -2
  18. package/dist/commands/status.js +2 -2
  19. package/dist/commands/stdin-key.d.ts +1 -0
  20. package/dist/commands/stdin-key.js +8 -0
  21. package/dist/conformance/guarantee-table.js +12 -0
  22. package/dist/conformance/types.d.ts +2 -0
  23. package/dist/core/approval.d.ts +3 -0
  24. package/dist/core/approval.js +18 -3
  25. package/dist/core/audit-io.d.ts +2 -0
  26. package/dist/core/audit-io.js +14 -0
  27. package/dist/core/audit-query.js +5 -1
  28. package/dist/core/capability/index.d.ts +2 -2
  29. package/dist/core/capability/index.js +2 -2
  30. package/dist/core/capability/paths.d.ts +1 -0
  31. package/dist/core/capability/paths.js +74 -6
  32. package/dist/core/capability/reasons.d.ts +3 -0
  33. package/dist/core/capability/reasons.js +8 -0
  34. package/dist/core/classify-tool.js +97 -27
  35. package/dist/core/config-layers.js +2 -1
  36. package/dist/core/config.d.ts +23 -3
  37. package/dist/core/config.js +112 -13
  38. package/dist/core/credential-store.d.ts +11 -0
  39. package/dist/core/credential-store.js +60 -0
  40. package/dist/core/gate-contract.d.ts +1 -1
  41. package/dist/core/gate-contract.js +1 -1
  42. package/dist/core/gate-engine.js +105 -14
  43. package/dist/core/index.d.ts +2 -2
  44. package/dist/core/index.js +2 -2
  45. package/dist/core/integrity.d.ts +2 -0
  46. package/dist/core/integrity.js +13 -0
  47. package/dist/core/judge-api-key.d.ts +19 -0
  48. package/dist/core/judge-api-key.js +74 -0
  49. package/dist/core/judge-cloud-consent.d.ts +13 -0
  50. package/dist/core/judge-cloud-consent.js +38 -0
  51. package/dist/core/judge-config.d.ts +45 -4
  52. package/dist/core/judge-config.js +266 -44
  53. package/dist/core/judge-doctor.d.ts +6 -1
  54. package/dist/core/judge-doctor.js +148 -97
  55. package/dist/core/judge-model-discovery.d.ts +24 -0
  56. package/dist/core/judge-model-discovery.js +168 -0
  57. package/dist/core/judge-model-policy.d.ts +5 -0
  58. package/dist/core/judge-model-policy.js +21 -0
  59. package/dist/core/judge-runtime-detection.d.ts +9 -0
  60. package/dist/core/judge-runtime-detection.js +68 -0
  61. package/dist/core/transactional/diff-evaluator.js +1 -19
  62. package/dist/core/types.d.ts +7 -3
  63. package/dist/core/{v2 → verdict}/adapter.d.ts +1 -0
  64. package/dist/core/{v2 → verdict}/adapter.js +16 -4
  65. package/dist/core/{v2 → verdict}/containment.d.ts +5 -0
  66. package/dist/core/{v2 → verdict}/containment.js +32 -2
  67. package/dist/core/{v2 → verdict}/egress-classify.js +3 -0
  68. package/dist/core/verdict/judge-catalog.d.ts +40 -0
  69. package/dist/core/verdict/judge-catalog.js +148 -0
  70. package/dist/core/verdict/judge-cli.d.ts +23 -0
  71. package/dist/core/verdict/judge-cli.js +280 -0
  72. package/dist/core/{v2 → verdict}/judge-factory.d.ts +15 -4
  73. package/dist/core/verdict/judge-factory.js +178 -0
  74. package/dist/core/{v2 → verdict}/judge.d.ts +20 -1
  75. package/dist/core/{v2 → verdict}/judge.js +85 -18
  76. package/dist/core/{v2 → verdict}/launcher-resolve.js +72 -1
  77. package/dist/core/verdict/persistent-paths.d.ts +8 -0
  78. package/dist/core/verdict/persistent-paths.js +52 -0
  79. package/dist/core/{v2 → verdict}/types.d.ts +6 -2
  80. package/dist/core/{v2 → verdict}/verdict.js +176 -46
  81. package/dist/corpus/evaluate.js +2 -2
  82. package/dist/installer.js +66 -14
  83. package/dist/types.d.ts +8 -1
  84. package/dist/version.d.ts +1 -1
  85. package/dist/version.js +1 -1
  86. package/package.json +2 -1
  87. package/skills/belay/SKILL.md +8 -7
  88. package/dist/commands/init-wizard.d.ts +0 -16
  89. package/dist/commands/init-wizard.js +0 -50
  90. package/dist/core/v2/judge-factory.js +0 -75
  91. /package/dist/core/{v2 → verdict}/egress-classify.d.ts +0 -0
  92. /package/dist/core/{v2 → verdict}/fingerprint.d.ts +0 -0
  93. /package/dist/core/{v2 → verdict}/fingerprint.js +0 -0
  94. /package/dist/core/{v2 → verdict}/index.d.ts +0 -0
  95. /package/dist/core/{v2 → verdict}/index.js +0 -0
  96. /package/dist/core/{v2 → verdict}/judge-audit.d.ts +0 -0
  97. /package/dist/core/{v2 → verdict}/judge-audit.js +0 -0
  98. /package/dist/core/{v2 → verdict}/judge-outbound.d.ts +0 -0
  99. /package/dist/core/{v2 → verdict}/judge-outbound.js +0 -0
  100. /package/dist/core/{v2 → verdict}/launcher-resolve.d.ts +0 -0
  101. /package/dist/core/{v2 → verdict}/overrides.d.ts +0 -0
  102. /package/dist/core/{v2 → verdict}/overrides.js +0 -0
  103. /package/dist/core/{v2 → verdict}/parser.d.ts +0 -0
  104. /package/dist/core/{v2 → verdict}/parser.js +0 -0
  105. /package/dist/core/{v2 → verdict}/types.js +0 -0
  106. /package/dist/core/{v2 → verdict}/verdict.d.ts +0 -0
@@ -3,7 +3,9 @@ import { canonicalStringify, toolFingerprint } from './fingerprint.js';
3
3
  import { matchesSensitivePath } from './glob.js';
4
4
  import { pathWithinRoot, relativeWithinRepo } from './path-utils.js';
5
5
  import { scrubValue } from './scrub.js';
6
- import { classifyShell } from './v2/adapter.js';
6
+ import { classifyShell, resolveClassifierTrustedCwd } from './verdict/adapter.js';
7
+ import { mutationPrescanRequiresAsk, tier1RequiresAsk } from './verdict/judge.js';
8
+ import { createJudgeFromConfig } from './verdict/judge-factory.js';
7
9
  const DEFAULT_SENSITIVE_PATHS = ['.env', '.env.*', '**/credentials/**'];
8
10
  const FILE_WRITE_TOOL_NAMES = new Set(['write']);
9
11
  const FILE_EDIT_TOOL_NAMES = new Set([
@@ -74,6 +76,74 @@ function applyPatchTargets(patch) {
74
76
  function normalizedToolName(toolName) {
75
77
  return toolName.trim().toLowerCase();
76
78
  }
79
+ function resolveFileTier1Judge(config, options, repoRoot) {
80
+ return options.tier1Judge ?? createJudgeFromConfig(config, { repoRoot });
81
+ }
82
+ async function classifyFileMutationWithTier1(params) {
83
+ const judge = resolveFileTier1Judge(params.config, params.options, params.repoRoot);
84
+ const trustedCwd = resolveClassifierTrustedCwd(params.cwd, params.options);
85
+ const prescan = mutationPrescanRequiresAsk({
86
+ targets: [params.filePath],
87
+ cwd: params.cwd,
88
+ repoRoot: params.repoRoot,
89
+ trustedCwd,
90
+ sensitivePaths: params.options.sensitivePaths ?? params.config.classifier.sensitivePaths,
91
+ });
92
+ if (prescan) {
93
+ return {
94
+ verdict: 'deny_pending_approval',
95
+ reason: 'tier1_catastrophic',
96
+ summary: params.filePath,
97
+ fingerprint: toolFingerprint(params.toolName, { path: params.filePath }, params.repoRoot),
98
+ assessment: {
99
+ reversibility: 'irreversible',
100
+ external: params.locationLabel === 'outside_repo',
101
+ blastRadius: params.locationLabel === 'outside_repo'
102
+ ? 'outside the repository'
103
+ : 'sensitive repository file',
104
+ confidence: 0.95,
105
+ signals: [...params.signals, 'tier1_catastrophic', prescan.reason],
106
+ },
107
+ };
108
+ }
109
+ const tier1Text = `file_mutation: ${params.toolKind} path=${params.resolvedPath} location=${params.locationLabel}`;
110
+ const tier1 = await judge.evaluate({
111
+ text: tier1Text,
112
+ context: { cwd: params.cwd, repoRoot: params.repoRoot },
113
+ });
114
+ const fingerprint = toolFingerprint(params.toolName, { path: params.filePath }, params.repoRoot);
115
+ if (tier1RequiresAsk(tier1)) {
116
+ return {
117
+ verdict: 'deny_pending_approval',
118
+ reason: 'tier1_catastrophic',
119
+ summary: params.filePath,
120
+ fingerprint,
121
+ assessment: {
122
+ reversibility: 'irreversible',
123
+ external: params.locationLabel === 'outside_repo',
124
+ blastRadius: params.locationLabel === 'outside_repo'
125
+ ? 'outside the repository'
126
+ : 'sensitive repository file',
127
+ confidence: 0.82,
128
+ signals: [...params.signals, 'tier1_catastrophic', tier1.reason],
129
+ },
130
+ };
131
+ }
132
+ const reason = params.isDelete ? 'file_delete' : 'file_mutation';
133
+ return {
134
+ verdict: 'allow_flagged',
135
+ reason,
136
+ summary: params.filePath,
137
+ fingerprint,
138
+ assessment: {
139
+ reversibility: 'recoverable_with_cost',
140
+ external: params.locationLabel === 'outside_repo',
141
+ blastRadius: params.locationLabel === 'outside_repo' ? 'outside the repository' : 'this repository',
142
+ confidence: 0.72,
143
+ signals: [...params.signals, 'tier1_restorable', tier1.reason],
144
+ },
145
+ };
146
+ }
77
147
  export async function classifyToolUse(payload, repoRoot, cwd, config, options = {}) {
78
148
  const toolName = String(payload.tool_name ?? '');
79
149
  const toolKind = normalizedToolName(toolName);
@@ -176,35 +246,35 @@ export async function classifyToolUse(payload, repoRoot, cwd, config, options =
176
246
  const relativePath = relativeWithinRepo(repoRoot, resolvedPath);
177
247
  if (relativePath === null) {
178
248
  signals.push('outside_repo_path');
179
- return {
180
- verdict: 'deny_pending_approval',
181
- reason: 'outside_repo_file_mutation',
182
- summary: filePath,
183
- fingerprint: toolFingerprint(toolName, { path: filePath }, repoRoot),
184
- assessment: {
185
- reversibility: 'irreversible',
186
- external: true,
187
- blastRadius: 'outside the repository',
188
- confidence: 0.9,
189
- signals,
190
- },
191
- };
249
+ return classifyFileMutationWithTier1({
250
+ toolName,
251
+ toolKind,
252
+ filePath,
253
+ resolvedPath,
254
+ repoRoot,
255
+ cwd,
256
+ config,
257
+ options,
258
+ signals,
259
+ isDelete: FILE_DELETE_TOOL_NAMES.has(toolKind),
260
+ locationLabel: 'outside_repo',
261
+ });
192
262
  }
193
263
  if (matchesSensitivePath(relativePath, sensitivePaths)) {
194
264
  signals.push('sensitive_path');
195
- return {
196
- verdict: 'deny_pending_approval',
197
- reason: 'sensitive_file_mutation',
198
- summary: filePath,
199
- fingerprint: toolFingerprint(toolName, { path: filePath }, repoRoot),
200
- assessment: {
201
- reversibility: 'irreversible',
202
- external: false,
203
- blastRadius: 'sensitive repository file',
204
- confidence: 0.88,
205
- signals,
206
- },
207
- };
265
+ return classifyFileMutationWithTier1({
266
+ toolName,
267
+ toolKind,
268
+ filePath,
269
+ resolvedPath,
270
+ repoRoot,
271
+ cwd,
272
+ config,
273
+ options,
274
+ signals,
275
+ isDelete: FILE_DELETE_TOOL_NAMES.has(toolKind),
276
+ locationLabel: 'sensitive_path',
277
+ });
208
278
  }
209
279
  if (FILE_DELETE_TOOL_NAMES.has(toolKind)) {
210
280
  signals.push('file_delete');
@@ -1,6 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import { applyConfigPreset } from '../presets.js';
3
- import { DEFAULT_CONFIG_V3, mergeConfig } from './config.js';
3
+ import { DEFAULT_CONFIG_V3, mergeConfig, rejectTeamLayerJudgeSecrets } from './config.js';
4
4
  export function teamConfigPath(homedir = () => process.env.HOME ?? process.env.USERPROFILE ?? '') {
5
5
  const xdg = process.env.XDG_CONFIG_HOME?.trim();
6
6
  const base = xdg || path.join(homedir(), '.config');
@@ -40,6 +40,7 @@ export function resolveLayeredConfig(params) {
40
40
  const teamRaw = teamFile.preset
41
41
  ? applyConfigPreset(teamFile.preset, teamFile.config ?? {})
42
42
  : (teamFile.config ?? params.teamConfig);
43
+ rejectTeamLayerJudgeSecrets(teamRaw.judge, 'team');
43
44
  config = mergeConfigLayer(config, asV3Layer(teamRaw));
44
45
  provenance.push({
45
46
  path: params.teamConfigPath ?? teamConfigPath(),
@@ -76,13 +76,32 @@ export interface BelayRedactionConfig {
76
76
  maskKeyValueSecrets: boolean;
77
77
  maskHighEntropyStrings: boolean;
78
78
  }
79
- export type JudgeProvider = 'ollama' | 'openai-compatible';
79
+ export type JudgeProvider = 'ollama' | 'openai-compatible' | 'anthropic';
80
+ export type JudgeProviderId = 'ollama' | 'codex' | 'claude' | 'cursor';
81
+ /** Read-only legacy ids; preserved on load with warning until `belay config` migrates. */
82
+ export type DeprecatedJudgeProviderId = 'openrouter' | 'custom';
83
+ export type JudgeCredentialMode = 'project' | 'apiKey';
84
+ export type JudgeCredentialRef = `store:judge` | `env:${string}`;
85
+ export interface JudgeCredentialConfig {
86
+ mode: JudgeCredentialMode;
87
+ ref?: JudgeCredentialRef;
88
+ }
89
+ export interface JudgeCloudConsent {
90
+ accepted: boolean;
91
+ at: string;
92
+ providerId: JudgeProviderId;
93
+ endpoint: string;
94
+ by: string;
95
+ }
80
96
  export interface BelayJudgeConfig {
81
97
  provider: JudgeProvider;
98
+ providerId?: JudgeProviderId | DeprecatedJudgeProviderId;
82
99
  model: string;
83
100
  timeoutMs: number;
84
101
  endpoint: string | null;
85
102
  keepAlive: string | null;
103
+ cloudConsent?: JudgeCloudConsent;
104
+ credential?: JudgeCredentialConfig;
86
105
  }
87
106
  export declare const DEFAULT_JUDGE_LOCAL_OLLAMA: BelayJudgeConfig;
88
107
  export declare const DEFAULT_JUDGE_OPENAI_COMPATIBLE_TEMPLATE: BelayJudgeConfig;
@@ -155,7 +174,7 @@ export declare const DEFAULT_CONFIDENCE_THRESHOLDS: BelayConfidenceThresholds;
155
174
  export declare const DEFAULT_MODEL_ASSIST: BelayModelAssistConfig;
156
175
  export declare const DEFAULT_TRANSACTIONAL_V3: BelayTransactionalConfig;
157
176
  export declare const LEGACY_POLICY_V3: BelayPolicyConfig;
158
- /** Fresh v0.4+ install defaults (fail-closed). */
177
+ /** Fresh install defaults: recoverable-first with opaque/unparseable fail-closed. */
159
178
  export declare const DEFAULT_POLICY_V3: BelayPolicyConfig;
160
179
  export declare const DEFAULT_OVERRIDES_V3: BelayOverridesConfig;
161
180
  export declare const DEFAULT_REDACTION_V3: BelayRedactionConfig;
@@ -180,8 +199,9 @@ export declare function isConfigV1(value: unknown): value is BelayConfigV1;
180
199
  export declare function isConfigV2(value: unknown): value is BelayConfigV2;
181
200
  export declare function isConfigV3(value: unknown): value is BelayConfigV4;
182
201
  export declare function isConfigV4(value: unknown): value is BelayConfigV4;
183
- export declare function normalizeJudgeProvider(provider: string | undefined): 'ollama' | 'openai-compatible';
202
+ export declare function normalizeJudgeProvider(provider: string | undefined): 'ollama' | 'openai-compatible' | 'anthropic';
184
203
  export declare function normalizeJudgeConfig(judge: BelayJudgeConfig): BelayJudgeConfig;
204
+ export declare function rejectTeamLayerJudgeSecrets(judge: Partial<BelayJudgeConfig> | undefined, source: 'team' | 'repo'): void;
185
205
  export declare function migrateV3ToV4(v3: BelayConfigV4, raw?: RawConfigInput): BelayConfigV4;
186
206
  type RawConfigInput = Partial<{
187
207
  version: number;
@@ -1,6 +1,9 @@
1
1
  import path from 'node:path';
2
+ import { warnDeprecatedJudgeModelAuto } from './judge-model-policy.js';
3
+ import { getJudgeProviderSpec, inferProviderIdFromConfig, isRemovedProviderId, normalizeLegacyProviderId, warnRemovedProviderId, } from './verdict/judge-catalog.js';
2
4
  export const DEFAULT_JUDGE_LOCAL_OLLAMA = {
3
5
  provider: 'ollama',
6
+ providerId: 'ollama',
4
7
  model: 'gemma4:e2b',
5
8
  endpoint: 'http://localhost:11434',
6
9
  timeoutMs: 25000,
@@ -8,7 +11,8 @@ export const DEFAULT_JUDGE_LOCAL_OLLAMA = {
8
11
  };
9
12
  export const DEFAULT_JUDGE_OPENAI_COMPATIBLE_TEMPLATE = {
10
13
  provider: 'openai-compatible',
11
- model: 'auto',
14
+ providerId: 'codex',
15
+ model: 'gpt-5.3-codex-high',
12
16
  timeoutMs: 8000,
13
17
  endpoint: null,
14
18
  keepAlive: null,
@@ -45,9 +49,9 @@ export const LEGACY_POLICY_V3 = {
45
49
  transactional: { ...DEFAULT_TRANSACTIONAL_V3 },
46
50
  fenceWarnThreshold: DEFAULT_FENCE_WARN_THRESHOLD,
47
51
  };
48
- /** Fresh v0.4+ install defaults (fail-closed). */
52
+ /** Fresh install defaults: recoverable-first with opaque/unparseable fail-closed. */
49
53
  export const DEFAULT_POLICY_V3 = {
50
- unknownLocalEffect: 'deny',
54
+ unknownLocalEffect: 'allow_flagged',
51
55
  unparseableShell: 'deny',
52
56
  codexUnmappedTool: 'deny',
53
57
  confidenceThresholds: { ...DEFAULT_CONFIDENCE_THRESHOLDS },
@@ -209,44 +213,139 @@ export function isConfigV4(value) {
209
213
  return typeof value === 'object' && value !== null && value.version === 4;
210
214
  }
211
215
  export function normalizeJudgeProvider(provider) {
216
+ if (provider === 'anthropic') {
217
+ return 'anthropic';
218
+ }
212
219
  if (provider === 'openai-compatible' || provider === 'cursor') {
213
220
  return 'openai-compatible';
214
221
  }
215
222
  return 'ollama';
216
223
  }
224
+ function defaultJudgeTemplateForProvider(provider) {
225
+ if (provider === 'ollama') {
226
+ return DEFAULT_JUDGE_LOCAL_OLLAMA;
227
+ }
228
+ if (provider === 'anthropic') {
229
+ return {
230
+ provider: 'anthropic',
231
+ providerId: 'claude',
232
+ model: 'claude-sonnet-4-6',
233
+ timeoutMs: 8000,
234
+ endpoint: null,
235
+ keepAlive: null,
236
+ };
237
+ }
238
+ return DEFAULT_JUDGE_OPENAI_COMPATIBLE_TEMPLATE;
239
+ }
217
240
  function synthesizeJudgeFromRaw(raw) {
218
241
  const judge = raw.judge;
219
242
  if (judge?.provider) {
220
- const provider = normalizeJudgeProvider(judge.provider);
221
- const base = provider === 'openai-compatible'
222
- ? DEFAULT_JUDGE_OPENAI_COMPATIBLE_TEMPLATE
223
- : DEFAULT_JUDGE_LOCAL_OLLAMA;
243
+ const rawProvider = String(judge.provider);
244
+ const provider = normalizeJudgeProvider(rawProvider);
245
+ const base = defaultJudgeTemplateForProvider(provider);
246
+ const providerId = judge.providerId && normalizeLegacyProviderId(judge.providerId)
247
+ ? normalizeLegacyProviderId(judge.providerId)
248
+ : rawProvider === 'cursor'
249
+ ? 'cursor'
250
+ : undefined;
224
251
  return normalizeJudgeConfig({
225
252
  ...base,
226
253
  ...judge,
227
254
  provider,
255
+ ...(providerId ? { providerId } : {}),
228
256
  });
229
257
  }
230
258
  return { ...DEFAULT_JUDGE_LOCAL_OLLAMA };
231
259
  }
232
260
  export function normalizeJudgeConfig(judge) {
233
261
  const provider = normalizeJudgeProvider(judge.provider);
234
- const base = provider === 'openai-compatible'
235
- ? DEFAULT_JUDGE_OPENAI_COMPATIBLE_TEMPLATE
236
- : DEFAULT_JUDGE_LOCAL_OLLAMA;
237
- const model = typeof judge.model === 'string' && judge.model.trim() ? judge.model.trim() : base.model;
262
+ const base = defaultJudgeTemplateForProvider(provider);
263
+ const rawProviderId = judge.providerId ? String(judge.providerId) : undefined;
264
+ if (rawProviderId && isRemovedProviderId(rawProviderId)) {
265
+ warnRemovedProviderId(rawProviderId);
266
+ let model = typeof judge.model === 'string' && judge.model.trim() ? judge.model.trim() : base.model;
267
+ if (model === 'auto') {
268
+ warnDeprecatedJudgeModelAuto();
269
+ model = base.model;
270
+ }
271
+ const timeoutMs = typeof judge.timeoutMs === 'number' && judge.timeoutMs > 0 ? judge.timeoutMs : base.timeoutMs;
272
+ const endpoint = typeof judge.endpoint === 'string' && judge.endpoint.trim() ? judge.endpoint.trim() : null;
273
+ return {
274
+ provider,
275
+ providerId: rawProviderId,
276
+ model,
277
+ timeoutMs,
278
+ endpoint,
279
+ keepAlive: provider === 'ollama' && typeof judge.keepAlive === 'string' && judge.keepAlive.trim()
280
+ ? judge.keepAlive.trim()
281
+ : provider === 'ollama'
282
+ ? DEFAULT_JUDGE_LOCAL_OLLAMA.keepAlive
283
+ : null,
284
+ ...(judge.cloudConsent ? { cloudConsent: judge.cloudConsent } : {}),
285
+ ...(judge.credential ? { credential: judge.credential } : {}),
286
+ };
287
+ }
288
+ let providerId = rawProviderId && normalizeLegacyProviderId(rawProviderId)
289
+ ? normalizeLegacyProviderId(rawProviderId)
290
+ : inferProviderIdFromConfig({ ...judge, provider });
291
+ const spec = getJudgeProviderSpec(providerId);
292
+ if (spec && spec.driver !== provider) {
293
+ providerId = inferProviderIdFromConfig({ ...judge, provider });
294
+ }
295
+ const catalogSpec = getJudgeProviderSpec(providerId);
296
+ let model = typeof judge.model === 'string' && judge.model.trim() ? judge.model.trim() : base.model;
297
+ if (model === 'auto' && catalogSpec?.defaultModel) {
298
+ warnDeprecatedJudgeModelAuto();
299
+ model = catalogSpec.defaultModel;
300
+ }
301
+ if (!model && catalogSpec?.defaultModel) {
302
+ model = catalogSpec.defaultModel;
303
+ }
238
304
  const timeoutMs = typeof judge.timeoutMs === 'number' && judge.timeoutMs > 0 ? judge.timeoutMs : base.timeoutMs;
239
- return {
305
+ let endpoint = typeof judge.endpoint === 'string' && judge.endpoint.trim() ? judge.endpoint.trim() : null;
306
+ if (!endpoint && catalogSpec?.defaultEndpoint) {
307
+ endpoint = catalogSpec.defaultEndpoint;
308
+ }
309
+ const normalized = {
240
310
  provider,
311
+ providerId,
241
312
  model,
242
313
  timeoutMs,
243
- endpoint: typeof judge.endpoint === 'string' && judge.endpoint.trim() ? judge.endpoint.trim() : null,
314
+ endpoint,
244
315
  keepAlive: provider === 'ollama' && typeof judge.keepAlive === 'string' && judge.keepAlive.trim()
245
316
  ? judge.keepAlive.trim()
246
317
  : provider === 'ollama'
247
318
  ? DEFAULT_JUDGE_LOCAL_OLLAMA.keepAlive
248
319
  : null,
249
320
  };
321
+ if (judge.cloudConsent?.accepted) {
322
+ normalized.cloudConsent = {
323
+ accepted: true,
324
+ at: judge.cloudConsent.at,
325
+ providerId: judge.cloudConsent.providerId ?? providerId,
326
+ endpoint: judge.cloudConsent.endpoint,
327
+ by: judge.cloudConsent.by,
328
+ };
329
+ }
330
+ if (judge.credential?.mode === 'project' || judge.credential?.mode === 'apiKey') {
331
+ normalized.credential = {
332
+ mode: judge.credential.mode,
333
+ ...(judge.credential.ref ? { ref: judge.credential.ref } : {}),
334
+ };
335
+ }
336
+ return normalized;
337
+ }
338
+ export function rejectTeamLayerJudgeSecrets(judge, source) {
339
+ if (source !== 'team' || !judge) {
340
+ return;
341
+ }
342
+ if (judge.credential?.mode === 'apiKey') {
343
+ throw new Error('team config cannot set judge.credential.mode to apiKey.');
344
+ }
345
+ const raw = judge;
346
+ if (raw.credential && 'key' in raw.credential && raw.credential.key) {
347
+ throw new Error('team config cannot contain inline judge credential keys.');
348
+ }
250
349
  }
251
350
  export function migrateV3ToV4(v3, raw) {
252
351
  return normalizeConfig({
@@ -0,0 +1,11 @@
1
+ export interface JudgeCredentialStoreFile {
2
+ version: 1;
3
+ judge?: string;
4
+ }
5
+ export declare function credentialStorePath(stateDir: string): string;
6
+ export declare function writeJudgeCredentialStore(stateDir: string, apiKey: string): Promise<string>;
7
+ export declare function clearJudgeCredentialStore(stateDir: string): Promise<void>;
8
+ export declare function readJudgeCredentialStore(stateDir: string): Promise<string | null>;
9
+ export declare function judgeCredentialStoreExists(stateDir: string): Promise<boolean>;
10
+ export type CredentialRef = `store:judge` | `env:${string}`;
11
+ export declare function parseCredentialRef(ref: string): CredentialRef | null;
@@ -0,0 +1,60 @@
1
+ import { chmod, mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ const STORE_FILENAME = 'credentials.json';
4
+ export function credentialStorePath(stateDir) {
5
+ return path.join(stateDir, STORE_FILENAME);
6
+ }
7
+ export async function writeJudgeCredentialStore(stateDir, apiKey) {
8
+ await mkdir(stateDir, { recursive: true, mode: 0o700 });
9
+ const filePath = credentialStorePath(stateDir);
10
+ const payload = {
11
+ version: 1,
12
+ judge: apiKey.trim(),
13
+ };
14
+ await writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, {
15
+ encoding: 'utf8',
16
+ mode: 0o600,
17
+ });
18
+ await chmod(filePath, 0o600);
19
+ return filePath;
20
+ }
21
+ export async function clearJudgeCredentialStore(stateDir) {
22
+ try {
23
+ await unlink(credentialStorePath(stateDir));
24
+ }
25
+ catch (error) {
26
+ if (error.code !== 'ENOENT') {
27
+ throw error;
28
+ }
29
+ }
30
+ }
31
+ export async function readJudgeCredentialStore(stateDir) {
32
+ const filePath = credentialStorePath(stateDir);
33
+ try {
34
+ const raw = await readFile(filePath, 'utf8');
35
+ const parsed = JSON.parse(raw);
36
+ const key = parsed.judge?.trim();
37
+ return key || null;
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }
43
+ export async function judgeCredentialStoreExists(stateDir) {
44
+ try {
45
+ await readFile(credentialStorePath(stateDir), 'utf8');
46
+ return true;
47
+ }
48
+ catch {
49
+ return false;
50
+ }
51
+ }
52
+ export function parseCredentialRef(ref) {
53
+ if (ref === 'store:judge') {
54
+ return ref;
55
+ }
56
+ if (ref.startsWith('env:') && ref.length > 4) {
57
+ return ref;
58
+ }
59
+ return null;
60
+ }
@@ -28,7 +28,7 @@ export interface GateVerdict extends GatePermissionResponse {
28
28
  approvalId?: string;
29
29
  wouldBlock: boolean;
30
30
  mode: 'enforce' | 'audit';
31
- v2?: ClassifyResult['v2'];
31
+ axes?: ClassifyResult['axes'];
32
32
  }
33
33
  export declare function isGatedAction(value: unknown): value is GatedAction;
34
34
  export declare function classifyResultToGateVerdict(params: {
@@ -25,7 +25,7 @@ export function classifyResultToGateVerdict(params) {
25
25
  approvalId,
26
26
  user_message,
27
27
  agent_message,
28
- v2: result.v2,
28
+ axes: result.axes,
29
29
  };
30
30
  }
31
31
  export function unnormalizedGateVerdict(params) {
@@ -1,11 +1,11 @@
1
1
  import { allPathsAllowlisted } from './capability/allowlist.js';
2
- import { collectOutsideRepoPaths } from './capability/paths.js';
2
+ import { collectOutsideRepoPaths, collectOutsideRepoPathsFromToolPayload, } from './capability/paths.js';
3
3
  import { classifySubagent } from './classify-subagent.js';
4
4
  import { classifyToolUse } from './classify-tool.js';
5
5
  import { classifierOptionsFromConfig } from './config.js';
6
6
  import { GATE_CONTRACT_VERSION } from './gate-contract.js';
7
7
  import { mergeAgentAssessment } from './judgment.js';
8
- import { classifyShell } from './v2/adapter.js';
8
+ import { classifyShell } from './verdict/adapter.js';
9
9
  export class GateNormalizationError extends Error {
10
10
  reason = 'normalization_failed';
11
11
  constructor(message) {
@@ -92,17 +92,63 @@ export function normalizeGatedAction(params) {
92
92
  agentAssessment,
93
93
  };
94
94
  }
95
- function applyShellPeripheralPolicy(command, action, result, options) {
96
- if (options.brokerFsScope &&
97
- result.verdict === 'deny_pending_approval' &&
98
- (result.reason === 'outside_repo_mutation' ||
99
- result.reason === 'outside_repo_redirect' ||
100
- result.reason === 'repo_outside_mutation' ||
101
- result.v2?.location === 'repo_outside')) {
102
- const outsideRepoPaths = collectOutsideRepoPaths(command, action.cwd, action.repoRoot);
103
- if (outsideRepoPaths.length > 0 &&
104
- options.fsScopeAllowlist &&
105
- allPathsAllowlisted(outsideRepoPaths, options.fsScopeAllowlist)) {
95
+ function applySandboxFsScopeBoundary(outsideRepoPaths, result, options, hints = {}) {
96
+ if (!options.brokerFsScope) {
97
+ return result;
98
+ }
99
+ if (outsideRepoPaths.length === 0) {
100
+ return result;
101
+ }
102
+ if (options.fsScopeAllowlist && allPathsAllowlisted(outsideRepoPaths, options.fsScopeAllowlist)) {
103
+ return result;
104
+ }
105
+ if (result.verdict === 'deny_pending_approval') {
106
+ return result;
107
+ }
108
+ const redirect = hints.redirect === true ||
109
+ result.reason === 'read_only' ||
110
+ result.assessment.signals.includes('read_only') ||
111
+ result.reason === 'outside_repo_redirect';
112
+ return {
113
+ ...result,
114
+ verdict: 'deny_pending_approval',
115
+ reason: redirect ? 'outside_repo_redirect' : 'outside_repo_mutation',
116
+ assessment: {
117
+ ...result.assessment,
118
+ external: true,
119
+ reversibility: 'irreversible',
120
+ signals: [...result.assessment.signals, 'sandbox_boundary_expected'],
121
+ },
122
+ };
123
+ }
124
+ function applyFsScopePeripheralPolicy(outsideRepoPaths, result, options) {
125
+ if (!options.brokerFsScope) {
126
+ return result;
127
+ }
128
+ if (outsideRepoPaths.length === 0) {
129
+ return result;
130
+ }
131
+ if (options.fsScopeAllowlist && allPathsAllowlisted(outsideRepoPaths, options.fsScopeAllowlist)) {
132
+ if (result.verdict === 'deny_pending_approval' &&
133
+ (result.reason === 'outside_repo_mutation' ||
134
+ result.reason === 'outside_repo_redirect' ||
135
+ result.reason === 'repo_outside_mutation' ||
136
+ result.axes?.location === 'repo_outside')) {
137
+ return {
138
+ ...result,
139
+ verdict: 'allow_flagged',
140
+ reason: 'capability_fs_hint',
141
+ assessment: {
142
+ ...result.assessment,
143
+ signals: [
144
+ ...result.assessment.signals,
145
+ 'capability_fs_hint',
146
+ 'sandbox_boundary_expected',
147
+ ],
148
+ },
149
+ };
150
+ }
151
+ if (result.verdict === 'allow' || result.verdict === 'allow_flagged') {
106
152
  return {
107
153
  ...result,
108
154
  verdict: 'allow_flagged',
@@ -120,6 +166,36 @@ function applyShellPeripheralPolicy(command, action, result, options) {
120
166
  }
121
167
  return result;
122
168
  }
169
+ function applySandboxOutsideBoundary(command, action, result, options) {
170
+ const outsideRepoPaths = collectOutsideRepoPaths(command, action.cwd, action.repoRoot);
171
+ return applySandboxFsScopeBoundary(outsideRepoPaths, result, options, {
172
+ redirect: command.includes('>'),
173
+ });
174
+ }
175
+ function applyShellPeripheralPolicy(command, action, result, options) {
176
+ const outsideRepoPaths = collectOutsideRepoPaths(command, action.cwd, action.repoRoot);
177
+ return applyFsScopePeripheralPolicy(outsideRepoPaths, result, options);
178
+ }
179
+ function outsideRepoPathsForToolAction(action) {
180
+ const payload = action.payload ?? {};
181
+ const paths = new Set(collectOutsideRepoPathsFromToolPayload(payload, action.cwd, action.repoRoot));
182
+ const command = shellCommandFromPayload(payload);
183
+ if (command) {
184
+ for (const resolved of collectOutsideRepoPaths(command, action.cwd, action.repoRoot)) {
185
+ paths.add(resolved);
186
+ }
187
+ }
188
+ return [...paths];
189
+ }
190
+ function applyToolSandboxPolicies(action, result, options) {
191
+ const outsideRepoPaths = outsideRepoPathsForToolAction(action);
192
+ const command = shellCommandFromPayload(action.payload ?? {});
193
+ let next = applySandboxFsScopeBoundary(outsideRepoPaths, result, options, {
194
+ redirect: command.includes('>'),
195
+ });
196
+ next = applyFsScopePeripheralPolicy(outsideRepoPaths, next, options);
197
+ return next;
198
+ }
123
199
  export async function classifyGatedAction(action, config, extraOptions = {}) {
124
200
  const options = { ...classifierOptionsFromConfig(config), ...extraOptions };
125
201
  if (action.kind === 'shell') {
@@ -128,6 +204,7 @@ export async function classifyGatedAction(action, config, extraOptions = {}) {
128
204
  throw new GateNormalizationError('Shell gated action requires a command.');
129
205
  }
130
206
  let result = await classifyShell(command, action.cwd, action.repoRoot, config, options);
207
+ result = applySandboxOutsideBoundary(command, action, result, options);
131
208
  result = applyShellPeripheralPolicy(command, action, result, options);
132
209
  if (!action.agentAssessment) {
133
210
  return result;
@@ -146,7 +223,21 @@ export async function classifyGatedAction(action, config, extraOptions = {}) {
146
223
  if (action.kind === 'subagent') {
147
224
  return classifySubagent(action.payload ?? {}, action.repoRoot, options);
148
225
  }
149
- return classifyToolUse(action.payload ?? {}, action.repoRoot, action.cwd, config, options);
226
+ let result = await classifyToolUse(action.payload ?? {}, action.repoRoot, action.cwd, config, options);
227
+ result = applyToolSandboxPolicies(action, result, options);
228
+ if (!action.agentAssessment) {
229
+ return result;
230
+ }
231
+ const merged = mergeAgentAssessment(result.assessment, action.agentAssessment);
232
+ if (!merged.mismatch) {
233
+ return { ...result, assessment: merged.assessment };
234
+ }
235
+ return {
236
+ ...result,
237
+ verdict: 'deny_pending_approval',
238
+ reason: 'agent_assessment_mismatch',
239
+ assessment: merged.assessment,
240
+ };
150
241
  }
151
242
  export async function classifyGatedActionAsync(action, config, extraOptions = {}) {
152
243
  return classifyGatedAction(action, config, extraOptions);
@@ -1,4 +1,4 @@
1
- export { approvalCommandMatch, buildRetryInstruction, compactApprovals, createApprovalRecord, isExpired, mergeApprovalStates, nowIso, } from './approval.js';
1
+ export { APPROVAL_EXECUTION_LEASE_MS, approvalCommandMatch, buildRetryInstruction, compactApprovals, createApprovalRecord, isExecutionLeaseExpired, isExpired, mergeApprovalStates, nowIso, } from './approval.js';
2
2
  export type { AuditMetricsReport } from './audit-metrics.js';
3
3
  export { computeAuditMetrics, parseAuditNdjson } from './audit-metrics.js';
4
4
  export { classifySubagent } from './classify-subagent.js';
@@ -16,4 +16,4 @@ export { findCommandSubstitutions, MAX_SUBSTITUTION_DEPTH } from './shell-substi
16
16
  export type { TransactionalDiffEvaluation, TransactionalExecutionResult, } from './transactional/index.js';
17
17
  export { isTransactionalEligible, runTransactionalExecution } from './transactional/index.js';
18
18
  export type { ApprovalRecord, ApprovalStateFile, Assessment, ClassifierOptions, ClassifyResult, HookVerdict, Reversibility, ScrubOptions, UnknownLocalEffectPolicy, } from './types.js';
19
- export { buildVerdictContext, classifyShell, verdict, verdictToClassifyResult, } from './v2/index.js';
19
+ export { buildVerdictContext, classifyShell, verdict, verdictToClassifyResult, } from './verdict/index.js';