diff --git a/.changeset/fruity-ghosts-brake.md b/.changeset/fruity-ghosts-brake.md new file mode 100644 index 0000000..84659d4 --- /dev/null +++ b/.changeset/fruity-ghosts-brake.md @@ -0,0 +1,5 @@ +--- +'@tanstack/intent': patch +--- + +Refuse to load a Yarn PnP runtime resolved from within node_modules; only a project root or ancestor .pnp.cjs is trusted. diff --git a/packages/intent/src/discovery/scanner.ts b/packages/intent/src/discovery/scanner.ts index b19a6c2..622113c 100644 --- a/packages/intent/src/discovery/scanner.ts +++ b/packages/intent/src/discovery/scanner.ts @@ -91,6 +91,10 @@ function findPnpFile(start: string): string | null { return null } +export function isPnpRuntimeWithinNodeModules(pnpPath: string): boolean { + return resolve(pnpPath).split(sep).includes('node_modules') +} + function assertLocalNodeModulesSupported(root: string): void { if ( existsSync(join(root, 'deno.json')) && @@ -106,6 +110,12 @@ function loadPnpApi(root: string): LoadedPnp | null { const pnpPath = findPnpFile(root) if (!pnpPath) return null + if (isPnpRuntimeWithinNodeModules(pnpPath)) { + throw new Error( + `Refusing to load a Yarn PnP runtime resolved from within node_modules: ${pnpPath}. Only a project root or ancestor .pnp.cjs is trusted.`, + ) + } + const moduleApi = requireFromHere('node:module') as NodeModuleInternals const originalResolveFilename = moduleApi._resolveFilename // Capture `fs` before setup(). Yarn's `setup()` patches the `fs` module in diff --git a/packages/intent/tests/cli-output.test.ts b/packages/intent/tests/cli-output.test.ts new file mode 100644 index 0000000..e6c79f2 --- /dev/null +++ b/packages/intent/tests/cli-output.test.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + ALLOW_ALL_NOTICE, + printNotices, + printWarnings, +} from '../src/shared/cli-output.js' + +const OTHER_NOTICE = 'intent.skills is empty — no skill sources are permitted.' + +describe('printNotices — ALLOW_ALL_NOTICE is non-suppressible', () => { + let errorSpy: ReturnType + let logSpy: ReturnType + const previousEnv = process.env.INTENT_NO_NOTICES + + beforeEach(() => { + delete process.env.INTENT_NO_NOTICES + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + + afterEach(() => { + errorSpy.mockRestore() + logSpy.mockRestore() + if (previousEnv === undefined) delete process.env.INTENT_NO_NOTICES + else process.env.INTENT_NO_NOTICES = previousEnv + }) + + function stderr(): string { + return errorSpy.mock.calls + .map((call: Array) => call.join(' ')) + .join('\n') + } + + it('prints the permit-all banner even when noNotices is set, and suppresses the rest', () => { + printNotices([OTHER_NOTICE, ALLOW_ALL_NOTICE], { noNotices: true }) + + const output = stderr() + expect(output).toContain(ALLOW_ALL_NOTICE) + expect(output).not.toContain(OTHER_NOTICE) + }) + + it('prints the permit-all banner even when INTENT_NO_NOTICES=1', () => { + process.env.INTENT_NO_NOTICES = '1' + + printNotices([OTHER_NOTICE, ALLOW_ALL_NOTICE]) + + const output = stderr() + expect(output).toContain(ALLOW_ALL_NOTICE) + expect(output).not.toContain(OTHER_NOTICE) + }) + + it('prints every notice when suppression is off', () => { + printNotices([OTHER_NOTICE, ALLOW_ALL_NOTICE]) + + const output = stderr() + expect(output).toContain(ALLOW_ALL_NOTICE) + expect(output).toContain(OTHER_NOTICE) + }) + + it('prints nothing when only non-banner notices are suppressed', () => { + printNotices([OTHER_NOTICE], { noNotices: true }) + + expect(errorSpy).not.toHaveBeenCalled() + }) + + it('prints nothing for an empty notice list', () => { + printNotices([], { noNotices: true }) + + expect(errorSpy).not.toHaveBeenCalled() + }) + + it('routes the banner through the notices (stderr) channel, never the warnings channel', () => { + printNotices([ALLOW_ALL_NOTICE], { noNotices: true }) + printWarnings([]) + + expect(stderr()).toContain(ALLOW_ALL_NOTICE) + const stdout = logSpy.mock.calls + .map((call: Array) => call.join(' ')) + .join('\n') + expect(stdout).not.toContain(ALLOW_ALL_NOTICE) + }) +}) diff --git a/packages/intent/tests/pnp-trust-boundary.test.ts b/packages/intent/tests/pnp-trust-boundary.test.ts new file mode 100644 index 0000000..0824b69 --- /dev/null +++ b/packages/intent/tests/pnp-trust-boundary.test.ts @@ -0,0 +1,40 @@ +import { sep } from 'node:path' +import { describe, expect, it } from 'vitest' +import { isPnpRuntimeWithinNodeModules } from '../src/discovery/scanner.js' + +const abs = (...segments: Array): string => sep + segments.join(sep) + +describe('isPnpRuntimeWithinNodeModules', () => { + it('trusts a project-root .pnp.cjs', () => { + expect(isPnpRuntimeWithinNodeModules(abs('app', '.pnp.cjs'))).toBe(false) + }) + + it('trusts an ancestor .pnp.cjs above the project', () => { + expect(isPnpRuntimeWithinNodeModules(abs('.pnp.cjs'))).toBe(false) + expect( + isPnpRuntimeWithinNodeModules(abs('workspace', 'app', '..', '.pnp.cjs')), + ).toBe(false) + }) + + it('rejects a .pnp.cjs resolved from within node_modules', () => { + expect( + isPnpRuntimeWithinNodeModules( + abs('app', 'node_modules', 'evil-pkg', '.pnp.cjs'), + ), + ).toBe(true) + }) + + it('rejects a nested-node_modules .pnp.cjs', () => { + expect( + isPnpRuntimeWithinNodeModules( + abs('app', 'node_modules', 'a', 'node_modules', 'b', '.pnp.cjs'), + ), + ).toBe(true) + }) + + it('does not match a directory whose name merely contains node_modules', () => { + expect( + isPnpRuntimeWithinNodeModules(abs('app', 'my-node_modules', '.pnp.cjs')), + ).toBe(false) + }) +})