Skip to content

Commit e52b49d

Browse files
avifeneshagent-core-bot
andauthored
chore: sync core lib and CLAUDE.md from agent-core (#32)
Co-authored-by: agent-core-bot <noreply@agent-sh.github.io>
1 parent 0616c8f commit e52b49d

21 files changed

Lines changed: 2184 additions & 295 deletions

lib/binary/index.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@ const { promisify } = require('util');
4848

4949
const execFileAsync = promisify(cp.execFile);
5050

51+
// repo-intel artifacts grow with history: a mature repo's JSON can exceed 20 MB
52+
// (agnix measured ~21 MB). Node's execFile default maxBuffer is 1 MB, which
53+
// silently fails init/update/query on any real repo with "stdout maxBuffer length
54+
// exceeded". Cap generously; callers can override via options.maxBuffer.
55+
const ANALYZER_MAX_BUFFER = 256 * 1024 * 1024;
56+
5157
const { ANALYZER_MIN_VERSION, BINARY_NAME, GITHUB_REPO } = require('./version');
5258

5359
const PLATFORM_MAP = {
@@ -957,7 +963,7 @@ function ensureBinarySync(options) {
957963
*/
958964
function runAnalyzer(args, options) {
959965
const binPath = ensureBinarySync();
960-
const opts = Object.assign({ encoding: 'utf8', windowsHide: true }, options);
966+
const opts = Object.assign({ encoding: 'utf8', windowsHide: true, maxBuffer: ANALYZER_MAX_BUFFER }, options);
961967
if (!opts.stdio) opts.stdio = ['pipe', 'pipe', 'pipe'];
962968
const result = cp.execFileSync(binPath, args, opts);
963969
return typeof result === 'string' ? result : result.toString('utf8');
@@ -971,7 +977,7 @@ function runAnalyzer(args, options) {
971977
*/
972978
async function runAnalyzerAsync(args, options) {
973979
const binPath = await ensureBinary();
974-
const opts = Object.assign({ encoding: 'utf8', windowsHide: true }, options);
980+
const opts = Object.assign({ encoding: 'utf8', windowsHide: true, maxBuffer: ANALYZER_MAX_BUFFER }, options);
975981
const result = await execFileAsync(binPath, args, opts);
976982
return result.stdout;
977983
}

lib/binary/shared-helpers.js

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
'use strict';
2+
3+
/**
4+
* Shared HTTP + archive helpers used by both binary resolvers
5+
* (`lib/binary/index.js` for `agent-analyzer`, `lib/embed/binary.js`
6+
* for `agent-analyzer-embed`).
7+
*
8+
* Extracted to keep the two resolvers from drifting on HTTP redirect
9+
* handling, GitHub auth, and archive extraction details — a single
10+
* fix to e.g. the timeout policy or the redirect cap lands once and
11+
* applies to both binaries.
12+
*
13+
* @module lib/binary/shared-helpers
14+
*/
15+
16+
const fs = require('fs');
17+
const path = require('path');
18+
const os = require('os');
19+
const https = require('https');
20+
const cp = require('child_process');
21+
22+
const DEFAULT_DOWNLOAD_TIMEOUT_MS = 30000;
23+
const MAX_REDIRECTS = 5;
24+
25+
/**
26+
* Fetch a URL into an in-memory Buffer following up to 5 redirects.
27+
*
28+
* Honors `GITHUB_TOKEN` / `GH_TOKEN` for authenticated requests
29+
* (raises rate limit, lets private-repo asset URLs work). Stalled
30+
* connections are killed by the per-request timeout — without this
31+
* a stuck socket would hang the process indefinitely.
32+
*
33+
* @param {string} url
34+
* @param {Object} [options]
35+
* @param {string} [options.userAgent='agent-sh/binary-resolver']
36+
* @param {number} [options.timeoutMs=30000] - per-request timeout
37+
* @returns {Promise<Buffer>}
38+
*/
39+
function downloadToBuffer(url, options) {
40+
const opts = options || {};
41+
const userAgent = opts.userAgent || 'agent-sh/binary-resolver';
42+
const timeoutMs = opts.timeoutMs || DEFAULT_DOWNLOAD_TIMEOUT_MS;
43+
44+
return new Promise(function (resolve, reject) {
45+
const ghToken = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
46+
47+
function request(reqUrl, redirectCount) {
48+
if (redirectCount > MAX_REDIRECTS) {
49+
reject(new Error('Too many redirects fetching from ' + url));
50+
return;
51+
}
52+
const headers = {
53+
'User-Agent': userAgent,
54+
'Accept': 'application/octet-stream'
55+
};
56+
if (ghToken) headers['Authorization'] = 'Bearer ' + ghToken;
57+
58+
const req = https.get(reqUrl, { headers: headers, timeout: timeoutMs }, function (res) {
59+
const sc = res.statusCode;
60+
if (sc === 301 || sc === 302 || sc === 307 || sc === 308) {
61+
res.resume();
62+
var loc = res.headers.location;
63+
if (loc && !loc.startsWith('https://')) {
64+
reject(new Error('Refusing non-HTTPS redirect to ' + loc));
65+
return;
66+
}
67+
request(loc, redirectCount + 1);
68+
return;
69+
}
70+
if (sc !== 200) {
71+
res.resume();
72+
const hint = sc === 403 ? ' (rate limited - set GITHUB_TOKEN env var)' : '';
73+
reject(new Error('HTTP ' + sc + hint + ' fetching ' + reqUrl));
74+
return;
75+
}
76+
const chunks = [];
77+
res.on('data', function (chunk) { chunks.push(chunk); });
78+
res.on('end', function () { resolve(Buffer.concat(chunks)); });
79+
res.on('error', reject);
80+
});
81+
req.on('error', reject);
82+
req.on('timeout', function () {
83+
req.destroy();
84+
reject(new Error('Timeout (' + timeoutMs + 'ms) fetching ' + reqUrl));
85+
});
86+
}
87+
88+
request(url, 0);
89+
});
90+
}
91+
92+
/**
93+
* Extract a `.tar.gz` Buffer into `destDir` using the system `tar`.
94+
* Available on Linux, macOS, and Windows (built into recent Win10/11).
95+
*
96+
* @param {Buffer} buf
97+
* @param {string} destDir
98+
* @returns {Promise<void>}
99+
*/
100+
function extractTarGz(buf, destDir) {
101+
return new Promise(function (resolve, reject) {
102+
const tarDest = process.platform === 'win32' ? destDir.replace(/\\/g, '/') : destDir;
103+
const tar = cp.spawn('tar', ['xz', '-C', tarDest], {
104+
stdio: ['pipe', 'pipe', 'pipe']
105+
});
106+
let stderr = '';
107+
tar.stderr.on('data', function (d) { stderr += d; });
108+
tar.stdin.write(buf);
109+
tar.stdin.end();
110+
tar.on('close', function (code) {
111+
if (code !== 0) {
112+
reject(new Error('tar extraction failed (code ' + code + '): ' + stderr));
113+
} else {
114+
resolve();
115+
}
116+
});
117+
tar.on('error', reject);
118+
});
119+
}
120+
121+
/**
122+
* Extract a `.zip` Buffer into `destDir` using PowerShell's
123+
* `Expand-Archive` (Windows-only).
124+
*
125+
* @param {Buffer} buf
126+
* @param {string} destDir
127+
* @param {string} binaryName - used as the temp-dir prefix
128+
* @returns {Promise<void>}
129+
*/
130+
function extractZip(buf, destDir, binaryName) {
131+
return new Promise(function (resolve, reject) {
132+
var tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), binaryName + '-'));
133+
var tmpZip = path.join(tmpDir, 'archive.zip');
134+
fs.writeFileSync(tmpZip, buf);
135+
var ps = cp.spawn(
136+
'powershell',
137+
['-NoProfile', '-NonInteractive', '-Command',
138+
'Expand-Archive', '-Path', tmpZip, '-DestinationPath', destDir, '-Force'],
139+
{ stdio: ['ignore', 'pipe', 'pipe'] }
140+
);
141+
var stderr = '';
142+
ps.stderr.on('data', function (d) { stderr += d; });
143+
ps.on('close', function (code) {
144+
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (e) { /* ignore */ }
145+
if (code !== 0) {
146+
reject(new Error('zip extraction failed (code ' + code + '): ' + stderr));
147+
} else {
148+
resolve();
149+
}
150+
});
151+
ps.on('error', reject);
152+
});
153+
}
154+
155+
module.exports = {
156+
downloadToBuffer,
157+
extractTarGz,
158+
extractZip,
159+
DEFAULT_DOWNLOAD_TIMEOUT_MS
160+
};

lib/collectors/codebase.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,10 @@ function extractSymbols(content) {
126126
symbols.functions.push(match[1]);
127127
}
128128

129-
const arrowPattern = /(?:const|let)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=\s*(?:async\s*)?\([^)]*\)\s*=>/g;
129+
// ReDoS fix: bound the unbounded \s* / async runs and the parameter list so the
130+
// matcher cannot backtrack polynomially on pathological input. Bounds are large
131+
// enough that all realistic source matches identically to the prior \s*/[^)]* form.
132+
const arrowPattern = /(?:const|let)\s{1,1000}([a-zA-Z_$][a-zA-Z0-9_$]*)\s{0,1000}=\s{0,1000}(?:async\s{0,1000})?\([^)]{0,2000}\)\s{0,1000}=>/g;
130133
while ((match = arrowPattern.exec(content)) !== null) {
131134
symbols.functions.push(match[1]);
132135
}
@@ -141,7 +144,9 @@ function extractSymbols(content) {
141144
symbols.exports.push(match[1]);
142145
}
143146

144-
const moduleExportsPattern = /module\.exports\s*=\s*\{([^}]+)\}/;
147+
// ReDoS fix: bound the \s* runs and capture length so the matcher stays linear;
148+
// bounds exceed any realistic module.exports declaration so matches are unchanged.
149+
const moduleExportsPattern = /module\.exports\s{0,1000}=\s{0,1000}\{([^}]{1,100000})\}/;
145150
const moduleMatch = content.match(moduleExportsPattern);
146151
if (moduleMatch) {
147152
const keys = moduleMatch[1].split(',').map(k => k.trim().split(':')[0].trim());

lib/collectors/documentation.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,9 @@ function safeReadFile(filePath, basePath) {
5050
* Analyze a single markdown file
5151
*/
5252
function analyzeMarkdownFile(content, filePath) {
53-
const sectionMatches = content.match(/^##\s+(.+)$/gm) || [];
53+
// ReDoS fix: bound the \s+ run after the ## marker; line-anchored (.+) cannot
54+
// cross newlines so this matches the same headings as before.
55+
const sectionMatches = content.match(/^##\s{1,1000}(.+)$/gm) || [];
5456
const sections = sectionMatches.slice(0, 10).map(s => s.replace(/^##\s+/, ''));
5557
const sectionLower = sections.map(s => s.toLowerCase()).join(' ');
5658

@@ -83,7 +85,11 @@ function extractCheckboxes(result, content) {
8385
* Extract documented features
8486
*/
8587
function extractFeatures(result, content) {
86-
const featurePattern = /^[-*]\s+\*{0,2}(.+?)\*{0,2}(?:\s*[-]\s*(.+))?$/gm;
88+
// ReDoS fix: bound the \s+ run and the line-content quantifiers so the lazy
89+
// (.+?) / optional trailing (.+) pair cannot backtrack polynomially. Using
90+
// [^\n] is equivalent to . here (. never matches newline), and the bounds far
91+
// exceed the 80-char feature cap applied below, so matches are unchanged.
92+
const featurePattern = /^[-*]\s{1,100}\*{0,2}([^\n]{1,2000}?)\*{0,2}(?:\s{0,100}[-]\s{0,100}([^\n]{1,2000}))?$/gm;
8793
let match;
8894

8995
while ((match = featurePattern.exec(content)) !== null && result.features.length < 20) {

lib/enhance/agent-patterns.js

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -439,8 +439,17 @@ const agentPatterns = {
439439

440440
// Look for hardcoded .claude/ references
441441
const hasHardcoded = /\.claude\//.test(content);
442-
// Exclude if using AI_STATE_DIR
443-
const usesEnvVar = /AI_STATE_DIR|\$\{.*STATE.*\}/i.test(content);
442+
// Exclude if using AI_STATE_DIR or a ${...STATE...} env expression.
443+
// ReDoS fix: the old /\$\{.*STATE.*\}/ (and the [^}]*STATE[^}]* rewrite)
444+
// has two ambiguous quantifier runs -> polynomial backtrack. Instead
445+
// scan each ${...} group with a single bounded [^}] run, then substring-
446+
// test for STATE. Linear, and matches STATE in ANY ${...} like before.
447+
let usesEnvVar = /AI_STATE_DIR/i.test(content);
448+
if (!usesEnvVar) {
449+
for (const m of content.matchAll(/\$\{([^}]{0,1000})\}/g)) {
450+
if (/STATE/i.test(m[1])) { usesEnvVar = true; break; }
451+
}
452+
}
444453

445454
if (hasHardcoded && !usesEnvVar) {
446455
return {
@@ -494,8 +503,12 @@ const agentPatterns = {
494503

495504
// Check if has code blocks or lists but no XML
496505
const hasCodeBlocks = /```[\s\S]+?```/.test(content);
497-
const hasLists = /^[-*]\s+.+$/m.test(content);
498-
const hasXML = /<\w+>[\s\S]*?<\/\w+>/.test(content);
506+
// ReDoS fix: bound the \s+ and line-content runs; line-anchored so this still
507+
// detects any "- item" / "* item" list line as before.
508+
const hasLists = /^[-*]\s{1,1000}[^\n]{1,2000}$/m.test(content);
509+
// ReDoS fix: bound the unbounded [\s\S]*? so an unterminated <tag> cannot
510+
// drive polynomial backtracking; 50k chars covers any realistic XML block.
511+
const hasXML = /<\w+>[\s\S]{0,50000}?<\/\w+>/.test(content);
499512
const sectionCount = (content.match(/^##\s+/gm) || []).length;
500513

501514
// Complex content without XML

lib/enhance/auto-suppression.js

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,12 @@ const PATTERN_HEURISTICS = {
5454
const contentLower = content.toLowerCase();
5555

5656
// Check if file is pattern documentation describing vague language detection
57+
// ReDoS fix: the .* runs never matched across newlines (. excludes \n), so
58+
// bounding them to [^\n]{0,N} keeps the same "within one line, in order"
59+
// semantics while removing the polynomial multi-.* backtracking.
5760
const isPatternDoc =
58-
/pattern.*detect.*usually|example.*vague|fuzzy.*language.*like/i.test(content) ||
59-
/vague.*terms.*like|"usually".*"sometimes"/i.test(content);
61+
/pattern[^\n]{0,500}detect[^\n]{0,500}usually|example[^\n]{0,500}vague|fuzzy[^\n]{0,500}language[^\n]{0,500}like/i.test(content) ||
62+
/vague[^\n]{0,500}terms[^\n]{0,500}like|"usually"[^\n]{0,500}"sometimes"/i.test(content);
6063

6164
if (isPatternDoc) {
6265
return {
@@ -129,7 +132,10 @@ const PATTERN_HEURISTICS = {
129132
const isOrchestrator =
130133
fileNameLower.includes('orchestrator') ||
131134
fileNameLower.includes('coordinator') ||
132-
/Task\s*\(\s*\{[\s\S]*subagent_type/i.test(content);
135+
// ReDoS fix: bound the unbounded [\s\S]* so a "Task({" with no following
136+
// subagent_type cannot drive polynomial backtracking; 50k chars covers any
137+
// realistic Task(...) call body.
138+
/Task\s{0,100}\(\s{0,100}\{[\s\S]{0,50000}subagent_type/i.test(content);
133139

134140
if (isOrchestrator) {
135141
return {
@@ -140,7 +146,9 @@ const PATTERN_HEURISTICS = {
140146

141147
// Check if workflow command that invokes agents
142148
const isWorkflowCommand =
143-
/spawn.*agent|invoke.*agent|Task\s*\(\s*\{/i.test(content) &&
149+
// ReDoS fix: bound the within-line .* runs and \s* runs ([^\n] == . here)
150+
// to keep the same matches without polynomial backtracking.
151+
/spawn[^\n]{0,500}agent|invoke[^\n]{0,500}agent|Task\s{0,100}\(\s{0,100}\{/i.test(content) &&
144152
fileNameLower.endsWith('.md');
145153

146154
if (isWorkflowCommand) {
@@ -159,9 +167,11 @@ const PATTERN_HEURISTICS = {
159167
*/
160168
missing_output_format: (finding, content, context) => {
161169
// Check if content spawns subagents with their own output specs
170+
// ReDoS fix: bound the within-line .* and \s* runs ([^\n] == . here) so the
171+
// same membership matches hold without polynomial backtracking.
162172
const spawnsSubagent =
163-
/subagent_type|spawn.*agent|Task\s*\(\s*\{/i.test(content) ||
164-
/enhance:.*-enhancer|enhance:.*-reporter/i.test(content);
173+
/subagent_type|spawn[^\n]{0,500}agent|Task\s{0,100}\(\s{0,100}\{/i.test(content) ||
174+
/enhance:[^\n]{0,500}-enhancer|enhance:[^\n]{0,500}-reporter/i.test(content);
165175

166176
if (spawnsSubagent) {
167177
return {
@@ -180,7 +190,9 @@ const PATTERN_HEURISTICS = {
180190
missing_constraints: (finding, content, context) => {
181191
// Check for constraint section presence
182192
const hasConstraintSection =
183-
/##\s*What\s+.*MUST\s+NOT\s+Do/i.test(content) ||
193+
// ReDoS fix: bound the within-line .* and \s runs ([^\n] == . here) so the
194+
// "## What ... MUST NOT Do" heading still matches without backtracking.
195+
/##\s{0,100}What\s{1,100}[^\n]{0,500}MUST\s{1,100}NOT\s{1,100}Do/i.test(content) ||
184196
/##\s*Constraints/i.test(content) ||
185197
/<constraints>/i.test(content) ||
186198
/##\s*Critical\s+Constraints/i.test(content) ||

lib/enhance/cross-file-analyzer.js

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,11 @@ const CRITICAL_PATTERNS = [
114114
const SUBAGENT_PATTERN = /subagent_type\s*[=:]\s*["']([^"']+)["']/g;
115115

116116
/** Pre-compiled patterns for cleaning content */
117-
const BAD_EXAMPLE_TAG_PATTERN = /<bad[_\- ]?example>[\s\S]*?<\/bad[_\- ]?example>/gi;
118-
const BAD_EXAMPLE_CODE_PATTERN = /```[^\n]*bad[^\n]*\n[\s\S]*?```/gi;
117+
// ReDoS fix: bound the lazy [\s\S]*? bodies so an unterminated <bad-example> or
118+
// ``` fence cannot drive polynomial backtracking; 50k chars covers any realistic
119+
// example block, so the stripped regions are unchanged for real content.
120+
const BAD_EXAMPLE_TAG_PATTERN = /<bad[_\- ]?example>[\s\S]{0,50000}?<\/bad[_\- ]?example>/gi;
121+
const BAD_EXAMPLE_CODE_PATTERN = /```[^\n]{0,500}bad[^\n]{0,500}\n[\s\S]{0,50000}?```/gi;
119122

120123
// ============================================
121124
// TOOL PATTERN CACHE
@@ -649,10 +652,14 @@ function analyzePromptConsistency(agents) {
649652

650653
// Extract action keywords
651654
let action;
655+
// ReDoS fix: bound the greedy prefix to non-newline chars. `line` is a single
656+
// trimmed line (no newlines), so [^\n]{0,N} is equivalent to the prior `.*`:
657+
// greedy match strips everything up to and including the LAST keyword plus its
658+
// trailing whitespace, preserving the word-boundary semantics exactly.
652659
if (isAlways) {
653-
action = line.replace(/.*\bALWAYS\b\s*/i, '').substring(0, ACTION_COMPARISON_LENGTH);
660+
action = line.replace(/[^\n]{0,2000}\bALWAYS\b\s{0,200}/i, '').substring(0, ACTION_COMPARISON_LENGTH);
654661
} else {
655-
action = line.replace(/.*\b(?:NEVER|DO NOT)\b\s*/i, '').substring(0, ACTION_COMPARISON_LENGTH);
662+
action = line.replace(/[^\n]{0,2000}\b(?:NEVER|DO NOT)\b\s{0,200}/i, '').substring(0, ACTION_COMPARISON_LENGTH);
656663
}
657664

658665
// Extract significant keywords from action

lib/enhance/docs-patterns.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ const docsPatterns = {
2424
if (!content || typeof content !== 'string') return null;
2525

2626
// Find markdown links
27-
const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
27+
// ReDoS fix: bound the negated-class captures so the matcher is linear;
28+
// bounds far exceed any realistic markdown link, so matches are unchanged.
29+
const linkRegex = /\[([^\]]{1,2000})\]\(([^)]{1,4000})\)/g;
2830
const brokenLinks = [];
2931
let match;
3032

@@ -40,7 +42,9 @@ const docsPatterns = {
4042
if (linkTarget.startsWith('#')) {
4143
const anchorId = linkTarget.slice(1).toLowerCase();
4244
// Generate expected heading anchors from content
43-
const headings = content.match(/^#{1,6}\s+(.+)$/gm) || [];
45+
// ReDoS fix: bound the \s+ run; line-anchored (.+) cannot cross newlines
46+
// so the same headings match as before.
47+
const headings = content.match(/^#{1,6}\s{1,1000}(.+)$/gm) || [];
4448
const anchors = headings.map(h => {
4549
return h.replace(/^#{1,6}\s+/, '')
4650
.toLowerCase()

0 commit comments

Comments
 (0)