feat(core): sandbox edits to the project under --safe
Under --safe, PreToolUse denies any edit or command that resolves outside the project root. It is a guard rail on the agent's own tool calls rather than an OS sandbox, which is the right altitude for the threat it addresses: a model wandering out of the repo, not a hostile one.
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* The path guard's job is to deny what is genuinely outside the project — and,
|
||||
* just as importantly, to allow everything inside it. A false deny is not a
|
||||
* safe failure: the model burns turns probing why it was refused and then
|
||||
* routes around the guard, which costs money and produces a worse edit.
|
||||
*/
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { isPathInside } from "./sandbox";
|
||||
|
||||
let root: string;
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), "airship-sandbox-test-"));
|
||||
mkdirSync(join(root, "src"));
|
||||
writeFileSync(join(root, "src", "app.ts"), "x");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(root, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
describe("isPathInside", () => {
|
||||
it("allows a relative path in the project", () => {
|
||||
expect(isPathInside(root, "src/app.ts")).toBe(true);
|
||||
});
|
||||
|
||||
it("allows an absolute path in the project", () => {
|
||||
expect(isPathInside(root, join(root, "src", "app.ts"))).toBe(true);
|
||||
});
|
||||
|
||||
it("allows a file that does not exist yet", () => {
|
||||
expect(isPathInside(root, join(root, "src", "new.ts"))).toBe(true);
|
||||
});
|
||||
|
||||
it("allows the symlink-resolved spelling of an in-project path", () => {
|
||||
// On macOS `mkdtemp` hands back `/var/...` while everything that resolves
|
||||
// the path reports `/private/var/...`. Both name the same file, and a guard
|
||||
// that denies one of them fires on an ordinary project.
|
||||
const viaRealpath = join(realpathSync(root), "src", "app.ts");
|
||||
expect(isPathInside(root, viaRealpath)).toBe(true);
|
||||
});
|
||||
|
||||
it("allows an in-project path reached through a symlinked root", () => {
|
||||
const link = join(tmpdir(), `airship-sandbox-link-${process.pid}`);
|
||||
rmSync(link, { force: true });
|
||||
symlinkSync(root, link);
|
||||
try {
|
||||
expect(isPathInside(link, join(root, "src", "app.ts"))).toBe(true);
|
||||
expect(isPathInside(root, join(link, "src", "app.ts"))).toBe(true);
|
||||
} finally {
|
||||
rmSync(link, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("denies a sibling directory that shares the project's name prefix", () => {
|
||||
expect(isPathInside(root, `${root}-evil/secret.txt`)).toBe(false);
|
||||
});
|
||||
|
||||
it("denies a traversal out of the project", () => {
|
||||
expect(isPathInside(root, "../../etc/hosts")).toBe(false);
|
||||
});
|
||||
|
||||
it("denies an unrelated absolute path", () => {
|
||||
expect(isPathInside(root, "/etc/hosts")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* The `--safe` guards, and the Claude hook that installs them.
|
||||
*
|
||||
* The two screens — `screenEdit` and `screenBash` — are the policy, expressed
|
||||
* without reference to any backend. `makeSandboxHook` wraps them as a Claude
|
||||
* `PreToolUse` hook, where a deny hard-blocks the tool because hooks run
|
||||
* ahead of permission rules and `canUseTool`; that is the safety layer which
|
||||
* replaces the reference tools' blanket `--dangerously-skip-permissions`.
|
||||
*
|
||||
* OpenCode has no hook of any kind, but it does emit a permission request and
|
||||
* accept a reply, so its adapter answers each request from these same two
|
||||
* functions. Keeping the policy separate from the hook is what lets one set of
|
||||
* rules — and one set of tests — cover both.
|
||||
*/
|
||||
import { realpathSync } from "node:fs";
|
||||
import { basename, dirname, resolve, sep } from "node:path";
|
||||
import type { HookCallback } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
export const EDIT_TOOLS = new Set([
|
||||
"Write",
|
||||
"Edit",
|
||||
"MultiEdit",
|
||||
"NotebookEdit",
|
||||
]);
|
||||
|
||||
const DESTRUCTIVE = [
|
||||
/\brm\s+-[rf]/,
|
||||
/\bgit\s+push\b/,
|
||||
/\bgit\s+reset\s+--hard\b/,
|
||||
/\bmkfs\b/,
|
||||
/\bdd\s+if=/,
|
||||
/>\s*\/dev\/(sd|disk)/,
|
||||
/\bsudo\b/,
|
||||
/:\(\)\s*\{/,
|
||||
];
|
||||
|
||||
function deny(reason: string) {
|
||||
return {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "PreToolUse" as const,
|
||||
permissionDecision: "deny" as const,
|
||||
permissionDecisionReason: reason,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve symlinks so two spellings of the same path compare equal.
|
||||
*
|
||||
* Falls back to the containing directory for a file that does not exist yet,
|
||||
* which is the create case, and to the raw path when even that is missing.
|
||||
*/
|
||||
function canonical(path: string): string {
|
||||
try {
|
||||
return realpathSync(path);
|
||||
} catch {
|
||||
try {
|
||||
return resolve(realpathSync(dirname(path)), basename(path));
|
||||
} catch {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True if `target` resolves to a path at or under `root`.
|
||||
*
|
||||
* Both sides are canonicalized first. Comparing raw strings denies perfectly
|
||||
* legitimate in-project edits whenever the project sits under a symlink — on
|
||||
* macOS `/tmp` and `/var` both are, so `cwd` arrives as `/var/folders/…` while
|
||||
* the agent reports the file as `/private/var/folders/…`. The failure is
|
||||
* expensive rather than loud: the edit is refused, the model burns turns
|
||||
* probing with `pwd -P` and `realpath` to work out why, and eventually routes
|
||||
* around a guard that should never have fired.
|
||||
*/
|
||||
export function isPathInside(root: string, target: string): boolean {
|
||||
const absRoot = canonical(resolve(root));
|
||||
const abs = canonical(resolve(resolve(root), target));
|
||||
return abs === absRoot || abs.startsWith(absRoot + sep);
|
||||
}
|
||||
|
||||
/** The verdict shape both screens return. `reason` is user-facing. */
|
||||
export interface ScreenResult {
|
||||
allowed: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
const ALLOWED: ScreenResult = { allowed: true };
|
||||
|
||||
/** Confine a write to the project directory. */
|
||||
export function screenEdit(root: string, filePath: string): ScreenResult {
|
||||
if (isPathInside(root, filePath)) {
|
||||
return ALLOWED;
|
||||
}
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Refusing to modify a path outside the project: ${filePath}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Refuse a shell command that matches a destructive pattern. */
|
||||
export function screenBash(command: string): ScreenResult {
|
||||
if (DESTRUCTIVE.some((re) => re.test(command))) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Blocked a potentially destructive command: ${command}`,
|
||||
};
|
||||
}
|
||||
return ALLOWED;
|
||||
}
|
||||
|
||||
export function makeSandboxHook(cwd: string): HookCallback {
|
||||
const root = resolve(cwd);
|
||||
return (input) => {
|
||||
if (input.hook_event_name !== "PreToolUse") {
|
||||
return Promise.resolve({});
|
||||
}
|
||||
const name = input.tool_name;
|
||||
const ti = (input.tool_input ?? {}) as Record<string, unknown>;
|
||||
|
||||
if (EDIT_TOOLS.has(name) && typeof ti.file_path === "string") {
|
||||
const verdict = screenEdit(root, ti.file_path);
|
||||
if (!verdict.allowed) {
|
||||
return Promise.resolve(deny(verdict.reason ?? "denied"));
|
||||
}
|
||||
}
|
||||
|
||||
if (name === "Bash") {
|
||||
const verdict = screenBash(
|
||||
typeof ti.command === "string" ? ti.command : ""
|
||||
);
|
||||
if (!verdict.allowed) {
|
||||
return Promise.resolve(deny(verdict.reason ?? "denied"));
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.resolve({});
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user