@guilz-dev/belay 0.7.0 → 0.8.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 (75) hide show
  1. package/README.md +19 -19
  2. package/dist/adapters/shared/gate-runtime.js +23 -5
  3. package/dist/bundle/claude-runtime.mjs +4909 -3504
  4. package/dist/bundle/codex-runtime.mjs +5050 -3645
  5. package/dist/bundle/cursor-runtime.mjs +5050 -3645
  6. package/dist/commands/doctor.js +7 -0
  7. package/dist/commands/dogfood.js +1 -1
  8. package/dist/commands/explain.js +2 -2
  9. package/dist/commands/harvest.d.ts +0 -1
  10. package/dist/commands/harvest.js +1 -4
  11. package/dist/commands/metrics.js +6 -0
  12. package/dist/commands/quality.js +1 -3
  13. package/dist/conformance/guarantee-table.js +133 -11
  14. package/dist/conformance/types.d.ts +1 -0
  15. package/dist/core/audit-metrics.d.ts +1 -0
  16. package/dist/core/audit-metrics.js +12 -4
  17. package/dist/core/audit-types.d.ts +3 -0
  18. package/dist/core/capability/index.d.ts +1 -1
  19. package/dist/core/capability/policy-engine.d.ts +31 -1
  20. package/dist/core/capability/policy-engine.js +186 -0
  21. package/dist/core/capability/request.d.ts +9 -0
  22. package/dist/core/capability/resolver.d.ts +0 -1
  23. package/dist/core/capability/resolver.js +0 -1
  24. package/dist/core/config.js +0 -2
  25. package/dist/core/effect-ir/audit.js +1 -0
  26. package/dist/core/effect-ir/build.js +5 -1
  27. package/dist/core/effect-ir/index.d.ts +6 -1
  28. package/dist/core/effect-ir/index.js +2 -0
  29. package/dist/core/effect-ir/normalize.js +9 -3
  30. package/dist/core/effect-ir/policy.d.ts +2 -1
  31. package/dist/core/effect-ir/policy.js +93 -6
  32. package/dist/core/effect-ir/shell-build.d.ts +43 -0
  33. package/dist/core/effect-ir/shell-build.js +77 -0
  34. package/dist/core/effect-ir/shell-lower.d.ts +14 -0
  35. package/dist/core/effect-ir/shell-lower.js +1509 -0
  36. package/dist/core/effect-ir/types.d.ts +9 -2
  37. package/dist/core/gate-engine.js +63 -23
  38. package/dist/core/git-resource-identity.d.ts +26 -0
  39. package/dist/core/git-resource-identity.js +284 -0
  40. package/dist/core/harvest.d.ts +3 -7
  41. package/dist/core/harvest.js +4 -27
  42. package/dist/core/index.d.ts +2 -0
  43. package/dist/core/index.js +1 -0
  44. package/dist/core/path-utils.js +42 -23
  45. package/dist/core/shell-substitution.d.ts +1 -0
  46. package/dist/core/shell-substitution.js +61 -0
  47. package/dist/core/standing-allow.d.ts +1 -2
  48. package/dist/core/standing-allow.js +3 -20
  49. package/dist/core/types.d.ts +4 -1
  50. package/dist/core/verdict/adapter.js +15 -15
  51. package/dist/core/verdict/containment.js +4 -0
  52. package/dist/core/verdict/egress-classify.d.ts +12 -0
  53. package/dist/core/verdict/egress-classify.js +746 -0
  54. package/dist/core/verdict/git-classifier.d.ts +12 -0
  55. package/dist/core/verdict/git-classifier.js +320 -0
  56. package/dist/core/verdict/launcher-resolve.js +77 -17
  57. package/dist/core/verdict/parser.d.ts +2 -0
  58. package/dist/core/verdict/parser.js +95 -1
  59. package/dist/core/verdict/types.d.ts +2 -3
  60. package/dist/core/verdict/verdict.js +103 -952
  61. package/dist/corpus/must-allow-commands.d.ts +2 -1
  62. package/dist/corpus/must-allow-commands.js +2 -1
  63. package/dist/corpus/runtime-match.d.ts +2 -1
  64. package/dist/corpus/runtime-match.js +2 -1
  65. package/dist/corpus/types.d.ts +2 -2
  66. package/dist/templates.js +7 -2
  67. package/dist/version.d.ts +1 -1
  68. package/dist/version.js +1 -1
  69. package/package.json +2 -2
  70. package/dist/core/verdict/overrides.d.ts +0 -7
  71. package/dist/core/verdict/overrides.js +0 -37
  72. package/dist/core/verdict/shell-policy.d.ts +0 -26
  73. package/dist/core/verdict/shell-policy.js +0 -40
  74. package/dist/corpus/standing-allow-catalog.generated.d.ts +0 -13
  75. package/dist/corpus/standing-allow-catalog.generated.js +0 -99
@@ -1,967 +1,118 @@
1
- import path from 'node:path';
2
- import { policyDecisionToLegacyReason } from '../capability/policy-bridge.js';
3
- import { combinePolicyDecisions, policyDecisionRequiresAsk } from '../capability/policy-engine.js';
4
- import { mergeEffectPlans } from '../effect-ir/normalize.js';
5
- import { isPackageExecLauncher } from '../effect-ir/package-exec.js';
6
- import { evaluatePackageExecSegment } from '../effect-ir/package-exec-eval.js';
7
- import { canonicalPath, resolveMutationTarget } from '../path-utils.js';
8
- import { extractRedirectTargets, isFdDuplication, isRedirectOperator, tokenizeShell, } from '../shell-tokenizer.js';
9
- import { analyzePathTargets, cwdRelative, isDestructiveHighStakesMutation, resolveTrustedPath, touchesProtectedRoot, } from './containment.js';
10
- import { classifyEgressTool } from './egress-classify.js';
1
+ import { collectRequirements } from '../effect-ir/build.js';
2
+ import { evaluateEffectPlanPolicy } from '../effect-ir/policy.js';
3
+ import { lowerShellEffectPlan } from '../effect-ir/shell-lower.js';
4
+ import { canonicalPath, pathWithinRoot } from '../path-utils.js';
5
+ import { cwdRelative } from './containment.js';
11
6
  import { verdictFingerprint } from './fingerprint.js';
12
- import { isReadOnlyLauncherInvocation, isRoutineLauncher, resolveLauncherRecipe, } from './launcher-resolve.js';
13
- import { allowFromCustomOverride, askFromCustomExternal, customAllowMatch, customExternalMatch, } from './overrides.js';
14
- import { extractRecursiveScript, isBareInterpreter, isVariableIndirectHead, parseSegment, peelTransparentWrappers, redactCommand, segmentOpacity, splitTopLevelSegments, substitutionInners, } from './parser.js';
15
- import { mutationPrescanRequiresAsk, prescanInterpreterCode, tier1RequiresAsk } from './prescan.js';
16
- import { evaluateSegmentShellPolicy, shellPolicyMetadata, } from './shell-policy.js';
17
- import { analyzeShellCommandSemantics, isPureReadOnlySemantics, LOCAL_MUTATION_KEYS, } from './shell-semantics.js';
18
- const DEFAULT_MAX_DEPTH = 8;
19
- const TIER0_EXTERNAL_KEYS = new Set([
20
- 'git push',
21
- 'docker push',
22
- 'docker run',
23
- 'npm publish',
24
- 'pnpm publish',
25
- 'terraform apply',
26
- 'firebase',
27
- 'fly',
28
- 'supabase',
29
- 'scp',
30
- 'ssh',
31
- ]);
32
- const TIER0_EXTERNAL_HEADS = new Set([
33
- 'dropdb',
34
- 'createdb',
35
- 'psql',
36
- 'mysql',
37
- 'mongosh',
38
- 'redis-cli',
39
- ]);
40
- const BELAY_SELF_COMMANDS = new Set(['approve', 'revoke']);
41
- const FIND_DANGEROUS_FLAGS = new Set(['-delete', '-exec', '-execdir', '-ok', '-okdir']);
42
- function isFindDangerous(tokens) {
43
- return tokens.some((token) => FIND_DANGEROUS_FLAGS.has(token) || token.startsWith('-exec') || token.startsWith('-ok'));
44
- }
45
- function worsePermission(left, right) {
46
- return left === 'ask' || right === 'ask' ? 'ask' : 'allow';
47
- }
48
- async function evaluateSubstitutions(command, context, depth) {
49
- const inners = substitutionInners(command);
50
- if (inners.length === 0) {
51
- return null;
52
- }
53
- if (context.unknownLocalEffect === 'deny') {
54
- return askVerdict({
55
- location: 'unknown',
56
- opacity: 'recursive',
57
- effect: 'unknown',
58
- confidence: 'deterministic',
59
- reason: 'command_substitution',
60
- signals: ['command_substitution'],
61
- });
62
- }
63
- let worst = null;
64
- for (const inner of inners) {
65
- const innerVerdict = await evaluateSegment(inner, context, depth + 1);
66
- if (innerVerdict.permission === 'ask') {
67
- return askVerdict({
68
- ...innerVerdict,
69
- opacity: 'recursive',
70
- reason: 'command_substitution',
71
- signals: [...innerVerdict.signals, 'command_substitution'],
72
- });
73
- }
74
- worst = worst ? combineInternal(worst, innerVerdict) : innerVerdict;
75
- }
76
- if (!worst || worst.permission === 'allow') {
77
- return null;
78
- }
79
- return {
80
- ...worst,
81
- permission: 'allow',
82
- opacity: 'recursive',
83
- reason: 'command_substitution',
84
- signals: [...worst.signals, 'command_substitution'],
85
- };
86
- }
87
- function mergeLocation(left, right) {
88
- if (left === right) {
89
- return left;
90
- }
91
- if (left === 'unknown' || right === 'unknown') {
92
- return 'unknown';
93
- }
94
- if (left === 'mixed' || right === 'mixed') {
95
- return 'mixed';
96
- }
97
- return 'mixed';
98
- }
99
- function combineInternal(left, right) {
100
- const policyDecisions = [
101
- ...(left.effectPlanPolicyDecisions ??
102
- (left.authorizationDecision ? [left.authorizationDecision] : [])),
103
- ...(right.effectPlanPolicyDecisions ??
104
- (right.authorizationDecision ? [right.authorizationDecision] : [])),
105
- ];
106
- return {
107
- permission: worsePermission(left.permission, right.permission),
108
- location: mergeLocation(left.location, right.location),
109
- opacity: left.opacity === 'unparseable' || right.opacity === 'unparseable'
110
- ? 'unparseable'
111
- : left.opacity === 'opaque' || right.opacity === 'opaque'
112
- ? 'opaque'
113
- : left.opacity === 'recursive' || right.opacity === 'recursive'
114
- ? 'recursive'
115
- : 'transparent',
116
- effect: left.effect === 'remote_mutation' || right.effect === 'remote_mutation'
117
- ? 'remote_mutation'
118
- : left.effect === 'unknown' || right.effect === 'unknown'
119
- ? 'unknown'
120
- : left.effect === 'local_mutation' || right.effect === 'local_mutation'
121
- ? 'local_mutation'
122
- : 'read_only',
123
- confidence: left.confidence === 'deterministic' || right.confidence === 'deterministic'
124
- ? 'deterministic'
125
- : left.confidence,
126
- reason: worsePermission(left.permission, right.permission) === 'ask'
127
- ? right.permission === 'ask'
128
- ? right.reason
129
- : left.reason
130
- : right.reason,
131
- signals: [...new Set([...left.signals, ...right.signals])],
132
- judgeTrace: right.judgeTrace ?? left.judgeTrace,
133
- capabilityRequests: [...(left.capabilityRequests ?? []), ...(right.capabilityRequests ?? [])],
134
- authorizationDecision: policyDecisions.length > 0 ? combinePolicyDecisions(policyDecisions) : undefined,
135
- effectPlan: mergeEffectPlans(left.effectPlan, right.effectPlan),
136
- effectPlanPolicyDecisions: policyDecisions,
137
- };
138
- }
139
- function askVerdict(params) {
140
- return { ...params, permission: 'ask' };
141
- }
142
- function resolveShellPathTargets(pathArgs, context) {
143
- if (!context.cwd) {
144
- return [];
145
- }
146
- return pathArgs.map((target) => {
147
- const resolved = (context.trustedCwd ? resolveTrustedPath(target, context.cwd, context.trustedCwd) : null) ??
148
- resolveMutationTarget(target, context.cwd);
149
- if (!resolved) {
150
- const joined = path.isAbsolute(target) ? target : path.join(context.cwd, target);
151
- return canonicalPath(joined);
152
- }
153
- return canonicalPath(resolved);
154
- });
155
- }
156
- function withShellPolicyMetadata(input, existing = {}) {
157
- if (existing.capabilityRequests?.length) {
158
- return existing;
159
- }
160
- return shellPolicyMetadata(input);
161
- }
162
- function allowVerdict(params) {
163
- return { ...params, permission: 'allow' };
164
- }
165
- function extractPathArgs(tokens) {
166
- const redirects = extractRedirectTargets(tokens);
167
- const args = [...redirects];
168
- for (let index = 1; index < tokens.length; index += 1) {
169
- const token = tokens[index];
170
- if (!token || token.startsWith('-') || isRedirectOperator(token) || isFdDuplication(token)) {
171
- continue;
172
- }
173
- if (redirects.includes(token)) {
174
- continue;
175
- }
176
- args.push(token);
177
- }
178
- return args;
179
- }
180
- function isVariableOrOpaquePathToken(token) {
181
- return token.includes('$') || token.includes('`');
182
- }
183
- function pathContextFromSemantics(context, semantics) {
184
- const cwd = semantics.gitWorkTree ?? semantics.effectiveCwd;
185
- if (!cwd || cwd === context.cwd) {
186
- return context;
187
- }
188
- return { ...context, cwd };
189
- }
190
- function isPureReadOnlySegment(segment, semantics) {
191
- return isPureReadOnlySemantics(segment, semantics);
192
- }
193
- function updateChainState(command, state) {
194
- const segment = parseSegment(command);
195
- if (segment.head !== 'cd') {
196
- return state;
197
- }
198
- if (!state.trustedCwd) {
199
- return state;
200
- }
201
- const target = segment.tokens[1] ?? '~';
202
- if (!target || target === '-' || isVariableOrOpaquePathToken(target)) {
203
- return { ...state, trustedCwd: false };
204
- }
205
- const resolved = resolveTrustedPath(target, state.cwd, state.trustedCwd);
206
- if (!resolved) {
207
- return { ...state, trustedCwd: false };
208
- }
209
- return {
210
- cwd: resolved,
211
- trustedCwd: true,
212
- };
213
- }
214
- function tier0ExternalMatch(key, head, tokens, signals = []) {
215
- if (TIER0_EXTERNAL_KEYS.has(key)) {
216
- return true;
217
- }
218
- if (signals.includes('git.push')) {
219
- return true;
220
- }
221
- if (TIER0_EXTERNAL_HEADS.has(head)) {
222
- return true;
223
- }
224
- if (head === 'npm' && tokens[1] === 'publish') {
225
- return true;
226
- }
227
- if (head === 'docker' &&
228
- (tokens[1] === 'push' ||
229
- tokens.some((t) => t === '--push' || t.startsWith('--output=type=registry')))) {
230
- return true;
231
- }
232
- if (head === 'terraform' && tokens[1] === 'apply') {
233
- return true;
234
- }
235
- return false;
236
- }
237
- function isBelayJudgeSelfCommand(tokens) {
238
- return (tokens[0] === 'belay' &&
239
- tokens[1] === 'judge' &&
240
- (tokens[2] === 'use' ||
241
- tokens[2] === 'status' ||
242
- tokens[2] === 'list' ||
243
- tokens[2] === 'test' ||
244
- tokens[2] === 'consent'));
245
- }
246
- function isBelayConfigSelfCommand(tokens) {
247
- if (tokens[0] !== 'belay' || tokens[1] !== 'config') {
248
- return false;
249
- }
250
- const sub = tokens[2];
251
- if (!sub) {
252
- return true;
253
- }
254
- if (sub === 'list' || sub === 'get' || sub === 'judge') {
255
- return true;
256
- }
257
- if (sub === 'credential') {
258
- return true;
259
- }
260
- if (sub === 'set' || sub === 'unset') {
261
- const pathKey = tokens[3] ?? '';
262
- return pathKey.startsWith('judge.');
263
- }
264
- return false;
265
- }
266
- function isBelaySelfCommand(tokens) {
267
- const head = tokens[0] ?? '';
268
- const subcommand = tokens[1] ?? '';
269
- return (head === 'belay' &&
270
- (BELAY_SELF_COMMANDS.has(subcommand) ||
271
- isBelayJudgeSelfCommand(tokens) ||
272
- isBelayConfigSelfCommand(tokens)));
273
- }
274
- function tier0HighStakesRm(tokens, context) {
275
- const head = (tokens[0] ?? '').split('/').pop() ?? '';
276
- if (head !== 'rm') {
277
- return null;
278
- }
279
- const targets = extractPathArgs(tokens);
280
- const analysis = analyzePathTargets({
281
- targets,
7
+ import { redactCommand } from './parser.js';
8
+ export async function verdict(command, context) {
9
+ const trimmed = command.trim();
10
+ const commandRedacted = redactCommand(trimmed);
11
+ const fingerprint = verdictFingerprint(cwdRelative(context.repoRoot, context.cwd), commandRedacted);
12
+ const effectPlan = lowerShellEffectPlan({
13
+ command: trimmed,
282
14
  cwd: context.cwd,
283
15
  repoRoot: context.repoRoot,
284
- trustedCwd: context.trustedCwd,
285
- trustedWorkspaceRoots: context.trustedWorkspaceRoots,
286
- sensitivePaths: context.sensitivePaths,
287
- protectedArtifactRoots: context.protectedArtifactRoots,
288
- });
289
- if (!context.trustedCwd || !context.cwd) {
290
- return askVerdict({
291
- location: 'unknown',
292
- opacity: 'transparent',
293
- effect: 'unknown',
294
- confidence: 'deterministic',
295
- reason: 'missing_trusted_cwd',
296
- signals: ['missing_trusted_cwd', ...analysis.signals],
297
- });
298
- }
299
- for (const target of targets) {
300
- const normalized = target.replace(/^['"]|['"]$/g, '');
301
- if (normalized === '~' || normalized === '$HOME' || normalized.startsWith('~/')) {
302
- return askVerdict({
303
- location: 'repo_outside',
304
- opacity: 'transparent',
305
- effect: 'local_mutation',
306
- confidence: 'deterministic',
307
- reason: 'high_stakes_path',
308
- signals: ['catastrophic_home_deletion', ...analysis.signals],
309
- });
310
- }
311
- }
312
- if (analysis.isHighStakes && analysis.signals.includes('high_stakes_path')) {
313
- let destructiveHighStakes = false;
314
- for (const target of targets) {
315
- const resolved = resolveTrustedPath(target, context.cwd, context.trustedCwd) ??
316
- resolveMutationTarget(target, context.cwd);
317
- if (resolved &&
318
- isDestructiveHighStakesMutation(head, resolved, context.repoRoot, context.sensitivePaths, context.protectedArtifactRoots, context.trustedWorkspaceRoots)) {
319
- destructiveHighStakes = true;
320
- break;
321
- }
322
- }
323
- if (destructiveHighStakes) {
324
- return askVerdict({
325
- location: analysis.location,
326
- opacity: 'transparent',
327
- effect: 'local_mutation',
328
- confidence: 'deterministic',
329
- reason: 'high_stakes_path',
330
- signals: ['high_stakes_path', ...analysis.signals],
331
- });
332
- }
333
- }
334
- return null;
335
- }
336
- function verdictFromOpacityPolicy(params) {
337
- const { requests, decision } = evaluateSegmentShellPolicy({
338
- command: params.command,
339
- segmentHead: params.segmentHead,
340
- effect: params.effect,
341
- location: params.location,
342
- opacity: params.opacity,
343
- pathArgs: params.pathArgs,
344
- signals: params.signals,
345
- context: params.context,
16
+ inputFingerprint: fingerprint,
346
17
  });
347
- const base = {
348
- location: params.location,
349
- opacity: params.opacity,
350
- effect: params.effect,
351
- confidence: 'deterministic',
352
- signals: [...params.signals, ...decision.signals],
353
- capabilityRequests: requests,
354
- authorizationDecision: decision,
355
- };
356
- if (policyDecisionRequiresAsk(decision)) {
357
- return {
358
- permission: 'ask',
359
- reason: policyDecisionToLegacyReason(decision),
360
- ...base,
361
- };
362
- }
18
+ const policy = evaluateEffectPlanPolicy(effectPlan, context);
19
+ const requirements = collectRequirements(effectPlan.root);
20
+ const presentation = effectPresentation(requirements, context);
21
+ const policySignals = [
22
+ ...new Set([...effectPlan.signals, ...policy.authorizationDecision.signals]),
23
+ ];
24
+ const reason = policySignals.includes('tier0_external') && policy.projection.permission === 'ask'
25
+ ? 'tier0_external'
26
+ : policySignals.includes('rsync_destructive') && policy.projection.permission === 'ask'
27
+ ? 'rsync_destructive'
28
+ : policy.authorizationDecision.matchedRule === 'grant.exact' &&
29
+ (presentation.location === 'repo_outside' || presentation.location === 'mixed') &&
30
+ presentation.effect === 'local_mutation'
31
+ ? 'repo_outside_local_mutation'
32
+ : policySignals.includes('shell.cwd_unknown') && policy.projection.permission === 'ask'
33
+ ? 'missing_trusted_cwd'
34
+ : policy.projection.reason;
35
+ const location = policySignals.includes('shell.cwd_unknown') ? 'unknown' : presentation.location;
363
36
  return {
364
- permission: 'allow',
365
- reason: policyDecisionToLegacyReason(decision),
366
- ...base,
37
+ permission: policy.projection.permission,
38
+ location,
39
+ opacity: effectPlan.opacity,
40
+ effect: presentation.effect,
41
+ confidence: 'deterministic',
42
+ reason: trimmed ? reason : 'empty_command',
43
+ commandRedacted,
44
+ fingerprint,
45
+ signals: [...new Set([...(trimmed ? [] : ['empty_command']), ...policySignals])],
46
+ capabilityRequests: policy.capabilityRequests,
47
+ authorizationDecision: policy.authorizationDecision,
48
+ effectPlan,
49
+ effectPlanPolicyDecisions: policy.decisions,
50
+ effectPlanProjection: policy.projection,
367
51
  };
368
52
  }
369
- async function evaluateSegment(command, context, depth) {
370
- const maxDepth = context.maxRecursionDepth ?? DEFAULT_MAX_DEPTH;
371
- if (depth > maxDepth) {
372
- return askVerdict({
373
- location: 'unknown',
374
- opacity: 'opaque',
375
- effect: 'unknown',
376
- confidence: 'deterministic',
377
- reason: 'recursion_depth_exceeded',
378
- signals: ['recursion_depth_exceeded'],
379
- });
380
- }
381
- const opacity = segmentOpacity(command);
382
- if (opacity === 'unparseable') {
383
- return verdictFromOpacityPolicy({
384
- command,
385
- segmentHead: parseSegment(command).head,
386
- opacity: 'unparseable',
387
- effect: 'unknown',
388
- location: 'unknown',
389
- pathArgs: [],
390
- signals: ['unparseable_shell'],
391
- context,
392
- });
393
- }
394
- const substitutionResult = await evaluateSubstitutions(command, context, depth);
395
- if (substitutionResult) {
396
- return substitutionResult;
397
- }
398
- const tokens = tokenizeShell(command);
399
- const { tokens: peeled, xargsStdinOpaque } = peelTransparentWrappers(tokens);
400
- if (xargsStdinOpaque || isBareInterpreter(tokens)) {
401
- return verdictFromOpacityPolicy({
402
- command,
403
- segmentHead: parseSegment(command).head,
404
- opacity: 'opaque',
405
- effect: 'unknown',
406
- location: 'unknown',
407
- pathArgs: [],
408
- signals: ['opaque_execution'],
409
- context,
410
- });
411
- }
412
- const segment = parseSegment(command);
413
- const allowOverride = customAllowMatch(command, segment, context);
414
- const externalOverride = customExternalMatch(command, segment, context);
415
- if (allowOverride && externalOverride) {
416
- return allowFromCustomOverride(opacity);
417
- }
418
- if (externalOverride) {
419
- return askFromCustomExternal(opacity);
420
- }
421
- if (allowOverride && isRoutineLauncher(peeled)) {
422
- return allowFromCustomOverride(opacity);
423
- }
424
- if (isVariableIndirectHead(segment.head)) {
425
- return askVerdict({
426
- location: 'unknown',
427
- opacity: 'opaque',
428
- effect: 'unknown',
429
- confidence: 'deterministic',
430
- reason: 'variable_indirect',
431
- signals: ['variable_indirect'],
432
- });
433
- }
434
- const recursiveScript = extractRecursiveScript(peeled);
435
- if (recursiveScript) {
436
- const prescan = prescanInterpreterCode(recursiveScript);
437
- if (prescan && tier1RequiresAsk(prescan)) {
438
- const { requests, decision } = evaluateSegmentShellPolicy({
439
- command: recursiveScript,
440
- segmentHead: segment.head,
441
- effect: 'unknown',
442
- location: 'unknown',
443
- opacity: 'recursive',
444
- pathArgs: [],
445
- signals: ['interpreter_secret_prescan', prescan.reason],
446
- context,
447
- });
448
- const legacyReason = policyDecisionRequiresAsk(decision)
449
- ? policyDecisionToLegacyReason(decision)
450
- : 'interpreter_secret_prescan';
451
- return askVerdict({
452
- location: 'unknown',
453
- opacity: 'recursive',
454
- effect: 'unknown',
455
- confidence: 'deterministic',
456
- reason: legacyReason,
457
- signals: ['interpreter_secret_prescan', prescan.reason, ...decision.signals],
458
- capabilityRequests: requests,
459
- authorizationDecision: decision,
460
- });
461
- }
462
- const innerVerdict = await evaluateSegment(recursiveScript, context, depth + 1);
463
- const wrapReason = segment.head === 'eval'
464
- ? 'dynamic_shell_evaluation'
465
- : ['bash', 'sh', 'zsh', 'dash', 'fish'].includes(segment.head)
466
- ? 'shell_interpreter_script'
467
- : innerVerdict.reason;
468
- return {
469
- ...innerVerdict,
470
- opacity: 'recursive',
471
- reason: wrapReason,
472
- signals: [...innerVerdict.signals, 'recursive_wrapper'],
473
- };
474
- }
475
- if (isPackageExecLauncher(peeled)) {
476
- return evaluatePackageExecSegment({
477
- command,
478
- peeled,
479
- context,
480
- evaluateInner: (innerRecipe) => evaluateSegment(innerRecipe, context, depth + 1),
481
- });
482
- }
483
- if (isRoutineLauncher(peeled)) {
484
- const resolution = resolveLauncherRecipe({
485
- tokens: peeled,
486
- cwd: context.cwd,
487
- repoRoot: context.repoRoot,
488
- depth,
489
- });
490
- if (!resolution) {
491
- return askVerdict({
492
- location: 'unknown',
493
- opacity: 'opaque',
494
- effect: 'unknown',
495
- confidence: 'deterministic',
496
- reason: 'launcher_unresolved',
497
- signals: ['launcher_unresolved'],
498
- });
499
- }
500
- if (resolution.opaque || resolution.recipes.length === 0) {
501
- return askVerdict({
502
- location: 'unknown',
503
- opacity: 'opaque',
504
- effect: 'unknown',
505
- confidence: 'deterministic',
506
- reason: resolution.reason,
507
- signals: [resolution.reason],
508
- });
53
+ const EFFECT_LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '0:0:0:0:0:0:0:1']);
54
+ function effectPresentation(requirements, context) {
55
+ let local = false;
56
+ let outside = false;
57
+ let remote = false;
58
+ let mutation = false;
59
+ let unknown = false;
60
+ const repoRoot = canonicalPath(context.repoRoot);
61
+ for (const requirement of requirements) {
62
+ if (requirement.action === 'indeterminate') {
63
+ unknown = true;
64
+ continue;
509
65
  }
510
- let innerVerdict = null;
511
- for (const recipe of resolution.recipes) {
512
- for (const sub of splitTopLevelSegments(recipe)) {
513
- const evaluated = await evaluateSegment(sub, context, depth + 1);
514
- innerVerdict = innerVerdict ? combineInternal(innerVerdict, evaluated) : evaluated;
66
+ if (requirement.resource.kind === 'path') {
67
+ const resolved = canonicalPath(requirement.resource.path);
68
+ if (pathWithinRoot(repoRoot, resolved)) {
69
+ local = true;
515
70
  }
516
- }
517
- if (!innerVerdict) {
518
- return askVerdict({
519
- location: 'unknown',
520
- opacity: 'opaque',
521
- effect: 'unknown',
522
- confidence: 'deterministic',
523
- reason: resolution.reason,
524
- signals: [resolution.reason],
525
- });
526
- }
527
- return {
528
- ...innerVerdict,
529
- opacity: 'recursive',
530
- signals: [...innerVerdict.signals, resolution.reason],
531
- };
532
- }
533
- const semantics = analyzeShellCommandSemantics(peeled, segment, context.cwd);
534
- const pathContext = pathContextFromSemantics(context, semantics);
535
- if (semantics.requiresAsk) {
536
- const { requests, decision } = evaluateSegmentShellPolicy({
537
- command,
538
- segmentHead: segment.head,
539
- effect: 'local_mutation',
540
- location: 'repo_local',
541
- opacity: 'transparent',
542
- pathArgs: semantics.pathTargets,
543
- signals: semantics.requiresAsk.signals,
544
- context: pathContext,
545
- });
546
- return askVerdict({
547
- location: 'repo_local',
548
- opacity: 'transparent',
549
- effect: 'local_mutation',
550
- confidence: 'deterministic',
551
- reason: semantics.requiresAsk.reason,
552
- signals: semantics.requiresAsk.signals,
553
- capabilityRequests: requests,
554
- authorizationDecision: decision,
555
- });
556
- }
557
- const egressClass = semantics.egressClass ?? classifyEgressTool(segment.head, peeled);
558
- if (egressClass === 'destructive') {
559
- const { requests, decision } = evaluateSegmentShellPolicy({
560
- command,
561
- segmentHead: segment.head,
562
- effect: 'remote_mutation',
563
- location: 'external',
564
- opacity: 'transparent',
565
- egressClass: 'destructive',
566
- pathArgs: [],
567
- signals: ['tier0_external', segment.head],
568
- context,
569
- });
570
- return askVerdict({
571
- location: 'external',
572
- opacity: 'transparent',
573
- effect: 'remote_mutation',
574
- confidence: 'deterministic',
575
- reason: 'tier0_external',
576
- signals: ['tier0_external', segment.head, ...decision.signals],
577
- capabilityRequests: requests,
578
- authorizationDecision: decision,
579
- });
580
- }
581
- if (tier0ExternalMatch(semantics.normalizedKey ?? segment.key, segment.head, peeled, semantics.signals)) {
582
- const tier0Key = semantics.normalizedKey ?? segment.key;
583
- const { requests, decision } = evaluateSegmentShellPolicy({
584
- command,
585
- segmentHead: segment.head,
586
- effect: 'remote_mutation',
587
- location: 'external',
588
- opacity: 'transparent',
589
- pathArgs: [],
590
- signals: ['tier0_external', tier0Key, ...semantics.signals],
591
- context: pathContext,
592
- });
593
- return askVerdict({
594
- location: 'external',
595
- opacity: 'transparent',
596
- effect: 'remote_mutation',
597
- confidence: 'deterministic',
598
- reason: 'tier0_external',
599
- signals: ['tier0_external', tier0Key, ...decision.signals],
600
- capabilityRequests: requests,
601
- authorizationDecision: decision,
602
- });
603
- }
604
- const rmVerdict = tier0HighStakesRm(peeled, pathContext);
605
- if (rmVerdict) {
606
- return rmVerdict;
607
- }
608
- if (isBelaySelfCommand(peeled)) {
609
- return allowVerdict({
610
- location: 'unknown',
611
- opacity: 'transparent',
612
- effect: 'local_mutation',
613
- confidence: 'deterministic',
614
- reason: 'belay_control_plane_command',
615
- signals: ['belay_control_plane_command', segment.head],
616
- });
617
- }
618
- let effect = semantics.effect;
619
- if (isReadOnlyLauncherInvocation(peeled)) {
620
- effect = 'read_only';
621
- }
622
- const pathArgs = semantics.pathTargets;
623
- const policyPathArgs = [...new Set([...pathArgs, ...(semantics.scopeTargets ?? [])])];
624
- const pathAnalysis = analyzePathTargets({
625
- targets: policyPathArgs,
626
- cwd: pathContext.cwd,
627
- repoRoot: pathContext.repoRoot,
628
- trustedCwd: pathContext.trustedCwd,
629
- trustedWorkspaceRoots: pathContext.trustedWorkspaceRoots,
630
- sensitivePaths: pathContext.sensitivePaths,
631
- protectedArtifactRoots: pathContext.protectedArtifactRoots,
632
- });
633
- // Path-less read/mutation in a trusted cwd: assume repo-local (e.g. git status, tsc --noEmit).
634
- const effectiveLocation = policyPathArgs.length === 0 &&
635
- pathContext.trustedCwd &&
636
- pathContext.cwd &&
637
- (effect === 'read_only' || effect === 'local_mutation') &&
638
- pathAnalysis.location === 'unknown'
639
- ? 'repo_local'
640
- : pathAnalysis.location;
641
- if (!context.trustedCwd || !context.cwd) {
642
- if (opacity === 'opaque' || effect === 'unknown' || effect === 'local_mutation') {
643
- return askVerdict({
644
- location: 'unknown',
645
- opacity,
646
- effect: effect === 'read_only' ? 'unknown' : effect,
647
- confidence: 'deterministic',
648
- reason: 'missing_trusted_cwd',
649
- signals: ['missing_trusted_cwd'],
650
- });
651
- }
652
- if (effect === 'read_only' && !isPureReadOnlySegment(segment, semantics)) {
653
- return askVerdict({
654
- location: 'unknown',
655
- opacity,
656
- effect: 'read_only',
657
- confidence: 'deterministic',
658
- reason: 'missing_trusted_cwd',
659
- signals: ['missing_trusted_cwd'],
660
- });
661
- }
662
- }
663
- if ((effect === 'local_mutation' || effect === 'unknown') &&
664
- context.protectedArtifactRoots &&
665
- context.protectedArtifactRoots.length > 0) {
666
- for (const target of pathArgs) {
667
- const resolved = resolveTrustedPath(target, pathContext.cwd, pathContext.trustedCwd) ??
668
- resolveMutationTarget(target, pathContext.cwd);
669
- if (resolved && touchesProtectedRoot(resolved, pathContext.protectedArtifactRoots ?? [])) {
670
- return askVerdict({
671
- location: pathAnalysis.location,
672
- opacity: 'transparent',
673
- effect: 'local_mutation',
674
- confidence: 'deterministic',
675
- reason: 'high_stakes_path',
676
- signals: ['high_stakes_path', 'control_plane_path'],
677
- });
71
+ else {
72
+ outside = true;
678
73
  }
679
74
  }
680
- }
681
- if (effect === 'local_mutation' || effect === 'unknown') {
682
- let destructiveHighStakes = false;
683
- for (const target of pathArgs) {
684
- const resolved = resolveTrustedPath(target, pathContext.cwd, pathContext.trustedCwd) ??
685
- resolveMutationTarget(target, pathContext.cwd);
686
- if (resolved &&
687
- isDestructiveHighStakesMutation(segment.head, resolved, pathContext.repoRoot, pathContext.sensitivePaths, pathContext.protectedArtifactRoots, pathContext.trustedWorkspaceRoots)) {
688
- destructiveHighStakes = true;
689
- break;
75
+ else if (requirement.resource.kind === 'network') {
76
+ const host = requirement.resource.host.replace(/^\[|\]$/g, '').toLowerCase();
77
+ if (EFFECT_LOOPBACK_HOSTS.has(host)) {
78
+ local = true;
79
+ }
80
+ else {
81
+ remote = true;
690
82
  }
691
83
  }
692
- if (destructiveHighStakes) {
693
- return askVerdict({
694
- location: pathAnalysis.location,
695
- opacity: 'transparent',
696
- effect: 'local_mutation',
697
- confidence: 'deterministic',
698
- reason: 'high_stakes_path',
699
- signals: pathAnalysis.signals,
700
- });
701
- }
702
- }
703
- if (segment.head === 'find' && isFindDangerous(peeled)) {
704
- return askVerdict({
705
- location: pathAnalysis.location === 'unknown' ? 'repo_local' : pathAnalysis.location,
706
- opacity: 'transparent',
707
- effect: 'local_mutation',
708
- confidence: 'deterministic',
709
- reason: 'find_dangerous_action',
710
- signals: ['find_dangerous_action'],
711
- });
712
- }
713
- if (effect === 'read_only' &&
714
- (pathAnalysis.location === 'repo_outside' || pathAnalysis.location === 'mixed')) {
715
- return allowVerdict({
716
- location: pathAnalysis.location,
717
- opacity,
718
- effect: 'read_only',
719
- confidence: 'deterministic',
720
- reason: 'read_only',
721
- signals: ['read_only', ...pathAnalysis.signals],
722
- });
723
- }
724
- if (pathAnalysis.location === 'unknown' &&
725
- pathArgs.length > 0 &&
726
- LOCAL_MUTATION_KEYS.has(segment.head)) {
727
- return askVerdict({
728
- location: 'unknown',
729
- opacity: 'transparent',
730
- effect: 'unknown',
731
- confidence: 'deterministic',
732
- reason: 'unknown_location_mutation',
733
- signals: ['unknown_location_mutation'],
734
- });
735
- }
736
- const resolvedPathTargets = resolveShellPathTargets(policyPathArgs, pathContext);
737
- const semanticSignals = semantics.signals;
738
- const buildPolicyInput = (overrides) => ({
739
- command: recursiveScript ?? command,
740
- segmentHead: segment.head,
741
- opacity,
742
- egressClass: egressClass ?? undefined,
743
- pathArgs: policyPathArgs,
744
- resolvedPathTargets,
745
- signals: [...semanticSignals, ...pathAnalysis.signals],
746
- context: pathContext,
747
- ...overrides,
748
- });
749
- const outsideMutation = pathAnalysis.location === 'repo_outside' || pathAnalysis.location === 'mixed';
750
- const mutationPrescan = (effect === 'local_mutation' || effect === 'unknown') && pathArgs.length > 0
751
- ? mutationPrescanRequiresAsk({
752
- targets: pathArgs,
753
- cwd: pathContext.cwd,
754
- repoRoot: pathContext.repoRoot,
755
- trustedCwd: pathContext.trustedCwd,
756
- trustedWorkspaceRoots: pathContext.trustedWorkspaceRoots,
757
- sensitivePaths: pathContext.sensitivePaths,
758
- })
759
- : null;
760
- if (mutationPrescan) {
761
- const { requests, decision } = evaluateSegmentShellPolicy(buildPolicyInput({
762
- effect: 'local_mutation',
763
- location: pathAnalysis.location === 'unknown'
764
- ? 'unknown'
765
- : pathAnalysis.location === 'repo_outside' || pathAnalysis.location === 'mixed'
766
- ? pathAnalysis.location
767
- : 'repo_local',
768
- signals: ['tier1_catastrophic', mutationPrescan.reason, ...pathAnalysis.signals],
769
- }));
770
- const legacyReason = policyDecisionRequiresAsk(decision)
771
- ? policyDecisionToLegacyReason(decision, { outsideMutation, effect: 'local_mutation' })
772
- : 'tier1_catastrophic';
773
- return askVerdict({
774
- location: pathAnalysis.location === 'unknown'
84
+ else {
85
+ local = true;
86
+ }
87
+ if (requirement.action === 'fs.write' ||
88
+ requirement.action === 'git.ref.write' ||
89
+ requirement.action === 'control_plane.write' ||
90
+ (requirement.action === 'process.exec' &&
91
+ requirement.resource.kind === 'executable' &&
92
+ requirement.resource.operation === 'spawn') ||
93
+ (requirement.action === 'network.connect' &&
94
+ requirement.resource.kind === 'network' &&
95
+ requirement.resource.mode === 'mutate')) {
96
+ mutation = true;
97
+ }
98
+ }
99
+ const location = remote && (local || outside)
100
+ ? 'mixed'
101
+ : remote
102
+ ? 'external'
103
+ : outside && local
104
+ ? 'mixed'
105
+ : outside
106
+ ? 'repo_outside'
107
+ : local || requirements.length === 0
108
+ ? 'repo_local'
109
+ : 'unknown';
110
+ const effect = remote && mutation
111
+ ? 'remote_mutation'
112
+ : mutation
113
+ ? 'local_mutation'
114
+ : unknown
775
115
  ? 'unknown'
776
- : pathAnalysis.location === 'repo_outside' || pathAnalysis.location === 'mixed'
777
- ? pathAnalysis.location
778
- : 'repo_local',
779
- opacity,
780
- effect: 'local_mutation',
781
- confidence: 'deterministic',
782
- reason: legacyReason,
783
- signals: ['tier1_catastrophic', mutationPrescan.reason, ...decision.signals],
784
- capabilityRequests: requests,
785
- authorizationDecision: decision,
786
- });
787
- }
788
- const needsPolicy = effect === 'unknown' ||
789
- TIER0_EXTERNAL_HEADS.has(segment.head) ||
790
- egressClass === 'ambiguous' ||
791
- egressClass === 'read' ||
792
- (outsideMutation && effect !== 'read_only');
793
- let policyMetadata = {};
794
- if (needsPolicy) {
795
- const { requests, decision } = evaluateSegmentShellPolicy(buildPolicyInput({
796
- effect,
797
- location: pathAnalysis.location,
798
- }));
799
- policyMetadata = {
800
- capabilityRequests: requests,
801
- authorizationDecision: decision,
802
- };
803
- if (policyDecisionRequiresAsk(decision)) {
804
- return askVerdict({
805
- location: pathAnalysis.location === 'unknown'
806
- ? 'unknown'
807
- : pathAnalysis.location === 'repo_outside' || pathAnalysis.location === 'mixed'
808
- ? pathAnalysis.location
809
- : 'repo_local',
810
- opacity,
811
- effect: decision.reason === 'external_effect' || decision.reason === 'network_connect'
812
- ? 'remote_mutation'
813
- : effect,
814
- confidence: 'deterministic',
815
- reason: policyDecisionToLegacyReason(decision, { outsideMutation, effect }),
816
- signals: ['policy_required', ...decision.signals],
817
- ...policyMetadata,
818
- });
819
- }
820
- }
821
- if (outsideMutation && effect !== 'read_only') {
822
- const policyAllowsOutside = policyMetadata.authorizationDecision?.outcome === 'allow' &&
823
- (policyMetadata.authorizationDecision.matchedRule === 'grant.exact' ||
824
- policyMetadata.authorizationDecision.matchedRule === 'boundary.verified' ||
825
- policyMetadata.authorizationDecision.matchedRule === 'builtin.trusted_workspace');
826
- if (!policyAllowsOutside) {
827
- const legacyReason = policyMetadata.authorizationDecision &&
828
- policyDecisionRequiresAsk(policyMetadata.authorizationDecision)
829
- ? policyDecisionToLegacyReason(policyMetadata.authorizationDecision, {
830
- outsideMutation,
831
- effect,
832
- })
833
- : 'outside_repo_mutation';
834
- return askVerdict({
835
- location: pathAnalysis.location,
836
- opacity,
837
- effect: 'local_mutation',
838
- confidence: 'deterministic',
839
- reason: legacyReason,
840
- signals: ['outside_repo_mutation', ...pathAnalysis.signals],
841
- ...policyMetadata,
842
- });
843
- }
844
- return allowVerdict({
845
- location: pathAnalysis.location,
846
- opacity,
847
- effect: 'local_mutation',
848
- confidence: 'deterministic',
849
- reason: 'repo_outside_local_mutation',
850
- signals: ['repo_outside_local_mutation', ...pathAnalysis.signals],
851
- ...policyMetadata,
852
- });
853
- }
854
- if (effectiveLocation === 'repo_local' &&
855
- (effect === 'read_only' || effect === 'local_mutation') &&
856
- opacity !== 'opaque') {
857
- return allowVerdict({
858
- location: 'repo_local',
859
- opacity,
860
- effect,
861
- confidence: 'assumed_repo_local',
862
- reason: effect === 'read_only' ? 'read_only' : 'repo_local_mutation',
863
- signals: effect === 'read_only' ? ['read_only'] : ['repo_local_mutation'],
864
- ...withShellPolicyMetadata(buildPolicyInput({ effect, location: 'repo_local' }), policyMetadata),
865
- });
866
- }
867
- if (effect === 'read_only') {
868
- const readOnlyLocation = pathContext.trustedCwd && pathContext.cwd
869
- ? effectiveLocation === 'unknown'
870
- ? 'repo_local'
871
- : effectiveLocation
872
- : 'unknown';
873
- return allowVerdict({
874
- location: readOnlyLocation,
875
- opacity,
876
- effect: 'read_only',
877
- confidence: context.trustedCwd && context.cwd ? 'assumed_repo_local' : 'deterministic',
878
- reason: 'read_only',
879
- signals: ['read_only'],
880
- ...withShellPolicyMetadata(buildPolicyInput({ effect: 'read_only', location: readOnlyLocation }), policyMetadata),
881
- });
882
- }
883
- if (allowOverride) {
884
- return allowFromCustomOverride(opacity);
885
- }
886
- if (context.unknownLocalEffect === 'allow_flagged') {
887
- return allowVerdict({
888
- location: effectiveLocation === 'unknown' ? 'repo_local' : effectiveLocation,
889
- opacity,
890
- effect: 'unknown',
891
- confidence: 'assumed_repo_local',
892
- reason: 'unknown_local_effect',
893
- signals: ['unknown_local_effect'],
894
- ...withShellPolicyMetadata(buildPolicyInput({
895
- effect: 'unknown',
896
- location: effectiveLocation === 'unknown' ? 'repo_local' : effectiveLocation,
897
- }), policyMetadata),
898
- });
899
- }
900
- return askVerdict({
901
- location: effectiveLocation,
902
- opacity,
903
- effect,
904
- confidence: 'deterministic',
905
- reason: 'unknown_local_effect',
906
- signals: ['unknown_local_effect'],
907
- ...withShellPolicyMetadata(buildPolicyInput({ effect, location: pathAnalysis.location }), policyMetadata),
908
- });
909
- }
910
- function toVerdictResult(internal, command, context, fingerprintCwd = context.cwd) {
911
- const commandRedacted = redactCommand(command);
912
- const relative = cwdRelative(context.repoRoot, fingerprintCwd);
913
- return {
914
- permission: internal.permission,
915
- location: internal.location,
916
- opacity: internal.opacity,
917
- effect: internal.effect,
918
- confidence: internal.confidence,
919
- reason: internal.reason,
920
- commandRedacted,
921
- fingerprint: verdictFingerprint(relative, commandRedacted),
922
- signals: internal.signals,
923
- judgeTrace: internal.judgeTrace,
924
- capabilityRequests: internal.capabilityRequests,
925
- authorizationDecision: internal.authorizationDecision,
926
- effectPlan: internal.effectPlan,
927
- effectPlanPolicyDecisions: internal.effectPlanPolicyDecisions,
928
- };
929
- }
930
- export async function verdict(command, context) {
931
- const trimmed = command.trim();
932
- if (!trimmed) {
933
- return toVerdictResult(allowVerdict({
934
- location: 'repo_local',
935
- opacity: 'transparent',
936
- effect: 'read_only',
937
- confidence: 'deterministic',
938
- reason: 'empty_command',
939
- signals: ['empty_command'],
940
- }), trimmed, context);
941
- }
942
- const segments = splitTopLevelSegments(trimmed);
943
- let combined = null;
944
- let chainState = {
945
- cwd: context.cwd,
946
- trustedCwd: context.trustedCwd,
947
- };
948
- for (const segment of segments) {
949
- const segmentContext = {
950
- ...context,
951
- cwd: chainState.cwd,
952
- trustedCwd: chainState.trustedCwd,
953
- };
954
- const segmentVerdict = await evaluateSegment(segment, segmentContext, 0);
955
- combined = combined ? combineInternal(combined, segmentVerdict) : segmentVerdict;
956
- chainState = updateChainState(segment, chainState);
957
- }
958
- return toVerdictResult(combined ??
959
- askVerdict({
960
- location: 'unknown',
961
- opacity: 'unparseable',
962
- effect: 'unknown',
963
- confidence: 'deterministic',
964
- reason: 'empty_segments',
965
- signals: ['empty_segments'],
966
- }), trimmed, context, chainState.cwd);
116
+ : 'read_only';
117
+ return { location, effect };
967
118
  }