From 249eaf66bbb3f743ad862f83cd59dae314b257c3 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 7 Oct 2026 19:21:52 -0500 Subject: [PATCH] fix(dot): exclude instance state from workspace tools Co-Authored-By: Paperclip --- doc/openai-dot-runner.md | 2 +- .../paperclip-runner-tool-authority.test.ts | 17 +++++++++ .../paperclip-runner-tool-authority.ts | 3 +- .../runner-bridge-tools.test.ts | 37 ++++++++++++++++++- .../native-runtime/runner-bridge-tools.ts | 33 +++++++++++++++-- 5 files changed, 86 insertions(+), 6 deletions(-) diff --git a/doc/openai-dot-runner.md b/doc/openai-dot-runner.md index 773cef4c1c9..75416d71a06 100644 --- a/doc/openai-dot-runner.md +++ b/doc/openai-dot-runner.md @@ -326,7 +326,7 @@ The shared catalog adds `get_task`, `comment_on_task`, `list_task_documents`, `r `list_assigned_skills` and `read_assigned_skill` read the turn's exact pinned skill versions, with manifest and file digest checks. Assigned app tools are projected by the existing MCP gateway; its permission checks, approvals, receipts and revocations remain authoritative. Credentials are kept on Paperclip. Newly granted app tools arrive in a continuation with a new pinned catalog. -Enable **Workspace files and commands** on the agent to expose `workspace_list`, `workspace_read`, `workspace_write`, and `workspace_run`. The server binds these to the admitted execution workspace. File paths reject absolute paths, traversal and symlinks. New writes require an absent file; overwrites require its observed SHA-256. Commands have bounded output/time and an OS sandbox, use a workspace-local home, and do not inherit credentials. Live authority loss kills the owned command process group. Register requested files with `register_deliverable` before finishing. Workspace mutation attempts are reserved durably before effects; an interrupted attempt returns an unknown outcome instead of replaying a possible effect. Inspect state before deciding another mutation. +Enable **Workspace files and commands** on the agent to expose `workspace_list`, `workspace_read`, `workspace_write`, and `workspace_run`. The server binds these to the admitted execution workspace. File paths reject absolute paths, traversal and symlinks. Paperclip instance state under `.paperclip` is excluded from file tools, uploads, and sandbox commands. macOS denies those paths in the sandbox; Linux masks existing instance directories and the workspace root instance directory with read-only empty mounts. The protection scan fails closed above 4,096 directories on either platform. New writes require an absent file; overwrites require its observed SHA-256. Commands have bounded output/time and an OS sandbox, use a workspace-local home, and do not inherit credentials. Live authority loss kills the owned command process group. Register requested files with `register_deliverable` before finishing. Workspace mutation attempts are reserved durably before effects; an interrupted attempt returns an unknown outcome instead of replaying a possible effect. Inspect state before deciding another mutation. Mailbox `follow_up` entries reference new comments on an accepted assignment. Read `get_task_history` and incorporate them at a safe boundary. This supplies new input without claiming OpenAI steering support. `paperclip_dot_tasks` pages by the last task ID. Each assignment allows up to 4,000 broker operations; lower domain-specific limits still apply. A fenced assignment's control acknowledgement remains available at that limit. `hire_agent` creates a distinct unpaired Paperclip Dot teammate; the operator still pairs a separate OpenAI Dot. No existing binding or workspace permission is inherited. diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts index b1d7d8c17d4..6bc4ea5f30f 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts @@ -614,6 +614,23 @@ describe("PaperclipRunnerToolAuthority", () => { ).resolves.toMatchObject({ approval: { id: approvalId }, tasks: [] }); }); + it("rejects Dot artifact publication from instance state before reading a file", async () => { + const [actor] = await db.select().from(agents).where(eq(agents.id, agentId)); + await db.update(agents).set({ adapterConfig: { ...actor.adapterConfig, dotWorkspaceAccess: true } }).where(eq(agents.id, agentId)); + const authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId, + dotRuntime: true, workspaceBridge: true, workspaceRoot: "/tmp/fixture-workspace" }); + try { + for (const contentRef of [".paperclip/.env", "nested/.PaPeRcLiP/config.json"]) { + await expect(authority.execute({ tool: "register_deliverable", callId: contentRef, arguments: { + idempotencyKey: contentRef, filename: "config.txt", title: "Config", contentType: "text/plain", + byteSize: 10, sha256: "0".repeat(64), contentRef, + } })).rejects.toThrow("runner_workspace_instance_state_denied"); + } + } finally { + await db.update(agents).set({ adapterConfig: actor.adapterConfig }).where(eq(agents.id, agentId)); + } + }); + it("fits large assigned catalogs alongside workspace and completion tools without dropping task tools", async () => { const listToolsForNamedGateway = vi.fn().mockResolvedValue(Array.from({ length: 224 }, (_, i) => ({ name: `app.action_${i}`, displayName: `Action ${i}`, description: "Read a fixture", diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts index 1f06c4fe4bf..263164bd66d 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts @@ -1,5 +1,5 @@ import { TaskAttachmentReadCache, TASK_ATTACHMENT_DEFINITIONS, TASK_ATTACHMENT_SCHEMAS, listTaskAttachments, readTaskAttachment } from "./runner-task-attachments.js"; -import { RUNNER_BRIDGE_SCHEMAS, runnerBridgeDefinitions, readAssignedSkill, executeWorkspaceTool, readWorkspaceUploadFile, settleRunnerBridgeRead } from "./runner-bridge-tools.js"; +import { RUNNER_BRIDGE_SCHEMAS, runnerBridgeDefinitions, readAssignedSkill, executeWorkspaceTool, readWorkspaceUploadFile, settleRunnerBridgeRead, assertRunnerWorkspacePathAllowed } from "./runner-bridge-tools.js"; import type { NativeRuntimeContextSnapshot } from "../../vendor/paperclip-runner/index.js"; import { assertCanManageIssueMonitor, prepareIssueMonitorUpdate, setTaskMonitorSchema, summarizeIssueMonitor } from "../issue-monitors.js"; import { readTaskQuestionContext } from "../issue-question-context.js"; @@ -1636,6 +1636,7 @@ export class PaperclipRunnerToolAuthority { if (this.binding.dotRuntime) { if (!this.binding.workspaceBridge || context.actor.adapterConfig?.dotWorkspaceAccess !== true) throw forbidden("Dot workspace access is required to publish workspace files."); + assertRunnerWorkspacePathAllowed(typeof input.contentRef === "string" ? input.contentRef : ""); await this.binding.assertBridgeAuthority?.(); } const prepared = await prepareNativeRunnerFileHandoff({ diff --git a/server/src/services/native-runtime/runner-bridge-tools.test.ts b/server/src/services/native-runtime/runner-bridge-tools.test.ts index 266adca486f..6ee817081b8 100644 --- a/server/src/services/native-runtime/runner-bridge-tools.test.ts +++ b/server/src/services/native-runtime/runner-bridge-tools.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; import { createHash } from "node:crypto"; import { materializeAsset } from "./runtime-context.js"; import { join } from "node:path"; @@ -51,6 +51,41 @@ describe("Runner workspace bridge", () => { await expect(read(6000)).rejects.toThrow("offset_splits_character"); }); + it("excludes instance state from workspace reads, writes, listings and upload reads", async () => { + const directory = await root(); + await mkdir(join(directory, ".paperclip")); + await writeFile(join(directory, ".paperclip", ".env"), "SYNTHETIC_INSTANCE_SECRET"); + await mkdir(join(directory, "nested", ".PaPeRcLiP"), { recursive: true }); + for (const path of [".paperclip/.env", "nested/.PaPeRcLiP/config.json"]) { + await expect(executeWorkspaceTool(directory, "workspace_read", { path }, authorize)).rejects.toThrow("instance_state_denied"); + await expect(executeWorkspaceTool(directory, "workspace_write", { path, text: "overwrite", expectedSha256: null }, authorize)).rejects.toThrow("instance_state_denied"); + await expect(readWorkspaceUploadFile(directory, path)).rejects.toThrow("instance_state_denied"); + } + await expect(executeWorkspaceTool(directory, "workspace_list", { path: ".paperclip" }, authorize)).rejects.toThrow("instance_state_denied"); + expect(await executeWorkspaceTool(directory, "workspace_list", { path: "" }, authorize)).toMatchObject({ entries: [{ name: "nested" }] }); + expect(await readFile(join(directory, ".paperclip", ".env"), "utf8")).toBe("SYNTHETIC_INSTANCE_SECRET"); + }); + + it.skipIf(!workspaceCommandSandboxAvailable())("denies command access to instance keys while preserving ordinary workspace work", async () => { + const directory = join(await root(), "project.[safe]"); + await mkdir(directory); + await mkdir(join(directory, ".paperclip")); + await mkdir(join(directory, "nested", ".PaPeRcLiP"), { recursive: true }); + const keys = [".paperclip/.env", "nested/.PaPeRcLiP/config.json"]; + for (const key of keys) await writeFile(join(directory, key), "SYNTHETIC_INSTANCE_SECRET"); + for (const key of keys) { + const read = await executeWorkspaceTool(directory, "workspace_run", { program: "/bin/cat", args: [key] }, authorize) as any; + expect(read.exitCode).not.toBe(0); + expect(read.output).not.toContain("SYNTHETIC_INSTANCE_SECRET"); + const write = await executeWorkspaceTool(directory, "workspace_run", { program: "/bin/sh", args: ["-c", 'printf overwrite > "$1"', "sh", key] }, authorize) as any; + expect(write.exitCode).not.toBe(0); + expect(await readFile(join(directory, key), "utf8")).toBe("SYNTHETIC_INSTANCE_SECRET"); + } + const ordinary = await executeWorkspaceTool(directory, "workspace_run", { program: "/bin/sh", args: ["-c", "printf hello > result.txt"] }, authorize) as any; + expect(ordinary.exitCode).toBe(0); + expect(await readFile(join(directory, "result.txt"), "utf8")).toBe("hello"); + }); + it("serializes overlapping writes so only one observed hash can commit", async () => { const directory = await root(); const initial = await executeWorkspaceTool(directory, "workspace_write", { path: "shared.txt", text: "initial", expectedSha256: null }, authorize) as any; diff --git a/server/src/services/native-runtime/runner-bridge-tools.ts b/server/src/services/native-runtime/runner-bridge-tools.ts index 21858e0bae8..8da8aceabcc 100644 --- a/server/src/services/native-runtime/runner-bridge-tools.ts +++ b/server/src/services/native-runtime/runner-bridge-tools.ts @@ -32,8 +32,31 @@ export function runnerBridgeDefinitions(options: { workspace: boolean; skills: b } const digest = (bytes: Buffer | string) => createHash("sha256").update(bytes).digest("hex"); const PAGE_BYTES = 24000; +const instanceStateName = (name: string) => name.toLowerCase() === ".paperclip"; +/** Instance state is never part of the remotely exposed task workspace. */ +export function assertRunnerWorkspacePathAllowed(name: string) { + if (name.split(/[\\/]/u).some(instanceStateName)) throw new Error("runner_workspace_instance_state_denied"); +} +async function protectedWorkspaceDirectories(root: string) { + const protectedPaths = new Set([path.join(root, ".paperclip")]); + const pending = [root]; + let scanned = 0; + while (pending.length) { + if (++scanned > 4096) throw new Error("runner_workspace_protection_scan_limit"); + const directory = pending.pop()!; + for (const entry of await fs.readdir(directory, { withFileTypes: true })) { + const target = path.join(directory, entry.name); + if (instanceStateName(entry.name)) { + if (!entry.isDirectory()) throw new Error("runner_workspace_instance_state_invalid"); + protectedPaths.add(target); + } else if (entry.isDirectory()) pending.push(target); + } + } + return [...protectedPaths]; +} async function confined(root: string, name: string, newFile = false) { const canonicalRoot = await fs.realpath(root); + assertRunnerWorkspacePathAllowed(name); const parts = name ? relativeFile.parse(name).split("/") : []; let target = canonicalRoot; for (const [index, part] of parts.entries()) { @@ -123,7 +146,7 @@ async function executeWorkspaceToolInLane(root: string, name: string, raw: unkno await authorize(); if (name === "workspace_list") { const entries = await fs.readdir(await confined(root, input.path), { withFileTypes: true }); - const page = entries.filter(entry => !input.after || entry.name > input.after).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0).slice(0, 100); + const page = entries.filter(entry => !instanceStateName(entry.name) && (!input.after || entry.name > input.after)).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0).slice(0, 100); return { entries: page.map(entry => ({ name: entry.name, kind: entry.isSymbolicLink() ? "symlink" : entry.isDirectory() ? "directory" : "file" })), nextAfter: page.length === 100 ? page.at(-1)!.name : null }; } if (name === "workspace_read") return readFilePage(await confined(root, input.path), input.offset); @@ -154,11 +177,15 @@ async function executeWorkspaceToolInLane(root: string, name: string, raw: unkno if (process.platform === "darwin") { // Deny by default. No /Users, host home, arbitrary /private or unrestricted /Library access. const quote = (value: string) => JSON.stringify(value); - const profile = `(version 1)(deny default)(allow process*)(allow file-read-metadata)(allow sysctl-read)(allow mach-lookup)(allow file-read* (subpath "/System") (subpath "/usr") (subpath "/bin") (subpath "/sbin") (subpath "/opt/homebrew/Cellar") (subpath "/opt/homebrew/lib") (subpath "/opt/homebrew/bin") (subpath "/opt/homebrew/opt") (literal "/") (literal "/dev/null") (literal "/dev/urandom") (literal "/dev/random"))(allow file-read* file-write* (subpath ${quote(cwd)}))`; + const protectedPaths = await protectedWorkspaceDirectories(cwd); + const excludedPaths = protectedPaths.map(directory => `(subpath ${quote(directory)})`).join(" "); + const profile = `(version 1)(deny default)(allow process*)(allow file-read-metadata)(allow sysctl-read)(allow mach-lookup)(allow file-read* (subpath "/System") (subpath "/usr") (subpath "/bin") (subpath "/sbin") (subpath "/opt/homebrew/Cellar") (subpath "/opt/homebrew/lib") (subpath "/opt/homebrew/bin") (subpath "/opt/homebrew/opt") (literal "/") (literal "/dev/null") (literal "/dev/urandom") (literal "/dev/random"))(allow file-read* file-write* (subpath ${quote(cwd)}))(deny file-read* file-write* ${excludedPaths})`; program = "/usr/bin/sandbox-exec"; args = ["-p", profile, input.program, ...input.args]; } else { program = "/usr/bin/bwrap"; - args = ["--die-with-parent", "--unshare-all", "--ro-bind", "/usr", "/usr", "--ro-bind", "/bin", "/bin", "--ro-bind", "/lib", "/lib", ...(existsSync("/lib64") ? ["--ro-bind", "/lib64", "/lib64"] : []), "--proc", "/proc", "--dev", "/dev", "--bind", cwd, cwd, "--chdir", cwd, "--", input.program, ...input.args]; + const protectedPaths = await protectedWorkspaceDirectories(cwd); + const masks = protectedPaths.flatMap(directory => ["--tmpfs", directory, "--remount-ro", directory]); + args = ["--die-with-parent", "--unshare-all", "--cap-drop", "ALL", "--ro-bind", "/usr", "/usr", "--ro-bind", "/bin", "/bin", "--ro-bind", "/lib", "/lib", ...(existsSync("/lib64") ? ["--ro-bind", "/lib64", "/lib64"] : []), "--proc", "/proc", "--dev", "/dev", "--bind", cwd, cwd, ...masks, "--chdir", cwd, "--", input.program, ...input.args]; } await authorize(); return new Promise>((resolve, reject) => {