Skip to content

Commit 542eb81

Browse files
avifeneshagent-core-bot
andauthored
chore: sync core lib and CLAUDE.md from agent-core (#375)
Co-authored-by: agent-core-bot <noreply@agent-sh.github.io>
1 parent 5fe2f51 commit 542eb81

9 files changed

Lines changed: 104 additions & 41 deletions

File tree

lib/collectors/codebase.js

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
const fs = require('fs');
1313
const path = require('path');
14+
const { readFileWithLimit } = require('../utils/fs-safe');
1415

1516
const DEFAULT_OPTIONS = {
1617
depth: 'thorough',
@@ -194,10 +195,10 @@ function scanFileSymbols(basePath, topLevelDirs) {
194195
if (entry.name.includes('.test.') || entry.name.includes('.spec.')) continue;
195196

196197
try {
197-
const stat = fs.statSync(fullPath);
198-
if (stat.size > MAX_FILE_SIZE) continue;
199-
200-
const content = fs.readFileSync(fullPath, 'utf8');
198+
// Open once and read through the fd (size check + read on the
199+
// same inode) to avoid a stat/read TOCTOU. Oversized or
200+
// unreadable files throw and are skipped by the catch below.
201+
const content = readFileWithLimit(fullPath, MAX_FILE_SIZE);
201202
const symbols = extractSymbols(content);
202203

203204
if (symbols.functions.length || symbols.classes.length || symbols.exports.length) {

lib/enhance/auto-suppression.js

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
const fs = require('fs');
1515
const path = require('path');
1616
const { execFileSync } = require('child_process');
17+
const { readFileWithLimit } = require('../utils/fs-safe');
18+
const { writeJsonAtomic } = require('../utils/atomic-write');
1719

1820
// Try to import cross-platform helpers
1921
let getSuppressionPath;
@@ -488,19 +490,20 @@ function saveAutoSuppressions(suppressionPath, projectId, findings) {
488490
*/
489491
function clearAutoSuppressions(suppressionPath, projectId) {
490492
try {
491-
if (!fs.existsSync(suppressionPath)) return;
492-
493-
const data = JSON.parse(fs.readFileSync(suppressionPath, 'utf8'));
493+
// Read via fd (no existsSync pre-check) and write atomically, so neither
494+
// the read nor the write races against a swap of the path. A missing file
495+
// throws ENOENT and is swallowed below.
496+
const data = JSON.parse(readFileWithLimit(suppressionPath));
494497

495498
if (data.projects?.[projectId]?.auto_learned) {
496499
data.projects[projectId].auto_learned = {
497500
patterns: {},
498501
stats: { totalSuppressed: 0, lastAnalysis: new Date().toISOString() }
499502
};
500-
fs.writeFileSync(suppressionPath, JSON.stringify(data, null, 2));
503+
writeJsonAtomic(suppressionPath, data);
501504
}
502505
} catch {
503-
// Ignore errors
506+
// Missing file or write error: ignore.
504507
}
505508
}
506509

lib/enhance/cross-file-analyzer.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
const fs = require('fs');
1212
const path = require('path');
13+
const { readFileWithLimit } = require('../utils/fs-safe');
1314
const { parseMarkdownFrontmatter } = require('./agent-analyzer');
1415
const { crossFilePatterns, loadKnownTools } = require('./cross-file-patterns');
1516

@@ -474,8 +475,9 @@ function loadAllSkills(rootDir, options = {}) {
474475
if (!isPathWithinRoot(skillPath, rootDir)) continue;
475476

476477
try {
477-
fs.accessSync(skillPath);
478-
const content = fs.readFileSync(skillPath, 'utf8');
478+
// Read through a single fd (no separate access check) to avoid an
479+
// access/read TOCTOU; a missing/unreadable file throws and is skipped.
480+
const content = readFileWithLimit(skillPath);
479481
const { frontmatter, body } = parseMarkdownFrontmatter(content);
480482

481483
skills.push({

lib/enhance/docs-analyzer.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
const fs = require('fs');
88
const path = require('path');
9+
const { writeFileAtomic } = require('../utils/atomic-write');
910
const { getPatternsForMode, estimateTokens } = require('./docs-patterns');
1011

1112
function analyzeDoc(docPath, options = {}) {
@@ -252,6 +253,9 @@ function applyDocsFixes(issues, options = {}) {
252253
}
253254

254255
let content = fs.readFileSync(filePath, 'utf8');
256+
// Snapshot the on-disk content now so the backup does not re-read
257+
// filePath right before the write (which would be a read/write TOCTOU).
258+
const originalContent = content;
255259
const appliedToFile = [];
256260

257261
for (const issue of fileIssues) {
@@ -283,9 +287,9 @@ function applyDocsFixes(issues, options = {}) {
283287
// Write changes
284288
if (!dryRun && appliedToFile.length > 0) {
285289
if (backup) {
286-
fs.writeFileSync(`${filePath}.backup`, fs.readFileSync(filePath, 'utf8'), 'utf8');
290+
writeFileAtomic(`${filePath}.backup`, originalContent);
287291
}
288-
fs.writeFileSync(filePath, content, 'utf8');
292+
writeFileAtomic(filePath, content);
289293
}
290294

291295
results.applied.push(...appliedToFile);

lib/enhance/fixer.js

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
const fs = require('fs');
88
const path = require('path');
9+
const { writeFileAtomic } = require('../utils/atomic-write');
910

1011
/**
1112
* Reject symlinks before read/write operations.
@@ -195,12 +196,12 @@ function applyFixes(issues, options = {}) {
195196
} else {
196197
newContent = JSON.stringify(modified, null, 2);
197198
}
198-
// Re-check immediately before write. Narrows the TOCTOU window
199-
// between the initial lstat and this writeFileSync (an attacker
200-
// who swaps the regular file for a symlink between calls will
201-
// be caught here).
199+
// Refuse a symlink swap (explicit, early), then write atomically via
200+
// a temp file + rename. The rename replaces the path entry itself and
201+
// never follows a symlink, so the write cannot be redirected through a
202+
// swapped link - closing the lstat/write TOCTOU window.
202203
assertNotSymlink(filePath);
203-
fs.writeFileSync(filePath, newContent, 'utf8');
204+
writeFileAtomic(filePath, newContent);
204205
}
205206

206207
results.applied.push(...appliedToFile);

lib/enhance/prompt-analyzer.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
const fs = require('fs');
1010
const path = require('path');
11+
const { writeFileAtomic } = require('../utils/atomic-write');
1112
const { promptPatterns, estimateTokens, extractCodeBlocks } = require('./prompt-patterns');
1213
const reporter = require('./reporter');
1314

@@ -344,6 +345,9 @@ function applyFixes(results, options = {}) {
344345
}
345346

346347
let content = fs.readFileSync(filePath, 'utf8');
348+
// Snapshot the on-disk content now so the backup does not re-read
349+
// filePath right before the write (which would be a read/write TOCTOU).
350+
const originalContent = content;
347351
const appliedToFile = [];
348352

349353
for (const issue of fileIssues) {
@@ -369,9 +373,9 @@ function applyFixes(results, options = {}) {
369373
// Write changes
370374
if (!dryRun && appliedToFile.length > 0) {
371375
if (backup) {
372-
fs.writeFileSync(`${filePath}.backup`, fs.readFileSync(filePath, 'utf8'), 'utf8');
376+
writeFileAtomic(`${filePath}.backup`, originalContent);
373377
}
374-
fs.writeFileSync(filePath, content, 'utf8');
378+
writeFileAtomic(filePath, content);
375379
}
376380

377381
fixResults.applied.push(...appliedToFile);

lib/enhance/suppression.js

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
const fs = require('fs');
77
const path = require('path');
8+
const { readFileWithLimit } = require('../utils/fs-safe');
89

910
/**
1011
* Default suppression config
@@ -34,20 +35,18 @@ function loadConfig(projectRoot) {
3435
];
3536

3637
for (const configPath of configPaths) {
37-
if (fs.existsSync(configPath)) {
38-
try {
39-
// Check file size before reading to prevent DoS
40-
const stats = fs.statSync(configPath);
41-
if (stats.size > MAX_CONFIG_SIZE) {
42-
console.error(`[WARN] Config file too large (${stats.size} bytes), using defaults`);
43-
continue;
44-
}
45-
const content = fs.readFileSync(configPath, 'utf8');
46-
const userConfig = JSON.parse(content);
47-
return mergeConfig(DEFAULT_CONFIG, userConfig);
48-
} catch (err) {
49-
// Invalid config, use defaults
38+
try {
39+
// Open once and read through the fd (size check + read on the same
40+
// inode) to avoid an exists/stat/read TOCTOU. A missing file throws
41+
// ENOENT and falls through to the next candidate / defaults.
42+
const content = readFileWithLimit(configPath, MAX_CONFIG_SIZE);
43+
const userConfig = JSON.parse(content);
44+
return mergeConfig(DEFAULT_CONFIG, userConfig);
45+
} catch (err) {
46+
if (err && err.code === 'EFBIG') {
47+
console.error('[WARN] Config file too large, using defaults');
5048
}
49+
// Missing or invalid config: try the next candidate / defaults.
5150
}
5251
}
5352

lib/patterns/slop-analyzers.js

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -858,15 +858,22 @@ function countEntryPointExports(repoPath, options = {}) {
858858
for (const entry of ENTRY_POINTS) {
859859
const fullPath = path.join(repoPath, entry);
860860
try {
861-
const stat = fs.statSync(fullPath);
862-
if (stat.isFile()) {
863-
const content = fs.readFileSync(fullPath, 'utf8');
864-
const lang = detectLanguage(entry);
865-
const exports = countExportsInContent(content, lang);
866-
if (exports > 0) {
867-
foundEntries.push(entry);
868-
count += exports;
861+
// Open once and check/read through the fd so the is-file check and the
862+
// read apply to the same inode (no stat/read TOCTOU). Uses the injected
863+
// fs so mocked tests keep working.
864+
const fd = fs.openSync(fullPath, 'r');
865+
try {
866+
if (fs.fstatSync(fd).isFile()) {
867+
const content = fs.readFileSync(fd, 'utf8');
868+
const lang = detectLanguage(entry);
869+
const exports = countExportsInContent(content, lang);
870+
if (exports > 0) {
871+
foundEntries.push(entry);
872+
count += exports;
873+
}
869874
}
875+
} finally {
876+
fs.closeSync(fd);
870877
}
871878
} catch {
872879
// File doesn't exist, try next

lib/utils/fs-safe.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
'use strict';
2+
3+
const fs = require('fs');
4+
5+
/**
6+
* Read a file with an optional size cap, free of check-then-use (TOCTOU)
7+
* races.
8+
*
9+
* The path is opened once and every subsequent operation (the regular-file
10+
* check, the size check, the read) acts on that file descriptor. Because the
11+
* descriptor is bound to a single inode, swapping the path for another file or
12+
* a symlink between calls cannot redirect the read - which a separate
13+
* `fs.statSync(path)` / `fs.existsSync(path)` followed by `fs.readFileSync(path)`
14+
* cannot guarantee.
15+
*
16+
* @param {string} filePath
17+
* @param {number} [maxSize] - byte ceiling; larger files throw `EFBIG`
18+
* @param {BufferEncoding} [encoding='utf8']
19+
* @returns {string|Buffer} file contents (string when an encoding is given)
20+
* @throws if the path is missing, not a regular file, too large, or unreadable
21+
*/
22+
function readFileWithLimit(filePath, maxSize, encoding = 'utf8') {
23+
const fd = fs.openSync(filePath, 'r');
24+
try {
25+
const stat = fs.fstatSync(fd);
26+
if (!stat.isFile()) {
27+
const err = new Error(`Not a regular file: ${filePath}`);
28+
err.code = 'ENOTFILE';
29+
throw err;
30+
}
31+
if (typeof maxSize === 'number' && stat.size > maxSize) {
32+
const err = new Error(`File too large: ${stat.size} > ${maxSize} bytes`);
33+
err.code = 'EFBIG';
34+
throw err;
35+
}
36+
return fs.readFileSync(fd, encoding);
37+
} finally {
38+
fs.closeSync(fd);
39+
}
40+
}
41+
42+
module.exports = { readFileWithLimit };

0 commit comments

Comments
 (0)