Files
openclaw/extensions/reef/protocol/pipeline.ts
Peter Steinberger 882b2fe900 feat(channels): bundle Reef guarded claw-to-claw channel (#106232)
* feat(channels): bundle Reef guarded claw-to-claw channel

Moves the Reef channel extension from openclaw/reef into the bundled extensions tree with the wire protocol vendored under extensions/reef/protocol. Includes channelConfigs manifest metadata, runtime status reporting via setStatus/buildAccountSnapshot, abort-aware inbox shutdown, monotonic device-auth timestamps, and immutable-model guard admission (dated snapshots plus documented gpt-5.6 ids).

* fix(reef): pin zod exactly per dependency pin guard

* style(reef): satisfy extension lint gates

Curly braces in vendored protocol, explicit type re-exports, typed catch callbacks, Object.assign over map-spread, abort-aware loop restructure.

* fix(reef): CI gates — knip workspace, boundary tsconfig, facade imports, cycle break, contract baselines

- knip: reef workspace project includes vendored protocol/ (owns noble deps)
- tsconfig: conform to canonical extension package-boundary include/exclude; add @openclaw/plugin-sdk devDependency
- imports: channel-inbound/channel-outbound facades instead of deprecated subpaths
- protocol: home ReplayStore contract in envelope.ts to break the envelope<->replay type cycle
- baselines: reef in unguarded runtime-api list; regenerate bundled channel config metadata

* feat(reef): plugin catalog cover art, channel label, changelog revert

* fix(reef): drop unused error-class exports, register lint suppression

* fix(reef): knip entry surface for vendored protocol, explicit WebSocketLike export
2026-07-13 05:17:44 -07:00

437 lines
11 KiB
TypeScript

import { sha256 } from "@noble/hashes/sha2.js";
import { appendAudit, type AuditStore } from "./audit.js";
import { canonicalBytes } from "./canonical.js";
import { deterministicChecks } from "./checks.js";
import { fromBase64url, hex } from "./encoding.js";
import {
bodyHash,
openClaimed,
ReplayedError,
seal,
validateEnvelopeMetadata,
validateMessageBody,
type Envelope,
type MessageBody,
} from "./envelope.js";
import {
admitVerdict,
type GuardAdapter,
type GuardDirection,
type GuardRequest,
type Verdict,
} from "./guard.js";
import { parseHandleEpoch } from "./identity.js";
import { signReceipt, type SignedReceipt } from "./receipts.js";
import type { ReplayStore } from "./replay.js";
export interface ReviewRequest {
id: string;
from: string;
to: string;
direction: GuardDirection;
bodyHash: string;
approvalDigest: string;
verdict: Verdict;
}
export interface ReviewApproval {
approved: boolean;
approvalDigest: string;
}
export type ReviewGate = (request: ReviewRequest) => Promise<ReviewApproval | undefined>;
export class PipelineError extends Error {
constructor(
readonly stage: "deterministic" | "guard" | "review",
message: string,
readonly verdict?: Verdict,
readonly receipt?: SignedReceipt,
readonly reviewOutcome?: "pending" | "denied",
readonly approvalDigest?: string,
) {
super(message);
this.name = "PipelineError";
}
}
interface GuardedPipelineOptions {
guard: GuardAdapter;
audit: AuditStore;
policyVersion: string;
reviewGate?: ReviewGate;
}
export interface ComposeOutboundOptions extends GuardedPipelineOptions {
id: string;
from: string;
to: string;
body: MessageBody;
senderSigningSecretKey: string;
recipientEncryptionPublicKey: string;
ts?: number;
rng?: (length: number) => Uint8Array;
}
export interface OutboundResult {
envelope: Envelope;
verdict: Verdict;
}
export async function composeOutbound(options: ComposeOutboundOptions): Promise<OutboundResult> {
validateEnvelopeMetadata(
options.id,
options.from,
options.to,
options.ts ?? Math.floor(Date.now() / 1000),
);
validateMessageBody(options.body);
if (
fromBase64url(options.senderSigningSecretKey).length !== 32 ||
fromBase64url(options.recipientEncryptionPublicKey).length !== 32
) {
throw new Error("invalid outbound key material");
}
const checks = deterministicChecks(options.body.text);
if (
checks.findings.some(
(finding) => finding.code === "invalid_utf8" || finding.code === "too_large",
)
) {
throw new PipelineError("deterministic", "invalid outbound message");
}
const proposalHash = bodyHash(options.body);
const approvalDigest = computeApprovalDigest(
options.id,
options.from,
options.to,
"outbound",
proposalHash,
options.policyVersion,
);
await appendAudit(options.audit, "proposal", {
id: options.id,
from: options.from,
to: options.to,
bodyHash: proposalHash,
approvalDigest,
body: options.body,
});
if (!checks.allowed) {
await appendAudit(options.audit, "deterministic_verdict", {
id: options.id,
approvalDigest,
decision: "deny",
findings: checks.findings,
});
throw new PipelineError("deterministic", "deterministic checks denied message");
}
const verdict = await classifyWithReview(
options,
"outbound",
options.id,
proposalHash,
approvalDigest,
options.from,
options.to,
options.body.text,
);
const envelope = seal(options);
await appendAudit(options.audit, "envelope", { id: options.id, approvalDigest, envelope });
return { envelope, verdict };
}
export interface ComposeInboundOptions extends GuardedPipelineOptions {
envelope: Envelope;
self: string;
recipientEncryptionSecretKey: string;
recipientSigningSecretKey: string;
senderSigningPublicKey?: string;
replayStore: ReplayStore;
now?: number;
maxAgeSeconds?: number;
maxFutureSkewSeconds?: number;
}
export type InboundResult =
| { disposition: "accepted"; body: MessageBody; verdict: Verdict; receipt: SignedReceipt }
| { disposition: "duplicate"; body?: MessageBody; receipt: SignedReceipt };
// Caller MUST ack the relay with receipt. For accepted or duplicate-accepted results, it MUST
// idempotently deliver every present body to channel ingress, keyed by envelope id.
export async function composeInbound(options: ComposeInboundOptions): Promise<InboundResult> {
const opened = await openClaimed(options);
if (opened.claim === "duplicate") {
if (opened.receipt === undefined) {
throw new ReplayedError("duplicate envelope");
}
return opened.body === undefined
? { disposition: "duplicate", receipt: opened.receipt }
: { disposition: "duplicate", body: opened.body, receipt: opened.receipt };
}
let finalized = false;
const peer = parseHandleEpoch(options.envelope.from).handle;
try {
const proposalHash = bodyHash(opened.body);
const approvalDigest = computeApprovalDigest(
options.envelope.id,
options.envelope.from,
options.self,
"inbound",
proposalHash,
options.policyVersion,
);
const checks = deterministicChecks(opened.body.text);
if (!checks.allowed) {
await appendAudit(options.audit, "deterministic_verdict", {
id: options.envelope.id,
approvalDigest,
decision: "deny",
findings: checks.findings,
});
const receipt = await completeRejection(
options,
peer,
proposalHash,
approvalDigest,
"deterministic_deny",
);
finalized = true;
throw new PipelineError(
"deterministic",
"deterministic checks denied message",
undefined,
receipt,
);
}
let verdict: Verdict;
try {
verdict = await classifyWithReview(
options,
"inbound",
options.envelope.id,
proposalHash,
approvalDigest,
options.envelope.from,
options.self,
opened.body.text,
);
} catch (error) {
if (
error instanceof PipelineError &&
error.stage === "guard" &&
error.verdict?.decision === "deny"
) {
const receipt = await completeRejection(
options,
peer,
proposalHash,
approvalDigest,
"guard_deny",
);
finalized = true;
throw new PipelineError("guard", error.message, error.verdict, receipt);
}
if (
error instanceof PipelineError &&
error.stage === "review" &&
error.reviewOutcome === "denied"
) {
const receipt = await completeRejection(
options,
peer,
proposalHash,
approvalDigest,
"review_denied",
);
finalized = true;
throw new PipelineError(
"review",
error.message,
error.verdict,
receipt,
"denied",
approvalDigest,
);
}
throw error;
}
const inboxEntry = await appendAudit(options.audit, "inbox", {
id: options.envelope.id,
bodyHash: proposalHash,
approvalDigest,
text: opened.body.text,
verdict,
});
const receipt = signReceipt(
{
id: options.envelope.id,
bodyHash: proposalHash,
auditHead: inboxEntry.entryHash,
status: "accepted",
},
options.recipientSigningSecretKey,
);
await appendAudit(options.audit, "receipt", {
id: options.envelope.id,
approvalDigest,
receipt,
});
await options.replayStore.complete(peer, options.envelope.id, receipt, opened.body);
finalized = true;
return { disposition: "accepted", body: opened.body, verdict, receipt };
} catch (error) {
if (!finalized) {
await options.replayStore.release(peer, options.envelope.id);
}
throw error;
}
}
async function completeRejection(
options: ComposeInboundOptions,
peer: string,
proposalHash: string,
approvalDigest: string,
category: string,
): Promise<SignedReceipt> {
const rejectionEntry = await appendAudit(options.audit, "inbox_rejected", {
id: options.envelope.id,
bodyHash: proposalHash,
approvalDigest,
decision: "deny",
category,
});
const receipt = signReceipt(
{
id: options.envelope.id,
bodyHash: proposalHash,
auditHead: rejectionEntry.entryHash,
status: "rejected",
category,
},
options.recipientSigningSecretKey,
);
await appendAudit(options.audit, "receipt", { id: options.envelope.id, approvalDigest, receipt });
await options.replayStore.complete(peer, options.envelope.id, receipt);
return receipt;
}
async function classifyWithReview(
options: GuardedPipelineOptions,
direction: GuardDirection,
id: string,
proposalHash: string,
approvalDigest: string,
source: string,
destination: string,
text: string,
): Promise<Verdict> {
const request: GuardRequest = {
direction,
source,
destination,
text,
policyVersion: options.policyVersion,
};
let verdict = admitVerdict(
await options.guard.classify(request),
options.guard.pinnedModel,
request.policyVersion,
);
await appendAudit(options.audit, "guard_verdict", {
id,
from: source,
to: destination,
direction,
bodyHash: proposalHash,
approvalDigest,
...verdict,
});
if (verdict.decision === "deny") {
throw new PipelineError("guard", "guard denied message", verdict);
}
if (verdict.decision === "review") {
const approval = await options.reviewGate?.({
id,
from: source,
to: destination,
direction,
bodyHash: proposalHash,
approvalDigest,
verdict,
});
if (approval === undefined) {
throw new PipelineError(
"review",
"review approval pending",
verdict,
undefined,
"pending",
approvalDigest,
);
}
if (approval.approvalDigest !== approvalDigest) {
throw new PipelineError(
"review",
"approval digest mismatch",
verdict,
undefined,
"pending",
approvalDigest,
);
}
if (!approval.approved) {
throw new PipelineError(
"review",
"review explicitly denied",
verdict,
undefined,
"denied",
approvalDigest,
);
}
await appendAudit(options.audit, "review_approval", {
id,
from: source,
to: destination,
direction,
bodyHash: proposalHash,
approvalDigest,
approved: true,
});
verdict = admitVerdict(
await options.guard.classify(request),
options.guard.pinnedModel,
request.policyVersion,
);
await appendAudit(options.audit, "guard_verdict", {
id,
from: source,
to: destination,
direction,
bodyHash: proposalHash,
approvalDigest,
afterApproval: true,
...verdict,
});
if (verdict.decision === "deny") {
throw new PipelineError("guard", "guard denied approved message", verdict);
}
}
return verdict;
}
function computeApprovalDigest(
id: string,
from: string,
to: string,
direction: GuardDirection,
proposalHash: string,
policyVersion: string,
): string {
return hex(
sha256(canonicalBytes({ id, from, to, direction, bodyHash: proposalHash, policyVersion })),
);
}