fix(install): dereference in-package symlinks on local install (#1668)#1676
fix(install): dereference in-package symlinks on local install (#1668)#1676danielmeppiel wants to merge 2 commits into
Conversation
Local-path install now materializes in-package symlinks as real files, matching the behavior of remote install (which uses robust_copytree with symlinks=False / git checkout semantics). Before this fix, _copy_local_package used shutil.copytree(symlinks=True), which preserved symlinks in apm_modules/. The downstream ignore_non_content filter in security/gate.py then silently dropped every symlink, causing referenced files to go missing without any warning. Changes: - Add _copy_tree_dereferencing_validated() to local_content.py: walks the source tree atomically (resolve -> ensure_path_within -> copy2 per entry). In-package symlinks are materialized as real files. Symlinks whose resolved target escapes the package root raise PathTraversalError immediately (hard-fail, never warn-and-skip). - Remove symlinks=True / stale SECURITY comment from _copy_local_package. - Add regression tests: in-package symlink materialized; escaping symlink hard-fails; non-symlink baseline unchanged. - Update test_local_deps.py: test_copy_preserves_symlinks_without_following now asserts the correct hard-fail behavior instead of the old preserve-as- symlink behavior. - Add threat-model note to docs/enterprise/security.md describing the dereference behavior and containment guarantee. Security guardrails (all from the maintainer-approved brief): 1. ensure_path_within() validates every symlink target before copy. 2. TOCTOU-safe: atomic per-file resolve->validate->copy2 sequence. 3. Escaping symlinks hard-fail (PathTraversalError), never warn-and-skip. 4. Only in-package symlinks dereferenced; external symlinks rejected. 5. Threat-model note added to security docs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR makes local-path apm install <dir> stage in-package symlinks as real files (matching remote install behavior) and hard-fails on symlinks whose resolved targets escape the package root, with accompanying unit-test coverage and security documentation updates.
Changes:
- Replaced local staging
copytree(..., symlinks=True)behavior with a validated, symlink-dereferencing copy routine. - Added regression/security tests covering in-package symlink materialization and escaping-symlink failure behavior.
- Updated enterprise security docs to describe the local-install containment guarantee.
Show a summary per file
| File | Description |
|---|---|
| src/apm_cli/install/phases/local_content.py | Adds validated dereferencing copy helper and switches local staging to use it. |
| tests/unit/install/test_local_content_symlink_deref.py | New regression/security tests for local install symlink dereference and escape handling. |
| tests/unit/test_local_deps.py | Updates existing test expectation to assert hard-fail on escaping symlink. |
| docs/src/content/docs/enterprise/security.md | Documents the local-install symlink dereference + containment posture. |
Copilot's findings
- Files reviewed: 4/4 changed files
- Comments generated: 3
| # Dereference in-package symlinks atomically (fix for #1668). | ||
| # Each symlink is resolved -> validated within `local` -> copied as a real | ||
| # file, matching the behavior of the remote install path which uses | ||
| # robust_copytree with symlinks=False. Symlinks that escape the package | ||
| # root raise PathTraversalError immediately (hard-fail, not warn-and-skip). | ||
| _copy_tree_dereferencing_validated(local, install_path, local) | ||
| return install_path |
| if resolved.is_dir(): | ||
| _copy_tree_dereferencing_validated(resolved, dst_entry, pkg_root) | ||
| else: | ||
| shutil.copy2(resolved, dst_entry) | ||
| shutil.copystat(entry, dst_entry) |
| def test_escaping_install_path_is_not_partially_created(self, tmp_path: Path) -> None: | ||
| """When an escaping symlink is found the install_path is not left partial. | ||
|
|
||
| The install must either complete fully or fail atomically. A partial | ||
| tree in apm_modules/ could cause subsequent installs to behave | ||
| incorrectly. | ||
| """ |
…anup, and extended tests Folds panel follow-ups on the security surface introduced by #1668: - Add visited-set cycle guard to _copy_tree_dereferencing_validated to detect circular directory-symlink chains deterministically (O(1), independent of OS ELOOP platform limits). Previously, circular in-package dir symlinks would overflow the Python call stack rather than producing a clear PathTraversalError. - Fix copystat calls to use the resolved path (not the symlink entry) as the stat source for symlink entries. The old call was effectively a no-op (os.stat follows symlinks, giving the same result), but was misleading to readers. - Wrap the _copy_tree_dereferencing_validated call in _copy_local_package with a try/except PathTraversalError + safe_rmtree cleanup to guarantee that no partial install_path content remains on failure. The escaping-symlink test already asserted this, but production code was relying on the outer error propagation without cleanup. - Remove [x] prefix from PathTraversalError message bodies. The rendering layer owns the prefix; embedding [x] inside the exception string causes double-prefix output when the caller's diagnostics handler wraps the error. - Add actionable fix hint to the escaping-symlink error message. - Add three new test classes: TestBrokenSymlink (broken/dangling symlink raises PathTraversalError), TestSymlinkToDirectory (in-package symlink-to-dir materializes as real directory tree), TestCircularSymlink (circular dir symlinks detected and aborted). Brings test file to 9 tests across 6 classes. - Fix docs/enterprise/security.md: soften 'Symlinks are never followed' intro to scope it accurately, update 'resolved atomically' to 'resolved per-file' to match the TOCTOU comment in the code, remove duplicate closing sentence, add visited-set item and cross-link to path-traversal-prevention section. - Add CHANGELOG [Unreleased] Fixed entry for #1668. Mutation-break gate: confirmed that removing the ensure_path_within guard causes TestEscapingSymlinkHardFails tests to fail. Addresses panel follow-ups: cycle guard (supply-chain-security-expert + python-architect, converged), partial-copy cleanup (supply-chain-security-expert), broken-symlink test (test-coverage-expert), copystat fix (python-architect), error message prefix (cli-logging-expert), doc accuracy (doc-writer), CHANGELOG entry (doc-writer). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
APM Review Panel:
|
| Persona | B | R | N | Takeaway |
|---|---|---|---|---|
| Python Architect | 0 | 2 | 1 | Architecturally sound private helper; per-file resolve->guard->copy is TOCTOU-safe. |
| CLI Logging Expert | 0 | 1 | 1 | [x] prefix in exception body causes double-prefix rendering; folded. |
| DevX UX Expert | 0 | 0 | 2 | Correct fix; local/remote install parity is right. No UX regressions. |
| Supply Chain Security Expert | 0 | 4 | 0 | Containment validated per-symlink; TOCTOU acceptable; cycle guard and cleanup recommended. |
| OSS Growth Hacker | 0 | 0 | 0 | Strong trust-building PR; enterprise-grade supply-chain hygiene signal. |
| Auth Expert | -- | -- | -- | Inactive: PR touches only local file copy logic; no auth surfaces changed. |
| Doc Writer | 0 | 5 | 2 | Accuracy fixes needed; majority folded. |
| Test Coverage Expert | 0 | 1 | 1 | Security regression trap confirmed; broken-symlink and dir-symlink tests added. |
B = blocking-severity findings, R = recommended, N = nits.
Counts are signal strength, not gates. The maintainer ships.
Top 5 follow-ups
-
[Supply Chain Security Expert] Add visited-set cycle guard for recursive directory symlink traversal -- OS ELOOP is platform-dependent (40 hops Linux, fewer macOS). Explicit visited-set is O(1), deterministic, and testable. Both security and architecture panelists converged on this independently. FOLDED in f301b57.
-
[Test Coverage Expert] Add broken/dangling symlink error path test (OSError -> PathTraversalError) -- outcome:missing on secure-by-default surface. The OSError catch-and-wrap branch is user-visible but untested. FOLDED in f301b57.
-
[Supply Chain Security Expert] Wrap _copy_tree_dereferencing_validated in try/except PathTraversalError with safe_rmtree cleanup -- Partial-copy dangling content on malicious packages is a real attack surface. FOLDED in f301b57.
-
[Doc Writer] Fix section intro contradiction and soften 'resolved atomically' overclaim in security.md -- Security docs that contradict implementation erode enterprise audit trust. FOLDED in f301b57.
-
[CLI Logging Expert] Remove [x] prefix from PathTraversalError message body to avoid double-prefix rendering -- Double [x][x] in terminal output looks broken and undermines CLI polish. FOLDED in f301b57.
Folded in this run (commit f301b57)
- Circular symlink visited-set guard (python-architect + supply-chain-security-expert)
- Partial-copy cleanup on PathTraversalError (supply-chain-security-expert)
- copystat(resolved, ...) instead of copystat(entry, ...) for symlink entries (python-architect)
- prefix removed from PathTraversalError messages (cli-logging-expert)
- Escaping-symlink error now includes fix suggestion (cli-logging-expert)
- Broken symlink test: TestBrokenSymlink class (test-coverage-expert)
- Symlink-to-directory test: TestSymlinkToDirectory class (test-coverage-expert)
- Circular symlink test: TestCircularSymlink class (test-coverage-expert)
- docs/enterprise/security.md: intro contradiction fixed, 'atomically' -> 'per-file', duplicate sentence removed, visited-set item added, cross-link to Path traversal prevention (doc-writer)
- CHANGELOG [Unreleased] Fixed entry for [BUG] Local-path install drops symlinked skill references; remote install keeps them #1668 (doc-writer)
Copilot signals reviewed
No Copilot inline comments found on this PR (copilot_rounds: 1, copilot_drained: true).
Deferred
- Integration test through real install pipeline (supply-chain-security-expert): scope-crossing -- would require tests/integration/ infrastructure not in this PR's scope.
- Cross-link style/issue citation cleanup in security.md (doc-writer): style convention not caused by this change.
- PermissionError handling in iterdir (python-architect): pre-existing pattern not introduced by this PR.
Lint evidence
$ uv run --extra dev ruff check src/ tests/ && uv run --extra dev ruff format --check src/ tests/
All checks passed!
1225 files already formatted
$ uv run --extra dev python -m pylint --disable=all --enable=R0801 --min-similarity-lines=10 --fail-on=R0801 src/apm_cli/
Your code has been rated at 10.00/10
$ bash scripts/lint-auth-signals.sh
[+] auth-signal lint clean
CI evidence (post-fold push)
CI GREEN on commit f301b57: all 15 checks passed.
Mergeability
| #PR | sha | CEO stance | iterations | folds | deferrals | copilot rounds | ci_status | mergeable | merge_state | notes |
|---|---|---|---|---|---|---|---|---|---|---|
| #1676 | f301b57 | ship_with_followups | 1 | 10 | 3 | 1 | pending | MERGEABLE | BLOCKED | awaiting CI + human review |
fix(install): dereference in-package symlinks on local install
TL;DR
Local-path
apm installnow materializes in-package symlinks as real files,matching the behavior of remote install. A symlink whose resolved target escapes
the package root causes a hard-fail (
PathTraversalError), never a silent drop.All five mandatory security guardrails from the maintainer brief are implemented.
Closes #1668
Problem (WHY)
The divergence had two root causes:
_copy_local_packagecalledshutil.copytree(..., symlinks=True), preservingevery symlink in
apm_modules/rather than materializing its content.ignore_non_content(security/gate.py) excludedall symlinks from the copy into target directories -- including in-package
ones that were safe to follow.
The old code also carried an explicit
SECURITYcomment acknowledging an openTOCTOU window and deferring the fix -- this PR closes that window.
Approach (WHAT)
local_content.pycopytree(symlinks=True)with_copy_tree_dereferencing_validated()resolve(strict=True)->ensure_path_within()->copy2PathTraversalErrorimmediately; no warn-and-skip, no partial copyignore_non_content(gate.py)docs/enterprise/security.mdImplementation (HOW)
src/apm_cli/install/phases/local_content.pyAdded
_copy_tree_dereferencing_validated(src, dst, pkg_root): walks the sourcetree recursively. For each symlink entry it atomically calls
entry.resolve(strict=True), passes the result toensure_path_within(resolved, pkg_root)(the #596 containment helper), thenshutil.copy2(resolved, dst_entry)for files or recurses for directories. Any symlink escaping
pkg_rootraisesPathTraversalErrorwith a human-readable message and aborts the entire copy.Replaced the
shutil.copytree(symlinks=True)call and the accompanyingunresolved TOCTOU note with a single call to the new helper.
tests/unit/install/test_local_content_symlink_deref.py(new)Six typed-coverage tests written as a failing regression trap before the fix:
TestInPackageSymlinkIsDerefed-- in-package symlink materialized as real fileTestEscapingSymlinkHardFails-- escaping symlink raisesPathTraversalError; no partial copyTestNonSymlinkFilesUnchanged-- non-symlink baseline unchangedtests/unit/test_local_deps.pyUpdated
test_copy_preserves_symlinks_without_followingto assert the newcorrect behavior (hard-fail) instead of the old incorrect behavior
(preserved-as-symlink with silent downstream drop).
docs/src/content/docs/enterprise/security.mdAdded "Local-install symlink dereference and containment guarantee" subsection
under Symlink handling, documenting all four security properties.
Diagrams
flowchart TD A[_copy_local_package called] --> B[_copy_tree_dereferencing_validated] B --> C{entry type?} C -- regular file --> D[shutil.copy2 to dst] C -- directory --> E[recurse] C -- symlink --> F[resolve strict=True] F --> G{resolved within pkg_root?} G -- yes --> H[copy2 resolved content to dst] G -- no --> I[raise PathTraversalError<br/>install aborted] D --> J[continue to next entry] H --> J E --> JsequenceDiagram participant Caller as _copy_local_package participant Helper as _copy_tree_dereferencing_validated participant PW as ensure_path_within participant FS as shutil.copy2 Caller->>Helper: src=local, dst=install_path, pkg_root=local loop for each entry Helper->>Helper: entry.is_symlink()? alt is symlink Helper->>Helper: resolve(strict=True) Helper->>PW: ensure_path_within(resolved, pkg_root) alt escapes pkg_root PW-->>Helper: PathTraversalError Helper-->>Caller: PathTraversalError (install aborted) else within pkg_root Helper->>FS: copy2(resolved, dst_entry) end else regular file Helper->>FS: copy2(entry, dst_entry) end end Helper-->>Caller: return install_pathTrade-offs
apm_modules/, downstream filter drops it without warning. New: hard-fail.Authors who had escaping symlinks (even unintentionally) now see an error
rather than silent data loss -- this is strictly safer.
ignore_non_contentchange. By fixing the staging phase, the deployfilter naturally handles in-package content as regular files. Keeping the
filter intact preserves its protection for any residual symlinks from other
code paths.
copystaton symlinks. Callingshutil.copystat(entry, dst_entry)afterthe dereference preserves the link's mtime, not the target's -- acceptable for
a package staging copy where timestamps are not semantically significant.
Benefits
in-package symlinks to share reference content (e.g. shared-contract.md).
a silent no-op, closing a latent attack surface.
validate, and copy are now a single atomic sequence per file.
threat model.
Validation evidence
Scenario Evidence
TestInPackageSymlinkIsDerefed::test_in_package_symlink_materialized_as_real_fileTestEscapingSymlinkHardFails::test_symlink_escaping_package_root_raisesTestEscapingSymlinkHardFails::test_escaping_install_path_is_not_partially_createdTestNonSymlinkFilesUnchanged::test_regular_files_copied_intactHow to test
apm install /tmp/symlink-demo --target claudeand verify.claude/skills/demo/references/shared.mdis a regular file with correct content.pytest tests/unit/install/test_local_content_symlink_deref.py -v--all 6 tests must pass.
ln -s /etc/hosts /tmp/symlink-demo/evil),run
apm install /tmp/symlink-demoand confirm the install fails with a clearPathTraversalErrormessage naming the offending symlink.