headlesscode 1.0.2

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 (232) hide show
  1. package/ATTRIBUTION.md +53 -0
  2. package/CODE_OF_CONDUCT.md +130 -0
  3. package/CONTRIBUTING.md +107 -0
  4. package/LICENSE +202 -0
  5. package/README.md +486 -0
  6. package/SECURITY.md +211 -0
  7. package/bin/headlesscode.mjs +83 -0
  8. package/package.json +63 -0
  9. package/shared/prompts/review-mode-prompt-short.md +93 -0
  10. package/shared/prompts/review-mode-prompt.md +281 -0
  11. package/shared/rules-code/rules.md +22 -0
  12. package/shared/stacks/cpp/rules.md +30 -0
  13. package/shared/stacks/fastapi/rules.md +30 -0
  14. package/shared/stacks/javascript/rules.md +37 -0
  15. package/shared/stacks/postgresql/rules.md +31 -0
  16. package/shared/stacks/python/rules.md +35 -0
  17. package/shared/stacks/react/rules.md +11 -0
  18. package/shared/stacks/typescript/rules.md +10 -0
  19. package/src/budget/budget.ts +221 -0
  20. package/src/budget/concurrency.ts +126 -0
  21. package/src/budget/cost.ts +309 -0
  22. package/src/budget/index.ts +8 -0
  23. package/src/checkpoints/cli.ts +256 -0
  24. package/src/checkpoints/service.ts +227 -0
  25. package/src/cli.ts +1535 -0
  26. package/src/cloud/docker-provider.ts +334 -0
  27. package/src/cloud/provider.ts +300 -0
  28. package/src/codeintel/call-graph.ts +78 -0
  29. package/src/codeintel/find-references.ts +123 -0
  30. package/src/codeintel/go-to-definition.ts +193 -0
  31. package/src/codeintel/handlers.ts +190 -0
  32. package/src/codeintel/import-graph.ts +173 -0
  33. package/src/codeintel/outline.ts +180 -0
  34. package/src/codeintel/position.ts +77 -0
  35. package/src/codeintel/program.ts +350 -0
  36. package/src/codeintel/rename-symbol.ts +213 -0
  37. package/src/codeintel/tools.ts +280 -0
  38. package/src/codemap/build.ts +135 -0
  39. package/src/codemap/cli.ts +190 -0
  40. package/src/codemap/extract.ts +339 -0
  41. package/src/codemap/files.ts +236 -0
  42. package/src/codemap/fingerprint.ts +65 -0
  43. package/src/codemap/flows.ts +62 -0
  44. package/src/codemap/html.ts +451 -0
  45. package/src/codemap/lock.ts +80 -0
  46. package/src/codemap/types.ts +101 -0
  47. package/src/codesearch/airunner-embedder.ts +185 -0
  48. package/src/codesearch/chunk.ts +339 -0
  49. package/src/codesearch/cli.ts +223 -0
  50. package/src/codesearch/embedder.ts +332 -0
  51. package/src/codesearch/files.ts +280 -0
  52. package/src/codesearch/index.ts +469 -0
  53. package/src/codesearch/ollama-embedder.ts +205 -0
  54. package/src/codesearch/search.ts +141 -0
  55. package/src/codesearch/types.ts +100 -0
  56. package/src/config/mode-models.ts +218 -0
  57. package/src/dashboard/aggregate.ts +364 -0
  58. package/src/dashboard/chat-thread.ts +141 -0
  59. package/src/dashboard/checkpoints.ts +124 -0
  60. package/src/dashboard/cli.ts +193 -0
  61. package/src/dashboard/codemap.ts +44 -0
  62. package/src/dashboard/files.ts +121 -0
  63. package/src/dashboard/page.ts +2803 -0
  64. package/src/dashboard/self-improvement-metrics.ts +282 -0
  65. package/src/dashboard/server.ts +1103 -0
  66. package/src/dashboard/session-launch.ts +310 -0
  67. package/src/dashboard/timeline.ts +273 -0
  68. package/src/dashboard/tool-exec.ts +107 -0
  69. package/src/dashboard/trend-cli.ts +141 -0
  70. package/src/dashboard/trend.ts +413 -0
  71. package/src/decision-proxy/cli.ts +261 -0
  72. package/src/decision-proxy/proxy.ts +569 -0
  73. package/src/deploy/gate-cli.ts +147 -0
  74. package/src/deploy/gate.ts +254 -0
  75. package/src/engine/condense.ts +512 -0
  76. package/src/engine/events.ts +428 -0
  77. package/src/engine/handoff.ts +71 -0
  78. package/src/engine/lazy-tools.ts +160 -0
  79. package/src/engine/local-explore.ts +653 -0
  80. package/src/engine/logger.ts +96 -0
  81. package/src/engine/loop.ts +5517 -0
  82. package/src/engine/parser.ts +347 -0
  83. package/src/engine/prompt.ts +860 -0
  84. package/src/engine/reports.ts +47 -0
  85. package/src/engine/stacks.ts +448 -0
  86. package/src/engine/types.ts +291 -0
  87. package/src/engine/usage.ts +186 -0
  88. package/src/github/app-auth.ts +161 -0
  89. package/src/github/cli.ts +448 -0
  90. package/src/github/installations.ts +133 -0
  91. package/src/github/pr.ts +321 -0
  92. package/src/github/provision.ts +118 -0
  93. package/src/github/push.ts +122 -0
  94. package/src/index-util.ts +50 -0
  95. package/src/index.ts +81 -0
  96. package/src/init/cli.ts +248 -0
  97. package/src/init/gitignore.ts +74 -0
  98. package/src/llm/ollama.ts +308 -0
  99. package/src/llm/openrouter.ts +868 -0
  100. package/src/llm/preflight.ts +367 -0
  101. package/src/llm/transcript-capture.ts +84 -0
  102. package/src/memory/embed.ts +110 -0
  103. package/src/memory/index.ts +22 -0
  104. package/src/memory/local.ts +259 -0
  105. package/src/memory/summarizer.ts +283 -0
  106. package/src/memory/types.ts +153 -0
  107. package/src/memory/uwuchat.ts +157 -0
  108. package/src/migrate/cli.ts +115 -0
  109. package/src/orchestrator/analyze-cli.ts +104 -0
  110. package/src/orchestrator/auto-split.ts +206 -0
  111. package/src/orchestrator/cleanup.ts +1003 -0
  112. package/src/orchestrator/cli.ts +3571 -0
  113. package/src/orchestrator/cost-estimate.ts +564 -0
  114. package/src/orchestrator/cost-history-cli.ts +242 -0
  115. package/src/orchestrator/cost-history.ts +397 -0
  116. package/src/orchestrator/git-sync.ts +250 -0
  117. package/src/orchestrator/index.ts +153 -0
  118. package/src/orchestrator/log-analysis.ts +0 -0
  119. package/src/orchestrator/merge-check.ts +108 -0
  120. package/src/orchestrator/pipeline.ts +411 -0
  121. package/src/orchestrator/resume.ts +1940 -0
  122. package/src/orchestrator/reviewer.ts +503 -0
  123. package/src/orchestrator/split.ts +296 -0
  124. package/src/orchestrator/state.ts +542 -0
  125. package/src/orchestrator/status.ts +697 -0
  126. package/src/orchestrator/verification-gate.ts +134 -0
  127. package/src/orchestrator/watch.ts +898 -0
  128. package/src/permissions/commands.ts +1083 -0
  129. package/src/permissions/config.ts +241 -0
  130. package/src/permissions/index.ts +12 -0
  131. package/src/permissions/protected-files.ts +96 -0
  132. package/src/permissions/store-protection.ts +272 -0
  133. package/src/project-store.ts +648 -0
  134. package/src/projects/cli.ts +382 -0
  135. package/src/qa/qa.ts +487 -0
  136. package/src/tools/browser/handler.ts +346 -0
  137. package/src/tools/browser/service.ts +406 -0
  138. package/src/tools/browser/smoke.ts +78 -0
  139. package/src/tools/browser/tool.ts +99 -0
  140. package/src/tools/executor.ts +2575 -0
  141. package/src/tools/language-detect.ts +183 -0
  142. package/src/tools/output-summarizer.ts +369 -0
  143. package/src/tools/run-tests.ts +302 -0
  144. package/src/tools/set-indentation-tool.ts +49 -0
  145. package/src/tools/test-selection.ts +160 -0
  146. package/src/vendor/tests/smoke.ts +103 -0
  147. package/src/vendor/zoo-code/VENDOR-NOTES.md +213 -0
  148. package/src/vendor/zoo-code/shim/anthropic.ts +71 -0
  149. package/src/vendor/zoo-code/shim/openai.d.ts +60 -0
  150. package/src/vendor/zoo-code/shim/os-name.ts +18 -0
  151. package/src/vendor/zoo-code/shim/strip-bom.ts +14 -0
  152. package/src/vendor/zoo-code/shim/vscode.ts +76 -0
  153. package/src/vendor/zoo-code/src/core/config/CustomModesManager.ts +1015 -0
  154. package/src/vendor/zoo-code/src/core/diff/strategies/multi-search-replace.ts +670 -0
  155. package/src/vendor/zoo-code/src/core/prompts/sections/capabilities.ts +46 -0
  156. package/src/vendor/zoo-code/src/core/prompts/sections/custom-instructions.ts +559 -0
  157. package/src/vendor/zoo-code/src/core/prompts/sections/index.ts +10 -0
  158. package/src/vendor/zoo-code/src/core/prompts/sections/markdown-formatting.ts +7 -0
  159. package/src/vendor/zoo-code/src/core/prompts/sections/modes.ts +35 -0
  160. package/src/vendor/zoo-code/src/core/prompts/sections/objective.ts +13 -0
  161. package/src/vendor/zoo-code/src/core/prompts/sections/rules.ts +95 -0
  162. package/src/vendor/zoo-code/src/core/prompts/sections/skills.ts +105 -0
  163. package/src/vendor/zoo-code/src/core/prompts/sections/system-info.ts +30 -0
  164. package/src/vendor/zoo-code/src/core/prompts/sections/tool-use-guidelines.ts +9 -0
  165. package/src/vendor/zoo-code/src/core/prompts/sections/tool-use.ts +7 -0
  166. package/src/vendor/zoo-code/src/core/prompts/system.ts +176 -0
  167. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/access_mcp_resource.ts +41 -0
  168. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_diff.ts +40 -0
  169. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_patch.ts +61 -0
  170. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/ask_followup_question.ts +62 -0
  171. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/attempt_completion.ts +33 -0
  172. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/codebase_search.ts +43 -0
  173. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/converters.ts +109 -0
  174. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit.ts +48 -0
  175. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit_file.ts +72 -0
  176. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/execute_command.ts +54 -0
  177. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/generate_image.ts +51 -0
  178. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/index.ts +75 -0
  179. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/list_files.ts +41 -0
  180. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/mcp_server.ts +75 -0
  181. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/new_task.ts +39 -0
  182. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_command_output.ts +81 -0
  183. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_file.ts +169 -0
  184. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/run_slash_command.ts +31 -0
  185. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_files.ts +50 -0
  186. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_replace.ts +51 -0
  187. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/skill.ts +33 -0
  188. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/switch_mode.ts +31 -0
  189. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/update_todo_list.ts +54 -0
  190. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/write_to_file.ts +40 -0
  191. package/src/vendor/zoo-code/src/core/prompts/types.ts +12 -0
  192. package/src/vendor/zoo-code/src/i18n/index.ts +19 -0
  193. package/src/vendor/zoo-code/src/integrations/misc/extract-text.ts +81 -0
  194. package/src/vendor/zoo-code/src/services/checkpoints/RepoPerTaskCheckpointService.ts +15 -0
  195. package/src/vendor/zoo-code/src/services/checkpoints/ShadowCheckpointService.ts +553 -0
  196. package/src/vendor/zoo-code/src/services/checkpoints/excludes.ts +212 -0
  197. package/src/vendor/zoo-code/src/services/checkpoints/index.ts +3 -0
  198. package/src/vendor/zoo-code/src/services/checkpoints/types.ts +35 -0
  199. package/src/vendor/zoo-code/src/services/code-index/manager.ts +19 -0
  200. package/src/vendor/zoo-code/src/services/mcp/McpHub.ts +36 -0
  201. package/src/vendor/zoo-code/src/services/roo-config/index.ts +441 -0
  202. package/src/vendor/zoo-code/src/services/search/file-search.ts +143 -0
  203. package/src/vendor/zoo-code/src/services/skills/SkillsManager.ts +20 -0
  204. package/src/vendor/zoo-code/src/shared/globalFileNames.ts +9 -0
  205. package/src/vendor/zoo-code/src/shared/language.ts +43 -0
  206. package/src/vendor/zoo-code/src/shared/modes.ts +257 -0
  207. package/src/vendor/zoo-code/src/shared/tools.ts +385 -0
  208. package/src/vendor/zoo-code/src/utils/fs.ts +39 -0
  209. package/src/vendor/zoo-code/src/utils/globalContext.ts +22 -0
  210. package/src/vendor/zoo-code/src/utils/json-schema.ts +16 -0
  211. package/src/vendor/zoo-code/src/utils/logging.ts +21 -0
  212. package/src/vendor/zoo-code/src/utils/mcp-name.ts +190 -0
  213. package/src/vendor/zoo-code/src/utils/object.ts +18 -0
  214. package/src/vendor/zoo-code/src/utils/path.ts +94 -0
  215. package/src/vendor/zoo-code/src/utils/shell.ts +376 -0
  216. package/src/vendor/zoo-code/src/utils/text-normalization.ts +99 -0
  217. package/src/vendor/zoo-code/types/global-settings.ts +19 -0
  218. package/src/vendor/zoo-code/types/index.ts +22 -0
  219. package/src/vendor/zoo-code/types/message.ts +375 -0
  220. package/src/vendor/zoo-code/types/mode.ts +241 -0
  221. package/src/vendor/zoo-code/types/todo.ts +19 -0
  222. package/src/vendor/zoo-code/types/tool-params.ts +116 -0
  223. package/src/vendor/zoo-code/types/tool.ts +67 -0
  224. package/src/vendor/zoo-code/types/vscode.ts +84 -0
  225. package/src/vision/describe.ts +242 -0
  226. package/src/vision/tool.ts +91 -0
  227. package/src/watcher/cli.ts +369 -0
  228. package/src/watcher/github.ts +304 -0
  229. package/src/watcher/index.ts +59 -0
  230. package/src/watcher/state.ts +254 -0
  231. package/src/watcher/watch.ts +562 -0
  232. package/tsconfig.json +18 -0
@@ -0,0 +1,1083 @@
1
+ /**
2
+ * Command allow/deny decision logic for the headless harness — a faithful port
3
+ * of Zoo Code's auto-approval command gating.
4
+ *
5
+ * Ported (with attribution) from the reference tree (read-only):
6
+ * zoo-code/src/shared/parse-command.ts — `parseCommand` (compound
7
+ * command splitting on &&/||/;/|/& + unterminated-quote detection)
8
+ * zoo-code/src/core/auto-approval/commands.ts — `containsDangerousSubstitution`,
9
+ * `findLongestPrefixMatch`, `getSingleCommandDecision`, `getCommandDecision`
10
+ *
11
+ * The vendored copy under src/vendor/zoo-code/ does NOT include these files;
12
+ * the reference lives at the repo-root `zoo-code/` tree.
13
+ *
14
+ * Verified precedence in the reference logic (do not assume; this is what the
15
+ * vendored code actually does):
16
+ *
17
+ * 1. `getCommandDecision` splits the command with `parseCommand`, then checks
18
+ * each sub-command with `getSingleCommandDecision`. **If ANY sub-command is
19
+ * denied, the whole command is denied** (`decisions.includes("auto_deny")`
20
+ * → `"auto_deny"`) — deny wins over allow at the compound-command level.
21
+ * 2. Dangerous substitutions (`containsDangerousSubstitution`) are NEVER
22
+ * auto-approved — upstream maps them to `"ask_user"` (a human must decide).
23
+ * 3. For a single command, allow-vs-deny conflicts use LONGEST-PREFIX-MATCH:
24
+ * the longer (more specific) pattern wins; a TIE goes to deny
25
+ * (`longestAllowedMatch.length > longestDeniedMatch.length ? approve : deny`).
26
+ * 4. No match at all → `"ask_user"`.
27
+ *
28
+ * Headless translation (our layer, on top of the port):
29
+ * - A headless harness has no human to prompt for `"ask_user"`, so it is
30
+ * refused (deny). Dangerous substitution ⇒ deny unconditionally, regardless
31
+ * of config.
32
+ * - DEFAULT-ALLOW decision (deliberate, documented in plans/permissions-parity.md
33
+ * and the completion report): an EMPTY `allowedCommands` list means "allow
34
+ * everything except the deny-list" — backward compatible with the
35
+ * pre-permissions harness. `deniedCommands` always applies. Only when an
36
+ * allow-list is explicitly configured does "not on the allow-list" mean deny.
37
+ * - CENTRAL-STORE PROTECTION (always-applied, NOT configurable): a recursive
38
+ * `rm` whose resolved target is the shared central store root
39
+ * (~/.local/share/headlesscode) or a parent of it is refused EVEN with an
40
+ * empty allow/deny config — the default-allow branch must never bypass it.
41
+ * This is a pattern-based speed bump against the exact incident documented
42
+ * in plans/protect-shared-store-from-destructive-commands.md, not a
43
+ * sandbox; see src/permissions/store-protection.ts for the honest scope.
44
+ * Per-workspace permissions.json cannot override it (the whole point is
45
+ * protecting the SHARED resource from any single workspace).
46
+ * - REDIRECT-ESCAPE PROTECTION (always-applied, NOT configurable, issue #122):
47
+ * an output redirect (`>`, `>>`, `2>`, `2>>`, `&>`, `&>>`) whose resolved
48
+ * target escapes the workspace root is refused EVEN with an empty allow/deny
49
+ * config. This closes the same-class hole the rules doc calls out in
50
+ * .roo/rules/rules.md: shell redirects to `/tmp` previously slipped through
51
+ * the command-string allow/deny gate even though every file tool rejects
52
+ * outside-workspace paths. Pattern-based like the store check — a script
53
+ * that writes outside the workspace via a non-redirect mechanism (e.g.
54
+ * `python -c "open('/tmp/x','w')"`) is out of scope; see
55
+ * redirectTargets/checkRedirectEscape below.
56
+ *
57
+ * `parseCommand` is ported WITHOUT the `shell-quote` dependency (not present in
58
+ * this repo's node_modules): quoted strings, arithmetic, parameter expansions,
59
+ * process substitutions, redirections and variables are all masked into
60
+ * placeholder tokens BEFORE splitting (identical masking order to the
61
+ * reference), so splitting the masked string on the chain operators and on
62
+ * top-level subshell placeholders is behaviorally equivalent to the
63
+ * reference's shell-quote token walk (which splits on exactly those operator
64
+ * tokens and promotes subshell contents to their own sub-command). The quote
65
+ * state machine (single/double/ANSI-C/locale/heredoc + comments) is ported
66
+ * verbatim.
67
+ */
68
+
69
+ import * as os from "node:os"
70
+ import * as path from "node:path"
71
+
72
+ import { checkCentralStoreDestruction, expandEnv, expandHome, splitCommandWords } from "./store-protection.js"
73
+
74
+ // ─── parseCommand port (zoo-code/src/shared/parse-command.ts) ───────────────
75
+
76
+ /**
77
+ * The style of quoting that opened a region (see the reference file for the
78
+ * full rationale; kept identical so masking behaves the same).
79
+ */
80
+ export type QuoteType = "posix-single" | "ansi-c" | "double" | "locale" | "heredoc"
81
+
82
+ /** Describes the opening of a quoted region that is never closed. */
83
+ export interface UnterminatedQuote {
84
+ quoteType: QuoteType
85
+ /** Index in the original command string of the character that opened the region. */
86
+ openIndex: number
87
+ /** Human-readable description suitable for surfacing to an agent as a tool error. */
88
+ message: string
89
+ }
90
+
91
+ /**
92
+ * The result of parsing a command string. `commands` is the list of individual
93
+ * sub-commands produced by splitting on unquoted newlines and chain operators.
94
+ * When `parseError` is non-null the command string is syntactically malformed
95
+ * (e.g. an unterminated quote) and `commands` contains the raw input as a
96
+ * single opaque token so callers can surface the error without splitting
97
+ * unsafe fragments.
98
+ */
99
+ export interface ParseResult {
100
+ commands: string[]
101
+ parseError: UnterminatedQuote | null
102
+ }
103
+
104
+ function unterminatedQuoteMessage(quoteType: QuoteType, openIndex: number, command: string): string {
105
+ const labels: Record<QuoteType, string> = {
106
+ "posix-single": "single quote (')",
107
+ "ansi-c": "ANSI-C quote ($')",
108
+ double: 'double quote (")',
109
+ locale: 'locale quote ($")',
110
+ heredoc: "heredoc (<<)",
111
+ }
112
+ const snippetStart = Math.max(0, openIndex - 10)
113
+ const snippetEnd = Math.min(command.length, openIndex + 20)
114
+ const prefix = snippetStart > 0 ? "..." : ""
115
+ const suffix = snippetEnd < command.length ? "..." : ""
116
+ const excerpt = prefix + command.slice(snippetStart, snippetEnd).replace(/\r?\n/g, "\\n") + suffix
117
+ return `Malformed command: unterminated ${labels[quoteType]} at position ${openIndex} -- near: \`${excerpt}\`. `
118
+ }
119
+
120
+ /** A contiguous quoted region found at the top level of a command string. */
121
+ interface QuoteSpan {
122
+ /** Index of the first character of the opening delimiter (e.g. `$` for `$'...'`). */
123
+ start: number
124
+ /** Index one past the last character of the closing delimiter. */
125
+ end: number
126
+ /** The style of quoting. */
127
+ quoteType: QuoteType
128
+ }
129
+
130
+ /** Result returned by the single shared state-machine walk. */
131
+ interface ScanResult {
132
+ spans: QuoteSpan[]
133
+ unterminatedQuote: UnterminatedQuote | null
134
+ }
135
+
136
+ /**
137
+ * Parse a heredoc delimiter word starting at position `start` in `command`
138
+ * (unquoted / 'EOF' / "EOF" / \EOF — see the reference for the rules).
139
+ */
140
+ function parseHeredocDelimiter(command: string, start: number): { delimiter: string; endIndex: number } {
141
+ let i = start
142
+ let delimiter = ""
143
+
144
+ if (command[i] === "'") {
145
+ i++
146
+ while (i < command.length && command[i] !== "'" && command[i] !== "\n") {
147
+ delimiter += command[i++]
148
+ }
149
+ if (command[i] === "'") i++
150
+ } else if (command[i] === '"') {
151
+ i++
152
+ while (i < command.length && command[i] !== '"' && command[i] !== "\n") {
153
+ delimiter += command[i++]
154
+ }
155
+ if (command[i] === '"') i++
156
+ } else if (command[i] === "\\") {
157
+ i++
158
+ while (i < command.length && command[i] !== "\n" && command[i] !== " " && command[i] !== "\t") {
159
+ delimiter += command[i++]
160
+ }
161
+ } else {
162
+ while (i < command.length && command[i] !== "\n" && command[i] !== " " && command[i] !== "\t") {
163
+ delimiter += command[i++]
164
+ }
165
+ }
166
+
167
+ return { delimiter, endIndex: i }
168
+ }
169
+
170
+ /**
171
+ * Single shared state-machine walk used by `parseCommand`: identifies every
172
+ * top-level quoted region (outside other quotes and `#` comments) and reports
173
+ * the first unterminated one. Ported verbatim from the reference.
174
+ */
175
+ function scanTopLevelQuotes(command: string): ScanResult {
176
+ const spans: QuoteSpan[] = []
177
+ let i = 0
178
+
179
+ while (i < command.length) {
180
+ const char = command[i]
181
+
182
+ if (char === "\\") {
183
+ i += 2
184
+ continue
185
+ }
186
+
187
+ if (char === "#" && (i === 0 || /\s/.test(command[i - 1]))) {
188
+ while (i < command.length && command[i] !== "\n" && command[i] !== "\r") {
189
+ i++
190
+ }
191
+ continue
192
+ }
193
+
194
+ // Herestring (<<<): single-line stdin redirect — no body or terminator.
195
+ if (char === "<" && command[i + 1] === "<" && command[i + 2] === "<") {
196
+ i += 3
197
+ continue
198
+ }
199
+
200
+ // Heredoc opener: <<[-]? followed by an optional-quoted delimiter word.
201
+ if (char === "<" && command[i + 1] === "<") {
202
+ const start = i
203
+ i += 2
204
+ const stripTabs = command[i] === "-"
205
+ if (stripTabs) i++
206
+ while (i < command.length && (command[i] === " " || command[i] === "\t")) {
207
+ i++
208
+ }
209
+ const { delimiter, endIndex } = parseHeredocDelimiter(command, i)
210
+ i = endIndex
211
+ while (i < command.length && command[i] !== "\n") i++
212
+ if (i < command.length) i++
213
+ if (delimiter.length > 0) {
214
+ let found = false
215
+ while (i < command.length) {
216
+ const lineStart = i
217
+ while (i < command.length && command[i] !== "\n" && command[i] !== "\r") {
218
+ i++
219
+ }
220
+ const rawLine = command.slice(lineStart, i)
221
+ const line = stripTabs ? rawLine.replace(/^\t*/, "") : rawLine
222
+ if (line === delimiter) {
223
+ found = true
224
+ break
225
+ }
226
+ if (i < command.length) i++
227
+ }
228
+ if (!found) {
229
+ return {
230
+ spans,
231
+ unterminatedQuote: {
232
+ quoteType: "heredoc",
233
+ openIndex: start,
234
+ message: unterminatedQuoteMessage("heredoc", start, command),
235
+ },
236
+ }
237
+ }
238
+ }
239
+ spans.push({ start, end: i, quoteType: "heredoc" })
240
+ continue
241
+ }
242
+
243
+ // ANSI-C quoting: $'...', escape-aware.
244
+ if (char === "$" && command[i + 1] === "'") {
245
+ const start = i
246
+ i += 2
247
+ let closed = false
248
+ while (i < command.length) {
249
+ if (command[i] === "\\") {
250
+ i += 2
251
+ } else if (command[i] === "'") {
252
+ i++
253
+ closed = true
254
+ break
255
+ } else {
256
+ i++
257
+ }
258
+ }
259
+ if (!closed) {
260
+ return {
261
+ spans,
262
+ unterminatedQuote: {
263
+ quoteType: "ansi-c",
264
+ openIndex: start,
265
+ message: unterminatedQuoteMessage("ansi-c", start, command),
266
+ },
267
+ }
268
+ }
269
+ spans.push({ start, end: i, quoteType: "ansi-c" })
270
+ continue
271
+ }
272
+
273
+ // Locale quoting: $"...", escape-aware like double quotes.
274
+ if (char === "$" && command[i + 1] === '"') {
275
+ const start = i
276
+ i += 2
277
+ let closed = false
278
+ while (i < command.length) {
279
+ if (command[i] === "\\") {
280
+ i += 2
281
+ } else if (command[i] === '"') {
282
+ i++
283
+ closed = true
284
+ break
285
+ } else {
286
+ i++
287
+ }
288
+ }
289
+ if (!closed) {
290
+ return {
291
+ spans,
292
+ unterminatedQuote: {
293
+ quoteType: "locale",
294
+ openIndex: start,
295
+ message: unterminatedQuoteMessage("locale", start, command),
296
+ },
297
+ }
298
+ }
299
+ spans.push({ start, end: i, quoteType: "locale" })
300
+ continue
301
+ }
302
+
303
+ // POSIX single quote: fully opaque, ends at the next literal '.
304
+ if (char === "'") {
305
+ const start = i
306
+ i++
307
+ while (i < command.length && command[i] !== "'") {
308
+ i++
309
+ }
310
+ if (i >= command.length) {
311
+ return {
312
+ spans,
313
+ unterminatedQuote: {
314
+ quoteType: "posix-single",
315
+ openIndex: start,
316
+ message: unterminatedQuoteMessage("posix-single", start, command),
317
+ },
318
+ }
319
+ }
320
+ i++
321
+ spans.push({ start, end: i, quoteType: "posix-single" })
322
+ continue
323
+ }
324
+
325
+ // Double quote: escape-aware, ends at the next unescaped ".
326
+ if (char === '"') {
327
+ const start = i
328
+ i++
329
+ let closed = false
330
+ while (i < command.length) {
331
+ if (command[i] === "\\") {
332
+ i += 2
333
+ } else if (command[i] === '"') {
334
+ i++
335
+ closed = true
336
+ break
337
+ } else {
338
+ i++
339
+ }
340
+ }
341
+ if (!closed) {
342
+ return {
343
+ spans,
344
+ unterminatedQuote: {
345
+ quoteType: "double",
346
+ openIndex: start,
347
+ message: unterminatedQuoteMessage("double", start, command),
348
+ },
349
+ }
350
+ }
351
+ spans.push({ start, end: i, quoteType: "double" })
352
+ continue
353
+ }
354
+
355
+ i++
356
+ }
357
+
358
+ return { spans, unterminatedQuote: null }
359
+ }
360
+
361
+ /**
362
+ * Walk `command` and replace every top-level quoted region with a placeholder
363
+ * token. Returns the masked string and the array of original quoted substrings
364
+ * so callers can restore them later. Ported verbatim from the reference.
365
+ */
366
+ function maskTopLevelQuotes(command: string): { masked: string; quotes: string[] } {
367
+ const { spans, unterminatedQuote } = scanTopLevelQuotes(command)
368
+
369
+ const effectiveSpans: QuoteSpan[] =
370
+ unterminatedQuote !== null
371
+ ? [
372
+ ...spans,
373
+ { start: unterminatedQuote.openIndex, end: command.length, quoteType: unterminatedQuote.quoteType },
374
+ ]
375
+ : spans
376
+
377
+ const quotes: string[] = []
378
+ let result = ""
379
+ let pos = 0
380
+
381
+ for (const span of effectiveSpans) {
382
+ result += command.slice(pos, span.start)
383
+ quotes.push(command.slice(span.start, span.end))
384
+ result += `__TOPLEVEL_QUOTE_${quotes.length - 1}__`
385
+ pos = span.end
386
+ }
387
+
388
+ result += command.slice(pos)
389
+
390
+ return { masked: result, quotes }
391
+ }
392
+
393
+ /**
394
+ * Split a command string into individual sub-commands by chaining operators
395
+ * (&&, ||, ;, |, &) and unquoted newlines, preserving quoted strings (including
396
+ * multi-line quoted strings) as atomic units. Returns the sub-command list and
397
+ * an optional parse error for unterminated quotes/heredocs. Ported from the
398
+ * reference; the shell-quote tokenization step is replaced by an equivalent
399
+ * operator split (see the module header).
400
+ */
401
+ export function parseCommand(command: string): ParseResult {
402
+ if (!command?.trim()) {
403
+ return { commands: [], parseError: null }
404
+ }
405
+
406
+ const { unterminatedQuote } = scanTopLevelQuotes(command)
407
+
408
+ if (unterminatedQuote !== null) {
409
+ return { commands: [command], parseError: unterminatedQuote }
410
+ }
411
+
412
+ // Pre-escape literal __ sequences so they cannot collide with the internal
413
+ // placeholder tokens. \x00 (the null byte) cannot appear in a real shell
414
+ // command, so it is a safe sentinel; the post-unescape step reverses it.
415
+ const escapedCommand = command.replace(/__/g, "\x00")
416
+
417
+ const { masked, quotes: topLevelQuotes } = maskTopLevelQuotes(escapedCommand)
418
+
419
+ const lines = masked.split(/\r\n|\r|\n/)
420
+ const allCommands: string[] = []
421
+
422
+ for (const line of lines) {
423
+ if (!line.trim()) {
424
+ continue
425
+ }
426
+
427
+ const restoredLine = line.replace(/__TOPLEVEL_QUOTE_(\d+)__/g, (_, i) => topLevelQuotes[parseInt(i)])
428
+
429
+ // A restored line with embedded newlines means a top-level quote (e.g. a
430
+ // heredoc) spanned multiple lines — the whole string is one atomic
431
+ // command; re-splitting would break on the embedded newlines/<<.
432
+ if (restoredLine.includes("\n")) {
433
+ allCommands.push(restoredLine)
434
+ continue
435
+ }
436
+
437
+ allCommands.push(...parseCommandLine(restoredLine))
438
+ }
439
+
440
+ return { commands: allCommands.map((cmd) => cmd.split("\x00").join("__")), parseError: null }
441
+ }
442
+
443
+ /**
444
+ * Parse a single line of commands into sub-commands. The masking pipeline is
445
+ * identical to the reference; the final shell-quote `parse()` call is replaced
446
+ * by a split on chain operators + top-level subshell placeholders (see the
447
+ * module header for why this is equivalent).
448
+ */
449
+ function parseCommandLine(command: string): string[] {
450
+ if (!command?.trim()) return []
451
+
452
+ const redirections: string[] = []
453
+ const subshells: string[] = []
454
+ const quotes: string[] = []
455
+ const singleQuotes: string[] = []
456
+ const arithmeticExpressions: string[] = []
457
+ const variables: string[] = []
458
+ const parameterExpansions: string[] = []
459
+
460
+ let processedCommand = command.replace(/\d*>&\d*/g, (match) => {
461
+ redirections.push(match)
462
+ return `__REDIR_${redirections.length - 1}__`
463
+ })
464
+
465
+ processedCommand = processedCommand.replace(/\$\(\([^)]*(?:\)[^)]*)*\)\)/g, (match) => {
466
+ arithmeticExpressions.push(match)
467
+ return `__ARITH_${arithmeticExpressions.length - 1}__`
468
+ })
469
+
470
+ processedCommand = processedCommand.replace(/\$\[[^\]]*\]/g, (match) => {
471
+ arithmeticExpressions.push(match)
472
+ return `__ARITH_${arithmeticExpressions.length - 1}__`
473
+ })
474
+
475
+ processedCommand = processedCommand.replace(/\$\{[^}]+\}/g, (match) => {
476
+ parameterExpansions.push(match)
477
+ return `__PARAM_${parameterExpansions.length - 1}__`
478
+ })
479
+
480
+ processedCommand = processedCommand.replace(/[<>]\(([^)]+)\)/g, (_, inner) => {
481
+ subshells.push(inner.trim())
482
+ return `__SUBSH_${subshells.length - 1}__`
483
+ })
484
+
485
+ // Locale quoting: $"...". Must run before variable masking so the leading $
486
+ // is captured as part of the quoted unit (see reference).
487
+ processedCommand = processedCommand.replace(/\$"(?:[^"\\]|\\.)*"/g, (match) => {
488
+ quotes.push(match)
489
+ return `__QUOTE_${quotes.length - 1}__`
490
+ })
491
+
492
+ // ANSI-C quoting: $'...'. Same ordering rationale as locale quoting.
493
+ processedCommand = processedCommand.replace(/\$'(?:[^'\\]|\\.)*'/g, (match) => {
494
+ singleQuotes.push(match)
495
+ return `__SQUOTE_${singleQuotes.length - 1}__`
496
+ })
497
+
498
+ // Simple variable references: $varname.
499
+ processedCommand = processedCommand.replace(/\$[a-zA-Z_][a-zA-Z0-9_]*/g, (match) => {
500
+ variables.push(match)
501
+ return `__VAR_${variables.length - 1}__`
502
+ })
503
+
504
+ // Special bash variables: $?, $!, $#, $$, $@, $*, $-, $0-$9.
505
+ processedCommand = processedCommand.replace(/\$[?!#$@*\-0-9]/g, (match) => {
506
+ variables.push(match)
507
+ return `__VAR_${variables.length - 1}__`
508
+ })
509
+
510
+ // Subshell commands $() and back-ticks.
511
+ processedCommand = processedCommand
512
+ .replace(/\$\((.*?)\)/g, (_, inner) => {
513
+ subshells.push(inner.trim())
514
+ return `__SUBSH_${subshells.length - 1}__`
515
+ })
516
+ .replace(/`(.*?)`/g, (_, inner) => {
517
+ subshells.push(inner.trim())
518
+ return `__SUBSH_${subshells.length - 1}__`
519
+ })
520
+
521
+ // Mask quoted strings (single + double) so their contents — including
522
+ // operators like &&, |, ; and embedded newlines — are not treated as
523
+ // command separators. Single quotes are fully opaque; double quotes are
524
+ // escape-aware. Matches the reference's alternation exactly.
525
+ processedCommand = processedCommand.replace(/'[^']*'|"(?:[^"\\]|\\.)*"/g, (match) => {
526
+ if (match.startsWith("'")) {
527
+ singleQuotes.push(match)
528
+ return `__SQUOTE_${singleQuotes.length - 1}__`
529
+ }
530
+ quotes.push(match)
531
+ return `__QUOTE_${quotes.length - 1}__`
532
+ })
533
+
534
+ // ── Replace the reference's `parse(processedCommand)` (shell-quote) with an
535
+ // equivalent self-contained token walk. Everything that could contain an
536
+ // operator is already masked above, so the only operator-like tokens left
537
+ // are the chain operators and __SUBSH_ placeholders. Tokenizing on
538
+ // whitespace collapses runs exactly like shell-quote's word tokens; chain
539
+ // operators split commands; and a subshell placeholder promotes its content
540
+ // to its own sub-command (mirroring the reference's token walk, including
541
+ // its unanchored __SUBSH_ match). Operators are split out even when attached
542
+ // to a word (`hi;`, `a||b`) — exactly like shell-quote's tokenizer — and
543
+ // whitespace runs collapse to single separators.
544
+ const tokens = processedCommand
545
+ .split(/(\s+|&&|\|\||;|\||&)/)
546
+ .filter((t) => t.length > 0 && !/^\s+$/.test(t))
547
+ const commands: string[] = []
548
+ let current: string[] = []
549
+
550
+ for (const token of tokens) {
551
+ if (token === "&&" || token === "||" || token === ";" || token === "|" || token === "&") {
552
+ if (current.length > 0) {
553
+ commands.push(current.join(" "))
554
+ current = []
555
+ }
556
+ } else {
557
+ const subshellMatch = token.match(/__SUBSH_(\d+)__/)
558
+ if (subshellMatch) {
559
+ if (current.length > 0) {
560
+ commands.push(current.join(" "))
561
+ current = []
562
+ }
563
+ commands.push(subshells[parseInt(subshellMatch[1])])
564
+ } else {
565
+ current.push(token)
566
+ }
567
+ }
568
+ }
569
+ if (current.length > 0) {
570
+ commands.push(current.join(" "))
571
+ }
572
+
573
+ return commands.map((cmd) =>
574
+ restorePlaceholders(
575
+ cmd,
576
+ quotes,
577
+ singleQuotes,
578
+ redirections,
579
+ arithmeticExpressions,
580
+ parameterExpansions,
581
+ variables,
582
+ subshells,
583
+ ),
584
+ )
585
+ }
586
+
587
+ /** Helper function to restore placeholders in a command string (reference). */
588
+ function restorePlaceholders(
589
+ command: string,
590
+ quotes: string[],
591
+ singleQuotes: string[],
592
+ redirections: string[],
593
+ arithmeticExpressions: string[],
594
+ parameterExpansions: string[],
595
+ variables: string[],
596
+ subshells: string[],
597
+ ): string {
598
+ let result = command
599
+ result = result.replace(/__QUOTE_(\d+)__/g, (_, i) => quotes[parseInt(i)])
600
+ result = result.replace(/__SQUOTE_(\d+)__/g, (_, i) => singleQuotes[parseInt(i)])
601
+ result = result.replace(/__REDIR_(\d+)__/g, (_, i) => redirections[parseInt(i)])
602
+ result = result.replace(/__ARITH_(\d+)__/g, (_, i) => arithmeticExpressions[parseInt(i)])
603
+ result = result.replace(/__PARAM_(\d+)__/g, (_, i) => parameterExpansions[parseInt(i)])
604
+ result = result.replace(/__VAR_(\d+)__/g, (_, i) => variables[parseInt(i)])
605
+ result = result.replace(/__SUBSH_(\d+)__/g, (_, i) => subshells[parseInt(i)])
606
+ return result
607
+ }
608
+
609
+ // ─── redirect-target extraction (shell redirects to outside the workspace) ──
610
+
611
+ /**
612
+ * A redirection found in a command string: the raw `>`-style operator
613
+ * (including an optional numeric fd prefix and `&` for `&>`), the index it
614
+ * starts at, and the word that follows it (the redirect target). Quoted words
615
+ * (e.g. `> "out file.txt"`) arrive with their quotes intact; callers strip
616
+ * them via splitCommandWords, which also removes backslash escapes.
617
+ */
618
+ export interface RedirectTarget {
619
+ operator: string
620
+ /** Index in the ORIGINAL command string where the operator starts. */
621
+ index: number
622
+ /** The raw word following the operator (quote-stripped by splitCommandWords). */
623
+ word: string
624
+ }
625
+
626
+ /**
627
+ * Extract the target words of every output redirection in a command string.
628
+ *
629
+ * Recognized operators: `>`, `>>`, `>|` (noclobber), `2>`, `2>>`, `2>|`,
630
+ * `&>`, `&>>`, `&>|` — the alternation is ordered longest-first so `>>` wins
631
+ * over `>`. `<`/`<<`/`<<<` input redirects are deliberately NOT matched —
632
+ * they read from a path instead of writing to it, so they cannot smuggle an
633
+ * outside-workspace WRITE. `2>&1`/`3>&2` fd duplication is not a path
634
+ * redirect: the `&` immediately after the `>` terminates the word, leaving
635
+ * no target, so it is skipped (see parseCommandLine, which masks the same
636
+ * shape).
637
+ *
638
+ * A target word is a shell WORD: unquoted characters up to the next
639
+ * metacharacter (`;`, `|`, `&`, `<`, `>`, whitespace) or a quote/escape that
640
+ * stays part of the word. When the redirect is immediately followed by a
641
+ * metacharacter (`echo hi >;ls`, `echo hi >`), no word exists — the shell
642
+ * errors on the missing operand and no file is created, so nothing is
643
+ * emitted. A quoted target (`> "out file.txt"`, `> '/tmp/x y'`) is emitted
644
+ * with its quotes intact; callers strip them via splitCommandWords.
645
+ */
646
+ export function redirectTargets(command: string): RedirectTarget[] {
647
+ const targets: RedirectTarget[] = []
648
+ // Word chars: anything but unquoted shell metacharacters/quotes, plus
649
+ // quoted regions (which may contain metacharacters) and backslash escapes.
650
+ const re =
651
+ /(?:\d*&>>|\d*&>\||\d*&>|\d*>>\||\d*>\||\d*>>|\d*>)(?:\s*)(?:[^\s;|&<>()'"]|"[^"]*"|'[^']*'|\\.)*/g
652
+ let match: RegExpExecArray | null
653
+ while ((match = re.exec(command)) !== null) {
654
+ const raw = match[0]
655
+ if (raw === undefined) {
656
+ continue
657
+ }
658
+ const operatorMatch = raw.match(/^(\d*&>>|\d*&>\||\d*&>|\d*>>\||\d*>\||\d*>>|\d*>)/)
659
+ if (operatorMatch === null) {
660
+ continue
661
+ }
662
+ const operator = operatorMatch[1] ?? raw
663
+ const wordText = raw.slice(operator.length).trimStart()
664
+ if (wordText === "") {
665
+ continue
666
+ }
667
+ const words = splitCommandWords(wordText)
668
+ if (words.length === 0) {
669
+ continue
670
+ }
671
+ targets.push({ operator, index: match.index, word: words[0] })
672
+ }
673
+ return targets
674
+ }
675
+
676
+ /**
677
+ * Resolve a redirect target word to an absolute path, anchored on the command's
678
+ * working directory. Applies the same `~`/`$VAR` expansion and `/*`-glob
679
+ * truncation as the central-store check (store-protection.ts's
680
+ * resolveCommandTarget), so `/tmp` and `/tmp/x` behave identically to `~` and
681
+ * `$TMPDIR` — a relative target like `> out.txt` anchors inside the workspace.
682
+ *
683
+ * A word the shell could expand (e.g. `$TMPDIR/x.txt`) resolves through the
684
+ * SAME expansions the central-store check applies (expandHome/expandEnv), so
685
+ * `> $TMPDIR/x` with TMPDIR set to /tmp refuses like the literal `/tmp/x` it
686
+ * expands to. An UNKNOWN variable stays literal — the shell would expand it
687
+ * to an empty word (the redirect would then hit a missing-operand error),
688
+ * which cannot write outside the workspace, so resolving it literally is the
689
+ * safe direction.
690
+ */
691
+ export function resolveRedirectTarget(target: string, workspaceRoot: string): string {
692
+ const expanded = expandEnv(expandHome(target))
693
+ return path.resolve(workspaceRoot, expanded)
694
+ }
695
+
696
+ /**
697
+ * True when the resolved redirect target escapes the workspace root — the
698
+ * same lexical containment rule `resolveWithinWorkspace` uses for the file
699
+ * tools (path.resolve prefix check; no symlink following, which matches the
700
+ * store-protection check's documented boundary).
701
+ *
702
+ * `/dev/null` and `/dev/fd/N` are deliberately NOT escapes: they are
703
+ * device/fd-backed targets with no persistent state and cannot leak data
704
+ * outside the workspace, unlike a file redirect. The agent routinely uses
705
+ * `2>/dev/null` to silence stderr; refusing it makes every otherwise-fine
706
+ * command fail with a redirect-escape error.
707
+ */
708
+ export function isOutsideWorkspace(root: string, target: string): boolean {
709
+ if (target === "/dev/null" || /^\/dev\/fd\/\d+$/.test(target)) {
710
+ return false
711
+ }
712
+ const rootAbs = path.resolve(root)
713
+ const t = path.resolve(target)
714
+ if (t === rootAbs) {
715
+ return false
716
+ }
717
+ return !t.startsWith(rootAbs.endsWith(path.sep) ? rootAbs : rootAbs + path.sep)
718
+ }
719
+
720
+ /**
721
+ * Check ONE sub-command (already split by parseCommand) for an output
722
+ * redirect whose resolved target escapes the workspace root. Returns the
723
+ * first offending redirect, or `null` when every redirect target stays inside
724
+ * the workspace. `workspaceRoot` anchors relative targets (defaults to
725
+ * process.cwd()).
726
+ */
727
+ export function checkRedirectEscape(subCommand: string, workspaceRoot?: string): RedirectTarget | null {
728
+ const root = path.resolve(workspaceRoot ?? process.cwd())
729
+ for (const target of redirectTargets(subCommand)) {
730
+ if (isOutsideWorkspace(root, resolveRedirectTarget(target.word, root))) {
731
+ return target
732
+ }
733
+ }
734
+ return null
735
+ }
736
+
737
+ /** Human-readable operator for the model-facing refusal message. */
738
+ export function describeRedirect(target: RedirectTarget): string {
739
+ const compact =
740
+ target.operator.includes("&") || target.operator.includes("|") || /^\d*>>/.test(target.operator)
741
+ return compact ? `${target.operator}${target.word}` : `${target.operator} ${target.word}`
742
+ }
743
+
744
+ // ─── allow/deny logic port (zoo-code/src/core/auto-approval/commands.ts) ────
745
+
746
+ /**
747
+ * Detect dangerous parameter substitutions that could lead to command
748
+ * execution. These patterns are never auto-approved (upstream) and are always
749
+ * refused in the headless harness. Ported verbatim from the reference.
750
+ */
751
+ export function containsDangerousSubstitution(source: string): boolean {
752
+ // ${var@P} prompt-string expansion, ${var@Q} quote removal, ${var@E} escape
753
+ // expansion, ${var@A} assignment statement, ${var@a} attribute flags.
754
+ const dangerousParameterExpansion = /\$\{[^}]*@[PQEAa][^}]*\}/.test(source)
755
+
756
+ // ${var=value} / ${var:=value} / ${var+value} / ${var:-value} / ${var:+value}
757
+ // / ${var:?value} with octal / hex / unicode escapes that can embed commands.
758
+ const parameterAssignmentWithEscapes =
759
+ /\$\{[^}]*[=+\-?][^}]*\\[0-7]{3}[^}]*\}/.test(source) ||
760
+ /\$\{[^}]*[=+\-?][^}]*\\x[0-9a-fA-F]{2}[^}]*\}/.test(source) ||
761
+ /\$\{[^}]*[=+\-?][^}]*\\u[0-9a-fA-F]{4}[^}]*\}/.test(source)
762
+
763
+ // ${!var} indirect expansion.
764
+ const indirectExpansion = /\$\{![^}]+\}/.test(source)
765
+
766
+ // <<<$(...) or <<<`...` here-strings with command substitution.
767
+ const hereStringWithSubstitution = /<<<\s*(\$\(|`)/.test(source)
768
+
769
+ // =(...) zsh process substitution that executes commands.
770
+ const zshProcessSubstitution = /(?:(?<=^)|(?<=[\s;|&(<]))=\([^)]+\)/.test(source)
771
+
772
+ // zsh glob qualifiers with code execution, e.g. *(e:whoami:), ?(e:rm -rf /:).
773
+ const zshGlobQualifier = /[*?+@!]\(e:[^:]+:\)/.test(source)
774
+
775
+ return (
776
+ dangerousParameterExpansion ||
777
+ parameterAssignmentWithEscapes ||
778
+ indirectExpansion ||
779
+ hereStringWithSubstitution ||
780
+ zshProcessSubstitution ||
781
+ zshGlobQualifier
782
+ )
783
+ }
784
+
785
+ /**
786
+ * Find the longest matching prefix from a list of prefixes for a given
787
+ * command (case-insensitive, startsWith-based). Wildcard "*" matches any
788
+ * command but is treated as length 1 for comparison. Ported verbatim.
789
+ */
790
+ export function findLongestPrefixMatch(command: string, prefixes: string[]): string | null {
791
+ if (!command || !prefixes?.length) {
792
+ return null
793
+ }
794
+
795
+ const trimmedCommand = command.trim().toLowerCase()
796
+ let longestMatch: string | null = null
797
+
798
+ for (const prefix of prefixes) {
799
+ const lowerPrefix = prefix.toLowerCase()
800
+ if (lowerPrefix === "*" || trimmedCommand.startsWith(lowerPrefix)) {
801
+ if (!longestMatch || lowerPrefix.length > longestMatch.length) {
802
+ longestMatch = lowerPrefix
803
+ }
804
+ }
805
+ }
806
+
807
+ return longestMatch
808
+ }
809
+
810
+ /** Command approval decision types (upstream). */
811
+ export type CommandDecision = "auto_approve" | "auto_deny" | "ask_user" | "malformed_command"
812
+
813
+ /**
814
+ * Decision for a single command using the longest-prefix-match rule (ported
815
+ * verbatim). Both-list conflict: longer (more specific) match wins; a TIE goes
816
+ * to deny. No match at all → "ask_user".
817
+ */
818
+ export function getSingleCommandDecision(
819
+ command: string,
820
+ allowedCommands: string[],
821
+ deniedCommands?: string[],
822
+ ): CommandDecision {
823
+ if (!command) return "auto_approve"
824
+
825
+ const longestAllowedMatch = findLongestPrefixMatch(command, allowedCommands || [])
826
+ const longestDeniedMatch = findLongestPrefixMatch(command, deniedCommands || [])
827
+
828
+ if (longestAllowedMatch && !longestDeniedMatch) {
829
+ return "auto_approve"
830
+ }
831
+ if (!longestAllowedMatch && longestDeniedMatch) {
832
+ return "auto_deny"
833
+ }
834
+ if (longestAllowedMatch && longestDeniedMatch) {
835
+ return longestAllowedMatch.length > longestDeniedMatch.length ? "auto_approve" : "auto_deny"
836
+ }
837
+ return "ask_user"
838
+ }
839
+
840
+ /**
841
+ * Unified command validation implementing the upstream decision flow (ported
842
+ * verbatim): any denied sub-command denies the whole command; dangerous
843
+ * substitutions are never auto-approved (→ "ask_user"); malformed commands
844
+ * (unterminated quotes) are rejected. See the module header for the verified
845
+ * precedence.
846
+ */
847
+ export function getCommandDecision(
848
+ command: string,
849
+ allowedCommands: string[],
850
+ deniedCommands?: string[],
851
+ ): CommandDecision {
852
+ if (!command?.trim()) {
853
+ return "auto_approve"
854
+ }
855
+
856
+ const { commands: subCommands, parseError } = parseCommand(command)
857
+
858
+ if (parseError !== null) {
859
+ return "malformed_command"
860
+ }
861
+
862
+ const decisions: CommandDecision[] = subCommands.map((cmd) => {
863
+ const cmdWithoutRedirection = cmd.replace(/\d*>&\d*/, "").trim()
864
+ return getSingleCommandDecision(cmdWithoutRedirection, allowedCommands, deniedCommands)
865
+ })
866
+
867
+ // Any denied sub-command denies the whole compound command (deny wins).
868
+ if (decisions.includes("auto_deny")) {
869
+ return "auto_deny"
870
+ }
871
+
872
+ if (containsDangerousSubstitution(command)) {
873
+ return "ask_user"
874
+ }
875
+
876
+ if (decisions.every((decision) => decision === "auto_approve")) {
877
+ return "auto_approve"
878
+ }
879
+
880
+ return "ask_user"
881
+ }
882
+
883
+ // ─── headless decision layer (our mapping, on top of the port) ──────────────
884
+
885
+ export type PermissionDecision = "allow" | "deny"
886
+
887
+ /** Details of a command refusal, for a clear model-facing error message. */
888
+ export interface CommandRefusal {
889
+ /**
890
+ * - "dangerous": contains a dangerous shell substitution — always blocked,
891
+ * not configurable (upstream never auto-approves these).
892
+ * - "malformed": shell syntax error (unterminated quote/heredoc).
893
+ * - "redirect_escape": an output redirect (`>`, `>>`, `2>`, `&>`) whose
894
+ * resolved target escapes the workspace root — always blocked, NOT
895
+ * configurable, closing the execute_command hole where shell redirects
896
+ * could write outside the workspace (e.g. `/tmp`) even though every
897
+ * file tool rejects those paths (issue #122).
898
+ * - "denied": matched the deny-list (deny wins over allow).
899
+ * - "not_allowed": allow-list configured but this sub-command matches
900
+ * neither list; a headless harness has no human to ask, so it is denied.
901
+ * - "protected_store": recursive delete targeting the shared central store
902
+ * or a parent of it — always blocked, NOT configurable (see
903
+ * src/permissions/store-protection.ts).
904
+ */
905
+ kind: "dangerous" | "malformed" | "redirect_escape" | "denied" | "not_allowed" | "protected_store"
906
+ /** The sub-command that triggered the refusal (denied/not_allowed/protected_store/redirect_escape). */
907
+ subCommand?: string
908
+ /** The deny-list pattern that matched (denied). */
909
+ pattern?: string
910
+ /** The parse error (malformed). */
911
+ parseError?: UnterminatedQuote
912
+ /** The resolved target that matched the store (protected_store). */
913
+ target?: string
914
+ /** The protected central store root (protected_store). */
915
+ storeRoot?: string
916
+ /** The redirect that escaped the workspace (redirect_escape). */
917
+ redirect?: RedirectTarget
918
+ }
919
+
920
+ /**
921
+ * Check a command against the resolved permissions. Returns a refusal
922
+ * descriptor when the command must not run, or `null` when it is allowed.
923
+ *
924
+ * Order (per the spec + upstream behavior):
925
+ * 1. Dangerous substitution → unconditional refuse (not configurable).
926
+ * 2. Malformed command (unterminated quote/heredoc) → refuse.
927
+ * 3. Always-applied redirect-escape guard: an output redirect (`>`, `>>`,
928
+ * `2>`, `&>`) whose target escapes the workspace root — e.g. `> /tmp/x`,
929
+ * `> $HOME/out`, `2> ../outside.log` — is refused even with an empty
930
+ * allow/deny config, and is NOT overridable. This closes the documented
931
+ * execute_command hole (issue #122): every file tool hard-rejects
932
+ * outside-workspace paths via resolveWithinWorkspace, but a shell redirect
933
+ * previously slipped through the command-string allow/deny gate and wrote
934
+ * to `/tmp`. Runs before parsing so it also catches redirects embedded in
935
+ * unparseable fragments (e.g. a heredoc body's own `> /tmp` line).
936
+ * 4. Each sub-command from `parseCommand` (so `echo hi && rm -rf /` is checked
937
+ * per sub-command, not as one opaque string): FIRST the central-store
938
+ * protection (recursive delete targeting the shared store or a parent of it
939
+ * — always refused, even with empty allow/deny config, and NOT overridable
940
+ * by any permissions config), THEN the redirect-escape guard per
941
+ * sub-command, then the deny-list, then the allow-list, with upstream's
942
+ * longest-prefix-match precedence.
943
+ *
944
+ * `options.workspaceRoot` anchors relative command targets for the
945
+ * central-store and redirect-escape checks (the executor passes the command's
946
+ * resolved `cwd`; default: process.cwd()). It has no effect on the
947
+ * allow/deny lists.
948
+ */
949
+ export function checkCommand(
950
+ command: string,
951
+ allowedCommands: string[],
952
+ deniedCommands: string[],
953
+ options?: { workspaceRoot?: string },
954
+ ): CommandRefusal | null {
955
+ if (!command?.trim()) {
956
+ return null
957
+ }
958
+
959
+ if (containsDangerousSubstitution(command)) {
960
+ return { kind: "dangerous" }
961
+ }
962
+
963
+ // Pre-parse redirect-escape scan: redirectTargets walks the raw command
964
+ // (heredoc bodies included), so it still catches redirects that parseCommand
965
+ // would classify as malformed (unterminated quote/heredoc).
966
+ const redirectEscape = checkRedirectEscape(command, options?.workspaceRoot)
967
+ if (redirectEscape !== null) {
968
+ return { kind: "redirect_escape", subCommand: command, redirect: redirectEscape }
969
+ }
970
+
971
+ const { commands: subCommands, parseError } = parseCommand(command)
972
+ if (parseError !== null) {
973
+ return { kind: "malformed", parseError, subCommand: command }
974
+ }
975
+
976
+ // Track a leading `cd <dir> && ...` chain's effective cwd across
977
+ // sub-commands (SEC-6): `cd /tmp && rm -rf x` must resolve `x` against
978
+ // /tmp, not the pre-cd workspace root, or a relative-path rm evades the
979
+ // central-store check. Only a plain `cd <path>` sub-command updates the
980
+ // tracked cwd (best-effort — `cd` inside a subshell, via a variable, or
981
+ // via `pushd`/`popd` is not tracked; see store-protection.ts header).
982
+ let effectiveCwd = path.resolve(options?.workspaceRoot ?? process.cwd())
983
+
984
+ for (const sub of subCommands) {
985
+ const cmd = sub.replace(/\d*>&\d*/, "").trim()
986
+ if (!cmd) {
987
+ continue
988
+ }
989
+ // Always-applied central-store protection: runs before (and
990
+ // independently of) the allow/deny lists, so the empty-allow-list
991
+ // default-ALLOW branch can never bypass it and no permissions.json
992
+ // entry can override it.
993
+ const storeRefusal = checkCentralStoreDestruction(cmd, effectiveCwd)
994
+ if (storeRefusal !== null) {
995
+ return {
996
+ kind: "protected_store",
997
+ subCommand: cmd,
998
+ target: storeRefusal.target,
999
+ storeRoot: storeRefusal.storeRoot,
1000
+ }
1001
+ }
1002
+ // Per-sub-command redirect-escape guard (the pre-parse scan above is
1003
+ // the same check on the whole command; this names the offending
1004
+ // sub-command for the refusal message). Anchored to the tracked
1005
+ // cd-chain cwd for the same reason as the store-destruction check
1006
+ // above — a relative redirect after `cd /tmp && ...` must resolve
1007
+ // against /tmp, not the pre-cd workspace root.
1008
+ const subRedirect = checkRedirectEscape(cmd, effectiveCwd)
1009
+ if (subRedirect !== null) {
1010
+ return { kind: "redirect_escape", subCommand: cmd, redirect: subRedirect }
1011
+ }
1012
+ const refusal = checkSingleCommand(cmd, allowedCommands, deniedCommands)
1013
+ if (refusal !== null) {
1014
+ return refusal
1015
+ }
1016
+ const cdTarget = matchLeadingCd(cmd)
1017
+ if (cdTarget !== null) {
1018
+ effectiveCwd = path.resolve(effectiveCwd, expandEnv(expandHome(cdTarget)))
1019
+ }
1020
+ }
1021
+
1022
+ return null
1023
+ }
1024
+
1025
+ /**
1026
+ * When `cmd` is a plain `cd <path>` (optionally quoted, no other words), return
1027
+ * the raw path argument; otherwise null. Deliberately narrow — `cd` combined
1028
+ * with anything else on the same sub-command word (e.g. `cd /tmp; ls`, already
1029
+ * split by parseCommand into separate sub-commands) or with no argument
1030
+ * (`cd` alone, which goes to $HOME) is not tracked.
1031
+ */
1032
+ function matchLeadingCd(cmd: string): string | null {
1033
+ const m = cmd.match(/^cd\s+(.+)$/)
1034
+ if (!m) {
1035
+ return null
1036
+ }
1037
+ let arg = m[1].trim()
1038
+ if ((arg.startsWith('"') && arg.endsWith('"')) || (arg.startsWith("'") && arg.endsWith("'"))) {
1039
+ arg = arg.slice(1, -1)
1040
+ }
1041
+ return arg || null
1042
+ }
1043
+
1044
+ function checkSingleCommand(
1045
+ command: string,
1046
+ allowedCommands: string[],
1047
+ deniedCommands: string[],
1048
+ ): CommandRefusal | null {
1049
+ // EMPTY allow-list = default-ALLOW (deliberate, documented decision): the
1050
+ // operator has not opted into allow-list gating, so only the deny-list
1051
+ // applies. This preserves today's behavior for sessions that configure
1052
+ // nothing while still making deniedCommands always apply.
1053
+ if (allowedCommands.length === 0) {
1054
+ const deniedPattern = findLongestPrefixMatch(command, deniedCommands)
1055
+ if (deniedPattern !== null) {
1056
+ return { kind: "denied", subCommand: command, pattern: deniedPattern }
1057
+ }
1058
+ return null
1059
+ }
1060
+
1061
+ // Allow-list configured: mirror the upstream longest-prefix-match decision.
1062
+ const decision = getSingleCommandDecision(command, allowedCommands, deniedCommands)
1063
+ if (decision === "auto_deny") {
1064
+ const pattern = findLongestPrefixMatch(command, deniedCommands) ?? findLongestPrefixMatch(command, allowedCommands)
1065
+ return { kind: "denied", subCommand: command, pattern: pattern ?? "(deny-list)" }
1066
+ }
1067
+ if (decision === "ask_user") {
1068
+ // Upstream would prompt a human here; a headless harness has none, so
1069
+ // anything not explicitly allowed is refused.
1070
+ return { kind: "not_allowed", subCommand: command }
1071
+ }
1072
+ return null // auto_approve
1073
+ }
1074
+
1075
+ /** Allow/deny for a command against the resolved lists (headless semantics). */
1076
+ export function decideCommand(
1077
+ command: string,
1078
+ allowedCommands: string[],
1079
+ deniedCommands: string[],
1080
+ options?: { workspaceRoot?: string },
1081
+ ): PermissionDecision {
1082
+ return checkCommand(command, allowedCommands, deniedCommands, options) === null ? "allow" : "deny"
1083
+ }