@deftai/directive-core 0.98.0 → 0.99.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 (59) hide show
  1. package/dist/authz/classify.js +400 -56
  2. package/dist/consumer-check-contract/evaluate.d.ts +40 -0
  3. package/dist/consumer-check-contract/evaluate.js +188 -3
  4. package/dist/consumer-check-contract/index.d.ts +1 -1
  5. package/dist/consumer-check-contract/index.js +1 -1
  6. package/dist/content-contracts/skills/greptile-detector.d.ts +42 -0
  7. package/dist/content-contracts/skills/greptile-detector.js +202 -4
  8. package/dist/decision/index.d.ts +17 -0
  9. package/dist/decision/index.js +35 -0
  10. package/dist/decision/list.d.ts +47 -0
  11. package/dist/decision/list.js +250 -0
  12. package/dist/decision/schema.d.ts +88 -0
  13. package/dist/decision/schema.js +293 -0
  14. package/dist/decision/write.d.ts +82 -0
  15. package/dist/decision/write.js +427 -0
  16. package/dist/eval/report.d.ts +29 -0
  17. package/dist/eval/report.js +69 -0
  18. package/dist/eval/run.d.ts +9 -0
  19. package/dist/eval/run.js +40 -4
  20. package/dist/eval/version-pin.d.ts +99 -0
  21. package/dist/eval/version-pin.js +181 -0
  22. package/dist/index.d.ts +1 -0
  23. package/dist/index.js +1 -0
  24. package/dist/platform/host-content-surface.d.ts +74 -0
  25. package/dist/platform/host-content-surface.js +214 -0
  26. package/dist/platform/index.d.ts +1 -0
  27. package/dist/platform/index.js +1 -0
  28. package/dist/policy/ceremony-dial.d.ts +233 -0
  29. package/dist/policy/ceremony-dial.js +829 -0
  30. package/dist/policy/deft-directive-disable.js +12 -2
  31. package/dist/policy/index.d.ts +1 -0
  32. package/dist/policy/index.js +15 -1
  33. package/dist/pr-merge-readiness/evaluate.js +10 -0
  34. package/dist/pr-merge-readiness/mergeability.js +5 -0
  35. package/dist/pr-merge-readiness/output.js +2 -0
  36. package/dist/pr-merge-readiness/parse.js +4 -0
  37. package/dist/pr-merge-readiness/types.d.ts +6 -0
  38. package/dist/scope/effort-activate-gate.d.ts +28 -0
  39. package/dist/scope/effort-activate-gate.js +64 -0
  40. package/dist/scope/index.d.ts +1 -0
  41. package/dist/scope/index.js +1 -0
  42. package/dist/scope/transition.js +8 -0
  43. package/dist/scope-provenance/evaluate.d.ts +21 -0
  44. package/dist/scope-provenance/evaluate.js +143 -33
  45. package/dist/scope-provenance/index.d.ts +1 -1
  46. package/dist/scope-provenance/index.js +1 -1
  47. package/dist/session/session-start.d.ts +24 -1
  48. package/dist/session/session-start.js +183 -26
  49. package/dist/swarm/index.d.ts +2 -0
  50. package/dist/swarm/index.js +2 -0
  51. package/dist/swarm/pre-dispatch-cli.d.ts +19 -0
  52. package/dist/swarm/pre-dispatch-cli.js +143 -0
  53. package/dist/swarm/pre-dispatch.d.ts +87 -0
  54. package/dist/swarm/pre-dispatch.js +373 -0
  55. package/dist/vbrief-activate/activate.js +6 -0
  56. package/dist/vbrief-validate/constants.d.ts +2 -0
  57. package/dist/vbrief-validate/constants.js +2 -0
  58. package/dist/vbrief-validate/schema.js +4 -1
  59. package/package.json +15 -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,11 +20,22 @@ 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") {
24
+ if (cur.length > 0) {
25
+ out.push(cur);
26
+ cur = "";
27
+ }
28
+ continue;
29
+ }
30
+ if (c === "\n") {
19
31
  if (cur.length > 0) {
20
32
  out.push(cur);
21
33
  cur = "";
22
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
+ }
23
39
  continue;
24
40
  }
25
41
  cur += c;
@@ -162,6 +178,279 @@ const POLICY_AUTHORITY_MUTATORS = new Set([
162
178
  ]);
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"];
181
+ /**
182
+ * Downloaders / decoders / remote-copy tools that can plant files without shell
183
+ * redirects (#3206 / #3213). Not in INDIRECT_WRITE_BINS: those feed hasWriteShape
184
+ * and would classify bare `curl $URL` as a store write via opaque-expansion heuristics.
185
+ */
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
+ ]);
196
+ /**
197
+ * File destination flags for downloaders/decoders (#3206).
198
+ * normalizeToken lowercases, so wget `-O` and curl `-o` share `-o`.
199
+ * scp: `-o` is an SSH option — excluded in isDownloaderDestFlag.
200
+ */
201
+ const DOWNLOADER_FILE_DEST_FLAGS = new Set([
202
+ "-o",
203
+ "--output",
204
+ "--output-document",
205
+ "-out",
206
+ "--out",
207
+ ]);
208
+ /** Directory destination flags (curl --output-dir / wget -P / aria2c -d); bin-scoped below. */
209
+ const CURL_DIR_DEST_FLAGS = new Set(["--output-dir"]);
210
+ const WGET_DIR_DEST_FLAGS = new Set(["-p", "--directory-prefix"]);
211
+ const ARIA2C_DIR_DEST_FLAGS = new Set(["-d", "--dir"]);
212
+ /**
213
+ * Symlink / hard-link plant bins (#3213). Absent from prior killWriteBins →
214
+ * `ln -sf … .deft-directive-disable` classified empty → UAT fail-open.
215
+ */
216
+ const SYMLINK_PLANT_BINS = new Set(["ln", "link", "mklink"]);
217
+ /** Final path segment of a token (path-qualified bins / .exe). */
218
+ function binBareName(token) {
219
+ const pathish = token.replace(/['"]/g, "").toLowerCase().replace(/\\/g, "/");
220
+ const base = pathish.includes("/") ? pathish.slice(pathish.lastIndexOf("/") + 1) : pathish;
221
+ return base.endsWith(".exe") ? base.slice(0, -4) : base;
222
+ }
223
+ /** True when token names a downloader/decoder bin (bare or path-qualified). */
224
+ function isDownloaderDecoderBin(token) {
225
+ const n = normalizeToken(token);
226
+ if (n.startsWith("-"))
227
+ return false;
228
+ return DOWNLOADER_DECODER_BINS.has(binBareName(token));
229
+ }
230
+ function isDownloaderDestFlag(flag, bin) {
231
+ // scp: `-o` is OpenSSH option (ProxyCommand, …), not a file dest flag.
232
+ if (bin === "scp")
233
+ return false;
234
+ if (DOWNLOADER_FILE_DEST_FLAGS.has(flag))
235
+ return true;
236
+ if (bin === "curl" && CURL_DIR_DEST_FLAGS.has(flag))
237
+ return true;
238
+ if (bin === "wget" && WGET_DIR_DEST_FLAGS.has(flag))
239
+ return true;
240
+ if (bin === "aria2c" && ARIA2C_DIR_DEST_FLAGS.has(flag))
241
+ return true;
242
+ return false;
243
+ }
244
+ /**
245
+ * OpenSSH/scp flags that take a separate value token (`-o ProxyCommand=…`, `-i key`, `-P port`).
246
+ * Not dest flags — must skip so value tokens are not mistaken for write destinations (#3213).
247
+ * Case-sensitive where needed: scp `-P` (port) takes a value; `-p` (preserve) does not.
248
+ */
249
+ const SCP_VALUE_FLAGS_LOWER = new Set(["-o", "-i", "-c", "-s", "-j", "-f", "-l", "-b"]);
250
+ const SCP_VALUE_FLAGS_EXACT = new Set(["-P", "-F", "-S", "-J"]);
251
+ /** Shell metacharacters that end a command segment (compound lists / pipelines). */
252
+ function isShellSegmentBreak(token) {
253
+ const t = token.trim();
254
+ if (t.length === 0)
255
+ return false;
256
+ if (t === ";" || t === "|" || t === "||" || t === "&&" || t === "&")
257
+ return true;
258
+ // Trailing operator glued to a prior token is handled by pathish stripping; bare ops here.
259
+ return false;
260
+ }
261
+ /**
262
+ * First unquoted, unescaped shell-list operator (`;` `&` `|`) in a token, or -1.
263
+ * Quoted operators (e.g. `'file;name'`) and escaped unquoted ops (e.g. `path\;file`)
264
+ * are literal data and must not end scp segments (#3213 Greptile residual).
265
+ * O(n). Unquoted / double-quoted `\` escapes the next char; single-quoted `\` is literal.
266
+ */
267
+ function firstUnquotedShellOpIndex(raw) {
268
+ let inSingle = false;
269
+ let inDouble = false;
270
+ for (let k = 0; k < raw.length; k++) {
271
+ const ch = raw[k];
272
+ // Outside single quotes, backslash escapes the next character (POSIX-ish).
273
+ if (ch === "\\" && k + 1 < raw.length && !inSingle) {
274
+ k++;
275
+ continue;
276
+ }
277
+ if (ch === "'" && !inDouble) {
278
+ inSingle = !inSingle;
279
+ continue;
280
+ }
281
+ if (ch === '"' && !inSingle) {
282
+ inDouble = !inDouble;
283
+ continue;
284
+ }
285
+ if (inSingle || inDouble)
286
+ continue;
287
+ if (ch === ";" || ch === "&" || ch === "|")
288
+ return k;
289
+ }
290
+ return -1;
291
+ }
292
+ /**
293
+ * Destinations from curl/wget/xxd/openssl/scp/aria2c/certutil via -o/--output/-O/-out/
294
+ * --output-dir/-P/-d/--dir (separate, =value, or attached short form), xxd -r path-like
295
+ * write positionals (#3206), and positional dests for scp/certutil (#3213).
296
+ * openssl uses flags only (no positional dests — avoids treating -in paths as writes).
297
+ * Segment stops at shell operators so compound `scp …authz…; echo` cannot overwrite dests.
298
+ * O(n) token walk — no nested-quantifier regex on untrusted input.
299
+ */
300
+ function downloaderDecoderDestinations(tokens) {
301
+ const dests = [];
302
+ let i = 0;
303
+ while (i < tokens.length) {
304
+ if (!isDownloaderDecoderBin(tokens[i])) {
305
+ i++;
306
+ continue;
307
+ }
308
+ const bin = binBareName(tokens[i]);
309
+ i++;
310
+ // xxd reverse mode writes; without -r, path positionals are dump inputs (read).
311
+ let xxdReverse = false;
312
+ // scp / certutil: collect pathish operands in this segment (#3213).
313
+ // Fail-closed under UAT: any pathish mentioning protected store/kill paths is a dest
314
+ // candidate (scp source-or-dest of `.deft/authz` is treated as settings — prefer deny
315
+ // over source/dest thrash). Last pathish remains the ordinary write dest.
316
+ // Segment breaks (`;`/`\n`/glued ops) prevent following-command overwrite.
317
+ let lastPositionalPath = null;
318
+ const protectedPathish = [];
319
+ while (i < tokens.length) {
320
+ const raw = tokens[i];
321
+ const n = normalizeToken(raw);
322
+ // Bare shell ops end this bin's segment. Do NOT use normalizeToken here —
323
+ // quoted `';'` becomes `;` after strip and would cut before the real authz dest
324
+ // (Greptile P1 residual #3213). Glued ops are handled by firstUnquotedShellOpIndex.
325
+ if (isShellSegmentBreak(raw)) {
326
+ break;
327
+ }
328
+ // New bare bin starts another command segment (not pathish ./wget operands).
329
+ if (!n.startsWith("-") &&
330
+ !raw.includes("/") &&
331
+ !raw.includes("\\") &&
332
+ isDownloaderDecoderBin(raw)) {
333
+ break;
334
+ }
335
+ if (bin === "xxd" && (n === "-r" || n === "--reverse")) {
336
+ xxdReverse = true;
337
+ i++;
338
+ continue;
339
+ }
340
+ // Skip openssl/xxd input flags + value so -in PATH is not a write dest.
341
+ if (n === "-in" || n === "--in" || n === "-inform" || n === "--inform") {
342
+ const next = tokens[i + 1];
343
+ if (next !== undefined && !String(next).startsWith("-")) {
344
+ i += 2;
345
+ continue;
346
+ }
347
+ i++;
348
+ continue;
349
+ }
350
+ // scp: skip OpenSSH value-taking flags + their values (-o Option=Value, -i key, -P port).
351
+ if (bin === "scp" &&
352
+ (SCP_VALUE_FLAGS_LOWER.has(n) ||
353
+ SCP_VALUE_FLAGS_EXACT.has(raw) ||
354
+ SCP_VALUE_FLAGS_EXACT.has(n))) {
355
+ const next = tokens[i + 1];
356
+ if (next !== undefined && !String(next).startsWith("-") && !isShellSegmentBreak(next)) {
357
+ i += 2;
358
+ continue;
359
+ }
360
+ i++;
361
+ continue;
362
+ }
363
+ // --flag=value (and rare -out=value); scp -oOption=Value attached form also lands here.
364
+ if (n.includes("=") && (n.startsWith("-") || n.startsWith("--"))) {
365
+ const eq = raw.indexOf("=");
366
+ const flag = normalizeToken(raw.slice(0, eq));
367
+ if (isDownloaderDestFlag(flag, bin)) {
368
+ dests.push(pathishToken(raw.slice(eq + 1)));
369
+ }
370
+ // scp attached -oProxyCommand=… is not a file dest — skip without recording.
371
+ i++;
372
+ continue;
373
+ }
374
+ // Attached short: -oPATH / -OPATH (after lowercasing both are -opath…)
375
+ // Skip for scp (OpenSSH -oOption=Value attached forms are not file dests).
376
+ if (bin !== "scp" && n.startsWith("-") && !n.startsWith("--") && n.length > 2) {
377
+ if (n.startsWith("-out") && n.length > 4 && !n.startsWith("-output")) {
378
+ dests.push(pathishToken(raw.slice(4)));
379
+ i++;
380
+ continue;
381
+ }
382
+ if (n.startsWith("-o") && !n.startsWith("-out") && n.length > 2) {
383
+ dests.push(pathishToken(raw.slice(2)));
384
+ i++;
385
+ continue;
386
+ }
387
+ // wget attached -Pdir
388
+ if (bin === "wget" && n.startsWith("-p") && n.length > 2 && !n.startsWith("-proxy")) {
389
+ dests.push(pathishToken(raw.slice(2)));
390
+ i++;
391
+ continue;
392
+ }
393
+ // aria2c attached -dDIR (#3213)
394
+ if (bin === "aria2c" && n.startsWith("-d") && n.length > 2) {
395
+ dests.push(pathishToken(raw.slice(2)));
396
+ i++;
397
+ continue;
398
+ }
399
+ }
400
+ // Separate value: -o PATH / --output PATH / -out PATH / --output-dir / -P / -d
401
+ if (isDownloaderDestFlag(n, bin)) {
402
+ const next = tokens[i + 1];
403
+ if (next !== undefined && !String(next).startsWith("-") && !isShellSegmentBreak(next)) {
404
+ dests.push(pathishToken(next));
405
+ i += 2;
406
+ continue;
407
+ }
408
+ i++;
409
+ continue;
410
+ }
411
+ // xxd -r only: path-like write positionals (not http(s) URLs). openssl: flags only.
412
+ if (bin === "xxd" && xxdReverse && !n.startsWith("-")) {
413
+ const p = pathishToken(raw);
414
+ if ((p.includes("/") || p.startsWith(".") || p.includes("\\")) &&
415
+ !p.startsWith("http:") &&
416
+ !p.startsWith("https:") &&
417
+ !p.startsWith("ftp:")) {
418
+ dests.push(p);
419
+ }
420
+ }
421
+ // scp / certutil: pathish operands (quote-aware glued-op cut).
422
+ // certutil under UAT: any `.deft/authz` / kill-switch pathish is fail-closed settings
423
+ // (read vs write thrash deferred — prefer deny over dest-parser perfection; #3213).
424
+ if ((bin === "scp" || bin === "certutil") && !n.startsWith("-")) {
425
+ const cut = firstUnquotedShellOpIndex(raw);
426
+ const cleaned = cut >= 0 ? raw.slice(0, cut) : raw;
427
+ const p = pathishToken(cleaned);
428
+ if (p.length > 0) {
429
+ lastPositionalPath = p;
430
+ // Fail-closed: protected store/kill basenames anywhere in scp/certutil pathish.
431
+ if (p.includes(".deft/authz") ||
432
+ p.includes(".deft-directive-disable") ||
433
+ p.includes(".no-deft-directive")) {
434
+ protectedPathish.push(p);
435
+ }
436
+ }
437
+ if (cut >= 0) {
438
+ i++;
439
+ break;
440
+ }
441
+ }
442
+ i++;
443
+ }
444
+ // Prefer protected pathish (fail-closed) then last ordinary dest.
445
+ for (const p of protectedPathish) {
446
+ dests.push(p);
447
+ }
448
+ if (lastPositionalPath !== null && !protectedPathish.includes(lastPositionalPath)) {
449
+ dests.push(lastPositionalPath);
450
+ }
451
+ }
452
+ return dests;
453
+ }
165
454
  function authzSubcommandFromToken(token) {
166
455
  const t = normalizeToken(token);
167
456
  if (t.startsWith("authz:")) {
@@ -237,45 +526,73 @@ function hasPolicyAuthorityMutator(tokens) {
237
526
  }
238
527
  return false;
239
528
  }
529
+ /** True when pathish token names a kill-switch basename (quote-strip resistant). */
530
+ function pathishMentionsKillSwitch(pathish) {
531
+ for (const name of KILL_SWITCH_BASENAMES) {
532
+ if (pathish === name || pathish.endsWith(`/${name}`) || pathish.includes(name)) {
533
+ return true;
534
+ }
535
+ }
536
+ return false;
537
+ }
240
538
  /**
241
- * Shell write targeting `.deft-directive-disable` / `.no-deft-directive` (#3186 / #3039).
539
+ * Shell write targeting `.deft-directive-disable` / `.no-deft-directive` (#3186 / #3039 / #3213).
242
540
  * Planting the kill-switch under UAT would full-bypass subsequent gates without operator recovery.
541
+ * Includes symlink plant bins (`ln`/`link`/`mklink`) and quote-split-resistant basename checks.
243
542
  */
244
543
  function hasKillSwitchShellWrite(command, tokens) {
245
544
  const lower = command.toLowerCase().replace(/\\/g, "/");
545
+ // Quote-stripped form so `'.deft'-directive-disable`-class splits still match (#3213).
546
+ const stripped = lower.replace(/['"]/g, "");
246
547
  let mentionsKill = false;
247
548
  for (const name of KILL_SWITCH_BASENAMES) {
248
- if (lower.includes(name)) {
549
+ if (lower.includes(name) || stripped.includes(name)) {
249
550
  mentionsKill = true;
250
551
  break;
251
552
  }
252
553
  }
554
+ if (!mentionsKill) {
555
+ for (const t of tokens) {
556
+ if (pathishMentionsKillSwitch(pathishToken(t))) {
557
+ mentionsKill = true;
558
+ break;
559
+ }
560
+ }
561
+ }
253
562
  if (!mentionsKill)
254
563
  return false;
255
- // Redirect dest region after each `>` / `>>` (O(n)).
256
- for (let i = 0; i < lower.length; i++) {
257
- if (lower[i] !== ">")
258
- continue;
259
- let j = i + 1;
260
- if (j < lower.length && lower[j] === ">")
261
- j++;
262
- let end = j;
263
- while (end < lower.length &&
264
- lower[end] !== "|" &&
265
- lower[end] !== ";" &&
266
- lower[end] !== "&" &&
267
- lower[end] !== "\n") {
268
- end++;
269
- }
270
- const dest = lower.slice(j, end);
271
- for (const name of KILL_SWITCH_BASENAMES) {
272
- if (dest.includes(name))
273
- return true;
564
+ // Redirect dest region after each `>` / `>>` (O(n)); check raw + quote-stripped.
565
+ for (const hay of [lower, stripped]) {
566
+ for (let i = 0; i < hay.length; i++) {
567
+ if (hay[i] !== ">")
568
+ continue;
569
+ let j = i + 1;
570
+ if (j < hay.length && hay[j] === ">")
571
+ j++;
572
+ let end = j;
573
+ while (end < hay.length &&
574
+ hay[end] !== "|" &&
575
+ hay[end] !== ";" &&
576
+ hay[end] !== "&" &&
577
+ hay[end] !== "\n") {
578
+ end++;
579
+ }
580
+ const dest = hay.slice(j, end);
581
+ for (const name of KILL_SWITCH_BASENAMES) {
582
+ if (dest.includes(name))
583
+ return true;
584
+ }
274
585
  }
275
586
  }
276
- // Write/destructive bins with a kill-switch path argument (touch, New-Item, ).
587
+ // Downloader/decoder destinations: curl -o .deft-directive-disable, scp, (#3206 / #3213).
588
+ for (const dest of downloaderDecoderDestinations(tokens)) {
589
+ if (pathishMentionsKillSwitch(dest))
590
+ return true;
591
+ }
592
+ // Write/destructive + symlink plant bins with a kill-switch path argument (#3213: ln/link/mklink).
277
593
  const killWriteBins = new Set([
278
594
  ...INDIRECT_WRITE_BINS,
595
+ ...SYMLINK_PLANT_BINS,
279
596
  "touch",
280
597
  "new-item",
281
598
  "ni",
@@ -284,26 +601,27 @@ function hasKillSwitchShellWrite(command, tokens) {
284
601
  "type",
285
602
  ]);
286
603
  for (let ti = 0; ti < tokens.length; ti++) {
287
- if (!killWriteBins.has(normalizeToken(tokens[ti])))
604
+ const binTok = normalizeToken(tokens[ti]);
605
+ const bare = binBareName(tokens[ti]);
606
+ if (!killWriteBins.has(binTok) && !killWriteBins.has(bare))
288
607
  continue;
289
608
  for (let tj = ti + 1; tj < tokens.length; tj++) {
290
- const p = pathishToken(tokens[tj]);
291
- for (const name of KILL_SWITCH_BASENAMES) {
292
- if (p.includes(name))
293
- return true;
294
- }
609
+ if (pathishMentionsKillSwitch(pathishToken(tokens[tj])))
610
+ return true;
295
611
  }
296
612
  }
297
- // Bare `touch .deft-directive-disable` first token may be touch, path later.
613
+ // Bare `touch .deft-directive-disable` / `ln -sf x .deft-directive-disable` path later.
298
614
  for (const t of tokens) {
299
615
  const p = pathishToken(t);
300
616
  for (const name of KILL_SWITCH_BASENAMES) {
301
617
  // Exact basename or ends with /basename
302
618
  if (p === name || p.endsWith(`/${name}`)) {
303
- // Require some write shape (redirect already handled; touch/ni/echo/…)
619
+ // Require some write shape (redirect already handled; touch/ni/echo/ln/…)
304
620
  if (hasWriteShape(command, tokens) ||
305
621
  lower.includes("touch") ||
306
- lower.includes("new-item")) {
622
+ lower.includes("new-item") ||
623
+ SYMLINK_PLANT_BINS.has(binBareName(tokens[0])) ||
624
+ tokens.some((tok) => SYMLINK_PLANT_BINS.has(binBareName(tok)))) {
307
625
  return true;
308
626
  }
309
627
  }
@@ -476,42 +794,68 @@ function pathishToken(token) {
476
794
  return token.replace(/['"]/g, "").toLowerCase().replace(/\\/g, "/");
477
795
  }
478
796
  /**
479
- * Shell **write** targeting `.deft/authz/` (#3110 AC-3).
797
+ * True when a pathish string targets `.deft/authz` (after quote strip / slash normalize).
798
+ * Quote-split forms like `'.deft/'authz'/grants/x'` become `.deft/authz/grants/x` via pathishToken.
799
+ */
800
+ function pathishIsAuthzDir(pathish) {
801
+ return pathish.includes(".deft/authz");
802
+ }
803
+ /**
804
+ * Shell **write** targeting `.deft/authz/` (#3110 AC-3 / #3206 / #3213).
480
805
  * Pure reads (`cat .deft/authz/state.json`) stay unclassifiable — use `authz:show`.
481
806
  * Redirects only count when the destination region contains `.deft/authz`.
807
+ * Pathish/token checks run even when the raw command lacks contiguous `.deft/authz`
808
+ * text (quote-split residual: `cp x '.deft/'authz'/grants/y'`).
482
809
  */
483
810
  function hasAuthzDirShellWrite(command, tokens) {
484
811
  const lower = command.toLowerCase().replace(/\\/g, "/");
485
- if (!lower.includes(".deft/authz"))
486
- return false;
812
+ // Quote-stripped contiguous form for redirect dest checks (#3213).
813
+ const stripped = lower.replace(/['"]/g, "");
487
814
  // Redirect dest region after each `>` / `>>` (O(n); no nested-quantifier regex).
488
- for (let i = 0; i < lower.length; i++) {
489
- if (lower[i] !== ">")
490
- continue;
491
- let j = i + 1;
492
- if (j < lower.length && lower[j] === ">")
493
- j++;
494
- // Dest until pipe/semicolon/ampersand/newline.
495
- let end = j;
496
- while (end < lower.length &&
497
- lower[end] !== "|" &&
498
- lower[end] !== ";" &&
499
- lower[end] !== "&" &&
500
- lower[end] !== "\n") {
501
- end++;
502
- }
503
- if (lower.slice(j, end).includes(".deft/authz"))
504
- return true;
815
+ // Check both raw and quote-stripped so quote-split dests still match.
816
+ for (const hay of [lower, stripped]) {
817
+ for (let i = 0; i < hay.length; i++) {
818
+ if (hay[i] !== ">")
819
+ continue;
820
+ let j = i + 1;
821
+ if (j < hay.length && hay[j] === ">")
822
+ j++;
823
+ // Dest until pipe/semicolon/ampersand/newline.
824
+ let end = j;
825
+ while (end < hay.length &&
826
+ hay[end] !== "|" &&
827
+ hay[end] !== ";" &&
828
+ hay[end] !== "&" &&
829
+ hay[end] !== "\n") {
830
+ end++;
831
+ }
832
+ if (hay.slice(j, end).includes(".deft/authz"))
833
+ return true;
834
+ }
505
835
  }
506
- // Write/destructive bins with an authz path argument.
836
+ // Write/destructive + symlink plant bins with an authz path argument (pathish =
837
+ // quote-strip resistant). Always run — do not gate on contiguous `.deft/authz`
838
+ // in the raw command (#3213). SYMLINK_PLANT_BINS: `ln -s forged .deft/authz/grants/x`
839
+ // must not fail-open as unclassifiable (SLizard residual).
507
840
  for (let ti = 0; ti < tokens.length; ti++) {
508
- if (!INDIRECT_WRITE_BINS.has(normalizeToken(tokens[ti])))
841
+ const bare = binBareName(tokens[ti]);
842
+ const n = normalizeToken(tokens[ti]);
843
+ if (!INDIRECT_WRITE_BINS.has(n) &&
844
+ !INDIRECT_WRITE_BINS.has(bare) &&
845
+ !SYMLINK_PLANT_BINS.has(bare)) {
509
846
  continue;
847
+ }
510
848
  for (let tj = ti + 1; tj < tokens.length; tj++) {
511
- if (pathishToken(tokens[tj]).includes(".deft/authz"))
849
+ if (pathishIsAuthzDir(pathishToken(tokens[tj])))
512
850
  return true;
513
851
  }
514
852
  }
853
+ // Downloader/decoder destinations under .deft/authz (#3206 / #3213 scp/aria2c/certutil).
854
+ for (const dest of downloaderDecoderDestinations(tokens)) {
855
+ if (pathishIsAuthzDir(dest))
856
+ return true;
857
+ }
858
+ // Contiguous mention without write shape stays false (reads like cat .deft/authz/…).
515
859
  return false;
516
860
  }
517
861
  /** Write/destructive shell bins (token match after normalizeToken). */
@@ -38,7 +38,47 @@ export interface ConsumerCheckContractOptions {
38
38
  /** When true, missing CI references warn only (default true for migration). */
39
39
  readonly ciWarnOnly?: boolean;
40
40
  readonly enforce?: boolean;
41
+ /**
42
+ * Inject included framework Taskfile text (canonical `.deft/core/Taskfile.yml`).
43
+ * When undefined, resolve from disk via {@link resolveCanonicalDeftTaskfileInclude}.
44
+ * #3218 greenfield include-only consumers.
45
+ */
46
+ readonly includedFrameworkTaskfileText?: string | null;
47
+ /**
48
+ * Inject included framework `tasks/verify.yml` text next to the canonical include.
49
+ * When undefined, resolve from disk beside the included Taskfile.
50
+ */
51
+ readonly includedVerifyTaskfileText?: string | null;
41
52
  }
53
+ /**
54
+ * Relative path forms accepted for the deft-install canonical Taskfile include
55
+ * (init-deposit `MINIMAL_TASKFILE` / `CANONICAL_TASKFILE_INCLUDE`).
56
+ * Use only as a path-value matcher — never as a whole-file text search (Greptile P1 #3218).
57
+ */
58
+ export declare const CANONICAL_DEFT_TASKFILE_PATH_RE: RegExp;
59
+ /** @deprecated Prefer path/line parsers; kept for export stability. */
60
+ export declare const CANONICAL_DEFT_TASKFILE_INCLUDE_RE: RegExp;
61
+ /**
62
+ * Return the relative path of the canonical `.deft/core/Taskfile.yml` include when
63
+ * present as an **active, direct** go-task `includes:` entry; otherwise null.
64
+ *
65
+ * Greenfield `directive init` deposits an include-only Taskfile (#3218); composition
66
+ * lives in the included framework graph, not in root `check` deps.
67
+ *
68
+ * Greptile P1 (#3218):
69
+ * - Path text in comments / cmds / vars / non-include keys must not match.
70
+ * - Only **direct** include entries count: short form `deft: path` or object form
71
+ * `deft: { taskfile: path }` (immediate property). Nested
72
+ * `includes.some.nested.taskfile` is not an executable go-task include.
73
+ * - Namespace must be `deft` so the documented `task deft:check` entrypoint exists.
74
+ */
75
+ export declare function resolveCanonicalDeftTaskfileInclude(rootTaskfileText: string): string | null;
76
+ /**
77
+ * True when a framework (included) Taskfile composes every required gate via
78
+ * `check` / `check:consumer` / `check:framework-source` deps or an orchestrator body,
79
+ * and `tasks/verify.yml` defines the local task keys.
80
+ */
81
+ export declare function frameworkTaskfileComposesRequiredGates(frameworkTaskfileText: string, verifyTaskfileText: string | null, required?: readonly string[]): boolean;
42
82
  /** True if `text` mentions the gate as a task dep, script, or CLI invocation. */
43
83
  export declare function textReferencesGate(text: string, gateId: string): boolean;
44
84
  /**