diff --git a/.changeset/create-followups-tarball-scan.md b/.changeset/create-followups-tarball-scan.md new file mode 100644 index 000000000..f7eb75fce --- /dev/null +++ b/.changeset/create-followups-tarball-scan.md @@ -0,0 +1,5 @@ +--- +"create-agent-bundle": patch +--- + +Validate every tar header when probing local framework tarballs, not only entries before `package/package.json`. Document the `src/cli.ts` → `src/cli/**` migration in the cli-tool template README so adopters avoid `AB4801`. diff --git a/packages/create-agent-bundle/src/framework.ts b/packages/create-agent-bundle/src/framework.ts index c4a93e3c4..0d1bd1633 100644 --- a/packages/create-agent-bundle/src/framework.ts +++ b/packages/create-agent-bundle/src/framework.ts @@ -90,6 +90,7 @@ const localTarballPackageName = async (packageSpec: string, baseDirectory: strin const path = resolve(baseDirectory, packageSpec.slice('file:'.length)); try { const archive = await unzip(await readFile(path)); + let packageName: string | undefined; for (let offset = 0; offset + tarBlockSize <= archive.length;) { const header = archive.subarray(offset, offset + tarBlockSize); if (isEndOfArchiveBlock(header)) break; @@ -108,12 +109,17 @@ const localTarballPackageName = async (packageSpec: string, baseDirectory: strin const manifest = JSON.parse(archive.subarray(contentsOffset, contentsOffset + size).toString('utf8')) as { readonly name?: unknown; }; - if (typeof manifest.name === 'string') return manifest.name; - throw new Error('Packed package manifest has no string name.'); + if (typeof manifest.name !== 'string') { + throw new Error('Packed package manifest has no string name.'); + } + packageName = manifest.name; } offset = contentsOffset + Math.ceil(size / tarBlockSize) * tarBlockSize; } - throw new Error('Packed package manifest was not found.'); + if (packageName === undefined) { + throw new Error('Packed package manifest was not found.'); + } + return packageName; } catch (error) { throw new UsageError( `Cannot inspect local package tarball "${packageSpec}": ${error instanceof Error ? error.message : String(error)}`, diff --git a/packages/create-agent-bundle/templates/cli-tool/README.md b/packages/create-agent-bundle/templates/cli-tool/README.md index afcb81149..002fadd21 100644 --- a/packages/create-agent-bundle/templates/cli-tool/README.md +++ b/packages/create-agent-bundle/templates/cli-tool/README.md @@ -45,6 +45,13 @@ Adopt the harness when the project grows a routed surface: - `src/cli/**` command routes make `invokeCli` / `cliJson` (the `cli-dispatch` level) meaningful — argv resolved and run through the routed CLI's own shell. + This template ships the conventional `src/cli.ts` entry (and a matching + `scripts` entry in `agent-bundle.config.ts`). Adding command routes while + that file remains triggers `AB4801`. Before creating `src/cli/**` modules, + remove `src/cli.ts`, drop the `./src/cli.ts` script entry, and port any + behavior into route modules — routed commands compile into + `dist/bin/.js` on their own. To keep the single-file CLI + instead, set `routes: { cli: 'conventional' }` and do not add `src/cli/**`. - `src/mcp//**` route modules make `renderRoute` (`route-unit`) and `invokeMcpTool` (`mcp-in-memory`) meaningful. diff --git a/packages/create-agent-bundle/tests/framework.test.ts b/packages/create-agent-bundle/tests/framework.test.ts index b14bd0ae4..ff2541926 100644 --- a/packages/create-agent-bundle/tests/framework.test.ts +++ b/packages/create-agent-bundle/tests/framework.test.ts @@ -12,7 +12,11 @@ import { validatedRuntimeSpecForFramework, } from '../src/framework.ts'; import { UsageError } from '../src/options.ts'; -import { packageTarball, tamperedPackageTarball } from './support/package-tarball.ts'; +import { + packageTarball, + tamperedPackageTarball, + tamperedTrailingHeaderPackageTarball, +} from './support/package-tarball.ts'; const withTarballDirectory = async ( run: (directory: string) => Promise, @@ -126,6 +130,16 @@ describe('assertLocalFrameworkTarball', () => { }); }); + it('rejects a tarball whose manifest is valid but a later tar header is corrupt', async () => { + await withTarballDirectory(async (directory) => { + const tarball = join(directory, 'agent-bundle-0.0.0.tgz'); + await writeFile(tarball, tamperedTrailingHeaderPackageTarball('agent-bundle')); + await expect(assertLocalFrameworkTarball(`file:${tarball}`, directory)).rejects.toThrow(UsageError); + await expect(assertLocalFrameworkTarball(`file:${tarball}`, directory)) + .rejects.toThrow('Invalid tar header checksum'); + }); + }); + it('resolves a relative file: spec against the base directory, not the process working directory', async () => { await withTarballDirectory(async (directory) => { await writeFile(join(directory, 'agent-bundle-0.0.0.tgz'), packageTarball('agent-bundle')); diff --git a/packages/create-agent-bundle/tests/support/package-tarball.ts b/packages/create-agent-bundle/tests/support/package-tarball.ts index 73d8599cf..d984d151d 100644 --- a/packages/create-agent-bundle/tests/support/package-tarball.ts +++ b/packages/create-agent-bundle/tests/support/package-tarball.ts @@ -52,3 +52,54 @@ export const tamperedPackageTarball = (name: string): Buffer => { writeOctalField(archive, 0o777, tarModeOffset, 8); return gzipSync(archive); }; + +const writeTarEntry = ( + archive: Buffer, + headerOffset: number, + entryName: string, + contents: Buffer, +): number => { + const header = archive.subarray(headerOffset, headerOffset + tarBlockSize); + header.fill(0); + header.write(entryName, 0, 'utf8'); + writeOctalField(header, 0o644, tarModeOffset, 8); + writeOctalField(header, 0, 108, 8); + writeOctalField(header, 0, 116, 8); + writeOctalField(header, contents.length, 124, 12); + writeOctalField(header, 0, 136, 12); + header.write('0', 156, 'ascii'); + header.write('ustar', 257, 'ascii'); + header.write('00', 263, 'ascii'); + applyHeaderChecksum(header); + const contentsOffset = headerOffset + tarBlockSize; + contents.copy(archive, contentsOffset); + return contentsOffset + Math.ceil(contents.length / tarBlockSize) * tarBlockSize; +}; + +/** + * Like `packageTarArchive`, but adds a trailing entry after `package/package.json` + * so callers can tamper with a later header without touching the manifest block. + */ +export const packageTarArchiveWithTrailingEntry = (name: string): Buffer => { + const manifest = Buffer.from(JSON.stringify({ name })); + const trailing = Buffer.from('trailing payload'); + const archive = Buffer.alloc( + tarBlockSize + + Math.ceil(manifest.length / tarBlockSize) * tarBlockSize + + tarBlockSize + + Math.ceil(trailing.length / tarBlockSize) * tarBlockSize + + tarBlockSize * 2, + ); + const manifestEnd = writeTarEntry(archive, 0, 'package/package.json', manifest); + writeTarEntry(archive, manifestEnd, 'package/trailing.txt', trailing); + return archive; +}; + +/** Manifest checksum is valid; a later tar header checksum is not. */ +export const tamperedTrailingHeaderPackageTarball = (name: string): Buffer => { + const archive = packageTarArchiveWithTrailingEntry(name); + const manifestBlocks = Math.ceil(Buffer.from(JSON.stringify({ name })).length / tarBlockSize) * tarBlockSize; + const trailingHeaderOffset = tarBlockSize + manifestBlocks; + writeOctalField(archive, 0o777, trailingHeaderOffset + tarModeOffset, 8); + return gzipSync(archive); +};