fix(core): mask credential-class fields on every write response - #21816
Conversation
The shared write-response helper every generic write mouth already routes through now applies the engine's read-path credential mask (secret fields, and password fields outside the exempt managedBy bucket) before omitting internal fields, so a write answers what a read of the same row would. The three write-mouth tripwires (protocol, REST, MCP) now also scan for stored credential values. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
… responses test(dogfood): pin write-response credential masking across every write door Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
📓 Docs Drift CheckThis PR changes 3 package(s): 8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 5 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 49 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c226288acbf94c5a80b51ce7845eca8aec9a2dba && git checkout c226288acbf94c5a80b51ce7845eca8aec9a2dba
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 18c7dfd2e68cd2630420080b49a5f6a62fe60a6a 5ec8fa7db16ea79de8ecc7c1a056f15f2afb12d5 && git checkout -B drift-repro 18c7dfd2e68cd2630420080b49a5f6a62fe60a6a && git merge --no-ff 5ec8fa7db16ea79de8ecc7c1a056f15f2afb12d5
node scripts/docs-audit/affected-docs.mjs --json 18c7dfd2e68cd2630420080b49a5f6a62fe60a6a
|
…rite responses - callData fallback write arms echo only the receipt when no schema resolves. - The cross-object batch update arm calls the core write-response helper directly instead of an optional protocol method, failing closed with no schema. - The declarative update action result masks its echoed patch and undo redo data through the same helper; no schema means no echoed patch. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
Fixes #21787
Clause-②: no
What changed
Credential-class field values are now masked on every write response, as on reads.
secret, andpasswordoutside the exemptmanagedBybucket, ADR-0100) on its read path only. Its write results keep the stored row whole by design, so privileged server-side writers can read back what they wrote. The rule for what an external caller receives therefore sits at the write mouths, the same boundary that already omitsinternal: truefields.omitInternalFieldsFromWriteResponse(@objectstack/core) is the one helper every generic write mouth already calls: protocol*Datafaces, the REST cross-object batch, and the MCP stdio bridge. It now applies the credential mask first (maskCredentialFieldsInWriteResponse, new export) and then omitsinternalfields, in the same order the engine's read path uses. The mask reads the sameisMaskedOnReadFieldTypedeclaration in@objectstack/spec/datathat the engine's read mask reads, so the two cannot drift. It never adds a key, so a field already removed by field-level security stays absent.callData's fallback create and update arms (@objectstack/runtime, used when no protocol service is registered) answered without that helper. They now call it too.Tests
All runs below are from head
91d3806ab5or from a commit whose tree matches it for the files each run covers.packages/core/src/utils/internal-write-response.test.ts: the collector, the mask, the order (mask, then omit), the better-auth exemption, and a field removed upstream staying absent. 5/5 pass.passwordplaintext and asecret:handle ref on every stored row, in the protocol, REST and MCP suites. Each tripwire enumerates its whole surface, so a new write mouth must register there. Protocol 18/18, REST 14/14, MCP 14/14 (MCP adds a test that the update echo does not return the caller's own credential in clear).packages/runtime/src/action-execution-calldata-write-response.test.ts: the fallback create and update arms. 3/3 pass.packages/qa/dogfood/test/write-response-credential-mask.dogfood.test.ts: a real boot with a synthetic object holding onepasswordand onesecretfield. Before anything else, the test proves the plaintext and the handle ref are really stored. It then checks single create, single update, createMany, updateMany, the per-object batch, the cross-object batch, and the masked-echo round trip. 8/8 pass.action-execution*,http-dispatcher.mcp*anddomains/mcpfiles pass (157/157). Neighbouring credential andinternaldogfood suites pass (97/97 across 8 files).--listFilescount confirms the new and edited test files are inside the compiled programs.Ablation, run through
scripts/ablation-replace.mjswith a dist preflight. The single call that applies the credential mask inside the shared helper was replaced.@objectstack/corewas rebuilt, and the preflight showed the marker present indist/.The restore was proven: blob equal to HEAD,
git diff HEADempty, and the core rebuild showed the marker absent fromdist/with a clean tree. A first ablation attempt used a preflight marker that the pristine build also emits, so its preflight reading was void. That run is not counted; the run above uses a unique marker.Gates:
node scripts/pm/dispatch-gates.mjs --ranreconciles 78 derived families: 77 run with exit 0, 1 NOT MEASURED.check:dual-build-cjs-loadsexited 3 with PREREQUISITE NOT MET because unrelated packages had nodist/.check:engine-double-contractasked for the new pinned double to be recorded, andscripts/engine-double-contract.pinned.jsonis updated.Declared narrowing, left to CI: the full runtime suite; the workspace type-check lanes;
mainwas not merged back in before opening.Acceptance notes
maskSecretFields. That is the boundary the existing ruling set for the siblinginternalguarantee: engine write results stay whole for privileged callers, and every external write mouth applies the response rules. Masking inside the engine would also mask results that server-side writers read back.omitInternalFieldsFromWriteResponsenow also masks credentials, so its name describes less than it does. A rename touches every write mouth and every tripwire, so it is left out of this PR. Carrier: none.docs/adr/**is governed. This PR does not touch it.Generated by Claude Code