@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.
- package/.claude/agents/{auditor.md → bs-auditor.md} +8 -5
- package/.claude/agents/{coder.md → bs-coder.md} +8 -7
- package/.claude/agents/{grader.md → bs-grader.md} +16 -9
- package/.claude/agents/{merger.md → bs-merger.md} +4 -4
- package/.claude/agents/{planner.md → bs-planner.md} +20 -6
- package/.claude/agents/{researcher.md → bs-researcher.md} +6 -5
- package/.claude/agents/{reviewer.md → bs-reviewer.md} +16 -5
- package/.claude/agents/{scribe.md → bs-scribe.md} +11 -2
- package/.claude/agents/{security-reviewer.md → bs-security-reviewer.md} +9 -6
- package/.claude/agents/{spec-reviewer.md → bs-spec-reviewer.md} +18 -10
- package/.claude/agents/{tester.md → bs-tester.md} +40 -12
- package/.claude/agents/bs-uiux.md +257 -0
- package/.claude/agents/{verifier.md → bs-verifier.md} +7 -4
- package/.claude/agents/{wave-runner.md → bs-wave-runner.md} +14 -5
- package/.claude/skills/bs/SKILL.md +99 -21
- package/.claude/skills/bs/audit.md +45 -31
- package/.claude/skills/bs/dispatch.md +133 -47
- package/.claude/skills/bs/lessons.md +28 -14
- package/.claude/skills/bs/mcp.md +4 -4
- package/.claude/skills/bs/new.md +3 -3
- package/.claude/skills/bs/plan.md +57 -16
- package/.claude/skills/bs/report.md +11 -11
- package/.claude/skills/bs/run.md +190 -52
- package/.claude/skills/bs/status.md +14 -7
- package/.claude/skills/bs/ui.md +21 -5
- package/.claude/skills/bs/waivers.md +78 -2
- package/.claude/skills/bs/wave.md +143 -50
- package/README.md +53 -32
- package/factory/orchestrator/dist/agentNames.js +43 -0
- package/factory/orchestrator/dist/agents-registry.js +11 -4
- package/factory/orchestrator/dist/agentsSync.js +70 -13
- package/factory/orchestrator/dist/artifacts.js +31 -0
- package/factory/orchestrator/dist/attribution.js +4 -0
- package/factory/orchestrator/dist/audit.js +245 -20
- package/factory/orchestrator/dist/autonomy.js +6 -2
- package/factory/orchestrator/dist/budgetAlarm.js +132 -37
- package/factory/orchestrator/dist/budgets.js +302 -84
- package/factory/orchestrator/dist/claims.js +9 -19
- package/factory/orchestrator/dist/cli.js +1087 -116
- package/factory/orchestrator/dist/cliName.js +27 -0
- package/factory/orchestrator/dist/crossFinding.js +20 -0
- package/factory/orchestrator/dist/crosscheck.js +136 -8
- package/factory/orchestrator/dist/daemon.js +352 -67
- package/factory/orchestrator/dist/db/projector.js +327 -54
- package/factory/orchestrator/dist/db/queries.js +1831 -94
- package/factory/orchestrator/dist/db/schema.js +57 -4
- package/factory/orchestrator/dist/declaredArtifactLine.js +18 -0
- package/factory/orchestrator/dist/delegation.js +86 -19
- package/factory/orchestrator/dist/diffstat.js +15 -8
- package/factory/orchestrator/dist/dispatchLint.js +108 -0
- package/factory/orchestrator/dist/effort.js +14 -2
- package/factory/orchestrator/dist/effortTiers.js +13 -0
- package/factory/orchestrator/dist/env.js +26 -0
- package/factory/orchestrator/dist/epic.js +494 -42
- package/factory/orchestrator/dist/epicBudget.js +93 -0
- package/factory/orchestrator/dist/epicWidth.js +22 -5
- package/factory/orchestrator/dist/errorIssues.js +182 -11
- package/factory/orchestrator/dist/escalation.js +2 -2
- package/factory/orchestrator/dist/events.js +164 -14
- package/factory/orchestrator/dist/feedback.js +169 -0
- package/factory/orchestrator/dist/findingContext.js +26 -0
- package/factory/orchestrator/dist/findings.js +108 -14
- package/factory/orchestrator/dist/gate.js +120 -10
- package/factory/orchestrator/dist/gh.js +19 -2
- package/factory/orchestrator/dist/git.js +20 -2
- package/factory/orchestrator/dist/goalCheck.js +2 -2
- package/factory/orchestrator/dist/harness.js +25 -17
- package/factory/orchestrator/dist/hookDecision.js +411 -39
- package/factory/orchestrator/dist/integration.js +20 -8
- package/factory/orchestrator/dist/issueReporter.js +81 -16
- package/factory/orchestrator/dist/judgePreflight.js +25 -4
- package/factory/orchestrator/dist/judgeRoles.js +47 -0
- package/factory/orchestrator/dist/judgeStopHook.js +239 -0
- package/factory/orchestrator/dist/judges.js +419 -21
- package/factory/orchestrator/dist/lessons.js +190 -11
- package/factory/orchestrator/dist/logCache.js +291 -0
- package/factory/orchestrator/dist/mcp.js +6 -6
- package/factory/orchestrator/dist/paths.js +20 -7
- package/factory/orchestrator/dist/plan.js +399 -33
- package/factory/orchestrator/dist/planQuorum.js +5 -2
- package/factory/orchestrator/dist/policy.js +1275 -30
- package/factory/orchestrator/dist/projectName.js +25 -0
- package/factory/orchestrator/dist/projects.js +12 -1
- package/factory/orchestrator/dist/promptCapture.js +306 -0
- package/factory/orchestrator/dist/promptHook.js +33 -0
- package/factory/orchestrator/dist/promptImport.js +281 -0
- package/factory/orchestrator/dist/prompts.js +13 -0
- package/factory/orchestrator/dist/providers/schema-validate.js +2 -2
- package/factory/orchestrator/dist/providers/types.js +1 -1
- package/factory/orchestrator/dist/queue.js +471 -30
- package/factory/orchestrator/dist/quorum.js +18 -5
- package/factory/orchestrator/dist/results.js +25 -7
- package/factory/orchestrator/dist/roadmap.js +10 -5
- package/factory/orchestrator/dist/run-cli.js +15 -10
- package/factory/orchestrator/dist/runner.js +2 -2
- package/factory/orchestrator/dist/scheduler.js +3 -3
- package/factory/orchestrator/dist/schemas.js +56 -8
- package/factory/orchestrator/dist/severity.js +8 -4
- package/factory/orchestrator/dist/spec.js +246 -12
- package/factory/orchestrator/dist/specChange.js +13 -9
- package/factory/orchestrator/dist/stack.js +6 -6
- package/factory/orchestrator/dist/storeRoot.js +90 -0
- package/factory/orchestrator/dist/symbols.js +261 -6
- package/factory/orchestrator/dist/taskEvents.js +192 -31
- package/factory/orchestrator/dist/taskStatus.js +14 -0
- package/factory/orchestrator/dist/taxonomy.js +11 -0
- package/factory/orchestrator/dist/testerAudit.js +31 -12
- package/factory/orchestrator/dist/testgate.js +20 -16
- package/factory/orchestrator/dist/uiuxGate.js +83 -0
- package/factory/orchestrator/dist/usage.js +113 -48
- package/factory/orchestrator/dist/waivers.js +99 -12
- package/factory/orchestrator/dist/waveBudget.js +2 -0
- package/factory/orchestrator/dist/waveConcurrency.js +143 -7
- package/factory/orchestrator/dist/waveSchedule.js +84 -0
- package/factory/orchestrator/dist/workroot.js +1 -1
- package/factory/orchestrator/dist/worktree.js +76 -21
- package/factory/orchestrator/drizzle/0013_operator_feedback.sql +19 -0
- package/factory/orchestrator/drizzle/0014_misty_madripoor.sql +2 -0
- package/factory/orchestrator/drizzle/0015_gifted_omega_flight.sql +1 -0
- package/factory/orchestrator/drizzle/0016_milestones_project_default.sql +30 -0
- package/factory/orchestrator/drizzle/0017_events_raw_cli_session_id.sql +2 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +35 -0
- package/factory/policies/budgets.yml +123 -33
- package/factory/policies/crosscheck.yml +51 -13
- package/factory/policies/delegation.yml +3 -3
- package/factory/policies/effort.yml +18 -10
- package/factory/policies/guardrails.yml +8 -6
- package/factory/policies/harness.yml +5 -5
- package/factory/policies/lessons.md +53 -2
- package/factory/policies/scheduler.yml +4 -4
- package/factory/policies/sensitive-paths.yml +1 -1
- package/factory/policies/severity.yml +13 -1
- package/factory/policies/stack.yml +6 -6
- package/factory/policies/taxonomy.yml +9 -3
- package/factory/policies/worktree.yml +15 -9
- package/factory/specs/roadmap.md +27 -3
- package/factory/specs/schema/event.schema.json +8 -1
- package/factory/specs/schema/result.schema.json +21 -8
- package/factory/specs/schema/task-spec.schema.json +12 -0
- package/package.json +16 -12
- 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
|
-
|
|
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
|
-
/**
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
965
|
-
*
|
|
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
|
|
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
|
|
2212
|
+
function isUnboundedRemovalCommand(command, repoRoot, policy, depth = 0) {
|
|
977
2213
|
const outOfBounds = splitChainSegments(command).some((segment) => {
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
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
|
-
|
|
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 `
|
|
1249
|
-
*
|
|
1250
|
-
* `state
|
|
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, '
|
|
2505
|
+
return (hasCommandWord(segment, 'bs') ||
|
|
2506
|
+
hasCommandWord(segment, 'smith') ||
|
|
1262
2507
|
hasCommandWord(segment, 'node') ||
|
|
1263
2508
|
/\bcli\.js\b/i.test(segment));
|
|
1264
2509
|
});
|