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.
Files changed (145) hide show
  1. {apm_cli-0.8.0/src/apm_cli.egg-info → apm_cli-0.8.1}/PKG-INFO +3 -3
  2. {apm_cli-0.8.0 → apm_cli-0.8.1}/README.md +2 -2
  3. {apm_cli-0.8.0 → apm_cli-0.8.1}/pyproject.toml +1 -1
  4. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/adapters/client/base.py +33 -0
  5. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/adapters/client/codex.py +18 -3
  6. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/adapters/client/copilot.py +5 -0
  7. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/adapters/client/vscode.py +37 -1
  8. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/bundle/packer.py +21 -17
  9. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/bundle/unpacker.py +50 -3
  10. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/commands/audit.py +96 -30
  11. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/commands/compile.py +19 -6
  12. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/commands/install.py +51 -64
  13. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/commands/pack.py +13 -1
  14. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/compilation/agents_compiler.py +13 -7
  15. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/compilation/claude_formatter.py +11 -6
  16. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/core/token_manager.py +64 -2
  17. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/deps/github_downloader.py +123 -14
  18. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/drift.py +7 -1
  19. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/integration/base_integrator.py +4 -1
  20. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/models/dependency.py +7 -28
  21. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/registry/client.py +28 -27
  22. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/registry/operations.py +16 -2
  23. apm_cli-0.8.1/src/apm_cli/security/__init__.py +24 -0
  24. apm_cli-0.8.1/src/apm_cli/security/audit_report.py +251 -0
  25. apm_cli-0.8.1/src/apm_cli/security/gate.py +222 -0
  26. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/utils/diagnostics.py +22 -0
  27. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/utils/github_host.py +25 -0
  28. {apm_cli-0.8.0 → apm_cli-0.8.1/src/apm_cli.egg-info}/PKG-INFO +3 -3
  29. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli.egg-info/SOURCES.txt +3 -0
  30. {apm_cli-0.8.0 → apm_cli-0.8.1}/tests/test_apm_package_models.py +15 -35
  31. {apm_cli-0.8.0 → apm_cli-0.8.1}/tests/test_enhanced_discovery.py +2 -2
  32. {apm_cli-0.8.0 → apm_cli-0.8.1}/tests/test_github_downloader.py +444 -0
  33. {apm_cli-0.8.0 → apm_cli-0.8.1}/tests/test_github_downloader_token_precedence.py +5 -2
  34. apm_cli-0.8.1/tests/test_token_manager.py +196 -0
  35. apm_cli-0.8.0/src/apm_cli/security/__init__.py +0 -5
  36. {apm_cli-0.8.0 → apm_cli-0.8.1}/AUTHORS +0 -0
  37. {apm_cli-0.8.0 → apm_cli-0.8.1}/LICENSE +0 -0
  38. {apm_cli-0.8.0 → apm_cli-0.8.1}/setup.cfg +0 -0
  39. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/__init__.py +0 -0
  40. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/adapters/__init__.py +0 -0
  41. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/adapters/client/__init__.py +0 -0
  42. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/adapters/client/cursor.py +0 -0
  43. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/adapters/client/opencode.py +0 -0
  44. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/adapters/package_manager/__init__.py +0 -0
  45. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/adapters/package_manager/base.py +0 -0
  46. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/adapters/package_manager/default_manager.py +0 -0
  47. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/bundle/__init__.py +0 -0
  48. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/bundle/lockfile_enrichment.py +0 -0
  49. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/cli.py +0 -0
  50. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/commands/__init__.py +0 -0
  51. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/commands/_helpers.py +0 -0
  52. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/commands/config.py +0 -0
  53. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/commands/deps.py +0 -0
  54. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/commands/init.py +0 -0
  55. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/commands/list_cmd.py +0 -0
  56. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/commands/mcp.py +0 -0
  57. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/commands/prune.py +0 -0
  58. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/commands/run.py +0 -0
  59. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/commands/runtime.py +0 -0
  60. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/commands/uninstall.py +0 -0
  61. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/commands/update.py +0 -0
  62. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/compilation/__init__.py +0 -0
  63. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/compilation/constants.py +0 -0
  64. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/compilation/constitution.py +0 -0
  65. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/compilation/constitution_block.py +0 -0
  66. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/compilation/context_optimizer.py +0 -0
  67. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/compilation/distributed_compiler.py +0 -0
  68. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/compilation/injector.py +0 -0
  69. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/compilation/link_resolver.py +0 -0
  70. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/compilation/template_builder.py +0 -0
  71. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/config.py +0 -0
  72. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/core/__init__.py +0 -0
  73. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/core/conflict_detector.py +0 -0
  74. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/core/docker_args.py +0 -0
  75. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/core/operations.py +0 -0
  76. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/core/safe_installer.py +0 -0
  77. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/core/script_runner.py +0 -0
  78. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/core/target_detection.py +0 -0
  79. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/deps/__init__.py +0 -0
  80. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/deps/aggregator.py +0 -0
  81. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/deps/apm_resolver.py +0 -0
  82. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/deps/collection_parser.py +0 -0
  83. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/deps/dependency_graph.py +0 -0
  84. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/deps/lockfile.py +0 -0
  85. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/deps/package_validator.py +0 -0
  86. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/deps/plugin_parser.py +0 -0
  87. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/deps/verifier.py +0 -0
  88. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/factory.py +0 -0
  89. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/integration/__init__.py +0 -0
  90. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/integration/agent_integrator.py +0 -0
  91. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/integration/command_integrator.py +0 -0
  92. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/integration/hook_integrator.py +0 -0
  93. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/integration/instruction_integrator.py +0 -0
  94. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/integration/mcp_integrator.py +0 -0
  95. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/integration/prompt_integrator.py +0 -0
  96. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/integration/skill_integrator.py +0 -0
  97. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/integration/skill_transformer.py +0 -0
  98. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/integration/targets.py +0 -0
  99. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/integration/utils.py +0 -0
  100. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/models/__init__.py +0 -0
  101. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/models/apm_package.py +0 -0
  102. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/models/plugin.py +0 -0
  103. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/models/validation.py +0 -0
  104. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/output/__init__.py +0 -0
  105. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/output/formatters.py +0 -0
  106. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/output/models.py +0 -0
  107. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/output/script_formatters.py +0 -0
  108. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/primitives/__init__.py +0 -0
  109. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/primitives/discovery.py +0 -0
  110. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/primitives/models.py +0 -0
  111. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/primitives/parser.py +0 -0
  112. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/registry/__init__.py +0 -0
  113. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/registry/integration.py +0 -0
  114. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/runtime/__init__.py +0 -0
  115. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/runtime/base.py +0 -0
  116. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/runtime/codex_runtime.py +0 -0
  117. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/runtime/copilot_runtime.py +0 -0
  118. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/runtime/factory.py +0 -0
  119. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/runtime/llm_runtime.py +0 -0
  120. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/runtime/manager.py +0 -0
  121. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/security/content_scanner.py +0 -0
  122. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/utils/__init__.py +0 -0
  123. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/utils/console.py +0 -0
  124. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/utils/helpers.py +0 -0
  125. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/utils/version_checker.py +0 -0
  126. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/version.py +0 -0
  127. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/workflow/__init__.py +0 -0
  128. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/workflow/discovery.py +0 -0
  129. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/workflow/parser.py +0 -0
  130. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli/workflow/runner.py +0 -0
  131. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli.egg-info/dependency_links.txt +0 -0
  132. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli.egg-info/entry_points.txt +0 -0
  133. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli.egg-info/requires.txt +0 -0
  134. {apm_cli-0.8.0 → apm_cli-0.8.1}/src/apm_cli.egg-info/top_level.txt +0 -0
  135. {apm_cli-0.8.0 → apm_cli-0.8.1}/tests/test_apm_resolver.py +0 -0
  136. {apm_cli-0.8.0 → apm_cli-0.8.1}/tests/test_codex_docker_args_fix.py +0 -0
  137. {apm_cli-0.8.0 → apm_cli-0.8.1}/tests/test_codex_empty_string_and_defaults.py +0 -0
  138. {apm_cli-0.8.0 → apm_cli-0.8.1}/tests/test_collision_integration.py +0 -0
  139. {apm_cli-0.8.0 → apm_cli-0.8.1}/tests/test_console.py +0 -0
  140. {apm_cli-0.8.0 → apm_cli-0.8.1}/tests/test_distributed_compilation.py +0 -0
  141. {apm_cli-0.8.0 → apm_cli-0.8.1}/tests/test_empty_string_and_defaults.py +0 -0
  142. {apm_cli-0.8.0 → apm_cli-0.8.1}/tests/test_lockfile.py +0 -0
  143. {apm_cli-0.8.0 → apm_cli-0.8.1}/tests/test_runnable_prompts.py +0 -0
  144. {apm_cli-0.8.0 → apm_cli-0.8.1}/tests/test_runtime_manager_token_precedence.py +0 -0
  145. {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.0
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.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "apm-cli"
7
- version = "0.8.0"
7
+ version = "0.8.1"
8
8
  description = "MCP configuration tool"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -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
- extra_args = [a for a in all_args if a != package_name] if all_args else []
224
- config["args"] = ["-y", package_name] + extra_args
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
- from ..security.content_scanner import ContentScanner
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.is_file():
169
- findings = ContentScanner.scan_file(src)
170
- if findings:
171
- _scan_findings_total += len(findings)
172
- elif src.is_dir():
173
- for dirpath, _dirnames, filenames in os.walk(src, followlinks=False):
174
- for fname in filenames:
175
- fpath = Path(dirpath) / fname
176
- if fpath.is_symlink():
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
- shutil.copytree(src, dest, dirs_exist_ok=True)
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
- shutil.copytree(src, dest, dirs_exist_ok=True)
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 os
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
- count = 0
61
- for dirpath, _dirs, filenames in os.walk(dir_path, followlinks=False):
62
- for fname in filenames:
63
- f = Path(dirpath) / fname
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
- if findings_by_file:
492
- _render_findings_table(findings_by_file, verbose=verbose)
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
- _render_summary(findings_by_file, files_scanned)
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
- # -- Exit code --
497
- # Info-only findings are informational → exit 0
498
- if not findings_by_file or not _has_actionable_findings(findings_by_file):
499
- sys.exit(0)
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
- all_findings = [f for ff in findings_by_file.values() for f in ff]
502
- if ContentScanner.has_critical(all_findings):
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
- from ..security.content_scanner import ContentScanner
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
- findings = ContentScanner.scan_text(
557
- final_content, filename=str(output_path)
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 findings:
560
- _, summary = ContentScanner.classify(findings)
561
- actionable = summary.get("critical", 0) + summary.get("warning", 0)
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.")