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
50 changes: 48 additions & 2 deletions src/apm_cli/install/helpers/security_scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
)
Comment on lines +70 to +75
if not verdict.has_findings:
return True

Expand All @@ -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

Expand Down
3 changes: 3 additions & 0 deletions src/apm_cli/security/gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +84 to 86

Expand All @@ -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)
Expand Down
29 changes: 29 additions & 0 deletions tests/unit/install/test_security_scan_scope.py
Original file line number Diff line number Diff line change
@@ -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")
Loading