Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/tools/propose-commit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,13 @@ function validateAbstraction(lines: string[]): ValidationError[] {
}

export async function proposeCommit(options: ProposeCommitOptions): Promise<string> {
const fullPath = resolve(options.rootDir, options.filePath);
const rootDir = resolve(options.rootDir);
const fullPath = resolve(rootDir, options.filePath);

if (fullPath !== rootDir && !fullPath.startsWith(rootDir + "/")) {
throw new Error(`Path traversal detected: resolved path escapes project root`);
}

const ext = extname(fullPath);
const lines = options.newContent.split("\n");
const allErrors: ValidationError[] = [];
Expand Down
112 changes: 112 additions & 0 deletions test/main/propose-commit-traversal.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// PoC test: CWE-22 Path Traversal in proposeCommit
// Demonstrates that an agent-supplied filePath with "../" can escape rootDir
// and write arbitrary files outside the project root.

import { describe, it, after, before } from "node:test";
import assert from "node:assert/strict";
import { proposeCommit } from "../../build/tools/propose-commit.js";
import { readFile, mkdir, rm, stat } from "fs/promises";
import { join, resolve } from "path";

const FIXTURE_ROOT = join(process.cwd(), "test", "_traversal_fixtures");
const PROJECT_DIR = join(FIXTURE_ROOT, "project");
const ESCAPE_TARGET = join(FIXTURE_ROOT, "escaped.txt");

describe("CWE-22: Path traversal in proposeCommit", async () => {
before(async () => {
await rm(FIXTURE_ROOT, { recursive: true, force: true });
await mkdir(PROJECT_DIR, { recursive: true });
});

it("should reject file_path that escapes rootDir via ../", async () => {
const maliciousPath = "../escaped.txt";
const maliciousContent = "PWNED - arbitrary file write outside project root";

let threw = false;
try {
await proposeCommit({
rootDir: PROJECT_DIR,
filePath: maliciousPath,
newContent: maliciousContent,
});
} catch (e) {
threw = true;
assert.ok(
e.message.toLowerCase().includes("traversal") ||
e.message.toLowerCase().includes("outside") ||
e.message.toLowerCase().includes("path"),
`Expected path traversal error, got: ${e.message}`
);
}

// Verify the file was NOT written outside rootDir
let fileExists = false;
try {
await stat(ESCAPE_TARGET);
fileExists = true;
} catch {
fileExists = false;
}

// Either it should have thrown, or the file should not exist
assert.ok(threw || !fileExists,
"proposeCommit should either throw on path traversal or not write the file outside rootDir");

// Stronger: it MUST throw
assert.ok(threw,
"proposeCommit MUST throw an error when file_path escapes rootDir");
});

it("should reject absolute paths outside rootDir", async () => {
const absoluteEscapePath = join(FIXTURE_ROOT, "escaped_abs.txt");

let threw = false;
try {
await proposeCommit({
rootDir: PROJECT_DIR,
filePath: absoluteEscapePath,
newContent: "PWNED via absolute path",
});
} catch (e) {
threw = true;
}

assert.ok(threw,
"proposeCommit MUST throw when an absolute path outside rootDir is provided");
});

it("should reject encoded traversal like ../../", async () => {
const maliciousPath = "subdir/../../escaped_deep.txt";

let threw = false;
try {
await proposeCommit({
rootDir: PROJECT_DIR,
filePath: maliciousPath,
newContent: "PWNED via nested traversal",
});
} catch (e) {
threw = true;
}

assert.ok(threw,
"proposeCommit MUST throw for nested directory traversal");
});

it("should still allow valid paths inside rootDir", async () => {
const content = "// Valid file\n// FEATURE: Test\n\nconst x = 1;\n";
const result = await proposeCommit({
rootDir: PROJECT_DIR,
filePath: "valid/test.ts",
newContent: content,
});
assert.ok(result.includes("✅") || result.includes("saved"),
"Valid paths inside rootDir should succeed");
const written = await readFile(join(PROJECT_DIR, "valid", "test.ts"), "utf-8");
assert.equal(written, content);
});

after(async () => {
await rm(FIXTURE_ROOT, { recursive: true, force: true });
});
});