@guilz-dev/belay 0.4.0 → 0.6.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 (158) hide show
  1. package/README.md +7 -1
  2. package/dist/adapters/layouts/scope.js +3 -2
  3. package/dist/adapters/shared/gate-runtime.d.ts +1 -2
  4. package/dist/adapters/shared/gate-runtime.js +226 -28
  5. package/dist/bundle/claude-runtime.mjs +6967 -4078
  6. package/dist/bundle/codex-runtime.mjs +5951 -3064
  7. package/dist/bundle/cursor-runtime.mjs +5951 -3064
  8. package/dist/cli.js +43 -1
  9. package/dist/commands/classify-for-report.js +1 -1
  10. package/dist/commands/config.d.ts +8 -0
  11. package/dist/commands/config.js +134 -11
  12. package/dist/commands/doctor.js +36 -1
  13. package/dist/commands/explain.js +11 -1
  14. package/dist/commands/judge.js +3 -2
  15. package/dist/commands/session.d.ts +20 -0
  16. package/dist/commands/session.js +51 -0
  17. package/dist/config-io.js +49 -12
  18. package/dist/conformance/guarantee-posture.d.ts +21 -0
  19. package/dist/conformance/guarantee-posture.js +46 -0
  20. package/dist/conformance/guarantee-table.d.ts +1 -0
  21. package/dist/conformance/guarantee-table.js +14 -9
  22. package/dist/core/approval-service.js +6 -2
  23. package/dist/core/approval.d.ts +2 -0
  24. package/dist/core/approval.js +14 -3
  25. package/dist/core/capability/approval-state-mutation.d.ts +13 -0
  26. package/dist/core/capability/approval-state-mutation.js +92 -0
  27. package/dist/core/capability/approval-v3.d.ts +15 -0
  28. package/dist/core/capability/approval-v3.js +74 -0
  29. package/dist/core/capability/attestation.d.ts +13 -0
  30. package/dist/core/capability/attestation.js +55 -0
  31. package/dist/core/capability/boundary-attestation-sign.d.ts +21 -0
  32. package/dist/core/capability/boundary-attestation-sign.js +84 -0
  33. package/dist/core/capability/boundary-driver-container.d.ts +7 -0
  34. package/dist/core/capability/boundary-driver-container.js +122 -0
  35. package/dist/core/capability/boundary-driver.d.ts +29 -0
  36. package/dist/core/capability/boundary-driver.js +43 -0
  37. package/dist/core/capability/boundary-egress.d.ts +21 -0
  38. package/dist/core/capability/boundary-egress.js +109 -0
  39. package/dist/core/capability/boundary-grant-materialize.d.ts +13 -0
  40. package/dist/core/capability/boundary-grant-materialize.js +78 -0
  41. package/dist/core/capability/boundary-profile.d.ts +15 -0
  42. package/dist/core/capability/boundary-profile.js +25 -0
  43. package/dist/core/capability/boundary-run.d.ts +23 -0
  44. package/dist/core/capability/boundary-run.js +23 -0
  45. package/dist/core/capability/boundary-session.d.ts +57 -0
  46. package/dist/core/capability/boundary-session.js +129 -0
  47. package/dist/core/capability/capability-request-hash.d.ts +2 -0
  48. package/dist/core/capability/capability-request-hash.js +8 -0
  49. package/dist/core/capability/gate-latency-ratchet.d.ts +7 -0
  50. package/dist/core/capability/gate-latency-ratchet.js +18 -0
  51. package/dist/core/capability/gate-policy-shadow.d.ts +25 -0
  52. package/dist/core/capability/gate-policy-shadow.js +108 -0
  53. package/dist/core/capability/gate-shadow-audit.d.ts +1 -0
  54. package/dist/core/capability/gate-shadow-audit.js +1 -0
  55. package/dist/core/capability/gate-shadow-ratchet.d.ts +14 -0
  56. package/dist/core/capability/gate-shadow-ratchet.js +81 -0
  57. package/dist/core/capability/grant-lease.d.ts +9 -0
  58. package/dist/core/capability/grant-lease.js +46 -0
  59. package/dist/core/capability/grant-loader.d.ts +12 -0
  60. package/dist/core/capability/grant-loader.js +23 -0
  61. package/dist/core/capability/grant-match.d.ts +6 -0
  62. package/dist/core/capability/grant-match.js +93 -0
  63. package/dist/core/capability/grant.d.ts +16 -0
  64. package/dist/core/capability/grant.js +16 -0
  65. package/dist/core/capability/index.d.ts +8 -0
  66. package/dist/core/capability/index.js +7 -0
  67. package/dist/core/capability/limits.d.ts +7 -0
  68. package/dist/core/capability/limits.js +55 -0
  69. package/dist/core/capability/policy-bridge.d.ts +18 -0
  70. package/dist/core/capability/policy-bridge.js +51 -0
  71. package/dist/core/capability/policy-engine.d.ts +70 -0
  72. package/dist/core/capability/policy-engine.js +476 -0
  73. package/dist/core/capability/policy-types.d.ts +20 -0
  74. package/dist/core/capability/policy-types.js +1 -0
  75. package/dist/core/capability/request.d.ts +45 -0
  76. package/dist/core/capability/request.js +1 -0
  77. package/dist/core/capability/resolver.d.ts +4 -0
  78. package/dist/core/capability/resolver.js +4 -0
  79. package/dist/core/classify-subagent.d.ts +2 -1
  80. package/dist/core/classify-subagent.js +38 -2
  81. package/dist/core/classify-tool.js +126 -62
  82. package/dist/core/config.d.ts +14 -3
  83. package/dist/core/config.js +56 -3
  84. package/dist/core/gate-contract.d.ts +3 -0
  85. package/dist/core/gate-contract.js +3 -0
  86. package/dist/core/gate-engine.js +6 -1
  87. package/dist/core/judge-doctor.js +39 -14
  88. package/dist/core/judge-fallback-hints.d.ts +13 -0
  89. package/dist/core/judge-fallback-hints.js +108 -0
  90. package/dist/core/path-utils.d.ts +1 -0
  91. package/dist/core/path-utils.js +5 -2
  92. package/dist/core/recovery/capability.d.ts +3 -0
  93. package/dist/core/recovery/capability.js +22 -0
  94. package/dist/core/recovery/fail-closed.d.ts +6 -0
  95. package/dist/core/recovery/fail-closed.js +25 -0
  96. package/dist/core/recovery/index.d.ts +3 -0
  97. package/dist/core/recovery/index.js +2 -0
  98. package/dist/core/recovery/types.d.ts +20 -0
  99. package/dist/core/recovery/types.js +1 -0
  100. package/dist/core/standing-allow.js +3 -0
  101. package/dist/core/transactional/diff-evaluator.js +4 -1
  102. package/dist/core/transactional/eligibility.js +4 -0
  103. package/dist/core/transactional/git-worktree.d.ts +10 -3
  104. package/dist/core/transactional/git-worktree.js +81 -11
  105. package/dist/core/transactional/reasons.d.ts +1 -0
  106. package/dist/core/transactional/reasons.js +4 -0
  107. package/dist/core/transactional/runner.js +22 -6
  108. package/dist/core/transactional/types.d.ts +4 -0
  109. package/dist/core/types.d.ts +21 -3
  110. package/dist/core/verdict/adapter.d.ts +3 -4
  111. package/dist/core/verdict/adapter.js +16 -12
  112. package/dist/core/verdict/cursor-acp-client.d.ts +67 -0
  113. package/dist/core/verdict/cursor-acp-client.js +345 -0
  114. package/dist/core/verdict/egress-classify.d.ts +2 -2
  115. package/dist/core/verdict/egress-classify.js +2 -2
  116. package/dist/core/verdict/git-classifier.d.ts +26 -0
  117. package/dist/core/verdict/git-classifier.js +533 -0
  118. package/dist/core/verdict/judge-audit.js +1 -0
  119. package/dist/core/verdict/judge-baseline.d.ts +1 -1
  120. package/dist/core/verdict/judge-baseline.js +1 -0
  121. package/dist/core/verdict/judge-broker-service.js +12 -0
  122. package/dist/core/verdict/judge-catalog.js +1 -0
  123. package/dist/core/verdict/judge-cli-fingerprint.d.ts +1 -0
  124. package/dist/core/verdict/judge-cli-fingerprint.js +6 -0
  125. package/dist/core/verdict/judge-cli.js +5 -3
  126. package/dist/core/verdict/judge-provider-matrix.js +1 -1
  127. package/dist/core/verdict/judge-runtime-config.d.ts +2 -0
  128. package/dist/core/verdict/judge-runtime-config.js +10 -6
  129. package/dist/core/verdict/judge-session-broker.d.ts +1 -1
  130. package/dist/core/verdict/judge-session-broker.js +10 -5
  131. package/dist/core/verdict/judge-transport.d.ts +5 -1
  132. package/dist/core/verdict/judge-transport.js +24 -14
  133. package/dist/core/verdict/judge.d.ts +2 -15
  134. package/dist/core/verdict/judge.js +5 -68
  135. package/dist/core/verdict/launcher-resolve.d.ts +2 -0
  136. package/dist/core/verdict/launcher-resolve.js +16 -0
  137. package/dist/core/verdict/prescan.d.ts +15 -0
  138. package/dist/core/verdict/prescan.js +68 -0
  139. package/dist/core/verdict/shell-policy.d.ts +25 -0
  140. package/dist/core/verdict/shell-policy.js +40 -0
  141. package/dist/core/verdict/shell-semantics.d.ts +24 -0
  142. package/dist/core/verdict/shell-semantics.js +225 -0
  143. package/dist/core/verdict/types.d.ts +13 -1
  144. package/dist/core/verdict/verdict.js +296 -178
  145. package/dist/corpus/evaluate.js +3 -7
  146. package/dist/corpus/gate-latency-budget.d.ts +39 -0
  147. package/dist/corpus/gate-latency-budget.js +33 -0
  148. package/dist/corpus/mutators.d.ts +1 -1
  149. package/dist/corpus/mutators.js +11 -0
  150. package/dist/corpus/runtime-match.js +1 -3
  151. package/dist/corpus/standing-allow-catalog.generated.js +29 -9
  152. package/dist/judge-broker-daemon.js +15 -1
  153. package/dist/services/sandbox-service.d.ts +4 -0
  154. package/dist/services/sandbox-service.js +28 -2
  155. package/dist/types.d.ts +2 -2
  156. package/dist/version.d.ts +1 -1
  157. package/dist/version.js +1 -1
  158. package/package.json +1 -1
@@ -1,12 +1,17 @@
1
- import { resolveMutationTarget } from '../path-utils.js';
1
+ import path from 'node:path';
2
+ import { policyDecisionToLegacyReason } from '../capability/policy-bridge.js';
3
+ import { policyDecisionRequiresAsk } from '../capability/policy-engine.js';
4
+ import { canonicalPath, resolveMutationTarget } from '../path-utils.js';
2
5
  import { extractRedirectTargets, isFdDuplication, isRedirectOperator, tokenizeShell, } from '../shell-tokenizer.js';
3
6
  import { analyzePathTargets, cwdRelative, isDestructiveHighStakesMutation, resolveTrustedPath, touchesProtectedRoot, } from './containment.js';
4
7
  import { classifyEgressTool } from './egress-classify.js';
5
8
  import { verdictFingerprint } from './fingerprint.js';
6
- import { mutationPrescanRequiresAsk, prescanInterpreterCode, tier1RequiresAsk } from './judge.js';
7
- import { isRoutineLauncher, resolveLauncherRecipe } from './launcher-resolve.js';
9
+ import { isReadOnlyLauncherInvocation, isRoutineLauncher, resolveLauncherRecipe, } from './launcher-resolve.js';
8
10
  import { allowFromCustomOverride, askFromCustomExternal, customAllowMatch, customExternalMatch, } from './overrides.js';
9
11
  import { extractRecursiveScript, isBareInterpreter, isVariableIndirectHead, parseSegment, peelTransparentWrappers, redactCommand, segmentOpacity, splitTopLevelSegments, substitutionInners, } from './parser.js';
12
+ import { mutationPrescanRequiresAsk, prescanInterpreterCode, tier1RequiresAsk } from './prescan.js';
13
+ import { evaluateSegmentShellPolicy, shellPolicyMetadata, } from './shell-policy.js';
14
+ import { analyzeShellCommandSemantics, isPureReadOnlySemantics, LOCAL_MUTATION_KEYS, } from './shell-semantics.js';
10
15
  const DEFAULT_MAX_DEPTH = 8;
11
16
  const TIER0_EXTERNAL_KEYS = new Set([
12
17
  'git push',
@@ -20,7 +25,6 @@ const TIER0_EXTERNAL_KEYS = new Set([
20
25
  'supabase',
21
26
  'scp',
22
27
  'ssh',
23
- 'rsync',
24
28
  ]);
25
29
  const TIER0_EXTERNAL_HEADS = new Set([
26
30
  'dropdb',
@@ -30,66 +34,6 @@ const TIER0_EXTERNAL_HEADS = new Set([
30
34
  'mongosh',
31
35
  'redis-cli',
32
36
  ]);
33
- const READ_ONLY_KEYS = new Set([
34
- 'cat',
35
- 'cd',
36
- 'echo',
37
- 'git diff',
38
- 'git log',
39
- 'git rev-parse',
40
- 'git show',
41
- 'git status',
42
- 'head',
43
- 'ls',
44
- 'pwd',
45
- 'rg',
46
- 'sort',
47
- 'tail',
48
- 'wc',
49
- 'which',
50
- 'find',
51
- ]);
52
- const PURE_READ_ONLY_KEYS = new Set([
53
- 'echo',
54
- 'git diff',
55
- 'git log',
56
- 'git rev-parse',
57
- 'git show',
58
- 'git status',
59
- 'pwd',
60
- 'which',
61
- ]);
62
- const LOCAL_MUTATION_KEYS = new Set([
63
- 'chmod',
64
- 'cp',
65
- 'git add',
66
- 'git clean',
67
- 'git commit',
68
- 'git mv',
69
- 'git reset',
70
- 'mkdir',
71
- 'mv',
72
- 'rm',
73
- 'sed',
74
- 'tee',
75
- 'touch',
76
- 'truncate',
77
- ]);
78
- /** Routine local build/test runners resolved from launcher recipes. */
79
- const LOCAL_ROUTINE_HEADS = new Set([
80
- 'tsc',
81
- 'vitest',
82
- 'vite',
83
- 'webpack',
84
- 'esbuild',
85
- 'rollup',
86
- 'jest',
87
- 'mocha',
88
- 'cargo',
89
- 'go',
90
- 'make',
91
- 'cmake',
92
- ]);
93
37
  const BELAY_SELF_COMMANDS = new Set(['approve', 'revoke']);
94
38
  const FIND_DANGEROUS_FLAGS = new Set(['-delete', '-exec', '-execdir', '-ok', '-okdir']);
95
39
  function isFindDangerous(tokens) {
@@ -126,7 +70,7 @@ async function evaluateSubstitutions(command, context, depth) {
126
70
  }
127
71
  worst = worst ? combineInternal(worst, innerVerdict) : innerVerdict;
128
72
  }
129
- if (!worst) {
73
+ if (!worst || worst.permission === 'allow') {
130
74
  return null;
131
75
  }
132
76
  return {
@@ -177,19 +121,39 @@ function combineInternal(left, right) {
177
121
  : right.reason,
178
122
  signals: [...new Set([...left.signals, ...right.signals])],
179
123
  judgeTrace: right.judgeTrace ?? left.judgeTrace,
124
+ capabilityRequests: [...(left.capabilityRequests ?? []), ...(right.capabilityRequests ?? [])],
125
+ authorizationDecision: worsePermission(left.permission, right.permission) === 'ask'
126
+ ? right.permission === 'ask'
127
+ ? (right.authorizationDecision ?? left.authorizationDecision)
128
+ : left.authorizationDecision
129
+ : (right.authorizationDecision ?? left.authorizationDecision),
180
130
  };
181
131
  }
182
132
  function askVerdict(params) {
183
133
  return { ...params, permission: 'ask' };
184
134
  }
185
- function allowVerdict(params) {
186
- return { ...params, permission: 'allow' };
135
+ function resolveShellPathTargets(pathArgs, context) {
136
+ if (!context.cwd) {
137
+ return [];
138
+ }
139
+ return pathArgs.map((target) => {
140
+ const resolved = (context.trustedCwd ? resolveTrustedPath(target, context.cwd, context.trustedCwd) : null) ??
141
+ resolveMutationTarget(target, context.cwd);
142
+ if (!resolved) {
143
+ const joined = path.isAbsolute(target) ? target : path.join(context.cwd, target);
144
+ return canonicalPath(joined);
145
+ }
146
+ return canonicalPath(resolved);
147
+ });
187
148
  }
188
- function withJudgeTrace(verdict, judgeTrace) {
189
- if (!judgeTrace) {
190
- return verdict;
149
+ function withShellPolicyMetadata(input, existing = {}) {
150
+ if (existing.capabilityRequests?.length) {
151
+ return existing;
191
152
  }
192
- return { ...verdict, judgeTrace };
153
+ return shellPolicyMetadata(input);
154
+ }
155
+ function allowVerdict(params) {
156
+ return { ...params, permission: 'allow' };
193
157
  }
194
158
  function extractPathArgs(tokens) {
195
159
  const redirects = extractRedirectTargets(tokens);
@@ -209,8 +173,15 @@ function extractPathArgs(tokens) {
209
173
  function isVariableOrOpaquePathToken(token) {
210
174
  return token.includes('$') || token.includes('`');
211
175
  }
212
- function isPureReadOnlySegment(segment) {
213
- return PURE_READ_ONLY_KEYS.has(segment.key) || PURE_READ_ONLY_KEYS.has(segment.head);
176
+ function pathContextFromSemantics(context, semantics) {
177
+ const cwd = semantics.gitWorkTree ?? semantics.effectiveCwd;
178
+ if (!cwd || cwd === context.cwd) {
179
+ return context;
180
+ }
181
+ return { ...context, cwd };
182
+ }
183
+ function isPureReadOnlySegment(segment, semantics) {
184
+ return isPureReadOnlySemantics(segment, semantics);
214
185
  }
215
186
  function updateChainState(command, state) {
216
187
  const segment = parseSegment(command);
@@ -233,10 +204,13 @@ function updateChainState(command, state) {
233
204
  trustedCwd: true,
234
205
  };
235
206
  }
236
- function tier0ExternalMatch(key, head, tokens) {
207
+ function tier0ExternalMatch(key, head, tokens, signals = []) {
237
208
  if (TIER0_EXTERNAL_KEYS.has(key)) {
238
209
  return true;
239
210
  }
211
+ if (signals.includes('git.push')) {
212
+ return true;
213
+ }
240
214
  if (TIER0_EXTERNAL_HEADS.has(head)) {
241
215
  return true;
242
216
  }
@@ -248,9 +222,6 @@ function tier0ExternalMatch(key, head, tokens) {
248
222
  tokens.some((t) => t === '--push' || t.startsWith('--output=type=registry')))) {
249
223
  return true;
250
224
  }
251
- if (head === 'git' && tokens[1] === 'push') {
252
- return true;
253
- }
254
225
  if (head === 'terraform' && tokens[1] === 'apply') {
255
226
  return true;
256
227
  }
@@ -355,6 +326,39 @@ function tier0HighStakesRm(tokens, context) {
355
326
  }
356
327
  return null;
357
328
  }
329
+ function verdictFromOpacityPolicy(params) {
330
+ const { request, decision } = evaluateSegmentShellPolicy({
331
+ command: params.command,
332
+ segmentHead: params.segmentHead,
333
+ effect: params.effect,
334
+ location: params.location,
335
+ opacity: params.opacity,
336
+ pathArgs: params.pathArgs,
337
+ signals: params.signals,
338
+ context: params.context,
339
+ });
340
+ const base = {
341
+ location: params.location,
342
+ opacity: params.opacity,
343
+ effect: params.effect,
344
+ confidence: 'deterministic',
345
+ signals: [...params.signals, ...decision.signals],
346
+ capabilityRequests: [request],
347
+ authorizationDecision: decision,
348
+ };
349
+ if (policyDecisionRequiresAsk(decision)) {
350
+ return {
351
+ permission: 'ask',
352
+ reason: policyDecisionToLegacyReason(decision),
353
+ ...base,
354
+ };
355
+ }
356
+ return {
357
+ permission: 'allow',
358
+ reason: policyDecisionToLegacyReason(decision),
359
+ ...base,
360
+ };
361
+ }
358
362
  async function evaluateSegment(command, context, depth) {
359
363
  const maxDepth = context.maxRecursionDepth ?? DEFAULT_MAX_DEPTH;
360
364
  if (depth > maxDepth) {
@@ -369,23 +373,15 @@ async function evaluateSegment(command, context, depth) {
369
373
  }
370
374
  const opacity = segmentOpacity(command);
371
375
  if (opacity === 'unparseable') {
372
- if (context.unparseableShell === 'deny') {
373
- return askVerdict({
374
- location: 'unknown',
375
- opacity: 'unparseable',
376
- effect: 'unknown',
377
- confidence: 'deterministic',
378
- reason: 'unparseable_shell',
379
- signals: ['unparseable_shell'],
380
- });
381
- }
382
- return allowVerdict({
383
- location: 'unknown',
376
+ return verdictFromOpacityPolicy({
377
+ command,
378
+ segmentHead: parseSegment(command).head,
384
379
  opacity: 'unparseable',
385
380
  effect: 'unknown',
386
- confidence: 'deterministic',
387
- reason: 'unparseable_shell',
381
+ location: 'unknown',
382
+ pathArgs: [],
388
383
  signals: ['unparseable_shell'],
384
+ context,
389
385
  });
390
386
  }
391
387
  const substitutionResult = await evaluateSubstitutions(command, context, depth);
@@ -395,13 +391,15 @@ async function evaluateSegment(command, context, depth) {
395
391
  const tokens = tokenizeShell(command);
396
392
  const { tokens: peeled, xargsStdinOpaque } = peelTransparentWrappers(tokens);
397
393
  if (xargsStdinOpaque || isBareInterpreter(tokens)) {
398
- return askVerdict({
399
- location: 'unknown',
394
+ return verdictFromOpacityPolicy({
395
+ command,
396
+ segmentHead: parseSegment(command).head,
400
397
  opacity: 'opaque',
401
398
  effect: 'unknown',
402
- confidence: 'deterministic',
403
- reason: 'opaque_execution',
399
+ location: 'unknown',
400
+ pathArgs: [],
404
401
  signals: ['opaque_execution'],
402
+ context,
405
403
  });
406
404
  }
407
405
  const segment = parseSegment(command);
@@ -430,13 +428,28 @@ async function evaluateSegment(command, context, depth) {
430
428
  if (recursiveScript) {
431
429
  const prescan = prescanInterpreterCode(recursiveScript);
432
430
  if (prescan && tier1RequiresAsk(prescan)) {
431
+ const { request, decision } = evaluateSegmentShellPolicy({
432
+ command: recursiveScript,
433
+ segmentHead: segment.head,
434
+ effect: 'unknown',
435
+ location: 'unknown',
436
+ opacity: 'recursive',
437
+ pathArgs: [],
438
+ signals: ['interpreter_secret_prescan', prescan.reason],
439
+ context,
440
+ });
441
+ const legacyReason = policyDecisionRequiresAsk(decision)
442
+ ? policyDecisionToLegacyReason(decision)
443
+ : 'interpreter_secret_prescan';
433
444
  return askVerdict({
434
445
  location: 'unknown',
435
446
  opacity: 'recursive',
436
447
  effect: 'unknown',
437
448
  confidence: 'deterministic',
438
- reason: 'interpreter_secret_prescan',
439
- signals: ['interpreter_secret_prescan'],
449
+ reason: legacyReason,
450
+ signals: ['interpreter_secret_prescan', prescan.reason, ...decision.signals],
451
+ capabilityRequests: [request],
452
+ authorizationDecision: decision,
440
453
  });
441
454
  }
442
455
  const innerVerdict = await evaluateSegment(recursiveScript, context, depth + 1);
@@ -481,8 +494,10 @@ async function evaluateSegment(command, context, depth) {
481
494
  }
482
495
  let innerVerdict = null;
483
496
  for (const recipe of resolution.recipes) {
484
- const evaluated = await evaluateSegment(recipe, context, depth + 1);
485
- innerVerdict = innerVerdict ? combineInternal(innerVerdict, evaluated) : evaluated;
497
+ for (const sub of splitTopLevelSegments(recipe)) {
498
+ const evaluated = await evaluateSegment(sub, context, depth + 1);
499
+ innerVerdict = innerVerdict ? combineInternal(innerVerdict, evaluated) : evaluated;
500
+ }
486
501
  }
487
502
  if (!innerVerdict) {
488
503
  return askVerdict({
@@ -500,38 +515,78 @@ async function evaluateSegment(command, context, depth) {
500
515
  signals: [...innerVerdict.signals, resolution.reason],
501
516
  };
502
517
  }
503
- const egressClass = classifyEgressTool(segment.head, peeled);
518
+ const semantics = analyzeShellCommandSemantics(peeled, segment, context.cwd);
519
+ const pathContext = pathContextFromSemantics(context, semantics);
520
+ if (semantics.requiresAsk) {
521
+ const { request, decision } = evaluateSegmentShellPolicy({
522
+ command,
523
+ segmentHead: segment.head,
524
+ effect: 'local_mutation',
525
+ location: 'repo_local',
526
+ opacity: 'transparent',
527
+ pathArgs: semantics.pathTargets,
528
+ signals: semantics.requiresAsk.signals,
529
+ context: pathContext,
530
+ });
531
+ return askVerdict({
532
+ location: 'repo_local',
533
+ opacity: 'transparent',
534
+ effect: 'local_mutation',
535
+ confidence: 'deterministic',
536
+ reason: semantics.requiresAsk.reason,
537
+ signals: semantics.requiresAsk.signals,
538
+ capabilityRequests: [request],
539
+ authorizationDecision: decision,
540
+ });
541
+ }
542
+ const egressClass = semantics.egressClass ?? classifyEgressTool(segment.head, peeled);
504
543
  if (egressClass === 'destructive') {
544
+ const { request, decision } = evaluateSegmentShellPolicy({
545
+ command,
546
+ segmentHead: segment.head,
547
+ effect: 'remote_mutation',
548
+ location: 'external',
549
+ opacity: 'transparent',
550
+ egressClass: 'destructive',
551
+ pathArgs: [],
552
+ signals: ['tier0_external', segment.head],
553
+ context,
554
+ });
505
555
  return askVerdict({
506
556
  location: 'external',
507
557
  opacity: 'transparent',
508
558
  effect: 'remote_mutation',
509
559
  confidence: 'deterministic',
510
560
  reason: 'tier0_external',
511
- signals: ['tier0_external', segment.head],
561
+ signals: ['tier0_external', segment.head, ...decision.signals],
562
+ capabilityRequests: [request],
563
+ authorizationDecision: decision,
512
564
  });
513
565
  }
514
- if (egressClass === 'read') {
515
- return allowVerdict({
566
+ if (tier0ExternalMatch(semantics.normalizedKey ?? segment.key, segment.head, peeled, semantics.signals)) {
567
+ const tier0Key = semantics.normalizedKey ?? segment.key;
568
+ const { request, decision } = evaluateSegmentShellPolicy({
569
+ command,
570
+ segmentHead: segment.head,
571
+ effect: 'remote_mutation',
516
572
  location: 'external',
517
573
  opacity: 'transparent',
518
- effect: 'read_only',
519
- confidence: 'deterministic',
520
- reason: 'egress_read',
521
- signals: ['egress_read', segment.head],
574
+ pathArgs: [],
575
+ signals: ['tier0_external', tier0Key, ...semantics.signals],
576
+ context: pathContext,
522
577
  });
523
- }
524
- if (tier0ExternalMatch(segment.key, segment.head, peeled)) {
525
578
  return askVerdict({
526
579
  location: 'external',
527
580
  opacity: 'transparent',
528
581
  effect: 'remote_mutation',
529
582
  confidence: 'deterministic',
530
583
  reason: 'tier0_external',
531
- signals: ['tier0_external', segment.key],
584
+ signals: ['tier0_external', tier0Key, ...decision.signals],
585
+ capabilityRequests: [request],
586
+ authorizationDecision: decision,
532
587
  });
533
588
  }
534
- const rmVerdict = tier0HighStakesRm(peeled, context);
589
+ const rmVerdict = tier0HighStakesRm(peeled, pathContext);
535
590
  if (rmVerdict) {
536
591
  return rmVerdict;
537
592
  }
@@ -545,29 +600,29 @@ async function evaluateSegment(command, context, depth) {
545
600
  signals: ['belay_control_plane_command', segment.head],
546
601
  });
547
602
  }
548
- let effect = 'unknown';
549
- if (READ_ONLY_KEYS.has(segment.key) || READ_ONLY_KEYS.has(segment.head)) {
603
+ let effect = semantics.effect;
604
+ if (isReadOnlyLauncherInvocation(peeled)) {
550
605
  effect = 'read_only';
551
606
  }
552
- else if (LOCAL_MUTATION_KEYS.has(segment.key) || LOCAL_MUTATION_KEYS.has(segment.head)) {
553
- effect = 'local_mutation';
554
- }
555
- else if (LOCAL_ROUTINE_HEADS.has(segment.head)) {
556
- effect = 'local_mutation';
557
- }
558
- const pathArgs = extractPathArgs(peeled);
559
- if (extractRedirectTargets(peeled).length > 0 && effect === 'read_only') {
560
- effect = 'local_mutation';
561
- }
607
+ const pathArgs = semantics.pathTargets;
608
+ const policyPathArgs = [...new Set([...pathArgs, ...(semantics.scopeTargets ?? [])])];
562
609
  const pathAnalysis = analyzePathTargets({
563
- targets: pathArgs,
564
- cwd: context.cwd,
565
- repoRoot: context.repoRoot,
566
- trustedCwd: context.trustedCwd,
567
- trustedWorkspaceRoots: context.trustedWorkspaceRoots,
568
- sensitivePaths: context.sensitivePaths,
569
- protectedArtifactRoots: context.protectedArtifactRoots,
610
+ targets: policyPathArgs,
611
+ cwd: pathContext.cwd,
612
+ repoRoot: pathContext.repoRoot,
613
+ trustedCwd: pathContext.trustedCwd,
614
+ trustedWorkspaceRoots: pathContext.trustedWorkspaceRoots,
615
+ sensitivePaths: pathContext.sensitivePaths,
616
+ protectedArtifactRoots: pathContext.protectedArtifactRoots,
570
617
  });
618
+ // Path-less read/mutation in a trusted cwd: assume repo-local (e.g. git status, tsc --noEmit).
619
+ const effectiveLocation = policyPathArgs.length === 0 &&
620
+ pathContext.trustedCwd &&
621
+ pathContext.cwd &&
622
+ (effect === 'read_only' || effect === 'local_mutation') &&
623
+ pathAnalysis.location === 'unknown'
624
+ ? 'repo_local'
625
+ : pathAnalysis.location;
571
626
  if (!context.trustedCwd || !context.cwd) {
572
627
  if (opacity === 'opaque' || effect === 'unknown' || effect === 'local_mutation') {
573
628
  return askVerdict({
@@ -579,7 +634,7 @@ async function evaluateSegment(command, context, depth) {
579
634
  signals: ['missing_trusted_cwd'],
580
635
  });
581
636
  }
582
- if (effect === 'read_only' && !isPureReadOnlySegment(segment)) {
637
+ if (effect === 'read_only' && !isPureReadOnlySegment(segment, semantics)) {
583
638
  return askVerdict({
584
639
  location: 'unknown',
585
640
  opacity,
@@ -594,9 +649,9 @@ async function evaluateSegment(command, context, depth) {
594
649
  context.protectedArtifactRoots &&
595
650
  context.protectedArtifactRoots.length > 0) {
596
651
  for (const target of pathArgs) {
597
- const resolved = resolveTrustedPath(target, context.cwd, context.trustedCwd) ??
598
- resolveMutationTarget(target, context.cwd);
599
- if (resolved && touchesProtectedRoot(resolved, context.protectedArtifactRoots)) {
652
+ const resolved = resolveTrustedPath(target, pathContext.cwd, pathContext.trustedCwd) ??
653
+ resolveMutationTarget(target, pathContext.cwd);
654
+ if (resolved && touchesProtectedRoot(resolved, pathContext.protectedArtifactRoots ?? [])) {
600
655
  return askVerdict({
601
656
  location: pathAnalysis.location,
602
657
  opacity: 'transparent',
@@ -611,10 +666,10 @@ async function evaluateSegment(command, context, depth) {
611
666
  if (effect === 'local_mutation' || effect === 'unknown') {
612
667
  let destructiveHighStakes = false;
613
668
  for (const target of pathArgs) {
614
- const resolved = resolveTrustedPath(target, context.cwd, context.trustedCwd) ??
615
- resolveMutationTarget(target, context.cwd);
669
+ const resolved = resolveTrustedPath(target, pathContext.cwd, pathContext.trustedCwd) ??
670
+ resolveMutationTarget(target, pathContext.cwd);
616
671
  if (resolved &&
617
- isDestructiveHighStakesMutation(segment.head, resolved, context.repoRoot, context.sensitivePaths, context.protectedArtifactRoots, context.trustedWorkspaceRoots)) {
672
+ isDestructiveHighStakesMutation(segment.head, resolved, pathContext.repoRoot, pathContext.sensitivePaths, pathContext.protectedArtifactRoots, pathContext.trustedWorkspaceRoots)) {
618
673
  destructiveHighStakes = true;
619
674
  break;
620
675
  }
@@ -663,18 +718,43 @@ async function evaluateSegment(command, context, depth) {
663
718
  signals: ['unknown_location_mutation'],
664
719
  });
665
720
  }
721
+ const resolvedPathTargets = resolveShellPathTargets(policyPathArgs, pathContext);
722
+ const semanticSignals = semantics.signals;
723
+ const buildPolicyInput = (overrides) => ({
724
+ command: recursiveScript ?? command,
725
+ segmentHead: segment.head,
726
+ opacity,
727
+ egressClass: egressClass ?? undefined,
728
+ pathArgs: policyPathArgs,
729
+ resolvedPathTargets,
730
+ signals: [...semanticSignals, ...pathAnalysis.signals],
731
+ context: pathContext,
732
+ ...overrides,
733
+ });
666
734
  const outsideMutation = pathAnalysis.location === 'repo_outside' || pathAnalysis.location === 'mixed';
667
735
  const mutationPrescan = (effect === 'local_mutation' || effect === 'unknown') && pathArgs.length > 0
668
736
  ? mutationPrescanRequiresAsk({
669
737
  targets: pathArgs,
670
- cwd: context.cwd,
671
- repoRoot: context.repoRoot,
672
- trustedCwd: context.trustedCwd,
673
- trustedWorkspaceRoots: context.trustedWorkspaceRoots,
674
- sensitivePaths: context.sensitivePaths,
738
+ cwd: pathContext.cwd,
739
+ repoRoot: pathContext.repoRoot,
740
+ trustedCwd: pathContext.trustedCwd,
741
+ trustedWorkspaceRoots: pathContext.trustedWorkspaceRoots,
742
+ sensitivePaths: pathContext.sensitivePaths,
675
743
  })
676
744
  : null;
677
745
  if (mutationPrescan) {
746
+ const { request, decision } = evaluateSegmentShellPolicy(buildPolicyInput({
747
+ effect: 'local_mutation',
748
+ location: pathAnalysis.location === 'unknown'
749
+ ? 'unknown'
750
+ : pathAnalysis.location === 'repo_outside' || pathAnalysis.location === 'mixed'
751
+ ? pathAnalysis.location
752
+ : 'repo_local',
753
+ signals: ['tier1_catastrophic', mutationPrescan.reason, ...pathAnalysis.signals],
754
+ }));
755
+ const legacyReason = policyDecisionRequiresAsk(decision)
756
+ ? policyDecisionToLegacyReason(decision, { outsideMutation, effect: 'local_mutation' })
757
+ : 'tier1_catastrophic';
678
758
  return askVerdict({
679
759
  location: pathAnalysis.location === 'unknown'
680
760
  ? 'unknown'
@@ -684,24 +764,28 @@ async function evaluateSegment(command, context, depth) {
684
764
  opacity,
685
765
  effect: 'local_mutation',
686
766
  confidence: 'deterministic',
687
- reason: 'tier1_catastrophic',
688
- signals: ['tier1_catastrophic', mutationPrescan.reason],
767
+ reason: legacyReason,
768
+ signals: ['tier1_catastrophic', mutationPrescan.reason, ...decision.signals],
769
+ capabilityRequests: [request],
770
+ authorizationDecision: decision,
689
771
  });
690
772
  }
691
- const needsTier1 = effect === 'unknown' ||
773
+ const needsPolicy = effect === 'unknown' ||
692
774
  TIER0_EXTERNAL_HEADS.has(segment.head) ||
693
775
  egressClass === 'ambiguous' ||
776
+ egressClass === 'read' ||
694
777
  (outsideMutation && effect !== 'read_only');
695
- let tier1Trace;
696
- if (needsTier1) {
697
- const tier1Text = recursiveScript ?? command;
698
- const tier1 = await context.judge.evaluate({
699
- text: tier1Text,
700
- context: { cwd: context.cwd, repoRoot: context.repoRoot },
701
- innerCode: recursiveScript ?? undefined,
702
- });
703
- tier1Trace = context.judge.lastTrace;
704
- if (tier1RequiresAsk(tier1)) {
778
+ let policyMetadata = {};
779
+ if (needsPolicy) {
780
+ const { request, decision } = evaluateSegmentShellPolicy(buildPolicyInput({
781
+ effect,
782
+ location: pathAnalysis.location,
783
+ }));
784
+ policyMetadata = {
785
+ capabilityRequests: [request],
786
+ authorizationDecision: decision,
787
+ };
788
+ if (policyDecisionRequiresAsk(decision)) {
705
789
  return askVerdict({
706
790
  location: pathAnalysis.location === 'unknown'
707
791
  ? 'unknown'
@@ -709,72 +793,104 @@ async function evaluateSegment(command, context, depth) {
709
793
  ? pathAnalysis.location
710
794
  : 'repo_local',
711
795
  opacity,
712
- effect: !tier1.local_recoverable ? 'remote_mutation' : effect,
713
- confidence: 'llm',
714
- reason: 'tier1_catastrophic',
715
- signals: ['tier1_catastrophic', tier1.reason],
716
- judgeTrace: tier1Trace,
796
+ effect: decision.reason === 'external_effect' || decision.reason === 'network_connect'
797
+ ? 'remote_mutation'
798
+ : effect,
799
+ confidence: 'deterministic',
800
+ reason: policyDecisionToLegacyReason(decision, { outsideMutation, effect }),
801
+ signals: ['policy_required', ...decision.signals],
802
+ ...policyMetadata,
717
803
  });
718
804
  }
719
805
  }
720
806
  if (outsideMutation && effect !== 'read_only') {
721
- return withJudgeTrace(allowVerdict({
807
+ const policyAllowsOutside = policyMetadata.authorizationDecision?.outcome === 'allow' &&
808
+ (policyMetadata.authorizationDecision.matchedRule === 'grant.exact' ||
809
+ policyMetadata.authorizationDecision.matchedRule === 'boundary.verified' ||
810
+ policyMetadata.authorizationDecision.matchedRule === 'builtin.trusted_workspace');
811
+ if (!policyAllowsOutside) {
812
+ const legacyReason = policyMetadata.authorizationDecision &&
813
+ policyDecisionRequiresAsk(policyMetadata.authorizationDecision)
814
+ ? policyDecisionToLegacyReason(policyMetadata.authorizationDecision, {
815
+ outsideMutation,
816
+ effect,
817
+ })
818
+ : 'outside_repo_mutation';
819
+ return askVerdict({
820
+ location: pathAnalysis.location,
821
+ opacity,
822
+ effect: 'local_mutation',
823
+ confidence: 'deterministic',
824
+ reason: legacyReason,
825
+ signals: ['outside_repo_mutation', ...pathAnalysis.signals],
826
+ ...policyMetadata,
827
+ });
828
+ }
829
+ return allowVerdict({
722
830
  location: pathAnalysis.location,
723
831
  opacity,
724
832
  effect: 'local_mutation',
725
- confidence: tier1Trace ? 'llm' : 'deterministic',
833
+ confidence: 'deterministic',
726
834
  reason: 'repo_outside_local_mutation',
727
835
  signals: ['repo_outside_local_mutation', ...pathAnalysis.signals],
728
- }), tier1Trace);
836
+ ...policyMetadata,
837
+ });
729
838
  }
730
- if (pathAnalysis.location === 'repo_local' &&
839
+ if (effectiveLocation === 'repo_local' &&
731
840
  (effect === 'read_only' || effect === 'local_mutation') &&
732
841
  opacity !== 'opaque') {
733
- return withJudgeTrace(allowVerdict({
842
+ return allowVerdict({
734
843
  location: 'repo_local',
735
844
  opacity,
736
845
  effect,
737
846
  confidence: 'assumed_repo_local',
738
847
  reason: effect === 'read_only' ? 'read_only' : 'repo_local_mutation',
739
848
  signals: effect === 'read_only' ? ['read_only'] : ['repo_local_mutation'],
740
- }), tier1Trace);
849
+ ...withShellPolicyMetadata(buildPolicyInput({ effect, location: 'repo_local' }), policyMetadata),
850
+ });
741
851
  }
742
852
  if (effect === 'read_only') {
743
- const readOnlyLocation = context.trustedCwd && context.cwd
744
- ? pathAnalysis.location === 'unknown'
853
+ const readOnlyLocation = pathContext.trustedCwd && pathContext.cwd
854
+ ? effectiveLocation === 'unknown'
745
855
  ? 'repo_local'
746
- : pathAnalysis.location
856
+ : effectiveLocation
747
857
  : 'unknown';
748
- return withJudgeTrace(allowVerdict({
858
+ return allowVerdict({
749
859
  location: readOnlyLocation,
750
860
  opacity,
751
861
  effect: 'read_only',
752
862
  confidence: context.trustedCwd && context.cwd ? 'assumed_repo_local' : 'deterministic',
753
863
  reason: 'read_only',
754
864
  signals: ['read_only'],
755
- }), tier1Trace);
865
+ ...withShellPolicyMetadata(buildPolicyInput({ effect: 'read_only', location: readOnlyLocation }), policyMetadata),
866
+ });
756
867
  }
757
868
  if (allowOverride) {
758
- return withJudgeTrace(allowFromCustomOverride(opacity), tier1Trace);
869
+ return allowFromCustomOverride(opacity);
759
870
  }
760
871
  if (context.unknownLocalEffect === 'allow_flagged') {
761
- return withJudgeTrace(allowVerdict({
762
- location: pathAnalysis.location === 'unknown' ? 'repo_local' : pathAnalysis.location,
872
+ return allowVerdict({
873
+ location: effectiveLocation === 'unknown' ? 'repo_local' : effectiveLocation,
763
874
  opacity,
764
875
  effect: 'unknown',
765
876
  confidence: 'assumed_repo_local',
766
877
  reason: 'unknown_local_effect',
767
878
  signals: ['unknown_local_effect'],
768
- }), tier1Trace);
879
+ ...withShellPolicyMetadata(buildPolicyInput({
880
+ effect: 'unknown',
881
+ location: effectiveLocation === 'unknown' ? 'repo_local' : effectiveLocation,
882
+ }), policyMetadata),
883
+ });
769
884
  }
770
- return withJudgeTrace(askVerdict({
771
- location: pathAnalysis.location,
885
+ return askVerdict({
886
+ location: effectiveLocation,
772
887
  opacity,
773
888
  effect,
774
889
  confidence: 'deterministic',
775
890
  reason: 'unknown_local_effect',
776
891
  signals: ['unknown_local_effect'],
777
- }), tier1Trace);
892
+ ...withShellPolicyMetadata(buildPolicyInput({ effect, location: pathAnalysis.location }), policyMetadata),
893
+ });
778
894
  }
779
895
  function toVerdictResult(internal, command, context, fingerprintCwd = context.cwd) {
780
896
  const commandRedacted = redactCommand(command);
@@ -790,6 +906,8 @@ function toVerdictResult(internal, command, context, fingerprintCwd = context.cw
790
906
  fingerprint: verdictFingerprint(relative, commandRedacted),
791
907
  signals: internal.signals,
792
908
  judgeTrace: internal.judgeTrace,
909
+ capabilityRequests: internal.capabilityRequests,
910
+ authorizationDecision: internal.authorizationDecision,
793
911
  };
794
912
  }
795
913
  export async function verdict(command, context) {