SPB Git

spb/autoscholar

Public
5.7 KB · 143 lines typescript
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
Files · 0d8280a29f83e41fbeda1fded3fa94709eba2e7d
Menu

Theme