Skip to content

Commit b5cce8e

Browse files
betegonclaude
andcommitted
fix: validate remote-supplied cwd against project directory
The remote workflow controls payload.cwd, but handleLocalOp never checked that it falls within options.directory. A misbehaving workflow could set cwd:"/" to escape the path sandbox entirely. Now cwd is validated at the top of handleLocalOp before any operation runs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a1a1629 commit b5cce8e

2 files changed

Lines changed: 62 additions & 4 deletions

File tree

src/lib/init/local-ops.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,19 @@ export async function handleLocalOp(
149149
options: WizardOptions
150150
): Promise<LocalOpResult> {
151151
try {
152+
// Validate that the remote-supplied cwd is within the user's project directory
153+
const normalizedCwd = path.resolve(payload.cwd);
154+
const normalizedDir = path.resolve(options.directory);
155+
if (
156+
normalizedCwd !== normalizedDir &&
157+
!normalizedCwd.startsWith(normalizedDir + path.sep)
158+
) {
159+
return {
160+
ok: false,
161+
error: `Blocked: cwd "${payload.cwd}" is outside project directory "${options.directory}"`,
162+
};
163+
}
164+
152165
switch (payload.operation) {
153166
case "list-dir":
154167
return await listDir(payload);

test/lib/init/local-ops.test.ts

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,10 +111,11 @@ describe("validateCommand", () => {
111111

112112
describe("handleLocalOp", () => {
113113
let testDir: string;
114-
const options = makeOptions();
114+
let options: WizardOptions;
115115

116116
beforeEach(() => {
117117
testDir = mkdtempSync(join("/tmp", "local-ops-test-"));
118+
options = makeOptions({ directory: testDir });
118119
});
119120

120121
afterEach(() => {
@@ -182,6 +183,50 @@ describe("handleLocalOp", () => {
182183
});
183184
});
184185

186+
describe("cwd sandboxing", () => {
187+
test("rejects cwd outside project directory", async () => {
188+
const payload: ListDirPayload = {
189+
type: "local-op",
190+
operation: "list-dir",
191+
cwd: "/",
192+
params: { path: "." },
193+
};
194+
195+
const result = await handleLocalOp(payload, options);
196+
expect(result.ok).toBe(false);
197+
expect(result.error).toContain("outside project directory");
198+
});
199+
200+
test("allows cwd equal to project directory", async () => {
201+
writeFileSync(join(testDir, "file.txt"), "x");
202+
203+
const payload: ListDirPayload = {
204+
type: "local-op",
205+
operation: "list-dir",
206+
cwd: testDir,
207+
params: { path: "." },
208+
};
209+
210+
const result = await handleLocalOp(payload, options);
211+
expect(result.ok).toBe(true);
212+
});
213+
214+
test("allows cwd that is a subdirectory of project directory", async () => {
215+
mkdirSync(join(testDir, "sub"));
216+
writeFileSync(join(testDir, "sub", "file.txt"), "x");
217+
218+
const payload: ListDirPayload = {
219+
type: "local-op",
220+
operation: "list-dir",
221+
cwd: join(testDir, "sub"),
222+
params: { path: "." },
223+
};
224+
225+
const result = await handleLocalOp(payload, options);
226+
expect(result.ok).toBe(true);
227+
});
228+
});
229+
185230
describe("list-dir", () => {
186231
test("lists files and directories with correct types", async () => {
187232
writeFileSync(join(testDir, "file1.txt"), "a");
@@ -489,7 +534,7 @@ describe("handleLocalOp", () => {
489534
params: { commands: ["rm -rf /", "echo hello"] },
490535
};
491536

492-
const dryRunOptions = makeOptions({ dryRun: true });
537+
const dryRunOptions = makeOptions({ dryRun: true, directory: testDir });
493538
const result = await handleLocalOp(payload, dryRunOptions);
494539
expect(result.ok).toBe(true);
495540
const results = (
@@ -657,7 +702,7 @@ describe("handleLocalOp", () => {
657702
},
658703
};
659704

660-
const dryRunOptions = makeOptions({ dryRun: true });
705+
const dryRunOptions = makeOptions({ dryRun: true, directory: testDir });
661706
const result = await handleLocalOp(payload, dryRunOptions);
662707
expect(result.ok).toBe(true);
663708

@@ -681,7 +726,7 @@ describe("handleLocalOp", () => {
681726
},
682727
};
683728

684-
const dryRunOptions = makeOptions({ dryRun: true });
729+
const dryRunOptions = makeOptions({ dryRun: true, directory: testDir });
685730
const result = await handleLocalOp(payload, dryRunOptions);
686731
expect(result.ok).toBe(false);
687732
expect(result.error).toContain("outside project directory");

0 commit comments

Comments
 (0)