Skip to content
Merged
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes to this project are documented here. The format is based on

## [Unreleased]

## [0.12.10] — 2026-07-23

### Fixed

- Symlink sandbox bypass in `isWithinPath` (security). The Pi extension's tool-call sandbox used lexical path comparison (`resolve()`) without resolving symlinks, allowing a sub-agent to create a symlink inside `.codecarto/` pointing to a file outside the allowed root and write through it. Added `isWithinPathResolved` which uses `realpath` before comparison, and updated the Pi tool-call hook to use it. Regression tests verify both the vulnerability (documented) and the fix. Discovered during the v0.12.9 self-analysis case study, independently confirmed, and fixed with regression coverage.

## [0.12.9] — 2026-07-23

### Fixed
Expand Down
21 changes: 21 additions & 0 deletions core/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,27 @@ export function isWithinPath(path: string, root: string): boolean {
return normalizedPath.startsWith(`${normalizedRoot}${process.platform === "win32" ? "\\" : "/"}`);
}

/**
* Symlink-aware version of isWithinPath. Resolves symlinks on both the path
* and root before comparing, preventing bypass via symlinks inside the
* allowed root that point outside it.
*
* Falls back to lexical isWithinPath if realpath fails (e.g., path does not
* exist yet), which is safe for write targets that haven't been created.
*/
export async function isWithinPathResolved(path: string, root: string): Promise<boolean> {
try {
const resolvedPath = await realpath(path);
const resolvedRoot = await realpath(root);
return isWithinPath(resolvedPath, resolvedRoot);
} catch {
// If realpath fails (path doesn't exist yet, broken symlink, etc.),
// fall back to lexical check. For write targets this is safe because
// the parent directory should already be within the root.
return isWithinPath(path, root);
}
}

export function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
Expand Down
6 changes: 5 additions & 1 deletion extensions/codecarto/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
getPipelineLabel,
getWorkspaceState,
isWithinPath,
isWithinPathResolved,
listSkillNames,
loadCodecartoConfig,
loadUsage,
Expand Down Expand Up @@ -217,7 +218,10 @@ export default function codeCartographerExtension(pi: ExtensionAPI) {
if (config.library.path && await discoverLibrary(config.library.path)) {
allowedRoots.push(await canonicalPath(config.library.path));
}
if (!allowedRoots.some((allowedRoot) => isWithinPath(targetPath, allowedRoot))) {
const withinAllowed = await Promise.all(
allowedRoots.map((allowedRoot) => isWithinPathResolved(targetPath, allowedRoot)),
);
if (!withinAllowed.some((result) => result)) {
if (ctx.hasUI) {
ctx.ui.notify(`Blocked ${event.toolName} outside .codecarto/ or configured library: ${inputPath}`, "warning");
}
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "codecartographer-pi",
"version": "0.12.9",
"version": "0.12.10",
"mcpName": "io.github.HuginnIndustries/codecartographer",
"description": "Evidence-backed reverse engineering and human-gated software planning for Pi and MCP coding agents.",
"type": "module",
Expand Down
4 changes: 2 additions & 2 deletions server.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@
"url": "https://github.com/HuginnIndustries/CodeCartographer",
"source": "github"
},
"version": "0.12.9",
"version": "0.12.10",
"packages": [
{
"registryType": "npm",
"identifier": "codecartographer-pi",
"version": "0.12.9",
"version": "0.12.10",
"transport": {
"type": "stdio"
}
Expand Down
60 changes: 60 additions & 0 deletions tests/symlink-sandbox.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdir, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { isWithinPath, isWithinPathResolved } from "../core/utils.ts";

let tmpRoot;

async function setup() {
tmpRoot = join(tmpdir(), `cc-symlink-test-${process.pid}-${Date.now()}`);
const allowedDir = join(tmpRoot, "allowed");
const outsideDir = join(tmpRoot, "outside");
await mkdir(allowedDir, { recursive: true });
await mkdir(outsideDir, { recursive: true });
await writeFile(join(outsideDir, "secret.txt"), "SECRET");
await symlink(join(outsideDir, "secret.txt"), join(allowedDir, "link.txt"));
return { allowedDir, outsideDir, symlinkPath: join(allowedDir, "link.txt") };
}

async function cleanup() {
await rm(tmpRoot, { recursive: true, force: true }).catch(() => {});
}

test("isWithinPath (lexical) is bypassed by symlinks - documents the vulnerability", async () => {
const { allowedDir, symlinkPath } = await setup();
try {
assert.equal(isWithinPath(symlinkPath, allowedDir), true);
} finally {
await cleanup();
}
});

test("isWithinPathResolved blocks symlinks pointing outside the root", async () => {
const { allowedDir, symlinkPath } = await setup();
try {
assert.equal(await isWithinPathResolved(symlinkPath, allowedDir), false);
} finally {
await cleanup();
}
});

test("isWithinPathResolved allows legitimate paths inside the root", async () => {
const { allowedDir } = await setup();
try {
const legitPath = join(allowedDir, "findings", "architecture.md");
assert.equal(await isWithinPathResolved(legitPath, allowedDir), true);
} finally {
await cleanup();
}
});

test("isWithinPathResolved allows the root itself", async () => {
const { allowedDir } = await setup();
try {
assert.equal(await isWithinPathResolved(allowedDir, allowedDir), true);
} finally {
await cleanup();
}
});