diff --git a/src/apm_cli/install/helpers/security_scan.py b/src/apm_cli/install/helpers/security_scan.py index 9cd8c6b24..76dfa1b67 100644 --- a/src/apm_cli/install/helpers/security_scan.py +++ b/src/apm_cli/install/helpers/security_scan.py @@ -11,6 +11,44 @@ from apm_cli.utils.diagnostics import DiagnosticCollector +_DEPLOYABLE_DIRS = frozenset( + { + ".apm", + ".github", + ".claude-plugin", + "agents", + "commands", + "context", + "contexts", + "hooks", + "instructions", + "memory", + "prompts", + "skills", + } +) +_DEPLOYABLE_NAMES = frozenset( + {"SKILL.md", "plugin.json", "hooks.json", ".mcp.json", "lsp.json"} +) +_DEPLOYABLE_SUFFIXES = ( + ".agent.md", + ".instructions.md", + ".context.md", + ".memory.md", + ".prompt.md", +) + + +def _is_deployable_source_path(relative_path: str) -> bool: + """Return whether a fetched source file belongs to deployable package content.""" + parts = tuple(part for part in relative_path.replace("\\", "/").split("/") if part) + if not parts: + return False + if parts[-1] in _DEPLOYABLE_NAMES or parts[-1].endswith(_DEPLOYABLE_SUFFIXES): + return True + return any(part in _DEPLOYABLE_DIRS for part in parts[:-1]) + + def _pre_deploy_security_scan( install_path: Path, diagnostics: DiagnosticCollector, @@ -29,7 +67,12 @@ def _pre_deploy_security_scan( """ from apm_cli.security.gate import BLOCK_POLICY, SecurityGate - verdict = SecurityGate.scan_files(install_path, policy=BLOCK_POLICY, force=force) + verdict = SecurityGate.scan_files( + install_path, + policy=BLOCK_POLICY, + force=force, + path_filter=_is_deployable_source_path, + ) if not verdict.has_findings: return True @@ -41,7 +84,10 @@ def _pre_deploy_security_scan( logger.error( f" Blocked: {package_name or 'package'} contains critical hidden character(s)" ) - logger.tree_item(f" |-- Inspect source: {install_path}") + logger.tree_item(f" |-- Source checkout: {install_path}") + logger.tree_item( + " |-- Note: a failed install may remove this checkout during transaction cleanup" + ) logger.tree_item(" |-- Use --force to deploy anyway") return False diff --git a/src/apm_cli/security/gate.py b/src/apm_cli/security/gate.py index c4343fa36..c304f69f4 100644 --- a/src/apm_cli/security/gate.py +++ b/src/apm_cli/security/gate.py @@ -81,6 +81,7 @@ def scan_files( *, policy: ScanPolicy = BLOCK_POLICY, force: bool = False, + path_filter=None, ) -> ScanVerdict: """Walk *root*, scan every regular file, return a verdict. @@ -96,6 +97,8 @@ def scan_files( if fpath.is_symlink(): continue rel = portable_relpath(fpath, root) + if path_filter is not None and not path_filter(rel): + continue scanned_files.add(rel) try: file_findings = ContentScanner.scan_file(fpath) diff --git a/tests/unit/install/test_security_scan_scope.py b/tests/unit/install/test_security_scan_scope.py new file mode 100644 index 000000000..2b4955b37 --- /dev/null +++ b/tests/unit/install/test_security_scan_scope.py @@ -0,0 +1,29 @@ +from apm_cli.install.helpers.security_scan import _is_deployable_source_path + + +def test_deployable_primitives_are_scanned(): + deployable = [ + "SKILL.md", + "plugin.json", + ".apm/agents/reviewer.agent.md", + ".github/instructions/security.instructions.md", + "skills/review/SKILL.md", + "hooks/hooks.json", + ] + + assert all(_is_deployable_source_path(path) for path in deployable) + + +def test_source_only_files_are_not_scanned_by_install_gate(): + source_only = [ + "src/DotnetInspector.HostileNameFixtures/HostileLiterals.cs", + "tests/fixtures/hostile-name.txt", + "docs/hostile-metadata.md", + "src/Program.cs", + ] + + assert not any(_is_deployable_source_path(path) for path in source_only) + + +def test_windows_paths_are_normalized(): + assert _is_deployable_source_path(r".github\agents\reviewer.agent.md")