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
45 changes: 40 additions & 5 deletions src/decompress-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { debug, logError, logInfo, logThrow } from "./log.js";
import { parseBlockIdArg, collectBlockContent, type CompressionBlock } from "acp-kernel";
import { entriesToCoreMessages } from "./messages.js";
import { writeFile, mkdir } from "node:fs/promises";
import { resolve, relative, isAbsolute, join } from "node:path";
import { existsSync, lstatSync, readlinkSync, realpathSync } from "node:fs";
import { resolve, relative, isAbsolute, join, basename, dirname } from "node:path";
import { tmpdir, homedir } from "node:os";

/** Directory for auto-generated decompress output files. */
Expand Down Expand Up @@ -69,14 +70,48 @@ function resolveToFilePath(targetPath: string): string | { error: string } {
? join(homedir(), targetPath.slice(2))
: targetPath;
const resolved = resolve(expanded);
const isAllowed = ALLOWED_DIRS.some((dir) => {
const rel = relative(dir, resolved);
// Resolve symlinks in the longest existing ancestor before the containment
// check — a symlinked dir inside an allowed root must not escape it.
let probe = resolved;
const suffix: string[] = [];
while (!existsSync(probe) && probe !== dirname(probe)) {
suffix.unshift(basename(probe));
probe = dirname(probe);
}
const real = existsSync(probe) ? realpathSync(probe) : probe;
// Re-resolve any dangling symlinks among the suffix components. existsSync
// follows links, so a symlink whose target does not (yet) exist is treated
// as non-existent and skipped by the walk above — but writing through it
// would land at the (possibly outside) target. Resolve via lstat/readlink.
let checked = real;
for (const part of suffix) {
checked = join(checked, part);
try {
if (lstatSync(checked).isSymbolicLink()) {
const target = readlinkSync(checked);
checked = isAbsolute(target) ? resolve(target) : resolve(dirname(checked), target);
}
} catch {
// not statable or not a symlink — keep the literal component
}
}
// Compare against realpath'd roots too: tmpdir() often sits behind a
// symlink (/var -> /private/var on macOS) and the string forms diverge.
const allowed = ALLOWED_DIRS.map((d) => {
try {
return realpathSync(d);
} catch {
return d; // root does not exist yet — keep the literal form
}
});
const isAllowed = allowed.some((dir) => {
const rel = relative(dir, checked);
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
});
if (!isAllowed) {
return { error: `Error: toFile path must be under ${tmpdir()}, ~/.cache/opencode, or ~/.cache/pi. Got: ${targetPath}` };
}
return resolved;
return checked;
}

/** Generate a unique auto file path for a block. Uses a timestamp so repeated
Expand Down Expand Up @@ -181,7 +216,7 @@ async function handleMessageRef(

async function handleDecompress(args: DecompressArgs, runtime: AcpRuntime, ctx: ExtensionContext): Promise<string> {
const { state, coreMessages } = await runtime.stateFor(ctx);
const arg = args.blockId.trim();
const arg = (args.blockId ?? "").trim();

// Resolve what `arg` refers to. Check message-ref FIRST (data-driven: a ref
// exists in some block's effectiveMessageIds). This must precede block-id
Expand Down
31 changes: 29 additions & 2 deletions tests/decompress-tool.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { rm, readFile, mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { rm, readFile, mkdtemp, symlink } from "node:fs/promises";
import { tmpdir, homedir } from "node:os";
import { join } from "node:path";
import { createAcpExtension } from "../src/index.js";

Expand Down Expand Up @@ -132,6 +132,33 @@ test("decompress toFile rejects paths outside allowed roots", async () => {
assert.match(text, /must be under/i, "rejects arbitrary filesystem path");
});

test("decompress toFile rejects paths that escape an allowed root via a symlink", async () => {
const { decompressTool, ctx } = await setupWithCompressedBlock();
const jail = await mkdtemp(join(tmpdir(), "pai-acp-jail-"));
// A symlink inside the (allowed) jail that points OUTSIDE all allowed roots.
// /etc exists on both Linux and macOS, so realpath can follow the link.
// The literal path looks contained (jail/evil-link/x.txt under tmpdir), but
// resolving the symlink reveals it lands in /etc — must be rejected.
const link = join(jail, "evil-link");
await symlink("/etc", link);
const target = join(link, "passwd.txt");
const res = await decompressTool.execute("tc-sym", { blockId: "b1", toFile: target }, undefined, undefined, ctx);
const text = (res.content[0] as any).text as string;
assert.match(text, /must be under/i, "rejects path escaping an allowed root through a symlink");
});

test("decompress toFile rejects a dangling symlink whose target escapes the allowed roots", async () => {
const { decompressTool, ctx } = await setupWithCompressedBlock();
const jail = await mkdtemp(join(tmpdir(), "pai-acp-jail-dangling-"));
const link = join(jail, "dangling-link");
const escapedTarget = join(homedir(), `acp-dangling-${Date.now()}-${Math.random().toString(36).slice(2)}`);
await symlink(escapedTarget, link);
const target = join(link, "out.txt");
const res = await decompressTool.execute("tc-sym2", { blockId: "b1", toFile: target }, undefined, undefined, ctx);
const text = (res.content[0] as any).text as string;
assert.match(text, /must be under/i, "rejects a dangling symlink that would write outside allowed roots");
});

test("decompress keeps the block active after a file-mode call", async () => {
const { decompressTool, ctx } = await setupWithCompressedBlock();
await decompressTool.execute("tc6", { blockId: "b1" }, undefined, undefined, ctx);
Expand Down
Loading