diff --git a/nemoclaw/src/blueprint/runner.test.ts b/nemoclaw/src/blueprint/runner.test.ts index 403bd9d04..8479cb5d8 100644 --- a/nemoclaw/src/blueprint/runner.test.ts +++ b/nemoclaw/src/blueprint/runner.test.ts @@ -550,6 +550,25 @@ describe("runner", () => { actionStatus("nc-run-1"); expect(stdoutText()).toContain('"status":"unknown"'); }); + + // ── Path traversal rejection ────────────────────────────────── + + it.each(["../../etc", "../tmp", "valid.with.dots", "foo\x00bar", "/absolute/path"])( + "rejects malicious run ID: %j", + (rid) => { + expect(() => { + actionStatus(rid); + }).toThrow(/Invalid run ID/); + }, + ); + + it("accepts a legitimate hyphenated run ID", () => { + const rid = "nc-20260406-abc12345"; + addDir(`${RUNS_DIR}/${rid}`); + addFile(`${RUNS_DIR}/${rid}/plan.json`, JSON.stringify({ run_id: rid })); + actionStatus(rid); + expect(stdoutText()).toContain(rid); + }); }); describe("actionRollback", () => { @@ -604,6 +623,15 @@ describe("runner", () => { expect(store.has(`${runDir}/rolled_back`)).toBe(true); }); + // ── Path traversal rejection ────────────────────────────────── + + it.each(["../../etc", "../tmp", "valid.with.dots", "foo\x00bar", "/absolute/path", ""])( + "rejects malicious run ID: %j", + async (rid) => { + await expect(actionRollback(rid)).rejects.toThrow(/Invalid run ID/); + }, + ); + it("defaults sandbox_name to 'openclaw' when not in plan", async () => { const runDir = `${RUNS_DIR}/nc-run-1`; addDir(runDir); diff --git a/nemoclaw/src/blueprint/runner.ts b/nemoclaw/src/blueprint/runner.ts index cf3aa56b8..13aab935d 100644 --- a/nemoclaw/src/blueprint/runner.ts +++ b/nemoclaw/src/blueprint/runner.ts @@ -15,7 +15,7 @@ import { randomUUID } from "node:crypto"; import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; -import { join } from "node:path"; +import { join, sep } from "node:path"; import { execa } from "execa"; import YAML from "yaml"; @@ -340,13 +340,30 @@ export async function actionApply( log(`Inference: ${providerName} -> ${model} @ ${endpoint}`); } +function validateRunId(rid: string): void { + if (!/^[a-zA-Z0-9_-]+$/.test(rid)) { + throw new Error( + `Invalid run ID: must contain only alphanumeric characters, hyphens, and underscores`, + ); + } +} + +function safeRunDir(runsDir: string, rid: string): string { + validateRunId(rid); + const resolved = join(runsDir, rid); + if (!resolved.startsWith(runsDir + sep)) { + throw new Error("Run ID resolves outside expected directory"); + } + return resolved; +} + export function actionStatus(rid?: string): void { emitRunId(); const runsDir = join(homedir(), ".nemoclaw", "state", "runs"); let runDir: string; if (rid) { - runDir = join(runsDir, rid); + runDir = safeRunDir(runsDir, rid); } else { let runs: string[]; try { @@ -373,7 +390,8 @@ export function actionStatus(rid?: string): void { export async function actionRollback(rid: string): Promise { emitRunId(); - const stateDir = join(homedir(), ".nemoclaw", "state", "runs", rid); + const runsDir = join(homedir(), ".nemoclaw", "state", "runs"); + const stateDir = safeRunDir(runsDir, rid); try { readdirSync(stateDir); } catch {