# Capability Spec Delta: serialize (serialize-proto-guard) **Package**: `@suaveplan/serialize` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. (See `openspec/AGENTS.md` Section 13.) ## ADDED Requirements ### Requirement: Prototype-pollution stripper The package SHALL expose `stripPrototypePolluters(value: unknown, options?: { allowDangerousKeys?: boolean }): void` that recursively removes the `__proto__`, `constructor`, and `prototype` keys from every plain-object and array node it encounters in `value`. The function SHALL mutate the input tree in place. Class instances, `Map`, `Set`, `Date`, `Uint8Array`, and other typed objects MUST NOT be traversed (their internal slots are not addressable as object keys). When `options.allowDangerousKeys === true`, the function SHALL be a no-op. #### Scenario: dangerous keys are stripped recursively - **WHEN** a caller invokes `stripPrototypePolluters({ a: { __proto__: {}, b: { constructor: 1 } } })` - **THEN** the input mutates to `{ a: { b: {} } }` (both `__proto__` and `constructor` removed) #### Scenario: class instances are not traversed - **WHEN** a caller invokes `stripPrototypePolluters({ d: new Date(0), m: new Map([["__proto__", 1]]) })` - **THEN** the `Date` and `Map` are unchanged (their internal slots are not addressed as enumerable string keys) #### Scenario: allowDangerousKeys is a no-op - **WHEN** a caller invokes `stripPrototypePolluters({ __proto__: {} }, { allowDangerousKeys: true })` - **THEN** the input is unchanged **Verified by:** `src/security/proto-guard/proto-guard.test.ts::"strips __proto__ / constructor / prototype recursively"` AND `src/security/proto-guard/proto-guard.test.ts::"does not traverse Date / Map / Set / Uint8Array internals"` AND `src/security/proto-guard/proto-guard.spec-conformance.test.ts::"allowDangerousKeys true makes the function a no-op"` --- ### Requirement: Non-mutating clone-and-strip The package SHALL expose `cloneAndStrip(value: T, options?: { allowDangerousKeys?: boolean }): T` that returns a deep clone of `value` with `stripPrototypePolluters` applied to the clone. The original input MUST NOT be mutated. #### Scenario: original input is preserved - **WHEN** a caller invokes `cloneAndStrip(input)` for `input = { __proto__: { polluted: true }, safe: 1 }` - **THEN** the returned object equals `{ safe: 1 }` AND `input.__proto__` still has `polluted: true` **Verified by:** `src/security/proto-guard/proto-guard.test.ts::"cloneAndStrip preserves the original input"` --- ### Requirement: OTel observability for prototype-guard runs `stripPrototypePolluters` and `cloneAndStrip` SHALL emit a passive OTel span event named `serialize.proto-guard.strip` carrying a numeric attribute `keysRemoved` representing the count of dangerous keys deleted by the call. The span event MUST be emitted from production paths regardless of whether the caller has configured a tracer. #### Scenario: keysRemoved attribute reflects the deletion count - **WHEN** a caller invokes `stripPrototypePolluters({ __proto__: 1, a: { constructor: 2 } })` under an in-memory tracer - **THEN** the recorded span event carries `keysRemoved: 2` **Verified by:** `src/security/proto-guard/proto-guard.spec-conformance.test.ts::"emits serialize.proto-guard.strip event with keysRemoved count"` --- ### Requirement: structuredClone wrapper with structured error The package SHALL expose `deepClone(value: T): T` from `./codec/structured-clone` that delegates to `globalThis.structuredClone`. When `globalThis.structuredClone` is `undefined` or non-callable, the function SHALL throw `SerializationError` with code `STRUCTURED_CLONE.UNAVAILABLE`. `deepClone` SHALL be re-exported from the root `index.ts`. #### Scenario: round-trip preserves nested structures - **WHEN** a caller invokes `deepClone(input)` for any input that platform `structuredClone` accepts (objects, arrays, `Map`, `Set`, `Date`, `Uint8Array`, primitives) - **THEN** the result is structurally equal to `input` AND identity-distinct (`!== input` for non-primitive values) #### Scenario: missing platform support throws structured error - **WHEN** `globalThis.structuredClone` is replaced with `undefined` AND a caller invokes `deepClone({})` - **THEN** the call throws `SerializationError` with code `STRUCTURED_CLONE.UNAVAILABLE` **Verified by:** `src/codec/structured-clone/structured-clone.test.ts::"round-trips nested objects / arrays / Map / Set / Date / Uint8Array"` AND `src/codec/structured-clone/structured-clone.spec-conformance.test.ts::"throws SerializationError(STRUCTURED_CLONE.UNAVAILABLE) when platform structuredClone is missing"` --- ### Requirement: JSON decoder opt-in post-strip The JSON decoder (`./codec/json/deserialize`) SHALL accept an optional `stripPrototypePolluters?: boolean` field on `DeserializeOptions`. When `true`, the decoder SHALL run `stripPrototypePolluters` on the parsed value after the existing prototype-pollution reviver pass completes. The default value SHALL remain `false` so existing call sites are unaffected. #### Scenario: opt-in flag runs the post-pass - **WHEN** a caller invokes `deserialize(jsonText, { stripPrototypePolluters: true })` against input that produces nested `__proto__` keys via reviver-bypass tricks (escaped strings, etc.) - **THEN** the returned value contains zero `__proto__` / `constructor` / `prototype` keys #### Scenario: default behaviour is unchanged - **WHEN** a caller invokes `deserialize(jsonText)` without the flag - **THEN** the decoder behaves exactly as before this change shipped (the existing reviver-only guard is the only protection) **Verified by:** `src/codec/json/json-decode.spec-conformance.test.ts::"stripPrototypePolluters true runs post-pass over decoded value"` AND `src/codec/json/json-decode.spec-conformance.test.ts::"default decoder behaviour is unchanged when flag is omitted"` --- ### Requirement: Subpath exports The package SHALL expose two new subpath exports in `package.json::exports`: `./security/proto-guard` (resolves to `./dist/security/proto-guard/index.js` plus types) and `./codec/structured-clone` (resolves to `./dist/codec/structured-clone/index.js` plus types). Importing only `deepClone` from the structured-clone subpath SHALL NOT pull in the JSON or MessagePack codecs. #### Scenario: subpath imports resolve - **WHEN** a caller writes `import { deepClone } from "@suaveplan/serialize/codec/structured-clone"` AND `import { stripPrototypePolluters } from "@suaveplan/serialize/security/proto-guard"` - **THEN** TypeScript resolves both imports **Verified by:** `src/codec/structured-clone/structured-clone.spec-conformance.test.ts::"subpath ./codec/structured-clone resolves and tree-shakes"` AND `src/security/proto-guard/proto-guard.spec-conformance.test.ts::"subpath ./security/proto-guard resolves and tree-shakes"`