@code-yeongyu/senpi 2026.10.25-4 → 2026.10.26-1
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/CHANGELOG.md +23 -0
- package/dist/bundle/chunks/{anthropic-messages-WBASBTIW.js → anthropic-messages-M235FJQV.js} +3 -3
- package/dist/bundle/chunks/{args-B444OQBK.js → args-FBNJOJSR.js} +1 -1
- package/dist/bundle/chunks/azure-openai-responses-YQRTQ7H6.js +2 -0
- package/dist/bundle/chunks/bedrock-converse-stream.js +3 -3
- package/dist/bundle/chunks/{chunk-4MOZXOJX.js → chunk-3A3XCLNH.js} +1 -1
- package/dist/bundle/chunks/{chunk-E6D4RIT7.js → chunk-3LPWILUZ.js} +2 -2
- package/dist/bundle/chunks/{chunk-3BTN337S.js → chunk-3WP5RFQR.js} +1 -1
- package/dist/bundle/chunks/{chunk-2RB5EL4X.js → chunk-5FQMNHXV.js} +1 -1
- package/dist/bundle/chunks/{chunk-STMXIBXF.js → chunk-5KZH6EYQ.js} +1 -1
- package/dist/bundle/chunks/{chunk-4P7WARQZ.js → chunk-5LWUUXMH.js} +1 -1
- package/dist/bundle/chunks/{chunk-5JJDR6XI.js → chunk-6U3TBFBA.js} +1 -1
- package/dist/bundle/chunks/{chunk-BWA4FVFW.js → chunk-7KOUQSBX.js} +2 -2
- package/dist/bundle/chunks/{chunk-7CNOCJCN.js → chunk-ADQAPDPV.js} +1 -1
- package/dist/bundle/chunks/{chunk-ITGYN22E.js → chunk-CDQPA4FP.js} +1 -1
- package/dist/bundle/chunks/{chunk-WDPQ6CZJ.js → chunk-DMFAVE5N.js} +1 -1
- package/dist/bundle/chunks/{chunk-XF3LJC3O.js → chunk-DYWIFF2Q.js} +1 -1
- package/dist/bundle/chunks/{chunk-C2MQ4QXN.js → chunk-EZP6AYW2.js} +35 -29
- package/dist/bundle/chunks/chunk-GQJYNFMO.js +2 -0
- package/dist/bundle/chunks/{chunk-SBGIXMX3.js → chunk-GWGNVYPB.js} +1 -1
- package/dist/bundle/chunks/{chunk-KKD4CYCI.js → chunk-H4WJ4LY3.js} +1 -1
- package/dist/bundle/chunks/{chunk-V2UYXTRR.js → chunk-HWTD4QF3.js} +1 -1
- package/dist/bundle/chunks/{chunk-WD6WBYC5.js → chunk-I7QWTWVZ.js} +1 -1
- package/dist/bundle/chunks/{chunk-BZ7726QO.js → chunk-J2YAYTB2.js} +1 -1
- package/dist/bundle/chunks/{chunk-TTU2RNVX.js → chunk-J5FCH7TI.js} +1 -1
- package/dist/bundle/chunks/chunk-JFQUWEN7.js +13 -0
- package/dist/bundle/chunks/{chunk-KNVNKDER.js → chunk-L2O6K3SJ.js} +1 -1
- package/dist/bundle/chunks/{chunk-4AOMNOGY.js → chunk-LO5MYLGF.js} +1 -1
- package/dist/bundle/chunks/{chunk-N7IN46Q5.js → chunk-MSHRIZGT.js} +8 -6
- package/dist/bundle/chunks/{chunk-T36LJ6MW.js → chunk-MY4ZD3EW.js} +1 -1
- package/dist/bundle/chunks/{chunk-UFJ2K7Q7.js → chunk-OQ3WDMB3.js} +1 -1
- package/dist/bundle/chunks/{chunk-7GZUFOM3.js → chunk-PH3JDRB2.js} +1 -1
- package/dist/bundle/chunks/{chunk-E6WYH3ZL.js → chunk-QREKAXKX.js} +7 -5
- package/dist/bundle/chunks/{chunk-3QCYBOKN.js → chunk-QYFOHFPS.js} +1 -1
- package/dist/bundle/chunks/{chunk-JMOUU75H.js → chunk-SOXQ42VS.js} +1 -1
- package/dist/bundle/chunks/{chunk-FHXR4ZAC.js → chunk-UDM7VO6O.js} +1 -1
- package/dist/bundle/chunks/{chunk-OLCDIITS.js → chunk-UW6M3EXY.js} +1 -1
- package/dist/bundle/chunks/chunk-VICDRG3J.js +410 -0
- package/dist/bundle/chunks/chunk-VL7O5PO4.js +5 -0
- package/dist/bundle/chunks/{chunk-PT6JJUN2.js → chunk-XMNPK472.js} +1 -1
- package/dist/bundle/chunks/{chunk-GSSD3R5X.js → chunk-XQ45JMGN.js} +1 -1
- package/dist/bundle/chunks/{client-WTYEICLH.js → chunk-Y3TMJV3J.js} +3 -3
- package/dist/bundle/chunks/{chunk-KXB5WIS5.js → chunk-Z6SAQ5O3.js} +1 -1
- package/dist/bundle/chunks/{chunk-LOIF6PHI.js → chunk-ZAUVY6ZC.js} +3 -3
- package/dist/bundle/chunks/{chunk-E4LBKOZG.js → chunk-ZLIVZQDY.js} +4 -4
- package/dist/bundle/chunks/{chunk-6AOVUTUR.js → chunk-ZXHLH4MW.js} +2 -2
- package/dist/bundle/chunks/cli-main-55EQA5PS.js +3 -0
- package/dist/bundle/chunks/client-LOS6GSEW.js +2 -0
- package/dist/bundle/chunks/{cloudflare-workers-ai-system-one-K5H6BJP3.js → cloudflare-workers-ai-system-one-ZJQGIIRC.js} +1 -1
- package/dist/bundle/chunks/cursor-agent.js +3 -3
- package/dist/bundle/chunks/devin-agent.js +3 -3
- package/dist/bundle/chunks/dispatch-C2IBP5VV.js +9 -0
- package/dist/bundle/chunks/google-generative-ai-5G4RIQA6.js +2 -0
- package/dist/bundle/chunks/google-vertex-ALD4P27O.js +2 -0
- package/dist/bundle/chunks/{help-fast-path-3Z26DQSV.js → help-fast-path-PEIKGCKZ.js} +1 -1
- package/dist/bundle/chunks/host-command-657VCQ34.js +2 -0
- package/dist/bundle/chunks/{host-lifecycle-3VHHIGDL.js → host-lifecycle-HX3EGRRW.js} +1 -1
- package/dist/bundle/chunks/host-lifecycle.js +2 -2
- package/dist/bundle/chunks/{host-session-command-56XF6ACS.js → host-session-command-OV5XDOJK.js} +1 -1
- package/dist/bundle/chunks/{host-socket-request-QL2WEZTN.js → host-socket-request-K4LCUOUD.js} +1 -1
- package/dist/bundle/chunks/interactive-mode-MLXSYK2H.js +2 -0
- package/dist/bundle/chunks/{mcp-presenter-bootstrap-XUSCQVRC.js → mcp-presenter-bootstrap-FOMKF4TU.js} +1 -1
- package/dist/bundle/chunks/{mistral-conversations-DH44NOYV.js → mistral-conversations-RAE4K2XY.js} +1 -1
- package/dist/bundle/chunks/{multi-session-host-Z2CW4SDM.js → multi-session-host-774MACJU.js} +5 -6
- package/dist/bundle/chunks/{openai-codex-responses-SERAFKAS.js → openai-codex-responses-3CW4R7TG.js} +1 -1
- package/dist/bundle/chunks/{openai-completions-BZB5O45A.js → openai-completions-5UIJXQUF.js} +1 -1
- package/dist/bundle/chunks/openai-responses-2BO44MRW.js +3 -0
- package/dist/bundle/chunks/{package-manager-cli-N55U6PEO.js → package-manager-cli-MZHVCX2S.js} +1 -1
- package/dist/bundle/chunks/{pi-messages-VXATX45B.js → pi-messages-RF6GER4R.js} +2 -2
- package/dist/bundle/chunks/{role-JVSBNKGE.js → role-ETSFSDTS.js} +1 -1
- package/dist/bundle/chunks/{rotation-stream-CBGCBYHV.js → rotation-stream-ZMQB4GPO.js} +1 -1
- package/dist/bundle/chunks/rpc-mode-UJXOIZCW.js +2 -0
- package/dist/bundle/chunks/{rpc-monitor-source-AE3C6EQV.js → rpc-monitor-source-DRZXJHTL.js} +1 -1
- package/dist/bundle/chunks/{schedule-command-VS7CZC2J.js → schedule-command-KA7LFFNK.js} +1 -1
- package/dist/bundle/chunks/session-control-endpoint-MFWXUR7Z.js +3 -0
- package/dist/bundle/chunks/{session-picker-E4KUJDLO.js → session-picker-BP33NRSL.js} +1 -1
- package/dist/bundle/chunks/session-worker.js +199 -190
- package/dist/bundle/chunks/{typesafe-system-one-P75WJ767.js → typesafe-system-one-GNNZ5O5U.js} +1 -1
- package/dist/bundle/cli.js +2 -2
- package/dist/bundle/host-supervisor.js +2 -2
- package/dist/bundle/index.js +1 -1
- package/dist/bundle/rpc-entry.js +1 -1
- package/dist/bundle/runtime-manifest.json +1 -1
- package/dist/cli/host-command-args.d.ts +1 -0
- package/dist/cli/host-command-args.js +5 -0
- package/dist/cli/host-command.js +7 -1
- package/dist/cli.js +21 -3
- package/dist/core/extensions/builtin/mcp/catalog-cache.d.ts +2 -1
- package/dist/core/extensions/builtin/mcp/catalog-cache.js +19 -6
- package/dist/core/extensions/builtin/mcp/catalog.d.ts +3 -2
- package/dist/core/extensions/builtin/mcp/catalog.js +20 -3
- package/dist/core/extensions/builtin/mcp/expose/call.d.ts +2 -1
- package/dist/core/extensions/builtin/mcp/expose/call.js +2 -1
- package/dist/core/extensions/builtin/mcp/expose/register.js +1 -1
- package/dist/core/extensions/builtin/mcp/expose/schema-compat.d.ts +2 -2
- package/dist/core/extensions/builtin/mcp/expose/schema-compat.js +11 -1
- package/dist/core/extensions/builtin/mcp/invocation.js +16 -2
- package/dist/core/extensions/builtin/mcp/modern/request.d.ts +1 -1
- package/dist/core/extensions/builtin/mcp/modern/request.js +1 -1
- package/dist/core/extensions/builtin/mcp/modern/sdk-bridge.js +18 -3
- package/dist/core/extensions/builtin/mcp/modern/stdio-probe.js +23 -0
- package/dist/core/extensions/builtin/mcp/modern/subscriptions.d.ts +14 -0
- package/dist/core/extensions/builtin/mcp/modern/subscriptions.js +226 -0
- package/dist/core/extensions/builtin/mcp/modern/tool-schema.d.ts +13 -0
- package/dist/core/extensions/builtin/mcp/modern/tool-schema.js +75 -0
- package/dist/core/extensions/builtin/mcp/modern/tools.d.ts +107 -0
- package/dist/core/extensions/builtin/mcp/modern/tools.js +53 -0
- package/dist/core/extensions/builtin/mcp/resources.js +4 -0
- package/dist/core/extensions/builtin/mcp/tool-client.d.ts +13 -0
- package/dist/core/extensions/builtin/mcp/tool-client.js +17 -0
- package/dist/core/extensions/builtin/mcp/tool-contracts.d.ts +19 -0
- package/dist/core/extensions/builtin/mcp/tool-contracts.js +2 -0
- package/dist/core/extensions/builtin/mcp/wrap.js +2 -2
- package/dist/core/extensions/builtin/permission-system/bash-command-segments.d.ts +37 -0
- package/dist/core/extensions/builtin/permission-system/bash-command-segments.js +899 -0
- package/dist/core/extensions/builtin/permission-system/parsers.d.ts +2 -0
- package/dist/core/extensions/builtin/permission-system/parsers.js +44 -10
- package/dist/modes/interactive/session-control-commands.js +21 -4
- package/dist/modes/interactive/session-control-rpc-commands.d.ts +1 -1
- package/dist/modes/interactive/session-control-rpc-commands.js +19 -0
- package/dist/modes/rpc/compact-content-store.d.ts +96 -0
- package/dist/modes/rpc/compact-content-store.js +269 -0
- package/dist/modes/rpc/compact-session-state.d.ts +111 -0
- package/dist/modes/rpc/compact-session-state.js +206 -0
- package/dist/modes/rpc/compact-spool-dir.d.ts +5 -0
- package/dist/modes/rpc/compact-spool-dir.js +61 -0
- package/dist/modes/rpc/compact-storage-io.d.ts +27 -0
- package/dist/modes/rpc/compact-storage-io.js +19 -0
- package/dist/modes/rpc/connection-handler.d.ts +2 -0
- package/dist/modes/rpc/connection-handler.js +39 -7
- package/dist/modes/rpc/connection-question-bridge.d.ts +2 -1
- package/dist/modes/rpc/connection-question-bridge.js +6 -2
- package/dist/modes/rpc/extension-ui-response.d.ts +7 -2
- package/dist/modes/rpc/extension-ui-response.js +27 -7
- package/dist/modes/rpc/get-snapshot.d.ts +18 -0
- package/dist/modes/rpc/get-snapshot.js +43 -0
- package/dist/modes/rpc/host-capabilities.js +3 -0
- package/dist/modes/rpc/host-ensure-start.js +1 -0
- package/dist/modes/rpc/host-ensure-types.d.ts +2 -0
- package/dist/modes/rpc/host-ensure.js +1 -0
- package/dist/modes/rpc/host-handoff.d.ts +2 -0
- package/dist/modes/rpc/host-handover-request.d.ts +1 -1
- package/dist/modes/rpc/host-handover-request.js +4 -3
- package/dist/modes/rpc/host-handover-wire.js +6 -0
- package/dist/modes/rpc/host-idle-handover.d.ts +2 -0
- package/dist/modes/rpc/host-idle-handover.js +3 -1
- package/dist/modes/rpc/host-observe-request.js +1 -0
- package/dist/modes/rpc/host-runner.d.ts +1 -0
- package/dist/modes/rpc/host-runner.js +9 -7
- package/dist/modes/rpc/host-successor.js +4 -0
- package/dist/modes/rpc/json-normal-value.d.ts +6 -0
- package/dist/modes/rpc/json-normal-value.js +100 -0
- package/dist/modes/rpc/mcp-presenter-bootstrap.js +5 -0
- package/dist/modes/rpc/media-placeholders.d.ts +9 -3
- package/dist/modes/rpc/media-placeholders.js +12 -4
- package/dist/modes/rpc/rpc-host-command-types.d.ts +2 -0
- package/dist/modes/rpc/rpc-question-types.d.ts +1 -0
- package/dist/modes/rpc/rpc-read-response-types.d.ts +7 -0
- package/dist/modes/rpc/rpc-session-command-types.d.ts +6 -0
- package/dist/modes/rpc/rpc-session-state-types.d.ts +3 -0
- package/dist/modes/rpc/rpc-session-state.js +2 -0
- package/dist/modes/rpc/rpc-types.d.ts +1 -0
- package/dist/modes/rpc/session-attachment-gate.js +1 -0
- package/dist/modes/rpc/session-binding.js +21 -18
- package/dist/modes/rpc/session-command-activity.js +1 -0
- package/dist/modes/rpc/session-event-fanout.d.ts +1 -1
- package/dist/modes/rpc/session-event-fanout.js +17 -9
- package/dist/modes/rpc/session-event-writer.js +22 -5
- package/dist/modes/rpc/session-extension-ui-requests.d.ts +2 -1
- package/dist/modes/rpc/session-extension-ui-requests.js +15 -10
- package/dist/modes/rpc/session-held.js +1 -0
- package/dist/modes/rpc/session-status-stream.d.ts +2 -1
- package/dist/modes/rpc/session-status-stream.js +8 -2
- package/dist/modes/rpc/session-status-subscriptions.d.ts +2 -1
- package/dist/modes/rpc/session-status-subscriptions.js +4 -0
- package/dist/modes/rpc/session-worker.js +5 -2
- package/dist/modes/rpc/snapshot-types.d.ts +29 -0
- package/dist/modes/rpc/snapshot-types.js +2 -0
- package/dist/modes/rpc/ui-answer-ledger.d.ts +13 -0
- package/dist/modes/rpc/ui-answer-ledger.js +34 -0
- package/dist/senpi +21 -3
- package/docs/mcp.md +20 -0
- package/docs/rpc.md +44 -0
- package/package.json +7 -6
- package/dist/bundle/chunks/azure-openai-responses-7I45DI72.js +0 -2
- package/dist/bundle/chunks/chunk-DMI55M2N.js +0 -2
- package/dist/bundle/chunks/chunk-DODH3N6U.js +0 -410
- package/dist/bundle/chunks/chunk-J37GRED2.js +0 -5
- package/dist/bundle/chunks/chunk-TBU6VYHN.js +0 -13
- package/dist/bundle/chunks/cli-main-YYH3IBC6.js +0 -3
- package/dist/bundle/chunks/dispatch-MXG5YWLV.js +0 -9
- package/dist/bundle/chunks/google-generative-ai-UM5Y7T6B.js +0 -2
- package/dist/bundle/chunks/google-vertex-OKU2VQHL.js +0 -2
- package/dist/bundle/chunks/host-command-TDLLHXVI.js +0 -2
- package/dist/bundle/chunks/interactive-mode-OEPULLZK.js +0 -2
- package/dist/bundle/chunks/openai-responses-TNHQ6SJS.js +0 -3
- package/dist/bundle/chunks/rpc-mode-ETXQWEB2.js +0 -2
- package/dist/bundle/chunks/session-control-endpoint-4LEIDIVI.js +0 -3
|
@@ -0,0 +1,899 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Splits a shell command into the simple commands it would run, so the `bash` permission decision
|
|
3
|
+
* can judge every one of them, not only the first.
|
|
4
|
+
*
|
|
5
|
+
* The lexer follows bash's word and operator rules closely enough to find each simple command:
|
|
6
|
+
* `;`, `&&`, `||`, `|`, `|&`, `&` and line breaks end one; subshells `( … )`, command substitution
|
|
7
|
+
* `$( … )` and backticks, process substitution `<( … )` / `>( … )`, `${ … }` and `$(( … ))` and the
|
|
8
|
+
* bodies of unquoted here-documents are searched for the commands they run; redirections and their
|
|
9
|
+
* targets are dropped. Every command name is then normalized: leading assignments and reserved
|
|
10
|
+
* words are skipped, a path is reduced to its basename, and a wrapper (`env`, `sudo`, `xargs`,
|
|
11
|
+
* `bash -c`, `find -exec`, …) yields both its own name and the command it runs.
|
|
12
|
+
*
|
|
13
|
+
* Anything the lexer does not understand (an unterminated quote, a `case`, a function definition,
|
|
14
|
+
* a command name built by an expansion, an unknown wrapper option) makes the whole command
|
|
15
|
+
* `unparsed`, and the caller must not let any command-specific rule allow it.
|
|
16
|
+
*/
|
|
17
|
+
class Unparsed extends Error {
|
|
18
|
+
}
|
|
19
|
+
const MAX_DEPTH = 12;
|
|
20
|
+
/** Longer commands are not lexed at all: they are `unparsed`, so only a `*` rule can decide them. */
|
|
21
|
+
export const MAX_COMMAND_LENGTH = 64 * 1024;
|
|
22
|
+
/** Total characters the lexer may visit across every nested scan of one command before giving up. */
|
|
23
|
+
const MAX_STEPS = 4_000_000;
|
|
24
|
+
/** Words a best-effort candidate needs: the longest `BashArity` prefix is three words. */
|
|
25
|
+
const CANDIDATE_WORDS = 3;
|
|
26
|
+
// Module state, safe because `analyzeBashCommand` is synchronous and never re-enters itself.
|
|
27
|
+
let steps = 0;
|
|
28
|
+
/** Counts lexer work; overrunning the budget makes the command `unparsed`, never slow. */
|
|
29
|
+
const step = (count = 1) => {
|
|
30
|
+
steps += count;
|
|
31
|
+
if (steps > MAX_STEPS)
|
|
32
|
+
fail();
|
|
33
|
+
};
|
|
34
|
+
const fail = () => {
|
|
35
|
+
throw new Unparsed();
|
|
36
|
+
};
|
|
37
|
+
const isDigits = (text) => /^[0-9]+$/.test(text);
|
|
38
|
+
const NAME_CHAR = /[A-Za-z0-9_]/;
|
|
39
|
+
const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*(\[[^\]]*\])?\+?=/;
|
|
40
|
+
/** A leading `NAME=value` word, decided on its plain prefix as bash decides it before expansion. */
|
|
41
|
+
const isAssignment = (word) => {
|
|
42
|
+
const match = ASSIGNMENT.exec(word.text);
|
|
43
|
+
return match !== null && match[0].length <= word.plainPrefix;
|
|
44
|
+
};
|
|
45
|
+
/** Index just past the `)` that closes a `(` opened right before `start`, skipping quoted text. */
|
|
46
|
+
function matchParen(source, start) {
|
|
47
|
+
let depth = 1;
|
|
48
|
+
let index = start;
|
|
49
|
+
while (index < source.length) {
|
|
50
|
+
step();
|
|
51
|
+
const char = source[index];
|
|
52
|
+
if (char === "\\") {
|
|
53
|
+
index += 2;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (char === "'") {
|
|
57
|
+
const end = source.indexOf("'", index + 1);
|
|
58
|
+
if (end < 0)
|
|
59
|
+
fail();
|
|
60
|
+
index = end + 1;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (char === '"') {
|
|
64
|
+
index = skipDoubleQuoted(source, index + 1);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (char === "`") {
|
|
68
|
+
index = skipBackticks(source, index + 1);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (char === "(")
|
|
72
|
+
depth += 1;
|
|
73
|
+
if (char === ")") {
|
|
74
|
+
depth -= 1;
|
|
75
|
+
if (depth === 0)
|
|
76
|
+
return index + 1;
|
|
77
|
+
}
|
|
78
|
+
index += 1;
|
|
79
|
+
}
|
|
80
|
+
return fail();
|
|
81
|
+
}
|
|
82
|
+
/** Index just past the closing `"` of a double-quoted string that starts at `start`. */
|
|
83
|
+
function skipDoubleQuoted(source, start) {
|
|
84
|
+
let index = start;
|
|
85
|
+
while (index < source.length) {
|
|
86
|
+
step();
|
|
87
|
+
const char = source[index];
|
|
88
|
+
if (char === "\\") {
|
|
89
|
+
index += 2;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (char === '"')
|
|
93
|
+
return index + 1;
|
|
94
|
+
if (char === "`") {
|
|
95
|
+
index = skipBackticks(source, index + 1);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (char === "$" && source[index + 1] === "(") {
|
|
99
|
+
index = matchParen(source, index + 2);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
index += 1;
|
|
103
|
+
}
|
|
104
|
+
return fail();
|
|
105
|
+
}
|
|
106
|
+
/** Index just past the closing backtick of a backtick substitution that starts at `start`. */
|
|
107
|
+
function skipBackticks(source, start) {
|
|
108
|
+
let index = start;
|
|
109
|
+
while (index < source.length) {
|
|
110
|
+
step();
|
|
111
|
+
const char = source[index];
|
|
112
|
+
if (char === "\\") {
|
|
113
|
+
index += 2;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (char === "`")
|
|
117
|
+
return index + 1;
|
|
118
|
+
index += 1;
|
|
119
|
+
}
|
|
120
|
+
return fail();
|
|
121
|
+
}
|
|
122
|
+
/** Index just past the `}` closing a `${` whose body starts at `start`. */
|
|
123
|
+
function matchBrace(source, start) {
|
|
124
|
+
let depth = 1;
|
|
125
|
+
let index = start;
|
|
126
|
+
while (index < source.length) {
|
|
127
|
+
step();
|
|
128
|
+
const char = source[index];
|
|
129
|
+
if (char === "\\") {
|
|
130
|
+
index += 2;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (char === "'") {
|
|
134
|
+
const end = source.indexOf("'", index + 1);
|
|
135
|
+
if (end < 0)
|
|
136
|
+
fail();
|
|
137
|
+
index = end + 1;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (char === '"') {
|
|
141
|
+
index = skipDoubleQuoted(source, index + 1);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (char === "`") {
|
|
145
|
+
index = skipBackticks(source, index + 1);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (char === "$" && source[index + 1] === "(") {
|
|
149
|
+
index = matchParen(source, index + 2);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (char === "{")
|
|
153
|
+
depth += 1;
|
|
154
|
+
if (char === "}") {
|
|
155
|
+
depth -= 1;
|
|
156
|
+
if (depth === 0)
|
|
157
|
+
return index + 1;
|
|
158
|
+
}
|
|
159
|
+
index += 1;
|
|
160
|
+
}
|
|
161
|
+
return fail();
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Whether the `((` whose first `(` is at `open` is arithmetic: its inner `(` must close right before the
|
|
165
|
+
* outer one. Otherwise bash reads it as a command substitution or subshell that starts with a subshell.
|
|
166
|
+
*/
|
|
167
|
+
function isArithmetic(source, open) {
|
|
168
|
+
const outerEnd = matchParen(source, open + 1);
|
|
169
|
+
const innerEnd = matchParen(source, open + 2);
|
|
170
|
+
return innerEnd === outerEnd - 1;
|
|
171
|
+
}
|
|
172
|
+
/** The unescaped body of a backtick substitution (bash removes `\` before `$`, `` ` `` and `\`). */
|
|
173
|
+
function unescapeBackticks(body) {
|
|
174
|
+
return body.replace(/\\([$`\\])/g, "$1");
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* The lexer for one command list. `segments` collects every simple command found, in source order,
|
|
178
|
+
* including the ones found inside substitutions.
|
|
179
|
+
*/
|
|
180
|
+
class Lexer {
|
|
181
|
+
constructor(source, depth, segments) {
|
|
182
|
+
this.index = 0;
|
|
183
|
+
this.words = [];
|
|
184
|
+
this.pendingHeredocs = [];
|
|
185
|
+
this.inDoubleBracket = false;
|
|
186
|
+
/** Searches text that is not a command list (a `${…}` body, a here-document) for substitutions. */
|
|
187
|
+
this.textDepth = 0;
|
|
188
|
+
if (depth > MAX_DEPTH)
|
|
189
|
+
fail();
|
|
190
|
+
this.source = source;
|
|
191
|
+
this.depth = depth;
|
|
192
|
+
this.segments = segments;
|
|
193
|
+
}
|
|
194
|
+
run() {
|
|
195
|
+
const source = this.source;
|
|
196
|
+
while (this.index < source.length) {
|
|
197
|
+
step();
|
|
198
|
+
const char = source[this.index];
|
|
199
|
+
if (this.word === undefined && char === "#") {
|
|
200
|
+
while (this.index < source.length && source[this.index] !== "\n")
|
|
201
|
+
this.index += 1;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (char === " " || char === "\t") {
|
|
205
|
+
this.endWord();
|
|
206
|
+
this.index += 1;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (char === "\n") {
|
|
210
|
+
this.endSegment();
|
|
211
|
+
this.index += 1;
|
|
212
|
+
this.readHeredocBodies();
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (char === "\\") {
|
|
216
|
+
if (source[this.index + 1] === "\n") {
|
|
217
|
+
this.index += 2;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (this.index + 1 >= source.length)
|
|
221
|
+
fail();
|
|
222
|
+
this.append(source[this.index + 1], { quoted: true });
|
|
223
|
+
this.index += 2;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (char === "'") {
|
|
227
|
+
const end = source.indexOf("'", this.index + 1);
|
|
228
|
+
if (end < 0)
|
|
229
|
+
fail();
|
|
230
|
+
this.append(source.slice(this.index + 1, end), { quoted: true });
|
|
231
|
+
this.index = end + 1;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (char === '"') {
|
|
235
|
+
this.readDoubleQuoted();
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (char === "`") {
|
|
239
|
+
const end = skipBackticks(source, this.index + 1);
|
|
240
|
+
this.nested(unescapeBackticks(source.slice(this.index + 1, end - 1)));
|
|
241
|
+
this.append("", { dynamic: true });
|
|
242
|
+
this.index = end;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (char === "$") {
|
|
246
|
+
this.readDollar();
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (this.inDoubleBracket && !(this.word !== undefined && !this.word.quoted && this.word.text === "]]")) {
|
|
250
|
+
this.append(char, {});
|
|
251
|
+
this.index += 1;
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if ((char === "<" || char === ">") && source[this.index + 1] === "(" && this.word === undefined) {
|
|
255
|
+
const end = matchParen(source, this.index + 2);
|
|
256
|
+
this.nested(source.slice(this.index + 2, end - 1));
|
|
257
|
+
this.append("", { dynamic: true });
|
|
258
|
+
this.index = end;
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (char === "<" || char === ">") {
|
|
262
|
+
this.readRedirection();
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (char === "&") {
|
|
266
|
+
if (source[this.index + 1] === ">") {
|
|
267
|
+
this.endWord();
|
|
268
|
+
this.index += source[this.index + 2] === ">" ? 3 : 2;
|
|
269
|
+
this.redirectNext = {};
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
this.endSegment();
|
|
273
|
+
this.index += source[this.index + 1] === "&" ? 2 : 1;
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
if (char === "|") {
|
|
277
|
+
this.endSegment();
|
|
278
|
+
this.index += source[this.index + 1] === "|" || source[this.index + 1] === "&" ? 2 : 1;
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
if (char === ";") {
|
|
282
|
+
if (source[this.index + 1] === ";" || source[this.index + 1] === "&")
|
|
283
|
+
fail();
|
|
284
|
+
this.endSegment();
|
|
285
|
+
this.index += 1;
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (char === "(") {
|
|
289
|
+
this.readOpenParen();
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (char === ")")
|
|
293
|
+
fail();
|
|
294
|
+
this.append(char, {});
|
|
295
|
+
if (this.word !== undefined && char === "{")
|
|
296
|
+
this.word.brace = true;
|
|
297
|
+
if (this.word !== undefined && (char === "*" || char === "?" || char === "["))
|
|
298
|
+
this.word.glob = true;
|
|
299
|
+
this.index += 1;
|
|
300
|
+
}
|
|
301
|
+
this.endSegment();
|
|
302
|
+
// A here-document with no line after it has an empty body; there is nothing left to run.
|
|
303
|
+
this.pendingHeredocs = [];
|
|
304
|
+
}
|
|
305
|
+
append(text, flags) {
|
|
306
|
+
if (this.word === undefined) {
|
|
307
|
+
this.word = {
|
|
308
|
+
text: "",
|
|
309
|
+
dynamic: false,
|
|
310
|
+
quoted: false,
|
|
311
|
+
plainPrefix: 0,
|
|
312
|
+
plainEnded: false,
|
|
313
|
+
brace: false,
|
|
314
|
+
glob: false,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
const word = this.word;
|
|
318
|
+
word.text += text;
|
|
319
|
+
if (flags.dynamic)
|
|
320
|
+
word.dynamic = true;
|
|
321
|
+
if (flags.quoted)
|
|
322
|
+
word.quoted = true;
|
|
323
|
+
if (flags.dynamic || flags.quoted)
|
|
324
|
+
word.plainEnded = true;
|
|
325
|
+
if (!word.plainEnded)
|
|
326
|
+
word.plainPrefix = word.text.length;
|
|
327
|
+
}
|
|
328
|
+
nested(source) {
|
|
329
|
+
new Lexer(source, this.depth + this.textDepth + 1, this.segments).run();
|
|
330
|
+
}
|
|
331
|
+
endWord() {
|
|
332
|
+
const word = this.word;
|
|
333
|
+
this.word = undefined;
|
|
334
|
+
if (word === undefined)
|
|
335
|
+
return;
|
|
336
|
+
// Only `{a,b}` and `{1..3}` expand; `{}` (find's placeholder) and `{x}` stay literal.
|
|
337
|
+
if (word.brace && /\{[^}]*(,|\.\.)[^}]*\}/.test(word.text))
|
|
338
|
+
word.dynamic = true;
|
|
339
|
+
// `[` and `[[` are the test command and keyword, not a pattern.
|
|
340
|
+
if (word.glob && word.text !== "[" && word.text !== "[[")
|
|
341
|
+
word.dynamic = true;
|
|
342
|
+
if (this.redirectNext !== undefined) {
|
|
343
|
+
const heredoc = this.redirectNext.heredoc;
|
|
344
|
+
this.redirectNext = undefined;
|
|
345
|
+
if (heredoc !== undefined) {
|
|
346
|
+
if (word.dynamic)
|
|
347
|
+
fail();
|
|
348
|
+
this.pendingHeredocs.push({ delimiter: word.text, expands: !word.quoted, stripTabs: heredoc.stripTabs });
|
|
349
|
+
}
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (!word.quoted &&
|
|
353
|
+
word.text === "[[" &&
|
|
354
|
+
this.words.every((leader) => !leader.quoted && RESERVED_LEADERS.has(leader.text))) {
|
|
355
|
+
this.inDoubleBracket = true;
|
|
356
|
+
}
|
|
357
|
+
if (!word.quoted && word.text === "]]" && this.inDoubleBracket)
|
|
358
|
+
this.inDoubleBracket = false;
|
|
359
|
+
this.words.push(word);
|
|
360
|
+
}
|
|
361
|
+
endSegment() {
|
|
362
|
+
this.endWord();
|
|
363
|
+
// A redirection operator with no target is a syntax error; bash runs nothing.
|
|
364
|
+
if (this.redirectNext !== undefined)
|
|
365
|
+
fail();
|
|
366
|
+
if (this.inDoubleBracket)
|
|
367
|
+
fail();
|
|
368
|
+
if (this.words.length > 0)
|
|
369
|
+
this.segments.push(this.words);
|
|
370
|
+
this.words = [];
|
|
371
|
+
}
|
|
372
|
+
readDoubleQuoted() {
|
|
373
|
+
const source = this.source;
|
|
374
|
+
this.append("", { quoted: true });
|
|
375
|
+
this.index += 1;
|
|
376
|
+
while (this.index < source.length) {
|
|
377
|
+
const char = source[this.index];
|
|
378
|
+
if (char === '"') {
|
|
379
|
+
this.index += 1;
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
if (char === "\\") {
|
|
383
|
+
const next = source[this.index + 1];
|
|
384
|
+
if (next === undefined)
|
|
385
|
+
fail();
|
|
386
|
+
if (next === "\n") {
|
|
387
|
+
this.index += 2;
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
this.append('$`"\\'.includes(next) ? next : `\\${next}`, {});
|
|
391
|
+
this.index += 2;
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
if (char === "`") {
|
|
395
|
+
const end = skipBackticks(source, this.index + 1);
|
|
396
|
+
this.nested(unescapeBackticks(source.slice(this.index + 1, end - 1)));
|
|
397
|
+
this.append("", { dynamic: true });
|
|
398
|
+
this.index = end;
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
if (char === "$") {
|
|
402
|
+
this.readDollar();
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
this.append(char, {});
|
|
406
|
+
this.index += 1;
|
|
407
|
+
}
|
|
408
|
+
fail();
|
|
409
|
+
}
|
|
410
|
+
/** `$…` in or out of double quotes: substitutions are searched, every expansion is dynamic. */
|
|
411
|
+
readDollar() {
|
|
412
|
+
const source = this.source;
|
|
413
|
+
const next = source[this.index + 1];
|
|
414
|
+
if (next === "(" && source[this.index + 2] === "(" && isArithmetic(source, this.index + 1)) {
|
|
415
|
+
const end = matchParen(source, this.index + 2);
|
|
416
|
+
this.nestedText(source.slice(this.index + 3, end - 2));
|
|
417
|
+
this.append("", { dynamic: true });
|
|
418
|
+
this.index = end;
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
if (next === "(") {
|
|
422
|
+
const end = matchParen(source, this.index + 2);
|
|
423
|
+
this.nested(source.slice(this.index + 2, end - 1));
|
|
424
|
+
this.append("", { dynamic: true });
|
|
425
|
+
this.index = end;
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (next === "{") {
|
|
429
|
+
const end = matchBrace(source, this.index + 2);
|
|
430
|
+
this.nestedText(source.slice(this.index + 2, end - 1));
|
|
431
|
+
this.append("", { dynamic: true });
|
|
432
|
+
this.index = end;
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
if (next === "'") {
|
|
436
|
+
// ANSI-C quoting decodes escapes, so its value is unknown to this lexer.
|
|
437
|
+
let index = this.index + 2;
|
|
438
|
+
while (index < source.length && source[index] !== "'")
|
|
439
|
+
index += source[index] === "\\" ? 2 : 1;
|
|
440
|
+
if (index >= source.length)
|
|
441
|
+
fail();
|
|
442
|
+
this.append("", { dynamic: true, quoted: true });
|
|
443
|
+
this.index = index + 1;
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
if (next !== undefined && (NAME_CHAR.test(next) || "@*#?$!-".includes(next))) {
|
|
447
|
+
let index = this.index + 1;
|
|
448
|
+
if (NAME_CHAR.test(next) && !/[0-9]/.test(next)) {
|
|
449
|
+
while (index < source.length && NAME_CHAR.test(source[index]))
|
|
450
|
+
index += 1;
|
|
451
|
+
}
|
|
452
|
+
else {
|
|
453
|
+
index += 1;
|
|
454
|
+
}
|
|
455
|
+
this.append("", { dynamic: true });
|
|
456
|
+
this.index = index;
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
this.append("$", {});
|
|
460
|
+
this.index += 1;
|
|
461
|
+
}
|
|
462
|
+
nestedText(text) {
|
|
463
|
+
this.textDepth += 1;
|
|
464
|
+
if (this.depth + this.textDepth > MAX_DEPTH)
|
|
465
|
+
fail();
|
|
466
|
+
try {
|
|
467
|
+
this.scanText(text);
|
|
468
|
+
}
|
|
469
|
+
finally {
|
|
470
|
+
this.textDepth -= 1;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
scanText(text) {
|
|
474
|
+
let index = 0;
|
|
475
|
+
while (index < text.length) {
|
|
476
|
+
const char = text[index];
|
|
477
|
+
if (char === "\\") {
|
|
478
|
+
index += 2;
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
if (char === "`") {
|
|
482
|
+
const end = skipBackticks(text, index + 1);
|
|
483
|
+
this.nested(unescapeBackticks(text.slice(index + 1, end - 1)));
|
|
484
|
+
index = end;
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
if (char === "$" && text[index + 1] === "(") {
|
|
488
|
+
const end = matchParen(text, index + 2);
|
|
489
|
+
if (text[index + 2] === "(" && isArithmetic(text, index + 1))
|
|
490
|
+
this.nestedText(text.slice(index + 3, end - 2));
|
|
491
|
+
else
|
|
492
|
+
this.nested(text.slice(index + 2, end - 1));
|
|
493
|
+
index = end;
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
if (char === "$" && text[index + 1] === "{") {
|
|
497
|
+
const end = matchBrace(text, index + 2);
|
|
498
|
+
this.nestedText(text.slice(index + 2, end - 1));
|
|
499
|
+
index = end;
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
index += 1;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
readRedirection() {
|
|
506
|
+
const source = this.source;
|
|
507
|
+
const word = this.word;
|
|
508
|
+
// A word made only of digits right before the operator is a file descriptor, not an argument.
|
|
509
|
+
if (word !== undefined && !word.quoted && !word.dynamic && isDigits(word.text)) {
|
|
510
|
+
this.word = undefined;
|
|
511
|
+
}
|
|
512
|
+
else {
|
|
513
|
+
this.endWord();
|
|
514
|
+
}
|
|
515
|
+
const char = source[this.index];
|
|
516
|
+
const next = source[this.index + 1];
|
|
517
|
+
if (char === "<" && next === "<" && source[this.index + 2] === "<") {
|
|
518
|
+
this.index += 3;
|
|
519
|
+
this.redirectNext = {};
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
if (char === "<" && next === "<") {
|
|
523
|
+
const stripTabs = source[this.index + 2] === "-";
|
|
524
|
+
this.index += stripTabs ? 3 : 2;
|
|
525
|
+
this.redirectNext = { heredoc: { stripTabs } };
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
this.index += 1;
|
|
529
|
+
if (char === ">" && (next === ">" || next === "|" || next === "&"))
|
|
530
|
+
this.index += 1;
|
|
531
|
+
else if (char === "<" && (next === ">" || next === "&"))
|
|
532
|
+
this.index += 1;
|
|
533
|
+
this.redirectNext = {};
|
|
534
|
+
}
|
|
535
|
+
readOpenParen() {
|
|
536
|
+
const source = this.source;
|
|
537
|
+
const word = this.word;
|
|
538
|
+
if (word !== undefined) {
|
|
539
|
+
// `name=( … )` is an array assignment; any other `word(` is a function definition or a syntax error.
|
|
540
|
+
if (word.quoted || !/^[A-Za-z_][A-Za-z0-9_]*\+?=$/.test(word.text))
|
|
541
|
+
fail();
|
|
542
|
+
const end = matchParen(source, this.index + 1);
|
|
543
|
+
this.nestedText(source.slice(this.index + 1, end - 1));
|
|
544
|
+
this.append("", { dynamic: true });
|
|
545
|
+
this.index = end;
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
if (source[this.index + 1] === "(" && isArithmetic(source, this.index)) {
|
|
549
|
+
const end = matchParen(source, this.index + 1);
|
|
550
|
+
this.nestedText(source.slice(this.index + 2, end - 2));
|
|
551
|
+
this.index = end;
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
const end = matchParen(source, this.index + 1);
|
|
555
|
+
this.endSegment();
|
|
556
|
+
this.nested(source.slice(this.index + 1, end - 1));
|
|
557
|
+
this.index = end;
|
|
558
|
+
}
|
|
559
|
+
readHeredocBodies() {
|
|
560
|
+
const source = this.source;
|
|
561
|
+
for (const heredoc of this.pendingHeredocs) {
|
|
562
|
+
const body = [];
|
|
563
|
+
for (;;) {
|
|
564
|
+
if (this.index >= source.length)
|
|
565
|
+
break;
|
|
566
|
+
const lineEnd = source.indexOf("\n", this.index);
|
|
567
|
+
const end = lineEnd < 0 ? source.length : lineEnd;
|
|
568
|
+
const raw = source.slice(this.index, end);
|
|
569
|
+
this.index = lineEnd < 0 ? source.length : lineEnd + 1;
|
|
570
|
+
const line = heredoc.stripTabs ? raw.replace(/^\t+/, "") : raw;
|
|
571
|
+
if (line === heredoc.delimiter)
|
|
572
|
+
break;
|
|
573
|
+
body.push(line);
|
|
574
|
+
}
|
|
575
|
+
if (heredoc.expands)
|
|
576
|
+
this.nestedText(body.join("\n"));
|
|
577
|
+
}
|
|
578
|
+
this.pendingHeredocs = [];
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
const RESERVED_LEADERS = new Set(["!", "{", "}", "if", "then", "else", "elif", "fi", "do", "done", "while", "until"]);
|
|
582
|
+
const UNSUPPORTED_LEADERS = new Set(["case", "esac", "function", "coproc"]);
|
|
583
|
+
const LOOP_HEADERS = new Set(["for", "select"]);
|
|
584
|
+
const SHELLS = new Set(["bash", "sh", "zsh", "dash", "ksh", "mksh", "ash", "yash", "posh", "fish", "csh", "tcsh"]);
|
|
585
|
+
const opts = (flags, values = "") => ({
|
|
586
|
+
...Object.fromEntries(flags
|
|
587
|
+
.split(" ")
|
|
588
|
+
.filter(Boolean)
|
|
589
|
+
.map((name) => [name, true])),
|
|
590
|
+
...Object.fromEntries(values
|
|
591
|
+
.split(" ")
|
|
592
|
+
.filter(Boolean)
|
|
593
|
+
.map((name) => [name, "value"])),
|
|
594
|
+
});
|
|
595
|
+
/**
|
|
596
|
+
* Skips a wrapper's options and returns the index of the wrapped command's first word, or
|
|
597
|
+
* `words.length` when there is none. An option the table does not list makes the command unparsed:
|
|
598
|
+
* guessing whether it takes a value could hide the wrapped command.
|
|
599
|
+
*/
|
|
600
|
+
function skipOptions(words, start, table) {
|
|
601
|
+
let index = start;
|
|
602
|
+
while (index < words.length) {
|
|
603
|
+
const word = words[index];
|
|
604
|
+
if (word.dynamic)
|
|
605
|
+
fail();
|
|
606
|
+
const text = word.text;
|
|
607
|
+
if (text === "--")
|
|
608
|
+
return index + 1;
|
|
609
|
+
if (!text.startsWith("-") || text === "-")
|
|
610
|
+
return index;
|
|
611
|
+
if (text.startsWith("--")) {
|
|
612
|
+
const separator = text.indexOf("=");
|
|
613
|
+
const name = separator < 0 ? text : text.slice(0, separator);
|
|
614
|
+
const kind = table[name];
|
|
615
|
+
if (kind === undefined)
|
|
616
|
+
fail();
|
|
617
|
+
index += kind === "value" && separator < 0 ? 2 : 1;
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
if (table[text] === "value") {
|
|
621
|
+
index += 2;
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
if (table[text] === true) {
|
|
625
|
+
index += 1;
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
// A cluster of single-letter flags (`-lc`), where only the last may take a value (`-un`).
|
|
629
|
+
const letters = text.slice(1).split("");
|
|
630
|
+
let consumed = 1;
|
|
631
|
+
for (const [position, letter] of letters.entries()) {
|
|
632
|
+
const kind = table[`-${letter}`];
|
|
633
|
+
if (kind === undefined) {
|
|
634
|
+
// Numeric options such as `nice -5`.
|
|
635
|
+
if (/^-[0-9]+$/.test(text) && table["-N"] === true)
|
|
636
|
+
break;
|
|
637
|
+
fail();
|
|
638
|
+
}
|
|
639
|
+
if (kind === "value") {
|
|
640
|
+
if (position === letters.length - 1)
|
|
641
|
+
consumed = 2;
|
|
642
|
+
break;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
index += consumed;
|
|
646
|
+
}
|
|
647
|
+
return index;
|
|
648
|
+
}
|
|
649
|
+
const WRAPPER_OPTIONS = {
|
|
650
|
+
command: opts("-p -v -V"),
|
|
651
|
+
builtin: opts(""),
|
|
652
|
+
exec: opts("-c -l", "-a"),
|
|
653
|
+
nohup: opts(""),
|
|
654
|
+
time: opts("-p -l -v -a --portability --verbose --append", "-o -f --output --format"),
|
|
655
|
+
nice: opts("-N", "-n --adjustment"),
|
|
656
|
+
stdbuf: opts("", "-i -o -e --input --output --error"),
|
|
657
|
+
caffeinate: opts("-d -i -m -s -u", "-t -w"),
|
|
658
|
+
sudo: opts("-A -b -E -H -K -k -n -P -S -V -v --askpass --background --preserve-env --set-home --non-interactive --stdin", "-u -g -h -p -C -D -r -t -U -T --user --group --host --prompt --close-from --chdir --role --type --other-user --command-timeout"),
|
|
659
|
+
doas: opts("-n -s", "-u -C"),
|
|
660
|
+
setsid: opts("-c -f -w --ctty --fork --wait"),
|
|
661
|
+
ionice: opts("-t --ignore", "-c -n --class --classdata"),
|
|
662
|
+
};
|
|
663
|
+
const ENV_OPTIONS = opts("-i -0 -v --ignore-environment --null --debug", "-u -C --unset --chdir");
|
|
664
|
+
const TIMEOUT_OPTIONS = opts("-v --preserve-status --foreground --verbose", "-s -k --signal --kill-after");
|
|
665
|
+
const XARGS_OPTIONS = opts("-0 -r -t -p -x -o --null --no-run-if-empty --verbose --interactive --exit --open-tty", "-I -L -n -P -s -d -E -a --max-lines --max-args --max-procs --max-chars --delimiter --eof --arg-file --replace --process-slot-var");
|
|
666
|
+
const SHELL_OPTIONS = opts("-e -u -x -v -f -n -l -i -s -p -a -b -h -k -m -B -C -E -H -P -T --login --norc --noprofile --posix --restricted --noediting -c", "-o -O --rcfile --init-file");
|
|
667
|
+
const pathBasename = (text) => {
|
|
668
|
+
const parts = text.split("/");
|
|
669
|
+
return parts[parts.length - 1] ?? text;
|
|
670
|
+
};
|
|
671
|
+
/**
|
|
672
|
+
* Turns one simple command's words into the command lines it runs: the wrapper's own name first,
|
|
673
|
+
* then the wrapped command, recursively. Every name is reduced to its basename.
|
|
674
|
+
*/
|
|
675
|
+
const expandSimpleCommand = (input, out) => {
|
|
676
|
+
let words = input;
|
|
677
|
+
// Leading assignments and reserved words do not name the command.
|
|
678
|
+
let start = 0;
|
|
679
|
+
while (start < words.length) {
|
|
680
|
+
const word = words[start];
|
|
681
|
+
if (!word.quoted && RESERVED_LEADERS.has(word.text)) {
|
|
682
|
+
start += 1;
|
|
683
|
+
continue;
|
|
684
|
+
}
|
|
685
|
+
if (!word.quoted && UNSUPPORTED_LEADERS.has(word.text))
|
|
686
|
+
fail();
|
|
687
|
+
if (isAssignment(word)) {
|
|
688
|
+
start += 1;
|
|
689
|
+
continue;
|
|
690
|
+
}
|
|
691
|
+
break;
|
|
692
|
+
}
|
|
693
|
+
words = words.slice(start);
|
|
694
|
+
const first = words[0];
|
|
695
|
+
if (first === undefined)
|
|
696
|
+
return;
|
|
697
|
+
if (!first.quoted && LOOP_HEADERS.has(first.text)) {
|
|
698
|
+
// `for NAME [in WORDS]` and `for (( … ))` run nothing themselves; substitutions in them were already found.
|
|
699
|
+
if (words.length === 1 || words.length === 2 || words[2]?.text === "in")
|
|
700
|
+
return;
|
|
701
|
+
fail();
|
|
702
|
+
}
|
|
703
|
+
if (first.dynamic || first.text === "")
|
|
704
|
+
fail();
|
|
705
|
+
const name = pathBasename(first.text);
|
|
706
|
+
if (name === "")
|
|
707
|
+
fail();
|
|
708
|
+
const rest = words.slice(1);
|
|
709
|
+
const line = [name, ...rest.map((word) => word.text)];
|
|
710
|
+
if (SHELLS.has(name)) {
|
|
711
|
+
out.push([name]);
|
|
712
|
+
const commandIndex = shellCommandIndex(rest);
|
|
713
|
+
if (commandIndex === undefined) {
|
|
714
|
+
out.push(line);
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
const script = rest[commandIndex];
|
|
718
|
+
if (script === undefined || script.dynamic)
|
|
719
|
+
fail();
|
|
720
|
+
for (const segment of lexSegments(script.text))
|
|
721
|
+
expandSimpleCommand(segment, out);
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
if (name === "eval") {
|
|
725
|
+
out.push([name]);
|
|
726
|
+
if (rest.some((word) => word.dynamic))
|
|
727
|
+
fail();
|
|
728
|
+
const args = rest[0]?.text === "--" ? rest.slice(1) : rest;
|
|
729
|
+
for (const segment of lexSegments(args.map((word) => word.text).join(" ")))
|
|
730
|
+
expandSimpleCommand(segment, out);
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
if (name === "su") {
|
|
734
|
+
out.push([name]);
|
|
735
|
+
const script = suCommand(rest);
|
|
736
|
+
if (script === undefined)
|
|
737
|
+
return;
|
|
738
|
+
for (const segment of lexSegments(script))
|
|
739
|
+
expandSimpleCommand(segment, out);
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
if (name === "trap") {
|
|
743
|
+
out.push([name]);
|
|
744
|
+
const scriptIndex = rest.findIndex((word) => !word.text.startsWith("-"));
|
|
745
|
+
const script = scriptIndex < 0 ? undefined : rest[scriptIndex];
|
|
746
|
+
if (script === undefined)
|
|
747
|
+
return;
|
|
748
|
+
if (script.dynamic)
|
|
749
|
+
fail();
|
|
750
|
+
for (const segment of lexSegments(script.text))
|
|
751
|
+
expandSimpleCommand(segment, out);
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
if (name === "env") {
|
|
755
|
+
out.push([name]);
|
|
756
|
+
if (rest.some((word) => !word.dynamic && (word.text === "-S" || word.text.startsWith("--split-string"))))
|
|
757
|
+
fail();
|
|
758
|
+
let index = skipOptions(rest, 0, ENV_OPTIONS);
|
|
759
|
+
// env itself, not the shell, reads these: any NAME=VALUE word, quoted or not, is an assignment.
|
|
760
|
+
while (index < rest.length && !rest[index].dynamic && /^[^=]+=/.test(rest[index].text))
|
|
761
|
+
index += 1;
|
|
762
|
+
expandSimpleCommand(rest.slice(index), out);
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
if (name === "timeout") {
|
|
766
|
+
out.push([name]);
|
|
767
|
+
const index = skipOptions(rest, 0, TIMEOUT_OPTIONS);
|
|
768
|
+
// The first operand is the duration.
|
|
769
|
+
expandSimpleCommand(rest.slice(index + 1), out);
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
if (name === "xargs") {
|
|
773
|
+
out.push([name]);
|
|
774
|
+
expandSimpleCommand(rest.slice(skipOptions(rest, 0, XARGS_OPTIONS)), out);
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
if (name === "find") {
|
|
778
|
+
out.push(line);
|
|
779
|
+
// find reads its words after quote removal, so a quoted action is still an action.
|
|
780
|
+
if (rest.some((word) => word.dynamic))
|
|
781
|
+
fail();
|
|
782
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
783
|
+
if (!["-exec", "-execdir", "-ok", "-okdir"].includes(rest[index].text))
|
|
784
|
+
continue;
|
|
785
|
+
let end = index + 1;
|
|
786
|
+
while (end < rest.length && rest[end].text !== ";" && rest[end].text !== "+")
|
|
787
|
+
end += 1;
|
|
788
|
+
if (end >= rest.length)
|
|
789
|
+
fail();
|
|
790
|
+
expandSimpleCommand(rest.slice(index + 1, end), out);
|
|
791
|
+
index = end;
|
|
792
|
+
}
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
const wrapperOptions = WRAPPER_OPTIONS[name];
|
|
796
|
+
if (wrapperOptions !== undefined) {
|
|
797
|
+
out.push([name]);
|
|
798
|
+
expandSimpleCommand(rest.slice(skipOptions(rest, 0, wrapperOptions)), out);
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
out.push(line);
|
|
802
|
+
};
|
|
803
|
+
/** The command string `su` runs (`-c`, `--command`, `--session-command`, or `c` in a flag cluster), if any. */
|
|
804
|
+
function suCommand(rest) {
|
|
805
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
806
|
+
const word = rest[index];
|
|
807
|
+
const text = word.text;
|
|
808
|
+
const long = /^--(session-)?command(=|$)/.exec(text);
|
|
809
|
+
if (long) {
|
|
810
|
+
const value = long[2] === "=" ? text.slice(long[0].length) : rest[index + 1]?.text;
|
|
811
|
+
const source = long[2] === "=" ? word : rest[index + 1];
|
|
812
|
+
if (value === undefined || source === undefined || source.dynamic)
|
|
813
|
+
fail();
|
|
814
|
+
return value;
|
|
815
|
+
}
|
|
816
|
+
if (/^-[A-Za-z]*c/.test(text) && !text.startsWith("--")) {
|
|
817
|
+
const attached = text.slice(text.indexOf("c") + 1);
|
|
818
|
+
const source = attached === "" ? rest[index + 1] : word;
|
|
819
|
+
if (source === undefined || source.dynamic)
|
|
820
|
+
fail();
|
|
821
|
+
return attached === "" ? source.text : attached;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
return undefined;
|
|
825
|
+
}
|
|
826
|
+
/** The index (in `rest`) of a shell's `-c` command string, or undefined when it reads a file or stdin. */
|
|
827
|
+
function shellCommandIndex(rest) {
|
|
828
|
+
let index = 0;
|
|
829
|
+
let hasCommandFlag = false;
|
|
830
|
+
while (index < rest.length) {
|
|
831
|
+
const word = rest[index];
|
|
832
|
+
if (word.dynamic)
|
|
833
|
+
fail();
|
|
834
|
+
const text = word.text;
|
|
835
|
+
if (text === "--" || text === "-") {
|
|
836
|
+
index += 1;
|
|
837
|
+
break;
|
|
838
|
+
}
|
|
839
|
+
if (!text.startsWith("-") && !text.startsWith("+"))
|
|
840
|
+
break;
|
|
841
|
+
if (text.startsWith("--")) {
|
|
842
|
+
const kind = SHELL_OPTIONS[text.split("=")[0]];
|
|
843
|
+
if (kind === undefined)
|
|
844
|
+
fail();
|
|
845
|
+
index += kind === "value" && !text.includes("=") ? 2 : 1;
|
|
846
|
+
continue;
|
|
847
|
+
}
|
|
848
|
+
const letters = text.slice(1);
|
|
849
|
+
if (letters.includes("c"))
|
|
850
|
+
hasCommandFlag = true;
|
|
851
|
+
for (const letter of letters)
|
|
852
|
+
if (SHELL_OPTIONS[`-${letter}`] === undefined)
|
|
853
|
+
fail();
|
|
854
|
+
index += letters.includes("o") || letters.includes("O") ? 2 : 1;
|
|
855
|
+
}
|
|
856
|
+
return hasCommandFlag ? index : undefined;
|
|
857
|
+
}
|
|
858
|
+
function lexSegments(command) {
|
|
859
|
+
const segments = [];
|
|
860
|
+
new Lexer(command, 0, segments).run();
|
|
861
|
+
return segments;
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Every simple command `command` would run, each as its normalized words (command name first), in
|
|
865
|
+
* source order; or `unparsed` when any part of the command is outside what this lexer understands.
|
|
866
|
+
*/
|
|
867
|
+
export function analyzeBashCommand(command) {
|
|
868
|
+
steps = 0;
|
|
869
|
+
if (command.length > MAX_COMMAND_LENGTH)
|
|
870
|
+
return { kind: "unparsed" };
|
|
871
|
+
try {
|
|
872
|
+
const out = [];
|
|
873
|
+
for (const segment of lexSegments(command))
|
|
874
|
+
expandSimpleCommand(segment, out);
|
|
875
|
+
return { kind: "parsed", segments: out };
|
|
876
|
+
}
|
|
877
|
+
catch {
|
|
878
|
+
// Fail closed: a parse failure, the depth or step budget, or a runtime error (a deep stack)
|
|
879
|
+
// all mean `unparsed`, which no command-specific rule can allow.
|
|
880
|
+
return { kind: "unparsed" };
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
/** Lexer steps the last `analyzeBashCommand` call used (for scaling tests). */
|
|
884
|
+
export function lastAnalysisSteps() {
|
|
885
|
+
return steps;
|
|
886
|
+
}
|
|
887
|
+
/**
|
|
888
|
+
* Candidate command names for a command the lexer could not parse, used only so a deny rule still
|
|
889
|
+
* matches: every plain word, at every position, read as the start of a command.
|
|
890
|
+
*/
|
|
891
|
+
export function bestEffortCommandLines(command) {
|
|
892
|
+
const words = command
|
|
893
|
+
.split(/[\s;&|()<>`$'"{}\\]+/)
|
|
894
|
+
.filter((word) => /^[A-Za-z0-9._+/-]+$/.test(word))
|
|
895
|
+
.map(pathBasename)
|
|
896
|
+
.filter((word) => word !== "");
|
|
897
|
+
return words.map((_word, index) => words.slice(index, index + CANDIDATE_WORDS));
|
|
898
|
+
}
|
|
899
|
+
//# sourceMappingURL=bash-command-segments.js.map
|