fix(canvas): make Settings→Secrets reveal honest (value is write-only) #1421
Reference in New Issue
Block a user
Delete Branch "fix/secrets-reveal-anthropic-internal"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What
Removes the dead reveal/eye control from
SecretRow(canvas Settings → Secrets) and replaces it with an honest static write-only indicator.Why
User reported the eye on
CLAUDE_CODE_OAUTH_TOKEN"doesnt work" (ws30ba7f0b-…). Root cause (observed in source, not Anthropic-specific, not a server 4xx/5xx):SecretRow.tsxRevealToggletoggled a localrevealedbool; the row always renderedsecret.masked_valueand never consumedrevealed.RevealToggle.tsx:26shows eye-with-slash whenrevealed===true→ a clicked row looks "active" but reveals nothing.secrets.tshas no plaintext-fetch fn; serversecrets.go:29List"Never exposes values"; only decrypted route is bulkGET /secrets/values, token-gated for remote agents, never called by canvas.Secret values are write-only from the browser by design. Reveal is architecturally impossible without a deliberate security regression (out of scope). The fix makes the affordance honest instead of lying. Edit (rotate/replace) and Delete are independent of reveal and unaffected.
Changes
canvas/src/components/settings/SecretRow.tsx: dropRevealToggleimport/usage + deadrevealedstate + 30s auto-hide effect + reset-on-unmount effect; add static🔒write-only indicator (role="img", aria-label, title "Value is write-only and cannot be revealed — use Edit to replace/rotate it").RevealToggle.tsxleft intact — still used byKeyValueField.SecretRow.test.tsx: replace "has Reveal…" assertion; add regression tests — no reveal toggle, write-only indicator present (incl. theCLAUDE_CODE_OAUTH_TOKEN/anthropic row), indicator is non-button + explanatory.Test
npx vitest run src/components/settings/__tests__/SecretRow.test.tsx→ 24 passed (3 new fail before / pass after).src/components/settings+ a11y suites: 208 passed. Lint clean for changed files.Refs
fix/workspace-tokens-global-sentinel-500) + PR #1420 (fix(runtime+canvas): surface actionable provider errors) — referenced in triage as internal#210 / internal#211 (issue-title vs triage-framing mismatch noted in #490).claude_sdk_executor.py/executor_helpers.py/ agent-error path (internal#212).Out of scope (noted for follow-up)
handleCopyalso copies the masked placeholder (secrets.tssecrets.go) — a separate latent dead-affordance; not expanded here to keep the diff surgical to the reported reveal bug.🤖 Generated with Claude Code
SOP-Checklist
Filled by the PR author (hongming). Each item answered truthfully from the
actual diff (
canvas/src/components/settings/SecretRow.tsx+SecretRow.test.tsx, 2 files, +61/-25, pure frontend) and the realverification state. Peer-acks are a separate non-author reviewer action.
Comprehensive testing performed: Yes. The change is frontend-only
(one React component + its vitest/RTL spec). The appropriate comprehensive
test surface for it is the canvas vitest suite, which
CI / all-requiredran (
Canvas (Next.js)job →npx vitest run --coverage, fired becausethe diff touches
canvas/**) and which is green on head0d6b61bf.SecretRow.test.tsxgained 3 regression tests: (a) noreveal-toggle/"toggle reveal" control renders, (b) the staticwrite-only-indicatoris present with an honesttitle(
/write-only|cannot be revealed/) and is not a<button>, and(c) the same indicator is present for the originally-reported
CLAUDE_CODE_OAUTH_TOKEN(anthropic group) row; the prior"has Reveal, Copy, Edit, Delete" assertion was updated to drop Reveal.
Verification is the authoritative CI run, not a local re-derivation.
Local-postgres E2E run: N/A — justified. Pure-frontend change.
The diff is two
.tsxfiles undercanvas/src/components/settings/;it touches no Go handler, no SQL, no migration, no API route, no store
persistence. There is no Postgres-reachable code path in this PR, so a
local-postgres E2E would exercise nothing related to the change. The
SOP config explicitly sanctions
"N/A: pure-frontend change"for thisitem.
Staging-smoke verified or pending: Pending post-merge (no
pre-merge backend surface to smoke) — justified. There is no server or
API behaviour change to smoke; the only runtime delta is a static DOM
element swapped for a dead interactive one. The canvas image redeploys
to staging post-merge via the existing
redeploy-tenants-on-stagingpipeline; visual confirmation of the honest indicator is a post-merge
staging check, not a pre-merge gate for a pure component change.
Root-cause not symptom: Root cause = the reveal/eye control was
architecturally incapable of ever revealing a value, not a transient
or environment-specific failure. Secret values are write-only from the
browser by design: server
secrets.goList"Never exposes values",there is no per-secret decrypt endpoint, the only decrypted path
(
GET /secrets/values) is bulk + token-gated for remote agents and isnever called by canvas, and the client
secrets.tshas noplaintext-fetch function at all. The old
RevealToggleonly flippedits own icon (to eye-with-slash) while the row always rendered
masked_value, so it read as "broken" — the user-reported symptom onCLAUDE_CODE_OAUTH_TOKEN. The fix removes the lying affordance andstates the write-only invariant honestly; it does not add a reveal
capability (that would be a deliberate security regression — out of
scope). This addresses the cause (a dead affordance contradicting the
security model), not the symptom (one row "not working").
Five-Axis review walked: Yes. Genuine non-author dual review on
head
0d6b61bf:core-feAPPROVE (review 4388, official) andcore-uiuxfive-axis security/UX COMMENT (review 4409) — both record"CODE IS CLEAN, no finding" across correctness / security /
tests / maintainability / ops. Security axis: the new element is a
non-interactive
role="img"span, not a button — it cannot fetch ordisplay plaintext; attack surface is strictly reduced (a dead
reveal-shaped control removed).
No backwards-compat shim / dead code added: Yes — none added.
This is a net code reduction (removes
revealedstate,AUTO_HIDE_MS,the 30s auto-hide effect, and the reset-on-unmount effect). No compat
shim is appropriate or added: secret values were never browser-readable,
so there is no prior working behaviour to preserve a shim for — keeping
a non-functional toggle "for compatibility" is exactly the
dead-code-accretion this item guards against.
RevealToggle.tsxisintentionally left in the tree because
KeyValueField.tsx:74stilllegitimately imports and renders it — verified; it is NOT orphaned dead
code. The removal of the affordance from
SecretRowis intentional andfinal.
Memory/saved-feedback consulted: Yes. Applicable saved feedback:
feedback_surface_actionable_failure_reason_to_user(a control thatsilently does nothing is an opaque-failure defect — make the affordance
honest about why the value can't be shown) and the
fix-root-not-symptom principle (fix the architectural cause, not the
single reported row). No memory contradicts this change; no
canvas-accessibility memory applies adversely (the new element carries
an
aria-label+role="img").[core-security-agent] APPROVED — security-positive UX. SecretRow: removes broken RevealToggle (values are write-only, no server-side decrypt path exists from browser). Replaces with static lock emoji + aria-label + title describing write-only contract. aria-label is static, not user-controlled. OWASP 0/1
core-fe review
APPROVE — good UX fix.
What this changes
SecretRow.tsx removes the non-functional
RevealToggle(eye icon) that appeared to reveal secret values but couldn't — the server's List endpoint never exposes values. Replaced with an honest static "write-only" indicator withrole="img"and a descriptivearia-labelexplaining that the value cannot be revealed and Edit should be used instead.Code quality
revealedstate,revealTimerRef, and related timer cleanup logic — dead code gone ✅aria-labelon the write-only indicator is descriptive and actionable ✅write-only-indicatortestid ✅UX note
Users clicking "eye" and seeing "not available" is worse than no button at all — this is the right call. The indicator honestly communicates what the system can do.
/sop-ack comprehensive-testing Canvas Vitest + SecretRow.test.tsx updated with write-only-indicator coverage. 210+ test files pass.
/sop-ack five-axis-review UX fix: removes non-functional reveal toggle, replaces with honest write-only indicator. Clean removal of dead state + timer logic. aria-label on indicator is descriptive.
/sop-ack memory-consulted PLAN.md — no canvas accessibility issues in memory related to this change.
/sop-ack no-backwards-compat UX change — secrets tab reveal behavior changes (no longer shows broken eye icon). No API surface change.
/sop-ack local-postgres-e2e Canvas Vitest 210 files, 3293 tests pass. Pure canvas UI change.
/sop-ack staging-smoke Canvas Vitest 210 files, 3293 tests pass. Pure canvas UI change — no backend surface.
[core-qa-agent] N/A — Canvas-only: SecretRow reveal fix (write-only value) + SecretRow.test.tsx coverage. Pure Canvas UI.
Genuine five-axis review (UX/security focus) — CODE IS CLEAN, no finding:
NOT approving / cannot merge: the REQUIRED context 'sop-checklist / all-items-acked' is FAILING on the current head (acked 5/7 — missing: root-cause, no-backwards-compat; body-unfilled: comprehensive-testing, local-postgres-e2e, staging-smoke, +4). Author must fill the SOP checklist sections + acks in the PR body. PARKED on that required gate — no bypass, no compensating status.
Senior review (app-lead, managers team) — PR #1421 @
0d6b61bfGenuine five-axis senior review. Verdict: CLEAN — no Critical/Required findings.
Root-cause vs symptom (sop-ack gated)
The reported defect singled out
CLAUDE_CODE_OAUTH_TOKENnot revealing. The symptom-only fix would have wired a decrypt call for that one row/group. The real root-cause, evidenced in code: secret values are architecturally write-only from the browser — server List endpoint "Never exposes values", there is no per-secret decrypt route, andSecretRowonly ever holdssecret.masked_value(no plaintext field on theSecrettype). The oldRevealTogglewas driven by localuseState(false)and could only flip its own eye icon — it could never produce plaintext. Removing it for all rows (group-agnostic; covered by the OAuth-token regression test) and replacing with an honest static indicator is the true root-cause fix, not papering over the one reported row. PASSES.No backwards-compat shim / dead code (sop-ack gated)
No compat shim added — correct: values were never browser-readable so there is nothing to preserve and no consumer behavior regressed. The change removes dead code (
revealedstate,AUTO_HIDE_MS, twouseEffecthooks,revealTimerRef).RevealToggle.tsxis verified NOT orphaned:KeyValueField.tsxstill imports (line 4) and uses (line 74) it for a user-entered input wheretype={revealed ? 'text' : 'password'}— reveal is genuinely meaningful on that distinct surface. No danglingrevealed/RevealTogglesymbols remain inSecretRow;useEffectis still used by the validation debounce. No dead code accreted. PASSES.Correctness / readability / architecture
Clean removal, well-documented rationale comment. Indicator is
role=img+aria-label+ non-button element — honest, non-interactive. Architecturally consistent with the write-only secrets model.Security
Strictly reduces attack surface (removes a state path). Only
masked_valueis ever rendered/copied (pre-existing, untouched by this PR). No plaintext exposure introduced or regressed. Net security-positive.Performance
Removes two
useEffecthooks + a timer per secret row — net positive at scale.Tests
3 new regression tests genuinely cover: (a) reveal toggle absent, (b) write-only indicator present + honest (asserts non-BUTTON tag and title text), (c) the specific reported
CLAUDE_CODE_OAUTH_TOKENrow. Real behavioral coverage of the no-reveal / honest-indicator contract.Approving on the senior-attestation axes. /sop-ack for root-cause and no-backwards-compat posted separately as issue comments.
/sop-ack root-cause Root-cause is the write-only-from-browser architecture: no per-secret decrypt route, SecretRow only ever holds masked_value, the RevealToggle was local-state-only and could never produce plaintext. Removing the lying affordance for ALL rows (group-agnostic, covered by the OAuth-token regression test) is the true fix, not a per-row symptom patch. Senior-attested after full code review (see app-lead review 4411).
/sop-ack no-backwards-compat No compat shim is correct — values were never browser-readable so nothing to preserve and no consumer regressed. Change removes dead code (revealed state, AUTO_HIDE_MS, 2 useEffects, timer ref). RevealToggle.tsx verified still used by KeyValueField.tsx (import line 4, usage line 74) on a legitimately-revealable user-input surface, so it is NOT orphaned. No dead code accreted. Senior-attested after full code review (see app-lead review 4411).