From 824313458c89269750c5d0c6268c9d028f84650a Mon Sep 17 00:00:00 2001
From: arika0093 <delete0093@gmail.com>
Date: Tue, 21 Apr 2026 07:01:32 +0000
Subject: [PATCH 01/22] feat: support allow-insecure HTTP dependencies (review
 item 14 stack split)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 docs/src/content/docs/guides/dependencies.md  |  11 +-
 .../content/docs/reference/cli-commands.md    |  21 +-
 .../.apm/skills/apm-usage/commands.md         |   2 +-
 plan.md                                       | 243 ++++++++++++++++++
 src/apm_cli/bundle/plugin_exporter.py         |   3 +
 src/apm_cli/commands/_helpers.py              |   5 +
 src/apm_cli/commands/install.py               |  81 +++++-
 src/apm_cli/config.py                         |  18 ++
 src/apm_cli/deps/github_downloader.py         |  37 +++
 src/apm_cli/deps/lockfile.py                  |  13 +
 src/apm_cli/drift.py                          |  15 +-
 src/apm_cli/models/dependency/reference.py    |  44 +++-
 tests/unit/test_auth_scoping.py               |  33 +++
 tests/unit/test_canonicalization.py           | 106 ++++++++
 tests/unit/test_install_command.py            | 136 ++++++++++
 tests/unit/test_install_update.py             |  47 ++++
 16 files changed, 794 insertions(+), 21 deletions(-)
 create mode 100644 plan.md

diff --git a/docs/src/content/docs/guides/dependencies.md b/docs/src/content/docs/guides/dependencies.md
index 8f7c93c3f..7614bf2e4 100644
--- a/docs/src/content/docs/guides/dependencies.md
+++ b/docs/src/content/docs/guides/dependencies.md
@@ -145,7 +145,16 @@ dependencies:
       ref: v1.0
 ```
 
-Fields: `git` (required), `path`, `ref`, `alias` (all optional). The `git` value is any HTTPS or SSH clone URL. Explicit URL schemes are honored exactly -- see [Transport selection](#transport-selection-ssh-vs-https) for the full contract. Custom ports are preserved across every attempt (including any cross-protocol fallback enabled with `--allow-protocol-fallback`), so `ssh://host:7999/...` retried over HTTPS becomes `https://host:7999/...`.
+Fields: `git` (required), `path`, `ref`, `alias` (all optional). The `git` value is any HTTPS, HTTP or SSH clone URL.
+
+Explicit URL schemes are honored exactly -- see [Transport selection](#transport-selection-ssh-vs-https) for the full contract. Custom ports are preserved across every attempt (including any cross-protocol fallback enabled with `--allow-protocol-fallback`), so `ssh://host:7999/...` retried over HTTPS becomes `https://host:7999/...`.
+
+:::caution
+Use HTTP dependencies only on trusted private networks. Declare them with
+`git: http://...` and `allow_insecure: true` in `apm.yml`. Installing them
+still requires `apm install --allow-insecure`, unless
+`apm config set allow-insecure true` is enabled globally.
+:::
 
 > **Nested groups (GitLab, Gitea, etc.):** APM treats all path segments after the host as the repo path, so `gitlab.com/group/subgroup/repo` resolves to a repo at `group/subgroup/repo`. Virtual paths on simple 2-segment repos work with shorthand (`gitlab.com/owner/repo/file.prompt.md`). But for **nested-group repos + virtual paths**, use the object format — the shorthand is ambiguous:
 >
diff --git a/docs/src/content/docs/reference/cli-commands.md b/docs/src/content/docs/reference/cli-commands.md
index 32546aa98..3b5dca51f 100644
--- a/docs/src/content/docs/reference/cli-commands.md
+++ b/docs/src/content/docs/reference/cli-commands.md
@@ -74,7 +74,7 @@ apm init my-plugin --plugin
 
 ### `apm install` - Install dependencies and deploy local content
 
-Install APM package and MCP server dependencies from `apm.yml` and deploy the project's own `.apm/` content to target directories (like `npm install`). Auto-creates minimal `apm.yml` when packages are specified but no manifest exists.
+Install APM package and MCP server dependencies from `apm.yml` and deploy the project's own `.apm/` content to target directories (like `npm install`). Auto-creates minimal `apm.yml` when packages are specified but no manifest exists. For `http://` dependencies, use `--allow-insecure` or enable the global `allow-insecure` config.
 
 ```bash
 apm install [PACKAGES...] [OPTIONS]
@@ -96,6 +96,7 @@ apm install [PACKAGES...] [OPTIONS]
 - `--trust-transitive-mcp` - Trust self-defined MCP servers from transitive packages (skip re-declaration requirement)
 - `--dev` - Add packages to [`devDependencies`](../manifest-schema/#5-devdependencies) instead of `dependencies`. Dev deps are installed locally but excluded from `apm pack --format plugin` bundles
 - `-g, --global` - Install to user scope (`~/.apm/`) instead of the current project. Primitives deploy to `~/.copilot/`, `~/.claude/`, etc. MCP servers are only installed for global-capable runtimes (Copilot CLI, Codex CLI); workspace-only runtimes are skipped.
+- `--allow-insecure` - Allow HTTP (insecure) dependencies. Required when adding or installing dependencies that use an `http://` URL.
 - `--ssh` - Force SSH for shorthand (`owner/repo`) dependencies. Mutually exclusive with `--https`. Ignored for URLs with an explicit scheme.
 - `--https` - Force HTTPS for shorthand dependencies. Mutually exclusive with `--ssh`. Default unless `git config url.<base>.insteadOf` rewrites the candidate to SSH.
 - `--allow-protocol-fallback` - Restore the legacy permissive cross-protocol fallback chain (HTTPS-then-SSH or vice-versa). Strict-by-default otherwise. Each retry emits a `[!]` warning naming both protocols.
@@ -1431,6 +1432,7 @@ apm config get [KEY]
 - `KEY` (optional) - Configuration key to retrieve. Supported keys:
   - `auto-integrate` - Whether to automatically integrate `.prompt.md` files into AGENTS.md
   - `temp-dir` - Custom temporary directory for clone/download operations
+  - `allow-insecure` - Whether HTTP (insecure) dependencies are allowed globally
 
 If `KEY` is omitted, displays all configuration values.
 
@@ -1439,6 +1441,9 @@ If `KEY` is omitted, displays all configuration values.
 # Get auto-integrate setting
 apm config get auto-integrate
 
+# Get allow-insecure setting
+apm config get allow-insecure
+
 # Show all configuration
 apm config get
 ```
@@ -1455,6 +1460,7 @@ apm config set KEY VALUE
 - `KEY` - Configuration key to set. Supported keys:
   - `auto-integrate` - Enable/disable automatic integration of `.prompt.md` files
   - `temp-dir` - Set a custom temporary directory path
+  - `allow-insecure` - Allow HTTP (insecure) dependencies globally
 - `VALUE` - Value to set. For boolean keys, use: `true`, `false`, `yes`, `no`, `1`, `0`
 
 **Configuration Keys:**
@@ -1467,6 +1473,11 @@ apm config set KEY VALUE
   - Set to `false` if you want to manually manage which prompts are compiled
   - Set to `true` to ensure all prompts are always included in the context
 
+**`allow-insecure`** - Allow HTTP (insecure) dependencies globally
+- **Type:** Boolean
+- **Default:** `false`
+- **Description:** When enabled, APM skips the requirement to pass `--allow-insecure` at install time for HTTP dependencies. The per-dependency `allow_insecure: true` field in apm.yml is still required. Use this for private network environments where all servers use HTTP.
+
 **Examples:**
 ```bash
 # Enable auto-integration (default)
@@ -1475,9 +1486,11 @@ apm config set auto-integrate true
 # Disable auto-integration
 apm config set auto-integrate false
 
-# Using alternative boolean values
-apm config set auto-integrate yes
-apm config set auto-integrate 1
+# Allow HTTP dependencies globally (skips --allow-insecure flag requirement)
+apm config set allow-insecure true
+
+# Disable globally (default)
+apm config set allow-insecure false
 ```
 
 **`temp-dir`** - Override the system temporary directory
diff --git a/packages/apm-guide/.apm/skills/apm-usage/commands.md b/packages/apm-guide/.apm/skills/apm-usage/commands.md
index 24ef8e61a..1e0e5730d 100644
--- a/packages/apm-guide/.apm/skills/apm-usage/commands.md
+++ b/packages/apm-guide/.apm/skills/apm-usage/commands.md
@@ -10,7 +10,7 @@
 
 | Command | Purpose | Key flags |
 |---------|---------|-----------|
-| `apm install [PKGS...]` | Install packages | `--update` refresh refs, `--force` overwrite, `--dry-run`, `--verbose`, `--only [apm\|mcp]`, `--target` (comma-separated), `--dev`, `-g` global, `--trust-transitive-mcp`, `--parallel-downloads N` |
+| `apm install [PKGS...]` | Install packages | `--update` refresh refs, `--force` overwrite, `--dry-run`, `--verbose`, `--only [apm\|mcp]`, `--target` (comma-separated), `--dev`, `-g` global, `--trust-transitive-mcp`, `--parallel-downloads N`, `--allow-insecure` |
 | `apm uninstall PKGS...` | Remove packages | `--dry-run`, `-g` global |
 | `apm prune` | Remove orphaned packages | `--dry-run` |
 | `apm deps list` | List installed packages | `-g` global, `--all` both scopes |
diff --git a/plan.md b/plan.md
new file mode 100644
index 000000000..d636f3ceb
--- /dev/null
+++ b/plan.md
@@ -0,0 +1,243 @@
+# Implementation Plan — PR #700 Architectural Fixes
+
+## Overview
+
+Three targeted refactors requested by review, all scoped inside a single PR.
+They share no mutual data-flow dependency, so they can be implemented as three
+independent commits, but must be **ordered** to minimise merge-conflict surface.
+
+---
+
+## Change 1: `LockedDependency.to_dependency_ref()` — Eliminate 3 Duplicate Reconstructions
+
+### Problem
+
+Three call sites manually reconstruct a `DependencyReference` from a
+`LockedDependency` using an identical 8-field constructor call.  The field
+mapping is non-trivial (`registry_prefix` → `artifactory_prefix`;
+`source == "local"` → `is_local`), creating a maintenance hazard.
+
+| # | File | Function | Lines |
+|---|------|----------|-------|
+| 1 | `src/apm_cli/deps/lockfile.py` | `LockFile.get_installed_paths()` | 375-385 |
+| 2 | `src/apm_cli/commands/_helpers.py` | `_build_expected_install_paths()` | 138-148 |
+| 3 | `src/apm_cli/bundle/plugin_exporter.py` | `_dep_install_path()` | 399-409 |
+
+### Design
+
+Add a new method on `LockedDependency`:
+
+```python
+def to_dependency_ref(self) -> "DependencyReference":
+    """Reconstruct the DependencyReference needed for path computation."""
+    from ..models.apm_package import DependencyReference
+    return DependencyReference(
+        repo_url=self.repo_url,
+        host=self.host,
+        virtual_path=self.virtual_path,
+        is_virtual=self.is_virtual,
+        artifactory_prefix=self.registry_prefix,
+        is_local=(self.source == "local"),
+        local_path=self.local_path,
+        is_insecure=self.is_insecure,
+        allow_insecure=self.allow_insecure,
+    )
+```
+
+Lazy import inside the method body avoids circular dependency
+(`lockfile.py` → `reference.py`; `reference.py` does NOT import lockfile).
+
+### Files Changed (4)
+
+| File | Change |
+|------|--------|
+| `src/apm_cli/deps/lockfile.py` | Add `to_dependency_ref()` to `LockedDependency`; refactor `get_installed_paths()` to call it |
+| `src/apm_cli/commands/_helpers.py` | Replace 10-line constructor with `dep.to_dependency_ref()` |
+| `src/apm_cli/bundle/plugin_exporter.py` | Replace 10-line constructor with `dep.to_dependency_ref()` |
+| `tests/test_lockfile.py` | Add unit test for `to_dependency_ref()` round-trip fidelity |
+
+### Risks
+
+- **Low**: Pure refactor — no behaviour change.  Existing tests for
+  `get_installed_paths`, `_build_expected_install_paths`, and
+  `_dep_install_path` serve as regression coverage.
+- **Field drift**: If a future field is added to the reconstruction,
+  only ONE site needs updating.  This is the whole point.
+
+---
+
+## Change 2: Scheme-Blind Identity / Drift Handling
+
+### Problem
+
+`get_unique_key()` (both on `DependencyReference` and `LockedDependency`)
+returns `repo_url`, which is scheme-blind: `owner/repo` is the same key
+whether the dependency is HTTP or HTTPS.  This means:
+
+1. **Identity collision** — an `http://` dep and an `https://` dep to the same
+   `owner/repo` silently overwrite each other in the lockfile dict (keyed by
+   `get_unique_key()`).
+2. **Drift not detected** — `detect_ref_change()` in `drift.py` compares
+   `dep_ref.reference != locked_dep.resolved_ref` but never checks whether
+   the *scheme* changed.  The docstring at `drift.py:41-46` explicitly
+   documents this as a known non-goal ("Source/host/scheme changes — *not*
+   detected").
+3. **Silent lockfile corruption on scheme switch** — if a user edits
+   `apm.yml` from `http://host/owner/repo` to `https://host/owner/repo`
+   (fixing a security issue), the lockfile entry retains `is_insecure: true`
+   from the old lock, and `build_download_ref()` (drift.py:253) restores
+   `is_insecure=True` to the download ref, defeating the upgrade.
+
+### Design
+
+This is a **policy decision** with two possible depths:
+
+**Option A — Minimal (recommended for this PR):** Make scheme change trigger
+re-download by adding a scheme check in `build_download_ref()`:
+
+```python
+# In build_download_ref(), after the lockfile lookup:
+if locked_dep.is_insecure != dep_ref.is_insecure:
+    return dep_ref  # scheme changed → re-download from manifest
+```
+
+This ensures that an HTTP→HTTPS (or HTTPS→HTTP) change in `apm.yml` is not
+masked by the lockfile replay.  The unique key stays scheme-blind (same
+filesystem path), but the *download* respects the manifest's scheme.
+
+**Option B — Full (consider for follow-up):** Include scheme in
+`get_unique_key()` so that HTTP and HTTPS versions of the same repo are
+truly distinct entries.  This has much wider blast radius (lockfile format,
+orphan detection, filesystem layout).
+
+### Files Changed (Option A — 3 files)
+
+| File | Change |
+|------|--------|
+| `src/apm_cli/drift.py` | Add scheme-change guard in `build_download_ref()`; update module docstring to remove the "not detected" caveat |
+| `tests/unit/test_install_update.py` | Add test: HTTP→HTTPS scheme change triggers re-download; add test: HTTPS→HTTP without `allow_insecure` is rejected |
+| `tests/unit/test_install_update.py` | Verify existing `test_http_lockfile_restores_insecure_scheme` still passes (no change to it, just run) |
+
+### Risks
+
+- **Medium — lockfile replay**: If an existing lockfile has `is_insecure: true`
+  and the user switches to HTTPS in `apm.yml`, the dep will be re-downloaded
+  on next install.  This is *correct* but is a behavioural change for users
+  who previously edited scheme without re-downloading.
+- **Low — identity stability**: `get_unique_key()` is unchanged, so lockfile
+  keying, orphan detection, and filesystem layout are unaffected.
+- **None — `get_identity()`**: Already scheme-blind by design (it serves
+  duplicate detection, not lockfile keying).  No change needed.
+
+---
+
+## Change 3: Move Insecure Policy Logic to Install Module
+
+### Problem
+
+10 insecure-policy functions and 1 dataclass live in
+`src/apm_cli/commands/install.py` (lines 111-306).  The resolve phase
+(`install/phases/resolve.py:300-305`) imports them back:
+
+```python
+from apm_cli.commands.install import (
+    _check_insecure_dependencies,
+    _collect_insecure_dependency_infos,
+    _guard_transitive_insecure_dependencies,
+    _warn_insecure_dependencies,
+)
+```
+
+This creates a **circular dependency smell** (`commands/ → install/` and
+`install/ → commands/`) and places pure-domain logic inside a Click CLI module.
+
+### Design
+
+Create `src/apm_cli/install/insecure_policy.py` containing:
+
+- `_InsecureDependencyInfo` (dataclass)
+- `_collect_insecure_dependency_infos()`
+- `_format_insecure_dependency_warning()`
+- `_warn_insecure_dependencies()`
+- `_normalize_allow_insecure_host()`
+- `_allow_insecure_host_callback()` (Click callback — needs `click` import)
+- `_get_insecure_dependency_host()`
+- `_get_allowed_transitive_insecure_hosts()`
+- `_guard_transitive_insecure_dependencies()`
+- `_check_insecure_dependencies()`
+
+In `commands/install.py`, add re-exports to preserve the existing test import
+contract:
+
+```python
+from apm_cli.install.insecure_policy import (
+    _InsecureDependencyInfo,
+    _check_insecure_dependencies,
+    _collect_insecure_dependency_infos,
+    _format_insecure_dependency_warning,
+    _guard_transitive_insecure_dependencies,
+    _warn_insecure_dependencies,
+    _allow_insecure_host_callback,
+    _normalize_allow_insecure_host,
+    _get_insecure_dependency_host,
+    _get_allowed_transitive_insecure_hosts,
+)
+```
+
+The resolve phase import changes to the canonical location:
+
+```python
+from apm_cli.install.insecure_policy import (
+    _check_insecure_dependencies,
+    _collect_insecure_dependency_infos,
+    _guard_transitive_insecure_dependencies,
+    _warn_insecure_dependencies,
+)
+```
+
+### Files Changed (4 + 1 new)
+
+| File | Change |
+|------|--------|
+| `src/apm_cli/install/insecure_policy.py` | **NEW** — all 10 functions + dataclass moved here |
+| `src/apm_cli/commands/install.py` | Delete function bodies (lines 111-306); add re-export block |
+| `src/apm_cli/install/phases/resolve.py` | Update import from `apm_cli.install.insecure_policy` |
+| `tests/unit/test_install_command.py` | No change needed if re-exports preserved.  Optionally add a parallel import path test. |
+| `tests/unit/install/test_insecure_policy.py` | **NEW** (optional) — dedicated tests for the new module, migrated from test_install_command.py |
+
+### Risks
+
+- **Low — import compat**: Re-exports from `commands/install.py` keep all 17
+  existing test patches (e.g. `@patch("apm_cli.commands.install._check_insecure_dependencies")`)
+  working.  This follows the precedent already set in `install.py` lines 34-70
+  for `_hash_deployed`, `_validate_package_exists`, `_pre_deploy_security_scan`, etc.
+- **Low — Click dependency**: `_allow_insecure_host_callback` needs `click`;
+  the install package already imports click transitively (via context/request).
+  Keep the import local to that one function.
+- **None — runtime**: Pure file-move.  No logic change.
+
+---
+
+## Recommended Commit Order
+
+| Order | Change | Rationale |
+|-------|--------|-----------|
+| **1** | `to_dependency_ref()` | Smallest blast radius (4 files). Self-contained refactor. No test import changes. Easiest to review. |
+| **2** | Scheme-blind drift fix | Builds on stable lockfile model from (1). Adds a single guard in `drift.py`. Small, focused. |
+| **3** | Move insecure policy | Largest file-move. Touches the most test-adjacent surface. Doing it last means reviewers already understand the insecure plumbing from (2). |
+
+Each commit should be independently green (all tests pass). This ordering
+minimises rebase conflicts if any commit needs rework.
+
+---
+
+## Test Strategy
+
+| Commit | New Tests | Existing Coverage |
+|--------|-----------|-------------------|
+| 1 | 1 unit test for `to_dependency_ref()` round-trip | `test_lockfile.py` (44 tests), `test_transitive_deps.py` |
+| 2 | 2 tests for scheme-change detection | `test_install_update.py` (existing HTTP lockfile test) |
+| 3 | Import-path smoke test (optional) | All 17 tests in `TestAllowInsecureFlag`, `TestInsecureDependencyWarnings`, `TestTransitiveInsecureDependencyGuard` pass unchanged via re-exports |
+
+Total new test count: ~3-4 tests.
+Total files touched: 8 (+ 1-2 new).
\ No newline at end of file
diff --git a/src/apm_cli/bundle/plugin_exporter.py b/src/apm_cli/bundle/plugin_exporter.py
index a71e9319a..45f85fb1c 100644
--- a/src/apm_cli/bundle/plugin_exporter.py
+++ b/src/apm_cli/bundle/plugin_exporter.py
@@ -401,8 +401,11 @@ def _dep_install_path(dep: LockedDependency, apm_modules_dir: Path) -> Path:
         host=dep.host,
         virtual_path=dep.virtual_path,
         is_virtual=dep.is_virtual,
+        artifactory_prefix=dep.registry_prefix,
         is_local=(dep.source == "local"),
         local_path=dep.local_path,
+        is_insecure=dep.is_insecure,
+        allow_insecure=dep.allow_insecure,
     )
     return dep_ref.get_install_path(apm_modules_dir)
 
diff --git a/src/apm_cli/commands/_helpers.py b/src/apm_cli/commands/_helpers.py
index 895a9984d..d4964ad68 100644
--- a/src/apm_cli/commands/_helpers.py
+++ b/src/apm_cli/commands/_helpers.py
@@ -140,6 +140,11 @@ def _build_expected_install_paths(declared_deps, lockfile, apm_modules_dir: Path
                     host=dep.host,
                     virtual_path=dep.virtual_path,
                     is_virtual=dep.is_virtual,
+                    artifactory_prefix=dep.registry_prefix,
+                    is_local=(dep.source == "local"),
+                    local_path=dep.local_path,
+                    is_insecure=dep.is_insecure,
+                    allow_insecure=dep.allow_insecure,
                 )
                 install_path = dep_ref.get_install_path(apm_modules_dir)
                 try:
diff --git a/src/apm_cli/commands/install.py b/src/apm_cli/commands/install.py
index bd4d1aa9a..9c26cb307 100644
--- a/src/apm_cli/commands/install.py
+++ b/src/apm_cli/commands/install.py
@@ -106,7 +106,45 @@
 # ---------------------------------------------------------------------------
 
 
-def _validate_and_add_packages_to_apm_yml(packages, dry_run=False, dev=False, logger=None, manifest_path=None, auth_resolver=None, scope=None):
+def _check_insecure_dependencies(
+    deps, allow_insecure_flag: bool, logger=None
+) -> None:
+    """Check APM dependencies for HTTP (insecure) URLs and enforce security policy."""
+    from ..config import get_allow_insecure
+
+    config_allow_insecure = get_allow_insecure()
+
+    for dep in deps:
+        dep_is_insecure = getattr(dep, "is_insecure", False) is True
+        if not dep_is_insecure:
+            continue
+        identity = dep.get_identity()
+        dep_allow_insecure = getattr(dep, "allow_insecure", False) is True
+        if not dep_allow_insecure:
+            message = (
+                f"Dependency '{identity}' uses HTTP (insecure) but "
+                f"'allow_insecure: true' is not set in its apm.yml entry. "
+                f"Add 'allow_insecure: true' to the dependency, or use a HTTPS URL instead."
+            )
+            if logger:
+                logger.error(message)
+            else:
+                _rich_error(message)
+            sys.exit(1)
+        if not (allow_insecure_flag or config_allow_insecure):
+            message = (
+                f"Dependency '{identity}' uses HTTP (insecure). "
+                f"Pass '--allow-insecure' to apm install, or run "
+                f"'apm config set allow-insecure true' to allow HTTP dependencies globally."
+            )
+            if logger:
+                logger.error(message)
+            else:
+                _rich_error(message)
+            sys.exit(1)
+
+
+def _validate_and_add_packages_to_apm_yml(packages, dry_run=False, dev=False, logger=None, manifest_path=None, auth_resolver=None, scope=None, allow_insecure=False):
     """Validate packages exist and can be accessed, then add to apm.yml dependencies section.
 
     Implements normalize-on-write: any input form (HTTPS URL, SSH URL, FQDN, shorthand)
@@ -129,7 +167,10 @@ def _validate_and_add_packages_to_apm_yml(packages, dry_run=False, dev=False, lo
     import tempfile
     from pathlib import Path
 
+    from ..config import get_allow_insecure
+
     apm_yml_path = manifest_path or Path(APM_YML_FILENAME)
+    config_allow_insecure = get_allow_insecure()
 
     # Read current apm.yml
     try:
@@ -170,6 +211,7 @@ def _validate_and_add_packages_to_apm_yml(packages, dry_run=False, dev=False, lo
     valid_outcomes = []  # (canonical, already_present) tuples
     invalid_outcomes = []  # (package, reason) tuples
     _marketplace_provenance = {}  # canonical -> {discovered_via, marketplace_plugin_name}
+    _apm_yml_entries = {}  # canonical -> apm.yml entry (str or dict for HTTP deps)
 
     if logger:
         logger.validation_start(len(packages))
@@ -244,6 +286,20 @@ def _validate_and_add_packages_to_apm_yml(packages, dry_run=False, dev=False, lo
                 logger.validation_fail(package, reason)
             continue
 
+        if dep_ref.is_insecure:
+            if not (allow_insecure or config_allow_insecure):
+                reason = (
+                    f"'{canonical}' uses HTTP (insecure). "
+                    f"Pass '--allow-insecure' or run "
+                    f"'apm config set allow-insecure true' to allow HTTP dependencies."
+                )
+                invalid_outcomes.append((package, reason))
+                if logger:
+                    logger.validation_fail(package, reason)
+                continue
+            dep_ref.allow_insecure = True
+            _apm_yml_entries[canonical] = dep_ref.to_apm_yml_entry()
+
         # Reject local packages at user scope -- relative paths resolve
         # against cwd during validation but against $HOME during copy,
         # causing silent failures.
@@ -315,7 +371,7 @@ def _validate_and_add_packages_to_apm_yml(packages, dry_run=False, dev=False, lo
     # Add validated packages to dependencies (already canonical)
     dep_label = "devDependencies" if dev else "apm.yml"
     for package in validated_packages:
-        current_deps.append(package)
+        current_deps.append(_apm_yml_entries.get(package, package))
         if logger:
             logger.verbose_detail(f"Added {package} to {dep_label}")
 
@@ -344,7 +400,7 @@ def _validate_and_add_packages_to_apm_yml(packages, dry_run=False, dev=False, lo
 
 
 @click.command(
-    help="Install APM and MCP dependencies (auto-creates apm.yml when installing packages)"
+    help="Install APM and MCP dependencies (auto-creates apm.yml; use --allow-insecure for http:// packages)"
 )
 @click.argument("packages", nargs=-1)
 @click.option("--runtime", help="Target specific runtime only (copilot, codex, vscode)")
@@ -388,6 +444,13 @@ def _validate_and_add_packages_to_apm_yml(packages, dry_run=False, dev=False, lo
     default=None,
     help="Target platform (comma-separated for multiple, e.g. claude,copilot). Use 'all' for every target. Overrides auto-detection.",
 )
+@click.option(
+    "--allow-insecure",
+    "allow_insecure",
+    is_flag=True,
+    default=False,
+    help="Allow HTTP (insecure) dependencies. Required when dependencies use http:// URLs.",
+)
 @click.option(
     "--global", "-g", "global_",
     is_flag=True,
@@ -416,13 +479,16 @@ def _validate_and_add_packages_to_apm_yml(packages, dry_run=False, dev=False, lo
     help="Restore the legacy permissive cross-protocol fallback chain (escape hatch for migrating users; also: APM_ALLOW_PROTOCOL_FALLBACK=1).",
 )
 @click.pass_context
-def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbose, trust_transitive_mcp, parallel_downloads, dev, target, global_, use_ssh, use_https, allow_protocol_fallback):
+def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbose, trust_transitive_mcp, parallel_downloads, dev, target, allow_insecure, global_, use_ssh, use_https, allow_protocol_fallback):
     """Install APM and MCP dependencies from apm.yml (like npm install).
 
     This command automatically detects AI runtimes from your apm.yml scripts and installs
     MCP servers for all detected and available runtimes. It also installs APM package
     dependencies from GitHub repositories.
 
+    HTTP dependencies require `allow_insecure: true` in apm.yml and either
+    `--allow-insecure` or `apm config set allow-insecure true`.
+
     The --only flag filters by dependency type (apm or mcp). Internally converted
     to an InstallMode enum for type-safe dispatch.
 
@@ -436,6 +502,7 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo
         apm install --update                    # Update dependencies to latest Git refs
         apm install --dry-run                   # Show what would be installed
         apm install -g org/pkg1                 # Install to user scope (~/.apm/)
+        apm install --allow-insecure http://my-server.example.com/owner/repo
     """
     try:
         # Create structured logger for install output early so exception
@@ -514,6 +581,7 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo
                 packages, dry_run, dev=dev, logger=logger,
                 manifest_path=manifest_path, auth_resolver=auth_resolver,
                 scope=scope,
+                allow_insecure=allow_insecure,
             )
             # Short-circuit: all packages failed validation -- nothing to install
             if outcome.all_failed:
@@ -546,6 +614,9 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo
         has_any_apm_deps = bool(apm_deps) or bool(dev_apm_deps)
         mcp_deps = apm_package.get_mcp_dependencies()
 
+        all_apm_deps = list(apm_deps) + list(dev_apm_deps)
+        _check_insecure_dependencies(all_apm_deps, allow_insecure, logger=logger)
+
         # Convert --only string to InstallMode enum
         if only is None:
             install_mode = InstallMode.ALL
@@ -804,5 +875,3 @@ def _install_apm_dependencies(
     return InstallService().run(request)
 
 
-
-
diff --git a/src/apm_cli/config.py b/src/apm_cli/config.py
index d0f8d93e7..458eb7c8d 100644
--- a/src/apm_cli/config.py
+++ b/src/apm_cli/config.py
@@ -146,3 +146,21 @@ def get_apm_temp_dir() -> Optional[str]:
     if config_val:
         return config_val
     return None
+
+
+def get_allow_insecure() -> bool:
+    """Get the allow-insecure setting.
+
+    Returns:
+        bool: Whether HTTP (insecure) dependencies are allowed globally (default: False).
+    """
+    return get_config().get("allow_insecure", False)
+
+
+def set_allow_insecure(enabled: bool) -> None:
+    """Set the allow-insecure setting.
+
+    Args:
+        enabled: Whether to allow HTTP (insecure) dependencies globally.
+    """
+    update_config({"allow_insecure": enabled})
diff --git a/src/apm_cli/deps/github_downloader.py b/src/apm_cli/deps/github_downloader.py
index 3c4e6d762..e45bf0561 100644
--- a/src/apm_cli/deps/github_downloader.py
+++ b/src/apm_cli/deps/github_downloader.py
@@ -659,6 +659,10 @@ def _build_repo_url(self, repo_ref: str, use_ssh: bool = False, dep_ref: Depende
         else:
             # Determine if this host should receive a GitHub token
             is_github = is_github_hostname(host)
+            # Use http:// only for dependencies explicitly marked as insecure.
+            is_insecure = False
+            if dep_ref is not None:
+                is_insecure = bool(getattr(dep_ref, "is_insecure", False))
             # Thread the user-declared custom port (e.g. 7999 for Bitbucket DC) through
             # the URL builders so neither SSH nor HTTPS attempts silently drop it.
             port = dep_ref.port if dep_ref else None
@@ -667,6 +671,9 @@ def _build_repo_url(self, repo_ref: str, use_ssh: bool = False, dep_ref: Depende
             elif is_github and github_token:
                 # Only send GitHub tokens to GitHub hosts
                 return build_https_clone_url(host, repo_ref, token=github_token, port=port)
+            elif is_insecure:
+                netloc = f"{host}:{port}" if port else host
+                return f"http://{netloc}/{repo_ref}.git"
             else:
                 # Generic hosts: plain HTTPS, let git credential helpers handle auth
                 return build_https_clone_url(host, repo_ref, token=None, port=port)
@@ -714,6 +721,36 @@ def _clone_with_fallback(self, repo_url_base: str, target_path: Path, progress_r
             f"protocol_pref={self._protocol_pref.value}, allow_fallback={self._allow_fallback}"
         )
 
+        # HTTP direct only: return before Method 1/2/3 and keep credential helpers enabled.
+        if dep_ref and getattr(dep_ref, "is_insecure", False):
+            insecure_env = {
+                k: v
+                for k, v in self.git_env.items()
+                if k not in ("GIT_ASKPASS", "GIT_CONFIG_GLOBAL", "GIT_CONFIG_NOSYSTEM")
+            }
+            insecure_env["GIT_TERMINAL_PROMPT"] = "0"
+            try:
+                http_url = self._build_repo_url(
+                    repo_url_base, use_ssh=False, dep_ref=dep_ref
+                )
+                repo = Repo.clone_from(
+                    http_url,
+                    target_path,
+                    env=insecure_env,
+                    progress=progress_reporter,
+                    **clone_kwargs,
+                )
+                if verbose_callback:
+                    verbose_callback(f"Cloned from: {http_url}")
+                return repo
+            except GitCommandError as e:
+                sanitized_error = self._sanitize_git_error(str(e))
+                raise RuntimeError(
+                    f"Failed to clone insecure HTTP repository {repo_url_base}. "
+                    f"HTTP dependencies do not fall back to SSH or HTTPS. "
+                    f"Last error: {sanitized_error}"
+                ) from e
+
         # Choose the clone env PER ATTEMPT (not per dependency): only the
         # token-bearing attempt should run with the locked-down env that
         # silences credential helpers. SSH and plain-HTTPS attempts in a
diff --git a/src/apm_cli/deps/lockfile.py b/src/apm_cli/deps/lockfile.py
index fe667c84c..e736c2c95 100644
--- a/src/apm_cli/deps/lockfile.py
+++ b/src/apm_cli/deps/lockfile.py
@@ -40,6 +40,8 @@ class LockedDependency:
     is_dev: bool = False  # True for devDependencies
     discovered_via: Optional[str] = None  # Marketplace name (provenance)
     marketplace_plugin_name: Optional[str] = None  # Plugin name in marketplace
+    is_insecure: bool = False  # True when the locked source was http://
+    allow_insecure: bool = False  # True when the manifest explicitly allowed HTTP
 
     def get_unique_key(self) -> str:
         """Returns unique key for this dependency."""
@@ -92,6 +94,10 @@ def to_dict(self) -> Dict[str, Any]:
             result["discovered_via"] = self.discovered_via
         if self.marketplace_plugin_name:
             result["marketplace_plugin_name"] = self.marketplace_plugin_name
+        if self.is_insecure:
+            result["is_insecure"] = True
+        if self.allow_insecure:
+            result["allow_insecure"] = True
         return result
 
     @classmethod
@@ -143,6 +149,8 @@ def from_dict(cls, data: Dict[str, Any]) -> "LockedDependency":
             is_dev=data.get("is_dev", False),
             discovered_via=data.get("discovered_via"),
             marketplace_plugin_name=data.get("marketplace_plugin_name"),
+            is_insecure=data.get("is_insecure", False),
+            allow_insecure=data.get("allow_insecure", False),
         )
 
     @classmethod
@@ -189,6 +197,8 @@ def from_dependency_ref(
             source="local" if dep_ref.is_local else None,
             local_path=dep_ref.local_path if dep_ref.is_local else None,
             is_dev=is_dev,
+            is_insecure=dep_ref.is_insecure,
+            allow_insecure=dep_ref.allow_insecure,
         )
 
 
@@ -367,8 +377,11 @@ def get_installed_paths(self, apm_modules_dir: Path) -> List[str]:
                 host=dep.host,
                 virtual_path=dep.virtual_path,
                 is_virtual=dep.is_virtual,
+                artifactory_prefix=dep.registry_prefix,
                 is_local=(dep.source == "local"),
                 local_path=dep.local_path,
+                is_insecure=dep.is_insecure,
+                allow_insecure=dep.allow_insecure,
             )
             install_path = dep_ref.get_install_path(apm_modules_dir)
             try:
diff --git a/src/apm_cli/drift.py b/src/apm_cli/drift.py
index 77edd988c..78ebbea19 100644
--- a/src/apm_cli/drift.py
+++ b/src/apm_cli/drift.py
@@ -38,12 +38,12 @@
   formatting-only change that produces the same unique key and is correctly
   treated as no drift.
 
-* **Source/host changes** — *not* detected.  If a user changes the host of
-  an otherwise identical package (e.g. adding an enterprise FQDN prefix), the
-  unique key (``repo_url``, host-blind for the default host) may not change
-  and ``detect_ref_change()`` will not signal a re-download.  Host-level
-  changes require the user to ``apm remove`` + ``apm install`` the package, or
-  use ``--update``.
+* **Source/host/scheme changes** — *not* detected.  If a user changes the host
+  or scheme of an otherwise identical package (for example, switching an
+  insecure HTTP dependency to HTTPS), the unique key may not change and
+  ``detect_ref_change()`` will not signal a re-download.  Source-level changes
+  require the user to ``apm remove`` + ``apm install`` the package, or use
+  ``--update``.
 """
 
 from __future__ import annotations
@@ -250,6 +250,9 @@ def build_download_ref(
             elif isinstance(getattr(locked_dep, "host", None), str) and locked_dep.host != dep_ref.host:
                 overrides["host"] = locked_dep.host
 
+            if getattr(locked_dep, "is_insecure", False) is True:
+                overrides["is_insecure"] = True
+
             # Use locked commit SHA for byte-for-byte reproducibility.
             if locked_dep.resolved_commit and locked_dep.resolved_commit != "cached":
                 overrides["reference"] = locked_dep.resolved_commit
diff --git a/src/apm_cli/models/dependency/reference.py b/src/apm_cli/models/dependency/reference.py
index 742c94522..3ef00e85e 100644
--- a/src/apm_cli/models/dependency/reference.py
+++ b/src/apm_cli/models/dependency/reference.py
@@ -60,6 +60,10 @@ class DependencyReference:
         None  # e.g., "artifactory/github" (repo key path)
     )
 
+    # HTTP (insecure) dependency fields
+    is_insecure: bool = False  # True when the dependency URL uses http://
+    allow_insecure: bool = False  # True if this HTTP dep is explicitly allowed
+
     # Supported file extensions for virtual packages
     VIRTUAL_FILE_EXTENSIONS = (
         ".prompt.md",
@@ -202,8 +206,9 @@ def to_canonical(self) -> str:
         - Virtual paths are appended              ->  owner/repo/path/to/thing
         - Refs are appended with #                ->  owner/repo#v1.0
         - Local paths are returned as-is          ->  ./packages/my-pkg
+        - HTTP deps preserve the http:// prefix   ->  http://host/owner/repo
 
-        No .git suffix, no https://, no git@  -- just the canonical identifier.
+        No .git suffix, no git@  -- just the canonical identifier.
 
         Returns:
             str: Canonical dependency string
@@ -212,6 +217,14 @@ def to_canonical(self) -> str:
             return self.local_path
 
         host = self.host or default_host()
+
+        # HTTP deps always include the full http://host/path form
+        if self.is_insecure:
+            result = f"http://{host}/{self.repo_url}"
+            if self.reference:
+                result = f"{result}#{self.reference}"
+            return result
+
         is_default = host.lower() == default_host().lower()
         # Custom port is part of the transport and must travel with the host label.
         host_label = f"{host}:{self.port}" if self.port else host
@@ -493,6 +506,7 @@ def parse_from_dict(cls, entry: dict) -> "DependencyReference":
         sub_path = entry.get("path")
         ref_override = entry.get("ref")
         alias_override = entry.get("alias")
+        allow_insecure = bool(entry.get("allow_insecure", False))
 
         # Validate sub_path if provided
         if sub_path is not None:
@@ -506,6 +520,7 @@ def parse_from_dict(cls, entry: dict) -> "DependencyReference":
 
         # Parse the git URL using the standard parser
         dep = cls.parse(git_url)
+        dep.allow_insecure = allow_insecure
 
         # Apply overrides from the object fields
         if ref_override is not None:
@@ -1058,8 +1073,29 @@ def parse(cls, dependency_str: str) -> "DependencyReference":
             ado_project=ado_project,
             ado_repo=ado_repo,
             artifactory_prefix=artifactory_prefix,
+            is_insecure=dependency_str.startswith("http://"),
         )
 
+    def to_apm_yml_entry(self):
+        """Return the entry to store in apm.yml.
+
+        For HTTP (insecure) deps, returns a dict with 'git' and 'allow_insecure' keys.
+        For all other deps, returns the canonical string (same as to_canonical()).
+
+        Returns:
+            str or dict: String for HTTPS/SSH/local deps; dict for HTTP deps.
+        """
+        if self.is_insecure:
+            host = self.host or default_host()
+            entry = {"git": f"http://{host}/{self.repo_url}"}
+            if self.reference:
+                entry["ref"] = self.reference
+            if self.alias:
+                entry["alias"] = self.alias
+            entry["allow_insecure"] = True
+            return entry
+        return self.to_canonical()
+
     def to_github_url(self) -> str:
         """Convert to full repository URL.
 
@@ -1073,16 +1109,18 @@ def to_github_url(self) -> str:
         host = self.host or default_host()
         netloc = f"{host}:{self.port}" if self.port else host
 
+        scheme = "http" if self.is_insecure else "https"
+
         if self.is_azure_devops():
             # ADO format: https://dev.azure.com/org/project/_git/repo
             project = urllib.parse.quote(self.ado_project, safe="")
             repo = urllib.parse.quote(self.ado_repo, safe="")
             return f"https://{netloc}/{self.ado_organization}/{project}/_git/{repo}"
         elif self.artifactory_prefix:
-            return f"https://{netloc}/{self.artifactory_prefix}/{self.repo_url}"
+            return f"{scheme}://{netloc}/{self.artifactory_prefix}/{self.repo_url}"
         else:
             # GitHub format: https://github.com/owner/repo
-            return f"https://{netloc}/{self.repo_url}"
+            return f"{scheme}://{netloc}/{self.repo_url}"
 
     def to_clone_url(self) -> str:
         """Convert to a clone-friendly URL (same as to_github_url for most purposes)."""
diff --git a/tests/unit/test_auth_scoping.py b/tests/unit/test_auth_scoping.py
index 304001962..3c634a07e 100644
--- a/tests/unit/test_auth_scoping.py
+++ b/tests/unit/test_auth_scoping.py
@@ -252,6 +252,39 @@ def test_github_host_with_token_tries_method1_first(self):
         assert "ghp_TESTTOKEN" in first_url
         assert _url_host(first_url) == "github.com"
 
+    def test_insecure_http_dep_does_not_fallback_to_ssh(self):
+        """HTTP deps should clone with HTTP only and never try SSH fallback."""
+        dl = _make_downloader(github_token="ghp_TESTTOKEN")
+        dep = _dep("http://gitlab.company.internal/acme/rules.git")
+
+        dl.auth_resolver._cache.clear()
+        with patch.dict(os.environ, {"GITHUB_APM_PAT": "ghp_TESTTOKEN"}, clear=True), \
+             patch(
+                 "apm_cli.core.token_manager.GitHubTokenManager.resolve_credential_from_git",
+                 return_value=None,
+             ), \
+             patch('apm_cli.deps.github_downloader.Repo') as MockRepo:
+            MockRepo.clone_from.side_effect = GitCommandError("clone", "failed")
+            target = Path(tempfile.mkdtemp())
+            try:
+                with pytest.raises(
+                    RuntimeError,
+                    match="do not fall back to SSH or HTTPS",
+                ):
+                    dl._clone_with_fallback(dep.repo_url, target, dep_ref=dep)
+            finally:
+                import shutil
+                shutil.rmtree(target, ignore_errors=True)
+
+            assert MockRepo.clone_from.call_count == 1
+            first_url = MockRepo.clone_from.call_args_list[0][0][0]
+            env_used = MockRepo.clone_from.call_args_list[0][1]["env"]
+            assert first_url == "http://gitlab.company.internal/acme/rules.git"
+            assert "GIT_ASKPASS" not in env_used
+            assert "GIT_CONFIG_GLOBAL" not in env_used
+            assert "GIT_CONFIG_NOSYSTEM" not in env_used
+            assert env_used.get("GIT_TERMINAL_PROMPT") == "0"
+
     def test_generic_host_error_message_mentions_credential_helpers(self):
         """When all methods fail for a generic host, the error suggests credential helpers."""
         dl = _make_downloader(github_token="ghp_TESTTOKEN")
diff --git a/tests/unit/test_canonicalization.py b/tests/unit/test_canonicalization.py
index 75a8b81e7..274119656 100644
--- a/tests/unit/test_canonicalization.py
+++ b/tests/unit/test_canonicalization.py
@@ -464,3 +464,109 @@ def test_filter_no_cross_host_match(self):
         dep = DependencyReference.parse("gitlab.com/microsoft/apm-sample-package")
         filter_ref = DependencyReference.parse("microsoft/apm-sample-package")
         assert dep.get_identity() != filter_ref.get_identity()
+
+
+# ── HTTP (allow_insecure) ────────────────────────────────────────────────────
+
+class TestHttpInsecureDeps:
+    """Tests for HTTP (insecure) dependency parsing and serialization."""
+
+    def test_http_url_sets_insecure_flag(self):
+        """Parsing an http:// URL marks the ref as insecure."""
+        dep = DependencyReference.parse("http://my-server.example.com/owner/repo")
+        assert dep.is_insecure is True
+        assert dep.host == "my-server.example.com"
+        assert dep.repo_url == "owner/repo"
+
+    def test_https_url_is_not_insecure(self):
+        """Parsing an https:// URL does not mark the ref as insecure."""
+        dep = DependencyReference.parse("https://gitlab.com/owner/repo.git")
+        assert dep.is_insecure is False
+
+    def test_shorthand_is_not_insecure(self):
+        """Parsing shorthand owner/repo does not mark the ref as insecure."""
+        dep = DependencyReference.parse("owner/repo")
+        assert dep.is_insecure is False
+
+    def test_http_allow_insecure_default_false(self):
+        """Freshly parsed HTTP dep has allow_insecure=False by default."""
+        dep = DependencyReference.parse("http://my-server.example.com/owner/repo")
+        assert dep.allow_insecure is False
+
+    def test_http_to_canonical_preserves_scheme(self):
+        """to_canonical() for HTTP dep includes http:// prefix."""
+        dep = DependencyReference.parse("http://my-server.example.com/owner/repo")
+        canonical = dep.to_canonical()
+        assert canonical.startswith("http://")
+        assert "my-server.example.com/owner/repo" in canonical
+
+    def test_http_to_canonical_with_ref(self):
+        """to_canonical() for HTTP dep with ref includes #ref."""
+        dep = DependencyReference.parse("http://my-server.example.com/owner/repo#main")
+        canonical = dep.to_canonical()
+        assert canonical == "http://my-server.example.com/owner/repo#main"
+
+    def test_http_to_apm_yml_entry_returns_dict(self):
+        """to_apm_yml_entry() for HTTP dep returns a dict with git key."""
+        dep = DependencyReference.parse("http://my-server.example.com/owner/repo")
+        dep.allow_insecure = True
+        entry = dep.to_apm_yml_entry()
+        assert isinstance(entry, dict)
+        assert entry["git"] == "http://my-server.example.com/owner/repo"
+        assert entry["allow_insecure"] is True
+
+    def test_http_to_apm_yml_entry_includes_ref(self):
+        """to_apm_yml_entry() includes ref when present."""
+        dep = DependencyReference.parse("http://my-server.example.com/owner/repo#v1.0")
+        dep.allow_insecure = True
+        entry = dep.to_apm_yml_entry()
+        assert entry.get("ref") == "v1.0"
+        assert "http://my-server.example.com/owner/repo" in entry["git"]
+
+    def test_https_to_apm_yml_entry_returns_string(self):
+        """to_apm_yml_entry() for HTTPS dep returns canonical string (not dict)."""
+        dep = DependencyReference.parse("owner/repo")
+        entry = dep.to_apm_yml_entry()
+        assert isinstance(entry, str)
+        assert entry == "owner/repo"
+
+    def test_parse_from_dict_git_http(self):
+        """parse_from_dict() supports git: http://... for HTTP deps."""
+        entry = {"git": "http://my-server.example.com/owner/repo", "allow_insecure": True}
+        dep = DependencyReference.parse_from_dict(entry)
+        assert dep.is_insecure is True
+        assert dep.allow_insecure is True
+        assert dep.repo_url == "owner/repo"
+        assert dep.host == "my-server.example.com"
+
+    def test_parse_from_dict_git_http_with_ref(self):
+        """parse_from_dict() reads ref from dict with git key."""
+        entry = {"git": "http://my-server.example.com/owner/repo", "ref": "main", "allow_insecure": True}
+        dep = DependencyReference.parse_from_dict(entry)
+        assert dep.reference == "main"
+
+    def test_parse_from_dict_git_http_allow_insecure_default_false(self):
+        """parse_from_dict() with git http URL defaults allow_insecure to False."""
+        entry = {"git": "http://my-server.example.com/owner/repo"}
+        dep = DependencyReference.parse_from_dict(entry)
+        assert dep.allow_insecure is False
+
+    def test_http_to_github_url_uses_http_scheme(self):
+        """to_github_url() uses http:// for HTTP deps."""
+        dep = DependencyReference.parse("http://my-server.example.com/owner/repo")
+        url = dep.to_github_url()
+        assert url.startswith("http://")
+        assert "my-server.example.com/owner/repo" in url
+
+    def test_https_to_github_url_uses_https_scheme(self):
+        """to_github_url() still uses https:// for HTTPS deps."""
+        dep = DependencyReference.parse("https://gitlab.com/owner/repo.git")
+        url = dep.to_github_url()
+        assert url.startswith("https://")
+
+    def test_http_identity_scheme_agnostic(self):
+        """HTTP and HTTPS deps to the same host/repo have the same identity."""
+        http_dep = DependencyReference.parse("http://gitlab.com/owner/repo")
+        https_dep = DependencyReference.parse("https://gitlab.com/owner/repo.git")
+        # Identity includes host but not scheme, so they are the same package
+        assert http_dep.get_identity() == https_dep.get_identity()
diff --git a/tests/unit/test_install_command.py b/tests/unit/test_install_command.py
index 8fc970d33..1197544f3 100644
--- a/tests/unit/test_install_command.py
+++ b/tests/unit/test_install_command.py
@@ -1113,3 +1113,139 @@ def test_missing_content_hash_skips_fallback(self):
             assert not fallback_triggered, (
                 "Fallback must not trigger when content_hash is None"
             )
+
+
+class TestAllowInsecureFlag:
+    """Tests for --allow-insecure flag and HTTP dependency security checks."""
+
+    def setup_method(self):
+        self.runner = CliRunner()
+        try:
+            self.original_dir = os.getcwd()
+        except FileNotFoundError:
+            self.original_dir = str(Path(__file__).parent.parent.parent)
+            os.chdir(self.original_dir)
+
+    def test_http_dep_rejected_without_allow_insecure_flag(self):
+        """Adding http:// package without --allow-insecure is rejected."""
+        with tempfile.TemporaryDirectory() as tmp_dir:
+            try:
+                os.chdir(tmp_dir)
+                with patch("apm_cli.commands.install._validate_package_exists", return_value=True), \
+                     patch("apm_cli.config.get_allow_insecure", return_value=False):
+                    result = self.runner.invoke(
+                        cli, ["install", "http://my-server.example.com/owner/repo"]
+                    )
+            finally:
+                os.chdir(self.original_dir)
+            assert "allow-insecure" in result.output or "insecure" in result.output.lower()
+
+    def test_install_help_mentions_allow_insecure_for_http_deps(self):
+        """Install help should mention the HTTP allow-insecure flow."""
+        result = self.runner.invoke(cli, ["install", "--help"])
+
+        assert result.exit_code == 0
+        normalized = " ".join(result.output.split())
+        assert "use --allow-insecure for http:// packages" in normalized
+
+    @patch("apm_cli.commands.install._validate_package_exists", return_value=True)
+    @patch("apm_cli.commands.install.APM_DEPS_AVAILABLE", True)
+    @patch("apm_cli.commands.install.APMPackage")
+    @patch("apm_cli.commands.install._install_apm_dependencies")
+    def test_http_dep_addition_passes_with_config_allow_insecure(
+        self, mock_install_apm, mock_apm_package, mock_validate
+    ):
+        """Global config allows adding an HTTP dependency without the CLI flag."""
+        with tempfile.TemporaryDirectory() as tmp_dir:
+            try:
+                os.chdir(tmp_dir)
+                mock_pkg_instance = MagicMock()
+                mock_pkg_instance.get_apm_dependencies.return_value = []
+                mock_pkg_instance.get_mcp_dependencies.return_value = []
+                mock_apm_package.from_apm_yml.return_value = mock_pkg_instance
+                mock_install_apm.return_value = InstallResult(
+                    diagnostics=MagicMock(
+                        has_diagnostics=False, has_critical_security=False
+                    )
+                )
+
+                with patch("apm_cli.config.get_allow_insecure", return_value=True):
+                    result = self.runner.invoke(
+                        cli, ["install", "http://my-server.example.com/owner/repo"]
+                    )
+
+                assert result.exit_code == 0
+                with open("apm.yml", encoding="utf-8") as f:
+                    config = yaml.safe_load(f)
+
+                assert config["dependencies"]["apm"] == [
+                    {
+                        "git": "http://my-server.example.com/owner/repo",
+                        "allow_insecure": True,
+                    }
+                ]
+            finally:
+                os.chdir(self.original_dir)
+
+    def test_http_dep_validation_check(self):
+        """_check_insecure_dependencies blocks HTTP dep without allow_insecure flag."""
+        from apm_cli.commands.install import _check_insecure_dependencies
+        from apm_cli.models.dependency.reference import DependencyReference
+
+        dep = DependencyReference.parse("http://my-server.example.com/owner/repo")
+        dep.allow_insecure = True
+
+        with patch("apm_cli.config.get_allow_insecure", return_value=False):
+            with pytest.raises(SystemExit) as exc_info:
+                _check_insecure_dependencies([dep], allow_insecure_flag=False)
+            assert exc_info.value.code == 1
+
+    def test_http_dep_passes_with_allow_insecure_flag(self):
+        """_check_insecure_dependencies passes when flag is set and dep has allow_insecure."""
+        from apm_cli.commands.install import _check_insecure_dependencies
+        from apm_cli.models.dependency.reference import DependencyReference
+
+        dep = DependencyReference.parse("http://my-server.example.com/owner/repo")
+        dep.allow_insecure = True
+
+        with patch("apm_cli.config.get_allow_insecure", return_value=False):
+            _check_insecure_dependencies([dep], allow_insecure_flag=True)
+
+    def test_http_dep_passes_with_config_allow_insecure(self):
+        """_check_insecure_dependencies passes when global config allow_insecure is true."""
+        from apm_cli.commands.install import _check_insecure_dependencies
+        from apm_cli.models.dependency.reference import DependencyReference
+
+        dep = DependencyReference.parse("http://my-server.example.com/owner/repo")
+        dep.allow_insecure = True
+
+        with patch("apm_cli.config.get_allow_insecure", return_value=True):
+            _check_insecure_dependencies([dep], allow_insecure_flag=False)
+
+    def test_http_dep_without_dep_level_allow_insecure_is_blocked(self):
+        """_check_insecure_dependencies blocks HTTP dep missing allow_insecure=True on dep."""
+        from apm_cli.commands.install import _check_insecure_dependencies
+        from apm_cli.models.dependency.reference import DependencyReference
+
+        dep = DependencyReference.parse("http://my-server.example.com/owner/repo")
+
+        with patch("apm_cli.config.get_allow_insecure", return_value=False):
+            with pytest.raises(SystemExit) as exc_info:
+                _check_insecure_dependencies([dep], allow_insecure_flag=True)
+            assert exc_info.value.code == 1
+
+    def test_https_dep_passes_without_flag(self):
+        """_check_insecure_dependencies does not block HTTPS deps."""
+        from apm_cli.commands.install import _check_insecure_dependencies
+        from apm_cli.models.dependency.reference import DependencyReference
+
+        dep = DependencyReference.parse("owner/repo")
+        with patch("apm_cli.config.get_allow_insecure", return_value=False):
+            _check_insecure_dependencies([dep], allow_insecure_flag=False)
+
+    def test_empty_deps_list_passes(self):
+        """_check_insecure_dependencies handles empty dep list."""
+        from apm_cli.commands.install import _check_insecure_dependencies
+
+        with patch("apm_cli.config.get_allow_insecure", return_value=False):
+            _check_insecure_dependencies([], allow_insecure_flag=False)
diff --git a/tests/unit/test_install_update.py b/tests/unit/test_install_update.py
index 05bac905d..7670aa2e9 100644
--- a/tests/unit/test_install_update.py
+++ b/tests/unit/test_install_update.py
@@ -11,6 +11,7 @@
 
 from unittest.mock import Mock
 
+from apm_cli.deps.lockfile import LockFile, LockedDependency
 from apm_cli.drift import build_download_ref, detect_config_drift, detect_orphans, detect_ref_change
 from apm_cli.models.apm_package import DependencyReference
 
@@ -229,6 +230,29 @@ def test_no_host_produces_plain_repo_url(self):
         assert ref.repo_url == "owner/repo"
         assert ref.reference == "abc123def456"
 
+    def test_http_lockfile_restores_insecure_scheme(self):
+        """HTTP deps should restore the locked insecure scheme on replay."""
+        dep = DependencyReference(
+            repo_url="acme/rules",
+            host="git.company.internal",
+            reference=None,
+        )
+        lockfile = LockFile()
+        lockfile.add_dependency(
+            LockedDependency(
+                repo_url="acme/rules",
+                host="git.company.internal",
+                resolved_commit="abc123def456",
+                is_insecure=True,
+                allow_insecure=True,
+            )
+        )
+
+        ref = build_download_ref(dep, lockfile, update_refs=False, ref_changed=False)
+        assert ref.host == "git.company.internal"
+        assert ref.reference == "abc123def456"
+        assert ref.is_insecure is True
+
 
 class TestPreDownloadRefLockfileOverride:
     """Same as TestDownloadRefLockfileOverride but for the parallel pre-download path.
@@ -270,6 +294,29 @@ def test_pre_download_lockfile_override_without_update(self):
         assert ref.reference == "abc123def456"
 
 
+class TestLockedDependencyHttpRoundTrip:
+    """Tests for lockfile preservation of HTTP dependency metadata."""
+
+    def test_to_yaml_round_trip_preserves_http_fields(self):
+        lockfile = LockFile()
+        lockfile.add_dependency(
+            LockedDependency(
+                repo_url="acme/rules",
+                host="git.company.internal",
+                resolved_commit="abc123def456",
+                is_insecure=True,
+                allow_insecure=True,
+            )
+        )
+
+        parsed = LockFile.from_yaml(lockfile.to_yaml())
+        dep = parsed.get_dependency("acme/rules")
+
+        assert dep is not None
+        assert dep.is_insecure is True
+        assert dep.allow_insecure is True
+
+
 class TestRefChangedDetection:
     """Tests for detect_ref_change() in drift.py.
 

From 5128b5ba1c10338748c05b20e8be3195dc521377 Mon Sep 17 00:00:00 2001
From: arika0093 <delete0093@gmail.com>
Date: Tue, 21 Apr 2026 07:02:07 +0000
Subject: [PATCH 02/22] fix: address Sergio review on insecure HTTP
 dependencies (review item 14 stack split)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 src/apm_cli/models/dependency/reference.py | 16 ++++++++++------
 tests/unit/test_canonicalization.py        | 21 +++++++++++++++++++++
 tests/unit/test_install_command.py         |  3 ++-
 3 files changed, 33 insertions(+), 7 deletions(-)

diff --git a/src/apm_cli/models/dependency/reference.py b/src/apm_cli/models/dependency/reference.py
index 3ef00e85e..99e6a907b 100644
--- a/src/apm_cli/models/dependency/reference.py
+++ b/src/apm_cli/models/dependency/reference.py
@@ -506,7 +506,9 @@ def parse_from_dict(cls, entry: dict) -> "DependencyReference":
         sub_path = entry.get("path")
         ref_override = entry.get("ref")
         alias_override = entry.get("alias")
-        allow_insecure = bool(entry.get("allow_insecure", False))
+        allow_insecure = entry.get("allow_insecure", False)
+        if not isinstance(allow_insecure, bool):
+            raise ValueError("'allow_insecure' field must be a boolean")
 
         # Validate sub_path if provided
         if sub_path is not None:
@@ -561,7 +563,7 @@ def _detect_virtual_package(cls, dependency_str: str):
         virtual_path = None
         validated_host = None
 
-        if temp_str.startswith(("git@", "https://", "http://", "ssh://")):
+        if temp_str.lower().startswith(("git@", "https://", "http://", "ssh://")):
             return is_virtual_package, virtual_path, validated_host
 
         check_str = temp_str
@@ -746,7 +748,9 @@ def _parse_standard_url(
         repo_url = repo_part.strip()
 
         # For virtual packages, extract just the owner/repo part (or org/project/repo for ADO)
-        if is_virtual_package and not repo_url.startswith(("https://", "http://")):
+        repo_url_lower = repo_url.lower()
+
+        if is_virtual_package and not repo_url_lower.startswith(("https://", "http://")):
             parts = repo_url.split("/")
 
             if "_git" in parts:
@@ -780,7 +784,7 @@ def _parse_standard_url(
                     repo_url = "/".join(parts[:2])
 
         # Normalize to URL format for secure parsing
-        if repo_url.startswith(("https://", "http://")):
+        if repo_url_lower.startswith(("https://", "http://")):
             parsed_url = urllib.parse.urlparse(repo_url)
             host = parsed_url.hostname or ""
             port = parsed_url.port  # capture :PORT from https://host:8443/...
@@ -1073,7 +1077,7 @@ def parse(cls, dependency_str: str) -> "DependencyReference":
             ado_project=ado_project,
             ado_repo=ado_repo,
             artifactory_prefix=artifactory_prefix,
-            is_insecure=dependency_str.startswith("http://"),
+            is_insecure=urllib.parse.urlparse(dependency_str).scheme.lower() == "http",
         )
 
     def to_apm_yml_entry(self):
@@ -1092,7 +1096,7 @@ def to_apm_yml_entry(self):
                 entry["ref"] = self.reference
             if self.alias:
                 entry["alias"] = self.alias
-            entry["allow_insecure"] = True
+            entry["allow_insecure"] = self.allow_insecure
             return entry
         return self.to_canonical()
 
diff --git a/tests/unit/test_canonicalization.py b/tests/unit/test_canonicalization.py
index 274119656..6cf3acc4e 100644
--- a/tests/unit/test_canonicalization.py
+++ b/tests/unit/test_canonicalization.py
@@ -471,6 +471,11 @@ def test_filter_no_cross_host_match(self):
 class TestHttpInsecureDeps:
     """Tests for HTTP (insecure) dependency parsing and serialization."""
 
+    def test_http_scheme_detection_is_case_insensitive(self):
+        """Parsing an uppercase HTTP scheme still marks the ref as insecure."""
+        dep = DependencyReference.parse("HTTP://my-server.example.com/owner/repo")
+        assert dep.is_insecure is True
+
     def test_http_url_sets_insecure_flag(self):
         """Parsing an http:// URL marks the ref as insecure."""
         dep = DependencyReference.parse("http://my-server.example.com/owner/repo")
@@ -515,6 +520,13 @@ def test_http_to_apm_yml_entry_returns_dict(self):
         assert entry["git"] == "http://my-server.example.com/owner/repo"
         assert entry["allow_insecure"] is True
 
+    def test_http_to_apm_yml_entry_preserves_allow_insecure_false(self):
+        """to_apm_yml_entry() preserves an explicit False opt-in state."""
+        dep = DependencyReference.parse("http://my-server.example.com/owner/repo")
+        entry = dep.to_apm_yml_entry()
+        assert isinstance(entry, dict)
+        assert entry["allow_insecure"] is False
+
     def test_http_to_apm_yml_entry_includes_ref(self):
         """to_apm_yml_entry() includes ref when present."""
         dep = DependencyReference.parse("http://my-server.example.com/owner/repo#v1.0")
@@ -551,6 +563,15 @@ def test_parse_from_dict_git_http_allow_insecure_default_false(self):
         dep = DependencyReference.parse_from_dict(entry)
         assert dep.allow_insecure is False
 
+    def test_parse_from_dict_rejects_non_boolean_allow_insecure(self):
+        """parse_from_dict() rejects non-boolean allow_insecure values."""
+        entry = {
+            "git": "http://my-server.example.com/owner/repo",
+            "allow_insecure": "false",
+        }
+        with pytest.raises(ValueError, match="'allow_insecure' field must be a boolean"):
+            DependencyReference.parse_from_dict(entry)
+
     def test_http_to_github_url_uses_http_scheme(self):
         """to_github_url() uses http:// for HTTP deps."""
         dep = DependencyReference.parse("http://my-server.example.com/owner/repo")
diff --git a/tests/unit/test_install_command.py b/tests/unit/test_install_command.py
index 1197544f3..924cf6574 100644
--- a/tests/unit/test_install_command.py
+++ b/tests/unit/test_install_command.py
@@ -842,7 +842,8 @@ def test_global_rejects_absolute_local_path(self):
                         cli, ["install", "--global", str(local_pkg)]
                     )
 
-                assert "not supported at user scope" in result.output
+                normalized = " ".join(result.output.split())
+                assert "not supported at user scope" in normalized
             finally:
                 os.chdir(self.original_dir)
 

From 3a49c79c6f213f37d407ca225fae305da4132c50 Mon Sep 17 00:00:00 2001
From: arika0093 <delete0093@gmail.com>
Date: Tue, 21 Apr 2026 07:02:56 +0000
Subject: [PATCH 03/22] feat: tighten insecure HTTP dependency safeguards
 (review item 14 stack split)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 CHANGELOG.md                                  |  5 +
 docs/src/content/docs/guides/dependencies.md  | 12 ++-
 .../content/docs/reference/cli-commands.md    | 23 ++---
 .../.apm/skills/apm-usage/commands.md         |  4 +-
 src/apm_cli/commands/deps/cli.py              | 69 ++++++++++---
 src/apm_cli/commands/install.py               | 38 +++++---
 src/apm_cli/config.py                         | 18 ----
 tests/unit/test_deps_list_tree_info.py        | 97 +++++++++++++++++++
 tests/unit/test_install_command.py            | 48 +++------
 9 files changed, 211 insertions(+), 103 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2a468bcd2..04954695c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ### Added
 
+- `apm deps list --insecure` now filters installed dependencies locked to `http://` sources and shows whether each one is direct or transitive (#700)
 - `apm install --ssh` / `--https` flags and `APM_GIT_PROTOCOL=ssh|https` env to pick the initial transport for shorthand dependencies (#778)
 - `apm install --allow-protocol-fallback` flag and `APM_ALLOW_PROTOCOL_FALLBACK=1` env as the migration escape hatch for cross-protocol fallback (#778)
 - Add APM Review Panel skill (`.github/skills/apm-review-panel/`) and four new specialist personas (`devx-ux-expert`, `supply-chain-security-expert`, `apm-ceo`, `oss-growth-hacker`) with auto-activating per-persona skills. Routes specialist findings through an APM CEO arbiter for strategic / breaking-change calls, with the OSS growth hacker side-channeling adoption insights via `WIP/growth-strategy.md`. Instrumentation per Handbook Ch. 9 (`The Instrumented Codebase`); PROSE-compliant (thin SKILL.md routers, persona detail lazy-loaded via markdown links, explicit boundaries per persona).
@@ -25,6 +26,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 - Multi-target support: `apm.yml` `target` field now accepts a list (`target: [claude, copilot]`) and CLI `--target` accepts comma-separated values (`-t claude,copilot`). Only specified targets are compiled, installed, and packed -- no redundant output for unused tools. Single-string syntax is fully backward compatible. (#628)
 
+### Changed
+
+- HTTP dependency installs now require per-run `--allow-insecure` and warn on every insecure fetch (#700)
+
 ### Fixed
 
 - VS Code adapter now defaults to `http` transport when `transport_type` is missing from remote registry data, matching Copilot adapter behavior (#654)
diff --git a/docs/src/content/docs/guides/dependencies.md b/docs/src/content/docs/guides/dependencies.md
index 7614bf2e4..45a9a9f6a 100644
--- a/docs/src/content/docs/guides/dependencies.md
+++ b/docs/src/content/docs/guides/dependencies.md
@@ -152,8 +152,13 @@ Explicit URL schemes are honored exactly -- see [Transport selection](#transport
 :::caution
 Use HTTP dependencies only on trusted private networks. Declare them with
 `git: http://...` and `allow_insecure: true` in `apm.yml`. Installing them
-still requires `apm install --allow-insecure`, unless
-`apm config set allow-insecure true` is enabled globally.
+still requires `apm install --allow-insecure`.
+
+HTTP has no transport authentication, so anyone who can intercept the
+connection can swap the package contents in transit. APM warns on every
+`http://` fetch, asks for extra confirmation before installing transitive
+HTTP dependencies, and requires `--allow-insecure-transitive` in CI or other
+non-interactive environments.
 :::
 
 > **Nested groups (GitLab, Gitea, etc.):** APM treats all path segments after the host as the repo path, so `gitlab.com/group/subgroup/repo` resolves to a repo at `group/subgroup/repo`. Virtual paths on simple 2-segment repos work with shorthand (`gitlab.com/owner/repo/file.prompt.md`). But for **nested-group repos + virtual paths**, use the object format — the shorthand is ambiguous:
@@ -225,6 +230,9 @@ apm install --dry-run
 # List installed packages
 apm deps list
 
+# Show only installed HTTP-backed packages
+apm deps list --insecure
+
 # Show dependency tree
 apm deps tree
 
diff --git a/docs/src/content/docs/reference/cli-commands.md b/docs/src/content/docs/reference/cli-commands.md
index 3b5dca51f..50cc2466e 100644
--- a/docs/src/content/docs/reference/cli-commands.md
+++ b/docs/src/content/docs/reference/cli-commands.md
@@ -74,7 +74,7 @@ apm init my-plugin --plugin
 
 ### `apm install` - Install dependencies and deploy local content
 
-Install APM package and MCP server dependencies from `apm.yml` and deploy the project's own `.apm/` content to target directories (like `npm install`). Auto-creates minimal `apm.yml` when packages are specified but no manifest exists. For `http://` dependencies, use `--allow-insecure` or enable the global `allow-insecure` config.
+Install APM package and MCP server dependencies from `apm.yml` and deploy the project's own `.apm/` content to target directories (like `npm install`). Auto-creates minimal `apm.yml` when packages are specified but no manifest exists. For `http://` dependencies, use `--allow-insecure`.
 
 ```bash
 apm install [PACKAGES...] [OPTIONS]
@@ -113,6 +113,7 @@ See [Dependencies: Transport selection](../../guides/dependencies/#transport-sel
 **Behavior:**
 - `apm install` (no args): Installs **all** packages from `apm.yml` and deploys the project's own `.apm/` content
 - `apm install <package>`: Installs **only** the specified package (adds to `apm.yml` if not present)
+- Each `http://` dependency is warned at install time before any fetch begins
 
 **Local `.apm/` Content Deployment:**
 
@@ -738,6 +739,7 @@ apm deps list [OPTIONS]
 **Options:**
 - `-g, --global` - List user-scope packages from `~/.apm/` instead of the current project
 - `--all` - List packages from both project and user scope
+- `--insecure` - Show only installed dependencies locked to `http://` sources
 
 **Examples:**
 ```bash
@@ -749,6 +751,9 @@ apm deps list -g
 
 # Show both scopes
 apm deps list --all
+
+# Show only insecure installed dependencies
+apm deps list --insecure
 ```
 
 **Sample Output:**
@@ -1432,7 +1437,6 @@ apm config get [KEY]
 - `KEY` (optional) - Configuration key to retrieve. Supported keys:
   - `auto-integrate` - Whether to automatically integrate `.prompt.md` files into AGENTS.md
   - `temp-dir` - Custom temporary directory for clone/download operations
-  - `allow-insecure` - Whether HTTP (insecure) dependencies are allowed globally
 
 If `KEY` is omitted, displays all configuration values.
 
@@ -1441,9 +1445,6 @@ If `KEY` is omitted, displays all configuration values.
 # Get auto-integrate setting
 apm config get auto-integrate
 
-# Get allow-insecure setting
-apm config get allow-insecure
-
 # Show all configuration
 apm config get
 ```
@@ -1460,7 +1461,6 @@ apm config set KEY VALUE
 - `KEY` - Configuration key to set. Supported keys:
   - `auto-integrate` - Enable/disable automatic integration of `.prompt.md` files
   - `temp-dir` - Set a custom temporary directory path
-  - `allow-insecure` - Allow HTTP (insecure) dependencies globally
 - `VALUE` - Value to set. For boolean keys, use: `true`, `false`, `yes`, `no`, `1`, `0`
 
 **Configuration Keys:**
@@ -1473,11 +1473,6 @@ apm config set KEY VALUE
   - Set to `false` if you want to manually manage which prompts are compiled
   - Set to `true` to ensure all prompts are always included in the context
 
-**`allow-insecure`** - Allow HTTP (insecure) dependencies globally
-- **Type:** Boolean
-- **Default:** `false`
-- **Description:** When enabled, APM skips the requirement to pass `--allow-insecure` at install time for HTTP dependencies. The per-dependency `allow_insecure: true` field in apm.yml is still required. Use this for private network environments where all servers use HTTP.
-
 **Examples:**
 ```bash
 # Enable auto-integration (default)
@@ -1485,12 +1480,6 @@ apm config set auto-integrate true
 
 # Disable auto-integration
 apm config set auto-integrate false
-
-# Allow HTTP dependencies globally (skips --allow-insecure flag requirement)
-apm config set allow-insecure true
-
-# Disable globally (default)
-apm config set allow-insecure false
 ```
 
 **`temp-dir`** - Override the system temporary directory
diff --git a/packages/apm-guide/.apm/skills/apm-usage/commands.md b/packages/apm-guide/.apm/skills/apm-usage/commands.md
index 1e0e5730d..c043529c3 100644
--- a/packages/apm-guide/.apm/skills/apm-usage/commands.md
+++ b/packages/apm-guide/.apm/skills/apm-usage/commands.md
@@ -10,10 +10,10 @@
 
 | Command | Purpose | Key flags |
 |---------|---------|-----------|
-| `apm install [PKGS...]` | Install packages | `--update` refresh refs, `--force` overwrite, `--dry-run`, `--verbose`, `--only [apm\|mcp]`, `--target` (comma-separated), `--dev`, `-g` global, `--trust-transitive-mcp`, `--parallel-downloads N`, `--allow-insecure` |
+| `apm install [PKGS...]` | Install packages | `--update` refresh refs, `--force` overwrite, `--dry-run`, `--verbose`, `--only [apm\|mcp]`, `--target` (comma-separated), `--dev`, `-g` global, `--trust-transitive-mcp`, `--parallel-downloads N`, `--allow-insecure`, `--allow-insecure-transitive` |
 | `apm uninstall PKGS...` | Remove packages | `--dry-run`, `-g` global |
 | `apm prune` | Remove orphaned packages | `--dry-run` |
-| `apm deps list` | List installed packages | `-g` global, `--all` both scopes |
+| `apm deps list` | List installed packages | `-g` global, `--all` both scopes, `--insecure` |
 | `apm deps tree` | Show dependency tree | -- |
 | `apm view PKG [FIELD]` | View package details or remote refs | `-g` global, `FIELD=versions` |
 | `apm outdated` | Check locked deps via SHA/semver comparison | `-g` global, `-v` verbose, `-j N` parallel checks |
diff --git a/src/apm_cli/commands/deps/cli.py b/src/apm_cli/commands/deps/cli.py
index 2decd41cf..1f521ddbc 100644
--- a/src/apm_cli/commands/deps/cli.py
+++ b/src/apm_cli/commands/deps/cli.py
@@ -29,11 +29,13 @@ def deps():
     pass
 
 
-def _show_scope_deps(scope_label, apm_dir, logger, console, has_rich):
+def _show_scope_deps(scope_label, apm_dir, logger, console, has_rich, insecure_only=False):
     """Display dependencies for a single scope (Project or Global)."""
     from ...deps.lockfile import LockFile, get_lockfile_path
 
     apm_modules_path = apm_dir / APM_MODULES_DIR
+    lockfile = None
+    insecure_lock_deps = {}
 
     # Check if apm_modules exists
     if not apm_modules_path.exists():
@@ -95,6 +97,8 @@ def _show_scope_deps(scope_label, apm_dir, logger, console, has_rich):
                 dep_key = dep.get_unique_key()
                 if dep_key and dep_key not in declared_sources:
                     declared_sources[dep_key] = 'github'
+                if getattr(dep, "is_insecure", False):
+                    insecure_lock_deps[dep_key] = dep
     except Exception:
         pass  # Continue without lockfile if it can't be read
 
@@ -135,21 +139,32 @@ def _show_scope_deps(scope_label, apm_dir, logger, console, has_rich):
             if is_orphaned:
                 orphaned_packages.append(org_repo_name)
 
+            locked_dep = insecure_lock_deps.get(org_repo_name)
             installed_packages.append({
                 'name': org_repo_name,
                 'version': version,
                 'source': 'orphaned' if is_orphaned else declared_sources.get(org_repo_name, 'github'),
                 'primitives': primitives,
                 'path': str(candidate),
-                'is_orphaned': is_orphaned
+                'is_orphaned': is_orphaned,
+                'is_insecure': locked_dep is not None,
+                'insecure_via': (
+                    f"via {locked_dep.resolved_by}" if locked_dep and locked_dep.resolved_by else "direct"
+                ),
             })
         except Exception as e:
             logger.warning(f"Failed to read package {org_repo_name}: {e}")
 
+    if insecure_only:
+        installed_packages = [pkg for pkg in installed_packages if pkg['is_insecure']]
+
     if not installed_packages:
-        logger.progress(
-            f"apm_modules/ directory exists but contains no valid packages ({scope_label} scope)"
-        )
+        if insecure_only:
+            logger.progress(f"No insecure APM dependencies installed ({scope_label} scope)")
+        else:
+            logger.progress(
+                f"apm_modules/ directory exists but contains no valid packages ({scope_label} scope)"
+            )
         return
 
     # Display packages in table format
@@ -157,13 +172,19 @@ def _show_scope_deps(scope_label, apm_dir, logger, console, has_rich):
         from rich.table import Table
 
         table = Table(
-            title=f" APM Dependencies ({scope_label})",
+            title=(
+                f" Insecure APM Dependencies ({scope_label})"
+                if insecure_only
+                else f" APM Dependencies ({scope_label})"
+            ),
             show_header=True,
             header_style="bold cyan",
         )
         table.add_column("Package", style="bold white")
         table.add_column("Version", style="yellow")
         table.add_column("Source", style="blue")
+        if insecure_only:
+            table.add_column("HTTP", style="bold red")
         table.add_column("Prompts", style="magenta", justify="center")
         table.add_column("Instructions", style="green", justify="center")
         table.add_column("Agents", style="cyan", justify="center")
@@ -176,6 +197,7 @@ def _show_scope_deps(scope_label, apm_dir, logger, console, has_rich):
                 pkg['name'],
                 pkg['version'],
                 pkg['source'],
+                *([pkg['insecure_via']] if insecure_only else []),
                 str(p.get('prompts', 0)) if p.get('prompts', 0) > 0 else "-",
                 str(p.get('instructions', 0)) if p.get('instructions', 0) > 0 else "-",
                 str(p.get('agents', 0)) if p.get('agents', 0) > 0 else "-",
@@ -193,21 +215,36 @@ def _show_scope_deps(scope_label, apm_dir, logger, console, has_rich):
             console.print("\n Run 'apm prune' to remove orphaned packages", style="cyan")
     else:
         # Fallback text table
-        click.echo(f" APM Dependencies ({scope_label}):")
-        click.echo(f"{'Package':<30} {'Version':<10} {'Source':<12} {'Prompts':>7} {'Instr':>7} {'Agents':>7} {'Skills':>7} {'Hooks':>7}")
-        click.echo("-" * 98)
+        if insecure_only:
+            click.echo(f" Insecure APM Dependencies ({scope_label}):")
+            click.echo(
+                f"{'Package':<30} {'Version':<10} {'Source':<12} {'HTTP':<18} "
+                f"{'Prompts':>7} {'Instr':>7} {'Agents':>7} {'Skills':>7} {'Hooks':>7}"
+            )
+            click.echo("-" * 117)
+        else:
+            click.echo(f" APM Dependencies ({scope_label}):")
+            click.echo(f"{'Package':<30} {'Version':<10} {'Source':<12} {'Prompts':>7} {'Instr':>7} {'Agents':>7} {'Skills':>7} {'Hooks':>7}")
+            click.echo("-" * 98)
 
         for pkg in installed_packages:
             p = pkg['primitives']
             name = pkg['name'][:28]
             version = pkg['version'][:8]
             source = pkg['source'][:10]
+            insecure_via = pkg['insecure_via'][:16]
             prompts = str(p.get('prompts', 0)) if p.get('prompts', 0) > 0 else "-"
             instructions = str(p.get('instructions', 0)) if p.get('instructions', 0) > 0 else "-"
             agents = str(p.get('agents', 0)) if p.get('agents', 0) > 0 else "-"
             skills = str(p.get('skills', 0)) if p.get('skills', 0) > 0 else "-"
             hooks = str(p.get('hooks', 0)) if p.get('hooks', 0) > 0 else "-"
-            click.echo(f"{name:<30} {version:<10} {source:<12} {prompts:>7} {instructions:>7} {agents:>7} {skills:>7} {hooks:>7}")
+            if insecure_only:
+                click.echo(
+                    f"{name:<30} {version:<10} {source:<12} {insecure_via:<18} "
+                    f"{prompts:>7} {instructions:>7} {agents:>7} {skills:>7} {hooks:>7}"
+                )
+            else:
+                click.echo(f"{name:<30} {version:<10} {source:<12} {prompts:>7} {instructions:>7} {agents:>7} {skills:>7} {hooks:>7}")
 
         # Show orphaned packages warning
         if orphaned_packages:
@@ -222,7 +259,9 @@ def _show_scope_deps(scope_label, apm_dir, logger, console, has_rich):
               help="List user-scope dependencies (~/.apm/) instead of project")
 @click.option("--all", "show_all", is_flag=True, default=False,
               help="Show both project and user-scope dependencies")
-def list_packages(global_, show_all):
+@click.option("--insecure", "insecure_only", is_flag=True, default=False,
+              help="Show only installed dependencies locked to http:// sources")
+def list_packages(global_, show_all, insecure_only):
     """Show all installed APM dependencies with context files and agent workflows."""
     logger = CommandLogger("deps-list")
 
@@ -242,14 +281,14 @@ def list_packages(global_, show_all):
 
         if show_all:
             # Show both scopes
-            _show_scope_deps("Project", get_apm_dir(InstallScope.PROJECT), logger, console, has_rich)
+            _show_scope_deps("Project", get_apm_dir(InstallScope.PROJECT), logger, console, has_rich, insecure_only=insecure_only)
             if console and has_rich:
                 console.print()  # spacing between tables
-            _show_scope_deps("Global", get_apm_dir(InstallScope.USER), logger, console, has_rich)
+            _show_scope_deps("Global", get_apm_dir(InstallScope.USER), logger, console, has_rich, insecure_only=insecure_only)
         elif global_:
-            _show_scope_deps("Global", get_apm_dir(InstallScope.USER), logger, console, has_rich)
+            _show_scope_deps("Global", get_apm_dir(InstallScope.USER), logger, console, has_rich, insecure_only=insecure_only)
         else:
-            _show_scope_deps("Project", get_apm_dir(InstallScope.PROJECT), logger, console, has_rich)
+            _show_scope_deps("Project", get_apm_dir(InstallScope.PROJECT), logger, console, has_rich, insecure_only=insecure_only)
     except Exception as e:
         logger.error(f"Error listing dependencies: {e}")
         sys.exit(1)
diff --git a/src/apm_cli/commands/install.py b/src/apm_cli/commands/install.py
index 9c26cb307..883d568cd 100644
--- a/src/apm_cli/commands/install.py
+++ b/src/apm_cli/commands/install.py
@@ -110,10 +110,6 @@ def _check_insecure_dependencies(
     deps, allow_insecure_flag: bool, logger=None
 ) -> None:
     """Check APM dependencies for HTTP (insecure) URLs and enforce security policy."""
-    from ..config import get_allow_insecure
-
-    config_allow_insecure = get_allow_insecure()
-
     for dep in deps:
         dep_is_insecure = getattr(dep, "is_insecure", False) is True
         if not dep_is_insecure:
@@ -131,11 +127,10 @@ def _check_insecure_dependencies(
             else:
                 _rich_error(message)
             sys.exit(1)
-        if not (allow_insecure_flag or config_allow_insecure):
+        if not allow_insecure_flag:
             message = (
                 f"Dependency '{identity}' uses HTTP (insecure). "
-                f"Pass '--allow-insecure' to apm install, or run "
-                f"'apm config set allow-insecure true' to allow HTTP dependencies globally."
+                f"Pass '--allow-insecure' to apm install to confirm this install."
             )
             if logger:
                 logger.error(message)
@@ -144,6 +139,20 @@ def _check_insecure_dependencies(
             sys.exit(1)
 
 
+def _warn_insecure_dependencies(deps, logger=None) -> None:
+    """Emit one warning per insecure dependency before fetch begins."""
+    for dep in deps:
+        if getattr(dep, "is_insecure", False) is not True:
+            continue
+        message = f"Fetching insecurely (no transport auth): {dep.to_canonical()}"
+        if logger:
+            logger.warning(message)
+        else:
+            from ..utils.console import _rich_warning
+
+            _rich_warning(message)
+
+
 def _validate_and_add_packages_to_apm_yml(packages, dry_run=False, dev=False, logger=None, manifest_path=None, auth_resolver=None, scope=None, allow_insecure=False):
     """Validate packages exist and can be accessed, then add to apm.yml dependencies section.
 
@@ -167,10 +176,7 @@ def _validate_and_add_packages_to_apm_yml(packages, dry_run=False, dev=False, lo
     import tempfile
     from pathlib import Path
 
-    from ..config import get_allow_insecure
-
     apm_yml_path = manifest_path or Path(APM_YML_FILENAME)
-    config_allow_insecure = get_allow_insecure()
 
     # Read current apm.yml
     try:
@@ -287,11 +293,10 @@ def _validate_and_add_packages_to_apm_yml(packages, dry_run=False, dev=False, lo
             continue
 
         if dep_ref.is_insecure:
-            if not (allow_insecure or config_allow_insecure):
+            if not allow_insecure:
                 reason = (
                     f"'{canonical}' uses HTTP (insecure). "
-                    f"Pass '--allow-insecure' or run "
-                    f"'apm config set allow-insecure true' to allow HTTP dependencies."
+                    f"Pass '--allow-insecure' to allow this install."
                 )
                 invalid_outcomes.append((package, reason))
                 if logger:
@@ -486,9 +491,6 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo
     MCP servers for all detected and available runtimes. It also installs APM package
     dependencies from GitHub repositories.
 
-    HTTP dependencies require `allow_insecure: true` in apm.yml and either
-    `--allow-insecure` or `apm config set allow-insecure true`.
-
     The --only flag filters by dependency type (apm or mcp). Internally converted
     to an InstallMode enum for type-safe dispatch.
 
@@ -614,6 +616,10 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo
         has_any_apm_deps = bool(apm_deps) or bool(dev_apm_deps)
         mcp_deps = apm_package.get_mcp_dependencies()
 
+        all_apm_deps = list(apm_deps) + list(dev_apm_deps)
+        _check_insecure_dependencies(all_apm_deps, allow_insecure, logger=logger)
+        _warn_insecure_dependencies(all_apm_deps, logger=logger)
+
         all_apm_deps = list(apm_deps) + list(dev_apm_deps)
         _check_insecure_dependencies(all_apm_deps, allow_insecure, logger=logger)
 
diff --git a/src/apm_cli/config.py b/src/apm_cli/config.py
index 458eb7c8d..d0f8d93e7 100644
--- a/src/apm_cli/config.py
+++ b/src/apm_cli/config.py
@@ -146,21 +146,3 @@ def get_apm_temp_dir() -> Optional[str]:
     if config_val:
         return config_val
     return None
-
-
-def get_allow_insecure() -> bool:
-    """Get the allow-insecure setting.
-
-    Returns:
-        bool: Whether HTTP (insecure) dependencies are allowed globally (default: False).
-    """
-    return get_config().get("allow_insecure", False)
-
-
-def set_allow_insecure(enabled: bool) -> None:
-    """Set the allow-insecure setting.
-
-    Args:
-        enabled: Whether to allow HTTP (insecure) dependencies globally.
-    """
-    update_config({"allow_insecure": enabled})
diff --git a/tests/unit/test_deps_list_tree_info.py b/tests/unit/test_deps_list_tree_info.py
index f9b14128c..a3fe09a16 100644
--- a/tests/unit/test_deps_list_tree_info.py
+++ b/tests/unit/test_deps_list_tree_info.py
@@ -180,6 +180,103 @@ def test_list_version_shown(self):
         assert result.exit_code == 0
         assert "2.3.1" in result.output
 
+    def test_list_insecure_filters_to_http_locked_deps(self):
+        """--insecure only shows installed packages locked to HTTP sources."""
+        with self._chdir_tmp() as tmp:
+            self._make_package(tmp, "safeorg", "saferepo")
+            self._make_package(tmp, "insecureorg", "insecurerepo")
+
+            secure_dep = MagicMock()
+            secure_dep.get_unique_key.return_value = "safeorg/saferepo"
+            secure_dep.is_insecure = False
+            secure_dep.resolved_by = None
+
+            insecure_dep = MagicMock()
+            insecure_dep.get_unique_key.return_value = "insecureorg/insecurerepo"
+            insecure_dep.is_insecure = True
+            insecure_dep.resolved_by = None
+
+            mock_lockfile = MagicMock()
+            mock_lockfile.dependencies = {
+                "safeorg/saferepo": secure_dep,
+                "insecureorg/insecurerepo": insecure_dep,
+            }
+
+            mock_lf_path = MagicMock()
+            mock_lf_path.exists.return_value = True
+
+            with (
+                patch("apm_cli.core.scope.get_apm_dir", return_value=tmp),
+                patch("apm_cli.deps.lockfile.LockFile.read", return_value=mock_lockfile),
+                patch("apm_cli.deps.lockfile.get_lockfile_path", return_value=mock_lf_path),
+                _force_rich_fallback(),
+            ):
+                result = self.runner.invoke(cli, ["deps", "list", "--insecure"])
+
+        assert result.exit_code == 0
+        assert "insecureorg/insecurerepo" in result.output
+        assert "safeorg/saferepo" not in result.output
+        assert "direct" in result.output
+
+    def test_list_insecure_shows_transitive_provenance(self):
+        """--insecure shows which parent introduced a transitive HTTP dep."""
+        with self._chdir_tmp() as tmp:
+            self._make_package(tmp, "childorg", "childrepo")
+
+            insecure_dep = MagicMock()
+            insecure_dep.get_unique_key.return_value = "childorg/childrepo"
+            insecure_dep.is_insecure = True
+            insecure_dep.resolved_by = "parentorg/parentrepo"
+
+            mock_lockfile = MagicMock()
+            mock_lockfile.dependencies = {
+                "childorg/childrepo": insecure_dep,
+            }
+
+            mock_lf_path = MagicMock()
+            mock_lf_path.exists.return_value = True
+
+            with (
+                patch("apm_cli.core.scope.get_apm_dir", return_value=tmp),
+                patch("apm_cli.deps.lockfile.LockFile.read", return_value=mock_lockfile),
+                patch("apm_cli.deps.lockfile.get_lockfile_path", return_value=mock_lf_path),
+                _force_rich_fallback(),
+            ):
+                result = self.runner.invoke(cli, ["deps", "list", "--insecure"])
+
+        assert result.exit_code == 0
+        assert "childorg/childrepo" in result.output
+        assert "via parentorg/pa" in result.output
+
+    def test_list_insecure_reports_clean_when_no_http_locked_deps(self):
+        """--insecure reports a clean result when no installed dep is insecure."""
+        with self._chdir_tmp() as tmp:
+            self._make_package(tmp, "safeorg", "saferepo")
+
+            secure_dep = MagicMock()
+            secure_dep.get_unique_key.return_value = "safeorg/saferepo"
+            secure_dep.is_insecure = False
+            secure_dep.resolved_by = None
+
+            mock_lockfile = MagicMock()
+            mock_lockfile.dependencies = {
+                "safeorg/saferepo": secure_dep,
+            }
+
+            mock_lf_path = MagicMock()
+            mock_lf_path.exists.return_value = True
+
+            with (
+                patch("apm_cli.core.scope.get_apm_dir", return_value=tmp),
+                patch("apm_cli.deps.lockfile.LockFile.read", return_value=mock_lockfile),
+                patch("apm_cli.deps.lockfile.get_lockfile_path", return_value=mock_lf_path),
+                _force_rich_fallback(),
+            ):
+                result = self.runner.invoke(cli, ["deps", "list", "--insecure"])
+
+        assert result.exit_code == 0
+        assert "No insecure APM dependencies installed" in result.output
+
 
 class TestDepsTreeCommand(_DepsCmdBase):
     """Tests for apm deps tree."""
diff --git a/tests/unit/test_install_command.py b/tests/unit/test_install_command.py
index 924cf6574..5af081444 100644
--- a/tests/unit/test_install_command.py
+++ b/tests/unit/test_install_command.py
@@ -1132,8 +1132,7 @@ def test_http_dep_rejected_without_allow_insecure_flag(self):
         with tempfile.TemporaryDirectory() as tmp_dir:
             try:
                 os.chdir(tmp_dir)
-                with patch("apm_cli.commands.install._validate_package_exists", return_value=True), \
-                     patch("apm_cli.config.get_allow_insecure", return_value=False):
+                with patch("apm_cli.commands.install._validate_package_exists", return_value=True):
                     result = self.runner.invoke(
                         cli, ["install", "http://my-server.example.com/owner/repo"]
                     )
@@ -1153,10 +1152,10 @@ def test_install_help_mentions_allow_insecure_for_http_deps(self):
     @patch("apm_cli.commands.install.APM_DEPS_AVAILABLE", True)
     @patch("apm_cli.commands.install.APMPackage")
     @patch("apm_cli.commands.install._install_apm_dependencies")
-    def test_http_dep_addition_passes_with_config_allow_insecure(
+    def test_http_dep_addition_passes_with_allow_insecure_flag(
         self, mock_install_apm, mock_apm_package, mock_validate
     ):
-        """Global config allows adding an HTTP dependency without the CLI flag."""
+        """HTTP dependency can be added when the CLI flag is passed."""
         with tempfile.TemporaryDirectory() as tmp_dir:
             try:
                 os.chdir(tmp_dir)
@@ -1170,10 +1169,9 @@ def test_http_dep_addition_passes_with_config_allow_insecure(
                     )
                 )
 
-                with patch("apm_cli.config.get_allow_insecure", return_value=True):
-                    result = self.runner.invoke(
-                        cli, ["install", "http://my-server.example.com/owner/repo"]
-                    )
+                result = self.runner.invoke(
+                    cli, ["install", "--allow-insecure", "http://my-server.example.com/owner/repo"]
+                )
 
                 assert result.exit_code == 0
                 with open("apm.yml", encoding="utf-8") as f:
@@ -1196,10 +1194,9 @@ def test_http_dep_validation_check(self):
         dep = DependencyReference.parse("http://my-server.example.com/owner/repo")
         dep.allow_insecure = True
 
-        with patch("apm_cli.config.get_allow_insecure", return_value=False):
-            with pytest.raises(SystemExit) as exc_info:
-                _check_insecure_dependencies([dep], allow_insecure_flag=False)
-            assert exc_info.value.code == 1
+        with pytest.raises(SystemExit) as exc_info:
+            _check_insecure_dependencies([dep], allow_insecure_flag=False)
+        assert exc_info.value.code == 1
 
     def test_http_dep_passes_with_allow_insecure_flag(self):
         """_check_insecure_dependencies passes when flag is set and dep has allow_insecure."""
@@ -1209,19 +1206,7 @@ def test_http_dep_passes_with_allow_insecure_flag(self):
         dep = DependencyReference.parse("http://my-server.example.com/owner/repo")
         dep.allow_insecure = True
 
-        with patch("apm_cli.config.get_allow_insecure", return_value=False):
-            _check_insecure_dependencies([dep], allow_insecure_flag=True)
-
-    def test_http_dep_passes_with_config_allow_insecure(self):
-        """_check_insecure_dependencies passes when global config allow_insecure is true."""
-        from apm_cli.commands.install import _check_insecure_dependencies
-        from apm_cli.models.dependency.reference import DependencyReference
-
-        dep = DependencyReference.parse("http://my-server.example.com/owner/repo")
-        dep.allow_insecure = True
-
-        with patch("apm_cli.config.get_allow_insecure", return_value=True):
-            _check_insecure_dependencies([dep], allow_insecure_flag=False)
+        _check_insecure_dependencies([dep], allow_insecure_flag=True)
 
     def test_http_dep_without_dep_level_allow_insecure_is_blocked(self):
         """_check_insecure_dependencies blocks HTTP dep missing allow_insecure=True on dep."""
@@ -1230,10 +1215,9 @@ def test_http_dep_without_dep_level_allow_insecure_is_blocked(self):
 
         dep = DependencyReference.parse("http://my-server.example.com/owner/repo")
 
-        with patch("apm_cli.config.get_allow_insecure", return_value=False):
-            with pytest.raises(SystemExit) as exc_info:
-                _check_insecure_dependencies([dep], allow_insecure_flag=True)
-            assert exc_info.value.code == 1
+        with pytest.raises(SystemExit) as exc_info:
+            _check_insecure_dependencies([dep], allow_insecure_flag=True)
+        assert exc_info.value.code == 1
 
     def test_https_dep_passes_without_flag(self):
         """_check_insecure_dependencies does not block HTTPS deps."""
@@ -1241,12 +1225,10 @@ def test_https_dep_passes_without_flag(self):
         from apm_cli.models.dependency.reference import DependencyReference
 
         dep = DependencyReference.parse("owner/repo")
-        with patch("apm_cli.config.get_allow_insecure", return_value=False):
-            _check_insecure_dependencies([dep], allow_insecure_flag=False)
+        _check_insecure_dependencies([dep], allow_insecure_flag=False)
 
     def test_empty_deps_list_passes(self):
         """_check_insecure_dependencies handles empty dep list."""
         from apm_cli.commands.install import _check_insecure_dependencies
 
-        with patch("apm_cli.config.get_allow_insecure", return_value=False):
-            _check_insecure_dependencies([], allow_insecure_flag=False)
+        _check_insecure_dependencies([], allow_insecure_flag=False)

From 30353b98c4fb2d8313895f1d33db5f50304c6351 Mon Sep 17 00:00:00 2001
From: arika <Delete0093@Gmail.com>
Date: Mon, 20 Apr 2026 06:16:47 +0000
Subject: [PATCH 04/22] feat: gate transitive insecure dependencies by host
 (review item 14 stack split)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 CHANGELOG.md                                  |   2 +-
 docs/src/content/docs/guides/dependencies.md  |   7 +-
 .../content/docs/reference/cli-commands.md    |   5 +
 .../.apm/skills/apm-usage/commands.md         |   2 +-
 src/apm_cli/commands/install.py               | 211 ++++++++++++++--
 src/apm_cli/install/context.py                |   2 +
 src/apm_cli/install/phases/resolve.py         |  24 ++
 src/apm_cli/install/pipeline.py               |   6 +
 src/apm_cli/install/request.py                |   4 +-
 src/apm_cli/install/service.py                |   2 +
 tests/unit/install/test_service.py            |  12 +
 tests/unit/test_install_command.py            | 230 +++++++++++++++++-
 12 files changed, 475 insertions(+), 32 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 04954695c..1a1f1d7b2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -28,7 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ### Changed
 
-- HTTP dependency installs now require per-run `--allow-insecure` and warn on every insecure fetch (#700)
+- HTTP dependency installs now require per-run `--allow-insecure`, warn on every insecure fetch, automatically allow same-host transitive `http://` sources, and use `--allow-insecure-host` for additional transitive hosts (#700)
 
 ### Fixed
 
diff --git a/docs/src/content/docs/guides/dependencies.md b/docs/src/content/docs/guides/dependencies.md
index 45a9a9f6a..9fc142982 100644
--- a/docs/src/content/docs/guides/dependencies.md
+++ b/docs/src/content/docs/guides/dependencies.md
@@ -156,9 +156,10 @@ still requires `apm install --allow-insecure`.
 
 HTTP has no transport authentication, so anyone who can intercept the
 connection can swap the package contents in transit. APM warns on every
-`http://` fetch, asks for extra confirmation before installing transitive
-HTTP dependencies, and requires `--allow-insecure-transitive` in CI or other
-non-interactive environments.
+`http://` fetch, allows same-host transitive HTTP dependencies when you
+already passed `--allow-insecure` for a direct HTTP dependency on that host,
+and otherwise requires `--allow-insecure-host <hostname>` for each additional
+transitive host you want to allow.
 :::
 
 > **Nested groups (GitLab, Gitea, etc.):** APM treats all path segments after the host as the repo path, so `gitlab.com/group/subgroup/repo` resolves to a repo at `group/subgroup/repo`. Virtual paths on simple 2-segment repos work with shorthand (`gitlab.com/owner/repo/file.prompt.md`). But for **nested-group repos + virtual paths**, use the object format — the shorthand is ambiguous:
diff --git a/docs/src/content/docs/reference/cli-commands.md b/docs/src/content/docs/reference/cli-commands.md
index 50cc2466e..6801f863f 100644
--- a/docs/src/content/docs/reference/cli-commands.md
+++ b/docs/src/content/docs/reference/cli-commands.md
@@ -97,6 +97,7 @@ apm install [PACKAGES...] [OPTIONS]
 - `--dev` - Add packages to [`devDependencies`](../manifest-schema/#5-devdependencies) instead of `dependencies`. Dev deps are installed locally but excluded from `apm pack --format plugin` bundles
 - `-g, --global` - Install to user scope (`~/.apm/`) instead of the current project. Primitives deploy to `~/.copilot/`, `~/.claude/`, etc. MCP servers are only installed for global-capable runtimes (Copilot CLI, Codex CLI); workspace-only runtimes are skipped.
 - `--allow-insecure` - Allow HTTP (insecure) dependencies. Required when adding or installing dependencies that use an `http://` URL.
+- `--allow-insecure-host HOSTNAME` - Allow transitive HTTP (insecure) dependencies from `HOSTNAME`. Repeat the flag to allow multiple hosts.
 - `--ssh` - Force SSH for shorthand (`owner/repo`) dependencies. Mutually exclusive with `--https`. Ignored for URLs with an explicit scheme.
 - `--https` - Force HTTPS for shorthand dependencies. Mutually exclusive with `--ssh`. Default unless `git config url.<base>.insteadOf` rewrites the candidate to SSH.
 - `--allow-protocol-fallback` - Restore the legacy permissive cross-protocol fallback chain (HTTPS-then-SSH or vice-versa). Strict-by-default otherwise. Each retry emits a `[!]` warning naming both protocols.
@@ -114,6 +115,7 @@ See [Dependencies: Transport selection](../../guides/dependencies/#transport-sel
 - `apm install` (no args): Installs **all** packages from `apm.yml` and deploys the project's own `.apm/` content
 - `apm install <package>`: Installs **only** the specified package (adds to `apm.yml` if not present)
 - Each `http://` dependency is warned at install time before any fetch begins
+- Transitive `http://` dependencies are allowed automatically when they use the same host as a direct insecure dependency you approved with `--allow-insecure`; other transitive hosts require `--allow-insecure-host HOSTNAME`
 
 **Local `.apm/` Content Deployment:**
 
@@ -191,6 +193,9 @@ apm install /home/user/repos/my-ai-package
 # Install to user scope (available across all projects)
 apm install -g microsoft/apm-sample-package
 
+# Allow an additional transitive HTTP host
+apm install --allow-insecure-host mirror.example.com
+
 # Install a plugin from a registered marketplace
 apm install code-review@acme-plugins
 
diff --git a/packages/apm-guide/.apm/skills/apm-usage/commands.md b/packages/apm-guide/.apm/skills/apm-usage/commands.md
index c043529c3..b0866e597 100644
--- a/packages/apm-guide/.apm/skills/apm-usage/commands.md
+++ b/packages/apm-guide/.apm/skills/apm-usage/commands.md
@@ -10,7 +10,7 @@
 
 | Command | Purpose | Key flags |
 |---------|---------|-----------|
-| `apm install [PKGS...]` | Install packages | `--update` refresh refs, `--force` overwrite, `--dry-run`, `--verbose`, `--only [apm\|mcp]`, `--target` (comma-separated), `--dev`, `-g` global, `--trust-transitive-mcp`, `--parallel-downloads N`, `--allow-insecure`, `--allow-insecure-transitive` |
+| `apm install [PKGS...]` | Install packages | `--update` refresh refs, `--force` overwrite, `--dry-run`, `--verbose`, `--only [apm\|mcp]`, `--target` (comma-separated), `--dev`, `-g` global, `--trust-transitive-mcp`, `--parallel-downloads N`, `--allow-insecure`, `--allow-insecure-host HOSTNAME` |
 | `apm uninstall PKGS...` | Remove packages | `--dry-run`, `-g` global |
 | `apm prune` | Remove orphaned packages | `--dry-run` |
 | `apm deps list` | List installed packages | `-g` global, `--all` both scopes, `--insecure` |
diff --git a/src/apm_cli/commands/install.py b/src/apm_cli/commands/install.py
index 883d568cd..c95bff531 100644
--- a/src/apm_cli/commands/install.py
+++ b/src/apm_cli/commands/install.py
@@ -2,6 +2,8 @@
 
 import builtins
 import sys
+import urllib.parse
+from dataclasses import dataclass
 from pathlib import Path
 from typing import List, Optional
 
@@ -106,10 +108,175 @@
 # ---------------------------------------------------------------------------
 
 
+@dataclass(frozen=True)
+class _InsecureDependencyInfo:
+    """Resolved insecure dependency details for warnings and consent checks."""
+
+    url: str
+    is_transitive: bool
+    introduced_by: str | None = None
+
+
+def _collect_insecure_dependency_infos(
+    deps_to_install, dependency_graph
+) -> list[_InsecureDependencyInfo]:
+    """Collect insecure dependency details from the resolved install set."""
+    insecure_infos: list[_InsecureDependencyInfo] = []
+    tree = dependency_graph.dependency_tree
+
+    for dep in deps_to_install:
+        if getattr(dep, "is_insecure", False) is not True:
+            continue
+
+        node = tree.get_node(dep.get_unique_key()) if tree else None
+        parent = node.parent if node else None
+        insecure_infos.append(
+            _InsecureDependencyInfo(
+                url=dep.to_canonical(),
+                is_transitive=parent is not None,
+                introduced_by=(
+                    parent.dependency_ref.get_display_name()
+                    if parent is not None
+                    else None
+                ),
+            )
+        )
+
+    return insecure_infos
+
+
+def _format_insecure_dependency_warning(info: _InsecureDependencyInfo) -> str:
+    """Render the install-time warning text for an insecure dependency."""
+    message = f"Fetching insecurely (no transport auth): {info.url}"
+    if info.is_transitive and info.introduced_by:
+        message = (
+            f"{message} (transitive, introduced by {info.introduced_by})"
+        )
+    return message
+
+
+def _warn_insecure_dependencies(insecure_infos, logger=None) -> None:
+    """Emit one warning per insecure dependency before fetch begins."""
+    for info in insecure_infos:
+        message = _format_insecure_dependency_warning(info)
+        if logger:
+            logger.warning(message)
+        else:
+            from ..utils.console import _rich_warning
+
+            _rich_warning(message)
+
+
+def _normalize_allow_insecure_host(hostname: str) -> str:
+    """Validate and normalize a hostname passed via --allow-insecure-host."""
+    normalized = hostname.strip().lower()
+    if not is_valid_fqdn(normalized):
+        raise ValueError(
+            f"Invalid hostname '{hostname}'. Use a bare hostname like 'mirror.example.com'."
+        )
+    return normalized
+
+
+def _allow_insecure_host_callback(ctx, param, value):
+    """Normalize repeatable --allow-insecure-host values for Click."""
+    normalized_hosts = []
+    seen_hosts = set()
+    for raw_host in value or ():
+        try:
+            normalized = _normalize_allow_insecure_host(raw_host)
+        except ValueError as exc:
+            raise click.BadParameter(str(exc))
+        if normalized not in seen_hosts:
+            seen_hosts.add(normalized)
+            normalized_hosts.append(normalized)
+    return tuple(normalized_hosts)
+
+
+def _get_insecure_dependency_host(info: _InsecureDependencyInfo) -> str | None:
+    """Extract the hostname from an insecure dependency warning record."""
+    parsed = urllib.parse.urlparse(info.url)
+    return parsed.hostname.lower() if parsed.hostname else None
+
+
+def _get_allowed_transitive_insecure_hosts(
+    insecure_infos,
+    *,
+    allow_insecure: bool,
+    allow_insecure_hosts,
+) -> set[str]:
+    """Build the hostname allowlist for transitive insecure dependencies."""
+    allowed_hosts = set(allow_insecure_hosts)
+    if not allow_insecure:
+        return allowed_hosts
+
+    for info in insecure_infos:
+        if info.is_transitive:
+            continue
+        host = _get_insecure_dependency_host(info)
+        if host:
+            allowed_hosts.add(host)
+    return allowed_hosts
+
+
+def _guard_transitive_insecure_dependencies(
+    insecure_infos,
+    *,
+    allow_insecure: bool,
+    allow_insecure_hosts=(),
+    logger=None,
+) -> None:
+    """Block transitive insecure dependencies from unapproved hosts."""
+    transitive_infos = [info for info in insecure_infos if info.is_transitive]
+    if not transitive_infos:
+        return
+
+    allowed_hosts = _get_allowed_transitive_insecure_hosts(
+        insecure_infos,
+        allow_insecure=allow_insecure,
+        allow_insecure_hosts=allow_insecure_hosts,
+    )
+    blocked_hosts = sorted(
+        {
+            host
+            for host in (
+                _get_insecure_dependency_host(info) for info in transitive_infos
+            )
+            if host and host not in allowed_hosts
+        }
+    )
+    if not blocked_hosts:
+        return
+
+    suggested_flags = " ".join(
+        f"--allow-insecure-host {host}" for host in blocked_hosts
+    )
+    message = (
+        "Transitive HTTP (insecure) dependencies were found on unapproved host(s): "
+        f"{', '.join(blocked_hosts)}. "
+        "--allow-insecure only covers direct HTTP dependencies and transitive "
+        "HTTP dependencies on the same host. "
+        f"Re-run with {suggested_flags} to allow these transitive hosts."
+    )
+    if logger:
+        logger.error(message)
+    else:
+        _rich_error(message)
+    sys.exit(1)
+
+
 def _check_insecure_dependencies(
     deps, allow_insecure_flag: bool, logger=None
 ) -> None:
-    """Check APM dependencies for HTTP (insecure) URLs and enforce security policy."""
+    """Check direct APM dependencies for HTTP (insecure) URLs and enforce policy.
+
+    Two conditions must BOTH be true for an HTTP dep to be allowed:
+    1. The dep entry in apm.yml must have allow_insecure: true
+    2. --allow-insecure must be set for this install invocation
+
+    Args:
+        deps: List of DependencyReference objects to check.
+        allow_insecure_flag: True if --allow-insecure was passed on the command line.
+    """
     for dep in deps:
         dep_is_insecure = getattr(dep, "is_insecure", False) is True
         if not dep_is_insecure:
@@ -139,20 +306,6 @@ def _check_insecure_dependencies(
             sys.exit(1)
 
 
-def _warn_insecure_dependencies(deps, logger=None) -> None:
-    """Emit one warning per insecure dependency before fetch begins."""
-    for dep in deps:
-        if getattr(dep, "is_insecure", False) is not True:
-            continue
-        message = f"Fetching insecurely (no transport auth): {dep.to_canonical()}"
-        if logger:
-            logger.warning(message)
-        else:
-            from ..utils.console import _rich_warning
-
-            _rich_warning(message)
-
-
 def _validate_and_add_packages_to_apm_yml(packages, dry_run=False, dev=False, logger=None, manifest_path=None, auth_resolver=None, scope=None, allow_insecure=False):
     """Validate packages exist and can be accessed, then add to apm.yml dependencies section.
 
@@ -456,6 +609,14 @@ def _validate_and_add_packages_to_apm_yml(packages, dry_run=False, dev=False, lo
     default=False,
     help="Allow HTTP (insecure) dependencies. Required when dependencies use http:// URLs.",
 )
+@click.option(
+    "--allow-insecure-host",
+    "allow_insecure_hosts",
+    multiple=True,
+    callback=_allow_insecure_host_callback,
+    metavar="HOSTNAME",
+    help="Allow transitive HTTP (insecure) dependencies from this hostname. Repeat for multiple hosts.",
+)
 @click.option(
     "--global", "-g", "global_",
     is_flag=True,
@@ -484,13 +645,18 @@ def _validate_and_add_packages_to_apm_yml(packages, dry_run=False, dev=False, lo
     help="Restore the legacy permissive cross-protocol fallback chain (escape hatch for migrating users; also: APM_ALLOW_PROTOCOL_FALLBACK=1).",
 )
 @click.pass_context
-def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbose, trust_transitive_mcp, parallel_downloads, dev, target, allow_insecure, global_, use_ssh, use_https, allow_protocol_fallback):
+def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbose, trust_transitive_mcp, parallel_downloads, dev, target, allow_insecure, allow_insecure_hosts, global_, use_ssh, use_https, allow_protocol_fallback):
     """Install APM and MCP dependencies from apm.yml (like npm install).
 
     This command automatically detects AI runtimes from your apm.yml scripts and installs
     MCP servers for all detected and available runtimes. It also installs APM package
     dependencies from GitHub repositories.
 
+    HTTP dependencies require `allow_insecure: true` in apm.yml and
+    `--allow-insecure` on the install command. Transitive HTTP dependencies are
+    allowed automatically when they stay on the same host as a direct HTTP
+    dependency, or explicitly with `--allow-insecure-host <hostname>`.
+
     The --only flag filters by dependency type (apm or mcp). Internally converted
     to an InstallMode enum for type-safe dispatch.
 
@@ -505,6 +671,7 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo
         apm install --dry-run                   # Show what would be installed
         apm install -g org/pkg1                 # Install to user scope (~/.apm/)
         apm install --allow-insecure http://my-server.example.com/owner/repo
+        apm install --allow-insecure-host mirror.example.com
     """
     try:
         # Create structured logger for install output early so exception
@@ -616,10 +783,6 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo
         has_any_apm_deps = bool(apm_deps) or bool(dev_apm_deps)
         mcp_deps = apm_package.get_mcp_dependencies()
 
-        all_apm_deps = list(apm_deps) + list(dev_apm_deps)
-        _check_insecure_dependencies(all_apm_deps, allow_insecure, logger=logger)
-        _warn_insecure_dependencies(all_apm_deps, logger=logger)
-
         all_apm_deps = list(apm_deps) + list(dev_apm_deps)
         _check_insecure_dependencies(all_apm_deps, allow_insecure, logger=logger)
 
@@ -701,6 +864,8 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo
                     scope=scope,
                     auth_resolver=auth_resolver,
                     target=target,
+                    allow_insecure=allow_insecure,
+                    allow_insecure_hosts=allow_insecure_hosts,
                     marketplace_provenance=(
                         outcome.marketplace_provenance if packages and outcome else None
                     ),
@@ -845,6 +1010,8 @@ def _install_apm_dependencies(
     scope=None,
     auth_resolver: "AuthResolver" = None,
     target: str = None,
+    allow_insecure: bool = False,
+    allow_insecure_hosts=(),
     marketplace_provenance: dict = None,
     protocol_pref=None,
     allow_protocol_fallback: "Optional[bool]" = None,
@@ -874,10 +1041,10 @@ def _install_apm_dependencies(
         scope=scope,
         auth_resolver=auth_resolver,
         target=target,
+        allow_insecure=allow_insecure,
+        allow_insecure_hosts=allow_insecure_hosts,
         marketplace_provenance=marketplace_provenance,
         protocol_pref=protocol_pref,
         allow_protocol_fallback=allow_protocol_fallback,
     )
     return InstallService().run(request)
-
-
diff --git a/src/apm_cli/install/context.py b/src/apm_cli/install/context.py
index 155c20f62..d83767408 100644
--- a/src/apm_cli/install/context.py
+++ b/src/apm_cli/install/context.py
@@ -46,6 +46,8 @@ class InstallContext:
     parallel_downloads: int = 4
     logger: Any = None  # InstallLogger
     target_override: Optional[str] = None  # CLI --target value
+    allow_insecure: bool = False
+    allow_insecure_hosts: Tuple[str, ...] = ()
 
     dry_run: bool = False
     force: bool = False
diff --git a/src/apm_cli/install/phases/resolve.py b/src/apm_cli/install/phases/resolve.py
index adce119d2..7fd4c4a7a 100644
--- a/src/apm_cli/install/phases/resolve.py
+++ b/src/apm_cli/install/phases/resolve.py
@@ -297,6 +297,30 @@ def _collect_descendants(node, visited=None):
             if dep.get_identity() in only_identities
         ]
 
+    from apm_cli.commands.install import (
+        _check_insecure_dependencies,
+        _collect_insecure_dependency_infos,
+        _guard_transitive_insecure_dependencies,
+        _warn_insecure_dependencies,
+    )
+
+    _check_insecure_dependencies(
+        ctx.all_apm_deps,
+        ctx.allow_insecure,
+        logger=ctx.logger,
+    )
+    insecure_infos = _collect_insecure_dependency_infos(
+        deps_to_install,
+        dependency_graph,
+    )
+    _warn_insecure_dependencies(insecure_infos, logger=ctx.logger)
+    _guard_transitive_insecure_dependencies(
+        insecure_infos,
+        allow_insecure=ctx.allow_insecure,
+        allow_insecure_hosts=ctx.allow_insecure_hosts,
+        logger=ctx.logger,
+    )
+
     ctx.deps_to_install = deps_to_install
 
     # ------------------------------------------------------------------
diff --git a/src/apm_cli/install/pipeline.py b/src/apm_cli/install/pipeline.py
index 7a8ba79b1..da8e8b5f8 100644
--- a/src/apm_cli/install/pipeline.py
+++ b/src/apm_cli/install/pipeline.py
@@ -53,6 +53,8 @@ def run_install_pipeline(
     scope=None,
     auth_resolver: "AuthResolver" = None,
     target: str = None,
+    allow_insecure: bool = False,
+    allow_insecure_hosts=(),
     marketplace_provenance: dict = None,
     protocol_pref=None,
     allow_protocol_fallback: "Optional[bool]" = None,
@@ -81,6 +83,8 @@ def run_install_pipeline(
         scope: InstallScope controlling project vs user deployment
         auth_resolver: Shared auth resolver for caching credentials
         target: Explicit target override from --target CLI flag
+        allow_insecure: Whether direct HTTP dependencies are approved
+        allow_insecure_hosts: Extra approved hosts for transitive HTTP dependencies
         marketplace_provenance: Marketplace provenance data for packages
     """
     # Late import: the ``APM_DEPS_AVAILABLE`` guard in commands/install.py
@@ -137,6 +141,8 @@ def run_install_pipeline(
         scope=scope,
         auth_resolver=auth_resolver,
         target_override=target,
+        allow_insecure=allow_insecure,
+        allow_insecure_hosts=allow_insecure_hosts,
         marketplace_provenance=marketplace_provenance,
         protocol_pref=protocol_pref,
         allow_protocol_fallback=allow_protocol_fallback,
diff --git a/src/apm_cli/install/request.py b/src/apm_cli/install/request.py
index 0d77e9b20..6aa85df45 100644
--- a/src/apm_cli/install/request.py
+++ b/src/apm_cli/install/request.py
@@ -9,7 +9,7 @@
 from __future__ import annotations
 
 from dataclasses import dataclass
-from typing import TYPE_CHECKING, Any, Dict, List, Optional
+from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
 
 if TYPE_CHECKING:
     from apm_cli.core.auth import AuthResolver
@@ -36,6 +36,8 @@ class InstallRequest:
     scope: Optional["InstallScope"] = None
     auth_resolver: Optional["AuthResolver"] = None
     target: Optional[str] = None
+    allow_insecure: bool = False
+    allow_insecure_hosts: Tuple[str, ...] = ()
     marketplace_provenance: Optional[Dict[str, Any]] = None
     protocol_pref: Any = None  # ProtocolPreference (NONE/SSH/HTTPS) for shorthand transport
     allow_protocol_fallback: Optional[bool] = None  # None => read APM_ALLOW_PROTOCOL_FALLBACK env
diff --git a/src/apm_cli/install/service.py b/src/apm_cli/install/service.py
index e96aeb4c0..dc6de38e0 100644
--- a/src/apm_cli/install/service.py
+++ b/src/apm_cli/install/service.py
@@ -71,6 +71,8 @@ def run(self, request: InstallRequest) -> "InstallResult":
             scope=request.scope,
             auth_resolver=request.auth_resolver,
             target=request.target,
+            allow_insecure=request.allow_insecure,
+            allow_insecure_hosts=request.allow_insecure_hosts,
             marketplace_provenance=request.marketplace_provenance,
             protocol_pref=request.protocol_pref,
             allow_protocol_fallback=request.allow_protocol_fallback,
diff --git a/tests/unit/install/test_service.py b/tests/unit/install/test_service.py
index 26de2b134..8dd7e8fcb 100644
--- a/tests/unit/install/test_service.py
+++ b/tests/unit/install/test_service.py
@@ -34,6 +34,8 @@ def _make_request(pkg, **overrides):
         scope=None,
         auth_resolver=None,
         target=None,
+        allow_insecure=False,
+        allow_insecure_hosts=(),
         marketplace_provenance=None,
     )
     base.update(overrides)
@@ -53,6 +55,8 @@ def test_request_defaults(self, fake_apm_package):
         assert request.parallel_downloads == 4
         assert request.only_packages is None
         assert request.target is None
+        assert request.allow_insecure is False
+        assert request.allow_insecure_hosts == ()
 
     def test_only_packages_is_shallow_immutable(self, fake_apm_package):
         # Documents the known limitation: frozen=True locks the
@@ -72,6 +76,8 @@ def test_run_delegates_to_pipeline_with_request_fields(self, fake_apm_package):
             force=True,
             parallel_downloads=8,
             target="copilot",
+            allow_insecure=True,
+            allow_insecure_hosts=("mirror.example.com",),
             only_packages=["alpha", "beta"],
             marketplace_provenance={"source": "test-marketplace"},
         )
@@ -88,6 +94,8 @@ def test_run_delegates_to_pipeline_with_request_fields(self, fake_apm_package):
         assert kwargs["force"] is True
         assert kwargs["parallel_downloads"] == 8
         assert kwargs["target"] == "copilot"
+        assert kwargs["allow_insecure"] is True
+        assert kwargs["allow_insecure_hosts"] == ("mirror.example.com",)
         assert kwargs["only_packages"] == ["alpha", "beta"]
         assert kwargs["marketplace_provenance"] == {"source": "test-marketplace"}
 
@@ -129,6 +137,8 @@ def test_install_apm_dependencies_builds_request_and_uses_service(
                 force=True,
                 parallel_downloads=2,
                 target="claude",
+                allow_insecure=True,
+                allow_insecure_hosts=("mirror.example.com",),
             )
 
         assert result == "wrapped-result"
@@ -140,3 +150,5 @@ def test_install_apm_dependencies_builds_request_and_uses_service(
         assert request.force is True
         assert request.parallel_downloads == 2
         assert request.target == "claude"
+        assert request.allow_insecure is True
+        assert request.allow_insecure_hosts == ("mirror.example.com",)
diff --git a/tests/unit/test_install_command.py b/tests/unit/test_install_command.py
index 5af081444..ae749c86d 100644
--- a/tests/unit/test_install_command.py
+++ b/tests/unit/test_install_command.py
@@ -1,14 +1,16 @@
 """Tests for the apm install command auto-bootstrap feature."""
 
 import contextlib
-import pytest
-import tempfile
 import os
-import yaml
+import tempfile
+import types
 from pathlib import Path
-from click.testing import CliRunner
 from unittest.mock import patch, MagicMock
 
+import pytest
+import yaml
+from click.testing import CliRunner
+
 from apm_cli.models.results import InstallResult
 
 from apm_cli.cli import cli
@@ -1147,6 +1149,22 @@ def test_install_help_mentions_allow_insecure_for_http_deps(self):
         assert result.exit_code == 0
         normalized = " ".join(result.output.split())
         assert "use --allow-insecure for http:// packages" in normalized
+        assert "--allow-insecure-host HOSTNAME" in result.output
+
+    def test_allow_insecure_host_rejects_non_hostname(self):
+        """The explicit transitive host option only accepts bare hostnames."""
+        result = self.runner.invoke(
+            cli,
+            [
+                "install",
+                "--allow-insecure-host",
+                "https://mirror.example.com",
+                "owner/repo",
+            ],
+        )
+
+        assert result.exit_code != 0
+        assert "Invalid value for '--allow-insecure-host'" in result.output
 
     @patch("apm_cli.commands.install._validate_package_exists", return_value=True)
     @patch("apm_cli.commands.install.APM_DEPS_AVAILABLE", True)
@@ -1183,6 +1201,48 @@ def test_http_dep_addition_passes_with_allow_insecure_flag(
                         "allow_insecure": True,
                     }
                 ]
+                assert mock_install_apm.call_args.kwargs["allow_insecure"] is True
+                assert mock_install_apm.call_args.kwargs["allow_insecure_hosts"] == ()
+            finally:
+                os.chdir(self.original_dir)
+
+    @patch("apm_cli.commands.install._validate_package_exists", return_value=True)
+    @patch("apm_cli.commands.install.APM_DEPS_AVAILABLE", True)
+    @patch("apm_cli.commands.install.APMPackage")
+    @patch("apm_cli.commands.install._install_apm_dependencies")
+    def test_allow_insecure_host_is_passed_to_install_engine(
+        self, mock_install_apm, mock_apm_package, mock_validate
+    ):
+        """The explicit transitive host option is threaded into the install engine."""
+        with tempfile.TemporaryDirectory() as tmp_dir:
+            try:
+                os.chdir(tmp_dir)
+                mock_pkg_instance = MagicMock()
+                mock_pkg_instance.get_apm_dependencies.return_value = [MagicMock()]
+                mock_pkg_instance.get_mcp_dependencies.return_value = []
+                mock_pkg_instance.get_dev_apm_dependencies.return_value = []
+                mock_apm_package.from_apm_yml.return_value = mock_pkg_instance
+                mock_install_apm.return_value = InstallResult(
+                    diagnostics=MagicMock(
+                        has_diagnostics=False, has_critical_security=False
+                    )
+                )
+
+                result = self.runner.invoke(
+                    cli,
+                    [
+                        "install",
+                        "--allow-insecure-host",
+                        "mirror.example.com",
+                        "owner/repo",
+                    ],
+                )
+
+                assert result.exit_code == 0
+                assert (
+                    mock_install_apm.call_args.kwargs["allow_insecure_hosts"]
+                    == ("mirror.example.com",)
+                )
             finally:
                 os.chdir(self.original_dir)
 
@@ -1232,3 +1292,165 @@ def test_empty_deps_list_passes(self):
         from apm_cli.commands.install import _check_insecure_dependencies
 
         _check_insecure_dependencies([], allow_insecure_flag=False)
+
+
+class TestInsecureDependencyWarnings:
+    """Tests for install-time insecure dependency warnings."""
+
+    def test_collect_insecure_dependency_infos_marks_direct_dependency(self):
+        """Direct HTTP dependencies are collected without parent provenance."""
+        from apm_cli.commands.install import (
+            _InsecureDependencyInfo,
+            _collect_insecure_dependency_infos,
+        )
+        from apm_cli.models.dependency.reference import DependencyReference
+
+        dep = DependencyReference.parse("http://mirror.example.com/acme/rules")
+        tree = MagicMock()
+        tree.get_node.return_value = None
+        graph = MagicMock(dependency_tree=tree)
+
+        infos = _collect_insecure_dependency_infos([dep], graph)
+
+        assert infos == [
+            _InsecureDependencyInfo(
+                url="http://mirror.example.com/acme/rules",
+                is_transitive=False,
+                introduced_by=None,
+            )
+        ]
+
+    def test_collect_insecure_dependency_infos_marks_transitive_dependency(self):
+        """Transitive HTTP dependencies carry introducer information."""
+        from apm_cli.commands.install import _collect_insecure_dependency_infos
+        from apm_cli.models.dependency.reference import DependencyReference
+
+        dep = DependencyReference.parse("http://mirror.example.com/acme/transitive")
+        parent_ref = DependencyReference.parse("owner/root-package")
+        parent_node = types.SimpleNamespace(dependency_ref=parent_ref)
+        node = types.SimpleNamespace(parent=parent_node)
+        tree = MagicMock()
+        tree.get_node.return_value = node
+        graph = MagicMock(dependency_tree=tree)
+
+        infos = _collect_insecure_dependency_infos([dep], graph)
+
+        assert len(infos) == 1
+        assert infos[0].url == "http://mirror.example.com/acme/transitive"
+        assert infos[0].is_transitive is True
+        assert infos[0].introduced_by == "owner/root-package"
+
+    def test_format_insecure_dependency_warning_for_transitive_dep(self):
+        """Transitive warning strings include the introducer."""
+        from apm_cli.commands.install import (
+            _InsecureDependencyInfo,
+            _format_insecure_dependency_warning,
+        )
+
+        message = _format_insecure_dependency_warning(
+            _InsecureDependencyInfo(
+                url="http://mirror.example.com/acme/transitive",
+                is_transitive=True,
+                introduced_by="owner/root-package",
+            )
+        )
+
+        assert "Fetching insecurely (no transport auth)" in message
+        assert "http://mirror.example.com/acme/transitive" in message
+        assert "transitive, introduced by owner/root-package" in message
+
+
+class TestTransitiveInsecureDependencyGuard:
+    """Tests for host-based consent around transitive insecure dependencies."""
+
+    def test_transitive_guard_blocks_unapproved_host(self):
+        """Transitive insecure deps are blocked when their host is not approved."""
+        from apm_cli.commands.install import (
+            _InsecureDependencyInfo,
+            _guard_transitive_insecure_dependencies,
+        )
+
+        with pytest.raises(SystemExit) as exc_info:
+            _guard_transitive_insecure_dependencies(
+                [
+                    _InsecureDependencyInfo(
+                        url="http://mirror.example.com/acme/transitive",
+                        is_transitive=True,
+                        introduced_by="owner/root-package",
+                    )
+                ],
+                allow_insecure=False,
+                allow_insecure_hosts=(),
+            )
+
+        assert exc_info.value.code == 1
+
+    def test_transitive_guard_allows_same_host_as_direct_insecure_dependency(self):
+        """A direct insecure dependency host also permits transitive deps on that host."""
+        from apm_cli.commands.install import (
+            _InsecureDependencyInfo,
+            _guard_transitive_insecure_dependencies,
+        )
+
+        _guard_transitive_insecure_dependencies(
+            [
+                _InsecureDependencyInfo(
+                    url="http://mirror.example.com/acme/direct",
+                    is_transitive=False,
+                    introduced_by=None,
+                ),
+                _InsecureDependencyInfo(
+                    url="http://mirror.example.com/acme/transitive",
+                    is_transitive=True,
+                    introduced_by="owner/root-package",
+                ),
+            ],
+            allow_insecure=True,
+            allow_insecure_hosts=(),
+        )
+
+    def test_transitive_guard_accepts_explicit_host(self):
+        """Explicitly allowed hosts permit transitive insecure dependencies."""
+        from apm_cli.commands.install import (
+            _InsecureDependencyInfo,
+            _guard_transitive_insecure_dependencies,
+        )
+
+        _guard_transitive_insecure_dependencies(
+            [
+                _InsecureDependencyInfo(
+                    url="http://mirror.example.com/acme/transitive",
+                    is_transitive=True,
+                    introduced_by="owner/root-package",
+                )
+            ],
+            allow_insecure=False,
+            allow_insecure_hosts=("mirror.example.com",),
+        )
+
+    def test_transitive_guard_blocks_different_host_without_explicit_allowance(self):
+        """A direct insecure host does not permit transitive deps on other hosts."""
+        from apm_cli.commands.install import (
+            _InsecureDependencyInfo,
+            _guard_transitive_insecure_dependencies,
+        )
+
+        with pytest.raises(SystemExit) as exc_info:
+            _guard_transitive_insecure_dependencies(
+                [
+                    _InsecureDependencyInfo(
+                        url="http://my-server.example.com/acme/direct",
+                        is_transitive=False,
+                        introduced_by=None,
+                    ),
+                    _InsecureDependencyInfo(
+                        url="http://mirror.example.com/acme/transitive",
+                        is_transitive=True,
+                        introduced_by="owner/root-package",
+                    ),
+                ],
+                allow_insecure=True,
+                allow_insecure_hosts=(),
+            )
+
+        assert exc_info.value.code == 1

From 036c599cbef6f5be3a13aefaabb8a279fcf68fbd Mon Sep 17 00:00:00 2001
From: arika0093 <delete0093@gmail.com>
Date: Tue, 21 Apr 2026 07:02:56 +0000
Subject: [PATCH 05/22] feat: refine HTTP dependency transport and safeguards
 (review item 14 stack split)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 docs/src/content/docs/guides/dependencies.md  |  2 +-
 .../content/docs/reference/cli-commands.md    |  3 -
 .../.apm/skills/apm-usage/dependencies.md     |  2 +-
 src/apm_cli/commands/install.py               |  4 +-
 src/apm_cli/deps/transport_selection.py       | 85 +++++++++++--------
 src/apm_cli/install/validation.py             | 14 ++-
 src/apm_cli/models/dependency/reference.py    |  2 +-
 tests/unit/test_install_command.py            | 21 +++++
 tests/unit/test_transport_selection.py        | 55 ++++++++++--
 9 files changed, 138 insertions(+), 50 deletions(-)

diff --git a/docs/src/content/docs/guides/dependencies.md b/docs/src/content/docs/guides/dependencies.md
index 9fc142982..f05fc13d8 100644
--- a/docs/src/content/docs/guides/dependencies.md
+++ b/docs/src/content/docs/guides/dependencies.md
@@ -460,7 +460,7 @@ migrating CI), set `APM_ALLOW_PROTOCOL_FALLBACK=1` or pass
 | Dependency form | What APM tries |
 |-----------------|----------------|
 | `ssh://...` or `git@host:...` | SSH only |
-| `https://...` or `http://...` | HTTPS only |
+| `https://...` or `http://...` | HTTP(S) only |
 | Shorthand (`owner/repo`, `host/owner/repo`) with `git config url.<base>.insteadOf` rewriting to SSH | SSH only |
 | Shorthand without a matching `insteadOf` rewrite | HTTPS only |
 
diff --git a/docs/src/content/docs/reference/cli-commands.md b/docs/src/content/docs/reference/cli-commands.md
index 6801f863f..991240543 100644
--- a/docs/src/content/docs/reference/cli-commands.md
+++ b/docs/src/content/docs/reference/cli-commands.md
@@ -193,9 +193,6 @@ apm install /home/user/repos/my-ai-package
 # Install to user scope (available across all projects)
 apm install -g microsoft/apm-sample-package
 
-# Allow an additional transitive HTTP host
-apm install --allow-insecure-host mirror.example.com
-
 # Install a plugin from a registered marketplace
 apm install code-review@acme-plugins
 
diff --git a/packages/apm-guide/.apm/skills/apm-usage/dependencies.md b/packages/apm-guide/.apm/skills/apm-usage/dependencies.md
index 1f098914f..56da2d0a7 100644
--- a/packages/apm-guide/.apm/skills/apm-usage/dependencies.md
+++ b/packages/apm-guide/.apm/skills/apm-usage/dependencies.md
@@ -56,7 +56,7 @@ across protocols.
 | Dependency form | What APM tries |
 |-----------------|----------------|
 | `ssh://...` or `git@host:...` | SSH only |
-| `https://...` or `http://...` | HTTPS only |
+| `https://...` or `http://...` | HTTP(S) only |
 | Shorthand with `git config url.<base>.insteadOf` rewriting to SSH | SSH only |
 | Shorthand otherwise | HTTPS only |
 
diff --git a/src/apm_cli/commands/install.py b/src/apm_cli/commands/install.py
index c95bff531..6ef848973 100644
--- a/src/apm_cli/commands/install.py
+++ b/src/apm_cli/commands/install.py
@@ -670,8 +670,8 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo
         apm install --update                    # Update dependencies to latest Git refs
         apm install --dry-run                   # Show what would be installed
         apm install -g org/pkg1                 # Install to user scope (~/.apm/)
-        apm install --allow-insecure http://my-server.example.com/owner/repo
-        apm install --allow-insecure-host mirror.example.com
+        apm install --allow-insecure http://my-server.example.com/owner/repo  # Install from HTTP URL with allow_insecure
+        apm install --allow-insecure-host mirror.example.com                  # Allow transitive HTTP dependencies from mirror.example.com
     """
     try:
         # Create structured logger for install output early so exception
diff --git a/src/apm_cli/deps/transport_selection.py b/src/apm_cli/deps/transport_selection.py
index a22f7a44a..d9cdb0f66 100644
--- a/src/apm_cli/deps/transport_selection.py
+++ b/src/apm_cli/deps/transport_selection.py
@@ -9,9 +9,9 @@
 substitute fakes and the orchestrator can re-use a single resolver instance
 across many dependency clones in one ``apm install`` run.
 
-Strict-by-default: explicit ``ssh://`` or ``https://`` URLs are honored
-exactly. Cross-protocol fallback is only attempted when the user opts in
-via ``--allow-protocol-fallback`` or ``APM_ALLOW_PROTOCOL_FALLBACK=1``.
+Strict-by-default: explicit ``ssh://``, ``https://``, and ``http://`` URLs
+are honored exactly. Cross-protocol fallback is only attempted when the user
+opts in via ``--allow-protocol-fallback`` or ``APM_ALLOW_PROTOCOL_FALLBACK=1``.
 """
 
 from __future__ import annotations
@@ -62,10 +62,11 @@ class TransportAttempt:
     """A single clone attempt in the transport plan.
 
     Attributes:
-        scheme: ``"ssh"`` or ``"https"``. Drives the URL builder.
+        scheme: ``"ssh"``, ``"https"``, or ``"http"``. Drives the URL
+            builder.
         use_token: When ``True`` the orchestrator embeds the resolved auth
             token in the HTTPS URL (auth-HTTPS). Only meaningful for
-            ``scheme == "https"``.
+            authenticated HTTPS attempts.
         label: Human-readable description for log/error output.
     """
 
@@ -204,9 +205,23 @@ def protocol_pref_from_env(env: Optional[dict] = None) -> ProtocolPreference:
 
 _AUTH_HTTPS = TransportAttempt(scheme="https", use_token=True, label="authenticated HTTPS")
 _PLAIN_HTTPS = TransportAttempt(scheme="https", use_token=False, label="plain HTTPS")
+_HTTP = TransportAttempt(scheme="http", use_token=False, label="insecure HTTP")
 _SSH = TransportAttempt(scheme="ssh", use_token=False, label="SSH")
 
 
+def _dedup_attempts(attempts: List[TransportAttempt]) -> List[TransportAttempt]:
+    """Deduplicate attempts while preserving order."""
+    seen = set()
+    unique_attempts: List[TransportAttempt] = []
+    for attempt in attempts:
+        key = (attempt.scheme, attempt.use_token)
+        if key in seen:
+            continue
+        seen.add(key)
+        unique_attempts.append(attempt)
+    return unique_attempts
+
+
 class TransportSelector:
     """Pure decision engine. Maps inputs to a :class:`TransportPlan`.
 
@@ -247,32 +262,37 @@ def select(
         """
         explicit = (getattr(dep_ref, "explicit_scheme", None) or "").lower() or None
 
-        # 1. Explicit scheme on the URL wins for the *initial* attempt.
+        # 1. Explicit scheme on the URL wins for the initial attempt.
         #    In strict mode (default) the plan contains exactly that one attempt.
-        #    With allow_fallback (escape hatch for migration), we fall through to
-        #    the shorthand chain logic below so the legacy permissive behavior is
-        #    preserved exactly (auth-HTTPS-if-token, SSH, plain-HTTPS).
-        if explicit in ("ssh", "https", "http") and not allow_fallback:
+        #    With allow_fallback (escape hatch for migration), we keep the user's
+        #    explicit starting protocol and then append the opposite protocol.
+        if explicit in ("ssh", "https", "http"):
             if explicit == "ssh":
                 initial = [_SSH]
+                chained = [_AUTH_HTTPS, _PLAIN_HTTPS] if has_token else [_PLAIN_HTTPS]
+            elif explicit == "https":
+                initial = [_AUTH_HTTPS] if has_token else [_PLAIN_HTTPS]
+                chained = [_SSH, _PLAIN_HTTPS] if has_token else [_SSH]
             else:
-                # https or http: build an HTTPS attempt either way. Plain `http://`
-                # URLs are normalized to HTTPS here (and never carry a token, so a
-                # cleartext credential leak is impossible) until #700 introduces a
-                # first-class HTTP transport with explicit `--allow-insecure` gating.
-                if explicit == "https":
-                    initial = [_AUTH_HTTPS] if has_token else [_PLAIN_HTTPS]
-                else:
-                    initial = [_PLAIN_HTTPS]
+                # Never embed a token in http:// URLs.
+                initial = [_HTTP]
+                chained = [_SSH]
+
+            if not allow_fallback:
+                return TransportPlan(
+                    attempts=initial,
+                    strict=True,
+                    fallback_hint=FALLBACK_HINT,
+                )
+
             return TransportPlan(
-                attempts=initial,
-                strict=True,
-                fallback_hint=FALLBACK_HINT,
+                attempts=_dedup_attempts(initial + chained),
+                strict=False,
+                fallback_hint=None,
             )
 
-        # 2. Shorthand (no explicit scheme), OR explicit scheme with
-        #    allow_fallback=True (legacy behavior). Consult the CLI preference
-        #    and git insteadOf rewrites to pick the initial protocol.
+        # 2. Shorthand (no explicit scheme). Consult the CLI preference and git
+        #    insteadOf rewrites to pick the initial protocol.
         prefer_ssh = cli_pref == ProtocolPreference.SSH
         prefer_https = cli_pref == ProtocolPreference.HTTPS
         if cli_pref == ProtocolPreference.NONE:
@@ -291,7 +311,8 @@ def select(
             initial = [_AUTH_HTTPS] if has_token else [_PLAIN_HTTPS]
             chained = [_SSH, _PLAIN_HTTPS] if has_token else [_SSH]
         else:
-            # Strict-by-default for shorthand-no-rewrite-no-pref: HTTPS only.
+            # Default shorthand initial attempt: HTTPS. If allow_fallback is on,
+            # append SSH (and plain HTTPS after auth) below.
             initial = [_AUTH_HTTPS] if has_token else [_PLAIN_HTTPS]
             chained = [_SSH, _PLAIN_HTTPS] if has_token else [_SSH]
 
@@ -303,12 +324,8 @@ def select(
             )
 
         # Permissive: append the chain, dedup while preserving order.
-        seen = set()
-        attempts: List[TransportAttempt] = []
-        for a in initial + chained:
-            key = (a.scheme, a.use_token)
-            if key in seen:
-                continue
-            seen.add(key)
-            attempts.append(a)
-        return TransportPlan(attempts=attempts, strict=False, fallback_hint=None)
+        return TransportPlan(
+            attempts=_dedup_attempts(initial + chained),
+            strict=False,
+            fallback_hint=None,
+        )
diff --git a/src/apm_cli/install/validation.py b/src/apm_cli/install/validation.py
index 774b2c5c8..84a770ffb 100644
--- a/src/apm_cli/install/validation.py
+++ b/src/apm_cli/install/validation.py
@@ -172,6 +172,10 @@ def _validate_package_exists(package, verbose=False, auth_resolver=None, logger=
                 dep_ref.repo_url, use_ssh=False, dep_ref=dep_ref, token=_url_token
             )
 
+            explicit_scheme = getattr(dep_ref, "explicit_scheme", None)
+            is_insecure = bool(getattr(dep_ref, "is_insecure", False))
+            prefer_web_probe_first = explicit_scheme in ("http", "https") or is_insecure
+
             # For generic hosts (not GitHub, not ADO), relax the env so native
             # credential helpers (SSH keys, macOS Keychain, etc.) can work.
             # This mirrors _clone_with_fallback() which does the same relaxation.
@@ -185,14 +189,18 @@ def _validate_package_exists(package, verbose=False, auth_resolver=None, logger=
             if verbose_log:
                 verbose_log(f"Trying git ls-remote for {dep_ref.host}")
 
-            # For generic hosts, try SSH first (no credentials needed when SSH
-            # keys are configured) before falling back to HTTPS.
+            # Generic shorthand keeps the legacy SSH-first probe order. Explicit
+            # HTTP(S) should test the web transport first so validation matches
+            # the clone plan closely enough to avoid surprise SSH prompts.
             urls_to_try = []
             if is_generic:
                 ssh_url = ado_downloader._build_repo_url(
                     dep_ref.repo_url, use_ssh=True, dep_ref=dep_ref
                 )
-                urls_to_try = [ssh_url, package_url]
+                if prefer_web_probe_first:
+                    urls_to_try = [package_url, ssh_url]
+                else:
+                    urls_to_try = [ssh_url, package_url]
             else:
                 urls_to_try = [package_url]
 
diff --git a/src/apm_cli/models/dependency/reference.py b/src/apm_cli/models/dependency/reference.py
index 99e6a907b..ea516077c 100644
--- a/src/apm_cli/models/dependency/reference.py
+++ b/src/apm_cli/models/dependency/reference.py
@@ -1123,7 +1123,7 @@ def to_github_url(self) -> str:
         elif self.artifactory_prefix:
             return f"{scheme}://{netloc}/{self.artifactory_prefix}/{self.repo_url}"
         else:
-            # GitHub format: https://github.com/owner/repo
+            # Git host format: https://github.com/owner/repo
             return f"{scheme}://{netloc}/{self.repo_url}"
 
     def to_clone_url(self) -> str:
diff --git a/tests/unit/test_install_command.py b/tests/unit/test_install_command.py
index ae749c86d..6d121fadc 100644
--- a/tests/unit/test_install_command.py
+++ b/tests/unit/test_install_command.py
@@ -946,6 +946,27 @@ def test_generic_host_returns_false_when_both_transports_fail(self, mock_run):
         assert result is False
         assert mock_run.call_count == 2  # tried SSH then HTTPS
 
+    @patch("subprocess.run")
+    def test_explicit_http_generic_host_tries_http_first(self, mock_run):
+        """Explicit HTTP must probe HTTP before any SSH fallback."""
+        from apm_cli.commands.install import _validate_package_exists
+
+        mock_run.return_value = self._make_completed_process(returncode=0)
+
+        result = _validate_package_exists(
+            "http://gitlab.company.internal/acme/rules.git", verbose=False
+        )
+
+        assert result is True
+        assert mock_run.call_count == 1
+        first_cmd = mock_run.call_args_list[0][0][0]
+        assert any("http://gitlab.company.internal" in arg for arg in first_cmd), (
+            f"Expected HTTP URL in first call, got: {first_cmd}"
+        )
+        assert all("git@" not in arg for arg in first_cmd), (
+            f"Expected no SSH URL in first call, got: {first_cmd}"
+        )
+
     @patch("subprocess.run")
     def test_github_host_skips_ssh_attempt(self, mock_run):
         """GitHub.com repositories do NOT go through the SSH-first ls-remote path."""
diff --git a/tests/unit/test_transport_selection.py b/tests/unit/test_transport_selection.py
index 05a08c6dd..98de5d10c 100644
--- a/tests/unit/test_transport_selection.py
+++ b/tests/unit/test_transport_selection.py
@@ -4,6 +4,7 @@
 
 * explicit ssh:// URLs are strict (no HTTPS fallback) -- closes #661
 * explicit https:// URLs are strict (no SSH fallback)
+* explicit http:// URLs are strict and never use token auth
 * shorthand consults git insteadOf rewrites and prefers SSH on rewrite hits
   -- closes #328
 * shorthand defaults to HTTPS-only when no rewrite, no CLI pref, no env
@@ -115,6 +116,18 @@ def test_explicit_https_without_token_uses_plain_https(self):
         assert _scheme_labels(plan) == ["https"]
         assert plan.attempts[0].use_token is False
 
+    def test_explicit_http_is_strict_and_never_uses_token(self):
+        sel = TransportSelector(insteadof_resolver=FakeInsteadOfResolver())
+        plan = sel.select(
+            dep_ref=_dep("http://gitlab.company.internal/acme/lib.git"),
+            cli_pref=ProtocolPreference.NONE,
+            allow_fallback=False,
+            has_token=True,
+        )
+        assert plan.strict is True
+        assert _scheme_labels(plan) == ["http"]
+        assert plan.attempts[0].use_token is False
+
     def test_explicit_ssh_ignores_cli_pref_https(self):
         """User-stated scheme on the dep wins over CLI default for shorthand."""
         sel = TransportSelector(insteadof_resolver=FakeInsteadOfResolver())
@@ -231,17 +244,49 @@ def test_shorthand_without_token_legacy_chain(self):
         assert "ssh" in schemes and "https" in schemes
         assert plan.strict is False
 
-    def test_explicit_https_with_allow_fallback_runs_chain(self):
-        """Migration safety: clones that succeeded under the legacy chain still succeed."""
+    def test_explicit_https_with_allow_fallback_keeps_https_first(self):
+        """Explicit https:// keeps HTTPS first when fallback is enabled."""
         sel = TransportSelector(insteadof_resolver=FakeInsteadOfResolver())
         plan = sel.select(
             dep_ref=_dep("https://gitlab.com/acme/lib.git"),
             cli_pref=ProtocolPreference.NONE,
             allow_fallback=True,
-            has_token=False,
+            has_token=True,
         )
-        schemes = _scheme_labels(plan)
-        assert "ssh" in schemes and "https" in schemes
+        assert [(a.scheme, a.use_token) for a in plan.attempts] == [
+            ("https", True),
+            ("ssh", False),
+            ("https", False),
+        ]
+        assert plan.strict is False
+
+    def test_explicit_ssh_with_allow_fallback_keeps_ssh_first(self):
+        sel = TransportSelector(insteadof_resolver=FakeInsteadOfResolver())
+        plan = sel.select(
+            dep_ref=_dep("ssh://git@github.com/owner/repo.git"),
+            cli_pref=ProtocolPreference.NONE,
+            allow_fallback=True,
+            has_token=True,
+        )
+        assert [(a.scheme, a.use_token) for a in plan.attempts] == [
+            ("ssh", False),
+            ("https", True),
+            ("https", False),
+        ]
+        assert plan.strict is False
+
+    def test_explicit_http_with_allow_fallback_keeps_http_first(self):
+        sel = TransportSelector(insteadof_resolver=FakeInsteadOfResolver())
+        plan = sel.select(
+            dep_ref=_dep("http://gitlab.company.internal/acme/lib.git"),
+            cli_pref=ProtocolPreference.NONE,
+            allow_fallback=True,
+            has_token=True,
+        )
+        assert [(a.scheme, a.use_token) for a in plan.attempts] == [
+            ("http", False),
+            ("ssh", False),
+        ]
         assert plan.strict is False
 
 

From 333e8f00415abac00e61f352a99e38ee1d0dd082 Mon Sep 17 00:00:00 2001
From: arika0093 <delete0093@gmail.com>
Date: Tue, 21 Apr 2026 07:05:04 +0000
Subject: [PATCH 06/22] refactor(config): share config command boolean helpers
 (review item 14 split)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 src/apm_cli/commands/config.py    | 91 ++++++++++++++++++++++++-------
 tests/unit/test_config_command.py |  2 +-
 2 files changed, 71 insertions(+), 22 deletions(-)

diff --git a/src/apm_cli/commands/config.py b/src/apm_cli/commands/config.py
index 2ddfbfc6c..3291f7a1d 100644
--- a/src/apm_cli/commands/config.py
+++ b/src/apm_cli/commands/config.py
@@ -14,6 +14,46 @@
 # Restore builtin since a subcommand is named ``set``
 set = builtins.set
 
+_BOOLEAN_TRUE_VALUES = {"true", "1", "yes"}
+_BOOLEAN_FALSE_VALUES = {"false", "0", "no"}
+_CONFIG_KEY_DISPLAY_NAMES = {
+    "auto_integrate": "auto-integrate",
+    "temp_dir": "temp-dir",
+}
+
+
+def _parse_bool_value(value: str) -> bool:
+    """Parse a CLI boolean value."""
+    normalized = value.strip().lower()
+    if normalized in _BOOLEAN_TRUE_VALUES:
+        return True
+    if normalized in _BOOLEAN_FALSE_VALUES:
+        return False
+    raise ValueError(f"Invalid value '{value}'. Use 'true' or 'false'.")
+
+
+def _get_config_setters():
+    """Return config setters keyed by CLI option name."""
+    from ..config import set_auto_integrate
+
+    return {
+        "auto-integrate": (set_auto_integrate, "Auto-integration"),
+    }
+
+
+def _get_config_getters():
+    """Return config getters keyed by CLI option name."""
+    from ..config import get_auto_integrate
+
+    return {
+        "auto-integrate": get_auto_integrate,
+    }
+
+
+def _valid_config_keys() -> str:
+    """Return valid config keys for messages."""
+    return ", ".join(["auto-integrate", "temp-dir"])
+
 
 @click.group(help="Configure APM CLI", invoke_without_command=True)
 @click.pass_context
@@ -128,35 +168,41 @@ def set(key, value):
         apm config set auto-integrate false
         apm config set auto-integrate true
     """
-    from ..config import set_auto_integrate, set_temp_dir
+    from ..config import get_temp_dir, set_temp_dir
 
     logger = CommandLogger("config set")
-    if key == "auto-integrate":
-        if value.lower() in ["true", "1", "yes"]:
-            set_auto_integrate(True)
-            logger.success("Auto-integration enabled")
-        elif value.lower() in ["false", "0", "no"]:
-            set_auto_integrate(False)
-            logger.success("Auto-integration disabled")
-        else:
-            logger.error(f"Invalid value '{value}'. Use 'true' or 'false'.")
-            sys.exit(1)
-    elif key == "temp-dir":
+    if key == "temp-dir":
         try:
             set_temp_dir(value)
-            from ..config import get_temp_dir
             logger.success(f"Temporary directory set to: {get_temp_dir()}")
         except ValueError as exc:
             logger.error(str(exc))
             sys.exit(1)
-    else:
+        return
+
+    setters = _get_config_setters()
+    config_entry = setters.get(key)
+    if config_entry is None:
         logger.error(f"Unknown configuration key: '{key}'")
-        logger.progress("Valid keys: auto-integrate, temp-dir")
+        logger.progress(f"Valid keys: {_valid_config_keys()}")
         logger.progress(
             "This error may indicate a bug in command routing. Please report this issue."
         )
         sys.exit(1)
 
+    try:
+        enabled = _parse_bool_value(value)
+    except ValueError as exc:
+        logger.error(str(exc))
+        sys.exit(1)
+
+    setter, label = config_entry
+    setter(enabled)
+    if enabled:
+        logger.success(f"{label} enabled")
+    else:
+        logger.success(f"{label} disabled")
+
 
 @config.command(help="Get a configuration value")
 @click.argument("key", required=False)
@@ -170,23 +216,26 @@ def get(key):
     from ..config import get_auto_integrate, get_temp_dir
 
     logger = CommandLogger("config get")
+    getters = _get_config_getters()
     if key:
-        if key == "auto-integrate":
-            value = get_auto_integrate()
-            click.echo(f"auto-integrate: {value}")
-        elif key == "temp-dir":
+        if key == "temp-dir":
             value = get_temp_dir()
             if value is None:
                 click.echo("temp-dir: Not set (using system default)")
             else:
                 click.echo(f"temp-dir: {value}")
-        else:
+            return
+
+        getter = getters.get(key)
+        if getter is None:
             logger.error(f"Unknown configuration key: '{key}'")
-            logger.progress("Valid keys: auto-integrate, temp-dir")
+            logger.progress(f"Valid keys: {_valid_config_keys()}")
             logger.progress(
                 "This error may indicate a bug in command routing. Please report this issue."
             )
             sys.exit(1)
+        value = getter()
+        click.echo(f"{key}: {value}")
     else:
         # Show all user-settable keys with their effective values (including
         # defaults).  Iterating raw config keys would hide settings that
diff --git a/tests/unit/test_config_command.py b/tests/unit/test_config_command.py
index bf89df6ff..ba610cd1a 100644
--- a/tests/unit/test_config_command.py
+++ b/tests/unit/test_config_command.py
@@ -233,7 +233,6 @@ def test_set_auto_integrate_case_insensitive(self):
         assert result.exit_code == 0
         mock_set.assert_called_once_with(True)
 
-
 class TestConfigGet:
     """Tests for `apm config get [key]`."""
 
@@ -494,3 +493,4 @@ def test_get_all_config_maps_temp_dir_key(self):
             result = self.runner.invoke(config, ["get"])
         assert result.exit_code == 0
         assert "temp-dir: /my/temp" in result.output
+

From ea45c9bf14967f330e72d95ba6d7d45f9a0ca6b5 Mon Sep 17 00:00:00 2001
From: arika0093 <delete0093@gmail.com>
Date: Tue, 21 Apr 2026 07:05:04 +0000
Subject: [PATCH 07/22] feat: refine HTTP clone and validation transport
 behavior (review item 14 stack split)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 src/apm_cli/deps/github_downloader.py | 58 +++++---------------
 tests/unit/test_auth_scoping.py       | 76 +++++++++++++++++++++++----
 2 files changed, 78 insertions(+), 56 deletions(-)

diff --git a/src/apm_cli/deps/github_downloader.py b/src/apm_cli/deps/github_downloader.py
index e45bf0561..0717ced92 100644
--- a/src/apm_cli/deps/github_downloader.py
+++ b/src/apm_cli/deps/github_downloader.py
@@ -48,6 +48,7 @@
     GitConfigInsteadOfResolver,
     InsteadOfResolver,
     ProtocolPreference,
+    TransportAttempt,
     TransportPlan,
     TransportSelector,
     is_fallback_allowed,
@@ -622,6 +623,7 @@ def _build_repo_url(self, repo_ref: str, use_ssh: bool = False, dep_ref: Depende
 
         # Check if this is Azure DevOps (either via dep_ref or host detection)
         is_ado = (dep_ref and dep_ref.is_azure_devops()) or is_azure_devops_hostname(host)
+        is_insecure = bool(getattr(dep_ref, "is_insecure", False)) if dep_ref is not None else False
 
         # Use provided token or fall back to instance default. Pass an empty
         # string ("") explicitly to suppress the per-instance token (used by
@@ -659,21 +661,17 @@ def _build_repo_url(self, repo_ref: str, use_ssh: bool = False, dep_ref: Depende
         else:
             # Determine if this host should receive a GitHub token
             is_github = is_github_hostname(host)
-            # Use http:// only for dependencies explicitly marked as insecure.
-            is_insecure = False
-            if dep_ref is not None:
-                is_insecure = bool(getattr(dep_ref, "is_insecure", False))
             # Thread the user-declared custom port (e.g. 7999 for Bitbucket DC) through
             # the URL builders so neither SSH nor HTTPS attempts silently drop it.
             port = dep_ref.port if dep_ref else None
             if use_ssh:
                 return build_ssh_url(host, repo_ref, port=port)
-            elif is_github and github_token:
-                # Only send GitHub tokens to GitHub hosts
-                return build_https_clone_url(host, repo_ref, token=github_token, port=port)
             elif is_insecure:
                 netloc = f"{host}:{port}" if port else host
                 return f"http://{netloc}/{repo_ref}.git"
+            elif is_github and github_token:
+                # Only send GitHub tokens to GitHub hosts
+                return build_https_clone_url(host, repo_ref, token=github_token, port=port)
             else:
                 # Generic hosts: plain HTTPS, let git credential helpers handle auth
                 return build_https_clone_url(host, repo_ref, token=None, port=port)
@@ -683,9 +681,9 @@ def _clone_with_fallback(self, repo_url_base: str, target_path: Path, progress_r
 
         The transport selector decides protocol order and strictness based on
         the user's URL form, CLI/env preferences, and git ``insteadOf`` config.
-        Strict-by-default: explicit ``ssh://`` and ``https://`` URLs no longer
-        silently fall back to a different protocol. To restore the legacy
-        permissive chain, set ``--allow-protocol-fallback`` or
+        Strict-by-default: explicit ``ssh://``, ``https://``, and ``http://``
+        URLs no longer silently fall back to a different protocol. To restore
+        the legacy permissive chain, set ``--allow-protocol-fallback`` or
         ``APM_ALLOW_PROTOCOL_FALLBACK=1``.
 
         Args:
@@ -721,36 +719,6 @@ def _clone_with_fallback(self, repo_url_base: str, target_path: Path, progress_r
             f"protocol_pref={self._protocol_pref.value}, allow_fallback={self._allow_fallback}"
         )
 
-        # HTTP direct only: return before Method 1/2/3 and keep credential helpers enabled.
-        if dep_ref and getattr(dep_ref, "is_insecure", False):
-            insecure_env = {
-                k: v
-                for k, v in self.git_env.items()
-                if k not in ("GIT_ASKPASS", "GIT_CONFIG_GLOBAL", "GIT_CONFIG_NOSYSTEM")
-            }
-            insecure_env["GIT_TERMINAL_PROMPT"] = "0"
-            try:
-                http_url = self._build_repo_url(
-                    repo_url_base, use_ssh=False, dep_ref=dep_ref
-                )
-                repo = Repo.clone_from(
-                    http_url,
-                    target_path,
-                    env=insecure_env,
-                    progress=progress_reporter,
-                    **clone_kwargs,
-                )
-                if verbose_callback:
-                    verbose_callback(f"Cloned from: {http_url}")
-                return repo
-            except GitCommandError as e:
-                sanitized_error = self._sanitize_git_error(str(e))
-                raise RuntimeError(
-                    f"Failed to clone insecure HTTP repository {repo_url_base}. "
-                    f"HTTP dependencies do not fall back to SSH or HTTPS. "
-                    f"Last error: {sanitized_error}"
-                ) from e
-
         # Choose the clone env PER ATTEMPT (not per dependency): only the
         # token-bearing attempt should run with the locked-down env that
         # silences credential helpers. SSH and plain-HTTPS attempts in a
@@ -812,7 +780,7 @@ def _env_for(use_token_attempt: bool) -> Dict[str, str]:
             try:
                 _debug(f"Attempting clone with {attempt.label} (URL sanitized)")
                 repo = Repo.clone_from(
-                    url, target_path, env=_env_for(attempt.use_token),
+                    url, target_path, env=_env_for(attempt),
                     progress=progress_reporter, **clone_kwargs,
                 )
                 if verbose_callback:
@@ -984,11 +952,9 @@ def list_remote_refs(self, dep_ref: DependencyReference) -> List[RemoteRef]:
         if dep_token:
             ls_env = self.git_env
         else:
-            ls_env = {
-                k: v for k, v in self.git_env.items()
-                if k not in ("GIT_ASKPASS", "GIT_CONFIG_GLOBAL", "GIT_CONFIG_NOSYSTEM")
-            }
-            ls_env["GIT_TERMINAL_PROMPT"] = "0"
+            ls_env = self._build_noninteractive_git_env(
+                preserve_config_isolation=bool(getattr(dep_ref, "is_insecure", False))
+            )
 
         # Build authenticated URL
         remote_url = self._build_repo_url(
diff --git a/tests/unit/test_auth_scoping.py b/tests/unit/test_auth_scoping.py
index 3c634a07e..9cce85c27 100644
--- a/tests/unit/test_auth_scoping.py
+++ b/tests/unit/test_auth_scoping.py
@@ -252,8 +252,8 @@ def test_github_host_with_token_tries_method1_first(self):
         assert "ghp_TESTTOKEN" in first_url
         assert _url_host(first_url) == "github.com"
 
-    def test_insecure_http_dep_does_not_fallback_to_ssh(self):
-        """HTTP deps should clone with HTTP only and never try SSH fallback."""
+    def test_insecure_http_dep_is_strict_by_default(self):
+        """HTTP deps stay on HTTP unless protocol fallback is enabled."""
         dl = _make_downloader(github_token="ghp_TESTTOKEN")
         dep = _dep("http://gitlab.company.internal/acme/rules.git")
 
@@ -267,10 +267,7 @@ def test_insecure_http_dep_does_not_fallback_to_ssh(self):
             MockRepo.clone_from.side_effect = GitCommandError("clone", "failed")
             target = Path(tempfile.mkdtemp())
             try:
-                with pytest.raises(
-                    RuntimeError,
-                    match="do not fall back to SSH or HTTPS",
-                ):
+                with pytest.raises(RuntimeError, match="via insecure HTTP"):
                     dl._clone_with_fallback(dep.repo_url, target, dep_ref=dep)
             finally:
                 import shutil
@@ -281,10 +278,49 @@ def test_insecure_http_dep_does_not_fallback_to_ssh(self):
             env_used = MockRepo.clone_from.call_args_list[0][1]["env"]
             assert first_url == "http://gitlab.company.internal/acme/rules.git"
             assert "GIT_ASKPASS" not in env_used
-            assert "GIT_CONFIG_GLOBAL" not in env_used
-            assert "GIT_CONFIG_NOSYSTEM" not in env_used
+            assert env_used.get("GIT_CONFIG_GLOBAL") == dl.git_env["GIT_CONFIG_GLOBAL"]
+            assert env_used.get("GIT_CONFIG_NOSYSTEM") == "1"
             assert env_used.get("GIT_TERMINAL_PROMPT") == "0"
 
+    def test_insecure_http_dep_with_allow_fallback_tries_http_then_ssh(self):
+        """HTTP deps keep HTTP first, then may retry over SSH when enabled."""
+        dl = _make_downloader(github_token="ghp_TESTTOKEN")
+        dl._allow_fallback = True
+        dep = _dep("http://gitlab.company.internal/acme/rules.git")
+
+        dl.auth_resolver._cache.clear()
+        with patch.dict(os.environ, {"GITHUB_APM_PAT": "ghp_TESTTOKEN"}, clear=True), \
+             patch(
+                 "apm_cli.core.token_manager.GitHubTokenManager.resolve_credential_from_git",
+                 return_value=None,
+             ), \
+             patch('apm_cli.deps.github_downloader.Repo') as MockRepo:
+            MockRepo.clone_from.side_effect = [
+                GitCommandError("clone", "http failed"),
+                GitCommandError("clone", "ssh failed"),
+            ]
+            target = Path(tempfile.mkdtemp())
+            try:
+                with pytest.raises(RuntimeError):
+                    dl._clone_with_fallback(dep.repo_url, target, dep_ref=dep)
+            finally:
+                import shutil
+                shutil.rmtree(target, ignore_errors=True)
+
+            urls = [c[0][0] for c in MockRepo.clone_from.call_args_list]
+            envs = [c[1]["env"] for c in MockRepo.clone_from.call_args_list]
+
+            assert urls == [
+                "http://gitlab.company.internal/acme/rules.git",
+                "git@gitlab.company.internal:acme/rules.git",
+            ]
+            assert "ghp_TESTTOKEN" not in urls[0]
+            assert all("GIT_ASKPASS" not in env_used for env_used in envs)
+            assert envs[0].get("GIT_CONFIG_GLOBAL") == dl.git_env["GIT_CONFIG_GLOBAL"]
+            assert envs[0].get("GIT_CONFIG_NOSYSTEM") == "1"
+            assert "GIT_CONFIG_GLOBAL" not in envs[1]
+            assert "GIT_CONFIG_NOSYSTEM" not in envs[1]
+
     def test_generic_host_error_message_mentions_credential_helpers(self):
         """When all methods fail for a generic host, the error suggests credential helpers."""
         dl = _make_downloader(github_token="ghp_TESTTOKEN")
@@ -765,11 +801,12 @@ def test_dict_dep_no_path_same_identity_as_string(self):
 # ===========================================================================
 
 class TestValidatePackageExistsEnv:
-    """Verify _validate_package_exists uses relaxed env for generic hosts.
+    """Verify _validate_package_exists uses the right env for generic hosts.
 
     Bug: previously, all non-GitHub.com hosts got the locked-down git_env
     (GIT_ASKPASS=echo, etc.), blocking credential helpers for generic hosts
-    like GitLab. The clone step already relaxed this, but validation didn't.
+    like GitLab. Explicit HTTP probes also need to keep git config isolation so
+    git cannot rewrite them to SSH before the first transport attempt.
     """
 
     @patch("apm_cli.core.token_manager.GitHubTokenManager.resolve_credential_from_git", return_value=None)
@@ -796,6 +833,25 @@ def test_generic_host_validation_allows_credential_helpers(self, mock_run, _mock
         # GIT_TERMINAL_PROMPT should still be '0' (no interactive prompts)
         assert env_used.get("GIT_TERMINAL_PROMPT") == "0"
 
+    @patch("apm_cli.core.token_manager.GitHubTokenManager.resolve_credential_from_git", return_value=None)
+    @patch("subprocess.run")
+    @patch.dict(os.environ, {}, clear=True)
+    def test_explicit_http_validation_preserves_config_isolation(self, mock_run, _mock_cred):
+        """Explicit HTTP validation must not allow git config rewrites to SSH."""
+        from apm_cli.commands.install import _validate_package_exists
+
+        mock_run.return_value = Mock(returncode=0)
+        _validate_package_exists("http://gitlab.company.internal/acme/rules.git")
+
+        assert mock_run.called
+        call_kwargs = mock_run.call_args
+        env_used = call_kwargs.kwargs.get("env") or call_kwargs[1].get("env", {})
+
+        assert env_used.get("GIT_ASKPASS") != "echo"
+        assert env_used.get("GIT_CONFIG_NOSYSTEM") == "1"
+        assert "GIT_CONFIG_GLOBAL" in env_used
+        assert env_used.get("GIT_TERMINAL_PROMPT") == "0"
+
     @patch("apm_cli.core.token_manager.GitHubTokenManager.resolve_credential_from_git", return_value=None)
     @patch("subprocess.run")
     @patch.dict(os.environ, {"ADO_APM_PAT": "test-ado-token"}, clear=True)

From b5ce27ebbb0a00b8ceeb20223e139dc519777152 Mon Sep 17 00:00:00 2001
From: arika0093 <delete0093@gmail.com>
Date: Tue, 21 Apr 2026 07:09:11 +0000
Subject: [PATCH 08/22] fix(security): suppress credential helpers on HTTP git
 attempts (review item 1)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 src/apm_cli/deps/github_downloader.py | 50 +++++++++++++++++++++++----
 src/apm_cli/install/validation.py     |  7 ++--
 tests/unit/test_auth_scoping.py       | 18 +++++++---
 tests/unit/test_list_remote_refs.py   | 28 +++++++++++++++
 4 files changed, 89 insertions(+), 14 deletions(-)

diff --git a/src/apm_cli/deps/github_downloader.py b/src/apm_cli/deps/github_downloader.py
index 0717ced92..6a5f92665 100644
--- a/src/apm_cli/deps/github_downloader.py
+++ b/src/apm_cli/deps/github_downloader.py
@@ -488,6 +488,37 @@ def _resolve_dep_token(self, dep_ref: Optional[DependencyReference] = None) -> O
         dep_ctx = self.auth_resolver.resolve_for_dep(dep_ref)
         return dep_ctx.token
 
+    def _build_noninteractive_git_env(
+        self,
+        *,
+        preserve_config_isolation: bool = False,
+        suppress_credential_helpers: bool = False,
+    ) -> Dict[str, str]:
+        """Return a non-interactive git env for unauthenticated git operations."""
+        env = dict(self.git_env)
+        env["GIT_TERMINAL_PROMPT"] = "0"
+        env.pop("GIT_ASKPASS", None)
+
+        if preserve_config_isolation or suppress_credential_helpers:
+            env["GIT_CONFIG_NOSYSTEM"] = "1"
+            if "GIT_CONFIG_GLOBAL" in self.git_env:
+                env["GIT_CONFIG_GLOBAL"] = self.git_env["GIT_CONFIG_GLOBAL"]
+        else:
+            env.pop("GIT_CONFIG_GLOBAL", None)
+            env.pop("GIT_CONFIG_NOSYSTEM", None)
+
+        if suppress_credential_helpers:
+            env["GIT_ASKPASS"] = "echo"
+            env["GIT_CONFIG_COUNT"] = "1"
+            env["GIT_CONFIG_KEY_0"] = "credential.helper"
+            env["GIT_CONFIG_VALUE_0"] = ""
+        else:
+            env.pop("GIT_CONFIG_COUNT", None)
+            env.pop("GIT_CONFIG_KEY_0", None)
+            env.pop("GIT_CONFIG_VALUE_0", None)
+
+        return env
+
     def _resilient_get(self, url: str, headers: Dict[str, str], timeout: int = 30, max_retries: int = 3) -> requests.Response:
         """HTTP GET with retry on 429/503 and rate-limit header awareness (#171).
 
@@ -725,13 +756,15 @@ def _clone_with_fallback(self, repo_url_base: str, target_path: Path, progress_r
         # mixed allow_fallback plan need the relaxed env so user-configured
         # credential helpers (gh auth, Keychain, ssh-agent passphrase
         # prompts) keep working.
-        def _env_for(use_token_attempt: bool) -> Dict[str, str]:
-            if use_token_attempt:
+        def _env_for(attempt: TransportAttempt) -> Dict[str, str]:
+            if attempt.use_token:
                 return self.git_env
-            relaxed = {k: v for k, v in self.git_env.items()
-                       if k not in ('GIT_ASKPASS', 'GIT_CONFIG_GLOBAL', 'GIT_CONFIG_NOSYSTEM')}
-            relaxed['GIT_TERMINAL_PROMPT'] = '0'
-            return relaxed
+            if attempt.scheme == "http":
+                return self._build_noninteractive_git_env(
+                    preserve_config_isolation=True,
+                    suppress_credential_helpers=True,
+                )
+            return self._build_noninteractive_git_env()
 
         plan: TransportPlan = self._transport_selector.select(
             dep_ref=dep_ref,
@@ -953,7 +986,10 @@ def list_remote_refs(self, dep_ref: DependencyReference) -> List[RemoteRef]:
             ls_env = self.git_env
         else:
             ls_env = self._build_noninteractive_git_env(
-                preserve_config_isolation=bool(getattr(dep_ref, "is_insecure", False))
+                preserve_config_isolation=bool(getattr(dep_ref, "is_insecure", False)),
+                suppress_credential_helpers=bool(
+                    getattr(dep_ref, "is_insecure", False)
+                ),
             )
 
         # Build authenticated URL
diff --git a/src/apm_cli/install/validation.py b/src/apm_cli/install/validation.py
index 84a770ffb..7d45c88fa 100644
--- a/src/apm_cli/install/validation.py
+++ b/src/apm_cli/install/validation.py
@@ -180,9 +180,10 @@ def _validate_package_exists(package, verbose=False, auth_resolver=None, logger=
             # credential helpers (SSH keys, macOS Keychain, etc.) can work.
             # This mirrors _clone_with_fallback() which does the same relaxation.
             if is_generic:
-                validate_env = {k: v for k, v in ado_downloader.git_env.items()
-                                if k not in ('GIT_ASKPASS', 'GIT_CONFIG_GLOBAL', 'GIT_CONFIG_NOSYSTEM')}
-                validate_env['GIT_TERMINAL_PROMPT'] = '0'
+                validate_env = ado_downloader._build_noninteractive_git_env(
+                    preserve_config_isolation=prefer_web_probe_first,
+                    suppress_credential_helpers=is_insecure,
+                )
             else:
                 validate_env = {**os.environ, **ado_downloader.git_env}
 
diff --git a/tests/unit/test_auth_scoping.py b/tests/unit/test_auth_scoping.py
index 9cce85c27..38439ecfa 100644
--- a/tests/unit/test_auth_scoping.py
+++ b/tests/unit/test_auth_scoping.py
@@ -277,9 +277,12 @@ def test_insecure_http_dep_is_strict_by_default(self):
             first_url = MockRepo.clone_from.call_args_list[0][0][0]
             env_used = MockRepo.clone_from.call_args_list[0][1]["env"]
             assert first_url == "http://gitlab.company.internal/acme/rules.git"
-            assert "GIT_ASKPASS" not in env_used
+            assert env_used.get("GIT_ASKPASS") == "echo"
             assert env_used.get("GIT_CONFIG_GLOBAL") == dl.git_env["GIT_CONFIG_GLOBAL"]
             assert env_used.get("GIT_CONFIG_NOSYSTEM") == "1"
+            assert env_used.get("GIT_CONFIG_COUNT") == "1"
+            assert env_used.get("GIT_CONFIG_KEY_0") == "credential.helper"
+            assert env_used.get("GIT_CONFIG_VALUE_0") == ""
             assert env_used.get("GIT_TERMINAL_PROMPT") == "0"
 
     def test_insecure_http_dep_with_allow_fallback_tries_http_then_ssh(self):
@@ -315,9 +318,13 @@ def test_insecure_http_dep_with_allow_fallback_tries_http_then_ssh(self):
                 "git@gitlab.company.internal:acme/rules.git",
             ]
             assert "ghp_TESTTOKEN" not in urls[0]
-            assert all("GIT_ASKPASS" not in env_used for env_used in envs)
+            assert envs[0].get("GIT_ASKPASS") == "echo"
             assert envs[0].get("GIT_CONFIG_GLOBAL") == dl.git_env["GIT_CONFIG_GLOBAL"]
             assert envs[0].get("GIT_CONFIG_NOSYSTEM") == "1"
+            assert envs[0].get("GIT_CONFIG_COUNT") == "1"
+            assert envs[0].get("GIT_CONFIG_KEY_0") == "credential.helper"
+            assert envs[0].get("GIT_CONFIG_VALUE_0") == ""
+            assert "GIT_ASKPASS" not in envs[1]
             assert "GIT_CONFIG_GLOBAL" not in envs[1]
             assert "GIT_CONFIG_NOSYSTEM" not in envs[1]
 
@@ -837,7 +844,7 @@ def test_generic_host_validation_allows_credential_helpers(self, mock_run, _mock
     @patch("subprocess.run")
     @patch.dict(os.environ, {}, clear=True)
     def test_explicit_http_validation_preserves_config_isolation(self, mock_run, _mock_cred):
-        """Explicit HTTP validation must not allow git config rewrites to SSH."""
+        """Explicit HTTP validation must block credential helpers and SSH rewrites."""
         from apm_cli.commands.install import _validate_package_exists
 
         mock_run.return_value = Mock(returncode=0)
@@ -847,9 +854,12 @@ def test_explicit_http_validation_preserves_config_isolation(self, mock_run, _mo
         call_kwargs = mock_run.call_args
         env_used = call_kwargs.kwargs.get("env") or call_kwargs[1].get("env", {})
 
-        assert env_used.get("GIT_ASKPASS") != "echo"
+        assert env_used.get("GIT_ASKPASS") == "echo"
         assert env_used.get("GIT_CONFIG_NOSYSTEM") == "1"
         assert "GIT_CONFIG_GLOBAL" in env_used
+        assert env_used.get("GIT_CONFIG_COUNT") == "1"
+        assert env_used.get("GIT_CONFIG_KEY_0") == "credential.helper"
+        assert env_used.get("GIT_CONFIG_VALUE_0") == ""
         assert env_used.get("GIT_TERMINAL_PROMPT") == "0"
 
     @patch("apm_cli.core.token_manager.GitHubTokenManager.resolve_credential_from_git", return_value=None)
diff --git a/tests/unit/test_list_remote_refs.py b/tests/unit/test_list_remote_refs.py
index a14144317..cc9c61520 100644
--- a/tests/unit/test_list_remote_refs.py
+++ b/tests/unit/test_list_remote_refs.py
@@ -305,6 +305,34 @@ def test_generic_host_no_token(self, MockGitCmd):
         assert "GIT_CONFIG_NOSYSTEM" not in used_env
         assert used_env.get("GIT_TERMINAL_PROMPT") == "0"
 
+    @patch("apm_cli.deps.github_downloader.git.cmd.Git")
+    def test_insecure_http_host_no_token_suppresses_credential_helpers(self, MockGitCmd):
+        """HTTP ls-remote must block credential helpers and preserve config isolation."""
+        dl = _build_downloader()
+        dep = _make_dep_ref(host="gitlab.example.com")
+        dep.is_insecure = True
+
+        dl._resolve_dep_token = MagicMock(return_value=None)
+        dl._build_repo_url = MagicMock(return_value="http://gitlab.example.com/owner/repo.git")
+        dl.git_env["GIT_ASKPASS"] = "echo"
+        dl.git_env["GIT_CONFIG_GLOBAL"] = "/dev/null"
+        dl.git_env["GIT_CONFIG_NOSYSTEM"] = "1"
+
+        mock_git = MockGitCmd.return_value
+        mock_git.ls_remote.return_value = SAMPLE_LS_REMOTE
+
+        dl.list_remote_refs(dep)
+
+        call_kwargs = mock_git.ls_remote.call_args
+        used_env = call_kwargs.kwargs.get("env")
+        assert used_env.get("GIT_ASKPASS") == "echo"
+        assert used_env.get("GIT_CONFIG_GLOBAL") == "/dev/null"
+        assert used_env.get("GIT_CONFIG_NOSYSTEM") == "1"
+        assert used_env.get("GIT_CONFIG_COUNT") == "1"
+        assert used_env.get("GIT_CONFIG_KEY_0") == "credential.helper"
+        assert used_env.get("GIT_CONFIG_VALUE_0") == ""
+        assert used_env.get("GIT_TERMINAL_PROMPT") == "0"
+
     @patch("apm_cli.deps.github_downloader.git.cmd.Git")
     def test_git_command_error_raises_runtime_error(self, MockGitCmd):
         """GitCommandError is wrapped in RuntimeError with auth context."""

From c67cfded99e65b2423feda4be72f0b7216275b38 Mon Sep 17 00:00:00 2001
From: arika0093 <delete0093@gmail.com>
Date: Tue, 21 Apr 2026 07:11:15 +0000
Subject: [PATCH 09/22] fix(drift): treat HTTP transport flips as dependency
 drift (review item 2)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 src/apm_cli/drift.py              | 20 +++++++-----
 tests/unit/test_install_update.py | 51 +++++++++++++++++++++++++++++++
 2 files changed, 64 insertions(+), 7 deletions(-)

diff --git a/src/apm_cli/drift.py b/src/apm_cli/drift.py
index 78ebbea19..b56fdba10 100644
--- a/src/apm_cli/drift.py
+++ b/src/apm_cli/drift.py
@@ -38,12 +38,13 @@
   formatting-only change that produces the same unique key and is correctly
   treated as no drift.
 
-* **Source/host/scheme changes** — *not* detected.  If a user changes the host
-  or scheme of an otherwise identical package (for example, switching an
-  insecure HTTP dependency to HTTPS), the unique key may not change and
-  ``detect_ref_change()`` will not signal a re-download.  Source-level changes
-  require the user to ``apm remove`` + ``apm install`` the package, or use
-  ``--update``.
+* **Host changes** — *not* detected.  If a user changes the host of an otherwise
+  identical package, the unique key may not change and ``detect_ref_change()``
+  will not signal a re-download.  Host-level changes still require the user to
+  ``apm remove`` + ``apm install`` the package, or use ``--update``.
+* **HTTP transport flips** — detected.  Switching between HTTPS and insecure
+  HTTP toggles ``is_insecure`` and forces a re-download even when the package
+  identity and ref are otherwise unchanged.
 """
 
 from __future__ import annotations
@@ -96,7 +97,12 @@ def detect_ref_change(
         return False  # new package — not drift, just a first install
     # Direct comparison: handles None→value, value→None, and value→value.
     # No truthiness guard on locked_dep.resolved_ref — None != "v1.0.0" is True.
-    return dep_ref.reference != locked_dep.resolved_ref
+    if dep_ref.reference != locked_dep.resolved_ref:
+        return True
+
+    return (getattr(dep_ref, "is_insecure", False) is True) != (
+        getattr(locked_dep, "is_insecure", False) is True
+    )
 
 
 # ---------------------------------------------------------------------------
diff --git a/tests/unit/test_install_update.py b/tests/unit/test_install_update.py
index 7670aa2e9..2dc53ebd1 100644
--- a/tests/unit/test_install_update.py
+++ b/tests/unit/test_install_update.py
@@ -99,6 +99,57 @@ def test_no_install_path_never_skips(self):
         ) is False
 
 
+class TestDetectRefChange:
+    """Tests for detect_ref_change()."""
+
+    def test_insecure_transport_flip_from_https_to_http_is_drift(self):
+        """Switching to insecure HTTP must force a re-download."""
+        dep = DependencyReference(
+            repo_url="owner/repo",
+            host="gitlab.example.com",
+            is_insecure=True,
+        )
+        locked = LockedDependency(
+            repo_url="owner/repo",
+            host="gitlab.example.com",
+            is_insecure=False,
+        )
+
+        assert detect_ref_change(dep, locked) is True
+
+    def test_insecure_transport_flip_from_http_to_https_is_drift(self):
+        """Switching back to HTTPS must also force a re-download."""
+        dep = DependencyReference(
+            repo_url="owner/repo",
+            host="gitlab.example.com",
+            is_insecure=False,
+        )
+        locked = LockedDependency(
+            repo_url="owner/repo",
+            host="gitlab.example.com",
+            is_insecure=True,
+        )
+
+        assert detect_ref_change(dep, locked) is True
+
+    def test_same_transport_and_ref_is_not_drift(self):
+        """Matching ref + transport must remain a no-drift case."""
+        dep = DependencyReference(
+            repo_url="owner/repo",
+            host="gitlab.example.com",
+            reference="main",
+            is_insecure=False,
+        )
+        locked = LockedDependency(
+            repo_url="owner/repo",
+            host="gitlab.example.com",
+            resolved_ref="main",
+            is_insecure=False,
+        )
+
+        assert detect_ref_change(dep, locked) is False
+
+
 class TestDownloadRefLockfileOverride:
     """Test that lockfile SHA override is gated behind `not update_refs`.
 

From 9032b9ef0d193844fd2d202d721416702d7ef6e0 Mon Sep 17 00:00:00 2001
From: arika0093 <delete0093@gmail.com>
Date: Tue, 21 Apr 2026 07:16:37 +0000
Subject: [PATCH 10/22] refactor(install): extract insecure dependency policy
 module (review item 3)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 src/apm_cli/commands/install.py        | 216 ++-----------------------
 src/apm_cli/install/insecure_policy.py | 206 +++++++++++++++++++++++
 src/apm_cli/install/phases/resolve.py  |   2 +-
 3 files changed, 217 insertions(+), 207 deletions(-)
 create mode 100644 src/apm_cli/install/insecure_policy.py

diff --git a/src/apm_cli/commands/install.py b/src/apm_cli/commands/install.py
index 6ef848973..7de111c24 100644
--- a/src/apm_cli/commands/install.py
+++ b/src/apm_cli/commands/install.py
@@ -2,8 +2,6 @@
 
 import builtins
 import sys
-import urllib.parse
-from dataclasses import dataclass
 from pathlib import Path
 from typing import List, Optional
 
@@ -36,7 +34,6 @@
 # working via "apm_cli.commands.install._hash_deployed".
 from apm_cli.install.phases.lockfile import compute_deployed_hashes as _hash_deployed
 
-from ..utils.github_host import default_host, is_valid_fqdn
 from ..utils.path_security import safe_rmtree
 
 # Re-export validation leaf helpers so that existing test patches like
@@ -62,6 +59,16 @@
     _has_local_apm_content,
     _project_has_root_primitives,
 )
+from apm_cli.install.insecure_policy import (
+    _InsecureDependencyInfo,
+    _allow_insecure_host_callback,
+    _check_insecure_dependencies,
+    _collect_insecure_dependency_infos,
+    _format_insecure_dependency_warning,
+    _guard_transitive_insecure_dependencies,
+    _normalize_allow_insecure_host,
+    _warn_insecure_dependencies,
+)
 
 # Re-export the pre-deploy security scan so that bare-name call sites inside
 # this module and ``tests/unit/test_install_scanning.py``'s direct import
@@ -103,209 +110,6 @@
     _APM_IMPORT_ERROR = str(e)
 
 
-# ---------------------------------------------------------------------------
-# Validation helpers
-# ---------------------------------------------------------------------------
-
-
-@dataclass(frozen=True)
-class _InsecureDependencyInfo:
-    """Resolved insecure dependency details for warnings and consent checks."""
-
-    url: str
-    is_transitive: bool
-    introduced_by: str | None = None
-
-
-def _collect_insecure_dependency_infos(
-    deps_to_install, dependency_graph
-) -> list[_InsecureDependencyInfo]:
-    """Collect insecure dependency details from the resolved install set."""
-    insecure_infos: list[_InsecureDependencyInfo] = []
-    tree = dependency_graph.dependency_tree
-
-    for dep in deps_to_install:
-        if getattr(dep, "is_insecure", False) is not True:
-            continue
-
-        node = tree.get_node(dep.get_unique_key()) if tree else None
-        parent = node.parent if node else None
-        insecure_infos.append(
-            _InsecureDependencyInfo(
-                url=dep.to_canonical(),
-                is_transitive=parent is not None,
-                introduced_by=(
-                    parent.dependency_ref.get_display_name()
-                    if parent is not None
-                    else None
-                ),
-            )
-        )
-
-    return insecure_infos
-
-
-def _format_insecure_dependency_warning(info: _InsecureDependencyInfo) -> str:
-    """Render the install-time warning text for an insecure dependency."""
-    message = f"Fetching insecurely (no transport auth): {info.url}"
-    if info.is_transitive and info.introduced_by:
-        message = (
-            f"{message} (transitive, introduced by {info.introduced_by})"
-        )
-    return message
-
-
-def _warn_insecure_dependencies(insecure_infos, logger=None) -> None:
-    """Emit one warning per insecure dependency before fetch begins."""
-    for info in insecure_infos:
-        message = _format_insecure_dependency_warning(info)
-        if logger:
-            logger.warning(message)
-        else:
-            from ..utils.console import _rich_warning
-
-            _rich_warning(message)
-
-
-def _normalize_allow_insecure_host(hostname: str) -> str:
-    """Validate and normalize a hostname passed via --allow-insecure-host."""
-    normalized = hostname.strip().lower()
-    if not is_valid_fqdn(normalized):
-        raise ValueError(
-            f"Invalid hostname '{hostname}'. Use a bare hostname like 'mirror.example.com'."
-        )
-    return normalized
-
-
-def _allow_insecure_host_callback(ctx, param, value):
-    """Normalize repeatable --allow-insecure-host values for Click."""
-    normalized_hosts = []
-    seen_hosts = set()
-    for raw_host in value or ():
-        try:
-            normalized = _normalize_allow_insecure_host(raw_host)
-        except ValueError as exc:
-            raise click.BadParameter(str(exc))
-        if normalized not in seen_hosts:
-            seen_hosts.add(normalized)
-            normalized_hosts.append(normalized)
-    return tuple(normalized_hosts)
-
-
-def _get_insecure_dependency_host(info: _InsecureDependencyInfo) -> str | None:
-    """Extract the hostname from an insecure dependency warning record."""
-    parsed = urllib.parse.urlparse(info.url)
-    return parsed.hostname.lower() if parsed.hostname else None
-
-
-def _get_allowed_transitive_insecure_hosts(
-    insecure_infos,
-    *,
-    allow_insecure: bool,
-    allow_insecure_hosts,
-) -> set[str]:
-    """Build the hostname allowlist for transitive insecure dependencies."""
-    allowed_hosts = set(allow_insecure_hosts)
-    if not allow_insecure:
-        return allowed_hosts
-
-    for info in insecure_infos:
-        if info.is_transitive:
-            continue
-        host = _get_insecure_dependency_host(info)
-        if host:
-            allowed_hosts.add(host)
-    return allowed_hosts
-
-
-def _guard_transitive_insecure_dependencies(
-    insecure_infos,
-    *,
-    allow_insecure: bool,
-    allow_insecure_hosts=(),
-    logger=None,
-) -> None:
-    """Block transitive insecure dependencies from unapproved hosts."""
-    transitive_infos = [info for info in insecure_infos if info.is_transitive]
-    if not transitive_infos:
-        return
-
-    allowed_hosts = _get_allowed_transitive_insecure_hosts(
-        insecure_infos,
-        allow_insecure=allow_insecure,
-        allow_insecure_hosts=allow_insecure_hosts,
-    )
-    blocked_hosts = sorted(
-        {
-            host
-            for host in (
-                _get_insecure_dependency_host(info) for info in transitive_infos
-            )
-            if host and host not in allowed_hosts
-        }
-    )
-    if not blocked_hosts:
-        return
-
-    suggested_flags = " ".join(
-        f"--allow-insecure-host {host}" for host in blocked_hosts
-    )
-    message = (
-        "Transitive HTTP (insecure) dependencies were found on unapproved host(s): "
-        f"{', '.join(blocked_hosts)}. "
-        "--allow-insecure only covers direct HTTP dependencies and transitive "
-        "HTTP dependencies on the same host. "
-        f"Re-run with {suggested_flags} to allow these transitive hosts."
-    )
-    if logger:
-        logger.error(message)
-    else:
-        _rich_error(message)
-    sys.exit(1)
-
-
-def _check_insecure_dependencies(
-    deps, allow_insecure_flag: bool, logger=None
-) -> None:
-    """Check direct APM dependencies for HTTP (insecure) URLs and enforce policy.
-
-    Two conditions must BOTH be true for an HTTP dep to be allowed:
-    1. The dep entry in apm.yml must have allow_insecure: true
-    2. --allow-insecure must be set for this install invocation
-
-    Args:
-        deps: List of DependencyReference objects to check.
-        allow_insecure_flag: True if --allow-insecure was passed on the command line.
-    """
-    for dep in deps:
-        dep_is_insecure = getattr(dep, "is_insecure", False) is True
-        if not dep_is_insecure:
-            continue
-        identity = dep.get_identity()
-        dep_allow_insecure = getattr(dep, "allow_insecure", False) is True
-        if not dep_allow_insecure:
-            message = (
-                f"Dependency '{identity}' uses HTTP (insecure) but "
-                f"'allow_insecure: true' is not set in its apm.yml entry. "
-                f"Add 'allow_insecure: true' to the dependency, or use a HTTPS URL instead."
-            )
-            if logger:
-                logger.error(message)
-            else:
-                _rich_error(message)
-            sys.exit(1)
-        if not allow_insecure_flag:
-            message = (
-                f"Dependency '{identity}' uses HTTP (insecure). "
-                f"Pass '--allow-insecure' to apm install to confirm this install."
-            )
-            if logger:
-                logger.error(message)
-            else:
-                _rich_error(message)
-            sys.exit(1)
-
-
 def _validate_and_add_packages_to_apm_yml(packages, dry_run=False, dev=False, logger=None, manifest_path=None, auth_resolver=None, scope=None, allow_insecure=False):
     """Validate packages exist and can be accessed, then add to apm.yml dependencies section.
 
diff --git a/src/apm_cli/install/insecure_policy.py b/src/apm_cli/install/insecure_policy.py
new file mode 100644
index 000000000..3f6f555da
--- /dev/null
+++ b/src/apm_cli/install/insecure_policy.py
@@ -0,0 +1,206 @@
+"""HTTP dependency policy helpers shared by install command and pipeline."""
+
+import sys
+import urllib.parse
+from dataclasses import dataclass
+
+import click
+
+from ..utils.console import _rich_error, _rich_warning
+from ..utils.github_host import is_valid_fqdn
+
+
+@dataclass(frozen=True)
+class _InsecureDependencyInfo:
+    """Resolved insecure dependency details for warnings and consent checks."""
+
+    url: str
+    is_transitive: bool
+    introduced_by: str | None = None
+
+
+def _collect_insecure_dependency_infos(
+    deps_to_install, dependency_graph
+) -> list[_InsecureDependencyInfo]:
+    """Collect insecure dependency details from the resolved install set."""
+    insecure_infos: list[_InsecureDependencyInfo] = []
+    tree = dependency_graph.dependency_tree
+
+    for dep in deps_to_install:
+        if getattr(dep, "is_insecure", False) is not True:
+            continue
+
+        node = tree.get_node(dep.get_unique_key()) if tree else None
+        parent = node.parent if node else None
+        insecure_infos.append(
+            _InsecureDependencyInfo(
+                url=dep.to_canonical(),
+                is_transitive=parent is not None,
+                introduced_by=(
+                    parent.dependency_ref.get_display_name()
+                    if parent is not None
+                    else None
+                ),
+            )
+        )
+
+    return insecure_infos
+
+
+def _format_insecure_dependency_warning(info: _InsecureDependencyInfo) -> str:
+    """Render the install-time warning text for an insecure dependency."""
+    message = f"Fetching insecurely (no transport auth): {info.url}"
+    if info.is_transitive and info.introduced_by:
+        message = (
+            f"{message} (transitive, introduced by {info.introduced_by})"
+        )
+    return message
+
+
+def _warn_insecure_dependencies(insecure_infos, logger=None) -> None:
+    """Emit one warning per insecure dependency before fetch begins."""
+    for info in insecure_infos:
+        message = _format_insecure_dependency_warning(info)
+        if logger:
+            logger.warning(message)
+        else:
+            _rich_warning(message)
+
+
+def _normalize_allow_insecure_host(hostname: str) -> str:
+    """Validate and normalize a hostname passed via --allow-insecure-host."""
+    normalized = hostname.strip().lower()
+    if not is_valid_fqdn(normalized):
+        raise ValueError(
+            f"Invalid hostname '{hostname}'. Use a bare hostname like 'mirror.example.com'."
+        )
+    return normalized
+
+
+def _allow_insecure_host_callback(ctx, param, value):
+    """Normalize repeatable --allow-insecure-host values for Click."""
+    normalized_hosts = []
+    seen_hosts = set()
+    for raw_host in value or ():
+        try:
+            normalized = _normalize_allow_insecure_host(raw_host)
+        except ValueError as exc:
+            raise click.BadParameter(str(exc))
+        if normalized not in seen_hosts:
+            seen_hosts.add(normalized)
+            normalized_hosts.append(normalized)
+    return tuple(normalized_hosts)
+
+
+def _get_insecure_dependency_host(info: _InsecureDependencyInfo) -> str | None:
+    """Extract the hostname from an insecure dependency warning record."""
+    parsed = urllib.parse.urlparse(info.url)
+    return parsed.hostname.lower() if parsed.hostname else None
+
+
+def _get_allowed_transitive_insecure_hosts(
+    insecure_infos,
+    *,
+    allow_insecure: bool,
+    allow_insecure_hosts,
+) -> set[str]:
+    """Build the hostname allowlist for transitive insecure dependencies."""
+    allowed_hosts = set(allow_insecure_hosts)
+    if not allow_insecure:
+        return allowed_hosts
+
+    for info in insecure_infos:
+        if info.is_transitive:
+            continue
+        host = _get_insecure_dependency_host(info)
+        if host:
+            allowed_hosts.add(host)
+    return allowed_hosts
+
+
+def _guard_transitive_insecure_dependencies(
+    insecure_infos,
+    *,
+    allow_insecure: bool,
+    allow_insecure_hosts=(),
+    logger=None,
+) -> None:
+    """Block transitive insecure dependencies from unapproved hosts."""
+    transitive_infos = [info for info in insecure_infos if info.is_transitive]
+    if not transitive_infos:
+        return
+
+    allowed_hosts = _get_allowed_transitive_insecure_hosts(
+        insecure_infos,
+        allow_insecure=allow_insecure,
+        allow_insecure_hosts=allow_insecure_hosts,
+    )
+    blocked_hosts = sorted(
+        {
+            host
+            for host in (
+                _get_insecure_dependency_host(info) for info in transitive_infos
+            )
+            if host and host not in allowed_hosts
+        }
+    )
+    if not blocked_hosts:
+        return
+
+    suggested_flags = " ".join(
+        f"--allow-insecure-host {host}" for host in blocked_hosts
+    )
+    message = (
+        "Transitive HTTP (insecure) dependencies were found on unapproved host(s): "
+        f"{', '.join(blocked_hosts)}. "
+        "--allow-insecure only covers direct HTTP dependencies and transitive "
+        "HTTP dependencies on the same host. "
+        f"Re-run with {suggested_flags} to allow these transitive hosts."
+    )
+    if logger:
+        logger.error(message)
+    else:
+        _rich_error(message)
+    sys.exit(1)
+
+
+def _check_insecure_dependencies(
+    deps, allow_insecure_flag: bool, logger=None
+) -> None:
+    """Check direct APM dependencies for HTTP (insecure) URLs and enforce policy.
+
+    Two conditions must BOTH be true for an HTTP dep to be allowed:
+    1. The dep entry in apm.yml must have allow_insecure: true
+    2. --allow-insecure must be set for this install invocation
+
+    Args:
+        deps: List of DependencyReference objects to check.
+        allow_insecure_flag: True if --allow-insecure was passed on the command line.
+    """
+    for dep in deps:
+        dep_is_insecure = getattr(dep, "is_insecure", False) is True
+        if not dep_is_insecure:
+            continue
+        identity = dep.get_identity()
+        dep_allow_insecure = getattr(dep, "allow_insecure", False) is True
+        if not dep_allow_insecure:
+            message = (
+                f"Dependency '{identity}' uses HTTP (insecure) but "
+                f"'allow_insecure: true' is not set in its apm.yml entry. "
+                f"Add 'allow_insecure: true' to the dependency, or use a HTTPS URL instead."
+            )
+            if logger:
+                logger.error(message)
+            else:
+                _rich_error(message)
+            sys.exit(1)
+        if not allow_insecure_flag:
+            message = (
+                f"Dependency '{identity}' uses HTTP (insecure). "
+                f"Pass '--allow-insecure' to apm install to confirm this install."
+            )
+            if logger:
+                logger.error(message)
+            else:
+                _rich_error(message)
+            sys.exit(1)
diff --git a/src/apm_cli/install/phases/resolve.py b/src/apm_cli/install/phases/resolve.py
index 7fd4c4a7a..3e3e51de6 100644
--- a/src/apm_cli/install/phases/resolve.py
+++ b/src/apm_cli/install/phases/resolve.py
@@ -297,7 +297,7 @@ def _collect_descendants(node, visited=None):
             if dep.get_identity() in only_identities
         ]
 
-    from apm_cli.commands.install import (
+    from apm_cli.install.insecure_policy import (
         _check_insecure_dependencies,
         _collect_insecure_dependency_infos,
         _guard_transitive_insecure_dependencies,

From 1fca10b9c395ea96524087344694362866fb7c21 Mon Sep 17 00:00:00 2001
From: arika0093 <delete0093@gmail.com>
Date: Tue, 21 Apr 2026 07:17:18 +0000
Subject: [PATCH 11/22] fix(lockfile): replay allow_insecure for HTTP
 dependencies (review item 4)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 src/apm_cli/drift.py              | 3 +++
 tests/unit/test_install_update.py | 1 +
 2 files changed, 4 insertions(+)

diff --git a/src/apm_cli/drift.py b/src/apm_cli/drift.py
index b56fdba10..d8944bc86 100644
--- a/src/apm_cli/drift.py
+++ b/src/apm_cli/drift.py
@@ -258,6 +258,9 @@ def build_download_ref(
 
             if getattr(locked_dep, "is_insecure", False) is True:
                 overrides["is_insecure"] = True
+                overrides["allow_insecure"] = getattr(
+                    locked_dep, "allow_insecure", False
+                )
 
             # Use locked commit SHA for byte-for-byte reproducibility.
             if locked_dep.resolved_commit and locked_dep.resolved_commit != "cached":
diff --git a/tests/unit/test_install_update.py b/tests/unit/test_install_update.py
index 2dc53ebd1..3e0a0f1f2 100644
--- a/tests/unit/test_install_update.py
+++ b/tests/unit/test_install_update.py
@@ -303,6 +303,7 @@ def test_http_lockfile_restores_insecure_scheme(self):
         assert ref.host == "git.company.internal"
         assert ref.reference == "abc123def456"
         assert ref.is_insecure is True
+        assert ref.allow_insecure is True
 
 
 class TestPreDownloadRefLockfileOverride:

From caeb13d9fba85b343f5e3d65a7ec46b52d20ad51 Mon Sep 17 00:00:00 2001
From: arika0093 <delete0093@gmail.com>
Date: Tue, 21 Apr 2026 07:19:18 +0000
Subject: [PATCH 12/22] fix(reference): keep canonical dependency strings
 scheme-free (review item 5)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 src/apm_cli/install/insecure_policy.py     | 14 +++++++++++++-
 src/apm_cli/models/dependency/reference.py | 20 +++++++-------------
 tests/unit/test_canonicalization.py        | 11 +++++------
 3 files changed, 25 insertions(+), 20 deletions(-)

diff --git a/src/apm_cli/install/insecure_policy.py b/src/apm_cli/install/insecure_policy.py
index 3f6f555da..402c3653f 100644
--- a/src/apm_cli/install/insecure_policy.py
+++ b/src/apm_cli/install/insecure_policy.py
@@ -34,7 +34,7 @@ def _collect_insecure_dependency_infos(
         parent = node.parent if node else None
         insecure_infos.append(
             _InsecureDependencyInfo(
-                url=dep.to_canonical(),
+                url=_get_insecure_dependency_url(dep),
                 is_transitive=parent is not None,
                 introduced_by=(
                     parent.dependency_ref.get_display_name()
@@ -47,6 +47,18 @@ def _collect_insecure_dependency_infos(
     return insecure_infos
 
 
+def _get_insecure_dependency_url(dep) -> str:
+    """Return the transport-aware display URL for an insecure dependency."""
+    entry = dep.to_apm_yml_entry()
+    if not isinstance(entry, dict):
+        return entry
+
+    url = entry["git"]
+    if entry.get("ref"):
+        return f"{url}#{entry['ref']}"
+    return url
+
+
 def _format_insecure_dependency_warning(info: _InsecureDependencyInfo) -> str:
     """Render the install-time warning text for an insecure dependency."""
     message = f"Fetching insecurely (no transport auth): {info.url}"
diff --git a/src/apm_cli/models/dependency/reference.py b/src/apm_cli/models/dependency/reference.py
index ea516077c..0cd95d68a 100644
--- a/src/apm_cli/models/dependency/reference.py
+++ b/src/apm_cli/models/dependency/reference.py
@@ -198,7 +198,7 @@ def get_unique_key(self) -> str:
         return self.repo_url
 
     def to_canonical(self) -> str:
-        """Return the canonical form of this dependency for storage in apm.yml.
+        """Return the canonical scheme-free identity string for this dependency.
 
         Follows the Docker-style default-registry convention:
         - Default host (github.com) is stripped  ->  owner/repo
@@ -206,9 +206,10 @@ def to_canonical(self) -> str:
         - Virtual paths are appended              ->  owner/repo/path/to/thing
         - Refs are appended with #                ->  owner/repo#v1.0
         - Local paths are returned as-is          ->  ./packages/my-pkg
-        - HTTP deps preserve the http:// prefix   ->  http://host/owner/repo
 
-        No .git suffix, no git@  -- just the canonical identifier.
+        No .git suffix, no git@, and no transport scheme -- just the canonical
+        identifier. Use ``to_apm_yml_entry()`` when the serialized apm.yml value
+        must preserve an explicit ``http://`` transport.
 
         Returns:
             str: Canonical dependency string
@@ -218,13 +219,6 @@ def to_canonical(self) -> str:
 
         host = self.host or default_host()
 
-        # HTTP deps always include the full http://host/path form
-        if self.is_insecure:
-            result = f"http://{host}/{self.repo_url}"
-            if self.reference:
-                result = f"{result}#{self.reference}"
-            return result
-
         is_default = host.lower() == default_host().lower()
         # Custom port is part of the transport and must travel with the host label.
         host_label = f"{host}:{self.port}" if self.port else host
@@ -277,7 +271,7 @@ def get_identity(self) -> str:
 
     @staticmethod
     def canonicalize(raw: str) -> str:
-        """Parse any raw input form and return its canonical storage form.
+        """Parse any raw input form and return its canonical identifier form.
 
         Convenience method that combines parse() + to_canonical().
 
@@ -285,7 +279,7 @@ def canonicalize(raw: str) -> str:
             raw: Any supported input form (shorthand, FQDN, HTTPS, SSH, etc.)
 
         Returns:
-            str: Canonical form for apm.yml storage
+            str: Canonical scheme-free identifier form
         """
         return DependencyReference.parse(raw).to_canonical()
 
@@ -296,7 +290,7 @@ def get_canonical_dependency_string(self) -> str:
         the filesystem layout in apm_modules/ which is also host-blind.
 
         For identity-based matching that includes non-default hosts, use get_identity().
-        For the full canonical form suitable for apm.yml storage, use to_canonical().
+        For the transport-aware apm.yml entry, use to_apm_yml_entry().
 
         Returns:
             str: Host-blind canonical string (e.g., "owner/repo")
diff --git a/tests/unit/test_canonicalization.py b/tests/unit/test_canonicalization.py
index 6cf3acc4e..383a9e1e9 100644
--- a/tests/unit/test_canonicalization.py
+++ b/tests/unit/test_canonicalization.py
@@ -498,18 +498,17 @@ def test_http_allow_insecure_default_false(self):
         dep = DependencyReference.parse("http://my-server.example.com/owner/repo")
         assert dep.allow_insecure is False
 
-    def test_http_to_canonical_preserves_scheme(self):
-        """to_canonical() for HTTP dep includes http:// prefix."""
+    def test_http_to_canonical_is_scheme_free(self):
+        """to_canonical() for HTTP dep keeps the canonical identifier scheme-free."""
         dep = DependencyReference.parse("http://my-server.example.com/owner/repo")
         canonical = dep.to_canonical()
-        assert canonical.startswith("http://")
-        assert "my-server.example.com/owner/repo" in canonical
+        assert canonical == "my-server.example.com/owner/repo"
 
     def test_http_to_canonical_with_ref(self):
-        """to_canonical() for HTTP dep with ref includes #ref."""
+        """to_canonical() for HTTP dep with ref stays scheme-free."""
         dep = DependencyReference.parse("http://my-server.example.com/owner/repo#main")
         canonical = dep.to_canonical()
-        assert canonical == "http://my-server.example.com/owner/repo#main"
+        assert canonical == "my-server.example.com/owner/repo#main"
 
     def test_http_to_apm_yml_entry_returns_dict(self):
         """to_apm_yml_entry() for HTTP dep returns a dict with git key."""

From 85be6d14d27c7b218c20b479a5e718c96e8fe883 Mon Sep 17 00:00:00 2001
From: arika0093 <delete0093@gmail.com>
Date: Tue, 21 Apr 2026 07:22:50 +0000
Subject: [PATCH 13/22] refactor(lockfile): centralize locked dependency
 reference mapping (review item 6)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 src/apm_cli/bundle/plugin_exporter.py | 12 +---------
 src/apm_cli/commands/_helpers.py      | 14 +----------
 src/apm_cli/deps/lockfile.py          | 28 +++++++++++++---------
 tests/unit/test_install_update.py     | 34 +++++++++++++++++++++++++++
 4 files changed, 53 insertions(+), 35 deletions(-)

diff --git a/src/apm_cli/bundle/plugin_exporter.py b/src/apm_cli/bundle/plugin_exporter.py
index 45f85fb1c..61dee0a71 100644
--- a/src/apm_cli/bundle/plugin_exporter.py
+++ b/src/apm_cli/bundle/plugin_exporter.py
@@ -396,17 +396,7 @@ def _update_plugin_json_paths(plugin_json: dict, output_files: List[str]) -> dic
 
 def _dep_install_path(dep: LockedDependency, apm_modules_dir: Path) -> Path:
     """Compute the filesystem install path for a locked dependency."""
-    dep_ref = DependencyReference(
-        repo_url=dep.repo_url,
-        host=dep.host,
-        virtual_path=dep.virtual_path,
-        is_virtual=dep.is_virtual,
-        artifactory_prefix=dep.registry_prefix,
-        is_local=(dep.source == "local"),
-        local_path=dep.local_path,
-        is_insecure=dep.is_insecure,
-        allow_insecure=dep.allow_insecure,
-    )
+    dep_ref = dep.to_dependency_ref()
     return dep_ref.get_install_path(apm_modules_dir)
 
 
diff --git a/src/apm_cli/commands/_helpers.py b/src/apm_cli/commands/_helpers.py
index d4964ad68..045137ea5 100644
--- a/src/apm_cli/commands/_helpers.py
+++ b/src/apm_cli/commands/_helpers.py
@@ -121,8 +121,6 @@ def _build_expected_install_paths(declared_deps, lockfile, apm_modules_dir: Path
     (depth > 1 from ``apm.lock``), using ``get_install_path()`` for
     consistency with how packages are actually installed.
     """
-    from ..models.apm_package import DependencyReference
-
     expected = builtins.set()
     for dep in declared_deps:
         install_path = dep.get_install_path(apm_modules_dir)
@@ -135,17 +133,7 @@ def _build_expected_install_paths(declared_deps, lockfile, apm_modules_dir: Path
     if lockfile:
         for dep in lockfile.get_all_dependencies():
             if dep.depth is not None and dep.depth > 1:
-                dep_ref = DependencyReference(
-                    repo_url=dep.repo_url,
-                    host=dep.host,
-                    virtual_path=dep.virtual_path,
-                    is_virtual=dep.is_virtual,
-                    artifactory_prefix=dep.registry_prefix,
-                    is_local=(dep.source == "local"),
-                    local_path=dep.local_path,
-                    is_insecure=dep.is_insecure,
-                    allow_insecure=dep.allow_insecure,
-                )
+                dep_ref = dep.to_dependency_ref()
                 install_path = dep_ref.get_install_path(apm_modules_dir)
                 try:
                     relative_path = install_path.relative_to(apm_modules_dir)
diff --git a/src/apm_cli/deps/lockfile.py b/src/apm_cli/deps/lockfile.py
index e736c2c95..f8b518eb9 100644
--- a/src/apm_cli/deps/lockfile.py
+++ b/src/apm_cli/deps/lockfile.py
@@ -201,6 +201,22 @@ def from_dependency_ref(
             allow_insecure=dep_ref.allow_insecure,
         )
 
+    def to_dependency_ref(self) -> DependencyReference:
+        """Reconstruct a DependencyReference from this locked dependency."""
+        return DependencyReference(
+            repo_url=self.repo_url,
+            host=self.host,
+            port=self.port,
+            reference=self.resolved_ref,
+            virtual_path=self.virtual_path,
+            is_virtual=self.is_virtual,
+            artifactory_prefix=self.registry_prefix,
+            is_local=(self.source == "local"),
+            local_path=self.local_path,
+            is_insecure=self.is_insecure,
+            allow_insecure=self.allow_insecure,
+        )
+
 
 @dataclass
 class LockFile:
@@ -372,17 +388,7 @@ def get_installed_paths(self, apm_modules_dir: Path) -> List[str]:
         seen: set = set()
         paths: List[str] = []
         for dep in self.get_all_dependencies():
-            dep_ref = DependencyReference(
-                repo_url=dep.repo_url,
-                host=dep.host,
-                virtual_path=dep.virtual_path,
-                is_virtual=dep.is_virtual,
-                artifactory_prefix=dep.registry_prefix,
-                is_local=(dep.source == "local"),
-                local_path=dep.local_path,
-                is_insecure=dep.is_insecure,
-                allow_insecure=dep.allow_insecure,
-            )
+            dep_ref = dep.to_dependency_ref()
             install_path = dep_ref.get_install_path(apm_modules_dir)
             try:
                 rel_path = install_path.relative_to(apm_modules_dir).as_posix()
diff --git a/tests/unit/test_install_update.py b/tests/unit/test_install_update.py
index 3e0a0f1f2..9fffba25f 100644
--- a/tests/unit/test_install_update.py
+++ b/tests/unit/test_install_update.py
@@ -306,6 +306,40 @@ def test_http_lockfile_restores_insecure_scheme(self):
         assert ref.allow_insecure is True
 
 
+class TestLockedDependencyToDependencyRef:
+    """Tests for LockedDependency.to_dependency_ref()."""
+
+    def test_to_dependency_ref_preserves_install_path_fields(self):
+        """Reconstructed refs keep the fields used by install path resolution."""
+        locked = LockedDependency(
+            repo_url="team/repo",
+            host="gitlab.example.com",
+            port=8443,
+            registry_prefix="artifactory/github",
+            resolved_ref="main",
+            virtual_path="prompts/review.prompt.md",
+            is_virtual=True,
+            source="local",
+            local_path="./packages/repo",
+            is_insecure=True,
+            allow_insecure=True,
+        )
+
+        dep_ref = locked.to_dependency_ref()
+
+        assert dep_ref.repo_url == "team/repo"
+        assert dep_ref.host == "gitlab.example.com"
+        assert dep_ref.port == 8443
+        assert dep_ref.reference == "main"
+        assert dep_ref.virtual_path == "prompts/review.prompt.md"
+        assert dep_ref.is_virtual is True
+        assert dep_ref.artifactory_prefix == "artifactory/github"
+        assert dep_ref.is_local is True
+        assert dep_ref.local_path == "./packages/repo"
+        assert dep_ref.is_insecure is True
+        assert dep_ref.allow_insecure is True
+
+
 class TestPreDownloadRefLockfileOverride:
     """Same as TestDownloadRefLockfileOverride but for the parallel pre-download path.
 

From f08a716aa3499b19fc834f353b6b15c77475f7f0 Mon Sep 17 00:00:00 2001
From: arika0093 <delete0093@gmail.com>
Date: Tue, 21 Apr 2026 07:24:43 +0000
Subject: [PATCH 14/22] fix(install): unify HTTP dependency remediation errors
 (review item 7)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 src/apm_cli/commands/install.py        |  7 ++++---
 src/apm_cli/install/insecure_policy.py | 23 +++++++++++++----------
 tests/unit/test_install_command.py     | 21 +++++++++++++++++----
 3 files changed, 34 insertions(+), 17 deletions(-)

diff --git a/src/apm_cli/commands/install.py b/src/apm_cli/commands/install.py
index 7de111c24..fe54b99de 100644
--- a/src/apm_cli/commands/install.py
+++ b/src/apm_cli/commands/install.py
@@ -65,7 +65,9 @@
     _check_insecure_dependencies,
     _collect_insecure_dependency_infos,
     _format_insecure_dependency_warning,
+    _format_insecure_dependency_requirements,
     _guard_transitive_insecure_dependencies,
+    _get_insecure_dependency_url,
     _normalize_allow_insecure_host,
     _warn_insecure_dependencies,
 )
@@ -251,9 +253,8 @@ def _validate_and_add_packages_to_apm_yml(packages, dry_run=False, dev=False, lo
 
         if dep_ref.is_insecure:
             if not allow_insecure:
-                reason = (
-                    f"'{canonical}' uses HTTP (insecure). "
-                    f"Pass '--allow-insecure' to allow this install."
+                reason = _format_insecure_dependency_requirements(
+                    _get_insecure_dependency_url(dep_ref)
                 )
                 invalid_outcomes.append((package, reason))
                 if logger:
diff --git a/src/apm_cli/install/insecure_policy.py b/src/apm_cli/install/insecure_policy.py
index 402c3653f..0f7a9cfc0 100644
--- a/src/apm_cli/install/insecure_policy.py
+++ b/src/apm_cli/install/insecure_policy.py
@@ -59,6 +59,16 @@ def _get_insecure_dependency_url(dep) -> str:
     return url
 
 
+def _format_insecure_dependency_requirements(url: str) -> str:
+    """Render the canonical remediation message for an HTTP dependency."""
+    return (
+        f"{url} -- HTTP dependency (no transport encryption)\n"
+        "To install:\n"
+        "  1. Set allow_insecure: true on the dep in apm.yml\n"
+        "  2. Pass --allow-insecure to apm install"
+    )
+
+
 def _format_insecure_dependency_warning(info: _InsecureDependencyInfo) -> str:
     """Render the install-time warning text for an insecure dependency."""
     message = f"Fetching insecurely (no transport auth): {info.url}"
@@ -193,24 +203,17 @@ def _check_insecure_dependencies(
         dep_is_insecure = getattr(dep, "is_insecure", False) is True
         if not dep_is_insecure:
             continue
-        identity = dep.get_identity()
+        url = _get_insecure_dependency_url(dep)
         dep_allow_insecure = getattr(dep, "allow_insecure", False) is True
         if not dep_allow_insecure:
-            message = (
-                f"Dependency '{identity}' uses HTTP (insecure) but "
-                f"'allow_insecure: true' is not set in its apm.yml entry. "
-                f"Add 'allow_insecure: true' to the dependency, or use a HTTPS URL instead."
-            )
+            message = _format_insecure_dependency_requirements(url)
             if logger:
                 logger.error(message)
             else:
                 _rich_error(message)
             sys.exit(1)
         if not allow_insecure_flag:
-            message = (
-                f"Dependency '{identity}' uses HTTP (insecure). "
-                f"Pass '--allow-insecure' to apm install to confirm this install."
-            )
+            message = _format_insecure_dependency_requirements(url)
             if logger:
                 logger.error(message)
             else:
diff --git a/tests/unit/test_install_command.py b/tests/unit/test_install_command.py
index 6d121fadc..e5effbe4d 100644
--- a/tests/unit/test_install_command.py
+++ b/tests/unit/test_install_command.py
@@ -1158,10 +1158,11 @@ def test_http_dep_rejected_without_allow_insecure_flag(self):
                 with patch("apm_cli.commands.install._validate_package_exists", return_value=True):
                     result = self.runner.invoke(
                         cli, ["install", "http://my-server.example.com/owner/repo"]
-                    )
+                     )
             finally:
                 os.chdir(self.original_dir)
-            assert "allow-insecure" in result.output or "insecure" in result.output.lower()
+            assert "allow_insecure: true" in result.output
+            assert "--allow-insecure" in result.output
 
     def test_install_help_mentions_allow_insecure_for_http_deps(self):
         """Install help should mention the HTTP allow-insecure flow."""
@@ -1275,9 +1276,15 @@ def test_http_dep_validation_check(self):
         dep = DependencyReference.parse("http://my-server.example.com/owner/repo")
         dep.allow_insecure = True
 
-        with pytest.raises(SystemExit) as exc_info:
+        with patch("apm_cli.install.insecure_policy._rich_error") as mock_error, pytest.raises(
+            SystemExit
+        ) as exc_info:
             _check_insecure_dependencies([dep], allow_insecure_flag=False)
         assert exc_info.value.code == 1
+        message = mock_error.call_args.args[0]
+        assert "http://my-server.example.com/owner/repo" in message
+        assert "allow_insecure: true" in message
+        assert "--allow-insecure" in message
 
     def test_http_dep_passes_with_allow_insecure_flag(self):
         """_check_insecure_dependencies passes when flag is set and dep has allow_insecure."""
@@ -1296,9 +1303,15 @@ def test_http_dep_without_dep_level_allow_insecure_is_blocked(self):
 
         dep = DependencyReference.parse("http://my-server.example.com/owner/repo")
 
-        with pytest.raises(SystemExit) as exc_info:
+        with patch("apm_cli.install.insecure_policy._rich_error") as mock_error, pytest.raises(
+            SystemExit
+        ) as exc_info:
             _check_insecure_dependencies([dep], allow_insecure_flag=True)
         assert exc_info.value.code == 1
+        message = mock_error.call_args.args[0]
+        assert "http://my-server.example.com/owner/repo" in message
+        assert "allow_insecure: true" in message
+        assert "--allow-insecure" in message
 
     def test_https_dep_passes_without_flag(self):
         """_check_insecure_dependencies does not block HTTPS deps."""

From 17e40a3bf762aac8b32b102b1050117e871e232d Mon Sep 17 00:00:00 2001
From: arika0093 <delete0093@gmail.com>
Date: Tue, 21 Apr 2026 07:25:23 +0000
Subject: [PATCH 15/22] docs(security): document HTTP dependency threat model
 (review item 8)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 docs/src/content/docs/enterprise/security.md | 20 ++++++++++++++++++++
 1 file changed, 20 insertions(+)

diff --git a/docs/src/content/docs/enterprise/security.md b/docs/src/content/docs/enterprise/security.md
index e6793bf7b..1bed55cb2 100644
--- a/docs/src/content/docs/enterprise/security.md
+++ b/docs/src/content/docs/enterprise/security.md
@@ -61,6 +61,26 @@ The `resolved_commit` field is a full 40-character SHA, not a branch name or tag
 
 APM does not use a package registry. Dependencies are specified as git repository URLs in `apm.yml`. This eliminates the registry compromise vector entirely — there is no centralized service that can be poisoned to redirect installs.
 
+### HTTP (insecure) dependencies
+
+APM supports `http://` git dependencies for private mirrors and air-gapped
+environments, but only behind explicit approval on both the manifest and CLI
+surfaces:
+
+- `allow_insecure: true` on the dependency entry records that the project
+  intentionally permits HTTP for that dependency.
+- `apm install --allow-insecure` approves direct HTTP dependencies for the
+  current install run.
+- Transitive HTTP dependencies inherit approval only when they come from the
+  same host as an approved direct HTTP dependency. Additional transitive hosts
+  require `--allow-insecure-host HOSTNAME`.
+
+These controls make the decision visible, but they do **not** make HTTP safe:
+
+- HTTP has no transport encryption or server authentication. A machine-in-the-middle can modify repository contents or refs in transit.
+- On the first HTTP fetch (or any update fetched over HTTP), the lockfile's `resolved_commit` and `content_hash` come from that same untrusted channel. They improve replay detection later, but they do not establish trustworthy provenance for the initial fetch.
+- APM explicitly suppresses git credential helpers for HTTP clone and `ls-remote` operations so stored tokens from Keychain, Credential Manager, `gh auth`, or other helpers are not sent over plaintext HTTP.
+
 ## Content scanning
 
 ### The threat

From 32cb21f7f31241179ee5ce199abe250ff69c095b Mon Sep 17 00:00:00 2001
From: arika0093 <delete0093@gmail.com>
Date: Tue, 21 Apr 2026 07:29:21 +0000
Subject: [PATCH 16/22] refactor(install): require logger for insecure policy
 diagnostics (review item 9)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 src/apm_cli/commands/install.py        |  2 +-
 src/apm_cli/install/insecure_policy.py | 28 +++++++---------------
 src/apm_cli/install/phases/resolve.py  |  6 ++---
 tests/unit/test_install_command.py     | 33 ++++++++++++++++----------
 4 files changed, 32 insertions(+), 37 deletions(-)

diff --git a/src/apm_cli/commands/install.py b/src/apm_cli/commands/install.py
index fe54b99de..612535ebd 100644
--- a/src/apm_cli/commands/install.py
+++ b/src/apm_cli/commands/install.py
@@ -589,7 +589,7 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo
         mcp_deps = apm_package.get_mcp_dependencies()
 
         all_apm_deps = list(apm_deps) + list(dev_apm_deps)
-        _check_insecure_dependencies(all_apm_deps, allow_insecure, logger=logger)
+        _check_insecure_dependencies(all_apm_deps, allow_insecure, logger)
 
         # Convert --only string to InstallMode enum
         if only is None:
diff --git a/src/apm_cli/install/insecure_policy.py b/src/apm_cli/install/insecure_policy.py
index 0f7a9cfc0..94a6f6987 100644
--- a/src/apm_cli/install/insecure_policy.py
+++ b/src/apm_cli/install/insecure_policy.py
@@ -6,7 +6,6 @@
 
 import click
 
-from ..utils.console import _rich_error, _rich_warning
 from ..utils.github_host import is_valid_fqdn
 
 
@@ -79,14 +78,11 @@ def _format_insecure_dependency_warning(info: _InsecureDependencyInfo) -> str:
     return message
 
 
-def _warn_insecure_dependencies(insecure_infos, logger=None) -> None:
+def _warn_insecure_dependencies(insecure_infos, logger, /) -> None:
     """Emit one warning per insecure dependency before fetch begins."""
     for info in insecure_infos:
         message = _format_insecure_dependency_warning(info)
-        if logger:
-            logger.warning(message)
-        else:
-            _rich_warning(message)
+        logger.warning(message)
 
 
 def _normalize_allow_insecure_host(hostname: str) -> str:
@@ -142,10 +138,11 @@ def _get_allowed_transitive_insecure_hosts(
 
 def _guard_transitive_insecure_dependencies(
     insecure_infos,
+    logger,
+    /,
     *,
     allow_insecure: bool,
     allow_insecure_hosts=(),
-    logger=None,
 ) -> None:
     """Block transitive insecure dependencies from unapproved hosts."""
     transitive_infos = [info for info in insecure_infos if info.is_transitive]
@@ -179,15 +176,12 @@ def _guard_transitive_insecure_dependencies(
         "HTTP dependencies on the same host. "
         f"Re-run with {suggested_flags} to allow these transitive hosts."
     )
-    if logger:
-        logger.error(message)
-    else:
-        _rich_error(message)
+    logger.error(message)
     sys.exit(1)
 
 
 def _check_insecure_dependencies(
-    deps, allow_insecure_flag: bool, logger=None
+    deps, allow_insecure_flag: bool, logger, /
 ) -> None:
     """Check direct APM dependencies for HTTP (insecure) URLs and enforce policy.
 
@@ -207,15 +201,9 @@ def _check_insecure_dependencies(
         dep_allow_insecure = getattr(dep, "allow_insecure", False) is True
         if not dep_allow_insecure:
             message = _format_insecure_dependency_requirements(url)
-            if logger:
-                logger.error(message)
-            else:
-                _rich_error(message)
+            logger.error(message)
             sys.exit(1)
         if not allow_insecure_flag:
             message = _format_insecure_dependency_requirements(url)
-            if logger:
-                logger.error(message)
-            else:
-                _rich_error(message)
+            logger.error(message)
             sys.exit(1)
diff --git a/src/apm_cli/install/phases/resolve.py b/src/apm_cli/install/phases/resolve.py
index 3e3e51de6..f6118ca78 100644
--- a/src/apm_cli/install/phases/resolve.py
+++ b/src/apm_cli/install/phases/resolve.py
@@ -307,18 +307,18 @@ def _collect_descendants(node, visited=None):
     _check_insecure_dependencies(
         ctx.all_apm_deps,
         ctx.allow_insecure,
-        logger=ctx.logger,
+        ctx.logger,
     )
     insecure_infos = _collect_insecure_dependency_infos(
         deps_to_install,
         dependency_graph,
     )
-    _warn_insecure_dependencies(insecure_infos, logger=ctx.logger)
+    _warn_insecure_dependencies(insecure_infos, ctx.logger)
     _guard_transitive_insecure_dependencies(
         insecure_infos,
+        ctx.logger,
         allow_insecure=ctx.allow_insecure,
         allow_insecure_hosts=ctx.allow_insecure_hosts,
-        logger=ctx.logger,
     )
 
     ctx.deps_to_install = deps_to_install
diff --git a/tests/unit/test_install_command.py b/tests/unit/test_install_command.py
index e5effbe4d..cd75c5e44 100644
--- a/tests/unit/test_install_command.py
+++ b/tests/unit/test_install_command.py
@@ -1275,13 +1275,12 @@ def test_http_dep_validation_check(self):
 
         dep = DependencyReference.parse("http://my-server.example.com/owner/repo")
         dep.allow_insecure = True
+        logger = MagicMock()
 
-        with patch("apm_cli.install.insecure_policy._rich_error") as mock_error, pytest.raises(
-            SystemExit
-        ) as exc_info:
-            _check_insecure_dependencies([dep], allow_insecure_flag=False)
+        with pytest.raises(SystemExit) as exc_info:
+            _check_insecure_dependencies([dep], False, logger)
         assert exc_info.value.code == 1
-        message = mock_error.call_args.args[0]
+        message = logger.error.call_args.args[0]
         assert "http://my-server.example.com/owner/repo" in message
         assert "allow_insecure: true" in message
         assert "--allow-insecure" in message
@@ -1293,8 +1292,9 @@ def test_http_dep_passes_with_allow_insecure_flag(self):
 
         dep = DependencyReference.parse("http://my-server.example.com/owner/repo")
         dep.allow_insecure = True
+        logger = MagicMock()
 
-        _check_insecure_dependencies([dep], allow_insecure_flag=True)
+        _check_insecure_dependencies([dep], True, logger)
 
     def test_http_dep_without_dep_level_allow_insecure_is_blocked(self):
         """_check_insecure_dependencies blocks HTTP dep missing allow_insecure=True on dep."""
@@ -1302,13 +1302,12 @@ def test_http_dep_without_dep_level_allow_insecure_is_blocked(self):
         from apm_cli.models.dependency.reference import DependencyReference
 
         dep = DependencyReference.parse("http://my-server.example.com/owner/repo")
+        logger = MagicMock()
 
-        with patch("apm_cli.install.insecure_policy._rich_error") as mock_error, pytest.raises(
-            SystemExit
-        ) as exc_info:
-            _check_insecure_dependencies([dep], allow_insecure_flag=True)
+        with pytest.raises(SystemExit) as exc_info:
+            _check_insecure_dependencies([dep], True, logger)
         assert exc_info.value.code == 1
-        message = mock_error.call_args.args[0]
+        message = logger.error.call_args.args[0]
         assert "http://my-server.example.com/owner/repo" in message
         assert "allow_insecure: true" in message
         assert "--allow-insecure" in message
@@ -1319,13 +1318,13 @@ def test_https_dep_passes_without_flag(self):
         from apm_cli.models.dependency.reference import DependencyReference
 
         dep = DependencyReference.parse("owner/repo")
-        _check_insecure_dependencies([dep], allow_insecure_flag=False)
+        _check_insecure_dependencies([dep], False, MagicMock())
 
     def test_empty_deps_list_passes(self):
         """_check_insecure_dependencies handles empty dep list."""
         from apm_cli.commands.install import _check_insecure_dependencies
 
-        _check_insecure_dependencies([], allow_insecure_flag=False)
+        _check_insecure_dependencies([], False, MagicMock())
 
 
 class TestInsecureDependencyWarnings:
@@ -1403,6 +1402,7 @@ def test_transitive_guard_blocks_unapproved_host(self):
             _InsecureDependencyInfo,
             _guard_transitive_insecure_dependencies,
         )
+        logger = MagicMock()
 
         with pytest.raises(SystemExit) as exc_info:
             _guard_transitive_insecure_dependencies(
@@ -1413,6 +1413,7 @@ def test_transitive_guard_blocks_unapproved_host(self):
                         introduced_by="owner/root-package",
                     )
                 ],
+                logger,
                 allow_insecure=False,
                 allow_insecure_hosts=(),
             )
@@ -1425,6 +1426,7 @@ def test_transitive_guard_allows_same_host_as_direct_insecure_dependency(self):
             _InsecureDependencyInfo,
             _guard_transitive_insecure_dependencies,
         )
+        logger = MagicMock()
 
         _guard_transitive_insecure_dependencies(
             [
@@ -1439,6 +1441,7 @@ def test_transitive_guard_allows_same_host_as_direct_insecure_dependency(self):
                     introduced_by="owner/root-package",
                 ),
             ],
+            logger,
             allow_insecure=True,
             allow_insecure_hosts=(),
         )
@@ -1449,6 +1452,7 @@ def test_transitive_guard_accepts_explicit_host(self):
             _InsecureDependencyInfo,
             _guard_transitive_insecure_dependencies,
         )
+        logger = MagicMock()
 
         _guard_transitive_insecure_dependencies(
             [
@@ -1458,6 +1462,7 @@ def test_transitive_guard_accepts_explicit_host(self):
                     introduced_by="owner/root-package",
                 )
             ],
+            logger,
             allow_insecure=False,
             allow_insecure_hosts=("mirror.example.com",),
         )
@@ -1468,6 +1473,7 @@ def test_transitive_guard_blocks_different_host_without_explicit_allowance(self)
             _InsecureDependencyInfo,
             _guard_transitive_insecure_dependencies,
         )
+        logger = MagicMock()
 
         with pytest.raises(SystemExit) as exc_info:
             _guard_transitive_insecure_dependencies(
@@ -1483,6 +1489,7 @@ def test_transitive_guard_blocks_different_host_without_explicit_allowance(self)
                         introduced_by="owner/root-package",
                     ),
                 ],
+                logger,
                 allow_insecure=True,
                 allow_insecure_hosts=(),
             )

From b067ab7a939f52a2a0685c8273290d9a3e4302a0 Mon Sep 17 00:00:00 2001
From: arika0093 <delete0093@gmail.com>
Date: Tue, 21 Apr 2026 07:33:12 +0000
Subject: [PATCH 17/22] refactor(install): raise policy errors instead of
 exiting (review item 10)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 src/apm_cli/commands/install.py        |  5 +++++
 src/apm_cli/install/insecure_policy.py | 11 +++++++----
 tests/unit/test_install_command.py     | 20 ++++++++++++--------
 3 files changed, 24 insertions(+), 12 deletions(-)

diff --git a/src/apm_cli/commands/install.py b/src/apm_cli/commands/install.py
index 612535ebd..1f52d1fd2 100644
--- a/src/apm_cli/commands/install.py
+++ b/src/apm_cli/commands/install.py
@@ -70,6 +70,7 @@
     _get_insecure_dependency_url,
     _normalize_allow_insecure_host,
     _warn_insecure_dependencies,
+    InsecureDependencyPolicyError,
 )
 
 # Re-export the pre-deploy security scan so that bare-name call sites inside
@@ -681,6 +682,8 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo
                 prompt_count = install_result.prompts_integrated
                 agent_count = install_result.agents_integrated
                 apm_diagnostics = install_result.diagnostics
+            except InsecureDependencyPolicyError:
+                sys.exit(1)
             except Exception as e:
                 logger.error(f"Failed to install APM dependencies: {e}")
                 if not verbose:
@@ -769,6 +772,8 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo
         if not force and apm_diagnostics and apm_diagnostics.has_critical_security:
             sys.exit(1)
 
+    except InsecureDependencyPolicyError:
+        sys.exit(1)
     except Exception as e:
         logger.error(f"Error installing dependencies: {e}")
         if not verbose:
diff --git a/src/apm_cli/install/insecure_policy.py b/src/apm_cli/install/insecure_policy.py
index 94a6f6987..3de67f35a 100644
--- a/src/apm_cli/install/insecure_policy.py
+++ b/src/apm_cli/install/insecure_policy.py
@@ -1,6 +1,5 @@
 """HTTP dependency policy helpers shared by install command and pipeline."""
 
-import sys
 import urllib.parse
 from dataclasses import dataclass
 
@@ -9,6 +8,10 @@
 from ..utils.github_host import is_valid_fqdn
 
 
+class InsecureDependencyPolicyError(RuntimeError):
+    """Raised when HTTP dependency policy blocks the current install."""
+
+
 @dataclass(frozen=True)
 class _InsecureDependencyInfo:
     """Resolved insecure dependency details for warnings and consent checks."""
@@ -177,7 +180,7 @@ def _guard_transitive_insecure_dependencies(
         f"Re-run with {suggested_flags} to allow these transitive hosts."
     )
     logger.error(message)
-    sys.exit(1)
+    raise InsecureDependencyPolicyError(message)
 
 
 def _check_insecure_dependencies(
@@ -202,8 +205,8 @@ def _check_insecure_dependencies(
         if not dep_allow_insecure:
             message = _format_insecure_dependency_requirements(url)
             logger.error(message)
-            sys.exit(1)
+            raise InsecureDependencyPolicyError(message)
         if not allow_insecure_flag:
             message = _format_insecure_dependency_requirements(url)
             logger.error(message)
-            sys.exit(1)
+            raise InsecureDependencyPolicyError(message)
diff --git a/tests/unit/test_install_command.py b/tests/unit/test_install_command.py
index cd75c5e44..d84156d88 100644
--- a/tests/unit/test_install_command.py
+++ b/tests/unit/test_install_command.py
@@ -1271,15 +1271,16 @@ def test_allow_insecure_host_is_passed_to_install_engine(
     def test_http_dep_validation_check(self):
         """_check_insecure_dependencies blocks HTTP dep without allow_insecure flag."""
         from apm_cli.commands.install import _check_insecure_dependencies
+        from apm_cli.install.insecure_policy import InsecureDependencyPolicyError
         from apm_cli.models.dependency.reference import DependencyReference
 
         dep = DependencyReference.parse("http://my-server.example.com/owner/repo")
         dep.allow_insecure = True
         logger = MagicMock()
 
-        with pytest.raises(SystemExit) as exc_info:
+        with pytest.raises(InsecureDependencyPolicyError) as exc_info:
             _check_insecure_dependencies([dep], False, logger)
-        assert exc_info.value.code == 1
+        assert "http://my-server.example.com/owner/repo" in str(exc_info.value)
         message = logger.error.call_args.args[0]
         assert "http://my-server.example.com/owner/repo" in message
         assert "allow_insecure: true" in message
@@ -1299,14 +1300,15 @@ def test_http_dep_passes_with_allow_insecure_flag(self):
     def test_http_dep_without_dep_level_allow_insecure_is_blocked(self):
         """_check_insecure_dependencies blocks HTTP dep missing allow_insecure=True on dep."""
         from apm_cli.commands.install import _check_insecure_dependencies
+        from apm_cli.install.insecure_policy import InsecureDependencyPolicyError
         from apm_cli.models.dependency.reference import DependencyReference
 
         dep = DependencyReference.parse("http://my-server.example.com/owner/repo")
         logger = MagicMock()
 
-        with pytest.raises(SystemExit) as exc_info:
+        with pytest.raises(InsecureDependencyPolicyError) as exc_info:
             _check_insecure_dependencies([dep], True, logger)
-        assert exc_info.value.code == 1
+        assert "http://my-server.example.com/owner/repo" in str(exc_info.value)
         message = logger.error.call_args.args[0]
         assert "http://my-server.example.com/owner/repo" in message
         assert "allow_insecure: true" in message
@@ -1402,9 +1404,10 @@ def test_transitive_guard_blocks_unapproved_host(self):
             _InsecureDependencyInfo,
             _guard_transitive_insecure_dependencies,
         )
+        from apm_cli.install.insecure_policy import InsecureDependencyPolicyError
         logger = MagicMock()
 
-        with pytest.raises(SystemExit) as exc_info:
+        with pytest.raises(InsecureDependencyPolicyError) as exc_info:
             _guard_transitive_insecure_dependencies(
                 [
                     _InsecureDependencyInfo(
@@ -1418,7 +1421,7 @@ def test_transitive_guard_blocks_unapproved_host(self):
                 allow_insecure_hosts=(),
             )
 
-        assert exc_info.value.code == 1
+        assert "mirror.example.com" in str(exc_info.value)
 
     def test_transitive_guard_allows_same_host_as_direct_insecure_dependency(self):
         """A direct insecure dependency host also permits transitive deps on that host."""
@@ -1473,9 +1476,10 @@ def test_transitive_guard_blocks_different_host_without_explicit_allowance(self)
             _InsecureDependencyInfo,
             _guard_transitive_insecure_dependencies,
         )
+        from apm_cli.install.insecure_policy import InsecureDependencyPolicyError
         logger = MagicMock()
 
-        with pytest.raises(SystemExit) as exc_info:
+        with pytest.raises(InsecureDependencyPolicyError) as exc_info:
             _guard_transitive_insecure_dependencies(
                 [
                     _InsecureDependencyInfo(
@@ -1494,4 +1498,4 @@ def test_transitive_guard_blocks_different_host_without_explicit_allowance(self)
                 allow_insecure_hosts=(),
             )
 
-        assert exc_info.value.code == 1
+        assert "mirror.example.com" in str(exc_info.value)

From 2dd90af05c5240197aa11fb8c4722f352dff6ab6 Mon Sep 17 00:00:00 2001
From: arika0093 <delete0093@gmail.com>
Date: Tue, 21 Apr 2026 07:35:02 +0000
Subject: [PATCH 18/22] fix(install): tighten insecure HTTP warning copy
 (review item 11)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 src/apm_cli/install/insecure_policy.py | 2 +-
 tests/unit/test_install_command.py     | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/apm_cli/install/insecure_policy.py b/src/apm_cli/install/insecure_policy.py
index 3de67f35a..dd335f640 100644
--- a/src/apm_cli/install/insecure_policy.py
+++ b/src/apm_cli/install/insecure_policy.py
@@ -73,7 +73,7 @@ def _format_insecure_dependency_requirements(url: str) -> str:
 
 def _format_insecure_dependency_warning(info: _InsecureDependencyInfo) -> str:
     """Render the install-time warning text for an insecure dependency."""
-    message = f"Fetching insecurely (no transport auth): {info.url}"
+    message = f"Insecure HTTP fetch (unencrypted): {info.url}"
     if info.is_transitive and info.introduced_by:
         message = (
             f"{message} (transitive, introduced by {info.introduced_by})"
diff --git a/tests/unit/test_install_command.py b/tests/unit/test_install_command.py
index d84156d88..a35658325 100644
--- a/tests/unit/test_install_command.py
+++ b/tests/unit/test_install_command.py
@@ -1390,7 +1390,7 @@ def test_format_insecure_dependency_warning_for_transitive_dep(self):
             )
         )
 
-        assert "Fetching insecurely (no transport auth)" in message
+        assert "Insecure HTTP fetch (unencrypted)" in message
         assert "http://mirror.example.com/acme/transitive" in message
         assert "transitive, introduced by owner/root-package" in message
 

From 783450fc56ed735a4f2d86b89c7bc3cf4cee79b9 Mon Sep 17 00:00:00 2001
From: arika0093 <delete0093@gmail.com>
Date: Tue, 21 Apr 2026 07:36:08 +0000
Subject: [PATCH 19/22] fix(install): shorten transitive HTTP remediation error
 (review item 12)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 src/apm_cli/install/insecure_policy.py |  7 ++-----
 tests/unit/test_install_command.py     | 12 ++++++++++--
 2 files changed, 12 insertions(+), 7 deletions(-)

diff --git a/src/apm_cli/install/insecure_policy.py b/src/apm_cli/install/insecure_policy.py
index dd335f640..80611f228 100644
--- a/src/apm_cli/install/insecure_policy.py
+++ b/src/apm_cli/install/insecure_policy.py
@@ -173,11 +173,8 @@ def _guard_transitive_insecure_dependencies(
         f"--allow-insecure-host {host}" for host in blocked_hosts
     )
     message = (
-        "Transitive HTTP (insecure) dependencies were found on unapproved host(s): "
-        f"{', '.join(blocked_hosts)}. "
-        "--allow-insecure only covers direct HTTP dependencies and transitive "
-        "HTTP dependencies on the same host. "
-        f"Re-run with {suggested_flags} to allow these transitive hosts."
+        f"Re-run with {suggested_flags} to allow transitive HTTP dependencies "
+        f"from unapproved host(s): {', '.join(blocked_hosts)}."
     )
     logger.error(message)
     raise InsecureDependencyPolicyError(message)
diff --git a/tests/unit/test_install_command.py b/tests/unit/test_install_command.py
index a35658325..2f356b97c 100644
--- a/tests/unit/test_install_command.py
+++ b/tests/unit/test_install_command.py
@@ -1421,7 +1421,11 @@ def test_transitive_guard_blocks_unapproved_host(self):
                 allow_insecure_hosts=(),
             )
 
-        assert "mirror.example.com" in str(exc_info.value)
+        message = str(exc_info.value)
+        assert message.startswith(
+            "Re-run with --allow-insecure-host mirror.example.com"
+        )
+        assert "unapproved host(s): mirror.example.com" in message
 
     def test_transitive_guard_allows_same_host_as_direct_insecure_dependency(self):
         """A direct insecure dependency host also permits transitive deps on that host."""
@@ -1498,4 +1502,8 @@ def test_transitive_guard_blocks_different_host_without_explicit_allowance(self)
                 allow_insecure_hosts=(),
             )
 
-        assert "mirror.example.com" in str(exc_info.value)
+        message = str(exc_info.value)
+        assert message.startswith(
+            "Re-run with --allow-insecure-host mirror.example.com"
+        )
+        assert "unapproved host(s): mirror.example.com" in message

From b8a5c5f9449fe60632ff910883cbc4d5d4199058 Mon Sep 17 00:00:00 2001
From: arika0093 <delete0093@gmail.com>
Date: Tue, 21 Apr 2026 07:37:09 +0000
Subject: [PATCH 20/22] fix(deps): rename insecure list origin column (review
 item 13)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 src/apm_cli/commands/deps/cli.py       | 4 ++--
 tests/unit/test_deps_list_tree_info.py | 2 ++
 2 files changed, 4 insertions(+), 2 deletions(-)

diff --git a/src/apm_cli/commands/deps/cli.py b/src/apm_cli/commands/deps/cli.py
index 1f521ddbc..fc937e3aa 100644
--- a/src/apm_cli/commands/deps/cli.py
+++ b/src/apm_cli/commands/deps/cli.py
@@ -184,7 +184,7 @@ def _show_scope_deps(scope_label, apm_dir, logger, console, has_rich, insecure_o
         table.add_column("Version", style="yellow")
         table.add_column("Source", style="blue")
         if insecure_only:
-            table.add_column("HTTP", style="bold red")
+            table.add_column("Origin", style="bold red")
         table.add_column("Prompts", style="magenta", justify="center")
         table.add_column("Instructions", style="green", justify="center")
         table.add_column("Agents", style="cyan", justify="center")
@@ -218,7 +218,7 @@ def _show_scope_deps(scope_label, apm_dir, logger, console, has_rich, insecure_o
         if insecure_only:
             click.echo(f" Insecure APM Dependencies ({scope_label}):")
             click.echo(
-                f"{'Package':<30} {'Version':<10} {'Source':<12} {'HTTP':<18} "
+                f"{'Package':<30} {'Version':<10} {'Source':<12} {'Origin':<18} "
                 f"{'Prompts':>7} {'Instr':>7} {'Agents':>7} {'Skills':>7} {'Hooks':>7}"
             )
             click.echo("-" * 117)
diff --git a/tests/unit/test_deps_list_tree_info.py b/tests/unit/test_deps_list_tree_info.py
index a3fe09a16..2cbc0d235 100644
--- a/tests/unit/test_deps_list_tree_info.py
+++ b/tests/unit/test_deps_list_tree_info.py
@@ -216,6 +216,7 @@ def test_list_insecure_filters_to_http_locked_deps(self):
         assert result.exit_code == 0
         assert "insecureorg/insecurerepo" in result.output
         assert "safeorg/saferepo" not in result.output
+        assert "Origin" in result.output
         assert "direct" in result.output
 
     def test_list_insecure_shows_transitive_provenance(self):
@@ -246,6 +247,7 @@ def test_list_insecure_shows_transitive_provenance(self):
 
         assert result.exit_code == 0
         assert "childorg/childrepo" in result.output
+        assert "Origin" in result.output
         assert "via parentorg/pa" in result.output
 
     def test_list_insecure_reports_clean_when_no_http_locked_deps(self):

From 2c7e81bf418dd9e63be9f9d574dacbf4e471903c Mon Sep 17 00:00:00 2001
From: danielmeppiel <dmeppiel@microsoft.com>
Date: Tue, 21 Apr 2026 20:52:36 +0200
Subject: [PATCH 21/22] review: address panel round-2 nits (N1-N5, F2, F3)

Round-2 apm-review-panel findings on PR #700. Five specialists converged
on APPROVE with minor nits; this commit applies the actionable ones.

N1 (cli-logging): fix double-URL rendering in the validation-time path.
`_format_insecure_dependency_requirements` already embeds the full URL in
the reason string, but `logger.validation_fail(package, reason)` prepends
"{package} -- " and produced output like
"[x] http://host/repo -- http://host/repo -- HTTP dependency...". Switch
to `logger.error(reason)` on the insecure-reject branch to match the
other two failure paths. `invalid_outcomes` still tracks the package so
the later validation summary sees it.

N2 (cli-logging): unify jargon register. Warning copy already says
"(unencrypted)" post-review #11; align the error recipe from
"(no transport encryption)" to "(unencrypted)" so both surfaces use the
same wording.

N3 (devx): context-aware remediation steps. `_check_insecure_dependencies`
previously emitted the full two-step recipe in both failure branches,
even when the user had already performed step 1 (manifest edit). The
factory now takes `missing_dep_allow` and `missing_cli_flag` keyword
args and only renders the missing step(s). The add-time validation
path keeps the default of both-steps since the dep is not yet in
apm.yml. Tests updated to match the new per-branch assertions.

N4 (security): document the intentional two-stage credential-helper
policy in `_build_noninteractive_git_env`. The pop-then-conditionally-
restore of GIT_ASKPASS is correct but fragile; an explicit docstring
prevents a future refactor from inverting the logic and leaking
credentials over plaintext HTTP or blocking system keychains on
HTTPS/SSH fallback.

N5 (doc-writer): add `apm deps list --insecure` sample output to
cli-commands.md showing the bold-red `Origin` column with `direct`
and `via <parent>` example rows.

F2 (doc-writer): document `is_insecure` and `allow_insecure` fields in
lockfile-spec.md section 4.2, including replay semantics and legacy
fail-closed behaviour.

F3 (doc-writer): add "HTTP dependencies (opt-in)" section to the
apm-guide dependencies.md skill resource so LLM agents consuming the
skill have the dual-opt-in model, example manifest, example CLI
invocation, and cross-references to commands.md and the enterprise
security guide.

Full unit suite: 4767/4767 passing (one deselect unrelated).
`install.py` at 1497 LOC (under the 1525-LOC invariant).

Panel verdict: microsoft/apm#700 (comment 4290979672)

Co-authored-by: arika0093 <arika0093@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 .../content/docs/reference/cli-commands.md    | 14 ++++++
 .../content/docs/reference/lockfile-spec.md   |  2 +
 .../.apm/skills/apm-usage/dependencies.md     | 32 ++++++++++++
 src/apm_cli/commands/install.py               |  5 +-
 src/apm_cli/deps/github_downloader.py         | 21 +++++++-
 src/apm_cli/install/insecure_policy.py        | 50 +++++++++++++++----
 tests/unit/test_install_command.py            | 10 ++--
 7 files changed, 119 insertions(+), 15 deletions(-)

diff --git a/docs/src/content/docs/reference/cli-commands.md b/docs/src/content/docs/reference/cli-commands.md
index a7ed14101..74d74ebae 100644
--- a/docs/src/content/docs/reference/cli-commands.md
+++ b/docs/src/content/docs/reference/cli-commands.md
@@ -784,6 +784,20 @@ apm deps list --insecure
 └─────────────────────┴─────────┴──────────┴─────────┴──────────────┴────────┴────────┘
 ```
 
+With `--insecure`, an additional `Origin` column (rendered bold red) sits
+between `Source` and `Prompts`. Values are `direct` for HTTP deps declared
+in `apm.yml` and `via <parent>` for transitive HTTP deps pulled in by
+another package:
+
+```
+┌─────────────────┬─────────┬──────────┬────────────────┬─────────┬──────────────┬────────┬────────┐
+│ Package         │ Version │ Source   │ Origin         │ Prompts │ Instructions │ Agents │ Skills │
+├─────────────────┼─────────┼──────────┼────────────────┼─────────┼──────────────┼────────┼────────┤
+│ internal-pkg    │ 1.0.0   │ github   │ direct         │    1    │      -       │   -    │   -    │
+│ shared-rules    │ 2.0.0   │ github   │ via acme/pkg   │    -    │      1       │   -    │   -    │
+└─────────────────┴─────────┴──────────┴────────────────┴─────────┴──────────────┴────────┴────────┘
+```
+
 **Output includes:**
 - Package name and version
 - Source information
diff --git a/docs/src/content/docs/reference/lockfile-spec.md b/docs/src/content/docs/reference/lockfile-spec.md
index bd1a228d0..1acf583b1 100644
--- a/docs/src/content/docs/reference/lockfile-spec.md
+++ b/docs/src/content/docs/reference/lockfile-spec.md
@@ -126,6 +126,8 @@ fields:
 | `deployed_files` | array of strings | MUST | Every file path APM deployed for this dependency, relative to project root. |
 | `source` | string | MAY | Dependency source. `"local"` for local path dependencies. Omitted for remote (git) dependencies. |
 | `local_path` | string | MAY | Filesystem path (relative or absolute) to the local package. Present only when `source` is `"local"`. |
+| `is_insecure` | boolean | MAY | `true` when the dep was fetched over HTTP (unencrypted). Omitted when `false`. Presence forces re-approval on the next install: the apm.yml entry MUST carry `allow_insecure: true` and the invocation MUST pass `--allow-insecure` (or `--allow-insecure-host` for transitive deps). Absent or `false` means HTTPS/SSH. |
+| `allow_insecure` | boolean | MAY | `true` when the user's manifest explicitly approved the HTTP fetch with `allow_insecure: true`. Persisted alongside `is_insecure` for replay safety: a legacy lockfile with `is_insecure: true` but no `allow_insecure` fail-closes to `allow_insecure: false`, forcing re-approval. Omitted when `false`. |
 
 Fields with empty or default values (empty strings, `false` booleans, empty
 lists) SHOULD be omitted from the serialized output to keep the file concise.
diff --git a/packages/apm-guide/.apm/skills/apm-usage/dependencies.md b/packages/apm-guide/.apm/skills/apm-usage/dependencies.md
index 8e55f578e..503297591 100644
--- a/packages/apm-guide/.apm/skills/apm-usage/dependencies.md
+++ b/packages/apm-guide/.apm/skills/apm-usage/dependencies.md
@@ -194,6 +194,38 @@ When installing from a marketplace, the `#` suffix overrides the `source.ref` fr
 | `plugin@mkt#main` | Override with branch | `plugin@mkt#main` |
 | `plugin@mkt#abc123d` | Override with commit SHA | `plugin@mkt#abc123d` |
 
+## HTTP dependencies (opt-in)
+
+HTTP is never attempted implicitly. A dep fetched over `http://` requires
+dual opt-in on every install:
+
+1. **Manifest approval** -- the apm.yml entry carries `allow_insecure: true`.
+2. **Invocation approval** -- `apm install --allow-insecure` for direct
+   deps, or `--allow-insecure-host HOSTNAME` (repeatable) for transitive
+   deps. Transitive HTTP deps from hosts not listed are blocked.
+
+Example apm.yml entry:
+
+```yaml
+dependencies:
+  apm:
+    - git: http://mirror.example.com/acme/rules.git
+      ref: v1.2.0
+      allow_insecure: true
+```
+
+Example invocation:
+
+```bash
+apm install --allow-insecure --allow-insecure-host mirror.example.com
+```
+
+Mental model: HTTP is opt-in per-dep AND per-invocation. Removing either
+side re-locks the dependency. The lockfile records `is_insecure: true` and
+`allow_insecure: true` on the entry so replays fail-closed when either
+approval is dropped. See `commands.md` for full flag syntax and the
+enterprise security guide for the threat model.
+
 ## What the lockfile pins
 
 `apm.lock.yaml` records the exact commit SHA for every dependency, regardless
diff --git a/src/apm_cli/commands/install.py b/src/apm_cli/commands/install.py
index 42f5c4167..dd289e5a6 100644
--- a/src/apm_cli/commands/install.py
+++ b/src/apm_cli/commands/install.py
@@ -289,12 +289,15 @@ def _validate_and_add_packages_to_apm_yml(packages, dry_run=False, dev=False, lo
 
         if dep_ref.is_insecure:
             if not allow_insecure:
+                # The reason string embeds the full URL already, so skip
+                # logger.validation_fail (which prepends "{package} -- ") to
+                # avoid rendering the URL twice. Use logger.error directly.
                 reason = _format_insecure_dependency_requirements(
                     _get_insecure_dependency_url(dep_ref)
                 )
                 invalid_outcomes.append((package, reason))
                 if logger:
-                    logger.validation_fail(package, reason)
+                    logger.error(reason)
                 continue
             dep_ref.allow_insecure = True
             _apm_yml_entries[canonical] = dep_ref.to_apm_yml_entry()
diff --git a/src/apm_cli/deps/github_downloader.py b/src/apm_cli/deps/github_downloader.py
index c46f89c24..baee03eab 100644
--- a/src/apm_cli/deps/github_downloader.py
+++ b/src/apm_cli/deps/github_downloader.py
@@ -508,7 +508,26 @@ def _build_noninteractive_git_env(
         preserve_config_isolation: bool = False,
         suppress_credential_helpers: bool = False,
     ) -> Dict[str, str]:
-        """Return a non-interactive git env for unauthenticated git operations."""
+        """Return a non-interactive git env for unauthenticated git operations.
+
+        Credential-helper policy (intentional two-stage design):
+
+        1. Start by clearing ``GIT_ASKPASS`` unconditionally. The default
+           APM env sets ``GIT_ASKPASS=echo`` for all authenticated ops; for
+           unauthenticated fallback attempts (HTTPS/SSH without a token), we
+           want the user's system credential helpers (e.g. macOS Keychain,
+           Windows credential manager, SSH agent) to resolve naturally.
+        2. Then re-set the full credential-helper *suppression* fence ONLY
+           when ``suppress_credential_helpers=True`` (HTTP transport). This
+           blocks all four credential channels: ``GIT_ASKPASS``,
+           ``GIT_TERMINAL_PROMPT``, ``GIT_CONFIG_NOSYSTEM``, and
+           ``credential.helper=`` (via ``GIT_CONFIG_COUNT/KEY/VALUE``).
+
+        Do NOT invert or flatten this pop-then-conditionally-restore pattern
+        without re-auditing every caller: removing step 1 would leak
+        credentials through user helpers on HTTPS/SSH fallbacks; removing
+        step 2 would leak them over plaintext HTTP.
+        """
         env = dict(self.git_env)
         env["GIT_TERMINAL_PROMPT"] = "0"
         env.pop("GIT_ASKPASS", None)
diff --git a/src/apm_cli/install/insecure_policy.py b/src/apm_cli/install/insecure_policy.py
index 80611f228..6d69f8914 100644
--- a/src/apm_cli/install/insecure_policy.py
+++ b/src/apm_cli/install/insecure_policy.py
@@ -61,14 +61,32 @@ def _get_insecure_dependency_url(dep) -> str:
     return url
 
 
-def _format_insecure_dependency_requirements(url: str) -> str:
-    """Render the canonical remediation message for an HTTP dependency."""
-    return (
-        f"{url} -- HTTP dependency (no transport encryption)\n"
-        "To install:\n"
-        "  1. Set allow_insecure: true on the dep in apm.yml\n"
-        "  2. Pass --allow-insecure to apm install"
-    )
+def _format_insecure_dependency_requirements(
+    url: str,
+    *,
+    missing_dep_allow: bool = True,
+    missing_cli_flag: bool = True,
+) -> str:
+    """Render the canonical remediation message for an HTTP dependency.
+
+    Args:
+        url: The HTTP dependency URL (with scheme) to include in the message.
+        missing_dep_allow: True when the dep entry in apm.yml lacks
+            ``allow_insecure: true``. When False, that step is omitted.
+        missing_cli_flag: True when ``--allow-insecure`` was not passed on
+            the command line. When False, that step is omitted.
+
+    Both flags default to True to preserve the full two-step recipe for the
+    add-time validation path (where the dep isn't in apm.yml yet).
+    """
+    lines = [f"{url} -- HTTP dependency (unencrypted)", "To install:"]
+    step = 1
+    if missing_dep_allow:
+        lines.append(f"  {step}. Set allow_insecure: true on the dep in apm.yml")
+        step += 1
+    if missing_cli_flag:
+        lines.append(f"  {step}. Pass --allow-insecure to apm install")
+    return "\n".join(lines)
 
 
 def _format_insecure_dependency_warning(info: _InsecureDependencyInfo) -> str:
@@ -200,10 +218,22 @@ def _check_insecure_dependencies(
         url = _get_insecure_dependency_url(dep)
         dep_allow_insecure = getattr(dep, "allow_insecure", False) is True
         if not dep_allow_insecure:
-            message = _format_insecure_dependency_requirements(url)
+            # Manifest is missing allow_insecure: true. The CLI flag may or
+            # may not be set; only ask the user to edit the steps that are
+            # actually missing.
+            message = _format_insecure_dependency_requirements(
+                url,
+                missing_dep_allow=True,
+                missing_cli_flag=not allow_insecure_flag,
+            )
             logger.error(message)
             raise InsecureDependencyPolicyError(message)
         if not allow_insecure_flag:
-            message = _format_insecure_dependency_requirements(url)
+            # Manifest is already set; only the CLI flag is missing.
+            message = _format_insecure_dependency_requirements(
+                url,
+                missing_dep_allow=False,
+                missing_cli_flag=True,
+            )
             logger.error(message)
             raise InsecureDependencyPolicyError(message)
diff --git a/tests/unit/test_install_command.py b/tests/unit/test_install_command.py
index 679fa42a3..49516e5d3 100644
--- a/tests/unit/test_install_command.py
+++ b/tests/unit/test_install_command.py
@@ -1283,8 +1283,10 @@ def test_http_dep_validation_check(self):
         assert "http://my-server.example.com/owner/repo" in str(exc_info.value)
         message = logger.error.call_args.args[0]
         assert "http://my-server.example.com/owner/repo" in message
-        assert "allow_insecure: true" in message
+        # Manifest is already set (allow_insecure: true on the dep), so only
+        # the CLI flag step should be mentioned - not the manifest edit step.
         assert "--allow-insecure" in message
+        assert "Set allow_insecure: true" not in message
 
     def test_http_dep_passes_with_allow_insecure_flag(self):
         """_check_insecure_dependencies passes when flag is set and dep has allow_insecure."""
@@ -1311,8 +1313,10 @@ def test_http_dep_without_dep_level_allow_insecure_is_blocked(self):
         assert "http://my-server.example.com/owner/repo" in str(exc_info.value)
         message = logger.error.call_args.args[0]
         assert "http://my-server.example.com/owner/repo" in message
-        assert "allow_insecure: true" in message
-        assert "--allow-insecure" in message
+        # CLI flag is already set, so only the manifest edit step should be
+        # mentioned - not the CLI flag step.
+        assert "Set allow_insecure: true" in message
+        assert "Pass --allow-insecure" not in message
 
     def test_https_dep_passes_without_flag(self):
         """_check_insecure_dependencies does not block HTTPS deps."""

From fe7355f6a7d754c3cac78be86566312672f6599a Mon Sep 17 00:00:00 2001
From: danielmeppiel <dmeppiel@microsoft.com>
Date: Tue, 21 Apr 2026 20:55:10 +0200
Subject: [PATCH 22/22] review: fix CodeQL "Incomplete URL substring
 sanitization" finding

CodeQL flagged `"http://gitlab.company.internal" in arg` at
test_install_command.py:963 as a potentially unsafe URL substring check:
the hostname could appear at an arbitrary position in the checked URL
(e.g. inside a path segment, query value, or userinfo) rather than as
the actual host, producing a false-positive match.

Replace substring checks with `urllib.parse.urlsplit` and compare
`scheme` and `netloc` explicitly:

- test_explicit_http_generic_host_tries_http_first (the flagged case):
  parse each subprocess-arg URL and require scheme == "http" AND
  netloc == "gitlab.company.internal".
- test_generic_host_falls_back_to_https_when_ssh_fails (sibling with
  the same pattern at line 929): same treatment for the HTTPS arg;
  keep the SSH SCP-form check as `"git@git.example.org:" in arg` since
  SCP-style URLs are not parseable by urlsplit.

Both tests still assert the same contract: explicit `http://` does not
fall back to SSH, and SSH failure falls back to HTTPS on the right host.

Co-authored-by: arika0093 <arika0093@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 tests/unit/test_install_command.py | 35 ++++++++++++++++++++++++------
 1 file changed, 28 insertions(+), 7 deletions(-)

diff --git a/tests/unit/test_install_command.py b/tests/unit/test_install_command.py
index 49516e5d3..728d723a4 100644
--- a/tests/unit/test_install_command.py
+++ b/tests/unit/test_install_command.py
@@ -905,6 +905,8 @@ def test_generic_host_tries_ssh_first_and_succeeds(self, mock_run):
     @patch("subprocess.run")
     def test_generic_host_falls_back_to_https_when_ssh_fails(self, mock_run):
         """HTTPS fallback is used for generic hosts when SSH ls-remote fails."""
+        from urllib.parse import urlsplit
+
         from apm_cli.commands.install import _validate_package_exists
 
         # SSH probe fails, HTTPS succeeds
@@ -919,15 +921,23 @@ def test_generic_host_falls_back_to_https_when_ssh_fails(self, mock_run):
 
         assert result is True
         assert mock_run.call_count == 2
-        # First call: SSH
+        # First call: SSH (SCP-style URLs are not parseable by urlsplit, so
+        # keep the substring check scoped to the SSH prefix form git@host:).
         first_cmd = mock_run.call_args_list[0][0][0]
-        assert any("git@git.example.org" in arg for arg in first_cmd), (
+        assert any("git@git.example.org:" in arg for arg in first_cmd), (
             f"Expected SSH URL in first call, got: {first_cmd}"
         )
-        # Second call: HTTPS
+        # Second call: HTTPS -- parse scheme + netloc explicitly to avoid
+        # substring false-positives.
         second_cmd = mock_run.call_args_list[1][0][0]
-        assert any("https://git.example.org" in arg for arg in second_cmd), (
-            f"Expected HTTPS URL in second call, got: {second_cmd}"
+        https_arg_found = False
+        for arg in second_cmd:
+            parts = urlsplit(arg)
+            if parts.scheme == "https" and parts.netloc == "git.example.org":
+                https_arg_found = True
+                break
+        assert https_arg_found, (
+            f"Expected https://git.example.org URL in second call, got: {second_cmd}"
         )
 
     @patch("subprocess.run")
@@ -949,6 +959,8 @@ def test_generic_host_returns_false_when_both_transports_fail(self, mock_run):
     @patch("subprocess.run")
     def test_explicit_http_generic_host_tries_http_first(self, mock_run):
         """Explicit HTTP must probe HTTP before any SSH fallback."""
+        from urllib.parse import urlsplit
+
         from apm_cli.commands.install import _validate_package_exists
 
         mock_run.return_value = self._make_completed_process(returncode=0)
@@ -960,8 +972,17 @@ def test_explicit_http_generic_host_tries_http_first(self, mock_run):
         assert result is True
         assert mock_run.call_count == 1
         first_cmd = mock_run.call_args_list[0][0][0]
-        assert any("http://gitlab.company.internal" in arg for arg in first_cmd), (
-            f"Expected HTTP URL in first call, got: {first_cmd}"
+        # Parse each arg as a URL and check scheme + netloc explicitly to
+        # avoid substring false-positives (e.g. the hostname appearing in a
+        # path segment or query value on an otherwise-SSH URL).
+        http_arg_found = False
+        for arg in first_cmd:
+            parts = urlsplit(arg)
+            if parts.scheme == "http" and parts.netloc == "gitlab.company.internal":
+                http_arg_found = True
+                break
+        assert http_arg_found, (
+            f"Expected http://gitlab.company.internal URL in first call, got: {first_cmd}"
         )
         assert all("git@" not in arg for arg in first_cmd), (
             f"Expected no SSH URL in first call, got: {first_cmd}"
