Files
braindump/src/vault/pathSafety.ts
T
codingetandClaude a5f62512e8 initial implementation: brain-dump session server with dual LLM backends
Headless Koa v3 server exposing REST + SSE around the brain-dump skill:
- AgentBackend adapter seam with two implementations: Claude Agent SDK
  (skill loading, session resume via sdk_session_id) and a hand-rolled
  OpenAI-compatible tool-calling loop (vault-scoped file tools, SKILL.md
  injected into the system prompt, history persisted in SQLite)
- session CRUD + one-turn-at-a-time SSE streaming (text deltas, file_write
  events, turn_complete), per-session lock, abort on disconnect
- node:sqlite storage (sessions, messages, turn_events) with migrations
- vault allow-list + symlink-aware path-escape prevention (incl. dangling
  symlink defense), read-only git status/diff endpoints, configurable CORS
- deterministic mock chat-completions server (scripts/mock-openai.ts) for
  end-to-end testing without a real model

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 13:48:17 +00:00

82 lines
3.2 KiB
TypeScript

import fs from "node:fs";
import path from "node:path";
import { VaultEscapeError } from "../errors.js";
/**
* Validate that `relPath` resolves to a location inside `vaultRoot`, and
* return the resolved absolute path. Throws VaultEscapeError otherwise.
*
* Defends against:
* - absolute paths passed as "relative"
* - `..` segments that walk out of the vault (even if they net back inside,
* we reject on principle — see below for the "stays inside" test case,
* which is allowed because the normalized path never leaves the root)
* - symlinks inside the vault that point outside of it
*/
export function assertInsideVault(vaultRoot: string, relPath: string): string {
if (path.isAbsolute(relPath)) {
throw new VaultEscapeError(`Path must be relative: ${relPath}`);
}
const normalized = path.normalize(relPath);
if (normalized === ".." || normalized.startsWith(`..${path.sep}`)) {
throw new VaultEscapeError(`Path escapes vault: ${relPath}`);
}
const resolvedRoot = path.resolve(vaultRoot);
const candidate = path.resolve(resolvedRoot, normalized);
// Belt-and-suspenders: candidate must still be within the root textually.
if (candidate !== resolvedRoot && !candidate.startsWith(resolvedRoot + path.sep)) {
throw new VaultEscapeError(`Path escapes vault: ${relPath}`);
}
// Symlink defense: realpath the deepest *existing* ancestor of the
// candidate (the candidate itself may not exist yet, e.g. a new file
// about to be written) and verify it lives inside the realpath'd vault
// root. This catches a symlink anywhere along the existing path prefix
// that would otherwise redirect us outside the vault.
let realRoot: string;
try {
realRoot = fs.realpathSync(resolvedRoot);
} catch {
// Vault root doesn't exist on disk (shouldn't normally happen) — fall
// back to the textual root.
realRoot = resolvedRoot;
}
// Walk up to the deepest ancestor that exists *as a filesystem entry of any
// kind*. Use lstatSync, not existsSync: existsSync follows symlinks, so a
// dangling symlink (e.g. vault/link -> /outside/does-not-exist) would be
// treated as "not existing" and skipped, letting a later mkdir -p + write
// follow the link and escape the vault. lstatSync treats the symlink itself
// as an existing entry so we stop on it and resolve its real target below.
let ancestor = candidate;
while (true) {
try {
fs.lstatSync(ancestor);
break;
} catch {
const parent = path.dirname(ancestor);
if (parent === ancestor) break; // reached filesystem root without finding anything
ancestor = parent;
}
}
// Fully resolve the ancestor (following any symlinks). If it's a dangling
// symlink, realpathSync throws — we reject, because we cannot prove the link
// stays inside the vault and following it on write could escape.
let realAncestor: string;
try {
realAncestor = fs.realpathSync(ancestor);
} catch {
throw new VaultEscapeError(`Path resolves through an unresolvable/broken symlink: ${relPath}`);
}
if (realAncestor !== realRoot && !realAncestor.startsWith(realRoot + path.sep)) {
throw new VaultEscapeError(`Path escapes vault via symlink: ${relPath}`);
}
return candidate;
}