Skip to content

Commit eab013d

Browse files
fix(pack): fold zip archive review followups
Co-authored-by: nadav-y <nadav-y@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 99a956b commit eab013d

22 files changed

Lines changed: 439 additions & 174 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,20 +18,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1818
unrelated config keys and refusing to overwrite a malformed file). `HERMES_HOME`
1919
overrides the Hermes home directory. See the [Hermes integration guide](https://microsoft.github.io/apm/integrations/hermes/).
2020
- Enterprise bootstrap mirror mode lets `install.sh`, `install.ps1`, and `apm self-update` use internal release, installer, and PyPI mirrors with fail-closed public fallback, and closes #1680. (#1733)
21-
2221
- `apm pack --archive-format [zip|tar.gz]` escape hatch (default `zip`) lets
2322
CI pipelines that depended on the previous `.tar.gz` default opt back in without
2423
changing the project default. Passing `--archive-format` without `--archive` is
2524
now a `UsageError`. (#1720)
2625

2726
### Changed
2827

29-
- `apm pack --archive` now produces `.zip` by default instead of `.tar.gz`. ZIP is
30-
natively extractable on Windows without WSL or a tar binary, and matches the format
31-
produced by `apm publish` and expected by Claude Code and plugin hosts.
32-
ZIP archives are typically 30-130% larger than `.tar.gz` for text-heavy skill
33-
bundles due to per-file compression; use `--archive-format tar.gz` if archive size
34-
is a priority. (#1720)
28+
- **BREAKING:** `apm pack --archive` now produces `.zip` by default instead of `.tar.gz`, matching the format produced by `apm publish` and expected by Claude Code and plugin hosts while staying natively extractable on Windows without WSL or a tar binary. Note: ZIP archives are typically 30-130% larger than `.tar.gz` for text-heavy skill bundles due to per-file compression; use `--archive-format tar.gz` if archive size is a priority. (#1720)
3529

3630
### Fixed
3731

docs/src/content/docs/consumer/deploy-a-bundle.md

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
---
22
title: Deploy a local bundle
3-
description: Install a plugin-format bundle from a directory or tarball without touching apm.yml.
3+
description: Install a plugin-format bundle from a directory or archive without touching apm.yml.
44
---
55

6-
You have a bundle on disk -- a directory or `.tar.gz` someone handed you, or
7-
the output of `apm pack --format plugin`. Drop it into a project with one
8-
command:
6+
You have a bundle on disk -- a directory or `.zip` someone handed you (or a
7+
legacy `.tar.gz`), or the output of `apm pack --format plugin`. Drop it into a
8+
project with one command:
99

1010
```bash
1111
apm install ./path/to/bundle
12-
apm install ./dist/my-pkg-1.0.0.tar.gz
12+
apm install ./dist/my-pkg-1.0.0.zip
1313
```
1414

1515
This is a sibling flow to [Install packages](../install-packages/). Instead
@@ -19,8 +19,8 @@ the install is imperative, like `dpkg -i` next to `apt install`.
1919

2020
## What counts as a bundle
2121

22-
A plugin-format bundle is a directory (or gzipped tarball of one) with a
23-
`plugin.json` at the root and primitive folders alongside it:
22+
A plugin-format bundle is a directory, zip archive, or legacy gzipped tarball
23+
with a `plugin.json` at the root and primitive folders alongside it:
2424

2525
```
2626
my-bundle/
@@ -44,16 +44,16 @@ warns and proceeds.
4444
## How the install works
4545

4646
```
47-
$ apm install ./dist/my-pkg-1.0.0.tar.gz --target copilot
48-
[>] Installing local bundle from ./dist/my-pkg-1.0.0.tar.gz
47+
$ apm install ./dist/my-pkg-1.0.0.zip --target copilot
48+
[>] Installing local bundle from ./dist/my-pkg-1.0.0.zip
4949
[+] Bundle integrity verified
5050
[+] Deployed 7 files to .github/
5151
```
5252

5353
Steps APM runs:
5454

5555
1. **Detect.** Path exists and contains `plugin.json` at the bundle root
56-
(tarballs are extracted to a temp directory first).
56+
(zip archives and legacy tarballs are extracted to a temp directory first).
5757
2. **Verify integrity.** Hash every file listed in `pack.bundle_files`;
5858
reject any symlink, hash mismatch, or unlisted file.
5959
3. **Deploy.** Map `agents/`, `skills/`, `commands/`, `hooks/` into the

docs/src/content/docs/consumer/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ You're here because you want to install someone else's APM packages and use them
1818
| You manage `apm.yml` and need lockfile / dependency commands | [Manage dependencies](./manage-dependencies/) |
1919
| You want APM to wire MCP servers (GitHub, Atlassian, ...) into your tools | [Install MCP servers](./install-mcp-servers/) |
2020
| You want APM to wire LSP servers into supported runtimes | [Install LSP servers](./install-lsp-servers/) |
21-
| You received a local `.tar.gz` bundle and need to install it | [Deploy a local bundle](./deploy-a-bundle/) |
21+
| You received a local `.zip` or `.tar.gz` bundle and need to install it | [Deploy a local bundle](./deploy-a-bundle/) |
2222
| You hit `Drift detected` after a `git pull` | [Drift and secure-by-default](./drift-and-secure-by-default/) |
2323
| Your org rolled out `apm-policy.yml` and your install is now blocked | [Governance on the consumer ramp](./governance-on-the-consumer-ramp/) |
2424

docs/src/content/docs/integrations/ci-cd.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ The APM bundle layout below assumes the upstream job ran `apm-action@v1` with `p
208208
- uses: actions/download-artifact@v4
209209
with:
210210
name: agent-config
211+
# Migrating from .tar.gz? Add --archive-format tar.gz to the apm pack step above.
211212
- run: unzip -o build/*.zip -d ./
212213
```
213214

docs/src/content/docs/producer/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ Five steps, in order. Each links to the page that owns it:
1818
| 1 | [Author primitives](./author-primitives/) | Skills, prompts, instructions, agents, hooks, commands, MCP under `.apm/` |
1919
| 2 | [Compile your package](./compile/) | `apm compile` writes deterministic per-target output you can git-diff |
2020
| 3 | [Preview and validate](./preview-and-validate/) | `apm preview` and `apm view` confirm what consumers will receive |
21-
| 4 | [Pack a bundle](./pack-a-bundle/) | `apm pack` produces a bundle you can ship offline or to a marketplace |
21+
| 4 | [Pack a bundle](./pack-a-bundle/) | `apm pack` produces a `.zip` you can ship offline or to a marketplace |
2222
| 5 | [Publish to a marketplace](./publish-to-a-marketplace/) | Others install your package with `apm install <owner>/<repo>` |
2323

2424
You don't need a marketplace to start. Step 4 is enough for internal teams; the marketplace step is for public discovery.

docs/src/content/docs/producer/pack-a-bundle.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ $ apm pack
4545

4646
Add `--archive` to get a single archive (`.zip` by default; use `--archive-format tar.gz`
4747
for legacy CI pipelines) instead of a directory; use `-o` to change the output location
48-
(default `./build`).
48+
(default `./build`). ZIP archives are natively extractable on Windows -- no WSL,
49+
tar, or additional tooling required.
4950

5051
```bash
5152
apm pack --archive -o ./dist

docs/src/content/docs/producer/releasing-from-any-ci.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ you need to customise any step.
8585
> **Reference deployment.** [`DevExpGbb/zava-agent-config`](https://github.com/DevExpGbb/zava-agent-config)
8686
> runs this exact pipeline. The
8787
> [v6.1.2 release](https://github.com/DevExpGbb/zava-agent-config/releases/tag/v6.1.2)
88-
> attaches 7 per-plugin tarballs + their `.sha256` companions +
88+
> attaches 7 per-plugin bundles + their `.sha256` companions +
8989
> `marketplace-6.1.2.json` (15 assets total) via the workflow in
9090
> [`.github/workflows/release.yml`](https://github.com/DevExpGbb/zava-agent-config/blob/main/.github/workflows/release.yml).
9191
> APM `0.16.0` and apm-action `v1.9.1` or newer required.

docs/src/content/docs/reference/cli/pack.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ Bundles are target-agnostic. The consumer's project decides where files land at
2929
| Flag | Default | Description |
3030
|---|---|---|
3131
| `--format plugin\|apm` | `plugin` | Bundle format. `plugin` emits a Claude Code plugin directory with `plugin.json` and plugin-native subdirs (`agents/`, `skills/`, `commands/`, `instructions/`, `hooks/`). `apm` emits the legacy APM bundle layout, kept for tooling that still consumes it (e.g. `microsoft/apm-action@v1` restore mode). |
32-
| `--archive` | off | Produce a `.zip` archive instead of a directory (use `--archive-format tar.gz` for legacy CI pipelines). Bundle only. |
33-
| `--archive-format zip\|tar.gz` | `zip` | Archive format when `--archive` is set. `zip` is natively extractable on Windows and matches the format expected by Claude Code and plugin hosts. `tar.gz` preserves the previous default for pipelines that depend on it. |
32+
| `--archive` | off | Produce a `.zip` archive instead of a directory (changed from `.tar.gz`; use `--archive-format tar.gz` for legacy CI pipelines). Bundle only. |
33+
| `--archive-format zip\|tar.gz` | `zip` | Archive format when `--archive` is set. `zip` is natively extractable on Windows and matches the format expected by Claude Code and plugin hosts. `tar.gz` is typically smaller for text-heavy bundles and preserves the previous default for pipelines that depend on it. |
3434
| `-o`, `--output PATH` | `./build` | Bundle output directory. Does not affect the `marketplace.json` path. |
3535
| `--force` | off | Allow overwriting on collision. In `plugin` bundle format, last writer wins instead of first; for generated `plugin.json` manifests, overwrites an existing file instead of preserving it. |
3636
| `--dry-run` | off | Print what would be packed without writing anything. |

packages/apm-guide/.apm/skills/apm-usage/commands.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ When `apm install` has already deployed instructions to `.claude/rules/`, `apm c
8080

8181
| Command | Purpose | Key flags |
8282
|---------|---------|-----------|
83-
| `apm pack` | Build distributable artifacts (bundle and/or marketplace.json -- driven by `apm.yml`). Default output is a Claude Code plugin directory. Bundles are **target-agnostic**: `pack.target` is recorded in every bundle for diagnostic purposes (typically `"all"` for target-agnostic packs, or the project's detected target) and is not authoritative at install time; `pack.bundle_files` (path -> sha256) drives integrity verification. The consumer's project decides where files land. Marketplace-publishing projects (`marketplace:` block, no `dependencies:`) no longer emit the misleading "No plugin.json found" warning; after a successful build, a vendor-neutral catalog of artifact paths is appended together with a single docs pointer (`producer/publish-to-a-marketplace/#consume-from-any-assistant`) listing per-assistant install paths. Release-time gates `--check-versions` and `--check-clean` are opt-in: when present, they run after the build and exit non-zero on misalignment / drift (codes 3 and 4 respectively) so release pipelines can fail fast. When `apm.yml` declares `target: claude` or `target: copilot` (or the plural `targets:` equivalent), `apm pack` also generates an ecosystem-specific `plugin.json`: `.claude-plugin/plugin.json` for Claude (includes `mcpServers` from `.mcp.json` if present) and `.github/plugin/plugin.json` for Copilot (omits `mcpServers`). An existing file at the target path is preserved (a warning is emitted and the write is skipped) unless `--force` is passed; `--dry-run` prevents writes. Credential-bearing keys and secret-shaped values in `.mcp.json` are stripped recursively at any depth from the Claude manifest before writing, so a committed manifest never leaks secrets (see the apm pack reference, `reference/cli/pack/#credential-stripping-claude-mcpservers`). | `-o PATH`, `--archive` (produce an archive instead of a directory), `--archive-format [zip\|tar.gz]` (default `zip`; only active with `--archive`), `--dry-run`, `--format [plugin\|apm]` (default `plugin`), `--force`, `--offline`, `--include-prerelease`, `--marketplace=FORMATS`, `--marketplace-path FORMAT=PATH`, `--json`, `--check-versions` (release gate: per-package versions match `marketplace.versioning.strategy`; exit 3 on failure), `--check-clean` (release gate: regenerate-and-diff against the committed `marketplace.json`; exit 4 on drift). `-t/--target` is **deprecated** (warn only). Exit codes: `0` success, `1` build/runtime error, `2` schema validation error, `3` `--check-versions` misalignment, `4` `--check-clean` drift. |
83+
| `apm pack` | Build distributable artifacts (bundle and/or marketplace.json -- driven by `apm.yml`). Default output is a Claude Code plugin directory. Bundles are **target-agnostic**: `pack.target` is recorded in every bundle for diagnostic purposes (typically `"all"` for target-agnostic packs, or the project's detected target) and is not authoritative at install time; `pack.bundle_files` (path -> sha256) drives integrity verification. The consumer's project decides where files land. Marketplace-publishing projects (`marketplace:` block, no `dependencies:`) no longer emit the misleading "No plugin.json found" warning; after a successful build, a vendor-neutral catalog of artifact paths is appended together with a single docs pointer (`producer/publish-to-a-marketplace/#consume-from-any-assistant`) listing per-assistant install paths. Release-time gates `--check-versions` and `--check-clean` are opt-in: when present, they run after the build and exit non-zero on misalignment / drift (codes 3 and 4 respectively) so release pipelines can fail fast. When `apm.yml` declares `target: claude` or `target: copilot` (or the plural `targets:` equivalent), `apm pack` also generates an ecosystem-specific `plugin.json`: `.claude-plugin/plugin.json` for Claude (includes `mcpServers` from `.mcp.json` if present) and `.github/plugin/plugin.json` for Copilot (omits `mcpServers`). An existing file at the target path is preserved (a warning is emitted and the write is skipped) unless `--force` is passed; `--dry-run` prevents writes. Credential-bearing keys and secret-shaped values in `.mcp.json` are stripped recursively at any depth from the Claude manifest before writing, so a committed manifest never leaks secrets (see the apm pack reference, `reference/cli/pack/#credential-stripping-claude-mcpservers`). | `-o PATH`, `--archive` (produce a `.zip` archive instead of a directory; changed from `.tar.gz`), `--archive-format [zip\|tar.gz]` (default `zip`; use `tar.gz` for smaller legacy CI artifacts; only active with `--archive`), `--dry-run`, `--format [plugin\|apm]` (default `plugin`), `--force`, `--offline`, `--include-prerelease`, `--marketplace=FORMATS`, `--marketplace-path FORMAT=PATH`, `--json`, `--check-versions` (release gate: per-package versions match `marketplace.versioning.strategy`; exit 3 on failure), `--check-clean` (release gate: regenerate-and-diff against the committed `marketplace.json`; exit 4 on drift). `-t/--target` is **deprecated** (warn only). Exit codes: `0` success, `1` build/runtime error, `2` schema validation error, `3` `--check-versions` misalignment, `4` `--check-clean` drift. |
8484
| `apm unpack BUNDLE` | **[Deprecated]** Extract a bundle. Use `apm install <bundle-path>` instead -- it deploys directly with integrity verification and target resolution. | `-o PATH`, `--skip-verify`, `--force`, `--dry-run` |
8585

8686
`apm install <BUNDLE-PATH>` -- when the positional argument resolves to a directory containing `plugin.json` at its root, or to a `.zip` (or legacy `.tar.gz`/`.tgz`) archive whose extracted root contains `plugin.json`, install switches to local-bundle mode: the bundle is integrity-verified against its embedded `apm.lock.yaml` (`pack.bundle_files`) and deployed into the consumer's resolved target. Target resolution follows the same precedence as registry installs (`--target` > `apm.yml` > directory detection); the bundle itself carries no target binding. Compile-only targets (opencode, codex, gemini) receive instructions staged under `apm_modules/<slug>/.apm/instructions/` and the install emits a hint to run `apm compile` to merge them. Other existing paths (e.g. a source-package directory without `plugin.json`) still flow through the normal local-path dependency-resolver pipeline. Files are recorded under `local_deployed_files` in the project lockfile -- `apm.yml` is **never** mutated. Honours `--target`, `--global`, `--force`, `--dry-run`, `--verbose`, plus `--as ALIAS` (log/display label only). Resolver/MCP/registry/policy flags (`--update`, `--mcp`, `--parallel-downloads`, `--allow-insecure-host`, `--skill`, ...) are rejected with a single consolidated error -- local-bundle install is an imperative deploy and bypasses those subsystems.

src/apm_cli/bundle/local_bundle.py

Lines changed: 17 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,15 @@
3737

3838
import yaml
3939

40+
from ..utils.archive import MAX_ZIP_ENTRIES, MAX_ZIP_UNCOMPRESSED, safe_extract_zip
4041
from ..utils.path_security import (
4142
PathTraversalError,
4243
ensure_path_within,
4344
validate_path_segments,
4445
)
4546

46-
_MAX_ZIP_ENTRIES = 10_000
47-
_MAX_ZIP_UNCOMPRESSED = 512 * 1024 * 1024 # 512 MB
47+
_MAX_ZIP_ENTRIES = MAX_ZIP_ENTRIES
48+
_MAX_ZIP_UNCOMPRESSED = MAX_ZIP_UNCOMPRESSED
4849

4950

5051
@dataclass(frozen=True)
@@ -204,44 +205,27 @@ def _find_extracted_root(extract_dir: Path) -> Path | None:
204205
def _extract_zip_bundle(path: Path) -> LocalBundleInfo | None:
205206
"""Extract a ``.zip`` bundle to a temp dir and return :class:`LocalBundleInfo`.
206207
207-
Applies the same security checks as the tar.gz branch: rejects absolute
208-
paths, path-traversal segments, and Unix symlink entries detected via
209-
``external_attr``. Returns ``None`` on any validation failure or I/O
210-
error so the caller can fall through to a generic error message.
208+
Applies the same security checks as the tar.gz branch and enforces the ZIP
209+
size quota while streaming each entry. Returns ``None`` only when the file
210+
is not a readable ZIP bundle or no ``plugin.json`` root is found; security
211+
violations raise ``ValueError`` with a targeted reason.
211212
"""
212213
temp_dir = Path(tempfile.mkdtemp(prefix="apm-local-bundle-"))
213214
try:
214215
with zipfile.ZipFile(path, "r") as zf:
215-
members = zf.infolist()
216-
# ZIP bomb guard: reject suspiciously large or deep archives
217-
if len(members) > _MAX_ZIP_ENTRIES:
218-
shutil.rmtree(temp_dir, ignore_errors=True)
219-
return None
220-
if sum(m.file_size for m in members) > _MAX_ZIP_UNCOMPRESSED:
221-
shutil.rmtree(temp_dir, ignore_errors=True)
222-
return None
223-
for member in members:
224-
name = member.filename
225-
if (
226-
name.startswith("/")
227-
or PureWindowsPath(name).drive
228-
or PureWindowsPath(name).is_absolute()
229-
):
230-
shutil.rmtree(temp_dir, ignore_errors=True)
231-
return None
232-
try:
233-
validate_path_segments(name, context="zip member")
234-
except PathTraversalError:
235-
shutil.rmtree(temp_dir, ignore_errors=True)
236-
return None
237-
# Detect Unix symlinks stored in zip external_attr
238-
if (member.external_attr >> 16) & 0o170000 == 0o120000:
239-
shutil.rmtree(temp_dir, ignore_errors=True)
240-
return None
241-
zf.extractall(temp_dir) # noqa: S202 -- validated above
216+
safe_extract_zip(
217+
zf,
218+
temp_dir,
219+
max_entries=_MAX_ZIP_ENTRIES,
220+
max_uncompressed=_MAX_ZIP_UNCOMPRESSED,
221+
error_type=ValueError,
222+
)
242223
except (zipfile.BadZipFile, OSError):
243224
shutil.rmtree(temp_dir, ignore_errors=True)
244225
return None
226+
except ValueError:
227+
shutil.rmtree(temp_dir, ignore_errors=True)
228+
raise
245229
bundle_root = _find_extracted_root(temp_dir)
246230
if bundle_root is None:
247231
shutil.rmtree(temp_dir, ignore_errors=True)

0 commit comments

Comments
 (0)