Skip to content
1 change: 1 addition & 0 deletions .apm/instructions/architecture.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ semicolon-delimited, and specific to the file(s) that own the fact.
| File-level deploy / sync / cleanup | BaseIntegrator (see integrators.instructions.md) | `src/apm_cli/integration/base_integrator.py` |
| Windows stable executable path | install.ps1 ($currentDir / $currentExe) | `install.ps1` |
| Git repository cache-key normalization | cache/url_normalize.py (normalize_repo_url / cache_shard_key) | `src/apm_cli/cache/url_normalize.py` |
| Sparse-cone setup, dangling-symlink repair, and materialized symlink validation | utils/git_sparse.py | `src/apm_cli/utils/git_sparse.py` |
| Self-update release -> installer ref + VERSION | commands/self_update.py (_ResolvedSelfUpdateRelease) | `src/apm_cli/commands/self_update.py` |
| Dependency comparison identity vs display-cased materialization path | models/dependency/identity.py + materialization.py + DependencyReference | `src/apm_cli/models/dependency/identity.py`; `src/apm_cli/models/dependency/materialization.py`; `src/apm_cli/models/dependency/reference.py` |
| Cached policy shape | policy/discovery.py (_policy_to_dict via _serialize_policy; ADO_POLICY_PROJECT; ADO_POLICY_REPOSITORY) | `src/apm_cli/policy/discovery.py` |
Expand Down
1 change: 1 addition & 0 deletions .github/instructions/architecture.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ semicolon-delimited, and specific to the file(s) that own the fact.
| File-level deploy / sync / cleanup | BaseIntegrator (see integrators.instructions.md) | `src/apm_cli/integration/base_integrator.py` |
| Windows stable executable path | install.ps1 ($currentDir / $currentExe) | `install.ps1` |
| Git repository cache-key normalization | cache/url_normalize.py (normalize_repo_url / cache_shard_key) | `src/apm_cli/cache/url_normalize.py` |
| Sparse-cone setup, dangling-symlink repair, and materialized symlink validation | utils/git_sparse.py | `src/apm_cli/utils/git_sparse.py` |
| Self-update release -> installer ref + VERSION | commands/self_update.py (_ResolvedSelfUpdateRelease) | `src/apm_cli/commands/self_update.py` |
| Dependency comparison identity vs display-cased materialization path | models/dependency/identity.py + materialization.py + DependencyReference | `src/apm_cli/models/dependency/identity.py`; `src/apm_cli/models/dependency/materialization.py`; `src/apm_cli/models/dependency/reference.py` |
| Cached policy shape | policy/discovery.py (_policy_to_dict via _serialize_policy; ADO_POLICY_PROJECT; ADO_POLICY_REPOSITORY) | `src/apm_cli/policy/discovery.py` |
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
under either `executables` or the deprecated `allowExecutables` key as an
actionable informational warning instead of omitting the check. (#2719)

- Git subdirectory dependencies with symlinks to files elsewhere in the same
repository now install successfully where Git materializes symlinks; APM
widens the checkout only when needed. On Windows, Git defaults to
`core.symlinks=false` and checks these entries out as plain files, which is
outside #2707's scope. (by @MohammedAlkindi, closes #2707, #2710)

## [0.29.0] - 2026-08-30

### Added
Expand Down
4 changes: 2 additions & 2 deletions apm.lock.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2783,7 +2783,7 @@ deployments:
owners:
- .
active_owner: .
content_hash: sha256:7ab9944e010603517123e62c171be8eb1bc674c05e0ec8370d737a8890acb07f
content_hash: sha256:3445ddcf51a14f5a730cddb7f3cc5ce2bb11b5078951521a4903a098250f6f86
- kind: project-relative
target: copilot
value: .github/instructions/changelog.instructions.md
Expand Down Expand Up @@ -3290,7 +3290,7 @@ local_deployed_file_hashes:
.github/agents/spec-tag-architect.agent.md: sha256:82907265c5e7cf1ac61ad96866fa7c5683b69c8f09b7a4c5f3cc241acc9568ca
.github/agents/supply-chain-security-expert.agent.md: sha256:8fb8cc426d6af17ba084a28b3f026c2b475b62e3ca63ed2f88b83bd823f877af
.github/agents/test-coverage-expert.agent.md: sha256:48c2172d1f18a394fa83ef9dc2be0b9b921a4e51e976498165250fed66369711
.github/instructions/architecture.instructions.md: sha256:7ab9944e010603517123e62c171be8eb1bc674c05e0ec8370d737a8890acb07f
.github/instructions/architecture.instructions.md: sha256:3445ddcf51a14f5a730cddb7f3cc5ce2bb11b5078951521a4903a098250f6f86
.github/instructions/changelog.instructions.md: sha256:1e51ec4c74e847967962bd279dc4c6e582c5d3578490b3c28d5f3acd3e05f73e
.github/instructions/cicd.instructions.md: sha256:33201cb88ea2f34b4950a9b52f87dc8dfb682796aaf53068ba7ae406c0c5e2c2
.github/instructions/cli.instructions.md: sha256:8e39e8d5047ce88575cb02f87c2bcede584dfef258bd86f7466c7badf136541a
Expand Down
11 changes: 8 additions & 3 deletions docs/src/content/docs/enterprise/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -329,9 +329,8 @@ Trust boundaries:

### Symlink handling

Symlinks are rejected in most APM operations; the only context where in-package
symlinks are followed is local-path install, under a per-symlink containment
check (see below):
Symlinks are rejected in most APM operations. They are followed only
during contained package materialization:

- **Primitive discovery** (instructions, agents, prompts, contexts, skills) rejects symlinked files during glob-based file enumeration. Symlinks are silently skipped.
- **Prompt resolution** (`apm preview`, `apm run`) rejects symlinked `.prompt.md` files with an explicit error message.
Expand All @@ -342,6 +341,12 @@ check (see below):
- **Manifest integrity** -- a malformed `apm.yml` (invalid YAML or non-mapping content) triggers a failing `manifest-parse` audit check. Policy and baseline CI checks never silently pass when the manifest cannot be parsed. If this check fires, fix the YAML syntax error in your `apm.yml` and re-run the audit.
- **Archive creation** -- `apm pack` excludes symlinks from bundled archives. Packaged artifacts contain no symbolic links, preventing symlink-based escape attacks in distributed bundles.

Remote Git subdirectory installs can dereference a symlink whose target is a
tracked file in the same checked-out commit. If sparse checkout excluded that
target, APM widens the checkout before copying the package. Generated Git
metadata, targets outside the repository, and links that remain broken after
widening hard-fail the install.

#### Local-install symlink dereference and containment guarantee

When installing a local-path dependency (`apm install /path/to/pkg`), APM
Expand Down
9 changes: 7 additions & 2 deletions docs/src/content/docs/reference/cli/cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,10 @@ Inside the cache root:
# for sparse-checkout consumers
checkouts_v1/ # per-SHA worktree checkouts, variant-keyed
# <shard>/<sha>/full/ -- full tree
# <shard>/<sha>/sparse-<hash>/ -- sparse cone
# <shard>/<sha>/sparse-<hash>/ -- sparse cone, or a
# full tree when a
# symlink target lies
# outside the cone
# (<hash> = first
# 16 hex of
# sha256(paths))
Expand All @@ -140,7 +143,9 @@ The `full/` and `sparse-<variant>/` subdirs let two consumers of the
same commit share storage when they want the same subdirs, and keep
distinct shards when they do not -- without the variant suffix the
sparse checkout would clobber the full tree for any other consumer
of that SHA.
of that SHA. A sparse variant widens to the full tree when a package
symlink targets a tracked file excluded from the sparse cone, so that
variant can consume more disk than its name suggests.

The cache root is created with mode `0700` and validated to be
absolute with no NUL bytes before use.
Expand Down
28 changes: 28 additions & 0 deletions scripts/lint-architecture-boundaries.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1112,6 +1112,34 @@ check_pattern \
'to_repository_cache_url' \
src/apm_cli

echo "[*] AC11a: sparse-cone materialization authority"
sparse_cone_owner="src/apm_cli/utils/git_sparse.py"
sparse_cone_raw_set_hits=$(
grep -rEn --include='*.py' \
'"sparse-checkout",[[:space:]]*"set"' src/apm_cli \
| grep -v "^${sparse_cone_owner}:" \
| grep -v '^src/apm_cli/deps/git_file_transport.py:' \
| grep -v 'architecture-authority-exempt:' \
|| true
)
if [ "$(grep -Ec '^def apply_sparse_cone\(' "$sparse_cone_owner")" -ne 1 ] \
|| [ "$(grep -Ec '^def repair_dangling_cone_symlinks\(' "$sparse_cone_owner")" -ne 1 ] \
|| [ "$(grep -Ec '^def _literal_pathspec\(' "$sparse_cone_owner")" -ne 1 ] \
|| [ "$(grep -Fc '"ls-tree",' "$sparse_cone_owner")" -ne 2 ] \
|| [ "$(grep -Fc '_literal_pathspec(path)' "$sparse_cone_owner")" -ne 2 ] \
|| [ "$(grep -Ec '^ def _finalize_sparse_checkout\(' src/apm_cli/cache/git_cache.py)" -ne 1 ] \
|| [ "$(grep -Fc 'self._finalize_sparse_checkout(' src/apm_cli/cache/git_cache.py)" -ne 3 ] \
|| [ "$(grep -Fc 'repair_dangling_cone_symlinks(' src/apm_cli/cache/git_cache.py)" -ne 1 ] \
|| [ "$(grep -Fc 'repair_dangling_cone_symlinks(' src/apm_cli/deps/bare_cache.py)" -ne 1 ] \
|| [ "$(grep -Fc 'repair_dangling_cone_symlinks(' src/apm_cli/deps/github_downloader.py)" -ne 1 ] \
|| ! grep -Fq 'return _repair(setup_env)' src/apm_cli/deps/github_downloader.py \
|| ! grep -Fq 'return _repair(env)' src/apm_cli/deps/github_downloader.py \
|| [ -n "$sparse_cone_raw_set_hits" ]; then
echo "[x] Sparse-cone materialization must route through utils/git_sparse.py"
[ -n "$sparse_cone_raw_set_hits" ] && echo "$sparse_cone_raw_set_hits"
violations=$((violations + 1))
fi

echo "[*] AC12: diagnostic printable-ASCII authority"
diagnostic_ascii_output=$(python3 scripts/check_diagnostic_ascii_owner.py --root "$ROOT" 2>&1)
diagnostic_ascii_status=$?
Expand Down
61 changes: 58 additions & 3 deletions src/apm_cli/cache/git_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
import subprocess
from pathlib import Path

from ..utils.git_sparse import apply_sparse_cone
from ..utils.git_sparse import apply_sparse_cone, repair_dangling_cone_symlinks
from ..utils.path_security import ensure_path_within
from .integrity import verify_checkout_sha
from .locking import atomic_land, cleanup_incomplete, shard_lock, stage_path
Expand Down Expand Up @@ -192,7 +192,12 @@ def get_checkout(
if not self._refresh and checkout_dir.is_dir():
if verify_checkout_sha(checkout_dir, sha):
_log.debug("Cache HIT: %s @ %s [%s]", url, sha[:12], variant)
return checkout_dir
with shard_lock(checkout_dir):
return self._finalize_sparse_checkout(
checkout_dir,
sparse_paths,
env=env,
)
else:
# Integrity failure -- evict
_log.warning(
Expand All @@ -218,6 +223,36 @@ def get_checkout(
promisor_url=url if use_partial else None,
)

def _finalize_sparse_checkout(
self,
checkout_dir: Path,
sparse_paths: list[str] | None,
*,
env: dict[str, str] | None,
) -> Path:
"""Repair and validate a sparse checkout before any cache return."""
if not sparse_paths:
return checkout_dir
from ..utils.git_env import get_git_executable, git_subprocess_env

git_exe = get_git_executable()
subprocess_env = env if env is not None else git_subprocess_env()
dangling = repair_dangling_cone_symlinks(
git_exe,
checkout_dir,
list(sparse_paths),
env=subprocess_env,
extra_git_args=_safe_git_args(),
)
if dangling is not None:
_log.info(
"Sparse-cone checkout of %s left a dangling symlink at %s; "
"widened to a full checkout so it resolves (#2707).",
checkout_dir,
dangling,
)
return checkout_dir

def _resolve_sha(
self,
url: str,
Expand Down Expand Up @@ -552,7 +587,11 @@ def _create_checkout(
sha[:12],
variant,
)
return final_dir
return self._finalize_sparse_checkout(
final_dir,
sparse_paths,
env=env,
)

staged = stage_path(final_dir)
ensure_path_within(staged, self._checkouts_root)
Expand Down Expand Up @@ -645,6 +684,22 @@ def _create_checkout(
env=subprocess_env,
check=True,
)
if sparse_paths:
# Correctness repair, not a failure fallback (#2707):
# if the cone left a dangling symlink (target outside
# the requested paths), widen to a full checkout so
# it resolves. Only fires when the narrow cone would
# otherwise ship a broken checkout.
self._finalize_sparse_checkout(
staged,
sparse_paths,
env=env,
)
except (RuntimeError, ValueError):
from ..utils.file_ops import robust_rmtree

robust_rmtree(staged, ignore_errors=True)
raise
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError) as exc:
from ..utils.file_ops import robust_rmtree

Expand Down
17 changes: 16 additions & 1 deletion src/apm_cli/deps/bare_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@

from git import Repo

from ..utils.git_sparse import apply_sparse_cone
from ..utils.git_sparse import apply_sparse_cone, repair_dangling_cone_symlinks

if TYPE_CHECKING:
from ..models.apm_package import DependencyReference
Expand Down Expand Up @@ -579,6 +579,11 @@ def materialize_from_bare(
- Sparse-checkout failures are RAISED (not silently fallen back)
because a silent fallback would re-introduce the 78 MB bloat
this parameter exists to avoid.
- After checkout, if the cone left a dangling symlink (target
outside the requested paths -- #2707), falls back to
``git sparse-checkout disable`` so the target resolves. This is
a correctness repair, not a failure fallback: it only fires
when the narrow cone would otherwise ship a broken checkout.

Returns:
The resolved commit SHA. Caller threads this into
Expand Down Expand Up @@ -657,6 +662,16 @@ def materialize_from_bare(
env=env,
check=True,
)
if sparse_paths:
dangling = repair_dangling_cone_symlinks(git_exe, consumer_dir, list(sparse_paths), env=env)
if dangling is not None:
_log.info(
"Sparse-cone checkout of %s left a dangling symlink at %s "
"(target outside the requested cone); widened to a full "
"checkout so it resolves (#2707).",
consumer_dir,
dangling,
)
return resolved_sha


Expand Down
Loading