Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
34 changes: 34 additions & 0 deletions nemoclaw/src/blueprint/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,27 @@
actionStatus("nc-run-1");
expect(stdoutText()).toContain('"status":"unknown"');
});

// ── Path traversal rejection ──────────────────────────────────

it.each([
"../../etc",
"../tmp",
"valid.with.dots",

Check failure on line 559 in nemoclaw/src/blueprint/runner.test.ts

View workflow job for this annotation

GitHub Actions / checks

[plugin] nemoclaw/src/blueprint/runner.test.ts > runner > actionStatus > rejects malicious run ID: ""

AssertionError: expected [Function] to throw an error - Expected: null + Received: undefined ❯ nemoclaw/src/blueprint/runner.test.ts:559:46
"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", () => {
Expand Down Expand Up @@ -604,6 +625,19 @@
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);
Expand Down
24 changes: 21 additions & 3 deletions nemoclaw/src/blueprint/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand All @@ -373,7 +390,8 @@ export function actionStatus(rid?: string): void {
export async function actionRollback(rid: string): Promise<void> {
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 {
Expand Down
Loading