import type { H2AActorRef, H2AActorRegistration, H2AAgentVersion, H2ASession, H2ASessionInterests, H2ASessionNotificationTopic, H2ASessionState, H2ASubagentBinding, H2AWorkStatus, H2AWorkspaceRef } from "@sentropic/h2a"; /** * Why a field is withheld. A closed vocabulary rather than free text: the * reasons are asserted by tests and read by reviewers, and "some comment * somewhere" is not a contract. */ export type MirrorWithholdReason = /** A real path on the sender's filesystem. */ "filesystem-path" /** A command line — reveals tooling, flags, and often paths inside it. */ | "command-line" /** Identifies a process on the sender's machine (pid, tty). */ | "process-identity" /** tmux session/window/pane — coordinates of a live terminal. */ | "terminal-coordinates" /** The RECEIVER establishes this itself; a sender's value would be a forgery. */ | "receiver-stamped" /** Nothing downstream reads it, so it has no business travelling. */ | "not-consumed-downstream"; interface SendPlan { readonly kind: "send"; } interface WithholdPlan { readonly kind: "withhold"; readonly because: MirrorWithholdReason; } interface NarrowPlan { readonly kind: "narrow"; readonly narrow: (value: NonNullable) => unknown; } /** * A value that may be transmitted VERBATIM: a primitive, or an array of * primitives. Deliberately narrow — an array of arrays or an array of objects is * composite and does not qualify. * * This is the type that makes "no composite is ever copied by reference" a * STRUCTURAL property instead of a convention, and it exists because convention * demonstrably was not enough. The `interests` leak this module was written to * fix was precisely a composite classified `send`: `applyPlan` copied the whole * object by reference, `isInterests` tolerated the extra keys, and a nested * `cwd`/`pid`/tmux payload came to rest on the receiver's disk. The field plan * was already in place at the time. It forced a classification and the * classification was `send`, which is a LEGAL classification — so the ratchet * fired, was answered, and the leak shipped anyway. * * A plan that forces you to classify does not force you to classify CORRECTLY. * Making `send` inexpressible for a composite removes the wrong answer from the * menu rather than trusting the next author not to pick it. * * ── WHERE THIS RULE STOPS ────────────────────────────────────────────────── * * It is structural for any field with a real type, and it is NOT structural for * a field typed `any`: a conditional type on `any` resolves to both branches, so * `SEND` stays assignable and the compiler says nothing. `unknown` is fine * (forced to `narrow`); `any` is the hole, and `any` is exactly what a field * added in a hurry looks like. That case is covered one rung lower, by a runtime * test that reads the plans directly rather than through a fixture * (`mirror-send-boundary.test.js`, "no plan classifies a COMPOSITE field as * `send`"). Naming the boundary here rather than letting "structural" be heard * as "total". */ type MirrorSendableValue = string | number | boolean | readonly (string | number | boolean)[]; /** * How one source field is treated at the send boundary. * * `send` is only in the union when the field's type is a * {@link MirrorSendableValue}. For a composite field the union collapses to * `withhold | narrow`, so `SEND` is not assignable and the build fails until the * field gets a plan of its own. That is the difference between "the ratchet * forces you to classify" and "the ratchet forces a classification that cannot * copy by reference". */ export type MirrorFieldPlan = ([NonNullable] extends [MirrorSendableValue] ? SendPlan : never) | WithholdPlan | NarrowPlan; /** Never transmitted, with the reason recorded in the closed vocabulary. */ declare function withhold(because: MirrorWithholdReason): WithholdPlan; /** Transmitted, but through its own nested allowlist. */ declare function narrow(fn: (value: NonNullable) => unknown): NarrowPlan; /** A plan covering EVERY field of `Source` — the `satisfies` target. */ type MirrorPlanFor = { readonly [K in keyof Required]: MirrorFieldPlan[K]>; }; /** Keys a plan withholds. */ type WithheldKey = { [K in keyof Plan]: Plan[K] extends WithholdPlan ? K : never; }[keyof Plan]; /** Keys a plan transmits (verbatim or narrowed). */ type SentKey = Exclude>; /** * Compile-time type equality. Used to pin each `H2AMirrored*` wire type's key * set to its plan: if they drift, `true` stops being assignable to `false` and * the build fails. */ type Exact = [A] extends [B] ? ([B] extends [A] ? true : false) : false; /** * Apply a plan: iterate the PLAN (never the record's own keys) and copy only * what it permits. * * Iterating the plan is what makes this an allowlist rather than a filter — an * unclassified field on `record` is not skipped, it is never looked at. Absent * values are omitted rather than serialized as `undefined`, so the payload keeps * the same "optional field absent" shape the store and the guards expect. * * ── THE TWO RUNTIME CHECKS, AND WHY A TYPE IS NOT ENOUGH ─────────────────── * * Both exist because this function's input is UNVALIDATED. The compile-time * ratchet governs the plan; nothing governs the record. * * - A `send` value that is not a primitive or an array of primitives is * DROPPED. The declared type says what the field must be, so anything else is * a lie, and a lie shaped like an object is how a `cwd` and a command line * ride out through a field declared `string`. Dropped rather than thrown: * throwing here would let any hostile registry row kill the push for every * session on the machine, trading availability for a confidentiality win * that dropping already secures. If the dropped field was required, the * receiver's own guard rejects the record — fail-closed, and visible. * - A `narrow` whose output was not itself built by this function is DROPPED. * `NarrowPlan.narrow` is an arbitrary callback returning `unknown`, so * `narrow((value) => value)` — an identity narrow — type-checks and copies a * composite wholesale, which is precisely the leak the plans exist to stop. * The compiler cannot reject it (identity returns a structurally compatible * type), so the membership test in {@link PLAN_BUILT} does. * * `null` is passed THROUGH to a narrow rather than short-circuited, so the * narrow's own hostile-input handling can run. That is what makes * `endpoints: null` produce `[]` instead of an absent field. */ declare function applyPlan(record: Source, plan: MirrorPlanFor): Record; /** * {@link applyPlan} and the plan constructors, exported for the send-boundary * tests ONLY. * * Exporting internals to test them is usually a smell. It is justified here * because the property under test is "a hostile PLAN cannot defeat the * boundary", and a plan cannot be made hostile from outside without these. The * alternative — trusting that no future plan uses an identity narrow — is the * assumption this module exists to eliminate. */ export declare const MIRROR_TEST_HOOKS: { readonly applyPlan: typeof applyPlan; readonly send: SendPlan; readonly narrow: typeof narrow; readonly withhold: typeof withhold; }; /** * Fields present on `record` that the plan does not classify at all. * * **This is a TEST-TIME assertion helper, not a runtime half of the ratchet.** * It is called from `test/mirror-send-boundary.test.js` and from nowhere on the * send path, and the earlier claim that it "covers the paths the compiler cannot * see" was wider than what is built — a guard that is never invoked cannot cover * anything. It is kept, and kept exported, because it is what lets a test fail by * NAMING the unclassified field instead of asserting an opaque key-set equality. * * It is deliberately not wired into the builders. Two reasons, and the second is * the honest one: (a) throwing on an unclassified field would turn an additive, * harmless field into a dead mirror pipeline — availability lost for no * confidentiality gained, since `applyPlan` never reads the field anyway; and * (b) the alternative, warning or counting, would make this module impure, and * the purity claim above ("everything here is PURE and happens before signing") * is load-bearing for the signature argument. The ratchet that actually holds is * the compiler (`satisfies MirrorPlanFor` on every plan in the table) plus * plan-iteration in {@link applyPlan}. There is no runtime ratchet; if one is * wanted it belongs in the caller, where a side effect is allowed. */ export declare function unclassifiedMirrorFields(record: object, plan: Record): string[]; /** * A workspace as it may LEAVE the machine: identity + label, never a path. * * `H2AWorkspaceRef.path` is "the absolute realpath on **this machine**" — by its * own definition it means nothing on any other host, and it is the single field * the feed's opacity boundary names first. `repo` is withheld too: nothing * downstream reads it and a git remote may itself be a local filesystem path. * * `id` survives because it lets a UI GROUP an owner's agents by place and because * it is not reversible into a path. Be precise about WHY it is not, because the * usual shorthand ("the salted `ws:` digest") describes the wrong branch: * * - The PRIMARY id, tried first at `runtime/identity/live.ts`, is * `durableWorkspaceId` (`runtime/identity/workspace-id.ts`) — an **unsalted** * `sha256(rootCommitSHA + "\n" + worktreeRelPath)` rendered as `ws:<64 hex>`. * Both inputs are PUBLIC: the repo's root-commit SHAs and a worktree name. It * carries no path because the path is not an input, not because a secret hides * it — so it is portable across clones by design, and an observer who already * knows the repo can confirm a guess at the worktree name. That is the actual * property, and it is weaker than "salted digest" implies. * - The SECOND fallback is `provider.workspaceHint`, which for `host: "remote"` * is the **unconstrained** `SESSION_WORKSPACE_ID` env var (`identity/ * resolver.ts` checks truthiness only — no prefix, charset or length check). * - Only the THIRD fallback, reached in a non-git directory, is the salted * `deriveWorkspaceId` (`identity.ts`: `sha256(machineId \0 realpath)` as * `ws:`). It is the branch the old comment described and the one least * often taken. * * None of the three transmits a path, which is the property this boundary needs; * the rationale is corrected because an inaccurate rationale inside a security * module is how the next reader over-trusts the field. * * `host` survives because it is a CLI name (`claude` / `codex` / ...) and because * `isH2AWorkspaceRef` requires it — see the note on `path` in `identity.ts`. */ export interface H2AMirroredWorkspaceRef { readonly id: string; readonly host: string; readonly label: string; } declare const WORKSPACE_PLAN: { id: SendPlan; host: SendPlan; label: SendPlan; path: WithholdPlan; repo: WithholdPlan; }; /** The plan, for tests. */ export declare const MIRROR_WORKSPACE_PLAN: Readonly>>; /** Pins {@link H2AMirroredWorkspaceRef} to {@link WORKSPACE_PLAN}. */ export type WorkspaceKeysMatchPlan = Exact>; export declare function sanitizeWorkspaceRefForMirror(workspace: H2AWorkspaceRef): H2AMirroredWorkspaceRef; /** The plan, for tests. */ export declare const MIRROR_VERSION_PLAN: Readonly>>; /** * Session interests as they may LEAVE the machine. * * This type and its plan exist because `interests` used to be classified `SEND`, * which made {@link applyPlan} copy the whole object **by reference**. The * consequence was not theoretical: `isH2ASession` validates `interests` through a * two-field spot-check (`session.ts` `isInterests` asserts `scopes` and * `negotiations` are string arrays and does **not** reject extra keys), so a * record carrying `interests: { scopes: [], negotiations: [], lc: { tmux: …, * cwd: …, pid: … } }` was well-formed by the guard, travelled whole, and came to * rest on the receiver's disk. A nested composite reached by reference is outside * both halves of the ratchet: the plan cannot filter what it does not iterate, * and `satisfies MirrorPlanFor` says nothing about the fields of * `H2ASessionInterests`. * * Both fields are transmitted — the hosted `writePresence` REQUIRES them, since * `isInterests` rejects a record missing either — but the object is now rebuilt * from the plan rather than passed through, so a third field cannot ride along. * The element VALUES stay free text: `runtime/mcp/sessions.ts` copies * `interests.scopes` verbatim from the `h2a_session_open` caller, so a scope * naming a real directory is transmissible and is disclosed as such. A field * allowlist bounds the SHAPE, never the content. */ export interface H2AMirroredSessionInterests { readonly scopes: readonly string[]; readonly negotiations: readonly string[]; } declare const INTERESTS_PLAN: { scopes: SendPlan; negotiations: SendPlan; }; /** The plan, for tests. */ export declare const MIRROR_INTERESTS_PLAN: Readonly>>; /** Pins {@link H2AMirroredSessionInterests} to {@link INTERESTS_PLAN}. */ export type InterestsKeysMatchPlan = Exact>; /** * A presence record as it may LEAVE the machine. * * Assignable to `H2ASession` on purpose: the hosted ingester writes it through * `writePresence`, which validates with `isH2ASession`, and the feed's * descriptor builders consume it as a session. Narrowing the payload therefore * required exactly one type change elsewhere — `H2AWorkspaceRef.path` became * optional, because a type that makes a filesystem path MANDATORY on every * workspace reference cannot express a sanitized reference at all. That * mandatory field was compelling the leak, not merely permitting it. */ export interface H2AMirroredSession { readonly sessionId: string; readonly instance: string; readonly host?: string; readonly name?: string; readonly startedAt: string; readonly heartbeatAt: string; readonly state: H2ASessionState; readonly interests: H2AMirroredSessionInterests; readonly subscribedTopics: readonly H2ASessionNotificationTopic[]; readonly workStatus?: H2AWorkStatus; readonly lastMcpActivityAt?: string; readonly version?: H2AAgentVersion; readonly workspace?: H2AMirroredWorkspaceRef; } /** * Every field of `H2ASession`, classified. * * The `send` set is exactly what a consumer of the mirror demonstrably needs: * - `sessionId` / `instance` — the row's own identity (`accept.ts` authorizes a * session by `instance`; the feed keys rows on both); * - `startedAt` / `heartbeatAt` / `state` / `lastMcpActivityAt` — the liveness * derivation (`deriveSessionState`, `deriveLiveness`, `isSessionExpired`); * - `interests` / `subscribedTopics` — required by `isH2ASession`, so the * hosted `writePresence` rejects the record without them; * - `host` / `name` / `workspace` — the feed's `host`, `topicOrTitle`, * `displayName` and `workspaceLabel`; * - `workStatus` / `version` — closed vocabularies, shown by `h2a_discover_*`. */ declare const PRESENCE_PLAN: { sessionId: SendPlan; instance: SendPlan; host: SendPlan; name: SendPlan; startedAt: SendPlan; heartbeatAt: SendPlan; state: SendPlan; interests: NarrowPlan; subscribedTopics: SendPlan; workStatus: SendPlan; lastMcpActivityAt: SendPlan; version: NarrowPlan; workspace: NarrowPlan; launchContext: WithholdPlan; pid: WithholdPlan; mirroredAt: WithholdPlan; }; /** The plan, for tests and for callers that want to state the contract. */ export declare const MIRROR_PRESENCE_PLAN: Readonly>>; /** Pins {@link H2AMirroredSession} to {@link PRESENCE_PLAN}. */ export type PresenceKeysMatchPlan = Exact>; /** One presence record → the record as it may leave the machine. */ export declare function sanitizePresenceForMirror(session: H2ASession): H2AMirroredSession; /** One endpoint of a registration, as declared locally. */ type H2AEndpointDeclaration = H2AActorRegistration["endpoints"][number]; /** * One endpoint as it may LEAVE the machine. Its own plan, so a field added to * the endpoints ELEMENT type fails the build instead of travelling — before * this, the element was copied whole by `Array.prototype.filter`, which is a * pass-through, and the ratchet stopped at `H2AActorRegistration`. */ export interface H2AMirroredEndpoint { readonly kind: H2AEndpointDeclaration["kind"]; readonly uri: string; } declare const ENDPOINT_PLAN: { kind: SendPlan; uri: SendPlan; }; /** The plan, for tests. */ export declare const MIRROR_ENDPOINT_PLAN: Readonly>>; /** Pins {@link H2AMirroredEndpoint} to {@link ENDPOINT_PLAN}. */ export type EndpointKeysMatchPlan = Exact>; /** * A registry row as it may LEAVE the machine. Structurally assignable to * `H2AActorRegistration`, which is what the ingest path expects when it applies * the row via `store.registerInstance`. * * That assignability is a TYPE-level property only, and deliberately stated that * way: `store.registerInstance` does **not** validate with * `isH2AActorRegistration` — nothing in production does (see * `sanitizeEndpointsForMirror`). So being assignable is what keeps the compiler * honest here; it is not evidence that the receiver checks the shape. */ export interface H2AMirroredRegistration { readonly id: string; readonly instance: string; readonly roles: H2AActorRegistration["roles"]; readonly scopes: string[]; readonly capabilities: string[]; readonly declaredCapabilities?: string[]; readonly endpoints: H2AMirroredEndpoint[]; readonly publicKeys: string[]; readonly acceptedPolicies: string[]; readonly createdAt: string; readonly principal?: string; readonly conductor?: string; readonly agentUuid?: string; readonly name?: string; readonly workspace?: H2AMirroredWorkspaceRef; } /** * Every field of `H2AActorRegistration`, classified. * * `capabilities` is transmitted DELIBERATELY, though it is authority-bearing and * the feed never displays it. Two gates on the RECEIVING side read it off the * registry row: the subagent capability ceiling (`subagents.ts` * `capabilities-exceed-parent` compares a binding against `parent.capabilities`) * and the attestation right (`canAttestComprehension(role, * registration.capabilities)` in `mcp/handlers.ts`). Withholding it would not be * "sanitizing" — it would silently change an authorization outcome on the * receiver, which is a worse failure than the one being fixed. It carries no * path, no command and no process identity. */ declare const REGISTRATION_PLAN: { id: SendPlan; instance: SendPlan; roles: SendPlan; scopes: SendPlan; capabilities: SendPlan; declaredCapabilities: SendPlan; publicKeys: SendPlan; acceptedPolicies: SendPlan; createdAt: SendPlan; principal: SendPlan; conductor: SendPlan; agentUuid: SendPlan; name: SendPlan; endpoints: NarrowPlan<{ kind: "mcp" | "local-files" | "remote"; uri: string; }[]>; workspace: NarrowPlan; }; /** The plan, for tests. */ export declare const MIRROR_REGISTRATION_PLAN: Readonly>>; /** Pins {@link H2AMirroredRegistration} to {@link REGISTRATION_PLAN}. */ export type RegistrationKeysMatchPlan = Exact>; export declare function sanitizeRegistrationForMirror(registration: H2AActorRegistration): H2AMirroredRegistration; /** * The envelope's `actor`, derived from the SANITIZED registration. * * Taking `H2AMirroredRegistration` rather than `H2AActorRegistration` is the * whole point and is worth not "simplifying" later: the raw record is not in * scope here, so this function CANNOT read a withheld field even by mistake. * Every value it can reach has already been through {@link REGISTRATION_PLAN}, * which makes "the envelope carries nothing the body does not already carry" a * consequence of the signature instead of a claim in a comment. * * `role` is re-intersected against `H2A_ROLES`. That is a genuinely closed * vocabulary check, not a shape check: `roles[]` element VALUES are otherwise * free text (disclosed above), and this is the one place where an element value * escapes the body into the envelope, so it is the one place worth closing. * * ── STRUCK, AND LEFT VISIBLE ON PURPOSE ──────────────────────────────────── * * An earlier version of this comment continued: ~~"`isH2AEnvelope` requires a * vocabulary role anyway, so a non-role here produced an envelope that fails the * protocol's own guard."~~ **That is false.** `isH2AEnvelope` delegates to * `validateH2AEnvelope` (`envelope.ts:109`), which checks `actor` only for * `typeof actor.instance === "string"`. It never looks at `role`. Measured: * * isH2AEnvelope(actor.role = "/home/antoinefa/NOTAROLE") -> true * isH2AEnvelope(actor with no role at all) -> true * isH2AEnvelope(actor with no instance) -> false * * There IS a function that checks the vocabulary — `isActorRef`, `envelope.ts:27` * — but it is module-private and referenced nowhere in the tree. A guard that * cannot fire. * * The sentence is struck rather than deleted because of what it was doing: it * supplied a REASON TO DELETE THE RE-INTERSECTION BELOW. "The protocol guard * catches it anyway" is exactly the argument someone would use to remove that * line, and removing it restores the leak. A false justification for a live * guard is more dangerous than no justification at all — nothing downstream * validates this field, so the check here is the only one there is. * * `scope` is NOT closed — there is no scope vocabulary to close it against — but * it is now sourced from the sanitized registration rather than hardcoded, so * the trap is disarmed by construction: the "obvious edit" has already been made * here, safely, under test. Its value is free text and rides out under the same * disclosed gap as `scopes[]` in the body, which already transmits it verbatim. */ export declare function sanitizeActorForMirror(instance: string, mirrored: H2AMirroredRegistration): H2AActorRef; /** * A subagent (NHI) binding as it may leave the machine. Every field is * transmitted — checked one by one, none is a path, a command or a process id — * but the plan exists anyway, so a future field on `H2ASubagentBinding` must be * classified before it can travel. That is the ratchet, not a formality: this * payload member had no boundary of its own before. */ export interface H2AMirroredSubagentBinding { readonly id: string; readonly parentInstance: string; readonly name: string; readonly capabilities?: readonly string[]; readonly createdAt: string; } declare const SUBAGENT_PLAN: { id: SendPlan; parentInstance: SendPlan; name: SendPlan; capabilities: SendPlan; createdAt: SendPlan; }; /** The plan, for tests. */ export declare const MIRROR_SUBAGENT_PLAN: Readonly>>; /** Pins {@link H2AMirroredSubagentBinding} to {@link SUBAGENT_PLAN}. */ export type SubagentKeysMatchPlan = Exact>; export declare function sanitizeSubagentForMirror(binding: H2ASubagentBinding): H2AMirroredSubagentBinding; export {}; //# sourceMappingURL=sanitize.d.ts.map