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.11] — 2026-07-23

### Fixed

- MCP `codecarto_publish` arbitrary file read via `spec_path` (security). The `readSpecArg` function accepted any absolute file path for `spec_path` without enforcing containment, allowing an MCP client to read any file on the filesystem (e.g. `/etc/passwd`). Added path containment check using `isWithinPathResolved`: `spec_path` must be within the workspace `.codecarto/` directory or the configured library path. Regression tests verify both rejection of paths outside allowed roots and acceptance of paths within the workspace. Discovered during the v0.12.9 self-analysis case study security triage.

## [0.12.10] — 2026-07-23

### Fixed
Expand Down
26 changes: 24 additions & 2 deletions mcp-server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
getPipelineLabel,
getWorkspaceState,
isValidSlug,
isWithinPathResolved,
type LibraryIndexEntry,
type LibraryVisibility,
listEntries,
Expand Down Expand Up @@ -429,7 +430,7 @@ function buildGenerationFromArg(model_metadata: unknown): EntryGeneration {
return out;
}

async function readSpecArg(args: { spec?: unknown; spec_path?: unknown }): Promise<string> {
async function readSpecArg(args: { spec?: unknown; spec_path?: unknown; cwd?: unknown }, allowedRoots: string[] = []): Promise<string> {
if (typeof args.spec === "string" && args.spec.length > 0) return args.spec;
if (typeof args.spec_path === "string" && args.spec_path.length > 0) {
if (!isAbsolute(args.spec_path)) {
Expand All @@ -438,6 +439,21 @@ async function readSpecArg(args: { spec?: unknown; spec_path?: unknown }): Promi
if (!(await pathExists(args.spec_path))) {
throw new McpError(ErrorCode.InvalidParams, `spec_path does not exist: ${args.spec_path}`);
}
// Enforce path containment: spec_path must be within an allowed root
// (cwd's .codecarto/ or the configured library path) to prevent
// arbitrary file reads.
if (allowedRoots.length > 0) {
const resolvedSpecPath = await canonicalPath(args.spec_path);
const withinAllowed = await Promise.all(
allowedRoots.map((root) => isWithinPathResolved(resolvedSpecPath, root)),
);
if (!withinAllowed.some((result) => result)) {
throw new McpError(
ErrorCode.InvalidParams,
`spec_path must be within the workspace (.codecarto/) or the configured library path. Got: ${args.spec_path}`,
);
}
}
return readFile(args.spec_path, "utf8");
}
throw new McpError(ErrorCode.InvalidParams, "Either spec (inline content) or spec_path (absolute file path) is required");
Expand Down Expand Up @@ -487,7 +503,13 @@ export async function handlePublish(args: Record<string, unknown>) {
);
}

const spec = await readSpecArg(args);
// Build allowed roots for spec_path containment: workspace .codecarto/ and library path
const allowedRoots: string[] = [libraryPath];
if (typeof args.cwd === "string" && args.cwd.trim() !== "") {
allowedRoots.push(join(args.cwd.trim(), ".codecarto"));
}

const spec = await readSpecArg(args, allowedRoots);
if (typeof args.source_repo !== "string" || args.source_repo.trim() === "") {
throw new McpError(ErrorCode.InvalidParams, "source_repo is required");
}
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.10",
"version": "0.12.11",
"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.10",
"version": "0.12.11",
"packages": [
{
"registryType": "npm",
"identifier": "codecartographer-pi",
"version": "0.12.10",
"version": "0.12.11",
"transport": {
"type": "stdio"
}
Expand Down
74 changes: 74 additions & 0 deletions tests/publish-path-containment.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdir, mkdtemp, writeFile, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { handlePublish } from "../mcp-server/server.ts";

let tmpRoot;

async function setup() {
tmpRoot = await mkdtemp(join(tmpdir(), "cc-publish-test-"));
const workspaceDir = join(tmpRoot, "target", ".codecarto");
const libraryDir = join(tmpRoot, "library");
await mkdir(join(workspaceDir, "workflow"), { recursive: true });
await mkdir(join(workspaceDir, "findings", "reimplementation-spec"), { recursive: true });
await mkdir(libraryDir, { recursive: true });

await writeFile(join(libraryDir, ".codecarto-library"), JSON.stringify({
schema_version: 1,
name: "test-library",
visibility: "internal",
namespaced: false,
created_at: "2026-01-01T00:00:00Z",
}));

const specContent = "# Test Spec\n\n## System Summary\n\nA test reimplementation spec.";
await writeFile(join(workspaceDir, "findings", "reimplementation-spec", "reimplementation-spec.md"), specContent);

await mkdir(join(tmpRoot, "outside"), { recursive: true });
await writeFile(join(tmpRoot, "outside", "secret.txt"), "SECRET CONTENT");

return { workspaceDir, libraryDir, specContent, secretPath: join(tmpRoot, "outside", "secret.txt") };
}

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

test("codecarto_publish rejects spec_path outside workspace and library", async () => {
const { secretPath, libraryDir } = await setup();
try {
await assert.rejects(
handlePublish({
library_path: libraryDir,
source_repo: "https://github.com/test/repo",
headline: "Test spec",
spec_path: secretPath,
}),
(err) => {
assert.match(err.message, /must be within the workspace/i);
return true;
},
);
} finally {
await cleanup();
}
});

test("codecarto_publish accepts spec_path within workspace", async () => {
const { workspaceDir, libraryDir } = await setup();
try {
await handlePublish({
library_path: libraryDir,
cwd: join(workspaceDir, ".."),
source_repo: "https://github.com/test/repo",
headline: "Test spec",
spec_path: join(workspaceDir, "findings", "reimplementation-spec", "reimplementation-spec.md"),
});
} catch (err) {
assert.doesNotMatch(err.message, /must be within the workspace/i);
} finally {
await cleanup();
}
});