apm-cli 0.8.0__tar.gz → 0.8.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {apm_cli-0.8.0/src/apm_cli.egg-info → apm_cli-0.8.1}/PKG-INFO +3 -3
- {apm_cli-0.8.0 → apm_cli-0.8.1}/README.md +2 -2
- {apm_cli-0.8.0 → apm_cli-0.8.1}/pyproject.toml +1 -1
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/adapters/client/base.py +33 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/adapters/client/codex.py +18 -3
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/adapters/client/copilot.py +5 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/adapters/client/vscode.py +37 -1
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/bundle/packer.py +21 -17
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/bundle/unpacker.py +50 -3
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/commands/audit.py +96 -30
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/commands/compile.py +19 -6
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/commands/install.py +51 -64
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/commands/pack.py +13 -1
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/compilation/agents_compiler.py +13 -7
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/compilation/claude_formatter.py +11 -6
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/core/token_manager.py +64 -2
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/deps/github_downloader.py +123 -14
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/drift.py +7 -1
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/integration/base_integrator.py +4 -1
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/models/dependency.py +7 -28
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/registry/client.py +28 -27
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/registry/operations.py +16 -2
- apm_cli-0.8.1/src/apm_cli/security/__init__.py +24 -0
- apm_cli-0.8.1/src/apm_cli/security/audit_report.py +251 -0
- apm_cli-0.8.1/src/apm_cli/security/gate.py +222 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/utils/diagnostics.py +22 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/utils/github_host.py +25 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1/src/apm_cli.egg-info}/PKG-INFO +3 -3
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli.egg-info/SOURCES.txt +3 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/tests/test_apm_package_models.py +15 -35
- {apm_cli-0.8.0 → apm_cli-0.8.1}/tests/test_enhanced_discovery.py +2 -2
- {apm_cli-0.8.0 → apm_cli-0.8.1}/tests/test_github_downloader.py +444 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/tests/test_github_downloader_token_precedence.py +5 -2
- apm_cli-0.8.1/tests/test_token_manager.py +196 -0
- apm_cli-0.8.0/src/apm_cli/security/__init__.py +0 -5
- {apm_cli-0.8.0 → apm_cli-0.8.1}/AUTHORS +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/LICENSE +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/setup.cfg +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/__init__.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/adapters/__init__.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/adapters/client/__init__.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/adapters/client/cursor.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/adapters/client/opencode.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/adapters/package_manager/__init__.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/adapters/package_manager/base.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/adapters/package_manager/default_manager.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/bundle/__init__.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/bundle/lockfile_enrichment.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/cli.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/commands/__init__.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/commands/_helpers.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/commands/config.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/commands/deps.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/commands/init.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/commands/list_cmd.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/commands/mcp.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/commands/prune.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/commands/run.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/commands/runtime.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/commands/uninstall.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/commands/update.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/compilation/__init__.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/compilation/constants.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/compilation/constitution.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/compilation/constitution_block.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/compilation/context_optimizer.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/compilation/distributed_compiler.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/compilation/injector.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/compilation/link_resolver.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/compilation/template_builder.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/config.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/core/__init__.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/core/conflict_detector.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/core/docker_args.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/core/operations.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/core/safe_installer.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/core/script_runner.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/core/target_detection.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/deps/__init__.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/deps/aggregator.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/deps/apm_resolver.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/deps/collection_parser.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/deps/dependency_graph.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/deps/lockfile.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/deps/package_validator.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/deps/plugin_parser.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/deps/verifier.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/factory.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/integration/__init__.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/integration/agent_integrator.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/integration/command_integrator.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/integration/hook_integrator.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/integration/instruction_integrator.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/integration/mcp_integrator.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/integration/prompt_integrator.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/integration/skill_integrator.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/integration/skill_transformer.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/integration/targets.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/integration/utils.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/models/__init__.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/models/apm_package.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/models/plugin.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/models/validation.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/output/__init__.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/output/formatters.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/output/models.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/output/script_formatters.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/primitives/__init__.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/primitives/discovery.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/primitives/models.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/primitives/parser.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/registry/__init__.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/registry/integration.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/runtime/__init__.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/runtime/base.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/runtime/codex_runtime.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/runtime/copilot_runtime.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/runtime/factory.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/runtime/llm_runtime.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/runtime/manager.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/security/content_scanner.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/utils/__init__.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/utils/console.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/utils/helpers.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/utils/version_checker.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/version.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/workflow/__init__.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/workflow/discovery.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/workflow/parser.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/workflow/runner.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli.egg-info/dependency_links.txt +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli.egg-info/entry_points.txt +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli.egg-info/requires.txt +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli.egg-info/top_level.txt +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/tests/test_apm_resolver.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/tests/test_codex_docker_args_fix.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/tests/test_codex_empty_string_and_defaults.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/tests/test_collision_integration.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/tests/test_console.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/tests/test_distributed_compilation.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/tests/test_empty_string_and_defaults.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/tests/test_lockfile.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/tests/test_runnable_prompts.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/tests/test_runtime_manager_token_precedence.py +0 -0
- {apm_cli-0.8.0 → apm_cli-0.8.1}/tests/test_virtual_package_multi_install.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: apm-cli
|
|
3
|
-
Version: 0.8.
|
|
3
|
+
Version: 0.8.1
|
|
4
4
|
Summary: MCP configuration tool
|
|
5
5
|
Author-email: Daniel Meppiel <user@example.com>
|
|
6
6
|
License: MIT License
|
|
@@ -88,7 +88,7 @@ dependencies:
|
|
|
88
88
|
# Specific agent primitives from any repository
|
|
89
89
|
- github/awesome-copilot/agents/api-architect.agent.md
|
|
90
90
|
# A full APM package with instructions, skills, prompts, hooks...
|
|
91
|
-
- microsoft/apm-sample-package
|
|
91
|
+
- microsoft/apm-sample-package#v1.0.0
|
|
92
92
|
```
|
|
93
93
|
|
|
94
94
|
```bash
|
|
@@ -149,7 +149,7 @@ pip install apm-cli
|
|
|
149
149
|
Then start adding packages:
|
|
150
150
|
|
|
151
151
|
```bash
|
|
152
|
-
apm install microsoft/apm-sample-package
|
|
152
|
+
apm install microsoft/apm-sample-package#v1.0.0
|
|
153
153
|
```
|
|
154
154
|
|
|
155
155
|
See the **[Getting Started guide](https://microsoft.github.io/apm/getting-started/quick-start/)** for the full walkthrough.
|
|
@@ -27,7 +27,7 @@ dependencies:
|
|
|
27
27
|
# Specific agent primitives from any repository
|
|
28
28
|
- github/awesome-copilot/agents/api-architect.agent.md
|
|
29
29
|
# A full APM package with instructions, skills, prompts, hooks...
|
|
30
|
-
- microsoft/apm-sample-package
|
|
30
|
+
- microsoft/apm-sample-package#v1.0.0
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
```bash
|
|
@@ -88,7 +88,7 @@ pip install apm-cli
|
|
|
88
88
|
Then start adding packages:
|
|
89
89
|
|
|
90
90
|
```bash
|
|
91
|
-
apm install microsoft/apm-sample-package
|
|
91
|
+
apm install microsoft/apm-sample-package#v1.0.0
|
|
92
92
|
```
|
|
93
93
|
|
|
94
94
|
See the **[Getting Started guide](https://microsoft.github.io/apm/getting-started/quick-start/)** for the full walkthrough.
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
"""Base adapter interface for MCP clients."""
|
|
2
2
|
|
|
3
|
+
import re
|
|
3
4
|
from abc import ABC, abstractmethod
|
|
4
5
|
|
|
6
|
+
_INPUT_VAR_RE = re.compile(r"\$\{input:([^}]+)\}")
|
|
7
|
+
|
|
5
8
|
|
|
6
9
|
class MCPClientAdapter(ABC):
|
|
7
10
|
"""Base adapter for MCP clients."""
|
|
@@ -84,3 +87,33 @@ class MCPClientAdapter(ABC):
|
|
|
84
87
|
return "nuget"
|
|
85
88
|
|
|
86
89
|
return ""
|
|
90
|
+
|
|
91
|
+
@staticmethod
|
|
92
|
+
def _warn_input_variables(mapping, server_name, runtime_label):
|
|
93
|
+
"""Emit a warning for each ``${input:...}`` reference found in *mapping*.
|
|
94
|
+
|
|
95
|
+
Runtimes that do not support VS Code-style input prompts (Copilot CLI,
|
|
96
|
+
Codex CLI, etc.) should call this so users know their placeholders
|
|
97
|
+
will not be resolved at runtime.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
mapping (dict): Header or env dict to scan.
|
|
101
|
+
server_name (str): Server name for the warning message.
|
|
102
|
+
runtime_label (str): Human-readable runtime name (e.g. "Copilot CLI").
|
|
103
|
+
"""
|
|
104
|
+
if not mapping:
|
|
105
|
+
return
|
|
106
|
+
seen: set = set()
|
|
107
|
+
for value in mapping.values():
|
|
108
|
+
if not isinstance(value, str):
|
|
109
|
+
continue
|
|
110
|
+
for match in _INPUT_VAR_RE.finditer(value):
|
|
111
|
+
var_id = match.group(1)
|
|
112
|
+
if var_id in seen:
|
|
113
|
+
continue
|
|
114
|
+
seen.add(var_id)
|
|
115
|
+
print(
|
|
116
|
+
f"[!] Warning: ${{input:{var_id}}} in server "
|
|
117
|
+
f"'{server_name}' will not be resolved \u2014 "
|
|
118
|
+
f"{runtime_label} does not support input variable prompts"
|
|
119
|
+
)
|
|
@@ -181,6 +181,7 @@ class CodexClientAdapter(MCPClientAdapter):
|
|
|
181
181
|
config["args"] = raw["args"]
|
|
182
182
|
if raw.get("env"):
|
|
183
183
|
config["env"] = raw["env"]
|
|
184
|
+
self._warn_input_variables(raw["env"], server_info.get("name", ""), "Codex CLI")
|
|
184
185
|
return config
|
|
185
186
|
|
|
186
187
|
# Note: Remote servers (SSE type) are handled in configure_mcp_server and rejected early
|
|
@@ -218,10 +219,24 @@ class CodexClientAdapter(MCPClientAdapter):
|
|
|
218
219
|
# Generate command and args based on package type
|
|
219
220
|
if registry_name == "npm":
|
|
220
221
|
config["command"] = runtime_hint or "npx"
|
|
221
|
-
# Always include package name; filter duplicates from legacy runtime_arguments
|
|
222
222
|
all_args = processed_runtime_args + processed_package_args
|
|
223
|
-
|
|
224
|
-
|
|
223
|
+
if all_args:
|
|
224
|
+
# If runtime_arguments already include the package (bare or
|
|
225
|
+
# versioned), use them as-is — they are authoritative from
|
|
226
|
+
# the registry and may carry a version pin.
|
|
227
|
+
has_pkg = any(
|
|
228
|
+
a == package_name or a.startswith(f"{package_name}@")
|
|
229
|
+
for a in all_args
|
|
230
|
+
)
|
|
231
|
+
if has_pkg:
|
|
232
|
+
config["args"] = all_args
|
|
233
|
+
else:
|
|
234
|
+
# Legacy: runtime_arguments don't mention the package,
|
|
235
|
+
# prepend -y + bare name ourselves.
|
|
236
|
+
extra_args = [a for a in all_args if a != "-y"]
|
|
237
|
+
config["args"] = ["-y", package_name] + extra_args
|
|
238
|
+
else:
|
|
239
|
+
config["args"] = ["-y", package_name]
|
|
225
240
|
# For NPM packages, also use env block for environment variables
|
|
226
241
|
if resolved_env:
|
|
227
242
|
config["env"] = resolved_env
|
|
@@ -173,6 +173,7 @@ class CopilotClientAdapter(MCPClientAdapter):
|
|
|
173
173
|
config["args"] = raw["args"]
|
|
174
174
|
if raw.get("env"):
|
|
175
175
|
config["env"] = raw["env"]
|
|
176
|
+
self._warn_input_variables(raw["env"], server_info.get("name", ""), "Copilot CLI")
|
|
176
177
|
# Apply tools override if present
|
|
177
178
|
tools_override = server_info.get("_apm_tools_override")
|
|
178
179
|
if tools_override:
|
|
@@ -218,6 +219,10 @@ class CopilotClientAdapter(MCPClientAdapter):
|
|
|
218
219
|
resolved_value = self._resolve_env_variable(header_name, header_value, env_overrides)
|
|
219
220
|
config["headers"][header_name] = resolved_value
|
|
220
221
|
|
|
222
|
+
# Warn about unresolvable ${input:...} references in headers
|
|
223
|
+
if config.get("headers"):
|
|
224
|
+
self._warn_input_variables(config["headers"], server_info.get("name", ""), "Copilot CLI")
|
|
225
|
+
|
|
221
226
|
# Apply tools override from MCP dependency overlay if present
|
|
222
227
|
tools_override = server_info.get("_apm_tools_override")
|
|
223
228
|
if tools_override:
|
|
@@ -8,7 +8,7 @@ https://code.visualstudio.com/docs/copilot/chat/mcp-servers
|
|
|
8
8
|
import json
|
|
9
9
|
import os
|
|
10
10
|
from pathlib import Path
|
|
11
|
-
from .base import MCPClientAdapter
|
|
11
|
+
from .base import MCPClientAdapter, _INPUT_VAR_RE
|
|
12
12
|
from ...registry.client import SimpleRegistryClient
|
|
13
13
|
from ...registry.integration import RegistryIntegration
|
|
14
14
|
|
|
@@ -197,6 +197,9 @@ class VSCodeClientAdapter(MCPClientAdapter):
|
|
|
197
197
|
}
|
|
198
198
|
if raw.get("env"):
|
|
199
199
|
server_config["env"] = raw["env"]
|
|
200
|
+
input_vars.extend(
|
|
201
|
+
self._extract_input_variables(raw["env"], server_info.get("name", ""))
|
|
202
|
+
)
|
|
200
203
|
return server_config, input_vars
|
|
201
204
|
|
|
202
205
|
# Check for packages information
|
|
@@ -308,6 +311,9 @@ class VSCodeClientAdapter(MCPClientAdapter):
|
|
|
308
311
|
"url": remote.get("url", ""),
|
|
309
312
|
"headers": headers,
|
|
310
313
|
}
|
|
314
|
+
input_vars.extend(
|
|
315
|
+
self._extract_input_variables(headers, server_info.get("name", ""))
|
|
316
|
+
)
|
|
311
317
|
# If no packages AND no endpoints/remotes, fail with clear error
|
|
312
318
|
else:
|
|
313
319
|
packages = server_info.get("packages", [])
|
|
@@ -323,6 +329,36 @@ class VSCodeClientAdapter(MCPClientAdapter):
|
|
|
323
329
|
|
|
324
330
|
return server_config, input_vars
|
|
325
331
|
|
|
332
|
+
def _extract_input_variables(self, mapping, server_name):
|
|
333
|
+
"""Scan dict values for ${input:...} references and return input variable definitions.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
mapping (dict): Header or env dict whose values may contain
|
|
337
|
+
``${input:<id>}`` placeholders.
|
|
338
|
+
server_name (str): Server name used in the description field.
|
|
339
|
+
|
|
340
|
+
Returns:
|
|
341
|
+
list[dict]: Input variable definitions (``promptString``, ``password: true``).
|
|
342
|
+
Duplicates within *mapping* are already deduplicated.
|
|
343
|
+
"""
|
|
344
|
+
seen: set = set()
|
|
345
|
+
result: list = []
|
|
346
|
+
for value in (mapping or {}).values():
|
|
347
|
+
if not isinstance(value, str):
|
|
348
|
+
continue
|
|
349
|
+
for match in _INPUT_VAR_RE.finditer(value):
|
|
350
|
+
var_id = match.group(1)
|
|
351
|
+
if var_id in seen:
|
|
352
|
+
continue
|
|
353
|
+
seen.add(var_id)
|
|
354
|
+
result.append({
|
|
355
|
+
"type": "promptString",
|
|
356
|
+
"id": var_id,
|
|
357
|
+
"description": f"{var_id} for MCP server {server_name}",
|
|
358
|
+
"password": True,
|
|
359
|
+
})
|
|
360
|
+
return result
|
|
361
|
+
|
|
326
362
|
@staticmethod
|
|
327
363
|
def _extract_package_args(package):
|
|
328
364
|
"""Extract positional arguments from a package entry.
|
|
@@ -156,8 +156,13 @@ def pack_bundle(
|
|
|
156
156
|
lockfile_enriched=True,
|
|
157
157
|
)
|
|
158
158
|
|
|
159
|
-
# 5b. Scan files for hidden characters before bundling
|
|
160
|
-
|
|
159
|
+
# 5b. Scan files for hidden characters before bundling.
|
|
160
|
+
# Intentionally non-blocking (warn only) — pack is an authoring tool.
|
|
161
|
+
# Critical findings here mean the author's own source files contain
|
|
162
|
+
# hidden characters. We surface them so the author can fix before
|
|
163
|
+
# publishing, but don't block the bundle. Consumers are protected by
|
|
164
|
+
# install/unpack which block on critical.
|
|
165
|
+
from ..security.gate import WARN_POLICY, SecurityGate
|
|
161
166
|
from ..utils.console import _rich_warning
|
|
162
167
|
|
|
163
168
|
_scan_findings_total = 0
|
|
@@ -165,19 +170,15 @@ def pack_bundle(
|
|
|
165
170
|
src = project_root / rel_path
|
|
166
171
|
if src.is_symlink():
|
|
167
172
|
continue
|
|
168
|
-
if src.
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
continue
|
|
178
|
-
findings = ContentScanner.scan_file(fpath)
|
|
179
|
-
if findings:
|
|
180
|
-
_scan_findings_total += len(findings)
|
|
173
|
+
if src.is_dir():
|
|
174
|
+
verdict = SecurityGate.scan_files(src, policy=WARN_POLICY)
|
|
175
|
+
_scan_findings_total += len(verdict.all_findings)
|
|
176
|
+
elif src.is_file():
|
|
177
|
+
verdict = SecurityGate.scan_text(
|
|
178
|
+
src.read_text(encoding="utf-8", errors="replace"),
|
|
179
|
+
str(src), policy=WARN_POLICY,
|
|
180
|
+
)
|
|
181
|
+
_scan_findings_total += len(verdict.all_findings)
|
|
181
182
|
if _scan_findings_total:
|
|
182
183
|
_rich_warning(
|
|
183
184
|
f"Bundle contains {_scan_findings_total} hidden character(s) across source files "
|
|
@@ -191,12 +192,15 @@ def pack_bundle(
|
|
|
191
192
|
# 7. Copy files preserving directory structure
|
|
192
193
|
for rel_path in unique_files:
|
|
193
194
|
src = project_root / rel_path
|
|
195
|
+
if src.is_symlink():
|
|
196
|
+
continue # Never bundle symlinks
|
|
194
197
|
dest = bundle_dir / rel_path
|
|
195
198
|
if src.is_dir():
|
|
196
|
-
|
|
199
|
+
from ..security.gate import ignore_symlinks
|
|
200
|
+
shutil.copytree(src, dest, dirs_exist_ok=True, ignore=ignore_symlinks)
|
|
197
201
|
else:
|
|
198
202
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
199
|
-
shutil.copy2(src, dest)
|
|
203
|
+
shutil.copy2(src, dest, follow_symlinks=False)
|
|
200
204
|
|
|
201
205
|
# 8. Enrich lockfile copy and write to bundle
|
|
202
206
|
enriched_yaml = enrich_lockfile_for_pack(lockfile, fmt, effective_target)
|
|
@@ -20,6 +20,8 @@ class UnpackResult:
|
|
|
20
20
|
verified: bool = False
|
|
21
21
|
dependency_files: Dict[str, List[str]] = field(default_factory=dict)
|
|
22
22
|
skipped_count: int = 0
|
|
23
|
+
security_warnings: int = 0
|
|
24
|
+
security_critical: int = 0
|
|
23
25
|
|
|
24
26
|
|
|
25
27
|
def unpack_bundle(
|
|
@@ -27,6 +29,7 @@ def unpack_bundle(
|
|
|
27
29
|
output_dir: Path = Path("."),
|
|
28
30
|
skip_verify: bool = False,
|
|
29
31
|
dry_run: bool = False,
|
|
32
|
+
force: bool = False,
|
|
30
33
|
) -> UnpackResult:
|
|
31
34
|
"""Extract and apply an APM bundle to a project directory.
|
|
32
35
|
|
|
@@ -39,6 +42,7 @@ def unpack_bundle(
|
|
|
39
42
|
output_dir: Target project directory to copy files into.
|
|
40
43
|
skip_verify: If *True*, skip completeness verification against the lockfile.
|
|
41
44
|
dry_run: If *True*, resolve the file list but write nothing to disk.
|
|
45
|
+
force: If *True*, deploy even when critical hidden characters are found.
|
|
42
46
|
|
|
43
47
|
Returns:
|
|
44
48
|
:class:`UnpackResult` describing what was (or would be) extracted.
|
|
@@ -55,12 +59,16 @@ def unpack_bundle(
|
|
|
55
59
|
cleanup_temp = True
|
|
56
60
|
try:
|
|
57
61
|
with tarfile.open(bundle_path, "r:gz") as tar:
|
|
58
|
-
# Security: prevent path traversal
|
|
62
|
+
# Security: prevent path traversal and special entries
|
|
59
63
|
for member in tar.getmembers():
|
|
60
64
|
if member.name.startswith("/") or ".." in member.name:
|
|
61
65
|
raise ValueError(
|
|
62
66
|
f"Refusing to extract path-traversal entry: {member.name}"
|
|
63
67
|
)
|
|
68
|
+
if member.issym() or member.islnk():
|
|
69
|
+
raise ValueError(
|
|
70
|
+
f"Refusing to extract symlink/hardlink: {member.name}"
|
|
71
|
+
)
|
|
64
72
|
# filter="data" was added in Python 3.12; use it when available
|
|
65
73
|
if sys.version_info >= (3, 12):
|
|
66
74
|
tar.extractall(temp_dir, filter="data")
|
|
@@ -131,6 +139,34 @@ def unpack_bundle(
|
|
|
131
139
|
if skip_verify:
|
|
132
140
|
verified = False
|
|
133
141
|
|
|
142
|
+
# 3b. Security scan: check bundle contents for hidden Unicode characters
|
|
143
|
+
from ..security.gate import BLOCK_POLICY, SecurityGate
|
|
144
|
+
|
|
145
|
+
# Scan all files under source_dir (SecurityGate handles symlink
|
|
146
|
+
# skipping, directory recursion, and OSError resilience)
|
|
147
|
+
verdict = SecurityGate.scan_files(
|
|
148
|
+
source_dir, policy=BLOCK_POLICY, force=force
|
|
149
|
+
)
|
|
150
|
+
security_warnings = verdict.warning_count
|
|
151
|
+
security_critical = verdict.critical_count
|
|
152
|
+
|
|
153
|
+
if verdict.should_block:
|
|
154
|
+
affected = []
|
|
155
|
+
for path, findings in verdict.findings_by_file.items():
|
|
156
|
+
c = sum(1 for f in findings if f.severity == "critical")
|
|
157
|
+
if c > 0:
|
|
158
|
+
affected.append(f" {path} ({c} critical)")
|
|
159
|
+
raise ValueError(
|
|
160
|
+
f"Blocked: bundle contains {len(affected)} file(s) "
|
|
161
|
+
f"with critical hidden characters\n\n"
|
|
162
|
+
f"Affected files:\n" + "\n".join(affected) + "\n\n"
|
|
163
|
+
"Next steps:\n"
|
|
164
|
+
" - Extract the bundle and run: apm audit --file <path> to inspect\n"
|
|
165
|
+
" - Run: apm unpack --force to deploy anyway "
|
|
166
|
+
"(not recommended)\n\n"
|
|
167
|
+
"Learn more: https://apm.github.io/apm/enterprise/security/"
|
|
168
|
+
)
|
|
169
|
+
|
|
134
170
|
# Dry-run: return file list without writing
|
|
135
171
|
if dry_run:
|
|
136
172
|
return UnpackResult(
|
|
@@ -138,6 +174,8 @@ def unpack_bundle(
|
|
|
138
174
|
files=unique_files,
|
|
139
175
|
verified=verified,
|
|
140
176
|
dependency_files=dep_file_map,
|
|
177
|
+
security_warnings=security_warnings,
|
|
178
|
+
security_critical=security_critical,
|
|
141
179
|
)
|
|
142
180
|
|
|
143
181
|
# 4. Copy target files to output_dir (additive, no deletes)
|
|
@@ -157,14 +195,21 @@ def unpack_bundle(
|
|
|
157
195
|
f"Refusing to unpack path that escapes output directory: {rel_path!r}"
|
|
158
196
|
)
|
|
159
197
|
src = source_dir / rel_path
|
|
198
|
+
if src.is_symlink():
|
|
199
|
+
# Security: skip symlinks to prevent scanning bypass
|
|
200
|
+
skipped += 1
|
|
201
|
+
continue
|
|
160
202
|
if not src.exists():
|
|
161
203
|
skipped += 1
|
|
162
204
|
continue # skip_verify may allow missing files
|
|
163
205
|
if src.is_dir():
|
|
164
|
-
|
|
206
|
+
from ..security.gate import ignore_symlinks
|
|
207
|
+
shutil.copytree(
|
|
208
|
+
src, dest, dirs_exist_ok=True, ignore=ignore_symlinks
|
|
209
|
+
)
|
|
165
210
|
else:
|
|
166
211
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
167
|
-
shutil.copy2(src, dest)
|
|
212
|
+
shutil.copy2(src, dest, follow_symlinks=False)
|
|
168
213
|
|
|
169
214
|
return UnpackResult(
|
|
170
215
|
extracted_dir=bundle_path,
|
|
@@ -172,6 +217,8 @@ def unpack_bundle(
|
|
|
172
217
|
verified=verified,
|
|
173
218
|
dependency_files=dep_file_map,
|
|
174
219
|
skipped_count=skipped,
|
|
220
|
+
security_warnings=security_warnings,
|
|
221
|
+
security_critical=security_critical,
|
|
175
222
|
)
|
|
176
223
|
finally:
|
|
177
224
|
# Clean up temp dir if we created one
|
|
@@ -47,28 +47,19 @@ def _scan_files_in_dir(
|
|
|
47
47
|
dir_path: Path,
|
|
48
48
|
base_label: str,
|
|
49
49
|
) -> Tuple[Dict[str, List[ScanFinding]], int]:
|
|
50
|
-
"""Recursively scan all files under a directory.
|
|
51
|
-
|
|
52
|
-
Uses ``os.walk(followlinks=False)`` to avoid following symlinked
|
|
53
|
-
directories outside the intended package tree.
|
|
50
|
+
"""Recursively scan all files under a directory via SecurityGate.
|
|
54
51
|
|
|
55
52
|
Returns (findings_by_file, files_scanned).
|
|
56
53
|
"""
|
|
57
|
-
import
|
|
54
|
+
from ..security.gate import REPORT_POLICY, SecurityGate
|
|
58
55
|
|
|
56
|
+
verdict = SecurityGate.scan_files(dir_path, policy=REPORT_POLICY)
|
|
57
|
+
# Re-key findings with the base_label prefix for display
|
|
59
58
|
findings: Dict[str, List[ScanFinding]] = {}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if f.is_symlink():
|
|
65
|
-
continue
|
|
66
|
-
count += 1
|
|
67
|
-
result = ContentScanner.scan_file(f)
|
|
68
|
-
if result:
|
|
69
|
-
label = f"{base_label}/{f.relative_to(dir_path).as_posix()}"
|
|
70
|
-
findings[label] = result
|
|
71
|
-
return findings, count
|
|
59
|
+
for rel_path, file_findings in verdict.findings_by_file.items():
|
|
60
|
+
label = f"{base_label}/{rel_path}"
|
|
61
|
+
findings[label] = file_findings
|
|
62
|
+
return findings, verdict.files_scanned
|
|
72
63
|
|
|
73
64
|
|
|
74
65
|
def _scan_lockfile_packages(
|
|
@@ -173,6 +164,7 @@ def _render_findings_table(
|
|
|
173
164
|
if console:
|
|
174
165
|
try:
|
|
175
166
|
from rich.table import Table
|
|
167
|
+
from ..security.audit_report import relative_path_for_report
|
|
176
168
|
|
|
177
169
|
table = Table(
|
|
178
170
|
title=f"{STATUS_SYMBOLS['search']} Content Scan Findings",
|
|
@@ -193,7 +185,7 @@ def _render_findings_table(
|
|
|
193
185
|
for f in rows:
|
|
194
186
|
table.add_row(
|
|
195
187
|
f.severity.upper(),
|
|
196
|
-
f.file,
|
|
188
|
+
relative_path_for_report(f.file),
|
|
197
189
|
f"{f.line}:{f.column}",
|
|
198
190
|
f.codepoint,
|
|
199
191
|
f.description,
|
|
@@ -411,8 +403,24 @@ def _preview_strip(
|
|
|
411
403
|
is_flag=True,
|
|
412
404
|
help="Preview what --strip would remove without modifying files",
|
|
413
405
|
)
|
|
406
|
+
@click.option(
|
|
407
|
+
"--format",
|
|
408
|
+
"-f",
|
|
409
|
+
"output_format",
|
|
410
|
+
type=click.Choice(["text", "json", "sarif", "markdown"], case_sensitive=False),
|
|
411
|
+
default="text",
|
|
412
|
+
help="Output format: text (default), json, sarif (GitHub Code Scanning), markdown (step summaries).",
|
|
413
|
+
)
|
|
414
|
+
@click.option(
|
|
415
|
+
"--output",
|
|
416
|
+
"-o",
|
|
417
|
+
"output_path",
|
|
418
|
+
type=click.Path(),
|
|
419
|
+
default=None,
|
|
420
|
+
help="Write output to file (auto-detects format from extension: .sarif, .json, .md).",
|
|
421
|
+
)
|
|
414
422
|
@click.pass_context
|
|
415
|
-
def audit(ctx, package, file_path, strip, verbose, dry_run):
|
|
423
|
+
def audit(ctx, package, file_path, strip, verbose, dry_run, output_format, output_path):
|
|
416
424
|
"""Scan deployed prompt files for hidden Unicode characters.
|
|
417
425
|
|
|
418
426
|
Detects invisible characters that could embed hidden instructions in
|
|
@@ -431,7 +439,25 @@ def audit(ctx, package, file_path, strip, verbose, dry_run):
|
|
|
431
439
|
apm audit my-package # Scan a specific package
|
|
432
440
|
apm audit --file .cursorrules # Scan any file
|
|
433
441
|
apm audit --strip # Remove dangerous/suspicious chars
|
|
442
|
+
apm audit -f sarif # SARIF output to stdout
|
|
443
|
+
apm audit -f markdown # Markdown to stdout
|
|
444
|
+
apm audit -o report.sarif # Write SARIF to file
|
|
445
|
+
apm audit -f json -o out.json # JSON report to file
|
|
434
446
|
"""
|
|
447
|
+
# Resolve effective format (auto-detect from extension when needed)
|
|
448
|
+
effective_format = output_format
|
|
449
|
+
if output_path and effective_format == "text":
|
|
450
|
+
from ..security.audit_report import detect_format_from_extension
|
|
451
|
+
|
|
452
|
+
effective_format = detect_format_from_extension(Path(output_path))
|
|
453
|
+
|
|
454
|
+
# --format json/sarif/markdown is incompatible with --strip / --dry-run
|
|
455
|
+
if effective_format != "text" and (strip or dry_run):
|
|
456
|
+
_rich_error(
|
|
457
|
+
f"--format {effective_format} cannot be combined with --strip or --dry-run"
|
|
458
|
+
)
|
|
459
|
+
sys.exit(1)
|
|
460
|
+
|
|
435
461
|
project_root = Path.cwd()
|
|
436
462
|
|
|
437
463
|
if file_path:
|
|
@@ -488,17 +514,57 @@ def audit(ctx, package, file_path, strip, verbose, dry_run):
|
|
|
488
514
|
sys.exit(0)
|
|
489
515
|
|
|
490
516
|
# -- Display findings --
|
|
491
|
-
|
|
492
|
-
|
|
517
|
+
# Determine exit code first (shared by all formats)
|
|
518
|
+
if not findings_by_file or not _has_actionable_findings(findings_by_file):
|
|
519
|
+
exit_code = 0
|
|
520
|
+
else:
|
|
521
|
+
all_findings = [f for ff in findings_by_file.values() for f in ff]
|
|
522
|
+
exit_code = 1 if ContentScanner.has_critical(all_findings) else 2
|
|
523
|
+
|
|
524
|
+
if effective_format == "text":
|
|
525
|
+
if output_path:
|
|
526
|
+
_rich_error(
|
|
527
|
+
"Text format does not support --output. "
|
|
528
|
+
"Use --format json, sarif, or markdown to write to a file."
|
|
529
|
+
)
|
|
530
|
+
sys.exit(1)
|
|
531
|
+
if findings_by_file:
|
|
532
|
+
_render_findings_table(findings_by_file, verbose=verbose)
|
|
533
|
+
_render_summary(findings_by_file, files_scanned)
|
|
534
|
+
elif effective_format == "markdown":
|
|
535
|
+
from ..security.audit_report import findings_to_markdown
|
|
536
|
+
|
|
537
|
+
md_report = findings_to_markdown(findings_by_file, files_scanned=files_scanned)
|
|
538
|
+
if output_path:
|
|
539
|
+
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
|
540
|
+
Path(output_path).write_text(md_report, encoding="utf-8")
|
|
541
|
+
_rich_success(f"Audit report written to {output_path}")
|
|
542
|
+
else:
|
|
543
|
+
click.echo(md_report)
|
|
544
|
+
else:
|
|
545
|
+
from ..security.audit_report import (
|
|
546
|
+
findings_to_json,
|
|
547
|
+
findings_to_sarif,
|
|
548
|
+
serialize_report,
|
|
549
|
+
write_report,
|
|
550
|
+
)
|
|
493
551
|
|
|
494
|
-
|
|
552
|
+
if effective_format == "sarif":
|
|
553
|
+
report = findings_to_sarif(
|
|
554
|
+
findings_by_file, files_scanned=files_scanned
|
|
555
|
+
)
|
|
556
|
+
else:
|
|
557
|
+
report = findings_to_json(
|
|
558
|
+
findings_by_file,
|
|
559
|
+
files_scanned=files_scanned,
|
|
560
|
+
exit_code=exit_code,
|
|
561
|
+
)
|
|
495
562
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
563
|
+
if output_path:
|
|
564
|
+
write_report(report, Path(output_path))
|
|
565
|
+
_rich_success(f"Audit report written to {output_path}")
|
|
566
|
+
else:
|
|
567
|
+
click.echo(serialize_report(report))
|
|
500
568
|
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
sys.exit(1)
|
|
504
|
-
sys.exit(2)
|
|
569
|
+
# -- Exit code --
|
|
570
|
+
sys.exit(exit_code)
|
|
@@ -7,7 +7,7 @@ import click
|
|
|
7
7
|
|
|
8
8
|
from ..compilation import AgentsCompiler, CompilationConfig
|
|
9
9
|
from ..primitives.discovery import discover_primitives
|
|
10
|
-
|
|
10
|
+
|
|
11
11
|
from ..utils.console import (
|
|
12
12
|
STATUS_SYMBOLS,
|
|
13
13
|
_rich_echo,
|
|
@@ -491,6 +491,7 @@ def compile(
|
|
|
491
491
|
# Perform compilation
|
|
492
492
|
compiler = AgentsCompiler(".")
|
|
493
493
|
result = compiler.compile(config)
|
|
494
|
+
compile_has_critical = result.has_critical_security
|
|
494
495
|
|
|
495
496
|
if result.success:
|
|
496
497
|
# Handle different compilation modes
|
|
@@ -553,12 +554,15 @@ def compile(
|
|
|
553
554
|
# Only rewrite when content materially changes (creation, update, missing constitution case)
|
|
554
555
|
if c_status in ("CREATED", "UPDATED", "MISSING"):
|
|
555
556
|
# Defense-in-depth: scan compiled output before writing
|
|
556
|
-
|
|
557
|
-
|
|
557
|
+
from ..security.gate import WARN_POLICY, SecurityGate
|
|
558
|
+
|
|
559
|
+
verdict = SecurityGate.scan_text(
|
|
560
|
+
final_content, str(output_path), policy=WARN_POLICY
|
|
558
561
|
)
|
|
559
|
-
if
|
|
560
|
-
|
|
561
|
-
|
|
562
|
+
if verdict.has_findings:
|
|
563
|
+
actionable = verdict.critical_count + verdict.warning_count
|
|
564
|
+
if verdict.has_critical:
|
|
565
|
+
compile_has_critical = True
|
|
562
566
|
if actionable:
|
|
563
567
|
_rich_warning(
|
|
564
568
|
f"Compiled output contains {actionable} hidden character(s) "
|
|
@@ -743,6 +747,15 @@ def compile(
|
|
|
743
747
|
except Exception:
|
|
744
748
|
pass # Continue if orphan check fails
|
|
745
749
|
|
|
750
|
+
# Hard-fail when critical security findings were detected in compiled
|
|
751
|
+
# output. Consistent with apm install and apm unpack behavior.
|
|
752
|
+
if compile_has_critical:
|
|
753
|
+
_rich_error(
|
|
754
|
+
"Compiled output contains critical hidden characters"
|
|
755
|
+
" — run 'apm audit' to inspect, 'apm audit --strip' to clean"
|
|
756
|
+
)
|
|
757
|
+
sys.exit(1)
|
|
758
|
+
|
|
746
759
|
except ImportError as e:
|
|
747
760
|
_rich_error(f"Compilation module not available: {e}")
|
|
748
761
|
_rich_info("This might be a development environment issue.")
|