diff --git a/.changeset/rich-actions-43.md b/.changeset/rich-actions-43.md new file mode 100644 index 0000000..8bb3823 --- /dev/null +++ b/.changeset/rich-actions-43.md @@ -0,0 +1,7 @@ +--- +'@opencoven/cave-client': minor +--- + +Add the privileged authority tier (attachments, rich content, attention, task handoffs, explicitly confirmed GitHub actions) with Cave remaining the sole executor and canonical owner: a capability registry derived from the authoritative contract fixture that resolves every privileged action class against the live operation table per call; fail-closed bounded attachment transfer (file count, file size, request size, MIME allowlist with magic-byte signature agreement, filename, traversal, symlink, and the atomic uploader-credential-plus-conversation binding) with metadata-only records so attachment bytes never enter canonical conversation JSON; a passive rich-content AST over a closed non-executable node vocabulary that rejects raw markup nodes, unknown fields, unsafe link schemes (`https:`/`mailto:` only), oversized payloads, and over-deep nesting while preserving markup-looking text inertly; task-handoff transitions that keep proposed, pending, completed, rejected, and failed strictly distinct behind a declared transition map; closed attention-response kinds with bounded notes; and confirmed GitHub action machinery whose curated union ships deliberately empty. + +The authoritative Cave fixture pinned at producer commit `4adc97b1` declares the privileged pairing scopes (`attachments:write`, `tasks:write`, `github:write`, `chat:write`, `conversations:write`) but no attachment, rich-content, attention, task, or GitHub operations, so the five optional `CaveTransport` bindings stay unbound, every privileged call resolves `undeclared`, and the client reports `unsupported_operation` after request validation (zero transport dispatch on validation failure) until the producer contract lands and `pnpm sync:contracts` imports it. No routes, scopes, capability families, or GitHub action kinds are invented. diff --git a/api-baselines/cave.d.ts b/api-baselines/cave.d.ts index f51b10f..b622dec 100644 --- a/api-baselines/cave.d.ts +++ b/api-baselines/cave.d.ts @@ -1,5 +1,5 @@ // Entrypoint: . -// Declaration: dist/client-ootQTXcj.d.ts +// Declaration: dist/client-M2RrMRyI.d.ts import { OperationObserver, OperationContext, PageOptions, OperationDefaults, SecretStore, SecretStoreReference, OperationOptions, Page, BoundedPageOptions, NormalizedError, CompatibilityAssessment } from '@opencoven/sdk-core/browser'; interface CaveCanonicalFamiliar { @@ -538,6 +538,501 @@ declare function createConversationEventTranslator(operationId: CaveConversation */ declare function caveConversationReconcileReason(error: unknown): CaveConversationReconcileReason | undefined; +/** + * Bounded attachment transfer for the privileged authority tier. + * + * This module owns the SDK half of the attachment contract: fail-closed + * preflight validation (file count, per-file size, total request size, + * declared MIME type versus signature, filename, traversal, symlink, and + * ownership binding) and the metadata-only records that bind an attachment + * to its uploader credential and conversation atomically. Attachment bytes + * exist only inside the in-flight upload request; they never enter the + * canonical attachment record, and therefore never enter canonical + * conversation JSON, browser storage, profile config, or diagnostic bundles. + * The SDK never hashes attachment bytes: the canonical byte digest is + * Cave's, computed server-side where the bytes land, and appears in records + * only as a validated string. + * + * Upstream-contract gap (stated, not invented): the authoritative Cave + * fixture pinned at `4adc97b1` declares the `attachments:write` pairing + * scope but no attachment operations and no attachment capability family, + * so no transport binding or route path ships; upload and download report + * `unsupported_operation` until the producer contract lands and + * `pnpm sync:contracts` imports it. Cave revalidates every limit, the + * content signature, and the ownership binding server-side. + * + * This module is import-pure: no discovery, credential, filesystem, network, + * or daemon I/O happens at import time. + */ +declare const CAVE_ATTACHMENT_LIMITS: Readonly<{ + /** Maximum attachments in one upload request. */ + maxFiles: 10; + /** Maximum byte size of one attachment. */ + maxFileBytes: number; + /** Maximum summed byte size of one upload request. */ + maxRequestBytes: number; + /** Maximum filename length in UTF-16 code units. */ + maxFilenameCharacters: 128; + /** Maximum canonical identifier length for attachment/credential IDs. */ + maxReferenceCharacters: 64; +}>; +/** + * The approved content-type allowlist. SVG, archive, and executable types + * are forbidden by the issue's non-goals and are not present. + */ +declare const CAVE_ATTACHMENT_CONTENT_TYPES: readonly ["image/png", "image/jpeg", "image/gif", "image/webp", "application/pdf", "text/plain"]; +type CaveAttachmentContentType = (typeof CAVE_ATTACHMENT_CONTENT_TYPES)[number]; +declare class CaveAttachmentSchemaError extends TypeError { + readonly field: string; + constructor(field: string); +} +/** + * Signature-sniff the declared content type from the leading bytes. Returns + * the matched allowlisted type, `'text/plain'` when the bytes decode as + * UTF-8 text without binary markers, or `undefined` when nothing matches. + * A declared binary type whose bytes do not carry its signature is never + * accepted. + */ +declare function sniffCaveAttachmentContentType(content: Uint8Array): CaveAttachmentContentType | undefined; +interface CaveAttachmentContent { + readonly filename: string; + readonly contentType: CaveAttachmentContentType; + readonly content: Uint8Array; + readonly symlink?: false; +} +interface CaveAttachmentDescriptor { + readonly filename: string; + readonly contentType: CaveAttachmentContentType; + readonly sizeBytes: number; +} +interface CaveAttachmentBinding { + readonly conversationId: string; + readonly uploaderCredentialId: string; + readonly attachments: readonly CaveAttachmentDescriptor[]; + readonly totalBytes: number; +} +interface CaveAttachmentUploadRequest { + readonly operationId: string; + readonly confirmed: true; + readonly conversationId: string; + readonly uploaderCredentialId: string; + readonly attachments: readonly CaveAttachmentContent[]; +} +interface CaveAttachmentDownloadRequest { + readonly operationId: string; + readonly confirmed: true; + readonly conversationId: string; + readonly attachmentId: string; + /** Optional ceiling; the parser defaults it to `maxFileBytes`. */ + readonly maxBytes?: number; +} +/** + * The canonical attachment record: metadata bound to its conversation and + * uploader credential. There is no byte field on this type by construction — + * attachment bytes never enter canonical conversation JSON. + */ +interface CaveAttachmentRecord { + readonly attachmentId: string; + readonly conversationId: string; + readonly uploaderCredentialId: string; + readonly filename: string; + readonly contentType: CaveAttachmentContentType; + readonly sizeBytes: number; + readonly digestSha256: string; +} +/** + * Bind validated attachments to their conversation and uploader credential + * atomically: every input is validated before any descriptor is produced, + * so a rejection leaves no partial binding. The binding is metadata-only. + */ +declare function bindCaveAttachments(conversationId: unknown, uploaderCredentialId: unknown, attachments: readonly unknown[]): CaveAttachmentBinding; +/** + * Parse and fully validate one attachment upload request. Validation is + * fail-closed and total: any malformed field rejects the whole request, and + * the caller performs zero transport work on rejection. + */ +declare function parseCaveAttachmentUploadRequest(value: unknown): CaveAttachmentUploadRequest; +/** + * Parse one bounded attachment download request. The byte ceiling is + * mandatory in effect: when omitted it defaults to `maxFileBytes`, and a + * larger value is rejected. + */ +declare function parseCaveAttachmentDownloadRequest(value: unknown): CaveAttachmentDownloadRequest; +/** + * Parse a canonical attachment record from a transport response. Exact keys: + * a record carrying a `content` (or any unknown) field is rejected, so bytes + * cannot re-enter canonical state through the record type. + */ +declare function parseCaveAttachmentRecord(value: unknown): CaveAttachmentRecord; + +interface CaveContractCursor { + current: string; + hasMore: boolean; + next: string; +} +interface CaveContractIdentity { + displayName: string; + id: string; + kind: string; +} +interface CaveContractRevision { + token: string; + updatedAt: string; +} +interface CaveContractOperation { + families: readonly string[]; + id: string; + ingress: string; + method: string; + path: string; + scope: string | null; +} +interface CaveContractPublicRoute { + method: string; + path: string; +} +interface CaveContractEnvelopeMetadata { + apiVersion: string; + capabilities: readonly string[]; + minimumClientVersion: string; + operations: readonly string[]; + requestId?: string; +} +interface CaveContractHealthData { + instanceId: string; + pairingRequired: boolean; + releaseVersion: string; +} +interface CaveContractPairingStatusData { + expiresAt: number; + id: string; + status: string; +} +interface CaveContractPairingCreatedData { + expiresAt: number; + requestId: string; + secret: string; +} +interface CaveContractPairingExchangeData { + bearer: string; + credential: { + appName: string; + createdAt: number; + id: string; + installationId: string; + lastUsedAt: number | null; + revocationReason: string | null; + revokedAt: number | null; + scopes: readonly string[]; + }; +} +interface CaveContractFixture { + contract: { + apiVersion: string; + capabilities: readonly string[]; + discovery: { + fileName: string; + mode: string; + version: number; + }; + errorCodes: readonly string[]; + identityKinds: readonly string[]; + limits: { + cursorCharacters: number; + declarationIdCharacters: number; + defaultPageSize: number; + errorDetailEntries: number; + errorDetailValueCharacters: number; + errorMessageCharacters: number; + idempotencyKeyCharacters: number; + instanceIdCharacters: number; + maxPageSize: number; + releaseVersionCharacters: number; + requestIdCharacters: number; + revisionTokenCharacters: number; + }; + minimumClientVersion: string; + operations: readonly CaveContractOperation[]; + pairingRequired: boolean; + pairingScopes: readonly string[]; + pairingSecretHeader: string; + publicRoutes: readonly CaveContractPublicRoute[]; + }; + examples: { + cursor: CaveContractCursor; + discoveryRecord: { + endpoint: string; + nonce: string; + pid: number; + startedAt: string; + version: number; + }; + errorEnvelope: CaveContractEnvelopeMetadata & { + error: { + code: string; + details: Record; + message: string; + retryable: boolean; + }; + requestId: string; + }; + health: CaveContractHealthData; + healthEnvelope: CaveContractEnvelopeMetadata & { + data: CaveContractHealthData; + }; + identity: CaveContractIdentity; + pairingCreatedEnvelope: CaveContractEnvelopeMetadata & { + data: CaveContractPairingCreatedData; + }; + pairingExchangeEnvelope: CaveContractEnvelopeMetadata & { + data: CaveContractPairingExchangeData; + }; + pairingStatusEnvelope: CaveContractEnvelopeMetadata & { + data: CaveContractPairingStatusData; + }; + revision: CaveContractRevision; + status: { + status: 'ok'; + }; + successEnvelope: CaveContractEnvelopeMetadata & { + cursor: CaveContractCursor; + data: { + status: 'ok'; + }; + identity: CaveContractIdentity; + requestId: string; + revision: CaveContractRevision; + }; + }; +} +type JsonObject = Record; +declare function digestCaveContractFixture(value: string | Uint8Array): string; +declare function verifyCaveContractFixtureDigest(value: string | Uint8Array, expectedDigest: string): string; +declare function parseCaveContractFixture(value: string | Uint8Array | JsonObject): CaveContractFixture; +declare function parseVerifiedCaveContractFixture(value: string | Uint8Array, expectedDigest: string): CaveContractFixture; + +/** + * Privileged authority capabilities for the attachment, rich-content, + * attention, task-handoff, and GitHub action tiers. + * + * Every privileged action class is gated by a capability resolution derived + * from the authoritative Cave contract fixture this SDK vendors: an action + * class is actionable only when the contract declares at least one operation + * carrying the required scope. The pinned fixture (Cave `4adc97b1`) declares + * the privileged scope names for pairing (`attachments:write`, `tasks:write`, + * `github:write`, `chat:write`, `conversations:write`) but declares no + * operation that uses them, so every privileged resolution is `undeclared` + * today and the client reports `unsupported_operation` before any transport + * dispatch. Nothing here invents routes, capability families, or scope names: + * scope identifiers come from the fixture's pairing-scope list, and declared + * operations come from the fixture's operation table. + * + * Resolutions are computed per call from the consulted contract data and + * returned as frozen descriptors. No capability object is cached across + * grants: Cave remains the sole authority for grants, confirmation + * revalidation, idempotency, audit, and domain mutation. + * + * This module is import-pure: no discovery, credential, filesystem, network, + * or daemon I/O happens at import time. + */ +type CavePrivilegedActionClass = 'attachment-transfer' | 'rich-content' | 'attention-response' | 'task-handoff' | 'github-action'; +declare const CAVE_PRIVILEGED_ACTION_CLASSES: readonly ["attachment-transfer", "rich-content", "attention-response", "task-handoff", "github-action"]; +interface CavePrivilegedActionRequirement { + readonly actionClass: CavePrivilegedActionClass; + /** Drawn only from the fixture-declared pairing scope vocabulary. */ + readonly requiredScope: CavePairingScope; + /** Every privileged action requires a direct, explicit confirmation. */ + readonly requiresConfirmation: true; + /** Idempotency is keyed by the caller-supplied 36-character operation UUID. */ + readonly idempotencyKey: 'operation-uuid'; +} +/** + * The SDK-declared requirement mapping. Scope identifiers are the pairing + * scopes the authoritative fixture declares; the authoritative grant mapping + * is Cave's and is revalidated server-side regardless of these values. + */ +declare const CAVE_PRIVILEGED_ACTION_REQUIREMENTS: Readonly>; +interface CaveDeclaredOperationRef { + readonly id: string; + readonly method: string; + readonly path: string; + readonly scope: string | null; +} +type CaveCapabilityStatus = 'declared' | 'undeclared'; +interface CaveCapabilityResolution { + readonly actionClass: CavePrivilegedActionClass; + readonly status: CaveCapabilityStatus; + readonly requirement: CavePrivilegedActionRequirement; + /** + * The capability families the consulted contract declares. The pinned + * fixture declares none of the privileged families. + */ + readonly declaredCapabilities: readonly string[]; + /** + * The operations the consulted contract declares with the required scope. + * Empty for every privileged class under the pinned fixture. + */ + readonly declaredOperations: readonly CaveDeclaredOperationRef[]; +} +interface CaveCapabilityRegistry { + resolve(actionClass: CavePrivilegedActionClass): CaveCapabilityResolution; +} +interface CaveCapabilityContractSource { + readonly capabilities: readonly string[]; + readonly operations: readonly CaveContractOperation[]; +} +/** + * Build a capability registry from a parsed (preferably digest-verified) + * Client v1 contract fixture. Resolution consults the operation table on + * every call: an action class is `declared` only when the contract declares + * at least one operation carrying the required scope. + */ +declare function createCaveCapabilityRegistry(contract: CaveCapabilityContractSource): CaveCapabilityRegistry; +/** + * The default capability source: the operation table of the authoritative + * fixture pinned at Cave `4adc97b1` (digest `b2694cd1…`). Tests assert this + * snapshot matches the vendored fixture exactly, so a fixture re-import + * forces a reviewed update here. Under this contract every privileged action + * class resolves `undeclared`. + */ +declare const CAVE_DEFAULT_CAPABILITY_CONTRACT: CaveCapabilityContractSource; +/** + * The default registry every `CaveClient` uses when no explicit registry is + * supplied. Under the pinned fixture all privileged action classes resolve + * `undeclared`. + */ +declare function createDefaultCaveCapabilityRegistry(): CaveCapabilityRegistry; +/** + * A privileged action carries a direct, explicit confirmation: exactly one + * `confirmed` field whose value is the literal `true`. Anything else — a + * missing field, `false`, a string, a truthy object — is a configuration + * error raised before any capability or transport work. + */ +declare function parsePrivilegedConfirmation(value: unknown): true; +/** + * Privileged actions key idempotency with the same Client v1 operation UUID + * contract as conversational control: exactly 36 characters, RFC-compatible, + * normalized to lowercase. + */ +declare function validatePrivilegedOperationId(value: unknown): string; + +/** + * Attention responses and task handoffs for the privileged authority tier. + * + * The five handoff states — proposed, pending, completed, rejected, failed — + * are kept strictly distinct: a handoff moves through the declared + * transition map only, and terminal states accept no further transitions. + * Attention responses carry a bounded note at most; no free-form payload + * flows through this surface. + * + * Upstream-contract gap (stated, not invented): the authoritative Cave + * fixture pinned at `4adc97b1` declares the `conversations:write` and + * `tasks:write` pairing scopes but no attention or task operations and no + * such capability families, so no transport binding or route path ships; + * every call reports `unsupported_operation` until the producer contract + * lands and `pnpm sync:contracts` imports it. The transition map below is + * the SDK-owned request model; Cave owns the authoritative state machine + * and revalidates every transition server-side. + * + * This module is import-pure: no discovery, credential, filesystem, network, + * or daemon I/O happens at import time. + */ +declare const CAVE_TASK_HANDOFF_STATES: readonly ["proposed", "pending", "completed", "rejected", "failed"]; +type CaveTaskHandoffState = (typeof CAVE_TASK_HANDOFF_STATES)[number]; +/** + * The declared transition map. Every state is distinct; `completed`, + * `rejected`, and `failed` are terminal. + */ +declare const CAVE_TASK_HANDOFF_TRANSITIONS: Readonly>; +declare const CAVE_ATTENTION_RESPONSE_KINDS: readonly ["acknowledge", "decline"]; +type CaveAttentionResponseKind = (typeof CAVE_ATTENTION_RESPONSE_KINDS)[number]; +interface CaveTaskHandoffRequest { + readonly operationId: string; + readonly confirmed: true; + readonly conversationId: string; + readonly handoffId: string; + /** The state the handoff is known to be in. */ + readonly from: CaveTaskHandoffState; + /** The requested next state; must be a legal transition from `from`. */ + readonly to: CaveTaskHandoffState; +} +interface CaveAttentionResponseRequest { + readonly operationId: string; + readonly confirmed: true; + readonly conversationId: string; + readonly attentionId: string; + readonly response: CaveAttentionResponseKind; + readonly note?: string; +} +/** + * Whether the declared model permits a handoff transition. Terminal states + * transition to nothing; `proposed` only advances to `pending`. + */ +declare function isCaveTaskHandoffTransition(from: CaveTaskHandoffState, to: CaveTaskHandoffState): boolean; +/** + * Parse one task-handoff request. The transition must be legal under the + * declared map, and the five states remain strictly distinct: an unknown + * state or a skipped transition is a configuration error before any + * capability or transport work. + */ +declare function parseCaveTaskHandoffRequest(value: unknown): CaveTaskHandoffRequest; +/** + * Parse one attention-response request. The response kind is a closed union + * and the optional note is bounded; nothing else can be sent. + */ +declare function parseCaveAttentionResponseRequest(value: unknown): CaveAttentionResponseRequest; + +/** + * Explicitly confirmed GitHub actions for the privileged authority tier. + * + * The curated action union is deliberately EMPTY: the authoritative Cave + * fixture pinned at `4adc97b1` declares the `github:write` pairing scope but + * no GitHub operation and no GitHub capability family, and no reviewed + * producer contract has curated which GitHub actions exist. Naming concrete + * action kinds here would fabricate a curation nobody reviewed, so the union + * ships closed (`CaveGitHubActionKind` is `never`) and every request is + * rejected before any capability or transport work — fail closed by + * construction. When the upstream Cave producer contract curates the union, + * `CAVE_GITHUB_ACTION_KINDS` gains its reviewed members and this module's + * validation machinery (exact confirmation, operation-UUID idempotency, + * bounded input) applies unchanged. + * + * Cave revalidates confirmation, scope, repository/project grant, and input + * bounds regardless of client confirmation. + * + * This module is import-pure: no discovery, credential, filesystem, network, + * or daemon I/O happens at import time. + */ +/** + * The curated GitHub action union. Typed `readonly never[]` so that the + * kind type itself is uninhabitable — fail closed at the type level. Empty + * pending the upstream producer contract; never extended by client-side + * guesswork. + */ +declare const CAVE_GITHUB_ACTION_KINDS: readonly never[]; +type CaveGitHubActionKind = (typeof CAVE_GITHUB_ACTION_KINDS)[number]; +/** + * The shape every confirmed GitHub action request will take once the union + * is curated. With the union empty, `action` is uninhabitable and no request + * can be constructed — the type system itself refuses the mutation. + */ +interface CaveGitHubActionRequest { + readonly operationId: string; + readonly confirmed: true; + readonly conversationId: string; + readonly action: CaveGitHubActionKind; + /** Bounded string-valued action input; structure owned by the union member. */ + readonly input: Readonly>; +} +/** + * Parse one confirmed GitHub action request. Confirmation, the operation + * UUID, and the bounded shape are validated first; the action kind is then + * checked against the curated union, which is empty today, so every kind is + * rejected with the precise upstream gap — before any capability resolution + * or transport dispatch, guaranteeing zero domain mutation. + */ +declare function parseCaveGitHubActionRequest(value: unknown): CaveGitHubActionRequest; + interface CaveTransport { health(context?: OperationContext): Promise; pairingCreate?(request: CavePairingRequest, context?: OperationContext): Promise; @@ -565,6 +1060,20 @@ interface CaveTransport { getConversationOperation?(operationId: CaveConversationOperationId, context?: OperationContext): Promise; readConversationOperationEvents?(operationId: CaveConversationOperationId, page: CaveConversationEventPageRequest, context?: OperationContext): Promise; stopConversationOperation?(operationId: CaveConversationOperationId, context?: OperationContext): Promise; + /** + * Privileged authority is optional for every transport. The attachment, + * attention, task-handoff, and GitHub action operations are not declared + * by the authoritative Cave contract fixture this SDK vendors, so no + * transport binds them today; the client gates every privileged call on + * the capability registry first and reports `unsupported_operation` + * rather than inventing a route. Results are `unknown` at this trust + * boundary and are validated by the client. + */ + uploadAttachment?(request: CaveAttachmentUploadRequest, context?: OperationContext): Promise; + downloadAttachment?(request: CaveAttachmentDownloadRequest, context?: OperationContext): Promise; + respondToAttention?(request: CaveAttentionResponseRequest, context?: OperationContext): Promise; + requestTaskHandoff?(request: CaveTaskHandoffRequest, context?: OperationContext): Promise; + submitGitHubAction?(request: CaveGitHubActionRequest, context?: OperationContext): Promise; /** * The familiar operations are optional so that a transport written against * an older Cave still satisfies this interface. The client reports a missing @@ -610,6 +1119,12 @@ interface CaveManagedNativeCredentialCustody { } interface CaveClientOptionsBase { operation?: OperationDefaults; + /** + * Capability registry for the privileged authority tiers. Defaults to the + * registry derived from the pinned contract fixture, under which every + * privileged action class resolves `undeclared`. + */ + capabilities?: CaveCapabilityRegistry; } interface CaveClientOptionsWithoutCredentials extends CaveClientOptionsBase { transport: CaveTransport; @@ -735,14 +1250,53 @@ declare class CaveClient { * once and never retries it after ambiguous transport completion. */ stopConversationOperation(operationId: string, options?: OperationOptions): Promise; + /** + * Bounded attachment upload. The request is validated fail closed (file + * count, size, request size, MIME/signature agreement, filename, symlink, + * and the atomic uploader-credential-plus-conversation binding) before any + * capability or transport work, so a validation rejection performs zero + * domain mutation. Under the pinned contract the attachment capability is + * undeclared and this reports `unsupported_operation`. + */ + uploadAttachment(request: CaveAttachmentUploadRequest, options?: OperationOptions): Promise; + /** + * Bounded attachment download. The request carries the byte ceiling; the + * canonical record is the validated result metadata. Under the pinned + * contract this reports `unsupported_operation` before any transport work. + */ + downloadAttachment(request: CaveAttachmentDownloadRequest, options?: OperationOptions): Promise; + /** + * One attention response with a closed response-kind union and a bounded + * optional note. Validation failure performs zero domain mutation; under + * the pinned contract the attention capability is undeclared and this + * reports `unsupported_operation`. + */ + respondToAttention(request: CaveAttentionResponseRequest, options?: OperationOptions): Promise; + /** + * One task-handoff transition. The declared transition map keeps + * proposed, pending, completed, rejected, and failed strictly distinct; + * an illegal or skipped transition is a configuration error before any + * capability or transport work. Under the pinned contract this reports + * `unsupported_operation`. + */ + requestTaskHandoff(request: CaveTaskHandoffRequest, options?: OperationOptions): Promise; + /** + * One explicitly confirmed GitHub action. The curated action union is + * empty under the pinned contract, so every request is rejected during + * request parsing — before any capability or transport work — and zero + * domain mutation is possible. When the upstream producer contract + * curates the union, the confirmed request flows through the capability + * gate to Cave, which revalidates confirmation, scope, grant, and bounds. + */ + submitGitHubAction(request: CaveGitHubActionRequest, options?: OperationOptions): Promise; } declare function createCaveClient(options: CaveClientOptions): CaveClient; -export { type CaveFamiliarContract as $, type CaveConversationMessage as A, type CaveConversationOperation as B, type CavePairingRequest as C, type CaveConversationOperationId as D, type CaveConversationOperationKind as E, type CaveConversationOperationState as F, type CaveConversationOriginatingScope as G, type CaveConversationReconcileReason as H, type CaveConversationStreamOptions as I, type CaveConversationTranslatedPage as J, type CaveCreateConversationRequest as K, type CaveCreateConversationResult as L, type CaveCredentialAccess as M, type CaveCredentialBinding as N, type CaveCredentialDisconnectedReason as O, type CaveCredentialMetadata as P, type CaveCredentialPersistingTransport as Q, type CaveCredentialStatus as R, type CaveExecutionAttempt as S, type CaveExecutionBackfill as T, type CaveExecutionCoverage as U, type CaveExecutionSlice as V, type CaveExecutionWindow as W, type CaveFamiliar as X, type CaveFamiliarAnalytics as Y, type CaveFamiliarAnalyticsOptions as Z, type CaveFamiliarAnalyticsResponse as _, CaveClient as a, type CaveFamiliarContractResponse as a0, type CaveFamiliarProperty as a1, type CaveFamiliarWire as a2, type CaveFamiliarsResponse as a3, type CaveHealth as a4, type CaveHealthData as a5, type CaveHealthResponse as a6, type CaveManagedCredentialStatusResult as a7, type CaveManagedCredentialTransport as a8, type CaveManagedForgetCredentialResult as a9, type CaveManagedNativeCredentialCustody as aa, type CaveManagedPairingCreated as ab, type CaveManagedPairingExchange as ac, type CavePairingCreated as ad, type CavePairingExchange as ae, type CavePairingScope as af, CavePairingSession as ag, type CavePairingState as ah, type CavePairingStatus as ai, type CaveProject as aj, type CavePropertyCoverage as ak, type CaveRetryConversationTurnRequest as al, type CaveSendConversationMessageRequest as am, type CaveSendConversationMessageResult as an, type CaveTransport as ao, caveConversationReconcileReason as ap, createCaveClient as aq, createConversationEventTranslator as ar, isCaveClientError as as, normalizeCaveError as at, validateConversationEventCursor as au, CAVE_ANALYTICS_WINDOWS as b, CAVE_CONVERSATION_EVENT_TYPES as c, CAVE_CONVERSATION_OPERATION_STATES as d, CAVE_CONVERSATION_ORIGINATING_SCOPES as e, CAVE_CONVERSATION_RECONCILE_REASONS as f, CAVE_CONVERSATION_TERMINAL_STATES as g, CAVE_FAMILIAR_PROPERTIES as h, CAVE_PAIRING_SCOPES as i, CAVE_PAIRING_STATUSES as j, type CaveAnalyticsWindowKey as k, type CaveAuthorityBinding as l, type CaveAuthorityBoundPairingExchange as m, type CaveCanonicalFamiliar as n, CaveClientError as o, type CaveClientOptions as p, type CaveContractFile as q, type CaveContractReport as r, type CaveContractViolation as s, type CaveConversation as t, type CaveConversationEvent as u, type CaveConversationEventBase as v, type CaveConversationEventPage as w, type CaveConversationEventPageRequest as x, type CaveConversationEventTranslator as y, type CaveConversationEventType as z }; +export { type CaveContractViolation as $, CaveAttachmentSchemaError as A, type CaveAttachmentUploadRequest as B, type CavePairingRequest as C, type CaveAttentionResponseKind as D, type CaveAttentionResponseRequest as E, type CaveAuthorityBinding as F, type CaveAuthorityBoundPairingExchange as G, type CaveCanonicalFamiliar as H, type CaveCapabilityContractSource as I, type CaveCapabilityRegistry as J, type CaveCapabilityResolution as K, type CaveCapabilityStatus as L, CaveClientError as M, type CaveClientOptions as N, type CaveContractCursor as O, type CaveContractEnvelopeMetadata as P, type CaveContractFile as Q, type CaveContractFixture as R, type CaveContractHealthData as S, type CaveContractIdentity as T, type CaveContractOperation as U, type CaveContractPairingCreatedData as V, type CaveContractPairingExchangeData as W, type CaveContractPairingStatusData as X, type CaveContractPublicRoute as Y, type CaveContractReport as Z, type CaveContractRevision as _, CaveClient as a, type CaveTaskHandoffRequest as a$, type CaveConversation as a0, type CaveConversationEvent as a1, type CaveConversationEventBase as a2, type CaveConversationEventPage as a3, type CaveConversationEventPageRequest as a4, type CaveConversationEventTranslator as a5, type CaveConversationEventType as a6, type CaveConversationMessage as a7, type CaveConversationOperation as a8, type CaveConversationOperationId as a9, type CaveFamiliarProperty as aA, type CaveFamiliarWire as aB, type CaveFamiliarsResponse as aC, type CaveGitHubActionKind as aD, type CaveGitHubActionRequest as aE, type CaveHealth as aF, type CaveHealthData as aG, type CaveHealthResponse as aH, type CaveManagedCredentialStatusResult as aI, type CaveManagedCredentialTransport as aJ, type CaveManagedForgetCredentialResult as aK, type CaveManagedNativeCredentialCustody as aL, type CaveManagedPairingCreated as aM, type CaveManagedPairingExchange as aN, type CavePairingCreated as aO, type CavePairingExchange as aP, type CavePairingScope as aQ, CavePairingSession as aR, type CavePairingState as aS, type CavePairingStatus as aT, type CavePrivilegedActionClass as aU, type CavePrivilegedActionRequirement as aV, type CaveProject as aW, type CavePropertyCoverage as aX, type CaveRetryConversationTurnRequest as aY, type CaveSendConversationMessageRequest as aZ, type CaveSendConversationMessageResult as a_, type CaveConversationOperationKind as aa, type CaveConversationOperationState as ab, type CaveConversationOriginatingScope as ac, type CaveConversationReconcileReason as ad, type CaveConversationStreamOptions as ae, type CaveConversationTranslatedPage as af, type CaveCreateConversationRequest as ag, type CaveCreateConversationResult as ah, type CaveCredentialAccess as ai, type CaveCredentialBinding as aj, type CaveCredentialDisconnectedReason as ak, type CaveCredentialMetadata as al, type CaveCredentialPersistingTransport as am, type CaveCredentialStatus as an, type CaveDeclaredOperationRef as ao, type CaveExecutionAttempt as ap, type CaveExecutionBackfill as aq, type CaveExecutionCoverage as ar, type CaveExecutionSlice as as, type CaveExecutionWindow as at, type CaveFamiliar as au, type CaveFamiliarAnalytics as av, type CaveFamiliarAnalyticsOptions as aw, type CaveFamiliarAnalyticsResponse as ax, type CaveFamiliarContract as ay, type CaveFamiliarContractResponse as az, CAVE_ANALYTICS_WINDOWS as b, type CaveTaskHandoffState as b0, type CaveTransport as b1, bindCaveAttachments as b2, caveConversationReconcileReason as b3, createCaveCapabilityRegistry as b4, createCaveClient as b5, createConversationEventTranslator as b6, createDefaultCaveCapabilityRegistry as b7, digestCaveContractFixture as b8, isCaveClientError as b9, isCaveTaskHandoffTransition as ba, normalizeCaveError as bb, parseCaveAttachmentDownloadRequest as bc, parseCaveAttachmentRecord as bd, parseCaveAttachmentUploadRequest as be, parseCaveAttentionResponseRequest as bf, parseCaveContractFixture as bg, parseCaveGitHubActionRequest as bh, parseCaveTaskHandoffRequest as bi, parsePrivilegedConfirmation as bj, parseVerifiedCaveContractFixture as bk, sniffCaveAttachmentContentType as bl, validateConversationEventCursor as bm, validatePrivilegedOperationId as bn, verifyCaveContractFixtureDigest as bo, CAVE_ATTACHMENT_CONTENT_TYPES as c, CAVE_ATTACHMENT_LIMITS as d, CAVE_ATTENTION_RESPONSE_KINDS as e, CAVE_CONVERSATION_EVENT_TYPES as f, CAVE_CONVERSATION_OPERATION_STATES as g, CAVE_CONVERSATION_ORIGINATING_SCOPES as h, CAVE_CONVERSATION_RECONCILE_REASONS as i, CAVE_CONVERSATION_TERMINAL_STATES as j, CAVE_DEFAULT_CAPABILITY_CONTRACT as k, CAVE_FAMILIAR_PROPERTIES as l, CAVE_GITHUB_ACTION_KINDS as m, CAVE_PAIRING_SCOPES as n, CAVE_PAIRING_STATUSES as o, CAVE_PRIVILEGED_ACTION_CLASSES as p, CAVE_PRIVILEGED_ACTION_REQUIREMENTS as q, CAVE_TASK_HANDOFF_STATES as r, CAVE_TASK_HANDOFF_TRANSITIONS as s, type CaveAnalyticsWindowKey as t, type CaveAttachmentBinding as u, type CaveAttachmentContent as v, type CaveAttachmentContentType as w, type CaveAttachmentDescriptor as x, type CaveAttachmentDownloadRequest as y, type CaveAttachmentRecord as z }; // Entrypoint: . // Declaration: dist/index.d.ts -import { C as CavePairingRequest, a as CaveClient } from './client-ootQTXcj.js'; -export { b as CAVE_ANALYTICS_WINDOWS, c as CAVE_CONVERSATION_EVENT_TYPES, d as CAVE_CONVERSATION_OPERATION_STATES, e as CAVE_CONVERSATION_ORIGINATING_SCOPES, f as CAVE_CONVERSATION_RECONCILE_REASONS, g as CAVE_CONVERSATION_TERMINAL_STATES, h as CAVE_FAMILIAR_PROPERTIES, i as CAVE_PAIRING_SCOPES, j as CAVE_PAIRING_STATUSES, k as CaveAnalyticsWindowKey, l as CaveAuthorityBinding, m as CaveAuthorityBoundPairingExchange, n as CaveCanonicalFamiliar, o as CaveClientError, p as CaveClientOptions, q as CaveContractFile, r as CaveContractReport, s as CaveContractViolation, t as CaveConversation, u as CaveConversationEvent, v as CaveConversationEventBase, w as CaveConversationEventPage, x as CaveConversationEventPageRequest, y as CaveConversationEventTranslator, z as CaveConversationEventType, A as CaveConversationMessage, B as CaveConversationOperation, D as CaveConversationOperationId, E as CaveConversationOperationKind, F as CaveConversationOperationState, G as CaveConversationOriginatingScope, H as CaveConversationReconcileReason, I as CaveConversationStreamOptions, J as CaveConversationTranslatedPage, K as CaveCreateConversationRequest, L as CaveCreateConversationResult, M as CaveCredentialAccess, N as CaveCredentialBinding, O as CaveCredentialDisconnectedReason, P as CaveCredentialMetadata, Q as CaveCredentialPersistingTransport, R as CaveCredentialStatus, S as CaveExecutionAttempt, T as CaveExecutionBackfill, U as CaveExecutionCoverage, V as CaveExecutionSlice, W as CaveExecutionWindow, X as CaveFamiliar, Y as CaveFamiliarAnalytics, Z as CaveFamiliarAnalyticsOptions, _ as CaveFamiliarAnalyticsResponse, $ as CaveFamiliarContract, a0 as CaveFamiliarContractResponse, a1 as CaveFamiliarProperty, a2 as CaveFamiliarWire, a3 as CaveFamiliarsResponse, a4 as CaveHealth, a5 as CaveHealthData, a6 as CaveHealthResponse, a7 as CaveManagedCredentialStatusResult, a8 as CaveManagedCredentialTransport, a9 as CaveManagedForgetCredentialResult, aa as CaveManagedNativeCredentialCustody, ab as CaveManagedPairingCreated, ac as CaveManagedPairingExchange, ad as CavePairingCreated, ae as CavePairingExchange, af as CavePairingScope, ag as CavePairingSession, ah as CavePairingState, ai as CavePairingStatus, aj as CaveProject, ak as CavePropertyCoverage, al as CaveRetryConversationTurnRequest, am as CaveSendConversationMessageRequest, an as CaveSendConversationMessageResult, ao as CaveTransport, ap as caveConversationReconcileReason, aq as createCaveClient, ar as createConversationEventTranslator, as as isCaveClientError, at as normalizeCaveError, au as validateConversationEventCursor } from './client-ootQTXcj.js'; +import { C as CavePairingRequest, a as CaveClient } from './client-M2RrMRyI.js'; +export { b as CAVE_ANALYTICS_WINDOWS, c as CAVE_ATTACHMENT_CONTENT_TYPES, d as CAVE_ATTACHMENT_LIMITS, e as CAVE_ATTENTION_RESPONSE_KINDS, f as CAVE_CONVERSATION_EVENT_TYPES, g as CAVE_CONVERSATION_OPERATION_STATES, h as CAVE_CONVERSATION_ORIGINATING_SCOPES, i as CAVE_CONVERSATION_RECONCILE_REASONS, j as CAVE_CONVERSATION_TERMINAL_STATES, k as CAVE_DEFAULT_CAPABILITY_CONTRACT, l as CAVE_FAMILIAR_PROPERTIES, m as CAVE_GITHUB_ACTION_KINDS, n as CAVE_PAIRING_SCOPES, o as CAVE_PAIRING_STATUSES, p as CAVE_PRIVILEGED_ACTION_CLASSES, q as CAVE_PRIVILEGED_ACTION_REQUIREMENTS, r as CAVE_TASK_HANDOFF_STATES, s as CAVE_TASK_HANDOFF_TRANSITIONS, t as CaveAnalyticsWindowKey, u as CaveAttachmentBinding, v as CaveAttachmentContent, w as CaveAttachmentContentType, x as CaveAttachmentDescriptor, y as CaveAttachmentDownloadRequest, z as CaveAttachmentRecord, A as CaveAttachmentSchemaError, B as CaveAttachmentUploadRequest, D as CaveAttentionResponseKind, E as CaveAttentionResponseRequest, F as CaveAuthorityBinding, G as CaveAuthorityBoundPairingExchange, H as CaveCanonicalFamiliar, I as CaveCapabilityContractSource, J as CaveCapabilityRegistry, K as CaveCapabilityResolution, L as CaveCapabilityStatus, M as CaveClientError, N as CaveClientOptions, O as CaveContractCursor, P as CaveContractEnvelopeMetadata, Q as CaveContractFile, R as CaveContractFixture, S as CaveContractHealthData, T as CaveContractIdentity, U as CaveContractOperation, V as CaveContractPairingCreatedData, W as CaveContractPairingExchangeData, X as CaveContractPairingStatusData, Y as CaveContractPublicRoute, Z as CaveContractReport, _ as CaveContractRevision, $ as CaveContractViolation, a0 as CaveConversation, a1 as CaveConversationEvent, a2 as CaveConversationEventBase, a3 as CaveConversationEventPage, a4 as CaveConversationEventPageRequest, a5 as CaveConversationEventTranslator, a6 as CaveConversationEventType, a7 as CaveConversationMessage, a8 as CaveConversationOperation, a9 as CaveConversationOperationId, aa as CaveConversationOperationKind, ab as CaveConversationOperationState, ac as CaveConversationOriginatingScope, ad as CaveConversationReconcileReason, ae as CaveConversationStreamOptions, af as CaveConversationTranslatedPage, ag as CaveCreateConversationRequest, ah as CaveCreateConversationResult, ai as CaveCredentialAccess, aj as CaveCredentialBinding, ak as CaveCredentialDisconnectedReason, al as CaveCredentialMetadata, am as CaveCredentialPersistingTransport, an as CaveCredentialStatus, ao as CaveDeclaredOperationRef, ap as CaveExecutionAttempt, aq as CaveExecutionBackfill, ar as CaveExecutionCoverage, as as CaveExecutionSlice, at as CaveExecutionWindow, au as CaveFamiliar, av as CaveFamiliarAnalytics, aw as CaveFamiliarAnalyticsOptions, ax as CaveFamiliarAnalyticsResponse, ay as CaveFamiliarContract, az as CaveFamiliarContractResponse, aA as CaveFamiliarProperty, aB as CaveFamiliarWire, aC as CaveFamiliarsResponse, aD as CaveGitHubActionKind, aE as CaveGitHubActionRequest, aF as CaveHealth, aG as CaveHealthData, aH as CaveHealthResponse, aI as CaveManagedCredentialStatusResult, aJ as CaveManagedCredentialTransport, aK as CaveManagedForgetCredentialResult, aL as CaveManagedNativeCredentialCustody, aM as CaveManagedPairingCreated, aN as CaveManagedPairingExchange, aO as CavePairingCreated, aP as CavePairingExchange, aQ as CavePairingScope, aR as CavePairingSession, aS as CavePairingState, aT as CavePairingStatus, aU as CavePrivilegedActionClass, aV as CavePrivilegedActionRequirement, aW as CaveProject, aX as CavePropertyCoverage, aY as CaveRetryConversationTurnRequest, aZ as CaveSendConversationMessageRequest, a_ as CaveSendConversationMessageResult, a$ as CaveTaskHandoffRequest, b0 as CaveTaskHandoffState, b1 as CaveTransport, b2 as bindCaveAttachments, b3 as caveConversationReconcileReason, b4 as createCaveCapabilityRegistry, b5 as createCaveClient, b6 as createConversationEventTranslator, b7 as createDefaultCaveCapabilityRegistry, b8 as digestCaveContractFixture, b9 as isCaveClientError, ba as isCaveTaskHandoffTransition, bb as normalizeCaveError, bc as parseCaveAttachmentDownloadRequest, bd as parseCaveAttachmentRecord, be as parseCaveAttachmentUploadRequest, bf as parseCaveAttentionResponseRequest, bg as parseCaveContractFixture, bh as parseCaveGitHubActionRequest, bi as parseCaveTaskHandoffRequest, bj as parsePrivilegedConfirmation, bk as parseVerifiedCaveContractFixture, bl as sniffCaveAttachmentContentType, bm as validateConversationEventCursor, bn as validatePrivilegedOperationId, bo as verifyCaveContractFixtureDigest } from './client-M2RrMRyI.js'; import { OperationOptions, OperationContext, PageOptions, OperationDefaults, SecretStore, SecretStoreReference } from '@opencoven/sdk-core'; import '@opencoven/sdk-core/browser'; @@ -889,157 +1443,133 @@ interface CaveDiscoveredClientOptions { } declare function createDiscoveredCaveClient(options: CaveDiscoveredClientOptions): CaveClient; -interface CaveContractCursor { - current: string; - hasMore: boolean; - next: string; -} -interface CaveContractIdentity { - displayName: string; - id: string; - kind: string; -} -interface CaveContractRevision { - token: string; - updatedAt: string; -} -interface CaveContractOperation { - families: readonly string[]; - id: string; - ingress: string; - method: string; - path: string; - scope: string | null; -} -interface CaveContractPublicRoute { - method: string; - path: string; -} -interface CaveContractEnvelopeMetadata { - apiVersion: string; - capabilities: readonly string[]; - minimumClientVersion: string; - operations: readonly string[]; - requestId?: string; -} -interface CaveContractHealthData { - instanceId: string; - pairingRequired: boolean; - releaseVersion: string; -} -interface CaveContractPairingStatusData { - expiresAt: number; - id: string; - status: string; -} -interface CaveContractPairingCreatedData { - expiresAt: number; - requestId: string; - secret: string; -} -interface CaveContractPairingExchangeData { - bearer: string; - credential: { - appName: string; - createdAt: number; - id: string; - installationId: string; - lastUsedAt: number | null; - revocationReason: string | null; - revokedAt: number | null; - scopes: readonly string[]; - }; -} -interface CaveContractFixture { - contract: { - apiVersion: string; - capabilities: readonly string[]; - discovery: { - fileName: string; - mode: string; - version: number; - }; - errorCodes: readonly string[]; - identityKinds: readonly string[]; - limits: { - cursorCharacters: number; - declarationIdCharacters: number; - defaultPageSize: number; - errorDetailEntries: number; - errorDetailValueCharacters: number; - errorMessageCharacters: number; - idempotencyKeyCharacters: number; - instanceIdCharacters: number; - maxPageSize: number; - releaseVersionCharacters: number; - requestIdCharacters: number; - revisionTokenCharacters: number; - }; - minimumClientVersion: string; - operations: readonly CaveContractOperation[]; - pairingRequired: boolean; - pairingScopes: readonly string[]; - pairingSecretHeader: string; - publicRoutes: readonly CaveContractPublicRoute[]; - }; - examples: { - cursor: CaveContractCursor; - discoveryRecord: { - endpoint: string; - nonce: string; - pid: number; - startedAt: string; - version: number; - }; - errorEnvelope: CaveContractEnvelopeMetadata & { - error: { - code: string; - details: Record; - message: string; - retryable: boolean; - }; - requestId: string; - }; - health: CaveContractHealthData; - healthEnvelope: CaveContractEnvelopeMetadata & { - data: CaveContractHealthData; - }; - identity: CaveContractIdentity; - pairingCreatedEnvelope: CaveContractEnvelopeMetadata & { - data: CaveContractPairingCreatedData; - }; - pairingExchangeEnvelope: CaveContractEnvelopeMetadata & { - data: CaveContractPairingExchangeData; - }; - pairingStatusEnvelope: CaveContractEnvelopeMetadata & { - data: CaveContractPairingStatusData; - }; - revision: CaveContractRevision; - status: { - status: 'ok'; - }; - successEnvelope: CaveContractEnvelopeMetadata & { - cursor: CaveContractCursor; - data: { - status: 'ok'; - }; - identity: CaveContractIdentity; - requestId: string; - revision: CaveContractRevision; - }; - }; -} -type JsonObject = Record; -declare function digestCaveContractFixture(value: string | Uint8Array): string; -declare function verifyCaveContractFixtureDigest(value: string | Uint8Array, expectedDigest: string): string; -declare function parseCaveContractFixture(value: string | Uint8Array | JsonObject): CaveContractFixture; -declare function parseVerifiedCaveContractFixture(value: string | Uint8Array, expectedDigest: string): CaveContractFixture; +/** + * Passive rich content: a strict, non-executable AST for message payloads. + * + * The parser accepts only the closed node vocabulary below, with exact keys + * and bounded sizes. Raw HTML is never interpreted: markup-looking text is + * preserved byte for byte inside inert text nodes, there is no HTML node + * type, and no node carries event handlers or executable attributes. Link + * targets are restricted to `https:` and `mailto:` schemes, so no unsafe + * target can be produced from a parsed document. Unknown node types, + * unknown fields, oversized payloads, and over-deep nesting are rejected + * (fail closed). + * + * Upstream-contract gap (stated, not invented): the authoritative Cave + * fixture pinned at `4adc97b1` declares no rich-content capability family + * and no route that would carry rich payloads. This module defines the + * SDK-side consumer half — the parsing and validation model — so that the + * deferred producer contract can only ever deliver inert content through + * it. The node vocabulary is SDK-owned and closed; extending it requires a + * reviewed change here, not data from the wire. + * + * This module is import-pure: no discovery, credential, filesystem, network, + * or daemon I/O happens at import time. + */ +declare const CAVE_RICH_CONTENT_LIMITS: Readonly<{ + /** Maximum total nodes in one document. */ + maxNodes: 512; + /** Maximum nesting depth (the document itself is depth 0). */ + maxDepth: 24; + /** Maximum characters in one text or code node. */ + maxTextCharacters: 8192; + /** Maximum characters across all text and code nodes of one document. */ + maxTotalCharacters: 65536; + /** Maximum characters in one link target. */ + maxUrlCharacters: 2048; + /** Maximum characters in one code language tag. */ + maxLanguageCharacters: 32; + /** Maximum characters in one link title. */ + maxTitleCharacters: 256; +}>; +type CaveRichContentUrlScheme = 'https' | 'mailto'; +declare const CAVE_RICH_CONTENT_URL_SCHEMES: readonly CaveRichContentUrlScheme[]; +interface CaveRichContentText { + readonly type: 'text'; + readonly text: string; +} +interface CaveRichContentCode { + readonly type: 'code'; + readonly text: string; +} +interface CaveRichContentLineBreak { + readonly type: 'lineBreak'; +} +interface CaveRichContentLink { + readonly type: 'link'; + /** Always an `https:` or `mailto:` target; everything else is rejected. */ + readonly href: string; + readonly title?: string; + readonly children: readonly (CaveRichContentText | CaveRichContentCode | CaveRichContentLineBreak)[]; +} +type CaveRichContentInline = CaveRichContentText | CaveRichContentCode | CaveRichContentLink | CaveRichContentLineBreak; +interface CaveRichContentParagraph { + readonly type: 'paragraph'; + readonly children: readonly CaveRichContentInline[]; +} +interface CaveRichContentHeading { + readonly type: 'heading'; + readonly level: 1 | 2 | 3 | 4 | 5 | 6; + readonly children: readonly CaveRichContentInline[]; +} +interface CaveRichContentCodeBlock { + readonly type: 'codeBlock'; + readonly language?: string; + readonly text: string; +} +interface CaveRichContentQuote { + readonly type: 'blockquote'; + readonly children: readonly CaveRichContentBlock[]; +} +interface CaveRichContentList { + readonly type: 'list'; + readonly ordered: boolean; + readonly children: readonly CaveRichContentListItem[]; +} +interface CaveRichContentListItem { + readonly type: 'listItem'; + readonly children: readonly CaveRichContentBlock[]; +} +type CaveRichContentBlock = CaveRichContentParagraph | CaveRichContentHeading | CaveRichContentCodeBlock | CaveRichContentQuote | CaveRichContentList; +interface CaveRichContentDocument { + readonly type: 'doc'; + readonly children: readonly CaveRichContentBlock[]; +} +declare class CaveRichContentError extends TypeError { + readonly field: string; + constructor(field: string); +} +/** + * Link targets must carry an `https:` or `mailto:` scheme. Every other + * scheme — `javascript:`, `data:`, `file:`, `vbscript:`, scheme-less + * relative targets — is rejected, so a parsed document can never carry an + * unsafe target. + */ +declare function parseCaveRichContentUrl(value: unknown, field: string): string; +/** + * Parse an untrusted rich-content payload into the strict inert AST. The + * parser is total over its closed vocabulary: unknown node types, unknown + * fields, executable markup declarations, unsafe link targets, oversized + * payloads, and over-deep nesting are all rejected. + */ +declare function parseCaveRichContent(value: unknown): CaveRichContentDocument; +/** + * Serialize a parsed document. Because the input type can only be produced + * by `parseCaveRichContent`, the output is inert by construction: it + * contains only the declared node types, never markup or event handlers. + */ +declare function serializeCaveRichContent(document: CaveRichContentDocument): string; +/** + * Collect every link target of a parsed document. Every returned target has + * already passed the `https:`/`mailto:` allowlist during parsing. + */ +declare function collectCaveRichContentUrls(document: CaveRichContentDocument): string[]; declare const CAVE_CLIENT_VERSION: string; -export { CAVE_CLIENT_VERSION, CaveClient, type CaveContractCursor, type CaveContractEnvelopeMetadata, type CaveContractFixture, type CaveContractHealthData, type CaveContractIdentity, type CaveContractOperation, type CaveContractPairingCreatedData, type CaveContractPairingExchangeData, type CaveContractPairingStatusData, type CaveContractPublicRoute, type CaveContractRevision, type CaveDiscoveredClientOptions, type CaveDiscoveredEndpoint, type CaveDiscoveryDependencies, CaveDiscoveryError, type CaveDiscoveryErrorCode, type CaveDiscoveryFileHandle, type CaveDiscoveryPathIdentity, type CaveDiscoveryRecordIdentity, type CaveEndpointFreshness, type CaveManagedClientOptions, type CaveManagedNativeDiscardResult, type CaveManagedNativePairingCreated, type CaveManagedNativePairingExchange, type CaveManagedNativeResponse, type CaveManagedNativeTransport, CavePairingRequest, type CaveWindowsPathTrustResult, type CaveWindowsPathTrustValidator, type DiscoverCaveEndpointOptions, createDiscoveredCaveClient, createManagedCaveClient, digestCaveContractFixture, discoverCaveEndpoint, isCaveDiscoveryError, parseCaveContractFixture, parseVerifiedCaveContractFixture, verifyCaveContractFixtureDigest }; +export { CAVE_CLIENT_VERSION, CAVE_RICH_CONTENT_LIMITS, CAVE_RICH_CONTENT_URL_SCHEMES, CaveClient, type CaveDiscoveredClientOptions, type CaveDiscoveredEndpoint, type CaveDiscoveryDependencies, CaveDiscoveryError, type CaveDiscoveryErrorCode, type CaveDiscoveryFileHandle, type CaveDiscoveryPathIdentity, type CaveDiscoveryRecordIdentity, type CaveEndpointFreshness, type CaveManagedClientOptions, type CaveManagedNativeDiscardResult, type CaveManagedNativePairingCreated, type CaveManagedNativePairingExchange, type CaveManagedNativeResponse, type CaveManagedNativeTransport, CavePairingRequest, type CaveRichContentBlock, type CaveRichContentDocument, CaveRichContentError, type CaveRichContentInline, type CaveRichContentUrlScheme, type CaveWindowsPathTrustResult, type CaveWindowsPathTrustValidator, type DiscoverCaveEndpointOptions, collectCaveRichContentUrls, createDiscoveredCaveClient, createManagedCaveClient, discoverCaveEndpoint, isCaveDiscoveryError, parseCaveRichContent, parseCaveRichContentUrl, serializeCaveRichContent }; // Entrypoint: ./managed -// Declaration: dist/client-ootQTXcj.d.ts +// Declaration: dist/client-M2RrMRyI.d.ts import { OperationObserver, OperationContext, PageOptions, OperationDefaults, SecretStore, SecretStoreReference, OperationOptions, Page, BoundedPageOptions, NormalizedError, CompatibilityAssessment } from '@opencoven/sdk-core/browser'; interface CaveCanonicalFamiliar { @@ -1578,6 +2108,501 @@ declare function createConversationEventTranslator(operationId: CaveConversation */ declare function caveConversationReconcileReason(error: unknown): CaveConversationReconcileReason | undefined; +/** + * Bounded attachment transfer for the privileged authority tier. + * + * This module owns the SDK half of the attachment contract: fail-closed + * preflight validation (file count, per-file size, total request size, + * declared MIME type versus signature, filename, traversal, symlink, and + * ownership binding) and the metadata-only records that bind an attachment + * to its uploader credential and conversation atomically. Attachment bytes + * exist only inside the in-flight upload request; they never enter the + * canonical attachment record, and therefore never enter canonical + * conversation JSON, browser storage, profile config, or diagnostic bundles. + * The SDK never hashes attachment bytes: the canonical byte digest is + * Cave's, computed server-side where the bytes land, and appears in records + * only as a validated string. + * + * Upstream-contract gap (stated, not invented): the authoritative Cave + * fixture pinned at `4adc97b1` declares the `attachments:write` pairing + * scope but no attachment operations and no attachment capability family, + * so no transport binding or route path ships; upload and download report + * `unsupported_operation` until the producer contract lands and + * `pnpm sync:contracts` imports it. Cave revalidates every limit, the + * content signature, and the ownership binding server-side. + * + * This module is import-pure: no discovery, credential, filesystem, network, + * or daemon I/O happens at import time. + */ +declare const CAVE_ATTACHMENT_LIMITS: Readonly<{ + /** Maximum attachments in one upload request. */ + maxFiles: 10; + /** Maximum byte size of one attachment. */ + maxFileBytes: number; + /** Maximum summed byte size of one upload request. */ + maxRequestBytes: number; + /** Maximum filename length in UTF-16 code units. */ + maxFilenameCharacters: 128; + /** Maximum canonical identifier length for attachment/credential IDs. */ + maxReferenceCharacters: 64; +}>; +/** + * The approved content-type allowlist. SVG, archive, and executable types + * are forbidden by the issue's non-goals and are not present. + */ +declare const CAVE_ATTACHMENT_CONTENT_TYPES: readonly ["image/png", "image/jpeg", "image/gif", "image/webp", "application/pdf", "text/plain"]; +type CaveAttachmentContentType = (typeof CAVE_ATTACHMENT_CONTENT_TYPES)[number]; +declare class CaveAttachmentSchemaError extends TypeError { + readonly field: string; + constructor(field: string); +} +/** + * Signature-sniff the declared content type from the leading bytes. Returns + * the matched allowlisted type, `'text/plain'` when the bytes decode as + * UTF-8 text without binary markers, or `undefined` when nothing matches. + * A declared binary type whose bytes do not carry its signature is never + * accepted. + */ +declare function sniffCaveAttachmentContentType(content: Uint8Array): CaveAttachmentContentType | undefined; +interface CaveAttachmentContent { + readonly filename: string; + readonly contentType: CaveAttachmentContentType; + readonly content: Uint8Array; + readonly symlink?: false; +} +interface CaveAttachmentDescriptor { + readonly filename: string; + readonly contentType: CaveAttachmentContentType; + readonly sizeBytes: number; +} +interface CaveAttachmentBinding { + readonly conversationId: string; + readonly uploaderCredentialId: string; + readonly attachments: readonly CaveAttachmentDescriptor[]; + readonly totalBytes: number; +} +interface CaveAttachmentUploadRequest { + readonly operationId: string; + readonly confirmed: true; + readonly conversationId: string; + readonly uploaderCredentialId: string; + readonly attachments: readonly CaveAttachmentContent[]; +} +interface CaveAttachmentDownloadRequest { + readonly operationId: string; + readonly confirmed: true; + readonly conversationId: string; + readonly attachmentId: string; + /** Optional ceiling; the parser defaults it to `maxFileBytes`. */ + readonly maxBytes?: number; +} +/** + * The canonical attachment record: metadata bound to its conversation and + * uploader credential. There is no byte field on this type by construction — + * attachment bytes never enter canonical conversation JSON. + */ +interface CaveAttachmentRecord { + readonly attachmentId: string; + readonly conversationId: string; + readonly uploaderCredentialId: string; + readonly filename: string; + readonly contentType: CaveAttachmentContentType; + readonly sizeBytes: number; + readonly digestSha256: string; +} +/** + * Bind validated attachments to their conversation and uploader credential + * atomically: every input is validated before any descriptor is produced, + * so a rejection leaves no partial binding. The binding is metadata-only. + */ +declare function bindCaveAttachments(conversationId: unknown, uploaderCredentialId: unknown, attachments: readonly unknown[]): CaveAttachmentBinding; +/** + * Parse and fully validate one attachment upload request. Validation is + * fail-closed and total: any malformed field rejects the whole request, and + * the caller performs zero transport work on rejection. + */ +declare function parseCaveAttachmentUploadRequest(value: unknown): CaveAttachmentUploadRequest; +/** + * Parse one bounded attachment download request. The byte ceiling is + * mandatory in effect: when omitted it defaults to `maxFileBytes`, and a + * larger value is rejected. + */ +declare function parseCaveAttachmentDownloadRequest(value: unknown): CaveAttachmentDownloadRequest; +/** + * Parse a canonical attachment record from a transport response. Exact keys: + * a record carrying a `content` (or any unknown) field is rejected, so bytes + * cannot re-enter canonical state through the record type. + */ +declare function parseCaveAttachmentRecord(value: unknown): CaveAttachmentRecord; + +interface CaveContractCursor { + current: string; + hasMore: boolean; + next: string; +} +interface CaveContractIdentity { + displayName: string; + id: string; + kind: string; +} +interface CaveContractRevision { + token: string; + updatedAt: string; +} +interface CaveContractOperation { + families: readonly string[]; + id: string; + ingress: string; + method: string; + path: string; + scope: string | null; +} +interface CaveContractPublicRoute { + method: string; + path: string; +} +interface CaveContractEnvelopeMetadata { + apiVersion: string; + capabilities: readonly string[]; + minimumClientVersion: string; + operations: readonly string[]; + requestId?: string; +} +interface CaveContractHealthData { + instanceId: string; + pairingRequired: boolean; + releaseVersion: string; +} +interface CaveContractPairingStatusData { + expiresAt: number; + id: string; + status: string; +} +interface CaveContractPairingCreatedData { + expiresAt: number; + requestId: string; + secret: string; +} +interface CaveContractPairingExchangeData { + bearer: string; + credential: { + appName: string; + createdAt: number; + id: string; + installationId: string; + lastUsedAt: number | null; + revocationReason: string | null; + revokedAt: number | null; + scopes: readonly string[]; + }; +} +interface CaveContractFixture { + contract: { + apiVersion: string; + capabilities: readonly string[]; + discovery: { + fileName: string; + mode: string; + version: number; + }; + errorCodes: readonly string[]; + identityKinds: readonly string[]; + limits: { + cursorCharacters: number; + declarationIdCharacters: number; + defaultPageSize: number; + errorDetailEntries: number; + errorDetailValueCharacters: number; + errorMessageCharacters: number; + idempotencyKeyCharacters: number; + instanceIdCharacters: number; + maxPageSize: number; + releaseVersionCharacters: number; + requestIdCharacters: number; + revisionTokenCharacters: number; + }; + minimumClientVersion: string; + operations: readonly CaveContractOperation[]; + pairingRequired: boolean; + pairingScopes: readonly string[]; + pairingSecretHeader: string; + publicRoutes: readonly CaveContractPublicRoute[]; + }; + examples: { + cursor: CaveContractCursor; + discoveryRecord: { + endpoint: string; + nonce: string; + pid: number; + startedAt: string; + version: number; + }; + errorEnvelope: CaveContractEnvelopeMetadata & { + error: { + code: string; + details: Record; + message: string; + retryable: boolean; + }; + requestId: string; + }; + health: CaveContractHealthData; + healthEnvelope: CaveContractEnvelopeMetadata & { + data: CaveContractHealthData; + }; + identity: CaveContractIdentity; + pairingCreatedEnvelope: CaveContractEnvelopeMetadata & { + data: CaveContractPairingCreatedData; + }; + pairingExchangeEnvelope: CaveContractEnvelopeMetadata & { + data: CaveContractPairingExchangeData; + }; + pairingStatusEnvelope: CaveContractEnvelopeMetadata & { + data: CaveContractPairingStatusData; + }; + revision: CaveContractRevision; + status: { + status: 'ok'; + }; + successEnvelope: CaveContractEnvelopeMetadata & { + cursor: CaveContractCursor; + data: { + status: 'ok'; + }; + identity: CaveContractIdentity; + requestId: string; + revision: CaveContractRevision; + }; + }; +} +type JsonObject = Record; +declare function digestCaveContractFixture(value: string | Uint8Array): string; +declare function verifyCaveContractFixtureDigest(value: string | Uint8Array, expectedDigest: string): string; +declare function parseCaveContractFixture(value: string | Uint8Array | JsonObject): CaveContractFixture; +declare function parseVerifiedCaveContractFixture(value: string | Uint8Array, expectedDigest: string): CaveContractFixture; + +/** + * Privileged authority capabilities for the attachment, rich-content, + * attention, task-handoff, and GitHub action tiers. + * + * Every privileged action class is gated by a capability resolution derived + * from the authoritative Cave contract fixture this SDK vendors: an action + * class is actionable only when the contract declares at least one operation + * carrying the required scope. The pinned fixture (Cave `4adc97b1`) declares + * the privileged scope names for pairing (`attachments:write`, `tasks:write`, + * `github:write`, `chat:write`, `conversations:write`) but declares no + * operation that uses them, so every privileged resolution is `undeclared` + * today and the client reports `unsupported_operation` before any transport + * dispatch. Nothing here invents routes, capability families, or scope names: + * scope identifiers come from the fixture's pairing-scope list, and declared + * operations come from the fixture's operation table. + * + * Resolutions are computed per call from the consulted contract data and + * returned as frozen descriptors. No capability object is cached across + * grants: Cave remains the sole authority for grants, confirmation + * revalidation, idempotency, audit, and domain mutation. + * + * This module is import-pure: no discovery, credential, filesystem, network, + * or daemon I/O happens at import time. + */ +type CavePrivilegedActionClass = 'attachment-transfer' | 'rich-content' | 'attention-response' | 'task-handoff' | 'github-action'; +declare const CAVE_PRIVILEGED_ACTION_CLASSES: readonly ["attachment-transfer", "rich-content", "attention-response", "task-handoff", "github-action"]; +interface CavePrivilegedActionRequirement { + readonly actionClass: CavePrivilegedActionClass; + /** Drawn only from the fixture-declared pairing scope vocabulary. */ + readonly requiredScope: CavePairingScope; + /** Every privileged action requires a direct, explicit confirmation. */ + readonly requiresConfirmation: true; + /** Idempotency is keyed by the caller-supplied 36-character operation UUID. */ + readonly idempotencyKey: 'operation-uuid'; +} +/** + * The SDK-declared requirement mapping. Scope identifiers are the pairing + * scopes the authoritative fixture declares; the authoritative grant mapping + * is Cave's and is revalidated server-side regardless of these values. + */ +declare const CAVE_PRIVILEGED_ACTION_REQUIREMENTS: Readonly>; +interface CaveDeclaredOperationRef { + readonly id: string; + readonly method: string; + readonly path: string; + readonly scope: string | null; +} +type CaveCapabilityStatus = 'declared' | 'undeclared'; +interface CaveCapabilityResolution { + readonly actionClass: CavePrivilegedActionClass; + readonly status: CaveCapabilityStatus; + readonly requirement: CavePrivilegedActionRequirement; + /** + * The capability families the consulted contract declares. The pinned + * fixture declares none of the privileged families. + */ + readonly declaredCapabilities: readonly string[]; + /** + * The operations the consulted contract declares with the required scope. + * Empty for every privileged class under the pinned fixture. + */ + readonly declaredOperations: readonly CaveDeclaredOperationRef[]; +} +interface CaveCapabilityRegistry { + resolve(actionClass: CavePrivilegedActionClass): CaveCapabilityResolution; +} +interface CaveCapabilityContractSource { + readonly capabilities: readonly string[]; + readonly operations: readonly CaveContractOperation[]; +} +/** + * Build a capability registry from a parsed (preferably digest-verified) + * Client v1 contract fixture. Resolution consults the operation table on + * every call: an action class is `declared` only when the contract declares + * at least one operation carrying the required scope. + */ +declare function createCaveCapabilityRegistry(contract: CaveCapabilityContractSource): CaveCapabilityRegistry; +/** + * The default capability source: the operation table of the authoritative + * fixture pinned at Cave `4adc97b1` (digest `b2694cd1…`). Tests assert this + * snapshot matches the vendored fixture exactly, so a fixture re-import + * forces a reviewed update here. Under this contract every privileged action + * class resolves `undeclared`. + */ +declare const CAVE_DEFAULT_CAPABILITY_CONTRACT: CaveCapabilityContractSource; +/** + * The default registry every `CaveClient` uses when no explicit registry is + * supplied. Under the pinned fixture all privileged action classes resolve + * `undeclared`. + */ +declare function createDefaultCaveCapabilityRegistry(): CaveCapabilityRegistry; +/** + * A privileged action carries a direct, explicit confirmation: exactly one + * `confirmed` field whose value is the literal `true`. Anything else — a + * missing field, `false`, a string, a truthy object — is a configuration + * error raised before any capability or transport work. + */ +declare function parsePrivilegedConfirmation(value: unknown): true; +/** + * Privileged actions key idempotency with the same Client v1 operation UUID + * contract as conversational control: exactly 36 characters, RFC-compatible, + * normalized to lowercase. + */ +declare function validatePrivilegedOperationId(value: unknown): string; + +/** + * Attention responses and task handoffs for the privileged authority tier. + * + * The five handoff states — proposed, pending, completed, rejected, failed — + * are kept strictly distinct: a handoff moves through the declared + * transition map only, and terminal states accept no further transitions. + * Attention responses carry a bounded note at most; no free-form payload + * flows through this surface. + * + * Upstream-contract gap (stated, not invented): the authoritative Cave + * fixture pinned at `4adc97b1` declares the `conversations:write` and + * `tasks:write` pairing scopes but no attention or task operations and no + * such capability families, so no transport binding or route path ships; + * every call reports `unsupported_operation` until the producer contract + * lands and `pnpm sync:contracts` imports it. The transition map below is + * the SDK-owned request model; Cave owns the authoritative state machine + * and revalidates every transition server-side. + * + * This module is import-pure: no discovery, credential, filesystem, network, + * or daemon I/O happens at import time. + */ +declare const CAVE_TASK_HANDOFF_STATES: readonly ["proposed", "pending", "completed", "rejected", "failed"]; +type CaveTaskHandoffState = (typeof CAVE_TASK_HANDOFF_STATES)[number]; +/** + * The declared transition map. Every state is distinct; `completed`, + * `rejected`, and `failed` are terminal. + */ +declare const CAVE_TASK_HANDOFF_TRANSITIONS: Readonly>; +declare const CAVE_ATTENTION_RESPONSE_KINDS: readonly ["acknowledge", "decline"]; +type CaveAttentionResponseKind = (typeof CAVE_ATTENTION_RESPONSE_KINDS)[number]; +interface CaveTaskHandoffRequest { + readonly operationId: string; + readonly confirmed: true; + readonly conversationId: string; + readonly handoffId: string; + /** The state the handoff is known to be in. */ + readonly from: CaveTaskHandoffState; + /** The requested next state; must be a legal transition from `from`. */ + readonly to: CaveTaskHandoffState; +} +interface CaveAttentionResponseRequest { + readonly operationId: string; + readonly confirmed: true; + readonly conversationId: string; + readonly attentionId: string; + readonly response: CaveAttentionResponseKind; + readonly note?: string; +} +/** + * Whether the declared model permits a handoff transition. Terminal states + * transition to nothing; `proposed` only advances to `pending`. + */ +declare function isCaveTaskHandoffTransition(from: CaveTaskHandoffState, to: CaveTaskHandoffState): boolean; +/** + * Parse one task-handoff request. The transition must be legal under the + * declared map, and the five states remain strictly distinct: an unknown + * state or a skipped transition is a configuration error before any + * capability or transport work. + */ +declare function parseCaveTaskHandoffRequest(value: unknown): CaveTaskHandoffRequest; +/** + * Parse one attention-response request. The response kind is a closed union + * and the optional note is bounded; nothing else can be sent. + */ +declare function parseCaveAttentionResponseRequest(value: unknown): CaveAttentionResponseRequest; + +/** + * Explicitly confirmed GitHub actions for the privileged authority tier. + * + * The curated action union is deliberately EMPTY: the authoritative Cave + * fixture pinned at `4adc97b1` declares the `github:write` pairing scope but + * no GitHub operation and no GitHub capability family, and no reviewed + * producer contract has curated which GitHub actions exist. Naming concrete + * action kinds here would fabricate a curation nobody reviewed, so the union + * ships closed (`CaveGitHubActionKind` is `never`) and every request is + * rejected before any capability or transport work — fail closed by + * construction. When the upstream Cave producer contract curates the union, + * `CAVE_GITHUB_ACTION_KINDS` gains its reviewed members and this module's + * validation machinery (exact confirmation, operation-UUID idempotency, + * bounded input) applies unchanged. + * + * Cave revalidates confirmation, scope, repository/project grant, and input + * bounds regardless of client confirmation. + * + * This module is import-pure: no discovery, credential, filesystem, network, + * or daemon I/O happens at import time. + */ +/** + * The curated GitHub action union. Typed `readonly never[]` so that the + * kind type itself is uninhabitable — fail closed at the type level. Empty + * pending the upstream producer contract; never extended by client-side + * guesswork. + */ +declare const CAVE_GITHUB_ACTION_KINDS: readonly never[]; +type CaveGitHubActionKind = (typeof CAVE_GITHUB_ACTION_KINDS)[number]; +/** + * The shape every confirmed GitHub action request will take once the union + * is curated. With the union empty, `action` is uninhabitable and no request + * can be constructed — the type system itself refuses the mutation. + */ +interface CaveGitHubActionRequest { + readonly operationId: string; + readonly confirmed: true; + readonly conversationId: string; + readonly action: CaveGitHubActionKind; + /** Bounded string-valued action input; structure owned by the union member. */ + readonly input: Readonly>; +} +/** + * Parse one confirmed GitHub action request. Confirmation, the operation + * UUID, and the bounded shape are validated first; the action kind is then + * checked against the curated union, which is empty today, so every kind is + * rejected with the precise upstream gap — before any capability resolution + * or transport dispatch, guaranteeing zero domain mutation. + */ +declare function parseCaveGitHubActionRequest(value: unknown): CaveGitHubActionRequest; + interface CaveTransport { health(context?: OperationContext): Promise; pairingCreate?(request: CavePairingRequest, context?: OperationContext): Promise; @@ -1605,6 +2630,20 @@ interface CaveTransport { getConversationOperation?(operationId: CaveConversationOperationId, context?: OperationContext): Promise; readConversationOperationEvents?(operationId: CaveConversationOperationId, page: CaveConversationEventPageRequest, context?: OperationContext): Promise; stopConversationOperation?(operationId: CaveConversationOperationId, context?: OperationContext): Promise; + /** + * Privileged authority is optional for every transport. The attachment, + * attention, task-handoff, and GitHub action operations are not declared + * by the authoritative Cave contract fixture this SDK vendors, so no + * transport binds them today; the client gates every privileged call on + * the capability registry first and reports `unsupported_operation` + * rather than inventing a route. Results are `unknown` at this trust + * boundary and are validated by the client. + */ + uploadAttachment?(request: CaveAttachmentUploadRequest, context?: OperationContext): Promise; + downloadAttachment?(request: CaveAttachmentDownloadRequest, context?: OperationContext): Promise; + respondToAttention?(request: CaveAttentionResponseRequest, context?: OperationContext): Promise; + requestTaskHandoff?(request: CaveTaskHandoffRequest, context?: OperationContext): Promise; + submitGitHubAction?(request: CaveGitHubActionRequest, context?: OperationContext): Promise; /** * The familiar operations are optional so that a transport written against * an older Cave still satisfies this interface. The client reports a missing @@ -1650,6 +2689,12 @@ interface CaveManagedNativeCredentialCustody { } interface CaveClientOptionsBase { operation?: OperationDefaults; + /** + * Capability registry for the privileged authority tiers. Defaults to the + * registry derived from the pinned contract fixture, under which every + * privileged action class resolves `undeclared`. + */ + capabilities?: CaveCapabilityRegistry; } interface CaveClientOptionsWithoutCredentials extends CaveClientOptionsBase { transport: CaveTransport; @@ -1775,14 +2820,53 @@ declare class CaveClient { * once and never retries it after ambiguous transport completion. */ stopConversationOperation(operationId: string, options?: OperationOptions): Promise; + /** + * Bounded attachment upload. The request is validated fail closed (file + * count, size, request size, MIME/signature agreement, filename, symlink, + * and the atomic uploader-credential-plus-conversation binding) before any + * capability or transport work, so a validation rejection performs zero + * domain mutation. Under the pinned contract the attachment capability is + * undeclared and this reports `unsupported_operation`. + */ + uploadAttachment(request: CaveAttachmentUploadRequest, options?: OperationOptions): Promise; + /** + * Bounded attachment download. The request carries the byte ceiling; the + * canonical record is the validated result metadata. Under the pinned + * contract this reports `unsupported_operation` before any transport work. + */ + downloadAttachment(request: CaveAttachmentDownloadRequest, options?: OperationOptions): Promise; + /** + * One attention response with a closed response-kind union and a bounded + * optional note. Validation failure performs zero domain mutation; under + * the pinned contract the attention capability is undeclared and this + * reports `unsupported_operation`. + */ + respondToAttention(request: CaveAttentionResponseRequest, options?: OperationOptions): Promise; + /** + * One task-handoff transition. The declared transition map keeps + * proposed, pending, completed, rejected, and failed strictly distinct; + * an illegal or skipped transition is a configuration error before any + * capability or transport work. Under the pinned contract this reports + * `unsupported_operation`. + */ + requestTaskHandoff(request: CaveTaskHandoffRequest, options?: OperationOptions): Promise; + /** + * One explicitly confirmed GitHub action. The curated action union is + * empty under the pinned contract, so every request is rejected during + * request parsing — before any capability or transport work — and zero + * domain mutation is possible. When the upstream producer contract + * curates the union, the confirmed request flows through the capability + * gate to Cave, which revalidates confirmation, scope, grant, and bounds. + */ + submitGitHubAction(request: CaveGitHubActionRequest, options?: OperationOptions): Promise; } declare function createCaveClient(options: CaveClientOptions): CaveClient; -export { type CaveFamiliarContract as $, type CaveConversationMessage as A, type CaveConversationOperation as B, type CavePairingRequest as C, type CaveConversationOperationId as D, type CaveConversationOperationKind as E, type CaveConversationOperationState as F, type CaveConversationOriginatingScope as G, type CaveConversationReconcileReason as H, type CaveConversationStreamOptions as I, type CaveConversationTranslatedPage as J, type CaveCreateConversationRequest as K, type CaveCreateConversationResult as L, type CaveCredentialAccess as M, type CaveCredentialBinding as N, type CaveCredentialDisconnectedReason as O, type CaveCredentialMetadata as P, type CaveCredentialPersistingTransport as Q, type CaveCredentialStatus as R, type CaveExecutionAttempt as S, type CaveExecutionBackfill as T, type CaveExecutionCoverage as U, type CaveExecutionSlice as V, type CaveExecutionWindow as W, type CaveFamiliar as X, type CaveFamiliarAnalytics as Y, type CaveFamiliarAnalyticsOptions as Z, type CaveFamiliarAnalyticsResponse as _, CaveClient as a, type CaveFamiliarContractResponse as a0, type CaveFamiliarProperty as a1, type CaveFamiliarWire as a2, type CaveFamiliarsResponse as a3, type CaveHealth as a4, type CaveHealthData as a5, type CaveHealthResponse as a6, type CaveManagedCredentialStatusResult as a7, type CaveManagedCredentialTransport as a8, type CaveManagedForgetCredentialResult as a9, type CaveManagedNativeCredentialCustody as aa, type CaveManagedPairingCreated as ab, type CaveManagedPairingExchange as ac, type CavePairingCreated as ad, type CavePairingExchange as ae, type CavePairingScope as af, CavePairingSession as ag, type CavePairingState as ah, type CavePairingStatus as ai, type CaveProject as aj, type CavePropertyCoverage as ak, type CaveRetryConversationTurnRequest as al, type CaveSendConversationMessageRequest as am, type CaveSendConversationMessageResult as an, type CaveTransport as ao, caveConversationReconcileReason as ap, createCaveClient as aq, createConversationEventTranslator as ar, isCaveClientError as as, normalizeCaveError as at, validateConversationEventCursor as au, CAVE_ANALYTICS_WINDOWS as b, CAVE_CONVERSATION_EVENT_TYPES as c, CAVE_CONVERSATION_OPERATION_STATES as d, CAVE_CONVERSATION_ORIGINATING_SCOPES as e, CAVE_CONVERSATION_RECONCILE_REASONS as f, CAVE_CONVERSATION_TERMINAL_STATES as g, CAVE_FAMILIAR_PROPERTIES as h, CAVE_PAIRING_SCOPES as i, CAVE_PAIRING_STATUSES as j, type CaveAnalyticsWindowKey as k, type CaveAuthorityBinding as l, type CaveAuthorityBoundPairingExchange as m, type CaveCanonicalFamiliar as n, CaveClientError as o, type CaveClientOptions as p, type CaveContractFile as q, type CaveContractReport as r, type CaveContractViolation as s, type CaveConversation as t, type CaveConversationEvent as u, type CaveConversationEventBase as v, type CaveConversationEventPage as w, type CaveConversationEventPageRequest as x, type CaveConversationEventTranslator as y, type CaveConversationEventType as z }; +export { type CaveContractViolation as $, CaveAttachmentSchemaError as A, type CaveAttachmentUploadRequest as B, type CavePairingRequest as C, type CaveAttentionResponseKind as D, type CaveAttentionResponseRequest as E, type CaveAuthorityBinding as F, type CaveAuthorityBoundPairingExchange as G, type CaveCanonicalFamiliar as H, type CaveCapabilityContractSource as I, type CaveCapabilityRegistry as J, type CaveCapabilityResolution as K, type CaveCapabilityStatus as L, CaveClientError as M, type CaveClientOptions as N, type CaveContractCursor as O, type CaveContractEnvelopeMetadata as P, type CaveContractFile as Q, type CaveContractFixture as R, type CaveContractHealthData as S, type CaveContractIdentity as T, type CaveContractOperation as U, type CaveContractPairingCreatedData as V, type CaveContractPairingExchangeData as W, type CaveContractPairingStatusData as X, type CaveContractPublicRoute as Y, type CaveContractReport as Z, type CaveContractRevision as _, CaveClient as a, type CaveTaskHandoffRequest as a$, type CaveConversation as a0, type CaveConversationEvent as a1, type CaveConversationEventBase as a2, type CaveConversationEventPage as a3, type CaveConversationEventPageRequest as a4, type CaveConversationEventTranslator as a5, type CaveConversationEventType as a6, type CaveConversationMessage as a7, type CaveConversationOperation as a8, type CaveConversationOperationId as a9, type CaveFamiliarProperty as aA, type CaveFamiliarWire as aB, type CaveFamiliarsResponse as aC, type CaveGitHubActionKind as aD, type CaveGitHubActionRequest as aE, type CaveHealth as aF, type CaveHealthData as aG, type CaveHealthResponse as aH, type CaveManagedCredentialStatusResult as aI, type CaveManagedCredentialTransport as aJ, type CaveManagedForgetCredentialResult as aK, type CaveManagedNativeCredentialCustody as aL, type CaveManagedPairingCreated as aM, type CaveManagedPairingExchange as aN, type CavePairingCreated as aO, type CavePairingExchange as aP, type CavePairingScope as aQ, CavePairingSession as aR, type CavePairingState as aS, type CavePairingStatus as aT, type CavePrivilegedActionClass as aU, type CavePrivilegedActionRequirement as aV, type CaveProject as aW, type CavePropertyCoverage as aX, type CaveRetryConversationTurnRequest as aY, type CaveSendConversationMessageRequest as aZ, type CaveSendConversationMessageResult as a_, type CaveConversationOperationKind as aa, type CaveConversationOperationState as ab, type CaveConversationOriginatingScope as ac, type CaveConversationReconcileReason as ad, type CaveConversationStreamOptions as ae, type CaveConversationTranslatedPage as af, type CaveCreateConversationRequest as ag, type CaveCreateConversationResult as ah, type CaveCredentialAccess as ai, type CaveCredentialBinding as aj, type CaveCredentialDisconnectedReason as ak, type CaveCredentialMetadata as al, type CaveCredentialPersistingTransport as am, type CaveCredentialStatus as an, type CaveDeclaredOperationRef as ao, type CaveExecutionAttempt as ap, type CaveExecutionBackfill as aq, type CaveExecutionCoverage as ar, type CaveExecutionSlice as as, type CaveExecutionWindow as at, type CaveFamiliar as au, type CaveFamiliarAnalytics as av, type CaveFamiliarAnalyticsOptions as aw, type CaveFamiliarAnalyticsResponse as ax, type CaveFamiliarContract as ay, type CaveFamiliarContractResponse as az, CAVE_ANALYTICS_WINDOWS as b, type CaveTaskHandoffState as b0, type CaveTransport as b1, bindCaveAttachments as b2, caveConversationReconcileReason as b3, createCaveCapabilityRegistry as b4, createCaveClient as b5, createConversationEventTranslator as b6, createDefaultCaveCapabilityRegistry as b7, digestCaveContractFixture as b8, isCaveClientError as b9, isCaveTaskHandoffTransition as ba, normalizeCaveError as bb, parseCaveAttachmentDownloadRequest as bc, parseCaveAttachmentRecord as bd, parseCaveAttachmentUploadRequest as be, parseCaveAttentionResponseRequest as bf, parseCaveContractFixture as bg, parseCaveGitHubActionRequest as bh, parseCaveTaskHandoffRequest as bi, parsePrivilegedConfirmation as bj, parseVerifiedCaveContractFixture as bk, sniffCaveAttachmentContentType as bl, validateConversationEventCursor as bm, validatePrivilegedOperationId as bn, verifyCaveContractFixtureDigest as bo, CAVE_ATTACHMENT_CONTENT_TYPES as c, CAVE_ATTACHMENT_LIMITS as d, CAVE_ATTENTION_RESPONSE_KINDS as e, CAVE_CONVERSATION_EVENT_TYPES as f, CAVE_CONVERSATION_OPERATION_STATES as g, CAVE_CONVERSATION_ORIGINATING_SCOPES as h, CAVE_CONVERSATION_RECONCILE_REASONS as i, CAVE_CONVERSATION_TERMINAL_STATES as j, CAVE_DEFAULT_CAPABILITY_CONTRACT as k, CAVE_FAMILIAR_PROPERTIES as l, CAVE_GITHUB_ACTION_KINDS as m, CAVE_PAIRING_SCOPES as n, CAVE_PAIRING_STATUSES as o, CAVE_PRIVILEGED_ACTION_CLASSES as p, CAVE_PRIVILEGED_ACTION_REQUIREMENTS as q, CAVE_TASK_HANDOFF_STATES as r, CAVE_TASK_HANDOFF_TRANSITIONS as s, type CaveAnalyticsWindowKey as t, type CaveAttachmentBinding as u, type CaveAttachmentContent as v, type CaveAttachmentContentType as w, type CaveAttachmentDescriptor as x, type CaveAttachmentDownloadRequest as y, type CaveAttachmentRecord as z }; // Entrypoint: ./managed // Declaration: dist/managed.d.ts -import { a8 as CaveManagedCredentialTransport, a as CaveClient } from './client-ootQTXcj.js'; -export { b as CAVE_ANALYTICS_WINDOWS, h as CAVE_FAMILIAR_PROPERTIES, i as CAVE_PAIRING_SCOPES, j as CAVE_PAIRING_STATUSES, n as CaveCanonicalFamiliar, o as CaveClientError, p as CaveClientOptions, t as CaveConversation, A as CaveConversationMessage, M as CaveCredentialAccess, N as CaveCredentialBinding, P as CaveCredentialMetadata, R as CaveCredentialStatus, Z as CaveFamiliarAnalyticsOptions, a4 as CaveHealth, a7 as CaveManagedCredentialStatusResult, a9 as CaveManagedForgetCredentialResult, aa as CaveManagedNativeCredentialCustody, ab as CaveManagedPairingCreated, ac as CaveManagedPairingExchange, C as CavePairingRequest, af as CavePairingScope, ag as CavePairingSession, ah as CavePairingState, ai as CavePairingStatus, aj as CaveProject, ao as CaveTransport, as as isCaveClientError, at as normalizeCaveError } from './client-ootQTXcj.js'; +import { aJ as CaveManagedCredentialTransport, a as CaveClient } from './client-M2RrMRyI.js'; +export { b as CAVE_ANALYTICS_WINDOWS, l as CAVE_FAMILIAR_PROPERTIES, n as CAVE_PAIRING_SCOPES, o as CAVE_PAIRING_STATUSES, H as CaveCanonicalFamiliar, M as CaveClientError, N as CaveClientOptions, a0 as CaveConversation, a7 as CaveConversationMessage, ai as CaveCredentialAccess, aj as CaveCredentialBinding, al as CaveCredentialMetadata, an as CaveCredentialStatus, aw as CaveFamiliarAnalyticsOptions, aF as CaveHealth, aI as CaveManagedCredentialStatusResult, aK as CaveManagedForgetCredentialResult, aL as CaveManagedNativeCredentialCustody, aM as CaveManagedPairingCreated, aN as CaveManagedPairingExchange, C as CavePairingRequest, aQ as CavePairingScope, aR as CavePairingSession, aS as CavePairingState, aT as CavePairingStatus, aW as CaveProject, b1 as CaveTransport, b9 as isCaveClientError, bb as normalizeCaveError } from './client-M2RrMRyI.js'; import { OperationOptions, OperationDefaults, OperationContext } from '@opencoven/sdk-core/browser'; interface CaveManagedDiscoverySource { diff --git a/api-baselines/cave.json b/api-baselines/cave.json index 7838096..fa3ae46 100644 --- a/api-baselines/cave.json +++ b/api-baselines/cave.json @@ -17,45 +17,75 @@ "entrypoints": { ".": { "declarationFiles": [ - "dist/client-ootQTXcj.d.ts", + "dist/client-M2RrMRyI.d.ts", "dist/index.d.ts" ], "runtimeExports": { "dist/index.js": [ "CAVE_ANALYTICS_WINDOWS", + "CAVE_ATTACHMENT_CONTENT_TYPES", + "CAVE_ATTACHMENT_LIMITS", + "CAVE_ATTENTION_RESPONSE_KINDS", "CAVE_CLIENT_VERSION", "CAVE_CONVERSATION_EVENT_TYPES", "CAVE_CONVERSATION_OPERATION_STATES", "CAVE_CONVERSATION_ORIGINATING_SCOPES", "CAVE_CONVERSATION_RECONCILE_REASONS", "CAVE_CONVERSATION_TERMINAL_STATES", + "CAVE_DEFAULT_CAPABILITY_CONTRACT", "CAVE_FAMILIAR_PROPERTIES", + "CAVE_GITHUB_ACTION_KINDS", "CAVE_PAIRING_SCOPES", "CAVE_PAIRING_STATUSES", + "CAVE_PRIVILEGED_ACTION_CLASSES", + "CAVE_PRIVILEGED_ACTION_REQUIREMENTS", + "CAVE_RICH_CONTENT_LIMITS", + "CAVE_RICH_CONTENT_URL_SCHEMES", + "CAVE_TASK_HANDOFF_STATES", + "CAVE_TASK_HANDOFF_TRANSITIONS", + "CaveAttachmentSchemaError", "CaveClient", "CaveClientError", "CaveDiscoveryError", "CavePairingSession", + "CaveRichContentError", + "bindCaveAttachments", "caveConversationReconcileReason", + "collectCaveRichContentUrls", + "createCaveCapabilityRegistry", "createCaveClient", "createConversationEventTranslator", + "createDefaultCaveCapabilityRegistry", "createDiscoveredCaveClient", "createManagedCaveClient", "digestCaveContractFixture", "discoverCaveEndpoint", "isCaveClientError", "isCaveDiscoveryError", + "isCaveTaskHandoffTransition", "normalizeCaveError", + "parseCaveAttachmentDownloadRequest", + "parseCaveAttachmentRecord", + "parseCaveAttachmentUploadRequest", + "parseCaveAttentionResponseRequest", "parseCaveContractFixture", + "parseCaveGitHubActionRequest", + "parseCaveRichContent", + "parseCaveRichContentUrl", + "parseCaveTaskHandoffRequest", + "parsePrivilegedConfirmation", "parseVerifiedCaveContractFixture", + "serializeCaveRichContent", + "sniffCaveAttachmentContentType", "validateConversationEventCursor", + "validatePrivilegedOperationId", "verifyCaveContractFixtureDigest" ] } }, "./managed": { "declarationFiles": [ - "dist/client-ootQTXcj.d.ts", + "dist/client-M2RrMRyI.d.ts", "dist/managed.d.ts" ], "runtimeExports": { diff --git a/packages/cave/README.md b/packages/cave/README.md index f0aeca6..ab85f7d 100644 --- a/packages/cave/README.md +++ b/packages/cave/README.md @@ -58,6 +58,10 @@ record. Unix discovery still requires a positive inode. - Conversational control adds the first bounded mutation authority while Cave remains the sole executor and canonical owner; see [Conversational control](#conversational-control) below. +- Privileged authority adds capability-gated attachment transfer, passive + rich content, attention responses, task handoffs, and the confirmed GitHub + action envelope while Cave remains the sole executor; see + [Privileged authority](#privileged-authority) below. - Four bounded async iterators, `iterateFamiliars()`, `iterateProjects()`, `iterateConversations()`, and `iterateConversationMessages()`, lazily compose the list routes. There is intentionally no iterator for the single-item @@ -543,6 +547,86 @@ streaming renderers (human/JSON/NDJSON ordering) are likewise staged for a follow-up PR per the design's PR plan, as no CLI command can execute a mutation against a real authority before that contract exists. +## Privileged authority + +The privileged authority tier covers attachment transfer, rich content, +attention responses, task handoffs, and explicitly confirmed GitHub actions. +Cave stays authoritative for storage, grants, confirmation revalidation, +idempotency, audit, and domain mutation; the SDK exposes capability-gated +typed operations only. + +### Capability gating + +`createCaveCapabilityRegistry(contract)` resolves an action class +(`attachment-transfer`, `rich-content`, `attention-response`, +`task-handoff`, `github-action`) against the live operation table on every +call. An action class is actionable only when the consulted contract +declares at least one operation carrying its required scope; resolutions are +fresh frozen descriptors, never cached capability objects. The default +registry mirrors the pinned fixture (verified by tests), under which every +privileged class resolves `undeclared` and `CaveClient` reports +`unsupported_operation`. Every privileged request additionally requires an +exact `confirmed: true` and a caller-supplied 36-character operation UUID. + +### Attachment transfer + +Upload and download requests are validated fail closed before any +capability or transport work: file count, per-file size, total request size, +declared MIME type versus magic-byte signature agreement, filename rules +(no separators, dot segments, control characters, or hidden dotfiles), +symlink refusal, and the atomic binding of every attachment to its uploader +credential and conversation. Canonical attachment records are metadata-only: +attachment bytes never enter canonical conversation JSON, browser storage, +profile config, or diagnostic bundles. + +### Rich content + +`parseCaveRichContent(value)` turns an untrusted payload into a strict, +non-executable AST over a closed node vocabulary (text, code, line break, +link, paragraph, heading, code block, blockquote, list). There is no HTML +node type and no event-handler field anywhere in the model: markup-looking +text is preserved byte for byte as inert text, unknown node types and +unknown fields are rejected, link targets allow only `https:` and +`mailto:` (no scheme-less targets, no userinfo), and node-count, depth, +and character limits fail closed. Oversized hostile payloads are rejected, +never truncated. + +### Attention and task handoffs + +`parseCaveAttentionResponseRequest(value)` accepts the closed response +kinds `acknowledge` and `decline` with a bounded optional note. +`parseCaveTaskHandoffRequest(value)` moves a handoff through the declared +transition map, keeping proposed, pending, completed, rejected, and failed +strictly distinct; terminal states transition to nothing. + +### GitHub actions + +The curated GitHub action union (`CAVE_GITHUB_ACTION_KINDS`) is deliberately +**empty**: no reviewed producer contract has curated which GitHub actions +exist, so naming concrete kinds would fabricate a curation. The type-level +union is therefore uninhabitable and `parseCaveGitHubActionRequest` rejects +every request with the precise gap — fail closed by construction — while +the confirmation, operation-UUID, and bounded-input machinery ship ready +for the curated union. + +### Upstream contract gap + +The authoritative Cave fixture pinned at producer commit `4adc97b1` declares +the privileged pairing scopes but **no attachment, rich-content, attention, +task, or GitHub operations and no such capability families**. The five +optional `CaveTransport` methods `uploadAttachment`, +`downloadAttachment`, `respondToAttention`, `requestTaskHandoff`, and +`submitGitHubAction` stay unbound, and no CLI commands ship for unsupported +privileged actions. The Cave producer routes, scope-to-operation mapping, +capability families, attachment storage semantics, rich-content payload +contract, attention/task state ownership, the curated GitHub action union, +and their conformance vectors are owed by the upstream Cave producer +contract; once that lands, `pnpm sync:contracts` imports the exact fixture +commit and the registry, request parsers, and transport bindings are +reviewed against it. The privileged authority tier additionally requires a +dedicated security review of privileged authority before any of it becomes +actionable. + ## Compatibility, deadlines, and retry guidance Cave Client v1 health accepts additive Cave API updates on major version `1` diff --git a/packages/cave/src/attachment-transfer.ts b/packages/cave/src/attachment-transfer.ts new file mode 100644 index 0000000..2d1cbcf --- /dev/null +++ b/packages/cave/src/attachment-transfer.ts @@ -0,0 +1,587 @@ +import { OperationConfigurationError } from '@opencoven/sdk-core/browser'; + +import { + parsePrivilegedConfirmation, + validatePrivilegedOperationId, +} from './privileged-capabilities.js'; + +/** + * Bounded attachment transfer for the privileged authority tier. + * + * This module owns the SDK half of the attachment contract: fail-closed + * preflight validation (file count, per-file size, total request size, + * declared MIME type versus signature, filename, traversal, symlink, and + * ownership binding) and the metadata-only records that bind an attachment + * to its uploader credential and conversation atomically. Attachment bytes + * exist only inside the in-flight upload request; they never enter the + * canonical attachment record, and therefore never enter canonical + * conversation JSON, browser storage, profile config, or diagnostic bundles. + * The SDK never hashes attachment bytes: the canonical byte digest is + * Cave's, computed server-side where the bytes land, and appears in records + * only as a validated string. + * + * Upstream-contract gap (stated, not invented): the authoritative Cave + * fixture pinned at `4adc97b1` declares the `attachments:write` pairing + * scope but no attachment operations and no attachment capability family, + * so no transport binding or route path ships; upload and download report + * `unsupported_operation` until the producer contract lands and + * `pnpm sync:contracts` imports it. Cave revalidates every limit, the + * content signature, and the ownership binding server-side. + * + * This module is import-pure: no discovery, credential, filesystem, network, + * or daemon I/O happens at import time. + */ + +export const CAVE_ATTACHMENT_LIMITS = Object.freeze({ + /** Maximum attachments in one upload request. */ + maxFiles: 10, + /** Maximum byte size of one attachment. */ + maxFileBytes: 10 * 1024 * 1024, + /** Maximum summed byte size of one upload request. */ + maxRequestBytes: 25 * 1024 * 1024, + /** Maximum filename length in UTF-16 code units. */ + maxFilenameCharacters: 128, + /** Maximum canonical identifier length for attachment/credential IDs. */ + maxReferenceCharacters: 64, +}); + +/** + * The approved content-type allowlist. SVG, archive, and executable types + * are forbidden by the issue's non-goals and are not present. + */ +export const CAVE_ATTACHMENT_CONTENT_TYPES = [ + 'image/png', + 'image/jpeg', + 'image/gif', + 'image/webp', + 'application/pdf', + 'text/plain', +] as const; + +export type CaveAttachmentContentType = + (typeof CAVE_ATTACHMENT_CONTENT_TYPES)[number]; + +const CONTENT_TYPE_SET: ReadonlySet = new Set( + CAVE_ATTACHMENT_CONTENT_TYPES, +); + +function isCaveAttachmentContentType( + value: string, +): value is CaveAttachmentContentType { + return CONTENT_TYPE_SET.has(value); +} + +/** Declared type → magic-byte signature prefixes (first bytes of the file). */ +const ATTACHMENT_SIGNATURES: Readonly< + Record, readonly number[]> +> = Object.freeze({ + 'image/png': [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], + 'image/jpeg': [0xff, 0xd8, 0xff], + 'image/gif': [0x47, 0x49, 0x46, 0x38], // "GIF8" covers GIF87a and GIF89a + 'image/webp': [0x52, 0x49, 0x46, 0x46], // "RIFF" + "WEBP" at offset 8 + 'application/pdf': [0x25, 0x50, 0x44, 0x46], // "%PDF" +}); + +/** Text detection inspects at most this many leading bytes. */ +const TEXT_SNIFF_WINDOW = 512; + +export class CaveAttachmentSchemaError extends TypeError { + readonly field: string; + + constructor(field: string) { + super(`${field} was malformed.`); + this.name = 'CaveAttachmentSchemaError'; + this.field = field; + } +} + +function matchesSignature( + content: Uint8Array, + signature: readonly number[], +): boolean { + if (content.length < signature.length) { + return false; + } + return signature.every((byte, index) => content[index] === byte); +} + +function isDeclaredText(content: Uint8Array): boolean { + const window = content.subarray(0, Math.min(TEXT_SNIFF_WINDOW, content.length)); + for (const byte of window) { + // NUL bytes mark binary content; all other validity is delegated to the + // strict UTF-8 decode below, which also covers malformed sequences. + if (byte === 0x00) { + return false; + } + } + try { + new TextDecoder('utf-8', { fatal: true }).decode(window); + } catch { + return false; + } + return true; +} + +/** + * Signature-sniff the declared content type from the leading bytes. Returns + * the matched allowlisted type, `'text/plain'` when the bytes decode as + * UTF-8 text without binary markers, or `undefined` when nothing matches. + * A declared binary type whose bytes do not carry its signature is never + * accepted. + */ +export function sniffCaveAttachmentContentType( + content: Uint8Array, +): CaveAttachmentContentType | undefined { + for (const [contentType, signature] of Object.entries(ATTACHMENT_SIGNATURES)) { + if (matchesSignature(content, signature)) { + if (contentType === 'image/webp') { + // "RIFF" alone is not WebP: the container magic "WEBP" must follow + // at offset 8. + if ( + content.length < 12 || + content[8] !== 0x57 || + content[9] !== 0x45 || + content[10] !== 0x42 || + content[11] !== 0x50 + ) { + return undefined; + } + } + return contentType as CaveAttachmentContentType; + } + } + if (isDeclaredText(content)) { + return 'text/plain'; + } + return undefined; +} + +function validateFilename(value: unknown): string { + if (typeof value !== 'string' || value.length === 0) { + throw new OperationConfigurationError( + 'attachment filename must be a non-empty string', + ); + } + if (value.length > CAVE_ATTACHMENT_LIMITS.maxFilenameCharacters) { + throw new OperationConfigurationError( + `attachment filename must be at most ${CAVE_ATTACHMENT_LIMITS.maxFilenameCharacters} characters`, + ); + } + if (value !== value.trim() || value.endsWith('.') || value.endsWith(' ')) { + throw new OperationConfigurationError( + 'attachment filename must not end with a dot or whitespace', + ); + } + if (value === '.' || value === '..') { + throw new OperationConfigurationError( + 'attachment filename must not be a dot path segment', + ); + } + if (value.startsWith('.')) { + // Hidden dotfiles are refused fail closed. + throw new OperationConfigurationError( + 'attachment filename must not start with a dot', + ); + } + for (const character of value) { + const code = character.charCodeAt(0); + if (code <= 0x1f || code === 0x7f) { + throw new OperationConfigurationError( + 'attachment filename must not contain control characters', + ); + } + } + if ( + value.includes('/') || + value.includes('\\') || + value.includes(':') || + value.includes('*') || + value.includes('?') || + value.includes('"') || + value.includes('<') || + value.includes('>') || + value.includes('|') + ) { + throw new OperationConfigurationError( + 'attachment filename must not contain path, separator, or reserved characters', + ); + } + return value; +} + +function validateReferenceId(value: unknown, label: string): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new OperationConfigurationError(`${label} must be a non-empty string`); + } + if (value === '.' || value === '..') { + throw new OperationConfigurationError(`${label} must not be a dot path segment`); + } + if (value.length > CAVE_ATTACHMENT_LIMITS.maxReferenceCharacters) { + throw new OperationConfigurationError( + `${label} must be at most ${CAVE_ATTACHMENT_LIMITS.maxReferenceCharacters} characters`, + ); + } + return value; +} + +function validateDigest(value: unknown, field: string): string { + if (typeof value !== 'string' || !/^[0-9a-f]{64}$/u.test(value)) { + throw new CaveAttachmentSchemaError(`${field}`); + } + return value; +} + +export interface CaveAttachmentContent { + readonly filename: string; + readonly contentType: CaveAttachmentContentType; + readonly content: Uint8Array; + readonly symlink?: false; +} + +export interface CaveAttachmentDescriptor { + readonly filename: string; + readonly contentType: CaveAttachmentContentType; + readonly sizeBytes: number; +} + +export interface CaveAttachmentBinding { + readonly conversationId: string; + readonly uploaderCredentialId: string; + readonly attachments: readonly CaveAttachmentDescriptor[]; + readonly totalBytes: number; +} + +export interface CaveAttachmentUploadRequest { + readonly operationId: string; + readonly confirmed: true; + readonly conversationId: string; + readonly uploaderCredentialId: string; + readonly attachments: readonly CaveAttachmentContent[]; +} + +export interface CaveAttachmentDownloadRequest { + readonly operationId: string; + readonly confirmed: true; + readonly conversationId: string; + readonly attachmentId: string; + /** Optional ceiling; the parser defaults it to `maxFileBytes`. */ + readonly maxBytes?: number; +} + +/** + * The canonical attachment record: metadata bound to its conversation and + * uploader credential. There is no byte field on this type by construction — + * attachment bytes never enter canonical conversation JSON. + */ +export interface CaveAttachmentRecord { + readonly attachmentId: string; + readonly conversationId: string; + readonly uploaderCredentialId: string; + readonly filename: string; + readonly contentType: CaveAttachmentContentType; + readonly sizeBytes: number; + readonly digestSha256: string; +} + +function validateAttachmentInput(input: unknown): CaveAttachmentContent { + if (typeof input !== 'object' || input === null || Array.isArray(input)) { + throw new OperationConfigurationError( + 'attachment must be an object', + ); + } + const record = input as Record; + const allowed = new Set(['filename', 'contentType', 'content', 'symlink']); + for (const key of Object.keys(record)) { + if (!allowed.has(key)) { + throw new OperationConfigurationError('attachment has an unknown field'); + } + } + if (record.symlink !== undefined && record.symlink !== false) { + // Fail closed: symlinked sources are never uploaded; the caller resolves + // the target explicitly and uploads the resolved regular file. + throw new OperationConfigurationError( + 'attachment must not be a symlink', + ); + } + + const filename = validateFilename(record.filename); + const contentType = record.contentType; + if ( + typeof contentType !== 'string' || + !isCaveAttachmentContentType(contentType) + ) { + throw new OperationConfigurationError( + 'attachment contentType is not on the approved allowlist', + ); + } + const content = record.content; + if (!(content instanceof Uint8Array) || content.length === 0) { + throw new OperationConfigurationError( + 'attachment content must be a non-empty byte array', + ); + } + if (content.length > CAVE_ATTACHMENT_LIMITS.maxFileBytes) { + throw new OperationConfigurationError( + `attachment content must be at most ${CAVE_ATTACHMENT_LIMITS.maxFileBytes} bytes`, + ); + } + const sniffed = sniffCaveAttachmentContentType(content); + if (sniffed === undefined) { + throw new OperationConfigurationError( + 'attachment content signature does not match any approved type', + ); + } + if (sniffed !== contentType) { + // A declared type whose bytes carry a different (or no) signature is a + // spoofed MIME declaration; fail closed. + throw new OperationConfigurationError( + 'attachment contentType does not match the content signature', + ); + } + return Object.freeze({ + filename, + contentType, + content, + }); +} + +/** + * Bind validated attachments to their conversation and uploader credential + * atomically: every input is validated before any descriptor is produced, + * so a rejection leaves no partial binding. The binding is metadata-only. + */ +export function bindCaveAttachments( + conversationId: unknown, + uploaderCredentialId: unknown, + attachments: readonly unknown[], +): CaveAttachmentBinding { + const validatedConversationId = validateReferenceId( + conversationId, + 'conversationId', + ); + const validatedCredentialId = validateReferenceId( + uploaderCredentialId, + 'uploaderCredentialId', + ); + if (!Array.isArray(attachments) || attachments.length === 0) { + throw new OperationConfigurationError( + 'attachment binding requires at least one attachment', + ); + } + if (attachments.length > CAVE_ATTACHMENT_LIMITS.maxFiles) { + throw new OperationConfigurationError( + `attachment upload accepts at most ${CAVE_ATTACHMENT_LIMITS.maxFiles} files`, + ); + } + + const descriptors: CaveAttachmentDescriptor[] = []; + let totalBytes = 0; + for (const input of attachments) { + const validated = validateAttachmentInput(input); + totalBytes += validated.content.length; + if (totalBytes > CAVE_ATTACHMENT_LIMITS.maxRequestBytes) { + throw new OperationConfigurationError( + `attachment upload must be at most ${CAVE_ATTACHMENT_LIMITS.maxRequestBytes} bytes in total`, + ); + } + descriptors.push( + Object.freeze({ + filename: validated.filename, + contentType: validated.contentType, + sizeBytes: validated.content.length, + }), + ); + } + + return Object.freeze({ + conversationId: validatedConversationId, + uploaderCredentialId: validatedCredentialId, + attachments: Object.freeze(descriptors), + totalBytes, + }); +} + +const UPLOAD_REQUEST_KEYS = new Set([ + 'operationId', + 'confirmed', + 'conversationId', + 'uploaderCredentialId', + 'attachments', +]); + +/** + * Parse and fully validate one attachment upload request. Validation is + * fail-closed and total: any malformed field rejects the whole request, and + * the caller performs zero transport work on rejection. + */ +export function parseCaveAttachmentUploadRequest( + value: unknown, +): CaveAttachmentUploadRequest { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new OperationConfigurationError( + 'uploadAttachment request must be an object', + ); + } + const record = value as Record; + for (const key of Object.keys(record)) { + if (!UPLOAD_REQUEST_KEYS.has(key)) { + throw new OperationConfigurationError( + 'uploadAttachment request has an unknown field', + ); + } + } + parsePrivilegedConfirmation({ confirmed: record.confirmed }); + const operationId = validatePrivilegedOperationId(record.operationId); + if (!Array.isArray(record.attachments)) { + throw new OperationConfigurationError( + 'uploadAttachment request requires attachments', + ); + } + const sourceAttachments = record.attachments; + const binding = bindCaveAttachments( + record.conversationId, + record.uploaderCredentialId, + sourceAttachments, + ); + return Object.freeze({ + operationId, + confirmed: true, + conversationId: binding.conversationId, + uploaderCredentialId: binding.uploaderCredentialId, + attachments: binding.attachments.map((descriptor, index) => { + const source = sourceAttachments[index] as CaveAttachmentContent; + return Object.freeze({ + filename: descriptor.filename, + contentType: descriptor.contentType, + content: source.content, + }); + }), + }); +} + +const DOWNLOAD_REQUEST_KEYS = new Set([ + 'operationId', + 'confirmed', + 'conversationId', + 'attachmentId', + 'maxBytes', +]); + +/** + * Parse one bounded attachment download request. The byte ceiling is + * mandatory in effect: when omitted it defaults to `maxFileBytes`, and a + * larger value is rejected. + */ +export function parseCaveAttachmentDownloadRequest( + value: unknown, +): CaveAttachmentDownloadRequest { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new OperationConfigurationError( + 'downloadAttachment request must be an object', + ); + } + const record = value as Record; + for (const key of Object.keys(record)) { + if (!DOWNLOAD_REQUEST_KEYS.has(key)) { + throw new OperationConfigurationError( + 'downloadAttachment request has an unknown field', + ); + } + } + parsePrivilegedConfirmation({ confirmed: record.confirmed }); + const operationId = validatePrivilegedOperationId(record.operationId); + const conversationId = validateReferenceId( + record.conversationId, + 'conversationId', + ); + const attachmentId = validateReferenceId(record.attachmentId, 'attachmentId'); + let maxBytes = CAVE_ATTACHMENT_LIMITS.maxFileBytes; + if (record.maxBytes !== undefined) { + if ( + !Number.isSafeInteger(record.maxBytes) || + (record.maxBytes as number) <= 0 + ) { + throw new OperationConfigurationError( + 'downloadAttachment maxBytes must be a positive integer', + ); + } + if ((record.maxBytes as number) > CAVE_ATTACHMENT_LIMITS.maxFileBytes) { + throw new OperationConfigurationError( + `downloadAttachment maxBytes must be at most ${CAVE_ATTACHMENT_LIMITS.maxFileBytes} bytes`, + ); + } + maxBytes = record.maxBytes as number; + } + return Object.freeze({ + operationId, + confirmed: true, + conversationId, + attachmentId, + maxBytes, + }); +} + +const RECORD_KEYS = new Set([ + 'attachmentId', + 'conversationId', + 'uploaderCredentialId', + 'filename', + 'contentType', + 'sizeBytes', + 'digestSha256', +]); + +/** + * Parse a canonical attachment record from a transport response. Exact keys: + * a record carrying a `content` (or any unknown) field is rejected, so bytes + * cannot re-enter canonical state through the record type. + */ +export function parseCaveAttachmentRecord(value: unknown): CaveAttachmentRecord { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new CaveAttachmentSchemaError('attachmentRecord'); + } + const record = value as Record; + for (const key of Object.keys(record)) { + if (!RECORD_KEYS.has(key)) { + throw new CaveAttachmentSchemaError(`attachmentRecord.${key}`); + } + } + const attachmentId = validateReferenceId( + record.attachmentId, + 'attachmentId', + ); + const conversationId = validateReferenceId( + record.conversationId, + 'conversationId', + ); + const uploaderCredentialId = validateReferenceId( + record.uploaderCredentialId, + 'uploaderCredentialId', + ); + const filename = validateFilename(record.filename); + const contentType = record.contentType; + if ( + typeof contentType !== 'string' || + !isCaveAttachmentContentType(contentType) + ) { + throw new CaveAttachmentSchemaError('attachmentRecord.contentType'); + } + const sizeBytes = record.sizeBytes; + if ( + !Number.isSafeInteger(sizeBytes) || + (sizeBytes as number) < 0 || + (sizeBytes as number) > CAVE_ATTACHMENT_LIMITS.maxFileBytes + ) { + throw new CaveAttachmentSchemaError('attachmentRecord.sizeBytes'); + } + const digestSha256 = validateDigest(record.digestSha256, 'attachmentRecord.digestSha256'); + return Object.freeze({ + attachmentId, + conversationId, + uploaderCredentialId, + filename, + contentType, + sizeBytes: sizeBytes as number, + digestSha256, + }); +} diff --git a/packages/cave/src/attention-handoff.ts b/packages/cave/src/attention-handoff.ts new file mode 100644 index 0000000..bcda932 --- /dev/null +++ b/packages/cave/src/attention-handoff.ts @@ -0,0 +1,254 @@ +import { OperationConfigurationError } from '@opencoven/sdk-core/browser'; + +import { CAVE_CONTRACT_LIMITS } from './contract-constraints.js'; +import { + parsePrivilegedConfirmation, + validatePrivilegedOperationId, +} from './privileged-capabilities.js'; + +/** + * Attention responses and task handoffs for the privileged authority tier. + * + * The five handoff states — proposed, pending, completed, rejected, failed — + * are kept strictly distinct: a handoff moves through the declared + * transition map only, and terminal states accept no further transitions. + * Attention responses carry a bounded note at most; no free-form payload + * flows through this surface. + * + * Upstream-contract gap (stated, not invented): the authoritative Cave + * fixture pinned at `4adc97b1` declares the `conversations:write` and + * `tasks:write` pairing scopes but no attention or task operations and no + * such capability families, so no transport binding or route path ships; + * every call reports `unsupported_operation` until the producer contract + * lands and `pnpm sync:contracts` imports it. The transition map below is + * the SDK-owned request model; Cave owns the authoritative state machine + * and revalidates every transition server-side. + * + * This module is import-pure: no discovery, credential, filesystem, network, + * or daemon I/O happens at import time. + */ + +export const CAVE_TASK_HANDOFF_STATES = [ + 'proposed', + 'pending', + 'completed', + 'rejected', + 'failed', +] as const; + +export type CaveTaskHandoffState = (typeof CAVE_TASK_HANDOFF_STATES)[number]; + +/** + * The declared transition map. Every state is distinct; `completed`, + * `rejected`, and `failed` are terminal. + */ +export const CAVE_TASK_HANDOFF_TRANSITIONS: Readonly< + Record +> = Object.freeze({ + proposed: Object.freeze(['pending']), + pending: Object.freeze([ + 'completed', + 'rejected', + 'failed', + ]), + completed: Object.freeze([]), + rejected: Object.freeze([]), + failed: Object.freeze([]), +}); + +export const CAVE_ATTENTION_RESPONSE_KINDS = [ + 'acknowledge', + 'decline', +] as const; + +export type CaveAttentionResponseKind = + (typeof CAVE_ATTENTION_RESPONSE_KINDS)[number]; + +const TASK_HANDOFF_STATE_SET: ReadonlySet = new Set( + CAVE_TASK_HANDOFF_STATES, +); + +const ATTENTION_RESPONSE_KIND_SET: ReadonlySet = new Set( + CAVE_ATTENTION_RESPONSE_KINDS, +); + +function boundedReference(value: unknown, label: string): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new OperationConfigurationError(`${label} must be a non-empty string`); + } + if (value === '.' || value === '..') { + throw new OperationConfigurationError( + `${label} must not be a dot path segment`, + ); + } + if (value.length > CAVE_CONTRACT_LIMITS.declarationIdCharacters) { + throw new OperationConfigurationError( + `${label} must be at most ${CAVE_CONTRACT_LIMITS.declarationIdCharacters} characters`, + ); + } + return value; +} + +export interface CaveTaskHandoffRequest { + readonly operationId: string; + readonly confirmed: true; + readonly conversationId: string; + readonly handoffId: string; + /** The state the handoff is known to be in. */ + readonly from: CaveTaskHandoffState; + /** The requested next state; must be a legal transition from `from`. */ + readonly to: CaveTaskHandoffState; +} + +export interface CaveAttentionResponseRequest { + readonly operationId: string; + readonly confirmed: true; + readonly conversationId: string; + readonly attentionId: string; + readonly response: CaveAttentionResponseKind; + readonly note?: string; +} + +const TASK_HANDOFF_REQUEST_KEYS = new Set([ + 'operationId', + 'confirmed', + 'conversationId', + 'handoffId', + 'from', + 'to', +]); + +/** + * Whether the declared model permits a handoff transition. Terminal states + * transition to nothing; `proposed` only advances to `pending`. + */ +export function isCaveTaskHandoffTransition( + from: CaveTaskHandoffState, + to: CaveTaskHandoffState, +): boolean { + return CAVE_TASK_HANDOFF_TRANSITIONS[from].includes(to); +} + +/** + * Parse one task-handoff request. The transition must be legal under the + * declared map, and the five states remain strictly distinct: an unknown + * state or a skipped transition is a configuration error before any + * capability or transport work. + */ +export function parseCaveTaskHandoffRequest( + value: unknown, +): CaveTaskHandoffRequest { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new OperationConfigurationError( + 'requestTaskHandoff request must be an object', + ); + } + const record = value as Record; + for (const key of Object.keys(record)) { + if (!TASK_HANDOFF_REQUEST_KEYS.has(key)) { + throw new OperationConfigurationError( + 'requestTaskHandoff request has an unknown field', + ); + } + } + parsePrivilegedConfirmation({ confirmed: record.confirmed }); + const operationId = validatePrivilegedOperationId(record.operationId); + const conversationId = boundedReference(record.conversationId, 'conversationId'); + const handoffId = boundedReference(record.handoffId, 'handoffId'); + if ( + typeof record.from !== 'string' || + !TASK_HANDOFF_STATE_SET.has(record.from) + ) { + throw new OperationConfigurationError( + 'requestTaskHandoff from must be a declared handoff state', + ); + } + if ( + typeof record.to !== 'string' || + !TASK_HANDOFF_STATE_SET.has(record.to) + ) { + throw new OperationConfigurationError( + 'requestTaskHandoff to must be a declared handoff state', + ); + } + const from = record.from as CaveTaskHandoffState; + const to = record.to as CaveTaskHandoffState; + if (from === to || !isCaveTaskHandoffTransition(from, to)) { + throw new OperationConfigurationError( + 'requestTaskHandoff transition is not declared', + ); + } + return Object.freeze({ + operationId, + confirmed: true, + conversationId, + handoffId, + from, + to, + }); +} + +const ATTENTION_REQUEST_KEYS = new Set([ + 'operationId', + 'confirmed', + 'conversationId', + 'attentionId', + 'response', + 'note', +]); + +/** + * Parse one attention-response request. The response kind is a closed union + * and the optional note is bounded; nothing else can be sent. + */ +export function parseCaveAttentionResponseRequest( + value: unknown, +): CaveAttentionResponseRequest { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new OperationConfigurationError( + 'respondToAttention request must be an object', + ); + } + const record = value as Record; + for (const key of Object.keys(record)) { + if (!ATTENTION_REQUEST_KEYS.has(key)) { + throw new OperationConfigurationError( + 'respondToAttention request has an unknown field', + ); + } + } + parsePrivilegedConfirmation({ confirmed: record.confirmed }); + const operationId = validatePrivilegedOperationId(record.operationId); + const conversationId = boundedReference(record.conversationId, 'conversationId'); + const attentionId = boundedReference(record.attentionId, 'attentionId'); + if ( + typeof record.response !== 'string' || + !ATTENTION_RESPONSE_KIND_SET.has(record.response) + ) { + throw new OperationConfigurationError( + 'respondToAttention response must be a declared response kind', + ); + } + let note: string | undefined; + if (record.note !== undefined) { + if (typeof record.note !== 'string' || record.note.trim().length === 0) { + throw new OperationConfigurationError( + 'respondToAttention note must be a non-empty string', + ); + } + if (record.note.length > CAVE_CONTRACT_LIMITS.errorMessageCharacters) { + throw new OperationConfigurationError( + `respondToAttention note must be at most ${CAVE_CONTRACT_LIMITS.errorMessageCharacters} characters`, + ); + } + note = record.note; + } + return Object.freeze({ + operationId, + confirmed: true, + conversationId, + attentionId, + response: record.response as CaveAttentionResponseKind, + ...(note === undefined ? {} : { note }), + }); +} diff --git a/packages/cave/src/client.ts b/packages/cave/src/client.ts index 9952e4d..2dde21e 100644 --- a/packages/cave/src/client.ts +++ b/packages/cave/src/client.ts @@ -57,6 +57,30 @@ import { parseFamiliarsEnvelope, parseProjectsEnvelope, } from './canonical-reads.js'; +import { + CaveAttachmentSchemaError, + parseCaveAttachmentDownloadRequest, + parseCaveAttachmentRecord, + parseCaveAttachmentUploadRequest, + type CaveAttachmentDownloadRequest, + type CaveAttachmentRecord, + type CaveAttachmentUploadRequest, +} from './attachment-transfer.js'; +import { + createDefaultCaveCapabilityRegistry, + type CaveCapabilityRegistry, + type CavePrivilegedActionClass, +} from './privileged-capabilities.js'; +import { + parseCaveAttentionResponseRequest, + parseCaveTaskHandoffRequest, + type CaveAttentionResponseRequest, + type CaveTaskHandoffRequest, +} from './attention-handoff.js'; +import { + parseCaveGitHubActionRequest, + type CaveGitHubActionRequest, +} from './github-actions.js'; import { forgetStoredCredential, inspectStoredCredentialMaterial, @@ -137,6 +161,12 @@ export interface CaveManagedNativeCredentialCustody { interface CaveClientOptionsBase { operation?: OperationDefaults; + /** + * Capability registry for the privileged authority tiers. Defaults to the + * registry derived from the pinned contract fixture, under which every + * privileged action class resolves `undeclared`. + */ + capabilities?: CaveCapabilityRegistry; } interface CaveClientOptionsWithoutCredentials extends CaveClientOptionsBase { @@ -472,6 +502,7 @@ interface CapturedCaveClientOptions { operation: OperationDefaults | undefined; credentials: CaveCredentialBinding | undefined; credentialCustody: { mode: unknown } | undefined; + capabilities: CaveCapabilityRegistry | undefined; } function captureCaveClientOptions( @@ -482,6 +513,7 @@ function captureCaveClientOptions( 'operation', 'credentials', 'credentialCustody', + 'capabilities', ]); if (options === undefined || !Object.hasOwn(options, 'transport')) { return undefined; @@ -542,6 +574,10 @@ function captureCaveClientOptions( operation, credentials, credentialCustody, + capabilities: + options.capabilities === undefined + ? undefined + : (options.capabilities as CaveCapabilityRegistry), }); } @@ -1780,6 +1816,7 @@ export class CaveClient { readonly #credentials: CaveCredentialBinding | undefined; readonly #managedCredentialTransport: CaveManagedCredentialTransport | undefined; readonly #stagedManagedCredentialTransport: CaveStagedManagedCredentialTransport | undefined; + readonly #capabilities: CaveCapabilityRegistry; constructor(options: CaveClientOptions) { const captured = captureCaveClientOptions(options); @@ -1798,10 +1835,18 @@ export class CaveClient { ) { throw new TypeError('Managed native credential custody cannot use a JavaScript SecretStore.'); } + if ( + captured.capabilities !== undefined && + typeof captured.capabilities.resolve !== 'function' + ) { + throw new TypeError('capabilities must be a CaveCapabilityRegistry.'); + } this.#transport = captured.transport; this.#operation = captured.operation; this.#credentials = captured.credentials; + this.#capabilities = + captured.capabilities ?? createDefaultCaveCapabilityRegistry(); this.#managedCredentialTransport = captured.credentialCustody?.mode === 'managed-native' ? captured.transport as CaveManagedCredentialTransport @@ -3331,6 +3376,40 @@ export class CaveClient { } } + /** + * Shared privileged-action dispatch. Request validation has already + * happened by the time this runs; the capability gate resolves the action + * class against the consulted contract on every call and reports + * `unsupported_operation` with zero transport dispatch for an undeclared + * class. The operation UUID is attached to every client error. + */ + async #privilegedMutation( + operation: string, + actionClass: CavePrivilegedActionClass, + operationId: string, + options: OperationOptions, + executor: (context: OperationContext) => Promise, + ): Promise { + try { + return await this.#execute( + operation, + options, + async (context) => { + this.#ensureActive(context, operation); + const resolution = this.#capabilities.resolve(actionClass); + if (resolution.status !== 'declared') { + throw unsupported(operation); + } + return await executor(context); + }, + true, + this.#usesManagedCredentialTransport(), + ); + } catch (error) { + throw attachConversationOperationId(error, operationId); + } + } + #parseConversationResponse(operation: string, parse: () => T): T { try { return parse(); @@ -3671,6 +3750,167 @@ export class CaveClient { }, ); } + + /** + * Bounded attachment upload. The request is validated fail closed (file + * count, size, request size, MIME/signature agreement, filename, symlink, + * and the atomic uploader-credential-plus-conversation binding) before any + * capability or transport work, so a validation rejection performs zero + * domain mutation. Under the pinned contract the attachment capability is + * undeclared and this reports `unsupported_operation`. + */ + async uploadAttachment( + request: CaveAttachmentUploadRequest, + options: OperationOptions = {}, + ): Promise { + const validated = parseCaveAttachmentUploadRequest(request); + + return await this.#privilegedMutation( + 'uploadAttachment', + 'attachment-transfer', + validated.operationId, + options, + async (context) => { + const call = this.#transport.uploadAttachment?.bind(this.#transport); + if (call === undefined) { + throw unsupported('uploadAttachment'); + } + const response = this.#managedSnapshot( + await call(validated, context), + 'uploadAttachment', + ); + try { + return parseCaveAttachmentRecord(response); + } catch (error) { + if (error instanceof CaveAttachmentSchemaError) { + throw invalidCanonicalResponse('uploadAttachment', error.field); + } + throw error; + } + }, + ); + } + + /** + * Bounded attachment download. The request carries the byte ceiling; the + * canonical record is the validated result metadata. Under the pinned + * contract this reports `unsupported_operation` before any transport work. + */ + async downloadAttachment( + request: CaveAttachmentDownloadRequest, + options: OperationOptions = {}, + ): Promise { + const validated = parseCaveAttachmentDownloadRequest(request); + + return await this.#privilegedMutation( + 'downloadAttachment', + 'attachment-transfer', + validated.operationId, + options, + async (context) => { + const call = this.#transport.downloadAttachment?.bind(this.#transport); + if (call === undefined) { + throw unsupported('downloadAttachment'); + } + const response = this.#managedSnapshot( + await call(validated, context), + 'downloadAttachment', + ); + try { + return parseCaveAttachmentRecord(response); + } catch (error) { + if (error instanceof CaveAttachmentSchemaError) { + throw invalidCanonicalResponse('downloadAttachment', error.field); + } + throw error; + } + }, + ); + } + + /** + * One attention response with a closed response-kind union and a bounded + * optional note. Validation failure performs zero domain mutation; under + * the pinned contract the attention capability is undeclared and this + * reports `unsupported_operation`. + */ + async respondToAttention( + request: CaveAttentionResponseRequest, + options: OperationOptions = {}, + ): Promise { + const validated = parseCaveAttentionResponseRequest(request); + + return await this.#privilegedMutation( + 'respondToAttention', + 'attention-response', + validated.operationId, + options, + async (context) => { + const call = this.#transport.respondToAttention?.bind(this.#transport); + if (call === undefined) { + throw unsupported('respondToAttention'); + } + await call(validated, context); + }, + ); + } + + /** + * One task-handoff transition. The declared transition map keeps + * proposed, pending, completed, rejected, and failed strictly distinct; + * an illegal or skipped transition is a configuration error before any + * capability or transport work. Under the pinned contract this reports + * `unsupported_operation`. + */ + async requestTaskHandoff( + request: CaveTaskHandoffRequest, + options: OperationOptions = {}, + ): Promise { + const validated = parseCaveTaskHandoffRequest(request); + + return await this.#privilegedMutation( + 'requestTaskHandoff', + 'task-handoff', + validated.operationId, + options, + async (context) => { + const call = this.#transport.requestTaskHandoff?.bind(this.#transport); + if (call === undefined) { + throw unsupported('requestTaskHandoff'); + } + await call(validated, context); + }, + ); + } + + /** + * One explicitly confirmed GitHub action. The curated action union is + * empty under the pinned contract, so every request is rejected during + * request parsing — before any capability or transport work — and zero + * domain mutation is possible. When the upstream producer contract + * curates the union, the confirmed request flows through the capability + * gate to Cave, which revalidates confirmation, scope, grant, and bounds. + */ + async submitGitHubAction( + request: CaveGitHubActionRequest, + options: OperationOptions = {}, + ): Promise { + const validated = parseCaveGitHubActionRequest(request); + + return await this.#privilegedMutation( + 'submitGitHubAction', + 'github-action', + validated.operationId, + options, + async (context) => { + const call = this.#transport.submitGitHubAction?.bind(this.#transport); + if (call === undefined) { + throw unsupported('submitGitHubAction'); + } + await call(validated, context); + }, + ); + } } export function createCaveClient(options: CaveClientOptions): CaveClient { diff --git a/packages/cave/src/github-actions.ts b/packages/cave/src/github-actions.ts new file mode 100644 index 0000000..60936ee --- /dev/null +++ b/packages/cave/src/github-actions.ts @@ -0,0 +1,141 @@ +import { OperationConfigurationError } from '@opencoven/sdk-core/browser'; + +import { + parsePrivilegedConfirmation, + validatePrivilegedOperationId, +} from './privileged-capabilities.js'; + +/** + * Explicitly confirmed GitHub actions for the privileged authority tier. + * + * The curated action union is deliberately EMPTY: the authoritative Cave + * fixture pinned at `4adc97b1` declares the `github:write` pairing scope but + * no GitHub operation and no GitHub capability family, and no reviewed + * producer contract has curated which GitHub actions exist. Naming concrete + * action kinds here would fabricate a curation nobody reviewed, so the union + * ships closed (`CaveGitHubActionKind` is `never`) and every request is + * rejected before any capability or transport work — fail closed by + * construction. When the upstream Cave producer contract curates the union, + * `CAVE_GITHUB_ACTION_KINDS` gains its reviewed members and this module's + * validation machinery (exact confirmation, operation-UUID idempotency, + * bounded input) applies unchanged. + * + * Cave revalidates confirmation, scope, repository/project grant, and input + * bounds regardless of client confirmation. + * + * This module is import-pure: no discovery, credential, filesystem, network, + * or daemon I/O happens at import time. + */ + +/** + * The curated GitHub action union. Typed `readonly never[]` so that the + * kind type itself is uninhabitable — fail closed at the type level. Empty + * pending the upstream producer contract; never extended by client-side + * guesswork. + */ +export const CAVE_GITHUB_ACTION_KINDS: readonly never[] = Object.freeze([]); + +export type CaveGitHubActionKind = (typeof CAVE_GITHUB_ACTION_KINDS)[number]; + +/** + * The shape every confirmed GitHub action request will take once the union + * is curated. With the union empty, `action` is uninhabitable and no request + * can be constructed — the type system itself refuses the mutation. + */ +export interface CaveGitHubActionRequest { + readonly operationId: string; + readonly confirmed: true; + readonly conversationId: string; + readonly action: CaveGitHubActionKind; + /** Bounded string-valued action input; structure owned by the union member. */ + readonly input: Readonly>; +} + +/** + * Parse one confirmed GitHub action request. Confirmation, the operation + * UUID, and the bounded shape are validated first; the action kind is then + * checked against the curated union, which is empty today, so every kind is + * rejected with the precise upstream gap — before any capability resolution + * or transport dispatch, guaranteeing zero domain mutation. + */ +export function parseCaveGitHubActionRequest( + value: unknown, +): CaveGitHubActionRequest { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new OperationConfigurationError( + 'submitGitHubAction request must be an object', + ); + } + const record = value as Record; + const allowed = new Set([ + 'operationId', + 'confirmed', + 'conversationId', + 'action', + 'input', + ]); + for (const key of Object.keys(record)) { + if (!allowed.has(key)) { + throw new OperationConfigurationError( + 'submitGitHubAction request has an unknown field', + ); + } + } + parsePrivilegedConfirmation({ confirmed: record.confirmed }); + const operationId = validatePrivilegedOperationId(record.operationId); + if ( + typeof record.conversationId !== 'string' || + record.conversationId.trim().length === 0 + ) { + throw new OperationConfigurationError( + 'submitGitHubAction conversationId must be a non-empty string', + ); + } + if ( + typeof record.input !== 'object' || + record.input === null || + Array.isArray(record.input) + ) { + throw new OperationConfigurationError( + 'submitGitHubAction input must be an object', + ); + } + for (const [key, entry] of Object.entries(record.input)) { + if (typeof entry !== 'string') { + throw new OperationConfigurationError( + 'submitGitHubAction input must contain only strings', + ); + } + if (key.length === 0 || key.length > 64) { + throw new OperationConfigurationError( + 'submitGitHubAction input keys must be at most 64 characters', + ); + } + if (entry.length > 256) { + throw new OperationConfigurationError( + 'submitGitHubAction input values must be at most 256 characters', + ); + } + } + if (typeof record.action !== 'string' || record.action.length === 0) { + throw new OperationConfigurationError( + 'submitGitHubAction requires an action from the curated union', + ); + } + // The curated union is empty: no kind is declared, so no request passes. + // The untrusted kind is never echoed back. + if ( + !(CAVE_GITHUB_ACTION_KINDS as readonly string[]).includes(record.action) + ) { + throw new OperationConfigurationError( + 'submitGitHubAction action is not declared by the curated union; the authoritative Cave contract declares no GitHub operations', + ); + } + return Object.freeze({ + operationId, + confirmed: true, + conversationId: record.conversationId, + action: record.action as CaveGitHubActionKind, + input: Object.freeze({ ...record.input }), + }); +} diff --git a/packages/cave/src/index.ts b/packages/cave/src/index.ts index db391b3..a222b63 100644 --- a/packages/cave/src/index.ts +++ b/packages/cave/src/index.ts @@ -77,6 +77,46 @@ export { createConversationEventTranslator, validateConversationEventCursor, } from './conversation-control.js'; +export { + CAVE_ATTACHMENT_CONTENT_TYPES, + CAVE_ATTACHMENT_LIMITS, + sniffCaveAttachmentContentType, + bindCaveAttachments, + parseCaveAttachmentDownloadRequest, + parseCaveAttachmentRecord, + parseCaveAttachmentUploadRequest, + CaveAttachmentSchemaError, +} from './attachment-transfer.js'; +export { + CAVE_PRIVILEGED_ACTION_CLASSES, + CAVE_PRIVILEGED_ACTION_REQUIREMENTS, + CAVE_DEFAULT_CAPABILITY_CONTRACT, + createCaveCapabilityRegistry, + createDefaultCaveCapabilityRegistry, + parsePrivilegedConfirmation, + validatePrivilegedOperationId, +} from './privileged-capabilities.js'; +export { + CAVE_RICH_CONTENT_LIMITS, + CAVE_RICH_CONTENT_URL_SCHEMES, + parseCaveRichContent, + serializeCaveRichContent, + collectCaveRichContentUrls, + parseCaveRichContentUrl, + CaveRichContentError, +} from './rich-content.js'; +export { + CAVE_TASK_HANDOFF_STATES, + CAVE_TASK_HANDOFF_TRANSITIONS, + CAVE_ATTENTION_RESPONSE_KINDS, + isCaveTaskHandoffTransition, + parseCaveTaskHandoffRequest, + parseCaveAttentionResponseRequest, +} from './attention-handoff.js'; +export { + CAVE_GITHUB_ACTION_KINDS, + parseCaveGitHubActionRequest, +} from './github-actions.js'; export type { CaveConversationEvent, CaveConversationEventBase, @@ -98,6 +138,40 @@ export type { CaveSendConversationMessageRequest, CaveSendConversationMessageResult, } from './conversation-control.js'; +export type { + CaveAttachmentBinding, + CaveAttachmentContent, + CaveAttachmentDescriptor, + CaveAttachmentDownloadRequest, + CaveAttachmentRecord, + CaveAttachmentUploadRequest, + CaveAttachmentContentType, +} from './attachment-transfer.js'; +export type { + CaveCapabilityRegistry, + CaveCapabilityResolution, + CaveCapabilityStatus, + CaveCapabilityContractSource, + CaveDeclaredOperationRef, + CavePrivilegedActionClass, + CavePrivilegedActionRequirement, +} from './privileged-capabilities.js'; +export type { + CaveRichContentDocument, + CaveRichContentBlock, + CaveRichContentInline, + CaveRichContentUrlScheme, +} from './rich-content.js'; +export type { + CaveTaskHandoffState, + CaveTaskHandoffRequest, + CaveAttentionResponseKind, + CaveAttentionResponseRequest, +} from './attention-handoff.js'; +export type { + CaveGitHubActionKind, + CaveGitHubActionRequest, +} from './github-actions.js'; export type { CaveAuthorityBinding, CaveAuthorityBoundPairingExchange, diff --git a/packages/cave/src/privileged-capabilities.ts b/packages/cave/src/privileged-capabilities.ts new file mode 100644 index 0000000..7263139 --- /dev/null +++ b/packages/cave/src/privileged-capabilities.ts @@ -0,0 +1,382 @@ +import { validateConversationOperationId } from './conversation-control.js'; +import type { + CaveContractOperation, +} from './contract-fixture.js'; +import type { CavePairingScope } from './schemas.js'; + +/** + * Privileged authority capabilities for the attachment, rich-content, + * attention, task-handoff, and GitHub action tiers. + * + * Every privileged action class is gated by a capability resolution derived + * from the authoritative Cave contract fixture this SDK vendors: an action + * class is actionable only when the contract declares at least one operation + * carrying the required scope. The pinned fixture (Cave `4adc97b1`) declares + * the privileged scope names for pairing (`attachments:write`, `tasks:write`, + * `github:write`, `chat:write`, `conversations:write`) but declares no + * operation that uses them, so every privileged resolution is `undeclared` + * today and the client reports `unsupported_operation` before any transport + * dispatch. Nothing here invents routes, capability families, or scope names: + * scope identifiers come from the fixture's pairing-scope list, and declared + * operations come from the fixture's operation table. + * + * Resolutions are computed per call from the consulted contract data and + * returned as frozen descriptors. No capability object is cached across + * grants: Cave remains the sole authority for grants, confirmation + * revalidation, idempotency, audit, and domain mutation. + * + * This module is import-pure: no discovery, credential, filesystem, network, + * or daemon I/O happens at import time. + */ + +export type CavePrivilegedActionClass = + | 'attachment-transfer' + | 'rich-content' + | 'attention-response' + | 'task-handoff' + | 'github-action'; + +export const CAVE_PRIVILEGED_ACTION_CLASSES = [ + 'attachment-transfer', + 'rich-content', + 'attention-response', + 'task-handoff', + 'github-action', +] as const; + +export interface CavePrivilegedActionRequirement { + readonly actionClass: CavePrivilegedActionClass; + /** Drawn only from the fixture-declared pairing scope vocabulary. */ + readonly requiredScope: CavePairingScope; + /** Every privileged action requires a direct, explicit confirmation. */ + readonly requiresConfirmation: true; + /** Idempotency is keyed by the caller-supplied 36-character operation UUID. */ + readonly idempotencyKey: 'operation-uuid'; +} + +/** + * The SDK-declared requirement mapping. Scope identifiers are the pairing + * scopes the authoritative fixture declares; the authoritative grant mapping + * is Cave's and is revalidated server-side regardless of these values. + */ +export const CAVE_PRIVILEGED_ACTION_REQUIREMENTS: Readonly< + Record +> = Object.freeze({ + 'attachment-transfer': Object.freeze({ + actionClass: 'attachment-transfer', + requiredScope: 'attachments:write', + requiresConfirmation: true, + idempotencyKey: 'operation-uuid', + }), + 'rich-content': Object.freeze({ + actionClass: 'rich-content', + requiredScope: 'chat:write', + requiresConfirmation: true, + idempotencyKey: 'operation-uuid', + }), + 'attention-response': Object.freeze({ + actionClass: 'attention-response', + requiredScope: 'conversations:write', + requiresConfirmation: true, + idempotencyKey: 'operation-uuid', + }), + 'task-handoff': Object.freeze({ + actionClass: 'task-handoff', + requiredScope: 'tasks:write', + requiresConfirmation: true, + idempotencyKey: 'operation-uuid', + }), + 'github-action': Object.freeze({ + actionClass: 'github-action', + requiredScope: 'github:write', + requiresConfirmation: true, + idempotencyKey: 'operation-uuid', + }), +}); + +export interface CaveDeclaredOperationRef { + readonly id: string; + readonly method: string; + readonly path: string; + readonly scope: string | null; +} + +export type CaveCapabilityStatus = 'declared' | 'undeclared'; + +export interface CaveCapabilityResolution { + readonly actionClass: CavePrivilegedActionClass; + readonly status: CaveCapabilityStatus; + readonly requirement: CavePrivilegedActionRequirement; + /** + * The capability families the consulted contract declares. The pinned + * fixture declares none of the privileged families. + */ + readonly declaredCapabilities: readonly string[]; + /** + * The operations the consulted contract declares with the required scope. + * Empty for every privileged class under the pinned fixture. + */ + readonly declaredOperations: readonly CaveDeclaredOperationRef[]; +} + +export interface CaveCapabilityRegistry { + resolve(actionClass: CavePrivilegedActionClass): CaveCapabilityResolution; +} + +export interface CaveCapabilityContractSource { + readonly capabilities: readonly string[]; + readonly operations: readonly CaveContractOperation[]; +} + +type JsonObject = Record; + +function isObject(value: unknown): value is JsonObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isCavePrivilegedActionClass(value: unknown): value is CavePrivilegedActionClass { + return ( + typeof value === 'string' && + Object.prototype.hasOwnProperty.call( + CAVE_PRIVILEGED_ACTION_REQUIREMENTS, + value, + ) + ); +} + +function frozenOperationRefs( + operations: readonly CaveContractOperation[], + requiredScope: string, +): readonly CaveDeclaredOperationRef[] { + const declared: CaveDeclaredOperationRef[] = []; + for (const operation of operations) { + if (operation.scope !== requiredScope) { + continue; + } + declared.push( + Object.freeze({ + id: operation.id, + method: operation.method, + path: operation.path, + scope: operation.scope, + }), + ); + } + return Object.freeze(declared); +} + +/** + * Build a capability registry from a parsed (preferably digest-verified) + * Client v1 contract fixture. Resolution consults the operation table on + * every call: an action class is `declared` only when the contract declares + * at least one operation carrying the required scope. + */ +export function createCaveCapabilityRegistry( + contract: CaveCapabilityContractSource, +): CaveCapabilityRegistry { + if (!isObject(contract)) { + throw new TypeError('capability contract source must be an object'); + } + if (!Array.isArray(contract.capabilities)) { + throw new TypeError('capability contract source capabilities must be an array'); + } + if (!Array.isArray(contract.operations)) { + throw new TypeError('capability contract source operations must be an array'); + } + + // The runtime checks above defend the JS boundary; re-type the validated + // data explicitly so the frozen copies below are well typed. + const capabilities = Object.freeze([ + ...(contract.capabilities as readonly string[]), + ]); + const operations = Object.freeze([ + ...(contract.operations as readonly CaveContractOperation[]), + ]); + + return { + resolve(actionClass: CavePrivilegedActionClass): CaveCapabilityResolution { + if (!isCavePrivilegedActionClass(actionClass)) { + throw new TypeError('unknown privileged action class'); + } + const requirement = CAVE_PRIVILEGED_ACTION_REQUIREMENTS[actionClass]; + const declaredOperations = frozenOperationRefs( + operations, + requirement.requiredScope, + ); + return Object.freeze({ + actionClass, + status: declaredOperations.length > 0 ? 'declared' : 'undeclared', + requirement, + declaredCapabilities: capabilities, + declaredOperations, + }); + }, + }; +} + +/** + * The default capability source: the operation table of the authoritative + * fixture pinned at Cave `4adc97b1` (digest `b2694cd1…`). Tests assert this + * snapshot matches the vendored fixture exactly, so a fixture re-import + * forces a reviewed update here. Under this contract every privileged action + * class resolves `undeclared`. + */ +export const CAVE_DEFAULT_CAPABILITY_CONTRACT: CaveCapabilityContractSource = + Object.freeze({ + capabilities: Object.freeze([ + 'health', + 'pairing', + 'credentials', + 'familiars', + 'projects', + 'conversations', + 'conversation-messages', + 'cursors', + ]), + operations: Object.freeze([ + Object.freeze({ + id: 'health.read', + families: Object.freeze(['health']), + ingress: 'public', + method: 'GET', + path: '/api/client/v1/health', + scope: null, + }), + Object.freeze({ + id: 'pairing.create', + families: Object.freeze(['pairing']), + ingress: 'public', + method: 'POST', + path: '/api/client/v1/pairing/requests', + scope: null, + }), + Object.freeze({ + id: 'pairing.poll', + families: Object.freeze(['pairing']), + ingress: 'public', + method: 'GET', + path: '/api/client/v1/pairing/requests/:id', + scope: null, + }), + Object.freeze({ + id: 'pairing.exchange', + families: Object.freeze(['pairing']), + ingress: 'public', + method: 'POST', + path: '/api/client/v1/pairing/requests/:id/exchange', + scope: null, + }), + Object.freeze({ + id: 'pairing.admin.list', + families: Object.freeze(['pairing']), + ingress: 'admin', + method: 'GET', + path: '/api/client/v1/admin/pairing-requests', + scope: null, + }), + Object.freeze({ + id: 'pairing.admin.decide', + families: Object.freeze(['pairing']), + ingress: 'admin', + method: 'POST', + path: '/api/client/v1/admin/pairing-requests/:id/decision', + scope: null, + }), + Object.freeze({ + id: 'credentials.admin.list', + families: Object.freeze(['credentials']), + ingress: 'admin', + method: 'GET', + path: '/api/client/v1/admin/credentials', + scope: null, + }), + Object.freeze({ + id: 'credentials.admin.revoke', + families: Object.freeze(['credentials']), + ingress: 'admin', + method: 'DELETE', + path: '/api/client/v1/admin/credentials/:id', + scope: null, + }), + Object.freeze({ + id: 'familiars.list', + families: Object.freeze(['familiars', 'cursors']), + ingress: 'authenticated', + method: 'GET', + path: '/api/client/v1/familiars', + scope: 'chat:read', + }), + Object.freeze({ + id: 'projects.list', + families: Object.freeze(['projects', 'cursors']), + ingress: 'authenticated', + method: 'GET', + path: '/api/client/v1/projects', + scope: 'chat:read', + }), + Object.freeze({ + id: 'conversations.list', + families: Object.freeze(['conversations', 'cursors']), + ingress: 'authenticated', + method: 'GET', + path: '/api/client/v1/conversations', + scope: 'chat:read', + }), + Object.freeze({ + id: 'conversations.read', + families: Object.freeze(['conversations']), + ingress: 'authenticated', + method: 'GET', + path: '/api/client/v1/conversations/:id', + scope: 'chat:read', + }), + Object.freeze({ + id: 'messages.list', + families: Object.freeze(['conversation-messages', 'cursors']), + ingress: 'authenticated', + method: 'GET', + path: '/api/client/v1/conversations/:id/messages', + scope: 'chat:read', + }), + ]), + }); + +/** + * The default registry every `CaveClient` uses when no explicit registry is + * supplied. Under the pinned fixture all privileged action classes resolve + * `undeclared`. + */ +export function createDefaultCaveCapabilityRegistry(): CaveCapabilityRegistry { + return createCaveCapabilityRegistry(CAVE_DEFAULT_CAPABILITY_CONTRACT); +} + +const CONFIRMATION_KEYS = new Set(['confirmed']); + +/** + * A privileged action carries a direct, explicit confirmation: exactly one + * `confirmed` field whose value is the literal `true`. Anything else — a + * missing field, `false`, a string, a truthy object — is a configuration + * error raised before any capability or transport work. + */ +export function parsePrivilegedConfirmation(value: unknown): true { + if (!isObject(value)) { + throw new TypeError('privileged confirmation must be an object'); + } + const keys = Object.keys(value); + if (keys.length !== 1 || !CONFIRMATION_KEYS.has(keys[0] ?? '')) { + throw new TypeError('privileged confirmation must contain exactly confirmed'); + } + if (value.confirmed !== true) { + throw new TypeError('privileged actions require confirmed to be exactly true'); + } + return true; +} + +/** + * Privileged actions key idempotency with the same Client v1 operation UUID + * contract as conversational control: exactly 36 characters, RFC-compatible, + * normalized to lowercase. + */ +export function validatePrivilegedOperationId(value: unknown): string { + return validateConversationOperationId(value); +} diff --git a/packages/cave/src/rich-content.ts b/packages/cave/src/rich-content.ts new file mode 100644 index 0000000..46d7cfc --- /dev/null +++ b/packages/cave/src/rich-content.ts @@ -0,0 +1,501 @@ +/** + * Passive rich content: a strict, non-executable AST for message payloads. + * + * The parser accepts only the closed node vocabulary below, with exact keys + * and bounded sizes. Raw HTML is never interpreted: markup-looking text is + * preserved byte for byte inside inert text nodes, there is no HTML node + * type, and no node carries event handlers or executable attributes. Link + * targets are restricted to `https:` and `mailto:` schemes, so no unsafe + * target can be produced from a parsed document. Unknown node types, + * unknown fields, oversized payloads, and over-deep nesting are rejected + * (fail closed). + * + * Upstream-contract gap (stated, not invented): the authoritative Cave + * fixture pinned at `4adc97b1` declares no rich-content capability family + * and no route that would carry rich payloads. This module defines the + * SDK-side consumer half — the parsing and validation model — so that the + * deferred producer contract can only ever deliver inert content through + * it. The node vocabulary is SDK-owned and closed; extending it requires a + * reviewed change here, not data from the wire. + * + * This module is import-pure: no discovery, credential, filesystem, network, + * or daemon I/O happens at import time. + */ + +export const CAVE_RICH_CONTENT_LIMITS = Object.freeze({ + /** Maximum total nodes in one document. */ + maxNodes: 512, + /** Maximum nesting depth (the document itself is depth 0). */ + maxDepth: 24, + /** Maximum characters in one text or code node. */ + maxTextCharacters: 8192, + /** Maximum characters across all text and code nodes of one document. */ + maxTotalCharacters: 65536, + /** Maximum characters in one link target. */ + maxUrlCharacters: 2048, + /** Maximum characters in one code language tag. */ + maxLanguageCharacters: 32, + /** Maximum characters in one link title. */ + maxTitleCharacters: 256, +}); + +export type CaveRichContentUrlScheme = 'https' | 'mailto'; + +export const CAVE_RICH_CONTENT_URL_SCHEMES: readonly CaveRichContentUrlScheme[] = + Object.freeze(['https', 'mailto']); + +export interface CaveRichContentText { + readonly type: 'text'; + readonly text: string; +} + +export interface CaveRichContentCode { + readonly type: 'code'; + readonly text: string; +} + +export interface CaveRichContentLineBreak { + readonly type: 'lineBreak'; +} + +export interface CaveRichContentLink { + readonly type: 'link'; + /** Always an `https:` or `mailto:` target; everything else is rejected. */ + readonly href: string; + readonly title?: string; + readonly children: readonly (CaveRichContentText | CaveRichContentCode | CaveRichContentLineBreak)[]; +} + +export type CaveRichContentInline = + | CaveRichContentText + | CaveRichContentCode + | CaveRichContentLink + | CaveRichContentLineBreak; + +export interface CaveRichContentParagraph { + readonly type: 'paragraph'; + readonly children: readonly CaveRichContentInline[]; +} + +export interface CaveRichContentHeading { + readonly type: 'heading'; + readonly level: 1 | 2 | 3 | 4 | 5 | 6; + readonly children: readonly CaveRichContentInline[]; +} + +export interface CaveRichContentCodeBlock { + readonly type: 'codeBlock'; + readonly language?: string; + readonly text: string; +} + +export interface CaveRichContentQuote { + readonly type: 'blockquote'; + readonly children: readonly CaveRichContentBlock[]; +} + +export interface CaveRichContentList { + readonly type: 'list'; + readonly ordered: boolean; + readonly children: readonly CaveRichContentListItem[]; +} + +export interface CaveRichContentListItem { + readonly type: 'listItem'; + readonly children: readonly CaveRichContentBlock[]; +} + +export type CaveRichContentBlock = + | CaveRichContentParagraph + | CaveRichContentHeading + | CaveRichContentCodeBlock + | CaveRichContentQuote + | CaveRichContentList; + +export interface CaveRichContentDocument { + readonly type: 'doc'; + readonly children: readonly CaveRichContentBlock[]; +} + +export class CaveRichContentError extends TypeError { + readonly field: string; + + constructor(field: string) { + super(`${field} was rejected by the passive rich-content model.`); + this.name = 'CaveRichContentError'; + this.field = field; + } +} + +type JsonObject = Record; + +class ParseCounter { + nodes = 0; + totalCharacters = 0; +} + +function isObject(value: unknown): value is JsonObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function node(value: unknown, field: string): JsonObject { + if (!isObject(value)) { + throw new CaveRichContentError(field); + } + return value; +} + +function rejectUnknownKeys( + value: JsonObject, + allowed: ReadonlySet, + field: string, +): void { + for (const key of Object.keys(value)) { + if (!allowed.has(key)) { + throw new CaveRichContentError(`${field}.${key}`); + } + } +} + +function textContent(value: unknown, field: string): string { + if (typeof value !== 'string') { + throw new CaveRichContentError(field); + } + if (value.length > CAVE_RICH_CONTENT_LIMITS.maxTextCharacters) { + throw new CaveRichContentError(field); + } + for (const character of value) { + const code = character.charCodeAt(0); + // Control characters other than tab, newline, and carriage return are + // malformed input, not content. + if ( + (code < 0x20 && code !== 0x09 && code !== 0x0a && code !== 0x0d) || + code === 0x7f + ) { + throw new CaveRichContentError(field); + } + } + return value; +} + +function languageTag(value: unknown, field: string): string | undefined { + if (value === undefined) { + return undefined; + } + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > CAVE_RICH_CONTENT_LIMITS.maxLanguageCharacters || + !/^[a-z0-9+#._-]+$/u.test(value) + ) { + throw new CaveRichContentError(field); + } + return value; +} + +function linkTitle(value: unknown, field: string): string | undefined { + if (value === undefined) { + return undefined; + } + if (typeof value !== 'string') { + throw new CaveRichContentError(field); + } + if ( + value.length === 0 || + value.length > CAVE_RICH_CONTENT_LIMITS.maxTitleCharacters + ) { + throw new CaveRichContentError(field); + } + for (const character of value) { + const code = character.charCodeAt(0); + if ((code < 0x20 && code !== 0x09) || code === 0x7f) { + throw new CaveRichContentError(field); + } + } + return value; +} + +/** + * Link targets must carry an `https:` or `mailto:` scheme. Every other + * scheme — `javascript:`, `data:`, `file:`, `vbscript:`, scheme-less + * relative targets — is rejected, so a parsed document can never carry an + * unsafe target. + */ +export function parseCaveRichContentUrl( + value: unknown, + field: string, +): string { + if (typeof value !== 'string' || value.length === 0) { + throw new CaveRichContentError(field); + } + if (value.length > CAVE_RICH_CONTENT_LIMITS.maxUrlCharacters) { + throw new CaveRichContentError(field); + } + for (const character of value) { + const code = character.charCodeAt(0); + // Control characters and whitespace are never valid in a target. + if (code <= 0x20 || code === 0x7f) { + throw new CaveRichContentError(field); + } + } + let parsed: URL; + try { + parsed = new URL(value); + } catch { + // Scheme-less or malformed targets are rejected, not resolved. + throw new CaveRichContentError(field); + } + const scheme = parsed.protocol.slice(0, -1); + if (scheme !== 'https' && scheme !== 'mailto') { + throw new CaveRichContentError(field); + } + if (scheme === 'https' && parsed.username.length > 0) { + // userinfo in an https target is a credential-leak pattern; reject. + throw new CaveRichContentError(field); + } + return parsed.toString(); +} + +function countNode(counter: ParseCounter, field: string): void { + counter.nodes += 1; + if (counter.nodes > CAVE_RICH_CONTENT_LIMITS.maxNodes) { + throw new CaveRichContentError(field); + } +} + +function parseInline(value: unknown, field: string, counter: ParseCounter): CaveRichContentInline { + const record = node(value, field); + countNode(counter, field); + const type = record.type; + if (type === 'text') { + rejectUnknownKeys(record, new Set(['type', 'text']), field); + const text = textContent(record.text, `${field}.text`); + counter.totalCharacters += text.length; + return Object.freeze({ type: 'text', text }); + } + if (type === 'code') { + rejectUnknownKeys(record, new Set(['type', 'text']), field); + const text = textContent(record.text, `${field}.text`); + counter.totalCharacters += text.length; + return Object.freeze({ type: 'code', text }); + } + if (type === 'lineBreak') { + rejectUnknownKeys(record, new Set(['type']), field); + return Object.freeze({ type: 'lineBreak' }); + } + if (type === 'link') { + rejectUnknownKeys( + record, + new Set(['type', 'href', 'title', 'children']), + field, + ); + const href = parseCaveRichContentUrl(record.href, `${field}.href`); + const title = linkTitle(record.title, `${field}.title`); + if (!Array.isArray(record.children)) { + throw new CaveRichContentError(`${field}.children`); + } + const children = record.children.map((child, index) => { + const parsed = parseInline(child, `${field}.children[${index}]`, counter); + // Links nest text, code, and line breaks only — never another link. + if (parsed.type === 'link') { + throw new CaveRichContentError(`${field}.children[${index}]`); + } + return parsed; + }); + return Object.freeze({ + type: 'link', + href, + ...(title === undefined ? {} : { title }), + children: Object.freeze(children), + }); + } + throw new CaveRichContentError(`${field}.type`); +} + +function parseBlock( + value: unknown, + field: string, + depth: number, + counter: ParseCounter, +): CaveRichContentBlock { + if (depth > CAVE_RICH_CONTENT_LIMITS.maxDepth) { + throw new CaveRichContentError(field); + } + const record = node(value, field); + countNode(counter, field); + const type = record.type; + if (type === 'paragraph') { + rejectUnknownKeys(record, new Set(['type', 'children']), field); + if (!Array.isArray(record.children)) { + throw new CaveRichContentError(`${field}.children`); + } + const children = record.children.map((child, index) => + parseInline(child, `${field}.children[${index}]`, counter), + ); + return Object.freeze({ type: 'paragraph', children: Object.freeze(children) }); + } + if (type === 'heading') { + rejectUnknownKeys(record, new Set(['type', 'level', 'children']), field); + const level = record.level; + if ( + typeof level !== 'number' || + !Number.isSafeInteger(level) || + level < 1 || + level > 6 + ) { + throw new CaveRichContentError(`${field}.level`); + } + if (!Array.isArray(record.children)) { + throw new CaveRichContentError(`${field}.children`); + } + const children = record.children.map((child, index) => + parseInline(child, `${field}.children[${index}]`, counter), + ); + return Object.freeze({ + type: 'heading', + level: level as 1 | 2 | 3 | 4 | 5 | 6, + children: Object.freeze(children), + }); + } + if (type === 'codeBlock') { + rejectUnknownKeys(record, new Set(['type', 'language', 'text']), field); + const language = languageTag(record.language, `${field}.language`); + const text = textContent(record.text, `${field}.text`); + counter.totalCharacters += text.length; + return Object.freeze({ + type: 'codeBlock', + ...(language === undefined ? {} : { language }), + text, + }); + } + if (type === 'blockquote') { + rejectUnknownKeys(record, new Set(['type', 'children']), field); + if (!Array.isArray(record.children)) { + throw new CaveRichContentError(`${field}.children`); + } + const children = record.children.map((child, index) => + parseBlock(child, `${field}.children[${index}]`, depth + 1, counter), + ); + return Object.freeze({ type: 'blockquote', children: Object.freeze(children) }); + } + if (type === 'list') { + rejectUnknownKeys(record, new Set(['type', 'ordered', 'children']), field); + if (typeof record.ordered !== 'boolean') { + throw new CaveRichContentError(`${field}.ordered`); + } + if (!Array.isArray(record.children)) { + throw new CaveRichContentError(`${field}.children`); + } + const children = record.children.map((child, index) => { + const item = node(child, `${field}.children[${index}]`); + countNode(counter, `${field}.children[${index}]`); + rejectUnknownKeys( + item, + new Set(['type', 'children']), + `${field}.children[${index}]`, + ); + if (item.type !== 'listItem') { + throw new CaveRichContentError(`${field}.children[${index}].type`); + } + if (!Array.isArray(item.children)) { + throw new CaveRichContentError(`${field}.children[${index}].children`); + } + const itemChildren = item.children.map((grandChild, grandIndex) => + parseBlock( + grandChild, + `${field}.children[${index}].children[${grandIndex}]`, + depth + 1, + counter, + ), + ); + return Object.freeze({ + type: 'listItem', + children: Object.freeze(itemChildren), + }); + }); + return Object.freeze({ + type: 'list', + ordered: record.ordered, + children: Object.freeze(children), + }); + } + throw new CaveRichContentError(`${field}.type`); +} + +/** + * Parse an untrusted rich-content payload into the strict inert AST. The + * parser is total over its closed vocabulary: unknown node types, unknown + * fields, executable markup declarations, unsafe link targets, oversized + * payloads, and over-deep nesting are all rejected. + */ +export function parseCaveRichContent(value: unknown): CaveRichContentDocument { + const record = node(value, 'doc'); + rejectUnknownKeys(record, new Set(['type', 'children']), 'doc'); + if (record.type !== 'doc') { + throw new CaveRichContentError('doc.type'); + } + if (!Array.isArray(record.children)) { + throw new CaveRichContentError('doc.children'); + } + const counter = new ParseCounter(); + const children = record.children.map((child, index) => + parseBlock(child, `children[${index}]`, 1, counter), + ); + if (counter.totalCharacters > CAVE_RICH_CONTENT_LIMITS.maxTotalCharacters) { + throw new CaveRichContentError('doc.totalCharacters'); + } + return Object.freeze({ type: 'doc', children: Object.freeze(children) }); +} + +/** + * Serialize a parsed document. Because the input type can only be produced + * by `parseCaveRichContent`, the output is inert by construction: it + * contains only the declared node types, never markup or event handlers. + */ +export function serializeCaveRichContent( + document: CaveRichContentDocument, +): string { + return JSON.stringify(document); +} + +/** + * Collect every link target of a parsed document. Every returned target has + * already passed the `https:`/`mailto:` allowlist during parsing. + */ +export function collectCaveRichContentUrls( + document: CaveRichContentDocument, +): string[] { + const urls: string[] = []; + const visitInline = (inline: CaveRichContentInline): void => { + if (inline.type === 'link') { + urls.push(inline.href); + for (const child of inline.children) { + visitInline(child); + } + return; + } + }; + const visitBlock = (block: CaveRichContentBlock): void => { + if (block.type === 'paragraph' || block.type === 'heading') { + for (const child of block.children) { + visitInline(child); + } + return; + } + if (block.type === 'blockquote' || block.type === 'list') { + for (const child of block.children) { + if (child.type === 'listItem') { + for (const grandChild of child.children) { + visitBlock(grandChild); + } + } else { + visitBlock(child); + } + } + } + }; + for (const child of document.children) { + visitBlock(child); + } + return urls; +} diff --git a/packages/cave/src/transport.ts b/packages/cave/src/transport.ts index 2d9790c..ff86b82 100644 --- a/packages/cave/src/transport.ts +++ b/packages/cave/src/transport.ts @@ -1,11 +1,20 @@ import type { OperationContext, PageOptions } from '@opencoven/sdk-core/browser'; +import type { + CaveAttentionResponseRequest, + CaveTaskHandoffRequest, +} from './attention-handoff.js'; +import type { + CaveAttachmentDownloadRequest, + CaveAttachmentUploadRequest, +} from './attachment-transfer.js'; import type { CaveConversationEventPageRequest, CaveConversationOperationId, CaveCreateConversationRequest, CaveSendConversationMessageRequest, } from './conversation-control.js'; +import type { CaveGitHubActionRequest } from './github-actions.js'; import type { CaveAuthorityBinding, CaveAuthorityBoundPairingExchange, @@ -91,6 +100,35 @@ export interface CaveTransport { operationId: CaveConversationOperationId, context?: OperationContext, ): Promise; + /** + * Privileged authority is optional for every transport. The attachment, + * attention, task-handoff, and GitHub action operations are not declared + * by the authoritative Cave contract fixture this SDK vendors, so no + * transport binds them today; the client gates every privileged call on + * the capability registry first and reports `unsupported_operation` + * rather than inventing a route. Results are `unknown` at this trust + * boundary and are validated by the client. + */ + uploadAttachment?( + request: CaveAttachmentUploadRequest, + context?: OperationContext, + ): Promise; + downloadAttachment?( + request: CaveAttachmentDownloadRequest, + context?: OperationContext, + ): Promise; + respondToAttention?( + request: CaveAttentionResponseRequest, + context?: OperationContext, + ): Promise; + requestTaskHandoff?( + request: CaveTaskHandoffRequest, + context?: OperationContext, + ): Promise; + submitGitHubAction?( + request: CaveGitHubActionRequest, + context?: OperationContext, + ): Promise; /** * The familiar operations are optional so that a transport written against * an older Cave still satisfies this interface. The client reports a missing diff --git a/tests/cave-attachment-transfer.spec.ts b/tests/cave-attachment-transfer.spec.ts new file mode 100644 index 0000000..8f4d49c --- /dev/null +++ b/tests/cave-attachment-transfer.spec.ts @@ -0,0 +1,328 @@ +import { createHash } from 'node:crypto'; + +import { + CAVE_ATTACHMENT_LIMITS, + bindCaveAttachments, + parseCaveAttachmentDownloadRequest, + parseCaveAttachmentRecord, + parseCaveAttachmentUploadRequest, + sniffCaveAttachmentContentType, + type CaveAttachmentContent, +} from '@opencoven/cave-client'; +import { OperationConfigurationError } from '@opencoven/sdk-core/browser'; +import { describe, expect, test } from 'vitest'; + +const OPERATION_ID = '018f4f1a-77c2-7a31-8a15-55a25aaba001'; + +function png(): Uint8Array { + // Minimal PNG magic followed by filler content. + return new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 73, 72, 68, 82, + ]); +} + +function jpeg(): Uint8Array { + return new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]); +} + +function elf(): Uint8Array { + return new Uint8Array([0x7f, 0x45, 0x4c, 0x46, 0x02, 0x01, 0x01, 0x00]); +} + +function text(content = 'hello world\n'): Uint8Array { + return new TextEncoder().encode(content); +} + +function webp(): Uint8Array { + const bytes = new Uint8Array(12); + bytes.set([0x52, 0x49, 0x46, 0x46], 0); // "RIFF" + bytes.set([0x57, 0x45, 0x42, 0x50], 8); // "WEBP" at offset 8 + return bytes; +} + +function attachment(overrides: Record = {}): CaveAttachmentContent { + return { + filename: 'notes.txt', + contentType: 'text/plain', + content: text(), + ...overrides, + }; +} + +function digestOf(content: Uint8Array): string { + return createHash('sha256').update(content).digest('hex'); +} + +describe('attachment content signatures', () => { + test('the canonical byte digest belongs to Cave, not the SDK', () => { + // Descriptors carry no digest field: Cave hashes the bytes server-side + // where they land. The record parser validates the digest string only. + const binding = bindCaveAttachments('conversation.v1', 'credential.v1', [ + attachment(), + ]); + expect(JSON.stringify(binding)).not.toContain('digest'); + }); + test('sniffs every approved signature', () => { + expect(sniffCaveAttachmentContentType(png())).toBe('image/png'); + expect(sniffCaveAttachmentContentType(jpeg())).toBe('image/jpeg'); + expect(sniffCaveAttachmentContentType(new TextEncoder().encode('GIF87a'))).toBe( + 'image/gif', + ); + expect(sniffCaveAttachmentContentType(new TextEncoder().encode('GIF89a'))).toBe( + 'image/gif', + ); + expect(sniffCaveAttachmentContentType(webp())).toBe('image/webp'); + expect(sniffCaveAttachmentContentType(new TextEncoder().encode('%PDF-1.7\n'))).toBe( + 'application/pdf', + ); + expect(sniffCaveAttachmentContentType(text())).toBe('text/plain'); + expect( + sniffCaveAttachmentContentType(new TextEncoder().encode('multi\nline étext\n')), + ).toBe('text/plain'); + }); + + test('rejects executable and unknown signatures as unmatched', () => { + expect(sniffCaveAttachmentContentType(elf())).toBeUndefined(); + expect(sniffCaveAttachmentContentType(new Uint8Array([0x00, 0x01, 0x02]))).toBeUndefined(); + // "RIFF" without the "WEBP" container magic is not WebP. + expect( + sniffCaveAttachmentContentType(new TextEncoder().encode('RIFFxxxxxxxx')), + ).toBeUndefined(); + }); +}); + +describe('attachment upload validation fails closed', () => { + test('rejects spoofed MIME declarations', () => { + for (const spoofed of [ + attachment({ filename: 'evil.png', contentType: 'image/png', content: elf() }), + attachment({ filename: 'evil.png', contentType: 'image/png', content: jpeg() }), + attachment({ filename: 'evil.bin', contentType: 'application/octet-stream' }), + attachment({ filename: 'evil.svg', contentType: 'image/svg+xml', content: text('') }), + attachment({ filename: 'evil.zip', contentType: 'application/zip', content: elf() }), + attachment({ filename: 'evil.sh', contentType: 'text/x-shellscript' }), + ]) { + expect(() => + bindCaveAttachments('conversation.v1', 'credential.v1', [spoofed]), + ).toThrowError(OperationConfigurationError); + } + }); + + test('rejects traversal and hostile filenames', () => { + for (const filename of [ + '../etc/passwd', + '..\\windows\\system32', + 'a/b.txt', + '.', + '..', + '.hidden.txt', + 'trailing.', + 'trailing ', + 'with\0null.txt', + 'bell\x07.txt', + '', + 'x'.repeat(CAVE_ATTACHMENT_LIMITS.maxFilenameCharacters + 1), + ]) { + expect(() => + bindCaveAttachments('conversation.v1', 'credential.v1', [ + attachment({ filename }), + ]), + ).toThrowError(OperationConfigurationError); + } + + expect(() => + bindCaveAttachments('conversation.v1', 'credential.v1', [attachment()]), + ).not.toThrowError(); + }); + + test('rejects symlinked sources fail closed', () => { + expect(() => + bindCaveAttachments('conversation.v1', 'credential.v1', [ + attachment({ symlink: true }), + ]), + ).toThrowError(OperationConfigurationError); + }); + + test('enforces file count, file size, and request size limits', () => { + const tooMany = Array.from( + { length: CAVE_ATTACHMENT_LIMITS.maxFiles + 1 }, + () => attachment(), + ); + expect(() => + bindCaveAttachments('conversation.v1', 'credential.v1', tooMany), + ).toThrowError(OperationConfigurationError); + + const oversized = new Uint8Array(CAVE_ATTACHMENT_LIMITS.maxFileBytes + 1); + expect(() => + bindCaveAttachments('conversation.v1', 'credential.v1', [ + attachment({ content: oversized }), + ]), + ).toThrowError(OperationConfigurationError); + + // Valid text attachments whose combined size exceeds the request limit. + const chunk = new TextEncoder().encode( + 'a'.repeat(Math.floor(CAVE_ATTACHMENT_LIMITS.maxRequestBytes / 3)), + ); + const nearLimit = Array.from( + { length: 4 }, + () => attachment({ content: chunk }), + ); + expect(() => + bindCaveAttachments('conversation.v1', 'credential.v1', nearLimit), + ).toThrowError(OperationConfigurationError); + }); + + test('binds uploader credential and conversation atomically', () => { + const binding = bindCaveAttachments('conversation.v1', 'credential.v1', [ + attachment(), + attachment({ filename: 'img.png', contentType: 'image/png', content: png() }), + ]); + + expect(binding.conversationId).toBe('conversation.v1'); + expect(binding.uploaderCredentialId).toBe('credential.v1'); + expect(binding.attachments.length).toBe(2); + expect(binding.totalBytes).toBe(text().length + png().length); + expect(binding.attachments[0]?.sizeBytes).toBe(text().length); + expect(binding.attachments[1]?.sizeBytes).toBe(png().length); + expect(Object.keys(binding.attachments[1] ?? {}).sort()).toEqual([ + 'contentType', + 'filename', + 'sizeBytes', + ]); + + // Missing owner fields reject the whole binding. + for (const [conversationId, credentialId] of [ + ['', 'credential.v1'], + ['conversation.v1', ''], + [undefined, 'credential.v1'], + ['conversation.v1', undefined], + ] as const) { + expect(() => + bindCaveAttachments(conversationId, credentialId, [attachment()]), + ).toThrowError(OperationConfigurationError); + } + }); + + test('the binding is metadata-only: attachment bytes never serialize', () => { + const binding = bindCaveAttachments('conversation.v1', 'credential.v1', [ + attachment(), + ]); + const serialized = JSON.stringify(binding); + expect(serialized).not.toContain('"content"'); + expect(serialized).not.toContain('"bytes"'); + // The serialized form is valid JSON with only descriptor fields. + const parsed = JSON.parse(serialized) as { attachments: Array> }; + for (const descriptor of parsed.attachments) { + expect(Object.keys(descriptor).sort()).toEqual([ + 'contentType', + 'filename', + 'sizeBytes', + ]); + } + }); +}); + +describe('attachment request parsers', () => { + test('parses a valid upload request and normalizes the operation id', () => { + const parsed = parseCaveAttachmentUploadRequest({ + operationId: OPERATION_ID.toUpperCase(), + confirmed: true, + conversationId: 'conversation.v1', + uploaderCredentialId: 'credential.v1', + attachments: [attachment()], + }); + expect(parsed.operationId).toBe(OPERATION_ID); + expect(parsed.confirmed).toBe(true); + expect(parsed.attachments.length).toBe(1); + expect(parsed.attachments[0]?.filename).toBe('notes.txt'); + }); + + test('rejects malformed upload requests before any capability or transport work', () => { + const base = { + operationId: OPERATION_ID, + confirmed: true, + conversationId: 'conversation.v1', + uploaderCredentialId: 'credential.v1', + attachments: [attachment()], + }; + for (const malformed of [ + { ...base, confirmed: false }, + { ...base, confirmed: 'true' }, + { ...base, operationId: 'not-a-uuid' }, + { ...base, attachments: [] }, + { ...base, uploaderCredentialId: '' }, + { ...base, extra: true }, + { ...base, attachments: 'nope' }, + ]) { + expect(() => parseCaveAttachmentUploadRequest(malformed)).toThrowError(Error); + } + }); + + test('parses bounded download requests', () => { + const parsed = parseCaveAttachmentDownloadRequest({ + operationId: OPERATION_ID, + confirmed: true, + conversationId: 'conversation.v1', + attachmentId: 'attachment-1', + }); + expect(parsed.maxBytes).toBe(CAVE_ATTACHMENT_LIMITS.maxFileBytes); + + const bounded = parseCaveAttachmentDownloadRequest({ + operationId: OPERATION_ID, + confirmed: true, + conversationId: 'conversation.v1', + attachmentId: 'attachment-1', + maxBytes: 1024, + }); + expect(bounded.maxBytes).toBe(1024); + + for (const maxBytes of [0, -1, CAVE_ATTACHMENT_LIMITS.maxFileBytes + 1, 'big']) { + expect(() => + parseCaveAttachmentDownloadRequest({ + operationId: OPERATION_ID, + confirmed: true, + conversationId: 'conversation.v1', + attachmentId: 'attachment-1', + maxBytes: maxBytes as never, + }), + ).toThrowError(OperationConfigurationError); + } + }); +}); + +describe('attachment records', () => { + const validRecord = { + attachmentId: 'attachment-1', + conversationId: 'conversation.v1', + uploaderCredentialId: 'credential.v1', + filename: 'notes.txt', + contentType: 'text/plain', + sizeBytes: 5, + digestSha256: digestOf(text('hello')), + }; + + test('parses a canonical record', () => { + const record = parseCaveAttachmentRecord(validRecord); + expect(record.attachmentId).toBe('attachment-1'); + expect(record.uploaderCredentialId).toBe('credential.v1'); + }); + + test('rejects byte-bearing or malformed records with exact keys', () => { + // A record can never carry bytes back into canonical state. + expect(() => + parseCaveAttachmentRecord({ ...validRecord, content: text('hello') }), + ).toThrowError(/malformed/u); + expect(() => + parseCaveAttachmentRecord({ ...validRecord, bytes: [1, 2, 3] }), + ).toThrowError(/malformed/u); + expect(() => + parseCaveAttachmentRecord({ ...validRecord, sizeBytes: -1 }), + ).toThrowError(/malformed/u); + expect(() => + parseCaveAttachmentRecord({ ...validRecord, digestSha256: 'deadbeef' }), + ).toThrowError(/malformed/u); + expect(() => + parseCaveAttachmentRecord({ ...validRecord, contentType: 'image/svg+xml' }), + ).toThrowError(/malformed/u); + expect(() => parseCaveAttachmentRecord(null)).toThrowError(/malformed/u); + }); +}); diff --git a/tests/cave-privileged-actions.spec.ts b/tests/cave-privileged-actions.spec.ts new file mode 100644 index 0000000..1b5e763 --- /dev/null +++ b/tests/cave-privileged-actions.spec.ts @@ -0,0 +1,421 @@ +import { + CAVE_GITHUB_ACTION_KINDS, + CAVE_TASK_HANDOFF_STATES, + CAVE_TASK_HANDOFF_TRANSITIONS, + CaveClient, + createCaveCapabilityRegistry, + isCaveClientError, + parseCaveAttachmentRecord, + parseCaveAttentionResponseRequest, + parseCaveTaskHandoffRequest, + type CaveAttachmentUploadRequest, + type CaveCapabilityRegistry, + type CaveGitHubActionRequest, + type CaveTransport, +} from '@opencoven/cave-client'; +import { createHash } from 'node:crypto'; +import { describe, expect, test, vi } from 'vitest'; + +const OPERATION_ID = '018f4f1a-77c2-7a31-8a15-55a25aaba001'; +const CONVERSATION_ID = 'conversation.v1'; + +function unreachableTransport(): CaveTransport { + return { + health() { + throw new Error('health is not expected in this test'); + }, + } satisfies CaveTransport; +} + +function spyTransport() { + const uploadAttachment = vi.fn(); + const downloadAttachment = vi.fn(); + const respondToAttention = vi.fn(); + const requestTaskHandoff = vi.fn(); + const submitGitHubAction = vi.fn(); + const transport = { + health() { + throw new Error('health is not expected in this test'); + }, + uploadAttachment, + downloadAttachment, + respondToAttention, + requestTaskHandoff, + submitGitHubAction, + } as unknown as CaveTransport; + return { + transport, + uploadAttachment, + downloadAttachment, + respondToAttention, + requestTaskHandoff, + submitGitHubAction, + }; +} + +async function errorOf(run: () => Promise): Promise { + try { + await run(); + } catch (error) { + return error; + } + throw new Error('expected the call to reject'); +} + +const ATTACHMENT_CONTENT = new TextEncoder().encode('hello'); + +function textAttachmentRequest(): CaveAttachmentUploadRequest { + return { + operationId: OPERATION_ID, + confirmed: true, + conversationId: CONVERSATION_ID, + uploaderCredentialId: 'credential.v1', + attachments: [ + { + filename: 'notes.txt', + contentType: 'text/plain', + content: ATTACHMENT_CONTENT, + }, + ], + }; +} + +// The curated GitHub union is uninhabitable, so its request type cannot be +// constructed statically. Runtime probes cross the trust boundary as +// unknown, exactly like a wire payload would. +function submitUnknownAction( + client: CaveClient, + request: unknown, +): Promise { + return client.submitGitHubAction(request as CaveGitHubActionRequest); +} + +describe('privileged capability gating (client level)', () => { + test('every privileged mutation reports unsupported_operation with zero transport dispatch under the pinned contract', async () => { + const { transport, uploadAttachment, downloadAttachment, respondToAttention, requestTaskHandoff, submitGitHubAction } = + spyTransport(); + const client = new CaveClient({ transport }); + + const upload = await errorOf(() => + client.uploadAttachment(textAttachmentRequest()), + ); + const download = await errorOf(() => + client.downloadAttachment({ + operationId: OPERATION_ID, + confirmed: true, + conversationId: CONVERSATION_ID, + attachmentId: 'attachment-1', + }), + ); + const attention = await errorOf(() => + client.respondToAttention({ + operationId: OPERATION_ID, + confirmed: true, + conversationId: CONVERSATION_ID, + attentionId: 'attention-1', + response: 'acknowledge', + }), + ); + const handoff = await errorOf(() => + client.requestTaskHandoff({ + operationId: OPERATION_ID, + confirmed: true, + conversationId: CONVERSATION_ID, + handoffId: 'handoff-1', + from: 'proposed', + to: 'pending', + }), + ); + + for (const [name, error] of [ + ['upload', upload], + ['download', download], + ['attention', attention], + ['handoff', handoff], + ] as const) { + expect(isCaveClientError(error), name).toBe(true); + expect((error as Error & { code: string }).code).toBe( + 'unsupported_operation', + ); + expect((error as Error & { operationId?: string }).operationId).toBe( + OPERATION_ID, + ); + } + + // The capability gate fires before any transport dispatch: the bound + // privileged methods are never called, even though they exist. + expect(uploadAttachment).not.toHaveBeenCalled(); + expect(downloadAttachment).not.toHaveBeenCalled(); + expect(respondToAttention).not.toHaveBeenCalled(); + expect(requestTaskHandoff).not.toHaveBeenCalled(); + expect(submitGitHubAction).not.toHaveBeenCalled(); + }); + + test('validation failure performs zero domain mutation and raises a configuration error', async () => { + const { transport, uploadAttachment, respondToAttention, requestTaskHandoff, downloadAttachment, submitGitHubAction } = + spyTransport(); + const client = new CaveClient({ transport }); + + const unconfirmed = await errorOf(() => + client.uploadAttachment({ + ...textAttachmentRequest(), + confirmed: false, + } as unknown as CaveAttachmentUploadRequest), + ); + expect(unconfirmed).toBeInstanceOf(TypeError); + expect(isCaveClientError(unconfirmed)).toBe(false); + + const malformedId = await errorOf(() => + client.respondToAttention({ + operationId: 'not-a-uuid', + confirmed: true, + conversationId: CONVERSATION_ID, + attentionId: 'attention-1', + response: 'acknowledge', + }), + ); + expect(malformedId).toBeInstanceOf(TypeError); + expect((malformedId as Error).message).not.toContain('not-a-uuid'); + + expect(uploadAttachment).not.toHaveBeenCalled(); + expect(respondToAttention).not.toHaveBeenCalled(); + expect(submitGitHubAction).not.toHaveBeenCalled(); + expect(requestTaskHandoff).not.toHaveBeenCalled(); + expect(downloadAttachment).not.toHaveBeenCalled(); + }); + + test('a declared capability lets a bound transport carry the validated request', async () => { + // Synthetic test contract only: proves the gate is registry-driven. No + // upstream contract is implied by this shape. + const registry = createCaveCapabilityRegistry({ + capabilities: ['attachments'], + operations: [ + { + id: 'attachments.upload', + families: ['attachments'], + ingress: 'authenticated', + method: 'POST', + path: '/api/client/v1/conversations/:id/attachments', + scope: 'attachments:write', + }, + ], + }); + const upload = vi.fn((request: CaveAttachmentUploadRequest) => + Promise.resolve({ + attachmentId: 'attachment-1', + conversationId: request.conversationId, + uploaderCredentialId: request.uploaderCredentialId, + filename: request.attachments[0]?.filename ?? 'notes.txt', + contentType: request.attachments[0]?.contentType ?? 'text/plain', + sizeBytes: request.attachments[0]?.content.length ?? 0, + digestSha256: createHash('sha256').update('hello').digest('hex'), + }), + ); + const transportWithoutDownload = { + health() { + throw new Error('health is not expected in this test'); + }, + uploadAttachment: upload, + downloadAttachment: undefined, + } as unknown as CaveTransport; + const client = new CaveClient({ + transport: transportWithoutDownload, + capabilities: registry, + }); + + const record = await client.uploadAttachment(textAttachmentRequest()); + + expect(record.attachmentId).toBe('attachment-1'); + expect(parseCaveAttachmentRecord(record)).toEqual(record); + expect(upload).toHaveBeenCalledTimes(1); + const sent = upload.mock.calls[0]![0]; + expect(sent.operationId).toBe(OPERATION_ID); + expect(sent.confirmed).toBe(true); + expect(sent.attachments[0]?.content).toBe(ATTACHMENT_CONTENT); + + // With no download binding on the transport, the declared attachment + // capability passes the gate and the missing method itself reports + // unsupported_operation. + const download = await errorOf(() => + client.downloadAttachment({ + operationId: OPERATION_ID, + confirmed: true, + conversationId: CONVERSATION_ID, + attachmentId: 'attachment-1', + }), + ); + expect((download as Error & { code: string }).code).toBe( + 'unsupported_operation', + ); + }); + + test('a registry without a resolve function is refused at construction', () => { + expect( + () => + new CaveClient({ + transport: unreachableTransport(), + capabilities: {} as unknown as CaveCapabilityRegistry, + }), + ).toThrowError(/CaveCapabilityRegistry/u); + }); +}); + +describe('attention responses', () => { + test('the response union is closed and the note is bounded', () => { + const valid = parseCaveAttentionResponseRequest({ + operationId: OPERATION_ID, + confirmed: true, + conversationId: CONVERSATION_ID, + attentionId: 'attention-1', + response: 'acknowledge', + note: 'on it', + }); + expect(valid.response).toBe('acknowledge'); + expect(valid.note).toBe('on it'); + + for (const malformed of [ + { response: 'snooze' }, + { response: 'ACKNOWLEDGE' }, + { response: 1 }, + { response: undefined }, + { note: '' }, + { note: 'x'.repeat(257) }, + { attentionId: '' }, + { extra: true }, + ]) { + expect(() => + parseCaveAttentionResponseRequest({ + operationId: OPERATION_ID, + confirmed: true, + conversationId: CONVERSATION_ID, + attentionId: 'attention-1', + response: 'acknowledge', + ...malformed, + }), + ).toThrowError(Error); + } + }); +}); + +describe('task handoff states', () => { + test('exactly the five declared states exist and stay distinct', () => { + expect([...CAVE_TASK_HANDOFF_STATES]).toEqual([ + 'proposed', + 'pending', + 'completed', + 'rejected', + 'failed', + ]); + expect(new Set(CAVE_TASK_HANDOFF_STATES).size).toBe(5); + }); + + test('the transition map keeps terminal states terminal', () => { + expect([...CAVE_TASK_HANDOFF_TRANSITIONS.proposed]).toEqual(['pending']); + expect([...CAVE_TASK_HANDOFF_TRANSITIONS.pending]).toEqual([ + 'completed', + 'rejected', + 'failed', + ]); + expect(CAVE_TASK_HANDOFF_TRANSITIONS.completed).toEqual([]); + expect(CAVE_TASK_HANDOFF_TRANSITIONS.rejected).toEqual([]); + expect(CAVE_TASK_HANDOFF_TRANSITIONS.failed).toEqual([]); + }); + + test('parses legal transitions and rejects skipped or unknown ones', () => { + const legal = parseCaveTaskHandoffRequest({ + operationId: OPERATION_ID, + confirmed: true, + conversationId: CONVERSATION_ID, + handoffId: 'handoff-1', + from: 'pending', + to: 'completed', + }); + expect(legal.from).toBe('pending'); + expect(legal.to).toBe('completed'); + + for (const [from, to] of [ + ['proposed', 'completed'], + ['proposed', 'failed'], + ['pending', 'proposed'], + ['pending', 'pending'], + ['completed', 'pending'], + ['rejected', 'pending'], + ['failed', 'pending'], + ['unknown', 'pending'], + ['proposed', 'unknown'], + ] as const) { + expect(() => + parseCaveTaskHandoffRequest({ + operationId: OPERATION_ID, + confirmed: true, + conversationId: CONVERSATION_ID, + handoffId: 'handoff-1', + from, + to, + }), + ).toThrowError(Error); + } + }); +}); + +describe('confirmed GitHub actions', () => { + test('the curated union is empty and frozen pending the upstream contract', () => { + expect(Object.isFrozen(CAVE_GITHUB_ACTION_KINDS)).toBe(true); + expect(CAVE_GITHUB_ACTION_KINDS).toEqual([]); + }); + + test('every request is rejected with the precise upstream gap and zero transport dispatch', async () => { + const { transport, submitGitHubAction } = spyTransport(); + const client = new CaveClient({ transport }); + + for (const action of [ + 'create_issue', + 'comment', + 'merge_pull_request', + 'workflow_dispatch', + '', + ]) { + const error = await errorOf(() => + submitUnknownAction(client, { + operationId: OPERATION_ID, + confirmed: true, + conversationId: CONVERSATION_ID, + action, + input: { repository: 'OpenCoven/sdk' }, + }), + ); + expect(error).toBeInstanceOf(TypeError); + expect((error as Error).message).toContain('curated union'); + // The untrusted action kind is never echoed. + if (action.length > 0) { + expect((error as Error).message).not.toContain(action); + } + } + + // Missing or soft confirmation rejects before the kind check. + const unconfirmed = await errorOf(() => + submitUnknownAction(client, { + operationId: OPERATION_ID, + confirmed: false, + conversationId: CONVERSATION_ID, + action: 'create_issue', + input: {}, + }), + ); + expect(unconfirmed).toBeInstanceOf(TypeError); + + // Oversized input bounds reject before the kind check. + const oversized = await errorOf(() => + submitUnknownAction(client, { + operationId: OPERATION_ID, + confirmed: true, + conversationId: CONVERSATION_ID, + action: 'create_issue', + input: { key: 'x'.repeat(257) }, + }), + ); + expect(oversized).toBeInstanceOf(TypeError); + + expect(submitGitHubAction).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/cave-privileged-capabilities.spec.ts b/tests/cave-privileged-capabilities.spec.ts new file mode 100644 index 0000000..2935f81 --- /dev/null +++ b/tests/cave-privileged-capabilities.spec.ts @@ -0,0 +1,234 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + CAVE_DEFAULT_CAPABILITY_CONTRACT, + CAVE_PAIRING_SCOPES, + CAVE_PRIVILEGED_ACTION_CLASSES, + CAVE_PRIVILEGED_ACTION_REQUIREMENTS, + createCaveCapabilityRegistry, + createDefaultCaveCapabilityRegistry, + parsePrivilegedConfirmation, + parseVerifiedCaveContractFixture, + validatePrivilegedOperationId, +} from '@opencoven/cave-client'; +import { describe, expect, test } from 'vitest'; + +const root = resolve(fileURLToPath(new URL('..', import.meta.url))); +const fixturePath = resolve(root, 'packages/cave/fixtures/contract-fixture.json'); +const digestPath = resolve(root, 'packages/cave/fixtures/contract-fixture.sha256'); + +const OPERATION_ID = '018f4f1a-77c2-7a31-8a15-55a25aaba001'; + +describe('privileged capability requirements', () => { + test('every privileged action class has a requirement keyed by a declared pairing scope', () => { + expect([...CAVE_PRIVILEGED_ACTION_CLASSES]).toEqual([ + 'attachment-transfer', + 'rich-content', + 'attention-response', + 'task-handoff', + 'github-action', + ]); + + const pairingScopes: ReadonlySet = new Set(CAVE_PAIRING_SCOPES); + for (const actionClass of CAVE_PRIVILEGED_ACTION_CLASSES) { + const requirement = CAVE_PRIVILEGED_ACTION_REQUIREMENTS[actionClass]; + expect(requirement.actionClass).toBe(actionClass); + // Scope names are drawn only from the fixture-declared pairing scope + // vocabulary — never invented. + expect(pairingScopes.has(requirement.requiredScope)).toBe(true); + expect(requirement.requiresConfirmation).toBe(true); + expect(requirement.idempotencyKey).toBe('operation-uuid'); + } + + expect(CAVE_PRIVILEGED_ACTION_REQUIREMENTS['attachment-transfer'].requiredScope).toBe( + 'attachments:write', + ); + expect(CAVE_PRIVILEGED_ACTION_REQUIREMENTS['rich-content'].requiredScope).toBe( + 'chat:write', + ); + expect(CAVE_PRIVILEGED_ACTION_REQUIREMENTS['attention-response'].requiredScope).toBe( + 'conversations:write', + ); + expect(CAVE_PRIVILEGED_ACTION_REQUIREMENTS['task-handoff'].requiredScope).toBe( + 'tasks:write', + ); + expect(CAVE_PRIVILEGED_ACTION_REQUIREMENTS['github-action'].requiredScope).toBe( + 'github:write', + ); + }); + + test('requirements are frozen', () => { + for (const actionClass of CAVE_PRIVILEGED_ACTION_CLASSES) { + expect(Object.isFrozen(CAVE_PRIVILEGED_ACTION_REQUIREMENTS[actionClass])).toBe( + true, + ); + } + expect(Object.isFrozen(CAVE_PRIVILEGED_ACTION_REQUIREMENTS)).toBe(true); + }); +}); + +describe('capability registry against the authoritative fixture', () => { + test('the default capability contract mirrors the pinned fixture exactly', () => { + const fixture = parseVerifiedCaveContractFixture( + readFileSync(fixturePath, 'utf8'), + readFileSync(digestPath, 'utf8').trim(), + ); + + // A fixture re-import forces a reviewed update of the default snapshot. + expect([...CAVE_DEFAULT_CAPABILITY_CONTRACT.capabilities]).toEqual([ + ...fixture.contract.capabilities, + ]); + expect(CAVE_DEFAULT_CAPABILITY_CONTRACT.operations.length).toBe( + fixture.contract.operations.length, + ); + expect([...CAVE_DEFAULT_CAPABILITY_CONTRACT.operations]).toEqual([ + ...fixture.contract.operations, + ]); + }); + + test('the pinned fixture declares thirteen operations and none carry a privileged scope', () => { + const fixture = parseVerifiedCaveContractFixture( + readFileSync(fixturePath, 'utf8'), + readFileSync(digestPath, 'utf8').trim(), + ); + + expect(fixture.contract.operations.length).toBe(13); + + const privilegedScopes = new Set([ + 'chat:write', + 'conversations:write', + 'attachments:write', + 'tasks:write', + 'github:write', + ]); + for (const operation of fixture.contract.operations) { + expect(privilegedScopes.has(operation.scope ?? '')).toBe(false); + } + for (const capability of fixture.contract.capabilities) { + expect([ + 'attachments', + 'tasks', + 'github', + 'rich-content', + 'attention', + ]).not.toContain(capability); + } + }); + + test('every privileged action class resolves undeclared under the pinned fixture', () => { + const fixture = parseVerifiedCaveContractFixture( + readFileSync(fixturePath, 'utf8'), + readFileSync(digestPath, 'utf8').trim(), + ); + const registry = createCaveCapabilityRegistry(fixture.contract); + + for (const actionClass of CAVE_PRIVILEGED_ACTION_CLASSES) { + const resolution = registry.resolve(actionClass); + expect(resolution.status).toBe('undeclared'); + expect(resolution.declaredOperations).toEqual([]); + expect(resolution.requirement).toBe( + CAVE_PRIVILEGED_ACTION_REQUIREMENTS[actionClass], + ); + } + }); + + test('resolution is computed per call and returns frozen descriptors', () => { + const registry = createDefaultCaveCapabilityRegistry(); + const first = registry.resolve('github-action'); + const second = registry.resolve('github-action'); + + expect(first).not.toBe(second); + expect(first).toEqual(second); + expect(Object.isFrozen(first)).toBe(true); + expect(Object.isFrozen(first.declaredOperations)).toBe(true); + expect(Object.isFrozen(first.requirement)).toBe(true); + }); + + test('an undeclared class becomes declared only when the contract declares a scoped operation', () => { + // Synthetic test fixture: proves the gate is fixture-driven, not a + // hardcoded refusal. No upstream contract is implied by this shape. + const synthetic = { + capabilities: ['conversations', 'attachments'], + operations: [ + { + id: 'attachments.upload', + families: ['attachments'], + ingress: 'authenticated', + method: 'POST', + path: '/api/client/v1/conversations/:id/attachments', + scope: 'attachments:write', + }, + ], + }; + const registry = createCaveCapabilityRegistry(synthetic); + + const declared = registry.resolve('attachment-transfer'); + expect(declared.status).toBe('declared'); + expect(declared.declaredOperations.map((operation) => operation.id)).toEqual([ + 'attachments.upload', + ]); + + // Other privileged classes stay undeclared under the same contract. + expect(registry.resolve('github-action').status).toBe('undeclared'); + }); + + test('rejects malformed contract sources and unknown action classes', () => { + expect(() => + createCaveCapabilityRegistry(null as unknown as Parameters< + typeof createCaveCapabilityRegistry + >[0]), + ).toThrowError(TypeError); + expect(() => + createCaveCapabilityRegistry({ + capabilities: [], + operations: 'nope', + } as unknown as Parameters[0]), + ).toThrowError(TypeError); + + const registry = createDefaultCaveCapabilityRegistry(); + expect(() => + registry.resolve('not-a-class' as never), + ).toThrowError(TypeError); + expect(() => registry.resolve('not-a-class' as never)).not.toThrowError( + /not-a-class/u, + ); + }); +}); + +describe('privileged confirmation and operation id', () => { + test('confirmation requires exactly confirmed true', () => { + expect(parsePrivilegedConfirmation({ confirmed: true })).toBe(true); + + for (const malformed of [ + undefined, + null, + 'confirmed', + {}, + { confirmed: false }, + { confirmed: 'true' }, + { confirmed: 1 }, + { confirmed: true, extra: true }, + ]) { + expect(() => parsePrivilegedConfirmation(malformed)).toThrowError(TypeError); + } + }); + + test('privileged operation ids follow the Client v1 UUID contract', () => { + expect(validatePrivilegedOperationId(OPERATION_ID)).toBe(OPERATION_ID); + expect( + validatePrivilegedOperationId('018F4F1A-77C2-7A31-8A15-55A25AABA001'), + ).toBe(OPERATION_ID); + + for (const malformed of ['nope', '018f4f1a-77c2-7a31-8a15-55a25aaba00', 42]) { + expect(() => validatePrivilegedOperationId(malformed)).toThrowError(TypeError); + try { + validatePrivilegedOperationId(malformed); + expect.unreachable(); + } catch (error) { + expect((error as Error).message).not.toContain(String(malformed)); + } + } + }); +}); diff --git a/tests/cave-rich-content.spec.ts b/tests/cave-rich-content.spec.ts new file mode 100644 index 0000000..4057c94 --- /dev/null +++ b/tests/cave-rich-content.spec.ts @@ -0,0 +1,207 @@ +import { + CAVE_RICH_CONTENT_LIMITS, + collectCaveRichContentUrls, + parseCaveRichContent, + parseCaveRichContentUrl, + serializeCaveRichContent, + type CaveRichContentDocument, +} from '@opencoven/cave-client'; +import { describe, expect, test } from 'vitest'; + +function doc(children: unknown): unknown { + return { type: 'doc', children }; +} + +function paragraph(children: unknown): unknown { + return { type: 'paragraph', children }; +} + +describe('passive rich-content parsing', () => { + test('parses a valid document of every declared node type', () => { + const parsed = parseCaveRichContent( + doc([ + paragraph([ + { type: 'text', text: 'hello ' }, + { type: 'code', text: 'code()' }, + { type: 'lineBreak' }, + { + type: 'link', + href: 'https://example.com/docs', + title: 'docs', + children: [{ type: 'text', text: 'read more' }], + }, + ]), + { + type: 'heading', + level: 2, + children: [{ type: 'text', text: 'heading' }], + }, + { type: 'codeBlock', language: 'ts', text: 'const x = 1;\n' }, + { type: 'blockquote', children: [paragraph([{ type: 'text', text: 'q' }])] }, + { + type: 'list', + ordered: true, + children: [ + { type: 'listItem', children: [paragraph([{ type: 'text', text: 'one' }])] }, + ], + }, + ]), + ); + + expect(parsed.type).toBe('doc'); + expect(parsed.children.length).toBe(5); + expect(collectCaveRichContentUrls(parsed)).toEqual(['https://example.com/docs']); + }); + + test('preserves markup-looking text inertly, byte for byte', () => { + const hostile = ''; + const parsed = parseCaveRichContent( + doc([paragraph([{ type: 'text', text: hostile }])]), + ); + const node = ( + parsed.children[0] as unknown as { children: Array<{ text: string }> } + ).children[0]; + // The hostile string is preserved as inert content, never interpreted. + expect(node?.text).toBe(hostile); + + const serialized = serializeCaveRichContent(parsed); + // It serializes as an escaped JSON string value only. + expect(serialized).toContain(JSON.stringify(hostile).slice(1, -1)); + // And no declared node type can represent markup. + expect(serialized).not.toContain('"type":"html"'); + expect(collectCaveRichContentUrls(parsed)).toEqual([]); + }); + + test('rejects markup node types and unknown fields', () => { + const hostileNodes = [ + { type: 'html', html: 'bold' }, + { type: 'script', children: [] }, + { type: 'iframe', src: 'https://example.com' }, + paragraph([{ type: 'text', text: 'ok', onClick: 'alert(1)' }]), + paragraph([{ type: 'text', text: 'ok', class: 'x' }]), + { type: 'paragraph', children: [], style: 'color:red' }, + { type: 'doc', children: [], onload: 'alert(1)' }, + ]; + for (const hostile of hostileNodes) { + expect(() => parseCaveRichContent(doc([hostile]))).toThrowError(/rejected/u); + } + }); + + test('rejects unsafe link targets', () => { + const unsafeTargets = [ + 'javascript:alert(1)', + 'JaVaScRiPt:alert(1)', + 'data:text/html,', + 'file:///etc/passwd', + 'vbscript:msgbox(1)', + 'https://example.com/ ok', // whitespace + '/relative/path', // scheme-less targets are rejected, not resolved + '//protocol.relative.example.com', + '://missing-scheme', // malformed target + '', + ]; + for (const href of unsafeTargets) { + expect(() => + parseCaveRichContent( + doc([paragraph([{ type: 'link', href, children: [{ type: 'text', text: 'x' }] }])]), + ), + ).toThrowError(/rejected/u); + } + // https with embedded userinfo is a credential-leak pattern. + expect(() => parseCaveRichContentUrl('https://user:pass@example.com/', 'href')).toThrowError( + /rejected/u, + ); + }); + + test('accepts the declared url schemes only', () => { + expect(parseCaveRichContentUrl('https://example.com/a?b=c', 'href')).toBe( + 'https://example.com/a?b=c', + ); + expect(parseCaveRichContentUrl('mailto:user@example.com', 'href')).toBe( + 'mailto:user@example.com', + ); + }); + + test('rejects oversized and over-deep structures', () => { + // One text node past the per-node limit. + expect(() => + parseCaveRichContent( + doc([paragraph([{ type: 'text', text: 'x'.repeat(CAVE_RICH_CONTENT_LIMITS.maxTextCharacters + 1) }])]), + ), + ).toThrowError(/rejected/u); + + // Total characters past the document limit: 9 x 8192 > 65536. + const bigText = 'x'.repeat(CAVE_RICH_CONTENT_LIMITS.maxTextCharacters); + expect(() => + parseCaveRichContent( + doc( + Array.from({ length: 9 }, () => + paragraph([{ type: 'text', text: bigText }]), + ), + ), + ), + ).toThrowError(/rejected/u); + + // More nodes than the document limit. + expect(() => + parseCaveRichContent( + doc( + Array.from( + { length: CAVE_RICH_CONTENT_LIMITS.maxNodes + 1 }, + () => paragraph([]), + ), + ), + ), + ).toThrowError(/rejected/u); + + // Nesting deeper than the depth limit. + let nested: unknown = paragraph([{ type: 'text', text: 'bottom' }]); + for (let index = 0; index < CAVE_RICH_CONTENT_LIMITS.maxDepth + 4; index += 1) { + nested = { type: 'blockquote', children: [nested] }; + } + expect(() => parseCaveRichContent(doc([nested]))).toThrowError(/rejected/u); + }); + + test('rejects malformed structure', () => { + for (const malformed of [ + null, + undefined, + 'text', + 42, + [], + {}, + { type: 'not-doc', children: [] }, + doc('nope'), + doc([paragraph('nope')]), + doc([{ type: 'heading', level: 7, children: [] }]), + doc([{ type: 'heading', level: 0, children: [] }]), + doc([{ type: 'list', ordered: 'yes', children: [] }]), + doc([{ type: 'list', ordered: true, children: [{ type: 'paragraph' }] }]), + doc([{ type: 'codeBlock', language: 'INVALID LANGUAGE', text: 'x' }]), + doc([{ type: 'codeBlock', text: 'x'.repeat(8193) }]), + doc([paragraph([{ type: 'link', href: 'https://example.com', children: [{ type: 'link', href: 'https://example.com', children: [] }] }])]), + ]) { + expect(() => parseCaveRichContent(malformed)).toThrowError(/rejected/u); + } + }); + + test('serialization of a parsed document contains only declared node types', () => { + const parsed: CaveRichContentDocument = parseCaveRichContent( + doc([ + paragraph([{ type: 'text', text: 'a' }]), + { type: 'codeBlock', text: 'b' }, + ]), + ); + const serialized = serializeCaveRichContent(parsed); + const reparsed = JSON.parse(serialized) as { children: Array<{ type: string }> }; + for (const block of reparsed.children) { + expect([ + 'paragraph', + 'heading', + 'codeBlock', + 'blockquote', + 'list', + ]).toContain(block.type); + } + }); +}); diff --git a/tests/public-contract.spec.ts b/tests/public-contract.spec.ts index dae6034..5df0401 100644 --- a/tests/public-contract.spec.ts +++ b/tests/public-contract.spec.ts @@ -433,32 +433,62 @@ describe('public package entry points', () => { ]); expect(exportedKeys(cave)).toEqual([ 'CAVE_ANALYTICS_WINDOWS', + 'CAVE_ATTACHMENT_CONTENT_TYPES', + 'CAVE_ATTACHMENT_LIMITS', + 'CAVE_ATTENTION_RESPONSE_KINDS', 'CAVE_CLIENT_VERSION', 'CAVE_CONVERSATION_EVENT_TYPES', 'CAVE_CONVERSATION_OPERATION_STATES', 'CAVE_CONVERSATION_ORIGINATING_SCOPES', 'CAVE_CONVERSATION_RECONCILE_REASONS', 'CAVE_CONVERSATION_TERMINAL_STATES', + 'CAVE_DEFAULT_CAPABILITY_CONTRACT', 'CAVE_FAMILIAR_PROPERTIES', + 'CAVE_GITHUB_ACTION_KINDS', 'CAVE_PAIRING_SCOPES', 'CAVE_PAIRING_STATUSES', + 'CAVE_PRIVILEGED_ACTION_CLASSES', + 'CAVE_PRIVILEGED_ACTION_REQUIREMENTS', + 'CAVE_RICH_CONTENT_LIMITS', + 'CAVE_RICH_CONTENT_URL_SCHEMES', + 'CAVE_TASK_HANDOFF_STATES', + 'CAVE_TASK_HANDOFF_TRANSITIONS', + 'CaveAttachmentSchemaError', 'CaveClient', 'CaveClientError', 'CaveDiscoveryError', 'CavePairingSession', + 'CaveRichContentError', + 'bindCaveAttachments', 'caveConversationReconcileReason', + 'collectCaveRichContentUrls', + 'createCaveCapabilityRegistry', 'createCaveClient', 'createConversationEventTranslator', + 'createDefaultCaveCapabilityRegistry', 'createDiscoveredCaveClient', 'createManagedCaveClient', 'digestCaveContractFixture', 'discoverCaveEndpoint', 'isCaveClientError', 'isCaveDiscoveryError', + 'isCaveTaskHandoffTransition', 'normalizeCaveError', + 'parseCaveAttachmentDownloadRequest', + 'parseCaveAttachmentRecord', + 'parseCaveAttachmentUploadRequest', + 'parseCaveAttentionResponseRequest', 'parseCaveContractFixture', + 'parseCaveGitHubActionRequest', + 'parseCaveRichContent', + 'parseCaveRichContentUrl', + 'parseCaveTaskHandoffRequest', + 'parsePrivilegedConfirmation', 'parseVerifiedCaveContractFixture', + 'serializeCaveRichContent', + 'sniffCaveAttachmentContentType', 'validateConversationEventCursor', + 'validatePrivilegedOperationId', 'verifyCaveContractFixtureDigest', ]); expect(exportedKeys(coven)).toEqual([