@juzser/blacksmith 0.3.0 → 0.5.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 (141) hide show
  1. package/.claude/agents/{auditor.md → bs-auditor.md} +8 -5
  2. package/.claude/agents/{coder.md → bs-coder.md} +8 -7
  3. package/.claude/agents/{grader.md → bs-grader.md} +16 -9
  4. package/.claude/agents/{merger.md → bs-merger.md} +4 -4
  5. package/.claude/agents/{planner.md → bs-planner.md} +20 -6
  6. package/.claude/agents/{researcher.md → bs-researcher.md} +6 -5
  7. package/.claude/agents/{reviewer.md → bs-reviewer.md} +16 -5
  8. package/.claude/agents/{scribe.md → bs-scribe.md} +11 -2
  9. package/.claude/agents/{security-reviewer.md → bs-security-reviewer.md} +9 -6
  10. package/.claude/agents/{spec-reviewer.md → bs-spec-reviewer.md} +18 -10
  11. package/.claude/agents/{tester.md → bs-tester.md} +40 -12
  12. package/.claude/agents/bs-uiux.md +257 -0
  13. package/.claude/agents/{verifier.md → bs-verifier.md} +7 -4
  14. package/.claude/agents/{wave-runner.md → bs-wave-runner.md} +14 -5
  15. package/.claude/skills/bs/SKILL.md +99 -21
  16. package/.claude/skills/bs/audit.md +45 -31
  17. package/.claude/skills/bs/dispatch.md +133 -47
  18. package/.claude/skills/bs/lessons.md +28 -14
  19. package/.claude/skills/bs/mcp.md +4 -4
  20. package/.claude/skills/bs/new.md +3 -3
  21. package/.claude/skills/bs/plan.md +57 -16
  22. package/.claude/skills/bs/report.md +11 -11
  23. package/.claude/skills/bs/run.md +190 -52
  24. package/.claude/skills/bs/status.md +14 -7
  25. package/.claude/skills/bs/ui.md +21 -5
  26. package/.claude/skills/bs/waivers.md +78 -2
  27. package/.claude/skills/bs/wave.md +143 -50
  28. package/README.md +53 -32
  29. package/factory/orchestrator/dist/agentNames.js +43 -0
  30. package/factory/orchestrator/dist/agents-registry.js +11 -4
  31. package/factory/orchestrator/dist/agentsSync.js +70 -13
  32. package/factory/orchestrator/dist/artifacts.js +31 -0
  33. package/factory/orchestrator/dist/attribution.js +4 -0
  34. package/factory/orchestrator/dist/audit.js +245 -20
  35. package/factory/orchestrator/dist/autonomy.js +6 -2
  36. package/factory/orchestrator/dist/budgetAlarm.js +132 -37
  37. package/factory/orchestrator/dist/budgets.js +302 -84
  38. package/factory/orchestrator/dist/claims.js +9 -19
  39. package/factory/orchestrator/dist/cli.js +1087 -116
  40. package/factory/orchestrator/dist/cliName.js +27 -0
  41. package/factory/orchestrator/dist/crossFinding.js +20 -0
  42. package/factory/orchestrator/dist/crosscheck.js +136 -8
  43. package/factory/orchestrator/dist/daemon.js +352 -67
  44. package/factory/orchestrator/dist/db/projector.js +327 -54
  45. package/factory/orchestrator/dist/db/queries.js +1831 -94
  46. package/factory/orchestrator/dist/db/schema.js +57 -4
  47. package/factory/orchestrator/dist/declaredArtifactLine.js +18 -0
  48. package/factory/orchestrator/dist/delegation.js +86 -19
  49. package/factory/orchestrator/dist/diffstat.js +15 -8
  50. package/factory/orchestrator/dist/dispatchLint.js +108 -0
  51. package/factory/orchestrator/dist/effort.js +14 -2
  52. package/factory/orchestrator/dist/effortTiers.js +13 -0
  53. package/factory/orchestrator/dist/env.js +26 -0
  54. package/factory/orchestrator/dist/epic.js +494 -42
  55. package/factory/orchestrator/dist/epicBudget.js +93 -0
  56. package/factory/orchestrator/dist/epicWidth.js +22 -5
  57. package/factory/orchestrator/dist/errorIssues.js +182 -11
  58. package/factory/orchestrator/dist/escalation.js +2 -2
  59. package/factory/orchestrator/dist/events.js +164 -14
  60. package/factory/orchestrator/dist/feedback.js +169 -0
  61. package/factory/orchestrator/dist/findingContext.js +26 -0
  62. package/factory/orchestrator/dist/findings.js +108 -14
  63. package/factory/orchestrator/dist/gate.js +120 -10
  64. package/factory/orchestrator/dist/gh.js +19 -2
  65. package/factory/orchestrator/dist/git.js +20 -2
  66. package/factory/orchestrator/dist/goalCheck.js +2 -2
  67. package/factory/orchestrator/dist/harness.js +25 -17
  68. package/factory/orchestrator/dist/hookDecision.js +411 -39
  69. package/factory/orchestrator/dist/integration.js +20 -8
  70. package/factory/orchestrator/dist/issueReporter.js +81 -16
  71. package/factory/orchestrator/dist/judgePreflight.js +25 -4
  72. package/factory/orchestrator/dist/judgeRoles.js +47 -0
  73. package/factory/orchestrator/dist/judgeStopHook.js +239 -0
  74. package/factory/orchestrator/dist/judges.js +419 -21
  75. package/factory/orchestrator/dist/lessons.js +190 -11
  76. package/factory/orchestrator/dist/logCache.js +291 -0
  77. package/factory/orchestrator/dist/mcp.js +6 -6
  78. package/factory/orchestrator/dist/paths.js +20 -7
  79. package/factory/orchestrator/dist/plan.js +399 -33
  80. package/factory/orchestrator/dist/planQuorum.js +5 -2
  81. package/factory/orchestrator/dist/policy.js +1275 -30
  82. package/factory/orchestrator/dist/projectName.js +25 -0
  83. package/factory/orchestrator/dist/projects.js +12 -1
  84. package/factory/orchestrator/dist/promptCapture.js +306 -0
  85. package/factory/orchestrator/dist/promptHook.js +33 -0
  86. package/factory/orchestrator/dist/promptImport.js +281 -0
  87. package/factory/orchestrator/dist/prompts.js +13 -0
  88. package/factory/orchestrator/dist/providers/schema-validate.js +2 -2
  89. package/factory/orchestrator/dist/providers/types.js +1 -1
  90. package/factory/orchestrator/dist/queue.js +471 -30
  91. package/factory/orchestrator/dist/quorum.js +18 -5
  92. package/factory/orchestrator/dist/results.js +25 -7
  93. package/factory/orchestrator/dist/roadmap.js +10 -5
  94. package/factory/orchestrator/dist/run-cli.js +15 -10
  95. package/factory/orchestrator/dist/runner.js +2 -2
  96. package/factory/orchestrator/dist/scheduler.js +3 -3
  97. package/factory/orchestrator/dist/schemas.js +56 -8
  98. package/factory/orchestrator/dist/severity.js +8 -4
  99. package/factory/orchestrator/dist/spec.js +246 -12
  100. package/factory/orchestrator/dist/specChange.js +13 -9
  101. package/factory/orchestrator/dist/stack.js +6 -6
  102. package/factory/orchestrator/dist/storeRoot.js +90 -0
  103. package/factory/orchestrator/dist/symbols.js +261 -6
  104. package/factory/orchestrator/dist/taskEvents.js +192 -31
  105. package/factory/orchestrator/dist/taskStatus.js +14 -0
  106. package/factory/orchestrator/dist/taxonomy.js +11 -0
  107. package/factory/orchestrator/dist/testerAudit.js +31 -12
  108. package/factory/orchestrator/dist/testgate.js +20 -16
  109. package/factory/orchestrator/dist/uiuxGate.js +83 -0
  110. package/factory/orchestrator/dist/usage.js +113 -48
  111. package/factory/orchestrator/dist/waivers.js +99 -12
  112. package/factory/orchestrator/dist/waveBudget.js +2 -0
  113. package/factory/orchestrator/dist/waveConcurrency.js +143 -7
  114. package/factory/orchestrator/dist/waveSchedule.js +84 -0
  115. package/factory/orchestrator/dist/workroot.js +1 -1
  116. package/factory/orchestrator/dist/worktree.js +76 -21
  117. package/factory/orchestrator/drizzle/0013_operator_feedback.sql +19 -0
  118. package/factory/orchestrator/drizzle/0014_misty_madripoor.sql +2 -0
  119. package/factory/orchestrator/drizzle/0015_gifted_omega_flight.sql +1 -0
  120. package/factory/orchestrator/drizzle/0016_milestones_project_default.sql +30 -0
  121. package/factory/orchestrator/drizzle/0017_events_raw_cli_session_id.sql +2 -0
  122. package/factory/orchestrator/drizzle/meta/_journal.json +35 -0
  123. package/factory/policies/budgets.yml +123 -33
  124. package/factory/policies/crosscheck.yml +51 -13
  125. package/factory/policies/delegation.yml +3 -3
  126. package/factory/policies/effort.yml +18 -10
  127. package/factory/policies/guardrails.yml +8 -6
  128. package/factory/policies/harness.yml +5 -5
  129. package/factory/policies/lessons.md +53 -2
  130. package/factory/policies/scheduler.yml +4 -4
  131. package/factory/policies/sensitive-paths.yml +1 -1
  132. package/factory/policies/severity.yml +13 -1
  133. package/factory/policies/stack.yml +6 -6
  134. package/factory/policies/taxonomy.yml +9 -3
  135. package/factory/policies/worktree.yml +15 -9
  136. package/factory/specs/roadmap.md +27 -3
  137. package/factory/specs/schema/event.schema.json +8 -1
  138. package/factory/specs/schema/result.schema.json +21 -8
  139. package/factory/specs/schema/task-spec.schema.json +12 -0
  140. package/package.json +16 -12
  141. package/.claude/agents/uiux.md +0 -133
@@ -365,7 +365,14 @@ function gitTokens(segment, word) {
365
365
  function isProtectedRef(ref, policy) {
366
366
  return policy.protectedBranchNames.some((name) => ref === name || ref.endsWith(`:${name}`) || ref.endsWith(`:refs/heads/${name}`));
367
367
  }
368
- function isProtectedBranchName(branch, policy) {
368
+ /**
369
+ * Exported so `hookDecision.ts` can tell, on the rule-3 fallback path,
370
+ * whether a directory the command named is itself protected — the
371
+ * difference between "the fallback found nowhere safe to land" (old reason
372
+ * stands) and "the fallback denied cwd, but the named directory would have
373
+ * been fine" (the reason needs to say so).
374
+ */
375
+ export function isProtectedBranchName(branch, policy) {
369
376
  return policy.protectedBranchNames.includes(branch);
370
377
  }
371
378
  /**
@@ -662,14 +669,762 @@ function checkPushToProtected(command, branch, policy) {
662
669
  }
663
670
  return null;
664
671
  }
665
- /** Rule 2: force push (`--force` / `--force-with-lease` / `-f`). Case-sensitive, same as guard.sh (its `grep -Eq` has no `-i`). */
666
- const FORCE_PUSH_RE = /(--force(-with-lease)?\b|(^|\s)-[a-zA-Z]*f[a-zA-Z]*(\s|$))/;
667
- function checkForcePush(command, policy) {
668
- if (!isGitSubcommand(command, 'push'))
672
+ /**
673
+ * Rule 2: force push (`--force` / `--force-with-lease` / `-f` / `--mirror`).
674
+ * Case-sensitive, same as guard.sh (its `grep -Eq` has no `-i`).
675
+ * `--force-if-includes` is already covered: it contains `--force` as a
676
+ * prefix, and `\b` stops at the `-` that follows. `--mirror` shares nothing
677
+ * with `--force`, so it needs its own alternative — a mirror push writes
678
+ * every ref on the remote to match the local one, which is a force push on
679
+ * every branch at once.
680
+ */
681
+ const FORCE_PUSH_RE = /(--force(-with-lease)?\b|--mirror\b|(^|\s)-[a-zA-Z]*f[a-zA-Z]*(\s|$))/;
682
+ const GIT_PUSH_ANYWHERE_RE = new RegExp(`\\bgit\\b[\\s\\S]*${bareWord('push')}`, 'i');
683
+ /**
684
+ * `$'...'` — bash's ANSI-C quoting — is decoded to real bytes before git (or
685
+ * anything else) ever sees it: `\x2d` is `-`, `\146` is `f`, `\x70ush` is
686
+ * `push`. A rule that scans the raw command text for `--force` or `push`
687
+ * never finds either spelled this way, so every regex rule 2 runs is run
688
+ * against this decoded reading instead of the raw segment.
689
+ *
690
+ * Only the escapes bash's own `$'...'` grammar defines are decoded: `\xHH`
691
+ * hex, `\NNN` octal (1-3 digits), `\uHHHH` / `\UHHHHHHHH` Unicode, and the
692
+ * single-letter escapes (`\n`, `\t`, `\\`, `\'`, `\"`, …). Anything else
693
+ * inside the quotes is copied through unchanged — under-decoding only
694
+ * leaves an escape sequence looking like itself, never invents a character
695
+ * that helps an evasion.
696
+ */
697
+ const ANSI_C_ESCAPE_RE = /\\(x[0-9a-fA-F]{1,2}|u[0-9a-fA-F]{1,4}|U[0-9a-fA-F]{1,8}|[0-7]{1,3}|n|t|r|a|b|f|v|e|E|\\|'|")/g;
698
+ function decodeAnsiCPayload(payload) {
699
+ return payload.replace(ANSI_C_ESCAPE_RE, (_match, esc) => {
700
+ switch (esc[0]) {
701
+ case 'x':
702
+ case 'u':
703
+ case 'U':
704
+ return String.fromCodePoint(Number.parseInt(esc.slice(1), 16));
705
+ case 'n':
706
+ return '\n';
707
+ case 't':
708
+ return '\t';
709
+ case 'r':
710
+ return '\r';
711
+ case 'a':
712
+ return '\x07';
713
+ case 'b':
714
+ return '\b';
715
+ case 'f':
716
+ return '\f';
717
+ case 'v':
718
+ return '\v';
719
+ case 'e':
720
+ case 'E':
721
+ return '\x1b';
722
+ case '\\':
723
+ return '\\';
724
+ case "'":
725
+ return "'";
726
+ case '"':
727
+ return '"';
728
+ default:
729
+ // Only octal digits reach here (the `[0-7]{1,3}` branch).
730
+ return String.fromCharCode(Number.parseInt(esc, 8) & 0xff);
731
+ }
732
+ });
733
+ }
734
+ /**
735
+ * Finds the unescaped `'` that closes a `$'` span starting at `start` (the
736
+ * index right after the opening `$'`). A backslash inside the span escapes
737
+ * whatever follows it, including a `'`, so `\'` never ends the span early.
738
+ * Returns -1 when the span runs off the end of the segment unterminated.
739
+ */
740
+ function findAnsiCQuoteEnd(text, start) {
741
+ let i = start;
742
+ while (i < text.length) {
743
+ if (text[i] === '\\') {
744
+ i += 2;
745
+ continue;
746
+ }
747
+ if (text[i] === "'")
748
+ return i;
749
+ i += 1;
750
+ }
751
+ return -1;
752
+ }
753
+ /**
754
+ * Replaces every `$'...'` span in a segment with its decoded content, so the
755
+ * rule-2 checks below read what git will actually receive rather than the
756
+ * quoted spelling. An unterminated span — no closing `'` before the segment
757
+ * ends — cannot be decoded with confidence; `unterminated` tells the caller
758
+ * to fail closed rather than silently reading past it.
759
+ */
760
+ function decodeAnsiCQuoting(segment) {
761
+ let result = '';
762
+ let i = 0;
763
+ while (i < segment.length) {
764
+ if (segment[i] === '$' && segment[i + 1] === "'") {
765
+ const end = findAnsiCQuoteEnd(segment, i + 2);
766
+ if (end === -1)
767
+ return { text: result + segment.slice(i), unterminated: true };
768
+ result += decodeAnsiCPayload(segment.slice(i + 2, end));
769
+ i = end + 1;
770
+ continue;
771
+ }
772
+ result += segment[i];
773
+ i += 1;
774
+ }
775
+ return { text: result, unterminated: false };
776
+ }
777
+ /**
778
+ * Joins a shell line continuation — a backslash immediately followed by a
779
+ * newline — the way bash does before word splitting even starts: the pair
780
+ * is deleted outright, wherever it sits, including mid-word (`gi\`<LF>`t`)
781
+ * and inside double quotes, which is why `git` spelled with one hidden in
782
+ * the middle reads as `git` to every scanner below rather than as two
783
+ * fragments a literal-substring or exact-word match would miss.
784
+ *
785
+ * Two spans are left untouched because bash itself treats a backslash there
786
+ * differently: a single-quoted span is fully literal, so a backslash-newline
787
+ * pair inside it is data, not a continuation; and a `$'...'` span decodes its
788
+ * own escapes — `decodeAnsiCPayload` already reads a `\`-newline pair inside
789
+ * one through unchanged, so joining it here first would be a second, and
790
+ * conflicting, decode of the same text.
791
+ */
792
+ function joinLineContinuations(command) {
793
+ let result = '';
794
+ let i = 0;
795
+ while (i < command.length) {
796
+ const c = command[i];
797
+ if (c === "'") {
798
+ const end = command.indexOf("'", i + 1);
799
+ const spanEnd = end === -1 ? command.length : end + 1;
800
+ result += command.slice(i, spanEnd);
801
+ i = spanEnd;
802
+ continue;
803
+ }
804
+ if (c === '$' && command[i + 1] === "'") {
805
+ const end = findAnsiCQuoteEnd(command, i + 2);
806
+ const spanEnd = end === -1 ? command.length : end + 1;
807
+ result += command.slice(i, spanEnd);
808
+ i = spanEnd;
809
+ continue;
810
+ }
811
+ if (c === '\\' && command[i + 1] === '\n') {
812
+ i += 2;
813
+ continue;
814
+ }
815
+ result += c;
816
+ i += 1;
817
+ }
818
+ return result;
819
+ }
820
+ /**
821
+ * Every way this segment could hand git an alias definition inline, without
822
+ * checking what the alias resolves to:
823
+ *
824
+ * - `-c` or `-c<nospace>` naming an `alias.` key, quoted or not
825
+ * (`-c alias.p=`, `-calias.p=`, `-c 'alias.p=...'`).
826
+ * - `--config-env` naming an `alias.` key, whose value is the *name* of an
827
+ * environment variable read later — never visible on this line at all.
828
+ * - `GIT_CONFIG_PARAMETERS`, git's own serialisation of a whole `-c` list,
829
+ * assigned or exported.
830
+ * - A `GIT_CONFIG_KEY_<n>=` pair whose value is an `alias.` key, or is a
831
+ * shell variable (`$V`, `${V}`) or command substitution (`` `cmd` ``,
832
+ * `$(cmd)`) this scanner cannot resolve — read alongside a
833
+ * `GIT_CONFIG_VALUE_<n>` this line may not even set.
834
+ *
835
+ * Issue #258's original fix denied an inline alias only when its value
836
+ * visibly mentioned `push`. That is not a question this line can always
837
+ * answer: the value can come from a shell variable, a named environment
838
+ * variable read later, or a `GIT_CONFIG_VALUE_<n>` set nowhere near the key.
839
+ * `git -c alias.st=status st` is refused for the same reason as everything
840
+ * else in this file errs the same direction — a rule that cannot always
841
+ * tell "harmless alias" from "force-push escape hatch" apart denies both,
842
+ * because the cost of the false deny is smaller than the cost of the hole.
843
+ */
844
+ function definesInlineGitAlias(segment) {
845
+ if (/(^|\s)-c\s*['"]?alias\./i.test(segment))
846
+ return true;
847
+ if (/--config-env(=|\s+)['"]?alias\./i.test(segment))
848
+ return true;
849
+ if (/\bGIT_CONFIG_PARAMETERS\b/.test(segment))
850
+ return true;
851
+ if (/\bGIT_CONFIG_KEY_\d+=(['"]?)(alias\.|\$|`)/i.test(segment))
852
+ return true;
853
+ return false;
854
+ }
855
+ /**
856
+ * Quote removal is the last step of shell word expansion, and it glues
857
+ * whatever sat on either side of a quote pair into one word: `--for""ce`,
858
+ * `--for''ce` and `-"-"force` all reach git as `--force`, with no
859
+ * `--force` substring ever appearing in the raw command text a regex would
860
+ * scan. Stripping every quote character before the rule-2 checks run reads
861
+ * the segment the way git will — the same "read it the way the shell will"
862
+ * move the `$'...'` decode above and the `+refspec` backslash-strip below
863
+ * already make. Stripping can only pull characters together, never hide
864
+ * one, so it costs nothing a substring check over the raw text was not
865
+ * already blind to.
866
+ */
867
+ function stripSpliceQuotes(text) {
868
+ return text.replace(/['"]/g, '');
869
+ }
870
+ /**
871
+ * A character the shell can still act on inside a word: an escape, a
872
+ * variable or command expansion, a brace list, or a glob. Three rounds of
873
+ * this rule chased individual tricks spelled with these characters — hex
874
+ * escapes, quote-splicing, brace expansion — one at a time, and each fix
875
+ * left the next spelling undetected. This is the fail-closed replacement:
876
+ * rather than keep enumerating tricks, refuse to read a word built from any
877
+ * of them at all.
878
+ */
879
+ const SHELL_ACTIVE_CHAR_RE = /[\\$`{}*?[()<>!\n]/;
880
+ /**
881
+ * True when `word` is exactly what it looks like to git: either free of
882
+ * every character the shell could still act on, or a single matching pair
883
+ * of quotes wrapped around such a word (`"feat"`, `'origin'` — the quotes
884
+ * themselves removed, nothing shell-active left inside). Anything else — a
885
+ * bare backslash, a `$`, a backtick, a brace, a glob character, or a quote
886
+ * that does not open and close the whole word — means this rule cannot read
887
+ * what git will actually receive, and it refuses rather than guess.
888
+ */
889
+ function isPlainWord(word) {
890
+ if (word === '')
891
+ return true;
892
+ const quoted = word.length >= 2 &&
893
+ ((word.startsWith("'") && word.endsWith("'")) || (word.startsWith('"') && word.endsWith('"')));
894
+ const inner = quoted ? word.slice(1, -1) : word;
895
+ if (/['"]/.test(inner))
896
+ return false;
897
+ return !SHELL_ACTIVE_CHAR_RE.test(inner);
898
+ }
899
+ const PLAIN_WORD_REASON = 'write this git command with plain words — no quotes inside words, escapes, variables, braces or globs — so the guard can read exactly what git will receive';
900
+ /**
901
+ * True when `word` is unreadable specifically because of an unexpanded
902
+ * shell expansion — a `$name` parameter, a `${...}` form, a `$(...)`
903
+ * command substitution, or a backtick span — rather than any other reason
904
+ * `isPlainWord` refuses a word (a bare glob, a stray `<`/`>`, an embedded
905
+ * newline). Rule 2 and rule 6 both recognise their command by its literal
906
+ * word (`git`, `rm`); this is their shared read of "that word's true value
907
+ * is unknowable here," narrowed to the expansion forms those two rules fail
908
+ * closed on rather than every non-plain word, so a path argument elsewhere
909
+ * in the same command — already its own, separately-read concern — is never
910
+ * what trips this.
911
+ */
912
+ function isUnexpandedCommandWord(word) {
913
+ return !isPlainWord(word.raw) && /[$`]/.test(word.raw);
914
+ }
915
+ /** Subcommands the plain-word gate covers unconditionally. */
916
+ const FORCE_GATE_SUBCOMMANDS = ['push', 'rebase', 'reset', 'filter-branch', 'update-ref'];
917
+ const BRANCH_SHORT_FLAG_RE = /^-[a-zA-Z]+$/;
918
+ /**
919
+ * `git branch` only rewrites or drops a ref when it forces or deletes one:
920
+ * `-D`, `-f`, `-M` or `--force`, bundled or spelled long. Plain `-d` (delete
921
+ * a merged branch) and `-m` (rename) are not covered — see the comment on
922
+ * `checkForcePush` for why a delete without force is let through unread.
923
+ */
924
+ function isForcingBranchCommand(tokens) {
925
+ return tokens.some((token) => token === '--force' || (BRANCH_SHORT_FLAG_RE.test(token) && /[DfM]/.test(token)));
926
+ }
927
+ /**
928
+ * Same read as `gitTokens`, but bridges a real newline in the remainder —
929
+ * `gitTokens`' `(.*)$` cannot, since `.` does not match `\n` and `$` without
930
+ * a multiline flag only anchors at the true end of the string. A word split
931
+ * across a line-continuation backslash-newline (`--for\`<LF>`ce`) still
932
+ * needs to be read as two whitespace-joined pieces rather than dropped as no
933
+ * match at all. Local to the gate rather than a change to `gitTokens`
934
+ * itself, which rules 1 and 3 also read through.
935
+ */
936
+ function wordsAfterSubcommand(text, subcommand) {
937
+ const match = new RegExp(`${bareWord(subcommand)}([\\s\\S]*)$`, 'i').exec(text);
938
+ if (!match)
939
+ return [];
940
+ return (match[1] ?? '')
941
+ .trim()
942
+ .split(/\s+/)
943
+ .filter((token) => token !== '');
944
+ }
945
+ /**
946
+ * `text` with every quoted span blanked to spaces of the same length, so a
947
+ * quoted phrase cannot be misread as a real subcommand invocation. `--command
948
+ * 'git push origin main'` and `git commit -m "... push origin main"` both
949
+ * hold that literal text, but neither one runs it — one is describing a
950
+ * command, the other is prose in a commit message. Lengths are preserved
951
+ * only so this stays a drop-in swap for `text` in a regex test; nothing here
952
+ * reads offsets.
953
+ */
954
+ function blankQuotedSpans(text) {
955
+ return text.replace(/'[^']*'|"[^"]*"/g, (span) => ' '.repeat(span.length));
956
+ }
957
+ /**
958
+ * `text` with the reading the plain-word gate needs: quoted spans blanked
959
+ * (so a subcommand word only inside quotes is not "really" invoked) and
960
+ * redirections removed (so `git push origin main > /dev/null` is not read as
961
+ * having `>` and `/dev/null` for words). Used only to decide *whether* a
962
+ * segment is covered — word extraction itself still reads real text, quotes
963
+ * and all, through `isPlainWord`.
964
+ */
965
+ function forceGateStructuralText(text) {
966
+ return stripRedirections(blankQuotedSpans(text));
967
+ }
968
+ /** Whether `text` reaches a git subcommand the plain-word gate must cover. */
969
+ function isCoveredForcePushSegment(text) {
970
+ const structural = forceGateStructuralText(text);
971
+ if (FORCE_GATE_SUBCOMMANDS.some((subcommand) => isGitSubcommand(structural, subcommand))) {
972
+ return true;
973
+ }
974
+ return (isGitSubcommand(structural, 'branch') &&
975
+ isForcingBranchCommand(wordsAfterSubcommand(stripRedirections(text), 'branch')));
976
+ }
977
+ /** True when every word after a covered subcommand in `text` is plain. */
978
+ function hasUnsafeWord(text) {
979
+ const structural = forceGateStructuralText(text);
980
+ const wordsText = stripRedirections(text);
981
+ for (const subcommand of FORCE_GATE_SUBCOMMANDS) {
982
+ if (!isGitSubcommand(structural, subcommand))
983
+ continue;
984
+ if (wordsAfterSubcommand(wordsText, subcommand).some((token) => !isPlainWord(token)))
985
+ return true;
986
+ }
987
+ if (isGitSubcommand(structural, 'branch')) {
988
+ const tokens = wordsAfterSubcommand(wordsText, 'branch');
989
+ if (isForcingBranchCommand(tokens) && tokens.some((token) => !isPlainWord(token)))
990
+ return true;
991
+ }
992
+ return false;
993
+ }
994
+ const EVAL_RE = /\beval\b/;
995
+ const SHELL_C_WRAPPER_RE = /\b(sh|bash|zsh|env)\b[^;&|]*-c\b/;
996
+ const FORCE_GATE_WORD_RE = /\b(push|rebase|reset|filter-branch|update-ref)\b/i;
997
+ /**
998
+ * The dequoted payload of a segment wrapped in `eval`, or a `-c` string
999
+ * handed to `sh`/`bash`/`zsh`/`env` — or `null` when `segment` is not one of
1000
+ * those wrappers at all, so a caller never mistakes "nothing to unwrap" for
1001
+ * an empty payload. Quotes and backslashes are stripped from the *whole*
1002
+ * segment, wrapper prefix included (`bash -c 'git rese""t --hard $(x)'`
1003
+ * names `git` and a spliced `reset` only once they're gone), which is also
1004
+ * why the result still reads correctly as one text to scan rather than an
1005
+ * isolated inner string. Shared so rule 2's and rule 6's own unexpanded-word
1006
+ * checks below read a nested shell's payload the same way
1007
+ * `isShellWrappedForceCandidate` and `checkUnboundedRm`'s wrapped-`rm` pass
1008
+ * already do, instead of a fourth copy of the same two lines.
1009
+ */
1010
+ function dequoteShellWrappedSegment(segment) {
1011
+ if (!EVAL_RE.test(segment) && !SHELL_C_WRAPPER_RE.test(segment))
1012
+ return null;
1013
+ // A line continuation is dropped outright, not just its backslash — rule
1014
+ // 6's callers (`hasUnknownRemovalCommandWord`, `shellWrappedRmPayload`)
1015
+ // reach this on a segment `joinLineContinuations` never ran over, so a
1016
+ // bare `.replace(/\\/g, '')` would leave the newline behind and still
1017
+ // split a flag cluster it glues back together (`-r\<LF>f` → `-r`, `<LF>`,
1018
+ // `f` instead of `-rf`).
1019
+ return stripSpliceQuotes(segment).replace(/\\\n/g, '').replace(/\\/g, '');
1020
+ }
1021
+ /**
1022
+ * `eval`, or a `-c` string handed to `sh`/`bash`/`zsh`/`env`, passes a whole
1023
+ * second command line to another shell to parse — a second chance for any
1024
+ * trick above to hide inside a string this rule would otherwise read as
1025
+ * inert text, without this scanner ever parsing the nested line itself.
1026
+ * Simplest fail-closed read: a segment naming one of those wrappers
1027
+ * alongside `git` and force-push-shaped subcommand text is refused outright,
1028
+ * rather than trusted to be read correctly.
1029
+ */
1030
+ function isShellWrappedForceCandidate(segment) {
1031
+ const dequoted = dequoteShellWrappedSegment(segment);
1032
+ if (dequoted === null)
1033
+ return false;
1034
+ return /\bgit\b/i.test(dequoted) && FORCE_GATE_WORD_RE.test(dequoted);
1035
+ }
1036
+ function splitDequotedWords(segment) {
1037
+ const words = [];
1038
+ let raw = '';
1039
+ let text = '';
1040
+ let inWord = false;
1041
+ const flush = () => {
1042
+ if (inWord)
1043
+ words.push({ raw, text });
1044
+ raw = '';
1045
+ text = '';
1046
+ inWord = false;
1047
+ };
1048
+ let i = 0;
1049
+ while (i < segment.length) {
1050
+ const c = segment.charAt(i);
1051
+ if (c === "'") {
1052
+ inWord = true;
1053
+ raw += c;
1054
+ i += 1;
1055
+ while (i < segment.length && segment.charAt(i) !== "'") {
1056
+ raw += segment.charAt(i);
1057
+ text += segment.charAt(i);
1058
+ i += 1;
1059
+ }
1060
+ if (i < segment.length) {
1061
+ raw += segment.charAt(i);
1062
+ i += 1;
1063
+ }
1064
+ continue;
1065
+ }
1066
+ if (c === '"') {
1067
+ inWord = true;
1068
+ raw += c;
1069
+ i += 1;
1070
+ while (i < segment.length && segment.charAt(i) !== '"') {
1071
+ if (segment.charAt(i) === '\\' && i + 1 < segment.length) {
1072
+ const escaped = segment.charAt(i + 1);
1073
+ raw += segment.charAt(i) + escaped;
1074
+ // A backslash-newline is a line continuation, deleted outright —
1075
+ // not a character the word's text carries, the same join
1076
+ // `joinLineContinuations` already does ahead of the callers that
1077
+ // run it first. This branch also reads segments nothing joins
1078
+ // first (rule 6's unjoined paths), so it has to hold on its own.
1079
+ if (escaped !== '\n')
1080
+ text += escaped;
1081
+ i += 2;
1082
+ }
1083
+ else {
1084
+ raw += segment.charAt(i);
1085
+ text += segment.charAt(i);
1086
+ i += 1;
1087
+ }
1088
+ }
1089
+ if (i < segment.length) {
1090
+ raw += segment.charAt(i);
1091
+ i += 1;
1092
+ }
1093
+ continue;
1094
+ }
1095
+ if (c === '\\' && i + 1 < segment.length) {
1096
+ const escaped = segment.charAt(i + 1);
1097
+ raw += c + escaped;
1098
+ // A line continuation alone starts nothing: `inWord` stays whatever
1099
+ // it already was, so a bare `\`-newline sitting between two real
1100
+ // words never pushes an empty word between them. Mid-word (`-r\<LF>f`)
1101
+ // `inWord` is already true from the characters before it, so this is
1102
+ // a no-op there — the flag cluster still comes out joined as `-rf`.
1103
+ if (escaped === '\n') {
1104
+ i += 2;
1105
+ continue;
1106
+ }
1107
+ inWord = true;
1108
+ text += escaped;
1109
+ i += 2;
1110
+ continue;
1111
+ }
1112
+ if (/\s/.test(c)) {
1113
+ flush();
1114
+ i += 1;
1115
+ continue;
1116
+ }
1117
+ inWord = true;
1118
+ raw += c;
1119
+ text += c;
1120
+ i += 1;
1121
+ }
1122
+ flush();
1123
+ return words;
1124
+ }
1125
+ /** `git` global options that spend the *next* word on a value rather than folding it in with `=`, enough to walk past them to the subcommand. */
1126
+ const GIT_GLOBAL_VALUE_FLAGS = new Set([
1127
+ '-C',
1128
+ '-c',
1129
+ '--git-dir',
1130
+ '--work-tree',
1131
+ '--namespace',
1132
+ '--exec-path',
1133
+ '--config-env',
1134
+ ]);
1135
+ /** The subcommand word of a `git` invocation found at `words[gitIndex]`, walking past global options the way `git` itself does. */
1136
+ function gitSubcommandWord(words, gitIndex) {
1137
+ let i = gitIndex + 1;
1138
+ while (i < words.length) {
1139
+ const word = words[i];
1140
+ if (word === undefined)
1141
+ break;
1142
+ if (word.text.startsWith('-')) {
1143
+ i += GIT_GLOBAL_VALUE_FLAGS.has(word.text) ? 2 : 1;
1144
+ continue;
1145
+ }
1146
+ return i;
1147
+ }
1148
+ return -1;
1149
+ }
1150
+ const SUBCOMMAND_NOT_PLAIN_REASON = 'the git subcommand is not written as a plain word, so the guard cannot tell what it runs — write it out plainly';
1151
+ const UNKNOWN_COMMAND_WORD_REASON = 'the command word here is an unexpanded parameter or substitution, so the guard cannot tell what it runs — write it out plainly';
1152
+ /**
1153
+ * Rule 2's gap once some word of the segment is unreadable: neither the
1154
+ * exact `git` match above nor its `hidden` fallback (which still needs the
1155
+ * dequoted text to end in "git") can place a word that only resolves to
1156
+ * `git` once a shell expands `$G`, `$(...)` or a backtick span — this file
1157
+ * never evaluates one of those. So when any word of the segment is
1158
+ * unreadable this way (`isUnexpandedCommandWord`), the rest of it is read
1159
+ * for the shape rule 2 already guards against on a recognised push: a
1160
+ * `push` word together with a force flag (`FORCE_PUSH_RE`, the same test
1161
+ * the raw-text fallback below uses) or a `+refspec` operand
1162
+ * (`pushOperands`, the same read rule 1 uses for a push's destination).
1163
+ * Resolving the expansion itself is out of scope — an open-ended evaluator
1164
+ * is the wrong tool — so this is shape only, same as rule 6's analogue.
1165
+ *
1166
+ * Every word is scanned, not just the one in command position: a wrapper
1167
+ * (`sudo`, `env`, `timeout 5`, `xargs`, a bare `VAR=val` prefix, …), a group
1168
+ * (`( … )`), or a keyword (`{ … }`, `if … then … fi`) can all sit in front
1169
+ * of the unreadable word without moving it out of the segment, and an
1170
+ * open-ended wrapper list would miss whichever one is not on it. No
1171
+ * allowlist narrows this scan the way rule 6's does: a `push` word plus a
1172
+ * force flag or `+refspec` is never the shape of an everyday command, so
1173
+ * there is no ordinary first word to carve an exception for.
1174
+ *
1175
+ * Also checked against the segment's dequoted payload when it is a nested
1176
+ * shell (`sh -c '…'`, `bash -c "…"`, `eval '…'`): `splitDequotedWords` keeps
1177
+ * a quoted span's internal text as one word, which can hide a `-f` token or
1178
+ * even the `push` word itself inside it until the quotes are gone.
1179
+ */
1180
+ function isUnknownForcePushShape(segment) {
1181
+ const words = splitDequotedWords(segment);
1182
+ if (!words.some((word) => isUnexpandedCommandWord(word)))
1183
+ return false;
1184
+ if (!new RegExp(bareWord('push'), 'i').test(segment))
1185
+ return false;
1186
+ if (FORCE_PUSH_RE.test(segment))
1187
+ return true;
1188
+ return pushOperands(segment).some((ref) => ref.replace(/\\/g, '').startsWith('+'));
1189
+ }
1190
+ function hasUnknownForcePushCommandWord(segment) {
1191
+ if (isUnknownForcePushShape(segment))
1192
+ return true;
1193
+ const wrapped = dequoteShellWrappedSegment(segment);
1194
+ return wrapped !== null && isUnknownForcePushShape(wrapped);
1195
+ }
1196
+ /**
1197
+ * Round 6's fix for a subcommand word the shell will glue back together
1198
+ * before git ever sees it — quote-splicing (`rese""t`), a stray backslash
1199
+ * (`p\ush`, `\git`) or an expansion (`upd${x}ate-ref`) — none of which
1200
+ * `isCoveredForcePushSegment` can see, since it matches a literal substring
1201
+ * against text that still holds the quotes and backslashes. This reads the
1202
+ * segment as shell words instead: find the actual `git` word (whatever
1203
+ * wrapper precedes it — this needs no wrapper list, since it is looking for
1204
+ * `git` itself, not what comes before it), walk to its subcommand, and
1205
+ * refuse outright if that word cannot be read plainly — not just when it
1206
+ * happens to name a covered subcommand, since a word this unreadable could
1207
+ * be any of them. A subcommand read plainly and found covered still needs
1208
+ * every word after it plain, the same gate `hasUnsafeWord` already applies.
1209
+ *
1210
+ * The scan covers every word of the segment rather than stopping at the
1211
+ * first one that is not a recognised wrapper: a wrapper list is open-ended
1212
+ * (`sudo`, `env`, `time`, `timeout`, `nice`, `nohup`, `xargs`, `stdbuf`,
1213
+ * `ionice`, `command`, `exec`, a bare `VAR=val` prefix, and whatever the
1214
+ * next one is), so stopping early would miss a disguised `git` sitting
1215
+ * right after a wrapper this function does not recognise. `echo g\it` being
1216
+ * refused along with it is accepted — fail-closed over precise.
1217
+ */
1218
+ function checkForcePushSubcommandWord(segment, rule) {
1219
+ const words = splitDequotedWords(stripRedirections(segment));
1220
+ const gitIndex = words.findIndex((word) => word.text.toLowerCase() === 'git');
1221
+ if (gitIndex === -1) {
1222
+ // Defence in depth for `joinLineContinuations` missing a spelling of its
1223
+ // own: a word whose dequoted text is not exactly `git` can still resolve
1224
+ // to it once the stray literal newline a shell trick left behind (not a
1225
+ // real word break — bash lets a quoted span carry a raw newline with no
1226
+ // backslash, which `joinLineContinuations` never touches) is stripped
1227
+ // out. Such a word is never plain — `isPlainWord` already refuses a bare
1228
+ // backslash or an embedded newline — so this only fires for a raw form
1229
+ // this rule could not read anyway; refuse it rather than silently pass
1230
+ // through. Only the newline itself is stripped, not every whitespace
1231
+ // character: an ordinary quoted argument can legitimately contain plain
1232
+ // spaces (a PR title, a commit message), and squeezing those out too
1233
+ // reads an innocent word ending in "...ing it" as ending in "git" —
1234
+ // issue #273.
1235
+ const hidden = words.some((word) => word.text.toLowerCase() !== 'git' &&
1236
+ !isPlainWord(word.raw) &&
1237
+ word.text.replace(/\r?\n/g, '').toLowerCase().endsWith('git'));
1238
+ if (hidden)
1239
+ return violation(rule, SUBCOMMAND_NOT_PLAIN_REASON);
1240
+ if (hasUnknownForcePushCommandWord(stripRedirections(segment))) {
1241
+ return violation(rule, UNKNOWN_COMMAND_WORD_REASON);
1242
+ }
669
1243
  return null;
670
- if (!FORCE_PUSH_RE.test(command))
1244
+ }
1245
+ // The `git` word itself, found above by its *dequoted* text, still needs
1246
+ // its raw spelling checked: a backslash inside it (`g\it`, `gi\t`) dequotes
1247
+ // to exactly `git` and so is found here, but it also breaks the literal
1248
+ // `\bgit\b` substring every raw-text check in this file scans for —
1249
+ // `GIT_PUSH_ANYWHERE_RE` chief among them, and, outside this file,
1250
+ // `isGitSubcommand`, which rules 1 (push-to-protected), 3
1251
+ // (merge-into-protected) and 5 (history-rewrite-on-protected) all read a
1252
+ // segment through. None of those ever sees a `git` word written this way,
1253
+ // so this refusal is their only defence against one — a future narrowing
1254
+ // of it must keep covering every subcommand those rules care about, not
1255
+ // just force-flagged pushes.
1256
+ const gitWord = words[gitIndex];
1257
+ if (gitWord !== undefined && !isPlainWord(gitWord.raw)) {
1258
+ return violation(rule, SUBCOMMAND_NOT_PLAIN_REASON);
1259
+ }
1260
+ const subcommandIndex = gitSubcommandWord(words, gitIndex);
1261
+ if (subcommandIndex === -1)
671
1262
  return null;
672
- return violation(requireRule(policy, 'force-push'));
1263
+ const subcommand = words[subcommandIndex];
1264
+ if (subcommand === undefined)
1265
+ return null;
1266
+ if (!isPlainWord(subcommand.raw))
1267
+ return violation(rule, SUBCOMMAND_NOT_PLAIN_REASON);
1268
+ const name = subcommand.text.toLowerCase();
1269
+ const rest = words.slice(subcommandIndex + 1);
1270
+ const covered = FORCE_GATE_SUBCOMMANDS.includes(name) ||
1271
+ (name === 'branch' && isForcingBranchCommand(rest.map((word) => word.text)));
1272
+ if (covered && rest.some((word) => !isPlainWord(word.raw))) {
1273
+ return violation(rule, PLAIN_WORD_REASON);
1274
+ }
1275
+ return null;
1276
+ }
1277
+ /**
1278
+ * Rule 2's main defence: a plain-word gate, not a growing list of individual
1279
+ * shell tricks. In a segment that reaches `push`, `rebase`, `reset`,
1280
+ * `filter-branch`, `update-ref`, or `branch` when it forces or deletes a
1281
+ * ref, every word after the subcommand must be plain — see `isPlainWord`.
1282
+ * A word this rule cannot read safely is refused rather than guessed at; the
1283
+ * gate decides only readability, not force-ness, which the checks below
1284
+ * still do once the words are known safe to read.
1285
+ */
1286
+ function checkForcePush(command, policy) {
1287
+ const rule = requireRule(policy, 'force-push');
1288
+ // Joined first, so every rule-2 layer below — the shell-wrapper check, the
1289
+ // subcommand-word gate, and the plain-word gate over the words after it —
1290
+ // reads the command the way bash will run it, with no continuation left to
1291
+ // hide a subcommand word inside.
1292
+ for (const segment of topLevelCommands(joinLineContinuations(command))) {
1293
+ if (isShellWrappedForceCandidate(segment))
1294
+ return violation(rule);
1295
+ const subcommandWordViolation = checkForcePushSubcommandWord(segment, rule);
1296
+ if (subcommandWordViolation)
1297
+ return subcommandWordViolation;
1298
+ const decoded = decodeAnsiCQuoting(segment);
1299
+ // An unterminated `$'` span cannot be decoded with confidence — fail
1300
+ // closed rather than guess at what it hides, the same direction as
1301
+ // `topLevelCommands`' own fallback for a construct it cannot read.
1302
+ if (decoded.unterminated) {
1303
+ if (/\bgit\b/i.test(segment))
1304
+ return violation(rule);
1305
+ continue;
1306
+ }
1307
+ if (definesInlineGitAlias(decoded.text))
1308
+ return violation(rule);
1309
+ // Subcommand detection reads the decoded text, so a subcommand word
1310
+ // hidden inside a `$'...'` span (`$'\x70ush'`) is still found. But a
1311
+ // `$'...'` span is itself one of the constructs the plain-word gate
1312
+ // exists to refuse — decoding it away first, then checking the decoded
1313
+ // words for plainness, would launder exactly what it is meant to catch.
1314
+ // So once a covered subcommand is found, an undecoded `$'...'` span
1315
+ // anywhere in the segment is already a plain-word failure on its own;
1316
+ // otherwise (no span at all) the raw segment is checked directly.
1317
+ if (isCoveredForcePushSegment(decoded.text)) {
1318
+ if (decoded.text !== segment)
1319
+ return violation(rule, PLAIN_WORD_REASON);
1320
+ if (hasUnsafeWord(segment))
1321
+ return violation(rule, PLAIN_WORD_REASON);
1322
+ }
1323
+ const text = stripSpliceQuotes(decoded.text);
1324
+ const withoutStash = text.replace(/\bstash\s+push\b/gi, 'stash');
1325
+ if (!GIT_PUSH_ANYWHERE_RE.test(withoutStash))
1326
+ continue;
1327
+ if (FORCE_PUSH_RE.test(text))
1328
+ return violation(rule);
1329
+ // A refspec operand starting with `+` forces the update the same way
1330
+ // `--force` does, with no flag on the line at all. Read the same way
1331
+ // rule 1 reads a push's destination: every non-flag operand of the push,
1332
+ // quotes stripped — and backslashes stripped too, since the shell
1333
+ // removes them before git ever sees the argument. `git push origin
1334
+ // \+feat` reaches git as `+feat`; scanning the raw `\+feat` for a
1335
+ // leading `+` misses it, so the operand is read the way git will.
1336
+ if (pushOperands(text).some((ref) => ref.replace(/\\/g, '').startsWith('+'))) {
1337
+ return violation(rule);
1338
+ }
1339
+ }
1340
+ return null;
1341
+ }
1342
+ /**
1343
+ * The command's top-level commands, split only where the shell itself ends a
1344
+ * command: `;`, `&&`, `||`, `|` and an unescaped newline, outside quotes,
1345
+ * backticks and `$( )` / `( )`. A redirection (`>`, `2>&1`, `>|`), a lone `&`
1346
+ * and everything nested stay inside the command they belong to.
1347
+ *
1348
+ * Anything it cannot read with confidence returns the whole command as one
1349
+ * segment: an unbalanced quote or paren, or a construct it does not model —
1350
+ * ANSI-C `$'…'` quoting, `${…}` expansion (whose operands may hold `;` or
1351
+ * `&&`), a heredoc (`<<`, whose body is not shell syntax) or any `#` other
1352
+ * than `$#`. A comment's text may hold a lone quote, and a comment starts not
1353
+ * only after a space but right after `;`, `&` or `)` too, so every `#` that
1354
+ * could open one falls back rather than the scan guessing which do. That is
1355
+ * the pre-split reading, so a parse it gets wrong can only over-refuse, never
1356
+ * let a force flag through.
1357
+ */
1358
+ function topLevelCommands(command) {
1359
+ if (/\$'|\$\{|<<|(^|[^$])#/.test(command))
1360
+ return [command];
1361
+ const segments = [];
1362
+ const stack = [];
1363
+ let start = 0;
1364
+ let i = 0;
1365
+ const cut = (end, next) => {
1366
+ segments.push(command.slice(start, end));
1367
+ start = next;
1368
+ };
1369
+ while (i < command.length) {
1370
+ const c = command[i];
1371
+ const top = stack[stack.length - 1];
1372
+ if (top === 'sq') {
1373
+ if (c === "'")
1374
+ stack.pop();
1375
+ i += 1;
1376
+ continue;
1377
+ }
1378
+ if (c === '\\') {
1379
+ i += 2;
1380
+ continue;
1381
+ }
1382
+ if (top === 'dq') {
1383
+ if (c === '"')
1384
+ stack.pop();
1385
+ else if (c === '`')
1386
+ stack.push('bt');
1387
+ else if (c === '$' && command[i + 1] === '(') {
1388
+ stack.push('paren');
1389
+ i += 1;
1390
+ }
1391
+ i += 1;
1392
+ continue;
1393
+ }
1394
+ if (top === 'bt' && c === '`') {
1395
+ stack.pop();
1396
+ i += 1;
1397
+ continue;
1398
+ }
1399
+ if (c === "'")
1400
+ stack.push('sq');
1401
+ else if (c === '"')
1402
+ stack.push('dq');
1403
+ else if (c === '`')
1404
+ stack.push('bt');
1405
+ else if (c === '(')
1406
+ stack.push('paren');
1407
+ else if (c === ')') {
1408
+ if (top !== 'paren')
1409
+ return [command];
1410
+ stack.pop();
1411
+ }
1412
+ else if (stack.length === 0) {
1413
+ const pair = command.slice(i, i + 2);
1414
+ if (pair === '&&' || pair === '||') {
1415
+ cut(i, i + 2);
1416
+ i += 2;
1417
+ continue;
1418
+ }
1419
+ if (c === ';' || c === '\n' || (c === '|' && command[i - 1] !== '>'))
1420
+ cut(i, i + 1);
1421
+ }
1422
+ i += 1;
1423
+ }
1424
+ if (stack.length > 0)
1425
+ return [command];
1426
+ segments.push(command.slice(start));
1427
+ return segments;
673
1428
  }
674
1429
  /** Rule 3: `git merge` or `git pull` while standing on a protected branch — the only place a merge can land. */
675
1430
  function checkMergeIntoProtected(command, branch, policy) {
@@ -791,8 +1546,6 @@ function checkHistoryRewriteOnProtected(command, branch, policy) {
791
1546
  const rule = requireRule(policy, 'history-rewrite-on-protected');
792
1547
  return violation(rule, renderBranch(rule.reason, branch));
793
1548
  }
794
- /** Rule 6 detection, half one: is `rm` invoked as a command in this segment? Looser than "first token", so `sudo rm …` still matches; stricter than a bare substring, so the `rm` inside `charm`/`confirm` is not this rule's business. */
795
- const RM_INVOKE_RE = /(^|[;&|]|\s)rm\s+/;
796
1549
  /** A bundled short-flag token (`-rf`, `-Rvf`) as opposed to a long flag (`--force`) or a path. */
797
1550
  const RM_SHORT_FLAG_RE = /^-[a-zA-Z]+$/;
798
1551
  /**
@@ -827,6 +1580,60 @@ function hasRecursiveForce(tokens) {
827
1580
  }
828
1581
  return recursive && force;
829
1582
  }
1583
+ /**
1584
+ * The same recursive-force cluster `hasRecursiveForce` reads out of a
1585
+ * tokenised word list, asked instead of two independent, linear scans over
1586
+ * raw, unparsed text — one per flag, not one combined pattern — because
1587
+ * there is no word list left to read: the text has not been dequoted or
1588
+ * split, so a cluster split across tokens (`-r -f`, `-r --force`) has to be
1589
+ * found without that split ever happening. This is what a substitution body
1590
+ * falls back to once it cannot be delimited with confidence — an
1591
+ * unterminated quote, an unbalanced paren, or a nesting depth past
1592
+ * `MAX_SUBSTITUTION_DEPTH`.
1593
+ *
1594
+ * Each scan looks for its flag at the start of a token — the start of the
1595
+ * text, or just past whitespace, a quote, `(`, a backtick, `;`, `|` or `&` —
1596
+ * then either the flag's long spelling (`--recursive`, `--force`) or a
1597
+ * single hyphen followed by nothing but letters containing the flag's
1598
+ * letter (`-r`, `-R`, `-rf`, `-Rvf`). The long spelling is checked on its
1599
+ * own branch, gated on a second, literal hyphen, rather than folded into
1600
+ * the bundled-letter scan: `hasRecursiveForce` never reads `--force` as
1601
+ * carrying a recursive flag, because its own bundled-flag test
1602
+ * (`RM_SHORT_FLAG_RE`) is anchored to exactly one leading hyphen and
1603
+ * `--force` has two, so it only ever matches `--force`'s own exact-token
1604
+ * branch. A combined scan that let the recursive pattern walk into a second
1605
+ * hyphen would read `--force`'s own tail letters — `f`, `o`, `r`, `c`, `e` —
1606
+ * and find the `r` in "force", flagging a force-only invocation as
1607
+ * recursive too. Splitting the hyphen count the same way `hasRecursiveForce`
1608
+ * does keeps that false read out. Over-matching in every other direction (a
1609
+ * bundled flag that is not really `rm`'s, a comment mentioning both words)
1610
+ * is the same direction every fail-closed read in this file already takes.
1611
+ */
1612
+ const TOKEN_START = String.raw `(?:^|[\s'"(\`;|&])`;
1613
+ const RAW_RECURSIVE_FLAG_RE = new RegExp(`${TOKEN_START}-(?:-recursive\\b|[A-Za-z]*[rR][A-Za-z]*)`);
1614
+ const RAW_FORCE_FLAG_RE = new RegExp(`${TOKEN_START}-(?:-force\\b|[A-Za-z]*f[A-Za-z]*)`);
1615
+ /**
1616
+ * Outside single quotes, the shell turns a backslash followed by any
1617
+ * character into that character, and drops a backslash-newline outright —
1618
+ * its line continuation. `-\r` reads as `-r`, so the two flag regexes above
1619
+ * need to see the escape resolved, not the backslash that is still sitting
1620
+ * in front of the letter. This text is exactly the text whose quoting
1621
+ * could not be resolved with confidence (an unterminated quote, or a
1622
+ * nesting depth this scanner gave up delimiting), so there is no reliable
1623
+ * way to tell whether a given backslash sits inside single quotes, where
1624
+ * the shell would have left it untouched. Reading every backslash as an
1625
+ * escape is the same fail-closed direction the rest of this fallback
1626
+ * already takes: over-denying a backslash that was really inside single
1627
+ * quotes costs nothing here, and under-denying one that was not is the gap
1628
+ * this closes.
1629
+ */
1630
+ function unescapeForFlagScan(text) {
1631
+ return text.replace(/\\([\s\S])/g, (_match, ch) => (ch === '\n' ? '' : ch));
1632
+ }
1633
+ function hasRecursiveForceInText(text) {
1634
+ const unescaped = unescapeForFlagScan(text);
1635
+ return RAW_RECURSIVE_FLAG_RE.test(unescaped) && RAW_FORCE_FLAG_RE.test(unescaped);
1636
+ }
830
1637
  /**
831
1638
  * The characters that separate one command in a chain from the next. `;&|`
832
1639
  * are the three guard.sh knew, and for a long time this file knew no others
@@ -867,9 +1674,17 @@ const SEPARATOR_CHARS = `${COMMAND_SEPARATOR_CHARS}${REDIRECTION_CHARS}`;
867
1674
  * Every separated segment of a command, so a chain can be inspected one
868
1675
  * invocation at a time — and so a rule reading a segment's operands is looking
869
1676
  * at that command's operands and nothing else.
1677
+ *
1678
+ * Joins line continuations first. The split below is a naive character class
1679
+ * that includes a bare newline, with no idea a backslash in front of one
1680
+ * means "this is not where the command ends" — left unjoined, `rm -r\<LF>f
1681
+ * /x` would split into `rm -r\` and `f /x` before either reached a word
1682
+ * splitter, and no fix inside one could ever reunite a flag cluster already
1683
+ * cut in half. `joinLineContinuations` is the same join `checkForcePush`
1684
+ * already runs first; every caller here gets it for the same reason.
870
1685
  */
871
1686
  function splitChainSegments(command) {
872
- return command.split(new RegExp(`[${SEPARATOR_CHARS}]`));
1687
+ return joinLineContinuations(command).split(new RegExp(`[${SEPARATOR_CHARS}]`));
873
1688
  }
874
1689
  /**
875
1690
  * A redirection, with its target: an optional file descriptor or `&`, one of
@@ -913,8 +1728,56 @@ function catchUpSegments(command) {
913
1728
  .split(new RegExp(`[${COMMAND_SEPARATOR_CHARS}]`))
914
1729
  .filter((s) => re.test(s));
915
1730
  }
1731
+ /** Rule 6 detection, half one: is `rm` invoked as a command in this segment? Looser than "first token", so `sudo rm …` still matches; stricter than a bare substring, so the `rm` inside `charm`/`confirm` is not this rule's business. Used as a fallback alongside the whole-segment word scan below — see `checkUnboundedRm`. */
1732
+ const RM_INVOKE_RE = /(^|[;&|]|\s)rm\s+/;
916
1733
  /** The `rm <args>` run up to the next separator, ported from guard.sh's `grep -Eo 'rm[[:space:]]+[^;&|]*'` and widened with it. Applied per chain segment (see `checkUnboundedRm`), so "up to the next separator" and "to the end of the string" mean the same thing by the time this runs — the negated class is belt and braces, kept in step with the split so the two can never disagree about where a command ends. */
917
1734
  const RM_ARGS_RE = new RegExp(`rm\\s+([^${SEPARATOR_CHARS}]*)`);
1735
+ /**
1736
+ * True when any word of `segment` has basename `rm` and resolves to it only
1737
+ * once a backslash is undone (`r\m`, `/bin/r\m`, `./r\m`) — fails closed,
1738
+ * refused outright wherever it sits rather than parsed for its flags and
1739
+ * paths, since neither can be trusted from a word that was never written
1740
+ * plainly. Every word of the segment is scanned, not just the one in command
1741
+ * position: an open-ended list of wrappers (`sudo`, `env`, `time`, `xargs`,
1742
+ * `nohup`, `stdbuf`, `ionice`, `command`, `exec`, a bare `VAR=val` prefix…)
1743
+ * can sit in front of `rm`, so narrowing the scan to command position would
1744
+ * miss a disguised `rm` behind any wrapper not on that list. `echo r\m`
1745
+ * being refused along with it is accepted.
1746
+ */
1747
+ function hasDisguisedRmWord(words) {
1748
+ return words.some((word) => {
1749
+ const basename = word.text.split(/[\\/]/).pop() ?? word.text;
1750
+ return basename === 'rm' && !isPlainWord(word.raw);
1751
+ });
1752
+ }
1753
+ /**
1754
+ * The index of the first plain (non-disguised) word in `words` whose
1755
+ * basename is `rm` — `rm`, `/bin/rm`, `./rm` — or `-1` if there is none.
1756
+ * Reading this word's position, rather than just whether one exists, is
1757
+ * what lets `extractRmArgsFromWords` take the words that follow it instead
1758
+ * of re-deriving them from raw text.
1759
+ */
1760
+ function plainRmWordIndex(words) {
1761
+ return words.findIndex((word) => {
1762
+ if (!isPlainWord(word.raw))
1763
+ return false;
1764
+ const basename = word.text.split(/[\\/]/).pop() ?? word.text;
1765
+ return basename === 'rm';
1766
+ });
1767
+ }
1768
+ /**
1769
+ * `rm`'s arguments, already dequoted, straight from the same word split that
1770
+ * found it. `RM_ARGS_RE` reads flags from the *raw* text
1771
+ * after a literal `rm `, so a quoted invocation like `"rm" -rf /x` was never
1772
+ * matched: the closing quote sits where the regex wants whitespace, the
1773
+ * match fails, and `-rf /x` is never seen at all. The word list has already
1774
+ * dequoted `"rm"` down to the plain word `rm` and split `-rf`/`/x` out as
1775
+ * their own words regardless of how `rm` itself was spelled, so slicing from
1776
+ * the word after it is both simpler and correct where the regex was not.
1777
+ */
1778
+ function extractRmArgsFromWords(words, rmIndex) {
1779
+ return words.slice(rmIndex + 1).map((word) => word.text);
1780
+ }
918
1781
  function extractRmArgs(segment) {
919
1782
  const match = RM_ARGS_RE.exec(segment);
920
1783
  if (!match)
@@ -924,6 +1787,15 @@ function extractRmArgs(segment) {
924
1787
  .split(/\s+/)
925
1788
  .filter((t) => t !== '');
926
1789
  }
1790
+ function isOutOfBoundsRemoval(args, repoRoot, policy) {
1791
+ if (!hasRecursiveForce(args))
1792
+ return false;
1793
+ return args.some((tok) => {
1794
+ if (tok.startsWith('-'))
1795
+ return false;
1796
+ return !isAllowedRemovalPath(normalizeRemovalPath(tok, repoRoot), policy);
1797
+ });
1798
+ }
927
1799
  /**
928
1800
  * Normalize an `rm` argument to a repo-root-relative path — the portable
929
1801
  * replacement for guard.sh's `normalize_path`, which shelled out to
@@ -961,32 +1833,404 @@ function isAllowedRemovalPath(normalizedPath, policy) {
961
1833
  policy.allowedRemovalRoots.includes(first));
962
1834
  }
963
1835
  /**
964
- * Rule 6: a recursive-force `rm` outside `allowed_roots`, however the two
965
- * flags are spelled (see `hasRecursiveForce`).
1836
+ * Whether `text` names `rm` outside `allowed_roots`, by itself — no chain
1837
+ * splitting, no wrapper unwrapping, just the three reads of one piece of
1838
+ * text: a disguised `rm` word anywhere is refused outright; a plain `rm`
1839
+ * word's flags come from the same word split that found it
1840
+ * (`extractRmArgsFromWords`), trusted directly; and the raw `RM_INVOKE_RE`
1841
+ * fallback — belt and braces for a spelling the word-level read might miss —
1842
+ * is read with less trust, since it is text `plainRmWordIndex` could not
1843
+ * place a word at. `RM_ARGS_RE` wants whitespace right after a literal `rm`,
1844
+ * so a quoted invocation like `"rm" -rf /x` leaves it with no match and
1845
+ * nothing to say was forced — handled for the plain-word case by reading
1846
+ * args from the words themselves, and for the raw-fallback case by failing
1847
+ * closed when it can find `rm` but not its flags, rather than reading
1848
+ * "found nothing" as "not forced".
1849
+ */
1850
+ function rmOutOfBoundsInText(text, repoRoot, policy) {
1851
+ const words = splitDequotedWords(text);
1852
+ if (hasDisguisedRmWord(words))
1853
+ return true;
1854
+ const rmIndex = plainRmWordIndex(words);
1855
+ if (rmIndex !== -1) {
1856
+ return isOutOfBoundsRemoval(extractRmArgsFromWords(words, rmIndex), repoRoot, policy);
1857
+ }
1858
+ if (!RM_INVOKE_RE.test(text))
1859
+ return false;
1860
+ const args = extractRmArgs(text);
1861
+ if (args.length === 0)
1862
+ return true;
1863
+ return isOutOfBoundsRemoval(args, repoRoot, policy);
1864
+ }
1865
+ /**
1866
+ * Rule 6's analogue of `checkForcePush`'s `isShellWrappedForceCandidate`: a
1867
+ * `-c` string handed to `sh`/`bash`/`zsh`/`env`, or `eval`, hides its payload
1868
+ * from a word-level read the same way it hides a force-push from rule 2 — a
1869
+ * pre-existing gap (`sh -c "rm -rf /x"` was allowed on every prior version).
1870
+ * Rather than parse the wrapped command a second time, hand the same
1871
+ * dequoted text back through `rmOutOfBoundsInText`: stripping every quote
1872
+ * character turns `sh -c "rm -rf /x"` into `sh -c rm -rf /x`, where `rm`
1873
+ * reads as an ordinary word like any other. A thin alias over
1874
+ * `dequoteShellWrappedSegment`, kept under this name since
1875
+ * `checkUnboundedRm`'s wrapped-`rm` pass below already reads by it.
1876
+ */
1877
+ function shellWrappedRmPayload(segment) {
1878
+ return dequoteShellWrappedSegment(segment);
1879
+ }
1880
+ /**
1881
+ * The index just past the quote that closes the one starting at `start`
1882
+ * (`text[start]` is `'` or `"`) — or `null` when the text ends first. Shared
1883
+ * by `findBalancedParenClose` and `findBacktickClose` so the two
1884
+ * substitution-delimiter scanners below read a quoted span inside a body the
1885
+ * same way, rather than a second copy of the same loop apiece.
1886
+ */
1887
+ function skipQuotedSpan(text, start) {
1888
+ if (text[start] === "'") {
1889
+ const end = text.indexOf("'", start + 1);
1890
+ return end === -1 ? null : end + 1;
1891
+ }
1892
+ let i = start + 1;
1893
+ while (i < text.length && text[i] !== '"') {
1894
+ i += text[i] === '\\' && i + 1 < text.length ? 2 : 1;
1895
+ }
1896
+ return i < text.length ? i + 1 : null;
1897
+ }
1898
+ /**
1899
+ * The index of the `)` that balances the `(` just before `start` — or `null`
1900
+ * when the text ends first, the same "cannot delimit this with confidence"
1901
+ * signal `topLevelCommands` falls back on for an unbalanced paren. A quoted
1902
+ * span inside the body is skipped whole (`skipQuotedSpan`), so a literal
1903
+ * `)` inside an argument, like the one in `$(echo ")")`, is never mistaken
1904
+ * for the substitution's own close.
1905
+ */
1906
+ function findBalancedParenClose(text, start) {
1907
+ let depth = 1;
1908
+ let i = start;
1909
+ while (i < text.length) {
1910
+ const c = text[i];
1911
+ if (c === '\\' && i + 1 < text.length) {
1912
+ i += 2;
1913
+ continue;
1914
+ }
1915
+ if (c === "'" || c === '"') {
1916
+ const next = skipQuotedSpan(text, i);
1917
+ if (next === null)
1918
+ return null;
1919
+ i = next;
1920
+ continue;
1921
+ }
1922
+ if (c === '(') {
1923
+ depth += 1;
1924
+ i += 1;
1925
+ continue;
1926
+ }
1927
+ if (c === ')') {
1928
+ depth -= 1;
1929
+ if (depth === 0)
1930
+ return i;
1931
+ i += 1;
1932
+ continue;
1933
+ }
1934
+ i += 1;
1935
+ }
1936
+ return null;
1937
+ }
1938
+ /**
1939
+ * The index of the unescaped backtick that closes a backtick substitution
1940
+ * starting at `start` — reading through a quoted span the same way
1941
+ * `findBalancedParenClose` does, so a backtick inside a quoted argument
1942
+ * never ends the span early. `null` when no close is found before the text
1943
+ * ends.
1944
+ */
1945
+ function findBacktickClose(text, start) {
1946
+ let i = start;
1947
+ while (i < text.length) {
1948
+ const c = text[i];
1949
+ if (c === '\\' && i + 1 < text.length) {
1950
+ i += 2;
1951
+ continue;
1952
+ }
1953
+ if (c === "'" || c === '"') {
1954
+ const next = skipQuotedSpan(text, i);
1955
+ if (next === null)
1956
+ return null;
1957
+ i = next;
1958
+ continue;
1959
+ }
1960
+ if (c === '`')
1961
+ return i;
1962
+ i += 1;
1963
+ }
1964
+ return null;
1965
+ }
1966
+ /**
1967
+ * Every command substitution (`$(...)`), backtick substitution, and process
1968
+ * substitution (`<(...)`/`>(...)`) body in `segment` — the shell still runs
1969
+ * each of these while it builds an allowlisted command's argv, which
1970
+ * `isUnknownRemovalSegment`'s first-word allowlist does not account for on
1971
+ * its own: `cp` cannot run a second program from one of its own
1972
+ * *arguments*, but `echo $(X=rm; $X -rf /x)` never puts anything dangerous
1973
+ * in an argument — the shell runs `$X -rf /x` as a command in its own right
1974
+ * to decide what `echo` should print. A body inside single quotes is not a
1975
+ * substitution at all, since single quotes suppress every expansion, so
1976
+ * that span is skipped rather than read; one inside double quotes still
1977
+ * runs and is read the same as an unquoted one.
1978
+ *
1979
+ * Reads as a small quote-state machine — unquoted, single-quoted or
1980
+ * double-quoted — rather than `skipQuotedSpan`'s "skip the whole span" read
1981
+ * the two delimiter finders above use: a single-quoted span truly has
1982
+ * nothing left to find inside it, but a double-quoted one still runs a
1983
+ * `$(...)` or backtick substitution this scan must keep recognising, so it
1984
+ * cannot be skipped whole. The one thing double quotes change inside their
1985
+ * own span is that `'` is just a character, not a span of its own — an
1986
+ * apostrophe in `"it's $(...)"` used to be read as opening a fresh
1987
+ * single-quoted span, which this text never closes, so the whole scan gave
1988
+ * up and returned `null` for a segment that was perfectly readable. Process
1989
+ * substitution (`<(`/`>(`) is the other thing double quotes change: they
1990
+ * suppress it, so it is only recognised unquoted, matching
1991
+ * `shellExpandsPayload`'s read of the same two forms for rule 4's message
1992
+ * payload. A backslash escapes the next character in either the unquoted or
1993
+ * the double-quoted state, the same as a real shell. `$((` arithmetic is
1994
+ * left exactly as it reads today: this scan does not special-case it, so it
1995
+ * is still found as a `$(` whose body happens to start with its own `(`.
1996
+ *
1997
+ * `null` means some substitution in `segment` could not be delimited with
1998
+ * confidence — an unterminated quote or an unbalanced paren — so the caller
1999
+ * reads that as "cannot prove this is safe", not "found nothing".
2000
+ */
2001
+ function extractSubstitutionBodies(segment) {
2002
+ const bodies = [];
2003
+ let i = 0;
2004
+ let inDouble = false;
2005
+ while (i < segment.length) {
2006
+ const c = segment[i];
2007
+ if (c === '\\' && i + 1 < segment.length) {
2008
+ i += 2;
2009
+ continue;
2010
+ }
2011
+ if (!inDouble && c === "'") {
2012
+ const next = skipQuotedSpan(segment, i);
2013
+ if (next === null)
2014
+ return null;
2015
+ i = next;
2016
+ continue;
2017
+ }
2018
+ if (c === '"') {
2019
+ inDouble = !inDouble;
2020
+ i += 1;
2021
+ continue;
2022
+ }
2023
+ if (c === '$' && segment[i + 1] === '(') {
2024
+ const close = findBalancedParenClose(segment, i + 2);
2025
+ if (close === null)
2026
+ return null;
2027
+ bodies.push(segment.slice(i + 2, close));
2028
+ i = close + 1;
2029
+ continue;
2030
+ }
2031
+ if (c === '`') {
2032
+ const close = findBacktickClose(segment, i + 1);
2033
+ if (close === null)
2034
+ return null;
2035
+ bodies.push(segment.slice(i + 1, close));
2036
+ i = close + 1;
2037
+ continue;
2038
+ }
2039
+ if (!inDouble && (c === '<' || c === '>') && segment[i + 1] === '(') {
2040
+ const close = findBalancedParenClose(segment, i + 2);
2041
+ if (close === null)
2042
+ return null;
2043
+ bodies.push(segment.slice(i + 2, close));
2044
+ i = close + 1;
2045
+ continue;
2046
+ }
2047
+ i += 1;
2048
+ }
2049
+ return bodies;
2050
+ }
2051
+ /**
2052
+ * Whether `segment` carries a removal the shell would run while building
2053
+ * another command's argv, even one behind a plain, allowlisted first word —
2054
+ * the gap a re-review found in `isUnknownRemovalSegment`'s allowlist: `echo`
2055
+ * cannot run a second program from an *argument*, but
2056
+ * `echo $(X=rm; $X -rf /x)` has the shell run `$X -rf /x` as a command in
2057
+ * its own right before `echo` ever sees a value to print. Each substitution
2058
+ * body found by `extractSubstitutionBodies` is checked as a full command
2059
+ * line of its own, by recursing into `isUnboundedRemovalCommand` — the same
2060
+ * literal-`rm`, unknown-word and nested-shell reads it runs on the outer
2061
+ * command — so a substitution nested inside a body is caught by that same
2062
+ * recursive call rather than a second extraction pass here.
2063
+ *
2064
+ * A body this scanner cannot delimit with confidence (an unbalanced paren, a
2065
+ * quote that never closes) is not read as "found nothing": the segment is
2066
+ * refused outright when it carries a recursive-force flag cluster anywhere
2067
+ * in its raw text, the same shape `isUnknownRemovalSegment` already refuses
2068
+ * an unreadable word for — read as a substring of the untouched text
2069
+ * (`hasRecursiveForceInText`), not a dequoted word list, because the same
2070
+ * quoting that defeated delimiting also defeats a clean word split: an
2071
+ * apostrophe stranded inside an unresolved double-quoted span is exactly
2072
+ * what left this body undelimitable in the first place, so the one word
2073
+ * list `splitDequotedWords` would produce here cannot be trusted either.
2074
+ *
2075
+ * `depth` counts how many substitution bodies deep this call already is —
2076
+ * zero at the outermost command, one past the first `$(...)`/backtick/
2077
+ * process substitution, and so on as each body recurses into
2078
+ * `isUnboundedRemovalCommand` below. Nesting is attacker-controlled and
2079
+ * unbounded (`$($($(...)))`), and following it one JS stack frame per level
2080
+ * throws past a few hundred levels — so once `depth` reaches
2081
+ * `MAX_SUBSTITUTION_DEPTH`, extraction stops outright rather than reading
2082
+ * whatever raw text is left: nesting sixteen levels deep is never a
2083
+ * legitimate agent command, and a check this far past the point it can
2084
+ * actually read the body has nothing to vouch for it with. Refusing here
2085
+ * does not depend on `hasRecursiveForceInText` finding anything — it denies
2086
+ * on depth alone, the same way an undelimitable body used to read as "found
2087
+ * nothing" if it happened to carry no flags. That keeps both the call stack
2088
+ * and the work done bounded, however deep the nesting actually goes.
2089
+ */
2090
+ const MAX_SUBSTITUTION_DEPTH = 16;
2091
+ function hasUnsafeSubstitution(segment, repoRoot, policy, depth = 0) {
2092
+ if (depth >= MAX_SUBSTITUTION_DEPTH)
2093
+ return true;
2094
+ const bodies = extractSubstitutionBodies(segment);
2095
+ if (bodies === null)
2096
+ return hasRecursiveForceInText(segment);
2097
+ return bodies.some((body) => isUnboundedRemovalCommand(body, repoRoot, policy, depth + 1));
2098
+ }
2099
+ /**
2100
+ * Plain first words that cannot hand their later arguments to another
2101
+ * program — the allowlist that keeps the every-word scan below from
2102
+ * refusing ordinary commands whose own flags happen to spell `-rf`/`-Rf`:
2103
+ * `cp -rf "$SRC" dst`, `ls -rf $DIR`, `chmod -Rf 755 $DIR`. None of these
2104
+ * runs a second program from an argument the way a wrapper (`sudo`, `env`,
2105
+ * `xargs`, `timeout 5`, a bare `VAR=val` prefix, …) or a nested shell can, so
2106
+ * an unreadable word later in the same line carries no risk here. `rm`
2107
+ * itself is deliberately left out of this set: a plain `rm` or `/bin/rm`
2108
+ * first word is exempted the same way, but keeps the allowed-roots analysis
2109
+ * above instead of skipping straight to "allowed" — see
2110
+ * `isUnknownRemovalSegment`.
2111
+ */
2112
+ const REMOVAL_SAFE_FIRST_WORDS = new Set([
2113
+ 'cp',
2114
+ 'mv',
2115
+ 'ls',
2116
+ 'chmod',
2117
+ 'chown',
2118
+ 'chgrp',
2119
+ 'ln',
2120
+ 'mkdir',
2121
+ 'touch',
2122
+ 'cat',
2123
+ 'echo',
2124
+ 'printf',
2125
+ 'grep',
2126
+ ]);
2127
+ /**
2128
+ * Rule 6's gap once some word of the segment is unreadable: neither
2129
+ * `hasDisguisedRmWord` (which still needs a word's basename to resolve to
2130
+ * the literal `rm`) nor the rest of `rmOutOfBoundsInText` (which needs to
2131
+ * find an `rm` word at all) can place a word that only resolves to `rm` once
2132
+ * a shell expands `$X`, `$(...)` or a backtick span — this file never
2133
+ * evaluates one of those. So when any word of the segment is unreadable this
2134
+ * way (`isUnexpandedCommandWord`), the rest of it is read for the one shape
2135
+ * this rule already guards against: a recursive-force flag cluster
2136
+ * (`hasRecursiveForce`, the same flag parsing the plain-`rm` path above
2137
+ * uses). Resolving the expansion itself is out of scope — an open-ended
2138
+ * evaluator is the wrong tool — so this is shape only.
2139
+ *
2140
+ * Every word is scanned, not just the one in command position: a wrapper, a
2141
+ * group (`( … )`), or a keyword (`{ … }`, `if … then … fi`) can all sit in
2142
+ * front of the unreadable word without moving it out of the segment. That
2143
+ * widens what trips the check, so a first word that is plain and cannot run
2144
+ * another program (`REMOVAL_SAFE_FIRST_WORDS`), or dequotes to the literal
2145
+ * `rm`/`/bin/rm`, is exempted — anything else in first position, including
2146
+ * an unrecognised command, falls through to the refusal. Fail-closed over
2147
+ * precise, same direction as every other read in this file.
2148
+ *
2149
+ * Checked before any of that: `hasUnsafeSubstitution`. The allowlist above
2150
+ * only says a plain first word cannot run another program from its own
2151
+ * *arguments* — it says nothing about a command or process substitution
2152
+ * sitting in one of those arguments, which the shell still runs as a
2153
+ * command in its own right while it builds the argv. `echo $(X=rm; $X -rf
2154
+ * /x)` is exactly `echo`'s shape and nothing else, so the allowlist alone
2155
+ * would wave it through; the substitution check below is what actually
2156
+ * reads what the shell runs before `echo` sees a value to print.
2157
+ */
2158
+ function isUnknownRemovalSegment(segment, repoRoot, policy, depth = 0) {
2159
+ if (hasUnsafeSubstitution(segment, repoRoot, policy, depth))
2160
+ return true;
2161
+ const words = splitDequotedWords(segment);
2162
+ const first = words[0];
2163
+ if (first !== undefined && isPlainWord(first.raw)) {
2164
+ const basename = first.text.split(/[\\/]/).pop() ?? first.text;
2165
+ if (basename === 'rm' || REMOVAL_SAFE_FIRST_WORDS.has(basename))
2166
+ return false;
2167
+ }
2168
+ if (!words.some((word) => isUnexpandedCommandWord(word)))
2169
+ return false;
2170
+ return hasRecursiveForce(words.map((word) => word.text));
2171
+ }
2172
+ /**
2173
+ * Reads `topLevelCommands`, not `splitChainSegments`: the naive
2174
+ * single-character split above cuts `$(echo rm) -rf /x` apart at the very
2175
+ * `(`/`)`/backtick that make the word unreadable, landing the flag cluster
2176
+ * in a different "segment" than the word it belongs to and missing the
2177
+ * combination entirely.
2178
+ *
2179
+ * Each top-level segment is also checked as a nested shell's dequoted
2180
+ * payload (`sh -c '…'`, `bash -c "…"`, `eval '…'`): `splitDequotedWords`
2181
+ * keeps a quoted span's internal text as one word, which hides a `-rf`
2182
+ * token as a standalone token until the quotes are gone — see
2183
+ * `dequoteShellWrappedSegment`.
2184
+ */
2185
+ function hasUnknownRemovalCommandWord(command, repoRoot, policy, depth = 0) {
2186
+ return topLevelCommands(command).some((segment) => {
2187
+ if (isUnknownRemovalSegment(segment, repoRoot, policy, depth))
2188
+ return true;
2189
+ const wrapped = dequoteShellWrappedSegment(segment);
2190
+ return wrapped !== null && isUnknownRemovalSegment(wrapped, repoRoot, policy, depth);
2191
+ });
2192
+ }
2193
+ /**
2194
+ * Rule 6's boolean core, pulled out of `checkUnboundedRm` so
2195
+ * `hasUnsafeSubstitution` can run the exact same check on a substitution
2196
+ * body: the literal-`rm` path (every chain segment read for a disguised or
2197
+ * plain `rm` outside `allowed_roots`) plus the unknown-word path
2198
+ * (`hasUnknownRemovalCommandWord`, which already folds in the nested-shell
2199
+ * path). `command` here is "a command line of its own" in the brief's
2200
+ * words — it may be the whole tool call, or it may be the text found inside
2201
+ * someone else's `$(...)`.
966
2202
  *
967
2203
  * Checks every chain segment independently, not just the first `rm` in the
968
2204
  * whole command. guard.sh's own `grep -Eo 'rm[[:space:]]+[^;&|]*'` — and this
969
- * function, until this fix — stopped at the first match in the *whole*
2205
+ * function, until an earlier fix — stopped at the first match in the *whole*
970
2206
  * command string, so `rm -rf workspaces/a; rm -rf src/important` read
971
2207
  * `workspaces/a` (allowed), decided the command was fine, and never looked
972
2208
  * at `src/important` at all. That is a deliberate behaviour change from a
973
2209
  * straight port: closing a chained-command bypass that guard.sh always had,
974
2210
  * not carrying it forward with new spelling.
975
2211
  */
976
- function checkUnboundedRm(command, repoRoot, policy) {
2212
+ function isUnboundedRemovalCommand(command, repoRoot, policy, depth = 0) {
977
2213
  const outOfBounds = splitChainSegments(command).some((segment) => {
978
- if (!RM_INVOKE_RE.test(segment))
979
- return false;
980
- const args = extractRmArgs(segment);
981
- if (!hasRecursiveForce(args))
982
- return false;
983
- return args.some((tok) => {
984
- if (tok.startsWith('-'))
985
- return false;
986
- return !isAllowedRemovalPath(normalizeRemovalPath(tok, repoRoot), policy);
987
- });
2214
+ const decoded = decodeAnsiCQuoting(segment);
2215
+ // An unterminated `$'` span cannot be decoded with confidence — fail
2216
+ // closed rather than guess at what it hides, the same direction
2217
+ // `checkForcePush` takes for the same construct.
2218
+ if (decoded.unterminated)
2219
+ return /\brm\b/i.test(segment);
2220
+ if (rmOutOfBoundsInText(decoded.text, repoRoot, policy))
2221
+ return true;
2222
+ const wrapped = shellWrappedRmPayload(decoded.text);
2223
+ return wrapped !== null && rmOutOfBoundsInText(wrapped, repoRoot, policy);
988
2224
  });
989
- if (!outOfBounds)
2225
+ return outOfBounds || hasUnknownRemovalCommandWord(command, repoRoot, policy, depth);
2226
+ }
2227
+ /**
2228
+ * Rule 6: a recursive-force `rm` outside `allowed_roots`, however the two
2229
+ * flags are spelled (see `hasRecursiveForce`) — see
2230
+ * `isUnboundedRemovalCommand` for the check itself.
2231
+ */
2232
+ function checkUnboundedRm(command, repoRoot, policy) {
2233
+ if (!isUnboundedRemovalCommand(command, repoRoot, policy))
990
2234
  return null;
991
2235
  return violation(requireRule(policy, 'unbounded-rm'));
992
2236
  }
@@ -1245,10 +2489,10 @@ function checkRoleWriteScope(command, filePath, lease, repoRoot, policy, scope)
1245
2489
  /**
1246
2490
  * Judge rule 3: a judge does not touch its own lease.
1247
2491
  *
1248
- * Without this the sandbox would be one `smith sandbox close` away from
1249
- * being off, and `rm state/sandboxes/<hash>.json` is a removal under
1250
- * `state/`, which the `unbounded-rm` rule above explicitly permits. The
1251
- * refusal is unconditional on the lease directory rather than scoped to
2492
+ * Without this the sandbox would be one `bs sandbox close` (or its
2493
+ * deprecated `smith` alias) away from being off, and
2494
+ * `rm state/sandboxes/<hash>.json` is a removal under `state/`, which the
2495
+ * `unbounded-rm` rule above explicitly permits. The refusal is unconditional on the lease directory rather than scoped to
1252
2496
  * *this* lease: reading another judge's lease is no more a judge's business
1253
2497
  * than deleting its own.
1254
2498
  */
@@ -1258,7 +2502,8 @@ function checkJudgeSandboxEscape(command, lease, policy) {
1258
2502
  return true;
1259
2503
  if (!/\bsandbox\b/i.test(segment))
1260
2504
  return false;
1261
- return (hasCommandWord(segment, 'smith') ||
2505
+ return (hasCommandWord(segment, 'bs') ||
2506
+ hasCommandWord(segment, 'smith') ||
1262
2507
  hasCommandWord(segment, 'node') ||
1263
2508
  /\bcli\.js\b/i.test(segment));
1264
2509
  });