Files
Triple-C/app/src/hooks/useSecretField.ts
T
shadow-testandClaude Opus 5 6a8972980d Stop an untouched secret field from deleting the stored credential
Making `null` actually clear a secret — which it had to, since a blanked
token was previously never revoked — turned the config editors into a
credential shredder. Secrets are `#[serde(skip_serializing)]`, so the
inputs always render empty whether or not one is stored, and the blur
handlers sent `value || null` unconditionally. Focusing the Git token
field and tabbing away deleted it, with nothing shown and no undo.

`useSecretField` encodes the rule: only a field the user typed in may
speak about a secret. Untouched, `patch()` contributes no key at all, and
Rust already distinguishes an absent key from an explicit null.

`withoutUntouchedSecrets` covers the structural half. `saveBedrock`
spreads `{ ...bedrock, ...patch }`, and when that falls back to
DEFAULT_BEDROCK_CONFIG the literal spells every secret out as `null` — so
editing the AWS region would have wiped the credentials as a side effect.

Also here: `WorkspaceSection` no longer saves a half-filled folder row,
which `update_project`'s new validation would refuse on every keystroke
between the two inputs; `snapshots_skipped` is declared on the wire type
rather than widened locally; and `#[must_use]` on `ProjectGuard` and
`ScrubOutcome` — which immediately caught the migration path discarding
its scrub outcome, the one scrub whose silence is expensive because the
layer it declined to clean is about to be committed.

The capability test reads the real file and is mutation-verified: adding
`core:default` back makes it fail. That grant pulls in an unscoped
`std::fs::read` of any host path and went unnoticed for months, because
nothing in the suite read the file at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
2026-08-23 13:23:00 -07:00

97 lines
3.6 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
/**
* A password input whose stored value the frontend can never see.
*
* Secrets live in the OS keychain and are `#[serde(skip_serializing)]`, so a
* `Project` arriving from Rust has no key for them at all and the input always
* renders empty — whether or not a credential is stored. That is fine on its
* own. What is not fine is the obvious blur handler:
*
* ```tsx
* onBlur={() => save({ git_token: gitToken || null })}
* ```
*
* An empty box sends `null`, and `null` now means **delete** (it used to mean
* "skip", which was its own bug — a blanked token was never actually revoked).
* So merely focusing a secret field and tabbing away destroyed the stored
* credential, with nothing shown and nothing to undo it.
*
* The rule this encodes: **only a field the user actually typed in may speak
* about a secret.** `patch()` returns `undefined` until then, and `undefined`
* is dropped by `JSON.stringify`, so the key never reaches Rust — which
* deliberately distinguishes an absent key ("leave it alone") from an explicit
* `null` ("clear it"). See `explicitly_cleared_secrets` in
* `commands/project_commands.rs`.
*/
export interface SecretField {
/** Current input value. Always starts empty for a stored secret. */
value: string;
/** Whether the user has typed in this field since it was last reset. */
edited: boolean;
/** `onChange` handler — marks the field edited. */
setValue: (next: string) => void;
/**
* What to put in the save patch, spread into it:
* `save({ ...token.patch("git_token") })`.
*
* Empty when untouched, so the key is absent and the stored secret stands.
*/
patch: <K extends string>(key: K) => Partial<Record<K, string | null>>;
}
export function useSecretField(projectId: string): SecretField {
const [value, setValueRaw] = useState("");
const [edited, setEdited] = useState(false);
// Reset when the editor moves to a different project, so a value typed for
// one project can never be saved onto another.
const lastProject = useRef(projectId);
useEffect(() => {
if (lastProject.current !== projectId) {
lastProject.current = projectId;
setValueRaw("");
setEdited(false);
}
}, [projectId]);
const setValue = useCallback((next: string) => {
setValueRaw(next);
setEdited(true);
}, []);
const patch = useCallback(
<K extends string>(key: K): Partial<Record<K, string | null>> =>
edited ? ({ [key]: value || null } as Partial<Record<K, string | null>>) : {},
[edited, value],
);
return { value, edited, setValue, patch };
}
/**
* Drop secret keys the caller did not explicitly set.
*
* The config editors save by spreading — `save({ bedrock_config: { ...bedrock,
* ...patch } })`. That is safe while `bedrock` comes from Rust, because secrets
* are never serialized and the keys are simply absent. It stops being safe the
* moment the spread falls back to a `DEFAULT_*_CONFIG` literal, because those
* spell every secret out as `null` — and `null` means delete. Editing the AWS
* region would then wipe the stored credentials as a side effect.
*
* So the merged object is filtered: a secret key survives only if it is in the
* caller's own patch, which is to say only if a `useSecretField` that the user
* typed into put it there.
*/
export function withoutUntouchedSecrets<T extends object>(
merged: T,
patch: Partial<T>,
secretKeys: readonly (keyof T)[],
): T {
const out = { ...merged };
for (const key of secretKeys) {
if (!(key in patch)) delete out[key];
}
return out;
}