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>
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user