Declare and validate bound ACP permission request envelopes

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-28 12:27:55 -05:00
1 parent c8fc6047f3
commit 170652d2d4
7 files changed
+408 -3

No files matched your search

@@ -90,7 +90,7 @@
{
"path": "schemas/request.schema.json",
"id": "https://paperclip.dev/schemas/prp/v1/request.schema.json",
"sha256": "c8d00e10150c90963f19641c075f50da7f67617dd817f41b356287ad85c53f2b"
"sha256": "985a3b0a6ae36ae5ad4cf7d708dc401db7c108f683a81cd44a03093fc4ca7862"
},
{
"path": "schemas/result.schema.json",
@@ -3,6 +3,181 @@
"$id": "https://paperclip.dev/schemas/prp/v1/request.schema.json",
"title": "PRP request",
"oneOf": [
{
"type": "object",
"required": [
"schema",
"requestKind",
"requestId",
"type",
"status",
"prompt",
"choices",
"origin",
"turnId"
],
"properties": {
"schema": {
"const": "paperclip.runtime_request.v2"
},
"requestKind": {
"const": "permission_approval"
},
"requestId": {
"type": "string",
"minLength": 1,
"maxLength": 160
},
"type": {
"const": "permission"
},
"status": {
"enum": [
"pending",
"resolved",
"expired",
"cancelled"
]
},
"prompt": {
"type": "string",
"minLength": 1,
"maxLength": 4000
},
"choices": {
"type": "array",
"minItems": 1,
"maxItems": 4,
"items": {
"type": "object",
"required": [
"key",
"label"
],
"properties": {
"key": {
"enum": [
"accept",
"accept_for_session",
"decline",
"cancel"
]
},
"label": {
"type": "string",
"minLength": 1,
"maxLength": 500
}
},
"additionalProperties": false
},
"allOf": [
{
"contains": {
"type": "object",
"required": [
"key"
],
"properties": {
"key": {
"const": "accept"
}
}
},
"minContains": 0,
"maxContains": 1
},
{
"contains": {
"type": "object",
"required": [
"key"
],
"properties": {
"key": {
"const": "accept_for_session"
}
}
},
"minContains": 0,
"maxContains": 1
},
{
"contains": {
"type": "object",
"required": [
"key"
],
"properties": {
"key": {
"const": "decline"
}
}
},
"minContains": 0,
"maxContains": 1
},
{
"contains": {
"type": "object",
"required": [
"key"
],
"properties": {
"key": {
"const": "cancel"
}
}
},
"minContains": 0,
"maxContains": 1
}
]
},
"details": {
"type": "object",
"additionalProperties": true
},
"origin": {
"type": "object",
"required": [
"adapter"
],
"properties": {
"adapter": {
"type": "string",
"minLength": 1,
"maxLength": 160
},
"provider": {
"type": "string",
"minLength": 1,
"maxLength": 160
},
"method": {
"type": "string",
"minLength": 1,
"maxLength": 500
}
},
"additionalProperties": false
},
"turnId": {
"type": "string",
"minLength": 1,
"maxLength": 240
},
"itemId": {
"type": [
"string",
"null"
],
"minLength": 1,
"maxLength": 240
}
},
"additionalProperties": false
},
{
"type": "object",
"required": ["schema", "requestKind", "requestId", "type", "status", "prompt", "input"],
@@ -9,6 +9,13 @@ function request(kinds = ["allow_once", "allow_always", "reject_once"]): AcpPerm
} } as AcpPermissionRequest;
}
describe("ACP permission normalization", () => {
it("keeps requests answerable when a provider omits its operation title", () => {
for (const title of [undefined, "", " "]) {
const value = request();
value.raw.toolCall.title = title;
expect(normalizeAcpxPermission(value).title).toBe("Approve provider operation");
}
});
it("offers only native choices and preserves cancellation", () => {
const value = normalizeAcpxPermission(request(["allow_once"]));
expect(value.choices.map(c => c.key)).toEqual(["accept", "cancel"]);
@@ -47,7 +47,8 @@ export function normalizeAcpxPermission(request: AcpPermissionRequest, options:
throw new Error("ACP permission request omitted its tool identity");
}
return {
title: typeof call.title === "string" ? call.title.slice(0, 4_000) : "Approve provider operation",
title: typeof call.title === "string" && call.title.trim()
? call.title.slice(0, 4_000) : "Approve provider operation",
kind: request.inferredKind ?? "other",
toolCallId: call.toolCallId,
choices,
@@ -3002,6 +3002,181 @@ export const requestSchema = {
"$id": "https://paperclip.dev/schemas/prp/v1/request.schema.json",
"title": "PRP request",
"oneOf": [
{
"type": "object",
"required": [
"schema",
"requestKind",
"requestId",
"type",
"status",
"prompt",
"choices",
"origin",
"turnId"
],
"properties": {
"schema": {
"const": "paperclip.runtime_request.v2"
},
"requestKind": {
"const": "permission_approval"
},
"requestId": {
"type": "string",
"minLength": 1,
"maxLength": 160
},
"type": {
"const": "permission"
},
"status": {
"enum": [
"pending",
"resolved",
"expired",
"cancelled"
]
},
"prompt": {
"type": "string",
"minLength": 1,
"maxLength": 4000
},
"choices": {
"type": "array",
"minItems": 1,
"maxItems": 4,
"items": {
"type": "object",
"required": [
"key",
"label"
],
"properties": {
"key": {
"enum": [
"accept",
"accept_for_session",
"decline",
"cancel"
]
},
"label": {
"type": "string",
"minLength": 1,
"maxLength": 500
}
},
"additionalProperties": false
},
"allOf": [
{
"contains": {
"type": "object",
"required": [
"key"
],
"properties": {
"key": {
"const": "accept"
}
}
},
"minContains": 0,
"maxContains": 1
},
{
"contains": {
"type": "object",
"required": [
"key"
],
"properties": {
"key": {
"const": "accept_for_session"
}
}
},
"minContains": 0,
"maxContains": 1
},
{
"contains": {
"type": "object",
"required": [
"key"
],
"properties": {
"key": {
"const": "decline"
}
}
},
"minContains": 0,
"maxContains": 1
},
{
"contains": {
"type": "object",
"required": [
"key"
],
"properties": {
"key": {
"const": "cancel"
}
}
},
"minContains": 0,
"maxContains": 1
}
]
},
"details": {
"type": "object",
"additionalProperties": true
},
"origin": {
"type": "object",
"required": [
"adapter"
],
"properties": {
"adapter": {
"type": "string",
"minLength": 1,
"maxLength": 160
},
"provider": {
"type": "string",
"minLength": 1,
"maxLength": 160
},
"method": {
"type": "string",
"minLength": 1,
"maxLength": 500
}
},
"additionalProperties": false
},
"turnId": {
"type": "string",
"minLength": 1,
"maxLength": 240
},
"itemId": {
"type": [
"string",
"null"
],
"minLength": 1,
"maxLength": 240
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
@@ -4,4 +4,4 @@
import equalModule from "ajv/dist/runtime/equal.js";
import ucs2LengthModule from "ajv/dist/runtime/ucs2length.js";
"use strict";export const fixtureValidator: unknown = validate20;const schema31 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/fixture.schema.json","title":"PRP scripted replay fixture","type":"object","required":["schema","fixtureVersion","protocolVersion","name","description","identity","capabilities","commands","events","result"],"properties":{"schema":{"const":"paperclip.prp.fixture.v1"},"fixtureVersion":{"const":1},"protocolVersion":{"enum":[1,2]},"name":{"type":"string","minLength":1,"maxLength":120},"description":{"type":"string","minLength":1,"maxLength":1000},"identity":{"$ref":"https://paperclip.dev/schemas/prp/v1/identity.schema.json"},"capabilities":{"oneOf":[{"$ref":"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json"},{"$ref":"https://paperclip.dev/schemas/prp/v2/capabilities.schema.json"}]},"commands":{"type":"array","items":{"oneOf":[{"$ref":"https://paperclip.dev/schemas/prp/v1/command.schema.json"},{"$ref":"https://paperclip.dev/schemas/prp/v2/command.schema.json"}]}},"events":{"type":"array","minItems":1,"items":{"oneOf":[{"$ref":"https://paperclip.dev/schemas/prp/v1/event.schema.json"},{"$ref":"https://paperclip.dev/schemas/prp/v2/event.schema.json"}]}},"requests":{"type":"array","items":{"$ref":"https://paperclip.dev/schemas/prp/v1/request.schema.json"}},"result":{"$ref":"https://paperclip.dev/schemas/prp/v1/result.schema.json"}},"additionalProperties":true};const schema41 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json","title":"PRP negotiated capabilities","type":"object","required":["schema","sessionReusePolicy","driver","steer","interrupt","resume","runtimeRequests","structuredResult","typedEvents"],"properties":{"schema":{"const":"paperclip.prp.capabilities.v1"},"sessionReusePolicy":{"enum":["new_per_run","reuse_per_issue","reuse_per_workspace"]},"driver":{"type":"object","required":["kind","version"],"properties":{"kind":{"type":"string","minLength":1,"maxLength":80},"version":{"type":"string","minLength":1,"maxLength":80}},"additionalProperties":true},"steer":{"type":"boolean"},"interrupt":{"type":"boolean"},"resume":{"type":"boolean"},"runtimeRequests":{"type":"boolean"},"structuredResult":{"type":"boolean"},"typedEvents":{"type":"boolean"},"typedEventFamilies":{"type":"array","maxItems":64,"items":{"type":"object","required":["family","version","availability","detailLevel"],"properties":{"family":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[a-z][a-z0-9_.-]*$"},"version":{"const":1},"availability":{"enum":["available","unsupported","policy_disabled"]},"detailLevel":{"enum":["summary","structured"]}},"additionalProperties":false}},"semanticTools":{"type":"object","required":["schema","schemaVersion","operations"],"properties":{"schema":{"const":"paperclip.prp.semantic_tools.v1"},"schemaVersion":{"const":1},"operations":{"type":"array","items":{"type":"object","required":["operationId","version","availability","redactionDisposition"],"properties":{"operationId":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"},"version":{"const":1},"availability":{"enum":["available","denied","unsupported"]},"redactionDisposition":{"const":"digest_only"},"requiredClaims":{"type":"array","items":{"type":"string","minLength":1,"maxLength":160},"uniqueItems":true},"reasonCode":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[a-z][a-z0-9_.:-]*$"}},"additionalProperties":true}}},"additionalProperties":true},"unsupported":{"type":"array","items":{"type":"string","minLength":1},"uniqueItems":true}},"additionalProperties":true};const schema45 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/command.schema.json","title":"PRP runner command","type":"object","required":["schema","commandId","controllerSeq","type","issuedAt","payload"],"properties":{"schema":{"const":"paperclip.prp.command.v1"},"commandId":{"type":"string","minLength":1,"maxLength":160},"controllerSeq":{"type":"integer","minimum":1},"type":{"enum":["run.prepare","run.attach","session.open","turn.start","turn.steer","turn.interrupt","turn.stop","request.resolve","interaction.receipt","semantic_tool.result","session.snapshot","session.close","session.budget.increase","session.destroy","run.cancel","runner.drain","runner.suspend","runner.shutdown"]},"issuedAt":{"type":"string","format":"date-time"},"deadlineAt":{"type":"string","format":"date-time"},"precondition":{"type":"object","properties":{"runnerState":{"type":"array","items":{"type":"string"}},"runState":{"type":"array","items":{"type":"string"}},"sessionState":{"type":"array","items":{"type":"string"}},"activeTurnId":{"type":["string","null"]}},"additionalProperties":true},"payload":{"type":"object","additionalProperties":true}},"additionalProperties":true};const schema46 = {"$schema":"https://json-schema.org/draft/2020-1 Line truncated
"use strict";export const fixtureValidator: unknown = validate20;const schema31 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/fixture.schema.json","title":"PRP scripted replay fixture","type":"object","required":["schema","fixtureVersion","protocolVersion","name","description","identity","capabilities","commands","events","result"],"properties":{"schema":{"const":"paperclip.prp.fixture.v1"},"fixtureVersion":{"const":1},"protocolVersion":{"enum":[1,2]},"name":{"type":"string","minLength":1,"maxLength":120},"description":{"type":"string","minLength":1,"maxLength":1000},"identity":{"$ref":"https://paperclip.dev/schemas/prp/v1/identity.schema.json"},"capabilities":{"oneOf":[{"$ref":"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json"},{"$ref":"https://paperclip.dev/schemas/prp/v2/capabilities.schema.json"}]},"commands":{"type":"array","items":{"oneOf":[{"$ref":"https://paperclip.dev/schemas/prp/v1/command.schema.json"},{"$ref":"https://paperclip.dev/schemas/prp/v2/command.schema.json"}]}},"events":{"type":"array","minItems":1,"items":{"oneOf":[{"$ref":"https://paperclip.dev/schemas/prp/v1/event.schema.json"},{"$ref":"https://paperclip.dev/schemas/prp/v2/event.schema.json"}]}},"requests":{"type":"array","items":{"$ref":"https://paperclip.dev/schemas/prp/v1/request.schema.json"}},"result":{"$ref":"https://paperclip.dev/schemas/prp/v1/result.schema.json"}},"additionalProperties":true};const schema41 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json","title":"PRP negotiated capabilities","type":"object","required":["schema","sessionReusePolicy","driver","steer","interrupt","resume","runtimeRequests","structuredResult","typedEvents"],"properties":{"schema":{"const":"paperclip.prp.capabilities.v1"},"sessionReusePolicy":{"enum":["new_per_run","reuse_per_issue","reuse_per_workspace"]},"driver":{"type":"object","required":["kind","version"],"properties":{"kind":{"type":"string","minLength":1,"maxLength":80},"version":{"type":"string","minLength":1,"maxLength":80}},"additionalProperties":true},"steer":{"type":"boolean"},"interrupt":{"type":"boolean"},"resume":{"type":"boolean"},"runtimeRequests":{"type":"boolean"},"structuredResult":{"type":"boolean"},"typedEvents":{"type":"boolean"},"typedEventFamilies":{"type":"array","maxItems":64,"items":{"type":"object","required":["family","version","availability","detailLevel"],"properties":{"family":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[a-z][a-z0-9_.-]*$"},"version":{"const":1},"availability":{"enum":["available","unsupported","policy_disabled"]},"detailLevel":{"enum":["summary","structured"]}},"additionalProperties":false}},"semanticTools":{"type":"object","required":["schema","schemaVersion","operations"],"properties":{"schema":{"const":"paperclip.prp.semantic_tools.v1"},"schemaVersion":{"const":1},"operations":{"type":"array","items":{"type":"object","required":["operationId","version","availability","redactionDisposition"],"properties":{"operationId":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"},"version":{"const":1},"availability":{"enum":["available","denied","unsupported"]},"redactionDisposition":{"const":"digest_only"},"requiredClaims":{"type":"array","items":{"type":"string","minLength":1,"maxLength":160},"uniqueItems":true},"reasonCode":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[a-z][a-z0-9_.:-]*$"}},"additionalProperties":true}}},"additionalProperties":true},"unsupported":{"type":"array","items":{"type":"string","minLength":1},"uniqueItems":true}},"additionalProperties":true};const schema45 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/command.schema.json","title":"PRP runner command","type":"object","required":["schema","commandId","controllerSeq","type","issuedAt","payload"],"properties":{"schema":{"const":"paperclip.prp.command.v1"},"commandId":{"type":"string","minLength":1,"maxLength":160},"controllerSeq":{"type":"integer","minimum":1},"type":{"enum":["run.prepare","run.attach","session.open","turn.start","turn.steer","turn.interrupt","turn.stop","request.resolve","interaction.receipt","semantic_tool.result","session.snapshot","session.close","session.budget.increase","session.destroy","run.cancel","runner.drain","runner.suspend","runner.shutdown"]},"issuedAt":{"type":"string","format":"date-time"},"deadlineAt":{"type":"string","format":"date-time"},"precondition":{"type":"object","properties":{"runnerState":{"type":"array","items":{"type":"string"}},"runState":{"type":"array","items":{"type":"string"}},"sessionState":{"type":"array","items":{"type":"string"}},"activeTurnId":{"type":["string","null"]}},"additionalProperties":true},"payload":{"type":"object","additionalProperties":true}},"additionalProperties":true};const schema46 = {"$schema":"https://json-schema.org/draft/2020-1 Line truncated
@@ -0,0 +1,47 @@
import Ajv2020 from "ajv/dist/2020.js";
import { describe, expect, it } from "vitest";
import { questionSetSchema, requestSchema } from "./generated/schema-bundle.js";
const ajv = new Ajv2020({ allErrors: true, strict: true, strictRequired: false });
ajv.addSchema(questionSetSchema);
const validate = ajv.compile(requestSchema);
const permission = {
schema: "paperclip.runtime_request.v2",
requestKind: "permission_approval",
requestId: "permission-1",
type: "permission",
status: "pending",
prompt: "Allow the proposed edit?",
choices: [{ key: "accept", label: "Allow once" }, { key: "cancel", label: "Cancel" }],
details: { toolCallId: "edit-1", kind: "edit" },
origin: { adapter: "acpx-runtime-sidecar", provider: "acpx", method: "session/request_permission" },
turnId: "turn-1",
itemId: null,
};
describe("canonical ACP permission request schema", () => {
it("admits the durable Rust projection and direct-driver item identity", () => {
expect(validate(permission), JSON.stringify(validate.errors)).toBe(true);
expect(validate({ ...permission, itemId: "permission-1" })).toBe(true);
});
it("rejects unknown, duplicate and unbounded approval choices", () => {
for (const choices of [
[],
[{ key: "accept_always", label: "Allow forever" }],
[{ key: "accept", label: "Once" }, { key: "accept", label: "Different label" }],
[{ key: "accept", label: "x".repeat(501) }],
[{ key: "accept", label: "Once", permissionPolicy: "full-auto" }],
]) expect(validate({ ...permission, choices })).toBe(false);
});
it("requires turn and origin binding and preserves the separate input contract", () => {
const { turnId: _turn, ...withoutTurn } = permission;
const { origin: _origin, ...withoutOrigin } = permission;
expect(validate(withoutTurn)).toBe(false);
expect(validate(withoutOrigin)).toBe(false);
expect(validate({ ...permission, type: "input" })).toBe(false);
expect(validate({ ...permission, requestKind: "runtime" })).toBe(false);
expect(validate({ ...permission, resolverPolicy: "human_only" })).toBe(false);
});
});