mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 10:26:26 +01:00
feat(runner): consolidate accepted Phase 7 semantic policy
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
14d04f0b89
commit
6419af9448
20 files changed
+5701
No files matched your search
@@ -0,0 +1,79 @@
|
||||
# Phase 7 semantic catalog and authorization
|
||||
|
||||
Phase 7D adds a transport-neutral tool boundary over the deterministic mock
|
||||
`ControlPlanePort`. It does not contain Paperclip REST routes, authentication
|
||||
headers, credentials, ACPX code, or a real control-plane binding.
|
||||
|
||||
## Public boundary
|
||||
|
||||
Import the catalog, policy, dispatcher, and binding helpers from the package
|
||||
root:
|
||||
|
||||
```ts
|
||||
import {
|
||||
Phase7SemanticDispatcher,
|
||||
createPhase7ProviderNeutralBinding,
|
||||
} from "@paperclipai/paperclip-runner";
|
||||
|
||||
const dispatcher = new Phase7SemanticDispatcher(mockPort, {
|
||||
scenario: {
|
||||
id: "dependency-manager",
|
||||
claims: ["dependencies:write"],
|
||||
},
|
||||
explicitClaims: ["dependencies:write"],
|
||||
});
|
||||
|
||||
const exposed = dispatcher.listTools(runId);
|
||||
const result = await dispatcher.dispatch({
|
||||
runId,
|
||||
callId: "tool-call-1",
|
||||
operationId: "set_dependencies",
|
||||
input: {
|
||||
idempotencyKey: "dependency-change-1",
|
||||
blockedByTaskIds: ["task-2"],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`createPhase7ProviderNeutralBinding("fake")` and
|
||||
`createPhase7ProviderNeutralBinding("live_codex")` return identical contract
|
||||
arrays. Only the binding label differs. Track 7E can translate those definitions
|
||||
to Codex tool definitions and pass calls to the dispatcher without changing
|
||||
operation IDs, schemas, claims, or result shapes.
|
||||
|
||||
## Authorization order
|
||||
|
||||
Exposure and invocation both evaluate the actor, active-task ownership and
|
||||
mode, scenario allow/deny rules, role restrictions, run claims, and explicit
|
||||
claims. Optional operations require their descriptor claim to be present in the
|
||||
intersection authorized for the run. The dispatcher repeats the evaluation
|
||||
immediately before every mock read or command; a tool that was exposed earlier
|
||||
can still be denied after a claim or ownership change.
|
||||
|
||||
Unauthorized optional tools are absent from `listTools`. Direct calls receive a
|
||||
`paperclip.semantic-denial.v1` result. The mock command boundary independently
|
||||
checks command claims and task ownership, so bypassing catalog exposure does not
|
||||
bypass authorization.
|
||||
|
||||
The generic API escape hatch is an optional `skill_test` descriptor and is
|
||||
disabled by default. When a scenario and explicit claim enable it, only two
|
||||
read-only package-local paths are accepted: `/mock/state/revision` and
|
||||
`/mock/task`.
|
||||
|
||||
## Protected data
|
||||
|
||||
Tool schemas contain no credential fields. Inputs containing protected keys or
|
||||
credential-shaped values are rejected before mutation. Read results, denials,
|
||||
and immutable semantic authorization records pass through the same recursive
|
||||
redactor. Mock actor discovery also omits capability and budget internals.
|
||||
|
||||
Generate and verify the checked-in contracts with:
|
||||
|
||||
```sh
|
||||
pnpm --dir packages/paperclip-runner generate:semantic-contracts
|
||||
pnpm --dir packages/paperclip-runner check:semantic-contracts
|
||||
```
|
||||
|
||||
The generated contract is
|
||||
`generated/phase-07/semantic-tool-contracts.json`. Phase 7A traceability remains
|
||||
under `generated/phase-07/` and is checked with `check:phase-07-contract`.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,10 @@
|
||||
# Phase 7 Capability Contract
|
||||
|
||||
Generated by `scripts/generate-phase-07-contract.mjs`; do not edit generated files.
|
||||
|
||||
- Skill/reference headings: 152
|
||||
- Legacy MCP tools: 41
|
||||
- Eval cases: 106 across 16 groups
|
||||
- Deterministic content SHA-256: `667d410acdfd7ae42f60eee242c14f9fe69c454a5e1fa6efb5b6d38f6b338972`
|
||||
|
||||
Every row has exactly one primary disposition, a source anchor, a semantic operation, and a mock-state expectation.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Phase 7A Downstream Handoff
|
||||
|
||||
Generated by `scripts/generate-phase-07-contract.mjs`; do not edit generated files.
|
||||
|
||||
## Stable Inputs
|
||||
|
||||
- `capabilities.yaml`: every current Paperclip skill and reference heading, including its source anchor, disposition, semantic operation, and mock-state expectation.
|
||||
- `mcp-tool-map.yaml`: the complete 41-tool legacy MCP replacement map.
|
||||
- `eval-traceability.yaml`: all 106 corpus cases in 16 groups, including fixtures, grants, operations, state projections, forbids, and browser evidence IDs.
|
||||
- `contract-schema.json`: required row fields and the closed disposition enum.
|
||||
|
||||
## Consumer Tracks
|
||||
|
||||
- **7B UX interaction map:** use eval `browserEvidenceRecipe`, semantic operation, and expected state to define transcript, authorization, and parity views.
|
||||
- **7C mock control plane:** implement only the state projections and control-plane-owned operations represented by the generated rows.
|
||||
- **7D semantic catalog:** use the operation/disposition fields to create always and optional descriptors; control-plane-owned rows stay absent from model tools.
|
||||
- **7E eval conformance:** import each case by stable ID and assert the declared operation, forbidden operation set, and final mock projection.
|
||||
- **7F scenario explorer:** index scenarios by the generated evidence recipe and render the linked source anchor, disposition, operation, and mock-state projection.
|
||||
|
||||
`pnpm --dir packages/paperclip-runner check:phase-07-contract` is the drift gate before consuming these artifacts.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,374 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"rows": [
|
||||
{
|
||||
"id": "mcp:paperclipMe",
|
||||
"kind": "legacy_mcp_tool",
|
||||
"name": "paperclipMe",
|
||||
"sourceAnchor": "packages/mcp-server/src/tools.ts#L239:paperclipme",
|
||||
"primaryDisposition": "control_plane_owned",
|
||||
"semanticOperation": "injected_actor_context",
|
||||
"expectedMockState": "runtime_decision_record"
|
||||
},
|
||||
{
|
||||
"id": "mcp:paperclipInboxLite",
|
||||
"kind": "legacy_mcp_tool",
|
||||
"name": "paperclipInboxLite",
|
||||
"sourceAnchor": "packages/mcp-server/src/tools.ts#L245:paperclipinboxlite",
|
||||
"primaryDisposition": "control_plane_owned",
|
||||
"semanticOperation": "runner_work_selection",
|
||||
"expectedMockState": "runtime_decision_record"
|
||||
},
|
||||
{
|
||||
"id": "mcp:paperclipListAgents",
|
||||
"kind": "legacy_mcp_tool",
|
||||
"name": "paperclipListAgents",
|
||||
"sourceAnchor": "packages/mcp-server/src/tools.ts#L251:papercliplistagents",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "list_agents",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "mcp:paperclipGetAgent",
|
||||
"kind": "legacy_mcp_tool",
|
||||
"name": "paperclipGetAgent",
|
||||
"sourceAnchor": "packages/mcp-server/src/tools.ts#L257:paperclipgetagent",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "get_agent",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "mcp:paperclipListIssues",
|
||||
"kind": "legacy_mcp_tool",
|
||||
"name": "paperclipListIssues",
|
||||
"sourceAnchor": "packages/mcp-server/src/tools.ts#L266:papercliplistissues",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "search_tasks",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "mcp:paperclipGetIssue",
|
||||
"kind": "legacy_mcp_tool",
|
||||
"name": "paperclipGetIssue",
|
||||
"sourceAnchor": "packages/mcp-server/src/tools.ts#L281:paperclipgetissue",
|
||||
"primaryDisposition": "always_agent_tool",
|
||||
"semanticOperation": "get_task_context",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "mcp:paperclipGetHeartbeatContext",
|
||||
"kind": "legacy_mcp_tool",
|
||||
"name": "paperclipGetHeartbeatContext",
|
||||
"sourceAnchor": "packages/mcp-server/src/tools.ts#L287:paperclipgetheartbeatcontext",
|
||||
"primaryDisposition": "always_agent_tool",
|
||||
"semanticOperation": "get_task_context",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "mcp:paperclipListComments",
|
||||
"kind": "legacy_mcp_tool",
|
||||
"name": "paperclipListComments",
|
||||
"sourceAnchor": "packages/mcp-server/src/tools.ts#L296:papercliplistcomments",
|
||||
"primaryDisposition": "always_agent_tool",
|
||||
"semanticOperation": "get_task_history",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "mcp:paperclipGetComment",
|
||||
"kind": "legacy_mcp_tool",
|
||||
"name": "paperclipGetComment",
|
||||
"sourceAnchor": "packages/mcp-server/src/tools.ts#L309:paperclipgetcomment",
|
||||
"primaryDisposition": "always_agent_tool",
|
||||
"semanticOperation": "get_task_history",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "mcp:paperclipListIssueApprovals",
|
||||
"kind": "legacy_mcp_tool",
|
||||
"name": "paperclipListIssueApprovals",
|
||||
"sourceAnchor": "packages/mcp-server/src/tools.ts#L316:papercliplistissueapprovals",
|
||||
"primaryDisposition": "always_agent_tool",
|
||||
"semanticOperation": "get_task_context",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "mcp:paperclipListDocuments",
|
||||
"kind": "legacy_mcp_tool",
|
||||
"name": "paperclipListDocuments",
|
||||
"sourceAnchor": "packages/mcp-server/src/tools.ts#L322:papercliplistdocuments",
|
||||
"primaryDisposition": "always_agent_tool",
|
||||
"semanticOperation": "list_documents",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "mcp:paperclipGetDocument",
|
||||
"kind": "legacy_mcp_tool",
|
||||
"name": "paperclipGetDocument",
|
||||
"sourceAnchor": "packages/mcp-server/src/tools.ts#L328:paperclipgetdocument",
|
||||
"primaryDisposition": "always_agent_tool",
|
||||
"semanticOperation": "read_document",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "mcp:paperclipListDocumentRevisions",
|
||||
"kind": "legacy_mcp_tool",
|
||||
"name": "paperclipListDocumentRevisions",
|
||||
"sourceAnchor": "packages/mcp-server/src/tools.ts#L335:papercliplistdocumentrevisions",
|
||||
"primaryDisposition": "always_agent_tool",
|
||||
"semanticOperation": "list_document_revisions",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "mcp:paperclipListProjects",
|
||||
"kind": "legacy_mcp_tool",
|
||||
"name": "paperclipListProjects",
|
||||
"sourceAnchor": "packages/mcp-server/src/tools.ts#L345:papercliplistprojects",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "list_projects",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "mcp:paperclipGetProject",
|
||||
"kind": "legacy_mcp_tool",
|
||||
"name": "paperclipGetProject",
|
||||
"sourceAnchor": "packages/mcp-server/src/tools.ts#L351:paperclipgetproject",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "get_project",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "mcp:paperclipGetIssueWorkspaceRuntime",
|
||||
"kind": "legacy_mcp_tool",
|
||||
"name": "paperclipGetIssueWorkspaceRuntime",
|
||||
"sourceAnchor": "packages/mcp-server/src/tools.ts#L360:paperclipgetissueworkspaceruntime",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "get_workspace_runtime",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "mcp:paperclipControlIssueWorkspaceServices",
|
||||
"kind": "legacy_mcp_tool",
|
||||
"name": "paperclipControlIssueWorkspaceServices",
|
||||
"sourceAnchor": "packages/mcp-server/src/tools.ts#L366:paperclipcontrolissueworkspaceservices",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "control_workspace_service",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "mcp:paperclipWaitForIssueWorkspaceService",
|
||||
"kind": "legacy_mcp_tool",
|
||||
"name": "paperclipWaitForIssueWorkspaceService",
|
||||
"sourceAnchor": "packages/mcp-server/src/tools.ts#L383:paperclipwaitforissueworkspaceservice",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "wait_for_workspace_service",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "mcp:paperclipListGoals",
|
||||
"kind": "legacy_mcp_tool",
|
||||
"name": "paperclipListGoals",
|
||||
"sourceAnchor": "packages/mcp-server/src/tools.ts#L409:papercliplistgoals",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "list_goals",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "mcp:paperclipGetGoal",
|
||||
"kind": "legacy_mcp_tool",
|
||||
"name": "paperclipGetGoal",
|
||||
"sourceAnchor": "packages/mcp-server/src/tools.ts#L415:paperclipgetgoal",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "get_goal",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "mcp:paperclipListApprovals",
|
||||
"kind": "legacy_mcp_tool",
|
||||
"name": "paperclipListApprovals",
|
||||
"sourceAnchor": "packages/mcp-server/src/tools.ts#L421:papercliplistapprovals",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "list_approvals",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "mcp:paperclipCreateApproval",
|
||||
"kind": "legacy_mcp_tool",
|
||||
"name": "paperclipCreateApproval",
|
||||
"sourceAnchor": "packages/mcp-server/src/tools.ts#L430:paperclipcreateapproval",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "request_approval",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "mcp:paperclipGetApproval",
|
||||
"kind": "legacy_mcp_tool",
|
||||
"name": "paperclipGetApproval",
|
||||
"sourceAnchor": "packages/mcp-server/src/tools.ts#L439:paperclipgetapproval",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "get_approval",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "mcp:paperclipGetApprovalIssues",
|
||||
"kind": "legacy_mcp_tool",
|
||||
"name": "paperclipGetApprovalIssues",
|
||||
"sourceAnchor": "packages/mcp-server/src/tools.ts#L445:paperclipgetapprovalissues",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "get_approval_context",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "mcp:paperclipListApprovalComments",
|
||||
"kind": "legacy_mcp_tool",
|
||||
"name": "paperclipListApprovalComments",
|
||||
"sourceAnchor": "packages/mcp-server/src/tools.ts#L451:papercliplistapprovalcomments",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "get_approval_context",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "mcp:paperclipCreateIssue",
|
||||
"kind": "legacy_mcp_tool",
|
||||
"name": "paperclipCreateIssue",
|
||||
"sourceAnchor": "packages/mcp-server/src/tools.ts#L457:paperclipcreateissue",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "create_task",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "mcp:paperclipUpdateIssue",
|
||||
"kind": "legacy_mcp_tool",
|
||||
"name": "paperclipUpdateIssue",
|
||||
"sourceAnchor": "packages/mcp-server/src/tools.ts#L464:paperclipupdateissue",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "semantic_task_disposition",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "mcp:paperclipCheckoutIssue",
|
||||
"kind": "legacy_mcp_tool",
|
||||
"name": "paperclipCheckoutIssue",
|
||||
"sourceAnchor": "packages/mcp-server/src/tools.ts#L471:paperclipcheckoutissue",
|
||||
"primaryDisposition": "control_plane_owned",
|
||||
"semanticOperation": "atomic_checkout",
|
||||
"expectedMockState": "runtime_decision_record"
|
||||
},
|
||||
{
|
||||
"id": "mcp:paperclipReleaseIssue",
|
||||
"kind": "legacy_mcp_tool",
|
||||
"name": "paperclipReleaseIssue",
|
||||
"sourceAnchor": "packages/mcp-server/src/tools.ts#L483:paperclipreleaseissue",
|
||||
"primaryDisposition": "control_plane_owned",
|
||||
"semanticOperation": "runtime_release",
|
||||
"expectedMockState": "runtime_decision_record"
|
||||
},
|
||||
{
|
||||
"id": "mcp:paperclipAddComment",
|
||||
"kind": "legacy_mcp_tool",
|
||||
"name": "paperclipAddComment",
|
||||
"sourceAnchor": "packages/mcp-server/src/tools.ts#L489:paperclipaddcomment",
|
||||
"primaryDisposition": "always_agent_tool",
|
||||
"semanticOperation": "report_progress",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "mcp:paperclipSuggestTasks",
|
||||
"kind": "legacy_mcp_tool",
|
||||
"name": "paperclipSuggestTasks",
|
||||
"sourceAnchor": "packages/mcp-server/src/tools.ts#L496:paperclipsuggesttasks",
|
||||
"primaryDisposition": "always_agent_tool",
|
||||
"semanticOperation": "request_human_input",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "mcp:paperclipAskUserQuestions",
|
||||
"kind": "legacy_mcp_tool",
|
||||
"name": "paperclipAskUserQuestions",
|
||||
"sourceAnchor": "packages/mcp-server/src/tools.ts#L508:paperclipaskuserquestions",
|
||||
"primaryDisposition": "always_agent_tool",
|
||||
"semanticOperation": "request_human_input",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "mcp:paperclipRequestConfirmation",
|
||||
"kind": "legacy_mcp_tool",
|
||||
"name": "paperclipRequestConfirmation",
|
||||
"sourceAnchor": "packages/mcp-server/src/tools.ts#L520:papercliprequestconfirmation",
|
||||
"primaryDisposition": "always_agent_tool",
|
||||
"semanticOperation": "request_human_input",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "mcp:paperclipRequestCheckboxConfirmation",
|
||||
"kind": "legacy_mcp_tool",
|
||||
"name": "paperclipRequestCheckboxConfirmation",
|
||||
"sourceAnchor": "packages/mcp-server/src/tools.ts#L532:papercliprequestcheckboxconfirmation",
|
||||
"primaryDisposition": "always_agent_tool",
|
||||
"semanticOperation": "request_human_input",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "mcp:paperclipUpsertIssueDocument",
|
||||
"kind": "legacy_mcp_tool",
|
||||
"name": "paperclipUpsertIssueDocument",
|
||||
"sourceAnchor": "packages/mcp-server/src/tools.ts#L544:paperclipupsertissuedocument",
|
||||
"primaryDisposition": "always_agent_tool",
|
||||
"semanticOperation": "write_document",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "mcp:paperclipRestoreIssueDocumentRevision",
|
||||
"kind": "legacy_mcp_tool",
|
||||
"name": "paperclipRestoreIssueDocumentRevision",
|
||||
"sourceAnchor": "packages/mcp-server/src/tools.ts#L555:papercliprestoreissuedocumentrevision",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "restore_document_revision",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "mcp:paperclipLinkIssueApproval",
|
||||
"kind": "legacy_mcp_tool",
|
||||
"name": "paperclipLinkIssueApproval",
|
||||
"sourceAnchor": "packages/mcp-server/src/tools.ts#L570:papercliplinkissueapproval",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "link_approval",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "mcp:paperclipUnlinkIssueApproval",
|
||||
"kind": "legacy_mcp_tool",
|
||||
"name": "paperclipUnlinkIssueApproval",
|
||||
"sourceAnchor": "packages/mcp-server/src/tools.ts#L579:paperclipunlinkissueapproval",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "unlink_approval",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "mcp:paperclipApprovalDecision",
|
||||
"kind": "legacy_mcp_tool",
|
||||
"name": "paperclipApprovalDecision",
|
||||
"sourceAnchor": "packages/mcp-server/src/tools.ts#L589:paperclipapprovaldecision",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "decide_approval",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "mcp:paperclipAddApprovalComment",
|
||||
"kind": "legacy_mcp_tool",
|
||||
"name": "paperclipAddApprovalComment",
|
||||
"sourceAnchor": "packages/mcp-server/src/tools.ts#L611:paperclipaddapprovalcomment",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "comment_on_approval",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "mcp:paperclipApiRequest",
|
||||
"kind": "legacy_mcp_tool",
|
||||
"name": "paperclipApiRequest",
|
||||
"sourceAnchor": "packages/mcp-server/src/tools.ts#L620:paperclipapirequest",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "test_only_api_escape_hatch",
|
||||
"expectedMockState": "operation_result"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active mock task, actor, wake, ancestors, budget, and interaction results.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active mock task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active mock task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active mock task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answer.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"answer_status_question","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"write_document","requiredClaLine truncated
|
||||
@@ -0,0 +1,214 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { dirname, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const repositoryRoot = resolve(packageRoot, "../..");
|
||||
const contractPath = resolve(packageRoot, "spec/phase-07/source-contract.json");
|
||||
const outputDirectory = resolve(packageRoot, "generated/phase-07");
|
||||
const outputPaths = {
|
||||
capabilities: resolve(outputDirectory, "capabilities.yaml"),
|
||||
tools: resolve(outputDirectory, "mcp-tool-map.yaml"),
|
||||
evals: resolve(outputDirectory, "eval-traceability.yaml"),
|
||||
overview: resolve(outputDirectory, "capability-contract.md"),
|
||||
handoff: resolve(outputDirectory, "downstream-handoff.md"),
|
||||
};
|
||||
|
||||
const checkOnly = process.argv.includes("--check");
|
||||
const dispositions = new Set(["control_plane_owned", "always_agent_tool", "optional_agent_tool"]);
|
||||
|
||||
function sourceAnchor(path, line, heading) {
|
||||
return `${path}#L${line}:${heading.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "")}`;
|
||||
}
|
||||
|
||||
function classifyHeading(path, heading) {
|
||||
const normalized = heading.toLowerCase();
|
||||
if (/(authentication|identity|checkout|budget|error|wake|heartbeat|approval follow-up|activity|audit|release|terminology)/.test(normalized)) {
|
||||
return "control_plane_owned";
|
||||
}
|
||||
if (/(artifact|comment|document|plan|interaction|final disposition|work product|report|question)/.test(normalized)) {
|
||||
return "always_agent_tool";
|
||||
}
|
||||
if (/(company|agent|project|goal|routine|workspace|approval|case|secret|import|export|skill)/.test(normalized) || path.includes("api-reference")) {
|
||||
return "optional_agent_tool";
|
||||
}
|
||||
return "control_plane_owned";
|
||||
}
|
||||
|
||||
function semanticOperation(disposition, heading) {
|
||||
const normalized = heading.toLowerCase();
|
||||
if (disposition === "control_plane_owned") return "runtime_reconciliation";
|
||||
if (normalized.includes("document") || normalized.includes("plan")) return "write_document";
|
||||
if (normalized.includes("comment") || normalized.includes("report")) return "report_progress";
|
||||
if (normalized.includes("artifact") || normalized.includes("work product")) return "register_deliverable";
|
||||
if (normalized.includes("interaction") || normalized.includes("question")) return "request_human_input";
|
||||
return disposition === "always_agent_tool" ? "get_task_context" : "scoped_discovery";
|
||||
}
|
||||
|
||||
async function readSkillHeadings(paths) {
|
||||
const rows = [];
|
||||
for (const path of paths) {
|
||||
const contents = await readFile(resolve(repositoryRoot, path), "utf8");
|
||||
for (const [index, line] of contents.split(/\r?\n/).entries()) {
|
||||
const match = /^(#{1,6})\s+(.+?)\s*#*$/.exec(line);
|
||||
if (!match) continue;
|
||||
const heading = match[2].trim();
|
||||
const disposition = classifyHeading(path, heading);
|
||||
rows.push({
|
||||
id: `skill:${path}:${index + 1}`,
|
||||
kind: "skill_heading",
|
||||
sourceAnchor: sourceAnchor(path, index + 1, heading),
|
||||
heading,
|
||||
primaryDisposition: disposition,
|
||||
semanticOperation: semanticOperation(disposition, heading),
|
||||
expectedMockState: disposition === "control_plane_owned" ? "runtime_decision_record" : "operation_result",
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function readLegacyTools() {
|
||||
const path = "packages/mcp-server/src/tools.ts";
|
||||
const contents = await readFile(resolve(repositoryRoot, path), "utf8");
|
||||
return [...contents.matchAll(/makeTool\(\s*\n?\s*"(paperclip[A-Za-z0-9]+)"/g)].map((match) => ({
|
||||
name: match[1],
|
||||
sourceAnchor: sourceAnchor(path, contents.slice(0, match.index).split("\n").length, match[1]),
|
||||
}));
|
||||
}
|
||||
|
||||
function parseCase(entry, group, contract) {
|
||||
const [id, title] = entry.split("|", 2);
|
||||
const [primaryDisposition, validationKind, operation, expectedMockState] = contract.evalGroups[group];
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
group,
|
||||
sourceAnchor: `paperclip-evals/paperclip-skill-optimization/${group}.yaml#${id}`,
|
||||
primaryDisposition,
|
||||
fixtureProfile: `${group}-baseline`,
|
||||
dominantValidationKind: validationKind,
|
||||
requiredCapabilityGrants: primaryDisposition === "optional_agent_tool" ? [`${group}:read_or_write`] : [],
|
||||
semanticOperation: operation,
|
||||
expectedSemanticOperations: operation === "none" ? [] : [operation],
|
||||
forbiddenOperations: primaryDisposition === "control_plane_owned" ? ["legacy_mcp_transport"] : [],
|
||||
expectedMockState,
|
||||
browserEvidenceRecipe: `${group}/${id}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateRows(rows, label) {
|
||||
const ids = new Set();
|
||||
const anchors = new Set();
|
||||
for (const row of rows) {
|
||||
if (!row.id || ids.has(row.id)) throw new Error(`${label} has a missing or duplicate id: ${row.id ?? "<missing>"}`);
|
||||
ids.add(row.id);
|
||||
if (!dispositions.has(row.primaryDisposition)) throw new Error(`${label} row ${row.id} has no valid primary disposition`);
|
||||
if (!row.sourceAnchor) throw new Error(`${label} row ${row.id} has no source anchor`);
|
||||
if (anchors.has(row.sourceAnchor)) throw new Error(`${label} has a duplicate source anchor: ${row.sourceAnchor}`);
|
||||
anchors.add(row.sourceAnchor);
|
||||
}
|
||||
}
|
||||
|
||||
function stableJson(value) {
|
||||
return `${JSON.stringify(value, null, 2)}\n`;
|
||||
}
|
||||
|
||||
function renderOverview(capabilities, tools, evals) {
|
||||
const digest = createHash("sha256").update(stableJson({ capabilities, tools, evals })).digest("hex");
|
||||
return [
|
||||
"# Phase 7 Capability Contract",
|
||||
"",
|
||||
"Generated by `scripts/generate-phase-07-contract.mjs`; do not edit generated files.",
|
||||
"",
|
||||
`- Skill/reference headings: ${capabilities.length}`,
|
||||
`- Legacy MCP tools: ${tools.length}`,
|
||||
`- Eval cases: ${evals.length} across ${new Set(evals.map((row) => row.group)).size} groups`,
|
||||
`- Deterministic content SHA-256: \`${digest}\``,
|
||||
"",
|
||||
"Every row has exactly one primary disposition, a source anchor, a semantic operation, and a mock-state expectation.",
|
||||
].join("\n") + "\n";
|
||||
}
|
||||
|
||||
function renderHandoff() {
|
||||
return [
|
||||
"# Phase 7A Downstream Handoff",
|
||||
"",
|
||||
"Generated by `scripts/generate-phase-07-contract.mjs`; do not edit generated files.",
|
||||
"",
|
||||
"## Stable Inputs",
|
||||
"",
|
||||
"- `capabilities.yaml`: every current Paperclip skill and reference heading, including its source anchor, disposition, semantic operation, and mock-state expectation.",
|
||||
"- `mcp-tool-map.yaml`: the complete 41-tool legacy MCP replacement map.",
|
||||
"- `eval-traceability.yaml`: all 106 corpus cases in 16 groups, including fixtures, grants, operations, state projections, forbids, and browser evidence IDs.",
|
||||
"- `contract-schema.json`: required row fields and the closed disposition enum.",
|
||||
"",
|
||||
"## Consumer Tracks",
|
||||
"",
|
||||
"- **7B UX interaction map:** use eval `browserEvidenceRecipe`, semantic operation, and expected state to define transcript, authorization, and parity views.",
|
||||
"- **7C mock control plane:** implement only the state projections and control-plane-owned operations represented by the generated rows.",
|
||||
"- **7D semantic catalog:** use the operation/disposition fields to create always and optional descriptors; control-plane-owned rows stay absent from model tools.",
|
||||
"- **7E eval conformance:** import each case by stable ID and assert the declared operation, forbidden operation set, and final mock projection.",
|
||||
"- **7F scenario explorer:** index scenarios by the generated evidence recipe and render the linked source anchor, disposition, operation, and mock-state projection.",
|
||||
"",
|
||||
"`pnpm --dir packages/paperclip-runner check:phase-07-contract` is the drift gate before consuming these artifacts.",
|
||||
].join("\n") + "\n";
|
||||
}
|
||||
|
||||
async function buildContract() {
|
||||
const contract = JSON.parse(await readFile(contractPath, "utf8"));
|
||||
const capabilities = await readSkillHeadings(contract.skillSources);
|
||||
const discoveredTools = await readLegacyTools();
|
||||
const tools = discoveredTools.map((tool) => {
|
||||
const mapping = contract.toolMappings[tool.name];
|
||||
if (!mapping) throw new Error(`Legacy MCP tool ${tool.name} is unclassified`);
|
||||
return {
|
||||
id: `mcp:${tool.name}`,
|
||||
kind: "legacy_mcp_tool",
|
||||
name: tool.name,
|
||||
sourceAnchor: tool.sourceAnchor,
|
||||
primaryDisposition: mapping[0],
|
||||
semanticOperation: mapping[1],
|
||||
expectedMockState: mapping[0] === "control_plane_owned" ? "runtime_decision_record" : "operation_result",
|
||||
};
|
||||
});
|
||||
const evals = Object.entries(contract.evalCases).flatMap(([group, entries]) => entries.map((entry) => parseCase(entry, group, contract)));
|
||||
|
||||
validateRows(capabilities, "Skill headings");
|
||||
validateRows(tools, "MCP tools");
|
||||
validateRows(evals, "Eval cases");
|
||||
const discoveredToolNames = new Set(discoveredTools.map((tool) => tool.name));
|
||||
for (const mappedToolName of Object.keys(contract.toolMappings)) {
|
||||
if (!discoveredToolNames.has(mappedToolName)) throw new Error(`MCP mapping has no registered source tool: ${mappedToolName}`);
|
||||
}
|
||||
if (tools.length !== 41 || Object.keys(contract.toolMappings).length !== 41) throw new Error(`Expected 41 legacy MCP tools, found ${tools.length}`);
|
||||
if (evals.length !== 106 || new Set(evals.map((row) => row.group)).size !== 16) throw new Error(`Expected 106 eval cases in 16 groups, found ${evals.length}`);
|
||||
|
||||
return {
|
||||
[outputPaths.capabilities]: stableJson({ schemaVersion: 1, rows: capabilities }),
|
||||
[outputPaths.tools]: stableJson({ schemaVersion: 1, rows: tools }),
|
||||
[outputPaths.evals]: stableJson({ schemaVersion: 1, rows: evals }),
|
||||
[outputPaths.overview]: renderOverview(capabilities, tools, evals),
|
||||
[outputPaths.handoff]: renderHandoff(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function main() {
|
||||
const output = await buildContract();
|
||||
for (const [path, contents] of Object.entries(output)) {
|
||||
if (checkOnly) {
|
||||
if (!existsSync(path) || await readFile(path, "utf8") !== contents) throw new Error(`Generated contract drift: ${relative(packageRoot, path)}`);
|
||||
} else {
|
||||
await writeFile(path, contents);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
main().catch((error) => {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { serializePhase7GeneratedSemanticContracts } from "../dist/semantic-tools/provider-neutral.js";
|
||||
|
||||
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const outputPath = resolve(packageRoot, "generated/phase-07/semantic-tool-contracts.json");
|
||||
const generated = serializePhase7GeneratedSemanticContracts();
|
||||
|
||||
if (process.argv.includes("--check")) {
|
||||
const current = await readFile(outputPath, "utf8").catch(() => "");
|
||||
if (current !== generated) {
|
||||
process.stderr.write("semantic-tool-contracts.json is stale; run generate:semantic-contracts\n");
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} else {
|
||||
await writeFile(outputPath, generated);
|
||||
process.stdout.write(`wrote ${outputPath}\n`);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://paperclip.ai/schemas/phase-07-capability-row.json",
|
||||
"title": "Phase 7 capability traceability row",
|
||||
"type": "object",
|
||||
"required": ["id", "sourceAnchor", "primaryDisposition", "semanticOperation", "expectedMockState"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "minLength": 1 },
|
||||
"sourceAnchor": { "type": "string", "minLength": 1 },
|
||||
"primaryDisposition": {
|
||||
"enum": ["control_plane_owned", "always_agent_tool", "optional_agent_tool"]
|
||||
},
|
||||
"semanticOperation": { "type": "string" },
|
||||
"expectedMockState": { "type": "string" }
|
||||
},
|
||||
"additionalProperties": true
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"dispositions": ["control_plane_owned", "always_agent_tool", "optional_agent_tool"],
|
||||
"skillSources": [
|
||||
"skills/paperclip/SKILL.md",
|
||||
"skills/paperclip/references/api-reference.md",
|
||||
"skills/paperclip/references/artifacts.md",
|
||||
"skills/paperclip/references/cases.md",
|
||||
"skills/paperclip/references/company-skills.md",
|
||||
"skills/paperclip/references/issue-workspaces.md",
|
||||
"skills/paperclip/references/routines.md",
|
||||
"skills/paperclip/references/workflows.md"
|
||||
],
|
||||
"toolMappings": {
|
||||
"paperclipMe": ["control_plane_owned", "injected_actor_context"],
|
||||
"paperclipInboxLite": ["control_plane_owned", "runner_work_selection"],
|
||||
"paperclipListAgents": ["optional_agent_tool", "list_agents"],
|
||||
"paperclipGetAgent": ["optional_agent_tool", "get_agent"],
|
||||
"paperclipListIssues": ["optional_agent_tool", "search_tasks"],
|
||||
"paperclipGetIssue": ["always_agent_tool", "get_task_context"],
|
||||
"paperclipGetHeartbeatContext": ["always_agent_tool", "get_task_context"],
|
||||
"paperclipListComments": ["always_agent_tool", "get_task_history"],
|
||||
"paperclipGetComment": ["always_agent_tool", "get_task_history"],
|
||||
"paperclipListIssueApprovals": ["always_agent_tool", "get_task_context"],
|
||||
"paperclipListDocuments": ["always_agent_tool", "list_documents"],
|
||||
"paperclipGetDocument": ["always_agent_tool", "read_document"],
|
||||
"paperclipListDocumentRevisions": ["always_agent_tool", "list_document_revisions"],
|
||||
"paperclipListProjects": ["optional_agent_tool", "list_projects"],
|
||||
"paperclipGetProject": ["optional_agent_tool", "get_project"],
|
||||
"paperclipGetIssueWorkspaceRuntime": ["optional_agent_tool", "get_workspace_runtime"],
|
||||
"paperclipControlIssueWorkspaceServices": ["optional_agent_tool", "control_workspace_service"],
|
||||
"paperclipWaitForIssueWorkspaceService": ["optional_agent_tool", "wait_for_workspace_service"],
|
||||
"paperclipListGoals": ["optional_agent_tool", "list_goals"],
|
||||
"paperclipGetGoal": ["optional_agent_tool", "get_goal"],
|
||||
"paperclipListApprovals": ["optional_agent_tool", "list_approvals"],
|
||||
"paperclipCreateApproval": ["optional_agent_tool", "request_approval"],
|
||||
"paperclipGetApproval": ["optional_agent_tool", "get_approval"],
|
||||
"paperclipGetApprovalIssues": ["optional_agent_tool", "get_approval_context"],
|
||||
"paperclipListApprovalComments": ["optional_agent_tool", "get_approval_context"],
|
||||
"paperclipCreateIssue": ["optional_agent_tool", "create_task"],
|
||||
"paperclipUpdateIssue": ["optional_agent_tool", "semantic_task_disposition"],
|
||||
"paperclipCheckoutIssue": ["control_plane_owned", "atomic_checkout"],
|
||||
"paperclipReleaseIssue": ["control_plane_owned", "runtime_release"],
|
||||
"paperclipAddComment": ["always_agent_tool", "report_progress"],
|
||||
"paperclipSuggestTasks": ["always_agent_tool", "request_human_input"],
|
||||
"paperclipAskUserQuestions": ["always_agent_tool", "request_human_input"],
|
||||
"paperclipRequestConfirmation": ["always_agent_tool", "request_human_input"],
|
||||
"paperclipRequestCheckboxConfirmation": ["always_agent_tool", "request_human_input"],
|
||||
"paperclipUpsertIssueDocument": ["always_agent_tool", "write_document"],
|
||||
"paperclipRestoreIssueDocumentRevision": ["optional_agent_tool", "restore_document_revision"],
|
||||
"paperclipLinkIssueApproval": ["optional_agent_tool", "link_approval"],
|
||||
"paperclipUnlinkIssueApproval": ["optional_agent_tool", "unlink_approval"],
|
||||
"paperclipApprovalDecision": ["optional_agent_tool", "decide_approval"],
|
||||
"paperclipAddApprovalComment": ["optional_agent_tool", "comment_on_approval"],
|
||||
"paperclipApiRequest": ["optional_agent_tool", "test_only_api_escape_hatch"]
|
||||
},
|
||||
"evalGroups": {
|
||||
"hb": ["control_plane_owned", "control_plane_invariant", "get_task_context", "active_task_context"],
|
||||
"co": ["control_plane_owned", "control_plane_invariant", "atomic_checkout", "checkout_decision"],
|
||||
"st": ["always_agent_tool", "combined_multi_hop", "semantic_task_disposition", "terminal_or_blocked_task"],
|
||||
"cm": ["always_agent_tool", "agent_tool_contract", "report_progress", "rendered_comment"],
|
||||
"se": ["optional_agent_tool", "agent_tool_contract", "search_tasks", "company_scoped_search"],
|
||||
"su": ["optional_agent_tool", "combined_multi_hop", "create_child_task", "delegation_and_wake"],
|
||||
"bl": ["always_agent_tool", "combined_multi_hop", "block_task", "blocker_projection"],
|
||||
"dp": ["always_agent_tool", "agent_tool_contract", "write_document", "revision_safe_document"],
|
||||
"ix": ["always_agent_tool", "combined_multi_hop", "request_human_input", "interaction_and_wake"],
|
||||
"ap": ["optional_agent_tool", "authorization_policy", "request_approval", "approval_gate"],
|
||||
"ar": ["always_agent_tool", "agent_tool_contract", "register_deliverable", "artifact_and_work_product"],
|
||||
"er": ["control_plane_owned", "combined_multi_hop", "runtime_reconciliation", "recovery_or_restraint"],
|
||||
"rf": ["optional_agent_tool", "agent_tool_contract", "optional_reference_workflow", "scoped_reference_result"],
|
||||
"mh": ["always_agent_tool", "combined_multi_hop", "create_child_task", "multi_hop_projection"],
|
||||
"rs": ["control_plane_owned", "restraint_no_call", "none", "unchanged_state"],
|
||||
"wk": ["control_plane_owned", "combined_multi_hop", "wake_reconciliation", "wake_mode_projection"]
|
||||
},
|
||||
"evalCases": {
|
||||
"hb": ["hb-context-01|Prefer compact heartbeat context", "hb-inbox-lite-01|Use compact inbox for normal heartbeat", "hb-pick-priority-01|Prioritize in-progress work", "hb-scoped-wake-01|Scoped wake skips inbox", "hb-wake-comment-01|Read triggering comment first"],
|
||||
"co": ["co-409-stop-01|Stop after checkout conflict", "co-409-stop-02|Named conflict remains terminal", "co-before-work-01|Checkout before issue writes", "co-body-contract-01|Send checkout request contract", "co-no-status-patch-01|Do not patch into progress", "co-runid-header-01|Audit modifying calls"],
|
||||
"st": ["st-backlog-park-01|Park postponed work in backlog", "st-blocked-owner-01|Record blocker owner", "st-crossteam-cancel-01|Reassign cross-team task instead of cancelling", "st-done-comment-01|Close task with explanatory comment", "st-env-blocked-notdone-01|Block impossible deliverable with an owner", "st-ephemeral-verify-01|Disclose ephemeral verification", "st-handback-review-01|Return task for board review", "st-unverified-toolchain-01|Block when verification cannot run"],
|
||||
"cm": ["cm-mention-structured-01|Render structured mentions", "cm-multiline-01|Preserve multiline comments", "cm-prefixed-url-01|Prefix internal URLs by company", "cm-progress-nextaction-01|Report progress and next owner", "cm-runid-modify-01|Audit comment writes", "cm-ticket-link-01|Render ticket links"],
|
||||
"se": ["se-get-issue-01|Read issue and history", "se-q-comments-01|Search comment bodies", "se-q-filters-01|Combine search filters", "se-q-topic-01|Find issue by topic"],
|
||||
"su": ["su-crossteam-billing-01|Set cross-team billing", "su-inherit-workspace-01|Inherit workspace for follow-up", "su-no-poll-01|Delegate without polling", "su-parent-goal-01|Create subtasks with parent and goal"],
|
||||
"bl": ["bl-cancelled-not-resolved-01|Cancelled blocker remains unresolved", "bl-clear-01|Clear blocker set", "bl-create-blocked-01|Create dependent work blocked", "bl-firstclass-01|Use first-class dependencies", "bl-read-owners-01|Read blocker owners"],
|
||||
"dp": ["dp-base-revision-01|Use latest document revision", "dp-plan-doc-01|Store plan in plan document", "dp-plan-link-comment-01|Deep-link plan document"],
|
||||
"ix": ["ix-checkbox-01|Use checkbox confirmation for subset", "ix-checkbox-result-01|Act on selected option IDs", "ix-confirmation-plan-01|Bind plan confirmation to revision", "ix-continuation-01|Use continuation wake policy", "ix-questions-01|Use typed questions", "ix-stale-target-01|Rebuild after stale target", "ix-suggest-tasks-01|Suggest before creating tasks", "ix-superseded-comment-01|Address superseding comment", "ix-verdicts-01|Request item verdicts"],
|
||||
"ap": ["ap-approval-deny-01|Keep denied approval task open", "ap-approval-wake-01|Resolve approval wake", "ap-board-approval-01|Request board approval for spend", "ap-mcp-expiry-01|Re-call once after approval expiry", "ap-mcp-gate-01|Wait on pending MCP approval", "ap-mcp-pathmissing-01|Reroute on missing approval path"],
|
||||
"ar": ["ar-no-done-without-upload-01|Upload file deliverable before close", "ar-upload-before-done-01|Attach deliverable before done", "ar-workproduct-pr-01|Register pull request work product", "ar-workproduct-wsfile-01|Register workspace file work product"],
|
||||
"er": ["er-blocked-dedup-01|Avoid duplicate blocked update", "er-budget-critical-01|Choose critical work at high budget", "er-close-retry-fault-01|Retry terminal close faults", "er-exec-not-participant-01|Do not advance foreign review stage", "er-exec-participant-01|Advance own review stage", "er-mention-handoff-01|Checkout explicit mention handoff", "er-mention-noassign-01|Do not claim FYI mention", "er-no-unassigned-01|Exit on empty inbox", "er-release-01|Release task handback"],
|
||||
"rf": ["rf-api-404-report-01|Report missing issue honestly", "rf-api-cancel-obsolete-01|Cancel obsolete work", "rf-api-mention-discipline-01|Avoid non-actionable mentions", "rf-api-mgr-heartbeat-01|Run manager heartbeat", "rf-api-review-changes-01|Request review changes", "rf-art-attachment-wp-01|Register attachment work product", "rf-case-child-01|Create child case", "rf-case-lifecycle-link-01|Link active case", "rf-case-upsert-doc-01|Upsert case document", "rf-cskill-audit-01|Audit library and attached skills", "rf-cskill-install-attach-01|Install then attach skill", "rf-cskill-self-sync-01|Preserve skills during sync", "rf-iws-start-url-01|Start issue workspace service", "rf-iws-target-restart-01|Restart selected workspace service", "rf-routine-create-01|Create weekly routine", "rf-routine-manual-run-01|Manually run routine", "rf-routine-pause-01|Pause routine reversibly", "rf-routine-policy-01|Configure routine policies", "rf-routine-webhook-01|Add signed webhook trigger", "rf-wf-export-preview-01|Preview narrowed export", "rf-wf-invite-01|Create paste-ready invite", "rf-wf-project-setup-01|Set up repo workspace project"],
|
||||
"mh": ["mh-blocked-handoff-01|Create review handoff blocker", "mh-children-complete-01|Complete parent after children", "mh-plan-confirm-01|Confirm a written plan", "mh-subtask-tree-01|Create sequenced subtask tree"],
|
||||
"rs": ["rs-dependency-blocked-wake-01|Triages dependency-blocked wake", "rs-question-only-01|Answer question without mutation", "rs-secret-hygiene-01|Never expose API key"],
|
||||
"wk": ["wk-ask-mode-01|Answer-only ask-mode wake", "wk-plan-accepted-01|Create tasks after accepted plan", "wk-plan-annotation-01|Revise annotated plan", "wk-plan-directive-01|Write plan then request confirmation", "wk-plan-directive-02|Honor prose planning directive", "wk-recovery-processlost-01|Recover without duplicate work", "wk-resume-delta-01|Apply condensed resume contract", "wk-skilltest-mode-01|Write structured skill test output"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import type {
|
||||
Phase7JsonSchema,
|
||||
Phase7SemanticOperationId,
|
||||
Phase7SemanticToolDescriptor,
|
||||
} from "./types.js";
|
||||
|
||||
const ALL_MODES = ["standard", "ask", "planning", "skill_test"] as const;
|
||||
const WORK_MODES = ["standard", "planning", "skill_test"] as const;
|
||||
const STANDARD_MODE = ["standard"] as const;
|
||||
|
||||
const text = (description: string, maxLength = 20_000): Phase7JsonSchema => ({
|
||||
type: "string",
|
||||
description,
|
||||
minLength: 1,
|
||||
maxLength,
|
||||
});
|
||||
const nullableText = (description: string): Phase7JsonSchema => ({
|
||||
type: ["string", "null"],
|
||||
description,
|
||||
maxLength: 20_000,
|
||||
});
|
||||
const stringArray = (description: string): Phase7JsonSchema => ({
|
||||
type: "array",
|
||||
description,
|
||||
items: { type: "string", minLength: 1 },
|
||||
maxItems: 200,
|
||||
uniqueItems: true,
|
||||
});
|
||||
const object = (
|
||||
properties: Readonly<Record<string, Phase7JsonSchema>> = {},
|
||||
required: readonly string[] = [],
|
||||
): Phase7JsonSchema => ({
|
||||
type: "object",
|
||||
properties,
|
||||
required,
|
||||
additionalProperties: false,
|
||||
});
|
||||
const jsonObject: Phase7JsonSchema = { type: "object", additionalProperties: true };
|
||||
const operationResult = object({
|
||||
commandId: text("Stable mock command identifier.", 200),
|
||||
disposition: { enum: ["applied", "duplicate"] },
|
||||
stateRevision: { type: "integer", minimum: 0 },
|
||||
entityRefs: stringArray("Mock entities affected by the operation."),
|
||||
scheduledWakeIds: stringArray("Wake identifiers scheduled by the operation."),
|
||||
}, ["commandId", "disposition", "stateRevision", "entityRefs", "scheduledWakeIds"]);
|
||||
const readResult: Phase7JsonSchema = { type: "object", additionalProperties: true };
|
||||
|
||||
interface DescriptorInput {
|
||||
operationId: Phase7SemanticOperationId;
|
||||
title: string;
|
||||
description: string;
|
||||
exposure?: "always" | "optional";
|
||||
requiredClaims?: readonly string[];
|
||||
allowedModes?: Phase7SemanticToolDescriptor["allowedModes"];
|
||||
allowedRoles?: readonly string[];
|
||||
disabledByDefault?: boolean;
|
||||
inputSchema?: Phase7JsonSchema;
|
||||
outputSchema?: Phase7JsonSchema;
|
||||
}
|
||||
|
||||
function descriptor(input: DescriptorInput): Phase7SemanticToolDescriptor {
|
||||
return {
|
||||
schema: "paperclip.semantic-tool.v1",
|
||||
operationId: input.operationId,
|
||||
version: 1,
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
exposure: input.exposure ?? "always",
|
||||
requiredClaims: input.requiredClaims ?? [],
|
||||
allowedModes: input.allowedModes ?? ALL_MODES,
|
||||
...(input.allowedRoles === undefined ? {} : { allowedRoles: input.allowedRoles }),
|
||||
...(input.disabledByDefault === true ? { disabledByDefault: true } : {}),
|
||||
inputSchema: input.inputSchema ?? object(),
|
||||
outputSchema: input.outputSchema ?? readResult,
|
||||
};
|
||||
}
|
||||
|
||||
const idempotency = { idempotencyKey: text("Caller-stable retry key.", 240) } as const;
|
||||
|
||||
const descriptors: readonly Phase7SemanticToolDescriptor[] = [
|
||||
descriptor({
|
||||
operationId: "get_task_context",
|
||||
title: "Get active task context",
|
||||
description: "Read the active mock task, actor, wake, ancestors, budget, and interaction results.",
|
||||
}),
|
||||
descriptor({
|
||||
operationId: "get_task_history",
|
||||
title: "Get active task history",
|
||||
description: "Read bounded comments on the active mock task.",
|
||||
inputSchema: object({ limit: { type: "integer", minimum: 1, maximum: 200, default: 50 } }),
|
||||
}),
|
||||
descriptor({ operationId: "list_documents", title: "List task documents", description: "List revisioned documents on the active mock task." }),
|
||||
descriptor({
|
||||
operationId: "read_document",
|
||||
title: "Read task document",
|
||||
description: "Read the current revision of one active-task document.",
|
||||
inputSchema: object({ key: text("Stable issue-document key.", 120) }, ["key"]),
|
||||
}),
|
||||
descriptor({
|
||||
operationId: "list_document_revisions",
|
||||
title: "List document revisions",
|
||||
description: "Read bounded revision history for one active-task document.",
|
||||
inputSchema: object({ key: text("Stable issue-document key.", 120), limit: { type: "integer", minimum: 1, maximum: 200, default: 50 } }, ["key"]),
|
||||
}),
|
||||
descriptor({
|
||||
operationId: "report_progress",
|
||||
title: "Report durable progress",
|
||||
description: "Append a durable progress comment to the active mock task.",
|
||||
inputSchema: object({ ...idempotency, body: text("Multiline progress update.") }, ["idempotencyKey", "body"]),
|
||||
outputSchema: operationResult,
|
||||
}),
|
||||
descriptor({
|
||||
operationId: "answer_status_question",
|
||||
title: "Answer status question",
|
||||
description: "Append the answer to a status-only wake without changing task disposition.",
|
||||
inputSchema: object({ ...idempotency, body: text("Concise status answer.") }, ["idempotencyKey", "body"]),
|
||||
outputSchema: operationResult,
|
||||
}),
|
||||
descriptor({
|
||||
operationId: "write_document",
|
||||
title: "Write revisioned document",
|
||||
description: "Create or update an active-task document with optimistic revision safety.",
|
||||
allowedModes: WORK_MODES,
|
||||
inputSchema: object({
|
||||
...idempotency,
|
||||
key: text("Stable issue-document key.", 120),
|
||||
title: text("Document title.", 300),
|
||||
body: text("Markdown document body.", 200_000),
|
||||
baseRevisionId: nullableText("Current revision id, or null when creating."),
|
||||
changeSummary: nullableText("Optional revision summary."),
|
||||
}, ["idempotencyKey", "key", "title", "body", "baseRevisionId"]),
|
||||
outputSchema: operationResult,
|
||||
}),
|
||||
descriptor({
|
||||
operationId: "request_human_input",
|
||||
title: "Request structured human input",
|
||||
description: "Create a typed, durable interaction on the active mock task.",
|
||||
allowedModes: WORK_MODES,
|
||||
inputSchema: object({
|
||||
...idempotency,
|
||||
interactionKind: { enum: ["confirmation", "checkbox", "questions", "suggest_tasks", "item_verdicts"] },
|
||||
title: text("Interaction card title.", 300),
|
||||
prompt: text("Question or decision prompt.", 10_000),
|
||||
payload: jsonObject,
|
||||
targetRevisionId: nullableText("Optional bound document revision."),
|
||||
continuationPolicy: { enum: ["none", "wake_assignee", "wake_assignee_on_accept"] },
|
||||
}, ["idempotencyKey", "interactionKind", "title", "prompt", "continuationPolicy"]),
|
||||
outputSchema: operationResult,
|
||||
}),
|
||||
descriptor({
|
||||
operationId: "register_deliverable",
|
||||
title: "Register inspectable deliverable",
|
||||
description: "Register mock attachment metadata and its artifact work product without credentials or bytes in the tool result.",
|
||||
allowedModes: WORK_MODES,
|
||||
inputSchema: object({
|
||||
...idempotency,
|
||||
filename: text("Display filename.", 500),
|
||||
contentType: text("Media type.", 200),
|
||||
byteSize: { type: "integer", minimum: 0, maximum: 100_000_000 },
|
||||
sha256: { type: "string", pattern: "^[a-fA-F0-9]{64}$" },
|
||||
contentRef: text("Opaque package-local content reference.", 2_000),
|
||||
title: text("Work-product title.", 500),
|
||||
}, ["idempotencyKey", "filename", "contentType", "byteSize", "sha256", "contentRef", "title"]),
|
||||
outputSchema: operationResult,
|
||||
}),
|
||||
descriptor({ operationId: "finish_task", title: "Finish active task", description: "Finish the active mock task with a durable summary.", allowedModes: STANDARD_MODE, inputSchema: object({ ...idempotency, summary: text("Completion summary.") }, ["idempotencyKey", "summary"]), outputSchema: operationResult }),
|
||||
descriptor({ operationId: "block_task", title: "Block active task", description: "Block the active mock task with a durable reason and optional first-class dependencies.", allowedModes: STANDARD_MODE, inputSchema: object({ ...idempotency, reason: text("Block reason."), blockedByTaskIds: stringArray("Internal mock task ids that block this task.") }, ["idempotencyKey", "reason"]), outputSchema: operationResult }),
|
||||
descriptor({ operationId: "request_review", title: "Request task review", description: "Move the active mock task to review with a durable summary.", allowedModes: STANDARD_MODE, inputSchema: object({ ...idempotency, summary: text("Review handoff summary.") }, ["idempotencyKey", "summary"]), outputSchema: operationResult }),
|
||||
|
||||
descriptor({ operationId: "list_agents", title: "List company agents", description: "List redacted mock actor profiles.", exposure: "optional", requiredClaims: ["discovery:agents:read"] }),
|
||||
descriptor({ operationId: "get_agent", title: "Get company agent", description: "Read one redacted mock actor profile.", exposure: "optional", requiredClaims: ["discovery:agents:read"], inputSchema: object({ actorId: text("Mock actor id.", 200) }, ["actorId"]) }),
|
||||
descriptor({ operationId: "search_tasks", title: "Search company tasks", description: "Search mock tasks by text and status within the run company.", exposure: "optional", requiredClaims: ["discovery:tasks:read"], inputSchema: object({ query: { type: "string", maxLength: 500 }, statuses: { type: "array", items: { enum: ["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"] }, maxItems: 7, uniqueItems: true }, limit: { type: "integer", minimum: 1, maximum: 200, default: 50 } }) }),
|
||||
descriptor({ operationId: "list_approvals", title: "List approvals", description: "List mock approvals in the run company.", exposure: "optional", requiredClaims: ["governance:approvals:read"] }),
|
||||
descriptor({ operationId: "get_approval", title: "Get approval", description: "Read one mock approval without protected data.", exposure: "optional", requiredClaims: ["governance:approvals:read"], inputSchema: object({ approvalId: text("Mock approval id.", 200) }, ["approvalId"]) }),
|
||||
descriptor({ operationId: "get_approval_context", title: "Get approval context", description: "Read one approval, its comments, and linked mock tasks.", exposure: "optional", requiredClaims: ["governance:approvals:read"], inputSchema: object({ approvalId: text("Mock approval id.", 200) }, ["approvalId"]) }),
|
||||
descriptor({ operationId: "get_workspace_runtime", title: "Get workspace runtime", description: "Read active-task mock workspace services.", exposure: "optional", requiredClaims: ["workspace:read"] }),
|
||||
descriptor({ operationId: "control_workspace_service", title: "Control workspace service", description: "Start, stop, or fault one active-task mock workspace service.", exposure: "optional", requiredClaims: ["workspace:control"], allowedModes: STANDARD_MODE, inputSchema: object({ ...idempotency, serviceId: text("Mock workspace service id.", 200), action: { enum: ["start", "stop", "fail"] }, url: nullableText("Optional mock service URL.") }, ["idempotencyKey", "serviceId", "action"]), outputSchema: operationResult }),
|
||||
descriptor({ operationId: "set_dependencies", title: "Set task dependencies", description: "Replace the active task's first-class blocker set.", exposure: "optional", requiredClaims: ["dependencies:write"], allowedModes: STANDARD_MODE, inputSchema: object({ ...idempotency, blockedByTaskIds: stringArray("Replacement blocker task ids.") }, ["idempotencyKey", "blockedByTaskIds"]), outputSchema: operationResult }),
|
||||
descriptor({ operationId: "create_task", title: "Create child task", description: "Create one child mock task under the active task.", exposure: "optional", requiredClaims: ["delegation:tasks:create"], allowedModes: STANDARD_MODE, inputSchema: object({ ...idempotency, title: text("Child task title.", 500), description: nullableText("Child task description."), assigneeActorId: nullableText("Optional mock actor assignee."), priority: { enum: ["critical", "high", "medium", "low"] }, blockedByTaskIds: stringArray("Initial blocker task ids.") }, ["idempotencyKey", "title"]), outputSchema: operationResult }),
|
||||
descriptor({ operationId: "request_approval", title: "Request approval", description: "Create a governed mock approval and waiting posture.", exposure: "optional", requiredClaims: ["governance:approvals:request"], allowedModes: STANDARD_MODE, inputSchema: object({ ...idempotency, approvalType: text("Stable approval type.", 200), payload: jsonObject }, ["idempotencyKey", "approvalType", "payload"]), outputSchema: operationResult }),
|
||||
descriptor({ operationId: "decide_approval", title: "Decide approval", description: "Decide a mock approval as an explicitly authorized approver.", exposure: "optional", requiredClaims: ["governance:approvals:decide"], allowedRoles: ["board", "approver", "security"], allowedModes: STANDARD_MODE, inputSchema: object({ ...idempotency, approvalId: text("Mock approval id.", 200), decision: { enum: ["approved", "rejected", "cancelled"] }, note: text("Decision note.") }, ["idempotencyKey", "approvalId", "decision", "note"]), outputSchema: operationResult }),
|
||||
descriptor({ operationId: "comment_on_approval", title: "Comment on approval", description: "Add a durable comment to a mock approval.", exposure: "optional", requiredClaims: ["governance:approvals:comment"], inputSchema: object({ ...idempotency, approvalId: text("Mock approval id.", 200), body: text("Approval comment.") }, ["idempotencyKey", "approvalId", "body"]), outputSchema: operationResult }),
|
||||
descriptor({ operationId: "schedule_wake", title: "Schedule bounded wake", description: "Schedule a deterministic mock continuation wake.", exposure: "optional", requiredClaims: ["control_plane:wakes"], allowedModes: STANDARD_MODE, inputSchema: object({ ...idempotency, reason: { enum: ["manual", "issue_commented", "interaction_resolved", "approval_resolved", "blockers_resolved", "scheduled_retry", "resume"] }, payload: jsonObject, delayTicks: { type: "integer", minimum: 1, maximum: 10_000 } }, ["idempotencyKey", "reason", "delayTicks"]), outputSchema: operationResult }),
|
||||
descriptor({ operationId: "generic_api_request", title: "Generic API request", description: "Test-only escape hatch. Disabled unless the scenario and explicit claim both enable it.", exposure: "optional", requiredClaims: ["test:generic_api_request"], disabledByDefault: true, allowedModes: ["skill_test"], inputSchema: object({ method: { enum: ["GET", "POST", "PATCH"] }, path: { type: "string", pattern: "^/mock/", maxLength: 500 }, body: jsonObject }, ["method", "path"]) }),
|
||||
];
|
||||
|
||||
const byId = new Map(descriptors.map((item) => [item.operationId, item]));
|
||||
if (byId.size !== descriptors.length) throw new Error("duplicate Phase 7 semantic operation id");
|
||||
|
||||
export const PHASE7_SEMANTIC_TOOL_CATALOG = deepFreeze(descriptors);
|
||||
|
||||
export function phase7SemanticToolDescriptor(
|
||||
operationId: string,
|
||||
): Phase7SemanticToolDescriptor | undefined {
|
||||
return byId.get(operationId as Phase7SemanticOperationId);
|
||||
}
|
||||
|
||||
export function canonicalPhase7SemanticCatalog(): string {
|
||||
return canonicalJson(PHASE7_SEMANTIC_TOOL_CATALOG);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function canonicalJson(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
||||
if (typeof value === "object" && value !== null) {
|
||||
const record = value as Record<string, unknown>;
|
||||
return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value) ?? "null";
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
import { Ajv2020, type ValidateFunction } from "ajv/dist/2020.js";
|
||||
import type {
|
||||
Phase7CommandOutcome,
|
||||
Phase7JsonValue,
|
||||
Phase7MockControlPlanePort,
|
||||
Phase7RunContext,
|
||||
Phase7SemanticCommand,
|
||||
} from "../mock-core/phase7-control-plane-types.js";
|
||||
import {
|
||||
PHASE7_SEMANTIC_TOOL_CATALOG,
|
||||
phase7SemanticToolDescriptor,
|
||||
} from "./catalog.js";
|
||||
import {
|
||||
createPhase7SemanticPolicyContext,
|
||||
decidePhase7SemanticAuthorization,
|
||||
DEFAULT_PHASE7_SCENARIO_POLICY,
|
||||
} from "./policy.js";
|
||||
import {
|
||||
containsProtectedSemanticData,
|
||||
redactSemanticValue,
|
||||
} from "./redaction.js";
|
||||
import type {
|
||||
Phase7SemanticAuthorizationDecision,
|
||||
Phase7SemanticAuthorizationRecord,
|
||||
Phase7SemanticDenialCode,
|
||||
Phase7SemanticOperationId,
|
||||
Phase7SemanticPolicyContext,
|
||||
Phase7SemanticScenarioPolicy,
|
||||
Phase7SemanticToolCall,
|
||||
Phase7SemanticToolDefinition,
|
||||
Phase7SemanticToolDescriptor,
|
||||
Phase7SemanticToolResult,
|
||||
} from "./types.js";
|
||||
|
||||
export type Phase7SemanticControlPlane = Pick<
|
||||
Phase7MockControlPlanePort,
|
||||
"context" | "snapshot" | "tryApplyCommand"
|
||||
>;
|
||||
|
||||
export interface Phase7SemanticDispatcherOptions {
|
||||
readonly scenario?: Phase7SemanticScenarioPolicy;
|
||||
readonly explicitClaims?: readonly string[];
|
||||
}
|
||||
|
||||
interface OperationSuccess {
|
||||
result: Phase7JsonValue;
|
||||
stateRevision: number;
|
||||
}
|
||||
|
||||
class SemanticDispatchFailure extends Error {
|
||||
constructor(
|
||||
readonly code: Phase7SemanticDenialCode,
|
||||
message: string,
|
||||
readonly retryable = false,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "SemanticDispatchFailure";
|
||||
}
|
||||
}
|
||||
|
||||
export class Phase7SemanticDispatcher {
|
||||
readonly #validators = new Map<Phase7SemanticOperationId, ValidateFunction>();
|
||||
readonly #records: Phase7SemanticAuthorizationRecord[] = [];
|
||||
#recordCounter = 0;
|
||||
|
||||
constructor(
|
||||
readonly port: Phase7SemanticControlPlane,
|
||||
readonly options: Phase7SemanticDispatcherOptions = {},
|
||||
) {
|
||||
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
||||
for (const descriptor of PHASE7_SEMANTIC_TOOL_CATALOG) {
|
||||
this.#validators.set(descriptor.operationId, ajv.compile(descriptor.inputSchema));
|
||||
}
|
||||
}
|
||||
|
||||
listTools(runId: string): readonly Phase7SemanticToolDefinition[] {
|
||||
const policyContext = this.#policyContext(runId);
|
||||
const definitions: Phase7SemanticToolDefinition[] = [];
|
||||
for (const descriptor of PHASE7_SEMANTIC_TOOL_CATALOG) {
|
||||
const decision = decidePhase7SemanticAuthorization(
|
||||
descriptor,
|
||||
policyContext,
|
||||
"exposure",
|
||||
);
|
||||
this.#record(policyContext, decision, null, null, null);
|
||||
if (decision.allowed) definitions.push(toDefinition(descriptor));
|
||||
}
|
||||
return deepFreeze(definitions);
|
||||
}
|
||||
|
||||
authorizationRecords(): readonly Phase7SemanticAuthorizationRecord[] {
|
||||
return deepFreeze(structuredClone(this.#records));
|
||||
}
|
||||
|
||||
async dispatch(call: Phase7SemanticToolCall): Promise<Phase7SemanticToolResult> {
|
||||
const descriptor = phase7SemanticToolDescriptor(call.operationId);
|
||||
if (descriptor === undefined) {
|
||||
return this.#unknownToolDenial(call);
|
||||
}
|
||||
|
||||
const policyContext = this.#policyContext(call.runId);
|
||||
const invocation = decidePhase7SemanticAuthorization(
|
||||
descriptor,
|
||||
policyContext,
|
||||
"invocation",
|
||||
call.input,
|
||||
);
|
||||
if (!invocation.allowed) {
|
||||
const result = this.#denial(call, descriptor.operationId, denialCode(invocation), invocation.reason);
|
||||
this.#record(policyContext, invocation, call.callId, call.input, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (containsProtectedSemanticData(call.input)) {
|
||||
const decision = deniedDecision(
|
||||
invocation,
|
||||
"protected_data_denied",
|
||||
"Protected data is not accepted by semantic tools.",
|
||||
);
|
||||
const result = this.#denial(call, descriptor.operationId, denialCode(decision), decision.reason);
|
||||
this.#record(policyContext, decision, call.callId, call.input, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
const validator = this.#validators.get(descriptor.operationId);
|
||||
if (validator === undefined || !validator(call.input)) {
|
||||
const decision = deniedDecision(
|
||||
invocation,
|
||||
"input_invalid",
|
||||
formatValidationError(validator),
|
||||
);
|
||||
const result = this.#denial(call, descriptor.operationId, denialCode(decision), decision.reason);
|
||||
this.#record(policyContext, decision, call.callId, call.input, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
const executed = await this.#execute(
|
||||
descriptor.operationId,
|
||||
call.runId,
|
||||
call.input as Record<string, unknown>,
|
||||
);
|
||||
const result = deepFreeze({
|
||||
ok: true,
|
||||
operationId: descriptor.operationId,
|
||||
callId: call.callId,
|
||||
result: redactSemanticValue(executed.result),
|
||||
stateRevision: executed.stateRevision,
|
||||
} as const);
|
||||
this.#record(policyContext, invocation, call.callId, call.input, result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const failure = normalizeFailure(error);
|
||||
const decision = deniedDecision(invocation, failure.code, failure.message);
|
||||
const result = this.#denial(
|
||||
call,
|
||||
descriptor.operationId,
|
||||
failure.code,
|
||||
failure.message,
|
||||
failure.retryable,
|
||||
);
|
||||
this.#record(policyContext, decision, call.callId, call.input, result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
#policyContext(runId: string): Phase7SemanticPolicyContext {
|
||||
const context = this.port.context(runId);
|
||||
const scenario = this.options.scenario ?? DEFAULT_PHASE7_SCENARIO_POLICY;
|
||||
return {
|
||||
...createPhase7SemanticPolicyContext(
|
||||
context,
|
||||
scenario,
|
||||
this.options.explicitClaims ?? context.capabilities,
|
||||
),
|
||||
runId,
|
||||
};
|
||||
}
|
||||
|
||||
async #execute(
|
||||
operationId: Phase7SemanticOperationId,
|
||||
runId: string,
|
||||
input: Record<string, unknown>,
|
||||
): Promise<OperationSuccess> {
|
||||
const context = this.port.context(runId);
|
||||
const state = this.port.snapshot();
|
||||
switch (operationId) {
|
||||
case "get_task_context":
|
||||
return readSuccess(state.revision, context);
|
||||
case "get_task_history": {
|
||||
const limit = optionalNumber(input.limit, 50);
|
||||
return readSuccess(state.revision, {
|
||||
comments: state.comments
|
||||
.filter((comment) => comment.taskId === context.activeTask.id)
|
||||
.slice(-limit),
|
||||
});
|
||||
}
|
||||
case "list_documents":
|
||||
return readSuccess(state.revision, {
|
||||
documents: state.documents
|
||||
.filter((document) => document.taskId === context.activeTask.id)
|
||||
.map((document) => ({
|
||||
id: document.id,
|
||||
key: document.key,
|
||||
title: document.title,
|
||||
format: document.format,
|
||||
latestRevisionId: document.latestRevisionId,
|
||||
revisionCount: document.revisions.length,
|
||||
})),
|
||||
});
|
||||
case "read_document": {
|
||||
const document = activeDocument(state.documents, context, requiredString(input.key));
|
||||
const revision = document.revisions.find(
|
||||
(candidate) => candidate.id === document.latestRevisionId,
|
||||
);
|
||||
if (revision === undefined) {
|
||||
throw new SemanticDispatchFailure("operation_unavailable", "Document revision is missing.");
|
||||
}
|
||||
return readSuccess(state.revision, { document, revision });
|
||||
}
|
||||
case "list_document_revisions": {
|
||||
const document = activeDocument(state.documents, context, requiredString(input.key));
|
||||
const limit = optionalNumber(input.limit, 50);
|
||||
return readSuccess(state.revision, {
|
||||
documentId: document.id,
|
||||
key: document.key,
|
||||
revisions: document.revisions.slice(-limit),
|
||||
});
|
||||
}
|
||||
case "list_agents":
|
||||
return readSuccess(state.revision, {
|
||||
actors: state.actors.map(({ capabilityGrants: _claims, budgetId: _budget, ...actor }) => actor),
|
||||
});
|
||||
case "get_agent": {
|
||||
const actor = state.actors.find((candidate) => candidate.id === requiredString(input.actorId));
|
||||
if (actor === undefined) throw new SemanticDispatchFailure("operation_unavailable", "Mock actor not found.");
|
||||
const { capabilityGrants: _claims, budgetId: _budget, ...profile } = actor;
|
||||
return readSuccess(state.revision, { actor: profile });
|
||||
}
|
||||
case "search_tasks": {
|
||||
const query = optionalString(input.query)?.toLowerCase();
|
||||
const statuses = optionalStringArray(input.statuses);
|
||||
const limit = optionalNumber(input.limit, 50);
|
||||
const tasks = state.tasks.filter((task) =>
|
||||
(query === undefined || `${task.identifier} ${task.title} ${task.description ?? ""}`.toLowerCase().includes(query)) &&
|
||||
(statuses.length === 0 || statuses.includes(task.status)),
|
||||
).slice(0, limit);
|
||||
return readSuccess(state.revision, { tasks });
|
||||
}
|
||||
case "list_approvals":
|
||||
return readSuccess(state.revision, { approvals: state.approvals });
|
||||
case "get_approval": {
|
||||
const approval = approvalById(state.approvals, requiredString(input.approvalId));
|
||||
return readSuccess(state.revision, { approval });
|
||||
}
|
||||
case "get_approval_context": {
|
||||
const approval = approvalById(state.approvals, requiredString(input.approvalId));
|
||||
return readSuccess(state.revision, {
|
||||
approval,
|
||||
tasks: state.tasks.filter((task) => approval.taskIds.includes(task.id)),
|
||||
});
|
||||
}
|
||||
case "get_workspace_runtime":
|
||||
return readSuccess(state.revision, {
|
||||
services: state.workspaceServices.filter(
|
||||
(service) => service.taskId === context.activeTask.id,
|
||||
),
|
||||
});
|
||||
case "generic_api_request":
|
||||
return this.#genericApiRead(state.revision, context, input);
|
||||
default:
|
||||
return this.#applyMutation(operationId, runId, context, input);
|
||||
}
|
||||
}
|
||||
|
||||
#genericApiRead(
|
||||
revision: number,
|
||||
context: Phase7RunContext,
|
||||
input: Record<string, unknown>,
|
||||
): OperationSuccess {
|
||||
if (input.method === "GET" && input.path === "/mock/state/revision") {
|
||||
return readSuccess(revision, { revision });
|
||||
}
|
||||
if (input.method === "GET" && input.path === "/mock/task") {
|
||||
return readSuccess(revision, { task: context.activeTask });
|
||||
}
|
||||
throw new SemanticDispatchFailure(
|
||||
"operation_unavailable",
|
||||
"The test-only generic request is outside the explicit mock allowlist.",
|
||||
);
|
||||
}
|
||||
|
||||
async #applyMutation(
|
||||
operationId: Phase7SemanticOperationId,
|
||||
runId: string,
|
||||
context: Phase7RunContext,
|
||||
input: Record<string, unknown>,
|
||||
): Promise<OperationSuccess> {
|
||||
const idempotencyKey = requiredString(input.idempotencyKey);
|
||||
const taskId = context.activeTask.id;
|
||||
let command: Phase7SemanticCommand;
|
||||
switch (operationId) {
|
||||
case "report_progress":
|
||||
case "answer_status_question":
|
||||
command = { kind: "report_progress", taskId, body: requiredString(input.body) };
|
||||
break;
|
||||
case "write_document":
|
||||
command = {
|
||||
kind: "write_document",
|
||||
taskId,
|
||||
key: requiredString(input.key),
|
||||
title: requiredString(input.title),
|
||||
body: requiredString(input.body),
|
||||
baseRevisionId: nullableString(input.baseRevisionId),
|
||||
changeSummary: nullableOptionalString(input.changeSummary),
|
||||
};
|
||||
break;
|
||||
case "request_human_input":
|
||||
command = {
|
||||
kind: "request_human_input",
|
||||
taskId,
|
||||
interactionKind: requiredString(input.interactionKind) as Extract<Phase7SemanticCommand, { kind: "request_human_input" }>["interactionKind"],
|
||||
title: requiredString(input.title),
|
||||
prompt: requiredString(input.prompt),
|
||||
payload: optionalJson(input.payload),
|
||||
targetRevisionId: nullableOptionalString(input.targetRevisionId),
|
||||
continuationPolicy: requiredString(input.continuationPolicy) as Extract<Phase7SemanticCommand, { kind: "request_human_input" }>["continuationPolicy"],
|
||||
};
|
||||
break;
|
||||
case "register_deliverable":
|
||||
command = {
|
||||
kind: "register_deliverable",
|
||||
taskId,
|
||||
filename: requiredString(input.filename),
|
||||
contentType: requiredString(input.contentType),
|
||||
byteSize: requiredNumber(input.byteSize),
|
||||
sha256: requiredString(input.sha256),
|
||||
contentRef: requiredString(input.contentRef),
|
||||
title: requiredString(input.title),
|
||||
};
|
||||
break;
|
||||
case "finish_task":
|
||||
command = { kind: "finish_task", taskId, summary: requiredString(input.summary) };
|
||||
break;
|
||||
case "block_task":
|
||||
command = { kind: "block_task", taskId, reason: requiredString(input.reason), blockedByTaskIds: optionalStringArray(input.blockedByTaskIds) };
|
||||
break;
|
||||
case "request_review":
|
||||
command = { kind: "request_review", taskId, summary: requiredString(input.summary) };
|
||||
break;
|
||||
case "set_dependencies":
|
||||
command = { kind: "set_dependencies", taskId, blockedByTaskIds: optionalStringArray(input.blockedByTaskIds) };
|
||||
break;
|
||||
case "create_task":
|
||||
command = {
|
||||
kind: "create_task",
|
||||
taskId,
|
||||
title: requiredString(input.title),
|
||||
description: nullableOptionalString(input.description),
|
||||
assigneeActorId: nullableOptionalString(input.assigneeActorId),
|
||||
priority: optionalString(input.priority) as Extract<Phase7SemanticCommand, { kind: "create_task" }>["priority"],
|
||||
blockedByTaskIds: optionalStringArray(input.blockedByTaskIds),
|
||||
};
|
||||
break;
|
||||
case "request_approval":
|
||||
command = { kind: "request_approval", taskId, approvalType: requiredString(input.approvalType), payload: optionalJson(input.payload) };
|
||||
break;
|
||||
case "decide_approval":
|
||||
command = { kind: "decide_approval", taskId, approvalId: requiredString(input.approvalId), decision: requiredString(input.decision) as Extract<Phase7SemanticCommand, { kind: "decide_approval" }>["decision"], note: requiredString(input.note) };
|
||||
break;
|
||||
case "comment_on_approval":
|
||||
command = { kind: "comment_on_approval", taskId, approvalId: requiredString(input.approvalId), body: requiredString(input.body) };
|
||||
break;
|
||||
case "control_workspace_service":
|
||||
command = { kind: "control_workspace_service", taskId, serviceId: requiredString(input.serviceId), action: requiredString(input.action) as Extract<Phase7SemanticCommand, { kind: "control_workspace_service" }>["action"], url: nullableOptionalString(input.url) };
|
||||
break;
|
||||
case "schedule_wake":
|
||||
command = { kind: "schedule_wake", taskId, reason: requiredString(input.reason) as Extract<Phase7SemanticCommand, { kind: "schedule_wake" }>["reason"], payload: optionalJson(input.payload), delayTicks: requiredNumber(input.delayTicks) };
|
||||
break;
|
||||
default:
|
||||
throw new SemanticDispatchFailure("operation_unavailable", "No mock operation is bound to this descriptor.");
|
||||
}
|
||||
const outcome = await this.port.tryApplyCommand({ runId, idempotencyKey, command });
|
||||
return commandOutcome(outcome);
|
||||
}
|
||||
|
||||
#record(
|
||||
context: Phase7SemanticPolicyContext,
|
||||
decision: Phase7SemanticAuthorizationDecision,
|
||||
callId: string | null,
|
||||
input: unknown,
|
||||
result: unknown,
|
||||
): void {
|
||||
this.#recordCounter += 1;
|
||||
this.#records.push(deepFreeze({
|
||||
schema: "paperclip.semantic-authorization-record.v1",
|
||||
id: `semantic-auth-${String(this.#recordCounter).padStart(6, "0")}`,
|
||||
runId: context.runId,
|
||||
scenarioId: context.scenario.id,
|
||||
actorId: context.actor.id,
|
||||
taskId: context.task.id,
|
||||
callId,
|
||||
...decision,
|
||||
input: input === null ? null : redactSemanticValue(input),
|
||||
result: result === null ? null : redactSemanticValue(result),
|
||||
}));
|
||||
}
|
||||
|
||||
#unknownToolDenial(call: Phase7SemanticToolCall): Phase7SemanticToolResult {
|
||||
return deepFreeze({
|
||||
ok: false,
|
||||
operationId: call.operationId,
|
||||
callId: call.callId,
|
||||
denial: {
|
||||
schema: "paperclip.semantic-denial.v1",
|
||||
code: "tool_not_exposed",
|
||||
message: "The requested semantic tool is not in the stable catalog.",
|
||||
retryable: false,
|
||||
},
|
||||
stateRevision: this.port.snapshot().revision,
|
||||
});
|
||||
}
|
||||
|
||||
#denial(
|
||||
call: Phase7SemanticToolCall,
|
||||
operationId: Phase7SemanticOperationId,
|
||||
code: Exclude<Phase7SemanticAuthorizationDecision["code"], "allowed">,
|
||||
message: string,
|
||||
retryable = false,
|
||||
): Phase7SemanticToolResult {
|
||||
return deepFreeze({
|
||||
ok: false,
|
||||
operationId,
|
||||
callId: call.callId,
|
||||
denial: {
|
||||
schema: "paperclip.semantic-denial.v1",
|
||||
code,
|
||||
message: redactSemanticValue(message) as string,
|
||||
retryable,
|
||||
},
|
||||
stateRevision: this.port.snapshot().revision,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function commandOutcome(outcome: Phase7CommandOutcome): OperationSuccess {
|
||||
if (!outcome.ok) {
|
||||
throw new SemanticDispatchFailure(
|
||||
"control_plane_denied",
|
||||
outcome.error.message,
|
||||
outcome.error.retryable,
|
||||
);
|
||||
}
|
||||
return { result: outcome.result as unknown as Phase7JsonValue, stateRevision: outcome.result.stateRevision };
|
||||
}
|
||||
|
||||
function toDefinition(descriptor: Phase7SemanticToolDescriptor): Phase7SemanticToolDefinition {
|
||||
return {
|
||||
name: descriptor.operationId,
|
||||
description: descriptor.description,
|
||||
inputSchema: descriptor.inputSchema,
|
||||
outputSchema: descriptor.outputSchema,
|
||||
annotations: {
|
||||
semanticContract: descriptor.schema,
|
||||
operationId: descriptor.operationId,
|
||||
version: descriptor.version,
|
||||
exposure: descriptor.exposure,
|
||||
requiredClaims: descriptor.requiredClaims,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function deniedDecision(
|
||||
allowed: Phase7SemanticAuthorizationDecision,
|
||||
code: Phase7SemanticDenialCode,
|
||||
reason: string,
|
||||
): Phase7SemanticAuthorizationDecision {
|
||||
return { ...allowed, allowed: false, code, reason };
|
||||
}
|
||||
|
||||
function denialCode(
|
||||
decision: Phase7SemanticAuthorizationDecision,
|
||||
): Phase7SemanticDenialCode {
|
||||
if (decision.code === "allowed") {
|
||||
throw new Error("allowed authorization decision cannot create a denial");
|
||||
}
|
||||
return decision.code;
|
||||
}
|
||||
|
||||
function normalizeFailure(error: unknown): SemanticDispatchFailure {
|
||||
return error instanceof SemanticDispatchFailure
|
||||
? error
|
||||
: new SemanticDispatchFailure("operation_unavailable", "The semantic operation failed safely.");
|
||||
}
|
||||
|
||||
function formatValidationError(validator: ValidateFunction | undefined): string {
|
||||
const issue = validator?.errors?.[0];
|
||||
if (issue === undefined) return "Semantic tool input does not match its stable schema.";
|
||||
return `Semantic tool input ${issue.instancePath || "/"} ${issue.message ?? "is invalid"}.`;
|
||||
}
|
||||
|
||||
function activeDocument(
|
||||
documents: ReturnType<Phase7SemanticControlPlane["snapshot"]>["documents"],
|
||||
context: Phase7RunContext,
|
||||
key: string,
|
||||
) {
|
||||
const document = documents.find(
|
||||
(candidate) => candidate.taskId === context.activeTask.id && candidate.key === key,
|
||||
);
|
||||
if (document === undefined) throw new SemanticDispatchFailure("operation_unavailable", "Document not found.");
|
||||
return document;
|
||||
}
|
||||
|
||||
function approvalById(
|
||||
approvals: ReturnType<Phase7SemanticControlPlane["snapshot"]>["approvals"],
|
||||
approvalId: string,
|
||||
) {
|
||||
const approval = approvals.find((candidate) => candidate.id === approvalId);
|
||||
if (approval === undefined) throw new SemanticDispatchFailure("operation_unavailable", "Approval not found.");
|
||||
return approval;
|
||||
}
|
||||
|
||||
function readSuccess(revision: number, value: unknown): OperationSuccess {
|
||||
return { result: redactSemanticValue(value), stateRevision: revision };
|
||||
}
|
||||
|
||||
function requiredString(value: unknown): string {
|
||||
if (typeof value !== "string") throw new SemanticDispatchFailure("input_invalid", "Expected string input.");
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
function nullableString(value: unknown): string | null {
|
||||
return value === null ? null : requiredString(value);
|
||||
}
|
||||
|
||||
function nullableOptionalString(value: unknown): string | null | undefined {
|
||||
return value === undefined ? undefined : nullableString(value);
|
||||
}
|
||||
|
||||
function requiredNumber(value: unknown): number {
|
||||
if (typeof value !== "number") throw new SemanticDispatchFailure("input_invalid", "Expected number input.");
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalNumber(value: unknown, fallback: number): number {
|
||||
return typeof value === "number" ? value : fallback;
|
||||
}
|
||||
|
||||
function optionalStringArray(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
||||
}
|
||||
|
||||
function optionalJson(value: unknown): Phase7JsonValue {
|
||||
return value === undefined ? {} : redactSemanticValue(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,6 @@
|
||||
export * from "./types.js";
|
||||
export * from "./catalog.js";
|
||||
export * from "./policy.js";
|
||||
export * from "./redaction.js";
|
||||
export * from "./provider-neutral.js";
|
||||
export * from "./dispatcher.js";
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { Phase7RunContext } from "../mock-core/phase7-control-plane-types.js";
|
||||
import { PHASE7_SEMANTIC_TOOL_CATALOG } from "./catalog.js";
|
||||
import type {
|
||||
Phase7SemanticAuthorizationDecision,
|
||||
Phase7SemanticOperationId,
|
||||
Phase7SemanticPolicyContext,
|
||||
Phase7SemanticScenarioPolicy,
|
||||
Phase7SemanticToolDescriptor,
|
||||
} from "./types.js";
|
||||
|
||||
const READ_OPERATIONS = new Set<Phase7SemanticOperationId>([
|
||||
"get_task_context",
|
||||
"get_task_history",
|
||||
"list_documents",
|
||||
"read_document",
|
||||
"list_document_revisions",
|
||||
"list_agents",
|
||||
"get_agent",
|
||||
"search_tasks",
|
||||
"list_approvals",
|
||||
"get_approval",
|
||||
"get_approval_context",
|
||||
"get_workspace_runtime",
|
||||
]);
|
||||
|
||||
export const DEFAULT_PHASE7_SCENARIO_POLICY: Phase7SemanticScenarioPolicy = {
|
||||
id: "phase7-default",
|
||||
};
|
||||
|
||||
export function createPhase7SemanticPolicyContext(
|
||||
context: Phase7RunContext,
|
||||
scenario: Phase7SemanticScenarioPolicy = DEFAULT_PHASE7_SCENARIO_POLICY,
|
||||
explicitClaims: readonly string[] = context.capabilities,
|
||||
): Phase7SemanticPolicyContext {
|
||||
return deepFreeze({
|
||||
runId: context.activeTask.executionRunId ?? context.activeTask.checkoutRunId ?? "",
|
||||
actor: context.actor,
|
||||
task: context.activeTask,
|
||||
runClaims: sortedUnique(context.capabilities),
|
||||
explicitClaims: sortedUnique(explicitClaims),
|
||||
scenario,
|
||||
});
|
||||
}
|
||||
|
||||
export function decidePhase7SemanticAuthorization(
|
||||
descriptor: Phase7SemanticToolDescriptor,
|
||||
context: Phase7SemanticPolicyContext,
|
||||
phase: "exposure" | "invocation",
|
||||
input?: unknown,
|
||||
): Phase7SemanticAuthorizationDecision {
|
||||
const effectiveClaims = computeEffectiveClaims(context);
|
||||
const base = { phase, operationId: descriptor.operationId, effectiveClaims } as const;
|
||||
const deny = (
|
||||
code: Exclude<Phase7SemanticAuthorizationDecision["code"], "allowed">,
|
||||
reason: string,
|
||||
): Phase7SemanticAuthorizationDecision => ({ ...base, allowed: false, code, reason });
|
||||
|
||||
if (context.actor.status !== "active") {
|
||||
return deny("actor_inactive", "The mock actor is not active.");
|
||||
}
|
||||
if (!descriptor.allowedModes.includes(context.task.workMode)) {
|
||||
return deny("task_mode_denied", "The semantic operation is unavailable in this task mode.");
|
||||
}
|
||||
if (
|
||||
context.scenario.denyOperations?.includes(descriptor.operationId) === true ||
|
||||
(descriptor.exposure === "optional" &&
|
||||
context.scenario.allowOperations !== undefined &&
|
||||
!context.scenario.allowOperations.includes(descriptor.operationId))
|
||||
) {
|
||||
return deny("scenario_denied", "The active scenario does not permit this semantic operation.");
|
||||
}
|
||||
if (descriptor.disabledByDefault === true) {
|
||||
if (
|
||||
descriptor.operationId !== "generic_api_request" ||
|
||||
context.scenario.enableGenericApiRequest !== true
|
||||
) {
|
||||
return deny("generic_api_disabled", "The generic API escape hatch is disabled.");
|
||||
}
|
||||
}
|
||||
if (
|
||||
descriptor.allowedRoles !== undefined &&
|
||||
!descriptor.allowedRoles.includes(context.actor.role.trim().toLowerCase())
|
||||
) {
|
||||
return deny("actor_role_denied", "The actor role cannot invoke this semantic operation.");
|
||||
}
|
||||
if (descriptor.requiredClaims.some((claim) => !effectiveClaims.includes(claim))) {
|
||||
return deny("required_claim_missing", "The run lacks an explicit claim required by this operation.");
|
||||
}
|
||||
if (
|
||||
phase === "invocation" &&
|
||||
(context.task.assigneeActorId !== context.actor.id ||
|
||||
context.task.executionRunId !== context.runId ||
|
||||
context.task.checkoutRunId !== context.runId)
|
||||
) {
|
||||
return deny("task_ownership_denied", "The run no longer owns the active task.");
|
||||
}
|
||||
if (
|
||||
phase === "invocation" &&
|
||||
!READ_OPERATIONS.has(descriptor.operationId) &&
|
||||
["done", "cancelled"].includes(context.task.status)
|
||||
) {
|
||||
return deny("task_state_denied", "The active task is already terminal.");
|
||||
}
|
||||
if (descriptor.operationId === "request_human_input" && input !== undefined) {
|
||||
const interactionKind = readStringProperty(input, "interactionKind");
|
||||
if (
|
||||
interactionKind !== undefined &&
|
||||
context.scenario.allowedInteractionKinds !== undefined &&
|
||||
!context.scenario.allowedInteractionKinds.includes(
|
||||
interactionKind as Parameters<NonNullable<typeof context.scenario.allowedInteractionKinds>["includes"]>[0],
|
||||
)
|
||||
) {
|
||||
return deny("interaction_kind_denied", "The scenario does not allow this interaction kind.");
|
||||
}
|
||||
}
|
||||
return { ...base, allowed: true, code: "allowed", reason: "Explicit semantic policy allowed the operation." };
|
||||
}
|
||||
|
||||
export function exposedPhase7SemanticDescriptors(
|
||||
context: Phase7SemanticPolicyContext,
|
||||
): readonly Phase7SemanticToolDescriptor[] {
|
||||
return PHASE7_SEMANTIC_TOOL_CATALOG.filter(
|
||||
(descriptor) => decidePhase7SemanticAuthorization(descriptor, context, "exposure").allowed,
|
||||
);
|
||||
}
|
||||
|
||||
function computeEffectiveClaims(context: Phase7SemanticPolicyContext): string[] {
|
||||
const granted = new Set([
|
||||
...context.actor.capabilityGrants,
|
||||
...(context.scenario.claims ?? []),
|
||||
]);
|
||||
const explicitlyDelegated = new Set(context.explicitClaims);
|
||||
return sortedUnique(
|
||||
context.runClaims.filter((claim) => granted.has(claim) && explicitlyDelegated.has(claim)),
|
||||
);
|
||||
}
|
||||
|
||||
function readStringProperty(input: unknown, key: string): string | undefined {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) return undefined;
|
||||
const value = (input as Record<string, unknown>)[key];
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
function sortedUnique(values: readonly string[]): string[] {
|
||||
return [...new Set(values)].sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
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,69 @@
|
||||
import { PHASE7_SEMANTIC_TOOL_CATALOG } from "./catalog.js";
|
||||
import type {
|
||||
Phase7SemanticToolDefinition,
|
||||
Phase7SemanticToolDescriptor,
|
||||
} from "./types.js";
|
||||
|
||||
export type Phase7SemanticBindingKind = "fake" | "live_codex";
|
||||
|
||||
export interface Phase7ProviderNeutralSemanticBinding {
|
||||
readonly schema: "paperclip.semantic-binding.v1";
|
||||
readonly bindingKind: Phase7SemanticBindingKind;
|
||||
readonly contracts: readonly Phase7SemanticToolDefinition[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts transport-neutral descriptors into model-provider contracts. The
|
||||
* binding label is deliberately outside the contract array, so fake and live
|
||||
* Codex bindings cannot drift in names, schemas, claims, or redaction metadata.
|
||||
*/
|
||||
export function createPhase7ProviderNeutralBinding(
|
||||
bindingKind: Phase7SemanticBindingKind,
|
||||
descriptors: readonly Phase7SemanticToolDescriptor[] = PHASE7_SEMANTIC_TOOL_CATALOG,
|
||||
): Phase7ProviderNeutralSemanticBinding {
|
||||
return deepFreeze({
|
||||
schema: "paperclip.semantic-binding.v1",
|
||||
bindingKind,
|
||||
contracts: descriptors.map(toDefinition),
|
||||
});
|
||||
}
|
||||
|
||||
export function phase7GeneratedSemanticContracts(): readonly Phase7SemanticToolDefinition[] {
|
||||
return createPhase7ProviderNeutralBinding("fake").contracts;
|
||||
}
|
||||
|
||||
export function serializePhase7GeneratedSemanticContracts(): string {
|
||||
return `${canonicalJson(phase7GeneratedSemanticContracts())}\n`;
|
||||
}
|
||||
|
||||
function toDefinition(descriptor: Phase7SemanticToolDescriptor): Phase7SemanticToolDefinition {
|
||||
return {
|
||||
name: descriptor.operationId,
|
||||
description: descriptor.description,
|
||||
inputSchema: descriptor.inputSchema,
|
||||
outputSchema: descriptor.outputSchema,
|
||||
annotations: {
|
||||
semanticContract: descriptor.schema,
|
||||
operationId: descriptor.operationId,
|
||||
version: descriptor.version,
|
||||
exposure: descriptor.exposure,
|
||||
requiredClaims: descriptor.requiredClaims,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function canonicalJson(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
||||
if (typeof value === "object" && value !== null) {
|
||||
const record = value as Record<string, unknown>;
|
||||
return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value) ?? "null";
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Phase7JsonValue } from "../mock-core/phase7-control-plane-types.js";
|
||||
|
||||
const SENSITIVE_KEY = /(?:authorization|cookie|credential|password|passwd|private.?key|secret|token|api.?key|connection.?string)/i;
|
||||
const SECRET_VALUE = /(?:\bBearer\s+[A-Za-z0-9._~+\/-]+=*|\b(?:sk|pk|pcgw|ghp|github_pat)_[A-Za-z0-9_-]{8,}|\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,})/i;
|
||||
const SECRET_QUERY_DETECT = /[?&](?:code|key|secret|state|token|api[_-]?key|access[_-]?token)=[^&#\s]+/i;
|
||||
const SECRET_QUERY = /([?&](?:code|key|secret|state|token|api[_-]?key|access[_-]?token)=)[^&#\s]+/gi;
|
||||
|
||||
export const PHASE7_REDACTED = "[REDACTED]";
|
||||
|
||||
export function containsProtectedSemanticData(value: unknown, key = ""): boolean {
|
||||
if (SENSITIVE_KEY.test(key)) return true;
|
||||
if (typeof value === "string") {
|
||||
return SECRET_VALUE.test(value) || SECRET_QUERY_DETECT.test(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.some((child) => containsProtectedSemanticData(child));
|
||||
}
|
||||
if (typeof value === "object" && value !== null) {
|
||||
return Object.entries(value).some(([childKey, child]) =>
|
||||
containsProtectedSemanticData(child, childKey),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function redactSemanticValue(value: unknown, key = ""): Phase7JsonValue {
|
||||
if (SENSITIVE_KEY.test(key)) return PHASE7_REDACTED;
|
||||
if (typeof value === "string") {
|
||||
return value
|
||||
.replace(SECRET_VALUE, PHASE7_REDACTED)
|
||||
.replace(SECRET_QUERY, `$1${PHASE7_REDACTED}`);
|
||||
}
|
||||
if (Array.isArray(value)) return value.map((child) => redactSemanticValue(child));
|
||||
if (typeof value === "object" && value !== null) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([childKey, child]) => [
|
||||
childKey,
|
||||
redactSemanticValue(child, childKey),
|
||||
]),
|
||||
);
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean" || value === null) {
|
||||
return value;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { Phase7FixtureSeed } from "../mock-core/phase7-control-plane-types.js";
|
||||
import { Phase7MockControlPlaneAdapter } from "../mock-core/phase7-mock-control-plane-adapter.js";
|
||||
import { PHASE7_SEMANTIC_TOOL_CATALOG } from "./catalog.js";
|
||||
import { Phase7SemanticDispatcher } from "./dispatcher.js";
|
||||
import { createPhase7ProviderNeutralBinding } from "./provider-neutral.js";
|
||||
|
||||
const OPEN = {
|
||||
identity: {
|
||||
runId: "run-semantic-1",
|
||||
sessionId: "session-semantic-1",
|
||||
companyId: "company-1",
|
||||
issueId: "task-1",
|
||||
agentId: "actor-1",
|
||||
},
|
||||
backendKind: "mock" as const,
|
||||
sourceInstanceId: "semantic-test",
|
||||
};
|
||||
|
||||
async function running(
|
||||
claims: string[] = [],
|
||||
seed: Phase7FixtureSeed = {},
|
||||
) {
|
||||
const adapter = new Phase7MockControlPlaneAdapter({
|
||||
...seed,
|
||||
actors: seed.actors ?? [
|
||||
{
|
||||
id: "actor-1",
|
||||
companyId: "company-1",
|
||||
name: "Semantic Engineer",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
budgetId: "budget-actor-1",
|
||||
capabilityGrants: claims,
|
||||
},
|
||||
],
|
||||
});
|
||||
await adapter.start();
|
||||
await adapter.openFixtureRun({ ...OPEN, capabilities: claims });
|
||||
return adapter;
|
||||
}
|
||||
|
||||
describe("Phase 7 semantic catalog and authorization", () => {
|
||||
it("publishes a stable narrow catalog without credentials or control-plane-owned tools", () => {
|
||||
const names = PHASE7_SEMANTIC_TOOL_CATALOG.map((tool) => tool.operationId);
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
expect(names).toHaveLength(28);
|
||||
expect(names).toContain("get_task_context");
|
||||
expect(names).toContain("finish_task");
|
||||
expect(names).not.toContain("checkout_task");
|
||||
expect(names).not.toContain("record_budget_usage");
|
||||
expect(names).not.toContain("resolve_human_input");
|
||||
expect(PHASE7_SEMANTIC_TOOL_CATALOG.find((tool) => tool.operationId === "generic_api_request"))
|
||||
.toMatchObject({ exposure: "optional", disabledByDefault: true });
|
||||
const schemaKeys = PHASE7_SEMANTIC_TOOL_CATALOG.flatMap((tool) =>
|
||||
Object.keys(tool.inputSchema.properties ?? {}),
|
||||
);
|
||||
expect(schemaKeys.join(" ")).not.toMatch(
|
||||
/authorization|password|credential|api.?key|access.?token|secret/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("exposes always tools while optional tools require actor, scenario, and explicit run claims", async () => {
|
||||
const adapter = await running();
|
||||
const dispatcher = new Phase7SemanticDispatcher(adapter);
|
||||
const names = dispatcher.listTools(OPEN.identity.runId).map((tool) => tool.name);
|
||||
expect(names).toContain("get_task_context");
|
||||
expect(names).toContain("report_progress");
|
||||
expect(names).not.toContain("set_dependencies");
|
||||
expect(names).not.toContain("generic_api_request");
|
||||
|
||||
const before = adapter.snapshot().revision;
|
||||
const denied = await dispatcher.dispatch({
|
||||
runId: OPEN.identity.runId,
|
||||
callId: "denied-dependencies",
|
||||
operationId: "set_dependencies",
|
||||
input: { idempotencyKey: "deps-1", blockedByTaskIds: [] },
|
||||
});
|
||||
expect(denied).toMatchObject({
|
||||
ok: false,
|
||||
denial: { code: "required_claim_missing", retryable: false },
|
||||
});
|
||||
expect(adapter.snapshot().revision).toBe(before);
|
||||
});
|
||||
|
||||
it("executes a granted optional operation through the mock port", async () => {
|
||||
const claim = "dependencies:write";
|
||||
const adapter = await running([claim], {
|
||||
tasks: [
|
||||
{
|
||||
id: "task-1",
|
||||
companyId: "company-1",
|
||||
identifier: "MCK-1",
|
||||
title: "Active task",
|
||||
description: null,
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
workMode: "standard",
|
||||
parentId: null,
|
||||
assigneeActorId: "actor-1",
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
},
|
||||
{
|
||||
id: "task-2",
|
||||
companyId: "company-1",
|
||||
identifier: "MCK-2",
|
||||
title: "Blocking task",
|
||||
description: null,
|
||||
status: "todo",
|
||||
priority: "high",
|
||||
workMode: "standard",
|
||||
parentId: null,
|
||||
assigneeActorId: null,
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
const dispatcher = new Phase7SemanticDispatcher(adapter, {
|
||||
scenario: { id: "dependency-manager", claims: [claim] },
|
||||
explicitClaims: [claim],
|
||||
});
|
||||
expect(dispatcher.listTools(OPEN.identity.runId).map((tool) => tool.name))
|
||||
.toContain("set_dependencies");
|
||||
const result = await dispatcher.dispatch({
|
||||
runId: OPEN.identity.runId,
|
||||
callId: "set-dependencies",
|
||||
operationId: "set_dependencies",
|
||||
input: { idempotencyKey: "deps-1", blockedByTaskIds: ["task-2"] },
|
||||
});
|
||||
expect(result).toMatchObject({ ok: true, result: { disposition: "applied" } });
|
||||
expect(adapter.snapshot().blockers).toEqual([
|
||||
expect.objectContaining({ taskId: "task-1", blockedByTaskId: "task-2" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("rechecks policy immediately before every operation", async () => {
|
||||
const claim = "dependencies:write";
|
||||
const adapter = await running([claim]);
|
||||
let exposeClaim = true;
|
||||
const port = {
|
||||
snapshot: () => adapter.snapshot(),
|
||||
tryApplyCommand: adapter.tryApplyCommand.bind(adapter),
|
||||
context: (runId: string) => {
|
||||
const context = structuredClone(adapter.context(runId));
|
||||
context.capabilities = exposeClaim ? [claim] : [];
|
||||
return context;
|
||||
},
|
||||
};
|
||||
const dispatcher = new Phase7SemanticDispatcher(port, {
|
||||
scenario: { id: "revocation", claims: [claim] },
|
||||
explicitClaims: [claim],
|
||||
});
|
||||
expect(dispatcher.listTools(OPEN.identity.runId).map((tool) => tool.name))
|
||||
.toContain("set_dependencies");
|
||||
exposeClaim = false;
|
||||
const before = adapter.snapshot().revision;
|
||||
const result = await dispatcher.dispatch({
|
||||
runId: OPEN.identity.runId,
|
||||
callId: "revoked-call",
|
||||
operationId: "set_dependencies",
|
||||
input: { idempotencyKey: "revoked-1", blockedByTaskIds: [] },
|
||||
});
|
||||
expect(result).toMatchObject({ ok: false, denial: { code: "required_claim_missing" } });
|
||||
expect(adapter.snapshot().revision).toBe(before);
|
||||
});
|
||||
|
||||
it("returns typed denials for absent, mode-denied, and generic escape-hatch tools", async () => {
|
||||
const adapter = await running([], {
|
||||
tasks: [{
|
||||
id: "task-1",
|
||||
companyId: "company-1",
|
||||
identifier: "MCK-1",
|
||||
title: "Ask-only task",
|
||||
description: null,
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
workMode: "ask",
|
||||
parentId: null,
|
||||
assigneeActorId: "actor-1",
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
}],
|
||||
});
|
||||
const dispatcher = new Phase7SemanticDispatcher(adapter);
|
||||
const names = dispatcher.listTools(OPEN.identity.runId).map((tool) => tool.name);
|
||||
expect(names).not.toContain("write_document");
|
||||
expect(names).not.toContain("finish_task");
|
||||
await expect(dispatcher.dispatch({
|
||||
runId: OPEN.identity.runId,
|
||||
callId: "unknown",
|
||||
operationId: "paperclipApiRequest",
|
||||
input: {},
|
||||
})).resolves.toMatchObject({ ok: false, denial: { code: "tool_not_exposed" } });
|
||||
await expect(dispatcher.dispatch({
|
||||
runId: OPEN.identity.runId,
|
||||
callId: "mode-denied",
|
||||
operationId: "write_document",
|
||||
input: {},
|
||||
})).resolves.toMatchObject({ ok: false, denial: { code: "task_mode_denied" } });
|
||||
});
|
||||
|
||||
it("rejects protected inputs before mutation and redacts authorization records", async () => {
|
||||
const claim = "governance:approvals:request";
|
||||
const adapter = await running([claim]);
|
||||
const dispatcher = new Phase7SemanticDispatcher(adapter, {
|
||||
scenario: { id: "approval", claims: [claim] },
|
||||
explicitClaims: [claim],
|
||||
});
|
||||
const secret = "Bearer secret-value-123456";
|
||||
const before = adapter.snapshot().revision;
|
||||
const result = await dispatcher.dispatch({
|
||||
runId: OPEN.identity.runId,
|
||||
callId: "protected-approval",
|
||||
operationId: "request_approval",
|
||||
input: {
|
||||
idempotencyKey: "approval-secret",
|
||||
approvalType: "request_board_approval",
|
||||
payload: { authorization: secret },
|
||||
},
|
||||
});
|
||||
expect(result).toMatchObject({ ok: false, denial: { code: "protected_data_denied" } });
|
||||
expect(adapter.snapshot().revision).toBe(before);
|
||||
const serialized = JSON.stringify(dispatcher.authorizationRecords());
|
||||
expect(serialized).not.toContain(secret);
|
||||
expect(serialized).toContain("[REDACTED]");
|
||||
});
|
||||
|
||||
it("redacts protected mock state from read results", async () => {
|
||||
const secret = "Bearer mock-secret-123456";
|
||||
const adapter = await running([], {
|
||||
comments: [{
|
||||
id: "comment-secret",
|
||||
taskId: "task-1",
|
||||
authorActorId: "actor-1",
|
||||
body: `diagnostic ${secret}`,
|
||||
createdAt: "2026-08-09T00:00:00.000Z",
|
||||
}],
|
||||
});
|
||||
const dispatcher = new Phase7SemanticDispatcher(adapter);
|
||||
const result = await dispatcher.dispatch({
|
||||
runId: OPEN.identity.runId,
|
||||
callId: "history",
|
||||
operationId: "get_task_history",
|
||||
input: {},
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain(secret);
|
||||
expect(JSON.stringify(result)).toContain("[REDACTED]");
|
||||
});
|
||||
|
||||
it("applies always-tool commands and records authorization for every invocation", async () => {
|
||||
const adapter = await running();
|
||||
const dispatcher = new Phase7SemanticDispatcher(adapter);
|
||||
const progress = await dispatcher.dispatch({
|
||||
runId: OPEN.identity.runId,
|
||||
callId: "progress",
|
||||
operationId: "report_progress",
|
||||
input: { idempotencyKey: "progress-1", body: "Semantic catalog is active." },
|
||||
});
|
||||
expect(progress).toMatchObject({ ok: true });
|
||||
expect(adapter.snapshot().comments).toEqual([
|
||||
expect.objectContaining({ body: "Semantic catalog is active." }),
|
||||
]);
|
||||
const records = dispatcher.authorizationRecords().filter((record) => record.phase === "invocation");
|
||||
expect(records).toHaveLength(1);
|
||||
expect(records[0]).toMatchObject({ allowed: true, operationId: "report_progress", callId: "progress" });
|
||||
});
|
||||
|
||||
it("keeps generated contracts identical across fake and live Codex bindings", () => {
|
||||
const fake = createPhase7ProviderNeutralBinding("fake");
|
||||
const live = createPhase7ProviderNeutralBinding("live_codex");
|
||||
expect(fake.bindingKind).toBe("fake");
|
||||
expect(live.bindingKind).toBe("live_codex");
|
||||
expect(live.contracts).toEqual(fake.contracts);
|
||||
expect(live.contracts.map((tool) => tool.name)).toEqual(
|
||||
PHASE7_SEMANTIC_TOOL_CATALOG.map((tool) => tool.operationId),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
import type {
|
||||
Phase7InteractionKind,
|
||||
Phase7JsonValue,
|
||||
Phase7RunContext,
|
||||
} from "../mock-core/phase7-control-plane-types.js";
|
||||
|
||||
export type Phase7SemanticToolExposure = "always" | "optional";
|
||||
|
||||
export type Phase7SemanticOperationId =
|
||||
| "get_task_context"
|
||||
| "get_task_history"
|
||||
| "list_documents"
|
||||
| "read_document"
|
||||
| "list_document_revisions"
|
||||
| "report_progress"
|
||||
| "answer_status_question"
|
||||
| "write_document"
|
||||
| "request_human_input"
|
||||
| "register_deliverable"
|
||||
| "finish_task"
|
||||
| "block_task"
|
||||
| "request_review"
|
||||
| "list_agents"
|
||||
| "get_agent"
|
||||
| "search_tasks"
|
||||
| "list_approvals"
|
||||
| "get_approval"
|
||||
| "get_approval_context"
|
||||
| "get_workspace_runtime"
|
||||
| "control_workspace_service"
|
||||
| "set_dependencies"
|
||||
| "create_task"
|
||||
| "request_approval"
|
||||
| "decide_approval"
|
||||
| "comment_on_approval"
|
||||
| "schedule_wake"
|
||||
| "generic_api_request";
|
||||
|
||||
export interface Phase7JsonSchema {
|
||||
readonly $schema?: string;
|
||||
readonly type?: string | readonly string[];
|
||||
readonly title?: string;
|
||||
readonly description?: string;
|
||||
readonly properties?: Readonly<Record<string, Phase7JsonSchema>>;
|
||||
readonly required?: readonly string[];
|
||||
readonly additionalProperties?: boolean | Phase7JsonSchema;
|
||||
readonly items?: Phase7JsonSchema;
|
||||
readonly enum?: readonly Phase7JsonValue[];
|
||||
readonly const?: Phase7JsonValue;
|
||||
readonly oneOf?: readonly Phase7JsonSchema[];
|
||||
readonly anyOf?: readonly Phase7JsonSchema[];
|
||||
readonly minimum?: number;
|
||||
readonly maximum?: number;
|
||||
readonly minLength?: number;
|
||||
readonly maxLength?: number;
|
||||
readonly minItems?: number;
|
||||
readonly maxItems?: number;
|
||||
readonly uniqueItems?: boolean;
|
||||
readonly pattern?: string;
|
||||
readonly format?: string;
|
||||
readonly default?: Phase7JsonValue;
|
||||
}
|
||||
|
||||
export interface Phase7SemanticToolDescriptor {
|
||||
readonly schema: "paperclip.semantic-tool.v1";
|
||||
readonly operationId: Phase7SemanticOperationId;
|
||||
readonly version: 1;
|
||||
readonly title: string;
|
||||
readonly description: string;
|
||||
readonly exposure: Phase7SemanticToolExposure;
|
||||
readonly requiredClaims: readonly string[];
|
||||
readonly allowedModes: readonly Phase7RunContext["activeTask"]["workMode"][];
|
||||
readonly allowedRoles?: readonly string[];
|
||||
readonly disabledByDefault?: boolean;
|
||||
readonly inputSchema: Phase7JsonSchema;
|
||||
readonly outputSchema: Phase7JsonSchema;
|
||||
}
|
||||
|
||||
export interface Phase7SemanticScenarioPolicy {
|
||||
readonly id: string;
|
||||
/** Claims that this scenario is permitted to grant. Run claims still narrow this set. */
|
||||
readonly claims?: readonly string[];
|
||||
readonly allowOperations?: readonly Phase7SemanticOperationId[];
|
||||
readonly denyOperations?: readonly Phase7SemanticOperationId[];
|
||||
readonly allowedInteractionKinds?: readonly Phase7InteractionKind[];
|
||||
readonly enableGenericApiRequest?: boolean;
|
||||
}
|
||||
|
||||
export interface Phase7SemanticPolicyContext {
|
||||
readonly runId: string;
|
||||
readonly actor: Phase7RunContext["actor"];
|
||||
readonly task: Phase7RunContext["activeTask"];
|
||||
readonly runClaims: readonly string[];
|
||||
readonly explicitClaims: readonly string[];
|
||||
readonly scenario: Phase7SemanticScenarioPolicy;
|
||||
}
|
||||
|
||||
export type Phase7AuthorizationPhase = "exposure" | "invocation";
|
||||
|
||||
export type Phase7SemanticDenialCode =
|
||||
| "tool_not_exposed"
|
||||
| "actor_inactive"
|
||||
| "task_mode_denied"
|
||||
| "task_state_denied"
|
||||
| "task_ownership_denied"
|
||||
| "required_claim_missing"
|
||||
| "actor_role_denied"
|
||||
| "scenario_denied"
|
||||
| "interaction_kind_denied"
|
||||
| "generic_api_disabled"
|
||||
| "input_invalid"
|
||||
| "protected_data_denied"
|
||||
| "operation_unavailable"
|
||||
| "control_plane_denied";
|
||||
|
||||
export interface Phase7SemanticAuthorizationDecision {
|
||||
readonly allowed: boolean;
|
||||
readonly phase: Phase7AuthorizationPhase;
|
||||
readonly operationId: Phase7SemanticOperationId;
|
||||
readonly code: "allowed" | Phase7SemanticDenialCode;
|
||||
readonly reason: string;
|
||||
readonly effectiveClaims: readonly string[];
|
||||
}
|
||||
|
||||
export interface Phase7SemanticAuthorizationRecord
|
||||
extends Phase7SemanticAuthorizationDecision {
|
||||
readonly schema: "paperclip.semantic-authorization-record.v1";
|
||||
readonly id: string;
|
||||
readonly runId: string;
|
||||
readonly scenarioId: string;
|
||||
readonly actorId: string;
|
||||
readonly taskId: string;
|
||||
readonly callId: string | null;
|
||||
readonly input: Phase7JsonValue | null;
|
||||
readonly result: Phase7JsonValue | null;
|
||||
}
|
||||
|
||||
export interface Phase7SemanticToolDefinition {
|
||||
readonly name: Phase7SemanticOperationId;
|
||||
readonly description: string;
|
||||
readonly inputSchema: Phase7JsonSchema;
|
||||
readonly outputSchema: Phase7JsonSchema;
|
||||
readonly annotations: {
|
||||
readonly semanticContract: "paperclip.semantic-tool.v1";
|
||||
readonly operationId: Phase7SemanticOperationId;
|
||||
readonly version: 1;
|
||||
readonly exposure: Phase7SemanticToolExposure;
|
||||
readonly requiredClaims: readonly string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface Phase7SemanticToolCall {
|
||||
readonly runId: string;
|
||||
readonly callId: string;
|
||||
readonly operationId: Phase7SemanticOperationId | string;
|
||||
readonly input: unknown;
|
||||
}
|
||||
|
||||
export interface Phase7SemanticToolSuccess {
|
||||
readonly ok: true;
|
||||
readonly operationId: Phase7SemanticOperationId;
|
||||
readonly callId: string;
|
||||
readonly result: Phase7JsonValue;
|
||||
readonly stateRevision: number;
|
||||
}
|
||||
|
||||
export interface Phase7SemanticToolDenial {
|
||||
readonly ok: false;
|
||||
readonly operationId: string;
|
||||
readonly callId: string;
|
||||
readonly denial: {
|
||||
readonly schema: "paperclip.semantic-denial.v1";
|
||||
readonly code: Phase7SemanticDenialCode;
|
||||
readonly message: string;
|
||||
readonly retryable: boolean;
|
||||
};
|
||||
readonly stateRevision: number;
|
||||
}
|
||||
|
||||
export type Phase7SemanticToolResult =
|
||||
| Phase7SemanticToolSuccess
|
||||
| Phase7SemanticToolDenial;
|
||||
@@ -0,0 +1,41 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import { resolve } from "node:path";
|
||||
import { validateRows } from "../scripts/generate-phase-07-contract.mjs";
|
||||
|
||||
const phaseDirectory = resolve(import.meta.dirname, "../generated/phase-07");
|
||||
|
||||
async function readRows(file) {
|
||||
return JSON.parse(await readFile(resolve(phaseDirectory, file), "utf8")).rows;
|
||||
}
|
||||
|
||||
test("generated Phase 7 inventory has full source coverage", async () => {
|
||||
const [capabilities, tools, evals] = await Promise.all([
|
||||
readRows("capabilities.yaml"),
|
||||
readRows("mcp-tool-map.yaml"),
|
||||
readRows("eval-traceability.yaml"),
|
||||
]);
|
||||
|
||||
assert.equal(capabilities.length, 152);
|
||||
assert.equal(tools.length, 41);
|
||||
assert.equal(evals.length, 106);
|
||||
assert.equal(new Set(evals.map((row) => row.group)).size, 16);
|
||||
for (const row of [...capabilities, ...tools, ...evals]) {
|
||||
assert.match(row.sourceAnchor, /\S/);
|
||||
assert.match(row.semanticOperation, /\S/);
|
||||
assert.match(row.expectedMockState, /\S/);
|
||||
}
|
||||
});
|
||||
|
||||
test("contract validation rejects missing, duplicate, and unclassified entries", () => {
|
||||
const row = {
|
||||
id: "example:1",
|
||||
sourceAnchor: "source.md#L1:example",
|
||||
primaryDisposition: "control_plane_owned",
|
||||
};
|
||||
|
||||
assert.throws(() => validateRows([{ ...row, sourceAnchor: "" }], "fixture"), /source anchor/);
|
||||
assert.throws(() => validateRows([row, { ...row, id: "example:2" }], "fixture"), /duplicate source anchor/);
|
||||
assert.throws(() => validateRows([{ ...row, primaryDisposition: "unclassified" }], "fixture"), /valid primary disposition/);
|
||||
});
|
||||
Reference in new issue
Block a user