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,92 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { assertInsideVault } from "./pathSafety.js";
|
||||
import { VaultEscapeError } from "../errors.js";
|
||||
|
||||
function makeTmpVault(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "braindump-vault-"));
|
||||
return dir;
|
||||
}
|
||||
|
||||
test("normal relative path resolves inside vault", () => {
|
||||
const vault = makeTmpVault();
|
||||
fs.writeFileSync(path.join(vault, "note.md"), "hello");
|
||||
const resolved = assertInsideVault(vault, "note.md");
|
||||
assert.equal(resolved, path.join(vault, "note.md"));
|
||||
});
|
||||
|
||||
test("nested new path resolves even if parent doesn't exist yet", () => {
|
||||
const vault = makeTmpVault();
|
||||
const resolved = assertInsideVault(vault, "projects/foo/bar.md");
|
||||
assert.equal(resolved, path.join(vault, "projects", "foo", "bar.md"));
|
||||
});
|
||||
|
||||
test("../escape is rejected", () => {
|
||||
const vault = makeTmpVault();
|
||||
assert.throws(() => assertInsideVault(vault, "../escape"), VaultEscapeError);
|
||||
});
|
||||
|
||||
test("absolute path is rejected", () => {
|
||||
const vault = makeTmpVault();
|
||||
assert.throws(() => assertInsideVault(vault, "/etc/passwd"), VaultEscapeError);
|
||||
});
|
||||
|
||||
test("embedded .. that escapes is rejected", () => {
|
||||
const vault = makeTmpVault();
|
||||
assert.throws(
|
||||
() => assertInsideVault(vault, "projects/../../escape"),
|
||||
VaultEscapeError
|
||||
);
|
||||
});
|
||||
|
||||
test("embedded .. that stays inside is allowed", () => {
|
||||
const vault = makeTmpVault();
|
||||
fs.mkdirSync(path.join(vault, "projects", "foo"), { recursive: true });
|
||||
const resolved = assertInsideVault(vault, "projects/foo/../bar.md");
|
||||
assert.equal(resolved, path.join(vault, "projects", "bar.md"));
|
||||
});
|
||||
|
||||
test("symlink inside vault pointing outside vault is rejected", () => {
|
||||
const vault = makeTmpVault();
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "braindump-outside-"));
|
||||
fs.writeFileSync(path.join(outside, "secret.txt"), "nope");
|
||||
fs.symlinkSync(outside, path.join(vault, "escape-link"), "dir");
|
||||
assert.throws(
|
||||
() => assertInsideVault(vault, "escape-link/secret.txt"),
|
||||
VaultEscapeError
|
||||
);
|
||||
});
|
||||
|
||||
test("writing a NEW file through a symlink that points outside is rejected", () => {
|
||||
const vault = makeTmpVault();
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "braindump-outside-"));
|
||||
fs.symlinkSync(outside, path.join(vault, "escape-link"), "dir");
|
||||
// outside dir exists but the target file does not — this is the write path.
|
||||
assert.throws(
|
||||
() => assertInsideVault(vault, "escape-link/newfile.md"),
|
||||
VaultEscapeError
|
||||
);
|
||||
});
|
||||
|
||||
test("dangling symlink pointing outside vault is rejected (escape via mkdir -p)", () => {
|
||||
const vault = makeTmpVault();
|
||||
const outsideBase = fs.mkdtempSync(path.join(os.tmpdir(), "braindump-outside-"));
|
||||
// Symlink target does NOT exist yet — existsSync would treat the link as
|
||||
// absent and wrongly allow it; lstat-based walk must still reject.
|
||||
fs.symlinkSync(path.join(outsideBase, "nonexistent"), path.join(vault, "dangling"), "dir");
|
||||
assert.throws(
|
||||
() => assertInsideVault(vault, "dangling/pwned.md"),
|
||||
VaultEscapeError
|
||||
);
|
||||
});
|
||||
|
||||
test("symlink to a directory INSIDE the vault is allowed", () => {
|
||||
const vault = makeTmpVault();
|
||||
fs.mkdirSync(path.join(vault, "real"), { recursive: true });
|
||||
fs.symlinkSync(path.join(vault, "real"), path.join(vault, "alias"), "dir");
|
||||
const resolved = assertInsideVault(vault, "alias/note.md");
|
||||
assert.equal(resolved, path.join(vault, "alias", "note.md"));
|
||||
});
|
||||
Reference in New Issue
Block a user