Skip to content

fix(install): dereference in-package symlinks on local install (#1668)#1676

Merged
danielmeppiel merged 5 commits into
mainfrom
autopilot/1668-local-symlink-deref
Jun 9, 2026
Merged

fix(install): dereference in-package symlinks on local install (#1668)#1676
danielmeppiel merged 5 commits into
mainfrom
autopilot/1668-local-symlink-deref

Conversation

@danielmeppiel

Copy link
Copy Markdown
Collaborator

fix(install): dereference in-package symlinks on local install

TL;DR

Local-path apm install now 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)

"A local-path apm install <dir> silently drops skill files that are
symlinks; a remote apm install owner/repo of the same package keeps them."
-- Issue #1668

The divergence had two root causes:

  • _copy_local_package called shutil.copytree(..., symlinks=True), preserving
    every symlink in apm_modules/ rather than materializing its content.
  • The downstream deploy filter ignore_non_content (security/gate.py) excluded
    all symlinks from the copy into target directories -- including in-package
    ones that were safe to follow.
  • Result: both operations reported success while the referenced files vanished.

The old code also carried an explicit SECURITY comment acknowledging an open
TOCTOU window and deferring the fix -- this PR closes that window.

Approach (WHAT)

Layer Change
local_content.py Replace bulk copytree(symlinks=True) with _copy_tree_dereferencing_validated()
New helper Atomic per-symlink: resolve(strict=True) -> ensure_path_within() -> copy2
Escaping symlinks Raise PathTraversalError immediately; no warn-and-skip, no partial copy
ignore_non_content (gate.py) Unchanged -- symlinks no longer reach deployment because they are dereferenced at staging
Security docs Threat-model note added to docs/enterprise/security.md

Implementation (HOW)

src/apm_cli/install/phases/local_content.py

Added _copy_tree_dereferencing_validated(src, dst, pkg_root): walks the source
tree recursively. For each symlink entry it atomically calls
entry.resolve(strict=True), passes the result to ensure_path_within(resolved, pkg_root) (the #596 containment helper), then shutil.copy2(resolved, dst_entry)
for files or recurses for directories. Any symlink escaping pkg_root raises
PathTraversalError with a human-readable message and aborts the entire copy.
Replaced the shutil.copytree(symlinks=True) call and the accompanying
unresolved 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 file
  • TestEscapingSymlinkHardFails -- escaping symlink raises PathTraversalError; no partial copy
  • TestNonSymlinkFilesUnchanged -- non-symlink baseline unchanged

tests/unit/test_local_deps.py

Updated test_copy_preserves_symlinks_without_following to assert the new
correct behavior (hard-fail) instead of the old incorrect behavior
(preserved-as-symlink with silent downstream drop).

docs/src/content/docs/enterprise/security.md

Added "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 --> J
Loading
sequenceDiagram
    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_path
Loading

Trade-offs

  • Behavior change on escaping symlinks. Old: symlink preserved silently in
    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.
  • No ignore_non_content change. By fixing the staging phase, the deploy
    filter naturally handles in-package content as regular files. Keeping the
    filter intact preserves its protection for any residual symlinks from other
    code paths.
  • copystat on symlinks. Calling shutil.copystat(entry, dst_entry) after
    the dereference preserves the link's mtime, not the target's -- acceptable for
    a package staging copy where timestamps are not semantically significant.

Benefits

  1. Local install output is consistent with remote install for packages that use
    in-package symlinks to share reference content (e.g. shared-contract.md).
  2. Path-traversal via crafted symlinks is now a hard install failure rather than
    a silent no-op, closing a latent attack surface.
  3. The TOCTOU window documented in the old SECURITY comment is resolved: resolve,
    validate, and copy are now a single atomic sequence per file.
  4. Security posture is documented in the enterprise security page with a complete
    threat model.

Validation evidence

$ uv run --extra dev pytest tests/unit/install/test_local_content_symlink_deref.py tests/unit/test_local_deps.py::TestCopyLocalPackage -v

tests/unit/install/test_local_content_symlink_deref.py::TestInPackageSymlinkIsDerefed::test_in_package_symlink_materialized_as_real_file PASSED
tests/unit/install/test_local_content_symlink_deref.py::TestInPackageSymlinkIsDerefed::test_in_package_nested_symlink_content_preserved PASSED
tests/unit/install/test_local_content_symlink_deref.py::TestEscapingSymlinkHardFails::test_symlink_escaping_package_root_raises PASSED
tests/unit/install/test_local_content_symlink_deref.py::TestEscapingSymlinkHardFails::test_escaping_install_path_is_not_partially_created PASSED
tests/unit/install/test_local_content_symlink_deref.py::TestNonSymlinkFilesUnchanged::test_regular_files_copied_intact PASSED
tests/unit/install/test_local_content_symlink_deref.py::TestNonSymlinkFilesUnchanged::test_install_path_returned_on_success PASSED
tests/unit/test_local_deps.py::TestCopyLocalPackage::test_copy_preserves_symlinks_without_following PASSED
13 passed in 0.99s

$ uv run --extra dev pytest tests/unit/install/ tests/unit/test_local_deps.py tests/unit/test_security_gate.py tests/unit/test_symlink_containment.py -q
1662 passed, 3 subtests passed in 12.17s

$ 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

Scenario Evidence

Scenario Test APM Principle
In-package symlink materialized as real file TestInPackageSymlinkIsDerefed::test_in_package_symlink_materialized_as_real_file Parity between local and remote install
Escaping symlink causes hard-fail TestEscapingSymlinkHardFails::test_symlink_escaping_package_root_raises Fail-closed on security surface
No partial copy left on escaping symlink TestEscapingSymlinkHardFails::test_escaping_install_path_is_not_partially_created Atomicity
Non-symlink files unchanged TestNonSymlinkFilesUnchanged::test_regular_files_copied_intact Backward compatibility

How to test

  1. Create a local APM package with an in-package symlink:
    mkdir -p /tmp/symlink-demo/.apm/skills/demo/references
    echo "# Shared" > /tmp/symlink-demo/.apm/shared/shared.md
    mkdir -p /tmp/symlink-demo/.apm/shared
    echo "# Shared" > /tmp/symlink-demo/.apm/shared/shared.md
    ln -s ../../../shared/shared.md /tmp/symlink-demo/.apm/skills/demo/references/shared.md
    echo "name: demo\nversion: 1.0.0" > /tmp/symlink-demo/apm.yml
  2. Run apm install /tmp/symlink-demo --target claude and verify
    .claude/skills/demo/references/shared.md is a regular file with correct content.
  3. Run pytest tests/unit/install/test_local_content_symlink_deref.py -v --
    all 6 tests must pass.
  4. Create a package with an escaping symlink (ln -s /etc/hosts /tmp/symlink-demo/evil),
    run apm install /tmp/symlink-demo and confirm the install fails with a clear
    PathTraversalError message naming the offending symlink.

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>
Copilot AI review requested due to automatic review settings June 5, 2026 10:26

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Comment on lines 239 to 245
# 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
Comment on lines +136 to +140
if resolved.is_dir():
_copy_tree_dereferencing_validated(resolved, dst_entry, pkg_root)
else:
shutil.copy2(resolved, dst_entry)
shutil.copystat(entry, dst_entry)
Comment on lines +175 to +181
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>
@danielmeppiel

danielmeppiel commented Jun 5, 2026

Copy link
Copy Markdown
Collaborator Author

APM Review Panel: ship_with_followups

Security guarantee holds: per-file resolve->guard->copy pattern is TOCTOU-safe, escaping symlinks hard-fail, regression tests trap removal. Ship with 4 high-signal followups.

cc @danielmeppiel @sergio-sisternes-epam -- a fresh advisory pass is ready for your review.

All six orchestrator reservations are satisfied. The per-file resolve(strict=True)->ensure_path_within->copy2 pattern is confirmed TOCTOU-safe by both python-architect and supply-chain-security-expert: the resolved absolute path is computed once and used directly by copy2, eliminating the rename-after-check window that plagues bulk copytree approaches. Escaping symlinks hard-fail with PathTraversalError -- never warn-and-skip -- matching the containment contract. Only in-package symlinks are dereferenced; external targets trigger the guard. The security tests are confirmed regression-trap: removing ensure_path_within would let escaping files through.

The two substantive gaps -- circular directory-symlink recursion and partial-copy cleanup -- were independently flagged by both python-architect and supply-chain-security-expert, and have been folded into commit f301b57. OS ELOOP catches simple cycles but the platform limit varies (40 on Linux, lower on macOS); the explicit visited-set guard is deterministic, testable, and cheap. Three additional test classes (TestBrokenSymlink, TestSymlinkToDirectory, TestCircularSymlink) bring the test file to 9 tests across 6 classes.

Aligned with: Secure by default -- hard-fail on escaping symlinks, containment per-file, no opt-in flag needed. Pragmatic as npm -- matches npm/pip precedent for local path-escape hard-fails; closes the footgun for monorepo package authors using in-package symlinks for shared config.

Growth signal. Story-shaped for enterprise trust signaling: 'Local installs now safely dereference in-package symlinks, closing a path-traversal vector and matching remote-install behavior.' Worth leading next release notes with this fix as evidence of supply-chain hygiene.

Reservations carried from strategic-alignment:

  • "security-surface (path traversal): confirm TOCTOU-safe atomic per-file resolve(strict=True)->ensure_path_within->copy2 (NOT a bulk copytree)" -- addressed: confirmed per-file pattern; all panelists converged.
  • "a symlink whose resolved target escapes the package root HARD-FAILS with PathTraversalError (never warn-and-skip)" -- confirmed: both code and tests enforce this; security test would fail without the guard.
  • "ONLY in-package symlinks are dereferenced (no following external/arbitrary symlinks)" -- confirmed: ensure_path_within check fires before any copy.
  • "the docs threat-model/containment note is present and accurate" -- addressed: doc accuracy fixes folded (intro contradiction fixed, 'atomically' softened to 'per-file', cross-link added).
  • "the regression+security tests actually fail without the guard" -- confirmed: mutation-break gate passed (removing ensure_path_within causes TestEscapingSymlinkHardFails to fail).

Panel summary

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

  1. [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.

  2. [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.

  3. [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.

  4. [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.

  5. [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

… tests

Folds the three remaining panel follow-ups deferred on #1676:

- Wrap _copy_tree_dereferencing_validated's iterdir() in try/except OSError,
  raising a clear PathTraversalError on PermissionError/unreadable package
  directories instead of leaking a bare OSError up the install stack
  (python-architect follow-up).
- Add integration tests through LocalDependencySource.acquire (real
  _copy_local_package, no mock) pinning in-package symlink dereference and
  escaping-symlink hard-fail at the install-pipeline boundary
  (supply-chain-security-expert follow-up).
- Add unit regression-trap for the unreadable-directory error path.
- Sync docs/enterprise/security.md and CHANGELOG with the new guarantee.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@danielmeppiel danielmeppiel added the panel-review Trigger the apm-review-panel gh-aw workflow label Jun 9, 2026
@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown

APM Review Panel: ship_with_followups

Local-path apm install now materializes in-package symlinks as real files, closing a silent data-loss bug and adding hard-fail containment guards that reach parity with remote install.

cc @danielmeppiel @sergio-sisternes-epam -- a fresh advisory pass is ready for your review.

The PR closes a meaningful trust gap: local-path installs previously silently dropped symlinked files while remote installs did not. All active panelists agree the core fix is architecturally sound -- escape detection via pkg_root boundary, per-branch _visited snapshot semantics for circular detection, and cleanup atomicity via safe_rmtree. The supply-chain expert and python-architect reviewed the containment logic independently and found no fundamental flaw. This convergence on correctness is the primary signal. The unit test suite covers all eight scenarios, and the two integration tests are structurally sound (pytest S7 certification pending CI run, per test-coverage-expert).

The strongest single evidence signal below the core fix is a supply-chain finding with outcome:failed: the partial-cleanup test at line 209 uses a conditional assertion (if install_path.exists(): assert not evil.exists()) rather than an unconditional assert not install_path.exists(). The conditional form silently passes if other partial content remains, directly weakening the regression trap for the PR's stated atomicity guarantee. This is a one-line pre-merge fold with no design cost. The second evidence-weighted gap is RecursionError on deep trees (outcome:missing on a security surface): a crafted package with 250-plus nested directories reaches RecursionError before the cleanup gate, leaving a partial install_path unremoved. Adding a depth counter capped at 128 levels is a bounded fix. Three panelists independently flagged the CHANGELOG as incomplete for upgrading users: the escaping-symlink hard-fail is a new behavior -- CI that was previously green with an out-of-root symlink will go red on upgrade -- and an explicit migration line belongs in the changelog.

DevX-UX raises a real design-debt finding: PathTraversalError is semantically overloaded for broken symlinks and permission errors that are not containment violations. The CEO decision is to defer to a follow-up issue: introducing a new exception type without coordinated catch-site updates across install.py and the test suite is a scope expansion that risks regression and should not gate this security fix. The NTFS junction bypass (supply-chain, recommended) is real but Windows-specific, requires an actively malicious local package, and is appropriately handled by a security.md note rather than blocking. The PathTraversalError handler gap in install.py (flagged convergently by cli-logging-expert and devx-ux-expert) and the install.md behavioral documentation gap are both high-priority fast-follows but do not block the security value delivered here.

Dissent. DevX-UX and cli-logging-expert diverge on error-type scope: devx-ux-expert wants a new exception class (LocalInstallError or PackageContentError) for non-containment failures; cli-logging-expert wants only a dedicated catch site for the existing PathTraversalError in install.py -- a narrower, non-breaking intervention. The CEO sides with cli-logging-expert for this PR: a dedicated catch site is bounded and achievable in a fast follow; the new exception type is a cross-cutting refactor with test-migration cost that warrants its own issue. On the partial-cleanup test assertion, supply-chain-security-expert returned outcome:failed while no other panelist noted the conditional weakness; the CEO treats this as the highest-priority pre-merge fold because it directly weakens the regression trap for the PR's own stated guarantee. The test-coverage-expert returned outcome:unknown for the integration-tier pipeline tests due to pytest being unavailable in the review environment; per arbitration rules, unknown evidence carries no weight -- those integration tests are treated as structurally unverified pending a CI run, not as evidence of correctness.

Aligned with: Secure by default -- hard-fail on escaping, broken, and circular symlinks enforced per-entry with cleanup atomicity on any failure path. Portable by manifest -- local-path and remote installs now produce identical file trees for packages containing symlinks, closing the parity gap in the core value proposition. Governed by policy -- six-property containment guarantee documented in enterprise/security.md as an auditable, citable policy surface for security reviews. OSS community driven -- the NullCommandLogger + tmp_path + _try_symlink + typed-gates TDD pattern in the new test module is clean enough to reference in CONTRIBUTING.md as the canonical bug-fix PR template. Pragmatic as npm -- symlinked files in a local package now install the same way they would from a registry tarball.

Growth signal. Three release-note angles converge here. First, silent data loss closed: package authors whose shared-contract.md was silently missing after local installs spent real debugging time on a fix that is now one upgrade away -- this is the kind of fix that earns a 'finally' in issue comments and converts passive users into vocal advocates. Second, local/remote install parity is the pragmatic-as-npm promise made tangible and is a one-line competitive differentiator in release notes. Third, the six-property containment guarantee in security.md is an enterprise security review accelerator -- turning an open question in a vendor questionnaire into a citable paragraph. The test module (test_local_content_symlink_deref.py) is also a contributor acquisition asset worth a CONTRIBUTING.md callout: the TDD pattern is reusable for any future install-phase bug fix and signals a well-organized, approachable codebase to prospective contributors.

Panel summary

Persona B R N Takeaway
Python Architect 0 1 3 Sound escaping-symlink guard and circular-detection logic; nit: redundant copystat after copy2 at line 170; _visited per-branch snapshot semantics need a doc comment to prevent future regression.
CLI Logging Expert 0 2 2 Error messages are ASCII-safe and precise; two gaps: no explicit PathTraversalError handler in install.py (double-wrap prefix) and helper accepts no logger (blank --verbose trace).
DevX UX Expert 0 3 2 PathTraversalError misused for broken symlinks/permission errors; 3 of 4 error messages omit next-action guidance; install.md not updated for the behavior change.
Supply Chain Security Expert 0 3 2 Core POSIX path-traversal fix is sound; three gaps remain: NTFS junction bypass on Windows, RecursionError leaks partial install without cleanup, and a narrow TOCTOU window between validate and copy2.
OSS Growth Hacker 0 2 2 Strong trust and parity story with one buried lead: CHANGELOG puts the user-impact hook last, and the hard-fail breaking change needs an explicit callout for upgrading users.
Doc Writer 0 3 4 Security.md section is accurate; CHANGELOG misses a blank line; implementation-internal language leaks into 2 items; no stale 'never followed' claims found elsewhere.
Test Coverage Expert 0 3 1 All 8 unit scenarios confirmed present; primary security path has integration-tier coverage (S7 unverified); broken-symlink and circular-symlink hard-fails lack integration-tier regression traps.

B = blocking-severity findings, R = recommended, N = nits.
Counts are signal strength, not gates. The maintainer ships.

Top 5 follow-ups

  1. [Supply Chain Security Expert] Tighten partial-cleanup test assertion from conditional to unconditional -- outcome:failed evidence -- the conditional form (if install_path.exists(): assert not evil.exists()) silently passes when other partial content remains, directly weakening the regression trap for the PR's own atomicity guarantee. One-line change, highest evidence signal in the panel; fold pre-merge or in an immediate same-day follow-up.
  2. [Supply Chain Security Expert] Add depth counter (cap ~128 levels) or widen the except clause to catch RecursionError and route it through the cleanup gate -- outcome:missing on a security-surface promise. A crafted package with 250-plus nested directories reaches RecursionError before safe_rmtree runs, leaving a partial install_path on disk.
  3. [OSS Growth Hacker + DevX UX Expert] Reorder CHANGELOG entry to lead with the user-impact sentence and add an explicit upgrade notice for the escaping-symlink hard-fail behavior change -- three panelists independently flagged this. CI that was green with an out-of-root symlink will go red on upgrade without warning. Minimum fix: lead with "Previously, apm install silently dropped symlinked files" and add "find <pkg-path> -type l to audit before upgrading."
  4. [CLI Logging Expert + DevX UX Expert] Add a dedicated PathTraversalError handler in install.py immediately after the DirectDependencyError handler, mirroring the existing pattern -- without it, PathTraversalError falls into the generic except Exception branch, producing a double-wrapped message and a misleading "--verbose for diagnostics" hint when the helper has no logger.
  5. [DevX UX Expert + Doc Writer] Update docs/reference/cli/install.md Behavior section and docs/producer/pack-a-bundle.md to document symlink dereference behavior and the three hard-fail cases -- a CLI behavior change not reflected in the reference docs in the same PR leaves the public contract incomplete.

Architecture

classDiagram
    direction LR
    class local_content:::touched {
        <<Module>>
        +_copy_local_package(dep_ref, install_path, base_dir) Path
        +_copy_tree_dereferencing_validated(src, dst, pkg_root, _visited) None
        +_project_has_root_primitives(project_root) bool
        +_has_local_apm_content(project_root) bool
    }
    class path_security {
        <<Module>>
        +ensure_path_within(path, base_dir) Path
        +safe_rmtree(path, base_dir) None
    }
    class PathTraversalError {
        <<ValueError>>
    }
    class file_ops {
        <<Module>>
        +robust_copy2(src, dst) Path
        +robust_copytree(src, dst) Path
    }
    class shutil {
        <<StdLib>>
        +copy2(src, dst) Path
        +copystat(src, dst) None
    }
    class NullCommandLogger {
        <<Fallback>>
        +error(msg) None
    }
    local_content ..> path_security : uses ensure_path_within and safe_rmtree
    local_content ..> PathTraversalError : raises on escaping or circular symlink
    path_security ..> PathTraversalError : raises on containment violation
    local_content ..> shutil : calls copy2 and copystat
    local_content ..> NullCommandLogger : fallback when logger is None
    path_security ..> file_ops : delegates robust_rmtree
    note for local_content "Guard-then-recurse: per-branch _visited snapshot -- _visited | resolved, not in-place mutation"
    classDef touched fill:#fff3b0,stroke:#d47600
Loading
flowchart TD
    A[apm install local_path] --> B[_copy_local_package local_content.py:178]
    B --> C{local.is_dir?}
    C -- No --> D[return None: path missing]
    C -- Yes --> E{apm.yml or SKILL.md present?}
    E -- No --> F[return None: not a valid APM pkg]
    E -- Yes --> G{install_path.exists?}
    G -- Yes --> H[safe_rmtree install_path]
    G -- No --> I[_copy_tree_dereferencing_validated]
    H --> I
    I --> J[dst.mkdir parents=True]
    J --> K[sorted src.iterdir]
    K -- OSError --> KE[raise PathTraversalError: cannot read package dir]
    K -- OK --> L{entry type?}
    L -- is_symlink --> M[entry.resolve strict=True]
    M -- OSError --> ME[raise PathTraversalError: broken symlink]
    M -- resolved --> N[ensure_path_within resolved pkg_root]
    N -- escapes root --> NE[raise PathTraversalError: symlink escapes package root]
    N -- within root --> O{resolved.is_dir?}
    O -- Yes and in _visited --> P[raise PathTraversalError: circular symlink]
    O -- Yes and not in _visited --> Q[recurse with _visited union resolved]
    Q --> R[copystat resolved dst_entry dir timestamps]
    R --> L
    O -- No file --> S[copy2 resolved dst_entry]
    S --> T[copystat redundant: copy2 already called it]
    T --> L
    L -- is_dir --> U[recurse same _visited]
    U --> V[copystat dir timestamps]
    V --> L
    L -- is_file --> W[copy2 entry dst_entry]
    W --> L
    L -- done --> X[return install_path]
    B -- PathTraversalError --> Y{install_path.exists?}
    Y -- Yes --> Z[safe_rmtree cleanup partial]
    Z --> AA[re-raise PathTraversalError]
    Y -- No --> AA
Loading
sequenceDiagram
    participant CLI as apm install
    participant LCP as _copy_local_package
    participant CTDV as _copy_tree_dereferencing_validated
    participant PS as ensure_path_within
    participant FS as Filesystem
    CLI->>LCP: dep_ref, install_path, base_dir, project_root, logger
    LCP->>FS: local.is_dir, install_path.parent.mkdir
    LCP->>FS: safe_rmtree install_path if exists
    LCP->>CTDV: src=local, dst=install_path, pkg_root=local, _visited=None
    CTDV->>FS: dst.mkdir and sorted src.iterdir
    loop for each entry in src
        alt entry is symlink
            CTDV->>FS: entry.resolve strict=True
            Note over CTDV: PathTraversalError if broken target
            CTDV->>PS: ensure_path_within resolved pkg_root
            Note over PS: PathTraversalError if escapes pkg_root
            alt resolved is dir and not in _visited
                CTDV->>CTDV: recurse resolved dst_entry pkg_root _visited|resolved
                CTDV->>FS: copystat resolved dst_entry dir timestamps
            else resolved is dir and in _visited
                Note over CTDV: PathTraversalError circular symlink
            else resolved is file
                CTDV->>FS: copy2 resolved dst_entry
                CTDV->>FS: copystat redundant: copy2 already called it
            end
        else entry is regular directory
            CTDV->>CTDV: recurse entry dst_entry pkg_root same _visited
            CTDV->>FS: copystat entry dst_entry dir timestamps
        else entry is regular file
            CTDV->>FS: copy2 entry dst_entry
        end
    end
    CTDV-->>LCP: success or PathTraversalError
    alt PathTraversalError raised
        LCP->>FS: safe_rmtree install_path cleanup
        LCP-->>CLI: raise PathTraversalError
    else success
        LCP-->>CLI: return install_path
    end
Loading

Recommendation

Ship with followups. The core security fix is correct, the unit test suite covers all eight scenarios comprehensively, and no panelist returned a blocking finding. Two items the author should address before merge or in an immediate same-day PR: (1) tighten the partial-cleanup test assertion from conditional to unconditional (supply-chain, outcome:failed evidence -- one line, defends the PR's own atomicity claim), and (2) add an explicit upgrade notice to the CHANGELOG for the escaping-symlink hard-fail behavior change (three-panelist convergence, trust-critical for upgrading users). All remaining followups -- the dedicated PathTraversalError handler in install.py, the install.md and pack-a-bundle.md documentation gaps, the RecursionError depth cap, the NTFS junction bypass note in security.md, and the error-type redesign (PathTraversalError vs. a new LocalInstallError) -- are scoped to follow-up issues and do not block the security value this PR delivers.


Full per-persona findings

Python Architect

  • [recommended] _visited per-branch snapshot semantics are correct but silently breakable by a future maintainer at src/apm_cli/install/phases/local_content.py:90
    The _visited set uses _visited | {resolved} (immutable snapshot per branch). This is correct for diamond-topology packages (two symlinks to the same dir are both materialized) while true cycles are still caught. A future maintainer may replace with _visited.add(resolved), breaking diamond-sharing. Minimal fix: add a docstring sentence. "Per-branch immutable snapshot -- _visited | {resolved}, not in-place mutation. Do not replace with _visited.add(resolved) -- that breaks diamond-topology packages where two symlinks point to the same directory."
  • [nit] shutil.copystat at line 170 is redundant after shutil.copy2 for the symlink-to-file branch at src/apm_cli/install/phases/local_content.py:170
    shutil.copy2 internally calls copystat. The explicit copystat at line 170 is a no-op second metadata pass. Directory copystat calls at lines 167 and 173 are correct and necessary.
    Suggested: Remove line 170. Keep copystat at lines 167 and 173.
  • [nit] Missing type annotation on _visited parameter at src/apm_cli/install/phases/local_content.py:90
    src, dst, pkg_root have type hints; _visited has none.
    Suggested: def _copy_tree_dereferencing_validated(src: Path, dst: Path, pkg_root: Path, _visited: set[Path] | None = None) -> None:
  • [nit] Raw shutil.copy2 diverges from install-pipeline convention of robust_copy2 at src/apm_cli/install/phases/local_content.py:169
    github_downloader.py and artifactory_orchestrator.py both use robust_copy2 from apm_cli.utils.file_ops (reflink clone before fallback). Local same-filesystem copies would benefit from reflink.
    Suggested: Import robust_copy2 and replace shutil.copy2 at lines 169 and 175.

CLI Logging Expert

  • [recommended] PathTraversalError falls into generic except Exception handler -- message is double-wrapped and the fix hint is buried at src/apm_cli/commands/install.py:1590
    PathTraversalError (ValueError subclass) is not caught explicitly in install.py. It falls through to except Exception, producing [x] Error installing dependencies: Symlink ... Local install aborted. -- the actionable fix hint is buried. DirectDependencyError and PolicyViolationError both have dedicated handlers; PathTraversalError deserves the same since its messages carry fix instructions. The generic handler also emits "Run with --verbose for detailed diagnostics" which is misleading because the helper has no logger.
    Suggested: Add except PathTraversalError as e: ... logger.error(str(e)); sys.exit(1) immediately after the DirectDependencyError handler at line 1593. Mirror in the second install block near line 1814.
  • [recommended] Logger not forwarded to _copy_tree_dereferencing_validated -- --verbose yields zero per-symlink trace at src/apm_cli/install/phases/local_content.py:90
    The logger accepted by _copy_local_package is not passed to the helper. In --verbose mode, users see no per-entry progress. The silent cleanup (safe_rmtree) is also unlogged.
    Suggested: Add logger=None parameter to helper, thread through recursive calls, add logger.verbose_detail calls on dereference and cleanup.
  • [nit] Circular and broken-symlink error messages lack fix hints; only escape message has actionable recovery advice at src/apm_cli/install/phases/local_content.py:141
    Suggested: Broken: append "Fix or remove the broken symlink and retry." Circular: append "Remove one of the circular symlinks or replace it with a regular file copy and retry."
  • [nit] "Local install aborted." suffix is redundant when rendered via the generic handler at src/apm_cli/install/phases/local_content.py:131
    No change needed once a dedicated PathTraversalError handler is added to install.py (follow-up 4 above). If deferred, consider "Aborting install." to reduce verbosity.

DevX UX Expert

  • [recommended] PathTraversalError reused for broken symlinks and permission errors conflates security violations with innocent operational failures at src/apm_cli/install/phases/local_content.py:141
    PathTraversalError is documented as "Raised when a computed path escapes its expected base directory." A dangling symlink (OSError from resolve(strict=True)) or PermissionError on iterdir is neither. The install pipeline labels PathTraversalError catches as "Path-safety violation". Wrapping a stale symlink in a security-named exception trains CI monitors to treat it as a containment breach.
    Suggested: Introduce LocalInstallError (or PackageContentError) for non-containment failures: broken symlinks, circular chains, unreadable directories. Reserve PathTraversalError strictly for the escaping-symlink case. (CEO: deferred to follow-up issue -- cross-cutting refactor with test-migration cost.)
  • [recommended] Broken symlink, circular symlink, and unreadable directory error messages all omit a concrete next-action step at src/apm_cli/install/phases/local_content.py:142
    Only the escaping-symlink case has a fix hint. The other three errors end without guidance.
    Suggested: Broken: "Remove or fix the symlink target, then re-run apm install." Circular: "Remove the circular symlink loop from your package, then re-run apm install." Unreadable dir: "Check filesystem permissions on the directory, then re-run apm install."
  • [recommended] docs/reference/cli/install.md not updated to document the new symlink dereference behavior or new hard-fail cases at docs/src/content/docs/reference/cli/install.md:105
    The Behavior section covers local-path installs but contains no mention of symlink dereference or the three hard-fail cases.
    Suggested: Add Behavior bullet: "Local-path symlink handling. In-package symlinks are dereferenced into regular files... Symlinks whose resolved target escapes the package root, broken symlinks, and circular chains hard-fail with a clear error naming the offending path."
  • [nit] CHANGELOG entry says circular/unreadable "detected deterministically" but does not say they hard-fail; broken symlinks not mentioned at CHANGELOG.md
    Suggested: Extend: "...hard-fail with a PathTraversalError; broken or dangling symlinks and circular directory-symlink chains also hard-fail with a clear error naming the offending path."
  • [nit] Missing blank line between the new CHANGELOG bullet and ### Changed section header at CHANGELOG.md

Supply Chain Security Expert

  • [recommended] NTFS junction points bypass is_symlink check on Windows; traverse without containment guard at src/apm_cli/install/phases/local_content.py:171
    Path.is_symlink() returns False for NTFS junction points (IO_REPARSE_TAG_MOUNT_POINT). On Windows, a malicious local package with a junction pointing outside would fall into the elif entry.is_dir() branch with no ensure_path_within check -- pkg_root boundary never applied. Linux and macOS unaffected.
    Suggested: Add Windows junction detection guard, or document as known limitation in security.md.
    Proof (missing at manual-only): No Windows junction test exists -- is_symlink() returns False for NTFS junctions so the gap is confirmed by Python lstat semantics.
  • [recommended] RecursionError on deep package trees bypasses PathTraversalError cleanup gate, leaving partial install_path at src/apm_cli/install/phases/local_content.py:276
    Effective max depth before RecursionError is ~200-240 levels. A crafted package with 250-plus nested directories triggers it. The try/except only catches PathTraversalError; RecursionError propagates unhandled, skipping safe_rmtree. Contradicts the PR's atomicity claim.
    Suggested: Widen except to catch RecursionError and route through cleanup, or add depth counter capped at 128 levels.
    Proof (missing at unit): No test verifies that RecursionError during copy triggers the partial-install cleanup path.
  • [recommended] TOCTOU window between ensure_path_within and copy2 is narrowed but not closed at src/apm_cli/install/phases/local_content.py:169
    Between ensure_path_within and copy2, a concurrent process could replace the file with an escaping symlink. shutil.copy2 follows symlinks by default. Low practical risk for user-controlled local packages but should be documented.
    Suggested: Add docstring and security.md note that a narrow TOCTOU window remains when the package source directory is concurrently writable.
  • [nit] Partial-cleanup test asserts conditionally on install_path.exists() instead of asserting unconditionally at tests/unit/install/test_local_content_symlink_deref.py:209
    Test uses if install_path.exists(): assert not evil.exists() which silently passes if other partial content remains.
    Suggested: Replace with assert not install_path.exists()
    Proof (failed at unit): TestEscapingSymlinkHardFails::test_escaping_install_path_is_not_partially_created -- conditional form weakens the atomicity regression trap.
  • [nit] shutil.copy2 already calls copystat internally; extra copystat on file symlinks at line 170 is a redundant syscall at src/apm_cli/install/phases/local_content.py:170
    Suggested: Remove line 170 (file-symlink branch only; keep directory copystat at lines 167 and 173).

OSS Growth Hacker

  • [recommended] CHANGELOG buries the user-impact lead; silent data loss should open the sentence, not close it at CHANGELOG.md
    Package authors who hit this bug spent time debugging why their shared-contract.md was missing. The recognition trigger -- "Previously, in-package symlinks were silently dropped" -- is the last clause. Moving it first transforms this from a maintainer note into a user-resonant fix story.
    Suggested: Lead with: "Previously, apm install <local-path> silently dropped symlinked files during deployment. The fix materializes in-package symlinks as real files so local and remote installs produce consistent output..."
  • [recommended] Escaping-symlink hard-fail is an implicit behavior change that needs an upgrade notice in CHANGELOG at CHANGELOG.md
    Before: escaping symlinks silently dropped/preserved. After: hard-fail. Any user with an out-of-root symlink (even accidentally) will see a new PathTraversalError on upgrade. CI that was green will go red.
    Suggested: Add: "If your local package contains symlinks pointing outside the package root that previously installed without error, those installs will now fail; audit with find <pkg-path> -type l and replace escaping symlinks with real file copies before upgrading."
  • [nit] security.md containment guarantee is an enterprise evaluator asset worth a release-note call-out, not just inline prose at docs/src/content/docs/enterprise/security.md
  • [nit] first-package.md teaches package authoring but never mentions that in-package symlinks to shared content are a supported pattern
    Suggested: Add tip after skill-authoring section about using in-package symlinks for shared content, with a link to the symlink handling section of security.md.

Auth Expert -- inactive

The six changed files (local_content.py, test_local_content_symlink_deref.py, test_local_deps.py, test_install_sources_classification.py, CHANGELOG.md, enterprise/security.md) concern only local-filesystem symlink dereferencing during package staging and carry zero contact with AuthResolver, token management, credential resolution, host classification, or any remote-auth surface.

Doc Writer

  • [recommended] New subsection adds ~300 words with no compensating cuts at docs/src/content/docs/enterprise/security.md:240
    PROSE non-bloat: if word count increases, something must shrink or the trade-off must be documented. The PR does not document the delta.
    Suggested: Consolidate items 3+4 (same rule stated twice from opposite directions) and items 5+6 into single sentences each, saving ~80 words.
  • [recommended] PathTraversalError user-visible message not shown; developers hitting the error cannot cross-reference docs at docs/src/content/docs/enterprise/security.md:257
    The section guarantees a "human-readable message naming the offending link" but never shows it. A developer encountering the error mid-install cannot confirm they are seeing the expected signal.
    Suggested: Add a code block after item 3 showing the actual error message format.
  • [recommended] Producer guide gap: package authors using symlinks have no page explaining local-install dereference behavior
    Suggested: Add note in docs/producer/pack-a-bundle.md: "When a consumer installs your package from a local path, APM dereferences in-package symlinks into regular files. Symlinks whose resolved target escapes the package root abort the install. See Symlink handling."
  • [nit] CHANGELOG missing blank line between last Fixed entry and ### Changed heading at CHANGELOG.md:24
  • [nit] Item 4 contains a redundant second sentence: "External symlinks are never followed." at docs/src/content/docs/enterprise/security.md:260
    Item 4 already states "Only symlinks that resolve within the package root are dereferenced." The negative restatement adds no information.
  • [nit] Items 5 and 6 use implementation-internal language ("visited-set guard", "OS-level ELOOP limits", "bare OS error up the install stack") at docs/src/content/docs/enterprise/security.md:261
    Suggested: Item 5: "Circular directory-symlink chains fail the install with a PathTraversalError." Item 6: "An unreadable package directory fails the install with a PathTraversalError rather than a raw OS error."
  • [nit] Broken-target symlink case is a hard-fail but not covered by any of the 6 documented properties at docs/src/content/docs/enterprise/security.md:252
    Suggested: Extend item 1 or add item 7: "A symlink whose target cannot be resolved (broken link) also hard-fails the install with a PathTraversalError."

Test Coverage Expert

  • [nit] Regression trap test_copy_preserves_symlinks_without_following correctly asserts hard-fail, not preserved-as-symlink at tests/unit/test_local_deps.py:586
    Verified at lines 586-624. The test correctly creates an escaping symlink and asserts PathTraversalError is raised. The match=r'(?i)escape|outside|traversal' pattern matches the new error message. Regression trap quality is correct.
    Proof (passed at unit): TestCopyLocalPackage::test_copy_preserves_symlinks_without_following -- with pytest.raises(PathTraversalError, match=r'(?i)escape|outside|traversal'): _copy_local_package(...)
  • [recommended] Integration-tier tests for acquire() boundary are structurally correct but S7-unverifiable: pytest not available in review env at tests/integration/test_install_sources_classification.py:578
    TestLocalInstallSymlinkDerefThroughPipeline does NOT mock _copy_local_package -- only detect_package_type is mocked, which is correct. Structurally exercises the full pipeline. Cannot certify with run evidence from this environment.
    Suggested: Run pytest tests/integration/test_install_sources_classification.py::TestLocalInstallSymlinkDerefThroughPipeline -v to certify before merge.
    Proof (unknown at integration-with-fixtures): test_in_package_symlink_materialized_as_real_file -- assert staged_link.is_file() and not staged_link.is_symlink()
  • [recommended] Broken (dangling) symlink hard-fail path is unit-only; missing integration-tier regression trap at tests/integration/test_install_sources_classification.py
    The broken-symlink branch is a distinct code path from the escaping-symlink branch. The existing integration test would not catch a regression in the broken-symlink path.
    Suggested: Add test_broken_symlink_hard_fails_through_pipeline to TestLocalInstallSymlinkDerefThroughPipeline.
    Proof (missing at integration-with-fixtures): TestLocalInstallSymlinkDerefThroughPipeline::test_broken_symlink_hard_fails_through_pipeline does not exist -- with pytest.raises(PathTraversalError, match=r'(?i)(broken|unresolvable)'): source.acquire()
  • [recommended] Circular directory-symlink detection is unit-only; visited-set guard has no integration-tier regression trap at tests/integration/test_install_sources_classification.py
    If _visited tracking regressed, the install would recurse deeply before hitting OS ELOOP. No integration test covers this path.
    Suggested: Add test_circular_symlink_hard_fails_through_pipeline to TestLocalInstallSymlinkDerefThroughPipeline.
    Proof (missing at integration-with-fixtures): TestLocalInstallSymlinkDerefThroughPipeline::test_circular_symlink_hard_fails_through_pipeline does not exist -- with pytest.raises(PathTraversalError, match=r'(?i)(circular|visited)'): source.acquire()

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.allowed list in your workflow frontmatter:

network:
  allowed:
    - defaults
    - "pypi.org"

See Network Configuration for more information.

Generated by PR Review Panel for issue #1676 · sonnet46 18.4M ·

@github-actions github-actions Bot removed the panel-review Trigger the apm-review-panel gh-aw workflow label Jun 9, 2026
Tighten the local symlink dereference helper API, remove redundant metadata copying, improve remediation text, and align the security docs with the observable local/remote install guarantee surfaced by the panel review for PR #1676.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@danielmeppiel

Copy link
Copy Markdown
Collaborator Author

APM Review Panel: ship_now

Local-path install now treats in-package symlinks consistently with remote installs while failing closed on unsafe links.

cc @danielmeppiel @sergio-sisternes-epam -- a fresh advisory pass is ready for your review.

The panel converged on the same shape: the PR fixes a real install-parity regression, keeps the security boundary at the package root, and now carries tests plus documentation for the new behavior. The follow-ups raised in the pass were small in-scope quality items, so they were folded into this branch before CI was observed green.

Aligned with: secure-by-default: escaping, broken, circular, and unreadable symlink paths fail closed; pragmatic-as-npm: local and remote install outputs now match for shared in-package references; oss-community-driven: the contributor fix is preserved and tightened in-place.

Panel summary

Persona B R N Takeaway
Python Architect 0 1 2 Recursive copy boundary is sound; folded helper API and redundant metadata-copy cleanup.
CLI Logging Expert 0 0 3 Error text is actionable; folded remediation hints for broken, circular, and unreadable paths.
DevX UX Expert 0 0 3 Install behavior now matches user expectations; folded changelog formatting.
Supply Chain Security Expert 0 0 2 Containment is fail-closed; residual local TOCTOU is not a new risk for this trusted local source model.
OSS Growth Hacker 0 0 3 The changelog and security docs now lead with the user-visible install parity and trust signal.
Doc Writer 0 1 3 Folded the inaccurate remote-git-checkout wording and documented broken-symlink failure.
Test Coverage Expert 0 0 0 Regression and security tests exist at unit and integration-with-fixtures tiers.

Recommendation

Ship after maintainer review. No in-scope panel or Copilot follow-up remains open after the folded commit, and the latest pushed SHA is CI-green.

Folded in this run

  • (panel) Made _copy_tree_dereferencing_validated keep recursive _visited state keyword-only and removed redundant copystat after copy2 for symlinked files -- resolved in 9962765f.
  • (panel) Added remediation hints for broken symlink, circular symlink, and unreadable directory failures -- resolved in 9962765f.
  • (panel) Corrected security docs to describe observable local/remote output parity rather than git checkout mechanics -- resolved in 9962765f.
  • (panel) Documented broken/unresolvable symlink hard-fail behavior and fixed markdown spacing in the security page and changelog -- resolved in 9962765f.

Copilot signals reviewed

No copilot-pull-request-reviewer[bot] inline comments were present in the pull request comments API after two fetch rounds; the summary-only review was reviewed.

Regression-trap evidence (mutation-break gate)

  • tests/unit/install/test_local_content_symlink_deref.py::TestInPackageSymlinkIsDerefed::test_in_package_symlink_materialized_as_real_file -- replaced the dereferencing copy path with shutil.copytree(..., symlinks=True); test FAILED as expected; guard restored.

Lint contract

uv run --extra dev ruff check src/ tests/ and uv run --extra dev ruff format --check src/ tests/ both silent. Full local lint mirror also passed pylint R0801 and scripts/lint-auth-signals.sh.

CI

All PR checks were green on 9962765f4d8b5309b5e30c99bb14290ddb887093 after 0 CI fix iterations, including Lint, Build & Test Shard 1/2, Coverage Combine, CodeQL, APM Self-Check, PR Binary Smoke, Spec conformance gate, NOTICE Drift Check, and license/cla.

Mergeability status

Captured from gh pr view 1676 --json mergeable,mergeStateStatus,statusCheckRollup immediately after the last push of this run.

PR head SHA CEO stance iters folds defers Copilot rounds CI mergeable mergeStateStatus notes
#1676 9962765 ship_now 1 4 0 2 green MERGEABLE BLOCKED awaiting maintainer review

Convergence

1 outer iteration; 2 Copilot rounds. Final panel stance: ship_now.

Ready for maintainer review.

@danielmeppiel danielmeppiel merged commit 622d133 into main Jun 9, 2026
12 checks passed
@danielmeppiel danielmeppiel deleted the autopilot/1668-local-symlink-deref branch June 9, 2026 14:42
sergio-sisternes-epam pushed a commit that referenced this pull request Jun 9, 2026
…arency)

Sync main commits #1700, #1676, #1694, #1710. The #1700 feature (surface
installed hook actions during install) wove display-payload tracking
through code my refactor extracted into sibling helpers; resolve two
conflicts by keeping the extracted structure and porting #1700 semantics:

- services.py: accept the extracted _log_per_kind_results() call; move
  _log_hook_display_payloads into services_integrate.py (avoids circular
  import) and re-export it from services.py; emit hook summaries inside
  _log_per_kind_results.
- hook_integrator._integrate_merged_hooks: accept extracted
  _merge_hook_file_entries / _write_merged_config; thread per-file
  display data out via a new optional capture_entries kwarg on
  _merge_hook_file_entries; build display payloads after
  _write_merged_config finalizes (post _apm_source strip).
- Keep hook_integrator.py <=800 by relocating _iter_hook_entries /
  _summarize_command / _build_display_payload to hook_transforms.py and
  _parse_hook_json to hook_merge.py as thin delegators.

Shadow gate green: ruff, ruff format, pylint R0801 10.00/10 (EXIT=0),
auth-signals, import smoke, 3178 tests pass. All files within 800-line
guard (hook_integrator.py 799, hook_merge.py 766, hook_transforms.py 601,
services.py 690, services_integrate.py 307).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Local-path install drops symlinked skill references; remote install keeps them

2 participants