@guilz-dev/belay 0.9.0 → 0.9.2

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 (46) hide show
  1. package/README.md +7 -1
  2. package/dist/adapters/claude/runtime-entry.js +2 -0
  3. package/dist/adapters/codex/runtime-entry.js +2 -0
  4. package/dist/adapters/cursor/hooks.d.ts +6 -1
  5. package/dist/adapters/cursor/hooks.js +23 -0
  6. package/dist/adapters/cursor/runtime-entry.js +5 -0
  7. package/dist/adapters/shared/gate-runtime.d.ts +1 -0
  8. package/dist/adapters/shared/gate-runtime.js +49 -52
  9. package/dist/bundle/claude-runtime.mjs +4295 -3624
  10. package/dist/bundle/codex-runtime.mjs +4136 -3465
  11. package/dist/bundle/cursor-runtime.mjs +4137 -3463
  12. package/dist/commands/doctor.js +21 -3
  13. package/dist/core/audit-analysis.js +7 -4
  14. package/dist/core/audit-io.d.ts +1 -0
  15. package/dist/core/audit-io.js +8 -6
  16. package/dist/core/audit-legacy-archive.d.ts +7 -0
  17. package/dist/core/audit-legacy-archive.js +56 -0
  18. package/dist/core/audit-metrics.d.ts +4 -0
  19. package/dist/core/audit-metrics.js +2 -2
  20. package/dist/core/audit-query.d.ts +2 -0
  21. package/dist/core/audit-query.js +33 -9
  22. package/dist/core/audit-recovery-metrics.js +2 -2
  23. package/dist/core/audit-serialize.d.ts +9 -0
  24. package/dist/core/audit-serialize.js +179 -0
  25. package/dist/core/audit-types.d.ts +1 -0
  26. package/dist/core/capability/policy-engine.js +32 -13
  27. package/dist/core/decision-config-fingerprint.d.ts +3 -0
  28. package/dist/core/decision-config-fingerprint.js +6 -0
  29. package/dist/core/effect-ir/shell-lower.js +208 -2
  30. package/dist/core/verdict/egress-classify.js +1 -0
  31. package/dist/core/verdict/launcher-resolve.js +51 -10
  32. package/dist/core/verdict/makefile-expand.d.ts +4 -0
  33. package/dist/core/verdict/makefile-expand.js +151 -0
  34. package/dist/core/verdict/parser.d.ts +1 -0
  35. package/dist/core/verdict/parser.js +24 -12
  36. package/dist/corpus/benign-probe-cores.d.ts +1 -1
  37. package/dist/corpus/benign-probe-cores.js +2 -0
  38. package/dist/defaults.js +5 -4
  39. package/dist/egress-daemon.js +20 -4
  40. package/dist/installer.js +3 -0
  41. package/dist/runtime-provenance.d.ts +10 -0
  42. package/dist/runtime-provenance.js +63 -0
  43. package/dist/templates.js +12 -7
  44. package/dist/version.d.ts +1 -1
  45. package/dist/version.js +1 -1
  46. package/package.json +2 -1
@@ -1,11 +1,12 @@
1
1
  import { lstatSync, realpathSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { inspectGitResourceIdentity } from '../git-resource-identity.js';
4
+ import { canonicalPath, pathWithinRoot } from '../path-utils.js';
4
5
  import { isFdDuplication, isRedirectOperator, tokenizeShell } from '../shell-tokenizer.js';
5
6
  import { decodeEgressEffects } from '../verdict/egress-classify.js';
6
7
  import { decodeGitEffects } from '../verdict/git-classifier.js';
7
8
  import { resolveLauncherRecipe } from '../verdict/launcher-resolve.js';
8
- import { extractRecursiveScript, isCommandInspection, isDynamicRecursiveEvaluation, parseSegment, redactCommand, segmentOpacity, splitStructuralShellSegments, structuralSubstitutionInners, } from '../verdict/parser.js';
9
+ import { extractDockerComposeRunScript, extractRecursiveScript, isCommandInspection, isDynamicRecursiveEvaluation, parseSegment, redactCommand, segmentOpacity, splitStructuralShellSegments, structuralSubstitutionInners, } from '../verdict/parser.js';
9
10
  import { joinEffectOpacity } from './normalize.js';
10
11
  import { classifyPackageAcquisitionSpec, innerRecipeFromPeel, peelPackageExecArgv, resolveLocalBin, } from './package-exec.js';
11
12
  import { buildShellEffectPlan, } from './shell-build.js';
@@ -130,7 +131,12 @@ function lowerSegment(command, context) {
130
131
  const environment = extractEnvironment(rawTokens, context.env);
131
132
  const env = environment.env;
132
133
  const parsed = parseSegment(command);
133
- const tokens = stripRedirects(environment.commandTokens ?? parsed.tokens).map((token) => expandKnownVariables(token, env));
134
+ const parsedTokens = environment.commandTokens ?? parsed.tokens;
135
+ const tokens = stripRedirects(parsedTokens.length === 0 &&
136
+ rawTokens.length > 0 &&
137
+ rawTokens.every((token) => ENV_PREFIX_PATTERN.test(token))
138
+ ? rawTokens
139
+ : parsedTokens).map((token) => expandKnownVariables(token, env));
134
140
  const head = path.basename(tokens[0] ?? parsed.head);
135
141
  let opacity = segmentOpacity(command);
136
142
  const signals = new Set();
@@ -210,6 +216,25 @@ function lowerSegment(command, context) {
210
216
  }
211
217
  return shellSegment(commandRedacted, head, requirements, 'recursive', signals);
212
218
  }
219
+ const dockerComposeScript = extractDockerComposeRunScript(tokens);
220
+ if (dockerComposeScript && opacity !== 'opaque' && opacity !== 'unparseable') {
221
+ requirements.push(processRequirement(head, 'spawn', commandRedacted, ['process.docker_compose_run']));
222
+ const nested = lowerTopLevelSegments(dockerComposeScript, {
223
+ ...context,
224
+ command: dockerComposeScript,
225
+ env,
226
+ depth: context.depth + 1,
227
+ });
228
+ for (const nestedSegment of nested) {
229
+ requirements.push(...nestedSegment.requirements.map((entry) => withInnerProvenance(entry, dockerComposeScript, head, commandRedacted)));
230
+ for (const signal of nestedSegment.signals) {
231
+ signals.add(signal);
232
+ }
233
+ opacity = joinNestedOpacity(opacity, nestedSegment);
234
+ }
235
+ signals.add('process.docker_compose_run');
236
+ return shellSegment(commandRedacted, head, requirements, 'recursive', signals);
237
+ }
213
238
  const launcher = resolveLauncherRecipe({
214
239
  tokens,
215
240
  cwd: context.cwd,
@@ -381,6 +406,101 @@ function railsReadOnlySubcommand(args) {
381
406
  }
382
407
  return RAILS_READ_ONLY_SUBCOMMANDS.has(subcommand);
383
408
  }
409
+ function isRubyTestScript(scriptPath) {
410
+ const base = path.basename(scriptPath);
411
+ return base.endsWith('_test.rb') || base.endsWith('_spec.rb');
412
+ }
413
+ function parseRubyTestInvocation(args) {
414
+ const includePaths = [];
415
+ for (let index = 0; index < args.length; index += 1) {
416
+ const arg = args[index] ?? '';
417
+ if (arg === '-e' || arg === '-r') {
418
+ return null;
419
+ }
420
+ if (arg === '-I') {
421
+ const includePath = args[index + 1];
422
+ if (!includePath) {
423
+ return null;
424
+ }
425
+ includePaths.push(includePath);
426
+ index += 1;
427
+ continue;
428
+ }
429
+ if (arg.startsWith('-I') && arg.length > 2) {
430
+ includePaths.push(arg.slice(2));
431
+ continue;
432
+ }
433
+ if (arg.startsWith('-')) {
434
+ if (arg === '-n') {
435
+ if (!args[index + 1]) {
436
+ return null;
437
+ }
438
+ index += 1;
439
+ continue;
440
+ }
441
+ if (arg.startsWith('-n')) {
442
+ continue;
443
+ }
444
+ return null;
445
+ }
446
+ if (isRubyTestScript(arg)) {
447
+ return { includePaths, scriptPath: arg };
448
+ }
449
+ return null;
450
+ }
451
+ return null;
452
+ }
453
+ function isRubocopMutating(args) {
454
+ return args.some((arg) => arg === '-A' ||
455
+ arg === '-a' ||
456
+ arg === '--auto-correct' ||
457
+ arg === '--autocorrect' ||
458
+ arg.startsWith('--auto-correct-all') ||
459
+ arg.startsWith('--autocorrect-all'));
460
+ }
461
+ function decodeBundleExecInner(innerHead, innerArgs, segment) {
462
+ const innerBase = executableBaseName(innerHead);
463
+ if (innerBase === 'rubocop') {
464
+ const mutating = isRubocopMutating(innerArgs);
465
+ return [
466
+ processRequirement(innerHead, mutating ? 'spawn' : 'inspect', segment, mutating ? ['process.linter.mutating'] : ['process.inspect.linter']),
467
+ ];
468
+ }
469
+ if (innerBase === 'rspec') {
470
+ const targetArgs = innerArgs.filter((arg) => !arg.startsWith('-'));
471
+ if (targetArgs.length === 0) {
472
+ return null;
473
+ }
474
+ return [processRequirement(innerHead, 'spawn', segment, ['process.test_runner.rspec'])];
475
+ }
476
+ return null;
477
+ }
478
+ function decodeRuby(args, cwd, repoRoot, segment) {
479
+ const parsed = parseRubyTestInvocation(args);
480
+ if (!parsed) {
481
+ return unsupportedProcess('ruby', segment, 'process.ruby_grammar_incomplete');
482
+ }
483
+ const scriptPath = resolvePathOperand(parsed.scriptPath, cwd);
484
+ if (!pathWithinRoot(canonicalPath(repoRoot), canonicalPath(scriptPath))) {
485
+ return unsupportedProcess('ruby', segment, 'process.ruby_outside_repo');
486
+ }
487
+ for (const includePath of parsed.includePaths) {
488
+ const resolvedInclude = resolvePathOperand(includePath, cwd);
489
+ if (!pathWithinRoot(canonicalPath(repoRoot), canonicalPath(resolvedInclude))) {
490
+ return unsupportedProcess('ruby', segment, 'process.ruby_outside_repo');
491
+ }
492
+ }
493
+ const lowered = [
494
+ processRequirement('ruby', 'spawn', segment, ['process.test_runner.minitest']),
495
+ requirement('fs.read', 'fs.read', { kind: 'path', path: scriptPath }, segment, [
496
+ 'ruby.minitest_script_read',
497
+ ]),
498
+ ];
499
+ for (const includePath of parsed.includePaths) {
500
+ lowered.push(requirement('fs.read', 'fs.read', { kind: 'path', path: resolvePathOperand(includePath, cwd) }, segment, ['ruby.minitest_load_path_read']));
501
+ }
502
+ return lowered;
503
+ }
384
504
  function decodeRuntimeMetadataProcess(head, args, segment) {
385
505
  if (head === 'bundle') {
386
506
  if (args.length === 1 && isMetadataOnlyArgv(args)) {
@@ -402,6 +522,10 @@ function decodeRuntimeMetadataProcess(head, args, segment) {
402
522
  ]),
403
523
  ];
404
524
  }
525
+ const bundleExecInner = decodeBundleExecInner(innerHead, innerArgs, segment);
526
+ if (bundleExecInner) {
527
+ return bundleExecInner;
528
+ }
405
529
  }
406
530
  return null;
407
531
  }
@@ -417,9 +541,75 @@ function decodeRuntimeMetadataProcess(head, args, segment) {
417
541
  }
418
542
  return null;
419
543
  }
544
+ function decodeSetBuiltin(args) {
545
+ let index = 0;
546
+ while (index < args.length) {
547
+ const arg = args[index] ?? '';
548
+ if (arg === '--') {
549
+ index += 1;
550
+ continue;
551
+ }
552
+ if (arg === '-o' || arg === '+o') {
553
+ if (!args[index + 1]) {
554
+ return false;
555
+ }
556
+ index += 2;
557
+ continue;
558
+ }
559
+ if (/^[-+][A-Za-z0-9]+$/.test(arg)) {
560
+ index += 1;
561
+ continue;
562
+ }
563
+ return false;
564
+ }
565
+ return true;
566
+ }
567
+ function decodeShellControlBuiltin(head, args) {
568
+ if (head === 'set') {
569
+ return decodeSetBuiltin(args) ? [] : null;
570
+ }
571
+ if (head === 'wait') {
572
+ if (args.length === 0 || args.every((arg) => /^\d+$/.test(arg))) {
573
+ return [];
574
+ }
575
+ return null;
576
+ }
577
+ if (head === 'exit') {
578
+ if (args.length === 0 || (args.length === 1 && /^-?\d+$/.test(args[0] ?? ''))) {
579
+ return [];
580
+ }
581
+ return null;
582
+ }
583
+ return null;
584
+ }
585
+ function decodeDockerComposeRun(head, args, segment) {
586
+ let composeArgs = null;
587
+ let command = head;
588
+ if (head === 'docker-compose') {
589
+ composeArgs = args;
590
+ }
591
+ else if (head === 'docker' && args[0] === 'compose') {
592
+ composeArgs = args.slice(1);
593
+ command = 'docker';
594
+ }
595
+ if (!composeArgs) {
596
+ return null;
597
+ }
598
+ if (composeArgs.includes('run')) {
599
+ return [processRequirement(command, 'spawn', segment, ['process.docker_compose_run'])];
600
+ }
601
+ return unsupportedProcess(command, segment, 'process.docker_compose_grammar_incomplete');
602
+ }
420
603
  function decodeProcessOrFilesystem(params) {
421
604
  const { tokens, head, env, cwd, repoRoot, segment } = params;
422
605
  const args = tokens.slice(1);
606
+ if (tokens.length > 0 && tokens.every((token) => ENV_PREFIX_PATTERN.test(token))) {
607
+ return [];
608
+ }
609
+ const shellControl = decodeShellControlBuiltin(head, args);
610
+ if (shellControl) {
611
+ return shellControl;
612
+ }
423
613
  if (isCommandInspection(tokens)) {
424
614
  return [processRequirement(head, 'inspect', segment, ['process.inspect.command_lookup'])];
425
615
  }
@@ -500,6 +690,19 @@ function decodeProcessOrFilesystem(params) {
500
690
  if (runtimeMetadata) {
501
691
  return runtimeMetadata;
502
692
  }
693
+ if (head === 'ruby') {
694
+ return decodeRuby(args, cwd, repoRoot, segment);
695
+ }
696
+ if (head === 'rubocop' || head === 'rspec') {
697
+ const decoded = decodeBundleExecInner(head, args, segment);
698
+ if (decoded) {
699
+ return decoded;
700
+ }
701
+ }
702
+ const dockerCompose = decodeDockerComposeRun(head, args, segment);
703
+ if (dockerCompose) {
704
+ return dockerCompose;
705
+ }
503
706
  if ((head === 'npm' || head === 'pnpm') && args.length === 1 && isMetadataOnlyArgv(args)) {
504
707
  return [processRequirement(head, 'inspect', segment, ['process.inspect.package_manager'])];
505
708
  }
@@ -518,6 +721,9 @@ function decodeProcessOrFilesystem(params) {
518
721
  if (head === 'sed') {
519
722
  return decodeSed(args, cwd, segment);
520
723
  }
724
+ if (head === 'base64' && args.length === 1 && ['-d', '-D', '--decode'].includes(args[0] ?? '')) {
725
+ return [processRequirement(head, 'inspect', segment, ['process.inspect.base64_stdin'])];
726
+ }
521
727
  if (head === 'node') {
522
728
  return decodeNode(args, cwd, segment);
523
729
  }
@@ -47,6 +47,7 @@ const GH_READ_COMMANDS = new Set([
47
47
  'repo view',
48
48
  'run list',
49
49
  'run view',
50
+ 'search code',
50
51
  'workflow list',
51
52
  'workflow view',
52
53
  ]);
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { expandMakeExpression, normalizeMakeRecipeLine, parseMakefileVariables, parsePhonyTargets, } from './makefile-expand.js';
3
4
  const MAX_RESOLVE_DEPTH = 8;
4
5
  const PNPM_BUILTIN_COMMANDS = new Set([
5
6
  'add',
@@ -145,10 +146,9 @@ function resolveNpmRecipe(cwd, repoRoot, scriptName, extraArgs) {
145
146
  reason: 'npm_script_resolved',
146
147
  };
147
148
  }
148
- function parseMakefileRecipes(makefilePath) {
149
+ function parseMakefileRecipeContent(content) {
149
150
  const targets = new Map();
150
151
  try {
151
- const content = readFileSync(makefilePath, 'utf8');
152
152
  const lines = content.split('\n');
153
153
  let currentTarget = null;
154
154
  let recipeLines = [];
@@ -206,7 +206,7 @@ function parseMakefileRecipes(makefilePath) {
206
206
  }
207
207
  return targets;
208
208
  }
209
- function resolveMakeRecipe(cwd, repoRoot, target) {
209
+ function resolveMakeRecipe(cwd, repoRoot, target, cliVars = {}) {
210
210
  const candidates = ['Makefile', 'makefile', 'GNUmakefile'];
211
211
  let makefilePath = null;
212
212
  let searchDir = path.resolve(cwd);
@@ -227,7 +227,10 @@ function resolveMakeRecipe(cwd, repoRoot, target) {
227
227
  if (!makefilePath) {
228
228
  return { recipes: [], opaque: true, reason: 'unknown_local_effect' };
229
229
  }
230
- const targets = parseMakefileRecipes(makefilePath);
230
+ const makefileContent = readFileSync(makefilePath, 'utf8');
231
+ const makefileVars = parseMakefileVariables(makefileContent);
232
+ const phonyTargets = parsePhonyTargets(makefileContent);
233
+ const targets = parseMakefileRecipeContent(makefileContent);
231
234
  if (!targets.has(target)) {
232
235
  return { recipes: [], opaque: true, reason: 'make_target_undefined' };
233
236
  }
@@ -253,7 +256,16 @@ function resolveMakeRecipe(cwd, repoRoot, target) {
253
256
  return false;
254
257
  }
255
258
  }
256
- recipeLines.push(...entry.recipes);
259
+ // When the requested target has its own recipes, treat those as the
260
+ // authorization authority. Skip .PHONY / `_`-prefixed prerequisite
261
+ // recipes (e.g. `_start_test_deps` docker-compose up) so setup-only
262
+ // side effects do not obscure the target's direct command.
263
+ const skipPhonyPrerequisiteRecipes = name !== target &&
264
+ (phonyTargets.has(name) || name.startsWith('_')) &&
265
+ (targets.get(target)?.recipes.length ?? 0) > 0;
266
+ if (!skipPhonyPrerequisiteRecipes) {
267
+ recipeLines.push(...entry.recipes);
268
+ }
257
269
  visiting.delete(name);
258
270
  visited.add(name);
259
271
  return true;
@@ -261,15 +273,24 @@ function resolveMakeRecipe(cwd, repoRoot, target) {
261
273
  if (!collect(target)) {
262
274
  return { recipes: recipeLines, opaque: true, reason: 'make_dependency_cycle' };
263
275
  }
276
+ const expandedRecipes = [];
264
277
  for (const line of recipeLines) {
265
- if (/\$\(/.test(line) || /\$\{/.test(line)) {
278
+ const normalized = normalizeMakeRecipeLine(line);
279
+ const expanded = expandMakeExpression(normalized, cliVars, makefileVars);
280
+ if (expanded === null) {
266
281
  return { recipes: recipeLines, opaque: true, reason: 'make_recipe_dynamic' };
267
282
  }
283
+ expandedRecipes.push(expanded);
284
+ }
285
+ for (const line of expandedRecipes) {
286
+ if (/\$\(/.test(line) || /\$\{/.test(line)) {
287
+ return { recipes: expandedRecipes, opaque: true, reason: 'make_recipe_dynamic' };
288
+ }
268
289
  }
269
290
  if (opaquePrerequisites) {
270
- return { recipes: recipeLines, opaque: true, reason: 'make_prerequisite_dynamic' };
291
+ return { recipes: expandedRecipes, opaque: true, reason: 'make_prerequisite_dynamic' };
271
292
  }
272
- return { recipes: recipeLines, opaque: false, reason: 'make_recipe_resolved' };
293
+ return { recipes: expandedRecipes, opaque: false, reason: 'make_recipe_resolved' };
273
294
  }
274
295
  export function resolveLauncherRecipe(params) {
275
296
  if (params.depth >= MAX_RESOLVE_DEPTH) {
@@ -291,8 +312,28 @@ export function resolveLauncherRecipe(params) {
291
312
  }
292
313
  return resolution;
293
314
  }
294
- if (tokens[0] === 'make' && tokens[1] && !tokens[1].startsWith('-')) {
295
- return resolveMakeRecipe(params.cwd, params.repoRoot, tokens[1]);
315
+ if (tokens[0] === 'make') {
316
+ if (tokens.includes('-n') || tokens.includes('--dry-run')) {
317
+ return null;
318
+ }
319
+ let target = null;
320
+ const cliVars = {};
321
+ for (const token of tokens.slice(1)) {
322
+ if (token.startsWith('-')) {
323
+ continue;
324
+ }
325
+ const assignment = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(token);
326
+ if (assignment) {
327
+ cliVars[assignment[1] ?? ''] = assignment[2] ?? '';
328
+ continue;
329
+ }
330
+ if (!target) {
331
+ target = token;
332
+ }
333
+ }
334
+ if (target) {
335
+ return resolveMakeRecipe(params.cwd, params.repoRoot, target, cliVars);
336
+ }
296
337
  }
297
338
  if (tokens[0] === 'pnpm' && tokens[1] === 'exec' && tokens[2]) {
298
339
  return {
@@ -0,0 +1,4 @@
1
+ export declare function parseMakefileVariables(content: string): Map<string, string>;
2
+ export declare function parsePhonyTargets(content: string): Set<string>;
3
+ export declare function normalizeMakeRecipeLine(line: string): string;
4
+ export declare function expandMakeExpression(expression: string, cliVars: Readonly<Record<string, string>>, makefileVars: ReadonlyMap<string, string>): string | null;
@@ -0,0 +1,151 @@
1
+ const MAX_EXPAND_DEPTH = 16;
2
+ export function parseMakefileVariables(content) {
3
+ const variables = new Map();
4
+ for (const line of content.split('\n')) {
5
+ const trimmed = line.trim();
6
+ if (!trimmed || trimmed.startsWith('#')) {
7
+ continue;
8
+ }
9
+ const match = /^([A-Za-z_][A-Za-z0-9_]*)\s*[:?]?=\s*(.+)$/.exec(trimmed);
10
+ if (!match) {
11
+ continue;
12
+ }
13
+ variables.set(match[1] ?? '', (match[2] ?? '').trim());
14
+ }
15
+ return variables;
16
+ }
17
+ export function parsePhonyTargets(content) {
18
+ const phony = new Set();
19
+ for (const line of content.split('\n')) {
20
+ const trimmed = line.trim();
21
+ const match = /^\.PHONY:\s*(.+)$/.exec(trimmed);
22
+ if (!match) {
23
+ continue;
24
+ }
25
+ for (const token of (match[1] ?? '').split(/\s+/)) {
26
+ if (token) {
27
+ phony.add(token);
28
+ }
29
+ }
30
+ }
31
+ return phony;
32
+ }
33
+ export function normalizeMakeRecipeLine(line) {
34
+ let normalized = line.trim();
35
+ while (normalized.startsWith('@') || normalized.startsWith('-') || normalized.startsWith('+')) {
36
+ normalized = normalized.slice(1).trimStart();
37
+ }
38
+ return normalized;
39
+ }
40
+ export function expandMakeExpression(expression, cliVars, makefileVars) {
41
+ if (/\$\(\s*shell\b/i.test(expression) || expression.includes('$$')) {
42
+ return null;
43
+ }
44
+ try {
45
+ const expanded = expandMakeValue(expression, cliVars, makefileVars, 0);
46
+ if (expanded === null || /\$\(/.test(expanded) || /\$\{/.test(expanded)) {
47
+ return null;
48
+ }
49
+ return expanded;
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
55
+ function expandMakeValue(expression, cliVars, makefileVars, depth) {
56
+ if (depth > MAX_EXPAND_DEPTH) {
57
+ return null;
58
+ }
59
+ let value = expression.trim();
60
+ let changed = true;
61
+ let iterations = 0;
62
+ while (changed && iterations < MAX_EXPAND_DEPTH) {
63
+ changed = false;
64
+ iterations += 1;
65
+ const orMatch = value.match(/\$\(\s*or\s+([^()]*(?:\([^)]*\)[^()]*)*)\)/);
66
+ if (orMatch) {
67
+ const [fullMatch, inner] = orMatch;
68
+ const parts = splitMakeFunctionArgs(inner ?? '');
69
+ let selected = null;
70
+ for (const part of parts) {
71
+ const expanded = expandMakeValue(part.trim(), cliVars, makefileVars, depth + 1);
72
+ if (expanded !== null && expanded.trim() !== '') {
73
+ selected = expanded;
74
+ break;
75
+ }
76
+ }
77
+ if (selected === null) {
78
+ const fallback = parts.at(-1)?.trim();
79
+ selected =
80
+ fallback === undefined ? '' : expandMakeValue(fallback, cliVars, makefileVars, depth + 1);
81
+ }
82
+ if (selected === null) {
83
+ return null;
84
+ }
85
+ value = value.replace(fullMatch, selected);
86
+ changed = true;
87
+ continue;
88
+ }
89
+ const varMatch = value.match(/\$\(([A-Za-z_][A-Za-z0-9_]*)\)/);
90
+ if (varMatch) {
91
+ const [fullMatch, name] = varMatch;
92
+ const resolved = resolveMakeVariable(name ?? '', cliVars, makefileVars, depth + 1);
93
+ if (resolved === null) {
94
+ return null;
95
+ }
96
+ value = value.replace(fullMatch, resolved);
97
+ changed = true;
98
+ continue;
99
+ }
100
+ const bracedMatch = value.match(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/);
101
+ if (bracedMatch) {
102
+ const [fullMatch, name] = bracedMatch;
103
+ const resolved = name === 'PWD' ? '.' : resolveMakeVariable(name ?? '', cliVars, makefileVars, depth + 1);
104
+ if (resolved === null) {
105
+ return null;
106
+ }
107
+ value = value.replace(fullMatch, resolved);
108
+ changed = true;
109
+ continue;
110
+ }
111
+ break;
112
+ }
113
+ return value;
114
+ }
115
+ function resolveMakeVariable(name, cliVars, makefileVars, depth) {
116
+ if (Object.hasOwn(cliVars, name)) {
117
+ return cliVars[name] ?? '';
118
+ }
119
+ const definition = makefileVars.get(name);
120
+ if (definition === undefined) {
121
+ return null;
122
+ }
123
+ return expandMakeValue(definition, cliVars, makefileVars, depth);
124
+ }
125
+ function splitMakeFunctionArgs(input) {
126
+ const parts = [];
127
+ let current = '';
128
+ let depth = 0;
129
+ for (const char of input) {
130
+ if (char === '(') {
131
+ depth += 1;
132
+ current += char;
133
+ continue;
134
+ }
135
+ if (char === ')') {
136
+ depth -= 1;
137
+ current += char;
138
+ continue;
139
+ }
140
+ if (char === ',' && depth === 0) {
141
+ parts.push(current.trim());
142
+ current = '';
143
+ continue;
144
+ }
145
+ current += char;
146
+ }
147
+ if (current.trim()) {
148
+ parts.push(current.trim());
149
+ }
150
+ return parts;
151
+ }
@@ -16,6 +16,7 @@ export declare function peelTransparentWrappers(tokens: string[]): {
16
16
  export declare function isVariableIndirectHead(head: string): boolean;
17
17
  export declare function extractEvalBody(tokens: string[]): string | null;
18
18
  export declare function extractRecursiveScript(tokens: string[]): string | null;
19
+ export declare function extractDockerComposeRunScript(tokens: string[]): string | null;
19
20
  /**
20
21
  * True when a recursive script is evaluated from a command argument rather
21
22
  * than expanded from a static launcher recipe. Callers use this semantic fact
@@ -300,7 +300,6 @@ export function extractRecursiveScript(tokens) {
300
300
  return null;
301
301
  }
302
302
  const head = normalizeHead(filtered[0] ?? '');
303
- const second = filtered[1] ?? '';
304
303
  if (head === 'eval') {
305
304
  const body = filtered.slice(1).join(' ').trim();
306
305
  return body || null;
@@ -308,20 +307,33 @@ export function extractRecursiveScript(tokens) {
308
307
  if (SHELL_INTERPRETERS.has(head) || CODE_INTERPRETERS.has(head)) {
309
308
  const flagIndex = filtered.findIndex((token) => SCRIPT_FLAGS.has(token));
310
309
  if (flagIndex !== -1) {
311
- const body = filtered
312
- .slice(flagIndex + 1)
313
- .join(' ')
314
- .replace(/^['"]|['"]$/g, '')
315
- .trim();
310
+ const body = filtered[flagIndex + 1] ?? '';
316
311
  return body || null;
317
312
  }
318
313
  }
319
- if (head === 'bash' && (second === '-lc' || second === '-c')) {
320
- const body = filtered
321
- .slice(2)
322
- .join(' ')
323
- .replace(/^['"]|['"]$/g, '')
324
- .trim();
314
+ return null;
315
+ }
316
+ export function extractDockerComposeRunScript(tokens) {
317
+ const head = normalizeHead(tokens[0] ?? '');
318
+ const usesCompose = head === 'docker-compose' || (head === 'docker' && (tokens[1] ?? '') === 'compose');
319
+ if (!usesCompose) {
320
+ return null;
321
+ }
322
+ if (!tokens.includes('run')) {
323
+ return null;
324
+ }
325
+ const runIndex = tokens.indexOf('run');
326
+ const tail = tokens.slice(runIndex + 1);
327
+ for (let index = 0; index < tail.length; index += 1) {
328
+ const shellHead = normalizeHead(tail[index] ?? '');
329
+ if (!SHELL_INTERPRETERS.has(shellHead)) {
330
+ continue;
331
+ }
332
+ const flag = tail[index + 1] ?? '';
333
+ if (flag !== '-lc' && flag !== '-c') {
334
+ continue;
335
+ }
336
+ const body = tail[index + 2] ?? '';
325
337
  return body || null;
326
338
  }
327
339
  return null;
@@ -3,4 +3,4 @@
3
3
  * These commands guard classifier availability in tests and never grant runtime authority.
4
4
  * @see src/__tests__/verdict/structural-suite.test.ts
5
5
  */
6
- export declare const BENIGN_PROBE_CORES: readonly ["npm test", "npm run build", "pnpm test", "pnpm build", "pnpm vitest run src/example.test.ts", "bash -lc 'git status'", "belay approve belay_deadbeef1234", "bundle -v", "ruby -v", "yarn --version", "make -n test", "bin/rails routes", "bundle exec rubocop --version"];
6
+ export declare const BENIGN_PROBE_CORES: readonly ["npm test", "npm run build", "pnpm test", "pnpm build", "pnpm vitest run src/example.test.ts", "bash -lc 'git status'", "belay approve belay_deadbeef1234", "bundle -v", "ruby -v", "yarn --version", "make -n test", "bin/rails routes", "bundle exec rubocop --version", "bundle exec rubocop test/upgrade_script_contract_test.rb", "ruby -Itest test/upgrade_script_contract_test.rb"];
@@ -17,4 +17,6 @@ export const BENIGN_PROBE_CORES = [
17
17
  'make -n test',
18
18
  'bin/rails routes',
19
19
  'bundle exec rubocop --version',
20
+ 'bundle exec rubocop test/upgrade_script_contract_test.rb',
21
+ 'ruby -Itest test/upgrade_script_contract_test.rb',
20
22
  ];
package/dist/defaults.js CHANGED
@@ -40,7 +40,7 @@ export function getManagedHookEntries(platform = process.platform, hooksDir, rep
40
40
  definition: {
41
41
  command: toolGate,
42
42
  placement: 'prepend',
43
- matcher: 'Shell',
43
+ matcher: 'Write',
44
44
  },
45
45
  },
46
46
  {
@@ -48,7 +48,7 @@ export function getManagedHookEntries(platform = process.platform, hooksDir, rep
48
48
  definition: {
49
49
  command: toolGate,
50
50
  placement: 'prepend',
51
- matcher: 'Write',
51
+ matcher: 'StrReplace',
52
52
  },
53
53
  },
54
54
  {
@@ -56,7 +56,7 @@ export function getManagedHookEntries(platform = process.platform, hooksDir, rep
56
56
  definition: {
57
57
  command: toolGate,
58
58
  placement: 'prepend',
59
- matcher: 'StrReplace',
59
+ matcher: 'Delete',
60
60
  },
61
61
  },
62
62
  {
@@ -64,7 +64,8 @@ export function getManagedHookEntries(platform = process.platform, hooksDir, rep
64
64
  definition: {
65
65
  command: toolGate,
66
66
  placement: 'prepend',
67
- matcher: 'Delete',
67
+ // Agent Shell tool (preToolUse) — distinct from terminal beforeShellExecution.
68
+ matcher: 'Shell',
68
69
  },
69
70
  },
70
71
  {