Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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 @@ -18,6 +18,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **BREAKING:** after consumers re-vendor the shared gh-aw `apm.md`, its import requires an explicit `target` instead of deprecated `all`; `apm-action` otherwise writes `all` into the isolated `apm.yml`, where it degrades to auto-detection without harness markers. Set the workflow engine's target and recompile; see the [gh-aw migration recipe](https://microsoft.github.io/apm/integrations/gh-aw/#shared-apmmd-import-recommended). (#2706)
- Re-vendored shared gh-aw workflows now default to APM 0.28.0 for both pack and restore, the version used for the recorded `microsoft/apm-action@v1.10.0` compatibility proof, not the latest CLI release; an explicit `apm-version` still overrides it. (#2706)

### Fixed

- `apm install` now explains skipped symlinked agent sources and points to the real-files remedy, without changing which files deploy. (#2932)

### Security

- The shared gh-aw APM pack job now declares `contents: read` (previously `permissions: {}`), the minimum the explicit built-in-token path needs. No write scope is added, and the token is not forwarded to restore or agent jobs. (#2706)
Expand Down
4 changes: 3 additions & 1 deletion CONFORMANCE.json
Original file line number Diff line number Diff line change
Expand Up @@ -1254,8 +1254,10 @@
"oracle": "tests/fixtures/spec-conformance/source-plan/req-sc-015.json",
"section": "10.16",
"status": "active",
"test_count": 3,
"test_count": 5,
"tests": [
"tests/spec_conformance/test_manifest_reqs.py::test_agent_skip_diagnostic_preserves_authorized_scan_and_materialization[False]",
"tests/spec_conformance/test_manifest_reqs.py::test_agent_skip_diagnostic_preserves_authorized_scan_and_materialization[True]",
"tests/spec_conformance/test_manifest_reqs.py::test_authorized_source_plan_fixture_oracle_covers_symlinked_content",
"tests/spec_conformance/test_manifest_reqs.py::test_authorized_source_plan_limits_scanning_and_skill_materialization",
"tests/spec_conformance/test_manifest_reqs.py::test_authorized_source_plan_requirement_covers_reintegration_and_symlinks"
Expand Down
2 changes: 1 addition & 1 deletion CONFORMANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ Repository-coordinate segments are case-insensitive for `github.com`, GitHub Ent
| [req-sc-012](docs/src/content/docs/specs/openapm-v0.1.md#req-sc-012) | MUST | 10.14 | consumer | active | 1 | - |
| [req-sc-013](docs/src/content/docs/specs/openapm-v0.1.md#req-sc-013) | MUST | 10.3 | consumer | active | 1 | - |
| [req-sc-014](docs/src/content/docs/specs/openapm-v0.1.md#req-sc-014) | MUST | 10.15 | consumer | active | 1 | - |
| [req-sc-015](docs/src/content/docs/specs/openapm-v0.1.md#req-sc-015) | MUST | 10.16 | consumer | active | 3 | tests/fixtures/spec-conformance/source-plan/req-sc-015.json |
| [req-sc-015](docs/src/content/docs/specs/openapm-v0.1.md#req-sc-015) | MUST | 10.16 | consumer | active | 5 | tests/fixtures/spec-conformance/source-plan/req-sc-015.json |
| [req-tg-001](docs/src/content/docs/specs/openapm-v0.1.md#req-tg-001) | MUST | 8.4 | consumer | active | 1 | - |
| [req-tg-002](docs/src/content/docs/specs/openapm-v0.1.md#req-tg-002) | MUST | 8.5 | consumer | active | 1 | - |
| [req-tg-003](docs/src/content/docs/specs/openapm-v0.1.md#req-tg-003) | MUST | 8.5 | consumer | active | 1 | - |
Expand Down
7 changes: 7 additions & 0 deletions docs/src/content/docs/consumer/install-packages.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,13 @@ For the full flag reference, run `apm install --help` or see
- **Drift between `apm_modules/` and the lockfile.** Run
`apm audit --ci` locally to reproduce the CI gate; see
[Update and refresh](../update-and-refresh/) to recover.
- **Skipped symlinked agent source.** `apm install` warns and skips agent
files or directories that are symlinks (for example
`.apm/agents -> ../agents`). Fix by shipping real files and directories in
the source package, then rerun `apm install`. Do not edit `apm_modules/` --
cached edits are not a durable fix. For a third-party package,
ask the author to publish real sources. See
[Instructions and agents](../../producer/author-primitives/instructions-and-agents/#agents).

Once your dependencies are installed, scripts run them.
[Run scripts](../run-scripts/) shows how to wire `apm.yml`'s
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,14 @@ my-package/

File names end in `.agent.md` and live under `.apm/agents/`.

For own-project and Git-backed package installs, symlinked agent source
files and directories (including `.apm/agents -> ../agents`) are skipped.
`apm install` warns with the skipped source path. Use real files and
directories under `.apm/agents/` or real `*.agent.md` files at the package
root, then rerun `apm install`. Local-path dependencies (`./...` or `../...`)
still work: contained symlinks are validated and copied as real files into
`apm_modules/` before agent discovery.

### Frontmatter

```markdown
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,14 @@ Per-primitive scan paths for `apm install`:
| agent | `.apm/agents/` | Yes: `*.agent.md` at root |
| skill | `.apm/skills/<name>/` | Yes: `skills/<name>/` (SKILL_BUNDLE or MARKETPLACE_PLUGIN) |

Own-project and Git-backed package installs skip symlinked agent source
files and directories, including `.apm/agents -> ../agents`.
`apm install` warns with the skipped source path. Use real files and
directories under `.apm/agents/` or real `*.agent.md` files at root, then
rerun `apm install`. Local-path dependencies (`./...` or `../...`) still work:
contained symlinks are validated and copied as real files into
`apm_modules/` before agent discovery.

**Recommendation for marketplace publishers:** use `.apm/<type>/` for
every primitive. This is the only layout that is symmetric between
`apm pack` and `apm install`.
Expand Down
67 changes: 50 additions & 17 deletions src/apm_cli/install/deployable_source_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,28 +9,38 @@
from __future__ import annotations

import os
from collections.abc import Iterator
from collections.abc import Callable, Iterator
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from typing import TYPE_CHECKING, Any

from apm_cli.install.cache_pin import MARKER_FILENAME
from apm_cli.models.dependency.subsets import skill_subset_filter_tokens
from apm_cli.utils.diagnostics import printable_ascii_text
from apm_cli.utils.path_security import (
PathTraversalError,
ensure_path_within_resolved,
has_symlink_component,
)
from apm_cli.utils.paths import portable_relpath

if TYPE_CHECKING:
from apm_cli.utils.diagnostics import DiagnosticCollector

def _is_safe_source_path(path: Path, source_root: Path) -> bool:
"""Return whether a source candidate stays in the real package tree."""

def _is_safe_source_path(
path: Path,
source_root: Path,
on_symlink: Callable[[Path], None] | None = None,
) -> bool:
"""Check containment; notify on_symlink only for symlink-component rejection."""
try:
path.relative_to(source_root)
except ValueError:
return False
if has_symlink_component(source_root, path):
if on_symlink is not None:
on_symlink(path)
return False
try:
ensure_path_within_resolved(path, source_root)
Expand Down Expand Up @@ -73,6 +83,8 @@ def create(
canvas_approved: bool,
skip_bin: bool,
plugin_bin_deployable: bool = False,
diagnostics: DiagnosticCollector | None = None,
package_name: str = "",
) -> DeployableSourcePlan:
"""Build the authorized deploy set after all deployment gates resolve."""
source_root = Path(package_info.install_path).resolve()
Expand All @@ -81,45 +93,66 @@ def create(
hook_source_selection = None
target_primitives = {primitive for target in targets for primitive in target.primitives}

def add_file(path: Path) -> None:
if _is_safe_source_path(path, source_root) and path.is_file():
def warn_agent_symlink(path: Path) -> None:
if diagnostics is not None:
# Name the rejected link, not the target portable_relpath resolves.
relative = path.relative_to(source_root).as_posix()
diagnostics.warn(
message=(
"Skipped symlinked agent source: "
f"{printable_ascii_text(relative)}. "
"Symlinked agent sources are not deployed. "
"Use real files and directories in .apm/agents/ "
"(or real *.agent.md files at the package root), then rerun apm install."
),
package=printable_ascii_text(package_name),
)

def add_file(path: Path, on_symlink: Callable[[Path], None] | None = None) -> None:
if _is_safe_source_path(path, source_root, on_symlink) and path.is_file():
paths.add(portable_relpath(path, source_root))

def tree_files(root: Path) -> Iterator[Path]:
if not _is_safe_source_path(root, source_root) or not root.is_dir():
def tree_files(
root: Path, on_symlink: Callable[[Path], None] | None = None
) -> Iterator[Path]:
if not _is_safe_source_path(root, source_root, on_symlink) or not root.is_dir():
return
for parent, directory_names, file_names in os.walk(root, followlinks=False):
parent_path = Path(parent)
directory_names[:] = [
name
for name in directory_names
if _is_safe_source_path(parent_path / name, source_root)
if _is_safe_source_path(parent_path / name, source_root, on_symlink)
]
yield from (parent_path / name for name in file_names)

def add_tree(root: Path) -> None:
for path in tree_files(root):
add_file(path)

def add_matching_files(root: Path, pattern: str) -> None:
for path in tree_files(root):
def add_matching_files(
root: Path, pattern: str, on_symlink: Callable[[Path], None] | None = None
) -> None:
for path in tree_files(root, on_symlink):
if path.match(pattern):
add_file(path)
add_file(path, on_symlink)

def add_direct_matching_files(root: Path, pattern: str) -> None:
if not _is_safe_source_path(root, source_root) or not root.is_dir():
def add_direct_matching_files(
root: Path, pattern: str, on_symlink: Callable[[Path], None] | None = None
) -> None:
if not _is_safe_source_path(root, source_root, on_symlink) or not root.is_dir():
return
for path in root.iterdir():
if path.match(pattern):
add_file(path)
add_file(path, on_symlink)

if "prompts" in target_primitives or "commands" in target_primitives:
add_direct_matching_files(source_root, "*.prompt.md")
add_matching_files(source_root / ".apm" / "prompts", "*.prompt.md")

if "agents" in target_primitives:
add_direct_matching_files(source_root, "*.agent.md")
add_matching_files(source_root / ".apm" / "agents", "*.md")
add_direct_matching_files(source_root, "*.agent.md", warn_agent_symlink)
add_matching_files(source_root / ".apm" / "agents", "*.md", warn_agent_symlink)

if "instructions" in target_primitives:
add_matching_files(source_root / ".apm" / "instructions", "*.instructions.md")
Expand Down
2 changes: 2 additions & 0 deletions src/apm_cli/install/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,8 @@ def _reconcile_excluded_targets() -> None:
hooks_approved=_hooks_approved,
canvas_approved=_canvas_approved or is_first_party,
skip_bin=_skip_bin,
diagnostics=diagnostics,
package_name=package_name,
plugin_bin_deployable=_plugin_bin_deployable(
package_info,
targets,
Expand Down
60 changes: 60 additions & 0 deletions tests/spec_conformance/test_manifest_reqs.py
Original file line number Diff line number Diff line change
Expand Up @@ -1296,6 +1296,66 @@ def materialize(destination: Path, source_plan: DeployableSourcePlan) -> frozens
)


@pytest.mark.req("req-sc-015")
@pytest.mark.parametrize("directory_link", [False, True])
def test_agent_skip_diagnostic_preserves_authorized_scan_and_materialization(
tmp_path: Path, directory_link: bool
) -> None:
"""req-sc-015 owns exclusion; the paired warning is APM UX, not a spec mandate."""
from apm_cli.install.deployable_source_plan import DeployableSourcePlan
from apm_cli.models.apm_package import PackageInfo, PackageType
from apm_cli.security.gate import BLOCK_POLICY

source = tmp_path / "source"
(source / ".apm").mkdir(parents=True)
(source / "safe.agent.md").write_text(
"---\nname: safe\ndescription: Safe agent.\n---\nBody.\n", encoding="utf-8"
)
hidden = source / "unselected"
hidden.mkdir()
(hidden / "hidden.agent.md").write_text("not scanned\u202e\n", encoding="utf-8")
link = source / (".apm/agents" if directory_link else "linked.agent.md")
link.symlink_to(
hidden if directory_link else hidden / "hidden.agent.md",
target_is_directory=directory_link,
)
package = PackageInfo(
package=APMPackage(name="source", version="1.0.0"),
install_path=source,
package_type=PackageType.APM_PACKAGE,
)
diagnostics = DiagnosticCollector()
target = KNOWN_TARGETS["claude"]
plan = DeployableSourcePlan.create(
package,
[target],
skill_subset=None,
hooks_approved=False,
canvas_approved=False,
skip_bin=True,
diagnostics=diagnostics,
package_name="source",
)
scan = plan.scan_security(policy=BLOCK_POLICY)
assert plan.paths == scan.scanned_files == frozenset({"safe.agent.md"})
assert not scan.has_findings

destination = tmp_path / "destination"
(destination / ".claude").mkdir(parents=True)
result = AgentIntegrator().integrate_agents_for_target(
target, package, destination, source_plan=plan, diagnostics=diagnostics
)
expected = destination / ".claude" / "agents" / "safe.md"
assert result.files_integrated == 1
assert result.target_paths == [expected]
assert list((destination / ".claude" / "agents").iterdir()) == [expected]
warnings = diagnostics.by_category()[CATEGORY_WARNING]
assert len(warnings) == 1
assert warnings[0].message.startswith(
f"Skipped symlinked agent source: {link.relative_to(source).as_posix()}."
)


@pytest.mark.req("req-sc-015")
def test_authorized_source_plan_requirement_covers_reintegration_and_symlinks() -> None:
"""The citation names every lifecycle and excludes symlink source entries."""
Expand Down
Loading