mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
feat(runner): add Phase 7 semantic tool authorization
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
4d97afc2ed
commit
3f20bf4626
8 files changed
+1741
No files matched your search
@@ -27,3 +27,4 @@ export * from "./reducer/session-reducer.js";
|
||||
export * from "./tracer/phase0-runner.js";
|
||||
export * from "./tracer/phase1-replay.js";
|
||||
export * from "./tracer/phase2-live.js";
|
||||
export * from "./tools/index.js";
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "./phase7-semantic-tool-types.js";
|
||||
export * from "./phase7-semantic-tool-catalog.js";
|
||||
export * from "./phase7-tool-authorization.js";
|
||||
export * from "./phase7-semantic-tool-runtime.js";
|
||||
export * from "./phase7-tool-bindings.js";
|
||||
@@ -0,0 +1,374 @@
|
||||
import type {
|
||||
Phase7JsonSchema,
|
||||
Phase7OptionalCatalogGroup,
|
||||
Phase7SemanticToolDescriptor,
|
||||
Phase7ToolTaskMode,
|
||||
} from "./phase7-semantic-tool-types.js";
|
||||
|
||||
const ALL_MODES: Phase7ToolTaskMode[] = ["standard", "ask", "planning", "skill_test"];
|
||||
const WORK_MODES: Phase7ToolTaskMode[] = ["standard", "planning", "skill_test"];
|
||||
const STANDARD_MODES: Phase7ToolTaskMode[] = ["standard", "skill_test"];
|
||||
const RESULT_SCHEMA = objectSchema({
|
||||
schema: stringSchema({ enum: ["paperclip.phase7.tool-result.v1"] }),
|
||||
ok: { type: "boolean" },
|
||||
operationId: stringSchema({ minLength: 1 }),
|
||||
operationResultId: stringSchema({ minLength: 1 }),
|
||||
value: {},
|
||||
commandResult: {},
|
||||
authorization: {},
|
||||
}, ["schema", "ok", "operationId", "operationResultId", "value", "commandResult", "authorization"]);
|
||||
const SECRET_REDACTION = [
|
||||
{
|
||||
path: "$.value",
|
||||
replacement: "[SECRET_VALUE]" as const,
|
||||
appliesTo: ["output", "error", "authorization_record"] as const,
|
||||
},
|
||||
];
|
||||
const AUTH_REDACTION = [
|
||||
{
|
||||
path: "$.headers.authorization",
|
||||
replacement: "[REDACTED]" as const,
|
||||
appliesTo: ["input", "output", "error", "authorization_record"] as const,
|
||||
},
|
||||
{
|
||||
path: "$.headers.cookie",
|
||||
replacement: "[REDACTED]" as const,
|
||||
appliesTo: ["input", "output", "error", "authorization_record"] as const,
|
||||
},
|
||||
];
|
||||
|
||||
function stringSchema(options: Partial<Phase7JsonSchema> = {}): Phase7JsonSchema {
|
||||
return { type: "string", ...options };
|
||||
}
|
||||
|
||||
function arraySchema(items: Phase7JsonSchema): Phase7JsonSchema {
|
||||
return { type: "array", items };
|
||||
}
|
||||
|
||||
function objectSchema(
|
||||
properties: Record<string, Phase7JsonSchema>,
|
||||
required: string[] = [],
|
||||
): Phase7JsonSchema {
|
||||
return { type: "object", properties, required, additionalProperties: false };
|
||||
}
|
||||
|
||||
function descriptor(
|
||||
value: Omit<Phase7SemanticToolDescriptor, "version" | "redaction"> & {
|
||||
redaction?: Phase7SemanticToolDescriptor["redaction"];
|
||||
},
|
||||
): Phase7SemanticToolDescriptor {
|
||||
const { redaction, ...rest } = value;
|
||||
return { version: 1, ...rest, redaction: redaction ?? [] };
|
||||
}
|
||||
|
||||
const ALWAYS_TOOLS: Phase7SemanticToolDescriptor[] = [
|
||||
descriptor({
|
||||
operationId: "get_task_context",
|
||||
title: "Get task context",
|
||||
description: "Read the active task, ancestors, wake context, linked results, and budget summary.",
|
||||
inputSchema: objectSchema({}),
|
||||
outputSchema: RESULT_SCHEMA,
|
||||
disposition: "always_agent_tool",
|
||||
optionalGroup: null,
|
||||
requiredClaims: [],
|
||||
taskModes: ALL_MODES,
|
||||
sideEffectClass: "read",
|
||||
idempotency: "none",
|
||||
mockCommandMapping: { kind: "context_read", projection: "active_task" },
|
||||
}),
|
||||
descriptor({
|
||||
operationId: "get_task_history",
|
||||
title: "Get task history",
|
||||
description: "Read comments and audit-visible history for the active task only.",
|
||||
inputSchema: objectSchema({ limit: { type: "integer", minimum: 1 } }),
|
||||
outputSchema: RESULT_SCHEMA,
|
||||
disposition: "always_agent_tool",
|
||||
optionalGroup: null,
|
||||
requiredClaims: [],
|
||||
taskModes: ALL_MODES,
|
||||
sideEffectClass: "read",
|
||||
idempotency: "none",
|
||||
mockCommandMapping: { kind: "snapshot_read", projection: "active_task_history" },
|
||||
}),
|
||||
descriptor({
|
||||
operationId: "list_documents",
|
||||
title: "List task documents",
|
||||
description: "List document metadata attached to the active task.",
|
||||
inputSchema: objectSchema({}),
|
||||
outputSchema: RESULT_SCHEMA,
|
||||
disposition: "always_agent_tool",
|
||||
optionalGroup: null,
|
||||
requiredClaims: [],
|
||||
taskModes: ALL_MODES,
|
||||
sideEffectClass: "read",
|
||||
idempotency: "none",
|
||||
mockCommandMapping: { kind: "snapshot_read", projection: "active_task_documents" },
|
||||
}),
|
||||
descriptor({
|
||||
operationId: "read_document",
|
||||
title: "Read task document",
|
||||
description: "Read one document on the active task by stable key.",
|
||||
inputSchema: objectSchema({ key: stringSchema({ minLength: 1 }) }, ["key"]),
|
||||
outputSchema: RESULT_SCHEMA,
|
||||
disposition: "always_agent_tool",
|
||||
optionalGroup: null,
|
||||
requiredClaims: [],
|
||||
taskModes: ALL_MODES,
|
||||
sideEffectClass: "read",
|
||||
idempotency: "none",
|
||||
mockCommandMapping: { kind: "snapshot_read", projection: "active_task_document" },
|
||||
}),
|
||||
descriptor({
|
||||
operationId: "list_document_revisions",
|
||||
title: "List document revisions",
|
||||
description: "Inspect revision history for one active-task document.",
|
||||
inputSchema: objectSchema({ key: stringSchema({ minLength: 1 }) }, ["key"]),
|
||||
outputSchema: RESULT_SCHEMA,
|
||||
disposition: "always_agent_tool",
|
||||
optionalGroup: null,
|
||||
requiredClaims: [],
|
||||
taskModes: ALL_MODES,
|
||||
sideEffectClass: "read",
|
||||
idempotency: "none",
|
||||
mockCommandMapping: { kind: "snapshot_read", projection: "active_task_document_revisions" },
|
||||
}),
|
||||
descriptor({
|
||||
operationId: "report_progress",
|
||||
title: "Report progress",
|
||||
description: "Append a durable progress update to the active task.",
|
||||
inputSchema: objectSchema({ body: stringSchema({ minLength: 1 }) }, ["body"]),
|
||||
outputSchema: RESULT_SCHEMA,
|
||||
disposition: "always_agent_tool",
|
||||
optionalGroup: null,
|
||||
requiredClaims: [],
|
||||
taskModes: WORK_MODES,
|
||||
sideEffectClass: "task_write",
|
||||
idempotency: "required",
|
||||
mockCommandMapping: { kind: "semantic_command", commandKind: "report_progress" },
|
||||
}),
|
||||
descriptor({
|
||||
operationId: "answer_status_question",
|
||||
title: "Answer status question",
|
||||
description: "Post an answer to a status question without changing task state.",
|
||||
inputSchema: objectSchema({ body: stringSchema({ minLength: 1 }) }, ["body"]),
|
||||
outputSchema: RESULT_SCHEMA,
|
||||
disposition: "always_agent_tool",
|
||||
optionalGroup: null,
|
||||
requiredClaims: [],
|
||||
taskModes: ALL_MODES,
|
||||
sideEffectClass: "task_write",
|
||||
idempotency: "required",
|
||||
mockCommandMapping: { kind: "semantic_command", commandKind: "report_progress" },
|
||||
}),
|
||||
descriptor({
|
||||
operationId: "finish_task",
|
||||
title: "Finish task",
|
||||
description: "Finish the active task with a durable completion summary.",
|
||||
inputSchema: objectSchema({ summary: stringSchema({ minLength: 1 }) }, ["summary"]),
|
||||
outputSchema: RESULT_SCHEMA,
|
||||
disposition: "always_agent_tool",
|
||||
optionalGroup: null,
|
||||
requiredClaims: [],
|
||||
taskModes: STANDARD_MODES,
|
||||
sideEffectClass: "task_write",
|
||||
idempotency: "required",
|
||||
mockCommandMapping: { kind: "semantic_command", commandKind: "finish_task" },
|
||||
}),
|
||||
descriptor({
|
||||
operationId: "block_task",
|
||||
title: "Block task",
|
||||
description: "Block the active task with a reason and optional first-class dependencies.",
|
||||
inputSchema: objectSchema({
|
||||
reason: stringSchema({ minLength: 1 }),
|
||||
blockedByTaskIds: arraySchema(stringSchema({ minLength: 1 })),
|
||||
}, ["reason"]),
|
||||
outputSchema: RESULT_SCHEMA,
|
||||
disposition: "always_agent_tool",
|
||||
optionalGroup: null,
|
||||
requiredClaims: [],
|
||||
taskModes: WORK_MODES,
|
||||
sideEffectClass: "task_write",
|
||||
idempotency: "required",
|
||||
mockCommandMapping: { kind: "semantic_command", commandKind: "block_task" },
|
||||
}),
|
||||
descriptor({
|
||||
operationId: "request_review",
|
||||
title: "Request task review",
|
||||
description: "Move the active task to review with a durable summary.",
|
||||
inputSchema: objectSchema({ summary: stringSchema({ minLength: 1 }) }, ["summary"]),
|
||||
outputSchema: RESULT_SCHEMA,
|
||||
disposition: "always_agent_tool",
|
||||
optionalGroup: null,
|
||||
requiredClaims: [],
|
||||
taskModes: WORK_MODES,
|
||||
sideEffectClass: "task_write",
|
||||
idempotency: "required",
|
||||
mockCommandMapping: { kind: "semantic_command", commandKind: "request_review" },
|
||||
}),
|
||||
descriptor({
|
||||
operationId: "write_document",
|
||||
title: "Write task document",
|
||||
description: "Create or revision-safely update an active-task document.",
|
||||
inputSchema: objectSchema({
|
||||
key: stringSchema({ minLength: 1 }),
|
||||
title: stringSchema({ minLength: 1 }),
|
||||
body: stringSchema(),
|
||||
baseRevisionId: { oneOf: [stringSchema({ minLength: 1 }), { type: "null" }] },
|
||||
changeSummary: stringSchema(),
|
||||
}, ["key", "title", "body", "baseRevisionId"]),
|
||||
outputSchema: RESULT_SCHEMA,
|
||||
disposition: "always_agent_tool",
|
||||
optionalGroup: null,
|
||||
requiredClaims: [],
|
||||
taskModes: WORK_MODES,
|
||||
sideEffectClass: "task_write",
|
||||
idempotency: "required",
|
||||
mockCommandMapping: { kind: "semantic_command", commandKind: "write_document" },
|
||||
}),
|
||||
descriptor({
|
||||
operationId: "request_human_input",
|
||||
title: "Request human input",
|
||||
description: "Create a typed confirmation, checkbox, question, task suggestion, or item-verdict request.",
|
||||
inputSchema: objectSchema({
|
||||
interactionKind: stringSchema({ enum: ["confirmation", "checkbox", "questions", "suggest_tasks", "item_verdicts"] }),
|
||||
title: stringSchema({ minLength: 1 }),
|
||||
prompt: stringSchema({ minLength: 1 }),
|
||||
payload: {},
|
||||
targetRevisionId: { oneOf: [stringSchema({ minLength: 1 }), { type: "null" }] },
|
||||
continuationPolicy: stringSchema({ enum: ["none", "wake_assignee", "wake_assignee_on_accept"] }),
|
||||
}, ["interactionKind", "title", "prompt", "continuationPolicy"]),
|
||||
outputSchema: RESULT_SCHEMA,
|
||||
disposition: "always_agent_tool",
|
||||
optionalGroup: null,
|
||||
requiredClaims: [],
|
||||
taskModes: WORK_MODES,
|
||||
sideEffectClass: "task_write",
|
||||
idempotency: "required",
|
||||
mockCommandMapping: { kind: "semantic_command", commandKind: "request_human_input" },
|
||||
}),
|
||||
descriptor({
|
||||
operationId: "register_deliverable",
|
||||
title: "Register deliverable",
|
||||
description: "Register an inspectable artifact and its work product on the active task.",
|
||||
inputSchema: objectSchema({
|
||||
filename: stringSchema({ minLength: 1 }),
|
||||
contentType: stringSchema({ minLength: 1 }),
|
||||
byteSize: { type: "integer", minimum: 0 },
|
||||
sha256: stringSchema({ minLength: 1 }),
|
||||
contentRef: stringSchema({ minLength: 1 }),
|
||||
title: stringSchema({ minLength: 1 }),
|
||||
}, ["filename", "contentType", "byteSize", "sha256", "contentRef", "title"]),
|
||||
outputSchema: RESULT_SCHEMA,
|
||||
disposition: "always_agent_tool",
|
||||
optionalGroup: null,
|
||||
requiredClaims: [],
|
||||
taskModes: STANDARD_MODES,
|
||||
sideEffectClass: "task_write",
|
||||
idempotency: "required",
|
||||
mockCommandMapping: { kind: "semantic_command", commandKind: "register_deliverable" },
|
||||
}),
|
||||
descriptor({
|
||||
operationId: "inspect_operation_result",
|
||||
title: "Inspect operation result",
|
||||
description: "Read a prior semantic operation result from this run.",
|
||||
inputSchema: objectSchema({ operationResultId: stringSchema({ minLength: 1 }) }, ["operationResultId"]),
|
||||
outputSchema: RESULT_SCHEMA,
|
||||
disposition: "always_agent_tool",
|
||||
optionalGroup: null,
|
||||
requiredClaims: [],
|
||||
taskModes: ALL_MODES,
|
||||
sideEffectClass: "read",
|
||||
idempotency: "none",
|
||||
mockCommandMapping: { kind: "operation_result" },
|
||||
}),
|
||||
];
|
||||
|
||||
function optional(
|
||||
operationId: string,
|
||||
group: Phase7OptionalCatalogGroup,
|
||||
requiredClaims: string[],
|
||||
sideEffectClass: Phase7SemanticToolDescriptor["sideEffectClass"],
|
||||
mapping: Phase7SemanticToolDescriptor["mockCommandMapping"],
|
||||
inputSchema: Phase7JsonSchema = objectSchema({}),
|
||||
options: Partial<Pick<Phase7SemanticToolDescriptor, "allowedRoles" | "redaction" | "taskModes" | "idempotency">> = {},
|
||||
): Phase7SemanticToolDescriptor {
|
||||
const title = operationId.split("_").map((part) => part[0]!.toUpperCase() + part.slice(1)).join(" ");
|
||||
return descriptor({
|
||||
operationId,
|
||||
title,
|
||||
description: `${title} through the Phase 7 ${group.replaceAll("_", " ")} capability set.`,
|
||||
inputSchema,
|
||||
outputSchema: RESULT_SCHEMA,
|
||||
disposition: "optional_agent_tool",
|
||||
optionalGroup: group,
|
||||
requiredClaims,
|
||||
taskModes: options.taskModes ?? STANDARD_MODES,
|
||||
sideEffectClass,
|
||||
idempotency: options.idempotency ?? (sideEffectClass === "read" ? "none" : "required"),
|
||||
redaction: options.redaction,
|
||||
allowedRoles: options.allowedRoles,
|
||||
mockCommandMapping: mapping,
|
||||
});
|
||||
}
|
||||
|
||||
const OPTIONAL_TOOLS: Phase7SemanticToolDescriptor[] = [
|
||||
optional("search_tasks", "discovery", ["discovery:tasks:read"], "read", { kind: "snapshot_read", projection: "company_tasks" }, objectSchema({ query: stringSchema(), status: stringSchema() })),
|
||||
optional("list_agents", "discovery", ["discovery:agents:read"], "read", { kind: "snapshot_read", projection: "company_actors" }),
|
||||
optional("list_projects", "discovery", ["discovery:projects:read"], "read", { kind: "mock_extension", extension: "discovery.projects" }),
|
||||
optional("list_goals", "discovery", ["discovery:goals:read"], "read", { kind: "mock_extension", extension: "discovery.goals" }),
|
||||
|
||||
optional("create_task", "delegation_dependencies", ["delegation:tasks:create"], "company_write", { kind: "semantic_command", commandKind: "create_task" }, objectSchema({ title: stringSchema({ minLength: 1 }), description: stringSchema(), assigneeActorId: stringSchema(), priority: stringSchema(), blockedByTaskIds: arraySchema(stringSchema()) }, ["title"])),
|
||||
optional("set_dependencies", "delegation_dependencies", ["dependencies:write"], "company_write", { kind: "semantic_command", commandKind: "set_dependencies" }, objectSchema({ blockedByTaskIds: arraySchema(stringSchema({ minLength: 1 })) }, ["blockedByTaskIds"])),
|
||||
|
||||
optional("list_approvals", "governance", ["governance:approvals:read"], "read", { kind: "snapshot_read", projection: "company_approvals" }),
|
||||
optional("request_approval", "governance", ["governance:approvals:request"], "governance", { kind: "semantic_command", commandKind: "request_approval" }, objectSchema({ approvalType: stringSchema({ minLength: 1 }), payload: {} }, ["approvalType", "payload"])),
|
||||
optional("decide_approval", "governance", ["governance:approvals:decide"], "governance", { kind: "semantic_command", commandKind: "decide_approval" }, objectSchema({ approvalId: stringSchema({ minLength: 1 }), decision: stringSchema({ enum: ["approved", "rejected", "cancelled"] }), note: stringSchema({ minLength: 1 }) }, ["approvalId", "decision", "note"]), { allowedRoles: ["board", "approver", "security"] }),
|
||||
optional("comment_on_approval", "governance", ["governance:approvals:comment"], "governance", { kind: "semantic_command", commandKind: "comment_on_approval" }, objectSchema({ approvalId: stringSchema({ minLength: 1 }), body: stringSchema({ minLength: 1 }) }, ["approvalId", "body"])),
|
||||
|
||||
optional("list_cases", "cases", ["cases:read"], "read", { kind: "mock_extension", extension: "cases.list" }),
|
||||
optional("upsert_case", "cases", ["cases:write"], "company_write", { kind: "mock_extension", extension: "cases.upsert" }, objectSchema({ key: stringSchema({ minLength: 1 }), body: stringSchema() }, ["key", "body"])),
|
||||
|
||||
optional("get_workspace_runtime", "workspace_runtime", ["workspace:read"], "read", { kind: "snapshot_read", projection: "active_task_workspace" }),
|
||||
optional("control_workspace_service", "workspace_runtime", ["workspace:control"], "workspace_control", { kind: "semantic_command", commandKind: "control_workspace_service" }, objectSchema({ serviceId: stringSchema({ minLength: 1 }), action: stringSchema({ enum: ["start", "stop", "fail"] }), url: stringSchema() }, ["serviceId", "action"])),
|
||||
|
||||
optional("list_routines", "routines", ["routines:read"], "read", { kind: "mock_extension", extension: "routines.list" }),
|
||||
optional("manage_routine", "routines", ["routines:write"], "admin", { kind: "mock_extension", extension: "routines.manage" }),
|
||||
|
||||
optional("list_company_skills", "company_skills", ["company_skills:read"], "read", { kind: "mock_extension", extension: "company_skills.list" }),
|
||||
optional("sync_company_skills", "company_skills", ["company_skills:write"], "admin", { kind: "mock_extension", extension: "company_skills.sync" }),
|
||||
|
||||
optional("list_secret_metadata", "secrets", ["secrets:metadata:read"], "read", { kind: "mock_extension", extension: "secrets.metadata" }),
|
||||
optional("read_secret_value", "secrets", ["secrets:values:read"], "secret_read", { kind: "mock_extension", extension: "secrets.value" }, objectSchema({ name: stringSchema({ minLength: 1 }) }, ["name"]), { redaction: [...SECRET_REDACTION], idempotency: "none" }),
|
||||
|
||||
optional("export_company", "portability_admin", ["portability:export"], "admin", { kind: "mock_extension", extension: "portability.export" }),
|
||||
optional("administer_company", "portability_admin", ["company:admin"], "admin", { kind: "mock_extension", extension: "company.admin" }, objectSchema({ action: stringSchema({ minLength: 1 }), payload: {} }, ["action"]), { allowedRoles: ["board", "ceo", "admin"] }),
|
||||
|
||||
optional("generic_api_request", "test_escape_hatch", ["test:generic_api"], "test_escape_hatch", { kind: "mock_extension", extension: "test.generic_api" }, objectSchema({ method: stringSchema({ minLength: 1 }), path: stringSchema({ minLength: 1 }), headers: { type: "object", additionalProperties: stringSchema() }, body: {} }, ["method", "path"]), { taskModes: ["skill_test"], redaction: [...AUTH_REDACTION] }),
|
||||
];
|
||||
|
||||
export const PHASE7_SEMANTIC_TOOL_CATALOG: readonly Phase7SemanticToolDescriptor[] = Object.freeze(
|
||||
[...ALWAYS_TOOLS, ...OPTIONAL_TOOLS].map((tool) => Object.freeze(tool)),
|
||||
);
|
||||
|
||||
export const PHASE7_OPTIONAL_TOOL_CATALOGS: Readonly<
|
||||
Record<Phase7OptionalCatalogGroup, readonly Phase7SemanticToolDescriptor[]>
|
||||
> = Object.freeze({
|
||||
discovery: toolsInGroup("discovery"),
|
||||
delegation_dependencies: toolsInGroup("delegation_dependencies"),
|
||||
governance: toolsInGroup("governance"),
|
||||
cases: toolsInGroup("cases"),
|
||||
workspace_runtime: toolsInGroup("workspace_runtime"),
|
||||
routines: toolsInGroup("routines"),
|
||||
company_skills: toolsInGroup("company_skills"),
|
||||
secrets: toolsInGroup("secrets"),
|
||||
portability_admin: toolsInGroup("portability_admin"),
|
||||
test_escape_hatch: toolsInGroup("test_escape_hatch"),
|
||||
});
|
||||
|
||||
export function phase7SemanticTool(operationId: string): Phase7SemanticToolDescriptor | undefined {
|
||||
return PHASE7_SEMANTIC_TOOL_CATALOG.find((tool) => tool.operationId === operationId);
|
||||
}
|
||||
|
||||
function toolsInGroup(group: Phase7OptionalCatalogGroup): readonly Phase7SemanticToolDescriptor[] {
|
||||
return Object.freeze(OPTIONAL_TOOLS.filter((tool) => tool.optionalGroup === group));
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
import type {
|
||||
Phase7CommandEnvelope,
|
||||
Phase7JsonValue,
|
||||
Phase7MockControlPlanePort,
|
||||
Phase7SemanticCommand,
|
||||
} from "../mock-core/phase7-control-plane-types.js";
|
||||
import { phase7SemanticTool } from "./phase7-semantic-tool-catalog.js";
|
||||
import { Phase7ToolAuthorizationEngine } from "./phase7-tool-authorization.js";
|
||||
import type {
|
||||
Phase7AuthorizationRecord,
|
||||
Phase7JsonSchema,
|
||||
Phase7PolicyDenial,
|
||||
Phase7ScenarioToolPolicy,
|
||||
Phase7SemanticToolDescriptor,
|
||||
Phase7ToolAuthorizationContext,
|
||||
Phase7ToolInvocation,
|
||||
Phase7ToolInvocationResult,
|
||||
Phase7ToolSuccess,
|
||||
Phase7VisibleToolSet,
|
||||
} from "./phase7-semantic-tool-types.js";
|
||||
|
||||
export interface Phase7SemanticToolRuntimeOptions {
|
||||
adapter: Phase7MockControlPlanePort;
|
||||
runId: string;
|
||||
scenarioGrants?: string[];
|
||||
policy?: Phase7ScenarioToolPolicy;
|
||||
resolveSecretValue?: (name: string) => Promise<string | null> | string | null;
|
||||
}
|
||||
|
||||
export class Phase7SemanticToolRuntime {
|
||||
readonly #adapter: Phase7MockControlPlanePort;
|
||||
readonly #runId: string;
|
||||
readonly #scenarioGrants: string[];
|
||||
readonly #policy: Phase7ScenarioToolPolicy | undefined;
|
||||
readonly #resolveSecretValue: Phase7SemanticToolRuntimeOptions["resolveSecretValue"];
|
||||
readonly #authorization = new Phase7ToolAuthorizationEngine();
|
||||
readonly #operationResults = new Map<string, Phase7JsonValue>();
|
||||
#resultSequence = 0;
|
||||
|
||||
constructor(options: Phase7SemanticToolRuntimeOptions) {
|
||||
this.#adapter = options.adapter;
|
||||
this.#runId = options.runId;
|
||||
this.#scenarioGrants = [...new Set(options.scenarioGrants ?? [])].sort();
|
||||
this.#policy = options.policy === undefined ? undefined : structuredClone(options.policy);
|
||||
this.#resolveSecretValue = options.resolveSecretValue;
|
||||
}
|
||||
|
||||
visibleTools(): Phase7VisibleToolSet {
|
||||
return this.#authorization.computeVisibleTools(this.#context());
|
||||
}
|
||||
|
||||
authorizationRecords(): readonly Phase7AuthorizationRecord[] {
|
||||
return this.#authorization.records();
|
||||
}
|
||||
|
||||
async invoke(invocation: Phase7ToolInvocation): Promise<Phase7ToolInvocationResult> {
|
||||
const context = this.#context();
|
||||
const authorization = this.#authorization.authorizeInvocation(
|
||||
invocation.operationId,
|
||||
invocation.input,
|
||||
context,
|
||||
);
|
||||
if (authorization.outcome !== "allowed") {
|
||||
return this.#denial(
|
||||
invocation.operationId,
|
||||
authorization,
|
||||
authorization.outcome === "absent" ? "operation_absent" : "policy_denied",
|
||||
);
|
||||
}
|
||||
|
||||
const descriptor = phase7SemanticTool(invocation.operationId)!;
|
||||
const validationIssues = validateJsonSchema(descriptor.inputSchema, invocation.input);
|
||||
if (validationIssues.length > 0) {
|
||||
const denied = this.#authorization.denyInvocation(
|
||||
invocation.operationId,
|
||||
context,
|
||||
"input_schema_invalid",
|
||||
);
|
||||
return this.#denial(invocation.operationId, denied, "input_invalid");
|
||||
}
|
||||
if (descriptor.idempotency === "required" && !invocation.idempotencyKey?.trim()) {
|
||||
const denied = this.#authorization.denyInvocation(
|
||||
invocation.operationId,
|
||||
context,
|
||||
"idempotency_key_required",
|
||||
);
|
||||
return this.#denial(invocation.operationId, denied, "input_invalid");
|
||||
}
|
||||
if (invocation.operationId === "decide_approval" && this.#isSelfApproval(invocation.input)) {
|
||||
const denied = this.#authorization.denyInvocation(
|
||||
invocation.operationId,
|
||||
context,
|
||||
"self_approval_conflict",
|
||||
);
|
||||
return this.#denial(invocation.operationId, denied, "policy_denied");
|
||||
}
|
||||
|
||||
const beforeRevision = this.#adapter.snapshot().revision;
|
||||
try {
|
||||
const execution = await this.#execute(descriptor, invocation);
|
||||
const finalAuthorization = this.#authorization.attachStateChange(
|
||||
authorization.sequence,
|
||||
beforeRevision,
|
||||
this.#adapter.snapshot().revision,
|
||||
execution.entityRefs,
|
||||
);
|
||||
const resultId = execution.commandResult?.commandId ?? `tool-result-${++this.#resultSequence}`;
|
||||
const success: Phase7ToolSuccess = {
|
||||
schema: "paperclip.phase7.tool-result.v1",
|
||||
ok: true,
|
||||
operationId: invocation.operationId,
|
||||
operationResultId: resultId,
|
||||
value: execution.value,
|
||||
commandResult: execution.commandResult,
|
||||
authorization: finalAuthorization,
|
||||
};
|
||||
this.#operationResults.set(resultId, redactStoredResult(descriptor, execution.value));
|
||||
return deepFreeze(success);
|
||||
} catch {
|
||||
const denied = this.#authorization.denyInvocation(
|
||||
invocation.operationId,
|
||||
context,
|
||||
"mock_operation_rejected",
|
||||
);
|
||||
return this.#denial(invocation.operationId, denied, "operation_unsupported");
|
||||
}
|
||||
}
|
||||
|
||||
async #execute(
|
||||
descriptor: Phase7SemanticToolDescriptor,
|
||||
invocation: Phase7ToolInvocation,
|
||||
): Promise<{ value: Phase7JsonValue; commandResult: Phase7ToolSuccess["commandResult"]; entityRefs: string[] }> {
|
||||
const input = asObject(invocation.input);
|
||||
switch (descriptor.mockCommandMapping.kind) {
|
||||
case "context_read":
|
||||
return { value: toJsonValue(this.#adapter.context(this.#runId)), commandResult: null, entityRefs: [] };
|
||||
case "snapshot_read":
|
||||
return {
|
||||
value: this.#snapshotProjection(descriptor.mockCommandMapping.projection, input),
|
||||
commandResult: null,
|
||||
entityRefs: [],
|
||||
};
|
||||
case "operation_result": {
|
||||
const id = requireString(input.operationResultId);
|
||||
const result = this.#operationResults.get(id);
|
||||
if (result === undefined) throw new Error("operation result is not available");
|
||||
return { value: structuredClone(result), commandResult: null, entityRefs: [] };
|
||||
}
|
||||
case "semantic_command": {
|
||||
const command = commandForOperation(descriptor.operationId, input);
|
||||
const envelope: Phase7CommandEnvelope = {
|
||||
runId: this.#runId,
|
||||
idempotencyKey: invocation.idempotencyKey!,
|
||||
command,
|
||||
};
|
||||
const commandResult = await this.#adapter.applyCommand(envelope);
|
||||
return {
|
||||
value: toJsonValue(commandResult),
|
||||
commandResult,
|
||||
entityRefs: commandResult.entityRefs,
|
||||
};
|
||||
}
|
||||
case "mock_extension":
|
||||
return this.#executeExtension(descriptor, input);
|
||||
}
|
||||
}
|
||||
|
||||
async #executeExtension(
|
||||
descriptor: Phase7SemanticToolDescriptor,
|
||||
input: Record<string, Phase7JsonValue>,
|
||||
): Promise<{ value: Phase7JsonValue; commandResult: null; entityRefs: string[] }> {
|
||||
switch (descriptor.mockCommandMapping.kind === "mock_extension"
|
||||
? descriptor.mockCommandMapping.extension
|
||||
: "") {
|
||||
case "discovery.projects":
|
||||
case "discovery.goals":
|
||||
case "cases.list":
|
||||
case "routines.list":
|
||||
case "company_skills.list":
|
||||
return { value: [], commandResult: null, entityRefs: [] };
|
||||
case "secrets.metadata":
|
||||
return { value: [], commandResult: null, entityRefs: [] };
|
||||
case "secrets.value": {
|
||||
const name = requireString(input.name);
|
||||
const value = await this.#resolveSecretValue?.(name);
|
||||
if (value === null || value === undefined) throw new Error("secret is not available");
|
||||
return { value: { name, value }, commandResult: null, entityRefs: [] };
|
||||
}
|
||||
case "portability.export": {
|
||||
const snapshot = this.#adapter.snapshot();
|
||||
return {
|
||||
value: {
|
||||
schema: "paperclip.phase7.mock-export.v1",
|
||||
company: { id: snapshot.company.id, name: snapshot.company.name },
|
||||
taskCount: snapshot.tasks.length,
|
||||
actorCount: snapshot.actors.length,
|
||||
},
|
||||
commandResult: null,
|
||||
entityRefs: [],
|
||||
};
|
||||
}
|
||||
case "test.generic_api":
|
||||
return {
|
||||
value: {
|
||||
status: 200,
|
||||
body: null,
|
||||
warning: "TEST-ONLY mock request; no control plane or network was contacted.",
|
||||
},
|
||||
commandResult: null,
|
||||
entityRefs: [],
|
||||
};
|
||||
default:
|
||||
throw new Error("mock extension is not implemented");
|
||||
}
|
||||
}
|
||||
|
||||
#snapshotProjection(
|
||||
projection: string,
|
||||
input: Record<string, Phase7JsonValue>,
|
||||
): Phase7JsonValue {
|
||||
const snapshot = this.#adapter.snapshot();
|
||||
const context = this.#adapter.context(this.#runId);
|
||||
switch (projection) {
|
||||
case "active_task_history":
|
||||
return toJsonValue(snapshot.comments.filter((comment) => comment.taskId === context.activeTask.id));
|
||||
case "active_task_documents":
|
||||
return toJsonValue(snapshot.documents
|
||||
.filter((document) => document.taskId === context.activeTask.id)
|
||||
.map(({ id, key, title, format, latestRevisionId, revisions }) => ({
|
||||
id, key, title, format, latestRevisionId, revisionCount: revisions.length,
|
||||
})));
|
||||
case "active_task_document": {
|
||||
const key = requireString(input.key);
|
||||
const document = snapshot.documents.find(
|
||||
(candidate) => candidate.taskId === context.activeTask.id && candidate.key === key,
|
||||
);
|
||||
if (document === undefined) throw new Error("document is not available");
|
||||
return toJsonValue(document);
|
||||
}
|
||||
case "active_task_document_revisions": {
|
||||
const key = requireString(input.key);
|
||||
const document = snapshot.documents.find(
|
||||
(candidate) => candidate.taskId === context.activeTask.id && candidate.key === key,
|
||||
);
|
||||
if (document === undefined) throw new Error("document is not available");
|
||||
return toJsonValue(document.revisions);
|
||||
}
|
||||
case "company_tasks": {
|
||||
const query = typeof input.query === "string" ? input.query.toLowerCase() : "";
|
||||
const status = typeof input.status === "string" ? input.status : null;
|
||||
return toJsonValue(snapshot.tasks.filter(
|
||||
(task) =>
|
||||
(query === "" || `${task.identifier} ${task.title} ${task.description ?? ""}`.toLowerCase().includes(query)) &&
|
||||
(status === null || task.status === status),
|
||||
));
|
||||
}
|
||||
case "company_actors":
|
||||
return toJsonValue(snapshot.actors.map(({ id, companyId, name, role, status }) => ({ id, companyId, name, role, status })));
|
||||
case "company_approvals":
|
||||
return toJsonValue(snapshot.approvals);
|
||||
case "active_task_workspace":
|
||||
return toJsonValue(snapshot.workspaceServices.filter((service) => service.taskId === context.activeTask.id));
|
||||
default:
|
||||
throw new Error("snapshot projection is not implemented");
|
||||
}
|
||||
}
|
||||
|
||||
#context(): Phase7ToolAuthorizationContext {
|
||||
const runContext = this.#adapter.context(this.#runId);
|
||||
return {
|
||||
runId: this.#runId,
|
||||
actor: {
|
||||
id: runContext.actor.id,
|
||||
role: runContext.actor.role,
|
||||
capabilityGrants: [...runContext.actor.capabilityGrants],
|
||||
},
|
||||
task: {
|
||||
id: runContext.activeTask.id,
|
||||
assigneeActorId: runContext.activeTask.assigneeActorId,
|
||||
workMode: runContext.activeTask.workMode,
|
||||
},
|
||||
scenarioGrants: [...this.#scenarioGrants],
|
||||
policy: this.#policy,
|
||||
};
|
||||
}
|
||||
|
||||
#isSelfApproval(input: Phase7JsonValue): boolean {
|
||||
const approvalId = asObject(input).approvalId;
|
||||
if (typeof approvalId !== "string") return false;
|
||||
const actorId = this.#adapter.context(this.#runId).actor.id;
|
||||
const approval = this.#adapter.snapshot().approvals.find((candidate) => candidate.id === approvalId);
|
||||
return approval?.requestedByActorId === actorId;
|
||||
}
|
||||
|
||||
#denial(
|
||||
operationId: string,
|
||||
authorization: Phase7AuthorizationRecord,
|
||||
code: Phase7PolicyDenial["error"]["code"],
|
||||
): Phase7PolicyDenial {
|
||||
return deepFreeze({
|
||||
schema: "paperclip.phase7.tool-result.v1",
|
||||
ok: false,
|
||||
error: {
|
||||
code,
|
||||
message: code === "operation_absent"
|
||||
? "The requested semantic operation is not available."
|
||||
: "The requested semantic operation was not executed.",
|
||||
operationId,
|
||||
reason: authorization.reason,
|
||||
},
|
||||
authorization,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function commandForOperation(
|
||||
operationId: string,
|
||||
input: Record<string, Phase7JsonValue>,
|
||||
): Phase7SemanticCommand {
|
||||
switch (operationId) {
|
||||
case "report_progress":
|
||||
case "answer_status_question":
|
||||
return { kind: "report_progress", body: requireString(input.body) };
|
||||
case "finish_task":
|
||||
return { kind: "finish_task", summary: requireString(input.summary) };
|
||||
case "block_task":
|
||||
return {
|
||||
kind: "block_task",
|
||||
reason: requireString(input.reason),
|
||||
blockedByTaskIds: optionalStringArray(input.blockedByTaskIds),
|
||||
};
|
||||
case "request_review":
|
||||
return { kind: "request_review", summary: requireString(input.summary) };
|
||||
case "write_document":
|
||||
return {
|
||||
kind: "write_document",
|
||||
key: requireString(input.key),
|
||||
title: requireString(input.title),
|
||||
body: requireString(input.body, true),
|
||||
baseRevisionId: input.baseRevisionId === null ? null : requireString(input.baseRevisionId),
|
||||
changeSummary: typeof input.changeSummary === "string" ? input.changeSummary : undefined,
|
||||
};
|
||||
case "request_human_input":
|
||||
return {
|
||||
kind: "request_human_input",
|
||||
interactionKind: requireString(input.interactionKind) as never,
|
||||
title: requireString(input.title),
|
||||
prompt: requireString(input.prompt),
|
||||
payload: input.payload,
|
||||
targetRevisionId: input.targetRevisionId === null || input.targetRevisionId === undefined
|
||||
? input.targetRevisionId
|
||||
: requireString(input.targetRevisionId),
|
||||
continuationPolicy: requireString(input.continuationPolicy) as never,
|
||||
};
|
||||
case "register_deliverable":
|
||||
return {
|
||||
kind: "register_deliverable",
|
||||
filename: requireString(input.filename),
|
||||
contentType: requireString(input.contentType),
|
||||
byteSize: input.byteSize as number,
|
||||
sha256: requireString(input.sha256),
|
||||
contentRef: requireString(input.contentRef),
|
||||
title: requireString(input.title),
|
||||
};
|
||||
case "create_task":
|
||||
return {
|
||||
kind: "create_task",
|
||||
title: requireString(input.title),
|
||||
description: typeof input.description === "string" ? input.description : undefined,
|
||||
assigneeActorId: typeof input.assigneeActorId === "string" ? input.assigneeActorId : undefined,
|
||||
priority: typeof input.priority === "string" ? input.priority as never : undefined,
|
||||
blockedByTaskIds: optionalStringArray(input.blockedByTaskIds),
|
||||
};
|
||||
case "set_dependencies":
|
||||
return { kind: "set_dependencies", blockedByTaskIds: optionalStringArray(input.blockedByTaskIds) ?? [] };
|
||||
case "request_approval":
|
||||
return { kind: "request_approval", approvalType: requireString(input.approvalType), payload: input.payload ?? {} };
|
||||
case "decide_approval":
|
||||
return {
|
||||
kind: "decide_approval",
|
||||
approvalId: requireString(input.approvalId),
|
||||
decision: requireString(input.decision) as never,
|
||||
note: requireString(input.note),
|
||||
};
|
||||
case "comment_on_approval":
|
||||
return { kind: "comment_on_approval", approvalId: requireString(input.approvalId), body: requireString(input.body) };
|
||||
case "control_workspace_service":
|
||||
return {
|
||||
kind: "control_workspace_service",
|
||||
serviceId: requireString(input.serviceId),
|
||||
action: requireString(input.action) as never,
|
||||
url: typeof input.url === "string" ? input.url : undefined,
|
||||
};
|
||||
default:
|
||||
throw new Error("semantic command mapping is not implemented");
|
||||
}
|
||||
}
|
||||
|
||||
export function validateJsonSchema(schema: Phase7JsonSchema, value: Phase7JsonValue, path = "$" ): string[] {
|
||||
if (schema.oneOf !== undefined) {
|
||||
return schema.oneOf.some((candidate) => validateJsonSchema(candidate, value, path).length === 0)
|
||||
? []
|
||||
: [`${path} does not match any allowed shape`];
|
||||
}
|
||||
if (schema.type !== undefined && !matchesType(schema.type, value)) return [`${path} must be ${schema.type}`];
|
||||
if (schema.enum !== undefined && !schema.enum.some((candidate) => JSON.stringify(candidate) === JSON.stringify(value))) {
|
||||
return [`${path} must be an allowed value`];
|
||||
}
|
||||
if (typeof value === "string" && schema.minLength !== undefined && value.length < schema.minLength) {
|
||||
return [`${path} is too short`];
|
||||
}
|
||||
if (typeof value === "number" && schema.minimum !== undefined && value < schema.minimum) {
|
||||
return [`${path} is below the minimum`];
|
||||
}
|
||||
if (Array.isArray(value) && schema.items !== undefined) {
|
||||
return value.flatMap((item, index) => validateJsonSchema(schema.items!, item, `${path}[${index}]`));
|
||||
}
|
||||
if (schema.type === "object" && typeof value === "object" && value !== null && !Array.isArray(value)) {
|
||||
const object = value as Record<string, Phase7JsonValue>;
|
||||
const issues = (schema.required ?? [])
|
||||
.filter((key) => !(key in object))
|
||||
.map((key) => `${path}.${key} is required`);
|
||||
for (const [key, child] of Object.entries(object)) {
|
||||
const childSchema = schema.properties?.[key];
|
||||
if (childSchema !== undefined) issues.push(...validateJsonSchema(childSchema, child, `${path}.${key}`));
|
||||
else if (schema.additionalProperties === false) issues.push(`${path}.${key} is not allowed`);
|
||||
else if (typeof schema.additionalProperties === "object") {
|
||||
issues.push(...validateJsonSchema(schema.additionalProperties, child, `${path}.${key}`));
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function matchesType(type: NonNullable<Phase7JsonSchema["type"]>, value: Phase7JsonValue): boolean {
|
||||
switch (type) {
|
||||
case "null": return value === null;
|
||||
case "array": return Array.isArray(value);
|
||||
case "object": return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
case "integer": return typeof value === "number" && Number.isSafeInteger(value);
|
||||
default: return typeof value === type;
|
||||
}
|
||||
}
|
||||
|
||||
function redactStoredResult(
|
||||
descriptor: Phase7SemanticToolDescriptor,
|
||||
value: Phase7JsonValue,
|
||||
): Phase7JsonValue {
|
||||
if (!descriptor.redaction.some((rule) => rule.appliesTo.includes("output"))) return structuredClone(value);
|
||||
if (descriptor.operationId === "read_secret_value" && typeof value === "object" && value !== null && !Array.isArray(value)) {
|
||||
return { ...value, value: "[SECRET_VALUE]" };
|
||||
}
|
||||
return structuredClone(value);
|
||||
}
|
||||
|
||||
function optionalStringArray(value: Phase7JsonValue | undefined): string[] | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) throw new Error("expected string array");
|
||||
return value as string[];
|
||||
}
|
||||
|
||||
function requireString(value: Phase7JsonValue | undefined, allowEmpty = false): string {
|
||||
if (typeof value !== "string" || (!allowEmpty && value.length === 0)) throw new Error("expected string");
|
||||
return value;
|
||||
}
|
||||
|
||||
function asObject(value: Phase7JsonValue): Record<string, Phase7JsonValue> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("expected object input");
|
||||
return value;
|
||||
}
|
||||
|
||||
function toJsonValue(value: unknown): Phase7JsonValue {
|
||||
return structuredClone(value) as Phase7JsonValue;
|
||||
}
|
||||
|
||||
function deepFreeze<T>(value: T): T {
|
||||
if (typeof value !== "object" || value === null || Object.isFrozen(value)) return value;
|
||||
Object.freeze(value);
|
||||
for (const child of Object.values(value)) deepFreeze(child);
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import type {
|
||||
Phase7CommandResult,
|
||||
Phase7InteractionKind,
|
||||
Phase7JsonValue,
|
||||
Phase7SemanticCommand,
|
||||
} from "../mock-core/phase7-control-plane-types.js";
|
||||
|
||||
export type { Phase7JsonValue } from "../mock-core/phase7-control-plane-types.js";
|
||||
|
||||
export type Phase7ToolDisposition = "always_agent_tool" | "optional_agent_tool";
|
||||
|
||||
export type Phase7OptionalCatalogGroup =
|
||||
| "discovery"
|
||||
| "delegation_dependencies"
|
||||
| "governance"
|
||||
| "cases"
|
||||
| "workspace_runtime"
|
||||
| "routines"
|
||||
| "company_skills"
|
||||
| "secrets"
|
||||
| "portability_admin"
|
||||
| "test_escape_hatch";
|
||||
|
||||
export type Phase7SideEffectClass =
|
||||
| "read"
|
||||
| "task_write"
|
||||
| "company_write"
|
||||
| "governance"
|
||||
| "workspace_control"
|
||||
| "secret_read"
|
||||
| "admin"
|
||||
| "test_escape_hatch";
|
||||
|
||||
export type Phase7IdempotencyBehavior = "none" | "required" | "recommended";
|
||||
|
||||
export type Phase7ToolTaskMode = "standard" | "ask" | "planning" | "skill_test";
|
||||
|
||||
export interface Phase7JsonSchema {
|
||||
$schema?: string;
|
||||
type?: "object" | "array" | "string" | "number" | "integer" | "boolean" | "null";
|
||||
title?: string;
|
||||
description?: string;
|
||||
properties?: Record<string, Phase7JsonSchema>;
|
||||
required?: string[];
|
||||
additionalProperties?: boolean | Phase7JsonSchema;
|
||||
items?: Phase7JsonSchema;
|
||||
enum?: Phase7JsonValue[];
|
||||
minLength?: number;
|
||||
minimum?: number;
|
||||
oneOf?: Phase7JsonSchema[];
|
||||
}
|
||||
|
||||
export interface Phase7RedactionRule {
|
||||
path: string;
|
||||
replacement: "[REDACTED]" | "[SECRET_VALUE]";
|
||||
appliesTo: ReadonlyArray<"input" | "output" | "error" | "authorization_record">;
|
||||
}
|
||||
|
||||
export type Phase7MockCommandMapping =
|
||||
| { kind: "context_read"; projection: string }
|
||||
| { kind: "snapshot_read"; projection: string }
|
||||
| { kind: "semantic_command"; commandKind: Phase7SemanticCommand["kind"] }
|
||||
| { kind: "operation_result" }
|
||||
| { kind: "mock_extension"; extension: string };
|
||||
|
||||
export interface Phase7SemanticToolDescriptor {
|
||||
operationId: string;
|
||||
version: 1;
|
||||
title: string;
|
||||
description: string;
|
||||
inputSchema: Phase7JsonSchema;
|
||||
outputSchema: Phase7JsonSchema;
|
||||
disposition: Phase7ToolDisposition;
|
||||
optionalGroup: Phase7OptionalCatalogGroup | null;
|
||||
requiredClaims: string[];
|
||||
allowedRoles?: string[];
|
||||
taskModes: Phase7ToolTaskMode[];
|
||||
sideEffectClass: Phase7SideEffectClass;
|
||||
idempotency: Phase7IdempotencyBehavior;
|
||||
redaction: Phase7RedactionRule[];
|
||||
mockCommandMapping: Phase7MockCommandMapping;
|
||||
}
|
||||
|
||||
export interface Phase7ScenarioToolPolicy {
|
||||
deniedOperationIds?: string[];
|
||||
deniedClaims?: string[];
|
||||
allowedInteractionKinds?: Phase7InteractionKind[];
|
||||
allowSecretValueAccess?: boolean;
|
||||
allowGenericEscapeHatch?: boolean;
|
||||
genericEscapeHatchAllowlist?: Array<{ method: string; path: string }>;
|
||||
}
|
||||
|
||||
export interface Phase7ToolAuthorizationContext {
|
||||
runId: string;
|
||||
actor: {
|
||||
id: string;
|
||||
role: string;
|
||||
capabilityGrants: string[];
|
||||
};
|
||||
task: {
|
||||
id: string;
|
||||
assigneeActorId: string | null;
|
||||
workMode: Phase7ToolTaskMode;
|
||||
};
|
||||
scenarioGrants: string[];
|
||||
policy?: Phase7ScenarioToolPolicy;
|
||||
}
|
||||
|
||||
export type Phase7AuthorizationOutcome = "exposed" | "allowed" | "denied" | "absent";
|
||||
|
||||
export interface Phase7AuthorizationRecord {
|
||||
schema: "paperclip.phase7.authorization-record.v1";
|
||||
sequence: number;
|
||||
runId: string;
|
||||
actorId: string;
|
||||
taskId: string;
|
||||
operationId: string;
|
||||
phase: "exposure" | "invocation";
|
||||
outcome: Phase7AuthorizationOutcome;
|
||||
consideredClaims: string[];
|
||||
grantedClaims: string[];
|
||||
missingClaims: string[];
|
||||
reason: string;
|
||||
redactions: Array<{ path: string; replacement: string }>;
|
||||
resultingStateChange: {
|
||||
beforeRevision: number | null;
|
||||
afterRevision: number | null;
|
||||
entityRefs: string[];
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface Phase7VisibleToolSet {
|
||||
schema: "paperclip.phase7.visible-tools.v1";
|
||||
tools: Phase7SemanticToolDescriptor[];
|
||||
authorizationRecords: Phase7AuthorizationRecord[];
|
||||
}
|
||||
|
||||
export interface Phase7PolicyDenial {
|
||||
schema: "paperclip.phase7.tool-result.v1";
|
||||
ok: false;
|
||||
error: {
|
||||
code: "policy_denied" | "operation_absent" | "input_invalid" | "operation_unsupported";
|
||||
message: string;
|
||||
operationId: string;
|
||||
reason: string;
|
||||
};
|
||||
authorization: Phase7AuthorizationRecord;
|
||||
}
|
||||
|
||||
export interface Phase7ToolSuccess {
|
||||
schema: "paperclip.phase7.tool-result.v1";
|
||||
ok: true;
|
||||
operationId: string;
|
||||
operationResultId: string;
|
||||
value: Phase7JsonValue;
|
||||
commandResult: Phase7CommandResult | null;
|
||||
authorization: Phase7AuthorizationRecord;
|
||||
}
|
||||
|
||||
export type Phase7ToolInvocationResult = Phase7ToolSuccess | Phase7PolicyDenial;
|
||||
|
||||
export interface Phase7ToolInvocation {
|
||||
operationId: string;
|
||||
input: Phase7JsonValue;
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
export const PHASE7_CONTROL_PLANE_OWNED_OPERATION_IDS = [
|
||||
"checkout_task",
|
||||
"release_task",
|
||||
"select_work",
|
||||
"route_wake",
|
||||
"enforce_budget",
|
||||
"append_audit_record",
|
||||
"persist_run",
|
||||
"replay_run",
|
||||
"schedule_blocker_wake",
|
||||
"reconcile_run",
|
||||
] as const;
|
||||
@@ -0,0 +1,390 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type {
|
||||
Phase7FixtureApproval,
|
||||
Phase7FixtureSeed,
|
||||
Phase7JsonValue,
|
||||
} from "../mock-core/phase7-control-plane-types.js";
|
||||
import { Phase7MockControlPlaneAdapter } from "../mock-core/phase7-mock-control-plane-adapter.js";
|
||||
import {
|
||||
PHASE7_OPTIONAL_TOOL_CATALOGS,
|
||||
PHASE7_SEMANTIC_TOOL_CATALOG,
|
||||
phase7SemanticTool,
|
||||
} from "./phase7-semantic-tool-catalog.js";
|
||||
import { Phase7SemanticToolRuntime, validateJsonSchema } from "./phase7-semantic-tool-runtime.js";
|
||||
import { Phase7CodexToolBinding, Phase7FakeAgentToolBinding } from "./phase7-tool-bindings.js";
|
||||
import { PHASE7_CONTROL_PLANE_OWNED_OPERATION_IDS } from "./phase7-semantic-tool-types.js";
|
||||
|
||||
const OPEN = {
|
||||
identity: {
|
||||
runId: "run-tools",
|
||||
sessionId: "session-tools",
|
||||
companyId: "company-1",
|
||||
issueId: "task-1",
|
||||
agentId: "actor-1",
|
||||
},
|
||||
backendKind: "mock" as const,
|
||||
sourceInstanceId: "phase7-tools-test",
|
||||
};
|
||||
|
||||
async function runtimeFor(options: {
|
||||
workMode?: "standard" | "ask" | "planning" | "skill_test";
|
||||
role?: string;
|
||||
actorGrants?: string[];
|
||||
scenarioGrants?: string[];
|
||||
policy?: ConstructorParameters<typeof Phase7SemanticToolRuntime>[0]["policy"];
|
||||
seed?: Phase7FixtureSeed;
|
||||
resolveSecretValue?: (name: string) => string | null;
|
||||
} = {}) {
|
||||
const actor = {
|
||||
id: "actor-1",
|
||||
companyId: "company-1",
|
||||
name: "Fixture Actor",
|
||||
role: options.role ?? "engineer",
|
||||
status: "active" as const,
|
||||
budgetId: "budget-actor-1",
|
||||
capabilityGrants: options.actorGrants ?? [],
|
||||
};
|
||||
const adapter = new Phase7MockControlPlaneAdapter({
|
||||
...options.seed,
|
||||
actors: [actor],
|
||||
tasks: options.seed?.tasks ?? [{
|
||||
id: "task-1",
|
||||
companyId: "company-1",
|
||||
identifier: "MCK-1",
|
||||
title: "Tool boundary fixture",
|
||||
description: "Protected task details",
|
||||
status: "todo",
|
||||
priority: "high",
|
||||
workMode: options.workMode ?? "standard",
|
||||
parentId: null,
|
||||
assigneeActorId: "actor-1",
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
}],
|
||||
});
|
||||
await adapter.start();
|
||||
await adapter.openFixtureRun(OPEN);
|
||||
return {
|
||||
adapter,
|
||||
runtime: new Phase7SemanticToolRuntime({
|
||||
adapter,
|
||||
runId: OPEN.identity.runId,
|
||||
scenarioGrants: options.scenarioGrants,
|
||||
policy: options.policy,
|
||||
resolveSecretValue: options.resolveSecretValue,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("Phase 7 semantic tool catalog", () => {
|
||||
it("defines a unique versioned provider-neutral catalog and every optional group", () => {
|
||||
const operationIds = PHASE7_SEMANTIC_TOOL_CATALOG.map((tool) => tool.operationId);
|
||||
expect(new Set(operationIds).size).toBe(operationIds.length);
|
||||
expect(PHASE7_SEMANTIC_TOOL_CATALOG.every((tool) => tool.version === 1)).toBe(true);
|
||||
expect(PHASE7_SEMANTIC_TOOL_CATALOG.every((tool) => tool.inputSchema.type === "object")).toBe(true);
|
||||
expect(PHASE7_SEMANTIC_TOOL_CATALOG.every((tool) => tool.outputSchema.type === "object")).toBe(true);
|
||||
expect(PHASE7_SEMANTIC_TOOL_CATALOG.every((tool) => tool.mockCommandMapping !== undefined)).toBe(true);
|
||||
|
||||
expect(Object.keys(PHASE7_OPTIONAL_TOOL_CATALOGS).sort()).toEqual([
|
||||
"cases",
|
||||
"company_skills",
|
||||
"delegation_dependencies",
|
||||
"discovery",
|
||||
"governance",
|
||||
"portability_admin",
|
||||
"routines",
|
||||
"secrets",
|
||||
"test_escape_hatch",
|
||||
"workspace_runtime",
|
||||
]);
|
||||
expect(Object.values(PHASE7_OPTIONAL_TOOL_CATALOGS).every((tools) => tools.length > 0)).toBe(true);
|
||||
expect([
|
||||
"get_task_context",
|
||||
"get_task_history",
|
||||
"list_documents",
|
||||
"read_document",
|
||||
"list_document_revisions",
|
||||
"report_progress",
|
||||
"answer_status_question",
|
||||
"finish_task",
|
||||
"block_task",
|
||||
"request_review",
|
||||
"write_document",
|
||||
"request_human_input",
|
||||
"register_deliverable",
|
||||
"inspect_operation_result",
|
||||
].every((id) => phase7SemanticTool(id)?.disposition === "always_agent_tool")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps control-plane operations absent and binds identical fake-agent and Codex surfaces", async () => {
|
||||
const { runtime } = await runtimeFor();
|
||||
const visible = runtime.visibleTools();
|
||||
const fake = new Phase7FakeAgentToolBinding().bind(visible);
|
||||
const codex = new Phase7CodexToolBinding().bind(visible);
|
||||
expect(fake.map((tool) => tool.operationId)).toEqual(codex.map((tool) => tool.name));
|
||||
expect(codex.every((tool) => tool.type === "function" && tool.strict)).toBe(true);
|
||||
for (const operationId of PHASE7_CONTROL_PLANE_OWNED_OPERATION_IDS) {
|
||||
expect(visible.tools.some((tool) => tool.operationId === operationId)).toBe(false);
|
||||
}
|
||||
expect(JSON.stringify({ fake, codex })).not.toContain("Bearer ");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Phase 7 exposure and authorization", () => {
|
||||
it("computes exposure before invocation from task mode, claims, role, and policy", async () => {
|
||||
const baseline = await runtimeFor();
|
||||
const baselineIds = baseline.runtime.visibleTools().tools.map((tool) => tool.operationId);
|
||||
expect(baselineIds).toContain("finish_task");
|
||||
expect(baselineIds).not.toContain("search_tasks");
|
||||
|
||||
const ask = await runtimeFor({ workMode: "ask" });
|
||||
const askIds = ask.runtime.visibleTools().tools.map((tool) => tool.operationId);
|
||||
expect(askIds).toContain("answer_status_question");
|
||||
expect(askIds).not.toContain("finish_task");
|
||||
expect(askIds).not.toContain("write_document");
|
||||
|
||||
const granted = await runtimeFor({
|
||||
scenarioGrants: ["discovery:tasks:read", "governance:approvals:decide"],
|
||||
role: "approver",
|
||||
policy: { deniedOperationIds: ["search_tasks"] },
|
||||
});
|
||||
const grantedIds = granted.runtime.visibleTools().tools.map((tool) => tool.operationId);
|
||||
expect(grantedIds).not.toContain("search_tasks");
|
||||
expect(grantedIds).toContain("decide_approval");
|
||||
expect(granted.runtime.authorizationRecords().some(
|
||||
(record) => record.operationId === "search_tasks" && record.reason === "operation_denied_by_policy",
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps always tools active-task scoped and returns typed denials without protected state", async () => {
|
||||
const protectedValue = "protected-approval-payload";
|
||||
const approval: Phase7FixtureApproval = {
|
||||
id: "approval-protected",
|
||||
companyId: "company-1",
|
||||
taskIds: ["task-1"],
|
||||
type: "request_board_approval",
|
||||
status: "pending",
|
||||
requestedByActorId: null,
|
||||
payload: { private: protectedValue },
|
||||
decisionNote: null,
|
||||
comments: [],
|
||||
createdAt: "2026-08-09T00:00:00.000Z",
|
||||
decidedAt: null,
|
||||
};
|
||||
const { adapter, runtime } = await runtimeFor({ seed: { approvals: [approval] } });
|
||||
const revision = adapter.snapshot().revision;
|
||||
const scoped = await runtime.invoke({
|
||||
operationId: "get_task_context",
|
||||
input: { taskId: "task-other" },
|
||||
});
|
||||
expect(scoped).toMatchObject({ ok: false, error: { code: "input_invalid" } });
|
||||
expect(adapter.snapshot().revision).toBe(revision);
|
||||
|
||||
const denied = await runtime.invoke({
|
||||
operationId: "decide_approval",
|
||||
input: { approvalId: "approval-protected", decision: "approved", note: "approve" },
|
||||
idempotencyKey: "decision-1",
|
||||
});
|
||||
expect(denied).toMatchObject({ ok: false, error: { code: "policy_denied" } });
|
||||
expect(JSON.stringify(denied)).not.toContain(protectedValue);
|
||||
|
||||
const absent = await runtime.invoke({ operationId: "checkout_task", input: {} });
|
||||
expect(absent).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "operation_absent", reason: "control_plane_owned_operation" },
|
||||
});
|
||||
expect(JSON.stringify(absent)).not.toContain("Protected task details");
|
||||
});
|
||||
|
||||
it("emits authorization records with decision reasons and resulting state changes", async () => {
|
||||
const { adapter, runtime } = await runtimeFor();
|
||||
const beforeRevision = adapter.snapshot().revision;
|
||||
const result = await runtime.invoke({
|
||||
operationId: "report_progress",
|
||||
input: { body: "Semantic progress." },
|
||||
idempotencyKey: "progress-1",
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) throw new Error("expected success");
|
||||
expect(result.authorization).toMatchObject({
|
||||
outcome: "allowed",
|
||||
reason: "task_scoped_always_tool",
|
||||
resultingStateChange: {
|
||||
beforeRevision,
|
||||
afterRevision: adapter.snapshot().revision,
|
||||
},
|
||||
});
|
||||
expect(result.authorization.resultingStateChange?.entityRefs).toEqual(
|
||||
expect.arrayContaining(["task:task-1"]),
|
||||
);
|
||||
expect(adapter.snapshot().comments.at(-1)?.body).toBe("Semantic progress.");
|
||||
expect(validateJsonSchema(
|
||||
phase7SemanticTool("report_progress")!.outputSchema,
|
||||
structuredClone(result) as unknown as Phase7JsonValue,
|
||||
)).toEqual([]);
|
||||
});
|
||||
|
||||
it("executes the minimum always-tool workflow through the mock command contract", async () => {
|
||||
const { adapter, runtime } = await runtimeFor();
|
||||
const written = await runtime.invoke({
|
||||
operationId: "write_document",
|
||||
input: {
|
||||
key: "plan",
|
||||
title: "Plan",
|
||||
body: "# Semantic plan",
|
||||
baseRevisionId: null,
|
||||
},
|
||||
idempotencyKey: "write-plan-1",
|
||||
});
|
||||
expect(written.ok).toBe(true);
|
||||
const revisionId = written.ok
|
||||
? written.commandResult?.entityRefs.find((ref) => ref.startsWith("revision:"))?.slice(9)
|
||||
: undefined;
|
||||
expect(revisionId).toBeTruthy();
|
||||
|
||||
const read = await runtime.invoke({ operationId: "read_document", input: { key: "plan" } });
|
||||
expect(read).toMatchObject({ ok: true, value: { key: "plan", latestRevisionId: revisionId } });
|
||||
const revisions = await runtime.invoke({
|
||||
operationId: "list_document_revisions",
|
||||
input: { key: "plan" },
|
||||
});
|
||||
expect(revisions).toMatchObject({ ok: true, value: [{ id: revisionId, revision: 1 }] });
|
||||
|
||||
expect(await runtime.invoke({
|
||||
operationId: "request_human_input",
|
||||
input: {
|
||||
interactionKind: "confirmation",
|
||||
title: "Confirm plan",
|
||||
prompt: "Accept this plan?",
|
||||
targetRevisionId: revisionId!,
|
||||
continuationPolicy: "wake_assignee",
|
||||
},
|
||||
idempotencyKey: "confirm-plan-1",
|
||||
})).toMatchObject({ ok: true });
|
||||
expect(await runtime.invoke({
|
||||
operationId: "register_deliverable",
|
||||
input: {
|
||||
filename: "report.json",
|
||||
contentType: "application/json",
|
||||
byteSize: 42,
|
||||
sha256: "fixture-sha256",
|
||||
contentRef: "fixture://report.json",
|
||||
title: "Semantic report",
|
||||
},
|
||||
idempotencyKey: "deliverable-1",
|
||||
})).toMatchObject({ ok: true });
|
||||
expect(await runtime.invoke({
|
||||
operationId: "request_review",
|
||||
input: { summary: "Ready for semantic review." },
|
||||
idempotencyKey: "review-1",
|
||||
})).toMatchObject({ ok: true });
|
||||
|
||||
expect(adapter.snapshot()).toMatchObject({
|
||||
tasks: [{ status: "in_review" }],
|
||||
documents: [{ key: "plan", revisions: [{ body: "# Semantic plan" }] }],
|
||||
interactions: [{ kind: "confirmation", status: "pending" }],
|
||||
artifacts: [{ filename: "report.json" }],
|
||||
workProducts: [{ type: "artifact" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Phase 7 security policy", () => {
|
||||
it("redacts secret values from records and stored operation results", async () => {
|
||||
const secret = "fixture-secret-value";
|
||||
const { runtime } = await runtimeFor({
|
||||
scenarioGrants: ["secrets:values:read"],
|
||||
policy: { allowSecretValueAccess: true },
|
||||
resolveSecretValue: (name) => name === "DEPLOY_TOKEN" ? secret : null,
|
||||
});
|
||||
expect(runtime.visibleTools().tools.map((tool) => tool.operationId)).toContain("read_secret_value");
|
||||
const result = await runtime.invoke({
|
||||
operationId: "read_secret_value",
|
||||
input: { name: "DEPLOY_TOKEN" },
|
||||
});
|
||||
expect(result).toMatchObject({ ok: true, value: { name: "DEPLOY_TOKEN", value: secret } });
|
||||
if (!result.ok) throw new Error("expected secret read success");
|
||||
expect(JSON.stringify(runtime.authorizationRecords())).not.toContain(secret);
|
||||
expect(result.authorization.redactions).toEqual([
|
||||
{ path: "$.value", replacement: "[SECRET_VALUE]" },
|
||||
]);
|
||||
|
||||
const inspected = await runtime.invoke({
|
||||
operationId: "inspect_operation_result",
|
||||
input: { operationResultId: result.operationResultId },
|
||||
});
|
||||
expect(inspected).toMatchObject({ ok: true, value: { value: "[SECRET_VALUE]" } });
|
||||
|
||||
const ungranted = await runtimeFor({
|
||||
scenarioGrants: ["secrets:values:read"],
|
||||
resolveSecretValue: () => secret,
|
||||
});
|
||||
expect(ungranted.runtime.visibleTools().tools.map((tool) => tool.operationId)).not.toContain("read_secret_value");
|
||||
});
|
||||
|
||||
it("prohibits self approval even with the role and explicit decision claim", async () => {
|
||||
const approval: Phase7FixtureApproval = {
|
||||
id: "approval-self",
|
||||
companyId: "company-1",
|
||||
taskIds: ["task-1"],
|
||||
type: "request_board_approval",
|
||||
status: "pending",
|
||||
requestedByActorId: "actor-1",
|
||||
payload: { summary: "self requested" },
|
||||
decisionNote: null,
|
||||
comments: [],
|
||||
createdAt: "2026-08-09T00:00:00.000Z",
|
||||
decidedAt: null,
|
||||
};
|
||||
const { adapter, runtime } = await runtimeFor({
|
||||
role: "approver",
|
||||
scenarioGrants: ["governance:approvals:decide"],
|
||||
seed: { approvals: [approval] },
|
||||
});
|
||||
const result = await runtime.invoke({
|
||||
operationId: "decide_approval",
|
||||
input: { approvalId: "approval-self", decision: "approved", note: "self approve" },
|
||||
idempotencyKey: "self-decision",
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "policy_denied", reason: "self_approval_conflict" },
|
||||
});
|
||||
expect(adapter.snapshot().approvals[0]?.status).toBe("pending");
|
||||
});
|
||||
|
||||
it("keeps the generic escape hatch test-only, explicitly granted, and allowlisted", async () => {
|
||||
const denied = await runtimeFor({ workMode: "skill_test", scenarioGrants: ["test:generic_api"] });
|
||||
expect(denied.runtime.visibleTools().tools.map((tool) => tool.operationId)).not.toContain("generic_api_request");
|
||||
|
||||
const { runtime } = await runtimeFor({
|
||||
workMode: "skill_test",
|
||||
scenarioGrants: ["test:generic_api"],
|
||||
policy: {
|
||||
allowGenericEscapeHatch: true,
|
||||
genericEscapeHatchAllowlist: [{ method: "GET", path: "/mock/health" }],
|
||||
},
|
||||
});
|
||||
expect(runtime.visibleTools().tools.map((tool) => tool.operationId)).toContain("generic_api_request");
|
||||
const allowed = await runtime.invoke({
|
||||
operationId: "generic_api_request",
|
||||
input: { method: "GET", path: "/mock/health", headers: { authorization: "Bearer not-forwarded" } },
|
||||
idempotencyKey: "escape-1",
|
||||
});
|
||||
expect(allowed).toMatchObject({ ok: true, value: { status: 200, body: null } });
|
||||
expect(JSON.stringify(allowed)).not.toContain("not-forwarded");
|
||||
|
||||
const blocked = await runtime.invoke({
|
||||
operationId: "generic_api_request",
|
||||
input: { method: "POST", path: "/api/companies" },
|
||||
idempotencyKey: "escape-2",
|
||||
});
|
||||
expect(blocked).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "policy_denied", reason: "generic_escape_hatch_target_denied" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
import { phase7SemanticTool, PHASE7_SEMANTIC_TOOL_CATALOG } from "./phase7-semantic-tool-catalog.js";
|
||||
import {
|
||||
PHASE7_CONTROL_PLANE_OWNED_OPERATION_IDS,
|
||||
type Phase7AuthorizationRecord,
|
||||
type Phase7JsonValue,
|
||||
type Phase7SemanticToolDescriptor,
|
||||
type Phase7ToolAuthorizationContext,
|
||||
type Phase7VisibleToolSet,
|
||||
} from "./phase7-semantic-tool-types.js";
|
||||
|
||||
export class Phase7ToolAuthorizationEngine {
|
||||
#sequence = 0;
|
||||
readonly #records: Phase7AuthorizationRecord[] = [];
|
||||
|
||||
computeVisibleTools(context: Phase7ToolAuthorizationContext): Phase7VisibleToolSet {
|
||||
const tools: Phase7SemanticToolDescriptor[] = [];
|
||||
const authorizationRecords = PHASE7_SEMANTIC_TOOL_CATALOG.map((tool) => {
|
||||
const decision = this.#evaluate(tool, context, undefined);
|
||||
const record = this.#record(context, tool, "exposure", decision.allowed ? "exposed" : "absent", decision.reason);
|
||||
if (decision.allowed) tools.push(tool);
|
||||
return record;
|
||||
});
|
||||
return deepFreeze({
|
||||
schema: "paperclip.phase7.visible-tools.v1",
|
||||
tools: clone(tools),
|
||||
authorizationRecords,
|
||||
});
|
||||
}
|
||||
|
||||
authorizeInvocation(
|
||||
operationId: string,
|
||||
input: Phase7JsonValue,
|
||||
context: Phase7ToolAuthorizationContext,
|
||||
): Phase7AuthorizationRecord {
|
||||
if ((PHASE7_CONTROL_PLANE_OWNED_OPERATION_IDS as readonly string[]).includes(operationId)) {
|
||||
return this.#recordUnknown(context, operationId, "absent", "control_plane_owned_operation");
|
||||
}
|
||||
const tool = phase7SemanticTool(operationId);
|
||||
if (tool === undefined) {
|
||||
return this.#recordUnknown(context, operationId, "absent", "unknown_operation");
|
||||
}
|
||||
const decision = this.#evaluate(tool, context, input);
|
||||
return this.#record(
|
||||
context,
|
||||
tool,
|
||||
"invocation",
|
||||
decision.allowed ? "allowed" : "denied",
|
||||
decision.reason,
|
||||
);
|
||||
}
|
||||
|
||||
denyInvocation(
|
||||
operationId: string,
|
||||
context: Phase7ToolAuthorizationContext,
|
||||
reason: string,
|
||||
): Phase7AuthorizationRecord {
|
||||
const tool = phase7SemanticTool(operationId);
|
||||
return tool === undefined
|
||||
? this.#recordUnknown(context, operationId, "absent", reason)
|
||||
: this.#record(context, tool, "invocation", "denied", reason);
|
||||
}
|
||||
|
||||
attachStateChange(
|
||||
sequence: number,
|
||||
beforeRevision: number,
|
||||
afterRevision: number,
|
||||
entityRefs: string[],
|
||||
): Phase7AuthorizationRecord {
|
||||
const record = this.#records.find((candidate) => candidate.sequence === sequence);
|
||||
if (record === undefined || record.outcome !== "allowed") {
|
||||
throw new Error(`cannot attach state change to authorization record ${sequence}`);
|
||||
}
|
||||
record.resultingStateChange = {
|
||||
beforeRevision,
|
||||
afterRevision,
|
||||
entityRefs: [...entityRefs].sort(),
|
||||
};
|
||||
return deepFreeze(clone(record));
|
||||
}
|
||||
|
||||
records(): readonly Phase7AuthorizationRecord[] {
|
||||
return deepFreeze(clone(this.#records));
|
||||
}
|
||||
|
||||
#evaluate(
|
||||
tool: Phase7SemanticToolDescriptor,
|
||||
context: Phase7ToolAuthorizationContext,
|
||||
input: Phase7JsonValue | undefined,
|
||||
): { allowed: boolean; reason: string } {
|
||||
const policy = context.policy ?? {};
|
||||
if (policy.deniedOperationIds?.includes(tool.operationId)) {
|
||||
return { allowed: false, reason: "operation_denied_by_policy" };
|
||||
}
|
||||
if (!tool.taskModes.includes(context.task.workMode)) {
|
||||
return { allowed: false, reason: "operation_not_available_in_task_mode" };
|
||||
}
|
||||
if (
|
||||
tool.allowedRoles !== undefined &&
|
||||
!tool.allowedRoles.map(normalize).includes(normalize(context.actor.role))
|
||||
) {
|
||||
return { allowed: false, reason: "actor_role_not_authorized" };
|
||||
}
|
||||
const deniedClaims = new Set(policy.deniedClaims ?? []);
|
||||
if (tool.requiredClaims.some((claim) => deniedClaims.has(claim))) {
|
||||
return { allowed: false, reason: "required_claim_denied_by_policy" };
|
||||
}
|
||||
const grants = new Set([...context.actor.capabilityGrants, ...context.scenarioGrants]);
|
||||
if (tool.requiredClaims.some((claim) => !grants.has(claim))) {
|
||||
return { allowed: false, reason: "required_claim_missing" };
|
||||
}
|
||||
if (tool.operationId === "read_secret_value" && policy.allowSecretValueAccess !== true) {
|
||||
return { allowed: false, reason: "secret_value_access_disabled" };
|
||||
}
|
||||
if (tool.operationId === "generic_api_request") {
|
||||
if (policy.allowGenericEscapeHatch !== true) {
|
||||
return { allowed: false, reason: "generic_escape_hatch_disabled" };
|
||||
}
|
||||
if (input !== undefined && !genericRequestAllowed(input, policy.genericEscapeHatchAllowlist ?? [])) {
|
||||
return { allowed: false, reason: "generic_escape_hatch_target_denied" };
|
||||
}
|
||||
}
|
||||
if (tool.operationId === "request_human_input" && input !== undefined) {
|
||||
const interactionKind = objectValue(input)?.interactionKind;
|
||||
if (
|
||||
typeof interactionKind === "string" &&
|
||||
policy.allowedInteractionKinds !== undefined &&
|
||||
!policy.allowedInteractionKinds.includes(interactionKind as never)
|
||||
) {
|
||||
return { allowed: false, reason: "interaction_kind_denied_by_policy" };
|
||||
}
|
||||
}
|
||||
return {
|
||||
allowed: true,
|
||||
reason: tool.disposition === "always_agent_tool" ? "task_scoped_always_tool" : "required_claims_granted",
|
||||
};
|
||||
}
|
||||
|
||||
#record(
|
||||
context: Phase7ToolAuthorizationContext,
|
||||
tool: Phase7SemanticToolDescriptor,
|
||||
phase: Phase7AuthorizationRecord["phase"],
|
||||
outcome: Phase7AuthorizationRecord["outcome"],
|
||||
reason: string,
|
||||
): Phase7AuthorizationRecord {
|
||||
const grants = [...new Set([...context.actor.capabilityGrants, ...context.scenarioGrants])].sort();
|
||||
const consideredClaims = [...tool.requiredClaims].sort();
|
||||
return this.#store({
|
||||
schema: "paperclip.phase7.authorization-record.v1",
|
||||
sequence: ++this.#sequence,
|
||||
runId: context.runId,
|
||||
actorId: context.actor.id,
|
||||
taskId: context.task.id,
|
||||
operationId: tool.operationId,
|
||||
phase,
|
||||
outcome,
|
||||
consideredClaims,
|
||||
grantedClaims: consideredClaims.filter((claim) => grants.includes(claim)),
|
||||
missingClaims: consideredClaims.filter((claim) => !grants.includes(claim)),
|
||||
reason,
|
||||
redactions: tool.redaction.map(({ path, replacement }) => ({ path, replacement })),
|
||||
resultingStateChange: null,
|
||||
});
|
||||
}
|
||||
|
||||
#recordUnknown(
|
||||
context: Phase7ToolAuthorizationContext,
|
||||
operationId: string,
|
||||
outcome: "absent" | "denied",
|
||||
reason: string,
|
||||
): Phase7AuthorizationRecord {
|
||||
return this.#store({
|
||||
schema: "paperclip.phase7.authorization-record.v1",
|
||||
sequence: ++this.#sequence,
|
||||
runId: context.runId,
|
||||
actorId: context.actor.id,
|
||||
taskId: context.task.id,
|
||||
operationId,
|
||||
phase: "invocation",
|
||||
outcome,
|
||||
consideredClaims: [],
|
||||
grantedClaims: [],
|
||||
missingClaims: [],
|
||||
reason,
|
||||
redactions: [],
|
||||
resultingStateChange: null,
|
||||
});
|
||||
}
|
||||
|
||||
#store(record: Phase7AuthorizationRecord): Phase7AuthorizationRecord {
|
||||
this.#records.push(record);
|
||||
return deepFreeze(clone(record));
|
||||
}
|
||||
}
|
||||
|
||||
function genericRequestAllowed(
|
||||
input: Phase7JsonValue,
|
||||
allowlist: Array<{ method: string; path: string }>,
|
||||
): boolean {
|
||||
const object = objectValue(input);
|
||||
if (object === undefined || typeof object.method !== "string" || typeof object.path !== "string") {
|
||||
return false;
|
||||
}
|
||||
const method = object.method.toUpperCase();
|
||||
return allowlist.some(
|
||||
(entry) => entry.method.toUpperCase() === method && pathMatches(entry.path, object.path as string),
|
||||
);
|
||||
}
|
||||
|
||||
function pathMatches(pattern: string, path: string): boolean {
|
||||
if (!pattern.endsWith("*")) return pattern === path;
|
||||
return path.startsWith(pattern.slice(0, -1));
|
||||
}
|
||||
|
||||
function objectValue(value: Phase7JsonValue): Record<string, Phase7JsonValue> | undefined {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function normalize(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function clone<T>(value: T): T {
|
||||
return structuredClone(value);
|
||||
}
|
||||
|
||||
function deepFreeze<T>(value: T): T {
|
||||
if (typeof value !== "object" || value === null || Object.isFrozen(value)) return value;
|
||||
Object.freeze(value);
|
||||
for (const child of Object.values(value)) deepFreeze(child);
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type {
|
||||
Phase7JsonSchema,
|
||||
Phase7SemanticToolDescriptor,
|
||||
Phase7VisibleToolSet,
|
||||
} from "./phase7-semantic-tool-types.js";
|
||||
|
||||
export interface Phase7ToolBinding<TDefinition> {
|
||||
readonly kind: "fake_agent" | "codex";
|
||||
bind(visibleTools: Phase7VisibleToolSet): readonly TDefinition[];
|
||||
operationId(definition: TDefinition): string;
|
||||
}
|
||||
|
||||
export interface Phase7FakeAgentToolDefinition {
|
||||
operationId: string;
|
||||
description: string;
|
||||
inputSchema: Phase7JsonSchema;
|
||||
outputSchema: Phase7JsonSchema;
|
||||
}
|
||||
|
||||
export interface Phase7CodexToolDefinition {
|
||||
type: "function";
|
||||
name: string;
|
||||
description: string;
|
||||
strict: true;
|
||||
parameters: Phase7JsonSchema;
|
||||
}
|
||||
|
||||
export class Phase7FakeAgentToolBinding implements Phase7ToolBinding<Phase7FakeAgentToolDefinition> {
|
||||
readonly kind = "fake_agent" as const;
|
||||
|
||||
bind(visibleTools: Phase7VisibleToolSet): readonly Phase7FakeAgentToolDefinition[] {
|
||||
return freezeDefinitions(visibleTools.tools.map((tool) => ({
|
||||
operationId: tool.operationId,
|
||||
description: tool.description,
|
||||
inputSchema: structuredClone(tool.inputSchema),
|
||||
outputSchema: structuredClone(tool.outputSchema),
|
||||
})));
|
||||
}
|
||||
|
||||
operationId(definition: Phase7FakeAgentToolDefinition): string {
|
||||
return definition.operationId;
|
||||
}
|
||||
}
|
||||
|
||||
export class Phase7CodexToolBinding implements Phase7ToolBinding<Phase7CodexToolDefinition> {
|
||||
readonly kind = "codex" as const;
|
||||
|
||||
bind(visibleTools: Phase7VisibleToolSet): readonly Phase7CodexToolDefinition[] {
|
||||
return freezeDefinitions(visibleTools.tools.map(toCodexDefinition));
|
||||
}
|
||||
|
||||
operationId(definition: Phase7CodexToolDefinition): string {
|
||||
return definition.name;
|
||||
}
|
||||
}
|
||||
|
||||
function toCodexDefinition(tool: Phase7SemanticToolDescriptor): Phase7CodexToolDefinition {
|
||||
return {
|
||||
type: "function",
|
||||
name: tool.operationId,
|
||||
description: tool.description,
|
||||
strict: true,
|
||||
parameters: structuredClone(tool.inputSchema),
|
||||
};
|
||||
}
|
||||
|
||||
function freezeDefinitions<T>(definitions: T[]): readonly T[] {
|
||||
for (const definition of definitions) deepFreeze(definition);
|
||||
return Object.freeze(definitions);
|
||||
}
|
||||
|
||||
function deepFreeze<T>(value: T): T {
|
||||
if (typeof value !== "object" || value === null || Object.isFrozen(value)) return value;
|
||||
Object.freeze(value);
|
||||
for (const child of Object.values(value)) deepFreeze(child);
|
||||
return value;
|
||||
}
|
||||
Reference in new issue
Block a user