Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- Global skill installs now deploy authorized files when `HOME` or `APM_HOME` uses a directory symlink alias, without changing environment settings or accepting symlinks inside packages. (by @DaveMeadAdjust, #2867)
Comment thread
danielmeppiel marked this conversation as resolved.
Outdated

## [0.30.0] - 2026-09-07

### Security
Expand Down
1 change: 1 addition & 0 deletions docs/src/content/docs/reference/cli/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ in `apm.yml`, then run `apm install` again.
- **No-op nudge.** When the lockfile is already satisfied and nothing needs deploying, install prints `[i] Run 'apm update' to check for newer versions.` so you know the silent success was not a missed refresh.
- **Frozen mode.** With `--frozen`, install resolves only what is in `apm.lock.yaml`. A missing lockfile, a direct dependency missing from it, or MCP config state that differs from `apm.yml` exits `1` before lockfile, target config, deployment, or cache mutation. Cold-cache installs (empty `apm_modules/`) with git `apm_package` deps are tolerated: MCP checks are skipped for absent package directories (the packages will be hydrated by the pipeline), and their MCP server configs are restored from the lockfile so no false drift is reported. Remote `claude_skill` dependencies declared at a repository root or subdirectory are also accepted from their locked type before materialization; once present, the lock type and detected skill shape must agree. Missing local paths still fail. See [`config-consistency`](../../baseline-checks/#config-consistency) for the full manifest rule. Run normal `apm install` to create or repair MCP-only lock state, then retry frozen mode. Add-style invocations (`apm install PACKAGE` and `apm install --mcp NAME`) are rejected because they mutate `apm.yml`. Orphan package lock entries are tolerated; local-path deps are skipped. This is a structural check, not a content check -- run `apm audit --ci` for hash verification.
- **Local `.apm/` deployment.** After dependencies are integrated, primitives in the project's own `.apm/` directory are deployed to the same targets. Local files win on collision. Skipped at `--global` and with `--only mcp`.
- **User-scope skill paths.** Global skill installation supports directory symlink aliases in `HOME` or `APM_HOME` without extra flags or environment changes. Symlinks inside the package remain rejected; root aliases deploy the same authorized content.
- **User-scope root context hint.** Compilation stays explicit. After `apm install -g`, targets with native user-scope instruction files pick up global instructions during install. Targets whose user-scope instruction surface is a root context file require [`apm compile --global`](../compile/#global-compilation); install prints a one-line `[i]` hint and writes no root context file.
- **OpenCode user scope.** `apm install -g --target opencode` deploys skills to
`~/.config/opencode/skills/`. Run `apm compile -g` to refresh
Expand Down
11 changes: 6 additions & 5 deletions src/apm_cli/integration/skill_integrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import shutil
import stat
from collections.abc import Callable, Iterable
from copy import copy
from dataclasses import replace
from pathlib import Path
from typing import TYPE_CHECKING
Expand Down Expand Up @@ -1459,12 +1460,12 @@ def integrate_package_skill(
"""
enforce_agent_plugin_deployment_boundary(package_info)

# Check if package type allows skill installation (T4 routing)
# SKILL and HYBRID -> install as skill
# INSTRUCTIONS and PROMPTS -> skip skill installation
# Canonicalize only the root; preserve descendant links and caller metadata.
package_info = copy(package_info)
package_info.install_path = package_info.install_path.resolve()

if not should_install_skill(package_info):
# Even non-skill packages may ship sub-skills under .apm/skills/.
# Promote them so Copilot can discover them independently.
# Non-skill packages may still ship sub-skills under .apm/skills/.
sub_skills_count, sub_deployed = self._promote_sub_skills_standalone(
package_info,
project_root,
Expand Down
11 changes: 11 additions & 0 deletions tests/integration/test_global_audit_deploy_root.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,23 @@ def _tree_snapshot(
return directories, files


@pytest.mark.parametrize("aliased_home", [False, True])
def test_global_install_audit_reads_home_deployment_root(
aliased_home: bool,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
isolated = IsolatedApmEnvironment.create(tmp_path / "global-audit", base_env=os.environ)
environment = isolated.subprocess_env()
if aliased_home:
alias = isolated.root / "home-alias"
try:
alias.symlink_to(isolated.home, target_is_directory=True)
except (OSError, NotImplementedError):
pytest.skip("directory symlinks unavailable")
environment["HOME"] = str(alias)
environment["USERPROFILE"] = str(alias)
environment["APM_HOME"] = str(alias / ".apm")
environment["APM_NO_CACHE"] = "1"
for name, value in environment.items():
monkeypatch.setenv(name, value)
Expand Down
78 changes: 77 additions & 1 deletion tests/unit/install/test_security_scan_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,15 @@

import pytest

from apm_cli.deps.plugin_parser import normalize_plugin_directory
from apm_cli.install.deployable_source_plan import DeployableSourcePlan
from apm_cli.install.helpers.security_scan import _pre_deploy_security_scan
from apm_cli.integration.agent_integrator import AgentIntegrator
from apm_cli.integration.command_integrator import CommandIntegrator
from apm_cli.integration.hook_integrator import HookIntegrator
from apm_cli.integration.instruction_integrator import InstructionIntegrator
from apm_cli.integration.prompt_integrator import PromptIntegrator
from apm_cli.integration.skill_integrator import copy_skill_to_target
from apm_cli.integration.skill_integrator import SkillIntegrator, copy_skill_to_target
from apm_cli.integration.targets import KNOWN_TARGETS
from apm_cli.models.apm_package import APMPackage, PackageInfo, PackageType
from apm_cli.security.gate import SecurityGate
Expand Down Expand Up @@ -205,6 +206,81 @@ def test_source_only_canvas_content_is_not_authorized_for_scan(tmp_path: Path) -
) == ["source-only"]


@pytest.mark.parametrize(
("layout", "package_type"),
[
(".", PackageType.CLAUDE_SKILL),
("skills/demo", PackageType.SKILL_BUNDLE),
(".apm/skills/demo", PackageType.APM_PACKAGE),
("skills/demo", PackageType.MARKETPLACE_PLUGIN),
],
)
def test_package_skill_root_alias_preserves_authorization(
tmp_path: Path, layout: str, package_type: PackageType
) -> None:
"""Root aliases deploy real files without authorizing descendant links."""
source = tmp_path / "demo"
skill = source / layout
skill.mkdir(parents=True)
alias = tmp_path / "source-alias"
try:
alias.symlink_to(source, target_is_directory=True)
except (OSError, NotImplementedError):
pytest.skip("directory symlinks unavailable")
if package_type is PackageType.MARKETPLACE_PLUGIN:
manifest = source / ".claude-plugin" / "plugin.json"
manifest.parent.mkdir()
manifest.write_text('{"name":"demo","skills":["./skills/demo"]}\n', encoding="utf-8")
(skill / "SKILL.md").write_text("# Demo\n", encoding="utf-8")
references = skill / "references"
references.mkdir()
(references / "safe.md").write_text("safe resource\n", encoding="utf-8")
if package_type is PackageType.MARKETPLACE_PLUGIN:
normalize_plugin_directory(source)
Comment thread
danielmeppiel marked this conversation as resolved.
Outdated
outside = tmp_path / "outside"
outside.mkdir()
secret = outside / "secret.md"
secret.write_text("outside resource\n", encoding="utf-8")
(references / "escape.md").symlink_to(secret)
(references / "internal-link.md").symlink_to(references / "safe.md")
(skill / "linked-directory").symlink_to(outside, target_is_directory=True)
project = tmp_path / "project"
project.mkdir()
package_info = PackageInfo(
package=APMPackage(name="demo", version="1.0.0"),
install_path=alias,
package_type=package_type,
)
original_metadata = vars(package_info).copy()
targets = [KNOWN_TARGETS["codex"]]
plan = DeployableSourcePlan.create(
package_info,
targets,
skill_subset=None,
hooks_approved=False,
canvas_approved=False,
skip_bin=True,
)

result = SkillIntegrator().integrate_package_skill(
package_info,
project,
targets=targets,
source_plan=plan,
skip_bin=True,
)

assert vars(package_info) == original_metadata
assert len(result.target_paths) == 1
deployed = result.target_paths[0]
assert (deployed / "SKILL.md").read_text(encoding="utf-8") == "# Demo\n"
assert (deployed / "references" / "safe.md").read_text(encoding="utf-8") == "safe resource\n"
assert not (deployed / "references" / "escape.md").exists()
assert not (deployed / "references" / "internal-link.md").exists()
assert not (deployed / "linked-directory").exists()
assert not plan.includes((Path(layout) / "references" / "escape.md").as_posix())


@pytest.mark.windows_compat
def test_direct_skill_copy_normalizes_equivalent_source_alias(
tmp_path: Path,
Expand Down
Loading