From 314ae4f886740f3c16c7189523292f0e21b4baaa Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Tue, 8 Sep 2026 12:42:19 +0200 Subject: [PATCH] fix(persona): Show asks the agent for the value, because it was never sent one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `persona/attribute/list` answers a values request with the metadata of every attribute resolving to `sensitivity: high` and the plaintext of none, unless the caller also sets `includeSensitive`. The console never set it. So the pane held no value for `profile.github`, `employer`, `phone.mobile` or anything else the registry treats as sensitive — and it masked the placeholder that stood in for them. The result was two states wearing one shape. A card read `••••` beside a *Show* button; pressing *Show* said "not requested"; and a line underneath explained that the agent "has already sent this value here", which was the one thing that had not happened. The reassuring reading was the wrong one, and nothing on screen distinguished a value being kept off the screen from a value that was never there. The specification is blunt about which half of sensitivity matters: "a consumer that masks a value it has already received defends a screen; it does not keep a card number out of a log, a crash dump or a process's memory. Ignoring this member and masking client-side is a conforming implementation of nothing." - `AttributeListParams.includeSensitive` in `@openvtc/pnm-core/admin`, passed through to the payload. The wire member has existed since trust-tasks 0.17.4; only the client was missing. - The pane keeps listing **without** it, so the plaintext genuinely is not in the page, and *Show* becomes the request: `manager/reveal-value.ts` asks for one attribute, narrowed by `typePrefix` to its own type and matched back by `attributeId` — there is no `attribute/get`, and a type can have siblings. *Hide* drops what was fetched rather than covering it over. - `FactValue` never masks a withheld value. The mask says "a value is here and is being kept off your screen"; over an absent one it said the opposite of the truth. A withheld value now reads "not on this page", and the strip's explanation splits into the two sentences it always needed. - The obvious shortcut — `includeSensitive` on the pane's own listing — is refused in `reveal-value.ts`'s header, with the reason: it is three lines, it makes every *Show* instant, and it puts every card and passport number the holder owns into a React tree because one button might be pressed. Three notes corrected while in the same paragraph, all of them stale rather than wrong when written: the read-path control now exists and is used (CLAUDE.md and `claim-sensitivity.ts`'s header both said it did not); the prefix walk landed in trust-tasks#377 and only ever tightens; and the vocabulary paragraph still said the on-screen word is *fact*, which #191 changed and #192 built on. 11 new tests (339 extension, 549 core), `tsc -b` clean, `npm run build` clean. Signed-off-by: Glenn Gore --- CLAUDE.md | 62 +++++++--- packages/core/src/admin/persona.ts | 23 ++++ .../src/manager/claim-sensitivity.ts | 19 +-- .../src/manager/panes/persona-editors.tsx | 84 +++++++++++-- .../src/manager/panes/persona-map.tsx | 26 +++- .../extension/src/manager/panes/persona.tsx | 11 ++ .../extension/src/manager/reveal-value.ts | 88 ++++++++++++++ .../tests/manager-reveal-value.test.mts | 98 +++++++++++++++ .../tests/persona-pane.render.test.mts | 113 ++++++++++++++++++ 9 files changed, 480 insertions(+), 44 deletions(-) create mode 100644 packages/extension/src/manager/reveal-value.ts create mode 100644 packages/extension/tests/manager-reveal-value.test.mts diff --git a/CLAUDE.md b/CLAUDE.md index da5e4fa..258f2cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -323,13 +323,18 @@ address and phone number without showing them any of it. the pool, the faces and every context's bindings, builds `identity-graph.ts`'s model, and shows either the guided setup (`persona-setup.tsx`, while the holder has no face) or the identity map (`persona-map.tsx`). What lights up when -something is selected — a fact's reach runs *down* to the contexts it goes to, -a context's runs *up* to the facts it holds — is computed in -`identity-graph.ts` and tested; the component only draws. The on-screen words -are a **fact**, a **face**, a **context** and a persona that **wears** a face, -per `design-docs/persona-vocabulary.md`; the spec's words (`attribute`, -`profile`, `binding`, `materialise`) stay in code and off the screen. Add copy -in those words, or change the document first. +something is selected — an attribute's reach runs *down* to the contexts it goes +to, a context's runs *up* to the attributes it holds — is computed in +`identity-graph.ts` and tested; the component only draws. The on-screen words are +an **attribute**, a **face**, a **context** and a persona that **wears** a face, +per `design-docs/persona-vocabulary.md`. The word for a value the holder keeps is +the spec's own: *fact* asserted a truth the model cannot promise — the card said +it directly above a provenance line reading *you said so* — and `fact` was +already spent on `vtc-service`'s verified policy inputs, very nearly the opposite +meaning in the same product (#191). It is banned from screen copy, and +`manager-holder-gate.test.mts` checks. The remaining spec words (`profile`, +`binding`, `materialise`) stay in code and off the screen. Add copy in those +words, or change the document first. **Colour on the map carries three things, in three channels that never overlap.** The **border** is selection and reach; the **inset stripe** on an @@ -373,16 +378,39 @@ registry's masking data — sensitivity and mask style per token, from the agent does not serve that table: `persona/claim-types/list` is an open question in `CLAIM-TYPES.md` §6, deferred until the first extension type ships. An unregistered or `x:` token resolves to the conservative default -(`high`/`full`) per §4 rule 3, and there is deliberately **no prefix walk**: the -JSON declares only leaves, so inventing a `payment.*` family rule locally would -make an unknown member of that family show *more* than the registry asks. - -**It is not a security control and must not be described as one.** The value was -fetched before any of it ran, so masking changes what is drawn and never what -the page holds. It defends against a shoulder, a screenshot and a screen share, -which is the whole scope. The control that would matter is a read-path one — -`includeSensitive` on `persona/attribute/list`, so a listing that did not ask is -answered without the values — and it does not exist yet. +(`high`/`full`) per §4 rule 3. The prefix walk **is** rule 3 and it only ever +*tightens*: an unregistered token takes the more protective of its longest +registered prefix and that default, per axis — so `payment.giftCard` inherits +`payment`'s gating and cannot be escaped by inventing a token, while +`name.somethingNew` does **not** inherit `name`'s `none` and stays masked. (This +note used to say there was deliberately no walk, which was true of the table +before the registry gained one in trust-tasks#377.) A local rule that walks in +the *loosening* direction is still the thing to refuse. + +**The mask is not the control. The request is.** Masking a value already +fetched defends a shoulder, a screenshot and a screen share, and nothing else — +never say more than that about it. The control that matters is on the read path, +it now exists, and the console uses it: `includeSensitive` on +`persona/attribute/list` (trust-tasks 0.17.4). The pane lists with +`includeValues` and **without** it, so the plaintext of every `sensitivity: high` +attribute is genuinely not in the page, and *Show* is the request for one — +`manager/reveal-value.ts`, narrowed by `typePrefix` to that attribute's type and +matched back by `attributeId`, because there is no `attribute/get` and a type can +have siblings. *Hide* then **drops** what was fetched rather than covering it. + +Before this the console never sent the member, so the agent answered with the +metadata of every sensitive attribute and the plaintext of none — and the pane +drew a mask over the placeholder. A card read `••••` beside a *Show* that +revealed "not requested", under a line promising the agent "has already sent +this value here". Two states, one shape on screen, and the reassuring one was +the lie. + +**What breaks it:** setting `includeSensitive` on the pane's own listing (three +lines, every *Show* instant, and every card and passport number the holder owns +sitting in a React tree because a button *might* be pressed — the decorative +version with extra steps); masking a withheld placeholder, which claims a value +is being held back when none arrived; matching a reveal by position rather than +`attributeId`; or a *Hide* that only covers what a press fetched. **A `release: stepUp` disclosure is refused, and the refusal is returned rather than thrown.** `payment.*` and `gov.*` resolve to `release: stepUp` in the diff --git a/packages/core/src/admin/persona.ts b/packages/core/src/admin/persona.ts index 5e13763..91c82d8 100644 --- a/packages/core/src/admin/persona.ts +++ b/packages/core/src/admin/persona.ts @@ -165,6 +165,28 @@ export interface AttributeListParams extends PersonaHolderParams { * holder's identity, and the agent decrypts every one to answer it. */ includeValues?: boolean; + /** + * Widen `includeValues` to cover attributes resolving to `sensitivity: high`. + * + * **This is the half of sensitivity that is not cosmetic.** Without it the + * agent answers a values listing with the metadata of every sensitive + * attribute and the plaintext of none, so a client that masks what it + * received is not the control — the request it did not make is. The + * specification says so directly: "a consumer that masks a value it has + * already received defends a screen; it does not keep a card number out of a + * log, a crash dump or a process's memory." + * + * Separate from `includeValues` rather than a third state of it, because a + * picker wants every name and no card and should not have to choose between + * plaintext for everything and plaintext for nothing. It has no effect on its + * own: it widens a values request and can never be the thing that introduces + * plaintext. + * + * Ask for it per attribute, at the moment a human asks to see one — not for + * a whole pool up front, which is the shape that makes a mask decorative + * again. + */ + includeSensitive?: boolean; /** * Include attributes whose backing credential can no longer be re-derived. * Defaults to *included* at the agent: a holder deciding what to present @@ -183,6 +205,7 @@ export async function personaAttributeList( const payload: PersonaAttributeListPayload = { ...(params.typePrefix !== undefined ? { typePrefix: params.typePrefix } : {}), ...(params.includeValues !== undefined ? { includeValues: params.includeValues } : {}), + ...(params.includeSensitive !== undefined ? { includeSensitive: params.includeSensitive } : {}), ...(params.includeStale !== undefined ? { includeStale: params.includeStale } : {}), ...(params.limit !== undefined ? { limit: params.limit } : {}), ...(params.cursor !== undefined ? { cursor: params.cursor } : {}), diff --git a/packages/extension/src/manager/claim-sensitivity.ts b/packages/extension/src/manager/claim-sensitivity.ts index 276d3f4..2a18195 100644 --- a/packages/extension/src/manager/claim-sensitivity.ts +++ b/packages/extension/src/manager/claim-sensitivity.ts @@ -15,15 +15,16 @@ // shared, a screenshot pasted into an issue. Those are real, and they are the // entire scope. // -// **The control that would matter does not exist yet.** It is a read-path one — -// an `includeSensitive` flag on `persona/attribute/list`, so a listing that did -// not ask for sensitive values is answered without them and the console never -// holds the string in the first place. `CLAIM-TYPES.md` §3.1 says the same -// thing in one sentence: "Masking a value already fetched is theatre. The -// control that matters is on the read path; the mask is what makes the control -// visible." Until that flag lands in the spec and the agent, this file is the -// visible half of a control whose enforcing half is missing. Do not describe it -// as anything more in a UI string, a commit message or a review. +// **The control that matters is the read path, and it now exists.** +// `includeSensitive` on `persona/attribute/list` (trust-tasks 0.17.4) is what +// keeps a sensitive value out of the page in the first place, and the persona +// pane lists *without* it: see `reveal-value.ts`, where *Show* becomes the +// request for one value rather than a curtain drawn back over a string that was +// already here. `CLAIM-TYPES.md` §3.1 says it in one sentence — "Masking a value +// already fetched is theatre. The control that matters is on the read path; the +// mask is what makes the control visible." This file is that visible half, and +// only that half. Do not describe it as anything more in a UI string, a commit +// message or a review. // // ## The table below is vendored, and will go stale // diff --git a/packages/extension/src/manager/panes/persona-editors.tsx b/packages/extension/src/manager/panes/persona-editors.tsx index f0e6994..01ad3cf 100644 --- a/packages/extension/src/manager/panes/persona-editors.tsx +++ b/packages/extension/src/manager/panes/persona-editors.tsx @@ -78,7 +78,10 @@ export function Label({ children }: { children: React.ReactNode }) { * printing a passport number in full, and it would look like ordinary code. */ function formatValue(value: unknown): { text: string; withheld: boolean } { - if (value === undefined) return { text: "not requested", withheld: true }; + // "not on this page" rather than "not requested": the second described the + // request that was made, which is a fact about the console, while the person + // reading it wants to know where the value is. It is with their agent. + if (value === undefined) return { text: "not on this page", withheld: true }; if (value === null) return { text: "null", withheld: false }; if (typeof value === "string") return { text: value, withheld: false }; if (typeof value === "number" || typeof value === "boolean") { @@ -116,6 +119,7 @@ export function FactValue({ value, style, textStyle, + reveal, }: { type: string; value: unknown; @@ -123,11 +127,59 @@ export function FactValue({ style?: React.CSSProperties; /** Wrapping or truncation for the value itself, which differs per surface. */ textStyle?: React.CSSProperties; + /** + * Ask the agent for this one value, when it did not send it. + * + * Optional, because not every surface can: a claim inside a face was read + * from a binding, not from the pool, and there is no second question to ask + * about it. Where it is absent a withheld value simply says so — which is + * the honest end of the sentence, and better than a *Show* that cannot. + */ + reveal?: () => Promise; }) { const [shown, setShown] = useState(false); - const { text, withheld } = formatValue(value); + const [revealed, setRevealed] = useState<{ value: unknown } | null>(null); + const [asking, setAsking] = useState(false); + const [refused, setRefused] = useState(null); + + const { text, withheld } = formatValue(revealed ? revealed.value : value); const { text: hidden, masked } = maskedFact(type, text); + // **A withheld value is never masked.** The mask is a statement that a value + // is here and is being kept off the screen; drawing it over "not on this + // page" said the opposite of the truth, and hid the fact that the console + // had never been sent anything. This is the line that makes the difference + // between the two states visible instead of identical. + const covered = masked && !withheld && !shown; + const askable = withheld && reveal !== undefined && !asking; + + const ask = async () => { + if (!reveal) return; + setAsking(true); + setRefused(null); + try { + setRevealed({ value: await reveal() }); + setShown(true); + } catch (e) { + setRefused(e instanceof Error ? e.message : String(e)); + } finally { + setAsking(false); + } + }; + + // Hiding a value this component fetched *drops* it, rather than covering it + // again. The plaintext arrived because a person asked; when they are done + // with it there is no reason for the page to keep holding it, and a mask over + // a value still in the tree is the decorative version this whole path exists + // to stop being. + const hide = () => { + setRevealed(null); + setShown(false); + }; + + const label = asking ? "Asking…" : shown || (revealed !== null) ? "Hide" : "Show"; + const pressable = askable || covered || shown || revealed !== null; + return ( - {masked && ( + {refused && ( + — {refused} + )} + {pressable && ( )} diff --git a/packages/extension/src/manager/panes/persona-map.tsx b/packages/extension/src/manager/panes/persona-map.tsx index b9a4109..4fb22c6 100644 --- a/packages/extension/src/manager/panes/persona-map.tsx +++ b/packages/extension/src/manager/panes/persona-map.tsx @@ -61,6 +61,7 @@ import { } from "./persona-editors.js"; import { holderGate } from "../holder-gate.js"; import { isSensitive } from "../claim-sensitivity.js"; +import type { RevealTarget } from "../reveal-value.js"; // ── Words for what the agent knows ────────────────────────────────────────── @@ -356,6 +357,7 @@ export function IdentityMap({ profiles, records, history, + onReveal, onChanged, banner, }: { @@ -368,6 +370,10 @@ export function IdentityMap({ /** Everything that has left, for "last left" on a selected attribute. Null while * loading or refused — the strip then says nothing rather than "never". */ history: DisclosureRecord[] | null; + /** Ask the agent for one withheld value. Threaded down rather than called + * here, because the parties belong to the pane and a component that could + * ask on its own is one that could ask for all of them. */ + onReveal: (target: RevealTarget) => Promise; onChanged: () => void; /** Shown once, above the map — the guided setup's hand-off. */ banner?: ReactNode; @@ -606,6 +612,7 @@ export function IdentityMap({ onReveal({ attributeId: f.id, type: f.type })} style={{ minWidth: 0, overflow: "hidden" }} textStyle={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }} /> @@ -836,6 +843,7 @@ export function IdentityMap({ profiles={profiles} records={records} history={history} + onReveal={onReveal} finding={selection.kind === "attribute" ? (valueLinked.get(selection.id) ?? null) : null} showing={showing} onShow={setShowing} @@ -906,6 +914,7 @@ function DetailStrip({ history, finding, showing, + onReveal, onShow, onEdit, onChanged, @@ -920,6 +929,7 @@ function DetailStrip({ history: DisclosureRecord[] | null; finding: CorrelationFinding | null; showing: "claims" | null; + onReveal: (target: RevealTarget) => Promise; onShow: (s: "claims" | null) => void; onEdit: (e: Editing) => void; onChanged: () => void; @@ -965,6 +975,7 @@ function DetailStrip({ onReveal({ attributeId: attribute.id, type: attribute.type })} style={{ fontSize: t.md, fontWeight: 640 }} textStyle={{ wordBreak: "break-word" }} /> @@ -972,14 +983,17 @@ function DetailStrip({ {attribute.label ? `${attribute.label} · ` : ""}{prov.text} {attribute.provenance.kind === "credentialBacked" ? " — provable, and the same signature to everyone who sees it" : attribute.provenance.kind === "selfAsserted" ? " — passed on, never proven" : ""} - {/* The one place with room to say what the mask is and is not. A - *Show* button with no explanation invites the reading that a - hidden value is one the console does not hold, and this console - holds every value it draws. */} + {/* The one place with room to say what the mask is and is not — + and the two cases are not the same sentence. A value the agent + sent is being kept off the screen and nothing more. A value it + withheld is not in this page at all, and *Show* is the request + for it. Saying the first about the second is what the strip did + before, and it was the one claim it must never make wrongly. */} {isSensitive(attribute.type) && ( - Hidden until you press Show — that is about who can see your screen. Your agent has - already sent this value here. + {attribute.value === undefined + ? "Your agent has not sent this value to this page. Show asks it for this one." + : "Hidden until you press Show — that is about who can see your screen. Your agent has already sent this value here."} )} {attribute.stale && Can no longer be proven ({attribute.staleReason ?? "stale"}).} diff --git a/packages/extension/src/manager/panes/persona.tsx b/packages/extension/src/manager/panes/persona.tsx index d791c98..872c999 100644 --- a/packages/extension/src/manager/panes/persona.tsx +++ b/packages/extension/src/manager/panes/persona.tsx @@ -62,6 +62,7 @@ import { buildGraph, type ContextInput } from "../identity-graph.js"; import { IdentityMap } from "./persona-map.js"; import { GuidedSetup } from "./persona-setup.js"; import { showsGuide } from "../persona-flow.js"; +import { revealAttributeValue, type RevealTarget } from "../reveal-value.js"; import { DisclosureHistoryPanel } from "./persona-editors.js"; /** @@ -125,10 +126,19 @@ export function PersonaPane({ // their own pool from a surface holding an unscoped holder credential — // the one place where showing them is the job — and a map of types with // no values cannot answer "is this the right phone number". + // Values are asked for, sensitive ones are not. `includeSensitive` is the + // half that is not cosmetic (see `reveal-value.ts`): without it the agent + // answers with the metadata of every `sensitivity: high` attribute and the + // plaintext of none, which is exactly the state this pane should be in until + // a person presses *Show* on one of them. const attributes = useAsync( async () => personaAttributeList(managerSender, { ...parties, includeValues: true }), [parties.holder.did, parties.service.did], ); + const reveal = useCallback( + (target: RevealTarget) => revealAttributeValue(managerSender, parties, target), + [parties], + ); const profiles = useAsync( async () => personaProfileList(managerSender, parties), [parties.holder.did, parties.service.did], @@ -227,6 +237,7 @@ export function PersonaPane({ profiles={profiles.data} records={records} history={history.data?.disclosures ?? null} + onReveal={reveal} onChanged={reloadAll} banner={ banner ? ( diff --git a/packages/extension/src/manager/reveal-value.ts b/packages/extension/src/manager/reveal-value.ts new file mode 100644 index 0000000..416d966 --- /dev/null +++ b/packages/extension/src/manager/reveal-value.ts @@ -0,0 +1,88 @@ +// Asking the agent for one sensitive value, at the moment a person asks to see it. +// +// ## Why this exists at all +// +// `persona/attribute/list` answers a values request with the metadata of every +// attribute resolving to `sensitivity: high` and the plaintext of none, unless +// the caller also sets `includeSensitive`. The console never set it, so those +// values never arrived — and the pane masked the placeholder that stood in for +// them. A card read `••••` beside a *Show* button, pressing *Show* said "not on +// this page", and a strip underneath explained that the agent "has already sent +// this value here", which was the one thing that had not happened. +// +// The masking was not wrong so much as pointed at nothing. The specification is +// blunt about which half matters: "a consumer that masks a value it has already +// received defends a screen; it does not keep a card number out of a log, a +// crash dump or a process's memory. Ignoring this member and masking +// client-side is a conforming implementation of nothing." +// +// ## One value, on a press, and not a pool +// +// So the pane keeps listing without sensitive values, and *Show* becomes the +// request. Until it is pressed the plaintext is genuinely not in the page — +// which is what makes the mask a control rather than a curtain — and pressing +// it fetches exactly the one attribute a person is looking at. +// +// The obvious shortcut is to set `includeSensitive` on the pane's own listing. +// It is three lines, it makes every *Show* instant, and it puts every passport +// number and card number the holder owns into a React tree for as long as the +// tab is open, on the reasoning that they might press one of the buttons. That +// is the decorative version with extra steps. +// +// ## Narrowed by type, matched by id +// +// There is no `attribute/get` in the vocabulary — `list`, `put`, `delete` — so +// the narrowest question available is `typePrefix` set to the attribute's own +// type. That can return siblings (two `phone.mobile` entries), which is why the +// answer is matched on `attributeId` and never on position. A prefix is a byte +// comparison at the agent (SPEC: "the maintainer MUST NOT interpret the value +// further"), so passing a whole type is a legal, and the tightest, prefix. + +import { personaAttributeList, type PoolAttribute } from "@openvtc/pnm-core/admin"; +import type { TrustTaskSender } from "@openvtc/pnm-core"; +import type { Parties } from "./use-vta.js"; + +/** What the caller already holds about the attribute it wants revealed: the + * metadata listing gave it both, and neither is the value. */ +export interface RevealTarget { + attributeId: string; + type: string; +} + +/** + * The plaintext of one attribute, asked for explicitly. + * + * Throws when the agent answers without it. That is deliberate: a caller that + * received `undefined` would render "not on this page" again and the person + * would press *Show* a second time, learning nothing. The two ways it happens + * are worth telling apart in the message a surface shows — the attribute is + * gone, or the agent declined to widen the listing — and both are the agent + * saying no, rather than this function failing to ask. + */ +export async function revealAttributeValue( + sender: TrustTaskSender, + parties: Parties, + target: RevealTarget, +): Promise { + const listed: PoolAttribute[] = await personaAttributeList(sender, { + ...parties, + typePrefix: target.type, + includeValues: true, + includeSensitive: true, + }); + const found = listed.find((a) => a.attributeId === target.attributeId); + if (!found) { + throw new Error("your agent no longer lists this attribute"); + } + if (found.value === undefined) { + // `stale` is the specification's own discriminator for an absent value on + // a credential-backed attribute, and it is a different sentence: the value + // could not be re-derived, so there is nothing being withheld. + throw new Error( + found.stale === true + ? `its backing credential could not be re-derived (${found.staleReason ?? "stale"})` + : "your agent held the value back", + ); + } + return found.value; +} diff --git a/packages/extension/tests/manager-reveal-value.test.mts b/packages/extension/tests/manager-reveal-value.test.mts new file mode 100644 index 0000000..cdedb37 --- /dev/null +++ b/packages/extension/tests/manager-reveal-value.test.mts @@ -0,0 +1,98 @@ +// Asking the agent for one withheld value. +// +// The bug this closes was silent in the worst way: the console never sent +// `includeSensitive`, so the agent answered with metadata and no plaintext, and +// the pane drew a mask over the placeholder. Every sensitive attribute looked +// like a value being kept off the screen and was in fact a value that had never +// arrived. So the first assertion here is simply that the member is sent — that +// is the whole defect — and the rest is about not repeating it in a subtler way. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { revealAttributeValue } from "../src/manager/reveal-value.ts"; + +const PARTIES = { + holder: { did: "did:key:holder" }, + service: { did: "did:web:agent" }, +} as never; + +/** A sender that records the envelope it was handed and answers with the given + * attributes. `send` resolves to the response *payload*, which is the contract + * `TrustTaskSender` declares. */ +function sender(attributes: unknown[]) { + const sent: { type: string; payload: Record }[] = []; + return { + sent, + send: async (envelope: { type: string; payload: Record }) => { + sent.push({ type: envelope.type, payload: envelope.payload }); + return { attributes } as never; + }, + }; +} + +const attr = (over: Record = {}) => ({ + attributeId: "a1", + type: "phone.mobile", + valueType: "string", + provenance: { kind: "selfAsserted" }, + version: 1, + updatedAt: "x", + ...over, +}); + +test("the request asks for values AND for the sensitive ones", async () => { + const s = sender([attr({ value: "+65 8262 2325" })]); + const value = await revealAttributeValue(s as never, PARTIES, { attributeId: "a1", type: "phone.mobile" }); + assert.equal(value, "+65 8262 2325"); + const payload = s.sent[0]!.payload; + assert.equal(payload.includeValues, true); + assert.equal(payload.includeSensitive, true, "without this the agent answers with no plaintext at all"); +}); + +test("the question is narrowed to the one type, not the whole pool", async () => { + // `includeSensitive` over a bare listing would pull every passport and card + // number the holder owns into the page because one button might be pressed. + const s = sender([attr({ value: "x" })]); + await revealAttributeValue(s as never, PARTIES, { attributeId: "a1", type: "phone.mobile" }); + assert.equal(s.sent[0]!.payload.typePrefix, "phone.mobile"); +}); + +test("the answer is matched by id, because a type can have siblings", async () => { + // Two phone numbers, and the one asked for is second. Taking the first would + // show the holder the wrong value with no sign anything was wrong. + const s = sender([ + attr({ attributeId: "a0", value: "the other one" }), + attr({ attributeId: "a1", value: "the one asked for" }), + ]); + const value = await revealAttributeValue(s as never, PARTIES, { attributeId: "a1", type: "phone.mobile" }); + assert.equal(value, "the one asked for"); +}); + +test("an agent that still withholds the value is an error, not another blank", async () => { + // Returning `undefined` would redraw "not on this page" and the person would + // press Show again, learning nothing about why. + const s = sender([attr()]); + await assert.rejects( + () => revealAttributeValue(s as never, PARTIES, { attributeId: "a1", type: "phone.mobile" }), + /held the value back/, + ); +}); + +test("a stale credential-backed attribute says that instead", async () => { + // `stale` is the specification's own discriminator for an absent value, and + // it is a different sentence: nothing is being withheld, the backing could + // not be re-derived. + const s = sender([attr({ stale: true, staleReason: "revoked", provenance: { kind: "credentialBacked" } })]); + await assert.rejects( + () => revealAttributeValue(s as never, PARTIES, { attributeId: "a1", type: "phone.mobile" }), + /could not be re-derived \(revoked\)/, + ); +}); + +test("an attribute the agent no longer lists says so", async () => { + const s = sender([]); + await assert.rejects( + () => revealAttributeValue(s as never, PARTIES, { attributeId: "a1", type: "phone.mobile" }), + /no longer lists this attribute/, + ); +}); diff --git a/packages/extension/tests/persona-pane.render.test.mts b/packages/extension/tests/persona-pane.render.test.mts index 218a3eb..3f2651a 100644 --- a/packages/extension/tests/persona-pane.render.test.mts +++ b/packages/extension/tests/persona-pane.render.test.mts @@ -528,3 +528,116 @@ test("attributes are grouped under the family their claim type comes from", asyn assert.doesNotMatch(text, /Not in the registry/, "no unregistered attribute here, so no heading for one"); await ui.unmount(); }); + +// ── A value the agent never sent (the "not requested" report) ─────────────── +// +// `persona/attribute/list` withholds the plaintext of every attribute +// resolving to `sensitivity: high` unless the caller sets `includeSensitive`, +// which the console never did. So the pane received metadata, and drew a mask +// over the placeholder standing in for the missing value: a card reading +// `••••` beside a *Show* that revealed "not requested", under a line promising +// that the agent "has already sent this value here". +// +// Two states had become one shape on screen. These tests keep them apart, in +// the direction that matters: a value that is here and covered, and a value +// that is not here at all. + +const WITHHELD = [ + attribute("f1", "name.legal", "Glenn Gore"), + // No value — exactly what the agent returns for an unregistered type, which + // resolves to the conservative `high`/`full`. + { ...attribute("f9", "profile.github", ""), value: undefined }, +]; + +const withheldMap = (extra: Record = {}) => + h(IdentityMap, { + parties: PARTIES, + authority: HOLDER, + graph: buildGraph(WITHHELD, [], []), + attributes: WITHHELD, + profiles: [], + records: CONTEXTS, + history: [], + onReveal: async () => "octocat", + onChanged: () => {}, + ...extra, + }); + +test("a value the agent withheld is said to be missing, not masked", async () => { + const a = agent({}); + const ui = await render(withheldMap(), { chrome: { runtime: { sendMessage: a.sendMessage } } }); + const text = ui.text(); + assert.match(text, /not on this page/, "the card says where the value is: with the agent"); + assert.doesNotMatch(text, /•/, "a mask over a value nobody sent claims one is being held back"); + await ui.unmount(); +}); + +test("Show fetches the one withheld value and displays it", async () => { + const asked: unknown[] = []; + const a = agent({}); + const ui = await render( + withheldMap({ + onReveal: async (target: unknown) => { + asked.push(target); + return "octocat"; + }, + }), + { chrome: { runtime: { sendMessage: a.sendMessage } } }, + ); + await ui.click(ui.button("Show")); + assert.match(ui.text(), /octocat/, "the value arrives only because it was asked for"); + assert.deepEqual(asked, [{ attributeId: "f9", type: "profile.github" }], "one attribute, not the pool"); + await ui.unmount(); +}); + +test("Hide drops a fetched value rather than covering it over", async () => { + // The whole point of asking on a press is that the plaintext is not in the + // page until then. Covering it again would put it back where it was. + const a = agent({}); + const ui = await render(withheldMap(), { chrome: { runtime: { sendMessage: a.sendMessage } } }); + await ui.click(ui.button("Show")); + assert.match(ui.text(), /octocat/); + await ui.click(ui.button("Hide")); + assert.doesNotMatch(ui.text(), /octocat/, "hidden means gone from the page, not greyed"); + assert.match(ui.text(), /not on this page/); + await ui.unmount(); +}); + +test("an agent that refuses says why, in place, and does not blank the card", async () => { + const a = agent({}); + const ui = await render( + withheldMap({ onReveal: async () => { throw new Error("your agent held the value back"); } }), + { chrome: { runtime: { sendMessage: a.sendMessage } } }, + ); + await ui.click(ui.button("Show")); + assert.match(ui.text(), /held the value back/); + assert.match(ui.text(), /not on this page/, "the card still says what it knows"); + await ui.unmount(); +}); + +test("a value the agent did send is still covered locally, with no second question", async () => { + // `phone.mobile` is registered `high`/`last2`, so this is the case where the + // console legitimately holds the value and hides it from the room. Pressing + // Show must not turn into a request. + const held = [attribute("f2", "phone.mobile", "+65 8262 2325")]; + let asked = 0; + const a = agent({}); + const ui = await render( + h(IdentityMap, { + parties: PARTIES, + authority: HOLDER, + graph: buildGraph(held, [], []), + attributes: held, + profiles: [], + records: CONTEXTS, + history: [], + onReveal: async () => { asked += 1; return "nope"; }, + onChanged: () => {}, + }), + { chrome: { runtime: { sendMessage: a.sendMessage } } }, + ); + await ui.click(ui.button("Show")); + assert.match(ui.text(), /8262 2325/); + assert.equal(asked, 0, "it was already here — asking again would be a second disclosure for nothing"); + await ui.unmount(); +});