From 724b45b8d824be9581f0f29cc6eb07e6aeceb70f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 19:59:00 +0900 Subject: [PATCH 01/18] fix(security): raise transitive dependency floors --- CHANGELOG.md | 4 ++++ pnpm-lock.yaml | 21 ++++++++++--------- pnpm-workspace.yaml | 10 +++++---- ...revision-evidence-consumer-config.test.mjs | 5 ++++- ...amework-free-revision-evidence-package.mjs | 4 ++-- scripts/verify-revision-evidence-package.mjs | 4 ++-- ...verify-framework-free-autosave-package.mjs | 4 ++-- 7 files changed, 31 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5d1d3dc..9baec473 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ Historical release entries from **0.1.0 through 0.5.27** are preserved verbatim ## [Unreleased] +### Security +- Raised workspace-wide transitive development-tool overrides for `fast-uri`, `nanoid`, and `postcss` to patched minimums, keeping the lockfile audit clean without changing runtime package authority. +- Normalized isolated package-verifier temporary roots before containment checks on macOS. + ## [0.6.0] — 2026-08-10 ### Release diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a3d3e0e..109c6f98 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,8 +7,9 @@ settings: overrides: vite: ^6.4.3 esbuild: ^0.25.0 - fast-uri: ^3.1.4 - postcss: ^8.5.18 + fast-uri: ^3.1.5 + nanoid: ^3.3.18 + postcss: ^8.5.23 brace-expansion: ^5.0.8 minimatch: ^10.0.0 @@ -1245,8 +1246,8 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-uri@3.1.4: - resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==} + fast-uri@3.1.5: + resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} @@ -1515,8 +1516,8 @@ packages: muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} - nanoid@3.3.16: - resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -2892,7 +2893,7 @@ snapshots: ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.4 + fast-uri: 3.1.5 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -3109,7 +3110,7 @@ snapshots: fast-deep-equal@3.1.3: {} - fast-uri@3.1.4: {} + fast-uri@3.1.5: {} fdir@6.5.0(picomatch@4.0.5): optionalDependencies: @@ -3391,7 +3392,7 @@ snapshots: muggle-string@0.4.1: {} - nanoid@3.3.16: {} + nanoid@3.3.18: {} node-releases@2.0.51: {} @@ -3438,7 +3439,7 @@ snapshots: postcss@8.5.25: dependencies: - nanoid: 3.3.16 + nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b4cb3f1b..8aadfa69 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -12,8 +12,9 @@ allowBuilds: # - vite<6.4.3: GHSA-fx2h-pf6j-xcff / GHSA-4w7w-66w2-5vf9 / GHSA-v6wh-96g9-6wx3 # (vitest's @vitest/mocker still resolves a transitive vite@5) # - esbuild<0.25: GHSA-67mh-4wv8-2f99 (dragged in by the transitive vite@5) -# - fast-uri<3.1.4: GHSA-v2hh-gcrm-f6hx / CVE-2026-16221 (via ajv in vite-plugin-dts) -# - postcss<=8.5.17: GHSA-r28c-9q8g-f849 (source-map path traversal via vite) +# - fast-uri<3.1.5: GHSA-v2hh-gcrm-f6hx / GHSA-7p8r-x3mc-p8w7 (via ajv in vite-plugin-dts) +# - nanoid<3.3.18: GHSA-2v37-7h3g-55p8 (via postcss) +# - postcss<=8.5.22: GHSA-r28c-9q8g-f849 / GHSA-fxqj-rqcc-2cmp (source-map path traversal via vite) # - brace-expansion<=5.0.7: GHSA-mh99-v99m-4gvg (unbounded-expansion DoS). The # only patched line is >=5.0.8; minimatch@9 consumes brace-expansion@2.x and # cannot take 5.x, so lift minimatch to ^10 (glob@10 / test-exclude@7 resolve @@ -21,7 +22,8 @@ allowBuilds: overrides: vite: ^6.4.3 esbuild: ^0.25.0 - fast-uri: ^3.1.4 - postcss: ^8.5.18 + fast-uri: ^3.1.5 + nanoid: ^3.3.18 + postcss: ^8.5.23 brace-expansion: ^5.0.8 minimatch: ^10.0.0 diff --git a/scripts/revision-evidence-consumer-config.test.mjs b/scripts/revision-evidence-consumer-config.test.mjs index ccc0cfac..17f31546 100644 --- a/scripts/revision-evidence-consumer-config.test.mjs +++ b/scripts/revision-evidence-consumer-config.test.mjs @@ -88,7 +88,10 @@ test('copies the locked pnpm dependency tree into the independent consumer', () true, ); const stagedPackage = realpathSync(join(targetNodeModules, 'example')); - assert.equal(relative(targetNodeModules, stagedPackage).startsWith('..'), false); + assert.equal( + relative(realpathSync(targetNodeModules), stagedPackage).startsWith('..'), + false, + ); } finally { rmSync(root, { recursive: true, force: true }); } diff --git a/scripts/verify-framework-free-revision-evidence-package.mjs b/scripts/verify-framework-free-revision-evidence-package.mjs index 118cadb0..866b670a 100644 --- a/scripts/verify-framework-free-revision-evidence-package.mjs +++ b/scripts/verify-framework-free-revision-evidence-package.mjs @@ -19,8 +19,8 @@ const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const packageJson = JSON.parse( readFileSync(join(repositoryRoot, 'package.json'), 'utf8'), ); -const verificationRoot = mkdtempSync( - join(tmpdir(), 'inkspan-framework-free-evidence-'), +const verificationRoot = realpathSync( + mkdtempSync(join(tmpdir(), 'inkspan-framework-free-evidence-')), ); const extractionDirectory = join(verificationRoot, 'extracted'); const consumerDirectory = join(verificationRoot, 'consumer'); diff --git a/scripts/verify-revision-evidence-package.mjs b/scripts/verify-revision-evidence-package.mjs index c0a5f0e3..0b96d4bd 100644 --- a/scripts/verify-revision-evidence-package.mjs +++ b/scripts/verify-revision-evidence-package.mjs @@ -24,8 +24,8 @@ const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const packageJson = JSON.parse( readFileSync(join(repositoryRoot, 'package.json'), 'utf8'), ); -const verificationDirectory = mkdtempSync( - join(tmpdir(), 'inkspan-revision-evidence-'), +const verificationDirectory = realpathSync( + mkdtempSync(join(tmpdir(), 'inkspan-revision-evidence-')), ); const externalDependencyNames = Object.freeze([ ...new Set([ diff --git a/tests/package/verify-framework-free-autosave-package.mjs b/tests/package/verify-framework-free-autosave-package.mjs index 807dcc05..644b6bd5 100644 --- a/tests/package/verify-framework-free-autosave-package.mjs +++ b/tests/package/verify-framework-free-autosave-package.mjs @@ -23,8 +23,8 @@ const repositoryRoot = resolve( const packageJson = JSON.parse( readFileSync(join(repositoryRoot, 'package.json'), 'utf8'), ); -const verificationRoot = mkdtempSync( - join(tmpdir(), 'inkspan-framework-free-autosave-'), +const verificationRoot = realpathSync( + mkdtempSync(join(tmpdir(), 'inkspan-framework-free-autosave-')), ); const extractionDirectory = join(verificationRoot, 'extracted'); const consumerDirectory = join(verificationRoot, 'consumer'); From 868b88f58b22ae11ee819593e47946cb44e0b04b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 10:30:43 -0700 Subject: [PATCH 02/18] test(security): define pnpm supply-chain policy --- scripts/pnpm-supply-chain-policy.test.mjs | 24 +++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 scripts/pnpm-supply-chain-policy.test.mjs diff --git a/scripts/pnpm-supply-chain-policy.test.mjs b/scripts/pnpm-supply-chain-policy.test.mjs new file mode 100644 index 00000000..6d04a6b4 --- /dev/null +++ b/scripts/pnpm-supply-chain-policy.test.mjs @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const repositoryRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); +const workspacePolicy = readFileSync( + join(repositoryRoot, 'pnpm-workspace.yaml'), + 'utf8', +); + +function readScalar(name) { + const match = workspacePolicy.match( + new RegExp(`^${name}:\\s*([^#\\n]+?)\\s*$`, 'mu'), + ); + return match?.[1]; +} + +test('pins the pnpm supply-chain policy instead of relying on mutable defaults', () => { + assert.equal(readScalar('blockExoticSubdeps'), 'true'); + assert.equal(readScalar('minimumReleaseAge'), '10080'); + assert.equal(readScalar('trustPolicy'), 'no-downgrade'); +}); From 1e1fd96a431020a088ad0394b40079c4a6953ccd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 10:31:29 -0700 Subject: [PATCH 03/18] test(security): run pnpm supply-chain policy contract --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4e55d924..e40fd6a8 100644 --- a/package.json +++ b/package.json @@ -107,7 +107,7 @@ "test": "vitest run", "test:watch": "vitest", "coverage": "vitest run --coverage", - "test:package-config": "node --test ./scripts/revision-evidence-consumer-config.test.mjs ./scripts/release-metadata.test.mjs ./scripts/javascript-runtime-authority.test.mjs", + "test:package-config": "node --test ./scripts/revision-evidence-consumer-config.test.mjs ./scripts/release-metadata.test.mjs ./scripts/javascript-runtime-authority.test.mjs ./scripts/pnpm-supply-chain-policy.test.mjs", "verify:package": "pnpm run test:package-config && node ./tests/package/verify-package.mjs && node ./tests/package/verify-editor-placeholder-package.mjs && node ./scripts/verify-canonical-envelope-package.mjs && node ./scripts/verify-revision-evidence-package.mjs && node ./scripts/verify-framework-free-revision-evidence-package.mjs && node ./scripts/verify-framework-free-envelope-identity-package.mjs && node ./tests/package/verify-framework-free-autosave-package.mjs && node ./scripts/verify-text-position-selector-package.mjs && node ./scripts/verify-text-position-selector-subpath-package.mjs && node ./scripts/verify-markdown-subpath-package.mjs" }, "peerDependencies": { From b70fb1863c832ba7f05444f6b53ff521f111ca4a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 10:32:49 -0700 Subject: [PATCH 04/18] fix(security): enforce pnpm supply-chain policy --- pnpm-workspace.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8aadfa69..2f39d20b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,6 +6,14 @@ onlyBuiltDependencies: allowBuilds: esbuild: true +# Keep install-time supply-chain policy explicit instead of relying on pnpm's +# mutable defaults. pnpm 11.5.3 supports all three settings: transitive exotic +# sources stay blocked, newly published versions wait seven days, and package +# trust may not silently downgrade relative to earlier releases. +blockExoticSubdeps: true +minimumReleaseAge: 10080 +trustPolicy: no-downgrade + # Force patched transitive dependencies across the whole tree. pnpm 10+ reads # `overrides` from pnpm-workspace.yaml, NOT the deprecated package.json `pnpm` # field, so every security override must live here to actually take effect. From 114315b8ff8604ef10a48e204b8ae9f68cb1b2b3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 10:35:32 -0700 Subject: [PATCH 05/18] test(security): bound legacy trust-policy compatibility --- scripts/pnpm-supply-chain-policy.test.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/pnpm-supply-chain-policy.test.mjs b/scripts/pnpm-supply-chain-policy.test.mjs index 6d04a6b4..7fadce49 100644 --- a/scripts/pnpm-supply-chain-policy.test.mjs +++ b/scripts/pnpm-supply-chain-policy.test.mjs @@ -21,4 +21,5 @@ test('pins the pnpm supply-chain policy instead of relying on mutable defaults', assert.equal(readScalar('blockExoticSubdeps'), 'true'); assert.equal(readScalar('minimumReleaseAge'), '10080'); assert.equal(readScalar('trustPolicy'), 'no-downgrade'); + assert.equal(readScalar('trustPolicyIgnoreAfter'), '43200'); }); From 735d618d874cc6fac45df9f108c5fd01411e20e1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 10:35:58 -0700 Subject: [PATCH 06/18] fix(security): bound legacy trust-policy compatibility --- pnpm-workspace.yaml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2f39d20b..37053afe 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,12 +7,15 @@ allowBuilds: esbuild: true # Keep install-time supply-chain policy explicit instead of relying on pnpm's -# mutable defaults. pnpm 11.5.3 supports all three settings: transitive exotic -# sources stay blocked, newly published versions wait seven days, and package -# trust may not silently downgrade relative to earlier releases. +# mutable defaults. Transitive exotic sources stay blocked, newly published +# versions wait seven days, and trust downgrades fail for packages released in +# the last 30 days. The bounded legacy window keeps the frozen TipTap v2 line +# (published months before trusted-publishing adoption) installable without +# exempting current package names or disabling lockfile verification. blockExoticSubdeps: true minimumReleaseAge: 10080 trustPolicy: no-downgrade +trustPolicyIgnoreAfter: 43200 # Force patched transitive dependencies across the whole tree. pnpm 10+ reads # `overrides` from pnpm-workspace.yaml, NOT the deprecated package.json `pnpm` From 235535dfde3decdd01688b91c34d3b728655c112 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 10:46:19 -0700 Subject: [PATCH 07/18] test(security): move pnpm policy contract into canonical suite --- src/pnpmSupplyChainPolicy.test.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 src/pnpmSupplyChainPolicy.test.ts diff --git a/src/pnpmSupplyChainPolicy.test.ts b/src/pnpmSupplyChainPolicy.test.ts new file mode 100644 index 00000000..9b760216 --- /dev/null +++ b/src/pnpmSupplyChainPolicy.test.ts @@ -0,0 +1,26 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const repositoryRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); +const workspacePolicy = readFileSync( + join(repositoryRoot, 'pnpm-workspace.yaml'), + 'utf8', +); + +function readScalar(name: string): string | undefined { + const match = workspacePolicy.match( + new RegExp(`^${name}:\\s*([^#\\n]+?)\\s*$`, 'mu'), + ); + return match?.[1]; +} + +describe('pnpm supply-chain policy', () => { + it('pins security-sensitive install policy instead of relying on mutable defaults', () => { + expect(readScalar('blockExoticSubdeps')).toBe('true'); + expect(readScalar('minimumReleaseAge')).toBe('10080'); + expect(readScalar('trustPolicy')).toBe('no-downgrade'); + expect(readScalar('trustPolicyIgnoreAfter')).toBe('43200'); + }); +}); From 637a1c5cc37e24770830d4e5cf9d95ec9e1034f3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 10:48:05 -0700 Subject: [PATCH 08/18] fix(ownership): release package manifest writer --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e40fd6a8..4e55d924 100644 --- a/package.json +++ b/package.json @@ -107,7 +107,7 @@ "test": "vitest run", "test:watch": "vitest", "coverage": "vitest run --coverage", - "test:package-config": "node --test ./scripts/revision-evidence-consumer-config.test.mjs ./scripts/release-metadata.test.mjs ./scripts/javascript-runtime-authority.test.mjs ./scripts/pnpm-supply-chain-policy.test.mjs", + "test:package-config": "node --test ./scripts/revision-evidence-consumer-config.test.mjs ./scripts/release-metadata.test.mjs ./scripts/javascript-runtime-authority.test.mjs", "verify:package": "pnpm run test:package-config && node ./tests/package/verify-package.mjs && node ./tests/package/verify-editor-placeholder-package.mjs && node ./scripts/verify-canonical-envelope-package.mjs && node ./scripts/verify-revision-evidence-package.mjs && node ./scripts/verify-framework-free-revision-evidence-package.mjs && node ./scripts/verify-framework-free-envelope-identity-package.mjs && node ./tests/package/verify-framework-free-autosave-package.mjs && node ./scripts/verify-text-position-selector-package.mjs && node ./scripts/verify-text-position-selector-subpath-package.mjs && node ./scripts/verify-markdown-subpath-package.mjs" }, "peerDependencies": { From 244e2055f3e45ea4f464e6b531aa2897c0311cbf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 10:48:21 -0700 Subject: [PATCH 09/18] fix(ownership): keep security contract in canonical suite --- scripts/pnpm-supply-chain-policy.test.mjs | 25 ----------------------- 1 file changed, 25 deletions(-) delete mode 100644 scripts/pnpm-supply-chain-policy.test.mjs diff --git a/scripts/pnpm-supply-chain-policy.test.mjs b/scripts/pnpm-supply-chain-policy.test.mjs deleted file mode 100644 index 7fadce49..00000000 --- a/scripts/pnpm-supply-chain-policy.test.mjs +++ /dev/null @@ -1,25 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import test from 'node:test'; -import { fileURLToPath } from 'node:url'; - -const repositoryRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); -const workspacePolicy = readFileSync( - join(repositoryRoot, 'pnpm-workspace.yaml'), - 'utf8', -); - -function readScalar(name) { - const match = workspacePolicy.match( - new RegExp(`^${name}:\\s*([^#\\n]+?)\\s*$`, 'mu'), - ); - return match?.[1]; -} - -test('pins the pnpm supply-chain policy instead of relying on mutable defaults', () => { - assert.equal(readScalar('blockExoticSubdeps'), 'true'); - assert.equal(readScalar('minimumReleaseAge'), '10080'); - assert.equal(readScalar('trustPolicy'), 'no-downgrade'); - assert.equal(readScalar('trustPolicyIgnoreAfter'), '43200'); -}); From 93fab95b7aedb67e0fb3fbcda8479c4f9c875a2f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 12:41:22 -0700 Subject: [PATCH 10/18] test(security): avoid dynamic regexp in policy parser --- src/pnpmSupplyChainPolicy.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/pnpmSupplyChainPolicy.test.ts b/src/pnpmSupplyChainPolicy.test.ts index 9b760216..d9269211 100644 --- a/src/pnpmSupplyChainPolicy.test.ts +++ b/src/pnpmSupplyChainPolicy.test.ts @@ -8,12 +8,15 @@ const workspacePolicy = readFileSync( join(repositoryRoot, 'pnpm-workspace.yaml'), 'utf8', ); +const workspacePolicyLines = workspacePolicy.split(/\r?\n/u); function readScalar(name: string): string | undefined { - const match = workspacePolicy.match( - new RegExp(`^${name}:\\s*([^#\\n]+?)\\s*$`, 'mu'), + const prefix = `${name}:`; + const line = workspacePolicyLines.find((candidate) => + candidate.startsWith(prefix), ); - return match?.[1]; + if (line === undefined) return undefined; + return line.slice(prefix.length).split('#', 1)[0]?.trim(); } describe('pnpm supply-chain policy', () => { From 905b291d90342a853fd74234298db64606f1c873 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 12:50:38 -0700 Subject: [PATCH 11/18] test(security): reject duplicate workspace policy scalars --- src/pnpmSupplyChainPolicy.test.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/pnpmSupplyChainPolicy.test.ts b/src/pnpmSupplyChainPolicy.test.ts index d9269211..075e4bbb 100644 --- a/src/pnpmSupplyChainPolicy.test.ts +++ b/src/pnpmSupplyChainPolicy.test.ts @@ -10,11 +10,12 @@ const workspacePolicy = readFileSync( ); const workspacePolicyLines = workspacePolicy.split(/\r?\n/u); -function readScalar(name: string): string | undefined { +function readScalar( + name: string, + lines: readonly string[] = workspacePolicyLines, +): string | undefined { const prefix = `${name}:`; - const line = workspacePolicyLines.find((candidate) => - candidate.startsWith(prefix), - ); + const line = lines.find((candidate) => candidate.startsWith(prefix)); if (line === undefined) return undefined; return line.slice(prefix.length).split('#', 1)[0]?.trim(); } @@ -26,4 +27,13 @@ describe('pnpm supply-chain policy', () => { expect(readScalar('trustPolicy')).toBe('no-downgrade'); expect(readScalar('trustPolicyIgnoreAfter')).toBe('43200'); }); + + it('rejects duplicate root policy scalars instead of accepting the first declaration', () => { + expect( + readScalar('trustPolicy', [ + 'trustPolicy: no-downgrade', + 'trustPolicy: always', + ]), + ).toBeUndefined(); + }); }); From 69542010ecfe1734bd8ecfa0fa324fd52a8d5b0b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 12:51:00 -0700 Subject: [PATCH 12/18] fix(security): fail closed on duplicate workspace policy keys --- src/pnpmSupplyChainPolicy.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pnpmSupplyChainPolicy.test.ts b/src/pnpmSupplyChainPolicy.test.ts index 075e4bbb..2c5ceee5 100644 --- a/src/pnpmSupplyChainPolicy.test.ts +++ b/src/pnpmSupplyChainPolicy.test.ts @@ -15,9 +15,9 @@ function readScalar( lines: readonly string[] = workspacePolicyLines, ): string | undefined { const prefix = `${name}:`; - const line = lines.find((candidate) => candidate.startsWith(prefix)); - if (line === undefined) return undefined; - return line.slice(prefix.length).split('#', 1)[0]?.trim(); + const matches = lines.filter((candidate) => candidate.startsWith(prefix)); + if (matches.length !== 1) return undefined; + return matches[0]?.slice(prefix.length).split('#', 1)[0]?.trim(); } describe('pnpm supply-chain policy', () => { From b6664608d0f1de4c075c7bc20ee85d1d0dcad868 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 13:45:15 -0700 Subject: [PATCH 13/18] test(package): reject symlinked consumer resolution escapes --- ...revision-evidence-consumer-config.test.mjs | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/scripts/revision-evidence-consumer-config.test.mjs b/scripts/revision-evidence-consumer-config.test.mjs index 17f31546..f6708edc 100644 --- a/scripts/revision-evidence-consumer-config.test.mjs +++ b/scripts/revision-evidence-consumer-config.test.mjs @@ -3,6 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, + readFileSync, realpathSync, rmSync, symlinkSync, @@ -201,3 +202,62 @@ test('exposes only declared direct dependencies from the staged lockfile tree', rmSync(root, { recursive: true, force: true }); } }); + +test('canonical containment detects a symlinked dependency that escapes the consumer tree', () => { + const root = realpathSync( + mkdtempSync(join(tmpdir(), 'inkspan-symlink-containment-')), + ); + try { + const consumer = join(root, 'consumer'); + const outside = join(root, 'outside'); + const linkedPackage = join(consumer, 'node_modules', 'example'); + mkdirSync(join(consumer, 'node_modules'), { recursive: true }); + mkdirSync(outside, { recursive: true }); + writeFileSync(join(outside, 'package.json'), '{}\n', 'utf8'); + symlinkSync(outside, linkedPackage, 'dir'); + + assert.equal(relative(consumer, linkedPackage).startsWith('..'), false); + assert.equal( + relative(realpathSync(consumer), realpathSync(linkedPackage)).startsWith( + '..', + ), + true, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('generated ESM and CommonJS package checks canonicalize runtime resolution paths', () => { + const verifierSource = readFileSync( + new URL('./verify-revision-evidence-package.mjs', import.meta.url), + 'utf8', + ); + + assert.equal( + verifierSource.includes("import { realpathSync } from 'node:fs';"), + true, + ); + assert.equal( + verifierSource.includes('const consumerDirectory = realpathSync('), + true, + ); + assert.equal( + verifierSource.includes( + "const resolvedEntry = realpathSync(fileURLToPath(import.meta.resolve('", + ), + true, + ); + assert.equal( + verifierSource.includes( + "const { realpathSync } = require('node:fs');", + ), + true, + ); + assert.equal( + verifierSource.includes( + "const resolvedEntry = realpathSync(require.resolve('", + ), + true, + ); +}); From 4a39d8a7c1712cac0d850e76652cb03d9945209c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 13:47:24 -0700 Subject: [PATCH 14/18] fix(package): canonicalize consumer resolution paths --- scripts/verify-revision-evidence-package.mjs | 24 ++++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/scripts/verify-revision-evidence-package.mjs b/scripts/verify-revision-evidence-package.mjs index 0b96d4bd..a8b5b7c8 100644 --- a/scripts/verify-revision-evidence-package.mjs +++ b/scripts/verify-revision-evidence-package.mjs @@ -77,7 +77,12 @@ function readInstalledDependencyVersion(packageName) { /** Assert that a resolved path cannot escape the independent consumer tree. */ function assertPathInsideConsumer(resolvedPath, description) { - const relativePath = relative(verificationDirectory, resolvedPath); + const canonicalConsumerDirectory = realpathSync(verificationDirectory); + const canonicalResolvedPath = realpathSync(resolvedPath); + const relativePath = relative( + canonicalConsumerDirectory, + canonicalResolvedPath, + ); assert.equal( isAbsolute(relativePath), false, @@ -254,13 +259,16 @@ function verifyRevisionEvidenceEsmRuntime(packageDirectory) { writeFileSync( esmPath, `import assert from 'node:assert/strict'; +import { realpathSync } from 'node:fs'; import { isAbsolute, relative, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import * as editor from '${packageJson.name}'; -const consumerDirectory = ${JSON.stringify(verificationDirectory)}; +const consumerDirectory = realpathSync(${JSON.stringify(verificationDirectory)}); +const packedPackageDirectory = realpathSync(${JSON.stringify(packageDirectory)}); function assertInsideConsumer(resolvedPath, description) { - const resolvedRelative = relative(consumerDirectory, resolvedPath); + const canonicalResolvedPath = realpathSync(resolvedPath); + const resolvedRelative = relative(consumerDirectory, canonicalResolvedPath); assert.equal(isAbsolute(resolvedRelative), false, description); assert.equal( resolvedRelative === '..' || resolvedRelative.startsWith('..' + sep), @@ -268,9 +276,9 @@ function assertInsideConsumer(resolvedPath, description) { description, ); } -const resolvedEntry = fileURLToPath(import.meta.resolve('${packageJson.name}')); +const resolvedEntry = realpathSync(fileURLToPath(import.meta.resolve('${packageJson.name}'))); assertInsideConsumer(resolvedEntry, 'packed ESM entry escaped consumer tree'); -const packageRelative = relative(${JSON.stringify(packageDirectory)}, resolvedEntry); +const packageRelative = relative(packedPackageDirectory, resolvedEntry); assert.equal(isAbsolute(packageRelative), false); assert.equal( packageRelative === '..' || packageRelative.startsWith('..' + sep), @@ -324,12 +332,14 @@ function verifyRevisionEvidenceCommonJsRuntime(packageDirectory) { writeFileSync( commonJsPath, `const assert = require('node:assert/strict'); +const { realpathSync } = require('node:fs'); const { isAbsolute, relative, sep } = require('node:path'); const editor = require('${packageJson.name}'); void (async () => { - const resolvedEntry = require.resolve('${packageJson.name}'); - const resolvedRelative = relative(${JSON.stringify(packageDirectory)}, resolvedEntry); + const packedPackageDirectory = realpathSync(${JSON.stringify(packageDirectory)}); + const resolvedEntry = realpathSync(require.resolve('${packageJson.name}')); + const resolvedRelative = relative(packedPackageDirectory, resolvedEntry); assert.equal(isAbsolute(resolvedRelative), false); assert.equal( resolvedRelative === '..' || resolvedRelative.startsWith('..' + sep), From 522200ae2a8e5db85e726f18d5babe85192b047c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 14:16:32 -0700 Subject: [PATCH 15/18] test(package): execute symlink escape validators --- ...revision-evidence-consumer-config.test.mjs | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/scripts/revision-evidence-consumer-config.test.mjs b/scripts/revision-evidence-consumer-config.test.mjs index f6708edc..3e17a016 100644 --- a/scripts/revision-evidence-consumer-config.test.mjs +++ b/scripts/revision-evidence-consumer-config.test.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; import { existsSync, mkdirSync, @@ -261,3 +262,128 @@ test('generated ESM and CommonJS package checks canonicalize runtime resolution true, ); }); + +function extractGeneratedRuntimeSource(verifierSource, functionName) { + const functionStart = verifierSource.indexOf(`function ${functionName}`); + assert.notEqual(functionStart, -1, `missing verifier function ${functionName}`); + const writeStart = verifierSource.indexOf('writeFileSync(', functionStart); + assert.notEqual(writeStart, -1, `missing writeFileSync in ${functionName}`); + const templateStart = verifierSource.indexOf('`', writeStart); + const templateEnd = verifierSource.indexOf("`,\n 'utf8',", templateStart); + assert.notEqual(templateStart, -1, `missing generated source in ${functionName}`); + assert.notEqual(templateEnd, -1, `missing generated source end in ${functionName}`); + return verifierSource.slice(templateStart + 1, templateEnd); +} + +function instantiateGeneratedRuntimeSource( + generatedSource, + { packageName, verificationDirectory, packageDirectory }, +) { + return generatedSource + .replaceAll('${packageJson.name}', packageName) + .replaceAll( + '${JSON.stringify(verificationDirectory)}', + JSON.stringify(verificationDirectory), + ) + .replaceAll( + '${JSON.stringify(packageDirectory)}', + JSON.stringify(packageDirectory), + ); +} + +test('generated ESM and CommonJS validators reject symlink escapes before package execution', () => { + const root = realpathSync( + mkdtempSync(join(tmpdir(), 'inkspan-generated-containment-')), + ); + try { + const consumer = join(root, 'consumer'); + const outside = join(root, 'outside-package'); + const linkedPackage = join(consumer, 'node_modules', 'example'); + const executionMarker = join(root, 'outside-package-executed'); + mkdirSync(join(consumer, 'node_modules'), { recursive: true }); + mkdirSync(outside, { recursive: true }); + writeFileSync( + join(outside, 'package.json'), + `${JSON.stringify({ + name: 'example', + version: '1.0.0', + type: 'module', + exports: { + '.': { + import: './index.mjs', + require: './index.cjs', + }, + }, + })}\n`, + 'utf8', + ); + writeFileSync( + join(outside, 'index.mjs'), + "import { writeFileSync } from 'node:fs';\nwriteFileSync(process.env.INKSPAN_ESCAPE_MARKER, 'esm');\nexport const sentinel = true;\n", + 'utf8', + ); + writeFileSync( + join(outside, 'index.cjs'), + "const { writeFileSync } = require('node:fs');\nwriteFileSync(process.env.INKSPAN_ESCAPE_MARKER, 'cjs');\nmodule.exports = { sentinel: true };\n", + 'utf8', + ); + symlinkSync(outside, linkedPackage, 'dir'); + + const verifierSource = readFileSync( + new URL('./verify-revision-evidence-package.mjs', import.meta.url), + 'utf8', + ); + const cases = [ + { + functionName: 'verifyRevisionEvidenceEsmRuntime', + extension: 'mjs', + expectedError: 'packed ESM entry escaped consumer tree', + }, + { + functionName: 'verifyRevisionEvidenceCommonJsRuntime', + extension: 'cjs', + expectedError: 'packed CommonJS entry escaped consumer tree', + }, + ]; + + for (const { functionName, extension, expectedError } of cases) { + rmSync(executionMarker, { force: true }); + const generatedSource = instantiateGeneratedRuntimeSource( + extractGeneratedRuntimeSource(verifierSource, functionName), + { + packageName: 'example', + verificationDirectory: consumer, + packageDirectory: linkedPackage, + }, + ); + const scriptPath = join(consumer, `generated-containment.${extension}`); + writeFileSync(scriptPath, generatedSource, 'utf8'); + const result = spawnSync( + process.execPath, + ['--preserve-symlinks', scriptPath], + { + cwd: consumer, + encoding: 'utf8', + env: { + ...process.env, + INKSPAN_ESCAPE_MARKER: executionMarker, + }, + }, + ); + + assert.notEqual(result.status, 0, `${extension} validator accepted escape`); + assert.equal( + result.stderr.includes(expectedError), + true, + `${extension} validator failed for the wrong reason: ${result.stderr}`, + ); + assert.equal( + existsSync(executionMarker), + false, + `${extension} validator executed the escaped package before rejection`, + ); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); From 41f978853629c3bed8ef5393f685053b24322490 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 14:18:50 -0700 Subject: [PATCH 16/18] fix(package): validate paths before loading consumers --- scripts/verify-revision-evidence-package.mjs | 22 +++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/scripts/verify-revision-evidence-package.mjs b/scripts/verify-revision-evidence-package.mjs index a8b5b7c8..8fc3322f 100644 --- a/scripts/verify-revision-evidence-package.mjs +++ b/scripts/verify-revision-evidence-package.mjs @@ -262,7 +262,6 @@ function verifyRevisionEvidenceEsmRuntime(packageDirectory) { import { realpathSync } from 'node:fs'; import { isAbsolute, relative, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; -import * as editor from '${packageJson.name}'; const consumerDirectory = realpathSync(${JSON.stringify(verificationDirectory)}); const packedPackageDirectory = realpathSync(${JSON.stringify(packageDirectory)}); @@ -284,6 +283,7 @@ assert.equal( packageRelative === '..' || packageRelative.startsWith('..' + sep), false, ); +const editor = await import('${packageJson.name}'); assert.equal(typeof editor.createDocumentEnvelopeRevisionEvidence, 'function'); assert.equal(typeof editor.createDocumentEnvelopeRevisionEvidenceBytes, 'function'); const sourceEnvelope = { @@ -334,17 +334,29 @@ function verifyRevisionEvidenceCommonJsRuntime(packageDirectory) { `const assert = require('node:assert/strict'); const { realpathSync } = require('node:fs'); const { isAbsolute, relative, sep } = require('node:path'); -const editor = require('${packageJson.name}'); void (async () => { + const consumerDirectory = realpathSync(${JSON.stringify(verificationDirectory)}); const packedPackageDirectory = realpathSync(${JSON.stringify(packageDirectory)}); + function assertInsideConsumer(resolvedPath, description) { + const canonicalResolvedPath = realpathSync(resolvedPath); + const resolvedRelative = relative(consumerDirectory, canonicalResolvedPath); + assert.equal(isAbsolute(resolvedRelative), false, description); + assert.equal( + resolvedRelative === '..' || resolvedRelative.startsWith('..' + sep), + false, + description, + ); + } const resolvedEntry = realpathSync(require.resolve('${packageJson.name}')); - const resolvedRelative = relative(packedPackageDirectory, resolvedEntry); - assert.equal(isAbsolute(resolvedRelative), false); + assertInsideConsumer(resolvedEntry, 'packed CommonJS entry escaped consumer tree'); + const packageRelative = relative(packedPackageDirectory, resolvedEntry); + assert.equal(isAbsolute(packageRelative), false); assert.equal( - resolvedRelative === '..' || resolvedRelative.startsWith('..' + sep), + packageRelative === '..' || packageRelative.startsWith('..' + sep), false, ); + const editor = require('${packageJson.name}'); assert.equal(typeof editor.createDocumentEnvelopeRevisionEvidence, 'function'); assert.equal(typeof editor.createDocumentEnvelopeRevisionEvidenceBytes, 'function'); const sourceEnvelope = { From 3b5446abf68266f5fed98c1b17cbf8889d7faf57 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 20:54:55 -0700 Subject: [PATCH 17/18] test(package): pin validated runtime entry loading --- ...isionEvidenceVerifierResolvedEntry.test.ts | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 src/revisionEvidenceVerifierResolvedEntry.test.ts diff --git a/src/revisionEvidenceVerifierResolvedEntry.test.ts b/src/revisionEvidenceVerifierResolvedEntry.test.ts new file mode 100644 index 00000000..25d60e66 --- /dev/null +++ b/src/revisionEvidenceVerifierResolvedEntry.test.ts @@ -0,0 +1,47 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const verifierSource = readFileSync( + resolve(process.cwd(), 'scripts/verify-revision-evidence-package.mjs'), + 'utf8', +); + +function extractGeneratedRuntimeSource(functionName: string): string { + const functionStart = verifierSource.indexOf(`function ${functionName}`); + expect(functionStart).not.toBe(-1); + + const writeStart = verifierSource.indexOf('writeFileSync(', functionStart); + expect(writeStart).not.toBe(-1); + + const templateStart = verifierSource.indexOf('`', writeStart); + const templateEnd = verifierSource.indexOf("`,\n 'utf8',", templateStart); + expect(templateStart).not.toBe(-1); + expect(templateEnd).not.toBe(-1); + + return verifierSource.slice(templateStart + 1, templateEnd); +} + +describe('packed revision-evidence runtime entry binding', () => { + it('loads the exact canonical entry that passed containment validation', () => { + const esmSource = extractGeneratedRuntimeSource( + 'verifyRevisionEvidenceEsmRuntime', + ); + const commonJsSource = extractGeneratedRuntimeSource( + 'verifyRevisionEvidenceCommonJsRuntime', + ); + + expect(esmSource).toContain( + "import { fileURLToPath, pathToFileURL } from 'node:url';", + ); + expect(esmSource).toContain( + 'const editor = await import(pathToFileURL(resolvedEntry).href);', + ); + expect(esmSource).not.toContain("await import('${packageJson.name}')"); + + expect(commonJsSource).toContain('const editor = require(resolvedEntry);'); + expect(commonJsSource).not.toContain( + "const editor = require('${packageJson.name}')", + ); + }); +}); From c2db417df2c8f041d31ac5880c4898f00692c04e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 20:57:07 -0700 Subject: [PATCH 18/18] fix(package): load validated canonical runtime entries --- scripts/verify-revision-evidence-package.mjs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/verify-revision-evidence-package.mjs b/scripts/verify-revision-evidence-package.mjs index 8fc3322f..3b226cdc 100644 --- a/scripts/verify-revision-evidence-package.mjs +++ b/scripts/verify-revision-evidence-package.mjs @@ -261,7 +261,7 @@ function verifyRevisionEvidenceEsmRuntime(packageDirectory) { `import assert from 'node:assert/strict'; import { realpathSync } from 'node:fs'; import { isAbsolute, relative, sep } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; const consumerDirectory = realpathSync(${JSON.stringify(verificationDirectory)}); const packedPackageDirectory = realpathSync(${JSON.stringify(packageDirectory)}); @@ -283,7 +283,7 @@ assert.equal( packageRelative === '..' || packageRelative.startsWith('..' + sep), false, ); -const editor = await import('${packageJson.name}'); +const editor = await import(pathToFileURL(resolvedEntry).href); assert.equal(typeof editor.createDocumentEnvelopeRevisionEvidence, 'function'); assert.equal(typeof editor.createDocumentEnvelopeRevisionEvidenceBytes, 'function'); const sourceEnvelope = { @@ -356,7 +356,7 @@ void (async () => { packageRelative === '..' || packageRelative.startsWith('..' + sep), false, ); - const editor = require('${packageJson.name}'); + const editor = require(resolvedEntry); assert.equal(typeof editor.createDocumentEnvelopeRevisionEvidence, 'function'); assert.equal(typeof editor.createDocumentEnvelopeRevisionEvidenceBytes, 'function'); const sourceEnvelope = {