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(); +});