Skip to content

Commit 9c45415

Browse files
authored
Merge pull request #194 from OpenVTC/fix/persona-sensitive-read-path
fix(persona): Show asks the agent for the value, because it was never sent one
2 parents 5993312 + 314ae4f commit 9c45415

9 files changed

Lines changed: 480 additions & 44 deletions

File tree

‎CLAUDE.md‎

Lines changed: 45 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -323,13 +323,18 @@ address and phone number without showing them any of it.
323323
the pool, the faces and every context's bindings, builds `identity-graph.ts`'s
324324
model, and shows either the guided setup (`persona-setup.tsx`, while the holder
325325
has no face) or the identity map (`persona-map.tsx`). What lights up when
326-
something is selected — a fact's reach runs *down* to the contexts it goes to,
327-
a context's runs *up* to the facts it holds — is computed in
328-
`identity-graph.ts` and tested; the component only draws. The on-screen words
329-
are a **fact**, a **face**, a **context** and a persona that **wears** a face,
330-
per `design-docs/persona-vocabulary.md`; the spec's words (`attribute`,
331-
`profile`, `binding`, `materialise`) stay in code and off the screen. Add copy
332-
in those words, or change the document first.
326+
something is selected — an attribute's reach runs *down* to the contexts it goes
327+
to, a context's runs *up* to the attributes it holds — is computed in
328+
`identity-graph.ts` and tested; the component only draws. The on-screen words are
329+
an **attribute**, a **face**, a **context** and a persona that **wears** a face,
330+
per `design-docs/persona-vocabulary.md`. The word for a value the holder keeps is
331+
the spec's own: *fact* asserted a truth the model cannot promise — the card said
332+
it directly above a provenance line reading *you said so* — and `fact` was
333+
already spent on `vtc-service`'s verified policy inputs, very nearly the opposite
334+
meaning in the same product (#191). It is banned from screen copy, and
335+
`manager-holder-gate.test.mts` checks. The remaining spec words (`profile`,
336+
`binding`, `materialise`) stay in code and off the screen. Add copy in those
337+
words, or change the document first.
333338

334339
**Colour on the map carries three things, in three channels that never
335340
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
373378
the agent does not serve that table: `persona/claim-types/list` is an open
374379
question in `CLAIM-TYPES.md` §6, deferred until the first extension type ships.
375380
An unregistered or `x:` token resolves to the conservative default
376-
(`high`/`full`) per §4 rule 3, and there is deliberately **no prefix walk**: the
377-
JSON declares only leaves, so inventing a `payment.*` family rule locally would
378-
make an unknown member of that family show *more* than the registry asks.
379-
380-
**It is not a security control and must not be described as one.** The value was
381-
fetched before any of it ran, so masking changes what is drawn and never what
382-
the page holds. It defends against a shoulder, a screenshot and a screen share,
383-
which is the whole scope. The control that would matter is a read-path one —
384-
`includeSensitive` on `persona/attribute/list`, so a listing that did not ask is
385-
answered without the values — and it does not exist yet.
381+
(`high`/`full`) per §4 rule 3. The prefix walk **is** rule 3 and it only ever
382+
*tightens*: an unregistered token takes the more protective of its longest
383+
registered prefix and that default, per axis — so `payment.giftCard` inherits
384+
`payment`'s gating and cannot be escaped by inventing a token, while
385+
`name.somethingNew` does **not** inherit `name`'s `none` and stays masked. (This
386+
note used to say there was deliberately no walk, which was true of the table
387+
before the registry gained one in trust-tasks#377.) A local rule that walks in
388+
the *loosening* direction is still the thing to refuse.
389+
390+
**The mask is not the control. The request is.** Masking a value already
391+
fetched defends a shoulder, a screenshot and a screen share, and nothing else —
392+
never say more than that about it. The control that matters is on the read path,
393+
it now exists, and the console uses it: `includeSensitive` on
394+
`persona/attribute/list` (trust-tasks 0.17.4). The pane lists with
395+
`includeValues` and **without** it, so the plaintext of every `sensitivity: high`
396+
attribute is genuinely not in the page, and *Show* is the request for one —
397+
`manager/reveal-value.ts`, narrowed by `typePrefix` to that attribute's type and
398+
matched back by `attributeId`, because there is no `attribute/get` and a type can
399+
have siblings. *Hide* then **drops** what was fetched rather than covering it.
400+
401+
Before this the console never sent the member, so the agent answered with the
402+
metadata of every sensitive attribute and the plaintext of none — and the pane
403+
drew a mask over the placeholder. A card read `••••` beside a *Show* that
404+
revealed "not requested", under a line promising the agent "has already sent
405+
this value here". Two states, one shape on screen, and the reassuring one was
406+
the lie.
407+
408+
**What breaks it:** setting `includeSensitive` on the pane's own listing (three
409+
lines, every *Show* instant, and every card and passport number the holder owns
410+
sitting in a React tree because a button *might* be pressed — the decorative
411+
version with extra steps); masking a withheld placeholder, which claims a value
412+
is being held back when none arrived; matching a reveal by position rather than
413+
`attributeId`; or a *Hide* that only covers what a press fetched.
386414

387415
**A `release: stepUp` disclosure is refused, and the refusal is returned rather
388416
than thrown.** `payment.*` and `gov.*` resolve to `release: stepUp` in the

‎packages/core/src/admin/persona.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,28 @@ export interface AttributeListParams extends PersonaHolderParams {
165165
* holder's identity, and the agent decrypts every one to answer it.
166166
*/
167167
includeValues?: boolean;
168+
/**
169+
* Widen `includeValues` to cover attributes resolving to `sensitivity: high`.
170+
*
171+
* **This is the half of sensitivity that is not cosmetic.** Without it the
172+
* agent answers a values listing with the metadata of every sensitive
173+
* attribute and the plaintext of none, so a client that masks what it
174+
* received is not the control — the request it did not make is. The
175+
* specification says so directly: "a consumer that masks a value it has
176+
* already received defends a screen; it does not keep a card number out of a
177+
* log, a crash dump or a process's memory."
178+
*
179+
* Separate from `includeValues` rather than a third state of it, because a
180+
* picker wants every name and no card and should not have to choose between
181+
* plaintext for everything and plaintext for nothing. It has no effect on its
182+
* own: it widens a values request and can never be the thing that introduces
183+
* plaintext.
184+
*
185+
* Ask for it per attribute, at the moment a human asks to see one — not for
186+
* a whole pool up front, which is the shape that makes a mask decorative
187+
* again.
188+
*/
189+
includeSensitive?: boolean;
168190
/**
169191
* Include attributes whose backing credential can no longer be re-derived.
170192
* Defaults to *included* at the agent: a holder deciding what to present
@@ -183,6 +205,7 @@ export async function personaAttributeList(
183205
const payload: PersonaAttributeListPayload = {
184206
...(params.typePrefix !== undefined ? { typePrefix: params.typePrefix } : {}),
185207
...(params.includeValues !== undefined ? { includeValues: params.includeValues } : {}),
208+
...(params.includeSensitive !== undefined ? { includeSensitive: params.includeSensitive } : {}),
186209
...(params.includeStale !== undefined ? { includeStale: params.includeStale } : {}),
187210
...(params.limit !== undefined ? { limit: params.limit } : {}),
188211
...(params.cursor !== undefined ? { cursor: params.cursor } : {}),

‎packages/extension/src/manager/claim-sensitivity.ts‎

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,16 @@
1515
// shared, a screenshot pasted into an issue. Those are real, and they are the
1616
// entire scope.
1717
//
18-
// **The control that would matter does not exist yet.** It is a read-path one —
19-
// an `includeSensitive` flag on `persona/attribute/list`, so a listing that did
20-
// not ask for sensitive values is answered without them and the console never
21-
// holds the string in the first place. `CLAIM-TYPES.md` §3.1 says the same
22-
// thing in one sentence: "Masking a value already fetched is theatre. The
23-
// control that matters is on the read path; the mask is what makes the control
24-
// visible." Until that flag lands in the spec and the agent, this file is the
25-
// visible half of a control whose enforcing half is missing. Do not describe it
26-
// as anything more in a UI string, a commit message or a review.
18+
// **The control that matters is the read path, and it now exists.**
19+
// `includeSensitive` on `persona/attribute/list` (trust-tasks 0.17.4) is what
20+
// keeps a sensitive value out of the page in the first place, and the persona
21+
// pane lists *without* it: see `reveal-value.ts`, where *Show* becomes the
22+
// request for one value rather than a curtain drawn back over a string that was
23+
// already here. `CLAIM-TYPES.md` §3.1 says it in one sentence — "Masking a value
24+
// already fetched is theatre. The control that matters is on the read path; the
25+
// mask is what makes the control visible." This file is that visible half, and
26+
// only that half. Do not describe it as anything more in a UI string, a commit
27+
// message or a review.
2728
//
2829
// ## The table below is vendored, and will go stale
2930
//

‎packages/extension/src/manager/panes/persona-editors.tsx‎

Lines changed: 72 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,10 @@ export function Label({ children }: { children: React.ReactNode }) {
7878
* printing a passport number in full, and it would look like ordinary code.
7979
*/
8080
function formatValue(value: unknown): { text: string; withheld: boolean } {
81-
if (value === undefined) return { text: "not requested", withheld: true };
81+
// "not on this page" rather than "not requested": the second described the
82+
// request that was made, which is a fact about the console, while the person
83+
// reading it wants to know where the value is. It is with their agent.
84+
if (value === undefined) return { text: "not on this page", withheld: true };
8285
if (value === null) return { text: "null", withheld: false };
8386
if (typeof value === "string") return { text: value, withheld: false };
8487
if (typeof value === "number" || typeof value === "boolean") {
@@ -116,18 +119,67 @@ export function FactValue({
116119
value,
117120
style,
118121
textStyle,
122+
reveal,
119123
}: {
120124
type: string;
121125
value: unknown;
122126
/** Typography for the row — applied to the wrapper, so the control inherits it. */
123127
style?: React.CSSProperties;
124128
/** Wrapping or truncation for the value itself, which differs per surface. */
125129
textStyle?: React.CSSProperties;
130+
/**
131+
* Ask the agent for this one value, when it did not send it.
132+
*
133+
* Optional, because not every surface can: a claim inside a face was read
134+
* from a binding, not from the pool, and there is no second question to ask
135+
* about it. Where it is absent a withheld value simply says so — which is
136+
* the honest end of the sentence, and better than a *Show* that cannot.
137+
*/
138+
reveal?: () => Promise<unknown>;
126139
}) {
127140
const [shown, setShown] = useState(false);
128-
const { text, withheld } = formatValue(value);
141+
const [revealed, setRevealed] = useState<{ value: unknown } | null>(null);
142+
const [asking, setAsking] = useState(false);
143+
const [refused, setRefused] = useState<string | null>(null);
144+
145+
const { text, withheld } = formatValue(revealed ? revealed.value : value);
129146
const { text: hidden, masked } = maskedFact(type, text);
130147

148+
// **A withheld value is never masked.** The mask is a statement that a value
149+
// is here and is being kept off the screen; drawing it over "not on this
150+
// page" said the opposite of the truth, and hid the fact that the console
151+
// had never been sent anything. This is the line that makes the difference
152+
// between the two states visible instead of identical.
153+
const covered = masked && !withheld && !shown;
154+
const askable = withheld && reveal !== undefined && !asking;
155+
156+
const ask = async () => {
157+
if (!reveal) return;
158+
setAsking(true);
159+
setRefused(null);
160+
try {
161+
setRevealed({ value: await reveal() });
162+
setShown(true);
163+
} catch (e) {
164+
setRefused(e instanceof Error ? e.message : String(e));
165+
} finally {
166+
setAsking(false);
167+
}
168+
};
169+
170+
// Hiding a value this component fetched *drops* it, rather than covering it
171+
// again. The plaintext arrived because a person asked; when they are done
172+
// with it there is no reason for the page to keep holding it, and a mask over
173+
// a value still in the tree is the decorative version this whole path exists
174+
// to stop being.
175+
const hide = () => {
176+
setRevealed(null);
177+
setShown(false);
178+
};
179+
180+
const label = asking ? "Asking…" : shown || (revealed !== null) ? "Hide" : "Show";
181+
const pressable = askable || covered || shown || revealed !== null;
182+
131183
return (
132184
<span style={{ display: "inline-flex", alignItems: "baseline", gap: 7, minWidth: 0, ...style }}>
133185
<span
@@ -137,26 +189,34 @@ export function FactValue({
137189
// make an attribute the holder has look exactly like an attribute they do not,
138190
// and the difference is the one thing a hidden value must still say.
139191
color: withheld ? c.faint : c.text,
140-
...(masked && !shown ? { fontFamily: font.mono, letterSpacing: 0.5 } : {}),
192+
...(covered ? { fontFamily: font.mono, letterSpacing: 0.5 } : {}),
141193
...textStyle,
142194
}}
143195
>
144-
{masked && !shown ? hidden : text}
196+
{covered ? hidden : text}
145197
</span>
146-
{masked && (
198+
{refused && (
199+
<span style={{ flexShrink: 0, fontSize: t.xs, color: c.warn }}>— {refused}</span>
200+
)}
201+
{pressable && (
147202
<button
148203
// The attribute card underneath is itself a click target — it selects the
149204
// attribute. Without this, revealing a value also moves the selection, and
150205
// the strip the operator was reading changes under them.
151206
onClick={(e) => {
152207
e.stopPropagation();
153-
setShown((s) => !s);
208+
if (revealed !== null || shown) hide();
209+
else if (withheld) void ask();
210+
else setShown(true);
154211
}}
212+
disabled={asking}
155213
title={
156-
shown
157-
? "Hide it again."
158-
: "Hidden because this kind of attribute is sensitive. Showing it changes what is on your " +
159-
"screen, not what this page holds — your agent has already sent the value here."
214+
shown || revealed !== null
215+
? "Hide it again — a value this page asked for is dropped, not covered over."
216+
: withheld
217+
? "Your agent has not sent this value to this page. Show asks it for this one value."
218+
: "Hidden because this kind of attribute is sensitive. Showing it changes what is on your " +
219+
"screen, not what this page holds — your agent has already sent the value here."
160220
}
161221
style={{
162222
flexShrink: 0,
@@ -168,10 +228,10 @@ export function FactValue({
168228
fontSize: t.xs,
169229
fontWeight: 600,
170230
fontFamily: "inherit",
171-
cursor: "pointer",
231+
cursor: asking ? "default" : "pointer",
172232
}}
173233
>
174-
{shown ? "Hide" : "Show"}
234+
{label}
175235
</button>
176236
)}
177237
</span>

‎packages/extension/src/manager/panes/persona-map.tsx‎

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import {
6161
} from "./persona-editors.js";
6262
import { holderGate } from "../holder-gate.js";
6363
import { isSensitive } from "../claim-sensitivity.js";
64+
import type { RevealTarget } from "../reveal-value.js";
6465

6566
// ── Words for what the agent knows ──────────────────────────────────────────
6667

@@ -356,6 +357,7 @@ export function IdentityMap({
356357
profiles,
357358
records,
358359
history,
360+
onReveal,
359361
onChanged,
360362
banner,
361363
}: {
@@ -368,6 +370,10 @@ export function IdentityMap({
368370
/** Everything that has left, for "last left" on a selected attribute. Null while
369371
* loading or refused — the strip then says nothing rather than "never". */
370372
history: DisclosureRecord[] | null;
373+
/** Ask the agent for one withheld value. Threaded down rather than called
374+
* here, because the parties belong to the pane and a component that could
375+
* ask on its own is one that could ask for all of them. */
376+
onReveal: (target: RevealTarget) => Promise<unknown>;
371377
onChanged: () => void;
372378
/** Shown once, above the map — the guided setup's hand-off. */
373379
banner?: ReactNode;
@@ -606,6 +612,7 @@ export function IdentityMap({
606612
<FactValue
607613
type={f.type}
608614
value={f.value}
615+
reveal={() => onReveal({ attributeId: f.id, type: f.type })}
609616
style={{ minWidth: 0, overflow: "hidden" }}
610617
textStyle={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}
611618
/>
@@ -836,6 +843,7 @@ export function IdentityMap({
836843
profiles={profiles}
837844
records={records}
838845
history={history}
846+
onReveal={onReveal}
839847
finding={selection.kind === "attribute" ? (valueLinked.get(selection.id) ?? null) : null}
840848
showing={showing}
841849
onShow={setShowing}
@@ -906,6 +914,7 @@ function DetailStrip({
906914
history,
907915
finding,
908916
showing,
917+
onReveal,
909918
onShow,
910919
onEdit,
911920
onChanged,
@@ -920,6 +929,7 @@ function DetailStrip({
920929
history: DisclosureRecord[] | null;
921930
finding: CorrelationFinding | null;
922931
showing: "claims" | null;
932+
onReveal: (target: RevealTarget) => Promise<unknown>;
923933
onShow: (s: "claims" | null) => void;
924934
onEdit: (e: Editing) => void;
925935
onChanged: () => void;
@@ -965,21 +975,25 @@ function DetailStrip({
965975
<FactValue
966976
type={attribute.type}
967977
value={attribute.value}
978+
reveal={() => onReveal({ attributeId: attribute.id, type: attribute.type })}
968979
style={{ fontSize: t.md, fontWeight: 640 }}
969980
textStyle={{ wordBreak: "break-word" }}
970981
/>
971982
<span style={{ fontSize: t.sm, color: c.faint }}>
972983
{attribute.label ? `${attribute.label} · ` : ""}{prov.text}
973984
{attribute.provenance.kind === "credentialBacked" ? " — provable, and the same signature to everyone who sees it" : attribute.provenance.kind === "selfAsserted" ? " — passed on, never proven" : ""}
974985
</span>
975-
{/* The one place with room to say what the mask is and is not. A
976-
*Show* button with no explanation invites the reading that a
977-
hidden value is one the console does not hold, and this console
978-
holds every value it draws. */}
986+
{/* The one place with room to say what the mask is and is not —
987+
and the two cases are not the same sentence. A value the agent
988+
sent is being kept off the screen and nothing more. A value it
989+
withheld is not in this page at all, and *Show* is the request
990+
for it. Saying the first about the second is what the strip did
991+
before, and it was the one claim it must never make wrongly. */}
979992
{isSensitive(attribute.type) && (
980993
<span style={{ fontSize: t.sm, color: c.faint }}>
981-
Hidden until you press Show — that is about who can see your screen. Your agent has
982-
already sent this value here.
994+
{attribute.value === undefined
995+
? "Your agent has not sent this value to this page. Show asks it for this one."
996+
: "Hidden until you press Show — that is about who can see your screen. Your agent has already sent this value here."}
983997
</span>
984998
)}
985999
{attribute.stale && <span style={{ fontSize: t.sm, color: c.warn }}>Can no longer be proven ({attribute.staleReason ?? "stale"}).</span>}

0 commit comments

Comments
 (0)