@naswerks/periscope 1.0.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/CHANGELOG.md +33 -0
- package/LICENSE +21 -0
- package/README.md +146 -0
- package/SECURITY.md +315 -0
- package/dist/bin/command.d.ts +97 -0
- package/dist/bin/command.js +141 -0
- package/dist/bin/config.d.ts +7 -0
- package/dist/bin/config.js +90 -0
- package/dist/bin/login.d.ts +48 -0
- package/dist/bin/login.js +82 -0
- package/dist/bin/main.d.ts +33 -0
- package/dist/bin/main.js +173 -0
- package/dist/bin/pair.d.ts +28 -0
- package/dist/bin/pair.js +198 -0
- package/dist/bin/periscope.d.ts +3 -0
- package/dist/bin/periscope.js +4 -0
- package/dist/bin/posture.d.ts +64 -0
- package/dist/bin/posture.js +157 -0
- package/dist/bin/reconfigure.d.ts +64 -0
- package/dist/bin/reconfigure.js +195 -0
- package/dist/bin/serve.d.ts +103 -0
- package/dist/bin/serve.js +441 -0
- package/dist/bin/status.d.ts +11 -0
- package/dist/bin/status.js +49 -0
- package/dist/bin/workspaces.d.ts +109 -0
- package/dist/bin/workspaces.js +215 -0
- package/dist/control/backoff.d.ts +25 -0
- package/dist/control/backoff.js +20 -0
- package/dist/control/codec.d.ts +15 -0
- package/dist/control/codec.js +501 -0
- package/dist/control/credential.d.ts +23 -0
- package/dist/control/credential.js +8 -0
- package/dist/control/frames.d.ts +1180 -0
- package/dist/control/frames.js +441 -0
- package/dist/control/link-state.d.ts +32 -0
- package/dist/control/link-state.js +39 -0
- package/dist/control/link.d.ts +109 -0
- package/dist/control/link.js +736 -0
- package/dist/control/queue.d.ts +110 -0
- package/dist/control/queue.js +174 -0
- package/dist/control/seq.d.ts +49 -0
- package/dist/control/seq.js +52 -0
- package/dist/control/stream-routing.d.ts +200 -0
- package/dist/control/stream-routing.js +167 -0
- package/dist/control/stream.d.ts +43 -0
- package/dist/control/stream.js +64 -0
- package/dist/core/async-queue.d.ts +26 -0
- package/dist/core/async-queue.js +63 -0
- package/dist/core/failure.d.ts +22 -0
- package/dist/core/failure.js +53 -0
- package/dist/core/index.d.ts +18 -0
- package/dist/core/index.js +7 -0
- package/dist/core/keyed-turns.d.ts +28 -0
- package/dist/core/keyed-turns.js +44 -0
- package/dist/core/paths.d.ts +25 -0
- package/dist/core/paths.js +77 -0
- package/dist/core/refusal.d.ts +43 -0
- package/dist/core/refusal.js +399 -0
- package/dist/core/result.d.ts +22 -0
- package/dist/core/result.js +16 -0
- package/dist/core/time.d.ts +19 -0
- package/dist/core/time.js +25 -0
- package/dist/core/vocab.d.ts +23 -0
- package/dist/core/vocab.js +37 -0
- package/dist/core/workspace-id.d.ts +12 -0
- package/dist/core/workspace-id.js +12 -0
- package/dist/gate/command.d.ts +136 -0
- package/dist/gate/command.js +551 -0
- package/dist/gate/decision.d.ts +111 -0
- package/dist/gate/decision.js +97 -0
- package/dist/gate/escalate.d.ts +82 -0
- package/dist/gate/escalate.js +92 -0
- package/dist/gate/gate.d.ts +173 -0
- package/dist/gate/gate.js +417 -0
- package/dist/gate/index.d.ts +39 -0
- package/dist/gate/index.js +31 -0
- package/dist/gate/jail.d.ts +92 -0
- package/dist/gate/jail.js +131 -0
- package/dist/gate/local.d.ts +69 -0
- package/dist/gate/local.js +51 -0
- package/dist/gate/outcome.d.ts +72 -0
- package/dist/gate/outcome.js +91 -0
- package/dist/gate/shell.d.ts +36 -0
- package/dist/gate/shell.js +226 -0
- package/dist/host/agent-process.d.ts +452 -0
- package/dist/host/agent-process.js +382 -0
- package/dist/host/bulk-post.d.ts +44 -0
- package/dist/host/bulk-post.js +114 -0
- package/dist/host/claude-transcripts.d.ts +84 -0
- package/dist/host/claude-transcripts.js +339 -0
- package/dist/host/config-file.d.ts +60 -0
- package/dist/host/config-file.js +216 -0
- package/dist/host/git-facts.d.ts +7 -0
- package/dist/host/git-facts.js +106 -0
- package/dist/host/hooks.d.ts +56 -0
- package/dist/host/hooks.js +75 -0
- package/dist/host/host.d.ts +356 -0
- package/dist/host/host.js +1294 -0
- package/dist/host/index.d.ts +35 -0
- package/dist/host/index.js +22 -0
- package/dist/host/link-state-file.d.ts +18 -0
- package/dist/host/link-state-file.js +66 -0
- package/dist/host/loopback.d.ts +38 -0
- package/dist/host/loopback.js +122 -0
- package/dist/host/machine.d.ts +9 -0
- package/dist/host/machine.js +19 -0
- package/dist/host/mcp-server.d.ts +11 -0
- package/dist/host/mcp-server.js +48 -0
- package/dist/host/package-facts.d.ts +3 -0
- package/dist/host/package-facts.js +26 -0
- package/dist/host/paired-credential-store.d.ts +32 -0
- package/dist/host/paired-credential-store.js +112 -0
- package/dist/host/paths.d.ts +80 -0
- package/dist/host/paths.js +165 -0
- package/dist/host/repository-read.d.ts +33 -0
- package/dist/host/repository-read.js +201 -0
- package/dist/host/session-store.d.ts +53 -0
- package/dist/host/session-store.js +100 -0
- package/dist/host/sign-in.d.ts +90 -0
- package/dist/host/sign-in.js +239 -0
- package/dist/host/telemetry.d.ts +42 -0
- package/dist/host/telemetry.js +74 -0
- package/dist/host/token-cache.d.ts +62 -0
- package/dist/host/token-cache.js +185 -0
- package/dist/host/transcript-fs.d.ts +4 -0
- package/dist/host/transcript-fs.js +104 -0
- package/dist/host/wire-request.d.ts +80 -0
- package/dist/host/wire-request.js +196 -0
- package/dist/host/workspace-fs.d.ts +14 -0
- package/dist/host/workspace-fs.js +84 -0
- package/dist/host/workspace-trust.d.ts +23 -0
- package/dist/host/workspace-trust.js +80 -0
- package/dist/identity/authorize.d.ts +59 -0
- package/dist/identity/authorize.js +72 -0
- package/dist/identity/config.d.ts +98 -0
- package/dist/identity/config.js +130 -0
- package/dist/identity/credential.d.ts +91 -0
- package/dist/identity/credential.js +76 -0
- package/dist/identity/device-code.d.ts +115 -0
- package/dist/identity/device-code.js +134 -0
- package/dist/identity/index.d.ts +33 -0
- package/dist/identity/index.js +10 -0
- package/dist/identity/mode.d.ts +106 -0
- package/dist/identity/mode.js +69 -0
- package/dist/identity/paired-credential.d.ts +52 -0
- package/dist/identity/paired-credential.js +43 -0
- package/dist/identity/pkce.d.ts +50 -0
- package/dist/identity/pkce.js +94 -0
- package/dist/identity/store.d.ts +43 -0
- package/dist/identity/store.js +43 -0
- package/dist/identity/token.d.ts +58 -0
- package/dist/identity/token.js +149 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +53 -0
- package/dist/mcp/descriptor.d.ts +59 -0
- package/dist/mcp/descriptor.js +14 -0
- package/dist/mcp/index.d.ts +14 -0
- package/dist/mcp/index.js +4 -0
- package/dist/mcp/schema.d.ts +37 -0
- package/dist/mcp/schema.js +175 -0
- package/dist/mcp/server.d.ts +107 -0
- package/dist/mcp/server.js +66 -0
- package/dist/persistence/entry.d.ts +74 -0
- package/dist/persistence/entry.js +105 -0
- package/dist/persistence/index.d.ts +22 -0
- package/dist/persistence/index.js +8 -0
- package/dist/persistence/key.d.ts +46 -0
- package/dist/persistence/key.js +33 -0
- package/dist/persistence/mirror.d.ts +71 -0
- package/dist/persistence/mirror.js +57 -0
- package/dist/persistence/receipt.d.ts +111 -0
- package/dist/persistence/receipt.js +85 -0
- package/dist/persistence/retention.d.ts +68 -0
- package/dist/persistence/retention.js +68 -0
- package/dist/persistence/store.d.ts +106 -0
- package/dist/persistence/store.js +86 -0
- package/dist/persistence/transition-log.d.ts +73 -0
- package/dist/persistence/transition-log.js +133 -0
- package/dist/protocol.d.ts +27 -0
- package/dist/protocol.js +12 -0
- package/dist/sessions/index.d.ts +8 -0
- package/dist/sessions/index.js +4 -0
- package/dist/sessions/registry.d.ts +186 -0
- package/dist/sessions/registry.js +190 -0
- package/dist/sessions/session.d.ts +178 -0
- package/dist/sessions/session.js +288 -0
- package/dist/sessions/spawn-env.d.ts +88 -0
- package/dist/sessions/spawn-env.js +277 -0
- package/dist/state/coverage.d.ts +308 -0
- package/dist/state/coverage.js +315 -0
- package/dist/state/index.d.ts +12 -0
- package/dist/state/index.js +7 -0
- package/dist/state/machine.d.ts +112 -0
- package/dist/state/machine.js +237 -0
- package/dist/state/model.d.ts +233 -0
- package/dist/state/model.js +214 -0
- package/dist/state/observer.d.ts +55 -0
- package/dist/state/observer.js +413 -0
- package/dist/state/reporter.d.ts +45 -0
- package/dist/state/reporter.js +35 -0
- package/dist/state/store.d.ts +56 -0
- package/dist/state/store.js +120 -0
- package/dist/telemetry/index.d.ts +11 -0
- package/dist/telemetry/index.js +2 -0
- package/dist/telemetry/usage.d.ts +78 -0
- package/dist/telemetry/usage.js +69 -0
- package/dist/workspace/git-worktree.d.ts +150 -0
- package/dist/workspace/git-worktree.js +417 -0
- package/dist/workspace/index.d.ts +9 -0
- package/dist/workspace/index.js +4 -0
- package/dist/workspace/plain-dir.d.ts +34 -0
- package/dist/workspace/plain-dir.js +90 -0
- package/dist/workspace/provider.d.ts +152 -0
- package/dist/workspace/provider.js +2 -0
- package/dist/workspace/worktree-porcelain.d.ts +29 -0
- package/dist/workspace/worktree-porcelain.js +100 -0
- package/docs/architecture.md +277 -0
- package/docs/configuration.md +187 -0
- package/docs/gate.md +219 -0
- package/docs/identity.md +107 -0
- package/docs/protocol.md +501 -0
- package/docs/state-machine.md +160 -0
- package/examples/README.md +141 -0
- package/examples/minimal-controller/controller.ts +38 -0
- package/examples/parallel-run-proof/permission-mode-probe.ts +129 -0
- package/examples/parallel-run-proof/run.ts +850 -0
- package/examples/test-controller/controller.ts +655 -0
- package/examples/test-controller/serve.ts +29 -0
- package/examples/tsconfig.json +30 -0
- package/package.json +98 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parsing a shell command into invocations, so the classifier can classify a shape instead of
|
|
3
|
+
* grepping a string.
|
|
4
|
+
*
|
|
5
|
+
* Why this file exists. A classifier that reads the raw command string produces three defects:
|
|
6
|
+
* a Python heredoc is refused because a boundary word sits in a code comment; `git -C <path>
|
|
7
|
+
* status` is refused as an unrecognised verb because global flags before the subcommand defeat
|
|
8
|
+
* the verb finder; and `gh pr create` is refused as `gh pr merge` because the word appears in the
|
|
9
|
+
* PR body prose — so any body discussing the landing is unpublishable by an agent. All three are
|
|
10
|
+
* one defect: text that the shell will never execute is read as if it were the command.
|
|
11
|
+
*
|
|
12
|
+
* The fix is structural, not a longer pattern. Data payloads are separated here, by construction,
|
|
13
|
+
* and the classifier is only ever handed what is left. Masking them out afterwards fails open the
|
|
14
|
+
* moment the masker misjudges a shape; separating them fails the other way — anything this parser
|
|
15
|
+
* cannot confidently classify as data stays in the scan.
|
|
16
|
+
*
|
|
17
|
+
* The direction is absolute: this narrows what is scanned, never what is denied. Every ambiguity
|
|
18
|
+
* — an unbalanced quote, an unterminated heredoc, a shape this file does not recognise — resolves by
|
|
19
|
+
* leaving the text in the scan. A false refusal costs one human click; a false allow costs the
|
|
20
|
+
* invariant.
|
|
21
|
+
*
|
|
22
|
+
* This is not a shell. It does not know operator precedence, expansion, or `$IFS`; it knows where
|
|
23
|
+
* a command can begin and which argument positions are provably inert data. A general parser would be
|
|
24
|
+
* a larger surface with more ways to be wrong, and the classifier does not need one.
|
|
25
|
+
*/
|
|
26
|
+
/** A flag that appears before the verb, with its argument when the grammar gives it one. */
|
|
27
|
+
export interface GlobalFlag {
|
|
28
|
+
readonly flag: string;
|
|
29
|
+
/** The following token, when this flag's argument is mandatory. Null otherwise. */
|
|
30
|
+
readonly argument: string | null;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* One invocation at a command position.
|
|
34
|
+
*
|
|
35
|
+
* `verb` is null when the invocation has no provable verb — bare `git`, flags and then nothing. That
|
|
36
|
+
* is a refusable state, not a missing value: a call whose verb cannot be determined cannot be shown
|
|
37
|
+
* to be safe.
|
|
38
|
+
*/
|
|
39
|
+
export interface Invocation {
|
|
40
|
+
/** The command word with any path and `.exe` stripped, lower-cased. `''` for an empty segment. */
|
|
41
|
+
readonly program: string;
|
|
42
|
+
readonly globalFlags: readonly GlobalFlag[];
|
|
43
|
+
readonly verb: string | null;
|
|
44
|
+
/** Everything after the verb that is not a separated data payload. Quotes preserved. */
|
|
45
|
+
readonly args: readonly string[];
|
|
46
|
+
/** Message arguments and heredoc bodies proven inert. Never handed to the classifier. */
|
|
47
|
+
readonly dataPayloads: readonly string[];
|
|
48
|
+
}
|
|
49
|
+
export interface ParsedCommand {
|
|
50
|
+
readonly invocations: readonly Invocation[];
|
|
51
|
+
/**
|
|
52
|
+
* Everything the shell could actually execute, reassembled — comments and inert data payloads
|
|
53
|
+
* removed, separators preserved so a pattern spanning a compound still matches.
|
|
54
|
+
*
|
|
55
|
+
* This is what the denylist layer scans. It is the raw command minus provable non-code, never a
|
|
56
|
+
* summary of it.
|
|
57
|
+
*/
|
|
58
|
+
readonly scannable: string;
|
|
59
|
+
/** True when a shell interpreter or eval token is present anywhere. See `INTERPRETER_NAMES`. */
|
|
60
|
+
readonly hasInterpreter: boolean;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Tokens that execute a string argument.
|
|
64
|
+
*
|
|
65
|
+
* Their presence inverts the tokenizer. Normally a quoted span is one argument, so
|
|
66
|
+
* `bash -c "git push origin main"` puts `git` in the middle of an argument and never at a command
|
|
67
|
+
* position. Handed to one of these, that quoted span is shell, so when any of them appears the quote
|
|
68
|
+
* characters themselves become separators and the nested command lands at a command position where
|
|
69
|
+
* the classifier can see it. Without this inversion, nesting smuggles.
|
|
70
|
+
*
|
|
71
|
+
* A closed, named set — extend by named entry, never by wildcard. Path components are stripped and
|
|
72
|
+
* `.exe` dropped, so `C:\Windows\System32\cmd.exe` fires and `deploy.sh` does not: running a script
|
|
73
|
+
* file was never scannable at this layer, and pretending otherwise would be a claim the code cannot
|
|
74
|
+
* keep.
|
|
75
|
+
*/
|
|
76
|
+
export declare const INTERPRETER_NAMES: readonly string[];
|
|
77
|
+
/** The command word with any directory prefix and a trailing `.exe` removed, lower-cased. */
|
|
78
|
+
export declare function programNameOf(token: string): string;
|
|
79
|
+
/**
|
|
80
|
+
* Is this token provably a literal that cannot expand?
|
|
81
|
+
*
|
|
82
|
+
* Parsing does not make a payload safe, and this is where that is enforced. A single-quoted span
|
|
83
|
+
* is literal by grammar in both POSIX shells and PowerShell. A double-quoted span is literal only
|
|
84
|
+
* without `$` or a backtick, because both shells expand `"$(git push …)"` inside double quotes — so
|
|
85
|
+
* an interpolating message argument stays in the scan and is classified like any other command text.
|
|
86
|
+
* A bare argument is never inert.
|
|
87
|
+
*/
|
|
88
|
+
export declare function isInertLiteral(token: string): boolean;
|
|
89
|
+
/**
|
|
90
|
+
* Drop `#`-to-end-of-line comments at word position, outside quotes.
|
|
91
|
+
*
|
|
92
|
+
* This is the precise fix for the comment-in-heredoc defect. A boundary word inside `# don't do X`
|
|
93
|
+
* cannot be executed by anything, yet a raw-string classifier reads it as command text and refuses
|
|
94
|
+
* a benign script. `#` opens a comment at word position in sh, PowerShell, Python, Perl, Ruby and
|
|
95
|
+
* PHP — it cannot open one mid-token (`a#b` is one word) and it cannot survive a quote, so both
|
|
96
|
+
* conditions are checked rather than assumed.
|
|
97
|
+
*
|
|
98
|
+
* Narrowing what is scanned, again: a comment cannot invoke anything, so removing it cannot admit
|
|
99
|
+
* an invocation. A `#` inside a quoted span is left alone, because a quoted span may be an argument
|
|
100
|
+
* to an interpreter and interpreters do not honour this parser's idea of a comment.
|
|
101
|
+
*/
|
|
102
|
+
export declare function stripComments(command: string): string;
|
|
103
|
+
/**
|
|
104
|
+
* Split into tokens, with separators preserved as their own single-character tokens.
|
|
105
|
+
*
|
|
106
|
+
* `quotesAreSeparators` is the interpreter inversion: when set, the quote characters that delimit a
|
|
107
|
+
* payload become separators, not merely token breaks, so an interpreter's quoted payload starts at
|
|
108
|
+
* a command position and the classifier can see the invocation inside it. A token break alone would
|
|
109
|
+
* not be enough: `bash -c "git send-pack …"` would put `git` immediately after `-c`, which is an
|
|
110
|
+
* argument position, and the verb allowlist only reads invocations at command positions.
|
|
111
|
+
*
|
|
112
|
+
* Inside a delimited payload a quote of the other kind is the nested command's own quoting and stays
|
|
113
|
+
* inside its token: `bash -c "git branch 'topic' -d"` must reach the classifier as one segment, or a
|
|
114
|
+
* quoted argument splits a segment-scoped rule between the verb and its flag. The delimiter itself
|
|
115
|
+
* always closes the payload, even inside such a nested quote, so text after it is never swallowed
|
|
116
|
+
* into a token and stays in the scan.
|
|
117
|
+
*
|
|
118
|
+
* An unbalanced quote does not fail: it falls through to the end of the input as one long token,
|
|
119
|
+
* which keeps its text in the scan. Failing would have to choose a direction, and the only safe
|
|
120
|
+
* direction is the one that scans more.
|
|
121
|
+
*
|
|
122
|
+
* Inside a delimited payload a `#` at word start opens a comment to the end of the line, as the
|
|
123
|
+
* interpreter receiving the payload reads it. `stripComments` leaves quoted spans alone because a
|
|
124
|
+
* quoted span may be data; once the inversion has decided a span is a nested command, the nested
|
|
125
|
+
* shell's own comment rule applies, so `bash -c "git status # push"` scans `git status` and
|
|
126
|
+
* nothing else, exactly as the same text does outside the quotes.
|
|
127
|
+
*/
|
|
128
|
+
export declare function tokenize(command: string, quotesAreSeparators?: boolean): string[];
|
|
129
|
+
/**
|
|
130
|
+
* Parse a command into its invocations and the text a classifier may scan.
|
|
131
|
+
*
|
|
132
|
+
* Total: every input produces a result. An input this parser cannot make sense of yields invocations
|
|
133
|
+
* it could read and a `scannable` that still holds everything else.
|
|
134
|
+
*/
|
|
135
|
+
export declare function parseCommand(command: string): ParsedCommand;
|
|
136
|
+
//# sourceMappingURL=command.d.ts.map
|
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parsing a shell command into invocations, so the classifier can classify a shape instead of
|
|
3
|
+
* grepping a string.
|
|
4
|
+
*
|
|
5
|
+
* Why this file exists. A classifier that reads the raw command string produces three defects:
|
|
6
|
+
* a Python heredoc is refused because a boundary word sits in a code comment; `git -C <path>
|
|
7
|
+
* status` is refused as an unrecognised verb because global flags before the subcommand defeat
|
|
8
|
+
* the verb finder; and `gh pr create` is refused as `gh pr merge` because the word appears in the
|
|
9
|
+
* PR body prose — so any body discussing the landing is unpublishable by an agent. All three are
|
|
10
|
+
* one defect: text that the shell will never execute is read as if it were the command.
|
|
11
|
+
*
|
|
12
|
+
* The fix is structural, not a longer pattern. Data payloads are separated here, by construction,
|
|
13
|
+
* and the classifier is only ever handed what is left. Masking them out afterwards fails open the
|
|
14
|
+
* moment the masker misjudges a shape; separating them fails the other way — anything this parser
|
|
15
|
+
* cannot confidently classify as data stays in the scan.
|
|
16
|
+
*
|
|
17
|
+
* The direction is absolute: this narrows what is scanned, never what is denied. Every ambiguity
|
|
18
|
+
* — an unbalanced quote, an unterminated heredoc, a shape this file does not recognise — resolves by
|
|
19
|
+
* leaving the text in the scan. A false refusal costs one human click; a false allow costs the
|
|
20
|
+
* invariant.
|
|
21
|
+
*
|
|
22
|
+
* This is not a shell. It does not know operator precedence, expansion, or `$IFS`; it knows where
|
|
23
|
+
* a command can begin and which argument positions are provably inert data. A general parser would be
|
|
24
|
+
* a larger surface with more ways to be wrong, and the classifier does not need one.
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* Tokens that execute a string argument.
|
|
28
|
+
*
|
|
29
|
+
* Their presence inverts the tokenizer. Normally a quoted span is one argument, so
|
|
30
|
+
* `bash -c "git push origin main"` puts `git` in the middle of an argument and never at a command
|
|
31
|
+
* position. Handed to one of these, that quoted span is shell, so when any of them appears the quote
|
|
32
|
+
* characters themselves become separators and the nested command lands at a command position where
|
|
33
|
+
* the classifier can see it. Without this inversion, nesting smuggles.
|
|
34
|
+
*
|
|
35
|
+
* A closed, named set — extend by named entry, never by wildcard. Path components are stripped and
|
|
36
|
+
* `.exe` dropped, so `C:\Windows\System32\cmd.exe` fires and `deploy.sh` does not: running a script
|
|
37
|
+
* file was never scannable at this layer, and pretending otherwise would be a claim the code cannot
|
|
38
|
+
* keep.
|
|
39
|
+
*/
|
|
40
|
+
export const INTERPRETER_NAMES = [
|
|
41
|
+
'bash',
|
|
42
|
+
'sh',
|
|
43
|
+
'zsh',
|
|
44
|
+
'dash',
|
|
45
|
+
'ksh',
|
|
46
|
+
'pwsh',
|
|
47
|
+
'powershell',
|
|
48
|
+
'cmd',
|
|
49
|
+
'xargs',
|
|
50
|
+
'eval',
|
|
51
|
+
'source',
|
|
52
|
+
'iex',
|
|
53
|
+
'icm',
|
|
54
|
+
'invoke-expression',
|
|
55
|
+
'invoke-command',
|
|
56
|
+
];
|
|
57
|
+
/**
|
|
58
|
+
* Where a new command can begin. Punctuation only; the keyword openers are separate.
|
|
59
|
+
*
|
|
60
|
+
* `{` and `}` are members because `if (…) { git send-pack … }` otherwise never reached a command
|
|
61
|
+
* position at all — the same hole `do`/`then` closed for loops.
|
|
62
|
+
*/
|
|
63
|
+
const SEPARATORS = new Set(['|', '&', ';', '(', ')', '`', '<', '>', '{', '}', '\n', '\r']);
|
|
64
|
+
/**
|
|
65
|
+
* Where a top-level segment ends, for deciding which invocation is receiving a heredoc.
|
|
66
|
+
*
|
|
67
|
+
* This is a different set from `SEPARATORS`, and confusing the two is a real defect: with the
|
|
68
|
+
* wider set, `cat > notes.txt <<BODY` is read wrongly — the redirect ends the segment, so the
|
|
69
|
+
* receiver reads as `notes.txt` rather than `cat`, and a heredoc that is plainly file content is
|
|
70
|
+
* scanned as command text. A redirect changes where output goes; it does not start a new command,
|
|
71
|
+
* so it must not end the segment that owns the heredoc.
|
|
72
|
+
*/
|
|
73
|
+
const SEGMENT_SEPARATORS = new Set(['|', '&', ';', '(', ')', '`', '\n', '\r']);
|
|
74
|
+
/**
|
|
75
|
+
* Shell keywords that open a command position without being punctuation.
|
|
76
|
+
*
|
|
77
|
+
* More separators and more openers can only ever create more command positions, so strictly more
|
|
78
|
+
* invocations reach the classifier and strictly more are refused. Widening this set is structurally
|
|
79
|
+
* incapable of admitting something that is refused today.
|
|
80
|
+
*/
|
|
81
|
+
const OPENER_KEYWORDS = new Set(['do', 'then', 'else', 'elif']);
|
|
82
|
+
/** Receivers whose heredoc body is file content, never command text. */
|
|
83
|
+
const DATA_RECEIVERS = new Set(['cat', 'tee']);
|
|
84
|
+
/** Programs whose message flags carry prose. The `gh` entries are keyed on `pr create`/`pr edit`. */
|
|
85
|
+
const MESSAGE_FLAGS = {
|
|
86
|
+
git: ['-m', '--message'],
|
|
87
|
+
gh: ['-t', '--title', '-b', '--body'],
|
|
88
|
+
};
|
|
89
|
+
const INLINE_MESSAGE_PREFIXES = {
|
|
90
|
+
git: ['--message='],
|
|
91
|
+
gh: ['--title=', '--body='],
|
|
92
|
+
};
|
|
93
|
+
/**
|
|
94
|
+
* Git global flags whose following token is their argument rather than the verb.
|
|
95
|
+
*
|
|
96
|
+
* Deliberately not here: `--exec-path`. Bare `git --exec-path` prints and exits, so its argument
|
|
97
|
+
* is optional and only legal in the `=` form — consuming the next token would swallow a real verb and
|
|
98
|
+
* under-refuse. The set is exactly the flags whose argument is mandatory; one added by resemblance
|
|
99
|
+
* rather than by grammar is a hole.
|
|
100
|
+
*/
|
|
101
|
+
const GIT_FLAGS_TAKING_AN_ARGUMENT = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace']);
|
|
102
|
+
/** Read-only terminal flags: git prints and exits, so they are the verb rather than a skipped flag. */
|
|
103
|
+
const TERMINAL_GIT_FLAGS = new Set(['--version', '--help']);
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
/** The command word with any directory prefix and a trailing `.exe` removed, lower-cased. */
|
|
106
|
+
export function programNameOf(token) {
|
|
107
|
+
const bare = token.replace(/^["']|["']$/g, '');
|
|
108
|
+
const lastSeparator = Math.max(bare.lastIndexOf('/'), bare.lastIndexOf('\\'));
|
|
109
|
+
const component = lastSeparator >= 0 ? bare.slice(lastSeparator + 1) : bare;
|
|
110
|
+
const withoutExe = component.toLowerCase().endsWith('.exe') ? component.slice(0, -4) : component;
|
|
111
|
+
return withoutExe.toLowerCase();
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Is this token provably a literal that cannot expand?
|
|
115
|
+
*
|
|
116
|
+
* Parsing does not make a payload safe, and this is where that is enforced. A single-quoted span
|
|
117
|
+
* is literal by grammar in both POSIX shells and PowerShell. A double-quoted span is literal only
|
|
118
|
+
* without `$` or a backtick, because both shells expand `"$(git push …)"` inside double quotes — so
|
|
119
|
+
* an interpolating message argument stays in the scan and is classified like any other command text.
|
|
120
|
+
* A bare argument is never inert.
|
|
121
|
+
*/
|
|
122
|
+
export function isInertLiteral(token) {
|
|
123
|
+
if (token.length < 2)
|
|
124
|
+
return false;
|
|
125
|
+
const quote = token[0];
|
|
126
|
+
if (quote !== '"' && quote !== "'")
|
|
127
|
+
return false;
|
|
128
|
+
if (token[token.length - 1] !== quote)
|
|
129
|
+
return false;
|
|
130
|
+
if (quote === "'")
|
|
131
|
+
return true;
|
|
132
|
+
return !token.includes('$') && !token.includes('`');
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Drop `#`-to-end-of-line comments at word position, outside quotes.
|
|
136
|
+
*
|
|
137
|
+
* This is the precise fix for the comment-in-heredoc defect. A boundary word inside `# don't do X`
|
|
138
|
+
* cannot be executed by anything, yet a raw-string classifier reads it as command text and refuses
|
|
139
|
+
* a benign script. `#` opens a comment at word position in sh, PowerShell, Python, Perl, Ruby and
|
|
140
|
+
* PHP — it cannot open one mid-token (`a#b` is one word) and it cannot survive a quote, so both
|
|
141
|
+
* conditions are checked rather than assumed.
|
|
142
|
+
*
|
|
143
|
+
* Narrowing what is scanned, again: a comment cannot invoke anything, so removing it cannot admit
|
|
144
|
+
* an invocation. A `#` inside a quoted span is left alone, because a quoted span may be an argument
|
|
145
|
+
* to an interpreter and interpreters do not honour this parser's idea of a comment.
|
|
146
|
+
*/
|
|
147
|
+
export function stripComments(command) {
|
|
148
|
+
let result = '';
|
|
149
|
+
let quote = '';
|
|
150
|
+
let atWordStart = true;
|
|
151
|
+
for (let i = 0; i < command.length; i += 1) {
|
|
152
|
+
const character = command[i];
|
|
153
|
+
if (quote !== '') {
|
|
154
|
+
result += character;
|
|
155
|
+
if (character === quote)
|
|
156
|
+
quote = '';
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (character === '"' || character === "'") {
|
|
160
|
+
quote = character;
|
|
161
|
+
result += character;
|
|
162
|
+
atWordStart = false;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (character === '#' && atWordStart) {
|
|
166
|
+
// Skip to the end of the line; the newline itself survives because it is a separator.
|
|
167
|
+
while (i < command.length && command[i] !== '\n')
|
|
168
|
+
i += 1;
|
|
169
|
+
if (i < command.length)
|
|
170
|
+
result += '\n';
|
|
171
|
+
atWordStart = true;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
result += character;
|
|
175
|
+
atWordStart =
|
|
176
|
+
character === ' ' ||
|
|
177
|
+
character === '\t' ||
|
|
178
|
+
character === '\n' ||
|
|
179
|
+
character === '\r' ||
|
|
180
|
+
SEPARATORS.has(character);
|
|
181
|
+
}
|
|
182
|
+
return result;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Split into tokens, with separators preserved as their own single-character tokens.
|
|
186
|
+
*
|
|
187
|
+
* `quotesAreSeparators` is the interpreter inversion: when set, the quote characters that delimit a
|
|
188
|
+
* payload become separators, not merely token breaks, so an interpreter's quoted payload starts at
|
|
189
|
+
* a command position and the classifier can see the invocation inside it. A token break alone would
|
|
190
|
+
* not be enough: `bash -c "git send-pack …"` would put `git` immediately after `-c`, which is an
|
|
191
|
+
* argument position, and the verb allowlist only reads invocations at command positions.
|
|
192
|
+
*
|
|
193
|
+
* Inside a delimited payload a quote of the other kind is the nested command's own quoting and stays
|
|
194
|
+
* inside its token: `bash -c "git branch 'topic' -d"` must reach the classifier as one segment, or a
|
|
195
|
+
* quoted argument splits a segment-scoped rule between the verb and its flag. The delimiter itself
|
|
196
|
+
* always closes the payload, even inside such a nested quote, so text after it is never swallowed
|
|
197
|
+
* into a token and stays in the scan.
|
|
198
|
+
*
|
|
199
|
+
* An unbalanced quote does not fail: it falls through to the end of the input as one long token,
|
|
200
|
+
* which keeps its text in the scan. Failing would have to choose a direction, and the only safe
|
|
201
|
+
* direction is the one that scans more.
|
|
202
|
+
*
|
|
203
|
+
* Inside a delimited payload a `#` at word start opens a comment to the end of the line, as the
|
|
204
|
+
* interpreter receiving the payload reads it. `stripComments` leaves quoted spans alone because a
|
|
205
|
+
* quoted span may be data; once the inversion has decided a span is a nested command, the nested
|
|
206
|
+
* shell's own comment rule applies, so `bash -c "git status # push"` scans `git status` and
|
|
207
|
+
* nothing else, exactly as the same text does outside the quotes.
|
|
208
|
+
*/
|
|
209
|
+
export function tokenize(command, quotesAreSeparators = false) {
|
|
210
|
+
const tokens = [];
|
|
211
|
+
let current = '';
|
|
212
|
+
let quote = '';
|
|
213
|
+
let nested = '';
|
|
214
|
+
let skippingComment = false;
|
|
215
|
+
const flush = () => {
|
|
216
|
+
if (current !== '')
|
|
217
|
+
tokens.push(current);
|
|
218
|
+
current = '';
|
|
219
|
+
};
|
|
220
|
+
for (const character of command) {
|
|
221
|
+
if (quotesAreSeparators) {
|
|
222
|
+
if (skippingComment) {
|
|
223
|
+
if (character !== '\n')
|
|
224
|
+
continue;
|
|
225
|
+
skippingComment = false;
|
|
226
|
+
}
|
|
227
|
+
if (character === '"' || character === "'") {
|
|
228
|
+
if (quote === '') {
|
|
229
|
+
flush();
|
|
230
|
+
tokens.push('\n');
|
|
231
|
+
quote = character;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (character === quote) {
|
|
235
|
+
flush();
|
|
236
|
+
tokens.push('\n');
|
|
237
|
+
quote = '';
|
|
238
|
+
nested = '';
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
nested = nested === character ? '' : character;
|
|
242
|
+
current += character;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (nested !== '') {
|
|
246
|
+
current += character;
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (quote !== '' && character === '#' && current === '') {
|
|
250
|
+
skippingComment = true;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
else if (quote !== '') {
|
|
255
|
+
current += character;
|
|
256
|
+
if (character === quote)
|
|
257
|
+
quote = '';
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
else if (character === '"' || character === "'") {
|
|
261
|
+
quote = character;
|
|
262
|
+
current += character;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (SEPARATORS.has(character)) {
|
|
266
|
+
flush();
|
|
267
|
+
tokens.push(character);
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
if (character === ' ' || character === '\t') {
|
|
271
|
+
flush();
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
current += character;
|
|
275
|
+
}
|
|
276
|
+
flush();
|
|
277
|
+
return tokens;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* The first top-level `<<DELIM` on a line, outside quotes. `<<<` is a here-string (one word) and is
|
|
281
|
+
* deliberately not one.
|
|
282
|
+
*/
|
|
283
|
+
function findHeredocIntro(line) {
|
|
284
|
+
let quote = '';
|
|
285
|
+
for (let i = 0; i < line.length - 1; i += 1) {
|
|
286
|
+
const character = line[i];
|
|
287
|
+
if (quote !== '') {
|
|
288
|
+
if (character === quote)
|
|
289
|
+
quote = '';
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (character === '"' || character === "'") {
|
|
293
|
+
quote = character;
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
if (character !== '<' || line[i + 1] !== '<')
|
|
297
|
+
continue;
|
|
298
|
+
if (line[i + 2] === '<') {
|
|
299
|
+
i += 2;
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
let j = i + 2;
|
|
303
|
+
if (line[j] === '-')
|
|
304
|
+
j += 1;
|
|
305
|
+
while (line[j] === ' ')
|
|
306
|
+
j += 1;
|
|
307
|
+
let delimiterQuote = '';
|
|
308
|
+
if (line[j] === '"' || line[j] === "'") {
|
|
309
|
+
delimiterQuote = line[j];
|
|
310
|
+
j += 1;
|
|
311
|
+
}
|
|
312
|
+
const start = j;
|
|
313
|
+
while (j < line.length && /[A-Za-z0-9_]/.test(line[j]))
|
|
314
|
+
j += 1;
|
|
315
|
+
if (j === start)
|
|
316
|
+
return null;
|
|
317
|
+
if (delimiterQuote !== '' && line[j] !== delimiterQuote)
|
|
318
|
+
return null;
|
|
319
|
+
return { delimiter: line.slice(start, j), index: i };
|
|
320
|
+
}
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Which program is receiving this line's heredoc, for the data-sink decision.
|
|
325
|
+
*
|
|
326
|
+
* A heredoc into an interpreter is code and stays scanned. That is why the set of sinks is an
|
|
327
|
+
* allowlist rather than "anything but a shell": an unknown receiver might execute its input, and
|
|
328
|
+
* unknown-means-scan is the only direction that cannot open a door.
|
|
329
|
+
*/
|
|
330
|
+
function heredocReceiverIsDataSink(line, upTo) {
|
|
331
|
+
let segmentStart = 0;
|
|
332
|
+
let quote = '';
|
|
333
|
+
for (let i = 0; i < upTo; i += 1) {
|
|
334
|
+
const character = line[i];
|
|
335
|
+
if (quote !== '') {
|
|
336
|
+
if (character === quote)
|
|
337
|
+
quote = '';
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
if (character === '"' || character === "'") {
|
|
341
|
+
quote = character;
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
if (SEGMENT_SEPARATORS.has(character))
|
|
345
|
+
segmentStart = i + 1;
|
|
346
|
+
}
|
|
347
|
+
const receiving = line.slice(segmentStart, upTo).trim();
|
|
348
|
+
const tokens = tokenize(receiving);
|
|
349
|
+
if (tokens.length === 0)
|
|
350
|
+
return false;
|
|
351
|
+
const program = programNameOf(tokens[0]);
|
|
352
|
+
if (DATA_RECEIVERS.has(program))
|
|
353
|
+
return true;
|
|
354
|
+
if (program === 'git')
|
|
355
|
+
return tokens.some((token) => token === 'commit');
|
|
356
|
+
if (program === 'gh') {
|
|
357
|
+
const words = tokens.slice(1).filter((token) => !token.startsWith('-'));
|
|
358
|
+
return words[0] === 'pr' && (words[1] === 'create' || words[1] === 'edit');
|
|
359
|
+
}
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Blank the bodies of heredocs whose receiver only ever treats them as data.
|
|
364
|
+
*
|
|
365
|
+
* An unterminated heredoc leaves everything in place — the over-refusal direction.
|
|
366
|
+
*/
|
|
367
|
+
function separateHeredocBodies(command) {
|
|
368
|
+
const lines = command.split('\n');
|
|
369
|
+
const payloads = [];
|
|
370
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
371
|
+
const intro = findHeredocIntro(lines[i]);
|
|
372
|
+
if (intro === null)
|
|
373
|
+
continue;
|
|
374
|
+
let terminator = i + 1;
|
|
375
|
+
while (terminator < lines.length && lines[terminator].trim() !== intro.delimiter)
|
|
376
|
+
terminator += 1;
|
|
377
|
+
if (terminator >= lines.length)
|
|
378
|
+
return { text: command, payloads: [] };
|
|
379
|
+
if (heredocReceiverIsDataSink(lines[i], intro.index)) {
|
|
380
|
+
for (let body = i + 1; body < terminator; body += 1) {
|
|
381
|
+
payloads.push(lines[body]);
|
|
382
|
+
lines[body] = '';
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
i = terminator;
|
|
386
|
+
}
|
|
387
|
+
return { text: lines.join('\n'), payloads };
|
|
388
|
+
}
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
/** Does this invocation's shape make its message flags carry prose rather than code? */
|
|
391
|
+
function isMessageBearing(program, tokens) {
|
|
392
|
+
if (program === 'git')
|
|
393
|
+
return tokens.some((token) => token === 'commit');
|
|
394
|
+
if (program !== 'gh')
|
|
395
|
+
return false;
|
|
396
|
+
const words = tokens.filter((token) => !token.startsWith('-'));
|
|
397
|
+
return words[0] === 'pr' && (words[1] === 'create' || words[1] === 'edit');
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* One command position's tokens, read as an invocation.
|
|
401
|
+
*
|
|
402
|
+
* The verb is found by grammar, not by position. Global flags are skipped, and the five whose
|
|
403
|
+
* argument is mandatory consume the token after them — which is the whole reason `git -C <path>
|
|
404
|
+
* status` reads `status` here and not `<path>`.
|
|
405
|
+
*/
|
|
406
|
+
function readInvocation(tokens) {
|
|
407
|
+
if (tokens.length === 0) {
|
|
408
|
+
return {
|
|
409
|
+
invocation: { program: '', globalFlags: [], verb: null, args: [], dataPayloads: [] },
|
|
410
|
+
scannable: [],
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
const program = programNameOf(tokens[0]);
|
|
414
|
+
const globalFlags = [];
|
|
415
|
+
const args = [];
|
|
416
|
+
const dataPayloads = [];
|
|
417
|
+
const messageBearing = isMessageBearing(program, tokens.slice(1));
|
|
418
|
+
const flags = MESSAGE_FLAGS[program] ?? [];
|
|
419
|
+
const inlinePrefixes = INLINE_MESSAGE_PREFIXES[program] ?? [];
|
|
420
|
+
// Built alongside, never filtered afterwards. Deriving the scannable tokens by removing anything
|
|
421
|
+
// that appears in `dataPayloads` silently fails on the inline form: the token is `--body="…"`
|
|
422
|
+
// while the payload is `"…"`, so the token matches nothing and the prose goes straight into the
|
|
423
|
+
// scan. Deciding what is scannable at the point the payload is recognised
|
|
424
|
+
// makes the two impossible to disagree.
|
|
425
|
+
const scannable = [tokens[0]];
|
|
426
|
+
let verb = null;
|
|
427
|
+
let index = 1;
|
|
428
|
+
// Global flags run until the first non-flag token, which is the verb.
|
|
429
|
+
while (index < tokens.length) {
|
|
430
|
+
const token = tokens[index];
|
|
431
|
+
if (program === 'git' && TERMINAL_GIT_FLAGS.has(token)) {
|
|
432
|
+
verb = token;
|
|
433
|
+
scannable.push(token);
|
|
434
|
+
index += 1;
|
|
435
|
+
break;
|
|
436
|
+
}
|
|
437
|
+
if (program === 'git' && GIT_FLAGS_TAKING_AN_ARGUMENT.has(token)) {
|
|
438
|
+
const argument = tokens[index + 1] ?? null;
|
|
439
|
+
globalFlags.push({ flag: token, argument });
|
|
440
|
+
scannable.push(token);
|
|
441
|
+
if (argument !== null)
|
|
442
|
+
scannable.push(argument);
|
|
443
|
+
index += 2;
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
if (token.startsWith('-')) {
|
|
447
|
+
globalFlags.push({ flag: token, argument: null });
|
|
448
|
+
scannable.push(token);
|
|
449
|
+
index += 1;
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
verb = token;
|
|
453
|
+
scannable.push(token);
|
|
454
|
+
index += 1;
|
|
455
|
+
break;
|
|
456
|
+
}
|
|
457
|
+
// The tail, with message payloads separated out where the shape earns it.
|
|
458
|
+
for (; index < tokens.length; index += 1) {
|
|
459
|
+
const token = tokens[index];
|
|
460
|
+
if (messageBearing) {
|
|
461
|
+
const inlinePrefix = inlinePrefixes.find((prefix) => token.startsWith(prefix));
|
|
462
|
+
if (inlinePrefix !== undefined) {
|
|
463
|
+
const payload = token.slice(inlinePrefix.length);
|
|
464
|
+
if (isInertLiteral(payload)) {
|
|
465
|
+
dataPayloads.push(payload);
|
|
466
|
+
// The flag itself stays visible; only its argument leaves the scan.
|
|
467
|
+
scannable.push(inlinePrefix);
|
|
468
|
+
}
|
|
469
|
+
else {
|
|
470
|
+
// An interpolating payload is not data — it goes back into the scan as an argument.
|
|
471
|
+
args.push(token);
|
|
472
|
+
scannable.push(token);
|
|
473
|
+
}
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
if (flags.includes(token)) {
|
|
477
|
+
args.push(token);
|
|
478
|
+
scannable.push(token);
|
|
479
|
+
const payload = tokens[index + 1];
|
|
480
|
+
if (payload !== undefined) {
|
|
481
|
+
if (isInertLiteral(payload))
|
|
482
|
+
dataPayloads.push(payload);
|
|
483
|
+
else {
|
|
484
|
+
args.push(payload);
|
|
485
|
+
scannable.push(payload);
|
|
486
|
+
}
|
|
487
|
+
index += 1;
|
|
488
|
+
}
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
args.push(token);
|
|
493
|
+
scannable.push(token);
|
|
494
|
+
}
|
|
495
|
+
return { invocation: { program, globalFlags, verb, args, dataPayloads }, scannable };
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Parse a command into its invocations and the text a classifier may scan.
|
|
499
|
+
*
|
|
500
|
+
* Total: every input produces a result. An input this parser cannot make sense of yields invocations
|
|
501
|
+
* it could read and a `scannable` that still holds everything else.
|
|
502
|
+
*/
|
|
503
|
+
export function parseCommand(command) {
|
|
504
|
+
const withoutComments = stripComments(command);
|
|
505
|
+
const hasInterpreter = tokenize(withoutComments).some((token) => INTERPRETER_NAMES.includes(programNameOf(token)));
|
|
506
|
+
const heredocs = separateHeredocBodies(withoutComments);
|
|
507
|
+
const tokens = tokenize(heredocs.text, hasInterpreter);
|
|
508
|
+
const invocations = [];
|
|
509
|
+
const scannableTokens = [];
|
|
510
|
+
let position = [];
|
|
511
|
+
let atCommandPosition = true;
|
|
512
|
+
const closePosition = () => {
|
|
513
|
+
if (position.length > 0) {
|
|
514
|
+
// The separation is what reaches the scan. The reader decides scannability as it recognises
|
|
515
|
+
// each payload, so the classifier is handed text that could actually run.
|
|
516
|
+
const read = readInvocation(position);
|
|
517
|
+
invocations.push(read.invocation);
|
|
518
|
+
scannableTokens.push(...read.scannable);
|
|
519
|
+
}
|
|
520
|
+
position = [];
|
|
521
|
+
};
|
|
522
|
+
for (const token of tokens) {
|
|
523
|
+
if (token.length === 1 && SEPARATORS.has(token)) {
|
|
524
|
+
closePosition();
|
|
525
|
+
scannableTokens.push(token);
|
|
526
|
+
atCommandPosition = true;
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
if (atCommandPosition && OPENER_KEYWORDS.has(token.toLowerCase())) {
|
|
530
|
+
// A keyword opener is not part of the invocation it introduces, and it opens another one.
|
|
531
|
+
closePosition();
|
|
532
|
+
scannableTokens.push(token);
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
if (!atCommandPosition && OPENER_KEYWORDS.has(token.toLowerCase())) {
|
|
536
|
+
closePosition();
|
|
537
|
+
scannableTokens.push(token);
|
|
538
|
+
atCommandPosition = true;
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
position.push(token);
|
|
542
|
+
atCommandPosition = false;
|
|
543
|
+
}
|
|
544
|
+
closePosition();
|
|
545
|
+
return {
|
|
546
|
+
invocations,
|
|
547
|
+
scannable: scannableTokens.join(' '),
|
|
548
|
+
hasInterpreter,
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
//# sourceMappingURL=command.js.map
|