apm-cli 0.8.1__tar.gz → 0.8.2__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.1/src/apm_cli.egg-info → apm_cli-0.8.2}/PKG-INFO +1 -1
  2. {apm_cli-0.8.1 → apm_cli-0.8.2}/pyproject.toml +1 -1
  3. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/commands/install.py +10 -3
  4. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/commands/prune.py +2 -1
  5. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/commands/uninstall.py +6 -2
  6. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/core/token_manager.py +23 -2
  7. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/deps/github_downloader.py +336 -14
  8. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/models/dependency.py +127 -23
  9. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/utils/github_host.py +63 -1
  10. apm_cli-0.8.2/src/apm_cli/utils/path_security.py +58 -0
  11. {apm_cli-0.8.1 → apm_cli-0.8.2/src/apm_cli.egg-info}/PKG-INFO +1 -1
  12. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli.egg-info/SOURCES.txt +1 -0
  13. {apm_cli-0.8.1 → apm_cli-0.8.2}/tests/test_token_manager.py +112 -0
  14. {apm_cli-0.8.1 → apm_cli-0.8.2}/AUTHORS +0 -0
  15. {apm_cli-0.8.1 → apm_cli-0.8.2}/LICENSE +0 -0
  16. {apm_cli-0.8.1 → apm_cli-0.8.2}/README.md +0 -0
  17. {apm_cli-0.8.1 → apm_cli-0.8.2}/setup.cfg +0 -0
  18. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/__init__.py +0 -0
  19. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/adapters/__init__.py +0 -0
  20. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/adapters/client/__init__.py +0 -0
  21. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/adapters/client/base.py +0 -0
  22. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/adapters/client/codex.py +0 -0
  23. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/adapters/client/copilot.py +0 -0
  24. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/adapters/client/cursor.py +0 -0
  25. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/adapters/client/opencode.py +0 -0
  26. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/adapters/client/vscode.py +0 -0
  27. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/adapters/package_manager/__init__.py +0 -0
  28. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/adapters/package_manager/base.py +0 -0
  29. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/adapters/package_manager/default_manager.py +0 -0
  30. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/bundle/__init__.py +0 -0
  31. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/bundle/lockfile_enrichment.py +0 -0
  32. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/bundle/packer.py +0 -0
  33. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/bundle/unpacker.py +0 -0
  34. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/cli.py +0 -0
  35. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/commands/__init__.py +0 -0
  36. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/commands/_helpers.py +0 -0
  37. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/commands/audit.py +0 -0
  38. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/commands/compile.py +0 -0
  39. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/commands/config.py +0 -0
  40. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/commands/deps.py +0 -0
  41. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/commands/init.py +0 -0
  42. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/commands/list_cmd.py +0 -0
  43. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/commands/mcp.py +0 -0
  44. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/commands/pack.py +0 -0
  45. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/commands/run.py +0 -0
  46. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/commands/runtime.py +0 -0
  47. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/commands/update.py +0 -0
  48. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/compilation/__init__.py +0 -0
  49. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/compilation/agents_compiler.py +0 -0
  50. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/compilation/claude_formatter.py +0 -0
  51. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/compilation/constants.py +0 -0
  52. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/compilation/constitution.py +0 -0
  53. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/compilation/constitution_block.py +0 -0
  54. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/compilation/context_optimizer.py +0 -0
  55. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/compilation/distributed_compiler.py +0 -0
  56. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/compilation/injector.py +0 -0
  57. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/compilation/link_resolver.py +0 -0
  58. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/compilation/template_builder.py +0 -0
  59. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/config.py +0 -0
  60. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/core/__init__.py +0 -0
  61. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/core/conflict_detector.py +0 -0
  62. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/core/docker_args.py +0 -0
  63. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/core/operations.py +0 -0
  64. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/core/safe_installer.py +0 -0
  65. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/core/script_runner.py +0 -0
  66. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/core/target_detection.py +0 -0
  67. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/deps/__init__.py +0 -0
  68. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/deps/aggregator.py +0 -0
  69. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/deps/apm_resolver.py +0 -0
  70. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/deps/collection_parser.py +0 -0
  71. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/deps/dependency_graph.py +0 -0
  72. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/deps/lockfile.py +0 -0
  73. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/deps/package_validator.py +0 -0
  74. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/deps/plugin_parser.py +0 -0
  75. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/deps/verifier.py +0 -0
  76. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/drift.py +0 -0
  77. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/factory.py +0 -0
  78. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/integration/__init__.py +0 -0
  79. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/integration/agent_integrator.py +0 -0
  80. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/integration/base_integrator.py +0 -0
  81. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/integration/command_integrator.py +0 -0
  82. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/integration/hook_integrator.py +0 -0
  83. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/integration/instruction_integrator.py +0 -0
  84. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/integration/mcp_integrator.py +0 -0
  85. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/integration/prompt_integrator.py +0 -0
  86. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/integration/skill_integrator.py +0 -0
  87. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/integration/skill_transformer.py +0 -0
  88. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/integration/targets.py +0 -0
  89. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/integration/utils.py +0 -0
  90. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/models/__init__.py +0 -0
  91. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/models/apm_package.py +0 -0
  92. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/models/plugin.py +0 -0
  93. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/models/validation.py +0 -0
  94. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/output/__init__.py +0 -0
  95. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/output/formatters.py +0 -0
  96. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/output/models.py +0 -0
  97. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/output/script_formatters.py +0 -0
  98. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/primitives/__init__.py +0 -0
  99. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/primitives/discovery.py +0 -0
  100. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/primitives/models.py +0 -0
  101. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/primitives/parser.py +0 -0
  102. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/registry/__init__.py +0 -0
  103. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/registry/client.py +0 -0
  104. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/registry/integration.py +0 -0
  105. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/registry/operations.py +0 -0
  106. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/runtime/__init__.py +0 -0
  107. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/runtime/base.py +0 -0
  108. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/runtime/codex_runtime.py +0 -0
  109. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/runtime/copilot_runtime.py +0 -0
  110. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/runtime/factory.py +0 -0
  111. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/runtime/llm_runtime.py +0 -0
  112. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/runtime/manager.py +0 -0
  113. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/security/__init__.py +0 -0
  114. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/security/audit_report.py +0 -0
  115. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/security/content_scanner.py +0 -0
  116. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/security/gate.py +0 -0
  117. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/utils/__init__.py +0 -0
  118. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/utils/console.py +0 -0
  119. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/utils/diagnostics.py +0 -0
  120. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/utils/helpers.py +0 -0
  121. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/utils/version_checker.py +0 -0
  122. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/version.py +0 -0
  123. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/workflow/__init__.py +0 -0
  124. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/workflow/discovery.py +0 -0
  125. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/workflow/parser.py +0 -0
  126. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli/workflow/runner.py +0 -0
  127. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli.egg-info/dependency_links.txt +0 -0
  128. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli.egg-info/entry_points.txt +0 -0
  129. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli.egg-info/requires.txt +0 -0
  130. {apm_cli-0.8.1 → apm_cli-0.8.2}/src/apm_cli.egg-info/top_level.txt +0 -0
  131. {apm_cli-0.8.1 → apm_cli-0.8.2}/tests/test_apm_package_models.py +0 -0
  132. {apm_cli-0.8.1 → apm_cli-0.8.2}/tests/test_apm_resolver.py +0 -0
  133. {apm_cli-0.8.1 → apm_cli-0.8.2}/tests/test_codex_docker_args_fix.py +0 -0
  134. {apm_cli-0.8.1 → apm_cli-0.8.2}/tests/test_codex_empty_string_and_defaults.py +0 -0
  135. {apm_cli-0.8.1 → apm_cli-0.8.2}/tests/test_collision_integration.py +0 -0
  136. {apm_cli-0.8.1 → apm_cli-0.8.2}/tests/test_console.py +0 -0
  137. {apm_cli-0.8.1 → apm_cli-0.8.2}/tests/test_distributed_compilation.py +0 -0
  138. {apm_cli-0.8.1 → apm_cli-0.8.2}/tests/test_empty_string_and_defaults.py +0 -0
  139. {apm_cli-0.8.1 → apm_cli-0.8.2}/tests/test_enhanced_discovery.py +0 -0
  140. {apm_cli-0.8.1 → apm_cli-0.8.2}/tests/test_github_downloader.py +0 -0
  141. {apm_cli-0.8.1 → apm_cli-0.8.2}/tests/test_github_downloader_token_precedence.py +0 -0
  142. {apm_cli-0.8.1 → apm_cli-0.8.2}/tests/test_lockfile.py +0 -0
  143. {apm_cli-0.8.1 → apm_cli-0.8.2}/tests/test_runnable_prompts.py +0 -0
  144. {apm_cli-0.8.1 → apm_cli-0.8.2}/tests/test_runtime_manager_token_precedence.py +0 -0
  145. {apm_cli-0.8.1 → apm_cli-0.8.2}/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.1
3
+ Version: 0.8.2
4
4
  Summary: MCP configuration tool
5
5
  Author-email: Daniel Meppiel <user@example.com>
6
6
  License: MIT License
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "apm-cli"
7
- version = "0.8.1"
7
+ version = "0.8.2"
8
8
  description = "MCP configuration tool"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -11,6 +11,7 @@ from ..drift import build_download_ref, detect_orphans, detect_ref_change
11
11
  from ..utils.console import _rich_error, _rich_info, _rich_success, _rich_warning
12
12
  from ..utils.diagnostics import DiagnosticCollector
13
13
  from ..utils.github_host import default_host, is_valid_fqdn
14
+ from ..utils.path_security import safe_rmtree
14
15
  from ._helpers import (
15
16
  _create_minimal_apm_yml,
16
17
  _get_default_config,
@@ -802,7 +803,10 @@ def _copy_local_package(dep_ref, install_path, project_root):
802
803
  # Ensure parent exists and clean target (always re-copy for local deps)
803
804
  install_path.parent.mkdir(parents=True, exist_ok=True)
804
805
  if install_path.exists():
805
- shutil.rmtree(install_path)
806
+ # install_path is already validated by get_install_path() (Layer 2),
807
+ # but use safe_rmtree for defense-in-depth.
808
+ apm_modules_dir = install_path.parent.parent # _local/<name> → apm_modules
809
+ safe_rmtree(install_path, apm_modules_dir)
806
810
 
807
811
  shutil.copytree(local, install_path, dirs_exist_ok=False, symlinks=True)
808
812
  return install_path
@@ -875,10 +879,13 @@ def _install_apm_dependencies(
875
879
  return result_path
876
880
  return None
877
881
 
878
- # Build repo_ref string - include host for GHE/ADO, plus reference if specified
882
+ # Build repo_ref string - include host for GHE/ADO/Artifactory, plus reference if specified
879
883
  repo_ref = dep_ref.repo_url
880
884
  if dep_ref.host and dep_ref.host not in ("github.com", None):
881
- repo_ref = f"{dep_ref.host}/{dep_ref.repo_url}"
885
+ if dep_ref.artifactory_prefix:
886
+ repo_ref = f"{dep_ref.host}/{dep_ref.artifactory_prefix}/{dep_ref.repo_url}"
887
+ else:
888
+ repo_ref = f"{dep_ref.host}/{dep_ref.repo_url}"
882
889
  if dep_ref.virtual_path:
883
890
  repo_ref = f"{repo_ref}/{dep_ref.virtual_path}"
884
891
 
@@ -7,6 +7,7 @@ from pathlib import Path
7
7
  import click
8
8
 
9
9
  from ..utils.console import _rich_error, _rich_info, _rich_success, _rich_warning
10
+ from ..utils.path_security import PathTraversalError, safe_rmtree
10
11
  from ._helpers import _build_expected_install_paths, _scan_installed_packages
11
12
 
12
13
  # APM Dependencies
@@ -80,7 +81,7 @@ def prune(ctx, dry_run):
80
81
  path_parts = org_repo_name.split("/")
81
82
  pkg_path = apm_modules_dir.joinpath(*path_parts)
82
83
  try:
83
- shutil.rmtree(pkg_path)
84
+ safe_rmtree(pkg_path, apm_modules_dir)
84
85
  _rich_info(f"+ Removed {org_repo_name}")
85
86
  removed_count += 1
86
87
  pruned_keys.append(org_repo_name)
@@ -8,6 +8,7 @@ from pathlib import Path
8
8
  import click
9
9
 
10
10
  from ..utils.console import _rich_error, _rich_info, _rich_success, _rich_warning
11
+ from ..utils.path_security import PathTraversalError, safe_rmtree
11
12
 
12
13
  # APM Dependencies
13
14
  try:
@@ -210,6 +211,9 @@ def uninstall(ctx, packages, dry_run):
210
211
  try:
211
212
  dep_ref = _parse_dependency_entry(package)
212
213
  package_path = dep_ref.get_install_path(apm_modules_dir)
214
+ except (PathTraversalError,) as e:
215
+ _rich_error(f"x Refusing to remove {package}: {e}")
216
+ continue
213
217
  except (ValueError, TypeError, AttributeError, KeyError):
214
218
  # Fallback for invalid format: use raw path segments
215
219
  package_str = package if isinstance(package, str) else str(package)
@@ -221,7 +225,7 @@ def uninstall(ctx, packages, dry_run):
221
225
 
222
226
  if package_path.exists():
223
227
  try:
224
- shutil.rmtree(package_path)
228
+ safe_rmtree(package_path, apm_modules_dir)
225
229
  _rich_info(f"+ Removed {package} from apm_modules/")
226
230
  removed_from_modules += 1
227
231
  deleted_pkg_paths.append(package_path)
@@ -306,7 +310,7 @@ def uninstall(ctx, packages, dry_run):
306
310
 
307
311
  if orphan_path.exists():
308
312
  try:
309
- shutil.rmtree(orphan_path)
313
+ safe_rmtree(orphan_path, apm_modules_dir)
310
314
  _rich_info(f"+ Removed transitive dependency {orphan_key} from apm_modules/")
311
315
  removed_from_modules += 1
312
316
  deleted_orphan_paths.append(orphan_path)
@@ -32,6 +32,7 @@ class GitHubTokenManager:
32
32
  'models': ['GITHUB_TOKEN', 'GITHUB_APM_PAT'], # GitHub Models prefers user-scoped PAT, falls back to APM PAT
33
33
  'modules': ['GITHUB_APM_PAT', 'GITHUB_TOKEN', 'GH_TOKEN'], # APM module access (GitHub)
34
34
  'ado_modules': ['ADO_APM_PAT'], # APM module access (Azure DevOps)
35
+ 'artifactory_modules': ['ARTIFACTORY_APM_TOKEN'], # APM module access (JFrog Artifactory)
35
36
  }
36
37
 
37
38
  # Runtime-specific environment variable mappings
@@ -50,6 +51,24 @@ class GitHubTokenManager:
50
51
  self.preserve_existing = preserve_existing
51
52
  self._credential_cache: Dict[str, Optional[str]] = {}
52
53
 
54
+ @staticmethod
55
+ def _is_valid_credential_token(token: str) -> bool:
56
+ """Validate that a credential-fill token looks like a real credential.
57
+
58
+ Rejects garbage values that can appear when GIT_ASKPASS or credential
59
+ helpers return prompt text instead of actual tokens.
60
+ """
61
+ if not token:
62
+ return False
63
+ if len(token) > 1024:
64
+ return False
65
+ if any(c in token for c in (' ', '\t', '\n', '\r')):
66
+ return False
67
+ prompt_fragments = ('Password for', 'Username for', 'password for', 'username for')
68
+ if any(fragment in token for fragment in prompt_fragments):
69
+ return False
70
+ return True
71
+
53
72
  @staticmethod
54
73
  def resolve_credential_from_git(host: str) -> Optional[str]:
55
74
  """Resolve a credential from the git credential store.
@@ -71,7 +90,7 @@ class GitHubTokenManager:
71
90
  capture_output=True,
72
91
  text=True,
73
92
  timeout=5,
74
- env={**os.environ, 'GIT_TERMINAL_PROMPT': '0', 'GIT_ASKPASS': 'echo'},
93
+ env={**os.environ, 'GIT_TERMINAL_PROMPT': '0', 'GIT_ASKPASS': ''},
75
94
  )
76
95
  if result.returncode != 0:
77
96
  return None
@@ -79,7 +98,9 @@ class GitHubTokenManager:
79
98
  for line in result.stdout.splitlines():
80
99
  if line.startswith('password='):
81
100
  token = line[len('password='):]
82
- return token if token else None
101
+ if token and GitHubTokenManager._is_valid_credential_token(token):
102
+ return token
103
+ return None
83
104
  return None
84
105
  except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
85
106
  return None
@@ -28,13 +28,14 @@ from ..models.apm_package import (
28
28
  APMPackage
29
29
  )
30
30
  from ..utils.github_host import (
31
- build_https_clone_url,
32
- build_ssh_url,
31
+ build_https_clone_url,
32
+ build_ssh_url,
33
33
  build_ado_https_clone_url,
34
34
  build_ado_ssh_url,
35
35
  build_ado_api_url,
36
36
  build_raw_content_url,
37
- sanitize_token_url_in_message,
37
+ build_artifactory_archive_url,
38
+ sanitize_token_url_in_message,
38
39
  default_host,
39
40
  is_azure_devops_hostname,
40
41
  is_github_hostname
@@ -199,8 +200,12 @@ class GitHubPackageDownloader:
199
200
  # Azure DevOps: ADO_APM_PAT
200
201
  self.ado_token = self.token_manager.get_token_for_purpose('ado_modules', env)
201
202
  self.has_ado_token = self.ado_token is not None
202
-
203
- _debug(f"Token setup: has_github_token={self.has_github_token}, has_ado_token={self.has_ado_token}"
203
+
204
+ # JFrog Artifactory: ARTIFACTORY_APM_TOKEN
205
+ self.artifactory_token = self.token_manager.get_token_for_purpose('artifactory_modules', env)
206
+ self.has_artifactory_token = self.artifactory_token is not None
207
+
208
+ _debug(f"Token setup: has_github_token={self.has_github_token}, has_ado_token={self.has_ado_token}, has_artifactory_token={self.has_artifactory_token}"
204
209
  f"{', source=credential_helper' if self._github_token_from_credential_fill else ''}")
205
210
 
206
211
  # Configure Git security settings
@@ -218,7 +223,157 @@ class GitHubPackageDownloader:
218
223
  env['GIT_CONFIG_GLOBAL'] = '/dev/null'
219
224
 
220
225
  return env
221
-
226
+
227
+ # --- Artifactory VCS archive download support ---
228
+
229
+ def _get_artifactory_headers(self) -> Dict[str, str]:
230
+ """Build HTTP headers for Artifactory requests."""
231
+ headers = {}
232
+ if self.artifactory_token:
233
+ headers['Authorization'] = f'Bearer {self.artifactory_token}'
234
+ return headers
235
+
236
+ def _download_artifactory_archive(self, host: str, prefix: str, owner: str, repo: str,
237
+ ref: str, target_path: Path, scheme: str = "https") -> None:
238
+ """Download and extract a zip archive from Artifactory VCS proxy.
239
+
240
+ Tries multiple URL patterns (GitHub-style and GitLab-style).
241
+ GitHub archives contain a single root directory named {repo}-{ref}/;
242
+ this method strips that prefix on extraction so files land directly
243
+ in *target_path*.
244
+
245
+ Raises RuntimeError on failure.
246
+ """
247
+ import io
248
+ import zipfile
249
+
250
+ archive_urls = build_artifactory_archive_url(host, prefix, owner, repo, ref, scheme=scheme)
251
+ headers = self._get_artifactory_headers()
252
+
253
+ # Guard: reject unreasonably large archives (default 500 MB)
254
+ max_archive_bytes = int(
255
+ os.environ.get('ARTIFACTORY_MAX_ARCHIVE_MB', '500')
256
+ ) * 1024 * 1024
257
+
258
+ last_error = None
259
+ for url in archive_urls:
260
+ _debug(f"Trying Artifactory archive: {url}")
261
+ try:
262
+ resp = self._resilient_get(url, headers=headers, timeout=60)
263
+ if resp.status_code == 200:
264
+ if len(resp.content) > max_archive_bytes:
265
+ last_error = f"Archive too large ({len(resp.content)} bytes) from {url}"
266
+ _debug(last_error)
267
+ continue
268
+ # Extract zip, stripping the top-level directory
269
+ target_path.mkdir(parents=True, exist_ok=True)
270
+ with zipfile.ZipFile(io.BytesIO(resp.content)) as zf:
271
+ # Identify the root prefix (e.g., "repo-main/")
272
+ names = zf.namelist()
273
+ if not names:
274
+ raise RuntimeError(f"Empty archive from {url}")
275
+ root_prefix = names[0]
276
+ if not root_prefix.endswith('/'):
277
+ # Single file archive; extract as-is
278
+ zf.extractall(target_path)
279
+ return
280
+ for member in zf.infolist():
281
+ # Strip root prefix
282
+ if member.filename == root_prefix:
283
+ continue
284
+ rel = member.filename[len(root_prefix):]
285
+ if not rel:
286
+ continue
287
+ # Guard: prevent zip path traversal (CWE-22)
288
+ dest = target_path / rel
289
+ if not dest.resolve().is_relative_to(target_path.resolve()):
290
+ _debug(f"Skipping zip entry escaping target: {member.filename}")
291
+ continue
292
+ if member.is_dir():
293
+ dest.mkdir(parents=True, exist_ok=True)
294
+ else:
295
+ dest.parent.mkdir(parents=True, exist_ok=True)
296
+ with zf.open(member) as src, open(dest, 'wb') as dst:
297
+ dst.write(src.read())
298
+ _debug(f"Extracted Artifactory archive to {target_path}")
299
+ return
300
+ else:
301
+ last_error = f"HTTP {resp.status_code} from {url}"
302
+ _debug(last_error)
303
+ except zipfile.BadZipFile:
304
+ last_error = f"Invalid zip archive from {url}"
305
+ _debug(last_error)
306
+ except requests.RequestException as e:
307
+ last_error = str(e)
308
+ _debug(f"Request failed: {last_error}")
309
+
310
+ raise RuntimeError(
311
+ f"Failed to download package {owner}/{repo}#{ref} from Artifactory "
312
+ f"({host}/{prefix}). Last error: {last_error}"
313
+ )
314
+
315
+ def _download_file_from_artifactory(self, host: str, prefix: str, owner: str,
316
+ repo: str, file_path: str, ref: str, scheme: str = "https") -> bytes:
317
+ """Download a single file from Artifactory by fetching the full archive and extracting it."""
318
+ import io
319
+ import zipfile
320
+
321
+ archive_urls = build_artifactory_archive_url(host, prefix, owner, repo, ref, scheme=scheme)
322
+ headers = self._get_artifactory_headers()
323
+
324
+ for url in archive_urls:
325
+ try:
326
+ resp = self._resilient_get(url, headers=headers, timeout=60)
327
+ if resp.status_code != 200:
328
+ continue
329
+ with zipfile.ZipFile(io.BytesIO(resp.content)) as zf:
330
+ names = zf.namelist()
331
+ root_prefix = names[0] if names else ""
332
+ target_name = root_prefix + file_path
333
+ if target_name in names:
334
+ return zf.read(target_name)
335
+ if file_path in names:
336
+ return zf.read(file_path)
337
+ except (zipfile.BadZipFile, requests.RequestException):
338
+ continue
339
+
340
+ raise RuntimeError(
341
+ f"Failed to download file '{file_path}' from Artifactory "
342
+ f"({host}/{prefix}/{owner}/{repo}#{ref})"
343
+ )
344
+
345
+ @staticmethod
346
+ def _is_artifactory_only() -> bool:
347
+ """Return True when ARTIFACTORY_ONLY is set, blocking all direct git operations."""
348
+ return os.environ.get('ARTIFACTORY_ONLY', '').strip().lower() in ('1', 'true', 'yes')
349
+
350
+ def _should_use_artifactory_proxy(self, dep_ref: 'DependencyReference') -> bool:
351
+ """Check if a dependency should be routed through the Artifactory transparent proxy."""
352
+ if dep_ref.is_artifactory():
353
+ return False # already explicit Artifactory
354
+ if self._is_artifactory_only():
355
+ return True
356
+ if dep_ref.is_azure_devops():
357
+ return False
358
+ host = dep_ref.host or default_host()
359
+ return is_github_hostname(host)
360
+
361
+ def _parse_artifactory_base_url(self) -> Optional[tuple]:
362
+ """Parse ARTIFACTORY_BASE_URL into (host, prefix, scheme)."""
363
+ import urllib.parse as urlparse
364
+ base_url = os.environ.get('ARTIFACTORY_BASE_URL', '').strip().rstrip('/')
365
+ if not base_url:
366
+ return None
367
+ parsed = urlparse.urlparse(base_url)
368
+ if parsed.scheme not in ('https', 'http'):
369
+ _debug(f"ARTIFACTORY_BASE_URL has unsupported scheme: {parsed.scheme}")
370
+ return None
371
+ host = parsed.hostname
372
+ path = parsed.path.strip('/')
373
+ if not host or not path:
374
+ return None
375
+ return (host, path, parsed.scheme)
376
+
222
377
  def _resilient_get(self, url: str, headers: Dict[str, str], timeout: int = 30, max_retries: int = 3) -> requests.Response:
223
378
  """HTTP GET with retry on 429/503 and rate-limit header awareness (#171).
224
379
 
@@ -514,7 +669,20 @@ class GitHubPackageDownloader:
514
669
 
515
670
  # Default to main branch if no reference specified
516
671
  ref = dep_ref.reference or "main"
517
-
672
+
673
+ # Artifactory: no git repo to query, return ref-based resolution
674
+ if dep_ref.is_artifactory() or (
675
+ self._parse_artifactory_base_url()
676
+ and self._should_use_artifactory_proxy(dep_ref)
677
+ ):
678
+ is_commit = re.match(r'^[a-f0-9]{7,40}$', ref.lower()) is not None
679
+ return ResolvedReference(
680
+ original_ref=repo_ref,
681
+ ref_type=GitReferenceType.COMMIT if is_commit else GitReferenceType.BRANCH,
682
+ resolved_commit=None,
683
+ ref_name=ref
684
+ )
685
+
518
686
  # Pre-analyze the reference type to determine the best approach
519
687
  is_likely_commit = re.match(r'^[a-f0-9]{7,40}$', ref.lower()) is not None
520
688
 
@@ -618,11 +786,30 @@ class GitHubPackageDownloader:
618
786
  RuntimeError: If download fails or file not found
619
787
  """
620
788
  host = dep_ref.host or default_host()
621
-
789
+
790
+ # Check if this is Artifactory (Mode 1: explicit FQDN)
791
+ if dep_ref.is_artifactory():
792
+ repo_parts = dep_ref.repo_url.split('/')
793
+ return self._download_file_from_artifactory(
794
+ dep_ref.host, dep_ref.artifactory_prefix,
795
+ repo_parts[0], repo_parts[1] if len(repo_parts) > 1 else repo_parts[0],
796
+ file_path, ref,
797
+ )
798
+
799
+ # Check if this should go through Artifactory proxy (Mode 2)
800
+ art_proxy = self._parse_artifactory_base_url()
801
+ if art_proxy and self._should_use_artifactory_proxy(dep_ref):
802
+ repo_parts = dep_ref.repo_url.split('/')
803
+ return self._download_file_from_artifactory(
804
+ art_proxy[0], art_proxy[1],
805
+ repo_parts[0], repo_parts[1] if len(repo_parts) > 1 else repo_parts[0],
806
+ file_path, ref, scheme=art_proxy[2],
807
+ )
808
+
622
809
  # Check if this is Azure DevOps
623
810
  if dep_ref.is_azure_devops():
624
811
  return self._download_ado_file(dep_ref, file_path, ref)
625
-
812
+
626
813
  # GitHub API
627
814
  return self._download_github_file(dep_ref, file_path, ref)
628
815
 
@@ -1379,6 +1566,9 @@ author: {dep_ref.repo_url.split('/')[0]}
1379
1566
 
1380
1567
  # Check if subdirectory exists
1381
1568
  source_subdir = temp_clone_path / subdir_path
1569
+ # Security: ensure subdirectory resolves within the cloned repo
1570
+ from ..utils.path_security import ensure_path_within
1571
+ ensure_path_within(source_subdir, temp_clone_path)
1382
1572
  if not source_subdir.exists():
1383
1573
  raise RuntimeError(f"Subdirectory '{subdir_path}' not found in repository")
1384
1574
 
@@ -1465,6 +1655,116 @@ author: {dep_ref.repo_url.split('/')[0]}
1465
1655
  package_type=validation_result.package_type
1466
1656
  )
1467
1657
 
1658
+ def _download_subdirectory_from_artifactory(
1659
+ self, dep_ref: 'DependencyReference', target_path: Path,
1660
+ proxy_info: tuple, progress_task_id=None, progress_obj=None,
1661
+ ) -> PackageInfo:
1662
+ """Download an archive from Artifactory and extract a subdirectory."""
1663
+ import tempfile
1664
+ ref = dep_ref.reference or "main"
1665
+ subdir_path = dep_ref.virtual_path
1666
+ repo_parts = dep_ref.repo_url.split('/')
1667
+ owner, repo = repo_parts[0], repo_parts[1] if len(repo_parts) > 1 else repo_parts[0]
1668
+ host, prefix, scheme = proxy_info
1669
+
1670
+ if progress_obj and progress_task_id is not None:
1671
+ progress_obj.update(progress_task_id, completed=10, total=100)
1672
+
1673
+ with tempfile.TemporaryDirectory() as temp_dir:
1674
+ temp_path = Path(temp_dir) / "full_pkg"
1675
+ self._download_artifactory_archive(host, prefix, owner, repo, ref, temp_path, scheme=scheme)
1676
+ if progress_obj and progress_task_id is not None:
1677
+ progress_obj.update(progress_task_id, completed=60, total=100)
1678
+ source_subdir = temp_path / subdir_path
1679
+ if not source_subdir.exists() or not source_subdir.is_dir():
1680
+ raise RuntimeError(
1681
+ f"Subdirectory '{subdir_path}' not found in archive from "
1682
+ f"Artifactory ({host}/{prefix}/{owner}/{repo}#{ref})"
1683
+ )
1684
+ target_path.mkdir(parents=True, exist_ok=True)
1685
+ if target_path.exists() and any(target_path.iterdir()):
1686
+ shutil.rmtree(target_path)
1687
+ target_path.mkdir(parents=True, exist_ok=True)
1688
+ for item in source_subdir.iterdir():
1689
+ src = source_subdir / item.name
1690
+ dst = target_path / item.name
1691
+ if src.is_dir():
1692
+ shutil.copytree(src, dst)
1693
+ else:
1694
+ shutil.copy2(src, dst)
1695
+
1696
+ if progress_obj and progress_task_id is not None:
1697
+ progress_obj.update(progress_task_id, completed=80, total=100)
1698
+ validation_result = validate_apm_package(target_path)
1699
+ if not validation_result.is_valid:
1700
+ raise RuntimeError(f"Subdirectory is not a valid APM package: {'; '.join(validation_result.errors)}")
1701
+ resolved_ref = ResolvedReference(original_ref=ref, ref_name=ref, ref_type=GitReferenceType.BRANCH, resolved_commit=None)
1702
+ if progress_obj and progress_task_id is not None:
1703
+ progress_obj.update(progress_task_id, completed=100, total=100)
1704
+ return PackageInfo(
1705
+ package=validation_result.package, install_path=target_path,
1706
+ resolved_reference=resolved_ref, installed_at=datetime.now().isoformat(),
1707
+ dependency_ref=dep_ref, package_type=validation_result.package_type
1708
+ )
1709
+
1710
+ def _download_package_from_artifactory(
1711
+ self, dep_ref: 'DependencyReference', target_path: Path,
1712
+ proxy_info: Optional[tuple] = None, progress_task_id=None, progress_obj=None,
1713
+ ) -> PackageInfo:
1714
+ """Download a package via Artifactory VCS archive."""
1715
+ ref = dep_ref.reference or "main"
1716
+ repo_parts = dep_ref.repo_url.split('/')
1717
+ if len(repo_parts) < 2 or not repo_parts[0] or not repo_parts[1]:
1718
+ raise ValueError(f"Invalid Artifactory repo reference '{dep_ref.repo_url}': expected 'owner/repo' format")
1719
+ owner, repo = repo_parts[0], repo_parts[1]
1720
+
1721
+ scheme = "https"
1722
+ if dep_ref.is_artifactory():
1723
+ host, prefix = dep_ref.host, dep_ref.artifactory_prefix
1724
+ if not host or not prefix:
1725
+ raise ValueError(f"Artifactory dependency '{dep_ref.repo_url}' is missing host or artifactory prefix")
1726
+ elif proxy_info:
1727
+ host, prefix, scheme = proxy_info
1728
+ else:
1729
+ raise RuntimeError("Artifactory download requires either FQDN or ARTIFACTORY_BASE_URL")
1730
+
1731
+ _debug(f"Downloading from Artifactory: {host}/{prefix}/{owner}/{repo}#{ref}")
1732
+ if target_path.exists() and any(target_path.iterdir()):
1733
+ shutil.rmtree(target_path)
1734
+ target_path.mkdir(parents=True, exist_ok=True)
1735
+ if progress_obj and progress_task_id is not None:
1736
+ progress_obj.update(progress_task_id, total=100, completed=10)
1737
+ try:
1738
+ self._download_artifactory_archive(host, prefix, owner, repo, ref, target_path, scheme=scheme)
1739
+ except RuntimeError:
1740
+ if target_path.exists():
1741
+ shutil.rmtree(target_path, ignore_errors=True)
1742
+ raise
1743
+ if progress_obj and progress_task_id is not None:
1744
+ progress_obj.update(progress_task_id, completed=70, total=100)
1745
+
1746
+ validation_result = validate_apm_package(target_path)
1747
+ if not validation_result.is_valid:
1748
+ if target_path.exists():
1749
+ shutil.rmtree(target_path, ignore_errors=True)
1750
+ error_msg = f"Invalid APM package {dep_ref.repo_url}:\n"
1751
+ for error in validation_result.errors:
1752
+ error_msg += f" - {error}\n"
1753
+ raise RuntimeError(error_msg.strip())
1754
+ if not validation_result.package:
1755
+ raise RuntimeError(f"Package validation succeeded but no package metadata found for {dep_ref.repo_url}")
1756
+ package = validation_result.package
1757
+ package.source = dep_ref.to_github_url()
1758
+ package.resolved_commit = None
1759
+ resolved_ref = ResolvedReference(original_ref=f"{dep_ref.repo_url}#{ref}", ref_type=GitReferenceType.BRANCH, resolved_commit=None, ref_name=ref)
1760
+ if progress_obj and progress_task_id is not None:
1761
+ progress_obj.update(progress_task_id, completed=100, total=100)
1762
+ return PackageInfo(
1763
+ package=package, install_path=target_path, resolved_reference=resolved_ref,
1764
+ installed_at=datetime.now().isoformat(), dependency_ref=dep_ref,
1765
+ package_type=validation_result.package_type
1766
+ )
1767
+
1468
1768
  def download_package(
1469
1769
  self,
1470
1770
  repo_ref: str,
@@ -1499,19 +1799,41 @@ author: {dep_ref.repo_url.split('/')[0]}
1499
1799
  # Handle virtual packages differently
1500
1800
  if dep_ref.is_virtual:
1501
1801
  if dep_ref.is_virtual_file():
1502
- # Individual file virtual package
1503
1802
  return self.download_virtual_file_package(dep_ref, target_path, progress_task_id, progress_obj)
1504
1803
  elif dep_ref.is_virtual_collection():
1505
- # Collection virtual package
1506
1804
  return self.download_collection_package(dep_ref, target_path, progress_task_id, progress_obj)
1507
1805
  elif dep_ref.is_virtual_subdirectory():
1508
- # Subdirectory package (e.g., Claude Skill in a monorepo)
1806
+ # When ARTIFACTORY_ONLY is set, download full archive and extract subdir
1807
+ art_proxy = self._parse_artifactory_base_url()
1808
+ if self._is_artifactory_only() and art_proxy:
1809
+ return self._download_subdirectory_from_artifactory(
1810
+ dep_ref, target_path, art_proxy, progress_task_id, progress_obj
1811
+ )
1509
1812
  return self.download_subdirectory_package(dep_ref, target_path, progress_task_id, progress_obj)
1510
1813
  else:
1511
1814
  raise ValueError(f"Unknown virtual package type for {dep_ref.virtual_path}")
1512
-
1815
+
1816
+ # Artifactory download path (Mode 1: explicit FQDN, Mode 2: transparent proxy)
1817
+ use_artifactory = dep_ref.is_artifactory()
1818
+ art_proxy = None
1819
+ if not use_artifactory:
1820
+ art_proxy = self._parse_artifactory_base_url()
1821
+ if art_proxy and self._should_use_artifactory_proxy(dep_ref):
1822
+ use_artifactory = True
1823
+
1824
+ if use_artifactory:
1825
+ return self._download_package_from_artifactory(
1826
+ dep_ref, target_path, art_proxy, progress_task_id, progress_obj
1827
+ )
1828
+
1829
+ # When ARTIFACTORY_ONLY is set but no Artifactory proxy matched, block direct git
1830
+ if self._is_artifactory_only():
1831
+ raise RuntimeError(
1832
+ f"ARTIFACTORY_ONLY is set but no Artifactory proxy is configured for '{repo_ref}'. "
1833
+ "Set ARTIFACTORY_BASE_URL or use explicit Artifactory FQDN syntax."
1834
+ )
1835
+
1513
1836
  # Regular package download (existing logic)
1514
- # Resolve the Git reference to get specific commit
1515
1837
  resolved_ref = self.resolve_git_reference(repo_ref)
1516
1838
 
1517
1839
  # Create target directory if it doesn't exist