From 142795af6c485626cb99e1db658328d8c5dcec21 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 00:10:30 +0900 Subject: [PATCH 01/16] test(reliability): reject invalid runtime image config --- src/extensions/imageConfigRuntime.test.ts | 38 +++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 src/extensions/imageConfigRuntime.test.ts diff --git a/src/extensions/imageConfigRuntime.test.ts b/src/extensions/imageConfigRuntime.test.ts new file mode 100644 index 00000000..87994cd8 --- /dev/null +++ b/src/extensions/imageConfigRuntime.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { buildExtensions } from './kit.js'; + +describe('runtime image configuration', () => { + it.each([ + ['maxSizeBytes', Number.NaN], + ['maxSizeBytes', -1], + ['maxSizeBytes', 1.5], + ['maxDimension', Number.POSITIVE_INFINITY], + ['maxDimension', -1], + ['maxDimension', 1.5], + ] as const)('rejects invalid %s values before extension setup', (key, value) => { + expect(() => + buildExtensions({ + image: { [key]: value } as never, + }), + ).toThrowError(new RangeError(`Image ${key} configuration is invalid.`)); + }); + + it.each([Number.NaN, Number.NEGATIVE_INFINITY, -0.1, 1.1])( + 'rejects invalid quality %s before extension setup', + (quality) => { + expect(() => + buildExtensions({ image: { quality } }), + ).toThrowError(new RangeError('Image quality configuration is invalid.')); + }, + ); + + it('preserves valid disabled and boundary configuration', () => { + const image = buildExtensions({ + image: { maxSizeBytes: 0, maxDimension: 0, quality: 1 }, + }).find((extension) => extension.name === 'image'); + + expect(image?.options.maxSizeBytes).toBe(0); + expect(image?.options.maxDimension).toBe(0); + expect(image?.options.quality).toBe(1); + }); +}); From be171aae2647708b4333a4e469bda7f724eff5f9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 00:16:23 +0900 Subject: [PATCH 02/16] fix(reliability): validate runtime image config --- src/extensions/kit.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/extensions/kit.ts b/src/extensions/kit.ts index 71554bc2..160596b9 100644 --- a/src/extensions/kit.ts +++ b/src/extensions/kit.ts @@ -37,11 +37,34 @@ export interface BuildExtensionsOptions { additionalExtensions?: Extensions; } +/** Reject invalid runtime size/dimension configuration before extension setup. */ +function validateImageNonNegativeSafeInteger( + key: 'maxSizeBytes' | 'maxDimension', + value: number | undefined, +): void { + if (value !== undefined && (!Number.isSafeInteger(value) || value < 0)) { + throw new RangeError(`Image ${key} configuration is invalid.`); + } +} + +/** Reject non-finite or out-of-range runtime image quality configuration. */ +function validateImageQuality(value: number | undefined): void { + if ( + value !== undefined && + (!Number.isFinite(value) || value < 0 || value > 1) + ) { + throw new RangeError('Image quality configuration is invalid.'); + } +} + /** Build the full extension list for an Inkspan editor surface. */ export function buildExtensions( options: BuildExtensionsOptions = {}, ): Extensions { const image = options.image ?? {}; + validateImageNonNegativeSafeInteger('maxSizeBytes', image.maxSizeBytes); + validateImageNonNegativeSafeInteger('maxDimension', image.maxDimension); + validateImageQuality(image.quality); const historyConfiguration = options.disableHistory ? { history: false as const } : {}; From 3995d336c4a8aa06b4632732fadc48816683165a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 00:22:20 +0900 Subject: [PATCH 03/16] test(reliability): reject malformed image config containers --- src/extensions/imageConfigRuntime.test.ts | 53 +++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/src/extensions/imageConfigRuntime.test.ts b/src/extensions/imageConfigRuntime.test.ts index 87994cd8..e7f67092 100644 --- a/src/extensions/imageConfigRuntime.test.ts +++ b/src/extensions/imageConfigRuntime.test.ts @@ -26,6 +26,59 @@ describe('runtime image configuration', () => { }, ); + it.each([null, [], 'invalid', 0, false])( + 'rejects malformed image configuration containers without coercion', + (image) => { + expect(() => buildExtensions({ image: image as never })).toThrowError( + new RangeError('Image configuration is invalid.'), + ); + }, + ); + + it('rejects accessor-backed image configuration without evaluating the accessor', () => { + let reads = 0; + const image = {}; + Object.defineProperty(image, 'maxSizeBytes', { + enumerable: true, + get() { + reads += 1; + return 1024; + }, + }); + + expect(() => buildExtensions({ image })).toThrowError( + new RangeError('Image configuration is invalid.'), + ); + expect(reads).toBe(0); + }); + + it('rejects non-enumerable image configuration data properties', () => { + const image = {}; + Object.defineProperty(image, 'quality', { + enumerable: false, + value: 0.8, + }); + + expect(() => buildExtensions({ image })).toThrowError( + new RangeError('Image configuration is invalid.'), + ); + }); + + it('redacts reflection failures at the image configuration boundary', () => { + const image = new Proxy( + {}, + { + getOwnPropertyDescriptor() { + throw new Error('private reflection detail'); + }, + }, + ); + + expect(() => buildExtensions({ image })).toThrowError( + new RangeError('Image configuration is invalid.'), + ); + }); + it('preserves valid disabled and boundary configuration', () => { const image = buildExtensions({ image: { maxSizeBytes: 0, maxDimension: 0, quality: 1 }, From 707221aa02b93e2bdfda532f37d6ec226013b501 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 00:32:45 +0900 Subject: [PATCH 04/16] fix(reliability): validate runtime image config shape --- src/extensions/kit.ts | 63 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 55 insertions(+), 8 deletions(-) diff --git a/src/extensions/kit.ts b/src/extensions/kit.ts index 160596b9..4d16c4a1 100644 --- a/src/extensions/kit.ts +++ b/src/extensions/kit.ts @@ -37,21 +37,71 @@ export interface BuildExtensionsOptions { additionalExtensions?: Extensions; } +/** Fail closed without reflecting caller-controlled image configuration. */ +function invalidImageConfiguration(): never { + throw new RangeError('Image configuration is invalid.'); +} + +/** Read one own enumerable data property without invoking accessors. */ +function readImageConfigurationProperty( + image: object, + key: keyof ImageConfig, +): unknown { + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = Object.getOwnPropertyDescriptor(image, key); + } catch { + invalidImageConfiguration(); + } + + if (descriptor === undefined) { + return undefined; + } + if (!descriptor.enumerable || !('value' in descriptor)) { + invalidImageConfiguration(); + } + return descriptor.value; +} + +/** Reject malformed runtime image configuration containers before property reads. */ +function resolveRuntimeImageConfiguration(value: unknown): ImageConfig { + if (value === undefined) { + return {}; + } + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + invalidImageConfiguration(); + } + + const maxSizeBytes = readImageConfigurationProperty(value, 'maxSizeBytes'); + const maxDimension = readImageConfigurationProperty(value, 'maxDimension'); + const quality = readImageConfigurationProperty(value, 'quality'); + + validateImageNonNegativeSafeInteger('maxSizeBytes', maxSizeBytes); + validateImageNonNegativeSafeInteger('maxDimension', maxDimension); + validateImageQuality(quality); + + return { + maxSizeBytes: maxSizeBytes as number | undefined, + maxDimension: maxDimension as number | undefined, + quality: quality as number | undefined, + }; +} + /** Reject invalid runtime size/dimension configuration before extension setup. */ function validateImageNonNegativeSafeInteger( key: 'maxSizeBytes' | 'maxDimension', - value: number | undefined, + value: unknown, ): void { - if (value !== undefined && (!Number.isSafeInteger(value) || value < 0)) { + if (value !== undefined && (!Number.isSafeInteger(value) || (value as number) < 0)) { throw new RangeError(`Image ${key} configuration is invalid.`); } } /** Reject non-finite or out-of-range runtime image quality configuration. */ -function validateImageQuality(value: number | undefined): void { +function validateImageQuality(value: unknown): void { if ( value !== undefined && - (!Number.isFinite(value) || value < 0 || value > 1) + (!Number.isFinite(value) || (value as number) < 0 || (value as number) > 1) ) { throw new RangeError('Image quality configuration is invalid.'); } @@ -61,10 +111,7 @@ function validateImageQuality(value: number | undefined): void { export function buildExtensions( options: BuildExtensionsOptions = {}, ): Extensions { - const image = options.image ?? {}; - validateImageNonNegativeSafeInteger('maxSizeBytes', image.maxSizeBytes); - validateImageNonNegativeSafeInteger('maxDimension', image.maxDimension); - validateImageQuality(image.quality); + const image = resolveRuntimeImageConfiguration(options.image); const historyConfiguration = options.disableHistory ? { history: false as const } : {}; From f18fd401775a34ea1fa679df6d71cc9945ce52db Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 01:10:48 +0900 Subject: [PATCH 05/16] test(reliability): reject unknown image config keys --- src/extensions/imageConfigRuntime.test.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/extensions/imageConfigRuntime.test.ts b/src/extensions/imageConfigRuntime.test.ts index e7f67092..50c39dd0 100644 --- a/src/extensions/imageConfigRuntime.test.ts +++ b/src/extensions/imageConfigRuntime.test.ts @@ -64,6 +64,27 @@ describe('runtime image configuration', () => { ); }); + it('rejects unknown runtime configuration keys instead of silently weakening policy', () => { + const image = { + maxSizeBytes: 1024, + maxSzieBytes: 16, + } as never; + + expect(() => buildExtensions({ image })).toThrowError( + new RangeError('Image configuration is invalid.'), + ); + }); + + it('rejects own symbol configuration keys without reflecting their identity', () => { + const privatePolicyKey = Symbol('private-policy-key'); + const image: Record = { maxSizeBytes: 1024 }; + image[privatePolicyKey] = 16; + + expect(() => buildExtensions({ image: image as never })).toThrowError( + new RangeError('Image configuration is invalid.'), + ); + }); + it('redacts reflection failures at the image configuration boundary', () => { const image = new Proxy( {}, From 9e1611b281c3cdb2243107c9e69b4dd9b919cc65 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 01:16:39 +0900 Subject: [PATCH 06/16] test(reliability): redact image config key reflection failures --- src/extensions/imageConfigRuntime.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/extensions/imageConfigRuntime.test.ts b/src/extensions/imageConfigRuntime.test.ts index 50c39dd0..d5acf8eb 100644 --- a/src/extensions/imageConfigRuntime.test.ts +++ b/src/extensions/imageConfigRuntime.test.ts @@ -85,6 +85,21 @@ describe('runtime image configuration', () => { ); }); + it('redacts own-key reflection failures at the image configuration boundary', () => { + const image = new Proxy( + {}, + { + ownKeys() { + throw new Error('private own-key reflection detail'); + }, + }, + ); + + expect(() => buildExtensions({ image })).toThrowError( + new RangeError('Image configuration is invalid.'), + ); + }); + it('redacts reflection failures at the image configuration boundary', () => { const image = new Proxy( {}, From 560cbd50976560ea1ab64f3cc66d87387f95fba3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 01:17:51 +0900 Subject: [PATCH 07/16] fix(reliability): reject unknown image config keys --- src/extensions/kit.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/extensions/kit.ts b/src/extensions/kit.ts index 4d16c4a1..898c037a 100644 --- a/src/extensions/kit.ts +++ b/src/extensions/kit.ts @@ -42,6 +42,26 @@ function invalidImageConfiguration(): never { throw new RangeError('Image configuration is invalid.'); } +/** Reject unknown own keys without evaluating any configuration property. */ +function validateImageConfigurationKeys(image: object): void { + let keys: PropertyKey[]; + try { + keys = Reflect.ownKeys(image); + } catch { + invalidImageConfiguration(); + } + + for (const key of keys) { + if ( + key !== 'maxSizeBytes' && + key !== 'maxDimension' && + key !== 'quality' + ) { + invalidImageConfiguration(); + } + } +} + /** Read one own enumerable data property without invoking accessors. */ function readImageConfigurationProperty( image: object, @@ -72,6 +92,7 @@ function resolveRuntimeImageConfiguration(value: unknown): ImageConfig { invalidImageConfiguration(); } + validateImageConfigurationKeys(value); const maxSizeBytes = readImageConfigurationProperty(value, 'maxSizeBytes'); const maxDimension = readImageConfigurationProperty(value, 'maxDimension'); const quality = readImageConfigurationProperty(value, 'quality'); From 8e58659f4ae8786461414b5e67f269f1d79076c5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 04:34:19 +0900 Subject: [PATCH 08/16] test(reliability): define build options runtime boundary RED --- .../buildExtensionsRuntimeBoundary.test.ts | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 src/extensions/buildExtensionsRuntimeBoundary.test.ts diff --git a/src/extensions/buildExtensionsRuntimeBoundary.test.ts b/src/extensions/buildExtensionsRuntimeBoundary.test.ts new file mode 100644 index 00000000..b6befbe6 --- /dev/null +++ b/src/extensions/buildExtensionsRuntimeBoundary.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { buildExtensions, type BuildExtensionsOptions } from './kit.js'; + +const INVALID_BUILD_EXTENSIONS_CONFIGURATION = + 'Build extensions configuration is invalid.'; + +describe('buildExtensions runtime configuration boundary', () => { + it('rejects malformed top-level option containers through one stable error', () => { + expect(() => + buildExtensions(null as unknown as BuildExtensionsOptions), + ).toThrowError(new RangeError(INVALID_BUILD_EXTENSIONS_CONFIGURATION)); + }); + + it('rejects accessor-backed options without evaluating the accessor', () => { + let reads = 0; + const options = {} as BuildExtensionsOptions; + Object.defineProperty(options, 'image', { + enumerable: true, + get() { + reads += 1; + return { maxSizeBytes: 1_024 }; + }, + }); + + expect(() => buildExtensions(options)).toThrowError( + new RangeError(INVALID_BUILD_EXTENSIONS_CONFIGURATION), + ); + expect(reads).toBe(0); + }); + + it('rejects unknown and symbol option keys instead of silently ignoring them', () => { + const unknownKey = { + maxSzieBytes: 1_024, + } as unknown as BuildExtensionsOptions; + expect(() => buildExtensions(unknownKey)).toThrowError( + new RangeError(INVALID_BUILD_EXTENSIONS_CONFIGURATION), + ); + + const symbolKey = Symbol('private-build-options'); + const options: Record = {}; + options[symbolKey] = true; + expect(() => buildExtensions(options as BuildExtensionsOptions)).toThrowError( + new RangeError(INVALID_BUILD_EXTENSIONS_CONFIGURATION), + ); + }); +}); From f2c480ba354bfce3b8336ef3dc02f8f7f40e734f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 04:39:57 +0900 Subject: [PATCH 09/16] test(reliability): cover build options reflection boundary --- .../buildExtensionsRuntimeBoundary.test.ts | 85 +++++++++++++++---- 1 file changed, 69 insertions(+), 16 deletions(-) diff --git a/src/extensions/buildExtensionsRuntimeBoundary.test.ts b/src/extensions/buildExtensionsRuntimeBoundary.test.ts index b6befbe6..5ed33948 100644 --- a/src/extensions/buildExtensionsRuntimeBoundary.test.ts +++ b/src/extensions/buildExtensionsRuntimeBoundary.test.ts @@ -4,12 +4,19 @@ import { buildExtensions, type BuildExtensionsOptions } from './kit.js'; const INVALID_BUILD_EXTENSIONS_CONFIGURATION = 'Build extensions configuration is invalid.'; +function expectInvalidBuildExtensionsOptions(options: unknown): void { + expect(() => + buildExtensions(options as BuildExtensionsOptions), + ).toThrowError(new RangeError(INVALID_BUILD_EXTENSIONS_CONFIGURATION)); +} + describe('buildExtensions runtime configuration boundary', () => { - it('rejects malformed top-level option containers through one stable error', () => { - expect(() => - buildExtensions(null as unknown as BuildExtensionsOptions), - ).toThrowError(new RangeError(INVALID_BUILD_EXTENSIONS_CONFIGURATION)); - }); + it.each([null, [], 'invalid', 0, false])( + 'rejects malformed top-level option container %p through one stable error', + (options) => { + expectInvalidBuildExtensionsOptions(options); + }, + ); it('rejects accessor-backed options without evaluating the accessor', () => { let reads = 0; @@ -22,25 +29,71 @@ describe('buildExtensions runtime configuration boundary', () => { }, }); - expect(() => buildExtensions(options)).toThrowError( - new RangeError(INVALID_BUILD_EXTENSIONS_CONFIGURATION), - ); + expectInvalidBuildExtensionsOptions(options); expect(reads).toBe(0); }); + it('rejects non-enumerable top-level option properties', () => { + const options = {} as BuildExtensionsOptions; + Object.defineProperty(options, 'disableHistory', { + enumerable: false, + value: true, + }); + + expectInvalidBuildExtensionsOptions(options); + }); + it('rejects unknown and symbol option keys instead of silently ignoring them', () => { - const unknownKey = { - maxSzieBytes: 1_024, - } as unknown as BuildExtensionsOptions; - expect(() => buildExtensions(unknownKey)).toThrowError( - new RangeError(INVALID_BUILD_EXTENSIONS_CONFIGURATION), - ); + expectInvalidBuildExtensionsOptions({ maxSzieBytes: 1_024 }); const symbolKey = Symbol('private-build-options'); const options: Record = {}; options[symbolKey] = true; - expect(() => buildExtensions(options as BuildExtensionsOptions)).toThrowError( - new RangeError(INVALID_BUILD_EXTENSIONS_CONFIGURATION), + expectInvalidBuildExtensionsOptions(options); + }); + + it('redacts hostile own-key reflection failures', () => { + const options = new Proxy( + {}, + { + ownKeys() { + throw new Error('private build-options own-key detail'); + }, + }, ); + + expectInvalidBuildExtensionsOptions(options); + }); + + it('redacts hostile property-descriptor reflection failures', () => { + const options = new Proxy( + {}, + { + ownKeys() { + return ['image']; + }, + getOwnPropertyDescriptor() { + throw new Error('private build-options descriptor detail'); + }, + }, + ); + + expectInvalidBuildExtensionsOptions(options); + }); + + it('rejects a reported option key without an own descriptor', () => { + const options = new Proxy( + {}, + { + ownKeys() { + return ['image']; + }, + getOwnPropertyDescriptor() { + return undefined; + }, + }, + ); + + expectInvalidBuildExtensionsOptions(options); }); }); From c4ba62d6938f2e99e4bbf57035e109e2c1873c60 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 04:40:53 +0900 Subject: [PATCH 10/16] fix(reliability): validate build extension options --- src/extensions/kit.ts | 90 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 83 insertions(+), 7 deletions(-) diff --git a/src/extensions/kit.ts b/src/extensions/kit.ts index 898c037a..b0a88cad 100644 --- a/src/extensions/kit.ts +++ b/src/extensions/kit.ts @@ -18,6 +18,16 @@ import { SafeClipboard } from './SafeClipboardExtension.js'; import { SafeLink, isSafeLinkHref } from './SafeLink.js'; import type { ImageConfig } from '../types.js'; +const BUILD_EXTENSIONS_OPTION_KEYS = [ + 'placeholder', + 'image', + 'clipboard', + 'onImageError', + 'onClipboardError', + 'disableHistory', + 'additionalExtensions', +] as const; + /** Options for constructing the shared Inkspan extension collection. */ export interface BuildExtensionsOptions { /** Static or lazily resolved visual empty-editor guidance. */ @@ -37,6 +47,71 @@ export interface BuildExtensionsOptions { additionalExtensions?: Extensions; } +/** Fail closed without reflecting caller-controlled top-level configuration. */ +function invalidBuildExtensionsConfiguration(): never { + throw new RangeError('Build extensions configuration is invalid.'); +} + +/** + * Copy only exact own data properties from the public runtime options object. + * + * TypeScript callers normally satisfy this shape at compile time, but JavaScript, + * deserialized, or otherwise untyped hosts can still pass arbitrary values. The + * detached copy prevents accessors, inherited values, symbols, and misspelled + * options from changing extension configuration implicitly. + */ +function resolveRuntimeBuildExtensionsOptions( + value: unknown, +): BuildExtensionsOptions { + if (value === undefined) { + return {}; + } + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + invalidBuildExtensionsConfiguration(); + } + + let keys: PropertyKey[]; + try { + keys = Reflect.ownKeys(value); + } catch { + invalidBuildExtensionsConfiguration(); + } + + const resolved: BuildExtensionsOptions = {}; + for (const key of keys) { + if ( + typeof key !== 'string' || + !BUILD_EXTENSIONS_OPTION_KEYS.includes( + key as (typeof BUILD_EXTENSIONS_OPTION_KEYS)[number], + ) + ) { + invalidBuildExtensionsConfiguration(); + } + + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = Object.getOwnPropertyDescriptor(value, key); + } catch { + invalidBuildExtensionsConfiguration(); + } + if ( + descriptor === undefined || + !descriptor.enumerable || + !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + invalidBuildExtensionsConfiguration(); + } + + Object.defineProperty(resolved, key, { + value: descriptor.value, + enumerable: true, + configurable: true, + writable: true, + }); + } + return resolved; +} + /** Fail closed without reflecting caller-controlled image configuration. */ function invalidImageConfiguration(): never { throw new RangeError('Image configuration is invalid.'); @@ -132,8 +207,9 @@ function validateImageQuality(value: unknown): void { export function buildExtensions( options: BuildExtensionsOptions = {}, ): Extensions { - const image = resolveRuntimeImageConfiguration(options.image); - const historyConfiguration = options.disableHistory + const resolvedOptions = resolveRuntimeBuildExtensionsOptions(options); + const image = resolveRuntimeImageConfiguration(resolvedOptions.image); + const historyConfiguration = resolvedOptions.disableHistory ? { history: false as const } : {}; @@ -153,11 +229,11 @@ export function buildExtensions( HTMLAttributes: { rel: 'noopener noreferrer nofollow' }, }), SafeClipboard.configure({ - config: options.clipboard, - onError: options.onClipboardError, + config: resolvedOptions.clipboard, + onError: resolvedOptions.onClipboardError, }), Placeholder.configure({ - placeholder: options.placeholder ?? 'Start writing…', + placeholder: resolvedOptions.placeholder ?? 'Start writing…', }), Table.configure({ resizable: true }), TableRow, @@ -167,8 +243,8 @@ export function buildExtensions( maxSizeBytes: image.maxSizeBytes ?? 10 * 1024 * 1024, maxDimension: image.maxDimension ?? 1600, quality: image.quality ?? 0.85, - onError: options.onImageError, + onError: resolvedOptions.onImageError, }), - ...(options.additionalExtensions ?? []), + ...(resolvedOptions.additionalExtensions ?? []), ]; } From 013958b871b77c2e54a6105239ec92861efa2fad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 04:58:39 +0900 Subject: [PATCH 11/16] fix(ci): remove unreachable build-options branch --- src/extensions/kit.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/extensions/kit.ts b/src/extensions/kit.ts index b0a88cad..70f95899 100644 --- a/src/extensions/kit.ts +++ b/src/extensions/kit.ts @@ -63,9 +63,6 @@ function invalidBuildExtensionsConfiguration(): never { function resolveRuntimeBuildExtensionsOptions( value: unknown, ): BuildExtensionsOptions { - if (value === undefined) { - return {}; - } if (typeof value !== 'object' || value === null || Array.isArray(value)) { invalidBuildExtensionsConfiguration(); } From fbc04052fdc40f77e2d12f2a52a0969fc6b6297c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 05:05:57 +0900 Subject: [PATCH 12/16] test(reliability): cover revoked extension config proxies --- .../buildExtensionsRuntimeBoundary.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/extensions/buildExtensionsRuntimeBoundary.test.ts b/src/extensions/buildExtensionsRuntimeBoundary.test.ts index 5ed33948..275d975e 100644 --- a/src/extensions/buildExtensionsRuntimeBoundary.test.ts +++ b/src/extensions/buildExtensionsRuntimeBoundary.test.ts @@ -18,6 +18,22 @@ describe('buildExtensions runtime configuration boundary', () => { }, ); + it('redacts revoked top-level proxy shape failures', () => { + const { proxy: options, revoke } = Proxy.revocable({}, {}); + revoke(); + + expectInvalidBuildExtensionsOptions(options); + }); + + it('redacts revoked image proxy shape failures', () => { + const { proxy: image, revoke } = Proxy.revocable({}, {}); + revoke(); + + expect(() => buildExtensions({ image })).toThrowError( + new RangeError('Image configuration is invalid.'), + ); + }); + it('rejects accessor-backed options without evaluating the accessor', () => { let reads = 0; const options = {} as BuildExtensionsOptions; From f6830e431d4907efff3b6d1ee1b60592aafd0301 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 05:12:27 +0900 Subject: [PATCH 13/16] fix(reliability): redact revoked extension config proxies --- src/extensions/kit.ts | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/src/extensions/kit.ts b/src/extensions/kit.ts index 70f95899..8577f287 100644 --- a/src/extensions/kit.ts +++ b/src/extensions/kit.ts @@ -52,6 +52,15 @@ function invalidBuildExtensionsConfiguration(): never { throw new RangeError('Build extensions configuration is invalid.'); } +/** Classify top-level option arrays without leaking hostile proxy failures. */ +function isBuildExtensionsOptionsArray(value: object): boolean { + try { + return Array.isArray(value); + } catch { + invalidBuildExtensionsConfiguration(); + } +} + /** * Copy only exact own data properties from the public runtime options object. * @@ -63,7 +72,11 @@ function invalidBuildExtensionsConfiguration(): never { function resolveRuntimeBuildExtensionsOptions( value: unknown, ): BuildExtensionsOptions { - if (typeof value !== 'object' || value === null || Array.isArray(value)) { + if ( + typeof value !== 'object' || + value === null || + isBuildExtensionsOptionsArray(value) + ) { invalidBuildExtensionsConfiguration(); } @@ -114,6 +127,15 @@ function invalidImageConfiguration(): never { throw new RangeError('Image configuration is invalid.'); } +/** Classify image configuration arrays without leaking hostile proxy failures. */ +function isImageConfigurationArray(value: object): boolean { + try { + return Array.isArray(value); + } catch { + invalidImageConfiguration(); + } +} + /** Reject unknown own keys without evaluating any configuration property. */ function validateImageConfigurationKeys(image: object): void { let keys: PropertyKey[]; @@ -160,7 +182,11 @@ function resolveRuntimeImageConfiguration(value: unknown): ImageConfig { if (value === undefined) { return {}; } - if (typeof value !== 'object' || value === null || Array.isArray(value)) { + if ( + typeof value !== 'object' || + value === null || + isImageConfigurationArray(value) + ) { invalidImageConfiguration(); } From 4f251108529a261e19953821a09461c1d38e89f0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:26:47 +0900 Subject: [PATCH 14/16] test(ci): cover event-specific Python matrix Signed-off-by: Seongho Bae --- office/tests/test_python_support_contract.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/office/tests/test_python_support_contract.py b/office/tests/test_python_support_contract.py index 7104fd66..209f4845 100644 --- a/office/tests/test_python_support_contract.py +++ b/office/tests/test_python_support_contract.py @@ -50,10 +50,14 @@ def test_python_support_range_matches_classifiers_and_ci_matrix() -> None: office_job = _workflow_job_block(workflow, "office") assert "runs-on: ubuntu-24.04" in office_job assert "runs-on: ubuntu-latest" not in office_job - matrix_match = re.search(r'python-version:\s*\[([^\]]+)\]', office_job) + matrix_match = re.search(r"python-version:\s*(.+)", office_job) assert matrix_match is not None - matrix_versions = tuple(re.findall(r'"(3\.\d+)"', matrix_match.group(1))) - assert matrix_versions == SUPPORTED_PYTHON_VERSIONS + pull_request_versions, push_versions = ( + tuple(re.findall(r'"(3\.\d+)"', versions)) + for versions in re.findall(r"fromJSON\('(\[[^']+\])'\)", matrix_match.group(1)) + ) + assert pull_request_versions == (SUPPORTED_PYTHON_VERSIONS[-1],) + assert push_versions == SUPPORTED_PYTHON_VERSIONS def test_python_support_documentation_matches_the_fixed_ci_environment() -> None: From 83eca563a19052bea56ff3a306bd29b408371dc3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:32:54 +0900 Subject: [PATCH 15/16] test(ci): bind Python matrix to event Signed-off-by: Seongho Bae --- office/tests/test_python_support_contract.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/office/tests/test_python_support_contract.py b/office/tests/test_python_support_contract.py index 209f4845..a52ddec3 100644 --- a/office/tests/test_python_support_contract.py +++ b/office/tests/test_python_support_contract.py @@ -50,11 +50,16 @@ def test_python_support_range_matches_classifiers_and_ci_matrix() -> None: office_job = _workflow_job_block(workflow, "office") assert "runs-on: ubuntu-24.04" in office_job assert "runs-on: ubuntu-latest" not in office_job - matrix_match = re.search(r"python-version:\s*(.+)", office_job) + matrix_match = re.search( + r"python-version:\s*\$\{\{\s*github\.event_name\s*==\s*'pull_request'" + r"\s*&&\s*fromJSON\('(\[[^']+\])'\)\s*\|\|\s*" + r"fromJSON\('(\[[^']+\])'\)\s*\}\}", + office_job, + ) assert matrix_match is not None pull_request_versions, push_versions = ( tuple(re.findall(r'"(3\.\d+)"', versions)) - for versions in re.findall(r"fromJSON\('(\[[^']+\])'\)", matrix_match.group(1)) + for versions in matrix_match.groups() ) assert pull_request_versions == (SUPPORTED_PYTHON_VERSIONS[-1],) assert push_versions == SUPPORTED_PYTHON_VERSIONS From 95763588317f8ef69058b35b830f909df648fa88 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 06:26:14 +0900 Subject: [PATCH 16/16] revert(ci): restore Office contract owner Remove the duplicated Python support contract changes from this image-config branch. PR #405 remains the single writer while this branch keeps its runtime validation delta. Signed-off-by: Seongho Bae Commit-Message-Assisted-by: Claude (via Claude Code) --- office/tests/test_python_support_contract.py | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/office/tests/test_python_support_contract.py b/office/tests/test_python_support_contract.py index a52ddec3..7104fd66 100644 --- a/office/tests/test_python_support_contract.py +++ b/office/tests/test_python_support_contract.py @@ -50,19 +50,10 @@ def test_python_support_range_matches_classifiers_and_ci_matrix() -> None: office_job = _workflow_job_block(workflow, "office") assert "runs-on: ubuntu-24.04" in office_job assert "runs-on: ubuntu-latest" not in office_job - matrix_match = re.search( - r"python-version:\s*\$\{\{\s*github\.event_name\s*==\s*'pull_request'" - r"\s*&&\s*fromJSON\('(\[[^']+\])'\)\s*\|\|\s*" - r"fromJSON\('(\[[^']+\])'\)\s*\}\}", - office_job, - ) + matrix_match = re.search(r'python-version:\s*\[([^\]]+)\]', office_job) assert matrix_match is not None - pull_request_versions, push_versions = ( - tuple(re.findall(r'"(3\.\d+)"', versions)) - for versions in matrix_match.groups() - ) - assert pull_request_versions == (SUPPORTED_PYTHON_VERSIONS[-1],) - assert push_versions == SUPPORTED_PYTHON_VERSIONS + matrix_versions = tuple(re.findall(r'"(3\.\d+)"', matrix_match.group(1))) + assert matrix_versions == SUPPORTED_PYTHON_VERSIONS def test_python_support_documentation_matches_the_fixed_ci_environment() -> None: