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/create-followups-tarball-scan.md
Original file line number Diff line number Diff line change
@@ -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`.
12 changes: 9 additions & 3 deletions packages/create-agent-bundle/src/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)}`,
Expand Down
7 changes: 7 additions & 0 deletions packages/create-agent-bundle/templates/cli-tool/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<plugin-name>.js` on their own. To keep the single-file CLI
Comment on lines +50 to +53

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add zod to the routed-CLI migration steps

When a user follows this migration to create any src/cli/** command, the scaffold still has no zod dependency, although routed commands must export the zod-based inputSchema and resultSchema consumed by the generated CLI. Consequently, the documented port cannot typecheck or build once the route imports zod; list zod with the dependencies to install alongside the first route module.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #254.

instead, set `routes: { cli: 'conventional' }` and do not add `src/cli/**`.
- `src/mcp/<server>/**` route modules make `renderRoute` (`route-unit`) and
`invokeMcpTool` (`mcp-in-memory`) meaningful.

Expand Down
16 changes: 15 additions & 1 deletion packages/create-agent-bundle/tests/framework.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>,
Expand Down Expand Up @@ -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'));
Expand Down
51 changes: 51 additions & 0 deletions packages/create-agent-bundle/tests/support/package-tarball.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};
Loading