@guilz-dev/belay 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) 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 +24 -21
  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 +39 -4
  12. package/dist/commands/quality.js +3 -9
  13. package/dist/commands/status.js +1 -1
  14. package/dist/conformance/guarantee-table.js +133 -11
  15. package/dist/conformance/types.d.ts +1 -0
  16. package/dist/core/audit-metrics.d.ts +24 -0
  17. package/dist/core/audit-metrics.js +82 -16
  18. package/dist/core/audit-types.d.ts +3 -0
  19. package/dist/core/capability/index.d.ts +1 -1
  20. package/dist/core/capability/policy-engine.d.ts +31 -1
  21. package/dist/core/capability/policy-engine.js +186 -0
  22. package/dist/core/capability/request.d.ts +9 -0
  23. package/dist/core/capability/resolver.d.ts +0 -1
  24. package/dist/core/capability/resolver.js +0 -1
  25. package/dist/core/config.js +0 -2
  26. package/dist/core/effect-ir/audit.js +1 -0
  27. package/dist/core/effect-ir/build.js +5 -1
  28. package/dist/core/effect-ir/index.d.ts +6 -1
  29. package/dist/core/effect-ir/index.js +2 -0
  30. package/dist/core/effect-ir/normalize.js +9 -3
  31. package/dist/core/effect-ir/policy.d.ts +2 -1
  32. package/dist/core/effect-ir/policy.js +93 -6
  33. package/dist/core/effect-ir/shell-build.d.ts +43 -0
  34. package/dist/core/effect-ir/shell-build.js +77 -0
  35. package/dist/core/effect-ir/shell-lower.d.ts +14 -0
  36. package/dist/core/effect-ir/shell-lower.js +1509 -0
  37. package/dist/core/effect-ir/types.d.ts +9 -2
  38. package/dist/core/gate-engine.js +63 -23
  39. package/dist/core/git-resource-identity.d.ts +26 -0
  40. package/dist/core/git-resource-identity.js +284 -0
  41. package/dist/core/harvest.d.ts +3 -7
  42. package/dist/core/harvest.js +4 -27
  43. package/dist/core/index.d.ts +2 -0
  44. package/dist/core/index.js +1 -0
  45. package/dist/core/path-utils.js +42 -23
  46. package/dist/core/shell-substitution.d.ts +1 -0
  47. package/dist/core/shell-substitution.js +61 -0
  48. package/dist/core/standing-allow.d.ts +1 -2
  49. package/dist/core/standing-allow.js +3 -20
  50. package/dist/core/types.d.ts +4 -1
  51. package/dist/core/verdict/adapter.js +15 -15
  52. package/dist/core/verdict/containment.js +4 -0
  53. package/dist/core/verdict/egress-classify.d.ts +12 -0
  54. package/dist/core/verdict/egress-classify.js +746 -0
  55. package/dist/core/verdict/git-classifier.d.ts +12 -0
  56. package/dist/core/verdict/git-classifier.js +320 -0
  57. package/dist/core/verdict/launcher-resolve.js +77 -17
  58. package/dist/core/verdict/parser.d.ts +2 -0
  59. package/dist/core/verdict/parser.js +95 -1
  60. package/dist/core/verdict/types.d.ts +2 -3
  61. package/dist/core/verdict/verdict.js +103 -952
  62. package/dist/corpus/must-allow-commands.d.ts +2 -1
  63. package/dist/corpus/must-allow-commands.js +2 -1
  64. package/dist/corpus/runtime-match.d.ts +2 -1
  65. package/dist/corpus/runtime-match.js +2 -1
  66. package/dist/corpus/types.d.ts +2 -2
  67. package/dist/operational-insights.d.ts +3 -0
  68. package/dist/operational-insights.js +7 -3
  69. package/dist/runtime-provenance.d.ts +8 -0
  70. package/dist/runtime-provenance.js +34 -0
  71. package/dist/templates.js +7 -2
  72. package/dist/types.d.ts +3 -0
  73. package/dist/version.d.ts +1 -1
  74. package/dist/version.js +1 -1
  75. package/package.json +2 -2
  76. package/dist/core/verdict/overrides.d.ts +0 -7
  77. package/dist/core/verdict/overrides.js +0 -37
  78. package/dist/core/verdict/shell-policy.d.ts +0 -26
  79. package/dist/core/verdict/shell-policy.js +0 -40
  80. package/dist/corpus/standing-allow-catalog.generated.d.ts +0 -13
  81. package/dist/corpus/standing-allow-catalog.generated.js +0 -99
@@ -1,3 +1,4 @@
1
+ import type { ShellEffectRequirement } from '../effect-ir/shell-build.js';
1
2
  import type { VerdictEffect } from './types.js';
2
3
  export interface NormalizedGitInvocation {
3
4
  subcommand: string;
@@ -21,6 +22,17 @@ export interface GitCommandSemantics {
21
22
  normalizedKey: string;
22
23
  isReadOnly: boolean;
23
24
  }
25
+ export interface DecodeGitEffectsParams {
26
+ tokens: string[];
27
+ cwd: string;
28
+ repoRoot: string;
29
+ segment: string;
30
+ }
24
31
  export declare function normalizeGitInvocation(tokens: string[], baseCwd: string): NormalizedGitInvocation | null;
25
32
  export declare function isGitReadOnlyInvocation(tokens: string[], baseCwd: string): boolean;
26
33
  export declare function classifyGitCommand(tokens: string[], baseCwd: string): GitCommandSemantics | null;
34
+ /**
35
+ * Decode git CLI grammar to typed effects without making an authorization
36
+ * decision. Legacy classification remains separate until the authority cutover.
37
+ */
38
+ export declare function decodeGitEffects(params: DecodeGitEffectsParams): ShellEffectRequirement[] | null;
@@ -1,4 +1,5 @@
1
1
  import path from 'node:path';
2
+ import { parseNetworkEndpoint } from '../network-endpoint.js';
2
3
  import { extractRedirectTargets } from '../shell-tokenizer.js';
3
4
  const GIT_BRANCH_MUTATION_FLAGS = new Set([
4
5
  '--copy',
@@ -531,3 +532,322 @@ export function classifyGitCommand(tokens, baseCwd) {
531
532
  isReadOnly: false,
532
533
  };
533
534
  }
535
+ /**
536
+ * Decode git CLI grammar to typed effects without making an authorization
537
+ * decision. Legacy classification remains separate until the authority cutover.
538
+ */
539
+ export function decodeGitEffects(params) {
540
+ const normalized = normalizeGitInvocation(params.tokens, params.cwd);
541
+ if (!normalized) {
542
+ return null;
543
+ }
544
+ const { subcommand, args } = normalized;
545
+ const signals = [
546
+ `git.${subcommand.replaceAll(' ', '.')}`,
547
+ ...(subcommand === 'push' ? ['tier0_external'] : []),
548
+ ];
549
+ const effectiveCwd = normalized.effectiveCwd ?? params.cwd;
550
+ const workTreeRoot = normalized.workTree
551
+ ? path.resolve(normalized.effectiveCwd ?? params.cwd, normalized.workTree)
552
+ : (normalized.effectiveCwd ?? params.repoRoot);
553
+ const gitRefRoot = normalized.gitDir
554
+ ? path.resolve(effectiveCwd, normalized.gitDir)
555
+ : workTreeRoot;
556
+ const gitControlRoot = normalized.gitDir ? gitRefRoot : path.join(gitRefRoot, '.git');
557
+ const requirements = [];
558
+ if (subcommand === 'fetch' || subcommand === 'pull') {
559
+ const positionals = gitRemotePositionals(args);
560
+ if (subcommand === 'fetch' && args.includes('--all')) {
561
+ return [
562
+ gitRequirement('network.connect', 'network.connect', {
563
+ kind: 'network',
564
+ host: 'unknown',
565
+ protocol: 'git-remote',
566
+ mode: 'read',
567
+ payload: 'none',
568
+ }, params.segment, [...signals, 'git.fetch_all_remotes_unknown']),
569
+ gitRequirement('git.ref.write', 'git.ref.write', { kind: 'git-ref', ref: 'refs/remotes/*', scope: 'local', repoPath: gitRefRoot }, params.segment, [...signals, 'git.ref.local']),
570
+ gitRequirement('indeterminate', 'indeterminate', { kind: 'unknown' }, params.segment, [
571
+ ...signals,
572
+ 'git.fetch_all_remotes_unknown',
573
+ ]),
574
+ ];
575
+ }
576
+ const multiple = subcommand === 'fetch' && args.includes('--multiple');
577
+ const remoteTokens = multiple ? positionals : [positionals[0]];
578
+ for (const remoteToken of remoteTokens) {
579
+ const remote = gitRemoteResource(remoteToken);
580
+ requirements.push(gitRequirement('network.connect', 'network.connect', {
581
+ kind: 'network',
582
+ ...remote.endpoint,
583
+ mode: 'read',
584
+ payload: 'none',
585
+ }, params.segment, [...signals, 'git.network.read']), gitRequirement('git.ref.write', 'git.ref.write', {
586
+ kind: 'git-ref',
587
+ ref: remote.ref ? `refs/remotes/${remote.ref}/*` : 'refs/remotes/*',
588
+ scope: 'local',
589
+ repoPath: gitRefRoot,
590
+ }, params.segment, [...signals, 'git.ref.local']));
591
+ }
592
+ const hazardousFetchSignals = gitFetchControlSignals(params.tokens, args, remoteTokens);
593
+ if (hazardousFetchSignals.length > 0) {
594
+ requirements.push(gitRequirement('indeterminate', 'indeterminate', { kind: 'unknown' }, params.segment, [
595
+ ...signals,
596
+ ...hazardousFetchSignals,
597
+ ]));
598
+ }
599
+ if (multiple && positionals.length === 0) {
600
+ requirements.push(gitRequirement('indeterminate', 'indeterminate', { kind: 'unknown' }, params.segment, [
601
+ ...signals,
602
+ 'git.fetch_multiple_remote_missing',
603
+ ]));
604
+ }
605
+ if (subcommand === 'pull') {
606
+ requirements.push(gitRequirement('fs.write', 'fs.write', { kind: 'path', path: workTreeRoot }, params.segment, [...signals, 'git.worktree.update']));
607
+ }
608
+ return requirements;
609
+ }
610
+ if (subcommand === 'push') {
611
+ const positionals = gitRemotePositionals(args);
612
+ const repositoryOption = gitOptionValue(args, '--repo');
613
+ const remote = gitRemoteResource(repositoryOption.value ?? positionals[0]);
614
+ const refs = repositoryOption.value ? positionals : positionals.slice(1);
615
+ const lowered = [
616
+ gitRequirement('network.connect', 'network.connect', {
617
+ kind: 'network',
618
+ ...remote.endpoint,
619
+ mode: 'mutate',
620
+ payload: 'present',
621
+ }, params.segment, [...signals, 'git.network.mutate']),
622
+ ];
623
+ for (const ref of refs.length > 0 ? refs : ['*']) {
624
+ lowered.push(gitRequirement('git.ref.write', 'git.ref.write', {
625
+ kind: 'git-ref',
626
+ ref: ref.startsWith('refs/') ? ref : `refs/heads/${ref}`,
627
+ scope: 'remote',
628
+ }, params.segment, [...signals, 'git.ref.remote']));
629
+ }
630
+ if (repositoryOption.missing) {
631
+ lowered.push(gitRequirement('indeterminate', 'indeterminate', { kind: 'unknown' }, params.segment, [
632
+ ...signals,
633
+ 'git.push_repository_missing',
634
+ ]));
635
+ }
636
+ return lowered;
637
+ }
638
+ if (subcommand === 'reflog') {
639
+ const operation = args[0];
640
+ const mutationFlags = [
641
+ '--expire',
642
+ '--expire-unreachable',
643
+ '--rewrite',
644
+ '--stale-fix',
645
+ '--updateref',
646
+ ];
647
+ const mutating = operation === 'expire' ||
648
+ operation === 'delete' ||
649
+ operation === 'drop' ||
650
+ args.some((arg) => mutationFlags.some((flag) => arg === flag || arg.startsWith(`${flag}=`)));
651
+ if (mutating) {
652
+ return [
653
+ gitRequirement('process.exec', 'process.exec', { kind: 'executable', command: 'git', operation: 'spawn' }, params.segment, [...signals, 'git_history_destructive', 'git.reflog.mutate']),
654
+ gitRequirement('control_plane.write', 'control_plane.write', { kind: 'path', path: path.join(gitControlRoot, 'logs') }, params.segment, [...signals, 'git_history_destructive', 'git.reflog.mutate']),
655
+ ];
656
+ }
657
+ if (isReadOnlyReflogInvocation(args)) {
658
+ return [
659
+ gitRequirement('process.exec', 'process.exec', { kind: 'executable', command: 'git', operation: 'inspect' }, params.segment, [...signals, 'git.inspect']),
660
+ gitRequirement('fs.read', 'fs.read', { kind: 'path', path: workTreeRoot }, params.segment, [
661
+ ...signals,
662
+ 'git.repository.read',
663
+ ]),
664
+ ];
665
+ }
666
+ return [
667
+ gitRequirement('process.exec', 'process.exec', { kind: 'executable', command: 'git', operation: 'spawn' }, params.segment, signals),
668
+ gitRequirement('indeterminate', 'indeterminate', { kind: 'unknown' }, params.segment, [
669
+ ...signals,
670
+ 'git.reflog.grammar_incomplete',
671
+ ]),
672
+ ];
673
+ }
674
+ const semantics = classifyGitCommand(params.tokens, params.cwd);
675
+ if (!semantics) {
676
+ return null;
677
+ }
678
+ if (semantics.isReadOnly) {
679
+ requirements.push(gitRequirement('process.exec', 'process.exec', { kind: 'executable', command: 'git', operation: 'inspect' }, params.segment, [...signals, 'git.inspect']), gitRequirement('fs.read', 'fs.read', { kind: 'path', path: workTreeRoot }, params.segment, [
680
+ ...signals,
681
+ 'git.repository.read',
682
+ ]));
683
+ for (const operand of semantics.pathTargets) {
684
+ requirements.push(gitRequirement('fs.read', 'fs.read', { kind: 'path', path: path.resolve(workTreeRoot, operand) }, params.segment, [...signals, 'git.path.read']));
685
+ }
686
+ return requirements;
687
+ }
688
+ if (semantics.effect === 'local_mutation') {
689
+ requirements.push(gitRequirement('process.exec', 'process.exec', { kind: 'executable', command: 'git', operation: 'spawn' }, params.segment, signals), gitRequirement('git.ref.write', 'git.ref.write', {
690
+ kind: 'git-ref',
691
+ ref: localGitRef(subcommand, args),
692
+ scope: 'local',
693
+ repoPath: gitRefRoot,
694
+ }, params.segment, [...signals, 'git.ref.local']));
695
+ for (const operand of semantics.pathTargets) {
696
+ requirements.push(gitRequirement('fs.write', 'fs.write', { kind: 'path', path: path.resolve(workTreeRoot, operand) }, params.segment, [...signals, 'git.path.write']));
697
+ }
698
+ if (semantics.requiresAsk) {
699
+ requirements.push(gitRequirement('fs.write', 'fs.write', { kind: 'path', path: workTreeRoot }, params.segment, [...signals, ...semantics.requiresAsk.signals, 'git.destructive_worktree_write']), gitRequirement('control_plane.write', 'control_plane.write', { kind: 'path', path: gitControlRoot }, params.segment, [...signals, ...semantics.requiresAsk.signals, 'git.destructive_history_write']));
700
+ }
701
+ return requirements;
702
+ }
703
+ return [
704
+ gitRequirement('process.exec', 'process.exec', { kind: 'executable', command: 'git', operation: 'spawn' }, params.segment, signals),
705
+ gitRequirement('indeterminate', 'indeterminate', { kind: 'unknown' }, params.segment, [
706
+ ...signals,
707
+ 'git.grammar_incomplete',
708
+ ]),
709
+ ];
710
+ }
711
+ function isReadOnlyReflogInvocation(args) {
712
+ if (args[0] === 'exists') {
713
+ return args.length === 2 && Boolean(args[1]) && !(args[1] ?? '').startsWith('-');
714
+ }
715
+ let index = args[0] === 'show' || args[0] === 'list' ? 1 : 0;
716
+ let positionalCount = 0;
717
+ while (index < args.length) {
718
+ const arg = args[index] ?? '';
719
+ if (arg === '--all' || /^-\d+$/.test(arg)) {
720
+ index += 1;
721
+ continue;
722
+ }
723
+ if (arg === '-n' || arg === '--max-count') {
724
+ const count = args[index + 1];
725
+ if (!count || !/^\d+$/.test(count)) {
726
+ return false;
727
+ }
728
+ index += 2;
729
+ continue;
730
+ }
731
+ if (/^-n\d+$/.test(arg) || /^--(?:max-count|date|format|pretty)=.+/.test(arg)) {
732
+ index += 1;
733
+ continue;
734
+ }
735
+ if (arg.startsWith('-')) {
736
+ return false;
737
+ }
738
+ positionalCount += 1;
739
+ if (args[0] !== 'show' && args[0] !== 'list') {
740
+ return false;
741
+ }
742
+ if (positionalCount > 1) {
743
+ return false;
744
+ }
745
+ index += 1;
746
+ }
747
+ return true;
748
+ }
749
+ const GIT_REMOTE_OPTIONS_WITH_VALUES = new Set([
750
+ '--depth',
751
+ '--exec',
752
+ '--filter',
753
+ '--jobs',
754
+ '--negotiation-tip',
755
+ '--push-option',
756
+ '--repo',
757
+ '--receive-pack',
758
+ '--recurse-submodules',
759
+ '--server-option',
760
+ '--shallow-exclude',
761
+ '--shallow-since',
762
+ '--upload-pack',
763
+ '-j',
764
+ '-o',
765
+ ]);
766
+ function gitFetchControlSignals(tokens, args, remoteTokens) {
767
+ const signals = [];
768
+ if (tokens.some((token) => token === '-c' ||
769
+ (token.startsWith('-c') && token.length > 2) ||
770
+ token === '--config-env' ||
771
+ token.startsWith('--config-env=') ||
772
+ token === '--exec-path' ||
773
+ token.startsWith('--exec-path='))) {
774
+ signals.push('git.fetch.global_execution_override');
775
+ }
776
+ if (args.some((arg) => ['--exec', '--server-option', '--upload-pack'].some((option) => arg === option || arg.startsWith(`${option}=`)))) {
777
+ signals.push('git.fetch.caller_controlled_execution_or_payload');
778
+ }
779
+ if (remoteTokens.some((remote) => remote?.startsWith('ext::'))) {
780
+ signals.push('git.fetch.external_remote_helper');
781
+ }
782
+ return signals;
783
+ }
784
+ function gitOptionValue(args, option) {
785
+ const exactIndex = args.indexOf(option);
786
+ if (exactIndex >= 0) {
787
+ const value = args[exactIndex + 1];
788
+ return value && !value.startsWith('-') ? { value, missing: false } : { missing: true };
789
+ }
790
+ const prefix = `${option}=`;
791
+ const inline = args.find((arg) => arg.startsWith(prefix));
792
+ if (inline !== undefined) {
793
+ const value = inline.slice(prefix.length);
794
+ return value ? { value, missing: false } : { missing: true };
795
+ }
796
+ return { missing: false };
797
+ }
798
+ function gitRemotePositionals(args) {
799
+ const positionals = [];
800
+ for (let index = 0; index < args.length; index += 1) {
801
+ const arg = args[index] ?? '';
802
+ if (arg === '--') {
803
+ positionals.push(...args.slice(index + 1));
804
+ break;
805
+ }
806
+ if (arg.startsWith('-')) {
807
+ if (GIT_REMOTE_OPTIONS_WITH_VALUES.has(arg) && !arg.includes('=')) {
808
+ index += args[index + 1] ? 1 : 0;
809
+ }
810
+ continue;
811
+ }
812
+ positionals.push(arg);
813
+ }
814
+ return positionals;
815
+ }
816
+ function gitRemoteResource(remoteToken) {
817
+ const token = remoteToken && !remoteToken.startsWith('-') ? remoteToken : undefined;
818
+ const endpoint = token
819
+ ? parseNetworkEndpoint(token, { allowHostedGitShorthand: true, allowScpStyle: true })
820
+ : null;
821
+ if (endpoint) {
822
+ return { endpoint };
823
+ }
824
+ const ref = token && !token.includes('/') ? token : undefined;
825
+ return {
826
+ endpoint: { host: ref ?? 'origin', protocol: 'git-remote' },
827
+ ...(ref ? { ref } : {}),
828
+ };
829
+ }
830
+ function localGitRef(subcommand, args) {
831
+ if (subcommand === 'branch') {
832
+ const name = args.find((arg) => arg && !arg.startsWith('-'));
833
+ return name ? `refs/heads/${name}` : 'refs/heads/*';
834
+ }
835
+ if (subcommand === 'tag') {
836
+ const name = args.find((arg) => arg && !arg.startsWith('-'));
837
+ return name ? `refs/tags/${name}` : 'refs/tags/*';
838
+ }
839
+ return 'refs/local/*';
840
+ }
841
+ function gitRequirement(tag, action, resource, segment, signals) {
842
+ return {
843
+ tag,
844
+ action,
845
+ resource,
846
+ evidence: {
847
+ level: tag === 'indeterminate' ? 'indeterminate' : 'certain',
848
+ signals: [...new Set(signals)].sort(),
849
+ basis: ['git_grammar'],
850
+ },
851
+ provenance: { segment },
852
+ };
853
+ }
@@ -123,32 +123,47 @@ function resolveNpmRecipe(cwd, repoRoot, scriptName, extraArgs) {
123
123
  if (!scripts || typeof scripts !== 'object') {
124
124
  return { recipes: [], opaque: true, reason: 'package_scripts_missing' };
125
125
  }
126
- const recipe = scripts[scriptName];
126
+ const scriptMap = scripts;
127
+ const recipe = scriptMap[scriptName];
127
128
  if (!recipe || typeof recipe !== 'string') {
128
129
  if (/deploy|publish|release|ship|prod/i.test(scriptName)) {
129
130
  return { recipes: [], opaque: true, reason: 'external_script' };
130
131
  }
131
132
  return { recipes: [], opaque: true, reason: 'npm_script_undefined' };
132
133
  }
133
- if (/\$\(/.test(recipe) || /\$\{/.test(recipe)) {
134
- return { recipes: [], opaque: true, reason: 'npm_script_dynamic' };
134
+ const lifecycleRecipes = [
135
+ scriptMap[`pre${scriptName}`],
136
+ applyForwardedArgs(recipe, extraArgs),
137
+ scriptMap[`post${scriptName}`],
138
+ ].filter((entry) => typeof entry === 'string' && entry.trim().length > 0);
139
+ if (lifecycleRecipes.some((entry) => /\$\(/.test(entry) || /\$\{/.test(entry))) {
140
+ return { recipes: lifecycleRecipes, opaque: true, reason: 'npm_script_dynamic' };
135
141
  }
136
142
  return {
137
- recipes: [applyForwardedArgs(recipe, extraArgs)],
143
+ recipes: lifecycleRecipes,
138
144
  opaque: false,
139
145
  reason: 'npm_script_resolved',
140
146
  };
141
147
  }
142
148
  function parseMakefileRecipes(makefilePath) {
143
- const recipes = new Map();
149
+ const targets = new Map();
144
150
  try {
145
151
  const content = readFileSync(makefilePath, 'utf8');
146
152
  const lines = content.split('\n');
147
153
  let currentTarget = null;
148
154
  let recipeLines = [];
149
155
  const flush = () => {
150
- if (currentTarget && recipeLines.length > 0) {
151
- recipes.set(currentTarget, recipeLines.map((line) => line.trim()).filter((line) => line.length > 0));
156
+ if (currentTarget) {
157
+ const current = targets.get(currentTarget) ?? {
158
+ prerequisites: [],
159
+ recipes: [],
160
+ opaquePrerequisites: false,
161
+ };
162
+ targets.set(currentTarget, {
163
+ prerequisites: current.prerequisites,
164
+ recipes: recipeLines.map((line) => line.trim()).filter((line) => line.length > 0),
165
+ opaquePrerequisites: current.opaquePrerequisites,
166
+ });
152
167
  }
153
168
  currentTarget = null;
154
169
  recipeLines = [];
@@ -157,13 +172,26 @@ function parseMakefileRecipes(makefilePath) {
157
172
  if (line.trim().startsWith('#')) {
158
173
  continue;
159
174
  }
160
- const targetMatch = /^([A-Za-z0-9_.-]+)\s*:(?!=)/.exec(line);
175
+ const targetMatch = /^([A-Za-z0-9_.-]+)\s*:(?!=)\s*([^#]*)/.exec(line);
161
176
  if (targetMatch) {
162
177
  flush();
163
178
  currentTarget = targetMatch[1] ?? null;
164
- const inline = line.slice(targetMatch[0].length).trim();
165
- if (inline && !inline.startsWith('#')) {
166
- recipeLines.push(inline);
179
+ if (currentTarget) {
180
+ const targetBody = (targetMatch[2] ?? '').trim();
181
+ const inlineRecipeIndex = targetBody.indexOf(';');
182
+ const prerequisiteText = inlineRecipeIndex >= 0 ? targetBody.slice(0, inlineRecipeIndex).trim() : targetBody;
183
+ const inlineRecipe = inlineRecipeIndex >= 0 ? targetBody.slice(inlineRecipeIndex + 1).trim() : '';
184
+ const prerequisiteTokens = prerequisiteText
185
+ .split(/\s+/)
186
+ .filter((token) => token && token !== '|');
187
+ targets.set(currentTarget, {
188
+ prerequisites: prerequisiteTokens.filter((token) => !token.includes('$(') && !token.includes('${')),
189
+ recipes: [],
190
+ opaquePrerequisites: prerequisiteTokens.some((token) => token.includes('$(') || token.includes('${')),
191
+ });
192
+ if (inlineRecipe) {
193
+ recipeLines.push(inlineRecipe);
194
+ }
167
195
  }
168
196
  continue;
169
197
  }
@@ -174,9 +202,9 @@ function parseMakefileRecipes(makefilePath) {
174
202
  flush();
175
203
  }
176
204
  catch {
177
- return recipes;
205
+ return targets;
178
206
  }
179
- return recipes;
207
+ return targets;
180
208
  }
181
209
  function resolveMakeRecipe(cwd, repoRoot, target) {
182
210
  const candidates = ['Makefile', 'makefile', 'GNUmakefile'];
@@ -199,16 +227,48 @@ function resolveMakeRecipe(cwd, repoRoot, target) {
199
227
  if (!makefilePath) {
200
228
  return { recipes: [], opaque: true, reason: 'unknown_local_effect' };
201
229
  }
202
- const recipes = parseMakefileRecipes(makefilePath);
203
- const recipeLines = recipes.get(target);
204
- if (!recipeLines || recipeLines.length === 0) {
230
+ const targets = parseMakefileRecipes(makefilePath);
231
+ if (!targets.has(target)) {
205
232
  return { recipes: [], opaque: true, reason: 'make_target_undefined' };
206
233
  }
234
+ const recipeLines = [];
235
+ const visiting = new Set();
236
+ const visited = new Set();
237
+ let opaquePrerequisites = false;
238
+ const collect = (name) => {
239
+ if (visited.has(name)) {
240
+ return true;
241
+ }
242
+ if (visiting.has(name)) {
243
+ return false;
244
+ }
245
+ const entry = targets.get(name);
246
+ if (!entry) {
247
+ return true;
248
+ }
249
+ visiting.add(name);
250
+ opaquePrerequisites ||= entry.opaquePrerequisites;
251
+ for (const prerequisite of entry.prerequisites) {
252
+ if (targets.has(prerequisite) && !collect(prerequisite)) {
253
+ return false;
254
+ }
255
+ }
256
+ recipeLines.push(...entry.recipes);
257
+ visiting.delete(name);
258
+ visited.add(name);
259
+ return true;
260
+ };
261
+ if (!collect(target)) {
262
+ return { recipes: recipeLines, opaque: true, reason: 'make_dependency_cycle' };
263
+ }
207
264
  for (const line of recipeLines) {
208
265
  if (/\$\(/.test(line) || /\$\{/.test(line)) {
209
- return { recipes: [], opaque: true, reason: 'make_recipe_dynamic' };
266
+ return { recipes: recipeLines, opaque: true, reason: 'make_recipe_dynamic' };
210
267
  }
211
268
  }
269
+ if (opaquePrerequisites) {
270
+ return { recipes: recipeLines, opaque: true, reason: 'make_prerequisite_dynamic' };
271
+ }
212
272
  return { recipes: recipeLines, opaque: false, reason: 'make_recipe_resolved' };
213
273
  }
214
274
  export function resolveLauncherRecipe(params) {
@@ -15,7 +15,9 @@ export declare function extractEvalBody(tokens: string[]): string | null;
15
15
  export declare function extractRecursiveScript(tokens: string[]): string | null;
16
16
  export declare function isBareInterpreter(tokens: string[]): boolean;
17
17
  export declare function splitTopLevelSegments(command: string): string[];
18
+ export declare function splitStructuralShellSegments(command: string): string[];
18
19
  export declare function parseSegment(command: string): ParsedSegment;
19
20
  export declare function segmentOpacity(command: string): VerdictOpacity;
20
21
  export declare function substitutionInners(command: string): string[];
22
+ export declare function structuralSubstitutionInners(command: string): string[];
21
23
  export declare function redactCommand(command: string): string;
@@ -1,5 +1,5 @@
1
1
  import path from 'node:path';
2
- import { findCommandSubstitutions } from '../shell-substitution.js';
2
+ import { findCommandSubstitutions, findStructuralCommandSubstitutions, } from '../shell-substitution.js';
3
3
  import { commandKey, tokenizeShell } from '../shell-tokenizer.js';
4
4
  import { detectUnparseableShell } from '../shell-unparseable.js';
5
5
  const ENV_PREFIX_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*=(?:'[^']*'|"[^"]*"|\S+)$/;
@@ -174,6 +174,97 @@ export function splitTopLevelSegments(command) {
174
174
  flush();
175
175
  return segments.filter((segment) => segment.trim().length > 0);
176
176
  }
177
+ export function splitStructuralShellSegments(command) {
178
+ const segments = [];
179
+ let current = '';
180
+ const outer = { quote: null, escaping: false, backtick: false };
181
+ const substitutions = [];
182
+ const flush = () => {
183
+ const segment = current.trim();
184
+ if (segment) {
185
+ segments.push(segment);
186
+ }
187
+ current = '';
188
+ };
189
+ for (let index = 0; index < command.length; index += 1) {
190
+ const char = command[index] ?? '';
191
+ const next = command[index + 1] ?? '';
192
+ const frame = substitutions.at(-1) ?? outer;
193
+ if (frame.escaping) {
194
+ current += char;
195
+ frame.escaping = false;
196
+ continue;
197
+ }
198
+ if (char === '\\' && frame.quote !== "'") {
199
+ current += char;
200
+ frame.escaping = true;
201
+ continue;
202
+ }
203
+ if (frame.quote === "'") {
204
+ current += char;
205
+ if (char === "'") {
206
+ frame.quote = null;
207
+ }
208
+ continue;
209
+ }
210
+ if (char === '`') {
211
+ frame.backtick = !frame.backtick;
212
+ current += char;
213
+ continue;
214
+ }
215
+ if (frame.backtick) {
216
+ current += char;
217
+ continue;
218
+ }
219
+ if (char === '$' && next === '(') {
220
+ current += '$(';
221
+ substitutions.push({ quote: null, escaping: false, backtick: false });
222
+ index += 1;
223
+ continue;
224
+ }
225
+ if (frame.quote) {
226
+ current += char;
227
+ if (char === frame.quote) {
228
+ frame.quote = null;
229
+ }
230
+ continue;
231
+ }
232
+ if (char === "'" || char === '"') {
233
+ frame.quote = char;
234
+ current += char;
235
+ continue;
236
+ }
237
+ if (substitutions.length > 0 && char === ')') {
238
+ substitutions.pop();
239
+ current += char;
240
+ continue;
241
+ }
242
+ if (substitutions.length > 0) {
243
+ current += char;
244
+ continue;
245
+ }
246
+ if (char === '&' &&
247
+ (command[index - 1] === '>' || command[index - 1] === '<' || next === '>')) {
248
+ current += char;
249
+ continue;
250
+ }
251
+ const twoCharacterOperator = (char === '&' && next === '&') ||
252
+ (char === '|' && next === '|') ||
253
+ (char === '|' && next === '&');
254
+ if (twoCharacterOperator) {
255
+ flush();
256
+ index += 1;
257
+ continue;
258
+ }
259
+ if (char === ';' || char === '|' || char === '&' || char === '\n' || char === '\r') {
260
+ flush();
261
+ continue;
262
+ }
263
+ current += char;
264
+ }
265
+ flush();
266
+ return segments;
267
+ }
177
268
  export function parseSegment(command) {
178
269
  const tokens = tokenizeShell(command);
179
270
  const { tokens: peeled } = peelTransparentWrappers(tokens);
@@ -210,6 +301,9 @@ export function segmentOpacity(command) {
210
301
  export function substitutionInners(command) {
211
302
  return findCommandSubstitutions(command);
212
303
  }
304
+ export function structuralSubstitutionInners(command) {
305
+ return findStructuralCommandSubstitutions(command);
306
+ }
213
307
  export function redactCommand(command) {
214
308
  return command
215
309
  .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [REDACTED]')
@@ -1,7 +1,7 @@
1
1
  import type { PolicyDecision } from '../capability/policy-types.js';
2
2
  import type { CapabilityRequestV1 } from '../capability/request.js';
3
3
  import type { BelayConfigV4 } from '../config.js';
4
- import type { EffectPlan } from '../effect-ir/types.js';
4
+ import type { EffectPlan, EffectPlanPolicyProjection } from '../effect-ir/types.js';
5
5
  export type VerdictPermission = 'allow' | 'ask';
6
6
  export type VerdictLocation = 'repo_local' | 'repo_outside' | 'external' | 'mixed' | 'unknown';
7
7
  export type VerdictOpacity = 'transparent' | 'recursive' | 'opaque' | 'unparseable';
@@ -22,6 +22,7 @@ export interface VerdictResult {
22
22
  authorizationDecision?: PolicyDecision;
23
23
  effectPlan?: EffectPlan;
24
24
  effectPlanPolicyDecisions?: PolicyDecision[];
25
+ effectPlanProjection?: EffectPlanPolicyProjection;
25
26
  }
26
27
  export interface Tier1Verdict {
27
28
  /** true = undoable via git/fs snapshot or trivial local revert (ADR-002 local-recoverable). */
@@ -75,8 +76,6 @@ export interface VerdictContext {
75
76
  trustedWorkspaceRoots?: string[];
76
77
  sensitivePaths: string[];
77
78
  protectedArtifactRoots?: string[];
78
- customAllowCommands?: string[];
79
- customExternalCommands?: string[];
80
79
  mode: VerdictMode;
81
80
  unknownLocalEffect: 'allow_flagged' | 'deny';
82
81
  unparseableShell: 'allow_flagged' | 'deny';