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:
Nayan
2026-07-30 11:02:00 +05:30
parent 98857d1111
commit fc6fd58022
2 changed files with 215 additions and 0 deletions
+76
View File
@@ -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);
});
});
+139
View File
@@ -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({});
};
}