feat(pack): switch --archive to .zip and add --archive-format zip|tar.gz#1720
feat(pack): switch --archive to .zip and add --archive-format zip|tar.gz#1720nadav-y wants to merge 2 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR switches apm pack --archive to produce .zip bundles by default, adds --archive-format zip|tar.gz for explicit selection, and extends bundle extraction to support .zip while keeping .tar.gz as a legacy-compatible input.
Changes:
- Default
apm pack --archiveoutput changes from.tar.gzto.zip, with a new--archive-formatoption to opt into.tar.gz. - Bundle packing/exporting now branches on
archive_formatto write either ZIP or tar.gz archives. - Bundle unpacking adds a primary ZIP extraction path with traversal + symlink-entry defenses, while retaining the existing tar.gz path.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
src/apm_cli/commands/pack.py |
Updates CLI help text for archive output; adds `--archive-format zip |
src/apm_cli/core/build_orchestrator.py |
Threads new bundle_archive_format option from CLI into bundle production. |
src/apm_cli/bundle/packer.py |
Implements archive-format branching for APM-format bundle archives. |
src/apm_cli/bundle/plugin_exporter.py |
Implements archive-format branching for plugin-format bundle archives. |
src/apm_cli/bundle/unpacker.py |
Adds ZIP extraction + security validation; preserves legacy tar.gz extraction. |
tests/unit/test_packer.py |
Updates unit expectations/validation from tar.gz to zip for pack_bundle(..., archive=True). |
tests/unit/test_unpacker.py |
Updates archive helper to generate ZIP archives for unpack unit tests. |
tests/unit/commands/test_pack_phase3.py |
Updates hardcoded bundle file names in CLI-related tests from .tar.gz to .zip. |
tests/unit/commands/test_pack_cli_surface.py |
Same .tar.gz -> .zip updates for CLI surface tests. |
tests/integration/test_pack_unpack_e2e.py |
Updates end-to-end pack/unpack flow to look for *.zip artifacts. |
tests/integration/test_wave6_init_pack_coverage.py |
Updates --archive docstring expectation to .zip. |
tests/integration/test_wave3_marketplace_coverage.py |
Updates --archive docstring expectation to .zip. |
392684c to
1815123
Compare
APM Review Panel:
|
| Persona | B | R | N | Takeaway |
|---|---|---|---|---|
| Python Architect | 0 | 2 | 1 | Well-motivated switch; packer.py skips ensure_path_within that plugin_exporter.py applies; extract shared _write_archive helper. |
| CLI Logging Expert | 1 | 2 | 2 | Post-pack success tip instructs 'apm install ./bundle.zip' but detect_local_bundle() in local_bundle.py does not recognize .zip -- command silently fails. |
| DevX UX Expert | 0 | 3 | 2 | ZIP-by-default is sound; no runtime hint for breaking default change; --archive-format silently no-ops without --archive; three doc surfaces stale. |
| Supply Chain Security Expert | 0 | 3 | 1 | Pre-validation chain is sound; packer.py omits ensure_path_within for archive_path; no ZIP bomb guard before extractall; Python 3.12 rglob symlink gap. |
| OSS Growth Hacker | 0 | 2 | 1 | Strong strategic alignment; missing CHANGELOG; ci-cd.md 'tar xzf' command breaks silently for upgrading users. |
| Doc Writer | 0 | 4 | 1 | Five documentation surfaces now incorrect: CHANGELOG, pack.md, ci-cd.md/gh-aw.md (live CI commands break), commands.md, unpack.md/install.md. |
| Test Coverage Expert | 1 | 1 | 0 | ZIP extraction security checks (path traversal, symlink, absolute path) have zero test coverage despite being the new default extraction path. |
| Performance Expert | 0 | 3 | 3 | ZIP archives are 30-127% larger for typical APM bundles (measured); size regression is real and undocumented in CHANGELOG. |
B = blocking-severity findings, R = recommended, N = nits.
Counts are signal strength, not gates. The maintainer ships.
Top 5 follow-ups
-
[CLI Logging Expert] (blocking-severity) Update
local_bundle.pydetect_local_bundle()and_looks_like_archive()to recognize.zipso the post-pack success message gives users a command that actually works. -- This is a correctness regression in the default path: the success tip instructs users to run a broken command. Fix is 2-3 lines. Must land in this PR before merge. -
[Test Coverage Expert] (blocking-severity) Add ZIP extraction security tests to
test_unpacker.pycovering path traversal, absolute path rejection, andexternal_attrsymlink detection -- mirroring the existingtest_unpack_with_path_traversal_tarballandtest_unpack_with_symlink_tarballtests for the tar.gz path. Also add a test for--archive-format tar.gzround-trip. -- ZIP is now the DEFAULT extraction path. Exhaustive grep confirms zero test coverage for these cases. New security-critical code on the default path with no automated guardrail is a regression trap. Must land in this PR before merge. -
[Python Architect + Supply Chain Security Expert] Add
ensure_path_within(archive_path, output_dir)inpacker.py(both archive branches) and an uncompressed-size/entry-count guard beforezf.extractallinunpacker.py. -- Two independent panelists confirmed the write-path-traversal gap: apkg_namelike../evilinapm.ymlescapesoutput_dirat pack time. The ZIP bomb guard prevents denial-of-service. Should land in this PR or a companion PR opened before merge. -
[Doc Writer + OSS Growth Hacker + DevX UX Expert] Replace
tar xzf build/*.tar.gzwith the.zipequivalent inci-cd.mdandgh-aw.md. Add CHANGELOG[Unreleased]entry for the default format change (size tradeoff disclosure + migration note). Add--archive-formattopack.mdoptions table. Emit a runtime warning when--archive-formatis passed without--archive. -- Three panelists flagged these surfaces independently. The CHANGELOG gap violates APM governance policy; theci-cd.mdCI command breaks silently for upgrading users. -
[Python Architect] Extract a shared
_write_archive()helper from the duplicate archive-writing blocks inpacker.pyandplugin_exporter.py. Type-narrowarchive_formattoLiteral['zip', 'tar.gz']in all function signatures andBuildOptions. -- The two blocks have already drifted (packer.py missingensure_path_within); the shared helper prevents future divergence and eliminates the security gap by construction.
Architecture
classDiagram
direction LR
class BuildOrchestrator {
<<Coordinator>>
-_producers list
+run(options, logger) BuildResult
}
class ArtifactProducer {
<<Protocol>>
+kind OutputKind
+produce(options, logger) ProducerResult
}
class BundleProducer {
<<ConcreteStrategy>>
+kind OutputKind
+produce(options, logger) ProducerResult
}
class BuildOptions {
<<ValueObject>>
+bundle_archive bool
+bundle_archive_format str
+bundle_format str
+bundle_output Path
+dry_run bool
}
class PackResult {
<<ValueObject>>
+bundle_path Path
+files list
}
class pack_bundle {
<<Function>>
+archive bool
+archive_format str
+fmt str
}
class export_plugin_bundle {
<<Function>>
+archive bool
+archive_format str
}
class unpack_bundle {
<<Function>>
+bundle_path Path
}
note for pack_bundle "packer.py apm fmt: NO sanitize NO ensure_path_within"
note for export_plugin_bundle "plugin_exporter.py: sanitize then ensure_path_within both branches"
BuildOrchestrator o-- ArtifactProducer : manages
BundleProducer ..|> ArtifactProducer : implements
BundleProducer ..> BuildOptions : reads
BundleProducer ..> pack_bundle : calls
pack_bundle ..> export_plugin_bundle : delegates plugin fmt
pack_bundle ..> PackResult : returns
export_plugin_bundle ..> PackResult : returns
class BuildOptions:::touched
class pack_bundle:::touched
class export_plugin_bundle:::touched
class unpack_bundle:::touched
classDef touched fill:#fff3b0,stroke:#d47600
flowchart TD
CLI["apm pack --archive --archive-format zip|tar.gz"]
BO["BuildOptions.bundle_archive_format - build_orchestrator.py:44"]
ORCH["BuildOrchestrator.run - build_orchestrator.py:414"]
BP["BundleProducer.produce - build_orchestrator.py:98"]
PB["pack_bundle - packer.py:26"]
EPB["export_plugin_bundle - plugin_exporter.py:406"]
ARCH1{"archive_format? plugin fmt"}
ARCH2{"archive_format? apm legacy fmt"}
ZIP1["ZipFile.write - plugin_exporter.py:662"]
TAR1["tarfile.open - plugin_exporter.py:654"]
EPW1["ensure_path_within archive_path - plugin_exporter.py:661"]
EPW2["ensure_path_within archive_path - plugin_exporter.py:653"]
RMTREE1["shutil.rmtree bundle_dir - plugin_exporter.py:669"]
ZIP2["ZipFile.write - packer.py:286"]
TAR2["tarfile.open - packer.py:279"]
GAP["GAP: no sanitize, no ensure_path_within - packer.py:277-294"]
RMTREE2["shutil.rmtree bundle_dir - packer.py:293"]
UNPACK["apm unpack .zip - unpacker.py:29"]
SCAN["pre-validate zip members: path-traversal + symlink - unpacker.py:66-83"]
EXTALL["zf.extractall temp_dir - unpacker.py:84"]
FINAL["shutil.rmtree temp_dir finally - unpacker.py:282"]
CLI --> BO --> ORCH --> BP --> PB
PB -->|"fmt=plugin (default)"| EPB
PB -->|"fmt=apm legacy"| ARCH2
EPB --> ARCH1
ARCH1 -->|"zip (new default)"| ZIP1
ARCH1 -->|"tar.gz (opt-in)"| TAR1
ZIP1 --> EPW1 --> RMTREE1
TAR1 --> EPW2 --> RMTREE1
ARCH2 -->|"zip"| ZIP2
ARCH2 -->|"tar.gz"| TAR2
ZIP2 --> GAP
TAR2 --> GAP
GAP --> RMTREE2
UNPACK --> SCAN --> EXTALL --> FINAL
Recommendation
The direction is correct and must ship. ZIP-by-default is the right call for Claude Code compatibility and Windows CI adoption, and the panel is unanimous on this. The rework ask is narrow and fast.
Two items must be fixed in this PR before merge. First, update local_bundle.py to recognize .zip so the post-pack success message gives users a command that actually works. This is 2-3 lines; there is no justification for shipping a success tip that instructs users to run a broken command. Second, add ZIP extraction security tests to test_unpacker.py covering path traversal, absolute path rejection, and symlink detection via external_attr. ZIP is now the default extraction path, the security gates are new code, and the tar.gz equivalents have explicit tests. Both items are fast to address in the existing PR branch.
Before merging, the maintainer should also open a companion PR covering: ensure_path_within in packer.py (confirmed write-path-traversal gap), the ci-cd.md and CHANGELOG updates (the live tar xzf CI command breaks upgrading users silently), and the ZIP bomb guard in unpacker.py. The companion PR does not need to merge first, but the commitment to it must be explicit -- a tracking issue at minimum -- before this branch lands. Once the two items are fixed in the PR and the companion PR is opened, this change ships cleanly and the Windows plus Claude Code narrative is ready to amplify.
Full per-persona findings
Python Architect
-
[recommended] packer.py archive block skips
_sanitize_bundle_nameandensure_path_withinthat plugin_exporter.py applies for both formats (src/apm_cli/bundle/packer.py:276)
plugin_exporter.py applies three layered guards before writing an archive: (1)_sanitize_bundle_name(pkg_name/pkg_version), (2)ensure_path_within(bundle_dir, output_dir), (3)ensure_path_within(archive_path, output_dir)for each branch. packer.py constructs archive_path directly from raw pkg_name and pkg_version with none of these guards. A pkg_name value of../evilwould produce an archive_path that escapes output_dir silently.
Suggested: Addensure_path_withinimport to packer.py. Apply_sanitize_bundle_nameto pkg_name/pkg_version, then callensure_path_within(archive_path, output_dir)in both branches, mirroring plugin_exporter.py:646-649 and plugin_exporter.py:653/661. -
[recommended] Identical archive-writing blocks in packer.py and plugin_exporter.py have already drifted; extract a shared
_write_archivehelper (src/apm_cli/bundle/packer.py:276)
packer.py:276-294 and plugin_exporter.py:650-670 contain functionally identical zip/tar.gz branch code. The only diff is that plugin_exporter.py callsensure_path_withinand packer.py does not -- exactly the divergence that duplicated blocks produce over time.
Suggested: Extract to_write_archive(bundle_dir, archive_path, archive_format, output_dir)that callsensure_path_withininternally, eliminating the security gap by construction. -
[nit]
archive_format: strshould beLiteral['zip', 'tar.gz']in function signatures and BuildOptions (src/apm_cli/bundle/packer.py:32)
click.Choice validates at the CLI boundary, but internal callers pass a raw string with no type-checker enforcement. Literal makes the two-value contract explicit to mypy/pyright.
Suggested:from typing import LiteralthenLiteral['zip', 'tar.gz']in pack_bundle, export_plugin_bundle, and BuildOptions.bundle_archive_format.
CLI Logging Expert
-
[blocking] 'Share with: apm install (bundle).zip' emits a command that does not work (
src/apm_cli/bundle/local_bundle.py:134)
Before this PR,apm pack --archiveproduced .tar.gz;detect_local_bundle()matches .tar.gz via_looks_like_archive(). After this PR, the default output is .zip, but_looks_like_archive()only returns True for .tar.gz/.tgz -- not .zip.detect_local_bundle()returns None for .zip paths; the install command falls through to registry-install with a confusing unrelated error.local_bundle.pywas NOT modified by this PR.
Suggested: Extend_looks_like_archive()to also match .zip and add the corresponding zip extraction branch todetect_local_bundle()(mirroring the .zip extraction logic already in unpacker.py). Also extend the IM7 guard in install.py to check '.zip'. -
[recommended]
--archive-formathelp text gives no basis for choosing between zip and tar.gz (src/apm_cli/commands/pack.py:179)
Current: 'Archive format when --archive is set.' The PR body has the right framing (Claude Code compatibility vs legacy CI) but it never reaches the terminal help text.
Suggested: "Archive format: 'zip' (default, Claude Code and plugin-host compatible; matches apm publish output) or 'tar.gz' (legacy CI pipelines that depended on the previous default)." -
[recommended] Three stale
.tar.gz-only references in install.py user-facing strings not updated (src/apm_cli/commands/install.py:1100)
(1)--asoption help at line 1100, (2) example usage at line 1176, (3)--aserror message at line 1326 all name only .tar.gz as the accepted archive format.
Suggested: Change '(directory or .tar.gz produced by apm pack)' to '(directory, .zip, or .tar.gz archive produced by apm pack)'. -
[nit]
--archivehelp cross-reference does not surface the default format (src/apm_cli/commands/pack.py:171)
Suggested: 'Produce an archive instead of a directory. Default format is .zip (see --archive-format to change).' -
[nit]
local_bundle.pymodule docstring andis_archivedocstring still describe .tar.gz as the only archive format (src/apm_cli/bundle/local_bundle.py:4)
Suggested: Update both to include '.zip or legacy .tar.gz'.
DevX UX Expert
-
[recommended] No runtime hint on the silent default-format change: existing scripts will break downstream (
src/apm_cli/commands/pack.py)
apm pack --archive previously produced .tar.gz; it now produces .zip with zero terminal output indicating the change. CI pipelines that runtar xzf *.tar.gzorls *.tar.gzafter pack will fail silently -- APM exits 0, the downstream shell command fails.
Suggested: In_render_bundle_resultor inpack_cmdafter a successful archive, emitlogger.infoorlogger.warningwhenarchive=Trueandarchive_format='zip', pointing to--archive-format tar.gzfor pipelines that relied on the old default. -
[recommended]
--archive-formatsilently no-ops without--archive; should warn (src/apm_cli/commands/pack.py)
archive_formatis accepted by the CLI and forwarded to pack_bundle() but is only evaluated inside theif archive:guard. Runningapm pack --archive-format tar.gzwithout--archiveproduces a directory, discards the flag, and exits 0 with no warning.
Suggested: Aftereffective_targetassignment, add:if archive_format != 'zip' and not archive: logger.warning('--archive-format has no effect without --archive'). -
[recommended] Three doc surfaces still describe
--archiveas producing .tar.gz;--archive-formatabsent from options table (docs/src/content/docs/reference/cli/pack.md)
(a) pack.md line 32 says--archiveproduces .tar.gz; --archive-format missing; (b) pack-a-bundle.md line 45 says '.tar.gz instead of a directory'; (c) commands.md line 83 lists --archive but not --archive-format.
Suggested: Update pack.md --archive row and add --archive-format row. Update pack-a-bundle.md .tar.gz examples to .zip. Add --archive-format to commands.md. -
[nit]
--archivehelp text defers to cross-reference instead of naming the default format (src/apm_cli/commands/pack.py)
Suggested:help='Produce a .zip archive instead of a directory (use --archive-format tar.gz for legacy pipelines).' -
[nit]
--archive-formathelp text does not explain when to choose each value (src/apm_cli/commands/pack.py)
Suggested:help='Archive format when --archive is set. Use tar.gz for CI pipelines that relied on the previous default.'
Supply Chain Security Expert
-
[recommended] packer.py omits
ensure_path_withinfor archive_path; pkg_name from apm.yml can escape output_dir (src/apm_cli/bundle/packer.py:278)
plugin_exporter.py callsensure_path_within(archive_path, output_dir)for both archive format branches. packer.py constructs archive_path directly fromoutput_dir / f'{pkg_name}-{pkg_version}.{ext}'where pkg_name/pkg_version come from apm.yml (user-controlled). A name like../../cron.d/evilcauses archive_path.resolve() to escape output_dir.
Suggested: After each archive_path assignment (lines 278 and 285), addensure_path_within(archive_path, output_dir), mirroring plugin_exporter.py:653 and :661. -
[recommended] No uncompressed-size or entry-count guard before
zf.extractall; ZIP bomb denial-of-service possible (src/apm_cli/bundle/unpacker.py:67)
The pre-validation loop checks path safety but never readsmember.file_size. A crafted ZIP with high compression ratio can decompress to gigabytes. Python's zipfile imposes no decompression limit.
Suggested: Accumulatetotal = sum(m.file_size for m in zf.infolist())and check entry count. Raise ValueError before extractall if total exceeds a size cap (e.g., 512 MB) or entry count exceeds a count cap (e.g., 10000). -
[recommended] Python 3.12 rglob follows symlinked directories by default; is_symlink() guard misses transitive files on that version (
src/apm_cli/bundle/plugin_exporter.py:655)
APM declaresrequires-python >= 3.10. In Python 3.12,Path.rglob()changed its default to follow symbolic links to subdirectories. If bundle_dir contains a symlink to an external directory, rglob yields every file inside that external directory; those files haveis_symlink() == Falseandis_file() == Trueand pass the guard. The oldtarfile.addwith_tar_filternever recursed into symlinked directories.
Suggested: After theis_symlink()check, addensure_path_within(fp, bundle_dir)(catching PathTraversalError and skipping) to reject files reachable only through symlinked directories regardless of Python version. -
[nit] Windows-format ZIP symlink detection via
external_attris a known limitation aligned with Python stdlib (src/apm_cli/bundle/unpacker.py:82)
The check reads Unix file-type bits; ZIPs created by Windows tools leave the upper 16 bits zero. However, Python'szipfile.extractall()uses the sameexternal_attrmechanism -- the check and the extractor are in sync. No exploitable gap; worth documenting as a known limitation in a code comment.
OSS Growth Hacker
-
[recommended] Missing CHANGELOG entry for a default-changing PR -- trust risk for upgrading users
The[Unreleased]section records theapm publishzip switch but is entirely silent onapm pack --archivenow emitting .zip. An operator runningapm upgradehas no signal that their artifact filename changed. The escape hatch only helps users who know they need it. -
[recommended] Three docs pages become stale copy-paste traps on merge -- including a live CI command that silently fails
Confirmed stale: (1) pack.md options table still says .tar.gz; (2) pack-a-bundle.md shows .tar.gz output examples; (3) ci-cd.md containstar xzf build/*.tar.gz -C ./which silently fails for upgrading users copying the snippet. -
[nit] The Windows story is the strongest adoption angle and it is unplayed in the PR body
ZIP is natively extractable on Windows without WSL, GNU tar, or third-party tools. Worth one sentence in the CHANGELOG entry and a phrase in the release note.
Auth Expert -- inactive
No auth files touched; PR changes archive format (zip vs tar.gz) in bundle/packer.py, bundle/plugin_exporter.py, bundle/unpacker.py, commands/pack.py, and core/build_orchestrator.py -- no authentication, token management, credential resolution, or AuthResolver surface is affected.
Doc Writer
-
[recommended] CHANGELOG.md has no
[Unreleased]entry for theapm pack --archiveformat change or the new--archive-formatflag (CHANGELOG.md)
Suggested: Add two bullets under### Addedin[Unreleased]: (1)apm pack --archivenow produces.zipby default instead of.tar.gz; (2)apm pack --archive-format [zip|tar.gz]escape hatch for legacy CI. -
[recommended]
reference/cli/pack.mdoption table describes--archiveas producing.tar.gzand is missing--archive-format(docs/src/content/docs/reference/cli/pack.md)
Line 32: '--archive | off | Produce a .tar.gz archive instead of a directory.' -- now wrong.--archive-formathas no row in the options table.
Suggested: Update line 32 and add--archive-format [zip|tar.gz]row immediately after. Fix line 53 comment and line 206 reference. -
[recommended]
integrations/ci-cd.mdandintegrations/gh-aw.mdshowtar xzfrestore commands that break against the new .zip default (docs/src/content/docs/integrations/ci-cd.md)
ci-cd.md line 211:tar xzf build/*.tar.gz -C ./. gh-aw.md line 126: similar claim. These will fail at runtime for upgrading users.
Suggested: Update restore guidance tounzip -d ./ build/*.zip, or show--archive-format tar.gzexplicitly for pipelines requiring tar.gz. -
[recommended]
packages/apm-guide/.apm/skills/apm-usage/commands.md-- apm pack options column missing--archive-format(packages/apm-guide/.apm/skills/apm-usage/commands.md)
Line 83 lists--archivebut not--archive-format. This is the authoritative machine-readable CLI summary consumed by agent skills.
Suggested: Append--archive-format [zip|tar.gz](default zip) to the apm pack options cell. -
[nit]
reference/cli/unpack.mdandreference/cli/install.mdstill describe only.tar.gzas a valid bundle archive format (docs/src/content/docs/reference/cli/unpack.md)
Suggested: Change 'a .tar.gz archive' to 'a .zip or .tar.gz archive' in unpack.md. Add .zip to packed bundles description in install.md.
Test Coverage Expert
-
[blocking] New ZIP-extraction security checks in unpacker.py -- path traversal, absolute path, external_attr symlink -- have no test; tar.gz equivalents are well covered (
src/apm_cli/bundle/unpacker.py)
Probed: (a) grep'd tests/ for 'external_attr' combined with 'unpack_bundle' -- zero matches; (b) read tests/unit/test_unpacker.py in full -- security tests at lines 191 and 205 exercise the directory bundle path, NOT the ZIP extraction path; (c) grep'd for 'zip.*traversal' and 'unpack_bundle.*zip' -- zero hits. By contrast, tar.gz equivalents DO have tests:test_unpack_with_path_traversal_tarball(test_wave4_pure_logic_coverage.py:945),test_unpack_with_symlink_tarball(test_wave4_pure_logic_coverage.py:962).
Proof (missing):tests/unit/test_unpacker.py::test_unpack_zip_rejects_path_traversal_member-- proves: apm unpack rejects ZIP archives whose member names contain..or absolute paths, and rejects Unix symlinks stored via external_attr [secure-by-default] -
[recommended]
--archive-format tar.gzCLI flag and packer.py tar.gz branch are untested at every tier (src/apm_cli/bundle/packer.py)
grep'd tests/ for 'archive_format', 'archive-format', 'archive_format.*tar' -- zero matches in executable test code. The unit testtest_pack_archivecallspack_bundle(project, out, archive=True)without archive_format, exercising only the default zip branch. packer.py lines 277-283 (the tar.gz branch) has zero test coverage.
Proof (missing):tests/integration/test_wave6_init_pack_coverage.py::test_pack_archive_format_tar_gz-- proves:apm pack --archive --archive-format tar.gzproduces exactly one .tar.gz archive in the build directory, not a .zip [devx, portability]
Performance Expert
-
[recommended] ZIP archives are 30-127% larger than tar.gz for typical APM bundle content; the size regression is real and undocumented (
src/apm_cli/bundle/packer.py)
Measured on representative APM bundles: 50 varied files produce ZIP=11.7 KB vs tar.gz=5.1 KB (+127%). Root cause: ZIP DEFLATE resets the compression dictionary at every entry boundary; tar.gz compresses as one stream, benefiting from cross-file pattern matching across YAML/Markdown skill files.
Suggested: Add a one-line note to the CHANGELOG: '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 when archive size is a priority.' -
[recommended] compresslevel is unset in both ZipFile constructors; default (zlib level 6) is correct but not observable (
src/apm_cli/bundle/packer.py:286)
Both packer.py:286 and plugin_exporter.py:662 call ZipFile without compresslevel. Consider exposing as an internal constant_ZIP_COMPRESS_LEVEL = 6for observability. -
[recommended] No perf benchmarks accompany the default archive format switch; size and speed trade-offs are unquantified in the PR
The PR changes the default archive format for all users with no test measuring archive size, pack wall-time, or unpack wall-time. A benchmark comment in the PR body establishes a regression baseline. -
[nit] sorted(bundle_dir.rglob(*)) overhead is negligible -- no change warranted (
src/apm_cli/bundle/packer.py)
Measured at 200 entries: sort overhead = 0.67 ms vs compression I/O phase of 60-321 ms. The sort is valuable for deterministic archive member ordering. No change warranted. -
[nit] Two-pass extraction in unpacker.py is not actually two disk passes (
src/apm_cli/bundle/unpacker.py)
ZipFile.infolist() returns a list populated during init; the central directory is read from disk exactly once. The validation loop iterates an in-memory list. Overhead measured: 0.7 ms for 200 files vs 26.3 ms extraction. Negligible. -
[nit] cleanup_temp handling is correct; no temp dir leak identified (
src/apm_cli/bundle/unpacker.py)
cleanup_temp is set to True and temp_dir is assigned before the try block. Exception handling and the outer finally block both handle cleanup correctly across all branches.
This panel is advisory. It does not block merge. Re-apply the
panel-review label after addressing feedback to re-run.
Generated by PR Review Panel for issue #1720 · sonnet46 22.4M · ◷
Replace tarfile/gzip with zipfile (ZIP_DEFLATED) in both pack_bundle (apm format) and export_plugin_bundle (plugin format). The unpacker gains .zip support as the primary path; .tar.gz extraction is kept for backward compatibility with existing bundles. Aligns apm pack --archive with apm publish, which switched to .zip in microsoft#1695, making the whole toolchain consistent on a single archive format. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Addressed the full panel review comments: detect_local_bundle gap: detect_local_bundle() now handles .zip via a new _extract_zip_bundle() helper that runs the full security chain — path traversal rejection, absolute path rejection, and Unix symlink detection via external_attr — before extractall. _looks_like_archive() is intentionally not changed: adding .zip there would silently route zip files into the tar branch and fail with TarError. The .zip branch is an explicit check that runs before the tar branch. The IM7 guard in install.py is extended to cover .zip as well. ZIP security tests: TestUnpackZipSecurity in test_unpacker.py now covers path traversal, absolute path, and external_attr-based symlink detection, mirroring the existing tar.gz equivalents. The --archive-format tar.gz round-trip and invalid-format rejection are also covered in test_packer.py and test_pack_cli_surface.py. Also addressed in this branch rather than deferred: ensure_path_within added to both archive branches in packer.py (mirrors what plugin_exporter.py already did); ZIP bomb guard before extractall in unpacker.py — rejects archives over 512 MB uncompressed or 10,000 entries (the check reads file_size, the declared uncompressed size, so it catches high-compression-ratio attacks before extraction starts); CHANGELOG [Unreleased] entry with the size tradeoff disclosure; ci-cd.md and gh-aw.md restore commands updated from tar xzf to unzip; pack.md options table updated with --archive-format. Also picked up from the advisory findings: --archive-format warns when passed without --archive; install.py user-facing strings updated to include .zip; help text rewritten with the Claude Code / legacy CI framing. Summary for the review: the panel reviewed new code, found real issues, but most of those issues were pre-existing on the tar.gz path too. But as those are security issues I think we must patch them at once. Also, fixed a small issue whereby publish_version in the registry client had Content-Type: application/gzip hard-coded from when publish used to produce tarballs. The archive bytes being uploaded were already a .zip, but the server was being told it was gzip. Fixed to application/zip, parameter renamed from tarball_bytes to archive_bytes |
APM Review Panel:
|
| Persona | B | R | N | Takeaway |
|---|---|---|---|---|
| Python Architect | 1 | 2 | 2 | Correctly switches ZIP to primary and adds --archive-format for backward-compat. ZIP bomb guard absent from local_bundle._extract_zip_bundle(); rglob archive block duplicated in packer.py and plugin_exporter.py; three divergent ZIP validation implementations. |
| CLI Logging Expert | 0 | 2 | 3 | CLI changes are well-scoped. No-op warning missing an action hint; ZIP-bomb ValueError reports raw byte integers instead of human-readable MB. |
| DevX UX Expert | 0 | 2 | 2 | Core ergonomics sound. Skill prose in commands.md still lists only .tar.gz for bundle install; --archive-format without --archive should be UsageError not a warning. |
| Supply Chain Security Expert | 0 | 2 | 3 | ZIP extraction in unpacker.py is solid. Bomb guard absent from _extract_zip_bundle(); guard sums attacker-controlled file_size field not measured output bytes. |
| OSS Growth Hacker | 0 | 2 | 2 | Strong net-positive for adoption. unpack.md and install.md still describe .tar.gz as the canonical format, creating a mismatch for new users. |
| Doc Writer | 0 | 5 | 5 | pack.md and ci-cd.md edits accurate. Five additional pages (pack-a-bundle.md, releasing-from-any-ci.md, install.md, unpack.md, commands.md) contradict the new .zip default -- releasing-from-any-ci.md CI globs will silently break. |
| Test Coverage Expert | 0 | 3 | 1 | Pack unit coverage solid. No integration test for apm install bundle.zip (new default format); no regression trap for the ZIP bomb guard in unpacker.py. |
| Performance Expert | 0 | 1 | 3 | Format-correctness PR with negligible performance impact. Missing bomb guard in _extract_zip_bundle() is the only substantive gap. |
B = blocking-severity findings, R = recommended, N = nits.
Counts are signal strength, not gates. The maintainer ships.
Top 5 follow-ups
- [Python Architect] (blocking-severity) Port
_MAX_ENTRIESand_MAX_UNCOMPRESSEDbomb guard fromunpacker.pylines 85-96 tolocal_bundle._extract_zip_bundle(); hoist both constants to module level for sharing --apm install <crafted.zip>is unguarded whileapm unpack <crafted.zip>is rejected cleanly; missing test on a secure-by-default surface inherits blocking weight; the fix is five lines. - [Doc Writer] Fix
releasing-from-any-ci.md: replace allbuild/*.tar.gzglobs (10+ instances across four CI provider sections) withbuild/*.zip; add a migration callout -- CI pipelines following the docs will silently produce zero release artifacts once--archivedefaults to.zip; confirmed at lines 24, 30-31, 99, 104, 123-129, 146-151, 174-180. - [Doc Writer] Update
pack-a-bundle.md,install.md,unpack.md, andcommands.mdto show.zipas primary and.tar.gzas legacy escape hatch -- five doc surfaces still describe.tar.gzas canonical, giving new users a wrong mental model on day one; converges findings from doc-writer, oss-growth-hacker, and devx-ux-expert. - [Test Coverage Expert] Add hermetic integration test
TestInstallLocalBundleE2E::test_install_local_bundle_from_zipintests/integration/test_install_local_bundle_e2e.pycovering detect ->_extract_zip_bundle-> install -> file-deployment -- outcome: missing on a portability-by-manifest surface; the primary distribution path in this PR has no integration-tier guardrail. - [DevX UX Expert] Replace the
--archive-format-without---archivewarning withclick.UsageErrorusingctx.get_parameter_source('archive_format') != ParameterSource.DEFAULT; fold the action-hint into the error message; add a test intest_pack_cli_surface.py-- current warning fires only fortar.gz(not explicitzip), inconsistent with APM flag-misuse pattern.
Architecture
classDiagram
direction TB
class BuildOptions {
<<ValueObject>>
+bundle_archive bool
+bundle_archive_format str
}
class PackResult {
<<ValueObject>>
+bundle_path Path
+files list
}
class LocalBundleInfo {
<<ValueObject>>
+is_archive bool
+temp_dir Path
}
class packer {
<<Module>>
+pack_bundle(archive_format) PackResult
}
class plugin_exporter {
<<Module>>
+export_plugin_bundle(archive_format) PackResult
}
class unpacker {
<<Module>>
+unpack_bundle(bundle_path) UnpackResult
}
class local_bundle {
<<Module>>
+detect_local_bundle(path) LocalBundleInfo
-_extract_zip_bundle(path) LocalBundleInfo
}
class extractor {
<<Module>>
+extract_archive(data, digest, dest) str
-_safe_extract_zip(zf, dest)
}
class BuildOrchestrator {
<<Orchestrator>>
+run(options) BuildResult
}
BuildOrchestrator ..> BuildOptions : reads
BuildOrchestrator ..> packer : fmt=apm
BuildOrchestrator ..> plugin_exporter : fmt=plugin
packer ..> PackResult : produces
plugin_exporter ..> PackResult : produces
local_bundle ..> LocalBundleInfo : produces
class packer:::touched
class plugin_exporter:::touched
class unpacker:::touched
class local_bundle:::touched
classDef touched fill:#fff3b0,stroke:#d47600
flowchart TD
P1["apm pack --format apm --archive"] --> W1["pack_bundle packer.py:277"]
P2["apm pack --format plugin --archive"] --> W2["export_plugin_bundle plugin_exporter.py:650"]
W1 --> WL["FS: identical rglob write-archive block\narchive_format==tar.gz: tarfile.open w:gz\nelse: ZipFile ZIP_DEFLATED + zf.write"]
W2 --> WL
I1["apm install local.zip"] --> R2["_extract_zip_bundle local_bundle.py:201"]
I2["apm unpack bundle.zip"] --> R1["unpack_bundle unpacker.py:59"]
I3["registry install path"] --> R3["_safe_extract_zip extractor.py:202"]
R1 --> RV1["validate path+symlink\nbomb guard: _MAX_ENTRIES=10k _MAX_UNCOMPRESSED=512MB\nunpacker.py:85-97"]
R2 --> RV2["validate path+symlink\nNO bomb guard\nlocal_bundle.py:212-230"]
R3 --> RV3["stream per-member via zf.open\nno extractall call\nextractor.py:209-233"]
RV1 --> EX1["FS: zf.extractall temp_dir"]
RV2 --> EX2["FS: zf.extractall temp_dir"]
style RV2 fill:#ffcccc,stroke:#cc0000
style WL fill:#fff3cc,stroke:#cc8800
Recommendation
Fix two items before merge: (1) port the ZIP bomb guard (_MAX_ENTRIES, _MAX_UNCOMPRESSED) from unpacker.py to local_bundle._extract_zip_bundle() -- five lines, eliminating an unguarded resource-exhaustion path on the new default distribution format; (2) update releasing-from-any-ci.md to replace build/*.tar.gz globs with build/*.zip across all four CI provider sections, preventing silent pipeline breakage for any team following the docs. Both fixes are bounded, reviewable in the same PR, and do not touch the core design. All remaining findings -- stale docs in pack-a-bundle.md, install.md, unpack.md, commands.md; UsageError vs warning; integration test for apm install bundle.zip; the pre-existing file_size accounting weakness in the bomb guard -- are queued as immediate follow-ups to land before the next release note. The core design is correct and the growth signal is real; two trivial fixes are all that stand between this PR and a clean merge.
Full per-persona findings
Python Architect
-
[blocking] ZIP bomb guard absent in
local_bundle._extract_zip_bundle()while present inunpacker.unpack_bundle()atsrc/apm_cli/bundle/local_bundle.py:201
Bothunpack_bundle()and_extract_zip_bundle()accept user-supplied.zipfiles and callzf.extractall()after per-member validation.unpacker.pyguards with_MAX_ENTRIES=10,000and_MAX_UNCOMPRESSED=512 MBat lines 85-97._extract_zip_bundle()applies identical path-traversal and symlink checks but has no bomb guard:apm install evil.zipwith 10,001 entries or >512 MB uncompressed will exhaust file descriptors or RAM duringextractall(), whileapm unpack evil.zipis rejected cleanly.
Suggested: Add the same guard immediately afterzf.infolist()in_extract_zip_bundle():if len(members) > _MAX_ZIP_ENTRIES or sum(m.file_size for m in members) > _MAX_ZIP_UNCOMPRESSED: shutil.rmtree(temp_dir, ignore_errors=True); return None. Hoist both constants to module level for sharing. -
[recommended] Archive-creation rglob block duplicated verbatim in
packer.pysection 10 andplugin_exporter.pysection 15 atsrc/apm_cli/bundle/packer.py:277
Both files contain byte-for-byte identical archive-creation logic:archive_formatbranch,ensure_path_within()guard,rglob('*')loop with symlink filter, andtarfile.add/zf.writewith the same arcname pattern. A future third format requires editing both files; a symlink-filter regression in one will not propagate to the other.
Suggested: Extract towrite_bundle_archive(bundle_dir, output_dir, name, archive_format) -> Pathhelper inbundle/packer.py. -
[recommended] Three independent ZIP member-validation implementations with diverging safety properties at
src/apm_cli/bundle/unpacker.py:65
extractor._safe_extract_zip()checks the full 16-bit Unix mode word;unpacker.pyandlocal_bundle.pycheck only the upper 4-bit type field. Path-traversal predicates are copy-pasted across files.
Suggested: Addvalidate_zip_member(member: zipfile.ZipInfo) -> Nonetoutils/path_security.pyand route all three paths through it. -
[nit]
_MAX_UNCOMPRESSEDand_MAX_ENTRIESdefined as block-local variables insideunpacker.pyif-branch atsrc/apm_cli/bundle/unpacker.py:85
Constants invisible to module-level tooling and cannot be referenced by callers or shared withlocal_bundle.py.
Suggested: Hoist to module level:_MAX_ZIP_UNCOMPRESSED = 512 * 1024 * 1024and_MAX_ZIP_ENTRIES = 10_000. -
[nit]
_looks_like_archive()only matches.tar.gz/.tgz-- misleading name atsrc/apm_cli/bundle/local_bundle.py:135
A future contributor scanning for archive-type detection will miss the.zipbranch dispatched separately.
Suggested: Rename to_looks_like_tarball().
CLI Logging Expert
-
[recommended] No-op warning missing an action hint at
src/apm_cli/commands/pack.py
APM output principle: every warning must include the fix."--archive-format tar.gz has no effect without --archive"leaves the user to infer the remedy.
Suggested:logger.warning(f"--archive-format {archive_format} has no effect without --archive -- add --archive to produce a .{archive_format} archive") -
[recommended] ZIP-bomb ValueError reports raw byte integers; human-readable units needed for CI operators at
src/apm_cli/bundle/unpacker.py
"629145600 bytes exceeds 536870912 bytes" requires mental arithmetic. CI operators need MB units to quickly assess severity.
Suggested:raise ValueError(f"ZIP archive uncompressed size {total_size // (1024 * 1024)} MB exceeds limit of {_MAX_UNCOMPRESSED // (1024 * 1024)} MB") -
[nit]
{archive_format!r}renders Python repr quotes in the warning string atsrc/apm_cli/commands/pack.py
Witharchive_format = "tar.gz",!rproduces'tar.gz'with single quotes in user output. Change to bare{archive_format}. -
[nit] Code comment overstates the warning scope at
src/apm_cli/commands/pack.py
Comment: "Warn when --archive-format is set but --archive is not -- the flag is a no-op." But condition isarchive_format != "zip", so explicit--archive-format zipwithout--archiveis silently swallowed. Better: "Warn when a non-default --archive-format is given without --archive." -
[nit] ZIP error says "symlink" while tar.gz branch says "symlink/hardlink" -- minor inconsistency at
src/apm_cli/bundle/unpacker.py
Align phrasing across archive format branches. ZIP has no hardlink concept so "symlink" is correct; make error message style consistent with sibling message.
DevX UX Expert
-
[recommended] Shipped skill prose for
apm install <BUNDLE-PATH>still lists only.tar.gz/.tgzatpackages/apm-guide/.apm/skills/apm-usage/commands.md
Agents loading this skill file at runtime will tell users only.tar.gzarchives are supported -- wrong guidance for the new.zipdefault on day one of merge.
Suggested: Change "or to a.tar.gz/.tgzarchive whose extracted root containsplugin.json" to "or to a.zip,.tar.gz, or.tgzarchive whose extracted root containsplugin.json". -
[recommended]
--archive-formatwithout--archiveshould raiseclick.UsageError, not emit a warning atsrc/apm_cli/commands/pack.py
Guard fires only fortar.gz(not explicitzip), producing inconsistent behavior. APM pattern isUsageErrorfor inapplicable flags (--aswithout a bundle path raisesUsageError). Package managers in the reference set (cargo, pip) error on inapplicable flags.
Suggested:if not archive and ctx.get_parameter_source('archive_format') != click.core.ParameterSource.DEFAULT: raise click.UsageError('--archive-format has no effect without --archive; add --archive to produce an archive.') -
[nit]
tar.gzas a period-containingclick.Choicestring may not complete cleanly in generated shell completions atsrc/apm_cli/commands/pack.py
Validateapm pack --archive-format <TAB>completes correctly in bash, zsh, and fish before shipping. Consider aliasing totgzwith help clarifying it means.tar.gzoutput if completions break. -
[nit]
--archivehelp text cross-references--archive-format, making a simple boolean flag paragraph busy atsrc/apm_cli/commands/pack.py
Suggested: "Produce an archive instead of a directory. Default format: .zip. See --archive-format."
Supply Chain Security Expert
-
[recommended] ZIP bomb guard absent from
local_bundle._extract_zip_bundle()-- asymmetry withunpacker.pyatsrc/apm_cli/bundle/local_bundle.py
apm install <user-supplied-zip>routes through_extract_zip_bundle(). No entry-count or size limit beforezf.extractall(). A crafted ZIP with 50,000 entries can exhaust temp-dir disk space while leaving no actionable error message.
Suggested: Port_MAX_ENTRIESand_MAX_UNCOMPRESSEDguard fromunpacker.pyimmediately beforezf.extractall()in_extract_zip_bundle().
Proof (missing at static): grep for bomb guard inlocal_bundle.pyreturns zero results; same check confirmed present inunpacker.pylines 85-97. -
[recommended] Bomb guard sums attacker-controlled
ZipInfo.file_size, not measured output bytes atsrc/apm_cli/bundle/unpacker.py
A crafted ZIP can declarefile_size=0for every entry while DEFLATE stream expands to gigabytes.sum(m.file_size)would be 0 -- passing the 512 MB guard -- whileextractall()writes unbounded content. APM-produced ZIPs have accurate metadata, butapm unpackandapm installboth accept arbitrary user-supplied archives.
Suggested: Stream extraction counting bytes written, or enforce RLIMIT_FSIZE on the temp_dir before callingextractall().
Proof (manual at static): Python docs: ZipInfo.file_size sourced from central directory record, not from decompression. Python's zipfile.ZipFile does not cross-check actual output size against this field. -
[nit] Entry-count and size bomb guard should precede member-iteration loop for early exit at
src/apm_cli/bundle/unpacker.py
A ZIP with 1,000,000 entries triggers 1,000,000validate_path_segments()calls before the 10,000-entry limit fires. Hoistmembers = zf.infolist()and the bomb guard immediately inside thewith zipfile.ZipFile(...)block before the for-member validation loop. -
[nit] No unit tests cover ZIP bomb guard thresholds at
tests/unit/test_unpacker.py
TestUnpackZipSecuritytests path traversal and symlinks but not_MAX_ENTRIES/_MAX_UNCOMPRESSED. If either constant is removed, no test regresses.
Proof (missing at unit):tests/unit/test_unpacker.py-- proves: bomb guard constants have regression coverage -
[nit] Windows reserved device names (COM1, NUL, CON) pass all path guards at
src/apm_cli/bundle/unpacker.py
PureWindowsPath('COM1').drive == ''(falsy), so device names are not caught. Not a traversal bypass but a platform-specific DoS vector on Windows installations.
Suggested: Add segment-level check:re.compile(r'^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\\..*)?$', re.IGNORECASE)applied to each path segment.
OSS Growth Hacker
-
[recommended]
apm unpackdocs still document.tar.gzexclusively despite this PR adding.zipextraction support atdocs/src/content/docs/reference/cli/unpack.md
Description says "extracts a .tar.gz archive". The PR adds.zipsupport tounpack_bundle()but the docs don't reflect it. New producers whoapm pack --archiveand share a.zipwill see docs that describe only.tar.gz.
Suggested: Update Description and first example to show.zipas primary,.tar.gzas legacy. -
[recommended]
install.mdandcommands.mdstill cite./bundle.tar.gzas canonical packed-bundle example atdocs/src/content/docs/reference/cli/install.md
install.mdPACKAGE_REF says "packed bundles (./bundle.tar.gz)".commands.mdlocal-bundle description still names only.tar.gz/.tgz. New users have the wrong extension for a bundle newly produced byapm pack --archive.
Suggested: Updateinstall.mdto list.zipfirst; updatecommands.mdparagraph. -
[nit] CHANGELOG Changed entry lacks a CI migration one-liner for pipeline maintainers at
CHANGELOG.md
CI pipelines that glob*.tar.gzartifacts will silently break. Append to the bullet: "CI migration: update artifact globs frombuild/*.tar.gztobuild/*.zip, or add--archive-format tar.gzto yourapm packinvocation to preserve the previous default." -
[nit]
publish.mdstill documents.tar.gzas auto-pack format (pre-existing drift from feat(publish): switch publish command archive format from tar.gz to zip #1695) atdocs/src/content/docs/reference/cli/publish.md
CHANGELOG references format alignment withapm publish, butpublish.mdstill describes.tar.gzoutput. Recommend a companion doc-fix PR before the next release note.
Auth Expert -- inactive
No auth-specific files changed. PR is a pure archive-format change with no impact on AuthResolver, token management, credential resolution, or remote-host auth semantics.
Doc Writer
-
[recommended]
producer/pack-a-bundle.mdstill describes.tar.gzas the archive output in three places atdocs/src/content/docs/producer/pack-a-bundle.md
Primary producer guide says "Add --archive to get a single .tar.gz", shows.tar.gzinline comment and a distribution example. New users following this guide will look for a file that no longer exists by default. -
[recommended]
releasing-from-any-ci.mdglobsbuild/*.tar.gzacross all four CI examples -- those globs match nothing once--archiveproduces.zipby default atdocs/src/content/docs/producer/releasing-from-any-ci.md
CI pipeline examples usebuild/*.tar.gzandbuild/*.tar.gz.sha256for sha256 loop and release-create upload. Globs expand to nothing once the default is.zip. Release artifacts silently omitted.
Suggested: Change all globs tobuild/*.zip; add--archiveto theapm packcall; add a migration callout in the section header. -
[recommended]
install.mddescription still references./bundle.tar.gzas the packed bundle format atdocs/src/content/docs/reference/cli/install.md
PACKAGE_REF description says "packed bundles (./bundle.tar.gz)". Install examples show only.tar.gzfor local bundle with--as. -
[recommended]
unpack.mddescription and all examples reference only.tar.gzatdocs/src/content/docs/reference/cli/unpack.md
BUNDLE_PATH description says "accepts either a .tar.gz archive". All five code examples usebundle.tar.gz. The CHANGELOG states.zipis now supported but docs don't reflect it. -
[recommended]
ci-cd.mdupdates file patterns without a migration callout for existing pipelines atdocs/src/content/docs/integrations/ci-cd.md
The "Pack & Distribute" section silently changes the glob without noting this is a change from previous behavior. Teams with existing pipelines hardcoding*.tar.gzpatterns get no warning.
Suggested: Add a :::note migration callout in the "Pack in CI" subsection. -
[nit] CHANGELOG places
--archive-formatunder### Changedinstead of### AddedatCHANGELOG.md
Keep a Changelog convention: new flags go in Added. The bullet even starts with "Add". -
[nit] CHANGELOG references
apm publishformat alignment butpublish.mdstill documents.tar.gzatCHANGELOG.md
Drop theapm publishcross-reference or open a companion task to updatepublish.mdfirst. -
[nit]
producer/index.mdstep 4 description still says.tar.gzatdocs/src/content/docs/producer/index.md
Suggested: Change to "apm pack produces a .zip bundle you can ship offline or to a marketplace". -
[nit]
concepts/glossary.mdbundle definition only mentions.tar.gzatdocs/src/content/docs/concepts/glossary.md
Suggested: "Either a directory or a.zip(default) or.tar.gzarchive containingplugin.jsonat the root." -
[nit]
enterprise/security.mdlocal bundle trust model says.tar.gzonly atdocs/src/content/docs/enterprise/security.md
Suggested: "apm install<bundle>accepts a directory,.zip, or.tar.gzproduced byapm pack."
Test Coverage Expert
-
[recommended] No hermetic integration test for
apm install <bundle.zip>-- the new default archive format attests/integration/test_install_local_bundle_e2e.py
test_install_local_bundle_e2e.pytests directory and tarball installs but has no zip test. The full pathdetect_local_bundle(.zip)->_extract_zip_bundle()->install_local_bundle()-> file deployment is untested at integration tier. A regression in the primary distribution path would be invisible until a user reports it.
Proof (missing at integration-with-fixtures):tests/integration/test_install_local_bundle_e2e.py::TestInstallLocalBundleE2E::test_install_local_bundle_from_zip-- proves: apm install (bundle.zip) deploys plugin bundle files end-to-end [devx,portability-by-manifest] -
[recommended] ZIP bomb guard in
unpacker.pyhas no regression trap attests/unit/test_unpacker.py
_MAX_ENTRIES=10,000and_MAX_UNCOMPRESSED=512 MBhave no test. If either constant is changed or the guard removed, no test catches the regression.
Proof (missing at unit):tests/unit/test_unpacker.py::TestUnpackZipSecurity::test_zip_bomb_entry_count_rejected-- proves: apm install (bloated.zip) is rejected before extraction begins [secure-by-default] -
[recommended]
TestUnpackZipSecurityis unit tier; path-segment validation on a security surface warrants an integration-with-fixtures CLI-level test attests/unit/test_unpacker.py
Unit tests exerciseunpack_bundle()directly with real zip files -- genuine protection. But no CLI-level test assertsapm install <traversal.zip>exits non-zero with a user-readable error message.
Proof (passed at unit):tests/unit/test_unpacker.py::TestUnpackZipSecurity::test_zip_path_traversal_rejected-- proves: unpack_bundle() rejects ZIP path-traversal entries at function level [secure-by-default] -
[nit]
--archive-formatwithout--archiveemits a warning but no test asserts the warning fires attests/unit/commands/test_pack_cli_surface.py
Proof (missing at unit):tests/unit/commands/test_pack_cli_surface.py::TestPackCmdFlags::test_archive_format_without_archive_emits_warning-- proves: --archive-format tar.gz without --archive shows the user a clear warning
Performance Expert
-
[recommended] Missing ZIP bomb guard in
_extract_zip_bundle()-- asymmetric withunpacker.pyatsrc/apm_cli/bundle/local_bundle.py
_extract_zip_bundle()callszf.extractall()with no entry-count or size limit. Disk-exhaustion from crafted ZIP viaapm install ./crafted.zipon the new primary distribution path.
Suggested: Port the five-line guard fromunpacker.py:85-97to_extract_zip_bundle(). -
[nit] Two separate
zf.infolist()calls inunpacker.py-- could be a single merged pass atsrc/apm_cli/bundle/unpacker.py
CPython'sinfolist()returnsself.filelistdirectly; two O(N) passes over the same list is sub-0.1ms for any realistic bundle. Aesthetic tightening only. -
[nit]
sorted(bundle_dir.rglob('*'))eagerly materializes all paths before filtering atsrc/apm_cli/bundle/packer.py
Negligible at bundle sizes (<200 files). Positive side-effect: deterministic archive entry order. No action required. -
[nit] No
compresslevelonZIP_DEFLATED-- default level 6 is appropriate but undocumented atsrc/apm_cli/bundle/packer.py
Suggested: Add comment:# level 6 (zlib default) -- sufficient for agent bundle sizes < 2MB.
This panel is advisory. It does not block merge. Re-apply the
panel-review label after addressing feedback to re-run.
Warning
Firewall blocked 1 domain
The following domain was blocked by the firewall during workflow execution:
pypi.org
To allow these domains, add them to the
network.allowedlist in your workflow frontmatter:
network:
allowed:
- defaults
- "pypi.org"See Network Configuration for more information.
Generated by PR Review Panel for issue #1720 · sonnet46 22.7M · ◷
Adds --archive-format [zip|tar.gz] to apm pack --archive so callers can opt into .tar.gz output. Default remains zip. Threaded through BuildOptions → pack_bundle → export_plugin_bundle (both apm and plugin bundle formats).
|
All findings addressed in da6d293. ZIP bomb guard scope (blocking) The guard was present in unpacker.py (the apm unpack path) but missing entirely from local_bundle._extract_zip_bundle() (the apm install ./bundle.zip path). Fixed: the same guard now applies in both paths. I also hoisted _MAX_ZIP_ENTRIES and _MAX_ZIP_UNCOMPRESSED to module level in both files so the thresholds are visible and easy to adjust, moved the check before the per-member validation loop (previously a million-entry ZIP still triggered a million validate_path_segments calls before being rejected), and updated the error messages to show sizes in MB rather than raw bytes. _looks_like_archive rename Renamed to _looks_like_tarball -- the old name was accurate for the original code but became actively misleading once .zip became the primary format. All three call sites updated. --archive-format without --archive Changed from a logger.warning (which only fired when the value was non-default) to a click.UsageError that fires whenever the flag is explicitly set on the command line without --archive, using ctx.get_parameter_source() to distinguish an explicit value from the default. The error message includes an action hint. releasing-from-any-ci.md CI globs All build/.tar.gz and build/.tar.gz.sha256 patterns replaced with build/.zip / build/.zip.sha256 across the canonical sequence and all four CI provider sections (GitHub Actions, GitLab CI, Jenkins, Azure DevOps). Remaining doc drift Updated pack-a-bundle.md, install.md, unpack.md, producer/index.md, glossary.md, security.md, and the commands.md skill file. The apm unpack reference preserves the mention of legacy .tar.gz support since that command is specifically the deploy path for --format apm tarballs. Tests Added two bomb guard threshold tests to TestUnpackZipSecurity (using monkeypatch to lower the limits so the tests are fast and deterministic) and two --archive-format CLI surface tests to TestPackCmdFlags. |
Summary
.tar.gzto.zipforapm pack --archive. ZIP is the format Claude Code and other plugin hosts expect, gives better Windows compatibility, and aligns withapm publish's auto-pack format.--archive-format [zip|tar.gz]so callers who need.tar.gz(existing CI pipelines, tooling that expects gzip) can opt in explicitly without a breaking change..tar.gzextraction toapm unpack/apm installso existing.tar.gzbundles continue to work after producers migrate to.zip.What changed
bundle/packer.pyimport tarfile; archive block branches onarchive_format; passes format toexport_plugin_bundlebundle/plugin_exporter.pyarchive_formatparam + branch in section 15bundle/unpacker.py.zipextraction path; legacy.tar.gzpath kept for backward compat; symlink detection viaexternal_attrfor zip entriescommands/pack.py--archivehelp updated; new--archive-format [zip|tar.gz]option (defaultzip)core/build_orchestrator.pybundle_archive_format: str = "zip"field inBuildOptions; threaded topack_bundleUsage
Test plan
pytest tests/unit/test_packer.py tests/unit/test_unpacker.py tests/integration/test_pack_unpack_e2e.py— all passpytest tests/unit/commands/test_pack_cli_surface.py tests/unit/commands/test_pack_phase3.py— all passapm pack --helpshows--archive-format [zip|tar.gz] [default: zip]apm pack --archiveproduces.zipapm pack --archive --archive-format tar.gzproduces.tar.gzapm install <bundle>.tar.gzstill works (backward compat)