@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.
Files changed (230) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/LICENSE +21 -0
  3. package/README.md +146 -0
  4. package/SECURITY.md +315 -0
  5. package/dist/bin/command.d.ts +97 -0
  6. package/dist/bin/command.js +141 -0
  7. package/dist/bin/config.d.ts +7 -0
  8. package/dist/bin/config.js +90 -0
  9. package/dist/bin/login.d.ts +48 -0
  10. package/dist/bin/login.js +82 -0
  11. package/dist/bin/main.d.ts +33 -0
  12. package/dist/bin/main.js +173 -0
  13. package/dist/bin/pair.d.ts +28 -0
  14. package/dist/bin/pair.js +198 -0
  15. package/dist/bin/periscope.d.ts +3 -0
  16. package/dist/bin/periscope.js +4 -0
  17. package/dist/bin/posture.d.ts +64 -0
  18. package/dist/bin/posture.js +157 -0
  19. package/dist/bin/reconfigure.d.ts +64 -0
  20. package/dist/bin/reconfigure.js +195 -0
  21. package/dist/bin/serve.d.ts +103 -0
  22. package/dist/bin/serve.js +441 -0
  23. package/dist/bin/status.d.ts +11 -0
  24. package/dist/bin/status.js +49 -0
  25. package/dist/bin/workspaces.d.ts +109 -0
  26. package/dist/bin/workspaces.js +215 -0
  27. package/dist/control/backoff.d.ts +25 -0
  28. package/dist/control/backoff.js +20 -0
  29. package/dist/control/codec.d.ts +15 -0
  30. package/dist/control/codec.js +501 -0
  31. package/dist/control/credential.d.ts +23 -0
  32. package/dist/control/credential.js +8 -0
  33. package/dist/control/frames.d.ts +1180 -0
  34. package/dist/control/frames.js +441 -0
  35. package/dist/control/link-state.d.ts +32 -0
  36. package/dist/control/link-state.js +39 -0
  37. package/dist/control/link.d.ts +109 -0
  38. package/dist/control/link.js +736 -0
  39. package/dist/control/queue.d.ts +110 -0
  40. package/dist/control/queue.js +174 -0
  41. package/dist/control/seq.d.ts +49 -0
  42. package/dist/control/seq.js +52 -0
  43. package/dist/control/stream-routing.d.ts +200 -0
  44. package/dist/control/stream-routing.js +167 -0
  45. package/dist/control/stream.d.ts +43 -0
  46. package/dist/control/stream.js +64 -0
  47. package/dist/core/async-queue.d.ts +26 -0
  48. package/dist/core/async-queue.js +63 -0
  49. package/dist/core/failure.d.ts +22 -0
  50. package/dist/core/failure.js +53 -0
  51. package/dist/core/index.d.ts +18 -0
  52. package/dist/core/index.js +7 -0
  53. package/dist/core/keyed-turns.d.ts +28 -0
  54. package/dist/core/keyed-turns.js +44 -0
  55. package/dist/core/paths.d.ts +25 -0
  56. package/dist/core/paths.js +77 -0
  57. package/dist/core/refusal.d.ts +43 -0
  58. package/dist/core/refusal.js +399 -0
  59. package/dist/core/result.d.ts +22 -0
  60. package/dist/core/result.js +16 -0
  61. package/dist/core/time.d.ts +19 -0
  62. package/dist/core/time.js +25 -0
  63. package/dist/core/vocab.d.ts +23 -0
  64. package/dist/core/vocab.js +37 -0
  65. package/dist/core/workspace-id.d.ts +12 -0
  66. package/dist/core/workspace-id.js +12 -0
  67. package/dist/gate/command.d.ts +136 -0
  68. package/dist/gate/command.js +551 -0
  69. package/dist/gate/decision.d.ts +111 -0
  70. package/dist/gate/decision.js +97 -0
  71. package/dist/gate/escalate.d.ts +82 -0
  72. package/dist/gate/escalate.js +92 -0
  73. package/dist/gate/gate.d.ts +173 -0
  74. package/dist/gate/gate.js +417 -0
  75. package/dist/gate/index.d.ts +39 -0
  76. package/dist/gate/index.js +31 -0
  77. package/dist/gate/jail.d.ts +92 -0
  78. package/dist/gate/jail.js +131 -0
  79. package/dist/gate/local.d.ts +69 -0
  80. package/dist/gate/local.js +51 -0
  81. package/dist/gate/outcome.d.ts +72 -0
  82. package/dist/gate/outcome.js +91 -0
  83. package/dist/gate/shell.d.ts +36 -0
  84. package/dist/gate/shell.js +226 -0
  85. package/dist/host/agent-process.d.ts +452 -0
  86. package/dist/host/agent-process.js +382 -0
  87. package/dist/host/bulk-post.d.ts +44 -0
  88. package/dist/host/bulk-post.js +114 -0
  89. package/dist/host/claude-transcripts.d.ts +84 -0
  90. package/dist/host/claude-transcripts.js +339 -0
  91. package/dist/host/config-file.d.ts +60 -0
  92. package/dist/host/config-file.js +216 -0
  93. package/dist/host/git-facts.d.ts +7 -0
  94. package/dist/host/git-facts.js +106 -0
  95. package/dist/host/hooks.d.ts +56 -0
  96. package/dist/host/hooks.js +75 -0
  97. package/dist/host/host.d.ts +356 -0
  98. package/dist/host/host.js +1294 -0
  99. package/dist/host/index.d.ts +35 -0
  100. package/dist/host/index.js +22 -0
  101. package/dist/host/link-state-file.d.ts +18 -0
  102. package/dist/host/link-state-file.js +66 -0
  103. package/dist/host/loopback.d.ts +38 -0
  104. package/dist/host/loopback.js +122 -0
  105. package/dist/host/machine.d.ts +9 -0
  106. package/dist/host/machine.js +19 -0
  107. package/dist/host/mcp-server.d.ts +11 -0
  108. package/dist/host/mcp-server.js +48 -0
  109. package/dist/host/package-facts.d.ts +3 -0
  110. package/dist/host/package-facts.js +26 -0
  111. package/dist/host/paired-credential-store.d.ts +32 -0
  112. package/dist/host/paired-credential-store.js +112 -0
  113. package/dist/host/paths.d.ts +80 -0
  114. package/dist/host/paths.js +165 -0
  115. package/dist/host/repository-read.d.ts +33 -0
  116. package/dist/host/repository-read.js +201 -0
  117. package/dist/host/session-store.d.ts +53 -0
  118. package/dist/host/session-store.js +100 -0
  119. package/dist/host/sign-in.d.ts +90 -0
  120. package/dist/host/sign-in.js +239 -0
  121. package/dist/host/telemetry.d.ts +42 -0
  122. package/dist/host/telemetry.js +74 -0
  123. package/dist/host/token-cache.d.ts +62 -0
  124. package/dist/host/token-cache.js +185 -0
  125. package/dist/host/transcript-fs.d.ts +4 -0
  126. package/dist/host/transcript-fs.js +104 -0
  127. package/dist/host/wire-request.d.ts +80 -0
  128. package/dist/host/wire-request.js +196 -0
  129. package/dist/host/workspace-fs.d.ts +14 -0
  130. package/dist/host/workspace-fs.js +84 -0
  131. package/dist/host/workspace-trust.d.ts +23 -0
  132. package/dist/host/workspace-trust.js +80 -0
  133. package/dist/identity/authorize.d.ts +59 -0
  134. package/dist/identity/authorize.js +72 -0
  135. package/dist/identity/config.d.ts +98 -0
  136. package/dist/identity/config.js +130 -0
  137. package/dist/identity/credential.d.ts +91 -0
  138. package/dist/identity/credential.js +76 -0
  139. package/dist/identity/device-code.d.ts +115 -0
  140. package/dist/identity/device-code.js +134 -0
  141. package/dist/identity/index.d.ts +33 -0
  142. package/dist/identity/index.js +10 -0
  143. package/dist/identity/mode.d.ts +106 -0
  144. package/dist/identity/mode.js +69 -0
  145. package/dist/identity/paired-credential.d.ts +52 -0
  146. package/dist/identity/paired-credential.js +43 -0
  147. package/dist/identity/pkce.d.ts +50 -0
  148. package/dist/identity/pkce.js +94 -0
  149. package/dist/identity/store.d.ts +43 -0
  150. package/dist/identity/store.js +43 -0
  151. package/dist/identity/token.d.ts +58 -0
  152. package/dist/identity/token.js +149 -0
  153. package/dist/index.d.ts +45 -0
  154. package/dist/index.js +53 -0
  155. package/dist/mcp/descriptor.d.ts +59 -0
  156. package/dist/mcp/descriptor.js +14 -0
  157. package/dist/mcp/index.d.ts +14 -0
  158. package/dist/mcp/index.js +4 -0
  159. package/dist/mcp/schema.d.ts +37 -0
  160. package/dist/mcp/schema.js +175 -0
  161. package/dist/mcp/server.d.ts +107 -0
  162. package/dist/mcp/server.js +66 -0
  163. package/dist/persistence/entry.d.ts +74 -0
  164. package/dist/persistence/entry.js +105 -0
  165. package/dist/persistence/index.d.ts +22 -0
  166. package/dist/persistence/index.js +8 -0
  167. package/dist/persistence/key.d.ts +46 -0
  168. package/dist/persistence/key.js +33 -0
  169. package/dist/persistence/mirror.d.ts +71 -0
  170. package/dist/persistence/mirror.js +57 -0
  171. package/dist/persistence/receipt.d.ts +111 -0
  172. package/dist/persistence/receipt.js +85 -0
  173. package/dist/persistence/retention.d.ts +68 -0
  174. package/dist/persistence/retention.js +68 -0
  175. package/dist/persistence/store.d.ts +106 -0
  176. package/dist/persistence/store.js +86 -0
  177. package/dist/persistence/transition-log.d.ts +73 -0
  178. package/dist/persistence/transition-log.js +133 -0
  179. package/dist/protocol.d.ts +27 -0
  180. package/dist/protocol.js +12 -0
  181. package/dist/sessions/index.d.ts +8 -0
  182. package/dist/sessions/index.js +4 -0
  183. package/dist/sessions/registry.d.ts +186 -0
  184. package/dist/sessions/registry.js +190 -0
  185. package/dist/sessions/session.d.ts +178 -0
  186. package/dist/sessions/session.js +288 -0
  187. package/dist/sessions/spawn-env.d.ts +88 -0
  188. package/dist/sessions/spawn-env.js +277 -0
  189. package/dist/state/coverage.d.ts +308 -0
  190. package/dist/state/coverage.js +315 -0
  191. package/dist/state/index.d.ts +12 -0
  192. package/dist/state/index.js +7 -0
  193. package/dist/state/machine.d.ts +112 -0
  194. package/dist/state/machine.js +237 -0
  195. package/dist/state/model.d.ts +233 -0
  196. package/dist/state/model.js +214 -0
  197. package/dist/state/observer.d.ts +55 -0
  198. package/dist/state/observer.js +413 -0
  199. package/dist/state/reporter.d.ts +45 -0
  200. package/dist/state/reporter.js +35 -0
  201. package/dist/state/store.d.ts +56 -0
  202. package/dist/state/store.js +120 -0
  203. package/dist/telemetry/index.d.ts +11 -0
  204. package/dist/telemetry/index.js +2 -0
  205. package/dist/telemetry/usage.d.ts +78 -0
  206. package/dist/telemetry/usage.js +69 -0
  207. package/dist/workspace/git-worktree.d.ts +150 -0
  208. package/dist/workspace/git-worktree.js +417 -0
  209. package/dist/workspace/index.d.ts +9 -0
  210. package/dist/workspace/index.js +4 -0
  211. package/dist/workspace/plain-dir.d.ts +34 -0
  212. package/dist/workspace/plain-dir.js +90 -0
  213. package/dist/workspace/provider.d.ts +152 -0
  214. package/dist/workspace/provider.js +2 -0
  215. package/dist/workspace/worktree-porcelain.d.ts +29 -0
  216. package/dist/workspace/worktree-porcelain.js +100 -0
  217. package/docs/architecture.md +277 -0
  218. package/docs/configuration.md +187 -0
  219. package/docs/gate.md +219 -0
  220. package/docs/identity.md +107 -0
  221. package/docs/protocol.md +501 -0
  222. package/docs/state-machine.md +160 -0
  223. package/examples/README.md +141 -0
  224. package/examples/minimal-controller/controller.ts +38 -0
  225. package/examples/parallel-run-proof/permission-mode-probe.ts +129 -0
  226. package/examples/parallel-run-proof/run.ts +850 -0
  227. package/examples/test-controller/controller.ts +655 -0
  228. package/examples/test-controller/serve.ts +29 -0
  229. package/examples/tsconfig.json +30 -0
  230. 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