Skip to content
Closed
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/verify-maintainer-package.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/intent': minor
---

Add read-only verification of supplied npm package archives against registered skills, package identity, bundled resources, and inline Markdown references. Support structured CI failures without extraction, publishing, or script execution, using a small dependency-free tar parser.
1 change: 1 addition & 0 deletions packages/intent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
],
"dependencies": {
"@clack/prompts": "1.7.0",
"@remix-run/tar-parser": "0.7.1",
"cac": "^7.0.0",
"jsonc-parser": "^3.3.1",
"semver": "^7.8.4",
Expand Down
5 changes: 4 additions & 1 deletion packages/intent/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ function createCli(
'Set up, author, synchronize, and check library skills',
)
.usage(
'maintainer <setup|adopt|add|status|sync|review|check> [name] [options]',
'maintainer <setup|adopt|add|status|sync|review|check|verify-package> [name] [options]',
)
.option(
'--artifacts <directory>',
Expand Down Expand Up @@ -260,6 +260,9 @@ function createCli(
.example('maintainer review --json')
.example('maintainer review --interactive')
.example('maintainer check --base origin/main')
.example(
'maintainer verify-package library.tgz --package packages/client --json',
)
.action(
async (
action: string,
Expand Down
27 changes: 25 additions & 2 deletions packages/intent/src/commands/maintainer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,13 @@ export async function runMaintainerCommand(
sync: ['artifacts'],
review: ['base', 'json', 'record', 'interactive'],
check: ['artifacts', 'base'],
'verify-package': ['artifacts', 'package', 'json'],
}
if (!allowed[action])
fail(
`Unknown maintainer action: ${action}. Expected setup, adopt, add, status, sync, review, or check.`,
`Unknown maintainer action: ${action}. Expected setup, adopt, add, status, sync, review, check, or verify-package.`,
)
if (name !== undefined && action !== 'add')
if (name !== undefined && !['add', 'verify-package'].includes(action))
fail(`maintainer ${action} does not take a skill name.`)
for (const key of Object.keys(options)) {
if (key !== '--' && !allowed[action].includes(key))
Expand Down Expand Up @@ -104,6 +105,28 @@ export async function runMaintainerCommand(
return
}
const project = resolveMaintainerProject(process.cwd(), options.artifacts)
if (action === 'verify-package') {
if (!name)
fail('Pass the package archive: maintainer verify-package <archive.tgz>.')
const { verifyPackageArchive } =
await import('../maintainer/verify-package.js')
const report = await verifyPackageArchive(project, name, options.package)
if (options.json) console.log(JSON.stringify(report, null, 2))
else {
console.log(
`${report.package.name}: ${report.skills.length} registered skill(s), ${report.problems.length} package problem(s).`,
)
for (const problem of report.problems)
console.log(
` ${JSON.stringify(problem.file)}${problem.target ? ` -> ${JSON.stringify(problem.target)}` : ''}: ${problem.message}`,
)
}
if (!report.valid)
fail(
'Package verification failed. Fix the package contents and rebuild the archive.',
)
return
}
if (action === 'adopt') {
let input: unknown
if (options.apply) {
Expand Down
29 changes: 22 additions & 7 deletions packages/intent/src/core/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,11 +197,11 @@ function rewriteMarkdownDestination({
}

function rewriteMarkdownLineDestinations({
context,
line,
rewrite,
}: {
context: MarkdownDestinationRewriteContext
line: string
rewrite: (destination: string) => string
}): string {
if (!line.includes('[')) return line

Expand Down Expand Up @@ -259,10 +259,7 @@ function rewriteMarkdownLineDestinations({
continue
}

const rewritten = rewriteMarkdownDestination({
context,
destination: destination.destination,
})
const rewritten = rewrite(destination.destination)
output +=
line.slice(linkStart, destination.destinationStart) +
rewritten +
Expand Down Expand Up @@ -290,6 +287,24 @@ export function rewriteLoadedSkillMarkdownDestinations({
skillDir: dirname(skillFilePath),
rewrittenDestinations: new Map(),
}
return mapMarkdownDestinations(content, (destination) =>
rewriteMarkdownDestination({ context, destination }),
)
}

export function collectMarkdownDestinations(content: string): Array<string> {
const destinations = new Set<string>()
mapMarkdownDestinations(content, (destination) => {
destinations.add(destination)
return destination
})
return [...destinations]
}

function mapMarkdownDestinations(
content: string,
rewrite: (destination: string) => string,
): string {
let inFence: '`' | '~' | null = null
const parts = content.split(/(\r?\n)/)
let output = ''
Expand All @@ -313,8 +328,8 @@ export function rewriteLoadedSkillMarkdownDestinations({

output +=
rewriteMarkdownLineDestinations({
context,
line,
rewrite,
}) + newline
}

Expand Down
237 changes: 237 additions & 0 deletions packages/intent/src/maintainer/verify-package.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
import { execFileSync } from 'node:child_process'
import { createReadStream, readFileSync, statSync } from 'node:fs'
import { posix, resolve } from 'node:path'
import { Readable } from 'node:stream'
import { parseTar } from '@remix-run/tar-parser'
import { collectMarkdownDestinations } from '../core/markdown.js'
import { parseFrontmatterText } from '../shared/utils.js'
import { isObject, projectPath, skillEntries } from './project.js'
import type { MaintainerProject } from './project.js'

interface PackageProblem {
file: string
target?: string
message: string
}

async function readArchive(archive: string) {
if (statSync(archive).size > 128 * 1024 * 1024)
throw new Error('Compressed archive exceeds the 128 MiB inspection limit.')
const input = createReadStream(archive)
const files = new Set<string>()
const documents = new Map<string, string>()
let expanded = 0
let textBytes = 0
let entries = 0
const stream = (Readable.toWeb(input) as ReadableStream<BufferSource>)
.pipeThrough(new DecompressionStream('gzip'))
.pipeThrough(
new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
expanded += chunk.byteLength
if (expanded > 512 * 1024 * 1024)
throw new Error(
'Expanded archive exceeds the 512 MiB inspection limit.',
)
controller.enqueue(chunk)
},
}),
)
try {
await parseTar(stream, { allowUnknownFormat: false }, (entry) => {
if (++entries > 50_000)
throw new Error('Archive exceeds the 50,000-entry inspection limit.')
const name = entry.name.replace(/^(\.\/)+/, '').replace(/\/$/, '')
if (
!name ||
/[\\:]/.test(name) ||
[...name].some(
(character) =>
character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127,
) ||
name
.split('/')
.some((part) => !part || part === '.' || part === '..') ||
(name !== 'package' && !name.startsWith('package/'))
)
throw new Error(
`Unsafe package archive path: ${JSON.stringify(entry.name)}`,
)
if (!['file', 'directory'].includes(entry.header.type))
throw new Error(
`Unsupported archive entry ${entry.header.type}: ${name}`,
)
if (!Number.isSafeInteger(entry.size) || entry.size < 0)
throw new Error(`Invalid archive entry size: ${name}`)
if (entry.header.type === 'directory') return
const path = name.slice('package/'.length)
if (!path || files.has(path))
throw new Error(`Duplicate or invalid package file: ${name}`)
files.add(path)
if (path !== 'package.json' && !path.endsWith('.md'))
return entry.body.pipeTo(new WritableStream())
textBytes += entry.size
if (entry.size > 4 * 1024 * 1024 || textBytes > 32 * 1024 * 1024)
throw new Error(
'Package text exceeds the 4 MiB file or 32 MiB total inspection limit.',
)
return entry.text().then((content) => {
documents.set(path, content)
})
})
} finally {
input.destroy()
}
return { files, documents }
}

export async function verifyPackageArchive(
project: MaintainerProject,
archive: string,
packageDirectory = '',
) {
if (packageDirectory === '.') packageDirectory = ''
const manifestPath = projectPath(
project.root,
packageDirectory ? `${packageDirectory}/package.json` : 'package.json',
)
const manifest: unknown = JSON.parse(readFileSync(manifestPath, 'utf8'))
if (!isObject(manifest) || typeof manifest.name !== 'string')
throw new Error('The selected package needs a valid package.json name.')
const registered = skillEntries(project).filter(
(entry) =>
(entry.package ?? '') === packageDirectory &&
!['planned', 'retired'].includes(String(entry.status)),
)
const problems: Array<PackageProblem> = []
const skills = registered.map((entry) => entry.path)
if (!skills.length)
problems.push({
file: 'skill_tree.yaml',
message: 'No active skills are registered for the selected package.',
})
const archivePath = resolve(archive)
try {
const { files, documents } = await readArchive(archivePath)
const packedManifest: unknown = JSON.parse(
documents.get('package.json') ?? 'null',
)
if (
!isObject(packedManifest) ||
packedManifest.name !== manifest.name ||
packedManifest.version !== manifest.version
)
problems.push({
file: 'package.json',
message:
'Archive name and version must match the selected source package.',
})
const sourceFiles = skills.length
? execFileSync(
'git',
[
'-c',
'core.fsmonitor=false',
'--literal-pathspecs',
'ls-files',
'--cached',
'--others',
'--exclude-standard',
'-z',
'--',
...skills.map((skill) =>
posix.join(packageDirectory, posix.dirname(skill)),
),
],
{ cwd: project.root, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 },
)
.split('\0')
.filter(Boolean)
: []
const resources = sourceFiles.map((file) =>
packageDirectory ? file.slice(packageDirectory.length + 1) : file,
)
for (const resource of resources) {
if (files.has(resource) || skills.includes(resource)) continue
problems.push({
file: skills.find((skill) =>
resource.startsWith(`${posix.dirname(skill)}/`),
)!,
target: resource,
message: 'Skill-folder resource is missing from the archive.',
})
}
const pending = [
...skills,
...resources.filter((file) => file.endsWith('.md')),
]
const visited = new Set<string>()
for (const entry of registered) {
const content = documents.get(entry.path)
const frontmatter =
content === undefined ? null : parseFrontmatterText(content)
if (!frontmatter || frontmatter.name !== (entry.slug ?? entry.name))
problems.push({
file: entry.path,
message:
'Registered skill is missing or has a different identity in the archive.',
})
}
for (const file of pending) {
if (visited.has(file)) continue
visited.add(file)
const content = documents.get(file)
if (content === undefined) continue
const body = content.replace(/^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/, '')
for (const destination of collectMarkdownDestinations(body)) {
if (
!destination ||
/^[#?]/.test(destination) ||
destination.startsWith('//')
)
continue
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(destination)) continue
const path = decodeURIComponent(destination.split(/[?#]/)[0]!).replace(
/\\([!"#$%&'()*+,\-./:;<=>?@[\]^_`{|}~\\])/g,
'$1',
)
const target = posix.normalize(posix.join(posix.dirname(file), path))
if (
path.startsWith('/') ||
target === '..' ||
target.startsWith('../') ||
target.includes('\\')
) {
problems.push({
file,
target: destination,
message: 'Reference leaves the installed package.',
})
continue
}
if (!files.has(target)) {
problems.push({
file,
target,
message: 'Required reference is missing from the archive.',
})
continue
}
if (target.endsWith('.md')) pending.push(target)
}
}
} catch (error) {
problems.push({
file: archivePath,
message: error instanceof Error ? error.message : String(error),
})
}
return {
schemaVersion: 1,
archive: archivePath,
package: { name: manifest.name, version: manifest.version },
skills,
problems,
valid: problems.length === 0,
}
}
6 changes: 6 additions & 0 deletions packages/intent/src/shared/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,12 @@ export function parseFrontmatter(
): Record<string, unknown> | null {
const content = readFrontmatterRegion(filePath, fs)
if (content === null) return null
return parseFrontmatterText(content)
}

export function parseFrontmatterText(
content: string,
): Record<string, unknown> | null {
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
if (!match?.[1]) return null
try {
Expand Down
Loading
Loading