SPB Git

spb/autoscholar

Public
6.1 KB · 149 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    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
Files · main
Menu

Theme