diff --git a/src/__tests__/tools-security.test.ts b/src/__tests__/tools-security.test.ts index 17539030..6a9eecd7 100644 --- a/src/__tests__/tools-security.test.ts +++ b/src/__tests__/tools-security.test.ts @@ -186,21 +186,60 @@ describe("write_file / edit_own_file protection parity", () => { for (const file of PROTECTED_FILES) { const result = await writeTool.execute( - { path: `/home/automaton/.automaton/${file}`, content: "malicious" }, + { path: `/root/.automaton/${file}`, content: "malicious" }, ctx, ); expect(result, `write_file should block ${file}`).toContain("Blocked"); } }); - it("write_file allows non-protected files", async () => { + it("write_file allows non-protected files inside sandbox home", async () => { const writeTool = tools.find((t) => t.name === "write_file")!; const result = await writeTool.execute( - { path: "/home/automaton/test.txt", content: "safe content" }, + { path: "/root/test.txt", content: "safe content" }, ctx, ); expect(result).toContain("File written"); }); + + it("write_file blocks paths outside sandbox home", async () => { + const writeTool = tools.find((t) => t.name === "write_file")!; + const outsidePaths = [ + "/etc/passwd", + "/tmp/evil.sh", + "/home/automaton/test.txt", + "/root/../etc/passwd", + "../../etc/shadow", + ]; + for (const p of outsidePaths) { + const result = await writeTool.execute( + { path: p, content: "malicious" }, + ctx, + ); + expect(result, `write_file should block ${p}`).toContain("Blocked"); + } + }); + + it("write_file allows relative paths that resolve inside sandbox home", async () => { + const writeTool = tools.find((t) => t.name === "write_file")!; + const result = await writeTool.execute( + { path: "project/file.txt", content: "safe content" }, + ctx, + ); + // Relative paths resolve against /root, so "project/file.txt" -> "/root/project/file.txt" + expect(result).toContain("File written"); + expect(result).toContain("/root/project/file.txt"); + }); + + it("write_file allows tilde paths within sandbox home", async () => { + const writeTool = tools.find((t) => t.name === "write_file")!; + const result = await writeTool.execute( + { path: "~/.automaton/skills/test/SKILL.md", content: "safe content" }, + ctx, + ); + expect(result).toContain("File written"); + expect(result).toContain("/root/.automaton/skills/test/SKILL.md"); + }); }); // ─── read_file Sensitive File Blocking ────────────────────────── diff --git a/src/agent/tools.ts b/src/agent/tools.ts index 3b725b4c..69ae0dc9 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -5,6 +5,7 @@ * Tools are organized by category and exposed to the inference model. */ +import nodePath from "node:path"; import { ulid } from "ulid"; import type { AutomatonTool, @@ -24,6 +25,31 @@ import { createLogger } from "../observability/logger.js"; const logger = createLogger("tools"); +// ─── Path Confinement ───────────────────────────────────────── +// write_file is restricted to the sandbox home directory tree. +// The sandbox home is /root for both local and remote execution. +const SANDBOX_HOME = "/root"; + +/** + * Validate that a file path resolves to within the allowed root directory. + * Returns the resolved absolute path, or an error string if out of bounds. + */ +function confinePathToSandbox(filePath: string): string | { error: string } { + // Resolve ~ to SANDBOX_HOME + const expanded = filePath.startsWith("~") + ? nodePath.join(SANDBOX_HOME, filePath.slice(1)) + : filePath; + // Resolve to absolute (relative paths resolve against SANDBOX_HOME) + const resolved = nodePath.resolve(SANDBOX_HOME, expanded); + // Ensure the resolved path is within the sandbox home + if (resolved !== SANDBOX_HOME && !resolved.startsWith(SANDBOX_HOME + "/")) { + return { + error: `Blocked: write_file path "${filePath}" resolves to "${resolved}" which is outside the allowed directory (${SANDBOX_HOME}). Writes are confined to the sandbox home.`, + }; + } + return resolved; +} + // Tools whose results come from external sources and need sanitization const EXTERNAL_SOURCE_TOOLS = new Set([ "exec", @@ -132,13 +158,16 @@ export function createBuiltinTools(sandboxId: string): AutomatonTool[] { }, execute: async (args, ctx) => { const filePath = args.path as string; + // Path confinement: restrict writes to sandbox home directory + const confined = confinePathToSandbox(filePath); + if (typeof confined === "object") return confined.error; // Guard against overwriting protected files (same check as edit_own_file) const { isProtectedFile } = await import("../self-mod/code.js"); - if (isProtectedFile(filePath)) { + if (isProtectedFile(confined)) { return "Blocked: Cannot overwrite protected file. This is a hard-coded safety invariant."; } - await ctx.conway.writeFile(filePath, args.content as string); - return `File written: ${filePath}`; + await ctx.conway.writeFile(confined, args.content as string); + return `File written: ${confined}`; }, }, {