@namzu/cli 29.1.0 → 32.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 (311) hide show
  1. package/CHANGELOG.md +296 -1
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +5 -1
  4. package/dist/cli.js.map +1 -1
  5. package/dist/commands/acp.d.ts +20 -2
  6. package/dist/commands/acp.d.ts.map +1 -1
  7. package/dist/commands/acp.js +42 -5
  8. package/dist/commands/acp.js.map +1 -1
  9. package/dist/commands/drain.d.ts.map +1 -1
  10. package/dist/commands/drain.js +2 -1
  11. package/dist/commands/drain.js.map +1 -1
  12. package/dist/commands/exec-json.d.ts.map +1 -1
  13. package/dist/commands/exec-json.js +24 -1
  14. package/dist/commands/exec-json.js.map +1 -1
  15. package/dist/commands/exec.d.ts.map +1 -1
  16. package/dist/commands/exec.js +5 -1
  17. package/dist/commands/exec.js.map +1 -1
  18. package/dist/commands/prior-messages.d.ts.map +1 -1
  19. package/dist/commands/prior-messages.js +42 -2
  20. package/dist/commands/prior-messages.js.map +1 -1
  21. package/dist/commands/schedule.d.ts.map +1 -1
  22. package/dist/commands/schedule.js +7 -5
  23. package/dist/commands/schedule.js.map +1 -1
  24. package/dist/config/composer-triggers.d.ts +41 -0
  25. package/dist/config/composer-triggers.d.ts.map +1 -0
  26. package/dist/config/composer-triggers.js +80 -0
  27. package/dist/config/composer-triggers.js.map +1 -0
  28. package/dist/config/load.d.ts.map +1 -1
  29. package/dist/config/load.js +58 -0
  30. package/dist/config/load.js.map +1 -1
  31. package/dist/config/schema.d.ts +34 -0
  32. package/dist/config/schema.d.ts.map +1 -1
  33. package/dist/config/schema.js.map +1 -1
  34. package/dist/context/doctrine.d.ts +3 -3
  35. package/dist/context/doctrine.d.ts.map +1 -1
  36. package/dist/context/doctrine.js +3 -3
  37. package/dist/context/doctrine.js.map +1 -1
  38. package/dist/context/environment.d.ts +26 -3
  39. package/dist/context/environment.d.ts.map +1 -1
  40. package/dist/context/environment.js +68 -9
  41. package/dist/context/environment.js.map +1 -1
  42. package/dist/context/runtime-message.d.ts.map +1 -1
  43. package/dist/context/runtime-message.js +4 -0
  44. package/dist/context/runtime-message.js.map +1 -1
  45. package/dist/integrations/mcp/servers.d.ts +72 -5
  46. package/dist/integrations/mcp/servers.d.ts.map +1 -1
  47. package/dist/integrations/mcp/servers.js +144 -23
  48. package/dist/integrations/mcp/servers.js.map +1 -1
  49. package/dist/integrations/notifications/desktop.d.ts +2 -0
  50. package/dist/integrations/notifications/desktop.d.ts.map +1 -1
  51. package/dist/integrations/notifications/desktop.js +6 -5
  52. package/dist/integrations/notifications/desktop.js.map +1 -1
  53. package/dist/integrations/plugins/runtime.d.ts +10 -2
  54. package/dist/integrations/plugins/runtime.d.ts.map +1 -1
  55. package/dist/integrations/plugins/runtime.js +16 -29
  56. package/dist/integrations/plugins/runtime.js.map +1 -1
  57. package/dist/integrations/providers/harness-credentials.d.ts +9 -4
  58. package/dist/integrations/providers/harness-credentials.d.ts.map +1 -1
  59. package/dist/integrations/providers/harness-credentials.js +16 -8
  60. package/dist/integrations/providers/harness-credentials.js.map +1 -1
  61. package/dist/integrations/resident/session-step.d.ts.map +1 -1
  62. package/dist/integrations/resident/session-step.js +2 -1
  63. package/dist/integrations/resident/session-step.js.map +1 -1
  64. package/dist/integrations/sessions/store.d.ts.map +1 -1
  65. package/dist/integrations/sessions/store.js +20 -2
  66. package/dist/integrations/sessions/store.js.map +1 -1
  67. package/dist/integrations/subagents/NamzuCliAgent.d.ts +8 -0
  68. package/dist/integrations/subagents/NamzuCliAgent.d.ts.map +1 -0
  69. package/dist/integrations/subagents/NamzuCliAgent.js +9 -0
  70. package/dist/integrations/subagents/NamzuCliAgent.js.map +1 -0
  71. package/dist/integrations/subagents/runtime.d.ts +5 -4
  72. package/dist/integrations/subagents/runtime.d.ts.map +1 -1
  73. package/dist/integrations/subagents/runtime.js +46 -11
  74. package/dist/integrations/subagents/runtime.js.map +1 -1
  75. package/dist/integrations/web/open-url.d.ts +24 -0
  76. package/dist/integrations/web/open-url.d.ts.map +1 -0
  77. package/dist/integrations/web/open-url.js +73 -0
  78. package/dist/integrations/web/open-url.js.map +1 -0
  79. package/dist/integrations/web/search.d.ts.map +1 -1
  80. package/dist/integrations/web/search.js +8 -6
  81. package/dist/integrations/web/search.js.map +1 -1
  82. package/dist/permissions/rules.d.ts +5 -1
  83. package/dist/permissions/rules.d.ts.map +1 -1
  84. package/dist/permissions/rules.js +85 -3
  85. package/dist/permissions/rules.js.map +1 -1
  86. package/dist/schedule/build.d.ts +26 -2
  87. package/dist/schedule/build.d.ts.map +1 -1
  88. package/dist/schedule/build.js +139 -20
  89. package/dist/schedule/build.js.map +1 -1
  90. package/dist/schedule/changes.d.ts +6 -0
  91. package/dist/schedule/changes.d.ts.map +1 -1
  92. package/dist/schedule/changes.js +23 -0
  93. package/dist/schedule/changes.js.map +1 -1
  94. package/dist/schedule/commands/add.d.ts +1 -1
  95. package/dist/schedule/commands/add.d.ts.map +1 -1
  96. package/dist/schedule/commands/add.js +188 -54
  97. package/dist/schedule/commands/add.js.map +1 -1
  98. package/dist/schedule/commands/lifecycle.d.ts.map +1 -1
  99. package/dist/schedule/commands/lifecycle.js +2 -1
  100. package/dist/schedule/commands/lifecycle.js.map +1 -1
  101. package/dist/schedule/commands/list.d.ts +2 -0
  102. package/dist/schedule/commands/list.d.ts.map +1 -1
  103. package/dist/schedule/commands/list.js +69 -20
  104. package/dist/schedule/commands/list.js.map +1 -1
  105. package/dist/schedule/daemon/daemon.d.ts +1 -1
  106. package/dist/schedule/daemon/daemon.d.ts.map +1 -1
  107. package/dist/schedule/daemon/daemon.js +22 -5
  108. package/dist/schedule/daemon/daemon.js.map +1 -1
  109. package/dist/schedule/daemon/notify.d.ts +29 -2
  110. package/dist/schedule/daemon/notify.d.ts.map +1 -1
  111. package/dist/schedule/daemon/notify.js +71 -2
  112. package/dist/schedule/daemon/notify.js.map +1 -1
  113. package/dist/schedule/env.d.ts +8 -4
  114. package/dist/schedule/env.d.ts.map +1 -1
  115. package/dist/schedule/env.js +13 -6
  116. package/dist/schedule/env.js.map +1 -1
  117. package/dist/schedule/fire/calls.d.ts +49 -0
  118. package/dist/schedule/fire/calls.d.ts.map +1 -0
  119. package/dist/schedule/fire/calls.js +114 -0
  120. package/dist/schedule/fire/calls.js.map +1 -0
  121. package/dist/schedule/fire/fire.d.ts +1 -1
  122. package/dist/schedule/fire/fire.d.ts.map +1 -1
  123. package/dist/schedule/fire/fire.js +209 -15
  124. package/dist/schedule/fire/fire.js.map +1 -1
  125. package/dist/schedule/fire/result.d.ts +1 -1
  126. package/dist/schedule/fire/result.d.ts.map +1 -1
  127. package/dist/schedule/fire/result.js +2 -1
  128. package/dist/schedule/fire/result.js.map +1 -1
  129. package/dist/schedule/fire/run-script.d.ts +46 -0
  130. package/dist/schedule/fire/run-script.d.ts.map +1 -0
  131. package/dist/schedule/fire/run-script.js +81 -0
  132. package/dist/schedule/fire/run-script.js.map +1 -0
  133. package/dist/schedule/fire/unattended-note.d.ts +8 -0
  134. package/dist/schedule/fire/unattended-note.d.ts.map +1 -1
  135. package/dist/schedule/fire/unattended-note.js +5 -0
  136. package/dist/schedule/fire/unattended-note.js.map +1 -1
  137. package/dist/schedule/fire/wake-gate.d.ts +39 -0
  138. package/dist/schedule/fire/wake-gate.d.ts.map +1 -0
  139. package/dist/schedule/fire/wake-gate.js +90 -0
  140. package/dist/schedule/fire/wake-gate.js.map +1 -0
  141. package/dist/schedule/floor.d.ts +28 -8
  142. package/dist/schedule/floor.d.ts.map +1 -1
  143. package/dist/schedule/floor.js +624 -114
  144. package/dist/schedule/floor.js.map +1 -1
  145. package/dist/schedule/policy.d.ts +40 -0
  146. package/dist/schedule/policy.d.ts.map +1 -1
  147. package/dist/schedule/policy.js +36 -0
  148. package/dist/schedule/policy.js.map +1 -1
  149. package/dist/schedule/script-check.d.ts +53 -0
  150. package/dist/schedule/script-check.d.ts.map +1 -0
  151. package/dist/schedule/script-check.js +145 -0
  152. package/dist/schedule/script-check.js.map +1 -0
  153. package/dist/schedule/script-shell.d.ts +3 -0
  154. package/dist/schedule/script-shell.d.ts.map +1 -0
  155. package/dist/schedule/script-shell.js +5 -0
  156. package/dist/schedule/script-shell.js.map +1 -0
  157. package/dist/schedule/service/index.d.ts +1 -1
  158. package/dist/schedule/service/index.d.ts.map +1 -1
  159. package/dist/schedule/service/index.js +20 -11
  160. package/dist/schedule/service/index.js.map +1 -1
  161. package/dist/schedule/service/manifest.js +1 -1
  162. package/dist/schedule/service/manifest.js.map +1 -1
  163. package/dist/schedule/service/windows-task.d.ts +7 -0
  164. package/dist/schedule/service/windows-task.d.ts.map +1 -1
  165. package/dist/schedule/service/windows-task.js +65 -0
  166. package/dist/schedule/service/windows-task.js.map +1 -1
  167. package/dist/schedule/service/wsl.d.ts +13 -1
  168. package/dist/schedule/service/wsl.d.ts.map +1 -1
  169. package/dist/schedule/service/wsl.js +16 -6
  170. package/dist/schedule/service/wsl.js.map +1 -1
  171. package/dist/schedule/store/atomic.d.ts +5 -2
  172. package/dist/schedule/store/atomic.d.ts.map +1 -1
  173. package/dist/schedule/store/atomic.js +7 -4
  174. package/dist/schedule/store/atomic.js.map +1 -1
  175. package/dist/schedule/store/claims.js +1 -1
  176. package/dist/schedule/store/claims.js.map +1 -1
  177. package/dist/schedule/store/history.d.ts +1 -1
  178. package/dist/schedule/store/history.d.ts.map +1 -1
  179. package/dist/schedule/store/history.js +4 -1
  180. package/dist/schedule/store/history.js.map +1 -1
  181. package/dist/schedule/store/jobs.d.ts +19 -3
  182. package/dist/schedule/store/jobs.d.ts.map +1 -1
  183. package/dist/schedule/store/jobs.js +83 -16
  184. package/dist/schedule/store/jobs.js.map +1 -1
  185. package/dist/schedule/store/state.js +1 -1
  186. package/dist/schedule/store/state.js.map +1 -1
  187. package/dist/schedule/types.d.ts +98 -6
  188. package/dist/schedule/types.d.ts.map +1 -1
  189. package/dist/schedule/types.js +25 -1
  190. package/dist/schedule/types.js.map +1 -1
  191. package/dist/schedule/visible-source.d.ts +18 -0
  192. package/dist/schedule/visible-source.d.ts.map +1 -0
  193. package/dist/schedule/visible-source.js +59 -0
  194. package/dist/schedule/visible-source.js.map +1 -0
  195. package/dist/skills/directory.d.ts +38 -0
  196. package/dist/skills/directory.d.ts.map +1 -0
  197. package/dist/skills/directory.js +51 -0
  198. package/dist/skills/directory.js.map +1 -0
  199. package/dist/test-support/toolset.d.ts +23 -0
  200. package/dist/test-support/toolset.d.ts.map +1 -0
  201. package/dist/test-support/toolset.js +27 -0
  202. package/dist/test-support/toolset.js.map +1 -0
  203. package/dist/tui/App.d.ts.map +1 -1
  204. package/dist/tui/App.js +343 -64
  205. package/dist/tui/App.js.map +1 -1
  206. package/dist/tui/ChoicePicker.d.ts +2 -0
  207. package/dist/tui/ChoicePicker.d.ts.map +1 -1
  208. package/dist/tui/ChoicePicker.js +10 -2
  209. package/dist/tui/ChoicePicker.js.map +1 -1
  210. package/dist/tui/Composer.d.ts +62 -3
  211. package/dist/tui/Composer.d.ts.map +1 -1
  212. package/dist/tui/Composer.js +162 -25
  213. package/dist/tui/Composer.js.map +1 -1
  214. package/dist/tui/ComposerFrame.d.ts +1 -1
  215. package/dist/tui/ComposerFrame.js +3 -3
  216. package/dist/tui/ComposerFrame.js.map +1 -1
  217. package/dist/tui/EffortSlider.d.ts +8 -9
  218. package/dist/tui/EffortSlider.d.ts.map +1 -1
  219. package/dist/tui/EffortSlider.js +15 -12
  220. package/dist/tui/EffortSlider.js.map +1 -1
  221. package/dist/tui/PermissionOverlay.d.ts +13 -1
  222. package/dist/tui/PermissionOverlay.d.ts.map +1 -1
  223. package/dist/tui/PermissionOverlay.js +26 -4
  224. package/dist/tui/PermissionOverlay.js.map +1 -1
  225. package/dist/tui/StatusBar.d.ts +12 -12
  226. package/dist/tui/StatusBar.d.ts.map +1 -1
  227. package/dist/tui/StatusBar.js +15 -15
  228. package/dist/tui/StatusBar.js.map +1 -1
  229. package/dist/tui/agent.d.ts +73 -12
  230. package/dist/tui/agent.d.ts.map +1 -1
  231. package/dist/tui/agent.js +430 -180
  232. package/dist/tui/agent.js.map +1 -1
  233. package/dist/tui/hypermode.d.ts +37 -0
  234. package/dist/tui/hypermode.d.ts.map +1 -0
  235. package/dist/tui/hypermode.js +58 -0
  236. package/dist/tui/hypermode.js.map +1 -0
  237. package/dist/tui/open-browser.d.ts +29 -1
  238. package/dist/tui/open-browser.d.ts.map +1 -1
  239. package/dist/tui/open-browser.js +83 -11
  240. package/dist/tui/open-browser.js.map +1 -1
  241. package/dist/tui/permission-review.d.ts +11 -2
  242. package/dist/tui/permission-review.d.ts.map +1 -1
  243. package/dist/tui/permission-review.js +176 -19
  244. package/dist/tui/permission-review.js.map +1 -1
  245. package/dist/tui/schedule/host-commands.d.ts.map +1 -1
  246. package/dist/tui/schedule/host-commands.js +95 -33
  247. package/dist/tui/schedule/host-commands.js.map +1 -1
  248. package/dist/tui/schedule/integration.d.ts +6 -2
  249. package/dist/tui/schedule/integration.d.ts.map +1 -1
  250. package/dist/tui/schedule/integration.js +3 -1
  251. package/dist/tui/schedule/integration.js.map +1 -1
  252. package/dist/tui/schedule/resume.d.ts.map +1 -1
  253. package/dist/tui/schedule/resume.js +10 -1
  254. package/dist/tui/schedule/resume.js.map +1 -1
  255. package/dist/tui/schedule/tool-host.d.ts +18 -1
  256. package/dist/tui/schedule/tool-host.d.ts.map +1 -1
  257. package/dist/tui/schedule/tool-host.js +384 -84
  258. package/dist/tui/schedule/tool-host.js.map +1 -1
  259. package/dist/tui/slashCommands.d.ts +23 -5
  260. package/dist/tui/slashCommands.d.ts.map +1 -1
  261. package/dist/tui/slashCommands.js +80 -31
  262. package/dist/tui/slashCommands.js.map +1 -1
  263. package/dist/tui/theme.d.ts +10 -4
  264. package/dist/tui/theme.d.ts.map +1 -1
  265. package/dist/tui/theme.js +4 -3
  266. package/dist/tui/theme.js.map +1 -1
  267. package/dist/tui/triggers/analyze.d.ts +57 -0
  268. package/dist/tui/triggers/analyze.d.ts.map +1 -0
  269. package/dist/tui/triggers/analyze.js +221 -0
  270. package/dist/tui/triggers/analyze.js.map +1 -0
  271. package/dist/tui/triggers/context-text.d.ts +21 -0
  272. package/dist/tui/triggers/context-text.d.ts.map +1 -0
  273. package/dist/tui/triggers/context-text.js +44 -0
  274. package/dist/tui/triggers/context-text.js.map +1 -0
  275. package/dist/tui/triggers/copy.d.ts +38 -0
  276. package/dist/tui/triggers/copy.d.ts.map +1 -0
  277. package/dist/tui/triggers/copy.js +163 -0
  278. package/dist/tui/triggers/copy.js.map +1 -0
  279. package/dist/tui/triggers/detect.d.ts +83 -0
  280. package/dist/tui/triggers/detect.d.ts.map +1 -0
  281. package/dist/tui/triggers/detect.js +295 -0
  282. package/dist/tui/triggers/detect.js.map +1 -0
  283. package/dist/tui/triggers/fold.d.ts +34 -0
  284. package/dist/tui/triggers/fold.d.ts.map +1 -0
  285. package/dist/tui/triggers/fold.js +44 -0
  286. package/dist/tui/triggers/fold.js.map +1 -0
  287. package/dist/tui/triggers/pattern.d.ts +66 -0
  288. package/dist/tui/triggers/pattern.d.ts.map +1 -0
  289. package/dist/tui/triggers/pattern.js +243 -0
  290. package/dist/tui/triggers/pattern.js.map +1 -0
  291. package/dist/tui/triggers/provenance.d.ts +58 -0
  292. package/dist/tui/triggers/provenance.d.ts.map +1 -0
  293. package/dist/tui/triggers/provenance.js +98 -0
  294. package/dist/tui/triggers/provenance.js.map +1 -0
  295. package/dist/tui/triggers/registry.d.ts +113 -0
  296. package/dist/tui/triggers/registry.d.ts.map +1 -0
  297. package/dist/tui/triggers/registry.js +164 -0
  298. package/dist/tui/triggers/registry.js.map +1 -0
  299. package/dist/tui/triggers/setting.d.ts +18 -0
  300. package/dist/tui/triggers/setting.d.ts.map +1 -0
  301. package/dist/tui/triggers/setting.js +61 -0
  302. package/dist/tui/triggers/setting.js.map +1 -0
  303. package/dist/tui/triggers/verbs.d.ts +29 -0
  304. package/dist/tui/triggers/verbs.d.ts.map +1 -0
  305. package/dist/tui/triggers/verbs.js +140 -0
  306. package/dist/tui/triggers/verbs.js.map +1 -0
  307. package/dist/tui/types.d.ts +3 -1
  308. package/dist/tui/types.d.ts.map +1 -1
  309. package/package.json +12 -12
  310. package/skills/README.md +5 -0
  311. package/skills/schedule-task/SKILL.md +123 -5
@@ -51,13 +51,19 @@
51
51
  * - **What the lexer cannot account for** — an opaque line (a command
52
52
  * substitution, `eval`-like constructs, a syntax error, a construct it does
53
53
  * not model), a command whose name expands, or a program that runs text as
54
- * code (`sh` reading its input, `sudo bash -c`, `python`, `xargs`, `watch`
55
- * …) — is denied when the line's text mentions anything the floor
56
- * protects: a scheduler word, `namzu`, NAMZU_HOME's name or a path into it.
57
- * That is the tripwire, deliberately textual and cheap. Text such a program
58
- * may run — an argument, a here-string, a here-document's body — is also
59
- * read as a command line of its own, so an escape only the inner shell
60
- * undoes does not hide it.
54
+ * code (`sh` reading its input, `sudo bash -c`, `powershell`, `python`,
55
+ * `xargs`, `watch` …) — is denied when the line's text holds something that
56
+ * could reach what the floor protects (see {@link Floor.tripwire}): a
57
+ * `schedule` subcommand that changes something with the CLI or an
58
+ * expansion in reach, the scheduler service's name, a service tool with a
59
+ * namzu name, a process killer, `NAMZU_HOME` by name or path, or the
60
+ * Windows browser's profile folder. That is the tripwire, deliberately
61
+ * textual and cheap. The product's name on its own is not in that list: a
62
+ * message box saying "Namzu: scheduled job running" reaches nothing, and a
63
+ * run that showed one was refused for it. Text such a program may run — an
64
+ * argument, a here-string, a here-document's body — is also read as a
65
+ * command line of its own, so an escape only the inner shell undoes does
66
+ * not hide it.
61
67
 
62
68
  * - **The Windows browser's profiles**, on the same words, spellings and
63
69
  * directories: a path whose segments run `AppData/Local/namzu` (any user,
@@ -77,13 +83,22 @@
77
83
  *
78
84
  * Everything here is linear in the input.
79
85
  */
80
- import { builtinCommandArguments, commandArgumentOf, lexShellCommandLine, } from '@namzu/sdk';
86
+ import { builtinCommandArguments, commandArgumentOf, lexShellCommandLine, nestedShellCommand, programPositions, } from '@namzu/sdk';
81
87
  /** Scheduler verbs a run may use: they read, they change nothing. */
82
88
  export const READ_ONLY_VERBS = ['list', 'show', 'status', 'history', 'logs'];
83
89
  /** The floor's reason for denying a call, or null: what the rule below decides on. */
84
90
  export function scheduledRunFloorVerdict(options) {
85
91
  const floor = new Floor(options);
86
- return (call) => floor.verdict(call);
92
+ return (call) => floor.find(call)?.reason ?? null;
93
+ }
94
+ /** The floor's finding for a call, or null: the reason and what matched. */
95
+ export function scheduledRunFloorFinding(options) {
96
+ const floor = new Floor(options);
97
+ return (call) => floor.find(call);
98
+ }
99
+ /** What a refusal says: the rule that matched, and that another wording will not help. */
100
+ export function floorRefusal(finding) {
101
+ return `the scheduled-run floor refused this call: ${finding.detail}. It holds for every scheduled run, so rewording the call will not help`;
87
102
  }
88
103
  /**
89
104
  * Rules this module made, which decide `deny` or nothing: a rule here never
@@ -100,7 +115,13 @@ export function scheduledRunFloorRule(options) {
100
115
  const rule = {
101
116
  type: 'predicate',
102
117
  description: `the scheduled-run floor: a scheduled run may not stop, disable or remove the scheduler, run a \`namzu schedule\` subcommand other than ${READ_ONLY_VERBS.join(', ')}, or name NAMZU_HOME (${options.namzuHome}) or the Windows browser's profile folder (%LOCALAPPDATA%\\namzu) in any argument. It holds for every scheduled run, so rewording the call will not help`,
103
- decide: (call) => (floor.verdict(call) === null ? null : 'deny'),
118
+ decide: (call) => (floor.find(call) === null ? null : 'deny'),
119
+ // Asked only after `decide` denied, so the reading is done twice for a
120
+ // refused call and once for every other.
121
+ describe: (call) => {
122
+ const finding = floor.find(call);
123
+ return finding ? floorRefusal(finding) : null;
124
+ },
104
125
  };
105
126
  DENY_ONLY.add(rule);
106
127
  return rule;
@@ -164,19 +185,19 @@ const STRING_OPTIONS = {
164
185
  sudo: /^-(?:[a-z]*[si][a-z]*|-shell|-login)$/,
165
186
  env: /^-(?:[a-z]*s[a-z]*|-split-string(?:=.*)?)$/i,
166
187
  };
167
- /** Words that, mentioned anywhere in a line the lexer cannot account for, deny it. */
168
- const SENSITIVE = [
169
- 'namzu',
170
- 'schedul',
171
- 'systemctl',
172
- 'launchctl',
173
- 'schtasks',
174
- 'pkill',
175
- 'killall',
176
- 'busctl',
177
- 'dbus-send',
178
- 'gdbus',
179
- ];
188
+ /** PowerShell's two names, wherever the floor treats them as an unread shell. */
189
+ const POWERSHELL_NAMES = new Set(['powershell', 'pwsh']);
190
+ /**
191
+ * `-EncodedCommand` and every unambiguous prefix PowerShell itself accepts
192
+ * (`-e`, `-en`, `-enc`, …, up to the full name), case-insensitive. Unlike
193
+ * `-Command`/`-c`, whose literal text the tripwire can still read, the
194
+ * argument here is base64 of UTF-16LE: nothing in it can be read as text, so
195
+ * a line the tripwire finds nothing in front of proves nothing about what it
196
+ * runs.
197
+ */
198
+ const ENCODED_COMMAND_FLAG = /^-e(?:n(?:c(?:o(?:d(?:e(?:d(?:c(?:o(?:m(?:m(?:a(?:n(?:d)?)?)?)?)?)?)?)?)?)?)?)?)?$/i;
199
+ /** What a refusal says about a `schedule` verb that is not read-only. */
200
+ const NOT_READ_ONLY = `a \`namzu schedule\` subcommand other than ${READ_ONLY_VERBS.join(', ')}`;
180
201
  const NAME_CHAR = /[a-z0-9._-]/;
181
202
  function lower(text) {
182
203
  return text.toLowerCase();
@@ -187,6 +208,12 @@ function commandName(value) {
187
208
  const base = lower(cut < 0 ? value : value.slice(cut + 1));
188
209
  return base.endsWith('.exe') ? base.slice(0, -4) : base;
189
210
  }
211
+ /** Text as a refusal quotes it: on one line, cut to `max` characters, in backticks. */
212
+ function shown(text, max = 80) {
213
+ const flat = text.replace(/\s*\n\s*/g, ' ⏎ ');
214
+ const cut = [...flat].length > max ? `${[...flat].slice(0, max - 1).join('')}…` : flat;
215
+ return cut.includes('`') ? `\`\` ${cut} \`\`` : `\`${cut}\``;
216
+ }
190
217
  /**
191
218
  * `$"…"` read as its text. The lexer leaves locale quoting as written and
192
219
  * flags the word, because a translation catalogue could change it; the text
@@ -214,68 +241,106 @@ function wordsOf(command, catalogue) {
214
241
  // ---------------------------------------------------------------------------
215
242
  // Scheduler commands
216
243
  /**
217
- * Whether one command's words, in order, reach the scheduler. Each role is
218
- * taken at its first place after the one before; for a question of "is
219
- * there such a subsequence" that loses nothing, and it is one pass.
244
+ * Whether one command's words, in order, reach the scheduler, and if so what
245
+ * they do, in words. Each role is taken at its first place after the one
246
+ * before; for a question of "is there such a subsequence" that loses
247
+ * nothing, and it is one pass.
220
248
  */
221
- function reachesScheduler(words, daemonCommandLine) {
249
+ function reachesScheduler(command, words, daemonCommandLine, dialect) {
222
250
  const n = words.length;
223
251
  const find = (from, test) => {
224
252
  for (let i = from; i < n; i++)
225
- if (test(words[i]))
253
+ if (test(words[i], i))
226
254
  return i;
227
255
  return -1;
228
256
  };
229
- const named = (name) => (w) => w.wild ? w.text.includes(name) : commandName(w.text) === name;
257
+ // Where a program name could stand: `programPositions` — the SDK's one
258
+ // answer to "where does this command actually exec a program" — unwraps
259
+ // a chain of re-exec wrappers (`sudo`, `env`, `nice`, `timeout`, …) with
260
+ // each one's own real option grammar, rather than assuming the program
261
+ // sits right after the wrapper's name (`timeout 5 prog`, `stdbuf -oL
262
+ // prog` and `ionice -c2 -n7 prog` all take at least one word of their
263
+ // own first). `words`/`command.words` share indices, so a resolved
264
+ // position's word maps straight back to one. A position `programPositions`
265
+ // could not resolve because that word ITSELF expands (`sudo
266
+ // $(echo systemctl) stop x`) still carries the word, so it becomes a
267
+ // head here too — same as before, just correctly placed after however
268
+ // many wrapper words came first. A literal `source`/`.` with a literal
269
+ // path, or a literal `eval` whose literal payload lexes cleanly, is read
270
+ // the same way and adds its own resolved position(s) here too. A
271
+ // position it could not resolve for a structural reason with no wild
272
+ // word of its own (an option a wrapper does not recognise, too many
273
+ // wrappers, an opaque `eval` payload) carries no word and adds nothing
274
+ // here; that gap is not this fix's scope — an interpreter or unread
275
+ // construct is the tripwire's and `runsUnreadText`'s job, not this one's.
276
+ const heads = new Set();
277
+ for (const position of programPositions(command, dialect)) {
278
+ if (position.word === undefined)
279
+ continue;
280
+ const idx = command.words.indexOf(position.word);
281
+ if (idx >= 0)
282
+ heads.add(idx);
283
+ }
284
+ // A wild (expanding) word in one of those positions cannot be read as
285
+ // NOT being the tool in question — a substring check on its raw,
286
+ // unevaluated text (`$(echo pk)ill`) is defeated by gluing a literal
287
+ // suffix onto the substitution, which never contains the tool's name as
288
+ // one contiguous run. Unknown never passes: such a word counts as being
289
+ // every name checked here, the same way `namesNamzu`/a verb check
290
+ // already treats a wild argument. A wild word elsewhere (an argument, a
291
+ // path — `find ~/.namzu -exec …`'s `~/.namzu`) is not a program name and
292
+ // is read as what it actually is, an argument, by the checks below.
293
+ const named = (name) => (w, i) => (w.wild && heads.has(i)) || commandName(w.text) === name;
230
294
  const namesNamzu = (w) => w.wild || w.text.includes('namzu');
231
295
  // systemctl <verb> <unit>
232
296
  const systemctl = find(0, named('systemctl'));
233
297
  if (systemctl >= 0) {
234
298
  if (find(systemctl + 1, (w) => !w.wild && SYSTEMCTL_VERBS_ALONE.has(w.text)) >= 0)
235
- return true;
299
+ return 'stops every service, the scheduler with them';
236
300
  const verb = find(systemctl + 1, (w) => w.wild || SYSTEMCTL_VERBS_WITH_UNIT.has(w.text));
237
301
  // systemctl matches unit names against a glob.
238
302
  if (verb >= 0 && find(verb + 1, (u) => namesNamzu(u) || /[*?[]/.test(u.text)) >= 0)
239
- return true;
303
+ return "stops or disables the scheduler's service";
240
304
  }
241
305
  // launchctl <verb> <label or plist>
242
306
  const launchctl = find(0, named('launchctl'));
243
307
  if (launchctl >= 0) {
244
308
  const verb = find(launchctl + 1, (w) => w.wild || LAUNCHCTL_VERBS.has(w.text));
245
309
  if (verb >= 0 && find(verb + 1, namesNamzu) >= 0)
246
- return true;
310
+ return "removes the scheduler's launchd agent";
247
311
  }
248
312
  // schtasks, whose switches come in any order.
249
313
  const schtasks = find(0, named('schtasks'));
250
314
  if (schtasks >= 0) {
251
315
  const rest = words.slice(schtasks + 1);
252
316
  if (rest.some((w) => w.wild || SCHTASKS_SWITCH.test(w.text)) && rest.some(namesNamzu))
253
- return true;
317
+ return "deletes, changes or ends the scheduler's task";
254
318
  }
255
319
  // pkill / killall: a pattern that could match the daemon's process.
256
- const killer = find(0, (w) => named('pkill')(w) || named('killall')(w));
320
+ const killer = find(0, (w, i) => named('pkill')(w, i) || named('killall')(w, i));
257
321
  if (killer >= 0) {
322
+ const kills = "has a pattern that could match the scheduler's process";
258
323
  const full = words
259
324
  .slice(killer + 1)
260
325
  .some((w) => /^-[a-z]*f/.test(w.text) || w.text === '--full');
261
326
  for (let i = killer + 1; i < n; i++) {
262
327
  const w = words[i];
263
328
  if (w.wild)
264
- return true;
329
+ return kills;
265
330
  if (w.text.startsWith('-'))
266
331
  continue;
267
332
  if (/[.*+?^$|()[\]{}\\]/.test(w.text))
268
- return true;
333
+ return kills;
269
334
  if (w.text.includes('namzu') || w.text.includes('schedul'))
270
- return true;
335
+ return kills;
271
336
  if ('node'.includes(w.text) || (full && daemonCommandLine.includes(w.text)))
272
- return true;
337
+ return kills;
273
338
  }
274
339
  }
275
340
  // A D-Bus call to systemd naming the unit.
276
- const dbus = find(0, (w) => DBUS_TOOLS.has(commandName(w.text)));
341
+ const dbus = find(0, (w, i) => (w.wild && heads.has(i)) || DBUS_TOOLS.has(commandName(w.text)));
277
342
  if (dbus >= 0 && find(dbus + 1, namesNamzu) >= 0)
278
- return true;
343
+ return 'calls systemd about a namzu unit';
279
344
  // The CLI, by name (`namzu`, a path to it, `npx @namzu/cli`) or through
280
345
  // its entry script or a JavaScript runtime: `schedule` and then anything
281
346
  // but a read-only verb.
@@ -283,7 +348,7 @@ function reachesScheduler(words, daemonCommandLine) {
283
348
  const weak = find(0, (w) => CLI_ENTRY.test(commandName(w.text)) || JS_RUNTIMES.has(commandName(w.text)));
284
349
  // After the CLI's own name, a word that expands may be `schedule stop`.
285
350
  if (strong >= 0 && find(strong + 1, (w) => w.wild) >= 0)
286
- return true;
351
+ return 'passes the CLI a word that expands at runtime, which could be a `schedule` subcommand';
287
352
  const cli = strong < 0 ? weak : weak < 0 ? strong : Math.min(strong, weak);
288
353
  if (cli >= 0) {
289
354
  for (let i = cli + 1; i < n; i++) {
@@ -292,40 +357,76 @@ function reachesScheduler(words, daemonCommandLine) {
292
357
  const verb = words[i + 1];
293
358
  if (verb === undefined)
294
359
  continue;
295
- if (verb.wild || !READ_ONLY_VERBS.includes(verb.text))
296
- return true;
360
+ if (verb.wild)
361
+ return 'runs a `schedule` subcommand that expands at runtime';
362
+ if (!READ_ONLY_VERBS.includes(verb.text))
363
+ return `runs \`schedule ${verb.text}\`, ${NOT_READ_ONLY}`;
297
364
  }
298
365
  }
299
- return false;
366
+ return null;
300
367
  }
301
368
  /**
302
- * Whether a command runs text the lexer did not read as commands: a shell
303
- * the lexer did not follow, an interpreter, `xargs`, `sudo -s`, `env -S`.
369
+ * Whether a command runs text the lexer did not read as commands — a shell
370
+ * the lexer did not follow, an interpreter, `xargs`, `sudo -s`, `env -S` —
371
+ * and if so which, in words.
304
372
  */
305
373
  function runsUnreadText(command) {
306
374
  const words = command.words;
307
375
  const head = command.assignments;
308
376
  const names = words.map((w) => (w.expands ? '' : commandName(w.value)));
309
- // The lexer reads the payload of a shell at the head of its command run
310
- // with `-c`. Anywhere else, or without `-c`, it did not.
311
- const dashAt = words.findIndex((w, i) => i > head && !w.expands && /^-[a-z]*c[a-z]*$/.test(w.value));
312
- // A payload that expands is not one the lexer could read.
313
- const dashC = dashAt > 0 && words[dashAt + 1] !== undefined && !words[dashAt + 1]?.expands;
377
+ // The shell at the head whose `-c` payload the lexer read, and `busybox`
378
+ // in front of it: exactly the lexer's own decision. Any other shell —
379
+ // `powershell -c`, `fish -c`, `bash.exe -c`, one given a script — runs
380
+ // text the lexer never read, whatever its options look like.
381
+ const nested = nestedShellCommand(words.slice(head));
382
+ const readTo = nested !== null && 'payload' in nested
383
+ ? head + (commandName(words[head].value) === 'busybox' ? 1 : 0)
384
+ : -1;
314
385
  for (let i = 0; i < words.length; i++) {
315
386
  const name = names[i];
316
387
  if (name === '')
317
388
  continue;
389
+ const program = shown(words[i].value);
318
390
  if (INTERPRETERS.test(name))
319
- return true;
391
+ return `${program} runs text as code`;
320
392
  if (i === head && name === '.')
321
- return true;
322
- if (SHELLS.has(name) && !(i === head && dashC))
323
- return true;
393
+ return '`.` runs a file as commands';
394
+ if (i === head && name === 'trap') {
395
+ // `trap [-lp] [action] [sigspec...]`: the action, when given, runs as
396
+ // a command line when the signal fires — read the same way a nested
397
+ // shell's `-c` payload is, just later. `-l`/`-p`/`--` alone print or
398
+ // reset and run nothing.
399
+ const action = words
400
+ .slice(i + 1)
401
+ .find((w) => w.expands || (w.value !== '-l' && w.value !== '-p' && w.value !== '--'));
402
+ if (action !== undefined)
403
+ return `${program} runs its action as a command when the signal fires`;
404
+ }
405
+ if (SHELLS.has(name) && !(i >= head && i <= readTo))
406
+ return `${program} runs commands the floor does not read`;
324
407
  }
325
- for (const [program, option] of Object.entries(STRING_OPTIONS))
326
- if (names.includes(program) && words.some((w) => !w.expands && option.test(w.value)))
327
- return true;
328
- return false;
408
+ for (const [program, option] of Object.entries(STRING_OPTIONS)) {
409
+ const flag = words.find((w) => !w.expands && option.test(w.value));
410
+ if (names.includes(program) && flag !== undefined)
411
+ return `\`${program} ${flag.value}\` runs a string as commands`;
412
+ }
413
+ return null;
414
+ }
415
+ /**
416
+ * `powershell`/`pwsh`, anywhere in the command, given `-EncodedCommand` (or
417
+ * an unambiguous prefix of it): the program word and the flag, or null.
418
+ */
419
+ function encodedCommandCall(command) {
420
+ const words = command.words;
421
+ for (let i = command.assignments; i < words.length; i++) {
422
+ const word = words[i];
423
+ if (word.expands || !POWERSHELL_NAMES.has(commandName(word.value)))
424
+ continue;
425
+ const flag = words.slice(i + 1).find((w) => !w.expands && ENCODED_COMMAND_FLAG.test(w.value));
426
+ if (flag !== undefined)
427
+ return { program: word, flag };
428
+ }
429
+ return null;
329
430
  }
330
431
  // ---------------------------------------------------------------------------
331
432
  // Paths
@@ -459,24 +560,77 @@ function endsShortOfProfile(path) {
459
560
  last !== 'namzu' &&
460
561
  'namzu'.startsWith(last));
461
562
  }
563
+ /** `schedule` as a command word: not a path segment, not a longer word (`scheduled`). */
564
+ const SCHEDULE_WORD = /(?:^|[\s,;([{|&=])schedule(?=$|[\s,;)\]}|&])/g;
565
+ /** The CLI, the runtimes it can be started through, or its entry script, as a word. */
566
+ const CLI_IN_TEXT = /namzu|bin\.[cm]?js|(?:^|[^a-z0-9_-])(?:node|nodejs|npx|pnpx|bunx|bun|deno|tsx|ts-node|npm|pnpm|yarn)(?![a-z0-9_-])/;
567
+ /** An expansion that could hold the CLI's name or path: `$X`, a backtick, `%X%`. */
568
+ const EXPANSION_IN_TEXT = /[$`]|%[a-z_][a-z0-9_]*%/;
569
+ /** The scheduler service's names: the unit and task (`namzu-scheduler…`), the launchd label. */
570
+ const SERVICE_NAME_IN_TEXT = /namzu-scheduler[a-z0-9._-]*|com\.namzu\.[a-z0-9._-]*/;
571
+ /** Tools that stop, change or remove a service or a scheduled task. */
572
+ const SERVICE_TOOL_IN_TEXT = /(?:^|[^a-z0-9_-])(systemctl|launchctl|schtasks|busctl|dbus-send|gdbus|(?:stop|disable|unregister|set)-scheduledtask|schedule\.service)(?![a-z0-9_-])/;
573
+ /** `pkill` and `killall` match a process by pattern, and the scheduler is a `node` process. */
574
+ const PROCESS_KILLER_IN_TEXT = /(?:^|[^a-z0-9_-])(pkill|killall)(?![a-z0-9_-])/;
575
+ /** `NAMZU_HOME` by name, in any shell's or language's spelling of a variable. */
576
+ const HOME_VARIABLE_IN_TEXT = /(?:^|[^a-z0-9_])namzu_home(?![a-z0-9_])/;
577
+ /** The Windows profile root's parent, spelled as a variable or a known-folder name. */
578
+ const LOCAL_APP_DATA_IN_TEXT = /localappdata|localapplicationdata|(?:^|[^a-z0-9])appdata(?![a-z0-9])/;
579
+ /** `namzu` as a path segment or a string of its own. */
580
+ const NAMZU_SEGMENT = /(?:^|[^a-z0-9_.-])namzu(?![a-z0-9_.-])/;
581
+ function escapeRegExp(text) {
582
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
583
+ }
584
+ /** A `schedule` subcommand that is not read-only, in one text, or null. */
585
+ function scheduleSubcommand(text) {
586
+ for (const match of text.matchAll(SCHEDULE_WORD)) {
587
+ const after = text.slice((match.index ?? 0) + match[0].length);
588
+ const verb = /^[\s,]*([a-z0-9][a-z0-9_-]*)/.exec(after)?.[1];
589
+ if (verb !== undefined && READ_ONLY_VERBS.includes(verb))
590
+ continue;
591
+ return verb === undefined ? 'schedule' : `schedule ${verb}`;
592
+ }
593
+ return null;
594
+ }
462
595
  class Floor {
463
596
  home;
597
+ /** NAMZU_HOME as the operator wrote it, for a refusal. */
598
+ homeShown;
464
599
  homeCore;
600
+ /** NAMZU_HOME's last segment, and the default's (`.namzu`), as path segments in text. */
601
+ homeSegments;
465
602
  userHome;
466
603
  folders;
467
604
  daemonCommandLine;
468
605
  builtinCommands;
469
606
  constructor(options) {
470
607
  this.home = normalize(lower(options.namzuHome));
608
+ this.homeShown = options.namzuHome;
471
609
  this.userHome = normalize(lower(options.userHome));
472
610
  this.folders = (options.folders ?? []).map((f) => normalize(lower(f)));
473
611
  const last = this.home.split('/').at(-1) ?? '';
474
612
  this.homeCore = last.replace(/^\.+/, '');
613
+ // A dotted name (`.namzu`) is a path segment wherever it stands; an
614
+ // undotted one only after a slash, or the product's name would be one.
615
+ this.homeSegments = [...new Set(['.namzu', last])]
616
+ .filter((name) => name.replace(/^\.+/, '').length >= 3)
617
+ .map((name) => ({
618
+ name,
619
+ pattern: name.startsWith('.')
620
+ ? new RegExp(`(?:^|[^a-z0-9_.-])${escapeRegExp(name)}(?![a-z0-9_.-])`)
621
+ : new RegExp(`[/\\\\]${escapeRegExp(name)}(?![a-z0-9_.-])`),
622
+ }));
475
623
  this.builtinCommands = builtinCommandArguments();
476
624
  this.daemonCommandLine = lower(options.daemonCommandLine ?? `${process.execPath} ${process.argv[1] ?? ''} schedule daemon`);
477
625
  }
478
- /** Why the floor denies the call, or null when it does not. */
479
- verdict(call) {
626
+ /** What the floor protects, as a refusal names it. */
627
+ protectedWords(what) {
628
+ return what === 'NAMZU_HOME'
629
+ ? `NAMZU_HOME (${this.homeShown})`
630
+ : "the Windows browser's profile folder (%LOCALAPPDATA%\\namzu)";
631
+ }
632
+ /** Why the floor denies the call and what matched, or null when it does not. */
633
+ find(call) {
480
634
  const argument = call.toolDef
481
635
  ? commandArgumentOf(call.toolDef)
482
636
  : this.builtinCommands.get(call.toolName);
@@ -490,40 +644,55 @@ class Floor {
490
644
  try {
491
645
  const named = this.stringsName(input, line);
492
646
  if (named !== null)
493
- return `names ${named}`;
647
+ return {
648
+ reason: `names ${named.what}`,
649
+ detail: `${named.where} names ${this.protectedWords(named.what)}`,
650
+ };
494
651
  if (line !== undefined)
495
652
  return this.lineVerdict(line, call.commandDialect);
496
653
  return null;
497
654
  }
498
655
  catch (error) {
499
656
  if (error instanceof TooMany)
500
- return 'too many spellings';
657
+ return {
658
+ reason: 'too many spellings',
659
+ detail: `a word in the command can be spelled more than ${MAX_ALTERNATIVES} ways once its variables are filled in, and the floor refuses rather than try them all`,
660
+ };
501
661
  throw error;
502
662
  }
503
663
  }
504
664
  // ---- any tool -----------------------------------------------------------
505
665
  /** Every string in the input but the command line, searched as text. */
506
666
  stringsName(input, line) {
507
- const stack = [input];
667
+ const stack = [
668
+ { value: input, at: '' },
669
+ ];
508
670
  let skipped = false;
509
671
  while (stack.length > 0) {
510
- const value = stack.pop();
672
+ const { value, at, key } = stack.pop();
511
673
  if (typeof value === 'string') {
512
674
  if (!skipped && value === line) {
513
675
  skipped = true;
514
676
  continue;
515
677
  }
678
+ const where = key
679
+ ? `the argument name ${shown(value)}`
680
+ : at === ''
681
+ ? 'the input'
682
+ : `the \`${at}\` argument`;
516
683
  if (this.textNamesHome(value))
517
- return 'NAMZU_HOME';
684
+ return { what: 'NAMZU_HOME', where };
518
685
  if (textNamesProfile(value))
519
- return 'the browser profiles';
686
+ return { what: 'the browser profiles', where };
520
687
  }
521
688
  else if (Array.isArray(value)) {
522
- stack.push(...value);
689
+ value.forEach((each, i) => stack.push({ value: each, at: `${at}[${i}]` }));
523
690
  }
524
691
  else if (value !== null && typeof value === 'object') {
525
- for (const [key, each] of Object.entries(value))
526
- stack.push(key, each);
692
+ for (const [name, each] of Object.entries(value)) {
693
+ const path = at === '' ? name : `${at}.${name}`;
694
+ stack.push({ value: name, at: path, key: true }, { value: each, at: path });
695
+ }
527
696
  }
528
697
  }
529
698
  return null;
@@ -554,59 +723,164 @@ class Floor {
554
723
  lineVerdict(line, dialect, depth = 0) {
555
724
  // A line for a shell that may be bash or a POSIX shell is read both
556
725
  // ways, and denied if either reading denies it.
557
- const readings = dialect === 'bash'
558
- ? [lexShellCommandLine(line, { dialect: 'bash' })]
559
- : [
560
- lexShellCommandLine(line, { dialect: 'sh' }),
561
- lexShellCommandLine(line, { dialect: 'bash' }),
562
- ];
726
+ const dialects = dialect === 'bash' ? ['bash'] : ['sh', 'bash'];
727
+ const readings = dialects.map((d) => lexShellCommandLine(line, { dialect: d }));
563
728
  const catalogue = /textdomain/i.test(line);
564
729
  const loops = /\b(?:while|until|for|select)\b/.test(line);
565
730
  let unaccounted = null;
566
- for (const reading of readings) {
567
- const reason = this.readingVerdict(reading, catalogue, loops);
568
- if (reason !== null)
569
- return reason;
570
- if (reading.opaque)
571
- unaccounted ??= 'opaque line mentions a protected name';
572
- else if (reading.commands.some(unreadCommand))
573
- unaccounted ??= 'unread text mentions a protected name';
731
+ for (let i = 0; i < readings.length; i += 1) {
732
+ const reading = readings[i];
733
+ const finding = this.readingVerdict(reading, catalogue, loops, dialects[i]);
734
+ if (finding !== null)
735
+ return finding;
736
+ if (reading.opaque) {
737
+ unaccounted ??= {
738
+ reason: 'opaque line mentions a protected name',
739
+ why: `the floor cannot read the line (${reading.reasons[0] ?? 'a construct it does not model'})`,
740
+ };
741
+ continue;
742
+ }
743
+ for (const command of reading.commands) {
744
+ const unread = unreadCommand(command);
745
+ if (unread === null)
746
+ continue;
747
+ unaccounted ??= { reason: 'unread text mentions a protected name', why: unread };
748
+ break;
749
+ }
574
750
  }
575
751
  if (unaccounted === null)
576
752
  return null;
577
- if (this.mentionsProtected(line, readings))
578
- return unaccounted;
753
+ const trip = this.tripwire(line, readings);
754
+ if (trip !== null)
755
+ return {
756
+ reason: unaccounted.reason,
757
+ detail: `${unaccounted.why}, and it holds ${shown(trip.token)} (${trip.what})${whereIn(readings, trip.pattern ?? trip.token)}`,
758
+ };
579
759
  // Text a program may run as a command line — an argument (`echo '…' |
580
760
  // sh`, `sudo bash -c '…'`), a here-string, a here-document's body —
581
761
  // is read as one, decoded, so what the tripwire looks for cannot hide
582
762
  // behind escapes that only the inner shell undoes.
583
763
  if (depth < MAX_TEXT_DEPTH)
584
764
  for (const text of fedTexts(readings)) {
585
- if (this.lineVerdict(text, 'bash', depth + 1) !== null)
586
- return unaccounted;
765
+ const inner = this.lineVerdict(text, 'bash', depth + 1);
766
+ if (inner !== null)
767
+ return {
768
+ reason: unaccounted.reason,
769
+ detail: `${unaccounted.why}, and read as a command line, ${shown(text)}: ${inner.detail}`,
770
+ };
587
771
  }
588
772
  return null;
589
773
  }
590
- /** The tripwire: the line's text names something the floor protects. */
591
- mentionsProtected(line, readings) {
592
- const squashed = lower(line)
593
- .replace(/\\\n/g, '')
594
- .replace(/["'\\$`]/g, '');
774
+ /**
775
+ * The tripwire, for a line the lexer could not account for: something in
776
+ * its text that can reach what the floor protects, or null. Read in the
777
+ * line as written, with quotes and expansion marks dropped (and once more
778
+ * with backslashes dropped too), and in every word the lexer decoded:
779
+ *
780
+ * - `NAMZU_HOME` by name (`$NAMZU_HOME`, `%NAMZU_HOME%`,
781
+ * `os.environ['NAMZU_HOME']`), its last segment as a path segment
782
+ * (`.namzu`, always), or a path into it;
783
+ * - the Windows browser's profile folder, as a path, or `LOCALAPPDATA` (or
784
+ * `AppData`) with `namzu` as a segment or a string of its own;
785
+ * - the scheduler service's name (`namzu-scheduler…`, `com.namzu.…`);
786
+ * - a service tool (`systemctl`, `launchctl`, `schtasks`, the D-Bus tools,
787
+ * PowerShell's `*-ScheduledTask`) with `namzu` or a glob in the text,
788
+ * `systemctl isolate` or `exit`; `pkill` or `killall` anywhere, because
789
+ * a pattern can match the scheduler's `node` process without naming it;
790
+ * - `schedule` as a command word followed by anything but a read-only
791
+ * verb, when the text also names the CLI (`namzu`, `@namzu/cli`,
792
+ * `bin.js`, a JavaScript runtime) or holds an expansion that could.
793
+ *
794
+ * Nothing else: the product's name in a string (`MessageBox::Show('Namzu:
795
+ * scheduled job running')`) reaches none of them.
796
+ */
797
+ tripwire(line, readings) {
798
+ const plain = lower(line).replace(/\\\n/g, '');
799
+ const unquoted = plain.replace(/["'$`]/g, '');
800
+ const squashed = unquoted.replace(/\\/g, '');
595
801
  const decoded = readings
596
802
  .flatMap((r) => r.commands.flatMap((c) => c.words.map((w) => lower(w.value))))
597
803
  .join(' ');
598
- const tokens = [...SENSITIVE, 'namzu_home'];
599
- if (this.homeCore.length >= 3)
600
- tokens.push(this.homeCore);
601
- for (const text of [squashed, decoded])
602
- if (tokens.some((token) => text.includes(token)))
603
- return true;
604
- return this.textNamesHome(line);
804
+ const texts = [squashed, unquoted, decoded];
805
+ const first = (pattern) => {
806
+ for (const text of texts) {
807
+ const match = pattern.exec(text);
808
+ if (match)
809
+ return match[1] ?? match[0].replace(/^[^a-z0-9%.]+/, '');
810
+ }
811
+ return null;
812
+ };
813
+ // NAMZU_HOME.
814
+ if (HOME_VARIABLE_IN_TEXT.test(plain) || first(HOME_VARIABLE_IN_TEXT) !== null)
815
+ return { token: 'NAMZU_HOME', what: 'the NAMZU_HOME variable' };
816
+ for (const { name, pattern } of this.homeSegments)
817
+ if (texts.some((text) => pattern.test(text)))
818
+ return { token: name, what: "NAMZU_HOME's folder name", pattern };
819
+ if (this.textNamesHome(line) || this.textNamesHome(decoded))
820
+ return { token: this.homeShown, what: 'a path into NAMZU_HOME' };
821
+ // The Windows browser's profiles.
822
+ if (textNamesProfile(line) || textNamesProfile(decoded))
823
+ return {
824
+ token: '%LOCALAPPDATA%\\namzu',
825
+ what: "a path into the Windows browser's profile folder",
826
+ };
827
+ const appData = first(LOCAL_APP_DATA_IN_TEXT);
828
+ if (appData !== null && texts.some((text) => NAMZU_SEGMENT.test(text)))
829
+ return {
830
+ token: `${appData} … namzu`,
831
+ what: "together, the Windows browser's profile folder",
832
+ };
833
+ // The service.
834
+ const service = first(SERVICE_NAME_IN_TEXT);
835
+ if (service !== null)
836
+ return { token: service, what: "the scheduler service's name" };
837
+ const tool = first(SERVICE_TOOL_IN_TEXT);
838
+ if (tool !== null) {
839
+ if (texts.some((text) => text.includes('namzu')))
840
+ return { token: `${tool} … namzu`, what: 'a service tool with a namzu name' };
841
+ if (plain.includes('*'))
842
+ return { token: `${tool} … *`, what: 'a service tool with a pattern that can match it' };
843
+ if (tool === 'systemctl') {
844
+ const all = first(/(?:^|\s)(isolate|exit)(?![a-z0-9_-])/);
845
+ if (all !== null)
846
+ return { token: `systemctl ${all}`, what: 'which stops every service' };
847
+ }
848
+ }
849
+ const killer = first(PROCESS_KILLER_IN_TEXT);
850
+ if (killer !== null)
851
+ return {
852
+ token: killer,
853
+ what: 'which kills by pattern, and the scheduler is a node process a pattern can match',
854
+ };
855
+ // The CLI's `schedule` subcommands.
856
+ // `$'…'` and `$"…"` are quotes, not expansions.
857
+ const cli = texts.some((text) => CLI_IN_TEXT.test(text)) ||
858
+ EXPANSION_IN_TEXT.test(plain.replace(/\$(?=['"])/g, ''));
859
+ if (cli)
860
+ for (const text of [squashed, decoded]) {
861
+ const subcommand = scheduleSubcommand(text);
862
+ if (subcommand !== null)
863
+ return { token: subcommand, what: NOT_READ_ONLY };
864
+ }
865
+ return null;
605
866
  }
606
- readingVerdict(reading, catalogue, loops) {
607
- for (const command of reading.commands)
608
- if (reachesScheduler(wordsOf(command, catalogue), this.daemonCommandLine))
609
- return 'scheduler command';
867
+ readingVerdict(reading, catalogue, loops, dialect) {
868
+ for (const command of reading.commands) {
869
+ const encoded = encodedCommandCall(command);
870
+ if (encoded !== null)
871
+ return {
872
+ reason: 'encoded command cannot be read at all',
873
+ detail: `${shown(encoded.program.value)} ${shown(encoded.flag.value)} runs a base64-encoded script the floor cannot read at all, so it is refused outright whatever it decodes to; use -Command '<literal text>' instead`,
874
+ };
875
+ }
876
+ for (const command of reading.commands) {
877
+ const does = reachesScheduler(command, wordsOf(command, catalogue), this.daemonCommandLine, dialect);
878
+ if (does !== null)
879
+ return {
880
+ reason: 'scheduler command',
881
+ detail: `${shown(command.words.map((w) => w.text).join(' '))} ${does}`,
882
+ };
883
+ }
610
884
  const variables = this.assignments(reading);
611
885
  const expanding = (w) => w.expands && !localeOnly(w.value, catalogue);
612
886
  const expands = reading.commands.some((c) => c.words.some(expanding)) ||
@@ -616,6 +890,7 @@ class Floor {
616
890
  // variable is spelled out with its value. One that is exported, or
617
891
  // could be, is checked where it stands.
618
892
  const exported = reading.commands.some((c) => c.words.some((w) => !w.expands && EXPORTING.has(commandName(w.value))));
893
+ const names = (what) => `names ${this.protectedWords(what)}`;
619
894
  const cwd = new Directories(this.folders);
620
895
  // In a loop, a `cd` late in the body applies to the next pass's first
621
896
  // command: every command gets every directory the line can reach.
@@ -623,8 +898,32 @@ class Floor {
623
898
  for (const command of reading.commands)
624
899
  cwd.after(command, (target) => this.spell(target, variables, cwd.current()), this.userHome);
625
900
  const seen = new Set();
901
+ /** Files an earlier command in this line wrote, resolved and normalized; content unverifiable. */
902
+ const written = new Set();
626
903
  for (const command of reading.commands) {
627
904
  const here = cwd.current();
905
+ const findExec = this.findExecFinding(command, variables, here, dialect);
906
+ if (findExec !== null)
907
+ return findExec;
908
+ const executed = executedPath(command);
909
+ if (executed !== null && !executed.expands) {
910
+ for (const base of isAbsolute(executed.value) ? [''] : here.dirs) {
911
+ const path = base === '' ? normalize(executed.value) : join(base, executed.value);
912
+ if (written.has(path)) {
913
+ return {
914
+ reason: 'runs a file the script wrote earlier',
915
+ detail: `${shown(command.text)} runs ${shown(executed.text)}, which an earlier command in this script wrote; its content cannot be verified, so it is refused rather than run unattended`,
916
+ };
917
+ }
918
+ }
919
+ }
920
+ for (const target of writeTargets(command)) {
921
+ if (target.expands)
922
+ continue;
923
+ for (const base of isAbsolute(target.value) ? [''] : here.dirs) {
924
+ written.add(base === '' ? normalize(target.value) : join(base, target.value));
925
+ }
926
+ }
628
927
  const check = (word) => this.wordNames(word, variables, here, expands);
629
928
  const standalone = command.words.length === command.assignments && !exported;
630
929
  for (const [i, word] of command.words.entries()) {
@@ -632,18 +931,27 @@ class Floor {
632
931
  continue;
633
932
  const named = check(word);
634
933
  if (named !== null)
635
- return `word names ${named}`;
934
+ return {
935
+ reason: `word names ${named}`,
936
+ detail: `the argument ${shown(word.text)} ${names(named)}`,
937
+ };
636
938
  // `X=~/.namzu` as an argument, or exported: the value on its own.
637
939
  const assigned = assignedValue(word);
638
940
  const value = assigned ? check(assigned.word) : null;
639
941
  if (value !== null)
640
- return `assigned value names ${value}`;
942
+ return {
943
+ reason: `assigned value names ${value}`,
944
+ detail: `the value assigned in ${shown(word.text)} ${names(value)}`,
945
+ };
641
946
  }
642
947
  for (const redirection of command.redirections) {
643
948
  seen.add(redirection);
644
949
  const named = redirectsTo(redirection) ? check(redirection.target) : null;
645
950
  if (named !== null)
646
- return `redirection names ${named}`;
951
+ return {
952
+ reason: `redirection names ${named}`,
953
+ detail: `the redirection ${shown(`${redirection.operator} ${redirection.target.text}`)} ${names(named)}`,
954
+ };
647
955
  }
648
956
  cwd.after(command, (target) => this.spell(target, variables, cwd.current()), this.userHome);
649
957
  }
@@ -656,12 +964,18 @@ class Floor {
656
964
  continue;
657
965
  const named = check(redirection.target);
658
966
  if (named !== null)
659
- return `redirection names ${named}`;
967
+ return {
968
+ reason: `redirection names ${named}`,
969
+ detail: `the redirection ${shown(`${redirection.operator} ${redirection.target.text}`)} ${names(named)}`,
970
+ };
660
971
  }
661
972
  for (const word of reading.compoundWords) {
662
973
  const named = check(word);
663
974
  if (named !== null)
664
- return `loop or case word names ${named}`;
975
+ return {
976
+ reason: `loop or case word names ${named}`,
977
+ detail: `the loop or case word ${shown(word.text)} ${names(named)}`,
978
+ };
665
979
  }
666
980
  return null;
667
981
  }
@@ -847,6 +1161,18 @@ class Floor {
847
1161
  }
848
1162
  return false;
849
1163
  }
1164
+ /** Whether text after an unknown expansion holds NAMZU_HOME's last segment as a path segment. */
1165
+ restNamesHome(rest) {
1166
+ let at = rest.indexOf(this.homeCore);
1167
+ while (at >= 0) {
1168
+ const before = at === 0 ? '' : rest[at - 1];
1169
+ const after = rest[at + this.homeCore.length] ?? '';
1170
+ if (/^[./\\]?$/.test(before) && !NAME_CHAR.test(after))
1171
+ return true;
1172
+ at = rest.indexOf(this.homeCore, at + 1);
1173
+ }
1174
+ return false;
1175
+ }
850
1176
  wordNamesHome(word, variables, cwd, lineExpands) {
851
1177
  if (word.expands && /\$\{?[!#]?namzu_home(?![a-z0-9_])/i.test(word.value))
852
1178
  return true;
@@ -896,13 +1222,184 @@ class Floor {
896
1222
  if (next.startsWith(partial))
897
1223
  return true;
898
1224
  }
899
- if (spelling.stop !== 'end' &&
900
- this.homeCore.length >= 3 &&
901
- spelling.rest.includes(this.homeCore))
1225
+ // After the unknown part, NAMZU_HOME's last segment as a path
1226
+ // segment: right after the expansion (`${X}namzu`, where X may end
1227
+ // in `/.`), or after a `.`, `/` or `\` (`$X/.namzu/x`). The name
1228
+ // inside other text (`$(echo namzu)`, `"$USER: namzu done"`) is not
1229
+ // a path.
1230
+ if (spelling.stop !== 'end' && this.homeCore.length >= 3 && this.restNamesHome(spelling.rest))
902
1231
  return true;
903
1232
  }
904
1233
  return false;
905
1234
  }
1235
+ /**
1236
+ * `find … -exec|-execdir|-ok|-okdir … ;|+`: the clause runs on every path
1237
+ * `find` matches, substituted for `{}` — a placeholder no static reading
1238
+ * can resolve. Refused when `{}` stands in that clause AND the search
1239
+ * could plausibly reach a protected target: the root is unknown or is
1240
+ * NAMZU_HOME itself or an ancestor of it (so `find` would recurse THROUGH
1241
+ * it), a `-name`/`-iname`/`-path`/`-ipath` pattern could match NAMZU_HOME's
1242
+ * own folder name, or the clause's own program is a tool that can stop or
1243
+ * remove a service (`{}` there could name the scheduler's unit without
1244
+ * ever spelling it in the line the floor can read).
1245
+ */
1246
+ findExecFinding(command, variables, cwd, dialect) {
1247
+ const words = command.words;
1248
+ const findAt = programPositions(command, dialect)
1249
+ .map((position) => position.word && !position.word.expands && commandName(position.word.value) === 'find'
1250
+ ? words.indexOf(position.word)
1251
+ : -1)
1252
+ .find((index) => index >= 0);
1253
+ if (findAt === undefined)
1254
+ return null;
1255
+ const head = words[findAt];
1256
+ const rootWords = [];
1257
+ let i = findAt + 1;
1258
+ while (i < words.length) {
1259
+ const w = words[i];
1260
+ if (!w.expands && (w.value.startsWith('-') || w.value === '(' || w.value === '!'))
1261
+ break;
1262
+ rootWords.push(w);
1263
+ i++;
1264
+ }
1265
+ const roots = rootWords.length > 0
1266
+ ? rootWords
1267
+ : [{ text: '.', value: '.', expands: false, quoted: false, substitutes: false }];
1268
+ const reachesHome = roots.some((root) => {
1269
+ if (root.expands)
1270
+ return true;
1271
+ for (const spelling of this.spell(root, variables, cwd)) {
1272
+ if (spelling.stop !== 'end')
1273
+ return true;
1274
+ const bases = isAbsolute(spelling.known) ? [''] : cwd.dirs.length > 0 ? cwd.dirs : [''];
1275
+ for (const base of bases) {
1276
+ const path = base === '' ? normalize(spelling.known) : join(base, spelling.known);
1277
+ if (this.inside(path) || this.belowAncestor(path) !== null)
1278
+ return true;
1279
+ }
1280
+ }
1281
+ return false;
1282
+ });
1283
+ let filterMatchesHome = false;
1284
+ for (let j = findAt; j < words.length; j++) {
1285
+ const flag = words[j];
1286
+ if (flag.expands || !/^-i?(?:name|path)$/.test(flag.value))
1287
+ continue;
1288
+ const pattern = words[j + 1];
1289
+ if (!pattern || pattern.expands)
1290
+ continue;
1291
+ if (this.homeSegments.some((seg) => mayBe(lower(pattern.value), seg.name))) {
1292
+ filterMatchesHome = true;
1293
+ break;
1294
+ }
1295
+ }
1296
+ for (let j = findAt + 1; j < words.length; j++) {
1297
+ const flag = words[j];
1298
+ if (flag.expands || !/^-(?:exec|execdir|ok|okdir)$/.test(flag.value))
1299
+ continue;
1300
+ let k = j + 1;
1301
+ let hasPlaceholder = false;
1302
+ const clauseStart = k;
1303
+ while (k < words.length) {
1304
+ const w = words[k];
1305
+ if (!w.expands && (w.value === ';' || w.value === '+'))
1306
+ break;
1307
+ if (!w.expands) {
1308
+ if (w.value.includes('{}'))
1309
+ hasPlaceholder = true;
1310
+ }
1311
+ k++;
1312
+ }
1313
+ // Ask the shared resolver what this clause actually runs. The
1314
+ // first word may be `env`/`sudo`/another wrapper, and a shell -c
1315
+ // payload can itself name a service tool. Reusing a one-clause
1316
+ // find command keeps the answer tied to this clause.
1317
+ const clauseCommand = {
1318
+ ...command,
1319
+ words: [head, flag, ...words.slice(clauseStart, k)],
1320
+ assignments: 0,
1321
+ };
1322
+ const toolIsSchedulerTool = programPositions(clauseCommand, dialect)
1323
+ .slice(1)
1324
+ .some((position) => position.unknown !== undefined ||
1325
+ (position.word !== undefined &&
1326
+ SCHEDULER_EXEC_TOOLS.has(commandName(position.word.value))));
1327
+ if (hasPlaceholder && (reachesHome || filterMatchesHome || toolIsSchedulerTool)) {
1328
+ const clauseText = words
1329
+ .slice(clauseStart, k)
1330
+ .map((w) => w.text)
1331
+ .join(' ');
1332
+ const why = toolIsSchedulerTool
1333
+ ? "its clause's own program can stop or remove a service"
1334
+ : filterMatchesHome
1335
+ ? 'a -name/-iname/-path/-ipath pattern could match NAMZU_HOME’s own folder name'
1336
+ : 'its search root is unknown, is NAMZU_HOME, or is an ancestor of it';
1337
+ return {
1338
+ reason: 'find -exec reaches a protected target',
1339
+ detail: `${shown(command.text)} runs \`${flag.value} ${clauseText}\` on whatever it finds, and ${why}; the floor cannot verify what {} will stand for`,
1340
+ };
1341
+ }
1342
+ j = k;
1343
+ }
1344
+ return null;
1345
+ }
1346
+ }
1347
+ /** Tools whose `find -exec`/`-execdir`/`-ok`/`-okdir` clause the floor refuses outright: each can stop or remove a service. */
1348
+ const SCHEDULER_EXEC_TOOLS = new Set([
1349
+ 'systemctl',
1350
+ 'launchctl',
1351
+ 'schtasks',
1352
+ 'pkill',
1353
+ 'killall',
1354
+ 'busctl',
1355
+ 'dbus-send',
1356
+ 'gdbus',
1357
+ ]);
1358
+ /** A write target this command names: `>`/`>>` redirections, `tee`'s arguments, `cp`/`mv`'s destination. */
1359
+ function writeTargets(command) {
1360
+ const targets = [];
1361
+ for (const r of command.redirections) {
1362
+ if (r.operator === '>' || r.operator === '>>')
1363
+ targets.push(r.target);
1364
+ }
1365
+ const words = command.words;
1366
+ const head = words[command.assignments];
1367
+ if (!head || head.expands)
1368
+ return targets;
1369
+ const name = commandName(head.value);
1370
+ const rest = words
1371
+ .slice(command.assignments + 1)
1372
+ .filter((w) => w.expands || !w.value.startsWith('-'));
1373
+ if (name === 'tee')
1374
+ targets.push(...rest);
1375
+ else if (name === 'cp' || name === 'mv') {
1376
+ const last = rest.at(-1);
1377
+ if (last)
1378
+ targets.push(last);
1379
+ }
1380
+ return targets;
1381
+ }
1382
+ /**
1383
+ * The path this command executes as a FILE — `./x.sh`, `some/dir/x.sh`,
1384
+ * `sh x.sh`, `bash x.sh`, `. x.sh`, `source x.sh` — or null. A bare name on
1385
+ * `PATH` (`x.sh` alone, no `/`) is not: that resolves to whatever `PATH`
1386
+ * finds, not a file this line can be shown to have written.
1387
+ */
1388
+ function executedPath(command) {
1389
+ const words = command.words;
1390
+ const head = words[command.assignments];
1391
+ if (!head || head.expands)
1392
+ return null;
1393
+ const name = commandName(head.value);
1394
+ if (name === 'sh' || name === 'bash' || name === '.' || name === 'source') {
1395
+ const rest = words
1396
+ .slice(command.assignments + 1)
1397
+ .filter((w) => w.expands || !w.value.startsWith('-'));
1398
+ return rest[0] ?? null;
1399
+ }
1400
+ if (!head.expands && head.value.includes('/'))
1401
+ return head;
1402
+ return null;
906
1403
  }
907
1404
  /** `NAME=value`, `NAME+=value` or `--opt=value`: the value as a word of its own. */
908
1405
  function assignedValue(word) {
@@ -920,6 +1417,7 @@ function assignedValue(word) {
920
1417
  value: word.value.slice(match[0].length),
921
1418
  expands: word.expands,
922
1419
  quoted: word.quoted,
1420
+ substitutes: word.substitutes,
923
1421
  },
924
1422
  };
925
1423
  }
@@ -1041,11 +1539,23 @@ function fedTexts(readings) {
1041
1539
  }
1042
1540
  return [...out];
1043
1541
  }
1044
- /** A command the lexer listed whose name or payload it could not read. */
1542
+ /** A command the lexer listed whose name or payload it could not read, in words, or null. */
1045
1543
  function unreadCommand(command) {
1046
1544
  const head = command.words[command.assignments];
1047
1545
  if (head?.expands)
1048
- return true;
1546
+ return `the command's name ${shown(head.text)} expands at runtime`;
1049
1547
  return runsUnreadText(command);
1050
1548
  }
1549
+ /** `, in the argument …` for the first word that holds `token`, or nothing. */
1550
+ function whereIn(readings, token) {
1551
+ const holds = typeof token === 'string'
1552
+ ? (text) => text.includes(lower(token))
1553
+ : (text) => token.test(text);
1554
+ for (const reading of readings)
1555
+ for (const command of reading.commands)
1556
+ for (const word of command.words)
1557
+ if (holds(lower(word.value)))
1558
+ return `, in the argument ${shown(word.value)}`;
1559
+ return '';
1560
+ }
1051
1561
  //# sourceMappingURL=floor.js.map