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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 45 additions & 17 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions packages/core/src/admin/persona.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 } : {}),
Expand Down
19 changes: 10 additions & 9 deletions packages/extension/src/manager/claim-sensitivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
//
Expand Down
84 changes: 72 additions & 12 deletions packages/extension/src/manager/panes/persona-editors.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -116,18 +119,67 @@ export function FactValue({
value,
style,
textStyle,
reveal,
}: {
type: string;
value: unknown;
/** Typography for the row — applied to the wrapper, so the control inherits it. */
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<unknown>;
}) {
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<string | null>(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 (
<span style={{ display: "inline-flex", alignItems: "baseline", gap: 7, minWidth: 0, ...style }}>
<span
Expand All @@ -137,26 +189,34 @@ export function FactValue({
// make an attribute the holder has look exactly like an attribute they do not,
// and the difference is the one thing a hidden value must still say.
color: withheld ? c.faint : c.text,
...(masked && !shown ? { fontFamily: font.mono, letterSpacing: 0.5 } : {}),
...(covered ? { fontFamily: font.mono, letterSpacing: 0.5 } : {}),
...textStyle,
}}
>
{masked && !shown ? hidden : text}
{covered ? hidden : text}
</span>
{masked && (
{refused && (
<span style={{ flexShrink: 0, fontSize: t.xs, color: c.warn }}>— {refused}</span>
)}
{pressable && (
<button
// The attribute card underneath is itself a click target — it selects the
// attribute. Without this, revealing a value also moves the selection, and
// the strip the operator was reading changes under them.
onClick={(e) => {
e.stopPropagation();
setShown((s) => !s);
if (revealed !== null || shown) hide();
else if (withheld) void ask();
else setShown(true);
}}
disabled={asking}
title={
shown
? "Hide it again."
: "Hidden because this kind of attribute is sensitive. Showing it changes what is on your " +
"screen, not what this page holds — your agent has already sent the value here."
shown || revealed !== null
? "Hide it again — a value this page asked for is dropped, not covered over."
: withheld
? "Your agent has not sent this value to this page. Show asks it for this one value."
: "Hidden because this kind of attribute is sensitive. Showing it changes what is on your " +
"screen, not what this page holds — your agent has already sent the value here."
}
style={{
flexShrink: 0,
Expand All @@ -168,10 +228,10 @@ export function FactValue({
fontSize: t.xs,
fontWeight: 600,
fontFamily: "inherit",
cursor: "pointer",
cursor: asking ? "default" : "pointer",
}}
>
{shown ? "Hide" : "Show"}
{label}
</button>
)}
</span>
Expand Down
26 changes: 20 additions & 6 deletions packages/extension/src/manager/panes/persona-map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────

Expand Down Expand Up @@ -356,6 +357,7 @@ export function IdentityMap({
profiles,
records,
history,
onReveal,
onChanged,
banner,
}: {
Expand All @@ -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<unknown>;
onChanged: () => void;
/** Shown once, above the map — the guided setup's hand-off. */
banner?: ReactNode;
Expand Down Expand Up @@ -606,6 +612,7 @@ export function IdentityMap({
<FactValue
type={f.type}
value={f.value}
reveal={() => onReveal({ attributeId: f.id, type: f.type })}
style={{ minWidth: 0, overflow: "hidden" }}
textStyle={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}
/>
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -906,6 +914,7 @@ function DetailStrip({
history,
finding,
showing,
onReveal,
onShow,
onEdit,
onChanged,
Expand All @@ -920,6 +929,7 @@ function DetailStrip({
history: DisclosureRecord[] | null;
finding: CorrelationFinding | null;
showing: "claims" | null;
onReveal: (target: RevealTarget) => Promise<unknown>;
onShow: (s: "claims" | null) => void;
onEdit: (e: Editing) => void;
onChanged: () => void;
Expand Down Expand Up @@ -965,21 +975,25 @@ function DetailStrip({
<FactValue
type={attribute.type}
value={attribute.value}
reveal={() => onReveal({ attributeId: attribute.id, type: attribute.type })}
style={{ fontSize: t.md, fontWeight: 640 }}
textStyle={{ wordBreak: "break-word" }}
/>
<span style={{ fontSize: t.sm, color: c.faint }}>
{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" : ""}
</span>
{/* 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) && (
<span style={{ fontSize: t.sm, color: c.faint }}>
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."}
</span>
)}
{attribute.stale && <span style={{ fontSize: t.sm, color: c.warn }}>Can no longer be proven ({attribute.staleReason ?? "stale"}).</span>}
Expand Down
Loading
Loading