mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
Allow authorized Paperclip actions with native provider defaults
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
da31d69f2e
commit
c0cb095f84
31 files changed
+176
-63
No files matched your search
+7
-5
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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::<AcpxPermissionMode>(value.clone()).unwrap(),
|
||||
mode
|
||||
);
|
||||
assert_eq!(serde_json::to_value(mode).unwrap(), value);
|
||||
}
|
||||
assert!(
|
||||
serde_json::from_value::<AcpxPermissionMode>(serde_json::json!("unknown")).is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<AcpRuntime["startTurn"]>;
|
||||
});
|
||||
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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: [],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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<Record<string, unknown>>[],
|
||||
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";
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
|
||||
@@ -66,6 +66,7 @@ export function parseProviderIdentity(
|
||||
if (
|
||||
permissionMode !== undefined &&
|
||||
permissionMode !== "approve-all" &&
|
||||
permissionMode !== "approve-paperclip" &&
|
||||
permissionMode !== "approve-reads" &&
|
||||
permissionMode !== "deny-all"
|
||||
) {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -3155,7 +3155,7 @@ export function createCapabilityRunnerdProviderEnvironment(input: {
|
||||
export function resolveRunnerdAcpxPermissionMode(
|
||||
configured: CapabilityRunnerdCodexTransportOptions["acpxPermissionMode"],
|
||||
): NonNullable<CapabilityRunnerdCodexTransportOptions["acpxPermissionMode"]> {
|
||||
return configured ?? "approve-reads";
|
||||
return configured ?? "approve-paperclip";
|
||||
}
|
||||
|
||||
const OPEN_CODE_RUNNER_ENVIRONMENT_KEYS = new Set([
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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] : []),
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user