@deftai/directive-core 0.98.1 → 0.100.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 (144) hide show
  1. package/dist/authz/actions.d.ts +6 -0
  2. package/dist/authz/actions.js +3 -0
  3. package/dist/authz/classify.js +401 -75
  4. package/dist/authz/decompose-apply.d.ts +67 -0
  5. package/dist/authz/decompose-apply.js +302 -0
  6. package/dist/authz/index.d.ts +3 -1
  7. package/dist/authz/index.js +3 -1
  8. package/dist/authz/store.d.ts +53 -0
  9. package/dist/authz/store.js +219 -1
  10. package/dist/authz/types.d.ts +19 -2
  11. package/dist/authz/types.js +5 -1
  12. package/dist/consumer-check-contract/evaluate.d.ts +40 -0
  13. package/dist/consumer-check-contract/evaluate.js +188 -3
  14. package/dist/consumer-check-contract/index.d.ts +1 -1
  15. package/dist/consumer-check-contract/index.js +1 -1
  16. package/dist/content-contracts/skills/greptile-detector.d.ts +42 -0
  17. package/dist/content-contracts/skills/greptile-detector.js +202 -4
  18. package/dist/decision/index.d.ts +17 -0
  19. package/dist/decision/index.js +35 -0
  20. package/dist/decision/list.d.ts +47 -0
  21. package/dist/decision/list.js +250 -0
  22. package/dist/decision/schema.d.ts +88 -0
  23. package/dist/decision/schema.js +293 -0
  24. package/dist/decision/write.d.ts +82 -0
  25. package/dist/decision/write.js +427 -0
  26. package/dist/doctor/checks.d.ts +44 -0
  27. package/dist/doctor/checks.js +428 -2
  28. package/dist/doctor/main.d.ts +6 -7
  29. package/dist/doctor/main.js +72 -79
  30. package/dist/doctor/which.d.ts +20 -1
  31. package/dist/doctor/which.js +67 -1
  32. package/dist/eval/report.d.ts +29 -0
  33. package/dist/eval/report.js +69 -0
  34. package/dist/eval/run.d.ts +9 -0
  35. package/dist/eval/run.js +40 -4
  36. package/dist/eval/version-pin.d.ts +99 -0
  37. package/dist/eval/version-pin.js +181 -0
  38. package/dist/finish-loop/pr-finish-loop.d.ts +13 -2
  39. package/dist/finish-loop/pr-finish-loop.js +116 -5
  40. package/dist/finish-loop/types.d.ts +1 -1
  41. package/dist/hooks/dispatcher.d.ts +9 -5
  42. package/dist/hooks/dispatcher.js +23 -15
  43. package/dist/hooks/readonly.d.ts +6 -3
  44. package/dist/hooks/readonly.js +44 -25
  45. package/dist/index.d.ts +2 -0
  46. package/dist/index.js +2 -0
  47. package/dist/init-deposit/agent-hooks.d.ts +20 -0
  48. package/dist/init-deposit/agent-hooks.js +51 -31
  49. package/dist/intake/issue-ingest.js +29 -0
  50. package/dist/lifecycle/completed-consistency.d.ts +60 -0
  51. package/dist/lifecycle/completed-consistency.js +443 -0
  52. package/dist/lifecycle/completed-tracked-on-delivery.d.ts +47 -0
  53. package/dist/lifecycle/completed-tracked-on-delivery.js +415 -0
  54. package/dist/lifecycle/index.d.ts +2 -0
  55. package/dist/lifecycle/index.js +2 -0
  56. package/dist/literal-acceptance/capture.d.ts +52 -0
  57. package/dist/literal-acceptance/capture.js +658 -0
  58. package/dist/literal-acceptance/evaluate.d.ts +47 -0
  59. package/dist/literal-acceptance/evaluate.js +193 -0
  60. package/dist/literal-acceptance/index.d.ts +12 -0
  61. package/dist/literal-acceptance/index.js +12 -0
  62. package/dist/literal-acceptance/run.d.ts +38 -0
  63. package/dist/literal-acceptance/run.js +216 -0
  64. package/dist/literal-acceptance/safety.d.ts +24 -0
  65. package/dist/literal-acceptance/safety.js +251 -0
  66. package/dist/literal-acceptance/types.d.ts +82 -0
  67. package/dist/literal-acceptance/types.js +26 -0
  68. package/dist/platform/host-content-surface.d.ts +74 -0
  69. package/dist/platform/host-content-surface.js +214 -0
  70. package/dist/platform/index.d.ts +1 -0
  71. package/dist/platform/index.js +1 -0
  72. package/dist/policy/ceremony-dial.d.ts +289 -0
  73. package/dist/policy/ceremony-dial.js +980 -0
  74. package/dist/policy/deft-directive-disable.js +12 -2
  75. package/dist/policy/index.d.ts +2 -0
  76. package/dist/policy/index.js +16 -1
  77. package/dist/policy/merge-approval-head.d.ts +129 -0
  78. package/dist/policy/merge-approval-head.js +461 -0
  79. package/dist/policy/require-human-merge.d.ts +5 -0
  80. package/dist/policy/require-human-merge.js +5 -0
  81. package/dist/pr-merge-readiness/ci-gate.d.ts +20 -3
  82. package/dist/pr-merge-readiness/ci-gate.js +65 -5
  83. package/dist/pr-merge-readiness/compute.d.ts +13 -0
  84. package/dist/pr-merge-readiness/compute.js +99 -14
  85. package/dist/pr-merge-readiness/evaluate.js +10 -0
  86. package/dist/pr-merge-readiness/gh.d.ts +49 -0
  87. package/dist/pr-merge-readiness/gh.js +268 -0
  88. package/dist/pr-merge-readiness/index.d.ts +2 -2
  89. package/dist/pr-merge-readiness/index.js +2 -2
  90. package/dist/pr-merge-readiness/mergeability.js +5 -0
  91. package/dist/pr-merge-readiness/output.js +2 -0
  92. package/dist/pr-merge-readiness/parse.js +4 -0
  93. package/dist/pr-merge-readiness/test-gh-fixtures.helpers.js +4 -0
  94. package/dist/pr-merge-readiness/types.d.ts +6 -0
  95. package/dist/pr-wait-mergeable/cascade.d.ts +11 -0
  96. package/dist/pr-wait-mergeable/cascade.js +115 -1
  97. package/dist/pr-wait-mergeable/main.d.ts +4 -0
  98. package/dist/pr-wait-mergeable/main.js +4 -0
  99. package/dist/pr-wait-mergeable/types.d.ts +3 -1
  100. package/dist/pr-wait-mergeable/wrappers.d.ts +6 -1
  101. package/dist/pr-wait-mergeable/wrappers.js +7 -1
  102. package/dist/preflight/evaluate.d.ts +15 -1
  103. package/dist/preflight/evaluate.js +33 -2
  104. package/dist/render/framework-commands.js +4 -0
  105. package/dist/scope/acceptance-evidence.d.ts +76 -0
  106. package/dist/scope/acceptance-evidence.js +348 -0
  107. package/dist/scope/coverage-map.d.ts +98 -0
  108. package/dist/scope/coverage-map.js +558 -0
  109. package/dist/scope/decompose.js +125 -11
  110. package/dist/scope/effort-activate-gate.d.ts +28 -0
  111. package/dist/scope/effort-activate-gate.js +64 -0
  112. package/dist/scope/index.d.ts +4 -0
  113. package/dist/scope/index.js +4 -0
  114. package/dist/scope/parent-lineage.d.ts +125 -0
  115. package/dist/scope/parent-lineage.js +626 -0
  116. package/dist/scope/transition.d.ts +8 -0
  117. package/dist/scope/transition.js +69 -2
  118. package/dist/session/active-cli.d.ts +79 -0
  119. package/dist/session/active-cli.js +382 -0
  120. package/dist/session/effort-budget.d.ts +130 -0
  121. package/dist/session/effort-budget.js +334 -0
  122. package/dist/session/index.d.ts +2 -0
  123. package/dist/session/index.js +2 -0
  124. package/dist/session/session-start.d.ts +30 -1
  125. package/dist/session/session-start.js +234 -26
  126. package/dist/session/verify-session-ritual.d.ts +14 -0
  127. package/dist/session/verify-session-ritual.js +33 -0
  128. package/dist/story-ready/evaluate.d.ts +11 -0
  129. package/dist/story-ready/evaluate.js +41 -3
  130. package/dist/swarm/index.d.ts +2 -0
  131. package/dist/swarm/index.js +2 -0
  132. package/dist/swarm/pre-dispatch-cli.d.ts +19 -0
  133. package/dist/swarm/pre-dispatch-cli.js +143 -0
  134. package/dist/swarm/pre-dispatch.d.ts +87 -0
  135. package/dist/swarm/pre-dispatch.js +373 -0
  136. package/dist/triage/actions/candidates-log.d.ts +1 -2
  137. package/dist/triage/actions/candidates-log.js +37 -6
  138. package/dist/vbrief-activate/activate.js +6 -0
  139. package/dist/vbrief-validate/constants.d.ts +2 -0
  140. package/dist/vbrief-validate/constants.js +2 -0
  141. package/dist/vbrief-validate/schema.js +4 -1
  142. package/dist/xbrief-migrate/migrate-project.d.ts +4 -0
  143. package/dist/xbrief-migrate/migrate-project.js +92 -3
  144. package/package.json +19 -3
@@ -7,7 +7,12 @@
7
7
  * (CodeQL js/polynomial-redos).
8
8
  */
9
9
  import { classifyMcpTool, classifyShellCommand, listShellOps, } from "../policy/runtime-authority.js";
10
- /** Split on whitespace without nested quantifiers (O(n)). */
10
+ /**
11
+ * Split on whitespace without nested quantifiers (O(n)).
12
+ * Newlines become `;` segment breaks so compound lists like
13
+ * `scp …authz…\necho ok` keep the scp dest (#3213 Greptile residual).
14
+ * Bare space/tab/CR still only separate tokens.
15
+ */
11
16
  function shellTokens(command) {
12
17
  const out = [];
13
18
  let cur = "";
@@ -15,13 +20,24 @@ function shellTokens(command) {
15
20
  const c = command[i];
16
21
  if (c === undefined)
17
22
  break;
18
- if (c === " " || c === "\t" || c === "\n" || c === "\r") {
23
+ if (c === " " || c === "\t" || c === "\r") {
19
24
  if (cur.length > 0) {
20
25
  out.push(cur);
21
26
  cur = "";
22
27
  }
23
28
  continue;
24
29
  }
30
+ if (c === "\n") {
31
+ if (cur.length > 0) {
32
+ out.push(cur);
33
+ cur = "";
34
+ }
35
+ // Emit a segment break once (avoid runs of `;` from blank lines).
36
+ if (out.length > 0 && out[out.length - 1] !== ";") {
37
+ out.push(";");
38
+ }
39
+ continue;
40
+ }
25
41
  cur += c;
26
42
  }
27
43
  if (cur.length > 0)
@@ -163,14 +179,55 @@ const POLICY_AUTHORITY_MUTATORS = new Set([
163
179
  /** Kill-switch / permanent opt-out basenames agents must not plant under UAT (#3186 / #3039). */
164
180
  const KILL_SWITCH_BASENAMES = [".deft-directive-disable", ".no-deft-directive"];
165
181
  /**
166
- * Downloaders / decoders that can plant files without shell redirects (#3206).
167
- * Not in INDIRECT_WRITE_BINS: those feed hasWriteShape and would classify bare
168
- * `curl $URL` as a store write via opaque-expansion heuristics.
182
+ * Downloaders / decoders / remote-copy tools that can plant files without shell
183
+ * redirects (#3206 / #3213 / #3245). Not in INDIRECT_WRITE_BINS: those feed hasWriteShape
184
+ * and would classify bare `curl $URL` as a store write via opaque-expansion heuristics.
169
185
  */
170
- const DOWNLOADER_DECODER_BINS = new Set(["curl", "wget", "xxd", "openssl"]);
186
+ const DOWNLOADER_DECODER_BINS = new Set([
187
+ "curl",
188
+ "wget",
189
+ "xxd",
190
+ "openssl",
191
+ // #3213 residual after #3206: alternate downloaders / remote copy.
192
+ "scp",
193
+ "aria2c",
194
+ "certutil",
195
+ // #3245 residual after #3213: archive extractors + further alt downloaders.
196
+ "tar",
197
+ "bsdtar",
198
+ "unzip",
199
+ "7z",
200
+ "7za",
201
+ "7zr",
202
+ "rclone",
203
+ "axel",
204
+ "fetch",
205
+ "socat",
206
+ "lftp",
207
+ ]);
208
+ /**
209
+ * Archive extractors / alt writers that can plant via pathish operands without
210
+ * shell redirects (#3245). Used for pathish authz/kill scans (prefer deny) and
211
+ * write-shape residual under UAT — not bare curl-class URL fetches.
212
+ */
213
+ const ARCHIVE_ALT_WRITE_BINS = new Set([
214
+ "tar",
215
+ "bsdtar",
216
+ "unzip",
217
+ "7z",
218
+ "7za",
219
+ "7zr",
220
+ "rclone",
221
+ "axel",
222
+ "fetch",
223
+ "socat",
224
+ "lftp",
225
+ ]);
171
226
  /**
172
227
  * File destination flags for downloaders/decoders (#3206).
173
228
  * normalizeToken lowercases, so wget `-O` and curl `-o` share `-o`.
229
+ * scp: `-o` is an SSH option — excluded in isDownloaderDestFlag.
230
+ * 7z: `-oDIR` is attached-only (no separate value token).
174
231
  */
175
232
  const DOWNLOADER_FILE_DEST_FLAGS = new Set([
176
233
  "-o",
@@ -179,9 +236,32 @@ const DOWNLOADER_FILE_DEST_FLAGS = new Set([
179
236
  "-out",
180
237
  "--out",
181
238
  ]);
182
- /** Directory destination flags (curl --output-dir / wget -P); bin-scoped below. */
239
+ /** Directory destination flags (curl --output-dir / wget -P / aria2c -d); bin-scoped below. */
183
240
  const CURL_DIR_DEST_FLAGS = new Set(["--output-dir"]);
184
241
  const WGET_DIR_DEST_FLAGS = new Set(["-p", "--directory-prefix"]);
242
+ const ARIA2C_DIR_DEST_FLAGS = new Set(["-d", "--dir"]);
243
+ /**
244
+ * tar/bsdtar extract directory (#3245).
245
+ * Case-sensitive: POSIX tar uses capital `-C` for chdir; lower `-c` is create and is NOT dest.
246
+ */
247
+ const TAR_DIR_DEST_FLAGS_EXACT = new Set(["-C"]);
248
+ const TAR_DIR_DEST_FLAGS_LOWER = new Set(["--directory"]);
249
+ /** unzip extract directory (#3245). */
250
+ const UNZIP_DIR_DEST_FLAGS = new Set(["-d"]);
251
+ /**
252
+ * Symlink / hard-link plant bins (#3213). Absent from prior killWriteBins →
253
+ * `ln -sf … .deft-directive-disable` classified empty → UAT fail-open.
254
+ */
255
+ const SYMLINK_PLANT_BINS = new Set(["ln", "link", "mklink"]);
256
+ /** socat address prefixes that open a write target (#3245). */
257
+ const SOCAT_WRITE_ADDR_PREFIXES = [
258
+ "open:",
259
+ "create:",
260
+ "creat:",
261
+ "append:",
262
+ "owronly:",
263
+ "oappend:",
264
+ ];
185
265
  /** Final path segment of a token (path-qualified bins / .exe). */
186
266
  function binBareName(token) {
187
267
  const pathish = token.replace(/['"]/g, "").toLowerCase().replace(/\\/g, "/");
@@ -195,19 +275,96 @@ function isDownloaderDecoderBin(token) {
195
275
  return false;
196
276
  return DOWNLOADER_DECODER_BINS.has(binBareName(token));
197
277
  }
198
- function isDownloaderDestFlag(flag, bin) {
278
+ /**
279
+ * True when `flag` (normalized lower) or `rawFlag` (original token) is a dest flag for `bin`.
280
+ * tar `-C` must use raw case: lowercased `-c` is create-archive, not chdir (#3245).
281
+ * 7z uses attached `-oDIR` only — separate `-o PATH` is not a 7z dest form (handled elsewhere).
282
+ */
283
+ function isDownloaderDestFlag(flag, bin, rawFlag) {
284
+ // scp: `-o` is OpenSSH option (ProxyCommand, …), not a file dest flag.
285
+ if (bin === "scp")
286
+ return false;
287
+ // 7z family: only attached `-oDIR` (parsed in attached-short branch), not separate `-o PATH`.
288
+ if (bin === "7z" || bin === "7za" || bin === "7zr") {
289
+ return false;
290
+ }
199
291
  if (DOWNLOADER_FILE_DEST_FLAGS.has(flag))
200
292
  return true;
201
293
  if (bin === "curl" && CURL_DIR_DEST_FLAGS.has(flag))
202
294
  return true;
203
295
  if (bin === "wget" && WGET_DIR_DEST_FLAGS.has(flag))
204
296
  return true;
297
+ if (bin === "aria2c" && ARIA2C_DIR_DEST_FLAGS.has(flag))
298
+ return true;
299
+ if (bin === "tar" || bin === "bsdtar") {
300
+ if (rawFlag !== undefined && TAR_DIR_DEST_FLAGS_EXACT.has(rawFlag))
301
+ return true;
302
+ if (TAR_DIR_DEST_FLAGS_LOWER.has(flag))
303
+ return true;
304
+ // Exact match on raw when normalize collapsed case for long flags only.
305
+ if (rawFlag !== undefined && TAR_DIR_DEST_FLAGS_LOWER.has(normalizeToken(rawFlag)))
306
+ return true;
307
+ return false;
308
+ }
309
+ if (bin === "unzip" && UNZIP_DIR_DEST_FLAGS.has(flag))
310
+ return true;
311
+ return false;
312
+ }
313
+ /**
314
+ * OpenSSH/scp flags that take a separate value token (`-o ProxyCommand=…`, `-i key`, `-P port`).
315
+ * Not dest flags — must skip so value tokens are not mistaken for write destinations (#3213).
316
+ * Case-sensitive where needed: scp `-P` (port) takes a value; `-p` (preserve) does not.
317
+ */
318
+ const SCP_VALUE_FLAGS_LOWER = new Set(["-o", "-i", "-c", "-s", "-j", "-f", "-l", "-b"]);
319
+ const SCP_VALUE_FLAGS_EXACT = new Set(["-P", "-F", "-S", "-J"]);
320
+ /** Shell metacharacters that end a command segment (compound lists / pipelines). */
321
+ function isShellSegmentBreak(token) {
322
+ const t = token.trim();
323
+ if (t.length === 0)
324
+ return false;
325
+ if (t === ";" || t === "|" || t === "||" || t === "&&" || t === "&")
326
+ return true;
327
+ // Trailing operator glued to a prior token is handled by pathish stripping; bare ops here.
205
328
  return false;
206
329
  }
207
330
  /**
208
- * Destinations from curl/wget/xxd/openssl via -o/--output/-O/-out/--output-dir/-P
209
- * (separate, =value, or attached short form) and xxd -r path-like write positionals (#3206).
331
+ * First unquoted, unescaped shell-list operator (`;` `&` `|`) in a token, or -1.
332
+ * Quoted operators (e.g. `'file;name'`) and escaped unquoted ops (e.g. `path\;file`)
333
+ * are literal data and must not end scp segments (#3213 Greptile residual).
334
+ * O(n). Unquoted / double-quoted `\` escapes the next char; single-quoted `\` is literal.
335
+ */
336
+ function firstUnquotedShellOpIndex(raw) {
337
+ let inSingle = false;
338
+ let inDouble = false;
339
+ for (let k = 0; k < raw.length; k++) {
340
+ const ch = raw[k];
341
+ // Outside single quotes, backslash escapes the next character (POSIX-ish).
342
+ if (ch === "\\" && k + 1 < raw.length && !inSingle) {
343
+ k++;
344
+ continue;
345
+ }
346
+ if (ch === "'" && !inDouble) {
347
+ inSingle = !inSingle;
348
+ continue;
349
+ }
350
+ if (ch === '"' && !inSingle) {
351
+ inDouble = !inDouble;
352
+ continue;
353
+ }
354
+ if (inSingle || inDouble)
355
+ continue;
356
+ if (ch === ";" || ch === "&" || ch === "|")
357
+ return k;
358
+ }
359
+ return -1;
360
+ }
361
+ /**
362
+ * Destinations from curl/wget/xxd/openssl/scp/aria2c/certutil via -o/--output/-O/-out/
363
+ * --output-dir/-P/-d/--dir (separate, =value, or attached short form), xxd -r path-like
364
+ * write positionals (#3206), positional dests for scp/certutil (#3213), and #3245 residual
365
+ * archive/alt bins (tar -C, unzip -d, 7z -oDIR, rclone positionals, socat OPEN:, axel/fetch -o).
210
366
  * openssl uses flags only (no positional dests — avoids treating -in paths as writes).
367
+ * Segment stops at shell operators so compound `scp …authz…; echo` cannot overwrite dests.
211
368
  * O(n) token walk — no nested-quantifier regex on untrusted input.
212
369
  */
213
370
  function downloaderDecoderDestinations(tokens) {
@@ -222,9 +379,33 @@ function downloaderDecoderDestinations(tokens) {
222
379
  i++;
223
380
  // xxd reverse mode writes; without -r, path positionals are dump inputs (read).
224
381
  let xxdReverse = false;
382
+ // scp / certutil / rclone / archive extractors: collect pathish operands in this segment.
383
+ // Fail-closed under UAT: any pathish mentioning protected store/kill paths is a dest
384
+ // candidate (scp source-or-dest of `.deft/authz` is treated as settings — prefer deny
385
+ // over source/dest thrash). Last pathish remains the ordinary write dest.
386
+ // Segment breaks (`;`/`\n`/glued ops) prevent following-command overwrite.
387
+ let lastPositionalPath = null;
388
+ const protectedPathish = [];
389
+ const collectsProtectedPositionals = bin === "scp" ||
390
+ bin === "certutil" ||
391
+ bin === "rclone" ||
392
+ bin === "tar" ||
393
+ bin === "bsdtar" ||
394
+ bin === "unzip" ||
395
+ bin === "7z" ||
396
+ bin === "7za" ||
397
+ bin === "7zr" ||
398
+ bin === "socat" ||
399
+ bin === "lftp";
225
400
  while (i < tokens.length) {
226
401
  const raw = tokens[i];
227
402
  const n = normalizeToken(raw);
403
+ // Bare shell ops end this bin's segment. Do NOT use normalizeToken here —
404
+ // quoted `';'` becomes `;` after strip and would cut before the real authz dest
405
+ // (Greptile P1 residual #3213). Glued ops are handled by firstUnquotedShellOpIndex.
406
+ if (isShellSegmentBreak(raw)) {
407
+ break;
408
+ }
228
409
  // New bare bin starts another command segment (not pathish ./wget operands).
229
410
  if (!n.startsWith("-") &&
230
411
  !raw.includes("/") &&
@@ -247,18 +428,36 @@ function downloaderDecoderDestinations(tokens) {
247
428
  i++;
248
429
  continue;
249
430
  }
250
- // --flag=value (and rare -out=value)
431
+ // scp: skip OpenSSH value-taking flags + their values (-o Option=Value, -i key, -P port).
432
+ if (bin === "scp" &&
433
+ (SCP_VALUE_FLAGS_LOWER.has(n) ||
434
+ SCP_VALUE_FLAGS_EXACT.has(raw) ||
435
+ SCP_VALUE_FLAGS_EXACT.has(n))) {
436
+ const next = tokens[i + 1];
437
+ if (next !== undefined && !String(next).startsWith("-") && !isShellSegmentBreak(next)) {
438
+ i += 2;
439
+ continue;
440
+ }
441
+ i++;
442
+ continue;
443
+ }
444
+ // --flag=value (and rare -out=value); scp -oOption=Value attached form also lands here.
445
+ // tar --directory=PATH also lands here.
251
446
  if (n.includes("=") && (n.startsWith("-") || n.startsWith("--"))) {
252
447
  const eq = raw.indexOf("=");
253
- const flag = normalizeToken(raw.slice(0, eq));
254
- if (isDownloaderDestFlag(flag, bin)) {
448
+ const flagRaw = raw.slice(0, eq);
449
+ const flag = normalizeToken(flagRaw);
450
+ if (isDownloaderDestFlag(flag, bin, flagRaw)) {
255
451
  dests.push(pathishToken(raw.slice(eq + 1)));
256
452
  }
453
+ // scp attached -oProxyCommand=… is not a file dest — skip without recording.
257
454
  i++;
258
455
  continue;
259
456
  }
260
457
  // Attached short: -oPATH / -OPATH (after lowercasing both are -opath…)
261
- if (n.startsWith("-") && !n.startsWith("--") && n.length > 2) {
458
+ // Skip for scp (OpenSSH -oOption=Value attached forms are not file dests).
459
+ // 7z requires attached -oDIR (#3245).
460
+ if (bin !== "scp" && n.startsWith("-") && !n.startsWith("--") && n.length > 2) {
262
461
  if (n.startsWith("-out") && n.length > 4 && !n.startsWith("-output")) {
263
462
  dests.push(pathishToken(raw.slice(4)));
264
463
  i++;
@@ -275,11 +474,29 @@ function downloaderDecoderDestinations(tokens) {
275
474
  i++;
276
475
  continue;
277
476
  }
477
+ // aria2c attached -dDIR (#3213)
478
+ if (bin === "aria2c" && n.startsWith("-d") && n.length > 2) {
479
+ dests.push(pathishToken(raw.slice(2)));
480
+ i++;
481
+ continue;
482
+ }
483
+ // unzip attached -dDIR (#3245)
484
+ if (bin === "unzip" && n.startsWith("-d") && n.length > 2) {
485
+ dests.push(pathishToken(raw.slice(2)));
486
+ i++;
487
+ continue;
488
+ }
489
+ // tar attached -CDIR (rare; capital C required)
490
+ if ((bin === "tar" || bin === "bsdtar") && raw.startsWith("-C") && raw.length > 2) {
491
+ dests.push(pathishToken(raw.slice(2)));
492
+ i++;
493
+ continue;
494
+ }
278
495
  }
279
- // Separate value: -o PATH / --output PATH / -out PATH / --output-dir / -P
280
- if (isDownloaderDestFlag(n, bin)) {
496
+ // Separate value: -o PATH / --output PATH / -out PATH / --output-dir / -P / -d / -C DIR
497
+ if (isDownloaderDestFlag(n, bin, raw)) {
281
498
  const next = tokens[i + 1];
282
- if (next !== undefined && !String(next).startsWith("-")) {
499
+ if (next !== undefined && !String(next).startsWith("-") && !isShellSegmentBreak(next)) {
283
500
  dests.push(pathishToken(next));
284
501
  i += 2;
285
502
  continue;
@@ -297,8 +514,52 @@ function downloaderDecoderDestinations(tokens) {
297
514
  dests.push(p);
298
515
  }
299
516
  }
517
+ // socat write address forms: OPEN:path, CREATE:path, APPEND:path (#3245).
518
+ if (bin === "socat" && !n.startsWith("-")) {
519
+ const p = pathishToken(raw);
520
+ for (const prefix of SOCAT_WRITE_ADDR_PREFIXES) {
521
+ if (p.startsWith(prefix) && p.length > prefix.length) {
522
+ const dest = p.slice(prefix.length);
523
+ dests.push(dest);
524
+ if (dest.includes(".deft/authz") ||
525
+ dest.includes(".deft-directive-disable") ||
526
+ dest.includes(".no-deft-directive")) {
527
+ protectedPathish.push(dest);
528
+ }
529
+ break;
530
+ }
531
+ }
532
+ }
533
+ // scp / certutil / rclone / archive extractors: pathish operands (quote-aware glued-op cut).
534
+ // Under UAT: any `.deft/authz` / kill-switch pathish is fail-closed settings
535
+ // (read vs write thrash deferred — prefer deny over dest-parser perfection; #3213 / #3245).
536
+ if (collectsProtectedPositionals && !n.startsWith("-")) {
537
+ const cut = firstUnquotedShellOpIndex(raw);
538
+ const cleaned = cut >= 0 ? raw.slice(0, cut) : raw;
539
+ const p = pathishToken(cleaned);
540
+ if (p.length > 0) {
541
+ lastPositionalPath = p;
542
+ // Fail-closed: protected store/kill basenames anywhere in pathish.
543
+ if (p.includes(".deft/authz") ||
544
+ p.includes(".deft-directive-disable") ||
545
+ p.includes(".no-deft-directive")) {
546
+ protectedPathish.push(p);
547
+ }
548
+ }
549
+ if (cut >= 0) {
550
+ i++;
551
+ break;
552
+ }
553
+ }
300
554
  i++;
301
555
  }
556
+ // Prefer protected pathish (fail-closed) then last ordinary dest.
557
+ for (const p of protectedPathish) {
558
+ dests.push(p);
559
+ }
560
+ if (lastPositionalPath !== null && !protectedPathish.includes(lastPositionalPath)) {
561
+ dests.push(lastPositionalPath);
562
+ }
302
563
  }
303
564
  return dests;
304
565
  }
@@ -377,52 +638,75 @@ function hasPolicyAuthorityMutator(tokens) {
377
638
  }
378
639
  return false;
379
640
  }
641
+ /** True when pathish token names a kill-switch basename (quote-strip resistant). */
642
+ function pathishMentionsKillSwitch(pathish) {
643
+ for (const name of KILL_SWITCH_BASENAMES) {
644
+ if (pathish === name || pathish.endsWith(`/${name}`) || pathish.includes(name)) {
645
+ return true;
646
+ }
647
+ }
648
+ return false;
649
+ }
380
650
  /**
381
- * Shell write targeting `.deft-directive-disable` / `.no-deft-directive` (#3186 / #3039).
651
+ * Shell write targeting `.deft-directive-disable` / `.no-deft-directive` (#3186 / #3039 / #3213).
382
652
  * Planting the kill-switch under UAT would full-bypass subsequent gates without operator recovery.
653
+ * Includes symlink plant bins (`ln`/`link`/`mklink`) and quote-split-resistant basename checks.
383
654
  */
384
655
  function hasKillSwitchShellWrite(command, tokens) {
385
656
  const lower = command.toLowerCase().replace(/\\/g, "/");
657
+ // Quote-stripped form so `'.deft'-directive-disable`-class splits still match (#3213).
658
+ const stripped = lower.replace(/['"]/g, "");
386
659
  let mentionsKill = false;
387
660
  for (const name of KILL_SWITCH_BASENAMES) {
388
- if (lower.includes(name)) {
661
+ if (lower.includes(name) || stripped.includes(name)) {
389
662
  mentionsKill = true;
390
663
  break;
391
664
  }
392
665
  }
666
+ if (!mentionsKill) {
667
+ for (const t of tokens) {
668
+ if (pathishMentionsKillSwitch(pathishToken(t))) {
669
+ mentionsKill = true;
670
+ break;
671
+ }
672
+ }
673
+ }
393
674
  if (!mentionsKill)
394
675
  return false;
395
- // Redirect dest region after each `>` / `>>` (O(n)).
396
- for (let i = 0; i < lower.length; i++) {
397
- if (lower[i] !== ">")
398
- continue;
399
- let j = i + 1;
400
- if (j < lower.length && lower[j] === ">")
401
- j++;
402
- let end = j;
403
- while (end < lower.length &&
404
- lower[end] !== "|" &&
405
- lower[end] !== ";" &&
406
- lower[end] !== "&" &&
407
- lower[end] !== "\n") {
408
- end++;
409
- }
410
- const dest = lower.slice(j, end);
411
- for (const name of KILL_SWITCH_BASENAMES) {
412
- if (dest.includes(name))
413
- return true;
676
+ // Redirect dest region after each `>` / `>>` (O(n)); check raw + quote-stripped.
677
+ for (const hay of [lower, stripped]) {
678
+ for (let i = 0; i < hay.length; i++) {
679
+ if (hay[i] !== ">")
680
+ continue;
681
+ let j = i + 1;
682
+ if (j < hay.length && hay[j] === ">")
683
+ j++;
684
+ let end = j;
685
+ while (end < hay.length &&
686
+ hay[end] !== "|" &&
687
+ hay[end] !== ";" &&
688
+ hay[end] !== "&" &&
689
+ hay[end] !== "\n") {
690
+ end++;
691
+ }
692
+ const dest = hay.slice(j, end);
693
+ for (const name of KILL_SWITCH_BASENAMES) {
694
+ if (dest.includes(name))
695
+ return true;
696
+ }
414
697
  }
415
698
  }
416
- // Downloader/decoder destinations: curl -o .deft-directive-disable, wget -O, … (#3206).
699
+ // Downloader/decoder destinations: curl -o .deft-directive-disable, scp, … (#3206 / #3213).
417
700
  for (const dest of downloaderDecoderDestinations(tokens)) {
418
- for (const name of KILL_SWITCH_BASENAMES) {
419
- if (dest.includes(name))
420
- return true;
421
- }
701
+ if (pathishMentionsKillSwitch(dest))
702
+ return true;
422
703
  }
423
- // Write/destructive bins with a kill-switch path argument (touch, New-Item, …).
704
+ // Write/destructive + symlink plant + archive/alt-write bins with a kill-switch path
705
+ // argument (#3213 ln/link/mklink; #3245 tar/axel/rclone/socat residual).
424
706
  const killWriteBins = new Set([
425
707
  ...INDIRECT_WRITE_BINS,
708
+ ...SYMLINK_PLANT_BINS,
709
+ ...ARCHIVE_ALT_WRITE_BINS,
426
710
  "touch",
427
711
  "new-item",
428
712
  "ni",
@@ -431,26 +715,34 @@ function hasKillSwitchShellWrite(command, tokens) {
431
715
  "type",
432
716
  ]);
433
717
  for (let ti = 0; ti < tokens.length; ti++) {
434
- if (!killWriteBins.has(normalizeToken(tokens[ti])))
718
+ const binTok = normalizeToken(tokens[ti]);
719
+ const bare = binBareName(tokens[ti]);
720
+ if (!killWriteBins.has(binTok) && !killWriteBins.has(bare))
435
721
  continue;
436
722
  for (let tj = ti + 1; tj < tokens.length; tj++) {
437
723
  const p = pathishToken(tokens[tj]);
438
- for (const name of KILL_SWITCH_BASENAMES) {
439
- if (p.includes(name))
724
+ if (pathishMentionsKillSwitch(p))
725
+ return true;
726
+ // socat OPEN:.deft-directive-disable — path after address prefix (#3245).
727
+ for (const prefix of SOCAT_WRITE_ADDR_PREFIXES) {
728
+ if (p.startsWith(prefix) && pathishMentionsKillSwitch(p.slice(prefix.length))) {
440
729
  return true;
730
+ }
441
731
  }
442
732
  }
443
733
  }
444
- // Bare `touch .deft-directive-disable` first token may be touch, path later.
734
+ // Bare `touch .deft-directive-disable` / `ln -sf x .deft-directive-disable` path later.
445
735
  for (const t of tokens) {
446
736
  const p = pathishToken(t);
447
737
  for (const name of KILL_SWITCH_BASENAMES) {
448
738
  // Exact basename or ends with /basename
449
739
  if (p === name || p.endsWith(`/${name}`)) {
450
- // Require some write shape (redirect already handled; touch/ni/echo/…)
740
+ // Require some write shape (redirect already handled; touch/ni/echo/ln/…)
451
741
  if (hasWriteShape(command, tokens) ||
452
742
  lower.includes("touch") ||
453
- lower.includes("new-item")) {
743
+ lower.includes("new-item") ||
744
+ SYMLINK_PLANT_BINS.has(binBareName(tokens[0])) ||
745
+ tokens.some((tok) => SYMLINK_PLANT_BINS.has(binBareName(tok)))) {
454
746
  return true;
455
747
  }
456
748
  }
@@ -623,47 +915,76 @@ function pathishToken(token) {
623
915
  return token.replace(/['"]/g, "").toLowerCase().replace(/\\/g, "/");
624
916
  }
625
917
  /**
626
- * Shell **write** targeting `.deft/authz/` (#3110 AC-3).
918
+ * True when a pathish string targets `.deft/authz` (after quote strip / slash normalize).
919
+ * Quote-split forms like `'.deft/'authz'/grants/x'` become `.deft/authz/grants/x` via pathishToken.
920
+ */
921
+ function pathishIsAuthzDir(pathish) {
922
+ return pathish.includes(".deft/authz");
923
+ }
924
+ /**
925
+ * Shell **write** targeting `.deft/authz/` (#3110 AC-3 / #3206 / #3213).
627
926
  * Pure reads (`cat .deft/authz/state.json`) stay unclassifiable — use `authz:show`.
628
927
  * Redirects only count when the destination region contains `.deft/authz`.
928
+ * Pathish/token checks run even when the raw command lacks contiguous `.deft/authz`
929
+ * text (quote-split residual: `cp x '.deft/'authz'/grants/y'`).
629
930
  */
630
931
  function hasAuthzDirShellWrite(command, tokens) {
631
932
  const lower = command.toLowerCase().replace(/\\/g, "/");
632
- if (!lower.includes(".deft/authz"))
633
- return false;
933
+ // Quote-stripped contiguous form for redirect dest checks (#3213).
934
+ const stripped = lower.replace(/['"]/g, "");
634
935
  // Redirect dest region after each `>` / `>>` (O(n); no nested-quantifier regex).
635
- for (let i = 0; i < lower.length; i++) {
636
- if (lower[i] !== ">")
637
- continue;
638
- let j = i + 1;
639
- if (j < lower.length && lower[j] === ">")
640
- j++;
641
- // Dest until pipe/semicolon/ampersand/newline.
642
- let end = j;
643
- while (end < lower.length &&
644
- lower[end] !== "|" &&
645
- lower[end] !== ";" &&
646
- lower[end] !== "&" &&
647
- lower[end] !== "\n") {
648
- end++;
936
+ // Check both raw and quote-stripped so quote-split dests still match.
937
+ for (const hay of [lower, stripped]) {
938
+ for (let i = 0; i < hay.length; i++) {
939
+ if (hay[i] !== ">")
940
+ continue;
941
+ let j = i + 1;
942
+ if (j < hay.length && hay[j] === ">")
943
+ j++;
944
+ // Dest until pipe/semicolon/ampersand/newline.
945
+ let end = j;
946
+ while (end < hay.length &&
947
+ hay[end] !== "|" &&
948
+ hay[end] !== ";" &&
949
+ hay[end] !== "&" &&
950
+ hay[end] !== "\n") {
951
+ end++;
952
+ }
953
+ if (hay.slice(j, end).includes(".deft/authz"))
954
+ return true;
649
955
  }
650
- if (lower.slice(j, end).includes(".deft/authz"))
651
- return true;
652
956
  }
653
- // Write/destructive bins with an authz path argument.
957
+ // Write/destructive + symlink plant + archive/alt-write bins with an authz path argument
958
+ // (pathish = quote-strip resistant). Always run — do not gate on contiguous `.deft/authz`
959
+ // in the raw command (#3213 / #3245). SYMLINK_PLANT_BINS: `ln -s forged .deft/authz/grants/x`
960
+ // must not fail-open as unclassifiable (SLizard residual). ARCHIVE_ALT_WRITE_BINS: tar/rclone
961
+ // residual pathish without dest-flag perfection thrash.
654
962
  for (let ti = 0; ti < tokens.length; ti++) {
655
- if (!INDIRECT_WRITE_BINS.has(normalizeToken(tokens[ti])))
963
+ const bare = binBareName(tokens[ti]);
964
+ const n = normalizeToken(tokens[ti]);
965
+ if (!INDIRECT_WRITE_BINS.has(n) &&
966
+ !INDIRECT_WRITE_BINS.has(bare) &&
967
+ !SYMLINK_PLANT_BINS.has(bare) &&
968
+ !ARCHIVE_ALT_WRITE_BINS.has(bare)) {
656
969
  continue;
970
+ }
657
971
  for (let tj = ti + 1; tj < tokens.length; tj++) {
658
- if (pathishToken(tokens[tj]).includes(".deft/authz"))
972
+ const p = pathishToken(tokens[tj]);
973
+ if (pathishIsAuthzDir(p))
659
974
  return true;
975
+ for (const prefix of SOCAT_WRITE_ADDR_PREFIXES) {
976
+ if (p.startsWith(prefix) && pathishIsAuthzDir(p.slice(prefix.length))) {
977
+ return true;
978
+ }
979
+ }
660
980
  }
661
981
  }
662
- // Downloader/decoder destinations under .deft/authz (#3206 residual after #3186).
982
+ // Downloader/decoder destinations under .deft/authz (#3206 / #3213 / #3245 archive+alt).
663
983
  for (const dest of downloaderDecoderDestinations(tokens)) {
664
- if (dest.includes(".deft/authz"))
984
+ if (pathishIsAuthzDir(dest))
665
985
  return true;
666
986
  }
987
+ // Contiguous mention without write shape stays false (reads like cat .deft/authz/…).
667
988
  return false;
668
989
  }
669
990
  /** Write/destructive shell bins (token match after normalizeToken). */
@@ -740,7 +1061,12 @@ function hasWriteShape(command, tokens) {
740
1061
  if (command.includes(">"))
741
1062
  return true;
742
1063
  for (const t of tokens) {
743
- if (INDIRECT_WRITE_BINS.has(normalizeToken(t)))
1064
+ const bare = binBareName(t);
1065
+ if (INDIRECT_WRITE_BINS.has(normalizeToken(t)) || INDIRECT_WRITE_BINS.has(bare))
1066
+ return true;
1067
+ // #3245: archive extractors / alt downloaders are write-shaped (defense-in-depth for
1068
+ // unclassifiable plant paths under UAT — not bare curl $URL which stays non-write-shaped).
1069
+ if (ARCHIVE_ALT_WRITE_BINS.has(bare))
744
1070
  return true;
745
1071
  }
746
1072
  return false;