Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
9a3c726
feat(console): VTA management console in the wallet, on the Trust Con…
stormer78 Sep 1, 2026
f2a2481
Merge remote-tracking branch 'origin/main' into vta-management
stormer78 Sep 1, 2026
8229305
fix(console): five things a live agent showed were wrong
stormer78 Sep 1, 2026
3479cb8
feat(console): app state, agent memory and credential issuance
stormer78 Sep 1, 2026
6e2b832
feat(console): the credentials this agent holds
stormer78 Sep 1, 2026
ed5e3f7
feat(console): the issued-credentials list, and the notice it replaces
stormer78 Sep 2, 2026
2421a63
ci: refuse key material in any extension bundle, console included
stormer78 Sep 2, 2026
78549f7
docs: say how to re-verify the key-material guard, because the obviou…
stormer78 Sep 2, 2026
c949b32
feat(console): proof of presence for the irreversible controls
stormer78 Sep 2, 2026
6df3d8d
test(console): pin the step-up as a gate, and stop it failing where s…
stormer78 Sep 2, 2026
a3e220d
feat(console): backup abort and reload-services, behind proof of pres…
stormer78 Sep 2, 2026
bd4ffc0
test(console): a section with no pane must fail the build, not render…
stormer78 Sep 2, 2026
94ba66d
refactor(core): take task types from the registry, and delete two dea…
stormer78 Sep 2, 2026
0f2b554
fix(core): swapAcl was reading fields the agent has never sent
stormer78 Sep 2, 2026
798a115
fix(core): the guard could not see a type-alias shadow, and one was h…
stormer78 Sep 3, 2026
03d9a36
chore(deps): fast-uri 3.1.5 -> 3.1.7, clearing four advisories
stormer78 Sep 3, 2026
d0b6b2a
refactor(core): every task URI now comes from the registry; correct t…
stormer78 Sep 3, 2026
cb4c3c8
feat(popup): reach the management console from the panel that names t…
stormer78 Sep 3, 2026
17463e6
feat(console): print the backup commands instead of pointing at the CLI
stormer78 Sep 3, 2026
1755505
refactor(console): Maintenance — the operations whose subject is the …
stormer78 Sep 3, 2026
93091f9
fix(console): a context editor showed the wrong context's name, and c…
stormer78 Sep 3, 2026
acedf36
feat(console): agent names, reassigning a context DID, and a refusal …
stormer78 Sep 3, 2026
e6b0e8f
feat(console): view a credential, find one at all, and switch agent
stormer78 Sep 3, 2026
0fee453
feat(console): pick a context's DID from a list instead of pasting one
stormer78 Sep 3, 2026
9982173
fix(console): show a credential under the row you clicked, not after …
stormer78 Sep 3, 2026
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
75 changes: 67 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,19 +66,78 @@ 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"

# A second, stricter guard — and the difference from the one above is the
# point.
#
# That guard is about *authority*: `admin/*` grants and revokes it, and
# the console is deliberately the one surface that holds it, so it names
# `manager.js` as an exception.
#
# These tasks are about *material*. `vta/seeds/export-mnemonic/1.0`
# returns a BIP-39 mnemonic — the seed every derived key in the agent
# comes from — and `list`/`rotate` are the rest of that family's surface.
# There is no browser context that should be able to ask for them, so this
# guard has **no exception**: not the console, not the wallet, nowhere in
# `dist/`.
#
# It exists because the alternative is an omission, and an omission is
# indistinguishable from not having got to it yet. Someone reasonable
# could add a seeds pane next year and no one would know it was refused on
# purpose. This is what says so.
#
# `vault/release/0.1` is deliberately NOT here: it releases a secret to a
# site the human just approved, which is the wallet's whole job.
- name: Assert no key-material surface ships at all
run: |
for task in 'vta/seeds/list/1.0' 'vta/seeds/rotate/1.0' 'vta/seeds/export-mnemonic/1.0'; do
found=$(grep -rlF "$task" packages/extension/dist/ || true)
if [ -n "$found" ]; then
echo "::error::$found contains $task — this family returns key material and must not ship in any extension bundle, the console included. See CLAUDE.md."
exit 1
fi
done
echo "OK: no key-material task URIs anywhere in dist/"

# 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
124 changes: 120 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ interaction code:
- **`vti-stack-development-guide.md`** — binding rules (R-numbers below);
paste its pre-merge checklist into PRs.
- **`vti-networking-remediation-plan.md`** — deliverable **D8** covers this
repo (with vti-didcomm-js and pnm-relay).
repo (with vti-didcomm-js; `pnm-relay` was the third and no longer exists —
see R4.1).
- **`vti-architectural-direction.md`** — design-level rationale.

Rules that bite hardest here:
Expand Down Expand Up @@ -54,9 +55,22 @@ Rules that bite hardest here:
network helper here takes an optional `fetch` for testability, so a literal
`grep "fetch("` finds almost nothing — the real calls are spelled `f(...)`,
`fetchFn(...)`, `this.fetchImpl(...)`.
- **R4.1 — shared code with pnm-relay and vti-didcomm-js is a liability until
extracted**: the relay never received this repo's body-first error-parsing
fix. Land contract/transport fixes in all three or extract the shared core.
- **R4.1 — the shared core is extracted; keep it that way.** This rule used to
read "shared code with pnm-relay and vti-didcomm-js is a liability until
extracted: the relay never received this repo's body-first error-parsing
fix". That is done and the note had gone stale: **`pnm-relay` no longer
exists.** Its `rest-channel.ts` / `request-task.ts` were consolidated into
`@openvtc/pnm-core` — the copy `pnm-extension` and `pnm-pwa` both consume,
which carries the body-first parse (`decodeTrustTaskHttpAck` reads the body,
then builds with `errorFromBody`; `errorFromResponse` appears nowhere) and the
`ConsentRequired` union. Nothing depends on `@openvtc/pnm-relay`, and
`rp-sdk-js` is a separate server-side SIOPv2 verifier, not its successor.
(`vti-networking-remediation-plan.md` F5, resolved by consolidation.)

What survives is the *rule*, not the defect: `vti-didcomm-js` is still a
separate implementation of the same wire contract, so a transport or
error-shape fix has to land in both. A third copy is what R4.1 exists to
prevent — do not reintroduce one.

## How persist-before-ack is held (R1.6)

Expand Down Expand Up @@ -128,6 +142,108 @@ 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.

## Key material never reaches a browser, and that is enforced

`vta/seeds/*` — `list`, `rotate`, `export-mnemonic` — is the one task family
this extension refuses outright. `export-mnemonic` returns a BIP-39 mnemonic:
the seed every derived key in the agent comes from, and the one secret whose
disclosure loses everything at once. `list` and `rotate` are the rest of that
family's surface.

**A second CI guard bans all three from anywhere in `dist/`, with no
exception.** That is the difference from the admin guard above, and the
difference is the point: `admin/*` is *authority*, which the console is meant to
hold, so that guard names `manager.js` as its one permitted file. These return
*material*, and no browser context should be able to ask for them — not the
console, not the wallet, nowhere.

**Why a guard rather than simply not building it.** Not building a seeds pane is
indistinguishable from not having got round to one. Someone reasonable adds it
next year, nothing objects, and the refusal was never recorded anywhere a person
would look. The guard is what makes the decision legible.

**Verified non-vacuous — and the way it is verified matters.** A seeds URI
merely *present* in console source is not enough: Rollup tree-shakes an
unreferenced export, the string never reaches `dist/`, and the guard correctly
stays silent. That is the guard being right, not weak — it asserts what
*ships* — but it means a probe that adds an unused `export const` proves
nothing and reads like a hole. To re-verify, put the URI somewhere the console
actually renders (a nav `label`, say), rebuild, and watch `manager.js` trip it.

`packages/core` has no seeds module and must not gain one. The guard catches
that too — a core function would be bundled into `manager.js` and grep would
find it there.

**`vault/release/0.1` is deliberately not on the list.** It releases a secret to
a site the human has just approved, which is the wallet's entire job. The line
is not "touches a secret"; it is "hands over material the holder cannot revoke,
to a surface that cannot contain it".

**What breaks it:** adding a seeds client to `packages/core`; relaxing the guard
to allow `manager.js` "for symmetry" with the admin one; or reading this as
advice rather than a refusal.

## Advertisement is not availability

A VTA's DID document says what it *offers*. `buildVtaSession` skips a channel
Expand Down
17 changes: 10 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,8 @@
"overrides": {
"uuid": "^11.1.1",
"esbuild": "^0.28.1"
},
"dependencies": {
"@openvtc/trust-tasks": "^0.16.8"
}
}
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@
"dependencies": {
"@cfworker/json-schema": "^4.1.1",
"@noble/curves": "^2.4.0",
"@openvtc/trust-tasks": "^0.16.3",
"@openvtc/trust-tasks": "^0.16.8",
"@openvtc/vti-didcomm-js": "^0.7.0",
"@openvtc/vti-tsp-js": "^0.2.0",
"@scure/base": "^2.2.0",
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
Loading
Loading