@deftai/directive-core 0.104.0 → 0.106.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 (287) hide show
  1. package/dist/authz/classify.js +1108 -67
  2. package/dist/check/cached-orchestrator.d.ts +1 -1
  3. package/dist/check/cached-orchestrator.js +2 -2
  4. package/dist/check/named-cause.d.ts +1 -1
  5. package/dist/check/named-cause.js +34 -4
  6. package/dist/codebase/default-extractor.d.ts +5 -0
  7. package/dist/codebase/default-extractor.js +7 -15
  8. package/dist/deposit/copy-tree.js +60 -0
  9. package/dist/deposit/python-free.js +26 -18
  10. package/dist/doctor/checks.d.ts +8 -0
  11. package/dist/doctor/checks.js +37 -25
  12. package/dist/doctor/doctor-state.d.ts +4 -0
  13. package/dist/doctor/doctor-state.js +7 -1
  14. package/dist/doctor/main.js +9 -19
  15. package/dist/doctor/types.d.ts +9 -0
  16. package/dist/forward-coverage/diff-coverage.d.ts +39 -0
  17. package/dist/forward-coverage/diff-coverage.js +112 -0
  18. package/dist/forward-coverage/diff-lines.d.ts +19 -0
  19. package/dist/forward-coverage/diff-lines.js +173 -0
  20. package/dist/forward-coverage/evaluate.d.ts +24 -9
  21. package/dist/forward-coverage/evaluate.js +125 -19
  22. package/dist/fs/contained-write.d.ts +115 -1
  23. package/dist/fs/contained-write.js +241 -5
  24. package/dist/fs/mutation-ledger.d.ts +54 -0
  25. package/dist/fs/mutation-ledger.js +133 -0
  26. package/dist/fs/non-product-dirs.d.ts +79 -0
  27. package/dist/fs/non-product-dirs.js +96 -0
  28. package/dist/hooks/dispatcher.d.ts +3 -1
  29. package/dist/hooks/dispatcher.js +14 -0
  30. package/dist/index.d.ts +1 -0
  31. package/dist/index.js +2 -0
  32. package/dist/init-deposit/agent-hooks.d.ts +1 -1
  33. package/dist/init-deposit/agent-hooks.js +18 -16
  34. package/dist/init-deposit/core-guard-pin-content.d.ts +7 -0
  35. package/dist/init-deposit/core-guard-pin-content.js +393 -0
  36. package/dist/init-deposit/gitignore.js +9 -0
  37. package/dist/init-deposit/hygiene.d.ts +47 -11
  38. package/dist/init-deposit/hygiene.js +172 -37
  39. package/dist/init-deposit/legacy-detect.js +6 -2
  40. package/dist/init-deposit/refresh.d.ts +21 -1
  41. package/dist/init-deposit/refresh.js +106 -9
  42. package/dist/init-deposit/scaffold.js +28 -279
  43. package/dist/init-deposit/slash-deposit.js +13 -7
  44. package/dist/init-deposit/xbrief-projections.d.ts +8 -1
  45. package/dist/init-deposit/xbrief-projections.js +24 -11
  46. package/dist/intake/clause-derivation.d.ts +47 -1
  47. package/dist/intake/clause-derivation.js +238 -9
  48. package/dist/intake/issue-ingest.d.ts +3 -1
  49. package/dist/intake/issue-ingest.js +35 -6
  50. package/dist/lifecycle/completed-tracked-on-delivery.d.ts +5 -0
  51. package/dist/lifecycle/completed-tracked-on-delivery.js +85 -18
  52. package/dist/lifecycle-visible/evaluate.d.ts +98 -0
  53. package/dist/lifecycle-visible/evaluate.js +710 -0
  54. package/dist/lifecycle-visible/index.d.ts +2 -0
  55. package/dist/lifecycle-visible/index.js +2 -0
  56. package/dist/literal-acceptance/capture.d.ts +10 -0
  57. package/dist/literal-acceptance/capture.js +226 -77
  58. package/dist/literal-acceptance/evaluate.d.ts +29 -4
  59. package/dist/literal-acceptance/evaluate.js +70 -2
  60. package/dist/literal-acceptance/index.d.ts +3 -3
  61. package/dist/literal-acceptance/index.js +3 -3
  62. package/dist/literal-acceptance/safety.d.ts +41 -0
  63. package/dist/literal-acceptance/safety.js +126 -2
  64. package/dist/literal-acceptance/types.d.ts +8 -0
  65. package/dist/orchestration/judgment-policy.d.ts +13 -0
  66. package/dist/orchestration/judgment-policy.js +15 -0
  67. package/dist/orchestration/probe-session.js +13 -8
  68. package/dist/orchestration/verify-judgment-gates.d.ts +4 -0
  69. package/dist/orchestration/verify-judgment-gates.js +43 -11
  70. package/dist/orphan-active/evaluate.d.ts +7 -1
  71. package/dist/orphan-active/evaluate.js +102 -23
  72. package/dist/platform/platform-capabilities.js +2 -3
  73. package/dist/policy/base-branch.d.ts +37 -0
  74. package/dist/policy/base-branch.js +114 -0
  75. package/dist/policy/branch-sync.d.ts +80 -0
  76. package/dist/policy/branch-sync.js +211 -0
  77. package/dist/policy/ceremony-dial-escalation.js +1 -0
  78. package/dist/policy/ceremony-dial.js +11 -21
  79. package/dist/policy/delivery-branch.d.ts +5 -3
  80. package/dist/policy/delivery-branch.js +7 -3
  81. package/dist/policy/file-size-thresholds.d.ts +24 -0
  82. package/dist/policy/file-size-thresholds.js +23 -0
  83. package/dist/policy/forge-outage-retry.d.ts +47 -0
  84. package/dist/policy/forge-outage-retry.js +157 -0
  85. package/dist/policy/host-hooks.d.ts +20 -0
  86. package/dist/policy/host-hooks.js +121 -2
  87. package/dist/policy/index.d.ts +7 -0
  88. package/dist/policy/index.js +66 -7
  89. package/dist/policy/org-force-on-migration.js +2 -1
  90. package/dist/policy/plan-extensions.d.ts +1 -1
  91. package/dist/policy/plan-extensions.js +2 -0
  92. package/dist/policy/product-signal.js +6 -7
  93. package/dist/policy/project-invariants.d.ts +50 -0
  94. package/dist/policy/project-invariants.js +205 -0
  95. package/dist/policy/require-human-merge.js +8 -6
  96. package/dist/policy/resolve.d.ts +7 -2
  97. package/dist/policy/resolve.js +25 -8
  98. package/dist/policy/sync-default.d.ts +129 -0
  99. package/dist/policy/sync-default.js +452 -0
  100. package/dist/policy/sync-max-files.d.ts +93 -0
  101. package/dist/policy/sync-max-files.js +192 -0
  102. package/dist/policy/value-feedback.js +7 -8
  103. package/dist/pr-monitor/absent-required.d.ts +11 -0
  104. package/dist/pr-monitor/absent-required.js +61 -0
  105. package/dist/pr-monitor/constants.d.ts +7 -0
  106. package/dist/pr-monitor/constants.js +7 -0
  107. package/dist/pr-monitor/index.d.ts +2 -1
  108. package/dist/pr-monitor/index.js +2 -1
  109. package/dist/pr-monitor/main.js +4 -0
  110. package/dist/pr-monitor/monitor.js +24 -1
  111. package/dist/pr-wait-mergeable/classify.js +6 -0
  112. package/dist/preflight/evaluate.d.ts +4 -0
  113. package/dist/preflight/evaluate.js +35 -1
  114. package/dist/preflight/index.d.ts +4 -0
  115. package/dist/preflight/index.js +2 -0
  116. package/dist/preflight/intended-placement.d.ts +62 -0
  117. package/dist/preflight/intended-placement.js +275 -0
  118. package/dist/preflight/project-invariants-gate.d.ts +29 -0
  119. package/dist/preflight/project-invariants-gate.js +82 -0
  120. package/dist/preflight-cache/evaluate.d.ts +12 -0
  121. package/dist/preflight-cache/evaluate.js +15 -1
  122. package/dist/preflight-cache/index.d.ts +1 -1
  123. package/dist/preflight-cache/index.js +1 -1
  124. package/dist/product-first-done-gate/acceptance-resolver.d.ts +128 -0
  125. package/dist/product-first-done-gate/acceptance-resolver.js +144 -0
  126. package/dist/product-first-done-gate/acceptance.d.ts +7 -4
  127. package/dist/product-first-done-gate/acceptance.js +79 -8
  128. package/dist/product-first-done-gate/empty-resolution.d.ts +4 -1
  129. package/dist/product-first-done-gate/empty-resolution.js +12 -1
  130. package/dist/product-first-done-gate/evaluate.d.ts +16 -0
  131. package/dist/product-first-done-gate/evaluate.js +298 -34
  132. package/dist/product-first-done-gate/index.d.ts +2 -0
  133. package/dist/product-first-done-gate/index.js +1 -0
  134. package/dist/release/build-dist.d.ts +101 -0
  135. package/dist/release/build-dist.js +428 -46
  136. package/dist/release/closed-verb-gate.d.ts +12 -0
  137. package/dist/release/closed-verb-gate.js +51 -0
  138. package/dist/release/index.d.ts +1 -0
  139. package/dist/release/index.js +1 -0
  140. package/dist/release/pipeline.js +7 -0
  141. package/dist/release/types.d.ts +12 -0
  142. package/dist/render/constants.d.ts +2 -1
  143. package/dist/render/constants.js +2 -1
  144. package/dist/render/framework-commands.js +4 -0
  145. package/dist/render/spec-validate.js +23 -12
  146. package/dist/run-summary/emit.d.ts +16 -10
  147. package/dist/run-summary/emit.js +26 -15
  148. package/dist/run-summary/index.d.ts +3 -2
  149. package/dist/run-summary/index.js +2 -1
  150. package/dist/run-summary/session-id.d.ts +21 -0
  151. package/dist/run-summary/session-id.js +92 -0
  152. package/dist/run-summary/types.d.ts +33 -4
  153. package/dist/run-summary/types.js +1 -0
  154. package/dist/scope/acceptance-evidence.d.ts +19 -7
  155. package/dist/scope/acceptance-evidence.js +38 -11
  156. package/dist/scope/delivery-evidence.d.ts +3 -3
  157. package/dist/scope/delivery-evidence.js +22 -27
  158. package/dist/scope/index.d.ts +1 -0
  159. package/dist/scope/index.js +1 -0
  160. package/dist/scope/main.js +15 -5
  161. package/dist/scope/parent-lineage.js +7 -6
  162. package/dist/scope/project-invariant-coverage.d.ts +54 -0
  163. package/dist/scope/project-invariant-coverage.js +322 -0
  164. package/dist/scope/promote-from-issue.js +5 -5
  165. package/dist/scope/transition.js +1 -1
  166. package/dist/scope-provenance/compare-intent.d.ts +27 -0
  167. package/dist/scope-provenance/compare-intent.js +233 -0
  168. package/dist/scope-provenance/digest.d.ts +18 -1
  169. package/dist/scope-provenance/digest.js +16 -11
  170. package/dist/scope-provenance/evaluate.d.ts +5 -1
  171. package/dist/scope-provenance/evaluate.js +67 -7
  172. package/dist/scope-provenance/extract-intent.d.ts +43 -0
  173. package/dist/scope-provenance/extract-intent.js +262 -0
  174. package/dist/scope-provenance/index.d.ts +7 -1
  175. package/dist/scope-provenance/index.js +7 -1
  176. package/dist/scope-provenance/intent-digest.d.ts +12 -0
  177. package/dist/scope-provenance/intent-digest.js +33 -0
  178. package/dist/scope-provenance/intent-evaluate.d.ts +43 -0
  179. package/dist/scope-provenance/intent-evaluate.js +149 -0
  180. package/dist/scope-provenance/json-tokenizer.d.ts +19 -0
  181. package/dist/scope-provenance/json-tokenizer.js +225 -0
  182. package/dist/scope-provenance/known-machine.d.ts +38 -0
  183. package/dist/scope-provenance/known-machine.js +157 -0
  184. package/dist/scope-provenance/mint-artifacts.d.ts +102 -0
  185. package/dist/scope-provenance/mint-artifacts.js +425 -0
  186. package/dist/session/ac-pass-banking.d.ts +9 -36
  187. package/dist/session/ac-pass-banking.js +33 -79
  188. package/dist/session/ac-pass-reuse.d.ts +53 -0
  189. package/dist/session/ac-pass-reuse.js +95 -0
  190. package/dist/session/git.js +19 -4
  191. package/dist/session/index.d.ts +4 -0
  192. package/dist/session/index.js +4 -0
  193. package/dist/session/occupancy-stress-worker.d.ts +7 -0
  194. package/dist/session/occupancy-stress-worker.js +36 -0
  195. package/dist/session/occupancy.d.ts +97 -0
  196. package/dist/session/occupancy.js +422 -0
  197. package/dist/session/openclaw-soft-rebind-deposit.js +6 -6
  198. package/dist/session/process-cost.d.ts +32 -10
  199. package/dist/session/process-cost.js +189 -1
  200. package/dist/session/product-state-hash.d.ts +26 -0
  201. package/dist/session/product-state-hash.js +700 -0
  202. package/dist/session/ritual-entrypoint.js +16 -1
  203. package/dist/session/session-ready.d.ts +2 -0
  204. package/dist/session/session-ready.js +52 -30
  205. package/dist/session/session-start.d.ts +13 -0
  206. package/dist/session/session-start.js +103 -9
  207. package/dist/session/verify-ac-session-cache.d.ts +42 -0
  208. package/dist/session/verify-ac-session-cache.js +126 -0
  209. package/dist/session/verify-session-ritual.d.ts +28 -0
  210. package/dist/session/verify-session-ritual.js +97 -5
  211. package/dist/slash/openclaw-deposit.js +13 -9
  212. package/dist/slice/constants.d.ts +2 -2
  213. package/dist/slice/constants.js +2 -2
  214. package/dist/slice/existing.js +9 -3
  215. package/dist/slice/lock.d.ts +16 -1
  216. package/dist/slice/lock.js +137 -11
  217. package/dist/story-ready/evaluate.d.ts +2 -0
  218. package/dist/story-ready/evaluate.js +13 -0
  219. package/dist/swarm/complete-cohort.d.ts +3 -0
  220. package/dist/swarm/complete-cohort.js +59 -11
  221. package/dist/swarm/finalize-cohort-cli.d.ts +7 -2
  222. package/dist/swarm/finalize-cohort-cli.js +165 -35
  223. package/dist/swarm/finalize-cohort.d.ts +1 -0
  224. package/dist/swarm/finalize-cohort.js +47 -4
  225. package/dist/swarm/launch.d.ts +22 -0
  226. package/dist/swarm/launch.js +155 -0
  227. package/dist/swarm/routing-set-cli.d.ts +11 -1
  228. package/dist/swarm/routing-set-cli.js +15 -3
  229. package/dist/swarm/routing-verify.d.ts +13 -1
  230. package/dist/swarm/routing-verify.js +60 -21
  231. package/dist/swarm/routing.d.ts +14 -0
  232. package/dist/swarm/routing.js +53 -3
  233. package/dist/telemetry-coverage/fake-trial.js +10 -0
  234. package/dist/telemetry-coverage/kinds.js +2 -0
  235. package/dist/triage/actions/index.js +4 -1
  236. package/dist/triage/bootstrap/index.js +6 -6
  237. package/dist/triage/help/registry-data.d.ts +1 -1
  238. package/dist/triage/help/registry-data.js +1 -1
  239. package/dist/triage/welcome/writers.d.ts +1 -1
  240. package/dist/triage/welcome/writers.js +10 -5
  241. package/dist/ts-check-lane/index.d.ts +3 -0
  242. package/dist/ts-check-lane/index.js +2 -0
  243. package/dist/ts-check-lane/progress-reporter.d.ts +17 -0
  244. package/dist/ts-check-lane/progress-reporter.js +38 -0
  245. package/dist/ts-check-lane/progress.d.ts +32 -0
  246. package/dist/ts-check-lane/progress.js +57 -0
  247. package/dist/ts-check-lane/run-lane.d.ts +3 -1
  248. package/dist/ts-check-lane/run-lane.js +8 -6
  249. package/dist/validate-content/validate-links.d.ts +8 -0
  250. package/dist/validate-content/validate-links.js +9 -15
  251. package/dist/value/feedback-file.js +12 -2
  252. package/dist/value/readback.d.ts +4 -1
  253. package/dist/value/readback.js +51 -12
  254. package/dist/vbrief-build/index.d.ts +1 -0
  255. package/dist/vbrief-build/index.js +1 -0
  256. package/dist/vbrief-build/project-invariants-io.d.ts +16 -0
  257. package/dist/vbrief-build/project-invariants-io.js +41 -0
  258. package/dist/vbrief-reconcile/graph.js +18 -1
  259. package/dist/vbrief-reconcile/index.d.ts +1 -1
  260. package/dist/vbrief-reconcile/index.js +1 -1
  261. package/dist/vbrief-reconcile/types.d.ts +13 -0
  262. package/dist/vbrief-reconcile/umbrellas.d.ts +14 -0
  263. package/dist/vbrief-reconcile/umbrellas.js +261 -26
  264. package/dist/vbrief-validate/index.d.ts +1 -1
  265. package/dist/vbrief-validate/index.js +1 -1
  266. package/dist/vbrief-validate/plan-hooks.d.ts +4 -0
  267. package/dist/vbrief-validate/plan-hooks.js +68 -0
  268. package/dist/vbrief-validate/schema.d.ts +4 -0
  269. package/dist/vbrief-validate/schema.js +1 -1
  270. package/dist/verify-ac/evaluate.d.ts +7 -1
  271. package/dist/verify-ac/evaluate.js +34 -26
  272. package/dist/verify-ac/flag.d.ts +4 -0
  273. package/dist/verify-ac/flag.js +16 -0
  274. package/dist/verify-ac/index.d.ts +2 -2
  275. package/dist/verify-ac/index.js +2 -2
  276. package/dist/verify-env/agent-hook-readiness.d.ts +2 -2
  277. package/dist/verify-env/agent-hook-readiness.js +12 -8
  278. package/dist/verify-env/agent-hooks-live-probe.d.ts +5 -1
  279. package/dist/verify-env/agent-hooks-live-probe.js +26 -5
  280. package/dist/verify-env/agent-hooks.js +3 -4
  281. package/dist/verify-source/contained-writes.js +2 -0
  282. package/dist/verify-source/verify-stubs.d.ts +15 -1
  283. package/dist/verify-source/verify-stubs.js +20 -16
  284. package/dist/vitest-runner/win32-coverage-tmp-setup.d.ts +5 -6
  285. package/dist/vitest-runner/win32-coverage-tmp-setup.js +5 -6
  286. package/dist/xbrief-migrate/migrate-project.js +2 -5
  287. package/package.json +7 -3
@@ -178,6 +178,37 @@ const POLICY_AUTHORITY_MUTATORS = new Set([
178
178
  ]);
179
179
  /** Kill-switch / permanent opt-out basenames agents must not plant under UAT (#3186 / #3039). */
180
180
  const KILL_SWITCH_BASENAMES = [".deft-directive-disable", ".no-deft-directive"];
181
+ /**
182
+ * Dest-form writers #3529 harvest missed (#3545). Shared across downloader,
183
+ * archive-alt, positional, and indirect-write membership so a named-peer
184
+ * allowlist cannot be the only fail-closed path.
185
+ */
186
+ const UAT_RESIDUAL_DEST_WRITE_BINS_3545 = [
187
+ "fromdos",
188
+ "todos",
189
+ "emacsclient",
190
+ "pico",
191
+ "pdftk",
192
+ "gs",
193
+ "dpkg",
194
+ "degit",
195
+ "composer",
196
+ "ddrescue",
197
+ "dc3dd",
198
+ "sg_dd",
199
+ ];
200
+ /** In-place residual writers: any protected operand is a dest. Extract/output bins stay dest-flag gated. */
201
+ const UAT_RESIDUAL_INPLACE_WRITE_BINS_3545 = [
202
+ "fromdos",
203
+ "todos",
204
+ "emacsclient",
205
+ "pico",
206
+ "degit",
207
+ "composer",
208
+ "ddrescue",
209
+ "dc3dd",
210
+ "sg_dd",
211
+ ];
181
212
  /**
182
213
  * Downloaders / decoders / remote-copy tools that can plant files without shell
183
214
  * redirects (#3206 / #3213 / #3245). Not in INDIRECT_WRITE_BINS: those feed hasWriteShape
@@ -262,10 +293,67 @@ const DOWNLOADER_DECODER_BINS = new Set([
262
293
  "sqlite3",
263
294
  "crane",
264
295
  "objcopy",
296
+ // #3382 residual after #3354: dest forms that are not generic -o/--output/--outfile.
297
+ "cmake",
298
+ "script",
299
+ "gallery-dl",
300
+ "megadl",
301
+ "ncftpget",
302
+ "svn",
303
+ "fossil",
304
+ "bzr",
305
+ "cvs",
306
+ "darcs",
307
+ "ed",
308
+ "nvim",
309
+ "nano",
310
+ "vim",
311
+ "vi",
312
+ // #3421 residual after #3382: dest forms the harvest missed.
313
+ "git",
314
+ "ex",
315
+ "dos2unix",
316
+ "unix2dos",
317
+ "mac2unix",
318
+ "unix2mac",
319
+ "aws",
320
+ "pijul",
321
+ "pg_dump",
322
+ "pg_restore",
323
+ "pg_dumpall",
324
+ "convert",
325
+ "magick",
326
+ "mogrify",
327
+ "fallocate",
328
+ "new-item",
329
+ // #3459 residual after #3421: GNU g* peers, forge clone, iwr, fsutil.
330
+ "iwr",
331
+ "invoke-webrequest",
332
+ "fsutil",
333
+ "gh",
334
+ "glab",
335
+ "hub",
336
+ // #3529 residual after #3459: dest-form writers harvest missed.
337
+ "xcopy",
338
+ "robocopy",
339
+ "bitsadmin",
340
+ "start-bitstransfer",
341
+ "expand-archive",
342
+ "tee-object",
343
+ "export-csv",
344
+ "pscp",
345
+ "jj",
346
+ "sponge",
347
+ "expand",
348
+ "extrac32",
349
+ "makecab",
350
+ "replace",
351
+ // #3545 residual after #3529: dest-form writers harvest missed.
352
+ ...UAT_RESIDUAL_DEST_WRITE_BINS_3545,
265
353
  ]);
266
354
  /**
267
355
  * Archive extractors / alt writers that can plant via pathish operands without
268
- * shell redirects (#3245 / #3288 / #3311 / #3336 / #3354). Used for pathish authz/kill scans (prefer deny)
356
+ * shell redirects (#3245 / #3288 / #3311 / #3336 / #3354 / #3382). Used for pathish authz/kill scans (prefer deny)
269
357
  * and write-shape residual under UAT — not bare curl-class URL fetches.
270
358
  */
271
359
  const ARCHIVE_ALT_WRITE_BINS = new Set([
@@ -338,9 +426,45 @@ const ARCHIVE_ALT_WRITE_BINS = new Set([
338
426
  "sqlite3",
339
427
  "crane",
340
428
  "objcopy",
429
+ // #3382 residual: dedicated dest-form writers (not general-purpose cmake/git/editors).
430
+ "gallery-dl",
431
+ "megadl",
432
+ "ncftpget",
433
+ // #3421 residual: dedicated dest-form writers (not general-purpose git/aws/editors).
434
+ "dos2unix",
435
+ "unix2dos",
436
+ "mac2unix",
437
+ "unix2mac",
438
+ "pijul",
439
+ "pg_dump",
440
+ "pg_restore",
441
+ "pg_dumpall",
442
+ "convert",
443
+ "magick",
444
+ "mogrify",
445
+ "fallocate",
446
+ // #3459 residual: dest writers that are not general-purpose gh/cmd.
447
+ "iwr",
448
+ "invoke-webrequest",
449
+ "fsutil",
450
+ // #3529 residual: dest writers that are not general-purpose cmd/git.
451
+ "xcopy",
452
+ "robocopy",
453
+ "bitsadmin",
454
+ "start-bitstransfer",
455
+ "expand-archive",
456
+ "tee-object",
457
+ "export-csv",
458
+ "sponge",
459
+ "expand",
460
+ "extrac32",
461
+ "makecab",
462
+ "replace",
463
+ // #3545 residual: dest writers that are not general-purpose cmd/git.
464
+ ...UAT_RESIDUAL_INPLACE_WRITE_BINS_3545,
341
465
  ]);
342
466
  /**
343
- * Bins whose pathish operands are scanned for authz/kill destinations (#3213 / #3245 / #3288 / #3311 / #3336 / #3354).
467
+ * Bins whose pathish operands are scanned for authz/kill destinations (#3213 / #3245 / #3288 / #3311 / #3336 / #3354 / #3382).
344
468
  * Prefer a Set over a long `||` chain so coverage counts one membership check, not N branches.
345
469
  */
346
470
  const PROTECTED_POSITIONAL_BINS = new Set([
@@ -403,7 +527,149 @@ const PROTECTED_POSITIONAL_BINS = new Set([
403
527
  "sqlite3",
404
528
  "crane",
405
529
  "objcopy",
530
+ // #3382 residual: positional dest writers (cmake copy / script / VCS / editors).
531
+ "cmake",
532
+ "script",
533
+ "gallery-dl",
534
+ "megadl",
535
+ "ncftpget",
536
+ "svn",
537
+ "fossil",
538
+ "bzr",
539
+ "cvs",
540
+ "darcs",
541
+ "ed",
542
+ "nvim",
543
+ "nano",
544
+ "vim",
545
+ "vi",
546
+ // #3421 residual: positional dest writers (aws, convert, New-Item).
547
+ // git clone/worktree/submodule dests are harvested only on those write subcommands.
548
+ "ex",
549
+ "dos2unix",
550
+ "unix2dos",
551
+ "mac2unix",
552
+ "unix2mac",
553
+ "aws",
554
+ "pijul",
555
+ "pg_dump",
556
+ "pg_restore",
557
+ "pg_dumpall",
558
+ "convert",
559
+ "magick",
560
+ "mogrify",
561
+ "fallocate",
562
+ "new-item",
563
+ // #3459 residual: positional dest writers (fsutil createnew, iwr dest flags).
564
+ // gh/glab/hub clone dests are harvested only on the clone subcommand.
565
+ "iwr",
566
+ "invoke-webrequest",
567
+ "fsutil",
568
+ // #3529 residual: positional dest writers (xcopy/robocopy/BitsTransfer/pscp).
569
+ // jj clone dests are harvested only on the clone subcommand.
570
+ "xcopy",
571
+ "robocopy",
572
+ "bitsadmin",
573
+ "start-bitstransfer",
574
+ "expand-archive",
575
+ "tee-object",
576
+ "export-csv",
577
+ "pscp",
578
+ "sponge",
579
+ "expand",
580
+ "extrac32",
581
+ "makecab",
582
+ "replace",
583
+ // #3545 residual: positional dest writers (fromdos/dpkg/degit/ddrescue).
584
+ ...UAT_RESIDUAL_DEST_WRITE_BINS_3545,
585
+ ]);
586
+ /**
587
+ * Bins whose dest is the last pathish operand (src … dest). A protected
588
+ * source that writes elsewhere is not a dest plant (#3421 residual).
589
+ */
590
+ const LAST_POSITIONAL_DEST_BINS = new Set(["aws", "convert", "magick"]);
591
+ /** Wrappers / assignments before the dest-owning bin (`env aws`, `FOO=1 convert`). */
592
+ const COMMAND_WRAPPER_BINS = new Set([
593
+ "env",
594
+ "command",
595
+ "nice",
596
+ "nohup",
597
+ "sudo",
598
+ "doas",
599
+ "time",
600
+ "stdbuf",
601
+ "timeout",
602
+ "ionice",
603
+ ]);
604
+ function isWrapperOrAssignmentToken(raw, n) {
605
+ if (COMMAND_WRAPPER_BINS.has(writeBinName(raw)))
606
+ return true;
607
+ return n.includes("=") && !n.startsWith("-") && !raw.includes("/") && !raw.includes("\\");
608
+ }
609
+ /** Wrapper flags that take a separate value (`env -C DIR`, `nice -n N`). */
610
+ const WRAPPER_VALUE_FLAGS = new Set([
611
+ "-c",
612
+ "--chdir",
613
+ "-u",
614
+ "--unset",
615
+ "-n",
616
+ "--adjustment",
617
+ "-o",
618
+ "--output",
619
+ "-i",
620
+ "--input",
621
+ "-e",
622
+ "--error",
623
+ "-s",
624
+ "--signal",
625
+ "-k",
626
+ "--kill-after",
406
627
  ]);
628
+ /** In-place ImageMagick writer: every protected operand is a dest, not last-only. */
629
+ const MAGICK_FAMILY_BINS = new Set(["convert", "magick", "mogrify"]);
630
+ const MAGICK_WRITE_DEST_FLAGS = new Set(["-write"]);
631
+ /** True when this token is a subcommand of a last-positional dest bin (`aws s3 cp`). */
632
+ function tokenEndsShellSegment(token) {
633
+ return isShellSegmentBreak(token) || firstUnquotedShellOpIndex(token) >= 0;
634
+ }
635
+ function segmentStartedByLastPositionalDest(tokens, tokenIndex) {
636
+ let start = tokenIndex;
637
+ while (start > 0 && !tokenEndsShellSegment(tokens[start - 1]))
638
+ start--;
639
+ let skipNext = false;
640
+ let afterWrapper = false;
641
+ for (let k = start; k < tokenIndex; k++) {
642
+ const raw = tokens[k];
643
+ if (isShellSegmentBreak(raw))
644
+ continue;
645
+ const n = normalizeToken(raw);
646
+ const bare = binBareName(raw);
647
+ if (skipNext) {
648
+ skipNext = false;
649
+ if (LAST_POSITIONAL_DEST_BINS.has(bare))
650
+ return true;
651
+ continue;
652
+ }
653
+ if (n.startsWith("-")) {
654
+ if (!n.includes("=") && WRAPPER_VALUE_FLAGS.has(n))
655
+ skipNext = true;
656
+ continue;
657
+ }
658
+ if (isWrapperOrAssignmentToken(raw, n)) {
659
+ afterWrapper = COMMAND_WRAPPER_BINS.has(bare);
660
+ continue;
661
+ }
662
+ if (LAST_POSITIONAL_DEST_BINS.has(bare))
663
+ return true;
664
+ // timeout 5 aws / similar: skip one non-bin operand after a wrapper.
665
+ if (afterWrapper) {
666
+ afterWrapper = false;
667
+ continue;
668
+ }
669
+ return false;
670
+ }
671
+ return false;
672
+ }
407
673
  /** wget family (directory-prefix dest flags). */
408
674
  const WGET_FAMILY_BINS = new Set(["wget", "wget2"]);
409
675
  /** aria2 family (dir dest flags). */
@@ -419,6 +685,82 @@ const SQLITE3_OUTPUT_META = [".output", ".once"];
419
685
  const TAR_FAMILY_BINS = new Set(["tar", "bsdtar", "gtar", "star", "gnutar"]);
420
686
  /** xh / httpie family (download-dir dest flags) (#3311). */
421
687
  const XH_FAMILY_BINS = new Set(["xh", "httpie"]);
688
+ /** gallery-dl dest-dir flags (#3382). */
689
+ const GALLERY_DL_FAMILY_BINS = new Set(["gallery-dl", "gallery_dl"]);
690
+ const GALLERY_DL_DIR_DEST_FLAGS = new Set(["-d", "--destination", "--dest"]);
691
+ /** megadl dest-path flags (#3382). */
692
+ const MEGADL_FAMILY_BINS = new Set(["megadl"]);
693
+ const MEGADL_PATH_DEST_FLAGS = new Set(["--path"]);
694
+ /**
695
+ * Extra dest flags harvested on unknown write-shaped bins when dest is protected (#3382 / #3421).
696
+ * Not merged into DOWNLOADER_FILE_DEST_FLAGS: curl `-d` is POST data, not a file dest.
697
+ */
698
+ const GENERIC_PROTECTED_EXTRA_DEST_FLAGS = new Set([
699
+ "-d",
700
+ "--dir",
701
+ "--destination",
702
+ "--dest",
703
+ "--path",
704
+ "--directory",
705
+ "--workdir",
706
+ "--file",
707
+ "--separate-git-dir",
708
+ "--pack-destination",
709
+ // #3529 residual dest flags (PowerShell single-dash + extra dir dests).
710
+ "--output-dir",
711
+ "--outdir",
712
+ "--target-directory",
713
+ "--destdir",
714
+ "-destination",
715
+ "-destinationpath",
716
+ "-filepath",
717
+ "--prefix",
718
+ // #3545 residual dest flags (VCS repo dest + ghostscript output).
719
+ "--repodir",
720
+ "--repo-dir",
721
+ "-soutputfile",
722
+ ]);
723
+ /** Bins whose `--file` / `-f` operand is an input, not a dest plant. */
724
+ const READ_SHAPED_FILE_FLAG_BINS = new Set([
725
+ "grep",
726
+ "egrep",
727
+ "fgrep",
728
+ "rg",
729
+ "ag",
730
+ "ack",
731
+ "get-content",
732
+ "gc",
733
+ "cat",
734
+ "type",
735
+ "less",
736
+ "more",
737
+ "head",
738
+ "tail",
739
+ ]);
740
+ const READ_INPUT_FILE_FLAGS = new Set(["--file", "-f"]);
741
+ /** PowerShell New-Item dest flags (not generic: Get-Content -Path is a read). */
742
+ const NEW_ITEM_PATH_DEST_FLAGS = new Set(["-path", "-literalpath", "--literalpath"]);
743
+ /** git subcommands whose positionals can plant a dest (#3421). */
744
+ const GIT_WRITE_SUBCOMMANDS = new Set(["clone", "worktree", "submodule"]);
745
+ /** git bundle subcommands that write a bundle file (#3529). Reads: verify / list-heads / unbundle. */
746
+ const GIT_BUNDLE_WRITE_SUBCOMMANDS = new Set(["create"]);
747
+ /** jj subcommands whose positionals can plant a dest (#3529). */
748
+ const JJ_WRITE_SUBCOMMANDS = new Set(["clone"]);
749
+ const JJ_PRE_SUBCOMMAND_BOOLEAN_FLAGS = new Set([
750
+ "--help",
751
+ "-h",
752
+ "--quiet",
753
+ "-q",
754
+ "--ignore-immutable",
755
+ "--no-pager",
756
+ ]);
757
+ /** Forge CLIs whose `clone` dest is harvested like git clone (#3459). */
758
+ const FORGE_CLONE_BINS = new Set(["gh", "glab", "hub"]);
759
+ /** fossil dest-dir flags (#3382 PATH form + #3421 --workdir=). */
760
+ const FOSSIL_WORKDIR_DEST_FLAGS = new Set(["--workdir"]);
761
+ /** pg_dump family dest-file flags (#3421). */
762
+ const PG_DUMP_FAMILY_BINS = new Set(["pg_dump", "pg_restore", "pg_dumpall"]);
763
+ const PG_DUMP_FILE_DEST_FLAGS = new Set(["-f", "--file"]);
422
764
  /**
423
765
  * File destination flags for downloaders/decoders (#3206).
424
766
  * normalizeToken lowercases, so wget `-O` and curl `-o` share `-o`.
@@ -433,6 +775,8 @@ const DOWNLOADER_FILE_DEST_FLAGS = new Set([
433
775
  "-out",
434
776
  "--out",
435
777
  "--outfile",
778
+ // PowerShell iwr/Invoke-WebRequest -OutFile (single-dash long form) (#3459).
779
+ "-outfile",
436
780
  // unar single-dash long form; GNU/others use --output-directory.
437
781
  "-output-directory",
438
782
  "--output-directory",
@@ -479,12 +823,46 @@ function binBareName(token) {
479
823
  const base = pathish.includes("/") ? pathish.slice(pathish.lastIndexOf("/") + 1) : pathish;
480
824
  return base.endsWith(".exe") ? base.slice(0, -4) : base;
481
825
  }
826
+ /**
827
+ * GNU coreutils g* prefixes (Homebrew `ginstall`, `gcp`).
828
+ * Stems only — `git` / `gh` / `gpg` do not map to `it` / `h` / `pg` (#3459).
829
+ */
830
+ const GNU_COREUTILS_WRITE_STEMS = new Set([
831
+ "cp",
832
+ "mv",
833
+ "rm",
834
+ "rmdir",
835
+ "unlink",
836
+ "shred",
837
+ "truncate",
838
+ "chmod",
839
+ "chown",
840
+ "dd",
841
+ "tee",
842
+ "install",
843
+ "ln",
844
+ "link",
845
+ "mkdir",
846
+ "touch",
847
+ ]);
848
+ function writeBinName(token) {
849
+ const bare = binBareName(token);
850
+ if (bare.length > 1 && bare.startsWith("g")) {
851
+ const stem = bare.slice(1);
852
+ if (GNU_COREUTILS_WRITE_STEMS.has(stem))
853
+ return stem;
854
+ }
855
+ return bare;
856
+ }
482
857
  /** True when token names a downloader/decoder bin (bare or path-qualified). */
483
858
  function isDownloaderDecoderBin(token) {
484
859
  const n = normalizeToken(token);
485
860
  if (n.startsWith("-"))
486
861
  return false;
487
- return DOWNLOADER_DECODER_BINS.has(binBareName(token));
862
+ return DOWNLOADER_DECODER_BINS.has(writeBinName(token));
863
+ }
864
+ function isScpFamilyBin(bin) {
865
+ return bin === "scp" || bin === "pscp";
488
866
  }
489
867
  /**
490
868
  * True when `flag` (normalized lower) or `rawFlag` (original token) is a dest flag for `bin`.
@@ -492,8 +870,8 @@ function isDownloaderDecoderBin(token) {
492
870
  * 7z uses attached `-oDIR` only — separate `-o PATH` is not a 7z dest form (handled elsewhere).
493
871
  */
494
872
  function isDownloaderDestFlag(flag, bin, rawFlag) {
495
- // scp: `-o` is OpenSSH option (ProxyCommand, …), not a file dest flag.
496
- if (bin === "scp")
873
+ // scp/pscp: `-o` is OpenSSH option (ProxyCommand, …), not a file dest flag.
874
+ if (isScpFamilyBin(bin))
497
875
  return false;
498
876
  // 7z family: only attached `-oDIR` (parsed in attached-short branch), not separate `-o PATH`.
499
877
  if (SEVEN_Z_FAMILY_BINS.has(bin)) {
@@ -532,6 +910,18 @@ function isDownloaderDestFlag(flag, bin, rawFlag) {
532
910
  return true;
533
911
  if (ATOOL_FAMILY_BINS.has(bin) && ATOOL_DIR_DEST_FLAGS.has(flag))
534
912
  return true;
913
+ if (GALLERY_DL_FAMILY_BINS.has(bin) && GALLERY_DL_DIR_DEST_FLAGS.has(flag))
914
+ return true;
915
+ if (MEGADL_FAMILY_BINS.has(bin) && MEGADL_PATH_DEST_FLAGS.has(flag))
916
+ return true;
917
+ if (bin === "fossil" && FOSSIL_WORKDIR_DEST_FLAGS.has(flag))
918
+ return true;
919
+ if (PG_DUMP_FAMILY_BINS.has(bin) && PG_DUMP_FILE_DEST_FLAGS.has(flag))
920
+ return true;
921
+ if (bin === "new-item" && NEW_ITEM_PATH_DEST_FLAGS.has(flag))
922
+ return true;
923
+ if (MAGICK_FAMILY_BINS.has(bin) && MAGICK_WRITE_DEST_FLAGS.has(flag))
924
+ return true;
535
925
  return false;
536
926
  }
537
927
  /**
@@ -582,6 +972,28 @@ function firstUnquotedShellOpIndex(raw) {
582
972
  }
583
973
  return -1;
584
974
  }
975
+ /**
976
+ * dpkg/pdftk/gs only harvest protected positionals on write forms
977
+ * (`dpkg -x`, `pdftk … output`, gs dest flags). Inspect/read forms stay unclassifiable.
978
+ */
979
+ function residualPositionalBinIsWriteShaped(bin, tokens, start) {
980
+ if (bin !== "dpkg" && bin !== "pdftk" && bin !== "gs")
981
+ return true;
982
+ for (let k = start; k < tokens.length; k++) {
983
+ const raw = tokens[k];
984
+ if (tokenEndsShellSegment(raw))
985
+ break;
986
+ const n = normalizeToken(raw);
987
+ if (bin === "dpkg" && (n === "-x" || n === "--extract" || n === "--vextract")) {
988
+ return true;
989
+ }
990
+ if (bin === "pdftk" && n === "output")
991
+ return true;
992
+ if (bin === "gs" && (n.startsWith("-soutputfile") || n === "-soutputfile"))
993
+ return true;
994
+ }
995
+ return false;
996
+ }
585
997
  /**
586
998
  * Destinations from curl/wget/xxd/openssl/scp/aria2c/certutil via -o/--output/-O/-out/
587
999
  * --output-dir/-P/-d/--dir (separate, =value, or attached short form), xxd -r path-like
@@ -599,7 +1011,7 @@ function downloaderDecoderDestinations(tokens) {
599
1011
  i++;
600
1012
  continue;
601
1013
  }
602
- const bin = binBareName(tokens[i]);
1014
+ const bin = writeBinName(tokens[i]);
603
1015
  i++;
604
1016
  // xxd reverse mode writes; without -r, path positionals are dump inputs (read).
605
1017
  let xxdReverse = false;
@@ -610,7 +1022,11 @@ function downloaderDecoderDestinations(tokens) {
610
1022
  // Segment breaks (`;`/`\n`/glued ops) prevent following-command overwrite.
611
1023
  let lastPositionalPath = null;
612
1024
  const protectedPathish = [];
613
- const collectsProtectedPositionals = PROTECTED_POSITIONAL_BINS.has(bin);
1025
+ const collectsProtectedPositionals = (PROTECTED_POSITIONAL_BINS.has(bin) && residualPositionalBinIsWriteShaped(bin, tokens, i)) ||
1026
+ (bin === "git" && gitHasWriteSubcommand(tokens, i)) ||
1027
+ (bin === "git" && gitHasBundleCreate(tokens, i)) ||
1028
+ (bin === "jj" && jjHasWriteSubcommand(tokens, i)) ||
1029
+ (FORGE_CLONE_BINS.has(bin) && forgeHasCloneSubcommand(tokens, i));
614
1030
  while (i < tokens.length) {
615
1031
  const raw = tokens[i];
616
1032
  const n = normalizeToken(raw);
@@ -642,8 +1058,8 @@ function downloaderDecoderDestinations(tokens) {
642
1058
  i++;
643
1059
  continue;
644
1060
  }
645
- // scp: skip OpenSSH value-taking flags + their values (-o Option=Value, -i key, -P port).
646
- if (bin === "scp" &&
1061
+ // scp/pscp: skip OpenSSH value-taking flags + their values (-o Option=Value, -i key, -P port).
1062
+ if (isScpFamilyBin(bin) &&
647
1063
  (SCP_VALUE_FLAGS_LOWER.has(n) ||
648
1064
  SCP_VALUE_FLAGS_EXACT.has(raw) ||
649
1065
  SCP_VALUE_FLAGS_EXACT.has(n))) {
@@ -669,10 +1085,11 @@ function downloaderDecoderDestinations(tokens) {
669
1085
  continue;
670
1086
  }
671
1087
  // Attached short: -oPATH / -OPATH (after lowercasing both are -opath…)
672
- // Skip for scp (OpenSSH -oOption=Value attached forms are not file dests).
1088
+ // Skip for scp/pscp (OpenSSH -oOption=Value attached forms are not file dests).
673
1089
  // 7z requires attached -oDIR (#3245).
674
- if (bin !== "scp" && n.startsWith("-") && !n.startsWith("--") && n.length > 2) {
675
- if (n.startsWith("-out") && n.length > 4 && !n.startsWith("-output")) {
1090
+ if (!isScpFamilyBin(bin) && n.startsWith("-") && !n.startsWith("--") && n.length > 2) {
1091
+ // `-outfile` is PowerShell -OutFile (next token is dest), not attached -outFILE (#3459).
1092
+ if (n.startsWith("-out") && n.length > 4 && !n.startsWith("-output") && n !== "-outfile") {
676
1093
  dests.push(pathishToken(raw.slice(4)));
677
1094
  i++;
678
1095
  continue;
@@ -698,6 +1115,12 @@ function downloaderDecoderDestinations(tokens) {
698
1115
  i++;
699
1116
  continue;
700
1117
  }
1118
+ // gallery-dl attached -dDIR (#3382)
1119
+ if (GALLERY_DL_FAMILY_BINS.has(bin) && n.startsWith("-d") && n.length > 2) {
1120
+ dests.push(pathishToken(raw.slice(2)));
1121
+ i++;
1122
+ continue;
1123
+ }
701
1124
  // unzip / cabextract attached -dDIR (#3245 / #3336)
702
1125
  if (UNZIP_DIR_DEST_BINS.has(bin) && n.startsWith("-d") && n.length > 2) {
703
1126
  dests.push(pathishToken(raw.slice(2)));
@@ -751,9 +1174,7 @@ function downloaderDecoderDestinations(tokens) {
751
1174
  if (p.startsWith(prefix) && p.length > prefix.length) {
752
1175
  const dest = p.slice(prefix.length);
753
1176
  dests.push(dest);
754
- if (dest.includes(".deft/authz") ||
755
- dest.includes(".deft-directive-disable") ||
756
- dest.includes(".no-deft-directive")) {
1177
+ if (pathishIsProtectedDest(dest)) {
757
1178
  protectedPathish.push(dest);
758
1179
  }
759
1180
  break;
@@ -765,7 +1186,7 @@ function downloaderDecoderDestinations(tokens) {
765
1186
  const meta = sqlite3MetaDest(raw, tokens[i + 1]);
766
1187
  if (meta !== null) {
767
1188
  dests.push(meta.dest);
768
- if (pathishIsAuthzDir(meta.dest) || pathishMentionsKillSwitch(meta.dest)) {
1189
+ if (pathishIsProtectedDest(meta.dest)) {
769
1190
  protectedPathish.push(meta.dest);
770
1191
  }
771
1192
  i += meta.consumedNext ? 2 : 1;
@@ -773,18 +1194,16 @@ function downloaderDecoderDestinations(tokens) {
773
1194
  }
774
1195
  }
775
1196
  // scp / certutil / rclone / archive extractors: pathish operands (quote-aware glued-op cut).
776
- // Under UAT: any `.deft/authz` / kill-switch pathish is fail-closed settings
777
- // (read vs write thrash deferred — prefer deny over dest-parser perfection; #3213 / #3245).
1197
+ // Under UAT: any `.deft/authz` / approved-scope / kill-switch pathish is
1198
+ // fail-closed settings (prefer deny over dest-parser perfection; #3213 / #3421).
1199
+ // Last-positional dest bins (aws/convert/magick): only the last operand is dest.
778
1200
  if (collectsProtectedPositionals && !n.startsWith("-")) {
779
1201
  const cut = firstUnquotedShellOpIndex(raw);
780
1202
  const cleaned = cut >= 0 ? raw.slice(0, cut) : raw;
781
1203
  const p = pathishToken(cleaned);
782
1204
  if (p.length > 0) {
783
1205
  lastPositionalPath = p;
784
- // Fail-closed: protected store/kill basenames anywhere in pathish.
785
- if (p.includes(".deft/authz") ||
786
- p.includes(".deft-directive-disable") ||
787
- p.includes(".no-deft-directive")) {
1206
+ if (pathishIsProtectedDest(p) && !LAST_POSITIONAL_DEST_BINS.has(bin)) {
788
1207
  protectedPathish.push(p);
789
1208
  }
790
1209
  }
@@ -886,13 +1305,316 @@ function hasPolicyAuthorityMutator(tokens) {
886
1305
  }
887
1306
  /** True when pathish token names a kill-switch basename (quote-strip resistant). */
888
1307
  function pathishIsProtectedDest(pathish) {
889
- return pathishIsAuthzDir(pathish) || pathishMentionsKillSwitch(pathish);
1308
+ return (pathishIsAuthzDir(pathish) ||
1309
+ pathishIsApprovedScopeDir(pathish) ||
1310
+ pathishMentionsKillSwitch(pathish));
1311
+ }
1312
+ function isGenericProtectedDestFlag(flag) {
1313
+ return DOWNLOADER_FILE_DEST_FLAGS.has(flag) || GENERIC_PROTECTED_EXTRA_DEST_FLAGS.has(flag);
890
1314
  }
1315
+ /** Env-style dest keys harvested even without a leading `--` (`make DESTDIR=`). */
1316
+ const DEST_ASSIGNMENT_KEYS = new Set(["destdir", "prefix", "install_root", "dest_dir"]);
1317
+ /** Bins whose DESTDIR=/PREFIX= assignment is a dest plant (not `echo DESTDIR=…`). */
1318
+ const DEST_ASSIGNMENT_OWNER_BINS = new Set(["make", "gmake"]);
1319
+ /**
1320
+ * First-command bins that print/read: a later `make` token is data, not the writer
1321
+ * (`echo DESTDIR=… make`, `git log make DESTDIR=…`). Also the first command of a
1322
+ * search-launcher `-exec` / `-c` payload (`find -exec echo make DESTDIR=`).
1323
+ * Search/launchers themselves are not in this set — `find -exec make DESTDIR=`
1324
+ * still harvests the payload writer.
1325
+ */
1326
+ const DEST_ASSIGNMENT_NON_WRITER_FIRST_BINS = new Set([
1327
+ "echo",
1328
+ "printf",
1329
+ "true",
1330
+ "false",
1331
+ ":",
1332
+ "git",
1333
+ "gh",
1334
+ "hg",
1335
+ "svn",
1336
+ "cat",
1337
+ "ls",
1338
+ "grep",
1339
+ "egrep",
1340
+ "fgrep",
1341
+ "rg",
1342
+ "head",
1343
+ "tail",
1344
+ "less",
1345
+ "more",
1346
+ "wc",
1347
+ "diff",
1348
+ "stat",
1349
+ "file",
1350
+ ]);
1351
+ /**
1352
+ * Search/launchers whose first token is not the writer. Harvest make/DESTDIR
1353
+ * only from `-exec` / `--exec` / `-c` payloads (`find -exec make DESTDIR=`).
1354
+ * The payload's first command still uses NON_WRITER (`find -exec echo make`).
1355
+ * `xargs make DESTDIR=` stays a wrapper (not in the non-writer set).
1356
+ */
1357
+ const DEST_ASSIGNMENT_SEARCH_LAUNCHER_BINS = new Set(["find"]);
1358
+ const DEST_ASSIGNMENT_EXEC_PAYLOAD_FLAGS = new Set([
1359
+ "-exec",
1360
+ "--exec",
1361
+ "-execdir",
1362
+ "--execdir",
1363
+ "-c",
1364
+ ]);
1365
+ /** find `-exec` payload terminator (`\;` or `+`). Resets so a later `-exec` is still operative. */
1366
+ function isFindExecPayloadTerminator(n) {
1367
+ return n === ";" || n === "+";
1368
+ }
1369
+ /** make targets that do not apply DESTDIR (avoid denying `make DESTDIR=… clean`). */
1370
+ const DEST_ASSIGNMENT_NON_WRITE_TARGETS = new Set([
1371
+ "clean",
1372
+ "distclean",
1373
+ "mostlyclean",
1374
+ "maintainer-clean",
1375
+ "check",
1376
+ "test",
1377
+ "tests",
1378
+ "help",
1379
+ ]);
1380
+ /** make flags that consume the next token (`-C dir`, `-f Makefile`). */
1381
+ const MAKE_VALUE_FLAGS = new Set([
1382
+ "-c",
1383
+ "--directory",
1384
+ "-f",
1385
+ "--file",
1386
+ "--makefile",
1387
+ "--include-dir",
1388
+ "-j",
1389
+ "--jobs",
1390
+ "-l",
1391
+ "--load-average",
1392
+ "--eval",
1393
+ ]);
891
1394
  /**
892
- * Fail-closed dest harvest for write-shaped Shell under UAT (#3354).
1395
+ * True when a DESTDIR/PREFIX assignment is an operative make write.
1396
+ * Looks for make/gmake anywhere after wrappers (including unknown wrappers
1397
+ * such as `xargs`), unless the first command is a print/read bin.
1398
+ * Search/launchers (`find`) still harvest from `-exec` / `--exec` / `-c`
1399
+ * payloads unless that payload's first command is a print/read bin.
1400
+ * Payload state resets at `\;` / `+` so a later `-exec make DESTDIR=` is
1401
+ * still operative (`find -exec echo hi \; -exec make DESTDIR= install`).
1402
+ * Non-writing targets (`clean`) are not operative.
1403
+ */
1404
+ function segmentDestAssignmentIsOperative(tokens, tokenIndex) {
1405
+ let start = tokenIndex;
1406
+ while (start > 0 && !tokenEndsShellSegment(tokens[start - 1]))
1407
+ start--;
1408
+ let skipNext = false;
1409
+ let firstCommand = "";
1410
+ let inExecPayload = false;
1411
+ let execPayloadFirstCommand = "";
1412
+ let sawMake = false;
1413
+ const targets = [];
1414
+ for (let k = start; k < tokens.length; k++) {
1415
+ const raw = tokens[k];
1416
+ if (tokenEndsShellSegment(raw))
1417
+ break;
1418
+ if (skipNext) {
1419
+ skipNext = false;
1420
+ continue;
1421
+ }
1422
+ const n = normalizeToken(raw);
1423
+ if (inExecPayload && isFindExecPayloadTerminator(n)) {
1424
+ inExecPayload = false;
1425
+ execPayloadFirstCommand = "";
1426
+ continue;
1427
+ }
1428
+ // Print/read `-exec` payload: skip until terminator so a later `-exec` is seen.
1429
+ if (inExecPayload &&
1430
+ execPayloadFirstCommand.length > 0 &&
1431
+ DEST_ASSIGNMENT_NON_WRITER_FIRST_BINS.has(execPayloadFirstCommand)) {
1432
+ continue;
1433
+ }
1434
+ if (n.startsWith("-")) {
1435
+ // find -exec / -c opens the payload; do not consume the next token as a
1436
+ // wrapper value. After make is seen, -C still skips its directory.
1437
+ if (DEST_ASSIGNMENT_EXEC_PAYLOAD_FLAGS.has(n) &&
1438
+ DEST_ASSIGNMENT_SEARCH_LAUNCHER_BINS.has(firstCommand) &&
1439
+ !sawMake) {
1440
+ inExecPayload = true;
1441
+ execPayloadFirstCommand = "";
1442
+ continue;
1443
+ }
1444
+ if (!n.includes("=") && (WRAPPER_VALUE_FLAGS.has(n) || MAKE_VALUE_FLAGS.has(n))) {
1445
+ skipNext = true;
1446
+ }
1447
+ continue;
1448
+ }
1449
+ if (isEnvAssign(raw))
1450
+ continue;
1451
+ const bare = writeBinName(raw);
1452
+ if (COMMAND_WRAPPER_BINS.has(bare))
1453
+ continue;
1454
+ if (bare.length > 0 && bare[0] !== undefined && bare[0] >= "0" && bare[0] <= "9") {
1455
+ continue;
1456
+ }
1457
+ if (firstCommand.length === 0) {
1458
+ firstCommand = bare;
1459
+ if (DEST_ASSIGNMENT_NON_WRITER_FIRST_BINS.has(bare))
1460
+ return false;
1461
+ }
1462
+ if (inExecPayload && execPayloadFirstCommand.length === 0) {
1463
+ execPayloadFirstCommand = bare;
1464
+ }
1465
+ if (inExecPayload && DEST_ASSIGNMENT_NON_WRITER_FIRST_BINS.has(execPayloadFirstCommand)) {
1466
+ continue;
1467
+ }
1468
+ if (DEST_ASSIGNMENT_OWNER_BINS.has(bare)) {
1469
+ if (DEST_ASSIGNMENT_SEARCH_LAUNCHER_BINS.has(firstCommand) && !inExecPayload) {
1470
+ continue;
1471
+ }
1472
+ sawMake = true;
1473
+ continue;
1474
+ }
1475
+ if (sawMake)
1476
+ targets.push(n);
1477
+ }
1478
+ if (!sawMake)
1479
+ return false;
1480
+ if (targets.length === 0)
1481
+ return true;
1482
+ for (const t of targets) {
1483
+ if (!DEST_ASSIGNMENT_NON_WRITE_TARGETS.has(t))
1484
+ return true;
1485
+ }
1486
+ return false;
1487
+ }
1488
+ function isReadShapedInputFileFlag(bin, flag) {
1489
+ return READ_SHAPED_FILE_FLAG_BINS.has(bin) && READ_INPUT_FILE_FLAGS.has(flag);
1490
+ }
1491
+ /**
1492
+ * Git globals that do not take a separate value. Unlisted globals consume the
1493
+ * next token so a later dest writer is still seen (#3421 residual).
1494
+ */
1495
+ const GIT_PRE_SUBCOMMAND_BOOLEAN_FLAGS = new Set([
1496
+ "--no-pager",
1497
+ "-p",
1498
+ "--paginate",
1499
+ "-P",
1500
+ "--bare",
1501
+ "--help",
1502
+ "-h",
1503
+ "--version",
1504
+ "--literal-pathspecs",
1505
+ "--glob-pathspecs",
1506
+ "--noglob-pathspecs",
1507
+ "--icase-pathspecs",
1508
+ "--no-replace-objects",
1509
+ "--no-optional-locks",
1510
+ "--no-lazy-fetch",
1511
+ "--no-advice",
1512
+ "--quiet",
1513
+ "-q",
1514
+ ]);
1515
+ /**
1516
+ * True only when the VCS *subcommand* (first non-flag token) is a dest writer.
1517
+ * Later operands named clone/worktree/submodule (e.g. `git log worktree -- …`)
1518
+ * are not write subcommands (#3423 residual).
1519
+ * Boolean globals (`--no-pager`) do not consume the next token. Other globals
1520
+ * consume it even when that token spells a subcommand (#3421 residual).
1521
+ */
1522
+ function hasWriteSubcommand(tokens, start, writeSubs, booleanFlags) {
1523
+ let i = start;
1524
+ while (i < tokens.length) {
1525
+ const raw = tokens[i];
1526
+ if (isShellSegmentBreak(raw))
1527
+ return false;
1528
+ const n = normalizeToken(raw);
1529
+ if (n.startsWith("-")) {
1530
+ if (!n.includes("=") && !booleanFlags.has(n) && !n.startsWith("--no-")) {
1531
+ const next = tokens[i + 1];
1532
+ if (next !== undefined && !String(next).startsWith("-") && !isShellSegmentBreak(next)) {
1533
+ i += 2;
1534
+ continue;
1535
+ }
1536
+ }
1537
+ i++;
1538
+ continue;
1539
+ }
1540
+ return writeSubs.has(n);
1541
+ }
1542
+ return false;
1543
+ }
1544
+ function gitHasWriteSubcommand(tokens, start) {
1545
+ return hasWriteSubcommand(tokens, start, GIT_WRITE_SUBCOMMANDS, GIT_PRE_SUBCOMMAND_BOOLEAN_FLAGS);
1546
+ }
1547
+ /**
1548
+ * True when this git segment is `bundle create` (write), not `bundle verify` / `list-heads`
1549
+ * / `unbundle` (read of an existing bundle) (#3529 Greptile P1).
1550
+ */
1551
+ function gitHasBundleCreate(tokens, start) {
1552
+ const nonFlags = [];
1553
+ let i = start;
1554
+ while (i < tokens.length) {
1555
+ const raw = tokens[i];
1556
+ if (isShellSegmentBreak(raw))
1557
+ break;
1558
+ const n = normalizeToken(raw);
1559
+ if (n.startsWith("-")) {
1560
+ if (!n.includes("=") && !GIT_PRE_SUBCOMMAND_BOOLEAN_FLAGS.has(n) && !n.startsWith("--no-")) {
1561
+ const next = tokens[i + 1];
1562
+ if (next !== undefined && !String(next).startsWith("-") && !isShellSegmentBreak(next)) {
1563
+ i += 2;
1564
+ continue;
1565
+ }
1566
+ }
1567
+ i++;
1568
+ continue;
1569
+ }
1570
+ nonFlags.push(n);
1571
+ if (nonFlags.length >= 2)
1572
+ break;
1573
+ i++;
1574
+ }
1575
+ return nonFlags[0] === "bundle" && GIT_BUNDLE_WRITE_SUBCOMMANDS.has(nonFlags[1] ?? "");
1576
+ }
1577
+ function jjHasWriteSubcommand(tokens, start) {
1578
+ return hasWriteSubcommand(tokens, start, JJ_WRITE_SUBCOMMANDS, JJ_PRE_SUBCOMMAND_BOOLEAN_FLAGS);
1579
+ }
1580
+ /**
1581
+ * True when this forge-CLI segment is a clone dest writer
1582
+ * (`gh repo clone`, `glab repo clone`, `hub clone`) (#3459).
1583
+ * `clone` as a later flag value (`gh pr create --title clone`) is not a dest writer.
1584
+ */
1585
+ function forgeHasCloneSubcommand(tokens, start) {
1586
+ const nonFlags = [];
1587
+ let i = start;
1588
+ while (i < tokens.length) {
1589
+ const raw = tokens[i];
1590
+ if (isShellSegmentBreak(raw))
1591
+ break;
1592
+ const n = normalizeToken(raw);
1593
+ if (n.startsWith("-")) {
1594
+ if (!n.includes("=") && (GH_VALUE_FLAGS.has(raw) || GH_VALUE_FLAGS.has(n))) {
1595
+ const next = tokens[i + 1];
1596
+ if (next !== undefined && !String(next).startsWith("-") && !isShellSegmentBreak(next)) {
1597
+ i += 2;
1598
+ continue;
1599
+ }
1600
+ }
1601
+ i++;
1602
+ continue;
1603
+ }
1604
+ nonFlags.push(n);
1605
+ i++;
1606
+ }
1607
+ if (nonFlags[0] === "clone")
1608
+ return true;
1609
+ return nonFlags[0] === "repo" && nonFlags[1] === "clone";
1610
+ }
1611
+ /**
1612
+ * Fail-closed dest harvest for write-shaped Shell under UAT (#3354 / #3382 / #3421).
893
1613
  * Named-bin parsers above are not the only path: any token that looks like
894
- * `-o` / `--output` / `--outfile` / `--output-file` (or attached `-oDIR`)
895
- * whose dest is authz/kill-switch is collected even when the bin is unknown.
1614
+ * `-o` / `--output` / `--outfile` / `--output-file` / `-d` / `--dir` /
1615
+ * `--destination` / `--path` / `--directory` / `--workdir` / `--file` / `-f`
1616
+ * (or attached `-oDIR` / `-dDIR`) whose dest is authz/kill-switch/approved-scope
1617
+ * is collected even when the bin is unknown.
896
1618
  * scp `-o` (OpenSSH option) and cpio `-o` (copy-out) stay excluded.
897
1619
  */
898
1620
  function genericProtectedDests(tokens) {
@@ -907,29 +1629,54 @@ function genericProtectedDests(tokens) {
907
1629
  currentBin = "";
908
1630
  continue;
909
1631
  }
910
- if (!n.startsWith("-")) {
1632
+ if (!n.startsWith("-") && !isEnvAssign(raw)) {
911
1633
  if (isDownloaderDecoderBin(raw)) {
912
- currentBin = binBareName(raw);
1634
+ currentBin = writeBinName(raw);
913
1635
  }
914
1636
  else if (currentBin.length === 0 &&
915
1637
  !raw.includes("/") &&
916
1638
  !raw.includes("\\") &&
917
1639
  n.length > 0) {
918
- currentBin = binBareName(raw);
1640
+ currentBin = writeBinName(raw);
919
1641
  }
920
1642
  }
921
- if (currentBin === "scp" || currentBin === "cpio")
1643
+ if (isScpFamilyBin(currentBin) || currentBin === "cpio")
922
1644
  continue;
1645
+ // Env-style dest assignments (`DESTDIR=`, `PREFIX=`) without a leading dash (#3545).
1646
+ if (n.includes("=") && !n.startsWith("-")) {
1647
+ const eq = raw.indexOf("=");
1648
+ const keyRaw = raw.slice(0, eq);
1649
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(keyRaw)) {
1650
+ const key = normalizeToken(keyRaw);
1651
+ if (DEST_ASSIGNMENT_KEYS.has(key) && segmentDestAssignmentIsOperative(tokens, i)) {
1652
+ const dest = pathishToken(raw.slice(eq + 1));
1653
+ if (pathishIsProtectedDest(dest))
1654
+ dests.push(dest);
1655
+ }
1656
+ }
1657
+ continue;
1658
+ }
923
1659
  if (n.includes("=") && (n.startsWith("-") || n.startsWith("--"))) {
924
1660
  const eq = raw.indexOf("=");
925
1661
  const flag = normalizeToken(raw.slice(0, eq));
926
- if (DOWNLOADER_FILE_DEST_FLAGS.has(flag)) {
1662
+ if (isGenericProtectedDestFlag(flag) && !isReadShapedInputFileFlag(currentBin, flag)) {
927
1663
  const dest = pathishToken(raw.slice(eq + 1));
928
1664
  if (pathishIsProtectedDest(dest))
929
1665
  dests.push(dest);
930
1666
  }
931
1667
  continue;
932
1668
  }
1669
+ // Named dest flags (`-Destination`, `--output-dir`) take the next token.
1670
+ // Check before attached `-oDIR` / `-dDIR` so `-destination` is not `-d` + `estination`.
1671
+ if (isGenericProtectedDestFlag(n) && !isReadShapedInputFileFlag(currentBin, n)) {
1672
+ const next = tokens[i + 1];
1673
+ if (next !== undefined && !String(next).startsWith("-") && !isShellSegmentBreak(next)) {
1674
+ const dest = pathishToken(next);
1675
+ if (pathishIsProtectedDest(dest))
1676
+ dests.push(dest);
1677
+ }
1678
+ continue;
1679
+ }
933
1680
  if (n.startsWith("-") &&
934
1681
  !n.startsWith("--") &&
935
1682
  n.startsWith("-o") &&
@@ -940,13 +1687,10 @@ function genericProtectedDests(tokens) {
940
1687
  dests.push(dest);
941
1688
  continue;
942
1689
  }
943
- if (DOWNLOADER_FILE_DEST_FLAGS.has(n)) {
944
- const next = tokens[i + 1];
945
- if (next !== undefined && !String(next).startsWith("-") && !isShellSegmentBreak(next)) {
946
- const dest = pathishToken(next);
947
- if (pathishIsProtectedDest(dest))
948
- dests.push(dest);
949
- }
1690
+ if (n.startsWith("-") && !n.startsWith("--") && n.startsWith("-d") && n.length > 2) {
1691
+ const dest = pathishToken(raw.slice(2));
1692
+ if (pathishIsProtectedDest(dest))
1693
+ dests.push(dest);
950
1694
  }
951
1695
  }
952
1696
  return dests;
@@ -969,8 +1713,9 @@ function sqlite3MetaDest(raw, next) {
969
1713
  return null;
970
1714
  }
971
1715
  function pathishMentionsKillSwitch(pathish) {
1716
+ const p = canonicalizePathish(pathish);
972
1717
  for (const name of KILL_SWITCH_BASENAMES) {
973
- if (pathish === name || pathish.endsWith(`/${name}`) || pathish.includes(name)) {
1718
+ if (p === name || p.endsWith(`/${name}`) || p.includes(name)) {
974
1719
  return true;
975
1720
  }
976
1721
  }
@@ -1045,9 +1790,13 @@ function hasKillSwitchShellWrite(command, tokens) {
1045
1790
  ]);
1046
1791
  for (let ti = 0; ti < tokens.length; ti++) {
1047
1792
  const binTok = normalizeToken(tokens[ti]);
1048
- const bare = binBareName(tokens[ti]);
1793
+ const bare = writeBinName(tokens[ti]);
1049
1794
  if (!killWriteBins.has(binTok) && !killWriteBins.has(bare))
1050
1795
  continue;
1796
+ // Last-positional dest bins (and their subcommands): dest harvest owns dest vs source.
1797
+ if (LAST_POSITIONAL_DEST_BINS.has(bare) || segmentStartedByLastPositionalDest(tokens, ti)) {
1798
+ continue;
1799
+ }
1051
1800
  for (let tj = ti + 1; tj < tokens.length; tj++) {
1052
1801
  const p = pathishToken(tokens[tj]);
1053
1802
  if (pathishMentionsKillSwitch(p))
@@ -1061,8 +1810,14 @@ function hasKillSwitchShellWrite(command, tokens) {
1061
1810
  }
1062
1811
  }
1063
1812
  // Bare `touch .deft-directive-disable` / `ln -sf x .deft-directive-disable` — path later.
1064
- for (const t of tokens) {
1065
- const p = pathishToken(t);
1813
+ // Skip tokens in a last-positional dest segment (dest harvest owns dest vs source).
1814
+ // Other segments still scan so `aws … ; touch .deft-directive-disable` is settings.
1815
+ for (let ti = 0; ti < tokens.length; ti++) {
1816
+ if (LAST_POSITIONAL_DEST_BINS.has(writeBinName(tokens[ti])) ||
1817
+ segmentStartedByLastPositionalDest(tokens, ti)) {
1818
+ continue;
1819
+ }
1820
+ const p = pathishToken(tokens[ti]);
1066
1821
  for (const name of KILL_SWITCH_BASENAMES) {
1067
1822
  // Exact basename or ends with /basename
1068
1823
  if (p === name || p.endsWith(`/${name}`)) {
@@ -1070,8 +1825,8 @@ function hasKillSwitchShellWrite(command, tokens) {
1070
1825
  if (hasWriteShape(command, tokens) ||
1071
1826
  lower.includes("touch") ||
1072
1827
  lower.includes("new-item") ||
1073
- SYMLINK_PLANT_BINS.has(binBareName(tokens[0])) ||
1074
- tokens.some((tok) => SYMLINK_PLANT_BINS.has(binBareName(tok)))) {
1828
+ SYMLINK_PLANT_BINS.has(writeBinName(tokens[0])) ||
1829
+ tokens.some((tok) => SYMLINK_PLANT_BINS.has(writeBinName(tok)))) {
1075
1830
  return true;
1076
1831
  }
1077
1832
  }
@@ -1099,17 +1854,39 @@ function isProgrammaticWriteBinToken(token) {
1099
1854
  bare === "nodejs" ||
1100
1855
  bare === "perl" ||
1101
1856
  bare === "ruby" ||
1857
+ bare === "jruby" ||
1858
+ bare === "pypy" ||
1859
+ bare === "pypy3" ||
1102
1860
  bare === "pwsh" ||
1103
- bare === "powershell") {
1861
+ bare === "powershell" ||
1862
+ bare === "tsx" ||
1863
+ bare === "ts-node" ||
1864
+ bare === "tsnode" ||
1865
+ bare === "bun" ||
1866
+ bare === "deno" ||
1867
+ bare === "php" ||
1868
+ bare === "lua" ||
1869
+ bare === "luajit") {
1104
1870
  return true;
1105
1871
  }
1106
- // Versioned: python3.11, python3.12, node18, …
1872
+ // Versioned: python3.11, python3.12, node18, php8.3, lua5.4,
1107
1873
  if (bare.startsWith("python3.") || bare.startsWith("python2."))
1108
1874
  return true;
1109
1875
  if (/^python\d+(\.\d+)*$/.test(bare))
1110
1876
  return true;
1111
1877
  if (/^node\d+$/.test(bare))
1112
1878
  return true;
1879
+ if (/^php\d+(\.\d+)*$/.test(bare))
1880
+ return true;
1881
+ if (/^lua\d+(\.\d+)*$/.test(bare))
1882
+ return true;
1883
+ // #3545 residual: versioned ruby / jruby / pypy (bare `ruby` already matched).
1884
+ if (/^ruby\d+(\.\d+)*$/.test(bare))
1885
+ return true;
1886
+ if (/^jruby\d+(\.\d+)*$/.test(bare))
1887
+ return true;
1888
+ if (bare.startsWith("pypy3.") || /^pypy\d+(\.\d+)*$/.test(bare))
1889
+ return true;
1113
1890
  return false;
1114
1891
  }
1115
1892
  /**
@@ -1117,6 +1894,74 @@ function isProgrammaticWriteBinToken(token) {
1117
1894
  * Escaped quotes (`\'` / `\"`) stay inside the string. Used so `print('.write(')` is not
1118
1895
  * a write API while `open(p,'w').write('x')` still matches `.write(` (Greptile conf residual).
1119
1896
  */
1897
+ /**
1898
+ * True when `ident` is followed by optional whitespace then `(` (PHP `file_put_contents (`).
1899
+ * Skips matches inside quotes so `echo "file_put_contents ($x)"` is not a write.
1900
+ * O(n) — no nested-quantifier regex.
1901
+ */
1902
+ function includesIdentCall(haystack, ident) {
1903
+ if (ident.length === 0 || haystack.length < ident.length)
1904
+ return false;
1905
+ let inSingle = false;
1906
+ let inDouble = false;
1907
+ for (let i = 0; i < haystack.length; i++) {
1908
+ const c = haystack[i];
1909
+ if (c === "\\" && i + 1 < haystack.length && (inSingle || inDouble)) {
1910
+ i++;
1911
+ continue;
1912
+ }
1913
+ if (c === "'" && !inDouble) {
1914
+ inSingle = !inSingle;
1915
+ continue;
1916
+ }
1917
+ if (c === '"' && !inSingle) {
1918
+ inDouble = !inDouble;
1919
+ continue;
1920
+ }
1921
+ if (inSingle || inDouble)
1922
+ continue;
1923
+ if (!haystack.startsWith(ident, i))
1924
+ continue;
1925
+ let j = i + ident.length;
1926
+ while (j < haystack.length) {
1927
+ const w = haystack[j];
1928
+ if (w === " " || w === "\t" || w === "\n" || w === "\r") {
1929
+ j++;
1930
+ continue;
1931
+ }
1932
+ break;
1933
+ }
1934
+ if (j < haystack.length && haystack[j] === "(")
1935
+ return true;
1936
+ }
1937
+ return false;
1938
+ }
1939
+ /** Strip matching wrapping quotes from a `-r`/`-e` payload. */
1940
+ function unwrapQuotedPayload(token) {
1941
+ if (token.length >= 2) {
1942
+ const a = token[0];
1943
+ const b = token[token.length - 1];
1944
+ if ((a === '"' && b === '"') || (a === "'" && b === "'")) {
1945
+ return token.slice(1, -1);
1946
+ }
1947
+ }
1948
+ return token;
1949
+ }
1950
+ /** Script bodies after interpreter `-r`/`-e`/`-c` (PHP/node one-liners). */
1951
+ function interpreterScriptPayloads(tokens) {
1952
+ const out = [];
1953
+ const flags = new Set(["-r", "-e", "-c", "--run", "--eval", "--execute"]);
1954
+ for (let i = 0; i < tokens.length; i++) {
1955
+ const n = normalizeToken(tokens[i]);
1956
+ if (!flags.has(n))
1957
+ continue;
1958
+ const next = tokens[i + 1];
1959
+ if (next === undefined || isShellSegmentBreak(next) || String(next).startsWith("-"))
1960
+ continue;
1961
+ out.push(unwrapQuotedPayload(next));
1962
+ }
1963
+ return out;
1964
+ }
1120
1965
  function includesOutsideQuotes(haystack, needle) {
1121
1966
  if (needle.length === 0 || haystack.length < needle.length)
1122
1967
  return false;
@@ -1143,6 +1988,85 @@ function includesOutsideQuotes(haystack, needle) {
1143
1988
  }
1144
1989
  return false;
1145
1990
  }
1991
+ /**
1992
+ * Perl `open F,">dest"` / `open FH, '>'` without parens (#3545).
1993
+ * Quote-aware so `print 'open F,>'` is not a write. O(n).
1994
+ */
1995
+ function hasPerlBareOpenWrite(haystack) {
1996
+ let inSingle = false;
1997
+ let inDouble = false;
1998
+ for (let i = 0; i < haystack.length; i++) {
1999
+ const c = haystack[i];
2000
+ if (c === "\\" && i + 1 < haystack.length && (inSingle || inDouble)) {
2001
+ i++;
2002
+ continue;
2003
+ }
2004
+ if (c === "'" && !inDouble) {
2005
+ inSingle = !inSingle;
2006
+ continue;
2007
+ }
2008
+ if (c === '"' && !inSingle) {
2009
+ inDouble = !inDouble;
2010
+ continue;
2011
+ }
2012
+ if (inSingle || inDouble)
2013
+ continue;
2014
+ if (!haystack.startsWith("open", i))
2015
+ continue;
2016
+ if (i > 0) {
2017
+ const prev = haystack[i - 1];
2018
+ if ((prev >= "a" && prev <= "z") || (prev >= "0" && prev <= "9") || prev === "_")
2019
+ continue;
2020
+ }
2021
+ let j = i + 4;
2022
+ while (j < haystack.length) {
2023
+ const w = haystack[j];
2024
+ if (w === " " || w === "\t" || w === "\n" || w === "\r") {
2025
+ j++;
2026
+ continue;
2027
+ }
2028
+ break;
2029
+ }
2030
+ const identStart = j;
2031
+ while (j < haystack.length) {
2032
+ const ch = haystack[j];
2033
+ if ((ch >= "a" && ch <= "z") ||
2034
+ (ch >= "A" && ch <= "Z") ||
2035
+ (ch >= "0" && ch <= "9") ||
2036
+ ch === "_") {
2037
+ j++;
2038
+ continue;
2039
+ }
2040
+ break;
2041
+ }
2042
+ if (j === identStart)
2043
+ continue;
2044
+ while (j < haystack.length) {
2045
+ const w = haystack[j];
2046
+ if (w === " " || w === "\t") {
2047
+ j++;
2048
+ continue;
2049
+ }
2050
+ break;
2051
+ }
2052
+ if (j >= haystack.length || haystack[j] !== ",")
2053
+ continue;
2054
+ j++;
2055
+ while (j < haystack.length) {
2056
+ const w = haystack[j];
2057
+ if (w === " " || w === "\t") {
2058
+ j++;
2059
+ continue;
2060
+ }
2061
+ break;
2062
+ }
2063
+ const q = haystack[j];
2064
+ if ((q === "'" || q === '"') && j + 1 < haystack.length && haystack[j + 1] === ">") {
2065
+ return true;
2066
+ }
2067
+ }
2068
+ return false;
2069
+ }
1146
2070
  /**
1147
2071
  * True when command uses a programmatic bin with a **write** API, optionally with
1148
2072
  * path-obfuscation markers (base64/bytes/chr). Pure reads / print-only stay unclassifiable.
@@ -1173,6 +2097,8 @@ function hasWriteCapableProgrammaticShell(command, tokens) {
1173
2097
  const writeApi = lower.includes("writefilesync") ||
1174
2098
  lower.includes("writefile") ||
1175
2099
  lower.includes("writetext") ||
2100
+ includesIdentCall(lower, "file_put_contents") ||
2101
+ interpreterScriptPayloads(tokens).some((p) => includesIdentCall(p.toLowerCase(), "file_put_contents")) ||
1176
2102
  lower.includes("set-content") ||
1177
2103
  lower.includes("out-file") ||
1178
2104
  lower.includes("fs.write") ||
@@ -1180,6 +2106,17 @@ function hasWriteCapableProgrammaticShell(command, tokens) {
1180
2106
  lower.includes("path.write(") ||
1181
2107
  lower.includes("file.write(") ||
1182
2108
  lower.includes("spurt") ||
2109
+ interpreterScriptPayloads(tokens).some((p) => {
2110
+ const pl = p.toLowerCase();
2111
+ return (includesIdentCall(pl, "write_file") ||
2112
+ includesIdentCall(pl, "spew") ||
2113
+ includesOutsideQuotes(pl, "->spew") ||
2114
+ hasPerlBareOpenWrite(pl));
2115
+ }) ||
2116
+ includesIdentCall(lower, "write_file") ||
2117
+ includesIdentCall(lower, "spew") ||
2118
+ includesOutsideQuotes(lower, "->spew") ||
2119
+ hasPerlBareOpenWrite(lower) ||
1183
2120
  lower.includes(">>") ||
1184
2121
  lower.includes("unlink(") ||
1185
2122
  lower.includes("rmsync") ||
@@ -1217,7 +2154,13 @@ function hasWriteCapableProgrammaticShell(command, tokens) {
1217
2154
  lower.includes("mode='a") ||
1218
2155
  lower.includes('mode="a') ||
1219
2156
  lower.includes("mode=w"));
1220
- const writeish = writeApi || openWriteMode;
2157
+ // Perl `open F,">dest"` `open(` is absent; `-e` payloads with spaces split tokens (#3545).
2158
+ const perlBareOpenWrite = (lower.includes("open ") || lower.includes("open\t")) &&
2159
+ (lower.includes(",'>") ||
2160
+ lower.includes(',">') ||
2161
+ lower.includes(", '>") ||
2162
+ lower.includes(', ">'));
2163
+ const writeish = writeApi || openWriteMode || perlBareOpenWrite;
1221
2164
  // Path construction that hides the destination from literal classifiers.
1222
2165
  const obfuscatedPath = lower.includes("base64") ||
1223
2166
  lower.includes("fromcharcode") ||
@@ -1243,17 +2186,89 @@ function hasWriteCapableProgrammaticShell(command, tokens) {
1243
2186
  function pathishToken(token) {
1244
2187
  return token.replace(/['"]/g, "").toLowerCase().replace(/\\/g, "/");
1245
2188
  }
2189
+ /**
2190
+ * Collapse `//`, `/./`, and `/../` lexically so equivalent dests still match
2191
+ * (`.deft/foo/../approved-scope/x`) without following symlinks or matching
2192
+ * prefix siblings. O(n) — no nested-quantifier regex.
2193
+ */
2194
+ function canonicalizePathish(pathish) {
2195
+ const parts = [];
2196
+ let cur = "";
2197
+ const abs = pathish.startsWith("/");
2198
+ for (let i = 0; i <= pathish.length; i++) {
2199
+ const c = i < pathish.length ? pathish[i] : "/";
2200
+ if (c === "/" || i === pathish.length) {
2201
+ if (cur.length === 0 || cur === ".") {
2202
+ // drop empty and `.`
2203
+ }
2204
+ else if (cur === "..") {
2205
+ if (parts.length > 0 && parts[parts.length - 1] !== "..") {
2206
+ parts.pop();
2207
+ }
2208
+ else if (!abs) {
2209
+ parts.push("..");
2210
+ }
2211
+ }
2212
+ else {
2213
+ parts.push(cur);
2214
+ }
2215
+ cur = "";
2216
+ }
2217
+ else {
2218
+ cur += c;
2219
+ }
2220
+ }
2221
+ const joined = parts.join("/");
2222
+ return abs ? `/${joined}` : joined;
2223
+ }
1246
2224
  /**
1247
2225
  * True when a pathish string targets `.deft/authz` (after quote strip / slash normalize).
1248
2226
  * Quote-split forms like `'.deft/'authz'/grants/x'` become `.deft/authz/grants/x` via pathishToken.
1249
2227
  */
2228
+ /** True when `dir` is a path segment (left-bounded; `foo.deft/authz` is not `.deft/authz`). */
2229
+ function pathishHasDirSegment(pathish, dir) {
2230
+ const p = canonicalizePathish(pathish.trim());
2231
+ if (p === dir || p.startsWith(`${dir}/`) || p.startsWith(`${dir}\\`))
2232
+ return true;
2233
+ for (let i = 0; i < p.length; i++) {
2234
+ const c = p[i];
2235
+ if (c !== "/" && c !== "\\" && c !== ":" && c !== "=")
2236
+ continue;
2237
+ const rest = p.slice(i + 1);
2238
+ if (rest === dir || rest.startsWith(`${dir}/`) || rest.startsWith(`${dir}\\`))
2239
+ return true;
2240
+ }
2241
+ return false;
2242
+ }
1250
2243
  function pathishIsAuthzDir(pathish) {
1251
- return pathish.includes(".deft/authz");
2244
+ return pathishHasDirSegment(pathish, ".deft/authz");
2245
+ }
2246
+ /** True when a pathish string targets `.deft/approved-scope` (#3421 / #3410 mint). */
2247
+ function pathishIsApprovedScopeDir(pathish) {
2248
+ return pathishHasDirSegment(pathish, ".deft/approved-scope");
2249
+ }
2250
+ function pathishIsSettingsStoreDir(pathish) {
2251
+ return pathishIsAuthzDir(pathish) || pathishIsApprovedScopeDir(pathish);
2252
+ }
2253
+ /** True when dest is a settings-store path or a split-path `authz` segment after `.deft`. */
2254
+ function destMentionsAuthzSegment(dest) {
2255
+ const p = canonicalizePathish(pathishToken(dest).trim());
2256
+ if (pathishIsSettingsStoreDir(p))
2257
+ return true;
2258
+ const segs = p.split("/").filter((s) => s.length > 0);
2259
+ for (let i = 0; i < segs.length; i++) {
2260
+ if (segs[i] !== "authz")
2261
+ continue;
2262
+ const prev = i > 0 ? segs[i - 1] : "";
2263
+ if (prev === ".deft" || prev.length === 0)
2264
+ return true;
2265
+ }
2266
+ return false;
1252
2267
  }
1253
2268
  /**
1254
- * Shell **write** targeting `.deft/authz/` (#3110 AC-3 / #3206 / #3213).
2269
+ * Shell **write** targeting `.deft/authz/` or `.deft/approved-scope/` (#3110 / #3421).
1255
2270
  * Pure reads (`cat .deft/authz/state.json`) stay unclassifiable — use `authz:show`.
1256
- * Redirects only count when the destination region contains `.deft/authz`.
2271
+ * Redirects only count when the destination region contains a settings-store dir.
1257
2272
  * Pathish/token checks run even when the raw command lacks contiguous `.deft/authz`
1258
2273
  * text (quote-split residual: `cp x '.deft/'authz'/grants/y'`).
1259
2274
  */
@@ -1279,7 +2294,7 @@ function hasAuthzDirShellWrite(command, tokens) {
1279
2294
  hay[end] !== "\n") {
1280
2295
  end++;
1281
2296
  }
1282
- if (hay.slice(j, end).includes(".deft/authz"))
2297
+ if (pathishIsSettingsStoreDir(hay.slice(j, end)))
1283
2298
  return true;
1284
2299
  }
1285
2300
  }
@@ -1289,7 +2304,7 @@ function hasAuthzDirShellWrite(command, tokens) {
1289
2304
  // must not fail-open as unclassifiable (SLizard residual). ARCHIVE_ALT_WRITE_BINS: tar/rclone
1290
2305
  // residual pathish without dest-flag perfection thrash.
1291
2306
  for (let ti = 0; ti < tokens.length; ti++) {
1292
- const bare = binBareName(tokens[ti]);
2307
+ const bare = writeBinName(tokens[ti]);
1293
2308
  const n = normalizeToken(tokens[ti]);
1294
2309
  if (!INDIRECT_WRITE_BINS.has(n) &&
1295
2310
  !INDIRECT_WRITE_BINS.has(bare) &&
@@ -1297,20 +2312,24 @@ function hasAuthzDirShellWrite(command, tokens) {
1297
2312
  !ARCHIVE_ALT_WRITE_BINS.has(bare)) {
1298
2313
  continue;
1299
2314
  }
2315
+ // Last-positional dest bins (and their subcommands): dest harvest owns dest vs source.
2316
+ if (LAST_POSITIONAL_DEST_BINS.has(bare) || segmentStartedByLastPositionalDest(tokens, ti)) {
2317
+ continue;
2318
+ }
1300
2319
  for (let tj = ti + 1; tj < tokens.length; tj++) {
1301
2320
  const p = pathishToken(tokens[tj]);
1302
- if (pathishIsAuthzDir(p))
2321
+ if (pathishIsSettingsStoreDir(p))
1303
2322
  return true;
1304
2323
  for (const prefix of SOCAT_WRITE_ADDR_PREFIXES) {
1305
- if (p.startsWith(prefix) && pathishIsAuthzDir(p.slice(prefix.length))) {
2324
+ if (p.startsWith(prefix) && pathishIsSettingsStoreDir(p.slice(prefix.length))) {
1306
2325
  return true;
1307
2326
  }
1308
2327
  }
1309
2328
  }
1310
2329
  }
1311
- // Downloader/decoder destinations under .deft/authz (#3206 / #3213 / #3245 archive+alt).
2330
+ // Downloader/decoder destinations under settings-store dirs (#3206 / #3421).
1312
2331
  for (const dest of downloaderDecoderDestinations(tokens)) {
1313
- if (pathishIsAuthzDir(dest))
2332
+ if (pathishIsSettingsStoreDir(dest))
1314
2333
  return true;
1315
2334
  }
1316
2335
  // Contiguous mention without write shape stays false (reads like cat .deft/authz/…).
@@ -1347,8 +2366,38 @@ const INDIRECT_WRITE_BINS = new Set([
1347
2366
  "remove-item",
1348
2367
  "ri",
1349
2368
  "ni",
2369
+ "new-item",
1350
2370
  "sc",
1351
2371
  "mi",
2372
+ // #3459 residual: Windows copy / fsutil / touch / TS runners.
2373
+ "copy",
2374
+ "fsutil",
2375
+ "touch",
2376
+ "tsx",
2377
+ "ts-node",
2378
+ // #3529 residual: Windows dest writers / rename / moreutils sponge / runtimes.
2379
+ "xcopy",
2380
+ "robocopy",
2381
+ "move",
2382
+ "ren",
2383
+ "rename",
2384
+ "rename-item",
2385
+ "sponge",
2386
+ "bun",
2387
+ "deno",
2388
+ "php",
2389
+ "lua",
2390
+ "start-bitstransfer",
2391
+ "bitsadmin",
2392
+ "expand-archive",
2393
+ "tee-object",
2394
+ "export-csv",
2395
+ "expand",
2396
+ "extrac32",
2397
+ "makecab",
2398
+ "replace",
2399
+ // #3545 residual: dest writers (fromdos/degit/ddrescue). dpkg/pdftk/gs stay dest-flag gated.
2400
+ ...UAT_RESIDUAL_INPLACE_WRITE_BINS_3545,
1352
2401
  ]);
1353
2402
  /**
1354
2403
  * O(n): true when command expands `$…` / `` `…` `` / `%VAR%`
@@ -1390,7 +2439,7 @@ function hasWriteShape(command, tokens) {
1390
2439
  if (command.includes(">"))
1391
2440
  return true;
1392
2441
  for (const t of tokens) {
1393
- const bare = binBareName(t);
2442
+ const bare = writeBinName(t);
1394
2443
  if (INDIRECT_WRITE_BINS.has(normalizeToken(t)) || INDIRECT_WRITE_BINS.has(bare))
1395
2444
  return true;
1396
2445
  // #3245: archive extractors / alt downloaders are write-shaped (defense-in-depth for
@@ -1551,15 +2600,7 @@ function hasProgrammaticAuthzEnvWrite(command, tokens) {
1551
2600
  const lower = command.toLowerCase();
1552
2601
  let hasProg = false;
1553
2602
  for (const t of tokens) {
1554
- const n = normalizeToken(t);
1555
- if (n === "python" ||
1556
- n === "python3" ||
1557
- n === "node" ||
1558
- n === "nodejs" ||
1559
- n === "perl" ||
1560
- n === "ruby" ||
1561
- n === "pwsh" ||
1562
- n === "powershell") {
2603
+ if (isProgrammaticWriteBinToken(t)) {
1563
2604
  hasProg = true;
1564
2605
  break;
1565
2606
  }
@@ -1748,7 +2789,7 @@ export function classifyShellAuthzOps(command) {
1748
2789
  lower[end] !== "\n") {
1749
2790
  end++;
1750
2791
  }
1751
- if (lower.slice(j, end).includes("authz")) {
2792
+ if (destMentionsAuthzSegment(lower.slice(j, end))) {
1752
2793
  found.add("settings");
1753
2794
  break;
1754
2795
  }