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>
82 lines
3.2 KiB
TypeScript
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;
|
|
}
|