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 expect(isDeny(await call("Bash", { command: "python3 solve.py", run_in_background: true }))).toBe(true);62 expect(isDeny(await call("Bash", { command: "nohup python3 solve.py > results/solve.log 2>&1 &" }))).toBe(63 false,64 );65 });66 it("allows ordinary research commands", async () => {67 for (const command of [68 "uv venv .venv && uv pip install numpy pandas",69 "python3 code/analysis.py --seed 1",70 "tectonic -X compile main.tex",71 "shasum -a 256 data/raw.csv",72 "curl -s https://api.openalex.org/works?search=x",73 ]) {74 expect(isDeny(await call("Bash", { command })), command).toBe(false);75 }76 });77 it("confines writes to the workspace, blocks frozen paths and symlink escapes", async () => {78 expect(isDeny(await call("Write", { file_path: join(ws, "results/metrics.json") }))).toBe(false);79 expect(isDeny(await call("Write", { file_path: join(ws, "../escape.txt") }))).toBe(true);80 expect(isDeny(await call("Write", { file_path: "/tmp/anything.txt" }))).toBe(true);81 expect(isDeny(await call("Edit", { file_path: join(ws, "CLAUDE.md") }))).toBe(true);82 expect(isDeny(await call("Write", { file_path: join(ws, "input/run.json") }))).toBe(true);83 expect(isDeny(await call("Write", { file_path: join(ws, ".claude/settings.json") }))).toBe(true);84 expect(isDeny(await call("Write", { file_path: join(ws, "code/.claude/settings.json") }))).toBe(true);85 expect(isDeny(await call("Write", { file_path: join(ws, "code/.git/hooks/pre-commit") }))).toBe(true);86 expect(isDeny(await call("Write", { file_path: join(ws, "results/link/evil.txt") }))).toBe(true); // symlink to outside87 });88 it("reviewer sessions may only write under review/", async () => {89 const rev = createPolicyHook({ workspace: ws, frozenPaths: ["CLAUDE.md"], writableOnly: ["review/"] });90 const c = (p: string) =>91 rev(92 {93 hook_event_name: "PreToolUse",94 tool_name: "Write",95 tool_input: { file_path: join(ws, p) },96 tool_use_id: "t",97 session_id: "s",98 transcript_path: "",99 cwd: ws,100 } as never,101 "t",102 { signal: new AbortController().signal },103 );104 expect(isDeny(await c("review/review-1.md"))).toBe(false);105 expect(isDeny(await c("manuscript/main.tex"))).toBe(true);106 });107 it("blocks SSRF targets for WebFetch/MCP tools and reads of sensitive paths", async () => {108 for (const url of [109 "http://192.168.2.72:8470/",110 "http://localhost:5432/",111 "http://127.0.0.1/",112 "http://M3U96b.maclustr.io:8470/",113 "http://100.69.26.91/",114 "file:///etc/passwd",115 "http://169.254.169.254/latest/meta-data",116 ]) {117 expect(isDeny(await call("WebFetch", { url })), url).toBe(true);118 }119 expect(isDeny(await call("Read", { file_path: "/Users/simon-pierreboucher/.ssh/id_ed25519" }))).toBe(120 true,121 );122 expect(isDeny(await call("Read", { file_path: "/etc/autoscholar/worker.env" }))).toBe(true);123 expect(isDeny(await call("Read", { file_path: join(ws, "data/x.csv") }))).toBe(false);124 expect(isDeny(await call("SomeUnknownTool", {}))).toBe(true);125 });126 it("classifies private addresses", async () => {127 expect(isPrivateIp("10.1.2.3")).toBe(true);128 expect(isPrivateIp("192.168.2.85")).toBe(true);129 expect(isPrivateIp("100.100.1.1")).toBe(true);130 expect(isPrivateIp("8.8.8.8")).toBe(false);131 await expect(assertPublicUrl("http://10.0.0.1/")).rejects.toThrow();132 await expect(assertPublicUrl("ftp://example.org/")).rejects.toThrow();133 });134});135136describe("no false positives on common research commands", () => {137 it("allows python open() and prose containing 'open'", async () => {138 for (const command of [139 "python3 - <<'EOF'\nt=open('plan/x.md').read()\nEOF",140 "echo 'an open question' >> notes.md",141 "grep -n 'say' results/robustness.md",142 ]) {143 expect(isDeny(await call("Bash", { command })), command).toBe(false);144 }145 expect(isDeny(await call("Bash", { command: "open -a Safari https://x" }))).toBe(true);146 expect(isDeny(await call("Bash", { command: "ls; open ." }))).toBe(true);147 });148});149