/**
* Chat-persist credential redaction — sentinel markers with proven vault
* identity (LUM-2768).
*
* The legacy persist path replaces detected secrets with a dead
* `` HTML marker that the chat markdown pipeline (no
* rehype-raw) can never render as an element, and that carries no way back
* to the vault entry it came from. This module implements the replacement
* behavior behind the `chat-credential-reveal` feature flag:
*
* 1. While a turn runs, every shell-style tool command is scanned for
* `credentials reveal` invocations and the referenced credentials
* (`--service`/`--field` flags or a positional UUID) are recorded as
* *candidates* ({@link collectRevealRefsFromCommand}).
* 2. At persist time, only those candidates' plaintexts are fetched from
* the credential store — a scoped read of secrets that were already in
* daemon memory this turn via the reveal stdout, never a vault scan
* ({@link resolveRevealCandidates}).
* 3. Each redacted span in persisted ASSISTANT TEXT is byte-compared
* against the candidate values (tool_result content keeps the legacy
* marker until the tool detail panel can render chips — see
* `buildToolResultBlocks`).
* An exact match *proves* the span is that credential, so the sentinel
* is enriched with `service:field` and the client can offer
* click-to-reveal. Anything else — hand-typed secrets, parse failures,
* value drift — degrades to a plain type-only sentinel that renders as
* a static badge ({@link redactSecretsForChat}).
*
* Failure direction is the design's spine: a wrong `service:field` would let
* the client reveal the *wrong* secret, so enrichment only ever happens on
* exact plaintext equality. Every uncertain path fails toward "badge",
* never toward "mislabeled chip".
*
* The legacy marker path (`redactSecrets`) is untouched — ingress blocking,
* tool-output scanning, and log redaction depend on its exact shape (see
* `security/AGENTS.md`). This module is additive and chat-persist-only.
*/
import {
buildRedactedSentinel,
createRedactedSentinelRegex,
decodeRedactedSentinelMatch,
neutralizeRedactedSentinels,
REDACTED_SENTINEL_CLOSE,
REDACTED_SENTINEL_OPEN,
REDACTED_SENTINEL_TAG,
} from "@vellumai/service-contracts/redacted-credential";
import { revealedValuesSince } from "../runtime/reveal-success-registry.js";
import { credentialKey } from "../security/credential-key.js";
import {
redactSecrets,
redactSecretsWith,
scanText,
} from "../security/secret-scanner.js";
import { getSecureKeyAsync } from "../security/secure-keys.js";
import { getCredentialMetadataById } from "../tools/credentials/metadata-store.js";
import { getLogger } from "../util/logger.js";
const log = getLogger("chat-credential-redaction");
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** A credential reference parsed from a `credentials reveal` invocation. */
export interface RevealCandidateRef {
service?: string;
field?: string;
/** Opaque credential UUID (positional CLI arg) — resolved to service/field. */
id?: string;
/**
* The plaintext the reveal route served for this ref, captured by
* {@link filterRefsByRevealProof} at proof time. When present, candidate
* resolution redacts these EXACT bytes and skips the vault read — so a
* rotation/deletion between reveal and persist can't make the redacted
* value diverge from what the tool actually printed. Absent only for refs
* that were never proven (which resolution drops anyway).
*/
provenValue?: string;
}
/** A candidate with its plaintext, ready for byte-comparison. */
export interface ResolvedRevealCandidate {
service: string;
field: string;
value: string;
}
// ---------------------------------------------------------------------------
// Command parsing
// ---------------------------------------------------------------------------
/** Matches `credentials reveal` with any CLI entry point (`assistant`, bin
* paths, …) — the two words are what identify the subcommand. */
const REVEAL_INVOCATION_RE = /\bcredentials\s+reveal\b/;
// An unquoted flag value ends at whitespace, a quote, or a shell
// metacharacter that can directly abut a word — `()`/backtick (command
// substitution) and `<>` (redirection) — so `--field token)` inside
// `$(… reveal --field token)` parses as `token`, the identity the route
// records proof under. Separators (`|&;`) never reach a segment unescaped;
// the splitter consumed them. A backslash escapes the next character.
const FLAG_VALUE = String.raw`(?:"((?:\\.|[^"\\])*)"|'([^']*)'|((?:\\.|[^\s"'\\()<>\`])+))`;
const SERVICE_FLAG_RE = new RegExp(String.raw`--service(?:=|\s+)${FLAG_VALUE}`);
const FIELD_FLAG_RE = new RegExp(String.raw`--field(?:=|\s+)${FLAG_VALUE}`);
const UUID_RE =
/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i;
/**
* Extract a flag's value from a segment, undoing the shell quoting the
* value arrived with. The shell strips quoting before the CLI sees argv,
* so the reveal route records proof under the UNESCAPED identity —
* `--service foo\ bar` must parse to `foo bar` or the staged ref can never
* match its proof and the reveal's output would bypass the guard and the
* candidate fallback. Single quotes are literal; a backslash escapes the
* next character when unquoted, and inside double quotes it escapes only
* the characters POSIX lets it escape that can appear in an identifier
* (`"` and `\` — the rest stay literal, matching the shell).
*/
function flagValue(segment: string, re: RegExp): string | undefined {
const m = re.exec(segment);
if (!m) {
return undefined;
}
if (m[1] !== undefined) {
return m[1].replace(/\\(["\\])/g, "$1");
}
if (m[2] !== undefined) {
return m[2];
}
return m[3]?.replace(/\\(.)/g, "$1");
}
/**
* Split a shell command into segments at UNQUOTED separators (`&&`, `||`,
* `;`, `|`, a lone `&`, newline). A separator inside a quoted flag value —
* `--service 'R&D'` — is part of the value, and cutting there would orphan
* the invocation's flags across two broken segments: no ref gets staged,
* so the reveal's output could stream or persist raw even though the route
* served it. Minimal POSIX-ish quoting: single quotes are literal to the
* next single quote, double quotes honor backslash escapes, and a
* backslash outside quotes escapes the next character. An unterminated
* quote runs to the end of the string — the remainder stays one segment,
* which at worst stages nothing (the same safe direction as before).
* Quotes are kept in the output so the flag regexes see them.
*/
function splitShellSegments(command: string): string[] {
const segments: string[] = [];
let current = "";
let quote: "'" | '"' | undefined;
let i = 0;
while (i < command.length) {
const ch = command[i]!;
if (quote === "'") {
current += ch;
if (ch === "'") {
quote = undefined;
}
i += 1;
continue;
}
if (quote === '"') {
if (ch === "\\" && i + 1 < command.length) {
current += ch + command[i + 1];
i += 2;
continue;
}
current += ch;
if (ch === '"') {
quote = undefined;
}
i += 1;
continue;
}
if (ch === "\\" && i + 1 < command.length) {
current += ch + command[i + 1];
i += 2;
continue;
}
if (ch === "'" || ch === '"') {
quote = ch;
current += ch;
i += 1;
continue;
}
if (ch === "&" || ch === "|") {
// `&&`/`||` consume both characters so the two-char operator is one
// boundary; a lone `&` (background job separator) or `|` splits too,
// so a second backgrounded/piped reveal is staged rather than
// swallowed into the first segment.
segments.push(current);
current = "";
i += command[i + 1] === ch ? 2 : 1;
continue;
}
if (ch === ";" || ch === "\n") {
segments.push(current);
current = "";
i += 1;
continue;
}
current += ch;
i += 1;
}
segments.push(current);
return segments;
}
/**
* Extract credential refs from a shell command that may contain one or more
* `credentials reveal` invocations (compound commands split on shell
* separators). Returns an empty array for commands that never touch reveal —
* the hot-path cost is one substring check.
*
* The parse is deliberately conservative: it recognizes the documented CLI
* shapes (`--service X --field Y`, `--service=X`, quoted values, positional
* UUID) and nothing else. An unparseable invocation yields no ref, which
* downstream means no candidate and a plain (non-revealable) sentinel —
* the safe direction.
*/
/**
* Slice a segment at each `credentials reveal` occurrence: one slice per
* invocation, running from the invocation to the start of the next.
* Command substitution can nest several reveals in ONE segment —
* `echo "$(… reveal --service a …)" "$(… reveal --service b …)"` — and
* parsing only the segment's first flag pair would leave every later
* identity unstaged even though the route proves each one, so their
* opaque values could stream and persist raw.
*/
function revealInvocationSlices(segment: string): string[] {
const re = new RegExp(REVEAL_INVOCATION_RE, "g");
const starts: number[] = [];
for (let m = re.exec(segment); m !== null; m = re.exec(segment)) {
starts.push(m.index);
}
return starts.map((start, i) => segment.slice(start, starts[i + 1]));
}
export function collectRevealRefsFromCommand(
command: string,
): RevealCandidateRef[] {
if (!command.includes("reveal")) {
return [];
}
const refs: RevealCandidateRef[] = [];
// Split compound commands so flags from one invocation can't bleed into
// another (`reveal --service a --field b && reveal --service c --field d`),
// honoring quotes so a separator inside a flag value can't cut the
// invocation apart (see `splitShellSegments`). Within a segment, each
// reveal invocation is parsed from its own slice (see
// `revealInvocationSlices`).
const segments = splitShellSegments(command);
for (const segment of segments) {
if (!REVEAL_INVOCATION_RE.test(segment)) {
continue;
}
for (const invocation of revealInvocationSlices(segment)) {
const service = flagValue(invocation, SERVICE_FLAG_RE);
const field = flagValue(invocation, FIELD_FLAG_RE);
if (service !== undefined && field !== undefined) {
refs.push({ service, field });
continue;
}
const id = UUID_RE.exec(invocation)?.[0];
if (id) {
refs.push({ id });
}
// No parseable identity → no ref. The span still gets redacted at
// persist; it just won't be revealable.
}
}
return refs;
}
/**
* Resolve id-form refs to service/field EAGERLY, at staging time — a
* metadata-only lookup, no secret access. Proof runs when the tool result
* arrives, which can be well after the reveal executed, and a `credentials
* remove` in between (even in the same compound command) makes the id
* unresolvable then — dropping the ref although the route recorded the
* success, so the printed value would persist raw. The id stays on the ref
* so proof time can still fall back to a lookup for a ref whose metadata
* only appears later.
*/
export function resolveRefIdentities(
refs: readonly RevealCandidateRef[],
): RevealCandidateRef[] {
return refs.map((ref) => {
if (
ref.id === undefined ||
(ref.service !== undefined && ref.field !== undefined)
) {
return ref;
}
try {
const meta = getCredentialMetadataById(ref.id);
if (!meta) {
return ref;
}
return { ...ref, service: meta.service, field: meta.field };
} catch (err) {
log.debug(
{ err },
"eager reveal ref id lookup failed; deferring to proof time",
);
return ref;
}
});
}
/**
* Keep only refs whose identity the reveal route ACTUALLY served after
* `watermark` was captured (see `reveal-success-registry`). A shell tool's
* overall success is not proof the nested reveal ran — `reveal … || true`
* succeeds when the route failed, and an `echo` containing the command
* text never calls the route — so candidate resolution (which reads
* plaintext from the store) must be gated on the route's own record.
* Identity for id-form refs is normally captured at staging (see
* `resolveRefIdentities`); the metadata lookup here is only a fallback —
* metadata-only, no secret access. Unresolvable refs are dropped — the
* safe direction.
*/
export function filterRefsByRevealProof(
refs: readonly RevealCandidateRef[],
watermark: number,
nonce: string,
): RevealCandidateRef[] {
const proven: RevealCandidateRef[] = [];
const seenValues = new Set();
for (const ref of refs) {
let service = ref.service;
let field = ref.field;
if (
ref.id !== undefined &&
(service === undefined || field === undefined)
) {
try {
const meta = getCredentialMetadataById(ref.id);
if (!meta) {
continue;
}
service = meta.service;
field = meta.field;
} catch (err) {
log.debug({ err }, "reveal proof id lookup failed; dropping ref");
continue;
}
}
if (service === undefined || field === undefined) {
continue;
}
// Capture the plaintexts the route served alongside the proof, in the
// same lookup. Resolving service/field here too means each returned ref
// is fully self-describing, so candidate resolution needs neither the
// metadata lookup nor the vault read again. One ref PER DISTINCT VALUE:
// a rotate-and-re-reveal inside the window (reveal v1 → `credentials
// set` → reveal v2) prints two different plaintexts to stdout, and
// keeping only one would let the other persist raw whenever the scanner
// cannot classify it.
for (const provenValue of revealedValuesSince(
watermark,
service,
field,
nonce,
)) {
const dedupeKey = JSON.stringify([service, field, provenValue]);
if (seenValues.has(dedupeKey)) {
continue;
}
seenValues.add(dedupeKey);
proven.push({ service, field, provenValue });
}
}
return proven;
}
// ---------------------------------------------------------------------------
// Candidate resolution (scoped store read)
// ---------------------------------------------------------------------------
/**
* Minimum plaintext length for the exact-match candidate fallback. The
* fallback replaces EVERY occurrence of a candidate's bytes across the
* rest of the turn — assistant text, tool output, persisted rows — and
* the set route validates values only as non-empty, so a credential whose
* plaintext is `1` or `ok` would turn every unrelated occurrence of that
* token into a marker/chip. Below this floor the value carries too little
* entropy for redaction to protect anything, while the false-positive
* damage is guaranteed; at or above it, collisions with incidental text
* are rare. Scanner-recognized secrets are unaffected — every scanner
* pattern requires far longer tokens than this.
*/
const MIN_CANDIDATE_VALUE_LENGTH = 6;
/**
* Every textual encoding of a credential plaintext that can appear on a
* transcript surface: the raw value plus its JSON-escaped form
* (`JSON.stringify` minus the outer quotes). They differ when the value
* contains quotes, backslashes, or control characters — a PEM key's
* newlines — because tool stdout (`credentials reveal --json` writes
* through `JSON.stringify`) and persisted tool_use inputs carry the
* ESCAPED bytes, which a raw exact-match list can never find.
* `JSON.stringify` is injective on the value, so an escaped-form match
* proves the same identity the raw form does. Deduped: a value needing no
* escaping yields a single entry.
*/
export function credentialValueEncodings(value: string): string[] {
const encodings = [value];
const escaped = JSON.stringify(value).slice(1, -1);
if (escaped !== value) {
encodings.push(escaped);
}
return encodings;
}
/**
* Append a candidate in EVERY stdout encoding of its plaintext
* ({@link credentialValueEncodings}), deduped on identity+value.
* Registering the escaped encoding as its own candidate covers that
* representation on every surface (persist byte-compare, live guard,
* tool_result fallback, output chunks).
*/
function appendCandidateEncodings(
candidates: ResolvedRevealCandidate[],
seen: Set,
service: string,
field: string,
value: string,
): void {
for (const encoded of credentialValueEncodings(value)) {
if (encoded.length < MIN_CANDIDATE_VALUE_LENGTH) {
continue;
}
const dedupeKey = JSON.stringify([service, field, encoded]);
if (seen.has(dedupeKey)) {
continue;
}
seen.add(dedupeKey);
candidates.push({ service, field, value: encoded });
}
}
/**
* Sync twin of {@link resolveRevealCandidates} for refs that already carry
* a proven value (the shape `filterRefsByRevealProof` returns) — used on
* the live tool-output path, whose handler must stay synchronous and may
* not touch the vault. Refs without a proven value are skipped, never
* vault-resolved.
*/
export function resolveProvenRevealCandidates(
refs: readonly RevealCandidateRef[],
): ResolvedRevealCandidate[] {
const seen = new Set();
const candidates: ResolvedRevealCandidate[] = [];
for (const ref of refs) {
if (ref.service === undefined || ref.field === undefined) {
continue;
}
if (ref.provenValue === undefined || ref.provenValue.length === 0) {
continue;
}
appendCandidateEncodings(
candidates,
seen,
ref.service,
ref.field,
ref.provenValue,
);
}
return candidates;
}
/**
* Resolve parsed refs to `{service, field, value}` candidates via scoped
* credential-store reads — one `getSecureKeyAsync` per distinct ref, only
* for credentials a reveal command in this turn already named. Refs that
* fail to resolve (unknown id, missing secret, store unreachable) are
* dropped; the corresponding spans persist as plain sentinels.
*/
export async function resolveRevealCandidates(
refs: readonly RevealCandidateRef[],
): Promise {
// Candidates dedupe on identity AND value, not identity alone: the same
// credential legitimately yields two candidates when it was rotated and
// re-revealed within one turn — both plaintexts hit a tool's stdout, so
// both must stay redactable. Vault reads still dedupe per identity (one
// scoped read per credential, and the vault only has the current value).
const seenCandidates = new Set();
const vaultReadKeys = new Set();
const candidates: ResolvedRevealCandidate[] = [];
const pushCandidate = (service: string, field: string, value: string) =>
appendCandidateEncodings(candidates, seenCandidates, service, field, value);
for (const ref of refs) {
let service = ref.service;
let field = ref.field;
if (
ref.id !== undefined &&
(service === undefined || field === undefined)
) {
try {
const meta = getCredentialMetadataById(ref.id);
if (!meta) {
continue;
}
service = meta.service;
field = meta.field;
} catch (err) {
log.debug({ err }, "reveal candidate id lookup failed; skipping");
continue;
}
}
if (service === undefined || field === undefined) {
continue;
}
// Prefer the plaintext the reveal route actually served (captured at proof
// time). Re-reading the vault here would return a rotated or deleted
// value, so the guard/fallback could miss the exact bytes the tool
// printed. Only fall back to a store read when a ref carries no proven
// value — which today means it was constructed outside the proof path.
if (ref.provenValue !== undefined && ref.provenValue.length > 0) {
pushCandidate(service, field, ref.provenValue);
continue;
}
const key = credentialKey(service, field);
if (vaultReadKeys.has(key)) {
continue;
}
vaultReadKeys.add(key);
try {
const value = await getSecureKeyAsync(key);
if (value != null && value.length > 0) {
pushCandidate(service, field, value);
}
} catch (err) {
// Store unreachable → no candidate → plain sentinel. Never block or
// fail the persist over enrichment.
log.debug(
{ err, service, field },
"reveal candidate fetch failed; span will persist as plain sentinel",
);
}
}
return candidates;
}
// ---------------------------------------------------------------------------
// Daemon-minted sentinels and re-mint authority
// ---------------------------------------------------------------------------
/**
* A sentinel identity the guard may restore to its canonical daemon-minted
* form. Sources: route-recorded `--for-chat` mints (validated against the
* conversation's reveal nonce and staged identities by the agent loop
* before they reach the guard) and candidates proven by this
* conversation's own plain reveals (nonce-bound at the proof registry).
*/
export interface SentinelRemintAuthority {
service: string;
field: string;
/** The canonical sentinel to restore for this identity. */
sentinel: string;
}
/**
* The canonical daemon-minted sentinel for a credential. Used by the
* `--for-chat` reveal channel (the route returns this instead of the
* plaintext) and by candidate-derived re-mint authorities, so every path
* that mints for the same identity produces the same bytes. The type label
* comes from the same anchored scanner classification persist-time
* enrichment uses ({@link classifyCandidateType}).
*/
export function buildForChatSentinel(candidate: {
service: string;
field: string;
value: string;
}): string {
return buildRedactedSentinel({
type: classifyCandidateType(candidate.value),
service: candidate.service,
field: candidate.field,
});
}
/**
* Re-mint authorities derived from this turn's PROVEN reveal candidates.
*
* A model that legitimately revealed a credential this turn holds its
* plaintext and could echo it verbatim — which the guards would swap into
* a genuine enriched sentinel. When it instead retypes the sentinel it saw
* in its (redacted) history, byte-equality can't prove anything, but the
* identity can: a sentinel-shaped span claiming a `service:field` that was
* proven this turn grants nothing a plaintext echo wouldn't. Re-minting it
* (from the candidate's own metadata — never from the span's claimed
* label) turns the confusing neutralized-glyph outcome into the same chip
* the echo would have produced. Unproven identities still neutralize.
*
* Deduped by identity: encoding-variant candidates share a service:field
* and must not produce duplicate authorities. Conversation scoping is
* inherent — candidates live in per-run state that only this
* conversation's own proven reveals populate.
*/
export function remintAuthoritiesFromCandidates(
candidates: readonly ResolvedRevealCandidate[],
): SentinelRemintAuthority[] {
const byIdentity = new Map();
for (const candidate of candidates) {
const key = credentialKey(candidate.service, candidate.field);
if (byIdentity.has(key)) {
continue;
}
byIdentity.set(key, {
service: candidate.service,
field: candidate.field,
sentinel: buildForChatSentinel(candidate),
});
}
return [...byIdentity.values()];
}
/**
* Sentinel guard for text that may legitimately contain daemon-minted
* sentinels. The forgery-guard spine assumes raw model output never
* carries a genuine sentinel — with two sanctioned, turn-scoped
* exceptions, both keyed by identity rather than trust in the span's own
* bytes:
*
* 1. `--for-chat` reveals: the daemon returned the sentinel as the reveal
* tool's result precisely so the model can copy it into its reply
* without ever touching the plaintext. Authority is the mint the
* reveal ROUTE recorded this turn ({@link ForChatMint}) — the route is
* the sole authority; a string parse of the requested command is not,
* because a segment that merely quotes a reveal invocation never
* executed one.
* 2. Proven plain-reveal candidates
* ({@link remintAuthoritiesFromCandidates}): the model provably held
* the plaintext this turn, so a span claiming that identity grants
* nothing a verbatim echo wouldn't.
*
* An enriched span whose decoded `service:field` identity matches an
* authority is RE-MINTED — replaced with the daemon's own canonical
* sentinel, so a tampered type label can't survive. Everything else —
* plain shapes, unknown identities, malformed escapes, partial triggers —
* flows through `transformRest` (neutralization by default; the persist
* path passes its neutralize+scan pipeline so a secret embedded inside a
* forged sentinel is still caught). Re-minted spans are inserted verbatim
* and never fed to `transformRest`: a minted sentinel is not
* scanner-inert — its service/field segments are arbitrary user strings
* that can look secret-shaped, and a scanner rewrite inside a fresh
* sentinel would corrupt it. With no authorities this is byte-identical
* to `transformRest(text)`.
*/
export function guardForChatSentinels(
text: string,
mints: readonly SentinelRemintAuthority[],
transformRest: (segment: string) => string = neutralizeRedactedSentinels,
): string {
if (mints.length === 0) {
return transformRest(text);
}
const re = createRedactedSentinelRegex();
let out = "";
let last = 0;
for (const m of text.matchAll(re)) {
const decoded = decodeRedactedSentinelMatch(m as RegExpExecArray);
const hit =
decoded.service !== undefined && decoded.field !== undefined
? mints.find(
(c) => c.service === decoded.service && c.field === decoded.field,
)
: undefined;
// Between-match text can still hold partial/malformed triggers —
// transform it segment-wise. Splits are at full-sentinel boundaries,
// so no trigger ever spans a segment edge.
out += transformRest(text.slice(last, m.index));
out += hit ? hit.sentinel : transformRest(m[0]);
last = m.index + m[0].length;
}
out += transformRest(text.slice(last));
return out;
}
// ---------------------------------------------------------------------------
// Live-stream forgery guard
// ---------------------------------------------------------------------------
const SENTINEL_TRIGGER = `${REDACTED_SENTINEL_OPEN}${REDACTED_SENTINEL_TAG}:`;
/**
* A reveal candidate prepared for live-stream substitution: when the model
* echoes `value` into its reply, the stream guard swaps it for `replacement`
* (the same enriched sentinel persist-time redaction produces) before the
* bytes reach the wire — so the plaintext never flashes in the live
* transcript and never leaves the daemon.
*/
export interface LiveRevealGuardEntry {
/** Credential plaintext exactly as it would appear in model output. */
value: string;
/** The sentinel `redactSecretsForChat` produces for those bytes. */
replacement: string;
}
/**
* Fallback type label for a candidate whose bare value the scanner cannot
* classify on its own (context-sensitive patterns like `password=…`).
*/
const FALLBACK_SENTINEL_TYPE = "Credential";
/**
* The generic legacy redaction marker for a credential value — byte-identical
* everywhere a credential span is replaced without a proven identity
* (persist-time fallback protection, the retroactive transcript scrub), so
* downstream scanners and renderers treat every redacted span the same way.
*/
export const LEGACY_CREDENTIAL_REDACTION_MARKER = ``;
/**
* Precompute live-swap entries from resolved reveal candidates. Every
* candidate gets an entry — the guard's job is to keep revealed plaintext
* off the wire, whatever the surrounding text turns out to be — and each
* replacement is exactly what persist-time redaction produces for the
* bare value, so the live stream and the persisted row agree
* byte-for-byte. `redactSecretsForChat` guarantees a candidate value
* never survives: the scanner classifies what it can, and the exact-match
* fallback (see `swapCandidateValueFallbacks`) covers the rest — opaque
* manual tokens and values the scanner only matches with surrounding
* context. The lone residual divergence: a contextual persist match
* (`password=`) may carry a more specific type label than the
* bare-value chip streamed live — cosmetic, same service:field identity.
*/
export function buildLiveRevealGuardEntries(
candidates: readonly ResolvedRevealCandidate[],
): LiveRevealGuardEntry[] {
const entries: LiveRevealGuardEntry[] = [];
const seen = new Set();
for (const candidate of candidates) {
// Dedupe by value — duplicate plaintexts would otherwise register two
// entries and split/join order would silently pick one.
if (seen.has(candidate.value)) {
continue;
}
seen.add(candidate.value);
// Derive the replacement against the FULL candidate list, exactly as
// the persist seam does: a value shared by two identities degrades to
// the plain sentinel there (see `uniqueCandidateForValue`), and the
// live swap must emit the same bytes or the streamed text and the
// persisted row would disagree on reconnect.
entries.push({
value: candidate.value,
replacement: redactSecretsForChat(candidate.value, candidates),
});
}
return entries;
}
/** A non-overlapping occurrence of a target in raw text. */
interface RawSpan {
start: number;
end: number;
/** Index into the caller's target list — lower = higher priority. */
priority: number;
}
/**
* Resolve the non-overlapping occurrence spans of `targets` in `text`:
* targets in priority order (list index — callers sort longest-first so a
* value that is a substring of another cannot pre-empt the larger match),
* each target's occurrences greedy left-to-right, skipping any occurrence
* that overlaps a higher-priority span. This is the single definition of
* the transforms' occurrence semantics: every swap in this module rebuilds
* its output from these spans, and the streaming stability analysis
* (`stableEmitLen`) resolves through the same function, so the two can
* never drift.
*
* `skipOccurrence` vetoes individual occurrences (the persist paths use it
* to let a longer scanner match outrank a candidate substring inside it —
* see `containedInLongerScannerMatch`); a vetoed occurrence is skipped
* exactly like an overlap loss, and scanning continues from the next byte.
*/
function resolveRawSpans(
text: string,
targets: readonly string[],
skipOccurrence?: (start: number, end: number) => boolean,
): RawSpan[] {
const spans: RawSpan[] = [];
targets.forEach((target, priority) => {
if (target.length === 0) {
return;
}
let scanFrom = 0;
for (;;) {
const idx = text.indexOf(target, scanFrom);
if (idx === -1) {
break;
}
const end = idx + target.length;
// An occurrence overlapping a higher-priority span loses to it — the
// higher-priority replacement consumes those bytes — but keep
// scanning from the next byte for a later disjoint occurrence.
if (
spans.some((s) => idx < s.end && end > s.start) ||
skipOccurrence?.(idx, end) === true
) {
scanFrom = idx + 1;
continue;
}
spans.push({ start: idx, end, priority });
scanFrom = end;
}
});
return spans;
}
/**
* Replace each resolved raw span with its target's replacement, rebuilding
* the string in ONE pass over the original text. A sequential
* split/join-per-target pass would re-scan earlier replacements — and a
* replacement sentinel is not inert text: it embeds type/service/field
* segments, so a candidate whose plaintext happens to equal another's
* service name (e.g. `openai`) would match inside the just-emitted
* sentinel and corrupt it into nested, unrenderable markers. Resolving all
* spans against the raw text first makes replacements structurally
* invisible to later targets.
*
* `transformGap` is applied to the text BETWEEN spans (and to the whole
* text when nothing matches). Callers use it for forgery neutralization:
* it must run over raw bytes the spans do not claim, and never over the
* spans themselves — a candidate value may itself contain the sentinel
* trigger, and mutating it before the exact-match resolution would break
* the strongest guarantee this module makes.
*/
function replaceRawSpans(
text: string,
targets: readonly string[],
replacementFor: (priority: number) => string,
transformGap: (segment: string) => string = (segment) => segment,
skipOccurrence?: (start: number, end: number) => boolean,
): string {
const spans = resolveRawSpans(text, targets, skipOccurrence);
if (spans.length === 0) {
return transformGap(text);
}
spans.sort((a, b) => a.start - b.start);
let out = "";
let cursor = 0;
for (const span of spans) {
out +=
transformGap(text.slice(cursor, span.start)) +
replacementFor(span.priority);
cursor = span.end;
}
return out + transformGap(text.slice(cursor));
}
/**
* Replace every occurrence of each entry's plaintext with its sentinel.
* Longest value first: when one candidate's plaintext is a prefix (or
* substring) of another's, insertion order would let the shorter entry
* consume the head of the longer value — emitting the shorter chip plus
* the longer credential's unmatched suffix as raw text, while persist-time
* redaction (whose scanner prefers the longer span) redacts the whole
* value. Descending-length ordering keeps the live swap consistent with
* persistence, and the span-based rebuild (`replaceRawSpans`) guarantees a
* later entry can never rewrite inside an earlier entry's emitted
* sentinel.
*/
export function swapLiveRevealValues(
text: string,
entries: readonly LiveRevealGuardEntry[],
): string {
const ordered = [...entries].sort((a, b) => b.value.length - a.value.length);
return replaceRawSpans(
text,
ordered.map((entry) => entry.value),
(priority) => ordered[priority].replacement,
);
}
/**
* The live-emit transform: swap candidate values for their sentinels AND
* neutralize forged sentinels — resolved together on the RAW bytes, with
* neutralization applied only to the text between candidate spans.
* Sequencing them instead (`swapLiveRevealValues(neutralizeRedactedSentinels(text))`)
* breaks when a candidate's plaintext itself contains the sentinel trigger
* (manual values are arbitrary strings): neutralization would mutate the
* value's occurrence first, the exact-match swap would miss it, and an
* opaque secret would cross the wire almost intact. Candidate spans win;
* forgery neutralization covers everything they don't claim — exactly the
* occurrence semantics `stableEmitLen` models when it analyzes the trigger
* and the values as one priority-ordered target set.
*/
export function neutralizeAndSwapLiveRevealValues(
text: string,
entries: readonly LiveRevealGuardEntry[],
forChatMints: readonly SentinelRemintAuthority[] = [],
): string {
const ordered = [...entries].sort((a, b) => b.value.length - a.value.length);
return replaceRawSpans(
text,
ordered.map((entry) => entry.value),
(priority) => ordered[priority].replacement,
(segment) => guardForChatSentinels(segment, forChatMints),
);
}
/**
* Length of the prefix of `buffer` whose transform result is STABLE —
* i.e. cannot change no matter what bytes arrive next — under the
* transform's occurrence semantics: targets resolved in priority order
* (the caller passes them highest-priority first, matching
* `swapLiveRevealValues`' longest-value-first stable sort), each target's
* occurrences greedy left-to-right, non-overlapping with higher-priority
* spans. Everything past the returned offset must be held back.
*
* Two steps:
*
* 1. Resolve the consumed spans of the CURRENT buffer exactly as the
* swap would.
* 2. Find every "threat": a suffix of the buffer that is a proper prefix
* of some target `T` — bytes a future chunk could complete into a
* `T`-occurrence. Each threat pulls the stable boundary back:
* - threat starts OUTSIDE any consumed span → hold from the threat
* start (the classic split-value hold);
* - threat starts INSIDE a consumed span of occurrence `E`:
* - `priority(T)` higher than `priority(E)` → the future `T` match
* would beat `E` in the full-text swap, so `E` cannot be
* committed yet — hold from `E`'s START (emitting any part of
* `E` would leak raw bytes on one continuation or emit the wrong
* replacement on the other);
* - otherwise → `E` wins regardless of future bytes (greedy
* left-to-right for the same target, priority order across
* targets), so the threat is moot — `E` commits whole.
*
* Each case guards a real leak shape: a self-overlapping value chunked at
* the overlap boundary (`abcabc` as `abc`+`abc`: the own-tail threat is
* moot, the occurrence commits whole); a candidate whose tail is a prefix
* of a longer candidate (the threat outranks it, so it is held whole —
* emitting it would leak the longer value's suffix raw once it
* completes); equal-length overlapping entries (`aba`/`bab`: the
* earlier-sorted entry outranks, so a completed `bab` holds while `aba`
* may still complete — keeping chunked output identical to the unchunked
* swap).
*
* The boundary never splits a consumed span: it lands either outside
* all spans or exactly on a span's start.
*/
function stableEmitLen(buffer: string, targets: readonly string[]): number {
// Same span resolution as the swap transforms — shared so the stability
// analysis can never disagree with what the transform actually consumes.
const consumed = resolveRawSpans(buffer, targets);
let emitLen = buffer.length;
targets.forEach((target, priority) => {
const maxLen = Math.min(target.length - 1, buffer.length);
for (let len = maxLen; len > 0; len--) {
if (!buffer.endsWith(target.slice(0, len))) {
continue;
}
const start = buffer.length - len;
const host = consumed.find((s) => s.start <= start && start < s.end);
if (host === undefined) {
if (start < emitLen) {
emitLen = start;
}
} else if (priority < host.priority && host.start < emitLen) {
emitLen = host.start;
}
}
});
return emitLen;
}
/**
* Streaming redaction guard for live `assistant_text_delta` emission
* (mirrors `drainDirectiveDisplayBuffer`'s hold-back pattern). Two jobs:
*
* 1. **Forgery neutralization** — genuine sentinels are created by the
* daemon, so a sentinel-shaped string the model streams neutralizes
* unless a re-mint authority for its identity exists this turn
* (`--for-chat` mints and proven reveal candidates — see
* `guardForChatSentinels`), in which case it is restored to the
* daemon's canonical mint.
* 2. **Live reveal swap** — a complete occurrence of a reveal candidate's
* plaintext is replaced with its enriched sentinel (`entries`), so the
* secret never flashes in the live stream: the client renders the chip
* immediately and the persisted row (which redacts the same bytes at
* persist time) matches what was streamed.
*
* A trailing PARTIAL trigger or candidate-value prefix (split across
* streaming chunks) is held back in `bufferedRemainder` so the next chunk
* decides whether it completes; when that partial prefix could outrank a
* completed occurrence it starts inside, the whole occurrence is held with
* it (see `stableEmitLen`), so the remainder is bounded by roughly twice
* the longest target. Callers must re-prepend the remainder to
* the next chunk and, at end-of-message, flush it through
* `neutralizeAndSwapLiveRevealValues` — nothing can complete a partial
* prefix after the message ends.
*
* `consumedRaw` is the untransformed input the emit covers. Callers that
* mirror streamed text for partial persistence must mirror `consumedRaw`,
* not `emitText`: persist-time redaction re-derives the sentinel from the
* raw bytes, whereas a mirrored already-swapped sentinel would be
* indistinguishable from a forgery and get neutralized.
*/
/**
* Upper bound on how much tail the guard will hold back waiting for an
* in-flight daemon-minted sentinel to close. A genuine sentinel is far
* shorter (type + encoded service/field); a trigger that runs this long
* without closing is forged garbage and is released (neutralized) rather
* than buffered forever.
*/
const UNCLOSED_SENTINEL_HOLD_CAP = 512;
export function drainSentinelGuardedText(
buffer: string,
entries: readonly LiveRevealGuardEntry[] = [],
forChatMints: readonly SentinelRemintAuthority[] = [],
): {
emitText: string;
consumedRaw: string;
bufferedRemainder: string;
} {
// Priority order must mirror the transforms exactly:
// `swapLiveRevealValues` sorts entries longest-value-first (stable),
// and the trigger joins the same ordering so its complete occurrences
// (consumed by neutralization) participate in the stability analysis.
const targets = [
SENTINEL_TRIGGER,
...entries.map((entry) => entry.value),
].sort((a, b) => b.length - a.length);
let emitLen = stableEmitLen(buffer, targets);
// With re-mint authorities in play, a COMPLETE trigger in the emit
// region may be the head of a genuine daemon-minted sentinel still
// streaming in. The plain guard neutralizes complete triggers eagerly
// (any live sentinel is forged by definition); here that would mangle
// the sanctioned case — so hold from the first trigger whose sentinel
// has not fully landed inside the emit region (bounded by the cap
// above), letting `guardForChatSentinels` decide re-mint vs neutralize
// on the whole span once it closes. Triggers consumed inside a
// candidate-value span are skipped: the span swaps wholesale and its
// bytes never reach the gap transform.
if (forChatMints.length > 0) {
const spans = resolveRawSpans(buffer, targets);
let searchFrom = 0;
for (;;) {
const t = buffer.indexOf(SENTINEL_TRIGGER, searchFrom);
if (t === -1) {
break;
}
const host = spans.find((s) => s.start <= t && t < s.end);
if (host !== undefined && targets[host.priority] !== SENTINEL_TRIGGER) {
searchFrom = t + SENTINEL_TRIGGER.length;
continue;
}
const close = buffer.indexOf(REDACTED_SENTINEL_CLOSE, t);
const sentinelEnd = close === -1 ? -1 : close + 1;
if (close !== -1 && sentinelEnd <= emitLen) {
searchFrom = sentinelEnd;
continue;
}
if (buffer.length - t <= UNCLOSED_SENTINEL_HOLD_CAP) {
emitLen = Math.min(emitLen, t);
}
break;
}
}
const consumedRaw = buffer.slice(0, emitLen);
return {
emitText: neutralizeAndSwapLiveRevealValues(
consumedRaw,
entries,
forChatMints,
),
consumedRaw,
bufferedRemainder: buffer.slice(emitLen),
};
}
/**
* Streaming redaction guard for live `tool_output_chunk` emission — the
* tool-output twin of {@link drainSentinelGuardedText}. A foreground
* reveal's stdout streams to the client WHILE the tool runs, before the
* tool_result seam redacts the final content, so each chunk must be
* redacted on the way out or the plaintext shows in the tool drawer until
* the redacted result replaces it. Complete occurrences of candidate
* values become the legacy marker (this surface renders no chips, and the
* final tool_result row uses the same treatment, so live and persisted
* bytes agree); a trailing partial occurrence is held back in
* `bufferedRemainder` for the tool's next chunk. The tool_result seam
* DISCARDS whatever remains: a held partial is raw credential prefix
* bytes that complete-value redaction cannot mask, and the redacted final
* result supersedes the streamed view immediately.
*/
export function drainCandidateGuardedChunk(
buffer: string,
candidates: readonly ResolvedRevealCandidate[],
): { emitText: string; bufferedRemainder: string } {
const seen = new Set();
const values: string[] = [];
for (const candidate of candidates) {
if (candidate.value.length === 0 || seen.has(candidate.value)) {
continue;
}
seen.add(candidate.value);
values.push(candidate.value);
}
values.sort((a, b) => b.length - a.length);
const emitLen = stableEmitLen(buffer, values);
return {
emitText: redactCandidateValuesLegacy(buffer.slice(0, emitLen), candidates),
bufferedRemainder: buffer.slice(emitLen),
};
}
// ---------------------------------------------------------------------------
// Sentinel redaction
// ---------------------------------------------------------------------------
/**
* Redact secrets in chat-persisted text using sentinels instead of the
* legacy HTML marker. A span whose bytes exactly equal a candidate's
* plaintext gets the enriched (revealable) shape — but only when that value
* maps to exactly one vault identity; everything else gets the plain
* type-only shape. Detection itself is unchanged — same scanner, same
* patterns, same overlap handling as `redactSecrets`.
*/
export function redactSecretsForChat(
text: string,
candidates: readonly ResolvedRevealCandidate[],
forChatMints: readonly SentinelRemintAuthority[] = [],
): string {
// Candidate spans resolve FIRST, on the raw bytes; forgery
// neutralization and the scanner then run ONLY over the text between
// them (the gap transform below). Raw bytes first because a manual
// candidate value may itself contain the sentinel trigger — mutating it
// up front would break the exact-match pass, the strongest guarantee
// here. Before the scanner because the scanner may only recognize a
// SUBSTRING of a proven plaintext (the `Private Key` pattern consumes
// just the `-----BEGIN … -----` header, leaving the key body), and once
// it has replaced that substring the full candidate value is no longer
// present for an exact-match pass to find — the body would
// stream/persist raw. Gaps only because a minted sentinel is not
// scanner-inert: its service/field segments are arbitrary user strings
// that can themselves look secret-shaped, and a scanner match inside a
// fresh sentinel would corrupt it into a nested, unrenderable marker.
//
// Within each gap the scanner still redacts secrets the reveal registry
// never proved (defense in depth against any secret the model emitted
// that the vault did not serve), with the precise type label for values
// it classifies inline. Sentinel-shaped spans in the gaps neutralize —
// except identities a re-mint authority vouches for this turn, which are
// restored to the daemon's canonical mint and kept out of the scanner's
// reach (see `guardForChatSentinels`); everything around them still runs
// the full neutralize+scan pipeline, so a secret embedded inside a
// forged sentinel cannot ride through unredacted.
const neutralizeAndScan = (segment: string): string =>
redactSecretsWith(
neutralizeRedactedSentinels(segment),
(match, rawValue) => {
const hit = uniqueCandidateForValue(candidates, rawValue);
return buildRedactedSentinel(
hit
? { type: match.type, service: hit.service, field: hit.field }
: { type: match.type },
);
},
);
const redactGap = (segment: string): string =>
guardForChatSentinels(segment, forChatMints, neutralizeAndScan);
return protectCandidateSpans(text, candidates, redactGap);
}
/**
* Replace every occurrence of a proven candidate plaintext with its sentinel,
* applying `transformGap` (the caller's neutralize+scan pipeline) only to the
* text between the spans. Candidate spans are resolved on the RAW bytes (a
* manual value may itself contain the sentinel trigger, and neutralizing
* first would break the exact match), and protection precedes the scanner so
* a value the scanner would only partially match (e.g. a PEM key, where only
* the header is recognized) is still redacted as a whole — its body can
* never survive into the streamed/persisted text. Minted sentinels are never
* fed back through `transformGap`: their service/field segments are
* arbitrary user strings that can look secret-shaped, and a scanner rewrite
* inside a fresh sentinel would corrupt it.
*
* Longest value first (mirroring `swapLiveRevealValues`) so a value that is a
* substring of another does not pre-empt the larger match; the span-based
* rebuild (`replaceRawSpans`) keeps one value's sentinel safe from another
* value that happens to appear inside it. Each value is
* classified by scanning it in isolation: when a single scanner match spans
* the ENTIRE value the precise type label is preserved (a recognizable key
* keeps "Anthropic API Key" rather than degrading to the generic type);
* otherwise the generic `Credential` type is used. Identity enrichment still
* requires a unique vault match, exactly as the scanner path.
*/
function protectCandidateSpans(
text: string,
candidates: readonly ResolvedRevealCandidate[],
transformGap: (segment: string) => string,
): string {
if (candidates.length === 0) {
return transformGap(text);
}
const seen = new Set();
const values: string[] = [];
for (const candidate of candidates) {
if (candidate.value.length === 0 || seen.has(candidate.value)) {
continue;
}
seen.add(candidate.value);
values.push(candidate.value);
}
values.sort((a, b) => b.length - a.length);
const sentinelCache = new Map();
const sentinelForValue = (value: string): string => {
let sentinel = sentinelCache.get(value);
if (sentinel === undefined) {
const hit = uniqueCandidateForValue(candidates, value);
sentinel = buildRedactedSentinel(
hit
? {
type: classifyCandidateType(value),
service: hit.service,
field: hit.field,
}
: { type: classifyCandidateType(value) },
);
sentinelCache.set(value, sentinel);
}
return sentinel;
};
return replaceRawSpans(
text,
values,
(priority) => sentinelForValue(values[priority]),
transformGap,
containedInLongerScannerMatch(text),
);
}
/**
* Occurrence veto that lets a LONGER scanner match outrank a candidate
* substring inside it. A proven value can be a strict substring of a
* DIFFERENT scanner-detectable secret — revealing a manual value `sk-pro`
* must not carve the prefix out of a full `sk-proj-…` key in the same
* text and leave the key's suffix raw. Vetoing the occurrence keeps the
* enclosing secret contiguous in a gap, where the scanner pass redacts it
* whole. The reverse containment keeps the candidate: a scanner match
* INSIDE a candidate value (a PEM key, where only the header is
* recognized) must not break whole-value protection, and an equal-length
* match is the candidate itself — the enriched sentinel wins.
*/
function containedInLongerScannerMatch(
text: string,
): (start: number, end: number) => boolean {
const matches = scanText(text);
if (matches.length === 0) {
return () => false;
}
return (start, end) =>
matches.some(
(m) =>
m.startIndex <= start &&
end <= m.endIndex &&
m.endIndex - m.startIndex > end - start,
);
}
/**
* The scanner type label for a candidate value, or the generic `Credential`
* type when the scanner does not recognize it. The value is scanned in
* isolation (so no neighbouring match bleeds in) and a match ANCHORED AT THE
* START is used — a value that begins with a recognized signature is that
* type even when the pattern consumes only a prefix (the `Private Key`
* pattern matches just the `-----BEGIN … -----` header, yet the value is
* unambiguously a private key). The longest such anchored match wins.
* Matches that merely appear mid-value are ignored: they could be a
* coincidental substring, and mislabelling a chip is worse than the generic
* type. Identity enrichment is still gated on a unique vault match upstream,
* so the label never drives which secret a chip can reveal.
*/
function classifyCandidateType(value: string): string {
let best: { type: string; endIndex: number } | undefined;
for (const m of scanText(value)) {
if (m.startIndex !== 0) {
continue;
}
if (best === undefined || m.endIndex > best.endIndex) {
best = { type: m.type, endIndex: m.endIndex };
}
}
return best?.type ?? FALLBACK_SENTINEL_TYPE;
}
/**
* Legacy-marker twin of the persist fallback, for surfaces that keep the
* `` marker instead of sentinels (persisted tool_result
* blocks — see `buildToolResultBlocks`). A `credentials reveal` whose
* stdout is an opaque/manual value with no scanner-recognizable shape
* would otherwise persist raw in the tool-result row and surface in the
* tool detail panel and history, even though every other surface redacts
* it. Exact occurrences of proven candidate plaintexts (deduped, longest
* value first) become the generic legacy marker; no identity is carried —
* these surfaces render no chips.
*/
export function redactCandidateValuesLegacy(
text: string,
candidates: readonly ResolvedRevealCandidate[],
): string {
// Protect every proven candidate value as an atomic legacy marker
// (neutralizing forged sentinels in the text between the spans), THEN run
// the scanner over what remains. Candidate spans resolve on the RAW
// bytes: a manual value may itself contain the sentinel trigger, and
// neutralizing the whole text first would mutate the occurrence and
// break the exact match. Candidate protection must precede the scanner:
// a value the scanner only partially recognizes (a PEM key, where the
// `Private Key` pattern consumes just the header) would otherwise have
// its recognized substring replaced first, destroying the full-value
// match so the key body persists raw. The inserted ``
// markers contain no secret-shaped bytes, so the scanner pass can
// neither re-match nor corrupt them.
const protectedText = protectCandidateValuesLegacy(text, candidates);
return redactSecrets(protectedText);
}
/**
* Replace every occurrence of a proven candidate plaintext with the generic
* legacy marker BEFORE the scanner runs (longest value first, mirroring
* `swapLiveRevealValues`). Twin of {@link protectCandidateSpans} for surfaces
* that keep the `` marker instead of sentinels; no
* identity is carried — these surfaces render no chips, so the generic type
* is always used.
*/
function protectCandidateValuesLegacy(
text: string,
candidates: readonly ResolvedRevealCandidate[],
): string {
if (candidates.length === 0) {
return neutralizeRedactedSentinels(text);
}
const seen = new Set();
const values: string[] = [];
for (const candidate of candidates) {
if (candidate.value.length === 0 || seen.has(candidate.value)) {
continue;
}
seen.add(candidate.value);
values.push(candidate.value);
}
values.sort((a, b) => b.length - a.length);
return replaceRawSpans(
text,
values,
() => LEGACY_CREDENTIAL_REDACTION_MARKER,
neutralizeRedactedSentinels,
containedInLongerScannerMatch(text),
);
}
/**
* Resolve a matched plaintext to a candidate identity — but only when the
* mapping is unambiguous. A byte match proves the VALUE, not the vault
* identity: if two revealed credentials happen to share the same plaintext
* (the same API key stored under two services), picking either would mint a
* chip whose label — and whose click-to-reveal path — names a credential the
* user may not have revealed, and whose value silently diverges if that
* credential later rotates. Duplicate-value spans therefore degrade to the
* plain type-only sentinel. Multiple candidate entries that agree on
* service:field (the same reveal parsed twice) are still unique.
*/
function uniqueCandidateForValue(
candidates: readonly ResolvedRevealCandidate[],
rawValue: string,
): ResolvedRevealCandidate | undefined {
let hit: ResolvedRevealCandidate | undefined;
for (const candidate of candidates) {
if (candidate.value !== rawValue) {
continue;
}
if (
hit !== undefined &&
(hit.service !== candidate.service || hit.field !== candidate.field)
) {
return undefined;
}
hit = candidate;
}
return hit;
}