From 0956a688cb6a003a663ba44aa9f8d85d62ffcd59 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Fri, 4 Sep 2026 18:25:47 -0700 Subject: [PATCH] fix: reject unreadable and malformed project policy manifests --- .changeset/quiet-policy-errors.md | 5 + docs/concepts/trust-model.md | 2 + packages/intent/src/core/package-json.ts | 35 ++++++- packages/intent/tests/core.test.ts | 96 +++++++++++++++++++ .../source-policy-surfaces.test.ts | 31 ++++++ 5 files changed, 164 insertions(+), 5 deletions(-) create mode 100644 .changeset/quiet-policy-errors.md diff --git a/.changeset/quiet-policy-errors.md b/.changeset/quiet-policy-errors.md new file mode 100644 index 00000000..50ca2322 --- /dev/null +++ b/.changeset/quiet-policy-errors.md @@ -0,0 +1,5 @@ +--- +'@tanstack/intent': patch +--- + +Stop policy-controlled skill listing and loading when a project policy manifest is unreadable, malformed, or not a JSON object. Report the manifest path instead of treating failed reads as missing policy and exposing skills. Preserve migration behavior for genuinely missing manifests. diff --git a/docs/concepts/trust-model.md b/docs/concepts/trust-model.md index 38cd69c2..6c0f98be 100644 --- a/docs/concepts/trust-model.md +++ b/docs/concepts/trust-model.md @@ -13,6 +13,8 @@ A package ships skills in a `skills/` directory. Discovery finds every installed The gate is opt-in today. A project with no `intent.skills` key still surfaces every discovered package, and Intent prints a deprecation notice to stderr on each run until you set `intent.skills`. A future version will require an explicit allowlist. See the [special forms](./configuration#special-forms) in Configuration. +A missing policy manifest is distinct from an invalid one. If a `package.json` used for project policy cannot be read, contains invalid JSON, or is not a JSON object, Intent stops policy-controlled listing and loading with an error naming the file. This includes inherited policy within a resolved workspace. If malformed JSON prevents workspace discovery from identifying the root itself, that remains a [known limitation](https://github.com/TanStack/intent/issues/240). + Trust does not propagate. A listed package may depend on another package that ships skills, but that dependency stays unlisted unless another entry matches it. A bare entry such as `foo` permits an npm source, while `workspace:foo` permits a workspace source. Their wildcard forms remain kind-specific. The exact `*` entry permits every discovered npm and workspace source. ## Static discovery diff --git a/packages/intent/src/core/package-json.ts b/packages/intent/src/core/package-json.ts index 80fa1052..ea50307f 100644 --- a/packages/intent/src/core/package-json.ts +++ b/packages/intent/src/core/package-json.ts @@ -1,12 +1,37 @@ -import { readFileSync } from 'node:fs' +import { lstatSync, readFileSync } from 'node:fs' import { join } from 'node:path' export function readPackageJson(dir: string): Record | null { + const filePath = join(dir, 'package.json') + let content: string try { - return JSON.parse( - readFileSync(join(dir, 'package.json'), 'utf8'), - ) as Record + content = readFileSync(filePath, 'utf8') + } catch (err) { + if ( + (err as NodeJS.ErrnoException).code === 'ENOENT' && + !lstatSync(filePath, { throwIfNoEntry: false }) + ) { + return null + } + throw new Error( + `Failed to read Intent policy from ${filePath}: ${err instanceof Error ? err.message : String(err)}`, + ) + } + + let parsed: unknown + try { + parsed = JSON.parse(content) } catch { - return null + throw new Error( + `Failed to parse Intent policy from ${filePath}: invalid JSON.`, + ) } + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error( + `Invalid Intent policy manifest ${filePath}: expected a JSON object.`, + ) + } + + return parsed as Record } diff --git a/packages/intent/tests/core.test.ts b/packages/intent/tests/core.test.ts index 0989b1fb..23b43c65 100644 --- a/packages/intent/tests/core.test.ts +++ b/packages/intent/tests/core.test.ts @@ -92,6 +92,102 @@ afterEach(() => { }) describe('listIntentSkills', () => { + it('preserves migration mode when the project manifest is missing', () => { + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + + expect( + listIntentSkills({ cwd: root }).skills.map((skill) => skill.use), + ).toEqual(['@tanstack/query#fetching']) + expect( + loadIntentSkill('@tanstack/query#fetching', { cwd: root }).content, + ).toContain('Skill content here.') + }) + + it('rejects malformed project policy instead of enabling migration mode', () => { + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + const packageJsonPath = join(root, 'package.json') + writeFileSync(packageJsonPath, '{"intent":{"skills":[]},') + + expect(() => listIntentSkills({ cwd: root })).toThrow(packageJsonPath) + expect(() => + loadIntentSkill('@tanstack/query#fetching', { cwd: root }), + ).toThrow(packageJsonPath) + }) + + it.each([null, [], 'invalid', 42, false])( + 'rejects a non-object policy manifest: %j', + (manifest) => { + writeJson(join(root, 'package.json'), manifest) + + expect(() => listIntentSkills({ cwd: root })).toThrow( + 'expected a JSON object', + ) + expect(() => + loadIntentSkill('@tanstack/query#fetching', { cwd: root }), + ).toThrow('expected a JSON object') + }, + ) + + it('rejects an unreadable policy manifest', () => { + const packageJsonPath = join(root, 'package.json') + mkdirSync(packageJsonPath) + + expect(() => listIntentSkills({ cwd: root })).toThrow( + `Failed to read Intent policy from ${packageJsonPath}`, + ) + expect(() => + loadIntentSkill('@tanstack/query#fetching', { cwd: root }), + ).toThrow(`Failed to read Intent policy from ${packageJsonPath}`) + }) + + it('rejects a dangling policy symlink instead of treating it as missing', () => { + const packageJsonPath = join(root, 'package.json') + symlinkSync(join(root, 'missing.json'), packageJsonPath) + + expect(() => listIntentSkills({ cwd: root })).toThrow(packageJsonPath) + expect(() => + loadIntentSkill('@tanstack/query#fetching', { cwd: root }), + ).toThrow(packageJsonPath) + }) + + it('rejects malformed inherited policy even when the child permits the skill', () => { + const appDir = join(root, 'packages', 'app') + const packageJsonPath = join(root, 'package.json') + writeFileSync( + join(root, 'pnpm-workspace.yaml'), + 'packages:\n - packages/*\n', + ) + writeFileSync( + packageJsonPath, + '{"workspaces":["packages/*"],"intent":{"exclude":["@tanstack/query"]},', + ) + writeJson(join(appDir, 'package.json'), { + name: 'app', + intent: { skills: ['@tanstack/query'] }, + }) + writeInstalledIntentPackage(appDir, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + + expect(() => listIntentSkills({ cwd: appDir })).toThrow(packageJsonPath) + expect(() => + loadIntentSkill('@tanstack/query#fetching', { cwd: appDir }), + ).toThrow(packageJsonPath) + }) + it('returns a flat skill list and package summaries', () => { writeJson(join(root, 'package.json'), { name: 'test-app', diff --git a/packages/intent/tests/integration/source-policy-surfaces.test.ts b/packages/intent/tests/integration/source-policy-surfaces.test.ts index 97ed3acf..4b07be0b 100644 --- a/packages/intent/tests/integration/source-policy-surfaces.test.ts +++ b/packages/intent/tests/integration/source-policy-surfaces.test.ts @@ -1,4 +1,5 @@ import { + existsSync, mkdirSync, mkdtempSync, realpathSync, @@ -76,6 +77,36 @@ describe('source policy — all four surfaces filter excluded and unlisted', () writeIntentPackage(root, EXCLUDED, 'core') } + it.each([ + ['list', '--json'], + ['load', `${LISTED}#core`], + ['load', `${LISTED}#core`, '--json'], + ['load', `${LISTED}#core`, '--path'], + ['install', '--map'], + ])( + 'fails without delivering skills for malformed policy: %j', + async (...args) => { + writeStandaloneFixture() + const packageJsonPath = join(root, 'package.json') + writeFileSync(packageJsonPath, '{"intent":{"skills":[]},') + process.env.INTENT_GLOBAL_NODE_MODULES = join(root, 'empty-global') + process.chdir(root) + const stdoutSpy = vi + .spyOn(process.stdout, 'write') + .mockImplementation(() => true) + + const exitCode = await main(args) + + expect(exitCode).toBe(1) + expect(logSpy).not.toHaveBeenCalled() + expect(stdoutSpy).not.toHaveBeenCalled() + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining(packageJsonPath), + ) + expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) + }, + ) + it('list surfaces only the listed package', () => { writeStandaloneFixture()