spb/autoscholar
Public
1import { mkdir, mkdtemp, symlink, writeFile } from "node:fs/promises";2import { tmpdir } from "node:os";3import { join } from "node:path";4import { beforeAll, describe, expect, it } from "vitest";5import { assertPublicUrl, createPolicyHook, isPrivateIp } from "./policy.js";67let ws: string;8let outside: string;9const denied: string[] = [];10let hook: ReturnType<typeof createPolicyHook>;11const call = (tool_name: string, tool_input: Record<string, unknown>) =>12 hook(13 {14 hook_event_name: "PreToolUse",15 tool_name,16 tool_input,17 tool_use_id: "t",18 session_id: "s",19 transcript_path: "",20 cwd: ws,21 } as never,22 "t",23 { signal: new AbortController().signal },24 );25const isDeny = (r: unknown) =>26 (r as { hookSpecificOutput?: { permissionDecision?: string } }).hookSpecificOutput?.permissionDecision ===27 "deny";2829beforeAll(async () => {30 ws = await mkdtemp(join(tmpdir(), "as-ws-"));31 outside = await mkdtemp(join(tmpdir(), "as-outside-"));32 await mkdir(join(ws, "results"), { recursive: true });33 await writeFile(join(ws, "CLAUDE.md"), "brief");34 await symlink(outside, join(ws, "results/link"));35 hook = createPolicyHook({36 workspace: ws,37 frozenPaths: ["CLAUDE.md", "input", "provenance"],38 onDenied: (t, r) => denied.push(`${t}:${r}`),39 });40});4142describe("platform policy hook", () => {43 it("denies privilege escalation, remote shells, system admin, LAN access and credential paths in Bash", async () => {44 for (const command of [45 "sudo -n true",46 "ssh M1M32 uptime",47 "launchctl list",48 "pm2 ls",49 "brew install wget",50 "curl -s http://192.168.2.72:22",51 "cat /etc/autoscholar/worker.env",52 "cat ~/.ssh/id_ed25519",53 "ls /Users/simon-pierreboucher",54 "cat /var/autoscholar/state/worker-token",55 "echo $ANTHROPIC_API_KEY",56 "nc -zv 10.0.0.1 22",57 ]) {58 expect(isDeny(await call("Bash", { command })), command).toBe(true);59 }60 expect(isDeny(await call("Bash", { command: "ls", dangerouslyDisableSandbox: true }))).toBe(true);61 });62 it("allows ordinary research commands", async () => {63 for (const command of [64 "uv venv .venv && uv pip install numpy pandas",65 "python3 code/analysis.py --seed 1",66 "tectonic -X compile main.tex",67 "shasum -a 256 data/raw.csv",68 "curl -s https://api.openalex.org/works?search=x",69 ]) {70 expect(isDeny(await call("Bash", { command })), command).toBe(false);71 }72 });73 it("confines writes to the workspace, blocks frozen paths and symlink escapes", async () => {74 expect(isDeny(await call("Write", { file_path: join(ws, "results/metrics.json") }))).toBe(false);75 expect(isDeny(await call("Write", { file_path: join(ws, "../escape.txt") }))).toBe(true);76 expect(isDeny(await call("Write", { file_path: "/tmp/anything.txt" }))).toBe(true);77 expect(isDeny(await call("Edit", { file_path: join(ws, "CLAUDE.md") }))).toBe(true);78 expect(isDeny(await call("Write", { file_path: join(ws, "input/run.json") }))).toBe(true);79 expect(isDeny(await call("Write", { file_path: join(ws, ".claude/settings.json") }))).toBe(true);80 expect(isDeny(await call("Write", { file_path: join(ws, "results/link/evil.txt") }))).toBe(true); // symlink to outside81 });82 it("reviewer sessions may only write under review/", async () => {83 const rev = createPolicyHook({ workspace: ws, frozenPaths: ["CLAUDE.md"], writableOnly: ["review/"] });84 const c = (p: string) =>85 rev(86 {87 hook_event_name: "PreToolUse",88 tool_name: "Write",89 tool_input: { file_path: join(ws, p) },90 tool_use_id: "t",91 session_id: "s",92 transcript_path: "",93 cwd: ws,94 } as never,95 "t",96 { signal: new AbortController().signal },97 );98 expect(isDeny(await c("review/review-1.md"))).toBe(false);99 expect(isDeny(await c("manuscript/main.tex"))).toBe(true);100 });101 it("blocks SSRF targets for WebFetch/MCP tools and reads of sensitive paths", async () => {102 for (const url of [103 "http://192.168.2.72:8470/",104 "http://localhost:5432/",105 "http://127.0.0.1/",106 "http://M3U96b.maclustr.io:8470/",107 "http://100.69.26.91/",108 "file:///etc/passwd",109 "http://169.254.169.254/latest/meta-data",110 ]) {111 expect(isDeny(await call("WebFetch", { url })), url).toBe(true);112 }113 expect(isDeny(await call("Read", { file_path: "/Users/simon-pierreboucher/.ssh/id_ed25519" }))).toBe(114 true,115 );116 expect(isDeny(await call("Read", { file_path: "/etc/autoscholar/worker.env" }))).toBe(true);117 expect(isDeny(await call("Read", { file_path: join(ws, "data/x.csv") }))).toBe(false);118 expect(isDeny(await call("SomeUnknownTool", {}))).toBe(true);119 });120 it("classifies private addresses", async () => {121 expect(isPrivateIp("10.1.2.3")).toBe(true);122 expect(isPrivateIp("192.168.2.85")).toBe(true);123 expect(isPrivateIp("100.100.1.1")).toBe(true);124 expect(isPrivateIp("8.8.8.8")).toBe(false);125 await expect(assertPublicUrl("http://10.0.0.1/")).rejects.toThrow();126 await expect(assertPublicUrl("ftp://example.org/")).rejects.toThrow();127 });128});129130describe("no false positives on common research commands", () => {131 it("allows python open() and prose containing 'open'", async () => {132 for (const command of [133 "python3 - <<'EOF'\nt=open('plan/x.md').read()\nEOF",134 "echo 'an open question' >> notes.md",135 "grep -n 'say' results/robustness.md",136 ]) {137 expect(isDeny(await call("Bash", { command })), command).toBe(false);138 }139 expect(isDeny(await call("Bash", { command: "open -a Safari https://x" }))).toBe(true);140 expect(isDeny(await call("Bash", { command: "ls; open ." }))).toBe(true);141 });142});143