From 5f0216480d7012b2142c6f0fa83368ef154b645e Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Sun, 23 Aug 2026 23:19:47 +0200 Subject: [PATCH] fix: insulate domain append descriptors Detach the property-descriptor prototype before Object.defineProperty in the module-local append() helpers of three src/domain boundaries, so inherited Object.prototype get/set poisoning can no longer reach ToPropertyDescriptor and turn otherwise-valid normalization/diagnostic reporting into a TypeError. Repairs (independent findings, one family PR): - C1-AI-F1 src/domain/agent-invocation.ts CURRENT / P3 / REPAIR_NOW - C1-AIR-F1 src/domain/agent-invocation-report.ts CURRENT / P2 / REPAIR_NOW - C1-RI-F1 src/domain/review-ingestion.ts CURRENT / P2 / REPAIR_NOW Each helper stays module-local (no shared abstraction), descriptor semantics and field ordering are unchanged, and only failure behavior changes from an unexpected throw to the existing intended normalized/diagnostic result. No public API, dependency, or authority-model change. Co-Authored-By: Claude Opus 4.8 --- src/domain/agent-invocation-report.ts | 12 +- src/domain/agent-invocation.ts | 12 +- src/domain/review-ingestion.ts | 12 +- .../agent-invocation-invariants.test.ts | 214 ++++++++++++++++ ...agent-invocation-report-invariants.test.ts | 231 ++++++++++++++++++ .../review-ingestion-invariants.test.ts | 230 +++++++++++++++++ 6 files changed, 705 insertions(+), 6 deletions(-) create mode 100644 tests/domain/agent-invocation-report-invariants.test.ts diff --git a/src/domain/agent-invocation-report.ts b/src/domain/agent-invocation-report.ts index 9c6d75b..33025fb 100644 --- a/src/domain/agent-invocation-report.ts +++ b/src/domain/agent-invocation-report.ts @@ -66,6 +66,7 @@ import { */ const objectFreeze = Object.freeze; const objectDefineProperty = Object.defineProperty; +const objectSetPrototypeOf = Object.setPrototypeOf; const objectHasOwn = Object.hasOwn; const arrayIsArray = Array.isArray; const numberIsInteger = Number.isInteger; @@ -73,12 +74,19 @@ const stringConstructor = String; /** Append by defining an own element, bypassing inherited index setters. */ function append(list: T[], value: T): void { - objectDefineProperty(list, list.length, { + // The descriptor object inherits from `Object.prototype`, and + // `Object.defineProperty` runs ToPropertyDescriptor over it — consulting + // inherited `get`/`set` via [[HasProperty]]. A poisoned `Object.prototype.get` + // or `.set` would therefore be read and make the call throw. Detaching the + // descriptor's prototype first means only its own data attributes are visible. + const descriptor: PropertyDescriptor = { value, writable: true, enumerable: true, configurable: true, - }); + }; + objectSetPrototypeOf(descriptor, null); + objectDefineProperty(list, list.length, descriptor); } /** diff --git a/src/domain/agent-invocation.ts b/src/domain/agent-invocation.ts index 7cb257c..4651eb2 100644 --- a/src/domain/agent-invocation.ts +++ b/src/domain/agent-invocation.ts @@ -45,6 +45,7 @@ */ const objectFreeze = Object.freeze; const objectDefineProperty = Object.defineProperty; +const objectSetPrototypeOf = Object.setPrototypeOf; const objectHasOwn = Object.hasOwn; const reflectApply = Reflect.apply; // Captured unbound on purpose and invoked through `Reflect.apply`, so neither a @@ -67,12 +68,19 @@ function containsValue(list: readonly string[], value: unknown): boolean { /** Append by defining an own element, bypassing inherited index setters. */ function append(list: T[], value: T): void { - objectDefineProperty(list, list.length, { + // The descriptor object inherits from `Object.prototype`, and + // `Object.defineProperty` runs ToPropertyDescriptor over it — consulting + // inherited `get`/`set` via [[HasProperty]]. A poisoned `Object.prototype.get` + // or `.set` would therefore be read and make the call throw. Detaching the + // descriptor's prototype first means only its own data attributes are visible. + const descriptor: PropertyDescriptor = { value, writable: true, enumerable: true, configurable: true, - }); + }; + objectSetPrototypeOf(descriptor, null); + objectDefineProperty(list, list.length, descriptor); } /** diff --git a/src/domain/review-ingestion.ts b/src/domain/review-ingestion.ts index 82a2d83..f56b906 100644 --- a/src/domain/review-ingestion.ts +++ b/src/domain/review-ingestion.ts @@ -44,18 +44,26 @@ import { */ const objectFreeze = Object.freeze; const objectDefineProperty = Object.defineProperty; +const objectSetPrototypeOf = Object.setPrototypeOf; const arrayIsArray = Array.isArray; const numberIsInteger = Number.isInteger; const stringConstructor = String; /** Append by defining an own element, bypassing inherited index setters. */ function append(list: T[], value: T): void { - objectDefineProperty(list, list.length, { + // The descriptor object inherits from `Object.prototype`, and + // `Object.defineProperty` runs ToPropertyDescriptor over it — consulting + // inherited `get`/`set` via [[HasProperty]]. A poisoned `Object.prototype.get` + // or `.set` would therefore be read and make the call throw. Detaching the + // descriptor's prototype first means only its own data attributes are visible. + const descriptor: PropertyDescriptor = { value, writable: true, enumerable: true, configurable: true, - }); + }; + objectSetPrototypeOf(descriptor, null); + objectDefineProperty(list, list.length, descriptor); } /** Read a positive integer line number, or `null`. */ diff --git a/tests/domain/agent-invocation-invariants.test.ts b/tests/domain/agent-invocation-invariants.test.ts index 58cc7e1..766718b 100644 --- a/tests/domain/agent-invocation-invariants.test.ts +++ b/tests/domain/agent-invocation-invariants.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { evaluateEvidenceFreshness, + findInvalidInvocationFields, ingestInvocationReport, ingestReview, INVOCATION_BOUNDS, @@ -734,3 +735,216 @@ describe('PR 005 correlation convention', () => { expect(Object.keys(result)).not.toContain('requestedAt'); }); }); + +/** + * C1-AI-F1 — the diagnostic append path stays total under a hostile + * `Object.prototype`. + * + * `findInvalidInvocationFields` (and its non-object `allRequiredFields` branch) + * build their result with `append`, which defines an own index via + * `Object.defineProperty`. A descriptor object literal inherits from + * `Object.prototype`, so an inherited `get`/`set` accessor was consulted by + * ToPropertyDescriptor and made the call throw a `TypeError` — turning valid + * diagnostic reporting into a crash. The repair detaches the descriptor's + * prototype before the define, so only its own data attributes are read. + * + * Poison installers use null-prototype descriptors so the harness never + * reproduces the bug itself; product code runs under poison, the realm is + * restored, and assertions run afterwards (Section 13 of the repair gate). + */ +describe('C1-AI-F1 append survives hostile Object.prototype get/set', () => { + const defineProp = Object.defineProperty; + const getOwnDesc = Object.getOwnPropertyDescriptor; + + function nullProto(object: T): T { + Object.setPrototypeOf(object, null); + return object; + } + + /** Plant inherited data-property poison; return a realm-restoring function. */ + function poisonPrototype(keys: readonly string[]): () => void { + const saved: Record = Object.create( + null, + ) as Record; + for (const key of keys) { + saved[key] = getOwnDesc(Object.prototype, key); + } + for (const key of keys) { + defineProp( + Object.prototype, + key, + nullProto({ value: 'inherited-poison', configurable: true, writable: true }), + ); + } + return () => { + for (const key of keys) { + const descriptor = saved[key]; + if (descriptor === undefined) { + Reflect.deleteProperty(Object.prototype, key); + } else { + defineProp(Object.prototype, key, nullProto({ ...descriptor })); + } + } + }; + } + + function underPoison( + keys: readonly string[], + run: () => T, + ): { result: T | null; thrown: unknown } { + const restore = poisonPrototype(keys); + let result: T | null = null; + let thrown: unknown = null; + try { + result = run(); + } catch (error: unknown) { + thrown = error; + } finally { + restore(); + } + return { result, thrown }; + } + + const invalidPurpose = (extra: Partial = {}): AgentInvocation => + ({ ...buildInvocation(extra), purpose: 'nope' }) as unknown as AgentInvocation; + + const POISON_SETS: readonly (readonly string[])[] = [['get'], ['set'], ['get', 'set']]; + + for (const keys of POISON_SETS) { + it(`reports a single invalid field under ${keys.join('+')} poison`, () => { + const { result, thrown } = underPoison(keys, () => + findInvalidInvocationFields(invalidPurpose()), + ); + + expect(thrown).toBeNull(); + expect(result && [...result]).toEqual(['purpose']); + }); + } + + for (const keys of POISON_SETS) { + it(`reports every required field for a non-object under ${keys.join('+')} poison`, () => { + const { result, thrown } = underPoison(keys, () => + findInvalidInvocationFields(null as unknown as AgentInvocation), + ); + + expect(thrown).toBeNull(); + expect(result && [...result]).toEqual([ + 'invocationId', + 'repositoryId', + 'targetCommitSha', + 'providerId', + 'agentId', + 'purpose', + 'requestedAt', + ]); + }); + } + + it('preserves invalid-field declaration order under get+set poison', () => { + const invocation = invalidPurpose({ invocationId: '', requestedAt: '' }); + const { result, thrown } = underPoison(['get', 'set'], () => + findInvalidInvocationFields(invocation), + ); + + expect(thrown).toBeNull(); + expect(result && [...result]).toEqual(['invocationId', 'purpose', 'requestedAt']); + }); + + it('survives a getter that installs get poison mid-evaluation', () => { + const saved = getOwnDesc(Object.prototype, 'get'); + let result: readonly string[] | null = null; + let thrown: unknown = null; + try { + const invocation = { ...buildInvocation() }; + defineProp(invocation, 'invocationId', { + get(): string { + defineProp( + Object.prototype, + 'get', + nullProto({ value: 'planted', configurable: true, writable: true }), + ); + return ''; // invalid, so append('invocationId') runs under the poison it just installed + }, + configurable: true, + enumerable: true, + }); + try { + result = findInvalidInvocationFields(invocation as AgentInvocation); + } catch (error: unknown) { + thrown = error; + } + } finally { + if (saved === undefined) { + Reflect.deleteProperty(Object.prototype, 'get'); + } else { + defineProp(Object.prototype, 'get', nullProto({ ...saved })); + } + } + + expect(thrown).toBeNull(); + expect(result && [...result]).toEqual(['invocationId']); + }); + + it('survives a getter that installs set poison mid-evaluation', () => { + const saved = getOwnDesc(Object.prototype, 'set'); + let result: readonly string[] | null = null; + let thrown: unknown = null; + try { + const invocation = { ...buildInvocation() }; + defineProp(invocation, 'invocationId', { + get(): string { + defineProp( + Object.prototype, + 'set', + nullProto({ value: 'planted', configurable: true, writable: true }), + ); + return ''; + }, + configurable: true, + enumerable: true, + }); + try { + result = findInvalidInvocationFields(invocation as AgentInvocation); + } catch (error: unknown) { + thrown = error; + } + } finally { + if (saved === undefined) { + Reflect.deleteProperty(Object.prototype, 'set'); + } else { + defineProp(Object.prototype, 'set', nullProto({ ...saved })); + } + } + + expect(thrown).toBeNull(); + expect(result && [...result]).toEqual(['invocationId']); + }); + + it('matches the clean control under get+set poison', () => { + const invocation = invalidPurpose({ invocationId: '' }); + const clean = [...findInvalidInvocationFields(invocation)]; + const { result, thrown } = underPoison(['get', 'set'], () => + findInvalidInvocationFields(invocation), + ); + + expect(thrown).toBeNull(); + expect(result && [...result]).toEqual(clean); + expect(clean).toEqual(['invocationId', 'purpose']); + }); + + it('leaves a clean valid invocation reporting nothing under poison', () => { + const { result, thrown } = underPoison(['get', 'set'], () => + findInvalidInvocationFields(buildInvocation()), + ); + + expect(thrown).toBeNull(); + expect(result && [...result]).toEqual([]); + }); + + it('restores Object.prototype after every poisoned run', () => { + underPoison(['get', 'set'], () => findInvalidInvocationFields(invalidPurpose())); + + expect(getOwnDesc(Object.prototype, 'get')).toBeUndefined(); + expect(getOwnDesc(Object.prototype, 'set')).toBeUndefined(); + }); +}); diff --git a/tests/domain/agent-invocation-report-invariants.test.ts b/tests/domain/agent-invocation-report-invariants.test.ts new file mode 100644 index 0000000..f403ecf --- /dev/null +++ b/tests/domain/agent-invocation-report-invariants.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it } from 'vitest'; + +import { + ingestInvocationReport, + type ClaimedArtifactInput, +} from '../../src/domain/index.js'; +import { buildClaim, buildInvocation, buildReport, SHA_B } from './invocation-fixtures.js'; + +/** + * C1-AIR-F1 — report ingestion stays total under a hostile `Object.prototype`. + * + * `ingestInvocationReport` accumulates normalized claims and rejected claims + * with `append`, which defines an own index via `Object.defineProperty`. Because + * a descriptor object literal inherits from `Object.prototype`, an inherited + * `get`/`set` accessor was consulted by ToPropertyDescriptor and made the call + * throw a `TypeError` — an otherwise-valid report carrying >= 1 artifact claim + * turned into a crash, losing the evidence the report was meant to construct. + * The repair detaches the descriptor's prototype before the define, so only its + * own data attributes are read. + * + * Poison installers use null-prototype descriptors so the harness never + * reproduces the bug itself; product code runs under poison, the realm is + * restored, and assertions run afterwards (Section 13 of the repair gate). + */ +describe('C1-AIR-F1 append survives hostile Object.prototype get/set', () => { + const defineProp = Object.defineProperty; + const getOwnDesc = Object.getOwnPropertyDescriptor; + + function nullProto(object: T): T { + Object.setPrototypeOf(object, null); + return object; + } + + /** Plant inherited data-property poison; return a realm-restoring function. */ + function poisonPrototype(keys: readonly string[]): () => void { + const saved: Record = Object.create( + null, + ) as Record; + for (const key of keys) { + saved[key] = getOwnDesc(Object.prototype, key); + } + for (const key of keys) { + defineProp( + Object.prototype, + key, + nullProto({ value: 'inherited-poison', configurable: true, writable: true }), + ); + } + return () => { + for (const key of keys) { + const descriptor = saved[key]; + if (descriptor === undefined) { + Reflect.deleteProperty(Object.prototype, key); + } else { + defineProp(Object.prototype, key, nullProto({ ...descriptor })); + } + } + }; + } + + function underPoison( + keys: readonly string[], + run: () => T, + ): { result: T | null; thrown: unknown } { + const restore = poisonPrototype(keys); + let result: T | null = null; + let thrown: unknown = null; + try { + result = run(); + } catch (error: unknown) { + thrown = error; + } finally { + restore(); + } + return { result, thrown }; + } + + const POISON_SETS: readonly (readonly string[])[] = [['get'], ['set'], ['get', 'set']]; + + for (const keys of POISON_SETS) { + it(`ingests a report with one artifact claim under ${keys.join('+')} poison`, () => { + const { result, thrown } = underPoison(keys, () => + ingestInvocationReport(buildInvocation(), buildReport([buildClaim()])), + ); + + expect(thrown).toBeNull(); + expect(result?.outcome).toBe('INGESTED'); + expect(result?.claims.length).toBe(1); + }); + } + + it('preserves claim order under get+set poison', () => { + const report = buildReport([ + buildClaim({ reference: 'ref-0' }), + buildClaim({ reference: 'ref-1' }), + buildClaim({ reference: 'ref-2' }), + ]); + const { result, thrown } = underPoison(['get', 'set'], () => + ingestInvocationReport(buildInvocation(), report), + ); + + expect(thrown).toBeNull(); + expect(result?.claims.map((claim) => claim.reference)).toEqual([ + 'ref-0', + 'ref-1', + 'ref-2', + ]); + expect(result?.claims.map((claim) => claim.claimId)).toEqual(['c0', 'c1', 'c2']); + }); + + it('produces claim content identical to the clean control under get+set poison', () => { + const report = buildReport([buildClaim({ commitSha: SHA_B })]); + const clean = ingestInvocationReport(buildInvocation(), report); + const { result, thrown } = underPoison(['get', 'set'], () => + ingestInvocationReport(buildInvocation(), report), + ); + + expect(thrown).toBeNull(); + expect(result).toEqual(clean); + }); + + it('appends a rejected claim under get+set poison', () => { + const report = buildReport([ + buildClaim({ reference: 'kept' }), + { reference: '' } as ClaimedArtifactInput, + ]); + const clean = ingestInvocationReport(buildInvocation(), report); + const { result, thrown } = underPoison(['get', 'set'], () => + ingestInvocationReport(buildInvocation(), report), + ); + + expect(thrown).toBeNull(); + expect(result?.claims.length).toBe(1); + expect(result?.claims[0]?.reference).toBe('kept'); + expect(result?.rejectedClaims.length).toBe(1); + expect(result?.rejectedClaims[0]?.reason).toBe('REFERENCE_MISSING'); + expect(result).toEqual(clean); + }); + + it('survives a claim getter that installs get poison mid-evaluation', () => { + const saved = getOwnDesc(Object.prototype, 'get'); + let result: ReturnType | null = null; + let thrown: unknown = null; + try { + const hostile = defineProp({ ...buildClaim() }, 'reference', { + get(): string { + defineProp( + Object.prototype, + 'get', + nullProto({ value: 'planted', configurable: true, writable: true }), + ); + return 'ref-mid'; // valid, so append(claims, claim) runs under the freshly installed poison + }, + configurable: true, + enumerable: true, + }) as ClaimedArtifactInput; + const report = buildReport([hostile, buildClaim({ reference: 'ref-after' })]); + try { + result = ingestInvocationReport(buildInvocation(), report); + } catch (error: unknown) { + thrown = error; + } + } finally { + if (saved === undefined) { + Reflect.deleteProperty(Object.prototype, 'get'); + } else { + defineProp(Object.prototype, 'get', nullProto({ ...saved })); + } + } + + expect(thrown).toBeNull(); + expect(result?.outcome).toBe('INGESTED'); + expect(result?.claims.map((claim) => claim.reference)).toEqual(['ref-mid', 'ref-after']); + }); + + it('survives a claim getter that installs set poison mid-evaluation', () => { + const saved = getOwnDesc(Object.prototype, 'set'); + let result: ReturnType | null = null; + let thrown: unknown = null; + try { + const hostile = defineProp({ ...buildClaim() }, 'reference', { + get(): string { + defineProp( + Object.prototype, + 'set', + nullProto({ value: 'planted', configurable: true, writable: true }), + ); + return 'ref-mid'; + }, + configurable: true, + enumerable: true, + }) as ClaimedArtifactInput; + const report = buildReport([hostile, buildClaim({ reference: 'ref-after' })]); + try { + result = ingestInvocationReport(buildInvocation(), report); + } catch (error: unknown) { + thrown = error; + } + } finally { + if (saved === undefined) { + Reflect.deleteProperty(Object.prototype, 'set'); + } else { + defineProp(Object.prototype, 'set', nullProto({ ...saved })); + } + } + + expect(thrown).toBeNull(); + expect(result?.outcome).toBe('INGESTED'); + expect(result?.claims.map((claim) => claim.reference)).toEqual(['ref-mid', 'ref-after']); + }); + + it('ingests a clean report identically with and without poison', () => { + const report = buildReport([buildClaim()]); + const clean = ingestInvocationReport(buildInvocation(), report); + const { result, thrown } = underPoison(['get', 'set'], () => + ingestInvocationReport(buildInvocation(), report), + ); + + expect(thrown).toBeNull(); + expect(result).toEqual(clean); + }); + + it('restores Object.prototype after every poisoned run', () => { + underPoison(['get', 'set'], () => + ingestInvocationReport(buildInvocation(), buildReport([buildClaim()])), + ); + + expect(getOwnDesc(Object.prototype, 'get')).toBeUndefined(); + expect(getOwnDesc(Object.prototype, 'set')).toBeUndefined(); + }); +}); diff --git a/tests/domain/review-ingestion-invariants.test.ts b/tests/domain/review-ingestion-invariants.test.ts index 5243369..7dbe4d7 100644 --- a/tests/domain/review-ingestion-invariants.test.ts +++ b/tests/domain/review-ingestion-invariants.test.ts @@ -425,3 +425,233 @@ describe('ingestion decides nothing beyond normalization', () => { expect(revived).toEqual(result); }); }); + +/** + * C1-RI-F1 — review ingestion stays total under a hostile `Object.prototype`. + * + * `ingestReview` accumulates normalized findings and rejected findings with + * `append`, which defines an own index via `Object.defineProperty`. Because a + * descriptor object literal inherits from `Object.prototype`, an inherited + * `get`/`set` accessor was consulted by ToPropertyDescriptor and made the call + * throw a `TypeError` — an otherwise-valid submission carrying >= 1 finding + * turned into a crash, losing the evidence being ingested. The repair detaches + * the descriptor's prototype before the define, so only its own data attributes + * are read. + * + * Poison installers use null-prototype descriptors so the harness never + * reproduces the bug itself; product code runs under poison, the realm is + * restored, and assertions run afterwards (Section 13 of the repair gate). + */ +describe('C1-RI-F1 append survives hostile Object.prototype get/set', () => { + const defineProp = Object.defineProperty; + const getOwnDesc = Object.getOwnPropertyDescriptor; + + function nullProto(object: T): T { + Object.setPrototypeOf(object, null); + return object; + } + + /** Plant inherited data-property poison; return a realm-restoring function. */ + function poisonPrototype(keys: readonly string[]): () => void { + const saved: Record = Object.create( + null, + ) as Record; + for (const key of keys) { + saved[key] = getOwnDesc(Object.prototype, key); + } + for (const key of keys) { + defineProp( + Object.prototype, + key, + nullProto({ value: 'inherited-poison', configurable: true, writable: true }), + ); + } + return () => { + for (const key of keys) { + const descriptor = saved[key]; + if (descriptor === undefined) { + Reflect.deleteProperty(Object.prototype, key); + } else { + defineProp(Object.prototype, key, nullProto({ ...descriptor })); + } + } + }; + } + + function underPoison( + keys: readonly string[], + run: () => T, + ): { result: T | null; thrown: unknown } { + const restore = poisonPrototype(keys); + let result: T | null = null; + let thrown: unknown = null; + try { + result = run(); + } catch (error: unknown) { + thrown = error; + } finally { + restore(); + } + return { result, thrown }; + } + + const POISON_SETS: readonly (readonly string[])[] = [['get'], ['set'], ['get', 'set']]; + + for (const keys of POISON_SETS) { + it(`ingests a submission with one finding under ${keys.join('+')} poison`, () => { + const { result, thrown } = underPoison(keys, () => + ingestReview(buildContext(), buildSubmission([buildFinding()])), + ); + + expect(thrown).toBeNull(); + expect(result?.outcome).toBe(INGESTION_OUTCOME.INGESTED); + expect(result?.findings.length).toBe(1); + }); + } + + it('preserves finding order under get+set poison', () => { + const submission = buildSubmission([ + buildFinding({ title: 'first' }), + buildFinding({ title: 'second' }), + buildFinding({ title: 'third' }), + ]); + const { result, thrown } = underPoison(['get', 'set'], () => + ingestReview(buildContext(), submission), + ); + + expect(thrown).toBeNull(); + expect(result?.findings.map((finding) => finding.title)).toEqual([ + 'first', + 'second', + 'third', + ]); + expect(result?.findings.map((finding) => finding.findingId)).toEqual(['f0', 'f1', 'f2']); + }); + + it('produces finding content identical to the clean control under get+set poison', () => { + const submission = buildSubmission([buildFinding()]); + const clean = ingestReview(buildContext(), submission); + const { result, thrown } = underPoison(['get', 'set'], () => + ingestReview(buildContext(), submission), + ); + + expect(thrown).toBeNull(); + expect(result).toEqual(clean); + }); + + it('appends a rejected finding under get+set poison', () => { + const submission = buildSubmission([ + buildFinding({ title: 'kept' }), + { message: 'no title' }, + ]); + const clean = ingestReview(buildContext(), submission); + const { result, thrown } = underPoison(['get', 'set'], () => + ingestReview(buildContext(), submission), + ); + + expect(thrown).toBeNull(); + expect(result?.findings.length).toBe(1); + expect(result?.findings[0]?.title).toBe('kept'); + expect(result?.rejected.length).toBe(1); + expect(result?.rejected[0]?.reason).toBe('REQUIRED_FIELD_MISSING'); + expect(result).toEqual(clean); + }); + + it('survives a finding getter that installs get poison mid-evaluation', () => { + const saved = getOwnDesc(Object.prototype, 'get'); + let result: ReturnType | null = null; + let thrown: unknown = null; + try { + const hostile = defineProp({ ...buildFinding() }, 'title', { + get(): string { + defineProp( + Object.prototype, + 'get', + nullProto({ value: 'planted', configurable: true, writable: true }), + ); + return 'title-mid'; // valid, so append(findings, finding) runs under the freshly installed poison + }, + configurable: true, + enumerable: true, + }) as ReturnType; + const submission = buildSubmission([hostile, buildFinding({ title: 'title-after' })]); + try { + result = ingestReview(buildContext(), submission); + } catch (error: unknown) { + thrown = error; + } + } finally { + if (saved === undefined) { + Reflect.deleteProperty(Object.prototype, 'get'); + } else { + defineProp(Object.prototype, 'get', nullProto({ ...saved })); + } + } + + expect(thrown).toBeNull(); + expect(result?.outcome).toBe(INGESTION_OUTCOME.INGESTED); + expect(result?.findings.map((finding) => finding.title)).toEqual([ + 'title-mid', + 'title-after', + ]); + }); + + it('survives a finding getter that installs set poison mid-evaluation', () => { + const saved = getOwnDesc(Object.prototype, 'set'); + let result: ReturnType | null = null; + let thrown: unknown = null; + try { + const hostile = defineProp({ ...buildFinding() }, 'title', { + get(): string { + defineProp( + Object.prototype, + 'set', + nullProto({ value: 'planted', configurable: true, writable: true }), + ); + return 'title-mid'; + }, + configurable: true, + enumerable: true, + }) as ReturnType; + const submission = buildSubmission([hostile, buildFinding({ title: 'title-after' })]); + try { + result = ingestReview(buildContext(), submission); + } catch (error: unknown) { + thrown = error; + } + } finally { + if (saved === undefined) { + Reflect.deleteProperty(Object.prototype, 'set'); + } else { + defineProp(Object.prototype, 'set', nullProto({ ...saved })); + } + } + + expect(thrown).toBeNull(); + expect(result?.outcome).toBe(INGESTION_OUTCOME.INGESTED); + expect(result?.findings.map((finding) => finding.title)).toEqual([ + 'title-mid', + 'title-after', + ]); + }); + + it('ingests a clean submission identically with and without poison', () => { + const submission = buildSubmission([buildFinding()]); + const clean = ingestReview(buildContext(), submission); + const { result, thrown } = underPoison(['get', 'set'], () => + ingestReview(buildContext(), submission), + ); + + expect(thrown).toBeNull(); + expect(result).toEqual(clean); + }); + + it('restores Object.prototype after every poisoned run', () => { + underPoison(['get', 'set'], () => + ingestReview(buildContext(), buildSubmission([buildFinding()])), + ); + + expect(getOwnDesc(Object.prototype, 'get')).toBeUndefined(); + expect(getOwnDesc(Object.prototype, 'set')).toBeUndefined(); + }); +});