fix(dot): exclude instance state from workspace tools

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-07 19:21:52 -05:00
1 parent 75eba6a417
commit 249eaf66bb
5 files changed
+86 -6

No files matched your search

+1 -1
View File
@@ -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.
@@ -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",
@@ -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({
@@ -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;
@@ -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<Record<string, unknown>>((resolve, reject) => {