mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 00:30:19 +02:00
fix(dot): exclude instance state from workspace tools
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
75eba6a417
commit
249eaf66bb
5 files changed
+86
-6
No files matched your search
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user