Skip to content

Commit e250533

Browse files
Merge pull request #51 from CodeAnt-AI/pranav/cod-1557-base-ref-injection-symlink-fixes
Block ref injection and out-of-repo symlink reads in --base (0.5.13)
2 parents 8d1eb94 + a0d7ce3 commit e250533

5 files changed

Lines changed: 216 additions & 17 deletions

File tree

‎changelog.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Changelog
22

3+
## [0.5.13] - 13/10/2026
4+
- Security: `--base` / `--base-commit` values (including from the MCP `codeant_review_local` tool) are resolved without a shell, and option-like values are rejected, so they can no longer run commands or pass options to git
5+
- Security: symlinks that point outside the repository, and files reached through them, are no longer read into review or secrets payloads. In 0.5.12, `--base` / `--base-commit` sent a committed symlink's target contents
6+
37
## [0.5.12] - 13/10/2026
48
- `review` / `secrets` with `--base` or `--base-commit` now include staged, unstaged, and untracked files (respecting `.gitignore`), not just commits. Previously `review --all --base main` silently skipped new files
59
- With `--base` / `--base-commit`, file content is read from the working tree so it matches the reviewed diff

‎package-lock.json‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codeant-cli",
3-
"version": "0.5.12",
3+
"version": "0.5.13",
44
"description": "Code review CLI tool",
55
"type": "module",
66
"repository": {

‎src/utils/gitDiffHelper.js‎

Lines changed: 68 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import fs from 'fs/promises';
2+
import { constants as fsConstants } from 'fs';
23
import path from 'path';
34
import { promisify } from 'util';
4-
import { exec } from 'child_process';
5+
import { exec, execFile } from 'child_process';
56

67
const execAsync = promisify(exec);
8+
const execFileAsync = promisify(execFile);
79

810
/**
911
* Expand untracked paths from `git status --porcelain`.
@@ -468,18 +470,58 @@ class GitDiffHelper {
468470
return diffs;
469471
}
470472

473+
/**
474+
* Read a working-tree file. Returns null if it resolves outside the repository (a symlink
475+
* pointing elsewhere) or isn't a regular file, so those contents are never uploaded.
476+
*/
477+
async _readRepoFile(relPath) {
478+
const realRoot = await fs.realpath(this.gitRoot);
479+
const isInside = p => {
480+
const rel = path.relative(realRoot, p);
481+
return !!rel && rel !== '..' && !rel.startsWith('..' + path.sep) && !path.isAbsolute(rel);
482+
};
483+
const realPath = await fs.realpath(path.resolve(this.gitRoot, relPath));
484+
if (!isInside(realPath)) return null;
485+
486+
// Open first and then check what was actually opened, so a symlink swapped in after the
487+
// check above can't redirect the read. O_NONBLOCK keeps a FIFO from blocking the open.
488+
const { O_RDONLY, O_NOFOLLOW = 0, O_NONBLOCK = 0 } = fsConstants;
489+
const handle = await fs.open(realPath, O_RDONLY | O_NOFOLLOW | O_NONBLOCK);
490+
try {
491+
const opened = await handle.stat();
492+
if (!opened.isFile()) return null;
493+
// The opened file must still be the file at this path, and the path must still resolve
494+
// inside the repo (a parent directory may have been swapped for a symlink)
495+
const nowPath = await fs.realpath(realPath);
496+
const now = await fs.stat(nowPath);
497+
if (!isInside(nowPath) || now.dev !== opened.dev || now.ino !== opened.ino) return null;
498+
return await handle.readFile('utf8');
499+
} finally {
500+
await handle.close();
501+
}
502+
}
503+
471504
/**
472505
* Build a synthetic "new file" diff for an untracked file, which git diff can't see.
473506
* Returns null if the file can't be read.
474507
*/
475508
async _buildUntrackedFileDiff(file) {
476509
try {
477-
const absolutePath = path.resolve(this.gitRoot, file);
478-
const content = await fs.readFile(absolutePath, 'utf8');
510+
let content = await this._readRepoFile(file).catch(() => null);
511+
let mode = '100644';
512+
if (content === null) {
513+
// A symlink we won't follow (it points outside the repo or nowhere): show the link
514+
// itself, as git would once it's committed, using its target path and never the
515+
// target's contents
516+
const absolutePath = path.resolve(this.gitRoot, file);
517+
if (!(await fs.lstat(absolutePath)).isSymbolicLink()) return null;
518+
content = await fs.readlink(absolutePath);
519+
mode = '120000';
520+
}
479521
const lines = content.split('\n');
480522

481523
let patchStr = `diff --git a/${file} b/${file}\n`;
482-
patchStr += `new file mode 100644\n`;
524+
patchStr += `new file mode ${mode}\n`;
483525
patchStr += `index 0000000..${this._generateRandomHash()}\n`;
484526
patchStr += `--- /dev/null\n`;
485527
patchStr += `+++ b/${file}\n`;
@@ -549,8 +591,7 @@ class GitDiffHelper {
549591
let resolvedRef;
550592
for (const candidate of [branch, `origin/${branch}`]) {
551593
try {
552-
const { stdout } = await execAsync(`git rev-parse ${candidate}`, { cwd: this.gitRoot });
553-
resolvedRef = stdout.trim();
594+
resolvedRef = await this._resolveCommitSha(candidate);
554595
break;
555596
} catch (err) {
556597
// try next
@@ -589,15 +630,29 @@ class GitDiffHelper {
589630
// Resolve the commit ref (supports HEAD~3, sha, tag, etc.)
590631
let resolvedCommit;
591632
try {
592-
const { stdout } = await execAsync(`git rev-parse ${commit}`, { cwd: this.gitRoot });
593-
resolvedCommit = stdout.trim();
633+
resolvedCommit = await this._resolveCommitSha(commit);
594634
} catch (err) {
595635
throw new Error(`Could not resolve commit: ${commit}`);
596636
}
597637

598638
return this._getWorkingTreeDiffAgainst(resolvedCommit);
599639
}
600640

641+
/**
642+
* Resolve a user-supplied ref (from the CLI or MCP) to a commit SHA without going through a
643+
* shell, so later git commands only ever see the SHA. Throws if it doesn't name a commit.
644+
*/
645+
async _resolveCommitSha(ref) {
646+
// A leading "-" would be parsed as a git option (e.g. --output=<file>)
647+
if (ref.startsWith('-')) throw new Error(`Invalid ref: ${ref}`);
648+
const { stdout } = await execFileAsync(
649+
'git',
650+
['rev-parse', '--verify', '--quiet', `${ref}^{commit}`],
651+
{ cwd: this.gitRoot }
652+
);
653+
return stdout.trim();
654+
}
655+
601656
/**
602657
* Diff a base commit against the working tree: commits since base, staged and unstaged
603658
* changes (including staged new files), plus untracked files not ignored by .gitignore.
@@ -1011,8 +1066,8 @@ class GitDiffHelper {
10111066
} catch (err) {
10121067
// Handle untracked files
10131068
try {
1014-
const absoluteFilePath = path.resolve(this.gitRoot, filePath);
1015-
const headFileStr = await fs.readFile(absoluteFilePath, 'utf8');
1069+
const headFileStr = await this._readRepoFile(filePath);
1070+
if (headFileStr === null) throw new Error('Not a regular file inside the repository');
10161071
const lines = headFileStr.split('\n');
10171072

10181073
let patchStr = `diff --git a/${filePath} b/${filePath}\n`;
@@ -1069,9 +1124,9 @@ class GitDiffHelper {
10691124
{ cwd: this.gitRoot }
10701125
).then(r => r.stdout).catch(() => '');
10711126
} else {
1072-
headFileStr = await fs.readFile(
1073-
path.join(this.gitRoot, filePath), 'utf8'
1074-
).catch(() => '');
1127+
headFileStr = await this._readRepoFile(filePath)
1128+
.then(content => content ?? '')
1129+
.catch(() => '');
10751130
}
10761131

10771132
// Get patch

‎tests/gitDiffHelper.test.js‎

Lines changed: 141 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
1+
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
22
import { execSync } from 'child_process';
33
import fs from 'fs';
4+
import fsp from 'fs/promises';
45
import path from 'path';
56
import os from 'os';
67
import GitDiffHelper from '../src/utils/gitDiffHelper.js';
@@ -1134,4 +1135,143 @@ describe('GitDiffHelper integration tests', () => {
11341135
).rejects.toThrow();
11351136
});
11361137
});
1138+
1139+
// ─────────────────────────────────────────────────────
1140+
// SECURITY: user-supplied refs and symlinks
1141+
// ─────────────────────────────────────────────────────
1142+
describe('security', () => {
1143+
let outsideDir;
1144+
let startBranch;
1145+
1146+
beforeEach(() => {
1147+
startBranch = git('rev-parse --abbrev-ref HEAD');
1148+
git('checkout -- .');
1149+
git('reset HEAD -- .');
1150+
git('clean -fd');
1151+
outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codeant-outside-'));
1152+
fs.writeFileSync(path.join(outsideDir, 'creds.txt'), 'OUTSIDE_SECRET\n');
1153+
fs.mkdirSync(path.join(outsideDir, 'dir'));
1154+
fs.writeFileSync(path.join(outsideDir, 'dir', 'inner.txt'), 'OUTSIDE_DIR_SECRET\n');
1155+
});
1156+
1157+
afterEach(() => {
1158+
git('reset HEAD -- .');
1159+
git('checkout -- .');
1160+
git('clean -fd');
1161+
if (git('rev-parse --abbrev-ref HEAD') !== startBranch) {
1162+
git(`checkout ${startBranch}`);
1163+
}
1164+
fs.rmSync(outsideDir, { recursive: true, force: true });
1165+
});
1166+
1167+
const leaked = diffs => diffs.filter(d => /OUTSIDE_(DIR_)?SECRET/.test(d.head_file_str + d.patch_str));
1168+
1169+
it('does not run shell metacharacters in a base branch or commit', async () => {
1170+
const marker = path.join(outsideDir, 'pwned');
1171+
const helper = await createHelper();
1172+
1173+
await expect(
1174+
helper.getDiffBasedOnReviewConfig({ type: 'base-branch', baseBranch: `HEAD; touch ${marker}` })
1175+
).rejects.toThrow('Could not resolve branch');
1176+
await expect(
1177+
helper.getDiffBasedOnReviewConfig({ type: 'base-commit', baseCommit: `HEAD$(touch ${marker})` })
1178+
).rejects.toThrow('Could not resolve commit');
1179+
1180+
expect(fs.existsSync(marker)).toBe(false);
1181+
});
1182+
1183+
it('rejects option-like refs instead of passing them to git', async () => {
1184+
const written = path.join(outsideDir, 'written.txt');
1185+
const helper = await createHelper();
1186+
1187+
await expect(
1188+
helper.getDiffBasedOnReviewConfig({ type: 'base-commit', baseCommit: `--output=${written}` })
1189+
).rejects.toThrow('Could not resolve commit');
1190+
1191+
expect(fs.existsSync(written)).toBe(false);
1192+
});
1193+
1194+
it('does not read untracked symlinks that point outside the repo', async () => {
1195+
fs.symlinkSync(path.join(outsideDir, 'creds.txt'), path.join(testDir, 'src/link-file'));
1196+
fs.symlinkSync(path.join(outsideDir, 'dir'), path.join(testDir, 'src/link-dir'));
1197+
1198+
const helper = await createHelper();
1199+
for (const config of [
1200+
{ type: 'base-commit', baseCommit: 'HEAD' },
1201+
{ type: 'uncommitted' },
1202+
{ type: 'all' },
1203+
]) {
1204+
const diffs = await helper.getDiffBasedOnReviewConfig(config);
1205+
expect(leaked(diffs)).toEqual([]);
1206+
}
1207+
});
1208+
1209+
it('shows untracked symlinks that point outside the repo as links, not their contents', async () => {
1210+
const target = path.join(outsideDir, 'creds.txt');
1211+
fs.symlinkSync(target, path.join(testDir, 'src/link-file'));
1212+
1213+
const helper = await createHelper();
1214+
const diffs = await helper.getDiffBasedOnReviewConfig({ type: 'base-commit', baseCommit: 'HEAD' });
1215+
1216+
const diff = diffs.find(d => d.filename_str === 'src/link-file');
1217+
expect(diff).toBeDefined();
1218+
expect(diff.edit_type_str).toBe('ADDED');
1219+
expect(diff.head_file_str).toBe(target);
1220+
expect(diff.patch_str).toContain('new file mode 120000');
1221+
expect(leaked(diffs)).toEqual([]);
1222+
});
1223+
1224+
it('does not follow a symlink swapped in after the path check', async () => {
1225+
writeFile('src/race/data.txt', 'inside\n');
1226+
fs.writeFileSync(path.join(outsideDir, 'data.txt'), 'OUTSIDE_SECRET\n');
1227+
const raceDir = path.join(testDir, 'src/race');
1228+
const realOpen = fsp.open;
1229+
let swapped = false;
1230+
// Swap the parent directory for a link to outside the repo between the check and the open
1231+
const spy = vi.spyOn(fsp, 'open').mockImplementation(async (file, ...rest) => {
1232+
if (!swapped && String(file).endsWith(path.join('race', 'data.txt'))) {
1233+
swapped = true;
1234+
fs.renameSync(raceDir, raceDir + '-orig');
1235+
fs.symlinkSync(outsideDir, raceDir);
1236+
}
1237+
return realOpen(file, ...rest);
1238+
});
1239+
1240+
try {
1241+
const helper = await createHelper();
1242+
const diffs = await helper.getDiffBasedOnReviewConfig({ type: 'uncommitted' });
1243+
expect(swapped).toBe(true);
1244+
expect(leaked(diffs)).toEqual([]);
1245+
} finally {
1246+
spy.mockRestore();
1247+
fs.rmSync(raceDir, { recursive: true, force: true });
1248+
fs.rmSync(raceDir + '-orig', { recursive: true, force: true });
1249+
}
1250+
});
1251+
1252+
it('does not read committed symlinks that point outside the repo', async () => {
1253+
const baseHash = git('rev-parse HEAD');
1254+
fs.symlinkSync(path.join(outsideDir, 'creds.txt'), path.join(testDir, 'src/committed-link'));
1255+
git('checkout -b security-committed-link');
1256+
git('add src/committed-link');
1257+
git('commit -m "committed link"');
1258+
1259+
const helper = await createHelper();
1260+
const diffs = await helper.getDiffBasedOnReviewConfig({ type: 'base-commit', baseCommit: baseHash });
1261+
1262+
expect(diffs.map(d => d.filename_str)).toContain('src/committed-link');
1263+
expect(leaked(diffs)).toEqual([]);
1264+
});
1265+
1266+
it('still reads symlinks that stay inside the repo', async () => {
1267+
fs.symlinkSync('index.js', path.join(testDir, 'src/inside-link.js'));
1268+
1269+
const helper = await createHelper();
1270+
const diffs = await helper.getDiffBasedOnReviewConfig({ type: 'base-commit', baseCommit: 'HEAD' });
1271+
1272+
const diff = diffs.find(d => d.filename_str === 'src/inside-link.js');
1273+
expect(diff).toBeDefined();
1274+
expect(diff.head_file_str).toBe(readFile('src/index.js'));
1275+
});
1276+
});
11371277
});

0 commit comments

Comments
 (0)