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
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ jobs:
id: pack
run: |
npm pack --json > pack-result.json
tarball="$(node --input-type=module -e 'import { readFileSync } from "node:fs"; const [result] = JSON.parse(readFileSync("pack-result.json", "utf8")); if (!result?.filename) throw new Error("npm pack returned no tarball"); process.stdout.write(result.filename);')"
tarball="$(./node_modules/.bin/tsx scripts/resolve-packed-tarball.ts pack-result.json)"
sha256sum "$tarball"
printf 'tarball=%s\n' "$tarball" >> "$GITHUB_OUTPUT"

Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ npm run contracts:check
npm run package:audit
```

The final package audit must inspect the exact tarball allowlist and packed manifest, require `bin.agentcommunity` to remain exactly `dist/cli.js`, scan packed files for likely secrets, install that tarball in a clean temporary project, prove the installed `.bin/agentcommunity` shim resolves to the packed executable, and run that exact shim. CI covers Node 22.14, 24, and 26 on Ubuntu 24.04 and macOS.
The final package audit must normalize and validate both supported `npm pack --json` contracts (npm 11's one-element array and npm 12's single package-keyed object), inspect the exact tarball allowlist and packed manifest, require `bin.agentcommunity` to remain exactly `dist/cli.js`, scan packed files for likely secrets, install that tarball in a clean temporary project, prove the installed `.bin/agentcommunity` shim resolves to the packed executable, and run that exact shim. CI covers Node 22.14, 24, and 26 on Ubuntu 24.04 and macOS.

## Security and release gates

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@agentcommunity/cli",
"version": "0.1.0",
"version": "0.1.1",
"description": "Official command-line client for Agent Community public data and user-claimed authorization",
"type": "module",
"bin": {
Expand Down
30 changes: 30 additions & 0 deletions scripts/__tests__/npm-pack-result.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, expect, test } from "vitest";

import { normalizeNpmPackResult } from "../npm-pack-result.js";

const result = {
name: "@agentcommunity/cli",
version: "0.1.1",
filename: "agentcommunity-cli-0.1.1.tgz",
files: [{ path: "package.json", size: 1200 }],
};

describe("npm pack JSON normalization", () => {
test("accepts the observed npm 11 array and npm 12 package-keyed object", () => {
expect(normalizeNpmPackResult([result], result.name, result.version)).toEqual(result);
expect(normalizeNpmPackResult({ [result.name]: result }, result.name, result.version)).toEqual(result);
});

test.each([
[],
[result, result],
{},
{ [result.name]: result, extra: result },
{ [result.name]: { ...result, name: "wrong" } },
{ [result.name]: { ...result, version: "0.1.0" } },
{ [result.name]: { ...result, filename: "other.tgz" } },
{ [result.name]: { ...result, files: [{ path: "package.json", size: -1 }] } },
])("rejects ambiguous or malformed output: %#", (value) => {
expect(() => normalizeNpmPackResult(value, result.name, result.version)).toThrow("Invalid npm pack JSON output");
});
});
17 changes: 11 additions & 6 deletions scripts/audit-package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@ import { tmpdir } from "node:os";
import { basename, join } from "node:path";
import { spawnSync } from "node:child_process";

interface PackFile { path: string; size: number }
interface PackResult { filename: string; files: Array<PackFile> }
import { normalizeNpmPackResult } from "./npm-pack-result.js";

const expectedFiles = ["LICENSE", "README.md", "SECURITY.md", "dist/cli.js", "package.json"];
const expectedBin = { agentcommunity: "dist/cli.js" };
Expand All @@ -24,6 +23,13 @@ function command(commandName: string, args: Array<string>, cwd: string): string
return result.stdout;
}

function npmCommand(args: Array<string>, cwd: string): string {
const npmExecPath = process.env.npm_execpath;
return npmExecPath === undefined
? command("npm", args, cwd)
: command(process.execPath, [npmExecPath, ...args], cwd);
}

function assertExactBin(packageJson: Record<string, unknown>, source: string): void {
if (JSON.stringify(packageJson.bin) !== JSON.stringify(expectedBin)) {
throw new Error(`${source} package manifest does not retain the exact agentcommunity binary mapping.`);
Expand All @@ -46,9 +52,8 @@ async function main(): Promise<void> {
const destination = await mkdtemp(join(tmpdir(), "agentcommunity-pack-"));
const project = await mkdtemp(join(tmpdir(), "agentcommunity-install-"));
try {
const packed = JSON.parse(command("npm", ["pack", "--json", "--pack-destination", destination], repositoryRoot)) as Array<PackResult>;
const result = packed[0];
if (result === undefined) throw new Error("npm pack returned no tarball.");
const packed = JSON.parse(npmCommand(["pack", "--json", "--pack-destination", destination], repositoryRoot));
const result = normalizeNpmPackResult(packed, packageJson.name, packageJson.version);
const inventory = result.files.map((file) => file.path).sort();
if (JSON.stringify(inventory) !== JSON.stringify(expectedFiles)) throw new Error(`Unexpected package inventory: ${inventory.join(", ")}`);
const tarballPath = join(destination, basename(result.filename));
Expand All @@ -63,7 +68,7 @@ async function main(): Promise<void> {
for (const pattern of secretPatterns) if (pattern.test(content)) throw new Error(`Possible secret in packed file ${path}.`);
}
await writeFile(join(project, "package.json"), '{"name":"agentcommunity-clean-install","private":true,"version":"1.0.0"}\n');
command("npm", ["install", "--ignore-scripts", "--no-audit", "--no-fund", tarballPath], project);
npmCommand(["install", "--ignore-scripts", "--no-audit", "--no-fund", tarballPath], project);
const installedPackageRoot = join(project, "node_modules/@agentcommunity/cli");
const installedManifest = JSON.parse(await readFile(join(installedPackageRoot, "package.json"), "utf8"));
assertExactBin(installedManifest, "Installed");
Expand Down
66 changes: 66 additions & 0 deletions scripts/npm-pack-result.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
export interface NpmPackFile {
path: string;
size: number;
}

export interface NpmPackResult {
name: string;
version: string;
filename: string;
files: Array<NpmPackFile>;
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

function invalidPackOutput(): never {
throw new Error("Invalid npm pack JSON output.");
}

export function normalizeNpmPackResult(
value: unknown,
expectedName: string,
expectedVersion: string,
): NpmPackResult {
let candidate: unknown;
if (Array.isArray(value)) {
if (value.length !== 1) invalidPackOutput();
candidate = value[0];
} else if (isRecord(value)) {
const keys = Object.keys(value);
if (keys.length !== 1 || keys[0] !== expectedName) invalidPackOutput();
candidate = value[expectedName];
} else {
invalidPackOutput();
}

if (!isRecord(candidate)) invalidPackOutput();
const expectedFilename = `agentcommunity-cli-${expectedVersion}.tgz`;
if (
candidate.name !== expectedName
|| candidate.version !== expectedVersion
|| candidate.filename !== expectedFilename
|| !Array.isArray(candidate.files)
) {
invalidPackOutput();
}
const files = candidate.files;
if (files.some(function (file) {
return !isRecord(file)
|| typeof file.path !== "string"
|| file.path.length === 0
|| typeof file.size !== "number"
|| !Number.isSafeInteger(file.size)
|| file.size < 0;
})) {
invalidPackOutput();
}

return {
name: candidate.name,
version: candidate.version,
filename: candidate.filename,
files: files as Array<NpmPackFile>,
};
}
14 changes: 14 additions & 0 deletions scripts/resolve-packed-tarball.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";

import { normalizeNpmPackResult } from "./npm-pack-result.js";

const [packResultPath] = process.argv.slice(2);
if (packResultPath === undefined || process.argv.length !== 3) {
throw new Error("Usage: resolve-packed-tarball <pack-result.json>");
}

const packageJson = JSON.parse(await readFile(join(process.cwd(), "package.json"), "utf8"));
const packResult = JSON.parse(await readFile(packResultPath, "utf8"));
const result = normalizeNpmPackResult(packResult, packageJson.name, packageJson.version);
process.stdout.write(result.filename);
39 changes: 33 additions & 6 deletions src/__tests__/package-boundary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@ import { spawnSync } from "node:child_process";
import { chmod, lstat, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, test } from "vitest";

import { normalizeNpmPackResult } from "../../scripts/npm-pack-result.js";

const root = new URL("../../", import.meta.url);
const rootPath = fileURLToPath(root);

function command(commandName: string, args: Array<string>, cwd: string): string {
const result = spawnSync(commandName, args, { cwd, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
Expand All @@ -24,6 +28,7 @@ describe("package and CI boundaries", () => {
bugs: { url: "https://github.com/agentcommunity/cli/issues" },
});
expect(packageJson.exports).toBeUndefined();
expect(packageJson.version).toMatch(/^\d+\.\d+\.\d+$/);
expect(packageJson.scripts.preinstall).toBeUndefined();
expect(packageJson.scripts.postinstall).toBeUndefined();
});
Expand All @@ -38,6 +43,7 @@ describe("package and CI boundaries", () => {
await mkdir(packed);
await mkdir(project);
const packageJson = JSON.parse(await readFile(new URL("package.json", root), "utf8"));
const packageVersion = packageJson.version as string;
await writeFile(join(source, "package.json"), `${JSON.stringify(packageJson, null, 2)}\n`);
for (const file of ["README.md", "LICENSE", "SECURITY.md"]) {
await writeFile(join(source, file), `${file} fixture\n`);
Expand All @@ -46,12 +52,32 @@ describe("package and CI boundaries", () => {
await writeFile(executable, "#!/usr/bin/env node\nconsole.log('packed-binary-ok');\n");
await chmod(executable, 0o755);

const packResult = JSON.parse(command("npm", ["pack", "--json", "--pack-destination", packed], source)) as Array<{ filename: string }>;
const filename = packResult[0]?.filename;
expect(filename).toBe("agentcommunity-cli-0.1.0.tgz");
const tarball = join(packed, basename(filename as string));
const packedManifest = JSON.parse(command("tar", ["-xOf", tarball, "package/package.json"], source));
expect(packedManifest.bin).toEqual({ agentcommunity: "dist/cli.js" });
const packCommands = [
{ label: "npm 11", commandName: "npm", prefix: [] as Array<string> },
{ label: "npm 12", commandName: "npx", prefix: ["--yes", "--package", "npm@12.0.2", "npm"] },
];
let tarball = "";
for (const packCommand of packCommands) {
const packDestination = join(packed, packCommand.label.replace(" ", "-"));
await mkdir(packDestination);
const rawResult = JSON.parse(command(
packCommand.commandName,
[...packCommand.prefix, "pack", "--json", "--pack-destination", packDestination],
source,
));
const packResult = normalizeNpmPackResult(rawResult, "@agentcommunity/cli", packageVersion);
expect(packResult.filename, packCommand.label).toBe(`agentcommunity-cli-${packageVersion}.tgz`);
const packResultPath = join(packDestination, "pack-result.json");
await writeFile(packResultPath, JSON.stringify(rawResult));
expect(command(
join(rootPath, "node_modules/.bin/tsx"),
[join(rootPath, "scripts/resolve-packed-tarball.ts"), packResultPath],
source,
), packCommand.label).toBe(packResult.filename);
tarball = join(packDestination, basename(packResult.filename));
const packedManifest = JSON.parse(command("tar", ["-xOf", tarball, "package/package.json"], source));
expect(packedManifest.bin, packCommand.label).toEqual({ agentcommunity: "dist/cli.js" });
}

await writeFile(join(project, "package.json"), '{"name":"package-boundary-install","private":true,"version":"1.0.0"}\n');
command("npm", ["install", "--ignore-scripts", "--no-audit", "--no-fund", tarball], project);
Expand Down Expand Up @@ -82,6 +108,7 @@ describe("package and CI boundaries", () => {
expect(release).toContain("id-token: write");
expect(release).toContain("npm run package:audit");
expect(release).toContain("npm pack --json");
expect(release).toContain('tarball="$(./node_modules/.bin/tsx scripts/resolve-packed-tarball.ts pack-result.json)"');
expect(release).toContain('npm publish "${{ steps.pack.outputs.tarball }}" --access public --provenance');
expect(release).not.toMatch(/NPM_TOKEN|NODE_AUTH_TOKEN|npm_[A-Za-z0-9]{20,}/);
});
Expand Down
Loading