@deftai/directive-core 0.97.0 → 0.98.1
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.
- package/dist/authz/classify.js +443 -0
- package/dist/check/cached-orchestrator.d.ts +5 -0
- package/dist/check/cached-orchestrator.js +18 -1
- package/dist/check/gate-lists.d.ts +20 -0
- package/dist/check/gate-lists.js +46 -9
- package/dist/check/index.d.ts +1 -1
- package/dist/check/index.js +1 -1
- package/dist/check/orchestrator.d.ts +4 -0
- package/dist/check/orchestrator.js +4 -0
- package/dist/doctor/checks.d.ts +7 -0
- package/dist/doctor/checks.js +83 -0
- package/dist/hooks/dispatcher.d.ts +5 -0
- package/dist/hooks/dispatcher.js +54 -4
- package/dist/init-deposit/hygiene.d.ts +70 -1
- package/dist/init-deposit/hygiene.js +582 -8
- package/dist/init-deposit/scaffold.js +277 -4
- package/dist/init-deposit/skill-discovery-deposit.js +15 -0
- package/dist/policy/check-resume.d.ts +72 -0
- package/dist/policy/check-resume.js +253 -0
- package/dist/policy/coverage-check-resume-presets.d.ts +46 -0
- package/dist/policy/coverage-check-resume-presets.js +228 -0
- package/dist/policy/coverage-debt.d.ts +76 -0
- package/dist/policy/coverage-debt.js +262 -0
- package/dist/policy/index.d.ts +3 -0
- package/dist/policy/index.js +50 -21
- package/dist/release/auto-hatch.d.ts +114 -0
- package/dist/release/auto-hatch.js +301 -0
- package/dist/release/coverage-debt-ledger.d.ts +22 -0
- package/dist/release/coverage-debt-ledger.js +157 -0
- package/dist/release/index.d.ts +3 -0
- package/dist/release/index.js +3 -0
- package/dist/release/pipeline.js +164 -12
- package/dist/release/suite-stamp.d.ts +44 -0
- package/dist/release/suite-stamp.js +133 -0
- package/dist/release/types.d.ts +19 -0
- package/dist/scope-provenance/evaluate.d.ts +21 -0
- package/dist/scope-provenance/evaluate.js +143 -33
- package/dist/scope-provenance/index.d.ts +1 -1
- package/dist/scope-provenance/index.js +1 -1
- package/dist/session/coverage-check-resume-nudge.d.ts +34 -0
- package/dist/session/coverage-check-resume-nudge.js +66 -0
- package/dist/session/index.d.ts +1 -0
- package/dist/session/index.js +1 -0
- package/dist/session/session-start.js +21 -0
- package/dist/triage/classify/label-mirror.d.ts +31 -1
- package/dist/triage/classify/label-mirror.js +78 -6
- package/dist/triage/help/registry-data.d.ts +6 -6
- package/dist/triage/help/registry-data.js +12 -3
- package/dist/vbrief-validate/plan-hooks.d.ts +4 -0
- package/dist/vbrief-validate/plan-hooks.js +54 -0
- package/package.json +3 -3
package/dist/authz/classify.js
CHANGED
|
@@ -150,6 +150,158 @@ function hasGhApiPath(tokens, needle) {
|
|
|
150
150
|
* active UAT they deny without a prior human grant — never empty → shell-op-unclassifiable fail-open.
|
|
151
151
|
*/
|
|
152
152
|
const AUTHZ_MUTATING_SUBCOMMANDS = new Set(["grant", "uat-start", "uat-suspend", "revoke"]);
|
|
153
|
+
/**
|
|
154
|
+
* Policy authority mutators that weaken merge / branch / directive gates (#3186).
|
|
155
|
+
* Classified as **settings** — agents must not self-serve these under active UAT.
|
|
156
|
+
*/
|
|
157
|
+
const POLICY_AUTHORITY_MUTATORS = new Set([
|
|
158
|
+
"allow-bot-merge",
|
|
159
|
+
"allow-direct-commits",
|
|
160
|
+
"disable-directive",
|
|
161
|
+
"enable-directive",
|
|
162
|
+
]);
|
|
163
|
+
/** Kill-switch / permanent opt-out basenames agents must not plant under UAT (#3186 / #3039). */
|
|
164
|
+
const KILL_SWITCH_BASENAMES = [".deft-directive-disable", ".no-deft-directive"];
|
|
165
|
+
/**
|
|
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.
|
|
169
|
+
*/
|
|
170
|
+
const DOWNLOADER_DECODER_BINS = new Set(["curl", "wget", "xxd", "openssl"]);
|
|
171
|
+
/**
|
|
172
|
+
* File destination flags for downloaders/decoders (#3206).
|
|
173
|
+
* normalizeToken lowercases, so wget `-O` and curl `-o` share `-o`.
|
|
174
|
+
*/
|
|
175
|
+
const DOWNLOADER_FILE_DEST_FLAGS = new Set([
|
|
176
|
+
"-o",
|
|
177
|
+
"--output",
|
|
178
|
+
"--output-document",
|
|
179
|
+
"-out",
|
|
180
|
+
"--out",
|
|
181
|
+
]);
|
|
182
|
+
/** Directory destination flags (curl --output-dir / wget -P); bin-scoped below. */
|
|
183
|
+
const CURL_DIR_DEST_FLAGS = new Set(["--output-dir"]);
|
|
184
|
+
const WGET_DIR_DEST_FLAGS = new Set(["-p", "--directory-prefix"]);
|
|
185
|
+
/** Final path segment of a token (path-qualified bins / .exe). */
|
|
186
|
+
function binBareName(token) {
|
|
187
|
+
const pathish = token.replace(/['"]/g, "").toLowerCase().replace(/\\/g, "/");
|
|
188
|
+
const base = pathish.includes("/") ? pathish.slice(pathish.lastIndexOf("/") + 1) : pathish;
|
|
189
|
+
return base.endsWith(".exe") ? base.slice(0, -4) : base;
|
|
190
|
+
}
|
|
191
|
+
/** True when token names a downloader/decoder bin (bare or path-qualified). */
|
|
192
|
+
function isDownloaderDecoderBin(token) {
|
|
193
|
+
const n = normalizeToken(token);
|
|
194
|
+
if (n.startsWith("-"))
|
|
195
|
+
return false;
|
|
196
|
+
return DOWNLOADER_DECODER_BINS.has(binBareName(token));
|
|
197
|
+
}
|
|
198
|
+
function isDownloaderDestFlag(flag, bin) {
|
|
199
|
+
if (DOWNLOADER_FILE_DEST_FLAGS.has(flag))
|
|
200
|
+
return true;
|
|
201
|
+
if (bin === "curl" && CURL_DIR_DEST_FLAGS.has(flag))
|
|
202
|
+
return true;
|
|
203
|
+
if (bin === "wget" && WGET_DIR_DEST_FLAGS.has(flag))
|
|
204
|
+
return true;
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
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).
|
|
210
|
+
* openssl uses flags only (no positional dests — avoids treating -in paths as writes).
|
|
211
|
+
* O(n) token walk — no nested-quantifier regex on untrusted input.
|
|
212
|
+
*/
|
|
213
|
+
function downloaderDecoderDestinations(tokens) {
|
|
214
|
+
const dests = [];
|
|
215
|
+
let i = 0;
|
|
216
|
+
while (i < tokens.length) {
|
|
217
|
+
if (!isDownloaderDecoderBin(tokens[i])) {
|
|
218
|
+
i++;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const bin = binBareName(tokens[i]);
|
|
222
|
+
i++;
|
|
223
|
+
// xxd reverse mode writes; without -r, path positionals are dump inputs (read).
|
|
224
|
+
let xxdReverse = false;
|
|
225
|
+
while (i < tokens.length) {
|
|
226
|
+
const raw = tokens[i];
|
|
227
|
+
const n = normalizeToken(raw);
|
|
228
|
+
// New bare bin starts another command segment (not pathish ./wget operands).
|
|
229
|
+
if (!n.startsWith("-") &&
|
|
230
|
+
!raw.includes("/") &&
|
|
231
|
+
!raw.includes("\\") &&
|
|
232
|
+
isDownloaderDecoderBin(raw)) {
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
if (bin === "xxd" && (n === "-r" || n === "--reverse")) {
|
|
236
|
+
xxdReverse = true;
|
|
237
|
+
i++;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
// Skip openssl/xxd input flags + value so -in PATH is not a write dest.
|
|
241
|
+
if (n === "-in" || n === "--in" || n === "-inform" || n === "--inform") {
|
|
242
|
+
const next = tokens[i + 1];
|
|
243
|
+
if (next !== undefined && !String(next).startsWith("-")) {
|
|
244
|
+
i += 2;
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
i++;
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
// --flag=value (and rare -out=value)
|
|
251
|
+
if (n.includes("=") && (n.startsWith("-") || n.startsWith("--"))) {
|
|
252
|
+
const eq = raw.indexOf("=");
|
|
253
|
+
const flag = normalizeToken(raw.slice(0, eq));
|
|
254
|
+
if (isDownloaderDestFlag(flag, bin)) {
|
|
255
|
+
dests.push(pathishToken(raw.slice(eq + 1)));
|
|
256
|
+
}
|
|
257
|
+
i++;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
// Attached short: -oPATH / -OPATH (after lowercasing both are -opath…)
|
|
261
|
+
if (n.startsWith("-") && !n.startsWith("--") && n.length > 2) {
|
|
262
|
+
if (n.startsWith("-out") && n.length > 4 && !n.startsWith("-output")) {
|
|
263
|
+
dests.push(pathishToken(raw.slice(4)));
|
|
264
|
+
i++;
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if (n.startsWith("-o") && !n.startsWith("-out") && n.length > 2) {
|
|
268
|
+
dests.push(pathishToken(raw.slice(2)));
|
|
269
|
+
i++;
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
// wget attached -Pdir
|
|
273
|
+
if (bin === "wget" && n.startsWith("-p") && n.length > 2 && !n.startsWith("-proxy")) {
|
|
274
|
+
dests.push(pathishToken(raw.slice(2)));
|
|
275
|
+
i++;
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
// Separate value: -o PATH / --output PATH / -out PATH / --output-dir / -P
|
|
280
|
+
if (isDownloaderDestFlag(n, bin)) {
|
|
281
|
+
const next = tokens[i + 1];
|
|
282
|
+
if (next !== undefined && !String(next).startsWith("-")) {
|
|
283
|
+
dests.push(pathishToken(next));
|
|
284
|
+
i += 2;
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
i++;
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
// xxd -r only: path-like write positionals (not http(s) URLs). openssl: flags only.
|
|
291
|
+
if (bin === "xxd" && xxdReverse && !n.startsWith("-")) {
|
|
292
|
+
const p = pathishToken(raw);
|
|
293
|
+
if ((p.includes("/") || p.startsWith(".") || p.includes("\\")) &&
|
|
294
|
+
!p.startsWith("http:") &&
|
|
295
|
+
!p.startsWith("https:") &&
|
|
296
|
+
!p.startsWith("ftp:")) {
|
|
297
|
+
dests.push(p);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
i++;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return dests;
|
|
304
|
+
}
|
|
153
305
|
function authzSubcommandFromToken(token) {
|
|
154
306
|
const t = normalizeToken(token);
|
|
155
307
|
if (t.startsWith("authz:")) {
|
|
@@ -158,6 +310,14 @@ function authzSubcommandFromToken(token) {
|
|
|
158
310
|
}
|
|
159
311
|
return AUTHZ_MUTATING_SUBCOMMANDS.has(t) ? t : null;
|
|
160
312
|
}
|
|
313
|
+
function policyMutatorFromToken(token) {
|
|
314
|
+
const t = normalizeToken(token);
|
|
315
|
+
if (t.startsWith("policy:")) {
|
|
316
|
+
const sub = t.slice("policy:".length);
|
|
317
|
+
return POLICY_AUTHORITY_MUTATORS.has(sub) ? sub : null;
|
|
318
|
+
}
|
|
319
|
+
return POLICY_AUTHORITY_MUTATORS.has(t) ? t : null;
|
|
320
|
+
}
|
|
161
321
|
/**
|
|
162
322
|
* Detect `deft|task|directive authz:grant` / `authz grant` (and wrappers) in shell tokens.
|
|
163
323
|
* O(n) token walk — no nested-quantifier regex on untrusted input.
|
|
@@ -189,6 +349,273 @@ function hasAuthzMutatingCli(tokens) {
|
|
|
189
349
|
}
|
|
190
350
|
return false;
|
|
191
351
|
}
|
|
352
|
+
/**
|
|
353
|
+
* Detect `policy:allow-bot-merge` / `policy allow-direct-commits` / peers (#3186).
|
|
354
|
+
* O(n) token walk — no nested-quantifier regex on untrusted input.
|
|
355
|
+
*/
|
|
356
|
+
function hasPolicyAuthorityMutator(tokens) {
|
|
357
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
358
|
+
const raw = tokens[i];
|
|
359
|
+
if (raw === undefined)
|
|
360
|
+
break;
|
|
361
|
+
const t = normalizeToken(raw);
|
|
362
|
+
if (policyMutatorFromToken(t) !== null && t.startsWith("policy:")) {
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
const isPolicyBin = t === "policy" ||
|
|
366
|
+
t.endsWith("/policy") ||
|
|
367
|
+
t.endsWith("\\policy") ||
|
|
368
|
+
t.endsWith("/policy.js") ||
|
|
369
|
+
t.endsWith("\\policy.js") ||
|
|
370
|
+
t.endsWith("/policy.ts") ||
|
|
371
|
+
t.endsWith("\\policy.ts");
|
|
372
|
+
if (!isPolicyBin)
|
|
373
|
+
continue;
|
|
374
|
+
const next = tokens[i + 1] !== undefined ? normalizeToken(tokens[i + 1]) : "";
|
|
375
|
+
if (policyMutatorFromToken(next) !== null)
|
|
376
|
+
return true;
|
|
377
|
+
}
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Shell write targeting `.deft-directive-disable` / `.no-deft-directive` (#3186 / #3039).
|
|
382
|
+
* Planting the kill-switch under UAT would full-bypass subsequent gates without operator recovery.
|
|
383
|
+
*/
|
|
384
|
+
function hasKillSwitchShellWrite(command, tokens) {
|
|
385
|
+
const lower = command.toLowerCase().replace(/\\/g, "/");
|
|
386
|
+
let mentionsKill = false;
|
|
387
|
+
for (const name of KILL_SWITCH_BASENAMES) {
|
|
388
|
+
if (lower.includes(name)) {
|
|
389
|
+
mentionsKill = true;
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
if (!mentionsKill)
|
|
394
|
+
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;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
// Downloader/decoder destinations: curl -o .deft-directive-disable, wget -O, … (#3206).
|
|
417
|
+
for (const dest of downloaderDecoderDestinations(tokens)) {
|
|
418
|
+
for (const name of KILL_SWITCH_BASENAMES) {
|
|
419
|
+
if (dest.includes(name))
|
|
420
|
+
return true;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
// Write/destructive bins with a kill-switch path argument (touch, New-Item, …).
|
|
424
|
+
const killWriteBins = new Set([
|
|
425
|
+
...INDIRECT_WRITE_BINS,
|
|
426
|
+
"touch",
|
|
427
|
+
"new-item",
|
|
428
|
+
"ni",
|
|
429
|
+
"echo",
|
|
430
|
+
"printf",
|
|
431
|
+
"type",
|
|
432
|
+
]);
|
|
433
|
+
for (let ti = 0; ti < tokens.length; ti++) {
|
|
434
|
+
if (!killWriteBins.has(normalizeToken(tokens[ti])))
|
|
435
|
+
continue;
|
|
436
|
+
for (let tj = ti + 1; tj < tokens.length; tj++) {
|
|
437
|
+
const p = pathishToken(tokens[tj]);
|
|
438
|
+
for (const name of KILL_SWITCH_BASENAMES) {
|
|
439
|
+
if (p.includes(name))
|
|
440
|
+
return true;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
// Bare `touch .deft-directive-disable` — first token may be touch, path later.
|
|
445
|
+
for (const t of tokens) {
|
|
446
|
+
const p = pathishToken(t);
|
|
447
|
+
for (const name of KILL_SWITCH_BASENAMES) {
|
|
448
|
+
// Exact basename or ends with /basename
|
|
449
|
+
if (p === name || p.endsWith(`/${name}`)) {
|
|
450
|
+
// Require some write shape (redirect already handled; touch/ni/echo/…)
|
|
451
|
+
if (hasWriteShape(command, tokens) ||
|
|
452
|
+
lower.includes("touch") ||
|
|
453
|
+
lower.includes("new-item")) {
|
|
454
|
+
return true;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return false;
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* True when a token is a write-capable language/runtime interpreter (#3186).
|
|
463
|
+
* Matches bare names, versioned bins (`python3.11`), and path-qualified forms
|
|
464
|
+
* (`/usr/bin/python3`, `C:\Python311\python.exe`) — exact Set membership alone
|
|
465
|
+
* would fail-open on absolute/versioned paths (Greptile P1).
|
|
466
|
+
*/
|
|
467
|
+
function isProgrammaticWriteBinToken(token) {
|
|
468
|
+
const t = normalizeToken(token);
|
|
469
|
+
if (t.length === 0)
|
|
470
|
+
return false;
|
|
471
|
+
// Path-qualified: keep final path segment after / or \ (normalizeToken strips quotes only).
|
|
472
|
+
const pathish = token.replace(/['"]/g, "").toLowerCase().replace(/\\/g, "/");
|
|
473
|
+
const base = pathish.includes("/") ? pathish.slice(pathish.lastIndexOf("/") + 1) : t;
|
|
474
|
+
const bare = base.endsWith(".exe") ? base.slice(0, -4) : base;
|
|
475
|
+
if (bare === "python" ||
|
|
476
|
+
bare === "python3" ||
|
|
477
|
+
bare === "node" ||
|
|
478
|
+
bare === "nodejs" ||
|
|
479
|
+
bare === "perl" ||
|
|
480
|
+
bare === "ruby" ||
|
|
481
|
+
bare === "pwsh" ||
|
|
482
|
+
bare === "powershell") {
|
|
483
|
+
return true;
|
|
484
|
+
}
|
|
485
|
+
// Versioned: python3.11, python3.12, node18, …
|
|
486
|
+
if (bare.startsWith("python3.") || bare.startsWith("python2."))
|
|
487
|
+
return true;
|
|
488
|
+
if (/^python\d+(\.\d+)*$/.test(bare))
|
|
489
|
+
return true;
|
|
490
|
+
if (/^node\d+$/.test(bare))
|
|
491
|
+
return true;
|
|
492
|
+
return false;
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* True when `needle` occurs in `haystack` outside single/double-quoted regions (O(n)).
|
|
496
|
+
* Escaped quotes (`\'` / `\"`) stay inside the string. Used so `print('.write(')` is not
|
|
497
|
+
* a write API while `open(p,'w').write('x')` still matches `.write(` (Greptile conf residual).
|
|
498
|
+
*/
|
|
499
|
+
function includesOutsideQuotes(haystack, needle) {
|
|
500
|
+
if (needle.length === 0 || haystack.length < needle.length)
|
|
501
|
+
return false;
|
|
502
|
+
let inSingle = false;
|
|
503
|
+
let inDouble = false;
|
|
504
|
+
for (let i = 0; i < haystack.length; i++) {
|
|
505
|
+
const c = haystack[i];
|
|
506
|
+
if (c === "\\" && i + 1 < haystack.length && (inSingle || inDouble)) {
|
|
507
|
+
i++; // skip escaped char inside quotes
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
if (c === "'" && !inDouble) {
|
|
511
|
+
inSingle = !inSingle;
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
if (c === '"' && !inSingle) {
|
|
515
|
+
inDouble = !inDouble;
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
if (inSingle || inDouble)
|
|
519
|
+
continue;
|
|
520
|
+
if (haystack.startsWith(needle, i))
|
|
521
|
+
return true;
|
|
522
|
+
}
|
|
523
|
+
return false;
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* True when command uses a programmatic bin with a **write** API, optionally with
|
|
527
|
+
* path-obfuscation markers (base64/bytes/chr). Pure reads / print-only stay unclassifiable.
|
|
528
|
+
*
|
|
529
|
+
* Chosen rule (#3186): classify write-capable programmatic Shell as **settings** so
|
|
530
|
+
* active UAT fails closed (not shell-op-unclassifiable allow) even when the path is
|
|
531
|
+
* built at runtime without a literal `.deft/authz` substring. Outside UAT, evaluate
|
|
532
|
+
* still returns authz-inactive allow — classification alone is not a hard deny.
|
|
533
|
+
*
|
|
534
|
+
* Read-only `open(...).read()` does **not** count as writeish (Greptile P1).
|
|
535
|
+
* Quoted data containing `.write(` does **not** count (Greptile conf residual).
|
|
536
|
+
* Obfuscation alone without a write API does **not** classify (avoid deny on decode-only).
|
|
537
|
+
*/
|
|
538
|
+
function hasWriteCapableProgrammaticShell(command, tokens) {
|
|
539
|
+
let hasProg = false;
|
|
540
|
+
for (const t of tokens) {
|
|
541
|
+
if (isProgrammaticWriteBinToken(t)) {
|
|
542
|
+
hasProg = true;
|
|
543
|
+
break;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
if (!hasProg)
|
|
547
|
+
return false;
|
|
548
|
+
const lower = command.toLowerCase();
|
|
549
|
+
// Shell wrappers put whole scripts in "…" after -c/-e, so long API names (writeFileSync)
|
|
550
|
+
// use full-string includes. Short ambiguous `.write(` uses quote-aware match so
|
|
551
|
+
// `print('.write(')` is not a write API (Greptile conf residual).
|
|
552
|
+
const writeApi = lower.includes("writefilesync") ||
|
|
553
|
+
lower.includes("writefile") ||
|
|
554
|
+
lower.includes("writetext") ||
|
|
555
|
+
lower.includes("set-content") ||
|
|
556
|
+
lower.includes("out-file") ||
|
|
557
|
+
lower.includes("fs.write") ||
|
|
558
|
+
lower.includes("createwritestream") ||
|
|
559
|
+
lower.includes("path.write(") ||
|
|
560
|
+
lower.includes("file.write(") ||
|
|
561
|
+
lower.includes("spurt") ||
|
|
562
|
+
lower.includes(">>") ||
|
|
563
|
+
lower.includes("unlink(") ||
|
|
564
|
+
lower.includes("rmsync") ||
|
|
565
|
+
lower.includes("rm_rf") ||
|
|
566
|
+
lower.includes("shutil.rmtree") ||
|
|
567
|
+
lower.includes("os.remove(") ||
|
|
568
|
+
lower.includes("os.unlink(") ||
|
|
569
|
+
lower.includes("fs.unlink") ||
|
|
570
|
+
lower.includes("fs.rm(") ||
|
|
571
|
+
lower.includes("fs.rmsync") ||
|
|
572
|
+
// Short `.write(` only counts outside quotes (avoids print('.write(') false positive).
|
|
573
|
+
// Also match when it appears after a shell -c/-e opening quote as code (common PoC shape):
|
|
574
|
+
// if the command has write mode open or other write API, those already hit above.
|
|
575
|
+
includesOutsideQuotes(lower, ".write(") ||
|
|
576
|
+
// Script body after -c/-e often sits in one double-quoted region: still detect `.write(`
|
|
577
|
+
// as code when paired with an assignment/call shape (f.write / .write(x)).
|
|
578
|
+
(lower.includes(".write(") &&
|
|
579
|
+
(lower.includes("open(") || lower.includes("=open") || lower.includes("fs.")));
|
|
580
|
+
// open(..., 'w' / "w" / 'a' / '>') — mode tokens are quoted; match on full command.
|
|
581
|
+
const openWriteMode = lower.includes("open(") &&
|
|
582
|
+
(lower.includes(",'w") ||
|
|
583
|
+
lower.includes(',"w') ||
|
|
584
|
+
lower.includes(", 'w") ||
|
|
585
|
+
lower.includes(', "w') ||
|
|
586
|
+
lower.includes(",'a") ||
|
|
587
|
+
lower.includes(',"a') ||
|
|
588
|
+
lower.includes(", 'a") ||
|
|
589
|
+
lower.includes(', "a') ||
|
|
590
|
+
lower.includes(",'>") ||
|
|
591
|
+
lower.includes(',">') ||
|
|
592
|
+
lower.includes(", '>") ||
|
|
593
|
+
lower.includes(', ">') ||
|
|
594
|
+
lower.includes("mode='w") ||
|
|
595
|
+
lower.includes('mode="w') ||
|
|
596
|
+
lower.includes("mode='a") ||
|
|
597
|
+
lower.includes('mode="a') ||
|
|
598
|
+
lower.includes("mode=w"));
|
|
599
|
+
const writeish = writeApi || openWriteMode;
|
|
600
|
+
// Path construction that hides the destination from literal classifiers.
|
|
601
|
+
const obfuscatedPath = lower.includes("base64") ||
|
|
602
|
+
lower.includes("fromcharcode") ||
|
|
603
|
+
lower.includes("bytes([") ||
|
|
604
|
+
lower.includes("bytearray") ||
|
|
605
|
+
lower.includes("buffer.from") ||
|
|
606
|
+
lower.includes("codecs.decode") ||
|
|
607
|
+
lower.includes("unhexlify") ||
|
|
608
|
+
lower.includes("fromhex") ||
|
|
609
|
+
lower.includes("string.fromcharcode") ||
|
|
610
|
+
lower.includes("atob(") ||
|
|
611
|
+
lower.includes("btoa(") ||
|
|
612
|
+
lower.includes("chr(");
|
|
613
|
+
// Fail-closed residual: write-ish programmatic shell (obfuscation alone not enough).
|
|
614
|
+
if (writeish)
|
|
615
|
+
return true;
|
|
616
|
+
void obfuscatedPath;
|
|
617
|
+
return false;
|
|
618
|
+
}
|
|
192
619
|
/**
|
|
193
620
|
* Path-ish normalize: keep separators (do not strip `\` like normalizeToken).
|
|
194
621
|
*/
|
|
@@ -232,6 +659,11 @@ function hasAuthzDirShellWrite(command, tokens) {
|
|
|
232
659
|
return true;
|
|
233
660
|
}
|
|
234
661
|
}
|
|
662
|
+
// Downloader/decoder destinations under .deft/authz (#3206 residual after #3186).
|
|
663
|
+
for (const dest of downloaderDecoderDestinations(tokens)) {
|
|
664
|
+
if (dest.includes(".deft/authz"))
|
|
665
|
+
return true;
|
|
666
|
+
}
|
|
235
667
|
return false;
|
|
236
668
|
}
|
|
237
669
|
/** Write/destructive shell bins (token match after normalizeToken). */
|
|
@@ -621,6 +1053,11 @@ export function classifyShellAuthzOps(command) {
|
|
|
621
1053
|
found.add("settings");
|
|
622
1054
|
if (hasAuthzDirShellWrite(cmd, tokens))
|
|
623
1055
|
found.add("settings");
|
|
1056
|
+
// #3186: kill-switch plant + policy authority mutators → settings (UAT fail-closed).
|
|
1057
|
+
if (hasKillSwitchShellWrite(cmd, tokens))
|
|
1058
|
+
found.add("settings");
|
|
1059
|
+
if (hasPolicyAuthorityMutator(tokens))
|
|
1060
|
+
found.add("settings");
|
|
624
1061
|
// Split path write: `cd .deft && echo x > authz/state.json` OR `cd .deft/authz && echo x > state.json`
|
|
625
1062
|
// OR `cd .deft/authz && cp … grants/x` (write bin without redirect).
|
|
626
1063
|
// When the command cds into an authz path, any write shape is settings (relative dest has no "authz" text).
|
|
@@ -665,6 +1102,12 @@ export function classifyShellAuthzOps(command) {
|
|
|
665
1102
|
}
|
|
666
1103
|
if (hasIndirectAuthzStoreWrite(cmd, tokens))
|
|
667
1104
|
found.add("settings");
|
|
1105
|
+
// #3186: programmatic write/obfuscated-path shells fail closed as settings (not unclassifiable allow).
|
|
1106
|
+
// Always merge settings even when other ops already matched (e.g. `pytest && python -c '…write…'`)
|
|
1107
|
+
// so a compound safe prefix cannot hide a write-capable residual (SLizard residual).
|
|
1108
|
+
if (hasWriteCapableProgrammaticShell(cmd, tokens)) {
|
|
1109
|
+
found.add("settings");
|
|
1110
|
+
}
|
|
668
1111
|
return [...found];
|
|
669
1112
|
}
|
|
670
1113
|
/** Map a PreToolUse tool name + optional shell command to authz ops. */
|
|
@@ -11,6 +11,11 @@ export interface CachedCheckOptions extends CheckOrchestratorSeams {
|
|
|
11
11
|
/**
|
|
12
12
|
* Run check gates sequentially with content-hash caching (#1713).
|
|
13
13
|
* Falls back to fail-open execution for undeclared / non-cacheable gates.
|
|
14
|
+
*
|
|
15
|
+
* Gate order is fast-before-slow (#3188): non-suite gates complete (or fail)
|
|
16
|
+
* before any suite gate (`ts:check-lane` / vitest+coverage) is started. A
|
|
17
|
+
* non-zero exit aborts the loop immediately — the suite never starts after a
|
|
18
|
+
* fast-gate failure (observable via `onGateStart` / suite start log).
|
|
14
19
|
*/
|
|
15
20
|
export declare function dispatchCachedTaskCheck(frameworkRoot: string, projectRoot: string, options?: CachedCheckOptions): number;
|
|
16
21
|
//# sourceMappingURL=cached-orchestrator.d.ts.map
|
|
@@ -4,7 +4,7 @@ import { lintShippedRegistry, resolveTaskContract, runWithCache, } from "../cach
|
|
|
4
4
|
import { readCorePackageVersion } from "../engine-version.js";
|
|
5
5
|
import { evaluateConsumerGateIntegrity, formatConsumerGateIntegrityFailure, } from "./consumer-gate-integrity.js";
|
|
6
6
|
import { resolveCheckTarget } from "./context.js";
|
|
7
|
-
import { checkGateId, checkGateSpawnArgs, gatesForCheckTarget } from "./gate-lists.js";
|
|
7
|
+
import { checkGateId, checkGateSpawnArgs, gatesForCheckTarget, isSuiteCheckGate, } from "./gate-lists.js";
|
|
8
8
|
function captureSpawn(taskBin, args, opts) {
|
|
9
9
|
const result = spawnSync(taskBin, args, {
|
|
10
10
|
cwd: opts.cwd,
|
|
@@ -20,6 +20,11 @@ function captureSpawn(taskBin, args, opts) {
|
|
|
20
20
|
/**
|
|
21
21
|
* Run check gates sequentially with content-hash caching (#1713).
|
|
22
22
|
* Falls back to fail-open execution for undeclared / non-cacheable gates.
|
|
23
|
+
*
|
|
24
|
+
* Gate order is fast-before-slow (#3188): non-suite gates complete (or fail)
|
|
25
|
+
* before any suite gate (`ts:check-lane` / vitest+coverage) is started. A
|
|
26
|
+
* non-zero exit aborts the loop immediately — the suite never starts after a
|
|
27
|
+
* fast-gate failure (observable via `onGateStart` / suite start log).
|
|
23
28
|
*/
|
|
24
29
|
export function dispatchCachedTaskCheck(frameworkRoot, projectRoot, options = {}) {
|
|
25
30
|
const resolvedFramework = resolve(frameworkRoot);
|
|
@@ -53,6 +58,11 @@ export function dispatchCachedTaskCheck(frameworkRoot, projectRoot, options = {}
|
|
|
53
58
|
}
|
|
54
59
|
for (const gateSpec of gates) {
|
|
55
60
|
const gateId = checkGateId(gateSpec);
|
|
61
|
+
// #3188: log suite entry so operators/tests can prove fast failures never
|
|
62
|
+
// reach vitest+coverage (suite gates are ordered last in gate-lists).
|
|
63
|
+
if (isSuiteCheckGate(gateSpec)) {
|
|
64
|
+
process.stderr.write(`check: starting suite gate ${gateId} after fast preflight (#3188)\n`);
|
|
65
|
+
}
|
|
56
66
|
options.onGateStart?.(gateId);
|
|
57
67
|
const contract = resolveTaskContract(gateId);
|
|
58
68
|
const taskArgs = checkGateSpawnArgs(gateSpec, taskfilePath);
|
|
@@ -78,7 +88,14 @@ export function dispatchCachedTaskCheck(frameworkRoot, projectRoot, options = {}
|
|
|
78
88
|
},
|
|
79
89
|
});
|
|
80
90
|
options.onGateComplete?.(gateId, result.exitCode, result.fromCache);
|
|
91
|
+
// Fail-fast: do not start later gates (including suite) after a failure.
|
|
81
92
|
if (result.exitCode !== 0) {
|
|
93
|
+
if (!isSuiteCheckGate(gateSpec)) {
|
|
94
|
+
const remaining = gates.some(isSuiteCheckGate)
|
|
95
|
+
? "skipping remaining gates including suite"
|
|
96
|
+
: "skipping remaining gates";
|
|
97
|
+
process.stderr.write(`check: fast gate ${gateId} failed (exit ${result.exitCode}); ${remaining} (#3188)\n`);
|
|
98
|
+
}
|
|
82
99
|
return result.exitCode;
|
|
83
100
|
}
|
|
84
101
|
}
|
|
@@ -12,6 +12,26 @@ export type CheckGateSpec = string | {
|
|
|
12
12
|
export declare function checkGateId(spec: CheckGateSpec): string;
|
|
13
13
|
/** Args for `task … --taskfile <path>` (optional `--` + public CLI flags). */
|
|
14
14
|
export declare function checkGateSpawnArgs(spec: CheckGateSpec, taskfilePath: string): string[];
|
|
15
|
+
/**
|
|
16
|
+
* Gates that own the long vitest+coverage (or equivalent) suite path.
|
|
17
|
+
* Shared `task check` composition runs every non-suite gate first so cheap
|
|
18
|
+
* failures never pay suite wall-clock (#3188). Release suite stamp/resume
|
|
19
|
+
* remains #3187 / release-scoped.
|
|
20
|
+
*/
|
|
21
|
+
export declare const SUITE_CHECK_GATE_IDS: readonly string[];
|
|
22
|
+
export declare function isSuiteCheckGate(spec: CheckGateSpec | string): boolean;
|
|
23
|
+
/**
|
|
24
|
+
* True when every suite gate appears after all non-suite gates (#3188).
|
|
25
|
+
* Empty lists and suite-only lists are valid; a non-suite after a suite is not.
|
|
26
|
+
*/
|
|
27
|
+
export declare function isFastBeforeSlowOrder(gates: readonly CheckGateSpec[]): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Framework-source check composition (#1713 / #3188).
|
|
30
|
+
*
|
|
31
|
+
* Order contract: cheap preflight gates first; `ts:check-lane` (lint+build+
|
|
32
|
+
* vitest coverage suite) last so a stale cache / orphan-active / branch miss
|
|
33
|
+
* fails in seconds without starting the suite.
|
|
34
|
+
*/
|
|
15
35
|
export declare const FRAMEWORK_CHECK_GATES: readonly CheckGateSpec[];
|
|
16
36
|
export declare const CONSUMER_CHECK_GATES: readonly CheckGateSpec[];
|
|
17
37
|
export declare function gatesForCheckTarget(target: string): readonly CheckGateSpec[];
|
package/dist/check/gate-lists.js
CHANGED
|
@@ -11,23 +11,59 @@ export function checkGateSpawnArgs(spec, taskfilePath) {
|
|
|
11
11
|
}
|
|
12
12
|
return [task, "--taskfile", taskfilePath];
|
|
13
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* Gates that own the long vitest+coverage (or equivalent) suite path.
|
|
16
|
+
* Shared `task check` composition runs every non-suite gate first so cheap
|
|
17
|
+
* failures never pay suite wall-clock (#3188). Release suite stamp/resume
|
|
18
|
+
* remains #3187 / release-scoped.
|
|
19
|
+
*/
|
|
20
|
+
export const SUITE_CHECK_GATE_IDS = ["ts:check-lane"];
|
|
21
|
+
export function isSuiteCheckGate(spec) {
|
|
22
|
+
const id = typeof spec === "string" ? spec : checkGateId(spec);
|
|
23
|
+
return SUITE_CHECK_GATE_IDS.includes(id);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* True when every suite gate appears after all non-suite gates (#3188).
|
|
27
|
+
* Empty lists and suite-only lists are valid; a non-suite after a suite is not.
|
|
28
|
+
*/
|
|
29
|
+
export function isFastBeforeSlowOrder(gates) {
|
|
30
|
+
let sawSuite = false;
|
|
31
|
+
for (const gate of gates) {
|
|
32
|
+
if (isSuiteCheckGate(gate)) {
|
|
33
|
+
sawSuite = true;
|
|
34
|
+
}
|
|
35
|
+
else if (sawSuite) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Framework-source check composition (#1713 / #3188).
|
|
43
|
+
*
|
|
44
|
+
* Order contract: cheap preflight gates first; `ts:check-lane` (lint+build+
|
|
45
|
+
* vitest coverage suite) last so a stale cache / orphan-active / branch miss
|
|
46
|
+
* fails in seconds without starting the suite.
|
|
47
|
+
*/
|
|
14
48
|
export const FRAMEWORK_CHECK_GATES = [
|
|
15
|
-
|
|
49
|
+
// --- Fast preflight (seconds–few min) — #3188 ---
|
|
50
|
+
"verify:branch",
|
|
51
|
+
"verify:encoding",
|
|
52
|
+
"verify:cache-fresh",
|
|
53
|
+
"verify:orphan-active",
|
|
54
|
+
"verify:license-sync",
|
|
55
|
+
"verify:contract-drift",
|
|
16
56
|
"toolchain:check",
|
|
17
57
|
"verify:stubs",
|
|
18
58
|
"verify:links",
|
|
19
59
|
"verify:rule-ownership",
|
|
20
60
|
"verify:biome-config",
|
|
21
61
|
"verify:content-manifest",
|
|
22
|
-
"verify:license-sync",
|
|
23
62
|
"verify:skill-external-fetch-gate",
|
|
24
|
-
"verify:contract-drift",
|
|
25
63
|
"verify:cursor-tier1",
|
|
26
64
|
"verify:openclaw-tier1",
|
|
27
65
|
"verify:go-freeze",
|
|
28
66
|
"verify:bridge-drift",
|
|
29
|
-
"verify:branch",
|
|
30
|
-
"verify:encoding",
|
|
31
67
|
"verify:forward-coverage",
|
|
32
68
|
// #3145: test/source boundary + approved-scope provenance + consumer gate composition
|
|
33
69
|
"verify:test-boundary",
|
|
@@ -38,11 +74,9 @@ export const FRAMEWORK_CHECK_GATES = [
|
|
|
38
74
|
"verify:scm-boundary",
|
|
39
75
|
"verify:xbrief-drift",
|
|
40
76
|
"verify:no-task-runtime",
|
|
41
|
-
"verify:cache-fresh",
|
|
42
77
|
"verify:pack-drift",
|
|
43
78
|
// Public surface for Taskfile verify-wip-cap-framework-self-check (#1124 / #2791)
|
|
44
79
|
{ task: "verify:wip-cap", args: ["--allow-over-cap"] },
|
|
45
|
-
"verify:orphan-active",
|
|
46
80
|
"verify:agents-md-budget",
|
|
47
81
|
// Public surfaces for internal eval-relocation framework shims (#2791)
|
|
48
82
|
{ task: "verify:eval-health-relocation", args: ["--base-ref", "origin/master"] },
|
|
@@ -51,14 +85,17 @@ export const FRAMEWORK_CHECK_GATES = [
|
|
|
51
85
|
"codebase:validate-structure",
|
|
52
86
|
"verify:codebase-map-fresh",
|
|
53
87
|
"verify-strategy-output",
|
|
88
|
+
// --- Suite last: vitest + coverage via ts:check-lane (#3188) ---
|
|
89
|
+
"ts:check-lane",
|
|
54
90
|
];
|
|
55
91
|
export const CONSUMER_CHECK_GATES = [
|
|
56
|
-
|
|
57
|
-
"toolchain:check-consumer",
|
|
92
|
+
// Cheap lifecycle / policy first (no suite co-list today; keep fail-fast order)
|
|
58
93
|
"verify:branch",
|
|
59
94
|
"verify:cache-fresh",
|
|
60
95
|
"verify:wip-cap",
|
|
61
96
|
"verify:orphan-active",
|
|
97
|
+
"doctor",
|
|
98
|
+
"toolchain:check-consumer",
|
|
62
99
|
// #3145 enforcement trio (test placement, scope provenance, gate composition)
|
|
63
100
|
"verify:test-boundary",
|
|
64
101
|
"verify:scope-provenance",
|
package/dist/check/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { dispatchCachedTaskCheck } from "./cached-orchestrator.js";
|
|
2
2
|
export { CHECK_GRAPH_REQUIRED_NAMESPACES, CONSUMER_GATE_INTEGRITY_RECOVERY, type ConsumerGateIntegrityFinding, type ConsumerGateIntegrityResult, type ConsumerGateIntegritySeams, checkGraphOptionalIncludeViolations, evaluateConsumerGateIntegrity, formatConsumerGateIntegrityFailure, gateLocalName, gateNamespace, includeTaskfileRel, parseTaskfileIncludes, requiredNamespacesForGates, taskDefinedInTaskfileYaml, } from "./consumer-gate-integrity.js";
|
|
3
|
-
export { type CheckGateSpec, CONSUMER_CHECK_GATES, checkGateId, checkGateSpawnArgs, FRAMEWORK_CHECK_GATES, gatesForCheckTarget, } from "./gate-lists.js";
|
|
3
|
+
export { type CheckGateSpec, CONSUMER_CHECK_GATES, checkGateId, checkGateSpawnArgs, FRAMEWORK_CHECK_GATES, gatesForCheckTarget, isFastBeforeSlowOrder, isSuiteCheckGate, SUITE_CHECK_GATE_IDS, } from "./gate-lists.js";
|
|
4
4
|
export type { CheckOrchestratorOptions, CheckOrchestratorSeams } from "./orchestrator.js";
|
|
5
5
|
export { dispatchTaskCheck, isFrameworkRepoRoot, isFrameworkSourceContext, resolveCheckTarget, } from "./orchestrator.js";
|
|
6
6
|
export { detectTestRunner, type RunnerDetectResult, runnerDetectionTable, type TestRunnerKind, } from "./runner-detect.js";
|