@guilz-dev/belay 0.6.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 (183) hide show
  1. package/README.md +39 -20
  2. package/dist/adapters/claude/runtime-entry.js +54 -25
  3. package/dist/adapters/shared/gate-runtime.d.ts +7 -0
  4. package/dist/adapters/shared/gate-runtime.js +443 -225
  5. package/dist/bundle/claude-runtime.mjs +13027 -7271
  6. package/dist/bundle/codex-runtime.mjs +12700 -6977
  7. package/dist/bundle/cursor-runtime.mjs +12695 -6977
  8. package/dist/cli.js +37 -1
  9. package/dist/commands/approve.js +34 -10
  10. package/dist/commands/doctor.js +58 -0
  11. package/dist/commands/dogfood.js +1 -1
  12. package/dist/commands/explain.js +2 -2
  13. package/dist/commands/harvest.d.ts +0 -1
  14. package/dist/commands/harvest.js +1 -4
  15. package/dist/commands/metrics.js +6 -0
  16. package/dist/commands/quality.js +1 -3
  17. package/dist/commands/recovery-checkpoints.d.ts +164 -0
  18. package/dist/commands/recovery-checkpoints.js +347 -0
  19. package/dist/commands/revoke.js +19 -7
  20. package/dist/commands/status.js +7 -0
  21. package/dist/config-io.js +49 -18
  22. package/dist/conformance/guarantee-table.js +137 -9
  23. package/dist/conformance/types.d.ts +2 -0
  24. package/dist/core/approval-replay.d.ts +1 -1
  25. package/dist/core/approval-replay.js +4 -1
  26. package/dist/core/approval-service.d.ts +6 -1
  27. package/dist/core/approval-service.js +86 -27
  28. package/dist/core/approval.d.ts +2 -0
  29. package/dist/core/approval.js +6 -3
  30. package/dist/core/audit-metrics.d.ts +1 -0
  31. package/dist/core/audit-metrics.js +12 -4
  32. package/dist/core/audit-types.d.ts +16 -0
  33. package/dist/core/capability/approval-state-mutation.d.ts +19 -0
  34. package/dist/core/capability/approval-state-mutation.js +76 -3
  35. package/dist/core/capability/approval-v3.d.ts +18 -1
  36. package/dist/core/capability/approval-v3.js +63 -23
  37. package/dist/core/capability/attestation.d.ts +3 -0
  38. package/dist/core/capability/attestation.js +7 -0
  39. package/dist/core/capability/boundary-driver-container.d.ts +12 -0
  40. package/dist/core/capability/boundary-driver-container.js +101 -34
  41. package/dist/core/capability/boundary-driver.js +1 -0
  42. package/dist/core/capability/boundary-grant-materialize.d.ts +1 -0
  43. package/dist/core/capability/boundary-grant-materialize.js +15 -0
  44. package/dist/core/capability/boundary-run.d.ts +25 -0
  45. package/dist/core/capability/boundary-run.js +37 -9
  46. package/dist/core/capability/boundary-session.d.ts +4 -0
  47. package/dist/core/capability/boundary-session.js +9 -0
  48. package/dist/core/capability/boundary-workspace-mount.d.ts +5 -0
  49. package/dist/core/capability/boundary-workspace-mount.js +68 -0
  50. package/dist/core/capability/gate-policy-shadow.js +23 -1
  51. package/dist/core/capability/grant-consumption.d.ts +4 -0
  52. package/dist/core/capability/grant-consumption.js +11 -0
  53. package/dist/core/capability/grant-lease.d.ts +36 -1
  54. package/dist/core/capability/grant-lease.js +213 -16
  55. package/dist/core/capability/grant-match.js +3 -0
  56. package/dist/core/capability/index.d.ts +2 -2
  57. package/dist/core/capability/index.js +1 -1
  58. package/dist/core/capability/policy-engine.d.ts +38 -1
  59. package/dist/core/capability/policy-engine.js +388 -15
  60. package/dist/core/capability/request.d.ts +12 -0
  61. package/dist/core/capability/resolver.d.ts +0 -1
  62. package/dist/core/capability/resolver.js +0 -1
  63. package/dist/core/config.d.ts +20 -0
  64. package/dist/core/config.js +62 -2
  65. package/dist/core/effect-ir/audit.d.ts +9 -0
  66. package/dist/core/effect-ir/audit.js +87 -0
  67. package/dist/core/effect-ir/build.d.ts +31 -0
  68. package/dist/core/effect-ir/build.js +281 -0
  69. package/dist/core/effect-ir/index.d.ts +13 -0
  70. package/dist/core/effect-ir/index.js +9 -0
  71. package/dist/core/effect-ir/normalize.d.ts +4 -0
  72. package/dist/core/effect-ir/normalize.js +110 -0
  73. package/dist/core/effect-ir/package-exec-eval.d.ts +8 -0
  74. package/dist/core/effect-ir/package-exec-eval.js +152 -0
  75. package/dist/core/effect-ir/package-exec.d.ts +29 -0
  76. package/dist/core/effect-ir/package-exec.js +292 -0
  77. package/dist/core/effect-ir/policy.d.ts +14 -0
  78. package/dist/core/effect-ir/policy.js +164 -0
  79. package/dist/core/effect-ir/shell-build.d.ts +43 -0
  80. package/dist/core/effect-ir/shell-build.js +77 -0
  81. package/dist/core/effect-ir/shell-lower.d.ts +14 -0
  82. package/dist/core/effect-ir/shell-lower.js +1509 -0
  83. package/dist/core/effect-ir/types.d.ts +62 -0
  84. package/dist/core/effect-ir/types.js +1 -0
  85. package/dist/core/egress/allowlist.d.ts +1 -0
  86. package/dist/core/egress/allowlist.js +8 -0
  87. package/dist/core/egress-approval.js +45 -51
  88. package/dist/core/gate-contract.d.ts +2 -0
  89. package/dist/core/gate-contract.js +2 -0
  90. package/dist/core/gate-engine.js +82 -25
  91. package/dist/core/git-resource-identity.d.ts +26 -0
  92. package/dist/core/git-resource-identity.js +284 -0
  93. package/dist/core/harvest.d.ts +3 -7
  94. package/dist/core/harvest.js +4 -27
  95. package/dist/core/index.d.ts +5 -1
  96. package/dist/core/index.js +3 -1
  97. package/dist/core/judge-cloud-consent.js +29 -15
  98. package/dist/core/network-endpoint.d.ts +10 -0
  99. package/dist/core/network-endpoint.js +51 -0
  100. package/dist/core/path-utils.js +42 -23
  101. package/dist/core/process-runner.d.ts +10 -0
  102. package/dist/core/process-runner.js +124 -0
  103. package/dist/core/recover-advice.js +2 -1
  104. package/dist/core/recovery/artifact-store.d.ts +29 -0
  105. package/dist/core/recovery/artifact-store.js +336 -0
  106. package/dist/core/recovery/checkpoint.d.ts +25 -0
  107. package/dist/core/recovery/checkpoint.js +260 -0
  108. package/dist/core/recovery/index.d.ts +3 -1
  109. package/dist/core/recovery/index.js +2 -0
  110. package/dist/core/recovery/operator-guidance.d.ts +7 -0
  111. package/dist/core/recovery/operator-guidance.js +39 -0
  112. package/dist/core/recovery/reconcile.d.ts +3 -0
  113. package/dist/core/recovery/reconcile.js +48 -0
  114. package/dist/core/recovery/resource-identity.d.ts +4 -0
  115. package/dist/core/recovery/resource-identity.js +34 -0
  116. package/dist/core/recovery/restore.d.ts +13 -0
  117. package/dist/core/recovery/restore.js +114 -0
  118. package/dist/core/recovery/snapshot-node.d.ts +27 -0
  119. package/dist/core/recovery/snapshot-node.js +261 -0
  120. package/dist/core/recovery/types.d.ts +85 -1
  121. package/dist/core/shell-substitution.d.ts +1 -0
  122. package/dist/core/shell-substitution.js +61 -0
  123. package/dist/core/standing-allow.d.ts +1 -2
  124. package/dist/core/standing-allow.js +3 -20
  125. package/dist/core/transactional/apply-observed-changes.d.ts +13 -0
  126. package/dist/core/transactional/apply-observed-changes.js +275 -0
  127. package/dist/core/transactional/backend-selector.d.ts +8 -0
  128. package/dist/core/transactional/backend-selector.js +118 -0
  129. package/dist/core/transactional/backend.d.ts +55 -0
  130. package/dist/core/transactional/backend.js +1 -0
  131. package/dist/core/transactional/file-checkpoint-backend.d.ts +3 -0
  132. package/dist/core/transactional/file-checkpoint-backend.js +278 -0
  133. package/dist/core/transactional/file-checkpoint-git.d.ts +11 -0
  134. package/dist/core/transactional/file-checkpoint-git.js +217 -0
  135. package/dist/core/transactional/file-checkpoint-staging.d.ts +13 -0
  136. package/dist/core/transactional/file-checkpoint-staging.js +54 -0
  137. package/dist/core/transactional/file-clone.d.ts +15 -0
  138. package/dist/core/transactional/file-clone.js +139 -0
  139. package/dist/core/transactional/file-tree-path.d.ts +7 -0
  140. package/dist/core/transactional/file-tree-path.js +50 -0
  141. package/dist/core/transactional/file-tree.d.ts +41 -0
  142. package/dist/core/transactional/file-tree.js +186 -0
  143. package/dist/core/transactional/git-worktree-backend.d.ts +2 -0
  144. package/dist/core/transactional/git-worktree-backend.js +50 -0
  145. package/dist/core/transactional/git-worktree.d.ts +9 -11
  146. package/dist/core/transactional/git-worktree.js +24 -119
  147. package/dist/core/transactional/index.d.ts +11 -0
  148. package/dist/core/transactional/index.js +10 -0
  149. package/dist/core/transactional/reasons.js +1 -0
  150. package/dist/core/transactional/runner.js +157 -25
  151. package/dist/core/transactional/snapshot-node.d.ts +26 -0
  152. package/dist/core/transactional/snapshot-node.js +87 -0
  153. package/dist/core/transactional/types.d.ts +17 -1
  154. package/dist/core/types.d.ts +13 -1
  155. package/dist/core/verdict/adapter.js +23 -15
  156. package/dist/core/verdict/containment.js +4 -0
  157. package/dist/core/verdict/egress-classify.d.ts +12 -0
  158. package/dist/core/verdict/egress-classify.js +746 -0
  159. package/dist/core/verdict/git-classifier.d.ts +12 -0
  160. package/dist/core/verdict/git-classifier.js +320 -0
  161. package/dist/core/verdict/launcher-resolve.js +79 -37
  162. package/dist/core/verdict/parser.d.ts +2 -0
  163. package/dist/core/verdict/parser.js +95 -1
  164. package/dist/core/verdict/types.d.ts +6 -2
  165. package/dist/core/verdict/verdict.js +103 -935
  166. package/dist/corpus/must-allow-commands.d.ts +2 -1
  167. package/dist/corpus/must-allow-commands.js +2 -1
  168. package/dist/corpus/runtime-match.d.ts +2 -1
  169. package/dist/corpus/runtime-match.js +2 -1
  170. package/dist/corpus/types.d.ts +2 -2
  171. package/dist/templates.js +7 -2
  172. package/dist/types.d.ts +11 -0
  173. package/dist/version.d.ts +1 -1
  174. package/dist/version.js +1 -1
  175. package/package.json +2 -2
  176. package/skills/belay/SKILL.md +8 -3
  177. package/skills/belay/belay-approve.md +10 -7
  178. package/dist/core/verdict/overrides.d.ts +0 -7
  179. package/dist/core/verdict/overrides.js +0 -37
  180. package/dist/core/verdict/shell-policy.d.ts +0 -25
  181. package/dist/core/verdict/shell-policy.js +0 -40
  182. package/dist/corpus/standing-allow-catalog.generated.d.ts +0 -13
  183. 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
+ }
@@ -34,17 +34,6 @@ const PNPM_BUILTIN_COMMANDS = new Set([
34
34
  'up',
35
35
  'why',
36
36
  ]);
37
- const PNPM_EXEC_LIKE_HEADS = new Set([
38
- 'vitest',
39
- 'vite',
40
- 'biome',
41
- 'eslint',
42
- 'jest',
43
- 'mocha',
44
- 'tsc',
45
- 'tsx',
46
- 'node',
47
- ]);
48
37
  function readPackageJson(dir) {
49
38
  const packagePath = path.join(dir, 'package.json');
50
39
  if (!existsSync(packagePath)) {
@@ -134,32 +123,47 @@ function resolveNpmRecipe(cwd, repoRoot, scriptName, extraArgs) {
134
123
  if (!scripts || typeof scripts !== 'object') {
135
124
  return { recipes: [], opaque: true, reason: 'package_scripts_missing' };
136
125
  }
137
- const recipe = scripts[scriptName];
126
+ const scriptMap = scripts;
127
+ const recipe = scriptMap[scriptName];
138
128
  if (!recipe || typeof recipe !== 'string') {
139
129
  if (/deploy|publish|release|ship|prod/i.test(scriptName)) {
140
130
  return { recipes: [], opaque: true, reason: 'external_script' };
141
131
  }
142
132
  return { recipes: [], opaque: true, reason: 'npm_script_undefined' };
143
133
  }
144
- if (/\$\(/.test(recipe) || /\$\{/.test(recipe)) {
145
- 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' };
146
141
  }
147
142
  return {
148
- recipes: [applyForwardedArgs(recipe, extraArgs)],
143
+ recipes: lifecycleRecipes,
149
144
  opaque: false,
150
145
  reason: 'npm_script_resolved',
151
146
  };
152
147
  }
153
148
  function parseMakefileRecipes(makefilePath) {
154
- const recipes = new Map();
149
+ const targets = new Map();
155
150
  try {
156
151
  const content = readFileSync(makefilePath, 'utf8');
157
152
  const lines = content.split('\n');
158
153
  let currentTarget = null;
159
154
  let recipeLines = [];
160
155
  const flush = () => {
161
- if (currentTarget && recipeLines.length > 0) {
162
- 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
+ });
163
167
  }
164
168
  currentTarget = null;
165
169
  recipeLines = [];
@@ -168,13 +172,26 @@ function parseMakefileRecipes(makefilePath) {
168
172
  if (line.trim().startsWith('#')) {
169
173
  continue;
170
174
  }
171
- const targetMatch = /^([A-Za-z0-9_.-]+)\s*:(?!=)/.exec(line);
175
+ const targetMatch = /^([A-Za-z0-9_.-]+)\s*:(?!=)\s*([^#]*)/.exec(line);
172
176
  if (targetMatch) {
173
177
  flush();
174
178
  currentTarget = targetMatch[1] ?? null;
175
- const inline = line.slice(targetMatch[0].length).trim();
176
- if (inline && !inline.startsWith('#')) {
177
- 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
+ }
178
195
  }
179
196
  continue;
180
197
  }
@@ -185,9 +202,9 @@ function parseMakefileRecipes(makefilePath) {
185
202
  flush();
186
203
  }
187
204
  catch {
188
- return recipes;
205
+ return targets;
189
206
  }
190
- return recipes;
207
+ return targets;
191
208
  }
192
209
  function resolveMakeRecipe(cwd, repoRoot, target) {
193
210
  const candidates = ['Makefile', 'makefile', 'GNUmakefile'];
@@ -210,16 +227,48 @@ function resolveMakeRecipe(cwd, repoRoot, target) {
210
227
  if (!makefilePath) {
211
228
  return { recipes: [], opaque: true, reason: 'unknown_local_effect' };
212
229
  }
213
- const recipes = parseMakefileRecipes(makefilePath);
214
- const recipeLines = recipes.get(target);
215
- if (!recipeLines || recipeLines.length === 0) {
230
+ const targets = parseMakefileRecipes(makefilePath);
231
+ if (!targets.has(target)) {
216
232
  return { recipes: [], opaque: true, reason: 'make_target_undefined' };
217
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
+ }
218
264
  for (const line of recipeLines) {
219
265
  if (/\$\(/.test(line) || /\$\{/.test(line)) {
220
- return { recipes: [], opaque: true, reason: 'make_recipe_dynamic' };
266
+ return { recipes: recipeLines, opaque: true, reason: 'make_recipe_dynamic' };
221
267
  }
222
268
  }
269
+ if (opaquePrerequisites) {
270
+ return { recipes: recipeLines, opaque: true, reason: 'make_prerequisite_dynamic' };
271
+ }
223
272
  return { recipes: recipeLines, opaque: false, reason: 'make_recipe_resolved' };
224
273
  }
225
274
  export function resolveLauncherRecipe(params) {
@@ -232,12 +281,12 @@ export function resolveLauncherRecipe(params) {
232
281
  const resolution = resolveNpmRecipe(params.cwd, params.repoRoot, scriptName, forwardedArgs(tokens));
233
282
  if (tokens[0] === 'pnpm' &&
234
283
  tokens[1] &&
235
- PNPM_EXEC_LIKE_HEADS.has(tokens[1]) &&
284
+ !PNPM_BUILTIN_COMMANDS.has(tokens[1]) &&
236
285
  resolution.reason === 'npm_script_undefined') {
237
286
  return {
238
287
  recipes: [tokens.slice(1).join(' ')],
239
288
  opaque: false,
240
- reason: 'pnpm_exec_like',
289
+ reason: 'pnpm_exec_shorthand',
241
290
  };
242
291
  }
243
292
  return resolution;
@@ -252,13 +301,6 @@ export function resolveLauncherRecipe(params) {
252
301
  reason: 'pnpm_exec',
253
302
  };
254
303
  }
255
- if (tokens[0] === 'pnpm' && tokens[1] && PNPM_EXEC_LIKE_HEADS.has(tokens[1])) {
256
- return {
257
- recipes: [tokens.slice(1).join(' ')],
258
- opaque: false,
259
- reason: 'pnpm_exec_like',
260
- };
261
- }
262
304
  return null;
263
305
  }
264
306
  const READ_ONLY_LAUNCHER_SUFFIXES = new Set(['--version', '-v', '--help', '-h']);
@@ -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]')