Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 35 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,19 +66,46 @@ jobs:
echo "OK: $bundle is a single bundle with no dynamic import()"

# `@openvtc/pnm-core/admin` is operator surface — granting authority at an
# agent, revoking it, destroying contexts. A wallet has no business
# shipping any of it, and the way it would arrive is someone importing it
# from the package root instead of the subpath. The task URIs are the
# tell: they only appear in a bundle that pulled the module in.
- name: Assert the wallet ships no agent-administration surface
# agent, revoking it, destroying contexts. The task URIs are the tell:
# they only appear in a bundle that pulled the module in.
#
# The management console (`manager.html`) administers the agent, so it
# imports the module deliberately. Every *wallet* surface — the service
# worker, the content and page-world scripts, the popup, the confirm
# window, the offscreen document, the options page — still must not, and
# the way it would arrive is someone importing from the package root
# instead of the subpath, or Rollup hoisting a shared chunk.
#
# Hence: banned everywhere in dist/ **except** `manager.js`. Phrased as an
# exclusion rather than a list of permitted files so it keeps holding as
# entries are added. `vite.config.manager.ts` builds the console alone
# with `codeSplitting: false`, which is what makes "exactly one file may
# contain this" a structural property rather than a convention.
- name: Assert agent-administration surface is confined to the console
run: |
for task in 'acl/grant/0.1' 'acl/revoke/0.1' 'acl/update/0.1' 'contexts/delete/1.0' 'keys/create/0.1' 'keys/sign/0.1' 'policy/upsert/0.2' 'device/wipe/0.1' 'config/patch/0.1' 'vta/did-templates/create/2.0' 'consent/approver-set/1.0' 'keys/import/0.1' 'did-management/did/delete/0.1' 'vta/services/enable/1.0' 'vta/services/disable/1.0' 'vta/credentials/issue/0.1' 'vta/credentials/revoke/0.1'; do
if grep -rlF "$task" packages/extension/dist/; then
echo "::error::the extension bundle contains $task — @openvtc/pnm-core/admin must not be reachable from the wallet (check for a root-barrel import)"
leaked=$(grep -rlF "$task" packages/extension/dist/ | grep -v '^packages/extension/dist/manager\.js$' || true)
if [ -n "$leaked" ]; then
echo "::error::$leaked contains $task — @openvtc/pnm-core/admin must not be reachable from any wallet surface (check for a root-barrel import, or a shared chunk)"
exit 1
fi
done
echo "OK: no admin task URIs in the extension bundle"
echo "OK: admin task URIs appear only in manager.js"

# The console's isolation rests on it being one self-contained file: the
# guard above names exactly one exception, so a second chunk would be a
# file nothing checks. Losing `codeSplitting: false` in a future upgrade
# is silent otherwise.
- name: Assert the console is a single self-contained bundle
run: |
bundle=packages/extension/dist/manager.js
test -f "$bundle" || { echo "::error::$bundle was not emitted — did the manager build run?"; exit 1; }
extra=$(ls packages/extension/dist/manager-split-*.js 2>/dev/null || true)
if [ -n "$extra" ]; then
echo "::error::the console emitted extra chunks ($extra); codeSplitting: false was lost and the admin guard now has unchecked files"
exit 1
fi
echo "OK: manager.js is a single bundle"

# Build the Chrome Web Store upload artefact from the dist/ that the
# Build step just produced (scripts/package.mjs re-stages it; it does
Expand Down
61 changes: 61 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,67 @@ routing either through a channel would overwrite or duplicate a proof.
document as the counterparty receives it — a signature copied from another
document satisfies an "is there a `proof` member" check and fails this one.

## The wallet ships no operator authority — the console does

`@openvtc/pnm-core/admin` is operator surface: granting authority at an agent,
revoking it, destroying contexts. It is deliberately absent from the package
root barrel, and CI greps the built output for 17 of its task URIs.

That guard used to read "banned anywhere in `dist/`", on the grounds that a
wallet has no business shipping any of it. The **management console**
(`manager.html`) makes that statement false on purpose — administering the agent
is its whole job — so the guard was **narrowed, not deleted**: banned everywhere
in `dist/` *except* `manager.js`. Every wallet surface (service worker, content
and page-world scripts, popup, confirm, offscreen, options) keeps the property
the guard was protecting.

**The console is its own vite build** (`vite.config.manager.ts`,
`codeSplitting: false`). That is what makes "exactly one file may contain admin"
structural rather than a convention: the main build emits popup, options,
confirm and offscreen *together*, and Rollup is free to hoist shared code into a
common `assets/*.js` chunk that wallet surfaces load. Building the console alone
means there is no other entry to share with. A second CI assertion fails if it
ever emits more than one chunk, because the first guard names exactly one
exception and an extra chunk is a file nothing checks.

**The console holds no key material.** It composes typed documents with the
`admin/*` helpers and the offscreen document signs them, so an XSS there cannot
exfiltrate a key. This is why `admin/*` and `vta/contexts.ts` type their
envelope parties as `TaskParty` (`vta/channel.ts`) — just a DID — rather than
`Identity` and `RemoteDidcommEndpoint`: only `.did` was ever read, and a
surface typed on `Identity` can only be called from somewhere holding a private
key. The REST convenience wrappers (`vtaListContexts`, `vtaCreateContext`) still
take the stricter pair, because they *build a channel*, and a channel signs.

**Only `type` and `payload` cross the bridge.** `RUNTIME_MANAGER_TASK` carries
those two members and nothing else; `carrier.ts` strips the envelope the admin
helper built, and `offscreen.ts`'s existing `OFFSCREEN_REQUEST_TASK` mints the
real one and signs it. `core/src/vta/request-task.ts` explains why the device
must mint it, and that reasoning does not soften because the composer is an
extension page: a wallet that counter-signs a document composed elsewhere
attests to fields it never checked. Reusing that path also inherits transport
selection, `TransportHealth`, and the same-browser approver ceremony for free —
`offscreen.ts` needed no change at all.

**The relay is gated on `sender.url`, not `sender.id`.** Every content script
carries this extension's id, so `sender.id` cannot separate a page from an
extension surface. `isExtensionPageSender` compares against
`chrome.runtime.getURL("")`. Unlike the page-facing `RUNTIME_REQUEST_TASK`, this
one does **not** prompt per call — the caller is the operator driving their own
console, and twelve identical dialogs to render one screen is dismissal, not
consent. What stands in its place: the agent's ACL, its policy engine (a
`requireConsent` comes back as `ConsentRequiredError` and renders as a match-code
ceremony, never as a red string), and preview-then-confirm on every irreversible
action, showing the agent's own account of what would be destroyed.

**What breaks it:** importing `admin` from the package root instead of the
subpath; folding `manager.html` into `vite.config.ts` (a shared chunk then
carries admin into wallet surfaces); losing `codeSplitting: false`; adding
`RUNTIME_MANAGER_TASK` to `PAGE_FACING_RUNTIME_TYPES` or to `content.ts`'s
dispatch table; gating on `sender.id`; or widening the carrier to pass the
envelope through. `tests/manager-sender.test.mts`,
`tests/manager-surface.test.mts` and the two CI assertions pin each of these.

## Advertisement is not availability

A VTA's DID document says what it *offers*. `buildVtaSession` skips a channel
Expand Down
8 changes: 3 additions & 5 deletions packages/core/src/admin/acl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,7 @@
// from the Rust structs, which is a copy that drifts, and got the nullability of
// `acl/show`'s response wrong in the process.

import type { Identity } from "../didcomm/index.js";
import type { TrustTaskSender } from "../vta/channel.js";
import type { RemoteDidcommEndpoint } from "../vta/didcomm.js";
import type { TaskParty, TrustTaskSender } from "../vta/channel.js";
import { buildTrustTask } from "../vta/trust-task.js";

import {
Expand Down Expand Up @@ -73,9 +71,9 @@ export type { AclEntry };
export interface AclCallerParams {
/** Envelope `issuer` — the caller's DIDComm identity. Its DID needs a role
* the agent accepts for this task; the whole family is manage-gated. */
holder: Identity;
holder: TaskParty;
/** The agent — envelope `recipient`. */
service: RemoteDidcommEndpoint;
service: TaskParty;
}

export interface AclGrantParams extends AclCallerParams {
Expand Down
8 changes: 3 additions & 5 deletions packages/core/src/admin/consent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,7 @@
// consent and carries a challenge the approver's decision is bound to. Most
// consoles will use `consentList`, `consentDecision` and `consentRevoke`.

import type { Identity } from "../didcomm/index.js";
import type { TrustTaskSender } from "../vta/channel.js";
import type { RemoteDidcommEndpoint } from "../vta/didcomm.js";
import type { TaskParty, TrustTaskSender } from "../vta/channel.js";
import { buildTrustTask } from "../vta/trust-task.js";

import {
Expand Down Expand Up @@ -63,8 +61,8 @@ import {
export type { ConsentGrant, ConsentSubject, ApproverBinding };

export interface ConsentCallerParams {
holder: Identity;
service: RemoteDidcommEndpoint;
holder: TaskParty;
service: TaskParty;
}

export interface ConsentListParams extends ConsentCallerParams {
Expand Down
8 changes: 3 additions & 5 deletions packages/core/src/admin/contexts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,16 @@
// snake_case. Both fields happen to be single words, which is exactly the kind
// of coincidence that hides a casing bug until someone adds `dry_run`.

import type { Identity } from "../didcomm/index.js";
import type { TrustTaskSender } from "../vta/channel.js";
import type { RemoteDidcommEndpoint } from "../vta/didcomm.js";
import type { TaskParty, TrustTaskSender } from "../vta/channel.js";
import { buildTrustTask } from "../vta/trust-task.js";

const TASK_CONTEXTS_DELETE = "https://trusttasks.org/spec/vta/contexts/delete/1.0";
const TASK_CONTEXTS_PREVIEW_DELETE =
"https://trusttasks.org/spec/vta/contexts/preview-delete/1.0";

export interface ContextDeleteParams {
holder: Identity;
service: RemoteDidcommEndpoint;
holder: TaskParty;
service: TaskParty;
/** Context id (full path for a nested context). */
id: string;
/** Delete even when the context still holds keys or DIDs. Default false. */
Expand Down
8 changes: 3 additions & 5 deletions packages/core/src/admin/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,7 @@
// two families version independently; a matching pair is a coincidence, not a
// rule, so read the import paths rather than assuming.

import type { Identity } from "../didcomm/index.js";
import type { TrustTaskSender } from "../vta/channel.js";
import type { RemoteDidcommEndpoint } from "../vta/didcomm.js";
import type { TaskParty, TrustTaskSender } from "../vta/channel.js";
import { buildTrustTask } from "../vta/trust-task.js";

import {
Expand All @@ -32,9 +30,9 @@ import {
/** Both calls are issued by an operator identity, to an agent. */
export interface CredentialIssuerCallerParams {
/** Envelope `issuer` — needs an agent role that carries issuing authority. */
holder: Identity;
holder: TaskParty;
/** The issuing agent — envelope `recipient`. */
service: RemoteDidcommEndpoint;
service: TaskParty;
}

export interface IssueCredentialParams extends CredentialIssuerCallerParams {
Expand Down
8 changes: 3 additions & 5 deletions packages/core/src/admin/devices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,7 @@
// request missing either: a wipe with no recorded reason is an audit gap. That
// is a deliberate obstacle, so this wrapper adds no default for either.

import type { Identity } from "../didcomm/index.js";
import type { TrustTaskSender } from "../vta/channel.js";
import type { RemoteDidcommEndpoint } from "../vta/didcomm.js";
import type { TaskParty, TrustTaskSender } from "../vta/channel.js";
import { buildTrustTask } from "../vta/trust-task.js";

import {
Expand Down Expand Up @@ -41,8 +39,8 @@ export type { DeviceBinding };
export type WipeScope = DeviceWipePayload["scope"];

export interface DeviceCallerParams {
holder: Identity;
service: RemoteDidcommEndpoint;
holder: TaskParty;
service: TaskParty;
}

export interface DeviceListParams extends DeviceCallerParams {
Expand Down
8 changes: 3 additions & 5 deletions packages/core/src/admin/did-templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,7 @@
// the operation to that context's templates; omitting it addresses the global
// set. The two are different namespaces, and a name can exist in both.

import type { Identity } from "../didcomm/index.js";
import type { TrustTaskSender } from "../vta/channel.js";
import type { RemoteDidcommEndpoint } from "../vta/didcomm.js";
import type { TaskParty, TrustTaskSender } from "../vta/channel.js";
import { buildTrustTask } from "../vta/trust-task.js";

import {
Expand Down Expand Up @@ -60,8 +58,8 @@ import {
export type { DidTemplate, DidTemplateRecord };

export interface DidTemplateCallerParams {
holder: Identity;
service: RemoteDidcommEndpoint;
holder: TaskParty;
service: TaskParty;
/** Scope the call to one context's templates. Omit for the global set —
* a different namespace, in which the same name may also exist. */
contextId?: string;
Expand Down
8 changes: 3 additions & 5 deletions packages/core/src/admin/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,7 @@
// the parameter type enforces it here; the cleartext warning cannot be
// enforced by any type and is stated where a caller will read it.

import type { Identity } from "../didcomm/index.js";
import type { TrustTaskSender } from "../vta/channel.js";
import type { RemoteDidcommEndpoint } from "../vta/didcomm.js";
import type { TaskParty, TrustTaskSender } from "../vta/channel.js";
import { buildTrustTask } from "../vta/trust-task.js";

import {
Expand Down Expand Up @@ -103,9 +101,9 @@ export type KeyRecord = SpecKeyRecord;

export interface KeysCallerParams {
/** Envelope `issuer` — the caller's DIDComm identity. */
holder: Identity;
holder: TaskParty;
/** The agent — envelope `recipient`. */
service: RemoteDidcommEndpoint;
service: TaskParty;
}

export interface KeysCreateParams extends KeysCallerParams {
Expand Down
8 changes: 3 additions & 5 deletions packages/core/src/admin/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,7 @@
// counterpart only in the sense that both name a key — the list is deliberately
// a directory, so enumerating memory does not spill its contents.

import type { Identity } from "../didcomm/index.js";
import type { TrustTaskSender } from "../vta/channel.js";
import type { RemoteDidcommEndpoint } from "../vta/didcomm.js";
import type { TaskParty, TrustTaskSender } from "../vta/channel.js";
import { buildTrustTask } from "../vta/trust-task.js";

import {
Expand All @@ -34,8 +32,8 @@ import {
} from "@openvtc/trust-tasks/vta/memory/delete/0.1/payload";

export interface MemoryCallerParams {
holder: Identity;
service: RemoteDidcommEndpoint;
holder: TaskParty;
service: TaskParty;
/** Required — memory has no global namespace. */
contextId: string;
}
Expand Down
8 changes: 3 additions & 5 deletions packages/core/src/admin/observability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,7 @@
// "under what settings", and `config/patch` is the one write, kept in the same
// file because you should never be looking at the second without the first.

import type { Identity } from "../didcomm/index.js";
import type { TrustTaskSender } from "../vta/channel.js";
import type { RemoteDidcommEndpoint } from "../vta/didcomm.js";
import type { TaskParty, TrustTaskSender } from "../vta/channel.js";
import { buildTrustTask } from "../vta/trust-task.js";

import {
Expand Down Expand Up @@ -40,8 +38,8 @@ import {
export type { AuditEnvelope, ConfigField };

export interface ObservabilityCallerParams {
holder: Identity;
service: RemoteDidcommEndpoint;
holder: TaskParty;
service: TaskParty;
}

export interface AuditListParams extends ObservabilityCallerParams {
Expand Down
8 changes: 3 additions & 5 deletions packages/core/src/admin/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,7 @@
//
// Payload and response types come from `@openvtc/trust-tasks`.

import type { Identity } from "../didcomm/index.js";
import type { TrustTaskSender } from "../vta/channel.js";
import type { RemoteDidcommEndpoint } from "../vta/didcomm.js";
import type { TaskParty, TrustTaskSender } from "../vta/channel.js";
import { buildTrustTask } from "../vta/trust-task.js";

import {
Expand Down Expand Up @@ -46,8 +44,8 @@ import {
export type { PolicyModule };

export interface PolicyCallerParams {
holder: Identity;
service: RemoteDidcommEndpoint;
holder: TaskParty;
service: TaskParty;
}

export interface PolicyListParams extends PolicyCallerParams {
Expand Down
8 changes: 3 additions & 5 deletions packages/core/src/admin/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,7 @@
// calls it off. A caller that treats `disable` as instantaneous will report an
// agent as off while it is still answering.

import type { Identity } from "../didcomm/index.js";
import type { TrustTaskSender } from "../vta/channel.js";
import type { RemoteDidcommEndpoint } from "../vta/didcomm.js";
import type { TaskParty, TrustTaskSender } from "../vta/channel.js";
import { buildTrustTask } from "../vta/trust-task.js";

import {
Expand Down Expand Up @@ -70,9 +68,9 @@ export type { ServiceState, ServiceKind };
/** Every `vta/services/*` call is issued by an operator identity, to an agent. */
export interface ServicesCallerParams {
/** Envelope `issuer`. Needs an admin role — the whole family is manage-gated. */
holder: Identity;
holder: TaskParty;
/** The agent — envelope `recipient`. */
service: RemoteDidcommEndpoint;
service: TaskParty;
}

/** Transport configuration. Which members apply depends on the `ServiceKind`. */
Expand Down
8 changes: 3 additions & 5 deletions packages/core/src/admin/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,7 @@
//
// Payload and response types come from `@openvtc/trust-tasks`.

import type { Identity } from "../didcomm/index.js";
import type { TrustTaskSender } from "../vta/channel.js";
import type { RemoteDidcommEndpoint } from "../vta/didcomm.js";
import type { TaskParty, TrustTaskSender } from "../vta/channel.js";
import { buildTrustTask } from "../vta/trust-task.js";

import {
Expand All @@ -40,8 +38,8 @@ import {
export type { Session };

export interface SessionCallerParams {
holder: Identity;
service: RemoteDidcommEndpoint;
holder: TaskParty;
service: TaskParty;
}

/**
Expand Down
Loading
Loading