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
146 changes: 146 additions & 0 deletions .github/workflows/cli-package-validation.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
name: CLI package validation

on:
pull_request:
branches: [main]
paths:
- '.github/workflows/cli-package-validation.yml'
- '.gitattributes'
- '.npmrc'
- 'LICENSE'
- 'NOTICE'
- 'package.json'
- 'package-lock.json'
- 'patches/**'
- 'packages/cli/**'
- 'packages/code-mode/**'
- 'packages/core/**'
- 'packages/eval/**'
- 'packages/mcp/**'
- 'packages/runtime/**'
- 'packages/runtime-host/**'
- 'packages/storage/**'
- 'scripts/apply-dependency-patches.mjs'
- 'scripts/clean-paths.mjs'
- 'scripts/generate-third-party-notices.mjs'
- 'scripts/install-electron-with-retry.mjs'
- 'scripts/npm-spawn.mjs'
- 'scripts/release-cli-*.mjs'
- 'scripts/smoke-release-cli-package.mjs'
- 'tsconfig*.json'
push:
branches: [main]
paths:
- '.github/workflows/cli-package-validation.yml'
- '.gitattributes'
- '.npmrc'
- 'LICENSE'
- 'NOTICE'
- 'package.json'
- 'package-lock.json'
- 'patches/**'
- 'packages/cli/**'
- 'packages/code-mode/**'
- 'packages/core/**'
- 'packages/eval/**'
- 'packages/mcp/**'
- 'packages/runtime/**'
- 'packages/runtime-host/**'
- 'packages/storage/**'
- 'scripts/apply-dependency-patches.mjs'
- 'scripts/clean-paths.mjs'
- 'scripts/generate-third-party-notices.mjs'
- 'scripts/install-electron-with-retry.mjs'
- 'scripts/npm-spawn.mjs'
- 'scripts/release-cli-*.mjs'
- 'scripts/smoke-release-cli-package.mjs'
- 'tsconfig*.json'
workflow_call:
workflow_dispatch:

permissions:
contents: read

concurrency:
group: cli-package-validation-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
build:
name: Build immutable tarball
runs-on: ubuntu-24.04
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22.19.0'
cache: npm
- name: Select the release npm toolchain
run: npm install --global --no-audit --no-fund npm@11.12.1

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- workflow references ---'
rg -n -C 4 'npm install --global|npm@11\.12\.1|package-lock|corepack|pnpm|yarn|npm ci|npm install' .github/workflows/cli-package-validation.yml

printf '%s\n' '--- repository package-manager files ---'
git ls-files | rg '(^|/)(package\.json|package-lock\.json|npm-shrinkwrap\.json|pnpm-lock\.yaml|yarn\.lock|\.npmrc|\.nvmrc|\.node-version|packageManager)$' || true

printf '%s\n' '--- package-manager declarations ---'
for f in package.json .npmrc .nvmrc .node-version; do
  if [ -f "$f" ]; then
    echo "### $f"
    cat -n "$f"
  fi
done

Repository: maka-agent/maka-agent

Length of output: 8024


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
import io
import json
import tarfile
import urllib.request

version = "11.12.1"
url = f"https://registry.npmjs.org/npm/{version}"
with urllib.request.urlopen(url, timeout=20) as response:
    metadata = json.load(response)

print("--- registry metadata ---")
print("name:", metadata.get("name"))
print("version:", metadata.get("version"))
dist = metadata.get("dist", {})
print("tarball:", dist.get("tarball"))
print("integrity:", dist.get("integrity"))
print("shasum:", dist.get("shasum"))

tarball_url = dist["tarball"]
with urllib.request.urlopen(tarball_url, timeout=30) as response:
    data = response.read()

print("--- published package files ---")
with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as archive:
    names = archive.getnames()
    for name in names:
        if name in {
            "package/package.json",
            "package/package-lock.json",
            "package/npm-shrinkwrap.json",
        } or name.endswith(("/package-lock.json", "/npm-shrinkwrap.json")):
            print(name)

    package_json = json.load(archive.extractfile("package/package.json"))
    print("--- npm package manifest ---")
    print("packageManager:", package_json.get("packageManager"))
    print("dependencies:", len(package_json.get("dependencies", {})))
    print("optionalDependencies:", len(package_json.get("optionalDependencies", {})))
    print("bundledDependencies:", package_json.get("bundledDependencies"))
PY

Repository: maka-agent/maka-agent

Length of output: 2445


🏁 Script executed:

#!/bin/bash
set -eu

tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT

curl -fsSLk 'https://registry.npmjs.org/npm/11.12.1' > "$tmpdir/metadata.json"

python3 - "$tmpdir/metadata.json" <<'PY'
import json, sys
metadata = json.load(open(sys.argv[1]))
dist = metadata["dist"]
print("--- registry metadata ---")
print("name:", metadata["name"])
print("version:", metadata["version"])
print("tarball:", dist["tarball"])
print("integrity:", dist.get("integrity"))
print("shasum:", dist.get("shasum"))
PY

tarball="$(python3 - "$tmpdir/metadata.json" <<'PY'
import json, sys
print(json.load(open(sys.argv[1]))["dist"]["tarball"])
PY
)"
curl -fsSLk "$tarball" > "$tmpdir/npm.tgz"

printf '%s\n' '--- published lockfile names ---'
tar -tzf "$tmpdir/npm.tgz" | grep -E '(^|/)(package-lock\.json|npm-shrinkwrap\.json)$' || true

printf '%s\n' '--- published npm manifest dependency fields ---'
tar -xOf "$tmpdir/npm.tgz" package/package.json |
  python3 -c 'import json,sys; p=json.load(sys.stdin); print("packageManager:",p.get("packageManager")); print("dependencies:",len(p.get("dependencies",{}))); print("optionalDependencies:",len(p.get("optionalDependencies",{}))); print("bundledDependencies:",p.get("bundledDependencies"))'

Repository: maka-agent/maka-agent

Length of output: 591


Pin npm’s dependency tree, not only its version.

The root package.json pins npm@11.12.1, but the published package has no lockfile or shrinkwrap and declares 65 dependencies. Both global installs can therefore resolve transitive dependencies outside the repository lockfile. Add a reviewed mechanism that pins the complete npm toolchain.

🧰 Tools
🪛 zizmor (1.29.0)

[warning] 82-82: ad-hoc installation of packages (adhoc-packages): installs a package outside of a lockfile

(adhoc-packages)

Sources: Path instructions, Linters/SAST tools

- name: Build the release tarball once
run: npm run release:cli:pack
- name: Upload the immutable release candidate
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: cli-release-candidate
path: |
packages/cli/release/*.tgz
packages/cli/release/*.tgz.sha256
packages/cli/release/*.tgz.files.json
if-no-files-found: error
retention-days: 7

smoke:
name: Validate installed CLI ${{ matrix.name }}
needs: build
runs-on: ${{ matrix.runner }}
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
include:
- name: Linux x64 / Node 22.19
runner: ubuntu-24.04
node: '22.19.0'
platform: linux
arch: x64
- name: Linux x64 / Node 24
runner: ubuntu-24.04
node: '24'
platform: linux
arch: x64
- name: macOS arm64 / Node 24
runner: macos-15
node: '24'
platform: darwin
arch: arm64
- name: Windows x64 / Node 24
runner: windows-2025
node: '24'
platform: win32
arch: x64
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ matrix.node }}
- name: Select the release npm toolchain
run: npm install --global --no-audit --no-fund npm@11.12.1
- name: Assert the runner architecture
env:
EXPECTED_PLATFORM: ${{ matrix.platform }}
EXPECTED_ARCH: ${{ matrix.arch }}
run: |
node -e "if (process.platform !== process.env.EXPECTED_PLATFORM || process.arch !== process.env.EXPECTED_ARCH) throw new Error('Expected ' + process.env.EXPECTED_PLATFORM + '/' + process.env.EXPECTED_ARCH + ', found ' + process.platform + '/' + process.arch)"
- name: Download the release candidate
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: cli-release-candidate
path: packages/cli/release
- name: Validate the installed tarball
run: node scripts/smoke-release-cli-package.mjs
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
"release:cli:smoke": "node scripts/smoke-release-cli-package.mjs",
"generate:windows-cargo-notices": "node scripts/generate-windows-cargo-notices.mjs",
"check:windows-cargo-notices": "node scripts/generate-windows-cargo-notices.mjs --check",
"check:release": "npm run check:stale && npm run check:third-party-notices && npm run check:cli-third-party-notices && node --test scripts/release-cli-file-policy.test.mjs",
"check:release": "npm run check:stale && npm run check:third-party-notices && npm run check:cli-third-party-notices && node --test scripts/release-cli-file-policy.test.mjs scripts/release-cli-artifact-policy.test.mjs",
"package:macos-arm64": "node scripts/package-macos-arm64.mjs",
"verify:macos-arm64": "node scripts/verify-macos-arm64-dmg.mjs",
"package:windows-x64": "node scripts/package-windows-x64.mjs",
Expand Down
22 changes: 22 additions & 0 deletions scripts/release-cli-artifact-policy.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// The first installable proof tarball was 15,005,877 compressed bytes,
// 77,684,229 unpacked bytes, and 7,511 entries. These ceilings preserve
// deliberate headroom while making an accidental dependency/content spike a
// reviewed release change instead of silently shipping it.
export const CLI_RELEASE_ARTIFACT_LIMITS = Object.freeze({
compressedBytes: 18 * 1024 * 1024,
unpackedBytes: 90 * 1024 * 1024,
entryCount: 9_000,
});

export function validateCliReleaseArtifactMetrics(metrics) {
for (const key of ['compressedBytes', 'unpackedBytes', 'entryCount']) {
const value = metrics[key];
if (!Number.isSafeInteger(value) || value < 0) {
throw new Error(`CLI release artifact ${key} must be a non-negative safe integer`);
}
const limit = CLI_RELEASE_ARTIFACT_LIMITS[key];
if (value > limit) {
throw new Error(`CLI release artifact ${key} is ${value}; limit is ${limit}`);
}
}
}
40 changes: 40 additions & 0 deletions scripts/release-cli-artifact-policy.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
import {
CLI_RELEASE_ARTIFACT_LIMITS,
validateCliReleaseArtifactMetrics,
} from './release-cli-artifact-policy.mjs';

describe('CLI release artifact policy', () => {
test('accepts the established proof-tarball baseline', () => {
assert.doesNotThrow(() =>
validateCliReleaseArtifactMetrics({
compressedBytes: 15_005_877,
unpackedBytes: 77_684_229,
entryCount: 7_511,
}),
);
});

test('rejects each metric above its reviewed ceiling', () => {
for (const key of Object.keys(CLI_RELEASE_ARTIFACT_LIMITS)) {
const metrics = {
...CLI_RELEASE_ARTIFACT_LIMITS,
[key]: CLI_RELEASE_ARTIFACT_LIMITS[key] + 1,
};
assert.throws(() => validateCliReleaseArtifactMetrics(metrics), new RegExp(key));
}
});

test('rejects malformed metrics instead of coercing them', () => {
assert.throws(
() =>
validateCliReleaseArtifactMetrics({
compressedBytes: Number.NaN,
unpackedBytes: 1,
entryCount: 1,
}),
/compressedBytes/,
);
});
});
6 changes: 6 additions & 0 deletions scripts/release-cli-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
import { tmpdir } from 'node:os';
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
import { npmSpawnOptions } from './npm-spawn.mjs';
import { validateCliReleaseArtifactMetrics } from './release-cli-artifact-policy.mjs';
import {
isMakaDevelopmentArtifact,
isThirdPartyDevelopmentArtifact,
Expand Down Expand Up @@ -123,6 +124,11 @@ function main() {
if (!pack?.filename || !Array.isArray(pack.files)) {
throw new Error('npm pack did not return one JSON package result');
}
validateCliReleaseArtifactMetrics({
compressedBytes: pack.size,
unpackedBytes: pack.unpackedSize,
entryCount: pack.entryCount,
});
const tarballPath = join(releaseRoot, pack.filename);
validatePackedFiles(pack.files, expectedDependencyManifests);
const sha256 = digestFile(tarballPath);
Expand Down
Loading
Loading