instar 1.3.1145 → 1.3.1147
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-blocking-process-scans.js +130 -4
- package/scripts/lint-no-direct-llm-http.js +159 -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.1146.md +22 -0
- package/upgrades/1.3.1147.md +55 -0
- package/upgrades/side-effects/blocking-process-scan-command-resolution.md +109 -0
- package/upgrades/side-effects/llm-http-constant-folding.md +82 -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.1147",
|
|
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": "20cdf255a4e7e5577036fc766d33e560f5fdca277d059195494ac048766322aa",
|
|
3
3
|
"registrySha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
|
|
4
|
-
"packageVersion": "1.3.
|
|
4
|
+
"packageVersion": "1.3.1147"
|
|
5
5
|
}
|
package/package.json
CHANGED
|
@@ -25,6 +25,29 @@
|
|
|
25
25
|
* line or the line directly above:
|
|
26
26
|
* // lint-allow-blocking-scan: <why this can't run periodically>
|
|
27
27
|
*
|
|
28
|
+
* ── 2026-08-14: the command name does not have to be written at the callsite.
|
|
29
|
+
* The original pattern required the scan command as a string literal INSIDE the
|
|
30
|
+
* call, so putting the name one step away walked past it — while the event loop
|
|
31
|
+
* stalled just the same, because the incident was about what the process DOES,
|
|
32
|
+
* not how the argument was spelled. instar-codey reproduced the concatenation
|
|
33
|
+
* form (`const cmd = 'pg' + 'rep'; execFileSync(cmd, ['node'])` → exit 0) while
|
|
34
|
+
* auditing rename-defeatable checks, and scoped the fix.
|
|
35
|
+
*
|
|
36
|
+
* NOW RESOLVED before the decision: literal `+` chains, local `const` string
|
|
37
|
+
* bindings, and import aliases of the sync entry points. The VALUE decides, so
|
|
38
|
+
* `const pgrep = 'tmux'` is legal and `const cmd = 'psql'` is not a `ps`.
|
|
39
|
+
*
|
|
40
|
+
* DELIBERATELY NOT CLOSED, so it is stated rather than implied:
|
|
41
|
+
* · A call split across MULTIPLE LINES. This lint is line-oriented; making it
|
|
42
|
+
* multi-line means an AST, which is a different check at a different layer.
|
|
43
|
+
* Pre-existing, not introduced here.
|
|
44
|
+
* · A command read from config, an argv, or another module. Not foldable
|
|
45
|
+
* without dataflow analysis, and guessing would over-block correct code —
|
|
46
|
+
* the more expensive failure for a check that blocks commits.
|
|
47
|
+
* · Scope: bindings are collected file-wide rather than per-scope, so an
|
|
48
|
+
* identifier bound twice to DIFFERENT values is treated as unresolvable and
|
|
49
|
+
* never flagged. That is the safe direction, chosen on purpose.
|
|
50
|
+
*
|
|
28
51
|
* Exit codes: 0 — clean; 1 — at least one violation.
|
|
29
52
|
*
|
|
30
53
|
* Usage:
|
|
@@ -46,11 +69,112 @@ const ROOT = path.resolve(path.dirname(__filename), '..');
|
|
|
46
69
|
const SCAN_DIRS = ['src/monitoring', 'src/server'];
|
|
47
70
|
const EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.mjs', '.cjs']);
|
|
48
71
|
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
const
|
|
72
|
+
// The scan commands themselves. Word-boundary matched against the RESOLVED
|
|
73
|
+
// command value, so `psql` never counts as `ps`.
|
|
74
|
+
const SCAN_COMMAND = /^(ps|pgrep|lsof|pkill)\b/;
|
|
75
|
+
|
|
76
|
+
// The synchronous child-process entry points. Import aliases of these are
|
|
77
|
+
// resolved per-file below — `import { execFileSync as run }` then `run('pgrep')`
|
|
78
|
+
// stalls the loop exactly as much as calling it by its own name.
|
|
79
|
+
const SYNC_BUILTINS = ['spawnSync', 'execSync', 'execFileSync'];
|
|
80
|
+
|
|
52
81
|
const ALLOW = /lint-allow-blocking-scan:/;
|
|
53
82
|
|
|
83
|
+
/** A string literal, or a `+` chain of string literals. Nothing else folds. */
|
|
84
|
+
const FOLDABLE = /^\s*(?:(?:'[^'\\]*'|"[^"\\]*"|`[^`\\$]*`)\s*\+\s*)*(?:'[^'\\]*'|"[^"\\]*"|`[^`\\$]*`)\s*$/;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Fold an expression to its string value, or null if it is not a pure literal
|
|
88
|
+
* chain. `'pg' + 'rep'` → `pgrep`. A template with `${}` never folds.
|
|
89
|
+
*/
|
|
90
|
+
function foldLiteral(expr) {
|
|
91
|
+
if (!FOLDABLE.test(expr)) return null;
|
|
92
|
+
const parts = expr.match(/'[^'\\]*'|"[^"\\]*"|`[^`\\$]*`/g);
|
|
93
|
+
if (!parts) return null;
|
|
94
|
+
return parts.map((p) => p.slice(1, -1)).join('');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Local `const NAME = <literal chain>` bindings, file-wide.
|
|
99
|
+
*
|
|
100
|
+
* Deliberately NOT scope-aware: this is a line-oriented lint, not a compiler.
|
|
101
|
+
* The safe direction for a check that BLOCKS COMMITS is to refuse to resolve
|
|
102
|
+
* anything ambiguous, so an identifier bound more than once to DIFFERENT values
|
|
103
|
+
* is recorded as unresolvable and never produces a violation. Over-blocking
|
|
104
|
+
* correct code is the more expensive failure here.
|
|
105
|
+
*/
|
|
106
|
+
function collectStringConsts(lines) {
|
|
107
|
+
const map = new Map();
|
|
108
|
+
const conflicted = new Set();
|
|
109
|
+
const DECL = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::\s*[^=]+?)?=\s*([^;\n]+)/g;
|
|
110
|
+
for (const line of lines) {
|
|
111
|
+
const trimmed = line.trimStart();
|
|
112
|
+
if (/^(\/\/|\*|\/\*)/.test(trimmed)) continue; // a commented-out const binds nothing
|
|
113
|
+
DECL.lastIndex = 0;
|
|
114
|
+
let m;
|
|
115
|
+
while ((m = DECL.exec(line)) !== null) {
|
|
116
|
+
const [, name, rhs] = m;
|
|
117
|
+
const value = foldLiteral(rhs);
|
|
118
|
+
if (value === null) { conflicted.add(name); continue; }
|
|
119
|
+
if (map.has(name) && map.get(name) !== value) conflicted.add(name);
|
|
120
|
+
else map.set(name, value);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
for (const name of conflicted) map.delete(name);
|
|
124
|
+
return map;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Names in this file that reach a synchronous child-process call. */
|
|
128
|
+
function collectSyncNames(text) {
|
|
129
|
+
const names = new Set(SYNC_BUILTINS);
|
|
130
|
+
const IMPORT = /import\s*\{([^}]*)\}\s*from\s*['"]node:child_process['"]/g;
|
|
131
|
+
let m;
|
|
132
|
+
while ((m = IMPORT.exec(text)) !== null) {
|
|
133
|
+
for (const clause of m[1].split(',')) {
|
|
134
|
+
const alias = clause.match(/([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)/);
|
|
135
|
+
if (alias && SYNC_BUILTINS.includes(alias[1])) names.add(alias[2]);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return names;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The first argument of `name(` starting at `from`, as source text. Bounded to
|
|
143
|
+
* the line, matching this lint's existing granularity — a call split across
|
|
144
|
+
* lines is a separate, pre-existing gap, declared in the header rather than
|
|
145
|
+
* silently implied.
|
|
146
|
+
*/
|
|
147
|
+
function firstArg(line, from) {
|
|
148
|
+
let depth = 0;
|
|
149
|
+
for (let i = from; i < line.length; i++) {
|
|
150
|
+
const c = line[i];
|
|
151
|
+
if (c === '(' || c === '[' || c === '{') depth++;
|
|
152
|
+
else if (c === ')' || c === ']' || c === '}') {
|
|
153
|
+
if (depth === 0) return line.slice(from, i);
|
|
154
|
+
depth--;
|
|
155
|
+
} else if (c === ',' && depth === 0) return line.slice(from, i);
|
|
156
|
+
}
|
|
157
|
+
return line.slice(from);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Does this line perform a synchronous scan? Resolves the command through
|
|
162
|
+
* literal folding and local constants before deciding.
|
|
163
|
+
*/
|
|
164
|
+
function scanViolation(line, syncNames, constMap) {
|
|
165
|
+
for (const name of syncNames) {
|
|
166
|
+
const call = new RegExp(`\\b${name}\\s*\\(`, 'g');
|
|
167
|
+
let m;
|
|
168
|
+
while ((m = call.exec(line)) !== null) {
|
|
169
|
+
const arg = firstArg(line, m.index + m[0].length).trim();
|
|
170
|
+
let value = foldLiteral(arg);
|
|
171
|
+
if (value === null && /^[A-Za-z_$][\w$]*$/.test(arg)) value = constMap.get(arg) ?? null;
|
|
172
|
+
if (value !== null && SCAN_COMMAND.test(value.trim())) return true;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
|
|
54
178
|
const inScanDir = (p) => SCAN_DIRS.some((d) => p.startsWith(d + '/'));
|
|
55
179
|
|
|
56
180
|
function listFiles() {
|
|
@@ -86,10 +210,12 @@ for (const rel of listFiles()) {
|
|
|
86
210
|
let content;
|
|
87
211
|
try { content = fs.readFileSync(full, 'utf-8'); } catch { continue; }
|
|
88
212
|
const lines = content.split('\n');
|
|
213
|
+
const syncNames = collectSyncNames(content);
|
|
214
|
+
const constMap = collectStringConsts(lines);
|
|
89
215
|
for (let i = 0; i < lines.length; i++) {
|
|
90
216
|
const trimmed = lines[i].trimStart();
|
|
91
217
|
if (/^(\/\/|\*|\/\*)/.test(trimmed)) continue; // comment-only mention
|
|
92
|
-
if (!
|
|
218
|
+
if (!scanViolation(lines[i], syncNames, constMap)) continue;
|
|
93
219
|
// Inline justification on this line or within the comment block directly
|
|
94
220
|
// above (scan back up to 4 lines so a multi-line reason is honoured).
|
|
95
221
|
let allowed = false;
|
|
@@ -98,6 +98,162 @@ const FORBIDDEN_PATTERNS = [
|
|
|
98
98
|
|
|
99
99
|
const EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.mjs', '.cjs']);
|
|
100
100
|
|
|
101
|
+
function lineForOffset(text, offset) {
|
|
102
|
+
let line = 1;
|
|
103
|
+
for (let i = 0; i < offset; i++) {
|
|
104
|
+
if (text[i] === '\n') line++;
|
|
105
|
+
}
|
|
106
|
+
return line;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function stripCommentsPreserveLayout(text) {
|
|
110
|
+
let out = '';
|
|
111
|
+
let i = 0;
|
|
112
|
+
let quote = null;
|
|
113
|
+
let escaped = false;
|
|
114
|
+
|
|
115
|
+
while (i < text.length) {
|
|
116
|
+
const ch = text[i];
|
|
117
|
+
const next = text[i + 1];
|
|
118
|
+
|
|
119
|
+
if (quote) {
|
|
120
|
+
out += ch;
|
|
121
|
+
if (escaped) {
|
|
122
|
+
escaped = false;
|
|
123
|
+
} else if (ch === '\\') {
|
|
124
|
+
escaped = true;
|
|
125
|
+
} else if (ch === quote) {
|
|
126
|
+
quote = null;
|
|
127
|
+
}
|
|
128
|
+
i++;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (ch === '"' || ch === "'" || ch === '`') {
|
|
133
|
+
quote = ch;
|
|
134
|
+
out += ch;
|
|
135
|
+
i++;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (ch === '/' && next === '/') {
|
|
140
|
+
out += ' ';
|
|
141
|
+
i += 2;
|
|
142
|
+
while (i < text.length && text[i] !== '\n') {
|
|
143
|
+
out += ' ';
|
|
144
|
+
i++;
|
|
145
|
+
}
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (ch === '/' && next === '*') {
|
|
150
|
+
out += ' ';
|
|
151
|
+
i += 2;
|
|
152
|
+
while (i < text.length) {
|
|
153
|
+
if (text[i] === '*' && text[i + 1] === '/') {
|
|
154
|
+
out += ' ';
|
|
155
|
+
i += 2;
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
out += text[i] === '\n' ? '\n' : ' ';
|
|
159
|
+
i++;
|
|
160
|
+
}
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
out += ch;
|
|
165
|
+
i++;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function skipWhitespace(text, index) {
|
|
172
|
+
while (index < text.length && /\s/.test(text[index])) index++;
|
|
173
|
+
return index;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function readStringLiteral(text, start) {
|
|
177
|
+
const quote = text[start];
|
|
178
|
+
if (quote !== '"' && quote !== "'" && quote !== '`') return null;
|
|
179
|
+
|
|
180
|
+
let value = '';
|
|
181
|
+
let i = start + 1;
|
|
182
|
+
let escaped = false;
|
|
183
|
+
while (i < text.length) {
|
|
184
|
+
const ch = text[i];
|
|
185
|
+
if (escaped) {
|
|
186
|
+
value += ch;
|
|
187
|
+
escaped = false;
|
|
188
|
+
i++;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (ch === '\\') {
|
|
192
|
+
escaped = true;
|
|
193
|
+
i++;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (quote === '`' && ch === '$' && text[i + 1] === '{') {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
if (ch === quote) {
|
|
200
|
+
return { value, end: i + 1 };
|
|
201
|
+
}
|
|
202
|
+
value += ch;
|
|
203
|
+
i++;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function findFoldedStringViolations(text, rel) {
|
|
210
|
+
const stripped = stripCommentsPreserveLayout(text);
|
|
211
|
+
const violations = [];
|
|
212
|
+
|
|
213
|
+
for (let i = 0; i < stripped.length; i++) {
|
|
214
|
+
const first = readStringLiteral(stripped, i);
|
|
215
|
+
if (!first) continue;
|
|
216
|
+
|
|
217
|
+
let cursor = skipWhitespace(stripped, first.end);
|
|
218
|
+
if (stripped[cursor] !== '+') {
|
|
219
|
+
i = first.end - 1;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const parts = [first.value];
|
|
224
|
+
let end = first.end;
|
|
225
|
+
let literalCount = 1;
|
|
226
|
+
while (stripped[cursor] === '+') {
|
|
227
|
+
const nextStart = skipWhitespace(stripped, cursor + 1);
|
|
228
|
+
const nextString = readStringLiteral(stripped, nextStart);
|
|
229
|
+
if (!nextString) break;
|
|
230
|
+
parts.push(nextString.value);
|
|
231
|
+
literalCount++;
|
|
232
|
+
end = nextString.end;
|
|
233
|
+
cursor = skipWhitespace(stripped, nextString.end);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (literalCount > 1) {
|
|
237
|
+
const folded = parts.join('');
|
|
238
|
+
const line = lineForOffset(stripped, i);
|
|
239
|
+
for (const pat of FORBIDDEN_PATTERNS) {
|
|
240
|
+
if (folded.includes(pat)) {
|
|
241
|
+
violations.push({
|
|
242
|
+
file: rel,
|
|
243
|
+
line,
|
|
244
|
+
pattern: pat,
|
|
245
|
+
text: folded.slice(0, 200),
|
|
246
|
+
folded: true,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
i = end - 1;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return violations;
|
|
255
|
+
}
|
|
256
|
+
|
|
101
257
|
function readGitignoreDirs() {
|
|
102
258
|
// Skip node_modules, dist, .instar/worktrees, etc.
|
|
103
259
|
return new Set(['node_modules', 'dist', 'build', '.instar', '.git', '.next', 'coverage']);
|
|
@@ -168,6 +324,7 @@ function checkFile(file) {
|
|
|
168
324
|
}
|
|
169
325
|
}
|
|
170
326
|
}
|
|
327
|
+
violations.push(...findFoldedStringViolations(text, rel));
|
|
171
328
|
return violations;
|
|
172
329
|
}
|
|
173
330
|
|
|
@@ -184,7 +341,8 @@ function main() {
|
|
|
184
341
|
console.error('src/core/ClaudeCliIntelligenceProvider.ts so the burn-detection system can');
|
|
185
342
|
console.error('attribute it. See docs/specs/token-burn-detection-and-self-heal.md Phase 1.\n');
|
|
186
343
|
for (const v of all) {
|
|
187
|
-
|
|
344
|
+
const reason = v.folded ? `folds to "${v.pattern}"` : `contains "${v.pattern}"`;
|
|
345
|
+
console.error(` ${v.file}:${v.line} — ${reason}`);
|
|
188
346
|
console.error(` ${v.text}`);
|
|
189
347
|
}
|
|
190
348
|
process.exit(1);
|
|
@@ -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-14T23:03:50.148Z",
|
|
5
|
+
"instarVersion": "1.3.1147",
|
|
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.1147",
|
|
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": "20cdf255a4e7e5577036fc766d33e560f5fdca277d059195494ac048766322aa",
|
|
3
3
|
"registrySha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
|
|
4
|
-
"packageVersion": "1.3.
|
|
4
|
+
"packageVersion": "1.3.1147"
|
|
5
5
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Upgrade Guide — vNEXT
|
|
2
|
+
|
|
3
|
+
<!-- assembled-by: assemble-next-md -->
|
|
4
|
+
<!-- bump: patch -->
|
|
5
|
+
|
|
6
|
+
## What Changed
|
|
7
|
+
|
|
8
|
+
`scripts/lint-no-direct-llm-http.js` now folds adjacent constant string and no-expression template literals before checking for direct LLM provider hosts. A raw provider URL split as `'https://api.' + 'anthropic.com/v1/messages'` now fails the same build lint as the unsplit host.
|
|
9
|
+
|
|
10
|
+
The scope is deliberately narrow: literal-plus-literal URL construction only. Dynamic URL construction is not guessed at, and the existing file-level distinction for OAuth/profile/usage metadata endpoints remains unchanged.
|
|
11
|
+
|
|
12
|
+
## What to Tell Your User
|
|
13
|
+
|
|
14
|
+
None — internal change (no user-facing surface).
|
|
15
|
+
|
|
16
|
+
## Summary of New Capabilities
|
|
17
|
+
|
|
18
|
+
None — internal change (no user-facing surface).
|
|
19
|
+
|
|
20
|
+
## Evidence
|
|
21
|
+
|
|
22
|
+
Negative control against the shipped lint: the focused unit file failed 2 tests, both new split-host rejection cases. After the fix, `tests/unit/burn-detection-phase-1.test.ts` passes 20/20, and `node scripts/lint-no-direct-llm-http.js` exits clean against the real tree.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Upgrade Guide — vNEXT
|
|
2
|
+
|
|
3
|
+
<!-- assembled-by: assemble-next-md -->
|
|
4
|
+
<!-- bump: patch -->
|
|
5
|
+
|
|
6
|
+
## What Changed
|
|
7
|
+
|
|
8
|
+
`scripts/lint-no-blocking-process-scans.js` enforces root cause #4 of the 2026-06-07 "server temporarily
|
|
9
|
+
down" post-mortem: a synchronous `ps`/`pgrep`/`lsof`/`pkill` on a runtime hot path blocks the single-threaded
|
|
10
|
+
event loop, those commands get slow under exactly the load that makes monitors fire, and the cumulative
|
|
11
|
+
stall starved `/health` until the supervisor restarted a server that was alive the whole time.
|
|
12
|
+
|
|
13
|
+
The check required the scan command as a string literal inside the call, so the name one step away walked
|
|
14
|
+
past it while the event loop stalled identically:
|
|
15
|
+
|
|
16
|
+
```js
|
|
17
|
+
const cmd = 'pgrep'; execFileSync(cmd, ['node']); // was not caught
|
|
18
|
+
const cmd = 'pg' + 'rep'; execFileSync(cmd, ['node']); // was not caught
|
|
19
|
+
import { execFileSync as run } from 'node:child_process';
|
|
20
|
+
run('pgrep', ['node']); // was not caught
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
instar-codey reproduced the concatenation form against the shipped lint (exit 0) while auditing
|
|
24
|
+
rename-defeatable checks, and scoped the remedy. The command is now resolved before the rule is applied:
|
|
25
|
+
literal `+` chains fold, local `const` string bindings resolve, and import aliases of the three sync entry
|
|
26
|
+
points are followed.
|
|
27
|
+
|
|
28
|
+
Three choices keep it from flagging correct code, each with its own test: the resolved VALUE decides and
|
|
29
|
+
never the variable name (`const pgrep = 'tmux'` is legal); matching is whole-word (`psql` is not `ps`); and
|
|
30
|
+
an identifier bound to two different values in one file is treated as unresolvable and never flagged. Async
|
|
31
|
+
calls stay legal — moving to async is the remedy the rule exists to push toward.
|
|
32
|
+
|
|
33
|
+
Deliberately left open and stated in the header: calls split across multiple lines (line-oriented lint;
|
|
34
|
+
closing it means an AST), and commands read from config/argv/another module (needs dataflow analysis, and
|
|
35
|
+
guessing would over-block).
|
|
36
|
+
|
|
37
|
+
## What to Tell Your User
|
|
38
|
+
|
|
39
|
+
None — internal change (no user-facing surface).
|
|
40
|
+
|
|
41
|
+
## Summary of New Capabilities
|
|
42
|
+
|
|
43
|
+
None — internal change (no user-facing surface).
|
|
44
|
+
|
|
45
|
+
## Evidence
|
|
46
|
+
|
|
47
|
+
- `tests/unit/lint-no-blocking-process-scans.test.ts` — 14/14 green (5 original + 9 added).
|
|
48
|
+
- Negative control: tests written BEFORE the fix and run against the shipped lint — **4 of 14 fail**
|
|
49
|
+
(const concatenation, plain variable, inline concatenation, import alias). The other 10 pass both ways,
|
|
50
|
+
which is what makes them controls.
|
|
51
|
+
- Real-tree verdict: `node scripts/lint-no-blocking-process-scans.js` → `clean`, exit 0 — no new flags on
|
|
52
|
+
existing code.
|
|
53
|
+
- Full `npm run lint` chain green.
|
|
54
|
+
- Side-effects review: `upgrades/side-effects/blocking-process-scan-command-resolution.md`.
|
|
55
|
+
- ELI16: `docs/specs/blocking-process-scan-command-resolution.eli16.md`.
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Side-Effects Review — blocking-process-scan lint resolves the command before deciding
|
|
2
|
+
|
|
3
|
+
**Version / slug:** `blocking-process-scan-command-resolution`
|
|
4
|
+
**Date:** `2026-08-14`
|
|
5
|
+
**Author:** `echo`
|
|
6
|
+
**Second-pass reviewer:** `not required — Tier 1 declared (CI-only tooling, riskFloor 1). No spec change: the §rule is unchanged; the check now resolves the command value before applying the same rule.`
|
|
7
|
+
|
|
8
|
+
## Summary of the change
|
|
9
|
+
|
|
10
|
+
`scripts/lint-no-blocking-process-scans.js` enforces the topic-21816 post-mortem's root cause #4: a
|
|
11
|
+
synchronous `ps`/`pgrep`/`lsof`/`pkill` on a runtime hot path blocks the single-threaded event loop, and those
|
|
12
|
+
commands get slow under exactly the load that makes monitors fire — the cumulative stall starved `/health`,
|
|
13
|
+
the supervisor declared the live server unresponsive, and restarted it.
|
|
14
|
+
|
|
15
|
+
The check required the scan command as a string literal INSIDE the call:
|
|
16
|
+
`/\b(spawnSync|execSync|execFileSync)\s*\(\s*['"\`]\s*(ps|pgrep|lsof|pkill)\b/`. Putting the name one step
|
|
17
|
+
away walked past it, while the event loop stalled just the same — the incident was about what the process
|
|
18
|
+
DOES, not how the argument was spelled. instar-codey reproduced the concatenation form against the shipped
|
|
19
|
+
lint (`const cmd = 'pg' + 'rep'; execFileSync(cmd, ['node'])` → exit 0) and scoped the fix.
|
|
20
|
+
|
|
21
|
+
The command is now RESOLVED before the rule is applied: literal `+` chains fold, local `const` string bindings
|
|
22
|
+
resolve, and import aliases of the three sync entry points are followed.
|
|
23
|
+
|
|
24
|
+
## Decision-point inventory
|
|
25
|
+
|
|
26
|
+
- `scanViolation()` — REPLACES the literal-only regex — resolves then decides. CI-time only; never runtime.
|
|
27
|
+
- `foldLiteral()` — ADD — pure literal chains only; a template with `${}` never folds.
|
|
28
|
+
- `collectStringConsts()` — ADD — file-wide, and **deliberately refuses ambiguity** (see §1).
|
|
29
|
+
- `collectSyncNames()` — ADD — follows `import { execFileSync as run } from 'node:child_process'`.
|
|
30
|
+
- The allow comment, the comment-only skip, the scan dirs, and the async remedy are all unchanged.
|
|
31
|
+
- No runtime block/allow decisions added or modified. This runs in `npm run lint` and CI only.
|
|
32
|
+
|
|
33
|
+
## 1. Over-block
|
|
34
|
+
|
|
35
|
+
This is the failure that matters here: the lint blocks commits, so flagging correct code costs more than
|
|
36
|
+
missing a case. Three structural choices push against it, and each has a test:
|
|
37
|
+
|
|
38
|
+
- **The VALUE decides, never the name.** `const pgrep = 'tmux'` is legal; the identifier being called `pgrep`
|
|
39
|
+
is irrelevant.
|
|
40
|
+
- **Word-boundary on the resolved value.** `psql` is not `ps`; `pstree` is not `ps`.
|
|
41
|
+
- **Ambiguity resolves to NOT-flagged.** Bindings are collected file-wide rather than per-scope (a
|
|
42
|
+
line-oriented lint is not a compiler). An identifier bound more than once to DIFFERENT values is recorded as
|
|
43
|
+
unresolvable and never produces a violation — the safe direction, chosen on purpose.
|
|
44
|
+
|
|
45
|
+
Async calls are untouched: `execFile(cmd, …)` with a folded command stays legal, because async yielding the
|
|
46
|
+
loop IS the remedy this lint exists to push people toward.
|
|
47
|
+
|
|
48
|
+
Verified against the real tree: `src/monitoring` + `src/server` report clean, exit 0 — the widened check
|
|
49
|
+
introduces no new flags on existing code.
|
|
50
|
+
|
|
51
|
+
## 2. Under-block
|
|
52
|
+
|
|
53
|
+
Stated in the header rather than implied:
|
|
54
|
+
|
|
55
|
+
- **A call split across multiple lines.** This lint is line-oriented; making it multi-line means an AST, which
|
|
56
|
+
is a different check at a different layer. Pre-existing — not introduced here.
|
|
57
|
+
- **A command read from config, argv, or another module.** Not foldable without dataflow analysis, and
|
|
58
|
+
guessing would over-block.
|
|
59
|
+
- Scope is still the two hot dirs (`src/monitoring`, `src/server`). `src/core`'s tmux-heavy session plumbing
|
|
60
|
+
remains the separate, larger conversion tracked in the post-mortem follow-up.
|
|
61
|
+
|
|
62
|
+
## 3. Level-of-abstraction fit
|
|
63
|
+
|
|
64
|
+
Same layer as the shipped check — line-oriented regex over source, no AST, no type information, no new
|
|
65
|
+
dependency. The added machinery (fold, const map, alias map, first-arg extractor) is the minimum needed to
|
|
66
|
+
answer "what command does this actually run?" without climbing to a parser. Codey rated this scope
|
|
67
|
+
"low/medium FP risk if limited to child_process sync aliases plus literal/constant-folded command values in
|
|
68
|
+
src/monitoring and src/server" — that is exactly the scope implemented.
|
|
69
|
+
|
|
70
|
+
## 4. Signal vs authority compliance
|
|
71
|
+
|
|
72
|
+
Unchanged. A CI guard, not a runtime authority. It forbids a synchronous enumeration on the hot path; the
|
|
73
|
+
async equivalent remains the sanctioned path, and the reviewed one-shot escape hatch still works.
|
|
74
|
+
|
|
75
|
+
## 5. Interactions
|
|
76
|
+
|
|
77
|
+
- `npm run lint` chain (`package.json`) — position unchanged; full chain green.
|
|
78
|
+
- `--staged` path and explicit-file args unchanged in behaviour.
|
|
79
|
+
- Husky pre-commit / CI run the same chain.
|
|
80
|
+
- No source module, route, config key, or state file is touched.
|
|
81
|
+
|
|
82
|
+
## 6. External surfaces
|
|
83
|
+
|
|
84
|
+
None. No HTTP route, no config key, no user-visible message, no CLAUDE.md template change (developer-facing
|
|
85
|
+
tooling, not an agent capability). Agent Awareness Standard does not apply.
|
|
86
|
+
|
|
87
|
+
## 7. Rollback cost
|
|
88
|
+
|
|
89
|
+
`git revert` of one script plus one test file. No migration, no state, no deployed artifact.
|
|
90
|
+
|
|
91
|
+
## Conclusion
|
|
92
|
+
|
|
93
|
+
Ship. Four evasions closed, each with a test that fails without the fix; six anti-over-block controls added
|
|
94
|
+
alongside; real tree verified clean.
|
|
95
|
+
|
|
96
|
+
## Second-pass review (if required)
|
|
97
|
+
|
|
98
|
+
Not required at Tier 1. Independent corroboration exists regardless: instar-codey reproduced the evasion
|
|
99
|
+
separately and pre-scoped the remedy, including the warning not to overmatch all sync child-process calls.
|
|
100
|
+
|
|
101
|
+
## Evidence pointers
|
|
102
|
+
|
|
103
|
+
- `tests/unit/lint-no-blocking-process-scans.test.ts` — 14/14 green (5 original + 9 added).
|
|
104
|
+
- Negative control: tests written BEFORE the fix and run against the shipped lint — **4 of 14 fail**
|
|
105
|
+
(const concatenation, plain variable, inline concatenation, import alias); the 10 others pass both ways,
|
|
106
|
+
which is what makes the controls controls.
|
|
107
|
+
- Real-tree verdict: `node scripts/lint-no-blocking-process-scans.js` → `clean`, exit 0.
|
|
108
|
+
- Full `npm run lint` chain green.
|
|
109
|
+
- Source incident: `docs/postmortems/2026-06-07-server-temporarily-down.md` (root cause #4).
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Side-Effects Review — LLM HTTP lint constant folding
|
|
2
|
+
|
|
3
|
+
**Version / slug:** `llm-http-constant-folding`
|
|
4
|
+
**Date:** `2026-08-14`
|
|
5
|
+
**Author:** `Instar-codey`
|
|
6
|
+
**Tier:** 1 (one lint script plus focused unit coverage; no runtime code, route, config, migration, or persistence change)
|
|
7
|
+
**Second-pass reviewer:** `not required — CI/pre-commit lint hardening only; no runtime outbound message or session lifecycle decision`
|
|
8
|
+
|
|
9
|
+
## Summary of the change
|
|
10
|
+
|
|
11
|
+
`scripts/lint-no-direct-llm-http.js` now folds adjacent constant string and no-expression template literals joined by `+` before checking for known LLM provider hosts. This closes the reproduced split-host bypass (`'api.' + 'anthropic.com/v1/messages'`) while keeping scope at literal URL construction only. `tests/unit/burn-detection-phase-1.test.ts` adds red/green coverage for split Anthropic, OpenAI, and Google host literals plus a non-folded dynamic construction control.
|
|
12
|
+
|
|
13
|
+
Build location re-grounding: work was built in fresh worktree `/Users/justin_instar_1/.instar/agents/instar-codey/.worktrees/agent-llm-http-constant-folding` from current `JKHeadley/main` (`4731ec6a90356ed319454a996b4eb72edcf38ab2`), created through `npx -y instar@1.3.1144 worktree create ... --base origin/main` after the local wrapper lacked an installed package. Remote verified as `origin https://github.com/JKHeadley/instar.git`; package version verified as `1.3.1144`.
|
|
14
|
+
|
|
15
|
+
## Decision-point inventory
|
|
16
|
+
|
|
17
|
+
- `scripts/lint-no-direct-llm-http.js` — modify — build-time block/allow decision for direct provider HTTP references outside the provider chokepoint and named metadata exceptions.
|
|
18
|
+
|
|
19
|
+
## 1. Over-block
|
|
20
|
+
|
|
21
|
+
The new over-block risk is a benign constant string in production source that names a provider host across literal pieces without making a call. That is the same policy as the existing unsplit-host lint: production source outside the allowlist should not carry raw provider host literals because they become copyable direct-call paths. The real-tree scan after the fix was clean.
|
|
22
|
+
|
|
23
|
+
OAuth/profile/usage metadata readers keep their existing allowlist/grandfather treatment; this change did not add endpoint-level bans that would collapse metadata reads into inference calls.
|
|
24
|
+
|
|
25
|
+
## 2. Under-block
|
|
26
|
+
|
|
27
|
+
The lint still misses dynamic construction, such as `'https://api.' + providerHost + '/v1/messages'`, computed arrays joined into a host, decoded strings, or runtime config that points at a provider endpoint. That is intentional for this PR: automatic discovery/dataflow was the high-false-positive direction. The closure here is constant URL literals, not semantic HTTP-call proof.
|
|
28
|
+
|
|
29
|
+
## 3. Level-of-abstraction fit
|
|
30
|
+
|
|
31
|
+
Correct layer: this is a deterministic CI lint in the existing `lint-no-*` family. The protected invariant is "new production raw LLM provider host literals outside the chokepoint require review." The lower-level primitive is a string-literal scanner, not an authority over runtime user intent.
|
|
32
|
+
|
|
33
|
+
## 4. Signal vs authority compliance
|
|
34
|
+
|
|
35
|
+
Reference: [docs/signal-vs-authority.md](../../docs/signal-vs-authority.md).
|
|
36
|
+
|
|
37
|
+
This change holds blocking authority with brittle logic, but it is a build-time hard invariant/safety guard, not a conversational judgment point. The lint blocks source code containing direct provider endpoint literals outside reviewed locations, analogous to a safety guard on data egress and spend attribution. It does not decide whether a user message is appropriate or whether an agent action should proceed at runtime.
|
|
38
|
+
|
|
39
|
+
## 4b. Judgment-point check
|
|
40
|
+
|
|
41
|
+
No new static heuristic at a competing-signals decision point. The input is source text and an enumerated provider-host list; there are no live competing signals such as urgency, recency, ownership, or liveness.
|
|
42
|
+
|
|
43
|
+
## 5. Interactions
|
|
44
|
+
|
|
45
|
+
- **Shadowing:** It runs inside the existing `npm run lint` chain. It may fail before later lints, as before; no downstream lint depends on side effects from this script.
|
|
46
|
+
- **Double-fire:** `lint-llm-attribution.js` and other provider-path checks may catch related defects, but this one reports direct HTTP host literals specifically. Duplicate CI failures are acceptable because they point at the same source change.
|
|
47
|
+
- **Races:** No shared state. It reads files and exits.
|
|
48
|
+
- **Feedback loops:** None.
|
|
49
|
+
|
|
50
|
+
## 6. External surfaces
|
|
51
|
+
|
|
52
|
+
No runtime external surface changes. Other agents and users only see this as a stricter build/pre-commit/CI failure when source contains a newly-recognized split provider URL. No Telegram, Slack, GitHub API, dashboard, database, ledger, or generated URL behavior changes. No operator-facing action is added.
|
|
53
|
+
|
|
54
|
+
## 6b. Operator-surface quality
|
|
55
|
+
|
|
56
|
+
No operator surface — not applicable.
|
|
57
|
+
|
|
58
|
+
## 7. Multi-machine posture
|
|
59
|
+
|
|
60
|
+
Machine-local by design: this is a repository lint run independently in each checkout/CI runner. It emits no user-facing notices, holds no durable state, and generates no URLs. Multi-machine consistency comes from the shared git commit containing the lint and tests.
|
|
61
|
+
|
|
62
|
+
## 8. Rollback cost
|
|
63
|
+
|
|
64
|
+
Pure code/test/docs change. Rollback is a hot-fix revert of the lint folding helper and tests. No data migration, no agent state repair, and no user-visible runtime regression while rollback propagates.
|
|
65
|
+
|
|
66
|
+
## Conclusion
|
|
67
|
+
|
|
68
|
+
The real-tree scan stayed clean and the negative control failed for the intended old-lint cases, so the scoped constant-folding fix is clear to ship. No broader automatic actuator discovery or dynamic URL dataflow was added.
|
|
69
|
+
|
|
70
|
+
## Second-pass review
|
|
71
|
+
|
|
72
|
+
Not required.
|
|
73
|
+
|
|
74
|
+
## Evidence pointers
|
|
75
|
+
|
|
76
|
+
- Old-lint focused run: `npx vitest run tests/unit/burn-detection-phase-1.test.ts` failed 2 of 20 tests, specifically the two new split-host rejection tests.
|
|
77
|
+
- Fixed targeted run: `npx vitest run tests/unit/burn-detection-phase-1.test.ts` passed 20/20.
|
|
78
|
+
- Fixed real-tree scan: `node scripts/lint-no-direct-llm-http.js` exited 0.
|
|
79
|
+
|
|
80
|
+
## Class-Closure Declaration (display-only mirror)
|
|
81
|
+
|
|
82
|
+
No agent-authored-artifact defect — not applicable.
|