Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-policy-errors.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions docs/concepts/trust-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ The gate is opt-in today. Without an effective `intent.skills` declaration, disc

Default `intent install` handles this state through interactive permission setup.

### Invalid policy files

Intent stops policy-controlled listing, loading, and installation when a policy `package.json` cannot be read, contains invalid JSON, or is not a JSON object. The error names the file. Repair or restore that file, then retry the command.

This also applies to inherited policy within a resolved workspace. Malformed JSON that prevents discovery from identifying the workspace root itself remains a [known limitation](https://github.com/TanStack/intent/issues/240).

## First-run permission review

When no effective policy exists, `intent install` follows this flow:
Expand Down
39 changes: 34 additions & 5 deletions packages/intent/src/core/package-json.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,41 @@
import { readFileSync } from 'node:fs'
import { lstatSync, readFileSync } from 'node:fs'
import { join } from 'node:path'

/**
* Reads a project policy manifest, returning null only when the file is absent.
* Unreadable or invalid manifests throw so failures cannot remove restrictions.
*/
export function readPackageJson(dir: string): Record<string, unknown> | null {
const filePath = join(dir, 'package.json')
let content: string
try {
return JSON.parse(
readFileSync(join(dir, 'package.json'), 'utf8'),
) as Record<string, unknown>
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<string, unknown>
}
9 changes: 6 additions & 3 deletions packages/intent/tests/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -613,7 +613,7 @@ describe('cli commands', () => {
expect(readFileSync(agentsPath, 'utf8')).toBe(guidance)
})

it('reports package validation failure as a permission failure without writes', async () => {
it('rejects invalid policy before permission setup without writes', async () => {
const root = mkdtempSync(
join(realTmpdir, 'intent-cli-install-invalid-package-'),
)
Expand All @@ -637,8 +637,11 @@ describe('cli commands', () => {
const errors = errorSpy.mock.calls.flat().join('\n')

expect(exitCode).toBe(1)
expect(errors).toContain('Permissions: failed:')
expect(errors).toContain('invalid JSONC')
expect(errors).toContain(
`Failed to parse Intent policy from ${packageJsonPath}: invalid JSON.`,
)
expect(prompts.confirmAllowAll).not.toHaveBeenCalled()
expect(logSpy).not.toHaveBeenCalled()
expect(readFileSync(packageJsonPath, 'utf8')).toBe(packageJson)
expect(existsSync(join(root, 'AGENTS.md'))).toBe(false)
})
Expand Down
96 changes: 96 additions & 0 deletions packages/intent/tests/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
32 changes: 32 additions & 0 deletions packages/intent/tests/integration/source-policy-surfaces.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
existsSync,
mkdirSync,
mkdtempSync,
realpathSync,
Expand Down Expand Up @@ -76,6 +77,37 @@ 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'],
['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()

Expand Down
Loading