@namzu/sdk 51.1.0 → 52.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 (323) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/dist/agents/QueryAgent.d.ts.map +1 -1
  3. package/dist/agents/QueryAgent.js +2 -0
  4. package/dist/agents/QueryAgent.js.map +1 -1
  5. package/dist/agents/SupervisorAgent.d.ts +1 -1
  6. package/dist/agents/SupervisorAgent.d.ts.map +1 -1
  7. package/dist/agents/SupervisorAgent.js.map +1 -1
  8. package/dist/agents/examples/SupervisorAgent.d.ts +42 -0
  9. package/dist/agents/examples/SupervisorAgent.d.ts.map +1 -1
  10. package/dist/agents/examples/SupervisorAgent.js +80 -4
  11. package/dist/agents/examples/SupervisorAgent.js.map +1 -1
  12. package/dist/agents/index.d.ts +1 -1
  13. package/dist/agents/index.d.ts.map +1 -1
  14. package/dist/agents/index.js.map +1 -1
  15. package/dist/authorization/protected-paths.d.ts +36 -0
  16. package/dist/authorization/protected-paths.d.ts.map +1 -0
  17. package/dist/authorization/protected-paths.js +107 -0
  18. package/dist/authorization/protected-paths.js.map +1 -0
  19. package/dist/authorization/readonly-commands.d.ts +39 -0
  20. package/dist/authorization/readonly-commands.d.ts.map +1 -0
  21. package/dist/authorization/readonly-commands.js +797 -0
  22. package/dist/authorization/readonly-commands.js.map +1 -0
  23. package/dist/authorization/readonly-flags.d.ts +19 -0
  24. package/dist/authorization/readonly-flags.d.ts.map +1 -0
  25. package/dist/authorization/readonly-flags.js +178 -0
  26. package/dist/authorization/readonly-flags.js.map +1 -0
  27. package/dist/authorization/removal.d.ts +52 -0
  28. package/dist/authorization/removal.d.ts.map +1 -0
  29. package/dist/authorization/removal.js +283 -0
  30. package/dist/authorization/removal.js.map +1 -0
  31. package/dist/authorization/shell-paths.d.ts +66 -0
  32. package/dist/authorization/shell-paths.d.ts.map +1 -0
  33. package/dist/authorization/shell-paths.js +697 -0
  34. package/dist/authorization/shell-paths.js.map +1 -0
  35. package/dist/bridge/acp/update.d.ts.map +1 -1
  36. package/dist/bridge/acp/update.js +1 -0
  37. package/dist/bridge/acp/update.js.map +1 -1
  38. package/dist/compaction/context-window.d.ts +24 -0
  39. package/dist/compaction/context-window.d.ts.map +1 -1
  40. package/dist/compaction/context-window.js +34 -0
  41. package/dist/compaction/context-window.js.map +1 -1
  42. package/dist/compaction/manual.d.ts.map +1 -1
  43. package/dist/compaction/manual.js +10 -2
  44. package/dist/compaction/manual.js.map +1 -1
  45. package/dist/compaction/plan.d.ts +4 -0
  46. package/dist/compaction/plan.d.ts.map +1 -1
  47. package/dist/compaction/plan.js +6 -2
  48. package/dist/compaction/plan.js.map +1 -1
  49. package/dist/compaction/verifier.d.ts +11 -1
  50. package/dist/compaction/verifier.d.ts.map +1 -1
  51. package/dist/compaction/verifier.js +19 -7
  52. package/dist/compaction/verifier.js.map +1 -1
  53. package/dist/config/runtime.d.ts +55 -7
  54. package/dist/config/runtime.d.ts.map +1 -1
  55. package/dist/config/runtime.js +23 -2
  56. package/dist/config/runtime.js.map +1 -1
  57. package/dist/connector/web/guarded-fetch.d.ts.map +1 -1
  58. package/dist/connector/web/guarded-fetch.js +24 -0
  59. package/dist/connector/web/guarded-fetch.js.map +1 -1
  60. package/dist/connector/web/index.d.ts +1 -1
  61. package/dist/connector/web/index.d.ts.map +1 -1
  62. package/dist/connector/web/page.d.ts +59 -0
  63. package/dist/connector/web/page.d.ts.map +1 -0
  64. package/dist/connector/web/page.js +112 -0
  65. package/dist/connector/web/page.js.map +1 -0
  66. package/dist/connector/web/types.d.ts +47 -0
  67. package/dist/connector/web/types.d.ts.map +1 -1
  68. package/dist/connector/web/types.js.map +1 -1
  69. package/dist/constants/continuation.d.ts +8 -0
  70. package/dist/constants/continuation.d.ts.map +1 -1
  71. package/dist/constants/continuation.js +8 -0
  72. package/dist/constants/continuation.js.map +1 -1
  73. package/dist/constants/plugin/index.d.ts +1 -1
  74. package/dist/constants/plugin/index.d.ts.map +1 -1
  75. package/dist/constants/plugin/index.js +1 -1
  76. package/dist/constants/plugin/index.js.map +1 -1
  77. package/dist/manager/agent/lifecycle.d.ts +7 -0
  78. package/dist/manager/agent/lifecycle.d.ts.map +1 -1
  79. package/dist/manager/agent/lifecycle.js +11 -2
  80. package/dist/manager/agent/lifecycle.js.map +1 -1
  81. package/dist/manager/session/turn-recorder.d.ts.map +1 -1
  82. package/dist/manager/session/turn-recorder.js +4 -2
  83. package/dist/manager/session/turn-recorder.js.map +1 -1
  84. package/dist/plugin/index.d.ts +1 -1
  85. package/dist/plugin/index.d.ts.map +1 -1
  86. package/dist/plugin/index.js +1 -1
  87. package/dist/plugin/index.js.map +1 -1
  88. package/dist/plugin/lifecycle.d.ts +6 -0
  89. package/dist/plugin/lifecycle.d.ts.map +1 -1
  90. package/dist/plugin/lifecycle.js +98 -114
  91. package/dist/plugin/lifecycle.js.map +1 -1
  92. package/dist/plugin/shell-hook.d.ts +90 -22
  93. package/dist/plugin/shell-hook.d.ts.map +1 -1
  94. package/dist/plugin/shell-hook.js +434 -41
  95. package/dist/plugin/shell-hook.js.map +1 -1
  96. package/dist/public-runtime.d.ts +4 -1
  97. package/dist/public-runtime.d.ts.map +1 -1
  98. package/dist/public-runtime.js +4 -1
  99. package/dist/public-runtime.js.map +1 -1
  100. package/dist/public-types.d.ts +2 -2
  101. package/dist/public-types.d.ts.map +1 -1
  102. package/dist/runtime/jobs/registry.d.ts +21 -0
  103. package/dist/runtime/jobs/registry.d.ts.map +1 -1
  104. package/dist/runtime/jobs/registry.js +15 -2
  105. package/dist/runtime/jobs/registry.js.map +1 -1
  106. package/dist/runtime/query/executor/tool-call-admission.d.ts +10 -0
  107. package/dist/runtime/query/executor/tool-call-admission.d.ts.map +1 -1
  108. package/dist/runtime/query/executor/tool-call-admission.js +74 -13
  109. package/dist/runtime/query/executor/tool-call-admission.js.map +1 -1
  110. package/dist/runtime/query/executor.d.ts +41 -2
  111. package/dist/runtime/query/executor.d.ts.map +1 -1
  112. package/dist/runtime/query/executor.js +190 -6
  113. package/dist/runtime/query/executor.js.map +1 -1
  114. package/dist/runtime/query/finalize-turn.d.ts +6 -0
  115. package/dist/runtime/query/finalize-turn.d.ts.map +1 -1
  116. package/dist/runtime/query/finalize-turn.js +1 -1
  117. package/dist/runtime/query/finalize-turn.js.map +1 -1
  118. package/dist/runtime/query/index.d.ts +29 -0
  119. package/dist/runtime/query/index.d.ts.map +1 -1
  120. package/dist/runtime/query/index.js +15 -3
  121. package/dist/runtime/query/index.js.map +1 -1
  122. package/dist/runtime/query/iteration/index.d.ts +31 -0
  123. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  124. package/dist/runtime/query/iteration/index.js +216 -7
  125. package/dist/runtime/query/iteration/index.js.map +1 -1
  126. package/dist/runtime/query/iteration/phases/compaction-breaker.d.ts +58 -0
  127. package/dist/runtime/query/iteration/phases/compaction-breaker.d.ts.map +1 -0
  128. package/dist/runtime/query/iteration/phases/compaction-breaker.js +91 -0
  129. package/dist/runtime/query/iteration/phases/compaction-breaker.js.map +1 -0
  130. package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
  131. package/dist/runtime/query/iteration/phases/compaction.js +89 -7
  132. package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
  133. package/dist/runtime/query/iteration/phases/context.d.ts +10 -0
  134. package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
  135. package/dist/runtime/query/iteration/phases/context.js.map +1 -1
  136. package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
  137. package/dist/runtime/query/iteration/phases/tool-review.js +165 -2
  138. package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
  139. package/dist/runtime/query/path-escalation.d.ts +36 -0
  140. package/dist/runtime/query/path-escalation.d.ts.map +1 -0
  141. package/dist/runtime/query/path-escalation.js +184 -0
  142. package/dist/runtime/query/path-escalation.js.map +1 -0
  143. package/dist/runtime/query/plugin-hooks.d.ts.map +1 -1
  144. package/dist/runtime/query/plugin-hooks.js +8 -0
  145. package/dist/runtime/query/plugin-hooks.js.map +1 -1
  146. package/dist/runtime/query/reminders.d.ts +35 -0
  147. package/dist/runtime/query/reminders.d.ts.map +1 -0
  148. package/dist/runtime/query/reminders.js +7 -0
  149. package/dist/runtime/query/reminders.js.map +1 -0
  150. package/dist/runtime/query/resume-pending.d.ts.map +1 -1
  151. package/dist/runtime/query/resume-pending.js +26 -0
  152. package/dist/runtime/query/resume-pending.js.map +1 -1
  153. package/dist/runtime/query/review-policy.d.ts +26 -0
  154. package/dist/runtime/query/review-policy.d.ts.map +1 -1
  155. package/dist/runtime/query/review-policy.js +79 -5
  156. package/dist/runtime/query/review-policy.js.map +1 -1
  157. package/dist/runtime/query/tool-output-budget.d.ts +46 -0
  158. package/dist/runtime/query/tool-output-budget.d.ts.map +1 -1
  159. package/dist/runtime/query/tool-output-budget.js +85 -1
  160. package/dist/runtime/query/tool-output-budget.js.map +1 -1
  161. package/dist/runtime/query/tooling.d.ts +1 -0
  162. package/dist/runtime/query/tooling.d.ts.map +1 -1
  163. package/dist/runtime/query/tooling.js +5 -0
  164. package/dist/runtime/query/tooling.js.map +1 -1
  165. package/dist/session/paths.d.ts +5 -1
  166. package/dist/session/paths.d.ts.map +1 -1
  167. package/dist/session/paths.js +8 -0
  168. package/dist/session/paths.js.map +1 -1
  169. package/dist/store/memory/disk.d.ts.map +1 -1
  170. package/dist/store/memory/disk.js +2 -2
  171. package/dist/store/memory/disk.js.map +1 -1
  172. package/dist/store/memory/markdown.d.ts.map +1 -1
  173. package/dist/store/memory/markdown.js +2 -2
  174. package/dist/store/memory/markdown.js.map +1 -1
  175. package/dist/store/memory/naming.d.ts +9 -0
  176. package/dist/store/memory/naming.d.ts.map +1 -1
  177. package/dist/store/memory/naming.js +12 -0
  178. package/dist/store/memory/naming.js.map +1 -1
  179. package/dist/tools/builtins/bash.d.ts +30 -1
  180. package/dist/tools/builtins/bash.d.ts.map +1 -1
  181. package/dist/tools/builtins/bash.js +170 -26
  182. package/dist/tools/builtins/bash.js.map +1 -1
  183. package/dist/tools/builtins/content-fingerprint.d.ts +2 -0
  184. package/dist/tools/builtins/content-fingerprint.d.ts.map +1 -1
  185. package/dist/tools/builtins/content-fingerprint.js +4 -0
  186. package/dist/tools/builtins/content-fingerprint.js.map +1 -1
  187. package/dist/tools/builtins/edit.d.ts.map +1 -1
  188. package/dist/tools/builtins/edit.js +60 -18
  189. package/dist/tools/builtins/edit.js.map +1 -1
  190. package/dist/tools/builtins/grep.d.ts +8 -1
  191. package/dist/tools/builtins/grep.d.ts.map +1 -1
  192. package/dist/tools/builtins/grep.js +152 -33
  193. package/dist/tools/builtins/grep.js.map +1 -1
  194. package/dist/tools/builtins/read-file.d.ts +1 -0
  195. package/dist/tools/builtins/read-file.d.ts.map +1 -1
  196. package/dist/tools/builtins/read-file.js +44 -5
  197. package/dist/tools/builtins/read-file.js.map +1 -1
  198. package/dist/tools/builtins/read-render.d.ts +52 -0
  199. package/dist/tools/builtins/read-render.d.ts.map +1 -1
  200. package/dist/tools/builtins/read-render.js +143 -0
  201. package/dist/tools/builtins/read-render.js.map +1 -1
  202. package/dist/tools/builtins/web.d.ts +13 -2
  203. package/dist/tools/builtins/web.d.ts.map +1 -1
  204. package/dist/tools/builtins/web.js +225 -32
  205. package/dist/tools/builtins/web.js.map +1 -1
  206. package/dist/tools/coordinator/ask-user-question.d.ts +2 -0
  207. package/dist/tools/coordinator/ask-user-question.d.ts.map +1 -1
  208. package/dist/tools/coordinator/ask-user-question.js +256 -92
  209. package/dist/tools/coordinator/ask-user-question.js.map +1 -1
  210. package/dist/tools/coordinator/question-options.d.ts +1 -0
  211. package/dist/tools/coordinator/question-options.d.ts.map +1 -1
  212. package/dist/tools/coordinator/question-options.js +6 -1
  213. package/dist/tools/coordinator/question-options.js.map +1 -1
  214. package/dist/tools/defineTool.d.ts +4 -1
  215. package/dist/tools/defineTool.d.ts.map +1 -1
  216. package/dist/tools/defineTool.js +6 -1
  217. package/dist/tools/defineTool.js.map +1 -1
  218. package/dist/tools/paths.d.ts +6 -0
  219. package/dist/tools/paths.d.ts.map +1 -1
  220. package/dist/tools/paths.js +1 -1
  221. package/dist/tools/paths.js.map +1 -1
  222. package/dist/types/agent/query.d.ts +3 -0
  223. package/dist/types/agent/query.d.ts.map +1 -1
  224. package/dist/types/errors/catalog.js +1 -1
  225. package/dist/types/errors/catalog.js.map +1 -1
  226. package/dist/types/hitl/index.d.ts +104 -0
  227. package/dist/types/hitl/index.d.ts.map +1 -1
  228. package/dist/types/hitl/index.js.map +1 -1
  229. package/dist/types/message/index.d.ts +1 -1
  230. package/dist/types/message/index.d.ts.map +1 -1
  231. package/dist/types/message/index.js +2 -0
  232. package/dist/types/message/index.js.map +1 -1
  233. package/dist/types/plugin/index.d.ts +93 -19
  234. package/dist/types/plugin/index.d.ts.map +1 -1
  235. package/dist/types/plugin/index.js +3 -0
  236. package/dist/types/plugin/index.js.map +1 -1
  237. package/dist/types/provider/errors.d.ts +19 -0
  238. package/dist/types/provider/errors.d.ts.map +1 -1
  239. package/dist/types/provider/errors.js +24 -0
  240. package/dist/types/provider/errors.js.map +1 -1
  241. package/dist/types/session/events.d.ts +10 -2
  242. package/dist/types/session/events.d.ts.map +1 -1
  243. package/dist/types/session/events.js.map +1 -1
  244. package/dist/types/session/records.d.ts +9 -9
  245. package/dist/types/session/records.d.ts.map +1 -1
  246. package/dist/types/session/records.js +1 -0
  247. package/dist/types/session/records.js.map +1 -1
  248. package/dist/types/session/stop-reason.d.ts +5 -0
  249. package/dist/types/session/stop-reason.d.ts.map +1 -1
  250. package/dist/types/tool/index.d.ts +52 -0
  251. package/dist/types/tool/index.d.ts.map +1 -1
  252. package/dist/types/tool/index.js.map +1 -1
  253. package/package.json +4 -2
  254. package/src/agents/QueryAgent.ts +5 -1
  255. package/src/agents/SupervisorAgent.ts +1 -1
  256. package/src/agents/examples/SupervisorAgent.ts +217 -106
  257. package/src/agents/index.ts +1 -1
  258. package/src/authorization/protected-paths.ts +110 -0
  259. package/src/authorization/readonly-commands.ts +771 -0
  260. package/src/authorization/readonly-flags.ts +196 -0
  261. package/src/authorization/removal.ts +326 -0
  262. package/src/authorization/shell-paths.ts +927 -0
  263. package/src/bridge/acp/update.ts +1 -0
  264. package/src/compaction/context-window.ts +41 -0
  265. package/src/compaction/manual.ts +10 -2
  266. package/src/compaction/plan.ts +11 -2
  267. package/src/compaction/verifier.ts +28 -3
  268. package/src/config/runtime.ts +23 -2
  269. package/src/connector/web/guarded-fetch.ts +26 -0
  270. package/src/connector/web/index.ts +3 -0
  271. package/src/connector/web/page.ts +130 -0
  272. package/src/connector/web/types.ts +47 -0
  273. package/src/constants/continuation.ts +10 -0
  274. package/src/constants/plugin/index.ts +1 -1
  275. package/src/manager/agent/lifecycle.ts +12 -4
  276. package/src/manager/session/turn-recorder.ts +10 -2
  277. package/src/plugin/index.ts +1 -0
  278. package/src/plugin/lifecycle.ts +107 -119
  279. package/src/plugin/shell-hook.ts +477 -43
  280. package/src/public-runtime.ts +8 -0
  281. package/src/public-types.ts +4 -0
  282. package/src/runtime/jobs/registry.ts +39 -2
  283. package/src/runtime/query/executor/tool-call-admission.ts +79 -13
  284. package/src/runtime/query/executor.ts +242 -8
  285. package/src/runtime/query/finalize-turn.ts +7 -1
  286. package/src/runtime/query/index.ts +45 -3
  287. package/src/runtime/query/iteration/index.ts +251 -12
  288. package/src/runtime/query/iteration/phases/compaction-breaker.ts +106 -0
  289. package/src/runtime/query/iteration/phases/compaction.ts +99 -12
  290. package/src/runtime/query/iteration/phases/context.ts +11 -0
  291. package/src/runtime/query/iteration/phases/tool-review.ts +177 -2
  292. package/src/runtime/query/path-escalation.ts +204 -0
  293. package/src/runtime/query/plugin-hooks.ts +8 -0
  294. package/src/runtime/query/reminders.ts +45 -0
  295. package/src/runtime/query/resume-pending.ts +27 -0
  296. package/src/runtime/query/review-policy.ts +106 -5
  297. package/src/runtime/query/tool-output-budget.ts +115 -1
  298. package/src/runtime/query/tooling.ts +6 -0
  299. package/src/session/paths.ts +11 -1
  300. package/src/store/memory/disk.ts +2 -1
  301. package/src/store/memory/markdown.ts +2 -1
  302. package/src/store/memory/naming.ts +13 -0
  303. package/src/tools/builtins/bash.ts +203 -34
  304. package/src/tools/builtins/content-fingerprint.ts +5 -0
  305. package/src/tools/builtins/edit.ts +69 -19
  306. package/src/tools/builtins/grep.ts +159 -38
  307. package/src/tools/builtins/read-file.ts +48 -5
  308. package/src/tools/builtins/read-render.ts +193 -0
  309. package/src/tools/builtins/web.ts +258 -33
  310. package/src/tools/coordinator/ask-user-question.ts +309 -103
  311. package/src/tools/coordinator/question-options.ts +8 -1
  312. package/src/tools/defineTool.ts +11 -2
  313. package/src/tools/paths.ts +1 -1
  314. package/src/types/agent/query.ts +3 -0
  315. package/src/types/errors/catalog.ts +1 -1
  316. package/src/types/hitl/index.ts +104 -0
  317. package/src/types/message/index.ts +2 -0
  318. package/src/types/plugin/index.ts +101 -18
  319. package/src/types/provider/errors.ts +33 -0
  320. package/src/types/session/events.ts +10 -2
  321. package/src/types/session/records.ts +1 -0
  322. package/src/types/session/stop-reason.ts +5 -0
  323. package/src/types/tool/index.ts +53 -2
@@ -0,0 +1,771 @@
1
+ /**
2
+ * Which shell lines only read, so that they run without a prompt.
3
+ *
4
+ * The reference terminal runs `ls`, `cat`, `git status` and `git diff` without
5
+ * asking, because each is on a vetted list and nothing in the line is decided
6
+ * at run time. This is that list and those conditions (version 2.1.302):
7
+ *
8
+ * - every command of the line is on the list, the line parses completely, and
9
+ * nothing in it is read at run time: no `$var` outside single quotes, no
10
+ * substitution, no subshell or group, no background `&`, no here-document,
11
+ * no unquoted glob except for a short set of commands that only list or show;
12
+ * - a redirection is allowed only to `/dev/null`, as a descriptor copy, or as
13
+ * an input from a plain file; a `~user` path and a UNC path are refused;
14
+ * - a leading assignment is allowed only for a variable that cannot change
15
+ * what a command does (`LANG`, `TZ`, `NO_COLOR`, ...);
16
+ * - `git` must match a vetted subcommand and every flag must be one the table
17
+ * names, with the kind of value it takes. `git diff --output=f`, `git -c`,
18
+ * `--exec-path` and `--config-env` are in no table, so they are refused;
19
+ * `git fetch` is deliberately absent. A `cd` in the same line as `git`
20
+ * refuses the line, because the directory it enters may hold a repository
21
+ * the person never looked at.
22
+ *
23
+ * Not ported, and so never read-only here: `sed`, `ps`, `lsof`, `ss`, `date`,
24
+ * `man`, `xargs`, `fd`, `gh`, `docker logs|inspect`, `printf`, `[[`, `test`
25
+ * and the shell builtins other than `cd`. The reference's check of the working
26
+ * directory for a planted bare repository needs the directory, which the tool
27
+ * predicate does not get; the trusted-project gate is what stands in for it.
28
+ *
29
+ * This decides only "does not need a prompt for being a command". The path
30
+ * floor still applies: a read-only command that reads outside the roots is
31
+ * asked about, and so is anything the protected-path and removal floors see.
32
+ */
33
+
34
+ import type { ShellDialect } from '../types/tool/index.js'
35
+ import { FLAG_TABLES, type FlagTable } from './readonly-flags.js'
36
+ import { type ShellWord, lexShellCommandLine } from './shell-lexer.js'
37
+
38
+ /** Lines longer than this are not analysed (the reference's budget). */
39
+ const MAX_LINE_LENGTH = 20_000
40
+
41
+ /** Variables whose value cannot change what a command does. */
42
+ const SAFE_ENVIRONMENT: ReadonlySet<string> = new Set([
43
+ 'GOEXPERIMENT',
44
+ 'GOOS',
45
+ 'GOARCH',
46
+ 'CGO_ENABLED',
47
+ 'GO111MODULE',
48
+ 'RUST_BACKTRACE',
49
+ 'RUST_LOG',
50
+ 'NODE_ENV',
51
+ 'PYTHONUNBUFFERED',
52
+ 'PYTHONDONTWRITEBYTECODE',
53
+ 'PYTEST_DISABLE_PLUGIN_AUTOLOAD',
54
+ 'PYTEST_DEBUG',
55
+ 'LANG',
56
+ 'LANGUAGE',
57
+ 'LC_ALL',
58
+ 'LC_CTYPE',
59
+ 'LC_TIME',
60
+ 'CHARSET',
61
+ 'TERM',
62
+ 'COLORTERM',
63
+ 'NO_COLOR',
64
+ 'FORCE_COLOR',
65
+ 'TZ',
66
+ 'LS_COLORS',
67
+ 'LSCOLORS',
68
+ 'GREP_COLOR',
69
+ 'GREP_COLORS',
70
+ 'GCC_COLORS',
71
+ 'TIME_STYLE',
72
+ 'BLOCK_SIZE',
73
+ 'BLOCKSIZE',
74
+ 'COLUMNS',
75
+ 'LINES',
76
+ 'CLICOLOR',
77
+ 'CLICOLOR_FORCE',
78
+ 'CI',
79
+ 'DEBIAN_FRONTEND',
80
+ 'GIT_TERMINAL_PROMPT',
81
+ ])
82
+
83
+ /** Commands that read and print, with any arguments (the reference's bare-safe list). */
84
+ const ANY_ARGUMENTS: ReadonlySet<string> = new Set([
85
+ 'cal',
86
+ 'uptime',
87
+ 'cat',
88
+ 'head',
89
+ 'tail',
90
+ 'wc',
91
+ 'stat',
92
+ 'strings',
93
+ 'hexdump',
94
+ 'od',
95
+ 'nl',
96
+ 'id',
97
+ 'uname',
98
+ 'free',
99
+ 'df',
100
+ 'du',
101
+ 'locale',
102
+ 'groups',
103
+ 'nproc',
104
+ 'basename',
105
+ 'dirname',
106
+ 'realpath',
107
+ 'cut',
108
+ 'paste',
109
+ 'tr',
110
+ 'column',
111
+ 'tac',
112
+ 'rev',
113
+ 'fold',
114
+ 'expand',
115
+ 'unexpand',
116
+ 'fmt',
117
+ 'comm',
118
+ 'cmp',
119
+ 'numfmt',
120
+ 'readlink',
121
+ 'diff',
122
+ 'true',
123
+ 'false',
124
+ 'sleep',
125
+ 'which',
126
+ 'type',
127
+ 'expr',
128
+ 'seq',
129
+ 'tsort',
130
+ 'pr',
131
+ 'echo',
132
+ 'ls',
133
+ ])
134
+
135
+ /** The commands whose unquoted glob arguments are still read-only. */
136
+ const GLOB_OK: ReadonlySet<string> = new Set([
137
+ 'ls',
138
+ 'cat',
139
+ 'head',
140
+ 'tail',
141
+ 'wc',
142
+ 'stat',
143
+ 'grep',
144
+ 'egrep',
145
+ 'fgrep',
146
+ 'diff',
147
+ 'du',
148
+ 'df',
149
+ 'echo',
150
+ 'strings',
151
+ 'hexdump',
152
+ 'od',
153
+ 'nl',
154
+ 'cut',
155
+ 'column',
156
+ 'tr',
157
+ 'tac',
158
+ 'rev',
159
+ 'cmp',
160
+ 'basename',
161
+ 'dirname',
162
+ 'realpath',
163
+ 'readlink',
164
+ 'sha256sum',
165
+ 'sha1sum',
166
+ 'md5sum',
167
+ 'cd',
168
+ ])
169
+
170
+ const FIND_WRITES: ReadonlySet<string> = new Set([
171
+ '-delete',
172
+ '-rm',
173
+ '-exec',
174
+ '-execdir',
175
+ '-ok',
176
+ '-okdir',
177
+ '-fprint',
178
+ '-fprint0',
179
+ '-fls',
180
+ '-fprintf',
181
+ '-files0-from',
182
+ ])
183
+
184
+ /** Options that point `docker` at another daemon, context or config. */
185
+ const DOCKER_REMOTE = [
186
+ '-H',
187
+ '-c',
188
+ '-r',
189
+ '--host',
190
+ '--context',
191
+ '--config',
192
+ '--tlscacert',
193
+ '--tlscert',
194
+ '--tlskey',
195
+ '--url',
196
+ '--connection',
197
+ '--identity',
198
+ '--remote',
199
+ '--module',
200
+ '--out',
201
+ ]
202
+ const DOCKER_REMOTE_SHORT = new Set(['H', 'c', 'r'])
203
+
204
+ const GREP_FAMILY: ReadonlySet<string> = new Set(['grep', 'egrep', 'fgrep'])
205
+
206
+ // ---------------------------------------------------------------------------
207
+ // the line as text
208
+ // ---------------------------------------------------------------------------
209
+
210
+ /**
211
+ * Whether the line carries syntax the word-level reading cannot vouch for:
212
+ * a variable expansion anywhere but single quotes, a substitution, a subshell, or a `&` that is not `&&` or a descriptor copy.
213
+ */
214
+ function hasDynamicSyntax(line: string): boolean {
215
+ let single = false
216
+ let double = false
217
+ for (let i = 0; i < line.length; i += 1) {
218
+ const c = line[i] as string
219
+ if (c === '\\' && !single) {
220
+ i += 1
221
+ continue
222
+ }
223
+ if (c === "'" && !double) {
224
+ single = !single
225
+ continue
226
+ }
227
+ if (c === '"' && !single) {
228
+ double = !double
229
+ continue
230
+ }
231
+ if (single) continue
232
+ if (c === '$') {
233
+ const next = line[i + 1]
234
+ if (next !== undefined && /[A-Za-z_@*#?!$0-9{(-]/.test(next)) return true
235
+ }
236
+ if (c === '`') return true
237
+ if (double) continue
238
+ if (c === '(' || c === ')') return true
239
+ // `{ ls; }` is a group; `HEAD@{1}` and `{a,b}` are words (the latter is
240
+ // refused with the other expansions).
241
+ if (c === '{') {
242
+ const previous = line[i - 1]
243
+ const next = line[i + 1]
244
+ if (
245
+ (previous === undefined || /[\s;&|]/.test(previous)) &&
246
+ (next === undefined || /\s/.test(next))
247
+ )
248
+ return true
249
+ }
250
+ if (c === '&') {
251
+ const previous = line[i - 1]
252
+ const next = line[i + 1]
253
+ if (next === '&' || previous === '&') {
254
+ i += next === '&' ? 1 : 0
255
+ continue
256
+ }
257
+ // `2>&1`, `>&2`, `<&3`: a descriptor copy.
258
+ if (previous === '>' || previous === '<') continue
259
+ return true
260
+ }
261
+ }
262
+ return false
263
+ }
264
+
265
+ /** An unquoted `*`, `?` or `[` in a word's source text. */
266
+ function hasUnquotedGlob(text: string): boolean {
267
+ let single = false
268
+ let double = false
269
+ for (let i = 0; i < text.length; i += 1) {
270
+ const c = text[i] as string
271
+ if (c === '\\' && !single) {
272
+ i += 1
273
+ continue
274
+ }
275
+ if (c === "'" && !double) {
276
+ single = !single
277
+ continue
278
+ }
279
+ if (c === '"' && !single) {
280
+ double = !double
281
+ continue
282
+ }
283
+ if (!single && !double && (c === '*' || c === '?' || c === '[')) return true
284
+ }
285
+ return false
286
+ }
287
+
288
+ /** `\\host\share` and `//host/share`: a path the OS may fetch over the network. */
289
+ function looksLikeNetworkPath(value: string): boolean {
290
+ return /^(?:\\\\|\/\/)[^\\/]/.test(value)
291
+ }
292
+
293
+ /** A word whose value is not what the shell will pass, other than by a plain glob or `~`. */
294
+ function isDynamic(word: ShellWord): boolean {
295
+ // `expands` is false for a single-quoted `$`, which is only text.
296
+ if (word.substitutes || (word.expands && /[$`]/.test(word.value))) return true
297
+ if (/\{[^{}]*(?:,|\.\.)[^{}]*\}/.test(word.text)) return true
298
+ // `~user` expands to another account's home; `~` and `~/x` are this one's.
299
+ return /^~(?!\/|$)/.test(word.text)
300
+ }
301
+
302
+ // ---------------------------------------------------------------------------
303
+ // a command's flags
304
+ // ---------------------------------------------------------------------------
305
+
306
+ const FLAG_LIKE = /^-[A-Za-z0-9_-]/
307
+
308
+ function isFlag(word: string): boolean {
309
+ return word.startsWith('-') && word.length > 1 && FLAG_LIKE.test(word)
310
+ }
311
+
312
+ function valueFits(value: string, kind: 'none' | 'string' | 'number'): boolean {
313
+ if (kind === 'number') return /^\d+$/.test(value)
314
+ return kind === 'string'
315
+ }
316
+
317
+ /** A flag's value that is dynamic or names a network path. */
318
+ function dangerousValue(value: string): boolean {
319
+ return /[$`]/.test(value) || looksLikeNetworkPath(value)
320
+ }
321
+
322
+ /**
323
+ * Check `args[start..]` against a flag table: every flag is listed, takes the
324
+ * value its kind says, and (for `string`) that value is not itself a flag.
325
+ */
326
+ function flagsAreVetted(
327
+ args: readonly string[],
328
+ start: number,
329
+ flagTable: FlagTable,
330
+ command: string,
331
+ ): boolean {
332
+ let i = start
333
+ while (i < args.length) {
334
+ const word = args[i] as string
335
+ if (word === '') {
336
+ i += 1
337
+ continue
338
+ }
339
+ if (word === '--') {
340
+ i += 1
341
+ if (flagTable.doubleDash) break
342
+ continue
343
+ }
344
+ if (!isFlag(word)) {
345
+ if (looksLikeNetworkPath(word)) return false
346
+ i += 1
347
+ continue
348
+ }
349
+ const equals = word.indexOf('=')
350
+ const name = equals === -1 ? word : word.slice(0, equals)
351
+ const attached = equals === -1 ? undefined : word.slice(equals + 1)
352
+ const kind = flagTable.flags[name]
353
+ if (kind === undefined) {
354
+ // `git log -5`
355
+ if (command === 'git' && /^-\d+$/.test(name)) {
356
+ i += 1
357
+ continue
358
+ }
359
+ const searching = GREP_FAMILY.has(command) || command === 'rg'
360
+ // `grep -A5`: a count glued to its flag.
361
+ if (searching && name.startsWith('-') && !name.startsWith('--') && name.length > 2) {
362
+ const head = flagTable.flags[name.slice(0, 2)]
363
+ if ((head === 'number' || head === 'string') && /^\d+$/.test(name.slice(2))) {
364
+ i += 1
365
+ continue
366
+ }
367
+ }
368
+ // `-rn`: a cluster of switches, each of which must be a switch.
369
+ if (name.startsWith('-') && !name.startsWith('--') && name.length > 2) {
370
+ for (const letter of name.slice(1))
371
+ if (flagTable.flags[`-${letter}`] !== 'none') return false
372
+ i += 1
373
+ continue
374
+ }
375
+ return false
376
+ }
377
+ if (kind === 'none') {
378
+ if (attached !== undefined) return false
379
+ i += 1
380
+ continue
381
+ }
382
+ let value: string
383
+ if (attached !== undefined) {
384
+ value = attached
385
+ i += 1
386
+ } else {
387
+ const next = args[i + 1]
388
+ if (next === undefined || isFlag(next)) return false
389
+ value = next
390
+ i += 2
391
+ }
392
+ if (dangerousValue(value)) return false
393
+ if (kind === 'string' && value.startsWith('-')) {
394
+ // `git for-each-ref --sort=-committerdate`, but not `--format -x`.
395
+ if (!(name === '--sort' && command === 'git' && /^-[a-zA-Z]/.test(value))) return false
396
+ }
397
+ if (!valueFits(value, kind)) return false
398
+ }
399
+ return true
400
+ }
401
+
402
+ // -- `git` hardening -------------------------------------------------------
403
+
404
+ const SIGNATURE = /%[-+ ]?G|%\(\*?signature/
405
+ const PRETTY_PRESETS = new Set(['oneline', 'short', 'medium', 'full', 'fuller', 'email', 'raw'])
406
+
407
+ function mentionsHomeDirectory(args: readonly string[]): boolean {
408
+ return args.some((a) => /(?:^|[=:])~/.test(a) || (a.includes('~') && a.includes('{')))
409
+ }
410
+
411
+ /** A `--format`/`--pretty`/`--sort`/`--group` value that runs a program or reads a signature. */
412
+ function formatIsDangerous(args: readonly string[]): boolean {
413
+ const bad = (value: string) =>
414
+ mentionsHomeDirectory([value]) ||
415
+ looksLikeNetworkPath(value) ||
416
+ SIGNATURE.test(value) ||
417
+ value.includes('signature')
418
+ for (let i = 0; i < args.length; i += 1) {
419
+ const word = args[i] as string
420
+ if (SIGNATURE.test(word)) return true
421
+ for (const option of ['--format', '--pretty', '--sort', '--group']) {
422
+ let value: string | undefined
423
+ if (word === option && i + 1 < args.length) value = args[i + 1]
424
+ else if (word.startsWith(`${option}=`)) value = word.slice(option.length + 1)
425
+ if (value === undefined) continue
426
+ if (bad(value)) return true
427
+ // A named pretty format that is not a built-in is read from the config.
428
+ if (
429
+ (option === '--format' || option === '--pretty') &&
430
+ value !== '' &&
431
+ !value.includes('%') &&
432
+ !value.startsWith('format:') &&
433
+ !value.startsWith('tformat:') &&
434
+ !PRETTY_PRESETS.has(value)
435
+ )
436
+ return true
437
+ }
438
+ }
439
+ return false
440
+ }
441
+
442
+ type Dangerous = (args: readonly string[]) => boolean
443
+
444
+ const GIT_CHECKS: Readonly<Record<string, Dangerous>> = {
445
+ 'git log': formatIsDangerous,
446
+ 'git show': formatIsDangerous,
447
+ 'git shortlog': formatIsDangerous,
448
+ 'git rev-list': formatIsDangerous,
449
+ 'git for-each-ref': formatIsDangerous,
450
+ 'git reflog': (args) => {
451
+ const first = args[0]
452
+ if (first !== undefined && !first.startsWith('-') && first !== 'show' && first !== 'list')
453
+ return true
454
+ return args.some((a) => ['expire', 'delete', 'exists', 'drop', 'write'].includes(a))
455
+ },
456
+ 'git ls-remote': (args) => {
457
+ if (formatIsDangerous(args)) return true
458
+ let afterDashes = false
459
+ for (let i = 0; i < args.length; i += 1) {
460
+ const word = args[i] as string
461
+ if (!afterDashes && word === '--') {
462
+ afterDashes = true
463
+ continue
464
+ }
465
+ if (!afterDashes && (word === '' || word.startsWith('-'))) {
466
+ if (word === '--sort') i += 1
467
+ continue
468
+ }
469
+ return true
470
+ }
471
+ return false
472
+ },
473
+ 'git remote show': (args) => {
474
+ const dashes = args.indexOf('--')
475
+ const options = dashes === -1 ? args : args.slice(0, dashes)
476
+ const operands = dashes === -1 ? [] : args.slice(dashes + 1)
477
+ const rest = options.filter((a) => a !== '-n').concat(operands)
478
+ if (rest.length !== 1) return true
479
+ if (!options.includes('-n')) return true
480
+ return !/^[a-zA-Z0-9_][a-zA-Z0-9_-]*$/.test(rest[0] as string)
481
+ },
482
+ 'git remote': (args) => args.some((a) => a !== '-v' && a !== '--verbose'),
483
+ 'git tag': (args) => {
484
+ if (formatIsDangerous(args)) return true
485
+ const takesValue = new Set([
486
+ '--contains',
487
+ '--no-contains',
488
+ '--merged',
489
+ '--no-merged',
490
+ '--points-at',
491
+ '--sort',
492
+ '--format',
493
+ '-n',
494
+ ])
495
+ let listing = false
496
+ let afterDashes = false
497
+ let i = 0
498
+ while (i < args.length) {
499
+ const word = args[i] as string
500
+ if (word === '') {
501
+ i += 1
502
+ continue
503
+ }
504
+ if (word === '--' && !afterDashes) {
505
+ afterDashes = true
506
+ i += 1
507
+ continue
508
+ }
509
+ if (!afterDashes && word.startsWith('-')) {
510
+ if (word === '--list' || word === '-l') listing = true
511
+ else if (
512
+ word[1] !== '-' &&
513
+ word.length > 2 &&
514
+ !word.includes('=') &&
515
+ word.slice(1).includes('l')
516
+ )
517
+ listing = true
518
+ if (word.includes('=')) i += 1
519
+ else if (takesValue.has(word)) i += 2
520
+ else i += 1
521
+ } else {
522
+ // A bare word creates a tag unless the list flag came with it.
523
+ if (!listing) return true
524
+ i += 1
525
+ }
526
+ }
527
+ return false
528
+ },
529
+ 'git branch': (args) => {
530
+ if (formatIsDangerous(args)) return true
531
+ const takesValue = new Set(['--contains', '--no-contains', '--points-at', '--sort'])
532
+ const optionalCommit = new Set(['--merged', '--no-merged'])
533
+ let listing = false
534
+ let afterDashes = false
535
+ let previous = ''
536
+ let i = 0
537
+ while (i < args.length) {
538
+ const word = args[i] as string
539
+ if (word === '') {
540
+ i += 1
541
+ continue
542
+ }
543
+ if (word === '--' && !afterDashes) {
544
+ afterDashes = true
545
+ previous = ''
546
+ i += 1
547
+ continue
548
+ }
549
+ if (!afterDashes && word.startsWith('-')) {
550
+ if (word === '--list' || word === '-l') listing = true
551
+ else if (word[1] !== '-' && word.length > 2 && !word.includes('=') && word.includes('l'))
552
+ listing = true
553
+ if (word.includes('=')) {
554
+ previous = word.slice(0, word.indexOf('='))
555
+ i += 1
556
+ } else if (takesValue.has(word)) {
557
+ previous = word
558
+ i += 2
559
+ } else {
560
+ previous = word
561
+ i += 1
562
+ }
563
+ } else {
564
+ // A bare word creates a branch unless listing or it is the commit of --merged.
565
+ if (!listing && !optionalCommit.has(previous)) return true
566
+ i += 1
567
+ }
568
+ }
569
+ return false
570
+ },
571
+ }
572
+
573
+ // -- the commands with a regex of their own in the reference ----------------
574
+
575
+ function uniqIsReadOnly(args: readonly string[]): boolean {
576
+ for (let i = 0; i < args.length; i += 1) {
577
+ const word = args[i] as string
578
+ if (/^-[a-zA-Z]+$/.test(word) || /^--[a-zA-Z-]+(?:=\S+)?$/.test(word)) continue
579
+ // `-f N`, `-s N`, `-w N`
580
+ if (/^-[fsw]$/.test(word)) continue
581
+ if (/^\d+$/.test(word) && /^-[fsw]$/.test(args[i - 1] ?? '')) continue
582
+ return false
583
+ }
584
+ return true
585
+ }
586
+
587
+ function jqIsReadOnly(args: readonly string[]): boolean {
588
+ const text = args.join(' ')
589
+ if (/\b(?:env|include|import)\b|\$ENV/.test(text)) return false
590
+ let filters = 0
591
+ for (const word of args) {
592
+ if (/^-[a-zA-Z]*[fL]/.test(word) && !word.startsWith('--')) return false
593
+ if (
594
+ ['--from-file', '--rawfile', '--slurpfile', '--run-tests', '--library-path'].some(
595
+ (x) => word === x || word.startsWith(`${x}=`),
596
+ )
597
+ )
598
+ return false
599
+ if (!word.startsWith('-')) filters += 1
600
+ }
601
+ return filters > 0
602
+ }
603
+
604
+ function findIsReadOnly(args: readonly string[]): boolean {
605
+ return !args.some((word) => FIND_WRITES.has(word))
606
+ }
607
+
608
+ function dockerIsReadOnly(args: readonly string[]): boolean {
609
+ if (args[0] !== 'ps' && args[0] !== 'images') return false
610
+ for (const word of args) {
611
+ if (mentionsHomeDirectory([word]) || looksLikeNetworkPath(word)) return false
612
+ if (DOCKER_REMOTE.some((o) => word === o || word.startsWith(`${o}=`))) return false
613
+ if (word.length > 2 && word.startsWith('-') && !word.startsWith('--')) {
614
+ if (word.startsWith('-H') || word.startsWith('-c') || word.startsWith('-r')) return false
615
+ for (const letter of word.slice(1)) if (DOCKER_REMOTE_SHORT.has(letter)) return false
616
+ }
617
+ }
618
+ return true
619
+ }
620
+
621
+ /** Whether one simple command, as its argument vector, is on the list. */
622
+ function commandIsReadOnly(argv: readonly string[], text: string): boolean {
623
+ const [head, ...rest] = argv
624
+ if (head === undefined) return false
625
+ if (ANY_ARGUMENTS.has(head)) return true
626
+ switch (head) {
627
+ case 'pwd':
628
+ case 'whoami':
629
+ case 'alias':
630
+ return rest.length === 0
631
+ case 'cd':
632
+ return rest.length <= 1
633
+ case 'history':
634
+ return rest.length === 0 || (rest.length === 1 && /^\d+$/.test(rest[0] as string))
635
+ case 'arch':
636
+ return rest.length === 0 || (rest.length === 1 && (rest[0] === '-h' || rest[0] === '--help'))
637
+ case 'ifconfig':
638
+ return (
639
+ rest.length === 0 ||
640
+ (rest.length === 1 && /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(rest[0] as string))
641
+ )
642
+ case 'ip':
643
+ return rest.length === 1 && rest[0] === 'addr'
644
+ case 'node':
645
+ return rest.length === 1 && (rest[0] === '-v' || rest[0] === '--version')
646
+ case 'python':
647
+ case 'python3':
648
+ return rest.length === 1 && rest[0] === '--version'
649
+ case 'claude':
650
+ return rest.length === 1 && (rest[0] === '-h' || rest[0] === '--help')
651
+ case 'uniq':
652
+ return uniqIsReadOnly(rest)
653
+ case 'jq':
654
+ return jqIsReadOnly(rest)
655
+ case 'find':
656
+ return findIsReadOnly(rest)
657
+ case 'docker':
658
+ return dockerIsReadOnly(rest)
659
+ default:
660
+ break
661
+ }
662
+ // Longest table key that starts the vector: `git config --get` before `git`.
663
+ let selected: { key: string; flagTable: FlagTable; words: number } | undefined
664
+ for (const [key, flagTable] of Object.entries(FLAG_TABLES)) {
665
+ const words = key.split(' ')
666
+ if (argv.length < words.length) continue
667
+ if (!words.every((w, i) => argv[i] === w)) continue
668
+ if (selected === undefined || words.length > selected.words)
669
+ selected = { key, flagTable, words: words.length }
670
+ }
671
+ if (selected === undefined) return false
672
+ const { key, flagTable, words } = selected
673
+ const rest2 = argv.slice(words)
674
+ for (const word of rest2) {
675
+ if (word.includes('$')) return false
676
+ if (word.includes('{') && (word.includes(',') || word.includes('..'))) return false
677
+ }
678
+ if (head === 'git' && argv[1] === 'ls-remote') {
679
+ if (argv.some((a) => a === '-o' || a === '--server-option' || a.startsWith('--server-option=')))
680
+ return false
681
+ }
682
+ if (!flagsAreVetted(argv, words, flagTable, head)) return false
683
+ if ((GREP_FAMILY.has(head) || head === 'rg') && /[\n\r]/.test(text)) return false
684
+ const check = GIT_CHECKS[key]
685
+ if (check?.(rest2)) return false
686
+ return true
687
+ }
688
+
689
+ // ---------------------------------------------------------------------------
690
+ // redirections
691
+ // ---------------------------------------------------------------------------
692
+
693
+ function redirectionsAreHarmless(
694
+ redirections: readonly {
695
+ readonly operator: string
696
+ readonly target: ShellWord
697
+ }[],
698
+ ): boolean {
699
+ for (const { operator, target } of redirections) {
700
+ const value = target.value
701
+ if (isDynamic(target)) return false
702
+ if (/^\/dev\/(?:tcp|udp)\//.test(value)) return false
703
+ if (operator === '>&' || operator === '<&') {
704
+ if (/^\d+$/.test(value) || value === '-') continue
705
+ if (value === '/dev/null') continue
706
+ return false
707
+ }
708
+ if (operator === '<') {
709
+ if (looksLikeNetworkPath(value)) return false
710
+ if (/^~(?!\/|$)/.test(target.text)) return false
711
+ continue
712
+ }
713
+ // Any writing operator: only the null device.
714
+ if (value === '/dev/null' && !target.expands) continue
715
+ return false
716
+ }
717
+ return true
718
+ }
719
+
720
+ // ---------------------------------------------------------------------------
721
+ // the entry point
722
+ // ---------------------------------------------------------------------------
723
+
724
+ /**
725
+ * Whether `line` only reads: every command in it is vetted and nothing in it
726
+ * is decided at run time. False for anything this module cannot vouch for.
727
+ */
728
+ export function isReadOnlyCommandLine(line: string, dialect: ShellDialect = 'bash'): boolean {
729
+ if (dialect === 'cmd') return false
730
+ if (line.trim() === '' || line.length > MAX_LINE_LENGTH) return false
731
+ if (hasDynamicSyntax(line)) return false
732
+ if (looksLikeNetworkPath(line.trim())) return false
733
+
734
+ const lexed = lexShellCommandLine(line, { dialect })
735
+ if (!lexed.complete || lexed.opaque || lexed.commands.length === 0) return false
736
+ if (lexed.compoundWords.length > 0) return false
737
+ if (!redirectionsAreHarmless(lexed.redirections)) return false
738
+
739
+ let runsGit = false
740
+ let changesDirectory = false
741
+ for (const command of lexed.commands) {
742
+ if (command.origin !== 'line') return false
743
+ if (!redirectionsAreHarmless(command.redirections)) return false
744
+ for (const word of command.words.slice(0, command.assignments)) {
745
+ const name = word.text.slice(0, Math.max(0, word.text.indexOf('=')))
746
+ if (!SAFE_ENVIRONMENT.has(name) || isDynamic(word) || word.expands) return false
747
+ }
748
+ const words = command.words.slice(command.assignments)
749
+ const head = words[0]
750
+ if (head === undefined || head.expands || head.quoted) return false
751
+ if (head.value.includes('/')) return false
752
+
753
+ // A word may differ from what the shell passes only by a plain glob or a
754
+ // leading `~`; anything else (a substitution, a brace list, `~user`, a
755
+ // locale escape) is decided at run time.
756
+ if (words.some(isDynamic)) return false
757
+ const plainExpansion = (word: ShellWord) =>
758
+ hasUnquotedGlob(word.text) || /^~(?:\/|$)/.test(word.text)
759
+ if (words.some((word) => word.expands && !plainExpansion(word))) return false
760
+ const glob = words.some((word) => word.expands && hasUnquotedGlob(word.text))
761
+ if (glob && !GLOB_OK.has(head.value)) return false
762
+ if (words.some((word) => looksLikeNetworkPath(word.value))) return false
763
+
764
+ const argv = words.map((word) => word.value)
765
+ if (!commandIsReadOnly(argv, command.text)) return false
766
+ if (head.value === 'git') runsGit = true
767
+ if (head.value === 'cd') changesDirectory = true
768
+ }
769
+ if (runsGit && changesDirectory) return false
770
+ return true
771
+ }