@guilz-dev/belay 0.9.4 → 0.10.1

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 (62) hide show
  1. package/README.md +3 -2
  2. package/dist/adapters/claude/runtime-entry.js +5 -18
  3. package/dist/adapters/codex/runtime-entry.js +10 -11
  4. package/dist/adapters/cursor/hook-dispatch-entry.d.ts +3 -0
  5. package/dist/adapters/cursor/hook-dispatch-entry.js +11 -0
  6. package/dist/adapters/cursor/hook-router.d.ts +1 -0
  7. package/dist/adapters/cursor/hook-router.js +39 -9
  8. package/dist/adapters/cursor/hook-routing-health.d.ts +1 -0
  9. package/dist/adapters/cursor/hook-routing-health.js +53 -0
  10. package/dist/adapters/cursor/hooks.d.ts +1 -1
  11. package/dist/adapters/cursor/hooks.js +16 -5
  12. package/dist/adapters/cursor/routing-config-trust.d.ts +1 -0
  13. package/dist/adapters/cursor/routing-config-trust.js +67 -0
  14. package/dist/adapters/cursor/runtime-entry.d.ts +2 -2
  15. package/dist/adapters/cursor/runtime-entry.js +35 -10
  16. package/dist/adapters/shared/gate-runtime.d.ts +0 -2
  17. package/dist/adapters/shared/gate-runtime.js +12 -52
  18. package/dist/bundle/claude-runtime.mjs +1083 -669
  19. package/dist/bundle/codex-runtime.mjs +1092 -698
  20. package/dist/bundle/cursor-dispatcher.mjs +119 -28
  21. package/dist/bundle/cursor-runtime.mjs +1132 -677
  22. package/dist/cli.js +53 -4
  23. package/dist/commands/approval-token.d.ts +10 -0
  24. package/dist/commands/approval-token.js +26 -0
  25. package/dist/commands/audit.d.ts +2 -1
  26. package/dist/commands/audit.js +2 -2
  27. package/dist/commands/config.d.ts +1 -1
  28. package/dist/commands/config.js +28 -2
  29. package/dist/commands/doctor.js +14 -54
  30. package/dist/commands/dogfood-check.d.ts +3 -0
  31. package/dist/commands/dogfood-check.js +116 -0
  32. package/dist/commands/dogfood.d.ts +1 -0
  33. package/dist/commands/dogfood.js +4 -3
  34. package/dist/commands/judge.js +2 -2
  35. package/dist/commands/recovery-checkpoints.js +2 -11
  36. package/dist/config-io.d.ts +1 -0
  37. package/dist/config-io.js +8 -1
  38. package/dist/core/capability/index.d.ts +1 -1
  39. package/dist/core/capability/index.js +1 -1
  40. package/dist/core/capability/policy-engine.d.ts +19 -0
  41. package/dist/core/capability/policy-engine.js +31 -0
  42. package/dist/core/classify-tool.js +372 -148
  43. package/dist/core/config.js +2 -2
  44. package/dist/core/dogfood-environment.d.ts +8 -0
  45. package/dist/core/dogfood-environment.js +55 -0
  46. package/dist/core/effect-ir/shell-lower/decoders/belay.js +12 -0
  47. package/dist/core/egress-approval.js +0 -18
  48. package/dist/core/gate-engine.js +2 -9
  49. package/dist/core/notify.d.ts +8 -2
  50. package/dist/core/notify.js +63 -7
  51. package/dist/core/recovery/operator-guidance.js +4 -4
  52. package/dist/core/repo-config-trust.d.ts +31 -0
  53. package/dist/core/repo-config-trust.js +152 -0
  54. package/dist/corpus/benign-probe-cores.d.ts +1 -1
  55. package/dist/corpus/benign-probe-cores.js +0 -1
  56. package/dist/defaults.js +0 -25
  57. package/dist/installer/scope-config.js +2 -2
  58. package/dist/installer.js +4 -4
  59. package/dist/types.d.ts +19 -0
  60. package/dist/version.d.ts +1 -1
  61. package/dist/version.js +1 -1
  62. package/package.json +5 -1
package/dist/config-io.js CHANGED
@@ -7,6 +7,7 @@ import { compactApprovals, isExpired, mergeApprovalStates } from './core/approva
7
7
  import { mutateApprovalStateWithRetry } from './core/capability/approval-state-mutation.js';
8
8
  import { approvedApprovalsFile, belayStateDir, configuredControlPlaneDir, mergeConfig, pendingApprovalsFile, stripForbiddenShellOverrideLists, } from './core/config.js';
9
9
  import { resolveLayeredConfig, teamConfigPath, } from './core/config-layers.js';
10
+ import { trustRepoConfig } from './core/repo-config-trust.js';
10
11
  export function resolveAdapterName(config) {
11
12
  if (config.adapter === 'claude') {
12
13
  return 'claude';
@@ -221,6 +222,12 @@ export async function writeConfigFile(repoRoot, config, adapter = resolveAdapter
221
222
  await unlink(temporaryPath).catch(() => undefined);
222
223
  }
223
224
  }
225
+ export async function writeTrustedConfigFile(repoRoot, config, adapter = resolveAdapterName(config)) {
226
+ await writeConfigFile(repoRoot, config, adapter);
227
+ const configPath = configPathFor(repoRoot, adapter);
228
+ const parsed = JSON.parse(await readFile(configPath, 'utf8'));
229
+ await trustRepoConfig(repoRoot, adapter, parsed);
230
+ }
224
231
  export async function mergeAndWriteConfig(repoRoot, adapter = 'cursor') {
225
232
  const layout = getAdapterLayout(adapter);
226
233
  const configPath = layout.configPath(repoRoot);
@@ -229,7 +236,7 @@ export async function mergeAndWriteConfig(repoRoot, adapter = 'cursor') {
229
236
  existing = JSON.parse(await readFile(configPath, 'utf8'));
230
237
  }
231
238
  const merged = mergeConfig(existing, layout.defaultConfig(repoRoot));
232
- await writeConfigFile(repoRoot, merged, adapter);
239
+ await writeTrustedConfigFile(repoRoot, merged, adapter);
233
240
  await ensureBelayStateDir(merged, repoRoot);
234
241
  if (merged.controlPlane.enabled) {
235
242
  await migrateRepoLocalApprovalsToControlPlane(repoRoot, merged);
@@ -6,7 +6,7 @@ export { CAPABILITY_GRANT_VERSION, type CapabilityGrantV1, isGrantScopeTooBroad,
6
6
  export { checkGatedActionLimits, MAX_SHELL_COMMAND_BYTES, MAX_TOOL_PAYLOAD_BYTES, } from './limits.js';
7
7
  export { collectOutsideRepoPaths, collectOutsideRepoPathsFromToolPayload } from './paths.js';
8
8
  export { type CapabilityAuthorizationMetadata, type PolicyLegacyReasonHints, policyDecisionToLegacyReason, policyReasonToLegacyReason, singleCapabilityMetadata, } from './policy-bridge.js';
9
- export { buildFileMutationCapabilityRequest, buildShellCapabilityRequest, createTypeScriptPolicyEngine, evaluateFileMutationPolicy, evaluateShellPolicy, evaluateSubagentPolicy, type FileMutationCapabilityAnalysis, getDefaultPolicyEngine, policyDecisionRequiresAsk, type ShellCapabilityAnalysis, type SubagentCapabilityAnalysis, } from './policy-engine.js';
9
+ export { buildFileMutationCapabilityRequest, buildShellCapabilityRequest, createTypeScriptPolicyEngine, evaluateFileMutationPolicy, evaluateFileReadPolicy, evaluateShellPolicy, evaluateSubagentPolicy, type FileMutationCapabilityAnalysis, type FileReadCapabilityAnalysis, getDefaultPolicyEngine, policyDecisionRequiresAsk, type ShellCapabilityAnalysis, type SubagentCapabilityAnalysis, } from './policy-engine.js';
10
10
  export type { AuthorizationContext, PolicyDecision, PolicyEngine, PolicyOutcome, } from './policy-types.js';
11
11
  export { FS_SCOPE_REASONS, shouldSkipBrokerApprovedOnce, shouldSkipBrokerApprovedRecord, } from './reasons.js';
12
12
  export { CAPABILITY_REQUEST_VERSION, type CapabilityAction, type CapabilityEvidence, type CapabilityEvidenceLevel, type CapabilityHookKind, type CapabilityPrincipal, type CapabilityRequestContext, type CapabilityRequestV1, type CapabilityResource, type GitRefScope, type NetworkMode, type NetworkPayload, type ProcessOperation, } from './request.js';
@@ -6,7 +6,7 @@ export { CAPABILITY_GRANT_VERSION, isGrantScopeTooBroad, } from './grant.js';
6
6
  export { checkGatedActionLimits, MAX_SHELL_COMMAND_BYTES, MAX_TOOL_PAYLOAD_BYTES, } from './limits.js';
7
7
  export { collectOutsideRepoPaths, collectOutsideRepoPathsFromToolPayload } from './paths.js';
8
8
  export { policyDecisionToLegacyReason, policyReasonToLegacyReason, singleCapabilityMetadata, } from './policy-bridge.js';
9
- export { buildFileMutationCapabilityRequest, buildShellCapabilityRequest, createTypeScriptPolicyEngine, evaluateFileMutationPolicy, evaluateShellPolicy, evaluateSubagentPolicy, getDefaultPolicyEngine, policyDecisionRequiresAsk, } from './policy-engine.js';
9
+ export { buildFileMutationCapabilityRequest, buildShellCapabilityRequest, createTypeScriptPolicyEngine, evaluateFileMutationPolicy, evaluateFileReadPolicy, evaluateShellPolicy, evaluateSubagentPolicy, getDefaultPolicyEngine, policyDecisionRequiresAsk, } from './policy-engine.js';
10
10
  export { FS_SCOPE_REASONS, shouldSkipBrokerApprovedOnce, shouldSkipBrokerApprovedRecord, } from './reasons.js';
11
11
  export { CAPABILITY_REQUEST_VERSION, } from './request.js';
12
12
  export { addTrustedWorkspaceRoot, isBroadTrustedWorkspaceRoot, isHighStakesTrustedWorkspaceRoot, isPathWithinTrustedWorkspaceRoots, loadTrustedWorkspaceRoots, loadTrustedWorkspaceRootsSync, normalizeTrustedWorkspaceRootPath, saveTrustedWorkspaceRoots, trustedWorkspaceRootsPath, validateTrustedWorkspaceRootCandidate, } from './trusted-workspace-roots.js';
@@ -105,3 +105,22 @@ export declare function evaluateFileMutationPolicy(analysis: FileMutationCapabil
105
105
  request: CapabilityRequestV1;
106
106
  decision: PolicyDecision;
107
107
  };
108
+ export interface FileReadCapabilityAnalysis {
109
+ hookKind: CapabilityHookKind;
110
+ toolKind: string;
111
+ filePath: string;
112
+ resolvedPath: string;
113
+ repoRoot: string;
114
+ cwd: string;
115
+ inputFingerprint: string;
116
+ signals: string[];
117
+ locationLabel: 'repo_local' | 'outside_repo' | 'sensitive_path' | 'control_plane';
118
+ trustedWorkspaceRoots?: string[];
119
+ sensitivePaths?: string[];
120
+ adapter?: string;
121
+ }
122
+ export declare function buildFileReadCapabilityRequest(analysis: FileReadCapabilityAnalysis): CapabilityRequestV1;
123
+ export declare function evaluateFileReadPolicy(analysis: FileReadCapabilityAnalysis, config: BelayConfigV4, auth?: PolicyAuthExtras): {
124
+ request: CapabilityRequestV1;
125
+ decision: PolicyDecision;
126
+ };
@@ -866,3 +866,34 @@ export function evaluateFileMutationPolicy(analysis, config, auth) {
866
866
  const decision = getDefaultPolicyEngine().evaluate(request, buildAuthorizationContext(config, analysis.trustedWorkspaceRoots, enriched));
867
867
  return { request, decision };
868
868
  }
869
+ export function buildFileReadCapabilityRequest(analysis) {
870
+ return {
871
+ version: CAPABILITY_REQUEST_VERSION,
872
+ principal: {
873
+ adapter: analysis.adapter,
874
+ repoRoot: analysis.repoRoot,
875
+ sessionHash: hashSession(`${analysis.repoRoot}:${analysis.cwd}`),
876
+ },
877
+ action: 'fs.read',
878
+ resource: { kind: 'path', path: analysis.resolvedPath },
879
+ context: {
880
+ cwd: analysis.cwd,
881
+ inputFingerprint: analysis.inputFingerprint,
882
+ hookKind: analysis.hookKind,
883
+ analysisBasis: [`tool:${analysis.toolKind}`, `location:${analysis.locationLabel}`, 'read'],
884
+ },
885
+ evidence: {
886
+ level: analysis.locationLabel === 'repo_local' ? 'certain' : 'possible',
887
+ signals: [...analysis.signals, 'effect.fs_read'],
888
+ },
889
+ };
890
+ }
891
+ export function evaluateFileReadPolicy(analysis, config, auth) {
892
+ const request = buildFileReadCapabilityRequest(analysis);
893
+ const enriched = enrichAuthWithMaterializedGrants(request, config, {
894
+ ...auth,
895
+ sensitivePaths: auth?.sensitivePaths ?? analysis.sensitivePaths,
896
+ });
897
+ const decision = getDefaultPolicyEngine().evaluate(request, buildAuthorizationContext(config, analysis.trustedWorkspaceRoots, enriched));
898
+ return { request, decision };
899
+ }
@@ -1,7 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import { BOUNDARY_PROFILE_L3_L4_ONLY } from './capability/boundary-profile.js';
3
3
  import { policyReasonToLegacyReason } from './capability/policy-bridge.js';
4
- import { evaluateFileMutationPolicy, policyDecisionRequiresAsk, } from './capability/policy-engine.js';
4
+ import { evaluateFileMutationPolicy, evaluateFileReadPolicy, policyDecisionRequiresAsk, } from './capability/policy-engine.js';
5
5
  import { canonicalStringify, toolFingerprint } from './fingerprint.js';
6
6
  import { matchesSensitivePath } from './glob.js';
7
7
  import { pathWithinRoot, resolveWorkspaceRootMatch } from './path-utils.js';
@@ -11,17 +11,44 @@ import { classifyShell, resolveClassifierTrustedCwd } from './verdict/adapter.js
11
11
  import { isGitPath } from './verdict/containment.js';
12
12
  import { mutationPrescanRequiresAsk } from './verdict/prescan.js';
13
13
  const DEFAULT_SENSITIVE_PATHS = ['.env', '.env.*', '**/credentials/**'];
14
- const FILE_WRITE_TOOL_NAMES = new Set(['write']);
15
- const FILE_EDIT_TOOL_NAMES = new Set([
16
- 'edit',
17
- 'multiedit',
18
- 'multi_edit',
19
- 'patch',
20
- 'strreplace',
21
- 'str_replace',
22
- ]);
23
- const FILE_DELETE_TOOL_NAMES = new Set(['delete']);
24
- const APPLY_PATCH_TOOL_NAMES = new Set(['apply_patch', 'applypatch']);
14
+ function toolInputRecord(payload) {
15
+ const toolInput = payload.tool_input;
16
+ if (!toolInput || typeof toolInput !== 'object' || Array.isArray(toolInput)) {
17
+ return null;
18
+ }
19
+ return toolInput;
20
+ }
21
+ function hasMutationFields(input) {
22
+ return (typeof input.contents === 'string' ||
23
+ typeof input.old_string === 'string' ||
24
+ typeof input.new_string === 'string' ||
25
+ typeof input.newContents === 'string');
26
+ }
27
+ function inferToolPayloadEffect(payload, toolName) {
28
+ if (extractShellCommand(payload)) {
29
+ return 'shell';
30
+ }
31
+ const input = toolInputRecord(payload);
32
+ if (!input) {
33
+ return 'indeterminate';
34
+ }
35
+ if (hasMutationFields(input)) {
36
+ return 'file_mutation';
37
+ }
38
+ if (extractPatch(payload)) {
39
+ return 'apply_patch';
40
+ }
41
+ if (typeof input.pattern === 'string' || typeof input.glob_pattern === 'string') {
42
+ return 'search_read';
43
+ }
44
+ if (typeof input.file_path === 'string') {
45
+ return 'file_read';
46
+ }
47
+ if (typeof input.path === 'string' || typeof input.target_file === 'string') {
48
+ return normalizedToolName(toolName) === 'delete' ? 'file_delete' : 'indeterminate';
49
+ }
50
+ return 'indeterminate';
51
+ }
25
52
  function policyAuth(options) {
26
53
  if (!options.grants &&
27
54
  options.attestation === undefined &&
@@ -201,6 +228,283 @@ function classifyFileMutationWithPolicy(params) {
201
228
  boundaryProfile: params.options.boundaryProfile ?? BOUNDARY_PROFILE_L3_L4_ONLY,
202
229
  };
203
230
  }
231
+ function classifyFileReadWithPolicy(params) {
232
+ const fingerprint = toolFingerprint(params.toolName, { path: params.filePath }, params.repoRoot);
233
+ const { request, decision } = evaluateFileReadPolicy({
234
+ hookKind: 'tool',
235
+ toolKind: params.toolKind,
236
+ filePath: params.filePath,
237
+ resolvedPath: params.resolvedPath,
238
+ repoRoot: params.repoRoot,
239
+ cwd: params.cwd,
240
+ inputFingerprint: fingerprint,
241
+ signals: params.signals,
242
+ locationLabel: params.locationLabel,
243
+ trustedWorkspaceRoots: params.options.trustedWorkspaceRoots,
244
+ sensitivePaths: params.options.sensitivePaths ?? params.config.classifier.sensitivePaths,
245
+ }, params.config, policyAuth(params.options));
246
+ if (policyDecisionRequiresAsk(decision)) {
247
+ return {
248
+ verdict: 'deny_pending_approval',
249
+ reason: policyReasonToLegacyReason(decision),
250
+ summary: params.filePath,
251
+ fingerprint,
252
+ assessment: {
253
+ reversibility: 'reversible',
254
+ external: params.locationLabel === 'outside_repo',
255
+ blastRadius: params.locationLabel === 'outside_repo'
256
+ ? 'outside the repository'
257
+ : 'sensitive repository file',
258
+ confidence: 0.85,
259
+ signals: [...params.signals, ...decision.signals],
260
+ },
261
+ capabilityRequests: [request],
262
+ authorizationDecision: decision,
263
+ boundaryProfile: params.options.boundaryProfile ?? BOUNDARY_PROFILE_L3_L4_ONLY,
264
+ };
265
+ }
266
+ return {
267
+ verdict: 'allow',
268
+ reason: 'effect.fs_read',
269
+ summary: params.filePath,
270
+ fingerprint,
271
+ assessment: {
272
+ reversibility: 'reversible',
273
+ external: false,
274
+ blastRadius: 'tool scope',
275
+ confidence: 0.88,
276
+ signals: [...params.signals, ...decision.signals],
277
+ },
278
+ capabilityRequests: [request],
279
+ authorizationDecision: decision,
280
+ boundaryProfile: params.options.boundaryProfile ?? BOUNDARY_PROFILE_L3_L4_ONLY,
281
+ };
282
+ }
283
+ function classifyFilePathMutation(params) {
284
+ const { toolName, toolKind, filePath, repoRoot, cwd, config, options, sensitivePaths, protectedRoots, isDelete, } = params;
285
+ const signals = [];
286
+ const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
287
+ const hitsProtectedRoot = protectedRoots.some((root) => pathWithinRoot(root, resolvedPath));
288
+ if (hitsProtectedRoot) {
289
+ signals.push('control_plane_path');
290
+ return classifyFileMutationWithPolicy({
291
+ toolName,
292
+ toolKind,
293
+ filePath,
294
+ resolvedPath,
295
+ repoRoot,
296
+ cwd,
297
+ config,
298
+ options,
299
+ signals,
300
+ isDelete,
301
+ locationLabel: 'control_plane',
302
+ });
303
+ }
304
+ const workspaceMatch = resolveWorkspaceRootMatch(repoRoot, options.trustedWorkspaceRoots, resolvedPath);
305
+ if (workspaceMatch === null) {
306
+ signals.push('outside_repo_path');
307
+ return classifyFileMutationWithPolicy({
308
+ toolName,
309
+ toolKind,
310
+ filePath,
311
+ resolvedPath,
312
+ repoRoot,
313
+ cwd,
314
+ config,
315
+ options,
316
+ signals,
317
+ isDelete,
318
+ locationLabel: 'outside_repo',
319
+ });
320
+ }
321
+ if (workspaceMatch.kind === 'trusted') {
322
+ signals.push('trusted_workspace_root');
323
+ }
324
+ const trustedCwd = resolveClassifierTrustedCwd(cwd, options);
325
+ const workspacePrescan = mutationPrescanRequiresAsk({
326
+ targets: [filePath],
327
+ cwd,
328
+ repoRoot,
329
+ trustedCwd,
330
+ trustedWorkspaceRoots: options.trustedWorkspaceRoots,
331
+ sensitivePaths,
332
+ });
333
+ if (workspacePrescan) {
334
+ return classifyFileMutationWithPolicy({
335
+ toolName,
336
+ toolKind,
337
+ filePath,
338
+ resolvedPath,
339
+ repoRoot,
340
+ cwd,
341
+ config,
342
+ options,
343
+ signals,
344
+ isDelete,
345
+ locationLabel: 'sensitive_path',
346
+ });
347
+ }
348
+ const relativePath = workspaceMatch.relativePath;
349
+ if (matchesSensitivePath(relativePath, sensitivePaths)) {
350
+ signals.push('sensitive_path');
351
+ return classifyFileMutationWithPolicy({
352
+ toolName,
353
+ toolKind,
354
+ filePath,
355
+ resolvedPath,
356
+ repoRoot,
357
+ cwd,
358
+ config,
359
+ options,
360
+ signals,
361
+ isDelete,
362
+ locationLabel: 'sensitive_path',
363
+ });
364
+ }
365
+ if (isDelete) {
366
+ if (isGitPath(resolvedPath, repoRoot)) {
367
+ signals.push('protected_artifact');
368
+ return classifyFileMutationWithPolicy({
369
+ toolName,
370
+ toolKind,
371
+ filePath,
372
+ resolvedPath,
373
+ repoRoot,
374
+ cwd,
375
+ config,
376
+ options,
377
+ signals,
378
+ isDelete: true,
379
+ locationLabel: 'sensitive_path',
380
+ });
381
+ }
382
+ signals.push('file_delete');
383
+ return classifyFileMutationWithPolicy({
384
+ toolName,
385
+ toolKind,
386
+ filePath,
387
+ resolvedPath,
388
+ repoRoot,
389
+ cwd,
390
+ config,
391
+ options,
392
+ signals,
393
+ isDelete: true,
394
+ locationLabel: 'repo_local',
395
+ });
396
+ }
397
+ signals.push('file_mutation');
398
+ return classifyFileMutationWithPolicy({
399
+ toolName,
400
+ toolKind,
401
+ filePath,
402
+ resolvedPath,
403
+ repoRoot,
404
+ cwd,
405
+ config,
406
+ options,
407
+ signals,
408
+ isDelete: false,
409
+ locationLabel: 'repo_local',
410
+ });
411
+ }
412
+ function classifyFilePathRead(params) {
413
+ const { toolName, toolKind, filePath, repoRoot, cwd, config, options, sensitivePaths, protectedRoots, } = params;
414
+ const signals = ['effect.fs_read'];
415
+ const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
416
+ if (protectedRoots.some((root) => pathWithinRoot(root, resolvedPath))) {
417
+ signals.push('control_plane_path');
418
+ return classifyFileReadWithPolicy({
419
+ toolName,
420
+ toolKind,
421
+ filePath,
422
+ resolvedPath,
423
+ repoRoot,
424
+ cwd,
425
+ config,
426
+ options,
427
+ signals,
428
+ locationLabel: 'control_plane',
429
+ });
430
+ }
431
+ const workspaceMatch = resolveWorkspaceRootMatch(repoRoot, options.trustedWorkspaceRoots, resolvedPath);
432
+ if (workspaceMatch === null) {
433
+ signals.push('outside_repo_path');
434
+ return classifyFileReadWithPolicy({
435
+ toolName,
436
+ toolKind,
437
+ filePath,
438
+ resolvedPath,
439
+ repoRoot,
440
+ cwd,
441
+ config,
442
+ options,
443
+ signals,
444
+ locationLabel: 'outside_repo',
445
+ });
446
+ }
447
+ const relativePath = workspaceMatch.relativePath;
448
+ if (matchesSensitivePath(relativePath, sensitivePaths)) {
449
+ signals.push('sensitive_path');
450
+ return classifyFileReadWithPolicy({
451
+ toolName,
452
+ toolKind,
453
+ filePath,
454
+ resolvedPath,
455
+ repoRoot,
456
+ cwd,
457
+ config,
458
+ options,
459
+ signals,
460
+ locationLabel: 'sensitive_path',
461
+ });
462
+ }
463
+ return classifyFileReadWithPolicy({
464
+ toolName,
465
+ toolKind,
466
+ filePath,
467
+ resolvedPath,
468
+ repoRoot,
469
+ cwd,
470
+ config,
471
+ options,
472
+ signals,
473
+ locationLabel: 'repo_local',
474
+ });
475
+ }
476
+ function indeterminateToolResult(toolName, payload, repoRoot, options) {
477
+ const summary = canonicalStringify(scrubPayload(payload.tool_input ?? {}, options));
478
+ const fingerprint = toolFingerprint(toolName, fingerprintPayload(payload, options), repoRoot);
479
+ if (options.unknownLocalEffect === 'deny') {
480
+ return {
481
+ verdict: 'deny_pending_approval',
482
+ reason: 'indeterminate_tool_effect',
483
+ summary,
484
+ fingerprint,
485
+ assessment: {
486
+ reversibility: 'irreversible',
487
+ external: false,
488
+ blastRadius: 'unknown tool action',
489
+ confidence: 0.5,
490
+ signals: ['indeterminate_tool_effect'],
491
+ },
492
+ };
493
+ }
494
+ return {
495
+ verdict: 'allow_flagged',
496
+ reason: 'indeterminate_tool_effect',
497
+ summary,
498
+ fingerprint,
499
+ assessment: {
500
+ reversibility: 'recoverable_with_cost',
501
+ external: false,
502
+ blastRadius: 'tool scope',
503
+ confidence: 0.5,
504
+ signals: ['indeterminate_tool_effect'],
505
+ },
506
+ };
507
+ }
204
508
  export async function classifyToolUse(payload, repoRoot, cwd, config, options = {}) {
205
509
  const toolName = String(payload.tool_name ?? '');
206
510
  const toolKind = normalizedToolName(toolName);
@@ -247,9 +551,8 @@ export async function classifyToolUse(payload, repoRoot, cwd, config, options =
247
551
  summary: command,
248
552
  };
249
553
  }
250
- if (FILE_WRITE_TOOL_NAMES.has(toolKind) ||
251
- FILE_EDIT_TOOL_NAMES.has(toolKind) ||
252
- FILE_DELETE_TOOL_NAMES.has(toolKind)) {
554
+ const effect = inferToolPayloadEffect(payload, toolName);
555
+ if (effect === 'file_mutation' || effect === 'file_delete') {
253
556
  const filePath = extractFilePath(payload);
254
557
  if (!filePath) {
255
558
  if (options.unknownLocalEffect === 'deny') {
@@ -281,134 +584,20 @@ export async function classifyToolUse(payload, repoRoot, cwd, config, options =
281
584
  },
282
585
  };
283
586
  }
284
- const signals = [];
285
- const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
286
- const hitsProtectedRoot = protectedRoots.some((root) => pathWithinRoot(root, resolvedPath));
287
- if (hitsProtectedRoot) {
288
- signals.push('control_plane_path');
289
- return classifyFileMutationWithPolicy({
290
- toolName,
291
- toolKind,
292
- filePath,
293
- resolvedPath,
294
- repoRoot,
295
- cwd,
296
- config,
297
- options,
298
- signals,
299
- isDelete: FILE_DELETE_TOOL_NAMES.has(toolKind),
300
- locationLabel: 'control_plane',
301
- });
302
- }
303
- const workspaceMatch = resolveWorkspaceRootMatch(repoRoot, options.trustedWorkspaceRoots, resolvedPath);
304
- if (workspaceMatch === null) {
305
- signals.push('outside_repo_path');
306
- return classifyFileMutationWithPolicy({
307
- toolName,
308
- toolKind,
309
- filePath,
310
- resolvedPath,
311
- repoRoot,
312
- cwd,
313
- config,
314
- options,
315
- signals,
316
- isDelete: FILE_DELETE_TOOL_NAMES.has(toolKind),
317
- locationLabel: 'outside_repo',
318
- });
319
- }
320
- if (workspaceMatch.kind === 'trusted') {
321
- signals.push('trusted_workspace_root');
322
- }
323
- const trustedCwd = resolveClassifierTrustedCwd(cwd, options);
324
- const workspacePrescan = mutationPrescanRequiresAsk({
325
- targets: [filePath],
326
- cwd,
327
- repoRoot,
328
- trustedCwd,
329
- trustedWorkspaceRoots: options.trustedWorkspaceRoots,
330
- sensitivePaths,
331
- });
332
- if (workspacePrescan) {
333
- return classifyFileMutationWithPolicy({
334
- toolName,
335
- toolKind,
336
- filePath,
337
- resolvedPath,
338
- repoRoot,
339
- cwd,
340
- config,
341
- options,
342
- signals,
343
- isDelete: FILE_DELETE_TOOL_NAMES.has(toolKind),
344
- locationLabel: 'sensitive_path',
345
- });
346
- }
347
- const relativePath = workspaceMatch.relativePath;
348
- if (matchesSensitivePath(relativePath, sensitivePaths)) {
349
- signals.push('sensitive_path');
350
- return classifyFileMutationWithPolicy({
351
- toolName,
352
- toolKind,
353
- filePath,
354
- resolvedPath,
355
- repoRoot,
356
- cwd,
357
- config,
358
- options,
359
- signals,
360
- isDelete: FILE_DELETE_TOOL_NAMES.has(toolKind),
361
- locationLabel: 'sensitive_path',
362
- });
363
- }
364
- if (FILE_DELETE_TOOL_NAMES.has(toolKind)) {
365
- if (isGitPath(resolvedPath, repoRoot)) {
366
- signals.push('protected_artifact');
367
- return classifyFileMutationWithPolicy({
368
- toolName,
369
- toolKind,
370
- filePath,
371
- resolvedPath,
372
- repoRoot,
373
- cwd,
374
- config,
375
- options,
376
- signals,
377
- isDelete: true,
378
- locationLabel: 'sensitive_path',
379
- });
380
- }
381
- signals.push('file_delete');
382
- return classifyFileMutationWithPolicy({
383
- toolName,
384
- toolKind,
385
- filePath,
386
- resolvedPath,
387
- repoRoot,
388
- cwd,
389
- config,
390
- options,
391
- signals,
392
- isDelete: true,
393
- locationLabel: 'repo_local',
394
- });
395
- }
396
- signals.push('file_mutation');
397
- return classifyFileMutationWithPolicy({
587
+ return classifyFilePathMutation({
398
588
  toolName,
399
589
  toolKind,
400
590
  filePath,
401
- resolvedPath,
402
591
  repoRoot,
403
592
  cwd,
404
593
  config,
405
594
  options,
406
- signals,
407
- isDelete: false,
408
- locationLabel: 'repo_local',
595
+ sensitivePaths,
596
+ protectedRoots,
597
+ isDelete: effect === 'file_delete',
409
598
  });
410
599
  }
411
- if (APPLY_PATCH_TOOL_NAMES.has(toolKind)) {
600
+ if (effect === 'apply_patch') {
412
601
  const patch = extractPatch(payload);
413
602
  const targets = patch ? applyPatchTargets(patch) : [];
414
603
  if (targets.length === 0) {
@@ -445,7 +634,7 @@ export async function classifyToolUse(payload, repoRoot, cwd, config, options =
445
634
  for (const target of targets) {
446
635
  const result = await classifyToolUse({
447
636
  tool_name: target.delete ? 'Delete' : 'Write',
448
- tool_input: { path: target.path },
637
+ tool_input: target.delete ? { path: target.path } : { path: target.path, contents: ' ' },
449
638
  }, repoRoot, cwd, config, options);
450
639
  if (result.verdict === 'deny_pending_approval') {
451
640
  return result;
@@ -466,17 +655,52 @@ export async function classifyToolUse(payload, repoRoot, cwd, config, options =
466
655
  },
467
656
  };
468
657
  }
469
- return {
470
- verdict: 'allow',
471
- reason: 'unclassified_tool',
472
- summary: canonicalStringify(scrubPayload(payload.tool_input ?? {}, options)),
473
- fingerprint: toolFingerprint(toolName, fingerprintPayload(payload, options), repoRoot),
474
- assessment: {
475
- reversibility: 'reversible',
476
- external: false,
477
- blastRadius: 'tool scope',
478
- confidence: 0.5,
479
- signals: ['unclassified_tool'],
480
- },
481
- };
658
+ if (effect === 'file_read') {
659
+ const filePath = extractFilePath(payload);
660
+ if (!filePath) {
661
+ return indeterminateToolResult(toolName, payload, repoRoot, options);
662
+ }
663
+ return classifyFilePathRead({
664
+ toolName,
665
+ toolKind,
666
+ filePath,
667
+ repoRoot,
668
+ cwd,
669
+ config,
670
+ options,
671
+ sensitivePaths,
672
+ protectedRoots,
673
+ });
674
+ }
675
+ if (effect === 'search_read') {
676
+ return {
677
+ verdict: 'allow',
678
+ reason: 'effect.search_read',
679
+ summary: canonicalStringify(scrubPayload(payload.tool_input ?? {}, options)),
680
+ fingerprint: toolFingerprint(toolName, fingerprintPayload(payload, options), repoRoot),
681
+ assessment: {
682
+ reversibility: 'reversible',
683
+ external: false,
684
+ blastRadius: 'tool scope',
685
+ confidence: 0.88,
686
+ signals: ['effect.search_read'],
687
+ },
688
+ };
689
+ }
690
+ const filePath = extractFilePath(payload);
691
+ if (filePath) {
692
+ return classifyFilePathMutation({
693
+ toolName,
694
+ toolKind,
695
+ filePath,
696
+ repoRoot,
697
+ cwd,
698
+ config,
699
+ options,
700
+ sensitivePaths,
701
+ protectedRoots,
702
+ isDelete: toolKind === 'delete',
703
+ });
704
+ }
705
+ return indeterminateToolResult(toolName, payload, repoRoot, options);
482
706
  }