Skip to content

Commit a0176ef

Browse files
fix(core): mask credential-class fields on every write response (#21816)
Fixes #21787 Clause-②: no ## What changed Credential-class field values are now masked on every write response, as on reads. - The engine masks credential-class fields (`secret`, and `password` outside the exempt `managedBy` bucket, 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 omits `internal: true` fields. - `omitInternalFieldsFromWriteResponse` (`@objectstack/core`) is the one helper every generic write mouth already calls: protocol `*Data` faces, the REST cross-object batch, and the MCP stdio bridge. It now applies the credential mask first (`maskCredentialFieldsInWriteResponse`, new export) and then omits `internal` fields, in the same order the engine's read path uses. The mask reads the same `isMaskedOnReadFieldType` declaration in `@objectstack/spec/data` that 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. - Unchanged: engine-level write results, and the echoed-mask write guard. A client that saves a masked value back leaves the stored credential as it was. The dogfood test pins this. ## Tests All runs below are from head `91d3806ab5` or from a commit whose tree matches it for the files each run covers. - New `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. - The three existing write-mouth tripwires now also check credential-class stored values: a `password` plaintext and a `secret:` 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). - New `packages/runtime/src/action-execution-calldata-write-response.test.ts`: the fallback create and update arms. 3/3 pass. - New `packages/qa/dogfood/test/write-response-credential-mask.dogfood.test.ts`: a real boot with a synthetic object holding one `password` and one `secret` field. 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. - Full package suites: core 2225/2225, metadata-protocol 5249 passed and 19 skipped, rest 5074 passed and 327 skipped, mcp 390/390. In runtime, the `action-execution*`, `http-dispatcher.mcp*` and `domains/mcp` files pass (157/157). Neighbouring credential and `internal` dogfood suites pass (97/97 across 8 files). - Typecheck is clean for core, runtime, metadata-protocol, rest, mcp and dogfood. A `--listFiles` count confirms the new and edited test files are inside the compiled programs. **Ablation**, run through `scripts/ablation-replace.mjs` with a dist preflight. The single call that applies the credential mask inside the shared helper was replaced. `@objectstack/core` was rebuilt, and the preflight showed the marker present in `dist/`. | Suite | Result with the mask call removed | |:---|:---| | core | 2 red | | protocol tripwire | 8 red (every write face plus the negative control) | | REST tripwire | 8 red, including both batch routes | | MCP tripwire | 3 red | | runtime fallback | 2 red | | dogfood | 7 red: every write door, while the arming test stayed green | The restore was proven: blob equal to HEAD, `git diff HEAD` empty, and the core rebuild showed the marker absent from `dist/` 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 --ran` reconciles 78 derived families: 77 run with exit 0, 1 NOT MEASURED. `check:dual-build-cjs-loads` exited 3 with PREREQUISITE NOT MET because unrelated packages had no `dist/`. `check:engine-double-contract` asked for the new pinned double to be recorded, and `scripts/engine-double-contract.pinned.json` is updated. **Declared narrowing, left to CI:** the full runtime suite; the workspace type-check lanes; `main` was not merged back in before opening. ## Acceptance notes - **Placement:** the fix is in the shared write-response helper, not the engine's `maskSecretFields`. That is the boundary the existing ruling set for the sibling `internal` guarantee: 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. - **Naming:** `omitInternalFieldsFromWriteResponse` now 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. - **ADR-0100:** the ADR describes the mask on the read path only. Whether it should gain a line recording the write-response half is a maintainer call, because `docs/adr/**` is governed. This PR does not touch it. - **Not examined:** outbound record surfaces that are not write responses, such as event payloads, were not checked against this rule. --- _Generated by [Claude Code](https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c4d5713 commit a0176ef

12 files changed

Lines changed: 712 additions & 40 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/core': patch
3+
'@objectstack/runtime': patch
4+
---
5+
6+
Credential-class field values are now masked on every write response, as on reads.
7+
8+
Clause-②: no
9+
10+
- A `secret` field, and a `password` field on an object that is not `managedBy: 'better-auth'`, already read back as `SECRET_MASK` (`null` when unset) on the generic read path (ADR-0100). Every write response that returns a record (REST, batch and MCP) now answers the same way.
11+
- The shared write-response helper every write door already calls (`omitInternalFieldsFromWriteResponse`, `@objectstack/core`) now applies the credential mask before it omits `internal: true` fields. New exports beside it: `maskCredentialFieldsInWriteResponse` and `collectCredentialWriteResponseFields`, which read the same `isMaskedOnReadFieldType` declaration as the engine's read mask.
12+
- `callData`'s fallback create and update arms (`@objectstack/runtime`, used when no protocol service is registered) now pass their response record through the same helper.
13+
- Unchanged: the engine's own write results still return the stored row whole to privileged server-side callers, and the echoed-mask write guard still treats a `SECRET_MASK` value as "leave unchanged", so a client that saves back a write response does not overwrite the stored credential.
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { SECRET_MASK } from '@objectstack/spec/data';
5+
import {
6+
collectCredentialWriteResponseFields,
7+
maskCredentialFieldsInWriteResponse,
8+
omitInternalFieldsFromWriteResponse,
9+
} from './internal-write-response.js';
10+
11+
const STORED_PASSWORD = 'stored-password-value-never-returned';
12+
const STORED_REF = 'secret:handle-never-returned';
13+
14+
const SCHEMA = {
15+
name: 'cred_holder',
16+
fields: {
17+
id: { type: 'text' },
18+
name: { type: 'text' },
19+
login_password: { type: 'password' },
20+
api_token: { type: 'secret' },
21+
hidden_hash: { type: 'text', internal: true },
22+
both: { type: 'secret', internal: true },
23+
},
24+
};
25+
26+
const row = () => ({
27+
id: 'r1',
28+
name: 'visible',
29+
login_password: STORED_PASSWORD,
30+
api_token: STORED_REF,
31+
hidden_hash: 'h',
32+
both: 'secret:x',
33+
});
34+
35+
describe('write-response credential mask', () => {
36+
it('collects the read-mask set: secret always, password outside the exempt bucket', () => {
37+
expect(collectCredentialWriteResponseFields(SCHEMA).sort()).toEqual(['api_token', 'both', 'login_password']);
38+
expect(collectCredentialWriteResponseFields({ ...SCHEMA, managedBy: 'better-auth' }).sort())
39+
.toEqual(['api_token', 'both']);
40+
expect(collectCredentialWriteResponseFields(undefined)).toEqual([]);
41+
expect(collectCredentialWriteResponseFields({ name: 'x' })).toEqual([]);
42+
});
43+
44+
it('masks a set credential, keeps an unset one null, never adds a key', () => {
45+
const r: Record<string, unknown> = { id: 'r1', login_password: STORED_PASSWORD, api_token: null };
46+
maskCredentialFieldsInWriteResponse(SCHEMA, r);
47+
expect(r).toEqual({ id: 'r1', login_password: SECRET_MASK, api_token: null });
48+
// Idempotent.
49+
maskCredentialFieldsInWriteResponse(SCHEMA, r);
50+
expect(r).toEqual({ id: 'r1', login_password: SECRET_MASK, api_token: null });
51+
});
52+
53+
it('the shared write-response helper masks credentials and omits internal fields, on every row', () => {
54+
const rows = [row(), null, 7, row()];
55+
omitInternalFieldsFromWriteResponse(SCHEMA, rows);
56+
for (const r of [rows[0], rows[3]] as Array<Record<string, unknown>>) {
57+
expect(r).toEqual({ id: 'r1', name: 'visible', login_password: SECRET_MASK, api_token: SECRET_MASK });
58+
}
59+
const wire = JSON.stringify(rows);
60+
expect(wire.includes(STORED_PASSWORD)).toBe(false);
61+
expect(wire.includes('secret:')).toBe(false);
62+
});
63+
64+
it('a field removed upstream (field-level security) stays absent', () => {
65+
const r: Record<string, unknown> = { id: 'r1', name: 'visible' };
66+
omitInternalFieldsFromWriteResponse(SCHEMA, r);
67+
expect(r).toEqual({ id: 'r1', name: 'visible' });
68+
});
69+
70+
it('a better-auth object keeps its password column, still masks its secret column', () => {
71+
const r = row();
72+
omitInternalFieldsFromWriteResponse({ ...SCHEMA, managedBy: 'better-auth' }, r);
73+
expect(r.login_password).toBe(STORED_PASSWORD);
74+
expect(r.api_token).toBe(SECRET_MASK);
75+
});
76+
});

‎packages/core/src/utils/internal-write-response.ts‎

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,15 +89,29 @@
8989
* columns are `required`, so a mask carries zero bits while still shipping a
9090
* value under a field whose description promises none.
9191
*
92+
* ## The credential-class mask rides the same helper
93+
*
94+
* The engine masks credential-class fields (`secret`, and `password` outside
95+
* the exempt `managedBy` buckets — ADR-0100) on its READ path only, and keeps
96+
* its write results whole for the same reason it keeps `internal` columns:
97+
* a privileged server-side writer may read the stored value back off the
98+
* result. Every external write response therefore owes the credential mask
99+
* too, at the same mouths. It is applied by the same helper, ahead of the
100+
* omission, so no mouth can hold one guarantee and miss the other, and the
101+
* three tripwires named above hold both by one enumeration.
102+
*
92103
* Deletion is IN PLACE and idempotent: records that already lack the field
93104
* (a re-stripped read result, a fake engine that never returned it) pass
94105
* through unchanged, and non-record values (`null`, an affected-row count, a
95106
* driver's boolean delete verdict) are skipped rather than judged.
96107
*/
97108

109+
import { SECRET_MASK, isMaskedOnReadFieldType } from '@objectstack/spec/data';
110+
98111
/** Minimal view of an object schema this module reads — the field map only. */
99112
interface SchemaWithFields {
100-
fields?: Record<string, { internal?: unknown } | undefined> | undefined;
113+
fields?: Record<string, { internal?: unknown; type?: unknown } | undefined> | undefined;
114+
managedBy?: unknown;
101115
}
102116

103117
/**
@@ -118,9 +132,65 @@ export function collectInternalWriteResponseFields(schema: unknown): string[] {
118132
}
119133

120134
/**
121-
* Drop every `internal: true` field from a write response's record(s), in
122-
* place. THE single helper every external write mouth goes through — see the
123-
* module header; the three tripwires enforce the "every".
135+
* Collect the names of the credential-class fields the engine masks on read
136+
* (ADR-0100: every `secret` field, every `password` field outside the exempt
137+
* `managedBy` buckets) — the write-response half of that mask reads the SAME
138+
* set, so a write answers what a read of the same row would.
139+
*
140+
* Not restated: the type set and its per-type `managedBy` exemptions are
141+
* declared once in `@objectstack/spec/data` and asked through
142+
* `isMaskedOnReadFieldType`, exactly as objectql's `collectMaskedReadFields`
143+
* asks it. ⛔ Do not add a `def.type === …` arm here; change the declaration.
144+
*/
145+
export function collectCredentialWriteResponseFields(schema: unknown): string[] {
146+
const s = schema as SchemaWithFields | null | undefined;
147+
const fields = s?.fields;
148+
if (!fields || typeof fields !== 'object') return [];
149+
const managedBy: unknown = s?.managedBy;
150+
const out: string[] = [];
151+
for (const [name, def] of Object.entries(fields)) {
152+
if (def && isMaskedOnReadFieldType(def.type, managedBy)) out.push(name);
153+
}
154+
return out;
155+
}
156+
157+
/**
158+
* Replace every credential-class field in a write response's record(s) with
159+
* `SECRET_MASK`, in place — the write-response mirror of the engine's read
160+
* mask (`maskSecretFields`). A set value becomes the mask; an unset one
161+
* (`null` / `undefined`) becomes `null`, so "a credential is set" is the only
162+
* bit a response carries. A field the record does not carry (never written, or
163+
* already removed by field-level security upstream) stays absent: this mask
164+
* never ADDS a key, so it composes with the security plugin's field masking
165+
* instead of re-exposing what that removed.
166+
*
167+
* Idempotent; non-objects are skipped, arrays are walked.
168+
*/
169+
export function maskCredentialFieldsInWriteResponse(schema: unknown, records: unknown): void {
170+
if (!records) return;
171+
const credentialFields = collectCredentialWriteResponseFields(schema);
172+
if (credentialFields.length === 0) return;
173+
const list = Array.isArray(records) ? records : [records];
174+
for (const row of list) {
175+
if (!row || typeof row !== 'object') continue;
176+
const r = row as Record<string, unknown>;
177+
for (const field of credentialFields) {
178+
if (!(field in r)) continue;
179+
r[field] = r[field] == null ? null : SECRET_MASK;
180+
}
181+
}
182+
}
183+
184+
/**
185+
* Apply the generic-data-path non-exposure rules to a write response's
186+
* record(s), in place: credential-class fields are MASKED
187+
* ({@link maskCredentialFieldsInWriteResponse}), then `internal: true` fields
188+
* are OMITTED. THE single helper every external write mouth goes through —
189+
* see the module header; the three tripwires enforce the "every".
190+
*
191+
* Mask first, omit second — the engine read path's order, so a field that is
192+
* both credential-typed and `internal` ends up omitted (the stricter
193+
* disposition wins).
124194
*
125195
* @param schema The registered object schema (`engine.registry.getObject(...)`
126196
* / the protocol's own registry view / `metadataService
@@ -133,6 +203,7 @@ export function collectInternalWriteResponseFields(schema: unknown): string[] {
133203
*/
134204
export function omitInternalFieldsFromWriteResponse(schema: unknown, records: unknown): void {
135205
if (!records) return;
206+
maskCredentialFieldsInWriteResponse(schema, records);
136207
const internalFields = collectInternalWriteResponseFields(schema);
137208
if (internalFields.length === 0) return;
138209
const list = Array.isArray(records) ? records : [records];

‎packages/mcp/src/mcp-write-response-internal-fields.tripwire.test.ts‎

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFin
5252
const SENTINEL = 'INTERNAL-SENTINEL-8497-NEVER-SERIALIZED';
5353
/** The value that MUST appear wherever a record was promised (falsifiability). */
5454
const CONTROL = 'CONTROL-VALUE-8497-RECORD-FLOWED';
55+
/** Credential-class stored values: a `password` plaintext and a `secret` handle ref. */
56+
const CREDENTIAL_SENTINELS = ['PASSWORD-SENTINEL-NEVER-SERIALIZED', 'secret:HANDLE-SENTINEL-NEVER-SERIALIZED'];
57+
const NEVER_SERIALIZED = [SENTINEL, ...CREDENTIAL_SENTINELS];
58+
const leaks = (wire: string) => NEVER_SERIALIZED.filter((v) => wire.includes(v));
5559

5660
const VAULT = {
5761
name: 'vault',
@@ -60,6 +64,8 @@ const VAULT = {
6064
id: { name: 'id', type: 'text' },
6165
name: { name: 'name', type: 'text' },
6266
vault_secret: { name: 'vault_secret', type: 'text', internal: true },
67+
vault_password: { name: 'vault_password', type: 'password' },
68+
vault_token: { name: 'vault_token', type: 'secret' },
6369
},
6470
// No `apiEnabled`/`apiMethods` narrowing: the ADR-0049 exposure gate must let
6571
// every verb through, or a recipe would be measuring a 404 instead of a body.
@@ -78,6 +84,8 @@ function makeSentinelEngine(): IDataEngine {
7884
id,
7985
name: (data?.name as string) ?? CONTROL,
8086
vault_secret: SENTINEL,
87+
vault_password: CREDENTIAL_SENTINELS[0],
88+
vault_token: CREDENTIAL_SENTINELS[1],
8189
});
8290
return {
8391
find: vi.fn(async () => [storedRow()]),
@@ -157,7 +165,7 @@ const RECIPES: Record<string, Recipe> = {
157165
remove: { invoke: (b) => b.remove('vault', 'row-1'), writesRecords: false },
158166
};
159167

160-
describe('#8497 tripwire: no MCP write response carries an `internal: true` value', () => {
168+
describe('#8497 tripwire: no MCP write response carries an `internal: true` value or a credential-class stored value', () => {
161169
it('the enumeration is real: it sees the bridge write verbs', () => {
162170
const faces = enumerateBridgeFaces(makeBridge());
163171
expect(faces).toEqual(expect.arrayContaining(['create', 'update', 'remove']));
@@ -181,10 +189,10 @@ describe('#8497 tripwire: no MCP write response carries an `internal: true` valu
181189
});
182190

183191
for (const [name, recipe] of Object.entries(RECIPES)) {
184-
it(`${name}: response never carries the internal sentinel${recipe.writesRecords ? ', and really returned a record' : ''}`, async () => {
192+
it(`${name}: response never carries the internal or credential sentinels${recipe.writesRecords ? ', and really returned a record' : ''}`, async () => {
185193
const bridge = makeBridge();
186194
const wire = JSON.stringify((await recipe.invoke(bridge)) ?? null);
187-
expect(wire.includes(SENTINEL), `${name} leaked an internal field: ${wire}`).toBe(false);
195+
expect(leaks(wire), `${name} leaked an internal or credential-class field: ${wire}`).toEqual([]);
188196
if (recipe.writesRecords) {
189197
expect(
190198
wire.includes(CONTROL),
@@ -207,6 +215,16 @@ describe('#8497 tripwire: no MCP write response carries an `internal: true` valu
207215
expect(wire.includes(CONTROL)).toBe(true); // still a real record echo
208216
});
209217

218+
it('the caller cannot read their own credential write back in clear from the update echo', async () => {
219+
const bridge = makeBridge();
220+
const wire = JSON.stringify(await bridge.update('vault', 'row-1', {
221+
name: CONTROL,
222+
vault_password: 'caller-sent-password',
223+
}));
224+
expect(wire.includes('caller-sent-password')).toBe(false);
225+
expect(wire.includes(CONTROL)).toBe(true);
226+
});
227+
210228
it('NEGATIVE CONTROL: the machinery goes red on a write mouth that skips the helper', async () => {
211229
// Exactly the defect this file was written after: an engine-only mouth that
212230
// echoes `engine.insert`'s (whole) result. Reintroduce it locally and prove
@@ -221,10 +239,10 @@ describe('#8497 tripwire: no MCP write response carries an `internal: true` valu
221239
};
222240

223241
const leaked = await leaky.create('vault', { name: CONTROL });
224-
expect(JSON.stringify(leaked).includes(SENTINEL)).toBe(true); // the scan bites
242+
expect(leaks(JSON.stringify(leaked))).toEqual(NEVER_SERIALIZED); // the scan bites
225243

226244
omitInternalFieldsFromWriteResponse(VAULT, (leaked as any).record);
227-
expect(JSON.stringify(leaked).includes(SENTINEL)).toBe(false); // the helper closes it
245+
expect(leaks(JSON.stringify(leaked))).toEqual([]); // the helper closes it
228246
expect(JSON.stringify(leaked).includes(CONTROL)).toBe(true); // …without eating the record
229247
});
230248
});

‎packages/metadata-protocol/src/protocol.write-response-internal-fields.tripwire.test.ts‎

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,24 @@ import {
5656
const SENTINEL = 'INTERNAL-SENTINEL-7823-NEVER-SERIALIZED';
5757
/** The value that MUST appear wherever a record was promised (falsifiability). */
5858
const CONTROL = 'CONTROL-VALUE-7823-RECORD-FLOWED';
59+
/**
60+
* Credential-class stored values (a `password` field's plaintext and a
61+
* `secret` field's handle ref) — masked on read by the engine, and owed the
62+
* same mask on every write response by the shared helper.
63+
*/
64+
const CREDENTIAL_SENTINELS = ['PASSWORD-SENTINEL-NEVER-SERIALIZED', 'secret:HANDLE-SENTINEL-NEVER-SERIALIZED'];
65+
/** Every stored value no write response may carry. */
66+
const NEVER_SERIALIZED = [SENTINEL, ...CREDENTIAL_SENTINELS];
67+
const leaks = (wire: string) => NEVER_SERIALIZED.filter((v) => wire.includes(v));
5968

6069
const VAULT_SCHEMA = {
6170
name: 'vault',
6271
fields: {
6372
id: { name: 'id', type: 'text' },
6473
name: { name: 'name', type: 'text' },
6574
vault_secret: { name: 'vault_secret', type: 'text', internal: true },
75+
vault_password: { name: 'vault_password', type: 'password' },
76+
vault_token: { name: 'vault_token', type: 'secret' },
6677
},
6778
enable: { clone: true },
6879
};
@@ -82,6 +93,8 @@ function makeSentinelEngine() {
8293
id,
8394
name: (data as any)?.name ?? CONTROL,
8495
vault_secret: SENTINEL,
96+
vault_password: CREDENTIAL_SENTINELS[0],
97+
vault_token: CREDENTIAL_SENTINELS[1],
8598
});
8699
let nextId = 1;
87100
const handle = { id: 'trx-1' };
@@ -241,7 +254,7 @@ const RECIPES: Record<string, Recipe> = {
241254
runAtomicBatchData: { coveredVia: 'batchData' },
242255
};
243256

244-
describe('#7823 tripwire: every generic data ingress strips `internal: true` from its write response', () => {
257+
describe('#7823 tripwire: every generic data ingress strips `internal: true` and masks credential-class fields in its write response', () => {
245258
const enumerated = enumerateDataMethods(ObjectStackProtocolImplementation.prototype);
246259

247260
it('the enumeration is real: it sees the three ruling-named ingresses', () => {
@@ -276,12 +289,12 @@ describe('#7823 tripwire: every generic data ingress strips `internal: true` fro
276289
for (const name of enumerated) {
277290
const recipe = RECIPES[name];
278291
if (!recipe || 'coveredVia' in recipe) continue;
279-
it(`${name}: response never carries the internal sentinel${recipe.expectRecord ? ', and really returned a record' : ''}`, async () => {
292+
it(`${name}: response never carries the internal or credential sentinels${recipe.expectRecord ? ', and really returned a record' : ''}`, async () => {
280293
for (const invoke of recipe.invocations) {
281294
const p = new ObjectStackProtocolImplementation(makeSentinelEngine());
282295
const response = await invoke(p);
283296
const wire = JSON.stringify(response ?? null);
284-
expect(wire.includes(SENTINEL), `${name} leaked an internal field: ${wire}`).toBe(false);
297+
expect(leaks(wire), `${name} leaked an internal or credential-class field: ${wire}`).toEqual([]);
285298
if (recipe.expectRecord) {
286299
expect(wire.includes(CONTROL), `${name} returned no record at all — the probe is blind: ${wire}`).toBe(true);
287300
}
@@ -305,12 +318,12 @@ describe('#7823 tripwire: every generic data ingress strips `internal: true` fro
305318

306319
const p = new LeakyProtocol(makeSentinelEngine());
307320
const wire = JSON.stringify(await p.leakyData({ object: 'vault', id: 'row-1', data: { name: 'x' } }));
308-
expect(wire.includes(SENTINEL)).toBe(true); // half 2: the scan detects the leak
321+
expect(leaks(wire)).toEqual(NEVER_SERIALIZED); // half 2: the scan detects every leak
309322

310323
// And the helper is exactly what closes it — same response, one call.
311324
const fixed = await p.leakyData({ object: 'vault', id: 'row-1', data: { name: 'x' } });
312325
omitInternalFieldsFromWriteResponse(VAULT_SCHEMA, (fixed as any).record);
313-
expect(JSON.stringify(fixed).includes(SENTINEL)).toBe(false);
326+
expect(leaks(JSON.stringify(fixed))).toEqual([]);
314327
});
315328

316329
it('the collector agrees with the engine rule: strict `internal === true` only', () => {

0 commit comments

Comments
 (0)