instar 1.3.1153 → 1.3.1155
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/data/standards-guard-index.json +1 -1
- package/dist/data/standards-guard-index.meta.json +2 -2
- package/dist/data/standards-registry.meta.json +1 -1
- package/package.json +1 -1
- package/scripts/lint-no-direct-destructive.js +176 -7
- package/scripts/lint-sync-subprocess-chokepoint.js +59 -1
- package/scripts/pre-push-gate.js +7 -1
- package/src/data/builtin-manifest.json +2 -2
- package/src/data/standards-guard-index.json +1 -1
- package/src/data/standards-guard-index.meta.json +2 -2
- package/src/data/standards-registry.meta.json +1 -1
- package/upgrades/1.3.1155.md +132 -0
- package/upgrades/side-effects/destructive-lint-local-bindings.md +193 -0
- package/upgrades/side-effects/sync-spawn-alias-resolution.md +166 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"generatedFrom": "source-tree",
|
|
4
4
|
"registrySha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
|
|
5
|
-
"packageVersion": "1.3.
|
|
5
|
+
"packageVersion": "1.3.1155",
|
|
6
6
|
"guards": [
|
|
7
7
|
{
|
|
8
8
|
"ref": "docs/audits/phase-b/f10-triage.md",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"sha256": "
|
|
2
|
+
"sha256": "4768f67585d114ee7064f8a97895cdf66f96074719ad149a661bcee76e79625b",
|
|
3
3
|
"registrySha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
|
|
4
|
-
"packageVersion": "1.3.
|
|
4
|
+
"packageVersion": "1.3.1155"
|
|
5
5
|
}
|
package/package.json
CHANGED
|
@@ -23,6 +23,9 @@
|
|
|
23
23
|
* Exit codes:
|
|
24
24
|
* 0 — no violations.
|
|
25
25
|
* 1 — at least one violation.
|
|
26
|
+
* 2 — could not inspect: files were scanned and NONE parsed (an environment
|
|
27
|
+
* problem, e.g. a missing `typescript`). Distinct from 1 on purpose —
|
|
28
|
+
* "I could not look" is not "I looked and found nothing".
|
|
26
29
|
*
|
|
27
30
|
* Usage:
|
|
28
31
|
* node scripts/lint-no-direct-destructive.js # full repo
|
|
@@ -39,6 +42,14 @@ import { fileURLToPath } from 'node:url';
|
|
|
39
42
|
// only when a TS file actually needs parsing.
|
|
40
43
|
let _ts = null;
|
|
41
44
|
function ts() {
|
|
45
|
+
// Test hook for the parsed-nothing refusal below. It can ONLY force the
|
|
46
|
+
// fail-closed path (every file fails to parse → the run refuses to report
|
|
47
|
+
// clean); there is no flag here that can make this lint pass something it
|
|
48
|
+
// would otherwise flag. Without it the refusal would be untested, which is
|
|
49
|
+
// the failure mode this whole guard exists to prevent.
|
|
50
|
+
if (process.env.INSTAR_LINT_FORCE_PARSE_FAILURE === '1') {
|
|
51
|
+
throw new Error('forced parse failure (INSTAR_LINT_FORCE_PARSE_FAILURE=1)');
|
|
52
|
+
}
|
|
42
53
|
if (_ts) return _ts;
|
|
43
54
|
_ts = require('typescript');
|
|
44
55
|
return _ts;
|
|
@@ -304,7 +315,15 @@ function lintTsFile(file, text) {
|
|
|
304
315
|
const T = ts();
|
|
305
316
|
const sf = T.createSourceFile(file, text, T.ScriptTarget.Latest, true);
|
|
306
317
|
const ctx = { text };
|
|
307
|
-
|
|
318
|
+
// Declaration collection runs BEFORE reporting (see the collect/report loop at
|
|
319
|
+
// the foot of this function), so a binding declared after the function that
|
|
320
|
+
// uses it is still resolved. While collecting, findings are suppressed —
|
|
321
|
+
// otherwise every violation would be reported once per collection pass.
|
|
322
|
+
let collecting = true;
|
|
323
|
+
const r = (f, l, c, m) => {
|
|
324
|
+
if (collecting) return;
|
|
325
|
+
report(f, l, c, m, ctx);
|
|
326
|
+
};
|
|
308
327
|
|
|
309
328
|
/** module name → local identifier (default + namespace + named) */
|
|
310
329
|
const childProcessIdentifiers = new Set();
|
|
@@ -319,6 +338,34 @@ function lintTsFile(file, text) {
|
|
|
319
338
|
return { line: lc.line + 1, col: lc.character + 1 };
|
|
320
339
|
}
|
|
321
340
|
|
|
341
|
+
/**
|
|
342
|
+
* Strip syntax that changes nothing at runtime but hides an identifier from a
|
|
343
|
+
* structural check — parentheses, `as` assertions, `<T>` assertions and `!`.
|
|
344
|
+
* `(fs as any)['rmSync'](p)` performs exactly the same delete as `fs.rmSync(p)`;
|
|
345
|
+
* without this it reached the computed-access branch as a ParenthesizedExpression
|
|
346
|
+
* and the `isIdentifier` test failed, so the call was invisible.
|
|
347
|
+
*/
|
|
348
|
+
/**
|
|
349
|
+
* Idempotent: collection now runs more than once (see the collect/report loop
|
|
350
|
+
* below), and an array push is the one collector here that is not naturally
|
|
351
|
+
* idempotent — repeated passes would duplicate entries and keep the binding
|
|
352
|
+
* count growing, so the fixpoint would never be reached.
|
|
353
|
+
*/
|
|
354
|
+
function addSimpleGitImport(localName) {
|
|
355
|
+
if (!simpleGitImports.some((s2) => s2.localName === localName)) {
|
|
356
|
+
simpleGitImports.push({ localName });
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function unwrap(node) {
|
|
361
|
+
let n = node;
|
|
362
|
+
while (n && (T.isParenthesizedExpression(n) || T.isAsExpression(n)
|
|
363
|
+
|| T.isTypeAssertionExpression?.(n) || T.isNonNullExpression(n))) {
|
|
364
|
+
n = n.expression;
|
|
365
|
+
}
|
|
366
|
+
return n;
|
|
367
|
+
}
|
|
368
|
+
|
|
322
369
|
function visit(node) {
|
|
323
370
|
// ── Imports ────────────────────────────────────────────────
|
|
324
371
|
if (T.isImportDeclaration(node) && node.moduleSpecifier && T.isStringLiteral(node.moduleSpecifier)) {
|
|
@@ -342,7 +389,7 @@ function lintTsFile(file, text) {
|
|
|
342
389
|
} else if (FS_MODULE_NAMES.has(mod) && DESTRUCTIVE_FS_NAMES.has(importedName)) {
|
|
343
390
|
fsNamedDestructiveImports.set(localName, importedName);
|
|
344
391
|
} else if (mod === 'simple-git' && importedName === 'simpleGit') {
|
|
345
|
-
|
|
392
|
+
addSimpleGitImport(localName);
|
|
346
393
|
}
|
|
347
394
|
}
|
|
348
395
|
}
|
|
@@ -355,7 +402,7 @@ function lintTsFile(file, text) {
|
|
|
355
402
|
} else if (FS_MODULE_NAMES.has(mod)) {
|
|
356
403
|
fsNamespaceIdentifiers.add(localName);
|
|
357
404
|
} else if (mod === 'simple-git') {
|
|
358
|
-
|
|
405
|
+
addSimpleGitImport(localName);
|
|
359
406
|
}
|
|
360
407
|
}
|
|
361
408
|
}
|
|
@@ -376,7 +423,7 @@ function lintTsFile(file, text) {
|
|
|
376
423
|
fsNamespaceIdentifiers.add(localName);
|
|
377
424
|
requireBindings.set(localName, mod);
|
|
378
425
|
} else if (mod === 'simple-git') {
|
|
379
|
-
|
|
426
|
+
addSimpleGitImport(localName);
|
|
380
427
|
}
|
|
381
428
|
}
|
|
382
429
|
// Destructured: const { execFileSync } = require('child_process')
|
|
@@ -392,7 +439,7 @@ function lintTsFile(file, text) {
|
|
|
392
439
|
} else if (FS_MODULE_NAMES.has(mod) && DESTRUCTIVE_FS_NAMES.has(importedName)) {
|
|
393
440
|
fsNamedDestructiveImports.set(localName, importedName);
|
|
394
441
|
} else if (mod === 'simple-git' && importedName === 'simpleGit') {
|
|
395
|
-
|
|
442
|
+
addSimpleGitImport(localName);
|
|
396
443
|
}
|
|
397
444
|
}
|
|
398
445
|
}
|
|
@@ -441,6 +488,83 @@ function lintTsFile(file, text) {
|
|
|
441
488
|
}
|
|
442
489
|
}
|
|
443
490
|
|
|
491
|
+
// ── Local re-bindings of a banned namespace or function ────
|
|
492
|
+
// The sets above were populated ONLY from imports/requires, so one line of
|
|
493
|
+
// ordinary tidying disabled the funnel for a whole file:
|
|
494
|
+
//
|
|
495
|
+
// const fsp = fs.promises; await fsp.rm(p, { recursive: true });
|
|
496
|
+
//
|
|
497
|
+
// Aliasing `fs.promises` to a short name is idiomatic JavaScript, not an
|
|
498
|
+
// evasion — and `fs.promises.rm` written out IS caught, so the difference
|
|
499
|
+
// between flagged and invisible was a variable. Resolving the binding feeds
|
|
500
|
+
// the EXISTING checks below rather than adding a parallel rule.
|
|
501
|
+
if (T.isVariableDeclaration(node) && node.initializer) {
|
|
502
|
+
const init = unwrap(node.initializer);
|
|
503
|
+
|
|
504
|
+
// `const X = <expr>` where X is a plain identifier.
|
|
505
|
+
if (T.isIdentifier(node.name)) {
|
|
506
|
+
const local = node.name.text;
|
|
507
|
+
|
|
508
|
+
// Whole-namespace alias: `const f = fs` / `const fsp = fs.promises`.
|
|
509
|
+
if (T.isIdentifier(init) && fsNamespaceIdentifiers.has(init.text)) {
|
|
510
|
+
fsNamespaceIdentifiers.add(local);
|
|
511
|
+
} else if (T.isIdentifier(init) && childProcessIdentifiers.has(init.text)) {
|
|
512
|
+
childProcessIdentifiers.add(local);
|
|
513
|
+
} else if (T.isPropertyAccessExpression(init)) {
|
|
514
|
+
const base = unwrap(init.expression);
|
|
515
|
+
const member = init.name.text;
|
|
516
|
+
|
|
517
|
+
// `const fsp = fs.promises` — still the fs namespace, so the member
|
|
518
|
+
// checks below apply to it unchanged.
|
|
519
|
+
if (T.isIdentifier(base) && fsNamespaceIdentifiers.has(base.text) && member === 'promises') {
|
|
520
|
+
fsNamespaceIdentifiers.add(local);
|
|
521
|
+
} else if (T.isIdentifier(base) && fsNamespaceIdentifiers.has(base.text)
|
|
522
|
+
&& DESTRUCTIVE_FS_NAMES.has(member)) {
|
|
523
|
+
// `const del = fs.rmSync`
|
|
524
|
+
fsNamedDestructiveImports.set(local, member);
|
|
525
|
+
} else if (T.isIdentifier(base) && childProcessIdentifiers.has(base.text)
|
|
526
|
+
&& CHILD_PROCESS_FNS.has(member)) {
|
|
527
|
+
// `const ex = cp.execFileSync`
|
|
528
|
+
childProcessNamedImports.set(local, member);
|
|
529
|
+
} else if (T.isPropertyAccessExpression(base) && T.isIdentifier(unwrap(base.expression))
|
|
530
|
+
&& fsNamespaceIdentifiers.has(unwrap(base.expression).text)
|
|
531
|
+
&& base.name.text === 'promises'
|
|
532
|
+
&& DESTRUCTIVE_FS_NAMES.has(member)) {
|
|
533
|
+
// `const del = fs.promises.rm`
|
|
534
|
+
fsNamedDestructiveImports.set(local, member);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// `const { rmSync, unlink: del } = fs` / `... = fs.promises`
|
|
540
|
+
if (T.isObjectBindingPattern(node.name)) {
|
|
541
|
+
const init2 = unwrap(node.initializer);
|
|
542
|
+
let isFsSource = false;
|
|
543
|
+
let isCpSource = false;
|
|
544
|
+
if (T.isIdentifier(init2)) {
|
|
545
|
+
isFsSource = fsNamespaceIdentifiers.has(init2.text);
|
|
546
|
+
isCpSource = childProcessIdentifiers.has(init2.text);
|
|
547
|
+
} else if (T.isPropertyAccessExpression(init2) && init2.name.text === 'promises') {
|
|
548
|
+
const base = unwrap(init2.expression);
|
|
549
|
+
isFsSource = T.isIdentifier(base) && fsNamespaceIdentifiers.has(base.text);
|
|
550
|
+
}
|
|
551
|
+
if (isFsSource || isCpSource) {
|
|
552
|
+
for (const el of node.name.elements) {
|
|
553
|
+
if (!T.isBindingElement(el) || !T.isIdentifier(el.name)) continue;
|
|
554
|
+
const original = el.propertyName && T.isIdentifier(el.propertyName)
|
|
555
|
+
? el.propertyName.text
|
|
556
|
+
: el.name.text;
|
|
557
|
+
const local = el.name.text;
|
|
558
|
+
if (isFsSource && DESTRUCTIVE_FS_NAMES.has(original)) {
|
|
559
|
+
fsNamedDestructiveImports.set(local, original);
|
|
560
|
+
} else if (isCpSource && CHILD_PROCESS_FNS.has(original)) {
|
|
561
|
+
childProcessNamedImports.set(local, original);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
444
568
|
// ── Member call on namespace identifier ────────────────────
|
|
445
569
|
if (T.isCallExpression(node) && T.isPropertyAccessExpression(node.expression)) {
|
|
446
570
|
const obj = node.expression.expression;
|
|
@@ -479,7 +603,7 @@ function lintTsFile(file, text) {
|
|
|
479
603
|
|
|
480
604
|
// ── Dynamic / computed access: fs['rm' + 'Sync'](...) ──────
|
|
481
605
|
if (T.isCallExpression(node) && T.isElementAccessExpression(node.expression)) {
|
|
482
|
-
const obj = node.expression.expression;
|
|
606
|
+
const obj = unwrap(node.expression.expression);
|
|
483
607
|
const arg = node.expression.argumentExpression;
|
|
484
608
|
if (T.isIdentifier(obj) && fsNamespaceIdentifiers.has(obj.text)) {
|
|
485
609
|
// Any computed member access on the fs namespace is suspicious;
|
|
@@ -513,6 +637,19 @@ function lintTsFile(file, text) {
|
|
|
513
637
|
return false;
|
|
514
638
|
}
|
|
515
639
|
|
|
640
|
+
// Collect, then report. Collection repeats until the binding sets stop
|
|
641
|
+
// growing, so a chain (`const a = fs; const b = a.promises`) resolves however
|
|
642
|
+
// it is ordered in the file, and a binding declared BELOW the function that
|
|
643
|
+
// uses it is still known. Bounded so a pathological file cannot spin.
|
|
644
|
+
const bindingCount = () =>
|
|
645
|
+
fsNamespaceIdentifiers.size + fsNamedDestructiveImports.size
|
|
646
|
+
+ childProcessIdentifiers.size + childProcessNamedImports.size + simpleGitImports.length;
|
|
647
|
+
for (let pass = 0; pass < 5; pass += 1) {
|
|
648
|
+
const before = bindingCount();
|
|
649
|
+
visit(sf);
|
|
650
|
+
if (bindingCount() === before) break;
|
|
651
|
+
}
|
|
652
|
+
collecting = false;
|
|
516
653
|
visit(sf);
|
|
517
654
|
}
|
|
518
655
|
|
|
@@ -661,6 +798,9 @@ function main() {
|
|
|
661
798
|
files = gatherAll();
|
|
662
799
|
}
|
|
663
800
|
|
|
801
|
+
// Scan-coverage counters: a run that parsed nothing inspected nothing.
|
|
802
|
+
let astAttempted = 0;
|
|
803
|
+
let astParsed = 0;
|
|
664
804
|
for (const file of files) {
|
|
665
805
|
let text;
|
|
666
806
|
try {
|
|
@@ -685,14 +825,43 @@ function main() {
|
|
|
685
825
|
if (hasAllowComment(text)) continue;
|
|
686
826
|
|
|
687
827
|
// AST lint for ts/js/mjs/cjs
|
|
828
|
+
astAttempted += 1;
|
|
688
829
|
try {
|
|
689
830
|
lintTsFile(file, text);
|
|
831
|
+
astParsed += 1;
|
|
690
832
|
} catch (err) {
|
|
691
|
-
// Parse failure → emit a soft warning, not a violation.
|
|
833
|
+
// Parse failure → emit a soft warning, not a violation. Deliberate: one
|
|
834
|
+
// unparseable file should not fail a build over syntax this parser does
|
|
835
|
+
// not accept. The total-failure case below is a different situation.
|
|
692
836
|
process.stderr.write(`[lint-no-direct-destructive] failed to parse ${rel}: ${err.message}\n`);
|
|
693
837
|
}
|
|
694
838
|
}
|
|
695
839
|
|
|
840
|
+
// A run in which NOTHING parsed did not inspect anything, and "no violations
|
|
841
|
+
// found" would be a statement about a scan that never happened. Measured live
|
|
842
|
+
// on 2026-08-15: in a checkout without node_modules the `typescript` require
|
|
843
|
+
// fails for every file, so this guard against unaudited deletes reported
|
|
844
|
+
// clean and exited 0, with only stderr lines a CI log buries.
|
|
845
|
+
//
|
|
846
|
+
// This is deliberately the TOTAL-failure case only, so it cannot fail a build
|
|
847
|
+
// over one file the parser dislikes: in any working checkout, files parse.
|
|
848
|
+
if (astAttempted > 0 && astParsed === 0) {
|
|
849
|
+
process.stderr.write('\n');
|
|
850
|
+
process.stderr.write(
|
|
851
|
+
`[lint-no-direct-destructive] COULD NOT INSPECT — ${astAttempted} file(s) were scanned `
|
|
852
|
+
+ 'and NONE could be parsed, so nothing was actually inspected. This is an environment '
|
|
853
|
+
+ "problem (most often a missing `typescript` dependency — run `npm install`), not a clean "
|
|
854
|
+
+ 'tree. Reporting success here would silently disable the destructive-operation funnel.\n'
|
|
855
|
+
);
|
|
856
|
+
// Exit 2, NOT 1. "I could not inspect" and "I found violations" are
|
|
857
|
+
// different facts and callers act on them differently: pre-push-gate.js
|
|
858
|
+
// already treats a lint that FAILED TO RUN as a warning and a lint that
|
|
859
|
+
// found VIOLATIONS as a push-blocking error. Returning 1 here reported an
|
|
860
|
+
// uninstalled scratch tree as violations — which is how this was caught,
|
|
861
|
+
// by three pre-push-gate tests going red in CI.
|
|
862
|
+
return 2;
|
|
863
|
+
}
|
|
864
|
+
|
|
696
865
|
if (violations.length === 0) {
|
|
697
866
|
return 0;
|
|
698
867
|
}
|
|
@@ -78,6 +78,61 @@ const ALLOW = /lint-allow-sync-spawn:/;
|
|
|
78
78
|
const FUNNELED = /\bwithSyncOp\s*\(/;
|
|
79
79
|
|
|
80
80
|
const inScanDir = (p) => SCAN_DIRS.some((d) => p === d || p.startsWith(d + '/'));
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Names this file has bound to a raw sync spawn, so `ex(...)` is seen the same
|
|
84
|
+
* as `execFileSync(...)`. Two forms, both ordinary code rather than evasions:
|
|
85
|
+
*
|
|
86
|
+
* import { execFileSync as run } from 'node:child_process'; // renamed import
|
|
87
|
+
* const ex = execFileSync; // local alias
|
|
88
|
+
*
|
|
89
|
+
* Measured before this was added: BOTH walked past the check while the plain
|
|
90
|
+
* form was caught, and NEITHER appears anywhere in the scanned directories
|
|
91
|
+
* today — so this is a pure forward ratchet with no baseline to grow.
|
|
92
|
+
*
|
|
93
|
+
* DELIBERATELY NOT COLLECTED: `const ex = <something>.execFileSync`. The
|
|
94
|
+
* VIOLATION regex excludes a dot-prefixed name on purpose, and that exclusion
|
|
95
|
+
* was measured to be RIGHT: all 14 namespace-form occurrences in the scanned
|
|
96
|
+
* dirs are either calls through `SafeGitExecutor` (the audited git funnel, 13
|
|
97
|
+
* of them) or sit inside a generated hook script's template literal, which runs
|
|
98
|
+
* in its own process and cannot block this event loop. Widening to dot-prefixed
|
|
99
|
+
* names would flag the funnel itself. Recorded here so it is not re-litigated.
|
|
100
|
+
*/
|
|
101
|
+
function collectSyncSpawnAliases(content) {
|
|
102
|
+
const names = new Set();
|
|
103
|
+
const SPAWNS = '(?:spawnSync|execSync|execFileSync)';
|
|
104
|
+
|
|
105
|
+
// import { execFileSync as run } from 'node:child_process'
|
|
106
|
+
const importRe = new RegExp(
|
|
107
|
+
String.raw`import\s*\{([^}]*)\}\s*from\s*['"\`](?:node:)?child_process['"\`]`,
|
|
108
|
+
'g'
|
|
109
|
+
);
|
|
110
|
+
let m;
|
|
111
|
+
while ((m = importRe.exec(content)) !== null) {
|
|
112
|
+
for (const part of m[1].split(',')) {
|
|
113
|
+
const bit = part.trim().match(new RegExp(String.raw`^${SPAWNS}\s+as\s+([A-Za-z_$][\w$]*)$`));
|
|
114
|
+
if (bit) names.add(bit[1]);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// const ex = execFileSync; (bare RHS only — see the dot note above)
|
|
119
|
+
const aliasRe = new RegExp(
|
|
120
|
+
String.raw`\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*${SPAWNS}\s*(?=[;,\n)])`,
|
|
121
|
+
'g'
|
|
122
|
+
);
|
|
123
|
+
while ((m = aliasRe.exec(content)) !== null) names.add(m[1]);
|
|
124
|
+
|
|
125
|
+
return names;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** A call-shape matcher for the collected names, or null when there are none. */
|
|
129
|
+
function aliasCallRegex(names) {
|
|
130
|
+
if (!names.size) return null;
|
|
131
|
+
const alt = [...names].map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
|
|
132
|
+
// Same dot-exclusion as VIOLATION: `obj.ex(...)` is a method on something
|
|
133
|
+
// else, not the bound spawn.
|
|
134
|
+
return new RegExp(String.raw`(?<![.\w])(?:${alt})\s*\(`);
|
|
135
|
+
}
|
|
81
136
|
const normalize = (p) => p.split(path.sep).join('/');
|
|
82
137
|
|
|
83
138
|
function listFiles() {
|
|
@@ -133,12 +188,15 @@ function collectHits() {
|
|
|
133
188
|
continue;
|
|
134
189
|
}
|
|
135
190
|
const lines = content.split('\n');
|
|
191
|
+
// Names this file has bound to a raw sync spawn. Collected up-front so a
|
|
192
|
+
// binding that appears BELOW the function using it is still resolved.
|
|
193
|
+
const aliasRe = aliasCallRegex(collectSyncSpawnAliases(content));
|
|
136
194
|
const seenLineText = new Map(); // trimmed-line-text → occurrence count so far
|
|
137
195
|
for (let i = 0; i < lines.length; i++) {
|
|
138
196
|
const raw = lines[i];
|
|
139
197
|
const trimmed = raw.trimStart();
|
|
140
198
|
if (/^(\/\/|\*|\/\*)/.test(trimmed)) continue; // comment-only mention
|
|
141
|
-
if (!VIOLATION.test(raw)) continue;
|
|
199
|
+
if (!VIOLATION.test(raw) && !(aliasRe && aliasRe.test(raw))) continue;
|
|
142
200
|
// FUNNELED: a sync spawn wrapped by withSyncOp(...) on the same line is the required
|
|
143
201
|
// pattern (the marker sees it) — allowed unconditionally, never grandfathered/baselined.
|
|
144
202
|
if (FUNNELED.test(raw)) continue;
|
package/scripts/pre-push-gate.js
CHANGED
|
@@ -420,7 +420,13 @@ try {
|
|
|
420
420
|
[path.join(ROOT, 'scripts/lint-no-direct-destructive.js')],
|
|
421
421
|
{ cwd: ROOT, stdio: ['ignore', 'inherit', 'inherit'] },
|
|
422
422
|
);
|
|
423
|
-
if (result.status
|
|
423
|
+
if (result.status === 2) {
|
|
424
|
+
// Exit 2 = the lint could not inspect anything (an environment problem such
|
|
425
|
+
// as a missing `typescript`), which is NOT a violation. The gate already
|
|
426
|
+
// treats a lint that failed to run as a warning; this is the same fact
|
|
427
|
+
// arriving through an exit code instead of a thrown error.
|
|
428
|
+
warnings.push('lint-no-direct-destructive could not inspect this tree (see output above)');
|
|
429
|
+
} else if (result.status !== 0) {
|
|
424
430
|
errors.push('lint-no-direct-destructive: violations detected (see output above)');
|
|
425
431
|
}
|
|
426
432
|
} catch (err) {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "./builtin-manifest.schema.json",
|
|
3
3
|
"schemaVersion": 1,
|
|
4
|
-
"generatedAt": "2026-08-
|
|
5
|
-
"instarVersion": "1.3.
|
|
4
|
+
"generatedAt": "2026-08-15T02:10:19.346Z",
|
|
5
|
+
"instarVersion": "1.3.1155",
|
|
6
6
|
"entryCount": 202,
|
|
7
7
|
"entries": {
|
|
8
8
|
"hook:session-start": {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"generatedFrom": "source-tree",
|
|
4
4
|
"registrySha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
|
|
5
|
-
"packageVersion": "1.3.
|
|
5
|
+
"packageVersion": "1.3.1155",
|
|
6
6
|
"guards": [
|
|
7
7
|
{
|
|
8
8
|
"ref": "docs/audits/phase-b/f10-triage.md",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"sha256": "
|
|
2
|
+
"sha256": "4768f67585d114ee7064f8a97895cdf66f96074719ad149a661bcee76e79625b",
|
|
3
3
|
"registrySha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
|
|
4
|
-
"packageVersion": "1.3.
|
|
4
|
+
"packageVersion": "1.3.1155"
|
|
5
5
|
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# Upgrade Guide — vNEXT
|
|
2
|
+
|
|
3
|
+
<!-- assembled-by: assemble-next-md -->
|
|
4
|
+
<!-- bump: patch -->
|
|
5
|
+
|
|
6
|
+
## What Changed
|
|
7
|
+
|
|
8
|
+
`scripts/lint-no-direct-destructive.js` — the funnel guard that keeps every
|
|
9
|
+
destructive git/fs operation inside `SafeGitExecutor` / `SafeFsExecutor`, so each
|
|
10
|
+
delete carries an audit entry — now resolves local bindings before applying its
|
|
11
|
+
rules.
|
|
12
|
+
|
|
13
|
+
Its identifier sets were populated ONLY from imports and requires. Measured
|
|
14
|
+
against the shipped lint with a positive control (`fs.rmSync`) firing in the same
|
|
15
|
+
pass:
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
const fsp = fs.promises;
|
|
19
|
+
await fsp.rm(p, { recursive: true, force: true }); // exit 0 — EVADED
|
|
20
|
+
const del = fs.rmSync; del(p); // exit 0 — EVADED
|
|
21
|
+
const { rmSync: del } = fs; del(p); // exit 0 — EVADED
|
|
22
|
+
(fs as any)['rmSync'](p); // exit 0 — EVADED
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`fs.promises.rm` written out IS caught, so the difference between flagged and
|
|
26
|
+
invisible was a variable. Aliasing a namespace to a short name is idiomatic
|
|
27
|
+
JavaScript — the gap needed a tidy afternoon, not an attacker.
|
|
28
|
+
|
|
29
|
+
The lint AST-walks, so it already caught renamed imports (`import { rmSync as
|
|
30
|
+
nuke }`) where a regex check could not. The gap was narrower than "it doesn't
|
|
31
|
+
understand names": it understood names arriving from imports and nothing about
|
|
32
|
+
names a file creates itself.
|
|
33
|
+
|
|
34
|
+
Added: `unwrap()` (strips parens / `as` / `!` before identity tests),
|
|
35
|
+
local-binding collection for namespace aliases, function aliases and object
|
|
36
|
+
binding patterns, and a collect-then-report loop that repeats until the binding
|
|
37
|
+
sets stop growing — so resolution is order-independent and alias chains resolve.
|
|
38
|
+
|
|
39
|
+
**Second finding, hit live rather than reasoned about.** Running the lint in a
|
|
40
|
+
worktree with no `node_modules`, the `typescript` require failed for EVERY file
|
|
41
|
+
and the lint reported **clean, exit 0** — a guard against unaudited deletes
|
|
42
|
+
silently became a no-op, with only stderr lines a CI log buries. The per-file
|
|
43
|
+
soft-warning is deliberate and right; ONE file failing and EVERY file failing are
|
|
44
|
+
different situations. A run that scanned files and parsed none of them now
|
|
45
|
+
refuses to report clean and names the likely cause, exiting **2** rather than 1:
|
|
46
|
+
"I could not look" and "I looked and found nothing" are different facts, and
|
|
47
|
+
`pre-push-gate.js` already treats a lint that failed to run as a warning and one
|
|
48
|
+
that found violations as a push-blocking error. (The first version returned 1 and
|
|
49
|
+
turned three pre-push-gate tests red — the gate copies itself into a scratch
|
|
50
|
+
fixture with no dependencies, so my "no working checkout can reach it" claim was
|
|
51
|
+
wrong; a test fixture reaches it.)
|
|
52
|
+
|
|
53
|
+
**Declared open in the source:** cross-module names, runtime-assembled access,
|
|
54
|
+
and indirection through a container object (`const io = { del: fs.unlinkSync }`)
|
|
55
|
+
— measured, and left open because that is not an ordinary way to write a delete
|
|
56
|
+
and chasing it widens over-block risk on a build-failing lint.
|
|
57
|
+
|
|
58
|
+
`scripts/lint-sync-subprocess-chokepoint.js` — the forward ratchet that keeps raw
|
|
59
|
+
synchronous subprocess spawns out of the runtime hot path, so a blocked-but-alive
|
|
60
|
+
server is never mistaken for a dead one — now resolves bound names.
|
|
61
|
+
|
|
62
|
+
It matched the spawn NAME on the call line, so two ordinary forms walked past
|
|
63
|
+
while the plain call was caught. Measured with a positive control firing in the
|
|
64
|
+
same run:
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import { execFileSync as run } from 'node:child_process'; run(...); // exit 0 — EVADED
|
|
68
|
+
const ex = execFileSync; ex(...); // exit 0 — EVADED
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
A renamed import is not an evasion; it is how a name collision gets resolved.
|
|
72
|
+
|
|
73
|
+
**Neither form appears anywhere in the scanned directories today** (0 local
|
|
74
|
+
aliases, 0 renamed imports, against a control of 53 files carrying plain named
|
|
75
|
+
imports), so this is a pure forward ratchet: the frozen baseline does not grow
|
|
76
|
+
and nothing existing can break.
|
|
77
|
+
|
|
78
|
+
**Scope reversed by measurement.** `VIOLATION` also excludes a DOT-prefixed name,
|
|
79
|
+
and there are 14 namespace-form occurrences in the scanned dirs — "14 invisible
|
|
80
|
+
blocking spawns" would have been the headline. Counting what they *are*:
|
|
81
|
+
**13 are `SafeGitExecutor.execSync(`**, i.e. calls THROUGH the audited git funnel
|
|
82
|
+
(flagging them would report correct use of the funnel as a bypass of it), and the
|
|
83
|
+
**1 remaining sits inside a generated hook script's template literal**, which runs
|
|
84
|
+
in its own process and cannot block this event loop. All 14 exclusions are
|
|
85
|
+
correct; the dot-exclusion is left alone and pinned by two tests so it is not
|
|
86
|
+
"fixed" later.
|
|
87
|
+
|
|
88
|
+
Added `collectSyncSpawnAliases()` (renamed imports from `(node:)child_process`,
|
|
89
|
+
and bare local aliases) and `aliasCallRegex()` (carrying the same dot-exclusion as
|
|
90
|
+
the original rule). `VIOLATION`, `FUNNELED`, `ALLOW`, the baseline format and the
|
|
91
|
+
exit codes are unchanged.
|
|
92
|
+
|
|
93
|
+
## What to Tell Your User
|
|
94
|
+
|
|
95
|
+
None — internal change (no user-facing surface).
|
|
96
|
+
|
|
97
|
+
None — internal change (no user-facing surface).
|
|
98
|
+
|
|
99
|
+
## Summary of New Capabilities
|
|
100
|
+
|
|
101
|
+
None — internal change (no user-facing surface).
|
|
102
|
+
|
|
103
|
+
None — internal change (no user-facing surface).
|
|
104
|
+
|
|
105
|
+
## Evidence
|
|
106
|
+
|
|
107
|
+
- `tests/unit/destructive-lint-local-bindings.test.ts` — 16/16 green.
|
|
108
|
+
- **Negative control: 7 of 16 fail** against the shipped lint (all six defect
|
|
109
|
+
cases plus the parsed-nothing refusal). The other 9 pass both ways and are the
|
|
110
|
+
controls. Script restored byte-exact after the control.
|
|
111
|
+
- Five anti-over-block controls, because this lint fails builds: a
|
|
112
|
+
non-destructive call; a non-destructive method on an aliased namespace;
|
|
113
|
+
`mkdir` through that alias (creating is not deleting); an unrelated object
|
|
114
|
+
exposing the same names; an alias that is never called.
|
|
115
|
+
- Real tree: `exit 0` before AND after. Full `npm run lint` chain exit 0.
|
|
116
|
+
`tsc --noEmit` exit 0.
|
|
117
|
+
- The new test routes its own teardown through `SafeFsExecutor` rather than
|
|
118
|
+
taking an allowlist entry — the test that argues for the rule follows it.
|
|
119
|
+
|
|
120
|
+
- `tests/unit/sync-spawn-alias-resolution.test.ts` — 12/12 green.
|
|
121
|
+
- **Negative control: 4 of 12 fail** against the shipped lint (exactly the four
|
|
122
|
+
defect cases). The other 8 pass both ways and are the controls. Script restored
|
|
123
|
+
byte-exact after the control.
|
|
124
|
+
- Six anti-over-block controls, because this lint fails builds — the two that
|
|
125
|
+
matter most: an aliased spawn wrapped by `withSyncOp` is still NOT flagged (the
|
|
126
|
+
funnel is the required pattern; overriding it would punish the code the rule
|
|
127
|
+
exists to produce), and an aliased spawn carrying an allow-comment is still NOT
|
|
128
|
+
flagged.
|
|
129
|
+
- Real tree: `exit 0` before AND after. `tsc --noEmit` exit 0. Full `npm run lint`
|
|
130
|
+
chain exit 0.
|
|
131
|
+
- Declared open in the source: dot-prefixed names (measured correct), cross-module
|
|
132
|
+
aliases, and `const ex = <ns>.execFileSync`.
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# Side-Effects Review — destructive-op guard resolves local bindings
|
|
2
|
+
|
|
3
|
+
**Version / slug:** `destructive-lint-local-bindings`
|
|
4
|
+
**Date:** `2026-08-15`
|
|
5
|
+
**Author:** `echo`
|
|
6
|
+
**Second-pass reviewer:** `not required — Tier 1 (CI-only lint script, no runtime path). The rule and the funnel are unchanged; the check now resolves one more kind of name before applying the rules it already had, and refuses to report clean on a run that inspected nothing.`
|
|
7
|
+
|
|
8
|
+
## Summary of the change
|
|
9
|
+
|
|
10
|
+
`scripts/lint-no-direct-destructive.js` is the funnel guard for
|
|
11
|
+
`COMPREHENSIVE-DESTRUCTIVE-TOOL-CONTAINMENT-SPEC` — only `SafeGitExecutor` /
|
|
12
|
+
`SafeFsExecutor` may call destructive git/fs primitives directly, so that every
|
|
13
|
+
delete carries an audit entry.
|
|
14
|
+
|
|
15
|
+
It AST-walks, which already buys it real import resolution. But its identifier
|
|
16
|
+
sets were populated ONLY from imports and requires, never from local bindings.
|
|
17
|
+
Measured against the shipped lint with a positive control (a plain `fs.rmSync`)
|
|
18
|
+
firing in the same pass:
|
|
19
|
+
|
|
20
|
+
| form | shipped |
|
|
21
|
+
|---|---|
|
|
22
|
+
| `fs.rmSync(p)` — POSITIVE CONTROL | exit 1 (caught) |
|
|
23
|
+
| `import { rmSync }` / `import { rmSync as nuke }` | exit 1 (caught — better than any regex lint here) |
|
|
24
|
+
| `fs.promises.rm(p)` written out | exit 1 (caught) |
|
|
25
|
+
| **`const fsp = fs.promises; await fsp.rm(p, …)`** | **exit 0 — EVADES** |
|
|
26
|
+
| `const del = fs.rmSync; del(p)` | **exit 0 — EVADES** |
|
|
27
|
+
| `const { rmSync: del } = fs; del(p)` | **exit 0 — EVADES** |
|
|
28
|
+
| `(fs as any)['rmSync'](p)` | **exit 0 — EVADES** |
|
|
29
|
+
|
|
30
|
+
The first evasion is the one that matters. `fs.promises.rm` IS caught, so the
|
|
31
|
+
difference between flagged and invisible was a variable — and aliasing a
|
|
32
|
+
namespace to a short name is idiomatic JavaScript, not an evasion. The gap needed
|
|
33
|
+
a tidy afternoon, not an attacker.
|
|
34
|
+
|
|
35
|
+
Resolution now feeds the EXISTING checks rather than adding rules beside them.
|
|
36
|
+
|
|
37
|
+
## The second finding, hit live rather than reasoned about
|
|
38
|
+
|
|
39
|
+
Running the lint in a fresh worktree with no `node_modules`, the `typescript`
|
|
40
|
+
require failed for **every** file — and the lint reported **clean, exit 0**, with
|
|
41
|
+
only stderr lines a CI log buries. A guard against unaudited deletes silently
|
|
42
|
+
became a no-op.
|
|
43
|
+
|
|
44
|
+
The per-file soft-warning is deliberate and stated in the source ("Parse failure
|
|
45
|
+
→ emit a soft warning, not a violation"), and it is right: one file the parser
|
|
46
|
+
dislikes should not fail a build. But ONE file failing and EVERY file failing are
|
|
47
|
+
different situations — the second means nothing was inspected, and "no violations
|
|
48
|
+
found" is then a statement about a scan that never happened.
|
|
49
|
+
|
|
50
|
+
Added: a run that scanned files and parsed NONE of them refuses to report clean
|
|
51
|
+
and names the likely cause. Deliberately the total-failure case only, so it
|
|
52
|
+
cannot fail a build over one awkward file.
|
|
53
|
+
|
|
54
|
+
**It exits 2, not 1, and CI taught me that distinction.** My first version
|
|
55
|
+
returned 1 — the violation code — and three `pre-push-gate` tests went red,
|
|
56
|
+
because the gate copies itself into a scratch fixture with no `node_modules`,
|
|
57
|
+
runs this lint there, and (correctly) reads a non-zero exit as "violations
|
|
58
|
+
detected". My claim that "no working checkout can reach it" was wrong: a test
|
|
59
|
+
fixture reaches it. The gate ALREADY distinguishes a lint that failed to RUN
|
|
60
|
+
(warning) from one that found VIOLATIONS (push-blocking error) — I was sending an
|
|
61
|
+
environment problem down the violation channel. Now the lint exits **2** for
|
|
62
|
+
could-not-inspect and `pre-push-gate.js` maps 2 onto its existing warning path.
|
|
63
|
+
"I could not look" and "I looked and found nothing" are different facts.
|
|
64
|
+
|
|
65
|
+
## Decision-point inventory
|
|
66
|
+
|
|
67
|
+
- `unwrap(node)` — ADD. Strips parens / `as` / `<T>` / `!` before identity tests.
|
|
68
|
+
- Local-binding collection in `visit` — ADD. Namespace aliases, destructive-function
|
|
69
|
+
aliases, object-binding patterns, for both fs and child_process.
|
|
70
|
+
- `addSimpleGitImport(localName)` — ADD. Idempotent; collection now runs more than
|
|
71
|
+
once and an array push is the one collector that is not naturally idempotent.
|
|
72
|
+
- Collect-then-report loop — CHANGED. Collection repeats until the binding sets
|
|
73
|
+
stop growing (bounded at 5), then one reporting pass. Makes resolution
|
|
74
|
+
order-independent and resolves alias chains.
|
|
75
|
+
- Parsed-nothing refusal — ADD.
|
|
76
|
+
- `INSTAR_LINT_FORCE_PARSE_FAILURE` — ADD, test hook. It can ONLY force the
|
|
77
|
+
fail-closed path; there is no flag here that can make this lint pass something
|
|
78
|
+
it would otherwise flag.
|
|
79
|
+
- Exit code **2** — ADD, for could-not-inspect. 0 and 1 keep their meanings.
|
|
80
|
+
- `scripts/pre-push-gate.js` — CHANGED: maps exit 2 onto its EXISTING
|
|
81
|
+
failed-to-run warning path instead of the violations error path.
|
|
82
|
+
- `ALLOWLIST`, `DESTRUCTIVE_FS_NAMES`, `CHILD_PROCESS_FNS`, the violation
|
|
83
|
+
messages and the shell/package.json grep — UNCHANGED.
|
|
84
|
+
|
|
85
|
+
## 1. Over-block
|
|
86
|
+
|
|
87
|
+
**The dominant risk — this lint fails builds, and a noisy check gets switched
|
|
88
|
+
off, after which it protects nothing.** Five controls, each with a test, all
|
|
89
|
+
passing under BOTH the old and new behaviour:
|
|
90
|
+
|
|
91
|
+
- a non-destructive fs call is not flagged;
|
|
92
|
+
- a NON-destructive method on an aliased namespace is not flagged (the alias is
|
|
93
|
+
not the violation, the destructive method is);
|
|
94
|
+
- `mkdir` through that same alias is not flagged — creating is not deleting;
|
|
95
|
+
- an unrelated object exposing the same names is not flagged (resolution is
|
|
96
|
+
anchored to the fs/child_process namespace, never to a method name);
|
|
97
|
+
- an alias that is never called is not flagged.
|
|
98
|
+
|
|
99
|
+
**Real tree: exit 0 before AND after, zero violations.** Full `npm run lint`
|
|
100
|
+
chain exit 0.
|
|
101
|
+
|
|
102
|
+
The parsed-nothing refusal is scoped to `attempted > 0 && parsed === 0`.
|
|
103
|
+
**I first wrote here that "no working checkout can reach it" — that was wrong and
|
|
104
|
+
CI proved it.** The pre-push gate copies itself into a scratch fixture with no
|
|
105
|
+
`node_modules` and runs this lint there, so three of its tests went red. The
|
|
106
|
+
condition is reachable; what was wrong was reporting it through the VIOLATION
|
|
107
|
+
exit code. Exit 2 fixes the signal rather than narrowing the condition, and the
|
|
108
|
+
three tests pass with the refusal still firing.
|
|
109
|
+
|
|
110
|
+
## 2. Under-block
|
|
111
|
+
|
|
112
|
+
Stated in the source rather than implied:
|
|
113
|
+
|
|
114
|
+
- **Cross-module names** — a destructive function re-exported from another file
|
|
115
|
+
is not followed. Needs a whole-program symbol graph, not one file at a time.
|
|
116
|
+
- **Runtime-assembled access** — a name built from a variable, a call, or a
|
|
117
|
+
template.
|
|
118
|
+
- **Indirection through a container** — `const io = { del: fs.unlinkSync }; io.del(p)`
|
|
119
|
+
remains invisible. Measured and left open: unlike the namespace alias, that is
|
|
120
|
+
not an ordinary way to write a delete, and chasing it widens over-block risk on
|
|
121
|
+
a build-failing lint for a contrived shape.
|
|
122
|
+
|
|
123
|
+
## 3. Level-of-abstraction fit
|
|
124
|
+
|
|
125
|
+
Same layer as the existing check — TypeScript AST over one file, no type checker,
|
|
126
|
+
no new dependency. Local-binding resolution is the smallest addition that answers
|
|
127
|
+
the question the existing rules already ask ("is this call a destructive fs/git
|
|
128
|
+
primitive?") for names the file creates itself.
|
|
129
|
+
|
|
130
|
+
## 4. Signal vs authority compliance
|
|
131
|
+
|
|
132
|
+
A CI guard, not a runtime authority. It gained reach (more forms of the same
|
|
133
|
+
violation) and one fail-closed condition, but no new decision-making power over
|
|
134
|
+
agent behaviour. The funnel itself is untouched.
|
|
135
|
+
|
|
136
|
+
## 5. Interactions
|
|
137
|
+
|
|
138
|
+
- Already in the `lint` chain CI runs; membership verified explicitly rather than
|
|
139
|
+
inferred from a `package.json` reference. Chain exit 0 with this change.
|
|
140
|
+
- Collection now runs up to 5 times per file plus one reporting pass. Measured on
|
|
141
|
+
the real tree: no perceptible change in chain duration.
|
|
142
|
+
- **The new test routes its own teardown through `SafeFsExecutor` rather than
|
|
143
|
+
taking an allowlist entry** — the test that argues for the rule follows it. This
|
|
144
|
+
is the one behavioural cost: one audit entry per test run.
|
|
145
|
+
- No source module, route, config key, or state file touched.
|
|
146
|
+
|
|
147
|
+
## 6. External surfaces
|
|
148
|
+
|
|
149
|
+
None. Developer tooling. The Agent Awareness Standard does not apply.
|
|
150
|
+
|
|
151
|
+
## 7. Multi-machine posture (Cross-Machine Coherence)
|
|
152
|
+
|
|
153
|
+
**Machine-local by design, and correct.** A CI-time source scan: reads files in
|
|
154
|
+
one checkout, returns an exit code. No durable state, no user-facing notice, no
|
|
155
|
+
generated URL, no runtime decision — nothing to replicate, merge on read, or
|
|
156
|
+
strand on a topic transfer. Every machine runs it over its own checkout of the
|
|
157
|
+
same tracked source and reaches the same verdict; determinism comes from the
|
|
158
|
+
source tree, not from coordination.
|
|
159
|
+
|
|
160
|
+
One honest note: the parsed-nothing refusal makes the verdict depend on the
|
|
161
|
+
checkout being *installed*, not just present. That is the intended behaviour —
|
|
162
|
+
an uninstalled checkout cannot inspect anything, and saying so is the point.
|
|
163
|
+
|
|
164
|
+
## 8. Rollback cost
|
|
165
|
+
|
|
166
|
+
`git revert` of one script plus the added test file. No migration, no state, no
|
|
167
|
+
deployed artifact, no runtime impact.
|
|
168
|
+
|
|
169
|
+
## Conclusion
|
|
170
|
+
|
|
171
|
+
Ship. Four evasions closed on a safety funnel — one of them an ordinary tidying
|
|
172
|
+
pattern rather than an evasion — a run that inspects nothing can no longer report
|
|
173
|
+
clean, five anti-over-block controls added, and the real tree verified clean in
|
|
174
|
+
both directions.
|
|
175
|
+
|
|
176
|
+
## Evidence pointers
|
|
177
|
+
|
|
178
|
+
- `tests/unit/destructive-lint-local-bindings.test.ts` — **16/16 green**.
|
|
179
|
+
- **Negative control: 7 of 16 fail** against the shipped lint (all six defect
|
|
180
|
+
cases plus the parsed-nothing refusal). The other 9 pass **both ways** — which
|
|
181
|
+
is what makes them controls. Script restored **byte-exact** after the control
|
|
182
|
+
(sha match).
|
|
183
|
+
- Reproduced by hand FIRST with a positive control firing in the same run; the
|
|
184
|
+
control is what makes the EVADES verdicts mean anything. A first probe batch in
|
|
185
|
+
an uninstalled worktree returned exit 0 for *everything including the controls*
|
|
186
|
+
— uniform results across independent subjects indicted the instrument, which is
|
|
187
|
+
how the parsed-nothing finding was discovered at all.
|
|
188
|
+
- Real-tree verdict: exit 0 before and after. Full `npm run lint` chain exit 0.
|
|
189
|
+
- `tsc --noEmit` exit 0.
|
|
190
|
+
- Tier **1** declared: `classifyTier` reports riskFloor 1 with no safety-invariant
|
|
191
|
+
match (directional controls: `SessionReaper.ts` and `SecretStore.ts` both floor
|
|
192
|
+
2). The size heuristic suggests 2 on added LOC alone — stated openly, since the
|
|
193
|
+
tier I choose is the one that lets my own change through.
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# Side-Effects Review — sync-spawn ratchet resolves bound names
|
|
2
|
+
|
|
3
|
+
**Version / slug:** `sync-spawn-alias-resolution`
|
|
4
|
+
**Date:** `2026-08-15`
|
|
5
|
+
**Author:** `echo`
|
|
6
|
+
**Second-pass reviewer:** `not required — Tier 1 (CI-only lint script, no runtime path). The rule, the funnel, the allow-comment escape and the frozen baseline are all unchanged; the check now resolves two more ways of naming the same banned call.`
|
|
7
|
+
|
|
8
|
+
## Summary of the change
|
|
9
|
+
|
|
10
|
+
`scripts/lint-sync-subprocess-chokepoint.js` is the forward ratchet for tmux
|
|
11
|
+
event-loop resilience: a synchronous subprocess spawn blocks the single-threaded
|
|
12
|
+
event loop for the child's whole lifetime, so outside the `withSyncOp` marker
|
|
13
|
+
funnel a raw sync spawn is banned. The incident behind it — a blocked-but-alive
|
|
14
|
+
server that looked dead to its supervisor and was restarted for being busy.
|
|
15
|
+
|
|
16
|
+
It matched the spawn NAME on the call line. Measured against the shipped lint
|
|
17
|
+
with a positive control (plain `execFileSync(...)`) firing in the same run:
|
|
18
|
+
|
|
19
|
+
| form | shipped |
|
|
20
|
+
|---|---|
|
|
21
|
+
| `execFileSync('tmux', …)` — POSITIVE CONTROL | exit 1 (caught) |
|
|
22
|
+
| **`import { execFileSync as run } …; run(…)`** | **exit 0 — EVADES** |
|
|
23
|
+
| **`const ex = execFileSync; ex(…)`** | **exit 0 — EVADES** |
|
|
24
|
+
|
|
25
|
+
A renamed import is not an evasion; it is how a name collision gets resolved.
|
|
26
|
+
|
|
27
|
+
**Neither form appears anywhere in the scanned directories today** (measured: 0
|
|
28
|
+
local aliases, 0 renamed imports, against a control of 53 files carrying plain
|
|
29
|
+
named imports). So this is a pure forward ratchet — nothing is added to the
|
|
30
|
+
frozen baseline and nothing existing can break.
|
|
31
|
+
|
|
32
|
+
## The scope decision, which measurement reversed
|
|
33
|
+
|
|
34
|
+
`VIOLATION` also excludes a DOT-prefixed name, and my first read was that this
|
|
35
|
+
was the same class of hole. There are **14** namespace-form occurrences in the
|
|
36
|
+
scanned directories, and "14 invisible blocking spawns" would have been the
|
|
37
|
+
headline.
|
|
38
|
+
|
|
39
|
+
Counting what they *are* rather than how many:
|
|
40
|
+
|
|
41
|
+
- **13 are `SafeGitExecutor.execSync(`** — calls THROUGH the audited git funnel.
|
|
42
|
+
Flagging them would invert the rule, reporting correct use of the funnel as a
|
|
43
|
+
bypass of it.
|
|
44
|
+
- **1 is `childProcess.execFileSync(` inside `getStopGateRouterHook()`** — which
|
|
45
|
+
returns a template literal for a generated hook script. That text runs in its
|
|
46
|
+
own short-lived process and cannot block this event loop.
|
|
47
|
+
|
|
48
|
+
**All 14 exclusions are correct. The dot-exclusion is left alone**, and two tests
|
|
49
|
+
pin it so a future reader does not "fix" it and break the funnel. The header
|
|
50
|
+
records the measurement for the same reason.
|
|
51
|
+
|
|
52
|
+
**A probe of mine returned the flattering answer and was wrong.** To test whether
|
|
53
|
+
the 14th sat inside a template literal I counted unescaped backticks before its
|
|
54
|
+
line — in a 16,000-line file, where backticks inside strings and comments corrupt
|
|
55
|
+
the count. It reported "not inside a template", which supported the bigger
|
|
56
|
+
finding. Reading the enclosing function signature settled it in one line.
|
|
57
|
+
|
|
58
|
+
## Decision-point inventory
|
|
59
|
+
|
|
60
|
+
- `collectSyncSpawnAliases(content)` — ADD. Per-file: renamed imports from
|
|
61
|
+
`(node:)child_process`, and `const|let|var X = <bare spawn name>`.
|
|
62
|
+
- `aliasCallRegex(names)` — ADD. Call-shape matcher carrying the SAME
|
|
63
|
+
dot-exclusion as `VIOLATION`; returns null when there are no names.
|
|
64
|
+
- The per-file loop — CHANGED: `if (!VIOLATION.test(raw) && !(aliasRe && aliasRe.test(raw))) continue;`
|
|
65
|
+
- `VIOLATION`, `FUNNELED`, `ALLOW`, `SCAN_DIRS`, `EXTENSIONS`, the baseline
|
|
66
|
+
format, the baseline file and the exit codes — UNCHANGED.
|
|
67
|
+
- No runtime block/allow decision added or modified. CI-time only.
|
|
68
|
+
|
|
69
|
+
## 1. Over-block
|
|
70
|
+
|
|
71
|
+
The dominant risk — this lint fails builds. Six controls, each with a test, all
|
|
72
|
+
passing under BOTH old and new behaviour:
|
|
73
|
+
|
|
74
|
+
- **an aliased spawn wrapped by `withSyncOp` is not flagged.** The most important
|
|
75
|
+
one: the funnel is the REQUIRED pattern, and if resolution overrode it the fix
|
|
76
|
+
would punish exactly the code the rule exists to produce.
|
|
77
|
+
- **an aliased spawn carrying `lint-allow-sync-spawn:` is not flagged** — the
|
|
78
|
+
existing escape for genuinely pre-runtime calls still works.
|
|
79
|
+
- an unrelated identifier that merely shares the name is not flagged — only a
|
|
80
|
+
name actually bound to a spawn is collected.
|
|
81
|
+
- a method call on another object (`helper.ex(...)`) is not flagged — the alias
|
|
82
|
+
matcher carries the same dot-exclusion as the original rule.
|
|
83
|
+
- a file with no sync spawn is not flagged.
|
|
84
|
+
- the two dot-exclusion pins above (`SafeGitExecutor.execSync`, `cp.execFileSync`).
|
|
85
|
+
|
|
86
|
+
**Real tree: exit 0 before AND after.** Full `npm run lint` chain exit 0.
|
|
87
|
+
|
|
88
|
+
Residual over-block risk, stated: a very short alias (`run`, `ex`) shadowed later
|
|
89
|
+
in the same file by an unrelated binding of the same name would be flagged. Not
|
|
90
|
+
observed anywhere today, and the failure is loud and one line to fix, unlike the
|
|
91
|
+
silent miss it replaces.
|
|
92
|
+
|
|
93
|
+
## 2. Under-block
|
|
94
|
+
|
|
95
|
+
Stated in the source:
|
|
96
|
+
|
|
97
|
+
- **Dot-prefixed names** — deliberately excluded, measured correct (above).
|
|
98
|
+
- **Cross-module aliases** — a wrapper exported from another file.
|
|
99
|
+
- **`const ex = <ns>.execFileSync`** — not collected, because collecting it would
|
|
100
|
+
require resolving the namespace, which is the dot case.
|
|
101
|
+
- The header's pre-existing honesty stands: this is a static line regex and
|
|
102
|
+
cannot prove a flagged line is actually wrapped at runtime — that is the
|
|
103
|
+
marker unit tests' job.
|
|
104
|
+
|
|
105
|
+
## 3. Level-of-abstraction fit
|
|
106
|
+
|
|
107
|
+
Same layer as the existing check — line regex over file text, no AST, no new
|
|
108
|
+
dependency. Alias collection is the smallest addition that answers the question
|
|
109
|
+
the rule already asks ("is this line a raw sync spawn?") for names the file
|
|
110
|
+
creates itself.
|
|
111
|
+
|
|
112
|
+
## 4. Signal vs authority compliance
|
|
113
|
+
|
|
114
|
+
A CI ratchet, not a runtime authority. It gained reach over two more spellings of
|
|
115
|
+
a violation it already forbade, and no new decision-making power. The funnel, the
|
|
116
|
+
escape and the baseline are untouched.
|
|
117
|
+
|
|
118
|
+
## 5. Interactions
|
|
119
|
+
|
|
120
|
+
- Already in the `lint` chain CI runs; chain exit 0 with this change.
|
|
121
|
+
- The frozen baseline is untouched and does not grow — the newly-reachable forms
|
|
122
|
+
have zero existing instances.
|
|
123
|
+
- Alias collection is one extra regex pass per file; no perceptible change in
|
|
124
|
+
chain duration.
|
|
125
|
+
- No source module, route, config key, or state file touched.
|
|
126
|
+
|
|
127
|
+
## 6. External surfaces
|
|
128
|
+
|
|
129
|
+
None. Developer tooling. The Agent Awareness Standard does not apply.
|
|
130
|
+
|
|
131
|
+
## 7. Multi-machine posture (Cross-Machine Coherence)
|
|
132
|
+
|
|
133
|
+
**Machine-local by design, and correct.** A CI-time source scan: reads files in
|
|
134
|
+
one checkout, returns an exit code. No durable state, no user-facing notice, no
|
|
135
|
+
generated URL, no runtime decision — nothing to replicate, merge on read, or
|
|
136
|
+
strand on a topic transfer. Every machine runs it over its own checkout of the
|
|
137
|
+
same tracked source and reaches the same verdict; alias collection is explicitly
|
|
138
|
+
per-file, so it cannot depend on the rest of the checkout, let alone another
|
|
139
|
+
machine.
|
|
140
|
+
|
|
141
|
+
## 8. Rollback cost
|
|
142
|
+
|
|
143
|
+
`git revert` of one script plus the added test file. No migration, no state, no
|
|
144
|
+
deployed artifact, no runtime impact, no baseline change to undo.
|
|
145
|
+
|
|
146
|
+
## Conclusion
|
|
147
|
+
|
|
148
|
+
Ship. Two ordinary ways of naming a banned blocking call are now seen, the
|
|
149
|
+
existing funnel and escape still win over the new reach, the deliberate
|
|
150
|
+
dot-exclusion is measured-correct and pinned rather than widened, and the real
|
|
151
|
+
tree is verified clean in both directions.
|
|
152
|
+
|
|
153
|
+
## Evidence pointers
|
|
154
|
+
|
|
155
|
+
- `tests/unit/sync-spawn-alias-resolution.test.ts` — **12/12 green**.
|
|
156
|
+
- **Negative control: 4 of 12 fail** against the shipped lint (exactly the four
|
|
157
|
+
defect cases). The other 8 pass **both ways** — one positive control, two
|
|
158
|
+
escape-still-wins, three over-block, two dot-exclusion pins. Script restored
|
|
159
|
+
**byte-exact** after the control (sha match).
|
|
160
|
+
- Reproduced by hand FIRST with a positive control in the same run.
|
|
161
|
+
- Zero existing instances of either newly-reached form (control: 53 files with
|
|
162
|
+
plain named imports), so the frozen baseline does not grow.
|
|
163
|
+
- Real-tree verdict: exit 0 before and after. `tsc --noEmit` exit 0. Full chain
|
|
164
|
+
exit 0.
|
|
165
|
+
- Tier **1** declared: CI-only script, no runtime path, no authority, no
|
|
166
|
+
capability.
|