instar 1.3.1143 → 1.3.1144
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-unfunneled-headless-launch.js +331 -40
- 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.1144.md +32 -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.1144",
|
|
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": "a01cf44f29d7c2c5e2bc284dbcd18e27b5c4d6035f3fee7b8e8254e8a993cf2d",
|
|
3
3
|
"registrySha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
|
|
4
|
-
"packageVersion": "1.3.
|
|
4
|
+
"packageVersion": "1.3.1144"
|
|
5
5
|
}
|
package/package.json
CHANGED
|
@@ -14,23 +14,80 @@
|
|
|
14
14
|
*
|
|
15
15
|
* Rule: outside the allowlist below, no source file may reference
|
|
16
16
|
* `buildHeadlessLaunch` (import OR call — an import is the bypass's first
|
|
17
|
-
* commit, flag it at the door)
|
|
17
|
+
* commit, flag it at the door), NOR any name an allowlisted module hands out
|
|
18
|
+
* that resolves to it.
|
|
19
|
+
*
|
|
20
|
+
* ── Evasion resistance (2026-08-14) ──────────────────────────────────────
|
|
21
|
+
* A peer audit classed this check DEFEATABLE and SAFETY-FLOOR, with this
|
|
22
|
+
* bypass stated verbatim: "Export a wrapper or alias from an allowlisted
|
|
23
|
+
* module, then call makeHeadlessLaunch(...) elsewhere; the non-funnel launch
|
|
24
|
+
* path is real, but the name is gone."
|
|
25
|
+
*
|
|
26
|
+
* Both halves were reproduced against the shipped check before this change,
|
|
27
|
+
* with a positive control caught in the same run:
|
|
28
|
+
*
|
|
29
|
+
* // in src/core/frameworkSessionLaunch.ts — ALLOWLISTED, so free
|
|
30
|
+
* export const makeHeadlessLaunch = buildHeadlessLaunch;
|
|
31
|
+
* // anywhere else — shipped lint said "clean", exit 0
|
|
32
|
+
* import { makeHeadlessLaunch } from './frameworkSessionLaunch.js';
|
|
33
|
+
* makeHeadlessLaunch(fw, opts);
|
|
34
|
+
*
|
|
35
|
+
* import * as m from './frameworkSessionLaunch.js'; // computed + split literal
|
|
36
|
+
* const fn = m['buildHeadless' + 'Launch'];
|
|
37
|
+
* fn(fw, opts);
|
|
38
|
+
*
|
|
39
|
+
* The first was reproduced end-to-end in the real tree: a live non-funnel
|
|
40
|
+
* launch path existed while this script printed `clean`.
|
|
41
|
+
*
|
|
42
|
+
* The close has two parts. Locally, bindings resolve to a fixpoint (aliased
|
|
43
|
+
* import, `{ X: Alias }` destructure, `const C = X` re-binding, namespace
|
|
44
|
+
* member, computed access over a collapsed concatenation). Across modules,
|
|
45
|
+
* the CLOSED allowlist is parsed for names it hands out that resolve to the
|
|
46
|
+
* builder, and those names are guarded at any non-allowlisted importer.
|
|
47
|
+
*
|
|
48
|
+
* ── What this deliberately does NOT classify as an alias ─────────────────
|
|
49
|
+
* Only a DIRECT re-binding (`export const X = buildHeadlessLaunch`,
|
|
50
|
+
* `export { buildHeadlessLaunch as X }`) or a PASS-THROUGH wrapper (a single
|
|
51
|
+
* `return buildHeadlessLaunch(...)`) counts. An exported function that does
|
|
52
|
+
* real work around the builder is NOT an alias — that is precisely the shape
|
|
53
|
+
* of the funnel itself, so treating it as one would flag every caller of
|
|
54
|
+
* `SessionManager.spawnSession()`. This check blocks commits; a widening that
|
|
55
|
+
* flags correct code is worse than the hole, because a noisy check gets
|
|
56
|
+
* switched off. The boundary is drawn there on purpose.
|
|
57
|
+
*
|
|
58
|
+
* ── Residuals, named rather than left to be discovered ───────────────────
|
|
59
|
+
* - A NON-pass-through wrapper in an allowlisted module (two statements
|
|
60
|
+
* instead of one) is not an alias. This is the price of the rule above.
|
|
61
|
+
* - A computed member resolved at RUNTIME (`m[process.env.K]`) cannot be
|
|
62
|
+
* read statically.
|
|
63
|
+
* - Re-assignment after declaration (`let mk; mk = buildHeadlessLaunch;`)
|
|
64
|
+
* is caught at the ASSIGNMENT, not at the later call.
|
|
65
|
+
* - A consumer importing an alias through a BARREL is not itself checked —
|
|
66
|
+
* but the barrel is not allowlisted, so re-exporting the alias through it
|
|
67
|
+
* fails here first; the chain cannot be built without tripping this.
|
|
68
|
+
* All four are pinned by tests asserting exactly this, so the boundary is
|
|
69
|
+
* documented rather than assumed.
|
|
18
70
|
*
|
|
19
71
|
* Exit codes: 0 — clean; 1 — at least one violation.
|
|
20
72
|
*
|
|
21
73
|
* Usage:
|
|
22
74
|
* node scripts/lint-no-unfunneled-headless-launch.js # full repo
|
|
23
75
|
* node scripts/lint-no-unfunneled-headless-launch.js --staged # staged files
|
|
76
|
+
* node scripts/lint-no-unfunneled-headless-launch.js <file…> # explicit files (tests)
|
|
24
77
|
*/
|
|
25
78
|
|
|
26
79
|
import fs from 'node:fs';
|
|
27
80
|
import path from 'node:path';
|
|
28
81
|
import { execSync } from 'node:child_process';
|
|
29
82
|
import { fileURLToPath } from 'node:url';
|
|
83
|
+
import ts from 'typescript';
|
|
30
84
|
|
|
31
85
|
const __filename = fileURLToPath(import.meta.url);
|
|
32
86
|
const ROOT = path.resolve(path.dirname(__filename), '..');
|
|
33
87
|
|
|
88
|
+
/** The one name the funnel is built on. Every other guarded name derives from it. */
|
|
89
|
+
export const CANONICAL = 'buildHeadlessLaunch';
|
|
90
|
+
|
|
34
91
|
// ── Allowlist (closed). Adding an entry requires review of WHY the callsite
|
|
35
92
|
// cannot route through SessionManager.spawnSession() (where the
|
|
36
93
|
// subscription-path reroute lives), and how its post-June-15 billing is
|
|
@@ -51,11 +108,233 @@ const ALLOWLIST = new Set([
|
|
|
51
108
|
const SCAN_DIRS = ['src', 'scripts', 'templates'];
|
|
52
109
|
const EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.mjs', '.cjs', '.sh']);
|
|
53
110
|
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
111
|
+
const VIOLATION_MSG =
|
|
112
|
+
`direct ${CANONICAL} reference outside the subscription-path funnel. ` +
|
|
113
|
+
`Spawn through SessionManager.spawnSession() (which carries the June-15 reroute), ` +
|
|
114
|
+
`or add an allowlist entry here with a billing-accountability justification.`;
|
|
115
|
+
|
|
116
|
+
const aliasMsg = (name, from) =>
|
|
117
|
+
`'${name}' resolves to ${CANONICAL} (handed out by ${from}) — reaching the headless ` +
|
|
118
|
+
`builder under another name is the same bypass of the subscription-path funnel. ` +
|
|
119
|
+
`Spawn through SessionManager.spawnSession(), or add an allowlist entry here with a ` +
|
|
120
|
+
`billing-accountability justification.`;
|
|
121
|
+
|
|
122
|
+
/** Comment lines are documentation, not a bypass — code cannot call through one. */
|
|
123
|
+
export function isCommentLine(line) {
|
|
124
|
+
const t = line.trimStart();
|
|
125
|
+
return t.startsWith('//') || t.startsWith('*') || t.startsWith('/*') || t.startsWith('#');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Collapse simple adjacent string concatenation so `'A' + 'B'` reads as `AB`.
|
|
130
|
+
* Applied PER LINE so reported line numbers stay exact.
|
|
131
|
+
*/
|
|
132
|
+
export function collapseConcatenation(text) {
|
|
133
|
+
let out = text;
|
|
134
|
+
for (let i = 0; i < 5; i++) {
|
|
135
|
+
const next = out.replace(/(['"`])\s*\+\s*(['"`])/g, '');
|
|
136
|
+
if (next === out) break;
|
|
137
|
+
out = next;
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const basenameKey = (p) => path.basename(p).replace(/\.(ts|tsx|js|mjs|cjs)$/, '');
|
|
143
|
+
|
|
144
|
+
// ── Cross-module: what names does the closed allowlist hand out? ──────────
|
|
145
|
+
|
|
146
|
+
/** Is this expression the guarded builder itself (identifier or namespace member)? */
|
|
147
|
+
function denotesGuarded(node, known) {
|
|
148
|
+
if (!node) return false;
|
|
149
|
+
if (ts.isIdentifier(node)) return known.has(node.text);
|
|
150
|
+
if (ts.isPropertyAccessExpression(node)) return known.has(node.name.text);
|
|
151
|
+
if (ts.isElementAccessExpression(node)) {
|
|
152
|
+
const a = node.argumentExpression;
|
|
153
|
+
return !!a && ts.isStringLiteralLike(a) && known.has(a.text);
|
|
154
|
+
}
|
|
155
|
+
if (ts.isParenthesizedExpression(node)) return denotesGuarded(node.expression, known);
|
|
156
|
+
if (ts.isAsExpression(node) || ts.isTypeAssertionExpression?.(node)) {
|
|
157
|
+
return denotesGuarded(node.expression, known);
|
|
158
|
+
}
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** A body that is nothing but `return <guarded>(...)` — a pass-through wrapper. */
|
|
163
|
+
function isPassThroughBody(body, known) {
|
|
164
|
+
if (!body) return false;
|
|
165
|
+
if (ts.isCallExpression(body)) return denotesGuarded(body.expression, known); // arrow shorthand
|
|
166
|
+
if (!ts.isBlock(body) || body.statements.length !== 1) return false;
|
|
167
|
+
const only = body.statements[0];
|
|
168
|
+
if (!ts.isReturnStatement(only) || !only.expression) return false;
|
|
169
|
+
return ts.isCallExpression(only.expression) && denotesGuarded(only.expression.expression, known);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const isExported = (node) =>
|
|
173
|
+
!!node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Names each allowlisted module hands out that resolve to the builder.
|
|
177
|
+
*
|
|
178
|
+
* `sources` is [{ path, content }]. Returns Map<aliasName, sourcePath>, run to
|
|
179
|
+
* a fixpoint across files so an allowlisted module re-exporting ANOTHER
|
|
180
|
+
* allowlisted module's alias closes too.
|
|
181
|
+
*/
|
|
182
|
+
export function collectFunnelAliasExports(sources) {
|
|
183
|
+
const aliases = new Map();
|
|
184
|
+
for (let pass = 0; pass < 10; pass++) {
|
|
185
|
+
const before = aliases.size;
|
|
186
|
+
for (const { path: p, content } of sources) {
|
|
187
|
+
let sf;
|
|
188
|
+
try {
|
|
189
|
+
sf = ts.createSourceFile(p, content, ts.ScriptTarget.Latest, true);
|
|
190
|
+
} catch {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
// Names bound to the builder INSIDE this module, to a fixpoint.
|
|
194
|
+
const known = new Set([CANONICAL, ...aliases.keys()]);
|
|
195
|
+
for (let inner = 0; inner < 10; inner++) {
|
|
196
|
+
const size = known.size;
|
|
197
|
+
const seed = (n) => {
|
|
198
|
+
if (ts.isImportSpecifier(n) && known.has((n.propertyName ?? n.name).text)) {
|
|
199
|
+
known.add(n.name.text);
|
|
200
|
+
}
|
|
201
|
+
if (ts.isVariableDeclaration(n) && ts.isIdentifier(n.name) && denotesGuarded(n.initializer, known)) {
|
|
202
|
+
known.add(n.name.text);
|
|
203
|
+
}
|
|
204
|
+
ts.forEachChild(n, seed);
|
|
205
|
+
};
|
|
206
|
+
ts.forEachChild(sf, seed);
|
|
207
|
+
if (known.size === size) break;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const record = (name) => {
|
|
211
|
+
if (name && name !== CANONICAL && !aliases.has(name)) aliases.set(name, p);
|
|
212
|
+
};
|
|
213
|
+
const visit = (n) => {
|
|
214
|
+
// export const X = <guarded>; / export const X = (...) => <guarded>(...)
|
|
215
|
+
if (ts.isVariableStatement(n) && isExported(n)) {
|
|
216
|
+
for (const d of n.declarationList.declarations) {
|
|
217
|
+
if (!ts.isIdentifier(d.name) || !d.initializer) continue;
|
|
218
|
+
if (denotesGuarded(d.initializer, known)) record(d.name.text);
|
|
219
|
+
else if (
|
|
220
|
+
(ts.isArrowFunction(d.initializer) || ts.isFunctionExpression(d.initializer)) &&
|
|
221
|
+
isPassThroughBody(d.initializer.body, known)
|
|
222
|
+
) record(d.name.text);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
// export function X(...) { return <guarded>(...); }
|
|
226
|
+
if (ts.isFunctionDeclaration(n) && isExported(n) && n.name && isPassThroughBody(n.body, known)) {
|
|
227
|
+
record(n.name.text);
|
|
228
|
+
}
|
|
229
|
+
// export { A as B }; / export { A as B } from '...';
|
|
230
|
+
if (ts.isExportDeclaration(n) && n.exportClause && ts.isNamedExports(n.exportClause)) {
|
|
231
|
+
for (const el of n.exportClause.elements) {
|
|
232
|
+
if (known.has((el.propertyName ?? el.name).text)) record(el.name.text);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
ts.forEachChild(n, visit);
|
|
236
|
+
};
|
|
237
|
+
ts.forEachChild(sf, visit);
|
|
238
|
+
}
|
|
239
|
+
if (aliases.size === before) break;
|
|
240
|
+
}
|
|
241
|
+
return aliases;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── Per-file: local bindings, resolved to a fixpoint ─────────────────────
|
|
245
|
+
|
|
246
|
+
/** Named specifiers imported (or re-exported) in `content`, with their offsets. */
|
|
247
|
+
function importSpecifiers(content) {
|
|
248
|
+
const out = [];
|
|
249
|
+
const re = /(?:import|export)\s*(?:type\s+)?(?:[\w$]+\s*,\s*)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g;
|
|
250
|
+
for (const m of content.matchAll(re)) {
|
|
251
|
+
const body = m[1];
|
|
252
|
+
const bodyStart = m.index + m[0].indexOf('{') + 1;
|
|
253
|
+
for (const spec of body.split(',')) {
|
|
254
|
+
const t = spec.trim();
|
|
255
|
+
if (!t) continue;
|
|
256
|
+
const parts = t.replace(/^type\s+/, '').split(/\s+as\s+/);
|
|
257
|
+
const imported = parts[0].trim();
|
|
258
|
+
const local = (parts[1] ?? parts[0]).trim();
|
|
259
|
+
if (!/^[A-Za-z_$][\w$]*$/.test(imported)) continue;
|
|
260
|
+
out.push({ imported, local, from: m[2], offset: bodyStart + body.indexOf(t) });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return out;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const lineOf = (content, offset) => content.slice(0, offset).split('\n').length;
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Local names in `content` bound to something guarded, to a fixpoint.
|
|
270
|
+
*
|
|
271
|
+
* Seeds are the canonical name plus any allowlist-exported alias that is
|
|
272
|
+
* actually IMPORTED here from the module that hands it out — a locally
|
|
273
|
+
* DEFINED function of the same name is not absorbed, which is what keeps this
|
|
274
|
+
* from flagging unrelated code.
|
|
275
|
+
*/
|
|
276
|
+
export function collectLocalBindings(content, aliasExports = new Map()) {
|
|
277
|
+
const names = new Set([CANONICAL]);
|
|
278
|
+
const seededAliases = [];
|
|
279
|
+
for (const spec of importSpecifiers(content)) {
|
|
280
|
+
const owner = aliasExports.get(spec.imported);
|
|
281
|
+
if (owner && basenameKey(spec.from) === basenameKey(owner)) {
|
|
282
|
+
names.add(spec.local);
|
|
283
|
+
seededAliases.push({ ...spec, owner });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
// Local re-binding chains, incl. destructures, namespace members and
|
|
287
|
+
// computed access over a collapsed concatenation.
|
|
288
|
+
for (let pass = 0; pass < 10; pass++) {
|
|
289
|
+
const before = names.size;
|
|
290
|
+
for (const known of [...names]) {
|
|
291
|
+
const esc = known.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
292
|
+
const forms = [
|
|
293
|
+
// import { known as alias } / const { known: alias } = …
|
|
294
|
+
new RegExp(`\\b${esc}\\s*(?:as|:)\\s*([A-Za-z_$][\\w$]*)`, 'g'),
|
|
295
|
+
// const alias = known; / const alias = ns.known; / const alias = ns['known'];
|
|
296
|
+
new RegExp(`\\b(?:const|let|var)\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*(?:[\\w$.]*\\.)?${esc}\\s*[;,\\n)]`, 'g'),
|
|
297
|
+
new RegExp(`\\b(?:const|let|var)\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*[\\w$.]*\\[\\s*['"\`]${esc}['"\`]\\s*\\]`, 'g'),
|
|
298
|
+
];
|
|
299
|
+
for (const re of forms) {
|
|
300
|
+
for (const m of collapseConcatenation(content).matchAll(re)) names.add(m[1]);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (names.size === before) break;
|
|
304
|
+
}
|
|
305
|
+
return { names, seededAliases };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Violations in `content`, as { line, msg }. Exported so the rules can be
|
|
310
|
+
* driven with fixtures rather than only end-to-end over the tree.
|
|
311
|
+
*/
|
|
312
|
+
export function findHeadlessLaunchViolations(content, aliasExports = new Map()) {
|
|
313
|
+
const hits = [];
|
|
314
|
+
const { names, seededAliases } = collectLocalBindings(content, aliasExports);
|
|
315
|
+
// A guarded alias arriving by import is a violation AT THE DOOR, reported
|
|
316
|
+
// against the import even though the name itself is unremarkable.
|
|
317
|
+
const importLines = new Set();
|
|
318
|
+
for (const spec of seededAliases) {
|
|
319
|
+
const line = lineOf(content, spec.offset);
|
|
320
|
+
importLines.add(line);
|
|
321
|
+
hits.push({ line, msg: aliasMsg(spec.imported, path.basename(spec.owner)) });
|
|
322
|
+
}
|
|
323
|
+
const lines = content.split('\n');
|
|
324
|
+
for (let i = 0; i < lines.length; i++) {
|
|
325
|
+
if (isCommentLine(lines[i])) continue;
|
|
326
|
+
if (importLines.has(i + 1)) continue;
|
|
327
|
+
const line = collapseConcatenation(lines[i]);
|
|
328
|
+
for (const name of names) {
|
|
329
|
+
const esc = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
330
|
+
if (new RegExp(`\\b${esc}\\b`).test(line) || new RegExp(`['"\`]${esc}['"\`]`).test(line)) {
|
|
331
|
+
hits.push({ line: i + 1, msg: name === CANONICAL ? VIOLATION_MSG : aliasMsg(name, 'the funnel') });
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return hits.sort((a, b) => a.line - b.line);
|
|
337
|
+
}
|
|
59
338
|
|
|
60
339
|
function listFiles() {
|
|
61
340
|
const staged = process.argv.includes('--staged');
|
|
@@ -87,42 +366,54 @@ function listFiles() {
|
|
|
87
366
|
return files;
|
|
88
367
|
}
|
|
89
368
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
try {
|
|
100
|
-
content = fs.readFileSync(full, 'utf-8');
|
|
101
|
-
} catch {
|
|
102
|
-
continue;
|
|
103
|
-
}
|
|
104
|
-
const lines = content.split('\n');
|
|
105
|
-
for (let i = 0; i < lines.length; i++) {
|
|
106
|
-
// Comment-only mentions are documentation, not a bypass — code can't
|
|
107
|
-
// call through a comment. (`//`, `*`, `/*` line starts and `#` for .sh.)
|
|
108
|
-
const trimmed = lines[i].trimStart();
|
|
109
|
-
if (/^(\/\/|\*|\/\*|#)/.test(trimmed)) continue;
|
|
110
|
-
for (const pattern of PATTERNS) {
|
|
111
|
-
if (pattern.test(lines[i])) {
|
|
112
|
-
console.error(
|
|
113
|
-
`${normalized}:${i + 1} — direct buildHeadlessLaunch reference outside the subscription-path funnel. ` +
|
|
114
|
-
`Spawn through SessionManager.spawnSession() (which carries the June-15 reroute), ` +
|
|
115
|
-
`or add an allowlist entry here with a billing-accountability justification.`,
|
|
116
|
-
);
|
|
117
|
-
violations++;
|
|
118
|
-
}
|
|
369
|
+
/** Read the allowlisted SOURCE modules — the only places an alias can be minted. */
|
|
370
|
+
export function readAllowlistSources(root = ROOT) {
|
|
371
|
+
const sources = [];
|
|
372
|
+
for (const rel of ALLOWLIST) {
|
|
373
|
+
if (!rel.startsWith('src/')) continue; // the lint script itself is not an alias source
|
|
374
|
+
try {
|
|
375
|
+
sources.push({ path: rel, content: fs.readFileSync(path.join(root, rel), 'utf-8') });
|
|
376
|
+
} catch {
|
|
377
|
+
/* a missing allowlist entry is the allowlist test's problem, not this scan's */
|
|
119
378
|
}
|
|
120
379
|
}
|
|
380
|
+
return sources;
|
|
121
381
|
}
|
|
122
382
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
383
|
+
// ── CLI body ─────────────────────────────────────────────────────────────
|
|
384
|
+
// Guarded so the exported detectors can be imported by tests WITHOUT running
|
|
385
|
+
// the scan: this module calls process.exit(1) on a violation, so an unguarded
|
|
386
|
+
// import would kill any test run the moment the repo had one. Same pattern as
|
|
387
|
+
// lint-no-unfunneled-credential-write.js and lint-telegram-egress-boundary.mjs.
|
|
388
|
+
const invokedDirectly =
|
|
389
|
+
process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href;
|
|
390
|
+
|
|
391
|
+
if (invokedDirectly) {
|
|
392
|
+
const aliasExports = collectFunnelAliasExports(readAllowlistSources());
|
|
393
|
+
let violations = 0;
|
|
394
|
+
for (const rel of listFiles()) {
|
|
395
|
+
const normalized = rel.split(path.sep).join('/');
|
|
396
|
+
if (ALLOWLIST.has(normalized)) continue;
|
|
397
|
+
if (!EXTENSIONS.has(path.extname(normalized))) continue;
|
|
398
|
+
// Explicit args may be absolute (e.g. the lint's own self-test sandbox);
|
|
399
|
+
// repo-walk entries are always ROOT-relative.
|
|
400
|
+
const full = path.isAbsolute(normalized) ? normalized : path.join(ROOT, normalized);
|
|
401
|
+
let content;
|
|
402
|
+
try {
|
|
403
|
+
content = fs.readFileSync(full, 'utf-8');
|
|
404
|
+
} catch {
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
for (const hit of findHeadlessLaunchViolations(content, aliasExports)) {
|
|
408
|
+
console.error(`${normalized}:${hit.line} — ${hit.msg}`);
|
|
409
|
+
violations++;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (violations > 0) {
|
|
414
|
+
console.error(`\nlint-no-unfunneled-headless-launch: ${violations} violation(s). ` +
|
|
415
|
+
`See docs/specs/june15-headless-spawn-reroute.md (finding F5).`);
|
|
416
|
+
process.exit(1);
|
|
417
|
+
}
|
|
418
|
+
console.log('lint-no-unfunneled-headless-launch: clean');
|
|
127
419
|
}
|
|
128
|
-
console.log('lint-no-unfunneled-headless-launch: clean');
|
|
@@ -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-14T21:37:12.442Z",
|
|
5
|
+
"instarVersion": "1.3.1144",
|
|
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.1144",
|
|
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": "a01cf44f29d7c2c5e2bc284dbcd18e27b5c4d6035f3fee7b8e8254e8a993cf2d",
|
|
3
3
|
"registrySha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
|
|
4
|
-
"packageVersion": "1.3.
|
|
4
|
+
"packageVersion": "1.3.1144"
|
|
5
5
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Upgrade Guide — vNEXT
|
|
2
|
+
|
|
3
|
+
<!-- assembled-by: assemble-next-md -->
|
|
4
|
+
<!-- bump: patch -->
|
|
5
|
+
|
|
6
|
+
## What Changed
|
|
7
|
+
|
|
8
|
+
The check that keeps every headless subprocess going through a single funnel identified that funnel by its name alone, so putting a second nameplate on it walked straight past.
|
|
9
|
+
|
|
10
|
+
- **A listed file could hand the builder out under another name**, and any file could then call that name — no forbidden text anywhere, and a real non-funnel spawn path. Reproduced in the real codebase before the fix: one appended line plus one new consumer left a working bypass while the check printed **clean**.
|
|
11
|
+
- **The check now follows the function, not just its name.** Inside a file, a name passed along — relabelled on import, pulled out of a bundle, copied to a variable, reached through a bracket with the text split in half — is followed until nothing new turns up. Across files, the short closed list is read for any name it hands out that leads back to the builder, and those names are guarded wherever they are imported.
|
|
12
|
+
- **The widening is deliberately narrow, and that was the harder half.** Only a plain re-labelling or a one-line pass-through counts as handing the builder out. A listed file that does real work around it does not — because that is exactly what the funnel itself looks like, and counting it would have flagged every ordinary spawn in the codebase.
|
|
13
|
+
- **Four remaining gaps are named in the check's own header** and pinned by tests asserting they are still open, so they read as decisions rather than oversights.
|
|
14
|
+
- **Nothing new is forbidden**, and the codebase passes cleanly before and after.
|
|
15
|
+
|
|
16
|
+
## What to Tell Your User
|
|
17
|
+
|
|
18
|
+
Every background process this agent starts is supposed to go through one door, because that door is where the limits are decided. A check enforced it — but only by looking for the door's name, so anyone who gave the door a second name got past without it noticing. That was real, not hypothetical: it was demonstrated on the live codebase while the check reported everything fine.
|
|
19
|
+
|
|
20
|
+
It now follows the door itself rather than the label on it. The care went into not over-correcting: a check that blocks work has to stay quiet about correct work, or someone turns it off, and then it guards nothing at all.
|
|
21
|
+
|
|
22
|
+
## Summary of New Capabilities
|
|
23
|
+
|
|
24
|
+
None. This widens what an existing check can see. No new command, route, setting, or rule.
|
|
25
|
+
|
|
26
|
+
## Evidence
|
|
27
|
+
|
|
28
|
+
Proven in both directions. Three deliberate mutations produce three precise failures: removing the cross-file resolution fails exactly the cross-file tests, widening the handout rule fails exactly the control that guards against flagging correct code, and removing the text-splitting defence fails exactly the three forms that depend on it.
|
|
29
|
+
|
|
30
|
+
That third one is worth recording, because it failed to fail the first time. The original test used a shape a different rule already caught, so it proved nothing about the line it was written for and would have shipped as false coverage. It was rewritten to three shapes with nothing else to catch them. A test that passes for the wrong reason is indistinguishable from real coverage until something depends on it.
|
|
31
|
+
|
|
32
|
+
Ten opposite-direction controls hold the other side, including two that would have caught the over-correction: a real-work function in a listed file is not treated as a handout, and the live list is asserted to hand out nothing today, so a future refactor that accidentally creates one becomes visible. The source was restored byte-identical after every mutation, the real codebase lints clean before and after, and the module is now safe to import — it previously had an unguarded exit path that would have stopped a test run the moment the codebase had a violation.
|