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