SPB Git

spb/autoscholar

Public
5.9 KB · 145 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, "code/.claude/settings.json") }))).toBe(true);81    expect(isDeny(await call("Write", { file_path: join(ws, "code/.git/hooks/pre-commit") }))).toBe(true);82    expect(isDeny(await call("Write", { file_path: join(ws, "results/link/evil.txt") }))).toBe(true); // symlink to outside83  });84  it("reviewer sessions may only write under review/", async () => {85    const rev = createPolicyHook({ workspace: ws, frozenPaths: ["CLAUDE.md"], writableOnly: ["review/"] });86    const c = (p: string) =>87      rev(88        {89          hook_event_name: "PreToolUse",90          tool_name: "Write",91          tool_input: { file_path: join(ws, p) },92          tool_use_id: "t",93          session_id: "s",94          transcript_path: "",95          cwd: ws,96        } as never,97        "t",98        { signal: new AbortController().signal },99      );100    expect(isDeny(await c("review/review-1.md"))).toBe(false);101    expect(isDeny(await c("manuscript/main.tex"))).toBe(true);102  });103  it("blocks SSRF targets for WebFetch/MCP tools and reads of sensitive paths", async () => {104    for (const url of [105      "http://192.168.2.72:8470/",106      "http://localhost:5432/",107      "http://127.0.0.1/",108      "http://M3U96b.maclustr.io:8470/",109      "http://100.69.26.91/",110      "file:///etc/passwd",111      "http://169.254.169.254/latest/meta-data",112    ]) {113      expect(isDeny(await call("WebFetch", { url })), url).toBe(true);114    }115    expect(isDeny(await call("Read", { file_path: "/Users/simon-pierreboucher/.ssh/id_ed25519" }))).toBe(116      true,117    );118    expect(isDeny(await call("Read", { file_path: "/etc/autoscholar/worker.env" }))).toBe(true);119    expect(isDeny(await call("Read", { file_path: join(ws, "data/x.csv") }))).toBe(false);120    expect(isDeny(await call("SomeUnknownTool", {}))).toBe(true);121  });122  it("classifies private addresses", async () => {123    expect(isPrivateIp("10.1.2.3")).toBe(true);124    expect(isPrivateIp("192.168.2.85")).toBe(true);125    expect(isPrivateIp("100.100.1.1")).toBe(true);126    expect(isPrivateIp("8.8.8.8")).toBe(false);127    await expect(assertPublicUrl("http://10.0.0.1/")).rejects.toThrow();128    await expect(assertPublicUrl("ftp://example.org/")).rejects.toThrow();129  });130});131132describe("no false positives on common research commands", () => {133  it("allows python open() and prose containing 'open'", async () => {134    for (const command of [135      "python3 - <<'EOF'\nt=open('plan/x.md').read()\nEOF",136      "echo 'an open question' >> notes.md",137      "grep -n 'say' results/robustness.md",138    ]) {139      expect(isDeny(await call("Bash", { command })), command).toBe(false);140    }141    expect(isDeny(await call("Bash", { command: "open -a Safari https://x" }))).toBe(true);142    expect(isDeny(await call("Bash", { command: "ls; open ." }))).toBe(true);143  });144});145
Files · 87b4e55c3b9fbcc2cd191a1e469b33de0a24ccc8
Menu

Theme