diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index cc944cdf8a1..8f391211284 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -1022,7 +1022,7 @@ Native Codex is qualified only with `codexPermissionMode: "never"`. The create and edit surfaces do not offer `on-request` or `untrusted`, and a persisted unsupported value fails with remediation instead of being silently coerced. OpenCode retains `allow`, `ask`, and `deny`; ACPX retains `approve-all`, -`approve-reads`, and `deny-all`. Codex conversion keeps a non-empty model and +`approve-paperclip` (the default), `approve-reads`, and `deny-all`. Codex conversion keeps a non-empty model and otherwise stores the shared `gpt-5.6-sol` default. The native execution boundary applies the same default to older runner rows whose model is missing or blank. @@ -1030,10 +1030,12 @@ For an Agent Chat test drive, enable **Agent Chat** in Experimental settings and configure two agents with Paperclip Runner: native Codex and ACPX Claude. Connect the Claude account through the agent's **AI connection** section (or supply an explicit supported provider credential); an ambient Claude CLI login alone is -not a credential source for its isolated runner home. The conservative ACPX -permission mode fails closed when no coordinator decision is available, including -for MCP tools. Use `approve-all` only for agents authorized to perform the test's -tool actions; the server still enforces company and action permissions. +not a credential source for its isolated runner home. The default +`approve-paperclip` setting lets assigned Paperclip actions run automatically; +company permissions and approval gates still apply. Explicit `approve-reads` or +`deny-all` settings remain restrictive. Unassigned, external, and provider-native +operations still require permission; do not select `approve-all` merely to save +plans, create tasks, or reassign work. Test questions, saved plan revisions, approval before task handoff, status lookups, and `/new` preserving chat history. Hiring additionally requires the diff --git a/packages/adapter-utils/src/paperclip-runner-permissions.test.ts b/packages/adapter-utils/src/paperclip-runner-permissions.test.ts index e23b957a577..1375940d044 100644 --- a/packages/adapter-utils/src/paperclip-runner-permissions.test.ts +++ b/packages/adapter-utils/src/paperclip-runner-permissions.test.ts @@ -16,15 +16,19 @@ describe("Paperclip Runner permission defaults", () => { expect(resolvePaperclipRunnerPermissionMode("codex", "untrusted")).toBe("never"); }); - it("uses interactive defaults for dormant non-Codex providers", () => { + it("defaults Claude to authorized Paperclip actions and OpenCode to ask", () => { expect(resolvePaperclipRunnerPermissionMode("opencode", undefined)).toBe( "ask", ); expect(resolvePaperclipRunnerPermissionMode("acpx", undefined)).toBe( - "approve-reads", + "approve-paperclip", ); }); + it.each(["approve-paperclip", "approve-reads", "deny-all", "approve-all"])("preserves explicit Claude %s settings", (mode) => { + expect(resolvePaperclipRunnerPermissionMode("acpx", mode)).toBe(mode); + }); + it("recognizes only exact provider identifiers", () => { expect(isPaperclipRunnerProvider("codex")).toBe(true); expect(isPaperclipRunnerProvider("opencode")).toBe(true); diff --git a/packages/adapter-utils/src/paperclip-runner-permissions.ts b/packages/adapter-utils/src/paperclip-runner-permissions.ts index 1824c822e1d..e82fc456b87 100644 --- a/packages/adapter-utils/src/paperclip-runner-permissions.ts +++ b/packages/adapter-utils/src/paperclip-runner-permissions.ts @@ -3,7 +3,7 @@ export type PaperclipRunnerProvider = export type CodexPermissionMode = "never" | "on-request" | "untrusted"; export type OpenCodePermissionMode = "allow" | "ask" | "deny"; -export type AcpxPermissionMode = "approve-all" | "approve-reads" | "deny-all"; +export type AcpxPermissionMode = "approve-all" | "approve-paperclip" | "approve-reads" | "deny-all"; export type PaperclipRunnerPermissionMode = CodexPermissionMode | OpenCodePermissionMode | AcpxPermissionMode; @@ -106,7 +106,7 @@ export const PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES = { acpx: { configurable: true, configKey: "acpxPermissionMode", - defaultMode: "approve-reads", + defaultMode: "approve-paperclip", description: "Controls ACPX agent operations inside the assigned Paperclip environment.", options: [ @@ -115,6 +115,12 @@ export const PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES = { label: "Full auto (approve all)", description: "Approve ACPX operations without approval pauses.", }, + { + value: "approve-paperclip", + label: "Automatic Paperclip actions", + description: + "Automatically run assigned Paperclip tools, including plans, tasks, and reassignment. Company permissions and approval requirements still apply. Other operations require permission.", + }, { value: "approve-reads", label: "Allow Paperclip reads", diff --git a/packages/paperclip-runner/README.md b/packages/paperclip-runner/README.md index fddbae5fb60..f283049cfe0 100644 --- a/packages/paperclip-runner/README.md +++ b/packages/paperclip-runner/README.md @@ -69,14 +69,21 @@ alias during selection and verification. Users can keep selecting models from the normal Claude catalog or entering custom IDs; unavailable models still fail at the provider rather than silently falling back. -For ACPX Claude, `approve-reads` is shown as **Allow Paperclip reads**. The host -intersects the run's public tools with the implementation catalog's read effects -and writes exact MCP permission rules into the isolated Claude settings. The -`paperclip` connection is always the runner's authenticated tool bridge; ambient -MCP configuration is excluded. Tool hints and provider permission metadata cannot -grant access. Unassigned tools, writes, external tools, and provider-native -operations do not receive automatic read permission. Protocol completion and -task-delivery controls keep their existing separate allowance. +ACPX Claude defaults to `approve-paperclip`, shown as **Automatic Paperclip +actions**. The host intersects the run's assigned public tools with the known +Paperclip action catalog and writes exact MCP permission rules into isolated +Claude settings. Plans, task creation, reassignment, and other assigned actions +can execute without a second provider permission prompt. The controller still +checks company access, action claims, task modes, and governed approvals on every +call. A tool's presence does not grant permission to act on another company's data. + +The `paperclip` connection is always the runner's authenticated tool bridge; +ambient MCP configuration is excluded. Tool hints and provider permission +metadata cannot grant access. Unassigned tools, external connections, and +provider-native shell or filesystem operations receive no automatic permission. +Explicit `approve-reads` (**Allow Paperclip reads**) and `deny-all` settings retain +their restrictive behavior. Existing saved settings are not migrated. Protocol +completion and task-delivery controls keep their separate allowance. This runtime has no interactive permission handler. An operation that still requires approval stops the turn with `approval_required`. The server marks the diff --git a/packages/paperclip-runner/docs/adding-a-harness.md b/packages/paperclip-runner/docs/adding-a-harness.md index d4216bcf7f3..be10a33a15e 100644 --- a/packages/paperclip-runner/docs/adding-a-harness.md +++ b/packages/paperclip-runner/docs/adding-a-harness.md @@ -11,7 +11,7 @@ identity, isolation behavior, and conformance coverage are defined. |---|---|---|---| | Codex | `codexPermissionMode` | `never`, `on-request`, `untrusted` | `never` | | OpenCode | `opencodePermissionMode` | `allow`, `ask`, `deny` | `allow` | -| ACPX (Claude, Codex) | `acpxPermissionMode` | `approve-all`, `approve-reads`, `deny-all` | `approve-reads` | +| ACPX (Claude, Codex) | `acpxPermissionMode` | `approve-all`, `approve-paperclip`, `approve-reads`, `deny-all` | `approve-paperclip` | The browser-safe source of truth for labels, defaults, and configuration validation is `PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES` in diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs index 4bc3d2d3ed8..09038b02424 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs @@ -29,6 +29,7 @@ const MAX_JSON_SAFE_INTEGER: u64 = 9_007_199_254_740_991; #[serde(rename_all = "kebab-case")] pub enum AcpxPermissionMode { ApproveAll, + ApprovePaperclip, ApproveReads, DenyAll, } @@ -1306,3 +1307,28 @@ mod tests { assert!(sidecar_tools[0].get("operationId").is_none()); } } + +#[cfg(test)] +mod permission_mode_tests { + use super::AcpxPermissionMode; + + #[test] + fn paperclip_permission_mode_round_trips_without_widening_legacy_modes() { + for (name, mode) in [ + ("approve-paperclip", AcpxPermissionMode::ApprovePaperclip), + ("approve-reads", AcpxPermissionMode::ApproveReads), + ("approve-all", AcpxPermissionMode::ApproveAll), + ("deny-all", AcpxPermissionMode::DenyAll), + ] { + let value = serde_json::json!(name); + assert_eq!( + serde_json::from_value::(value.clone()).unwrap(), + mode + ); + assert_eq!(serde_json::to_value(mode).unwrap(), value); + } + assert!( + serde_json::from_value::(serde_json::json!("unknown")).is_err() + ); + } +} diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts index 0086211758f..bf64c5c53c8 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts @@ -1133,13 +1133,14 @@ function requiredPermissionMode( ): AcpxSidecarOpenParams["permissionMode"] { if ( value === "approve-all" || + value === "approve-paperclip" || value === "approve-reads" || value === "deny-all" ) { return value; } throw new Error( - "permissionMode must be approve-all, approve-reads, or deny-all", + "permissionMode must be approve-all, approve-paperclip, approve-reads, or deny-all", ); } diff --git a/packages/paperclip-runner/src/contracts/harness-driver.ts b/packages/paperclip-runner/src/contracts/harness-driver.ts index 8caa64fcbdc..3400b419c9c 100644 --- a/packages/paperclip-runner/src/contracts/harness-driver.ts +++ b/packages/paperclip-runner/src/contracts/harness-driver.ts @@ -453,7 +453,7 @@ export interface AcpxSessionIdentity { requestedModel: string; effectiveModel: string; /** Missing on legacy snapshots; those used the historical approve-reads behavior. */ - permissionMode?: "approve-all" | "approve-reads" | "deny-all"; + permissionMode?: "approve-all" | "approve-paperclip" | "approve-reads" | "deny-all"; providerLifetimeFenceCandidates: readonly [number, number, number]; } diff --git a/packages/paperclip-runner/src/contracts/native-execution.ts b/packages/paperclip-runner/src/contracts/native-execution.ts index b18d1f41542..b5a31b85f7f 100644 --- a/packages/paperclip-runner/src/contracts/native-execution.ts +++ b/packages/paperclip-runner/src/contracts/native-execution.ts @@ -80,7 +80,7 @@ export interface NativeAwsAgentCoreProfileSnapshot { export type NativeAcpxAgent = "pi" | "claude" | "codex"; export type NativeCodexApprovalPolicy = "never" | "on-request" | "untrusted"; export type NativeOpenCodePermissionMode = "allow" | "ask" | "deny"; -export type NativeAcpxPermissionMode = "approve-all" | "approve-reads" | "deny-all"; +export type NativeAcpxPermissionMode = "approve-all" | "approve-paperclip" | "approve-reads" | "deny-all"; export interface NativeAcpxProfileSnapshot { driverKind: "acpx_runtime"; @@ -534,8 +534,8 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput throw new NativeExecutionInputError("input.provider.agent must be pi, claude, or codex"); } if (isV4) { - if (provider.permissionMode !== "approve-all" && provider.permissionMode !== "approve-reads" && provider.permissionMode !== "deny-all") { - throw new NativeExecutionInputError("input.provider.permissionMode must be approve-all, approve-reads, or deny-all"); + if (provider.permissionMode !== "approve-all" && provider.permissionMode !== "approve-paperclip" && provider.permissionMode !== "approve-reads" && provider.permissionMode !== "deny-all") { + throw new NativeExecutionInputError("input.provider.permissionMode must be approve-all, approve-paperclip, approve-reads, or deny-all"); } } else if (provider.permissionPolicy !== "interactive") { throw new NativeExecutionInputError("input.provider.permissionPolicy must be interactive"); diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts index dee21eb6f9f..1d7aa48256e 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts @@ -435,7 +435,7 @@ export class CodexAcpxDriver implements HarnessDriver { workingDirectory: input.workingDirectory, agent: this.#options.agent ?? "codex", model: this.#options.model, - permissionMode: this.#options.permissionMode ?? "approve-reads", + permissionMode: this.#options.permissionMode ?? "approve-paperclip", systemInstructions: this.#options.systemInstructions, environment: this.#options.environment, managedCodexCredentialSourcePath: @@ -1903,7 +1903,7 @@ function validateRecoverySnapshot(snapshot: PersistedHarnessSession): void { !/^sha256:[a-f0-9]{64}$/.test(identity.profileDigest) || !/^sha256:[a-f0-9]{64}$/.test(identity.workspaceDigest) || (identity.permissionMode !== undefined && - !["approve-all", "approve-reads", "deny-all"].includes( + !["approve-all", "approve-paperclip", "approve-reads", "deny-all"].includes( identity.permissionMode, )) || !validProviderLifetimeFenceCandidates( diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts index f9bd7620050..9d53e2cab1b 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts @@ -28,7 +28,7 @@ const HANDLE: AcpRuntimeHandle = { }; describe("Codex ACPX runtime adapter", () => { - it("stops a turn with an actionable error when approval has no handler", async () => { + it.each(["approve-reads", "approve-paperclip"] as const)("%s stops unassigned operations when approval has no handler", async (permissionMode) => { const runtime = fakeRuntime(); let runtimeOptions: AcpRuntimeOptions | undefined; let signal: AbortSignal | undefined; @@ -47,12 +47,14 @@ describe("Codex ACPX runtime adapter", () => { } as ReturnType; }); const options = openOptions(fakeCommand()); - options.permissionMode = "approve-reads"; + options.permissionMode = permissionMode; const port = await openCodexAcpxRuntime(options, { createRegistry: () => registry(), createStore: () => store(), createRuntime: (created) => { runtimeOptions = created; return runtime; }, }); + expect(runtimeOptions!.permissionMode).toBe("approve-reads"); + expect(runtimeOptions!.permissionPolicy).toMatchObject({ defaultAction: "escalate" }); const turn = port.startTurn({ text: "Attempt a write.", requestId: "permission-test" }); try { await runtimeOptions!.onPermissionRequest!({ @@ -1635,6 +1637,7 @@ describe("Codex ACPX runtime adapter", () => { ["codex", "gpt-5.6-sol", "deny-all", { outcome: "reject_once" }], ["claude", "claude-sonnet-5", "approve-all", { outcome: "allow_once" }], ["claude", "claude-sonnet-5", "approve-reads", { outcome: "reject_once" }], + ["claude", "claude-sonnet-5", "approve-paperclip", { outcome: "reject_once" }], ["claude", "claude-sonnet-5", "deny-all", { outcome: "reject_once" }], ] as const)( "applies the %s/%s ACPX profile's %s mode without an implicit prompt bridge", diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts index 264b1e3afa4..1d237ed8d28 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts @@ -277,7 +277,11 @@ export async function openQualifiedAcpxRuntime( ? "/paperclip-verified/claude-agent-acp" : VERIFIED_COMMAND_SENTINEL] }, }), - permissionMode: options.permissionMode, + // ACPX does not know the Paperclip-specific mode. Exact SDK rules allow + // admitted actions; all remaining requests keep its closed read policy. + permissionMode: options.permissionMode === "approve-paperclip" + ? "approve-reads" + : options.permissionMode, elicitationModes: ["form"], nonInteractivePermissions: "fail", permissionPolicy: { diff --git a/packages/paperclip-runner/src/drivers/acpx/driver-profile.test.ts b/packages/paperclip-runner/src/drivers/acpx/driver-profile.test.ts index acf5efce76a..03764e1f987 100644 --- a/packages/paperclip-runner/src/drivers/acpx/driver-profile.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/driver-profile.test.ts @@ -51,7 +51,7 @@ describe("ACPX driver profile", () => { ] as const)("accepts the exact qualified %s model", (agent, model) => { expect(validateAcpxDriverConfig({ agent, model })).toEqual({ ok: true, - config: { agent, model, permissionMode: "approve-all" }, + config: { agent, model, permissionMode: "approve-paperclip" }, issues: [], }); }); diff --git a/packages/paperclip-runner/src/drivers/acpx/driver-profile.ts b/packages/paperclip-runner/src/drivers/acpx/driver-profile.ts index 97b18805505..3b90d72ad01 100644 --- a/packages/paperclip-runner/src/drivers/acpx/driver-profile.ts +++ b/packages/paperclip-runner/src/drivers/acpx/driver-profile.ts @@ -15,6 +15,7 @@ import { const ACPX_AGENTS = ["claude", "codex"] as const; const ACPX_PERMISSION_MODES = [ "approve-all", + "approve-paperclip", "approve-reads", "deny-all", ] as const; @@ -109,12 +110,12 @@ export function validateAcpxDriverConfig( "permissionMode", ) ? text(config.permissionMode) - : "approve-all"; + : "approve-paperclip"; if (!isPermissionMode(permissionMode)) { return invalid( "permissionMode", "invalid_permission_mode", - "ACPX permission mode must be approve-all, approve-reads, or deny-all.", + "ACPX permission mode must be approve-all, approve-paperclip, approve-reads, or deny-all.", ); } diff --git a/packages/paperclip-runner/src/drivers/acpx/permission-policy.test.ts b/packages/paperclip-runner/src/drivers/acpx/permission-policy.test.ts index 0585555e9ce..562cfc3a149 100644 --- a/packages/paperclip-runner/src/drivers/acpx/permission-policy.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/permission-policy.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { acpxRuntimePermissionPolicy, - claudeReadPermissionRules, + claudePaperclipPermissionRules, decideAcpxPermission, } from "./permission-policy.js"; @@ -12,10 +12,22 @@ describe("ACPX permission policy", () => { "paperclip__get_task_context", "read_document", "write_document", "call_api", "request_approval", "unknown_read", "mcp__other__get_task_context", ].map((name) => ({ name, annotations: { readOnlyHint: true, effect: "read" } })); - expect(claudeReadPermissionRules(tools)).toEqual([ + expect(claudePaperclipPermissionRules(tools, "approve-reads")).toEqual([ "mcp__paperclip__get_task_context", "mcp__paperclip__read_document", ]); - expect(claudeReadPermissionRules([])).toEqual([]); + expect(claudePaperclipPermissionRules([], "approve-reads")).toEqual([]); + }); + + it("allows assigned Paperclip mutations without admitting unknown or external tools", () => { + const tools = ["paperclip__write_document", "create_task", "reassign_task", "request_approval", + "write_document", "mcp__other__create_task", "Bash", "unknown_write", "mcp__paperclip__create_task"] + .map(name => ({ name, annotations: { readOnlyHint: true } })); + expect(claudePaperclipPermissionRules(tools, "approve-paperclip")).toEqual([ + "mcp__paperclip__create_task", "mcp__paperclip__reassign_task", + "mcp__paperclip__request_approval", "mcp__paperclip__write_document", + ]); + expect(claudePaperclipPermissionRules([], "approve-paperclip")).toEqual([]); + expect(claudePaperclipPermissionRules(tools, "deny-all")).toEqual([]); }); it("maps each configured mode to a closed ACP runtime policy", () => { @@ -25,6 +37,7 @@ describe("ACPX permission policy", () => { expect(acpxRuntimePermissionPolicy("deny-all")).toEqual({ defaultAction: "deny", }); + expect(acpxRuntimePermissionPolicy("approve-paperclip")).toEqual({ defaultAction: "escalate" }); expect(acpxRuntimePermissionPolicy("approve-reads")).toEqual({ defaultAction: "escalate", }); @@ -35,6 +48,9 @@ describe("ACPX permission policy", () => { ["approve-reads", "read", "delegate"], ["approve-reads", "search", "delegate"], ["approve-reads", "execute", "delegate"], + ["approve-paperclip", "read", "delegate"], + ["approve-paperclip", "write", "delegate"], + ["approve-paperclip", "execute", "delegate"], ["deny-all", "read", "reject_once"], ] as const)("%s maps %s to %s", (mode, inferredKind, expected) => { expect( @@ -65,7 +81,7 @@ describe("ACPX permission policy", () => { } }); - it("does not let provider metadata widen approve-reads", () => { + it.each(["approve-reads", "approve-paperclip"] as const)("does not let provider metadata widen %s", (mode) => { for (const [agent, inferredKind, raw, options] of [ [ "codex", @@ -104,7 +120,7 @@ describe("ACPX permission policy", () => { expect( decideAcpxPermission( agent, - "approve-reads", + mode, { inferredKind, raw }, options, ), diff --git a/packages/paperclip-runner/src/drivers/acpx/permission-policy.ts b/packages/paperclip-runner/src/drivers/acpx/permission-policy.ts index f27d010248a..ce20b2673d0 100644 --- a/packages/paperclip-runner/src/drivers/acpx/permission-policy.ts +++ b/packages/paperclip-runner/src/drivers/acpx/permission-policy.ts @@ -7,7 +7,7 @@ export class AcpxApprovalRequiredError extends Error { readonly code = "approval_required"; constructor() { super( - "Approval required. This operation is not an automatically allowed Paperclip read, " + + "Approval required. This operation is not an automatically allowed Paperclip action, " + "and this runner has no interactive approval handler. Review the operation and " + "update the agent's permission setting before retrying.", ); @@ -16,16 +16,22 @@ export class AcpxApprovalRequiredError extends Error { } /** Exact SDK rules for the run's runner-owned Paperclip MCP connection. */ -export function claudeReadPermissionRules( +export function claudePaperclipPermissionRules( tools: readonly Readonly>[], + mode: NativeAcpxPermissionMode, ): string[] { + if (mode !== "approve-reads" && mode !== "approve-paperclip") return []; const names = tools.flatMap((tool) => { if (typeof tool.name !== "string") return []; const name = canonicalRunnerToolName(tool.name); // Effects come from Paperclip's implementation catalog, never tool hints // or the provider's permission-request metadata. Unknown operations stay // subject to approval even when their names or annotations claim a read. - return paperclipSemanticAction(name)?.effect === "read" + const action = paperclipSemanticAction(name); + // This only bypasses the provider's redundant permission prompt. The + // authenticated bridge and controller still validate run authority, + // company scope, claims, task mode, and governed-action approvals. + return action && (mode === "approve-paperclip" || action.effect === "read") ? [`mcp__paperclip__${name}`] : []; }); @@ -60,7 +66,7 @@ export function acpxRuntimePermissionPolicy( if (mode === "deny-all") return { defaultAction: "deny" }; // ACPX derives permission kinds from provider-originated requests. Until the // host can bind a request to independent authority, no kind is safe to - // auto-approve here. Verified Paperclip reads are allowed earlier at the + // auto-approve here. Assigned Paperclip actions are allowed earlier at the // Claude SDK dispatch boundary. Other requests require an approval handler. return { defaultAction: "escalate" }; } @@ -78,8 +84,8 @@ export function decideAcpxPermission( if (mode === "deny-all") return "reject_once"; if (mode === "approve-all") return "allow_once"; // inferredKind and raw semantic/MCP metadata both originate outside the - // runner trust boundary. Neither can grant local read or semantic authority. - // Verified Paperclip reads use exact SDK rules on the runner-owned MCP + // runner trust boundary. Neither can grant local semantic authority. + // Assigned Paperclip actions use exact SDK rules on the runner-owned MCP // connection. Anything reaching this fallback still requires approval. return "delegate"; } diff --git a/packages/paperclip-runner/src/drivers/acpx/recovery-identity.ts b/packages/paperclip-runner/src/drivers/acpx/recovery-identity.ts index 0f0ae1bf8d9..61d65fe666a 100644 --- a/packages/paperclip-runner/src/drivers/acpx/recovery-identity.ts +++ b/packages/paperclip-runner/src/drivers/acpx/recovery-identity.ts @@ -351,7 +351,7 @@ function isDigest(value: unknown): value is string { function isPermissionMode(value: unknown): value is NativeAcpxPermissionMode { return ( typeof value === "string" && - ["approve-all", "approve-reads", "deny-all"].includes(value) + ["approve-all", "approve-paperclip", "approve-reads", "deny-all"].includes(value) ); } diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts index f6b637a4f16..d7aeda29e7f 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts @@ -209,7 +209,11 @@ describe("ACPX runtime host", () => { } finally { await host.close({ reason: "gateway test complete" }); } }); - it("automatically permits only admitted Paperclip reads in the Claude SDK", async () => { + it.each([ + ["approve-reads", ["mcp__paperclip__get_task_context"]], + ["approve-paperclip", ["mcp__paperclip__create_task", "mcp__paperclip__get_task_context", "mcp__paperclip__reassign_task", "mcp__paperclip__write_document"]], + ["deny-all", undefined], + ] as const)("binds exact assigned Claude tools to %s before provider launch", async (permissionMode, allow) => { const fixture = await hostFixture(); const dependencies = fixture.dependencies({ openRuntime: async (options) => { @@ -217,9 +221,7 @@ describe("ACPX runtime host", () => { join(options.launchEnvironment.CLAUDE_CONFIG_DIR!, "settings.json"), "utf8", )); - expect(settings.permissions).toEqual({ - allow: ["mcp__paperclip__get_task_context"], - }); + expect(settings.permissions).toEqual(allow ? { allow } : undefined); expect(options.mcpServers).toMatchObject([ { name: "paperclip", runnerOwned: true }, ]); @@ -232,9 +234,9 @@ describe("ACPX runtime host", () => { ...fixture.options, agent: "claude", model: "claude-sonnet-5", - permissionMode: "approve-reads", + permissionMode, semanticTools: { - tools: ["get_task_context", "write_document", "unknown_read"].map((name) => ({ + tools: ["get_task_context", "write_document", "create_task", "reassign_task", "unknown_read"].map((name) => ({ name, inputSchema: { type: "object" }, // Supplied hints cannot grant write or unknown operations read access. diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts index fa90b278918..30d2f4cbb79 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts @@ -28,7 +28,7 @@ import { } from "node:path"; import { createSanitizedAcpxSpawnInput } from "./environment.js"; -import { claudeReadPermissionRules } from "./permission-policy.js"; +import { claudePaperclipPermissionRules } from "./permission-policy.js"; import type { QualifiedAcpxAgent } from "./qualified-profiles.js"; import { resolveAcpxRuntimeRoot, @@ -378,8 +378,8 @@ export async function prepareAcpxRuntimeSandbox(input: { `${JSON.stringify({ model: input.binding.requestedModel, availableModels: [input.binding.requestedModel], - ...(input.binding.permissionMode === "approve-reads" - ? { permissions: { allow: claudeReadPermissionRules(input.tools ?? []) } } + ...((input.binding.permissionMode === "approve-reads" || input.binding.permissionMode === "approve-paperclip") + ? { permissions: { allow: claudePaperclipPermissionRules(input.tools ?? [], input.binding.permissionMode) } } : {}), })}\n`, ); diff --git a/packages/paperclip-runner/src/drivers/codex/codex-driver-values.ts b/packages/paperclip-runner/src/drivers/codex/codex-driver-values.ts index 30069d80985..68c32a3c9a6 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-driver-values.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-driver-values.ts @@ -66,6 +66,7 @@ export function parseProviderIdentity( if ( permissionMode !== undefined && permissionMode !== "approve-all" && + permissionMode !== "approve-paperclip" && permissionMode !== "approve-reads" && permissionMode !== "deny-all" ) { diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts index da7244a4fbe..a9235f1f367 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts @@ -1258,8 +1258,8 @@ it("preserves answer and internal wait descriptions in the serialized native too ).toContain("not in the top-level user-facing summary"); }); -it("defaults runnerd ACPX permissions to approve reads", () => { - expect(resolveRunnerdAcpxPermissionMode(undefined)).toBe("approve-reads"); +it("defaults runnerd ACPX permissions to authorized Paperclip actions", () => { + expect(resolveRunnerdAcpxPermissionMode(undefined)).toBe("approve-paperclip"); expect(resolveRunnerdAcpxPermissionMode("deny-all")).toBe("deny-all"); }); diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index 54e75690f8d..582af97dab0 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -3155,7 +3155,7 @@ export function createCapabilityRunnerdProviderEnvironment(input: { export function resolveRunnerdAcpxPermissionMode( configured: CapabilityRunnerdCodexTransportOptions["acpxPermissionMode"], ): NonNullable { - return configured ?? "approve-reads"; + return configured ?? "approve-paperclip"; } const OPEN_CODE_RUNNER_ENVIRONMENT_KEYS = new Set([ diff --git a/server/src/__tests__/adapter-routes.test.ts b/server/src/__tests__/adapter-routes.test.ts index a0de71aa3d5..221b2b0da21 100644 --- a/server/src/__tests__/adapter-routes.test.ts +++ b/server/src/__tests__/adapter-routes.test.ts @@ -362,7 +362,7 @@ describe("adapter routes", () => { }), expect.objectContaining({ key: "acpxPermissionMode", - default: "approve-reads", + default: "approve-paperclip", meta: { visibleWhen: { key: "provider", value: "acpx" } }, }), expect.objectContaining({ diff --git a/server/src/services/native-runtime/native-execution-input.test.ts b/server/src/services/native-runtime/native-execution-input.test.ts index 1a7f082237a..17ec070fc68 100644 --- a/server/src/services/native-runtime/native-execution-input.test.ts +++ b/server/src/services/native-runtime/native-execution-input.test.ts @@ -469,6 +469,9 @@ describe("native execution input external-chat framing", () => { }, runtimeContext: nativeRuntimeContextFixture(), }); + expect(input.provider).toMatchObject(provider === "acpx" + ? { kind: "acpx", permissionMode: "approve-paperclip" } + : { kind: "codex", approvalPolicy: "never" }); expect(input.task.prompt).not.toContain("## Questions that need a user response"); expect(input.task.prompt).toContain("Use Paperclip's request_human_input for durable task questions."); expect(input.task.prompt).not.toContain("payload.questionSet"); diff --git a/server/src/services/native-runtime/native-execution-input.ts b/server/src/services/native-runtime/native-execution-input.ts index 4fe577aa1df..2444039572a 100644 --- a/server/src/services/native-runtime/native-execution-input.ts +++ b/server/src/services/native-runtime/native-execution-input.ts @@ -240,7 +240,7 @@ export function buildNativeExecutionInput(input: { kind: "acpx", agent: acpxProfile!.agent, model: input.model, - permissionMode: input.acpxPermissionMode ?? "approve-reads", + permissionMode: input.acpxPermissionMode ?? "approve-paperclip", profile: { driverKind: acpxProfile!.driverKind, protocolVersion: acpxProfile!.protocolVersion, diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index 62712c6a608..29458cd1df2 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -82,7 +82,7 @@ type RunnerTransportOptions = { provider?: "codex" | "opencode" | "acpx"; opencodePermissionMode?: "allow" | "ask" | "deny"; acpxAgent?: "claude" | "codex"; - acpxPermissionMode?: "approve-all" | "approve-reads" | "deny-all"; + acpxPermissionMode?: "approve-all" | "approve-paperclip" | "approve-reads" | "deny-all"; resumeActiveTurnId?: string | null; resumeProviderSession?: { driverSessionId: string; diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 148a62a5f72..1722dbf6c47 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -5167,7 +5167,7 @@ export function providerSessionIdentityFromDurableProviderState(input: { identity.requestedModel !== expectedModel || identity.effectiveModel !== expectedModel || identity.permissionMode !== input.execution.provider.permissionMode || - !["approve-all", "approve-reads", "deny-all"].includes( + !["approve-all", "approve-paperclip", "approve-reads", "deny-all"].includes( String(identity.permissionMode), ) || !Array.isArray(identity.providerLifetimeFenceCandidates) || diff --git a/server/src/services/native-runtime/provider-profile.ts b/server/src/services/native-runtime/provider-profile.ts index a0d7f63b7ff..7cd125783d7 100644 --- a/server/src/services/native-runtime/provider-profile.ts +++ b/server/src/services/native-runtime/provider-profile.ts @@ -109,7 +109,7 @@ export type PaperclipRunnerNativeProviderInput = provider: "acpx"; model: string; acpxAgent: QualifiedPaperclipRunnerAcpxAgent; - acpxPermissionMode: "approve-all" | "approve-reads" | "deny-all"; + acpxPermissionMode: "approve-all" | "approve-paperclip" | "approve-reads" | "deny-all"; }; export class PaperclipRunnerProviderProfileError extends Error { @@ -476,7 +476,7 @@ export function resolvePaperclipRunnerNativeProviderInput(input: { acpxPermissionMode: resolvePaperclipRunnerPermissionMode( "acpx", config.acpxPermissionMode, - ) as "approve-all" | "approve-reads" | "deny-all", + ) as "approve-all" | "approve-paperclip" | "approve-reads" | "deny-all", }; } if (profile.provider === "claude_managed") { diff --git a/tests/runner-e2e/README.md b/tests/runner-e2e/README.md index c941263d33c..1a2092f4334 100644 --- a/tests/runner-e2e/README.md +++ b/tests/runner-e2e/README.md @@ -144,6 +144,8 @@ suite. Cancelled turns and execution-task runs remain included in billing and cleanup. The production chat directive is injected normally; fixtures do not replace it with completion instructions. Daytona is excluded. +The native chat profiles use production provider permission defaults, rather than +full-auto overrides, for plan handoff, task creation, and reassignment. The native Codex and Claude profiles also cover reassignment of existing ready and backlog tasks. The oracle verifies stable task IDs, preserved descriptions, assignment audit evidence, exactly one successful successor run and its output diff --git a/tests/runner-e2e/catalog.test.ts b/tests/runner-e2e/catalog.test.ts index f0f24e74372..356e29a4c6a 100644 --- a/tests/runner-e2e/catalog.test.ts +++ b/tests/runner-e2e/catalog.test.ts @@ -33,6 +33,22 @@ describe("runner E2E catalog", () => { expect(connectionReviewSuite.tasks.every(task => task.flow === "governed_tool_review")).toBe(true); }); + it("tests native chat plans, tasks, and reassignment with production permission defaults", () => { + const suite = runnerSuites.find(suite => suite.id === "agent-chat")!; + for (const id of ["runner-codex", "runner-acpx-claude"]) { + const profile = suite.profiles.find(profile => profile.id === id)!; + const payload = profile.buildAgent({ + executionId: "default-permissions", workspacePath: "/workspace", environmentId: "env-1", environmentFixtureId: "local", + secretRefs: { + [profile.credential]: { type: "secret_ref", secretId: "22222222-2222-4222-8222-222222222222", version: "latest" }, + }, + }); + expect(payload.adapterConfig).not.toHaveProperty("acpxPermissionMode"); + expect(payload.adapterConfig).not.toHaveProperty("codexPermissionMode"); + expect(suite.tasks.map(task => task.id)).toEqual(expect.arrayContaining(["plan-handoff", "reassign-task"])); + } + }); + it("validates the core, local-integrity, breadth, and warm suites", () => { expect(runnerProfiles).toHaveLength(7); expect(openRouterBreadthProfiles).toHaveLength(4); diff --git a/tests/runner-e2e/catalog.ts b/tests/runner-e2e/catalog.ts index c7b2f36d36a..d4317857423 100644 --- a/tests/runner-e2e/catalog.ts +++ b/tests/runner-e2e/catalog.ts @@ -197,6 +197,19 @@ function nativeProfile(input: { }; } +/** Chat acceptance exercises the shipped provider defaults, not full-auto fixtures. */ +function defaultPermissionProfile(profile: RunnerProfileFixture): RunnerProfileFixture { + if (profile.generation !== "native") return profile; + return { + ...profile, + buildAgent(input) { + const payload = profile.buildAgent(input); + const { codexPermissionMode: _codex, acpxPermissionMode: _acpx, ...adapterConfig } = payload.adapterConfig as Record; + return { ...payload, adapterConfig }; + }, + }; +} + const claudeLegacyModel = "claude-sonnet-4-6"; if (!claudeModels.some((model) => model.id === claudeLegacyModel)) { throw new Error( @@ -928,10 +941,10 @@ export const runnerSuites: readonly RunnerSuiteFixture[] = [ id: "agent-chat", label: "Persistent Agent Chat", description: "Task-backed conversations, session resets, and project plan handoff.", groups: ["chat"], - profiles: runnerProfiles.filter(profile => ["legacy-codex", "legacy-claude", "runner-codex", "runner-acpx-claude"].includes(profile.id)), + profiles: runnerProfiles.filter(profile => ["legacy-codex", "legacy-claude", "runner-codex", "runner-acpx-claude"].includes(profile.id)).map(defaultPermissionProfile), environments: [localEnvironment], tasks: chatTasks, expectedMatrixSize: 26, excludedExecutionIds: ["legacy-codex", "legacy-claude"].map(profile => `agent-chat.${profile}.local.reassign-task`), - definitionMetadata: { version: 2, resetRunsCountedSeparately: true }, + definitionMetadata: { version: 3, resetRunsCountedSeparately: true, permissions: "production-defaults" }, }, ...(process.env.PAPERCLIP_RUNNER_E2E_CONNECTION_REVIEWS === "1" ? [connectionReviewSuite] : []), {