Skip to content

fix(marketplace): enhance repository input parsing for GitLab subgroups and HTTPS URLs#1034

Merged
danielmeppiel merged 7 commits intomicrosoft:mainfrom
anrouxel:fix/marketplace-url
Apr 30, 2026
Merged

fix(marketplace): enhance repository input parsing for GitLab subgroups and HTTPS URLs#1034
danielmeppiel merged 7 commits intomicrosoft:mainfrom
anrouxel:fix/marketplace-url

Conversation

@Antonin-Rouxel-LaPoste-BGPN
Copy link
Copy Markdown
Contributor

Note

Closes #1027

TL;DR

apm marketplace add was limited to exactly 2 or 3 path segments, making it impossible to register a GitLab marketplace hosted under subgroups (e.g. mycompany/myorg/specs-and-standards/repo). This PR replaces the rigid segment counter with a unified parser that accepts any N-segment shorthand path and full HTTPS URLs.


Problem

Why the old parser failed

The previous implementation split the input on / and checked len(parts) == 2 or len(parts) == 3. Any deeper path — which GitLab subgroups require — was rejected with "Expected 'OWNER/REPO'".

$ apm marketplace add gitlab.com/mycompany/myorg/specs-and-standards/internal-marketplace
[x] Invalid format: '...'. Expected 'OWNER/REPO'

Two separate root causes were reported in #1027:

  1. Parser rejects subgroup paths — only OWNER/REPO (2 parts) or HOST/OWNER/REPO (3 parts) were accepted.
  2. Full HTTPS URLs were not supported — users pasting a browser URL received the same rejection error.

Important

The MarketplaceSource model already stores owner as a plain string with no segment-count constraint, so the fix is entirely in the parser; no schema migration is required.


Approach

Input form Before After
OWNER/REPO
HOST/OWNER/REPO
HOST/group/sub/.../REPO
OWNER/group/sub/.../REPO
https://host/group/sub/.../repo[.git]

The rule is: everything except the last segment is owner; the last segment is repo. This mirrors how dependency/reference.py already handles generic hosts for apm add (see _resolve_shorthand_to_parsed_url), giving both surfaces consistent behaviour.


Implementation

Files changed

src/apm_cli/commands/marketplace.pyadd command parser

Replaced the 2/3-segment if/elif/else block with a three-branch strategy:

HTTPS URL  →  urlparse → strip .git → split path → owner = all-but-last, repo = last
Shorthand with FQDN first segment  →  host = parts[0], owner = parts[1..-2], repo = last
Shorthand without host  →  owner = parts[0..-2], repo = last

Path-traversal sequences (.., .) in the parsed owner and repo_name are validated through the existing validate_path_segments guard (required by the path-security rules in copilot-instructions.md). Conflicting --host flags are still caught in all branches.

src/apm_cli/marketplace/errors.pyMarketplaceNotFoundError

Updated the user-facing hint to mention the HTTPS URL form.

tests/unit/marketplace/test_marketplace_commands.py

Added 6 new test cases to TestMarketplaceAdd:

  • GitLab subgroup shorthand (HOST/group/sub/.../repo)
  • Full HTTPS URL
  • .git suffix stripping
  • Conflicting --host flag with HTTPS URL
  • Single-segment URL path (rejected)
  • Path traversal injection (rejected)

Flow diagram

The diagram below shows the updated parse strategy inside the add command.

flowchart TD
    A(["apm marketplace add INPUT"]) --> B{"starts with https://?"}
    B -- yes --> C["urlparse / strip .git"]
    C --> D{"path segments >= 2?"}
    D -- no --> ERR1(["error: need OWNER/REPO"])
    D -- yes --> E["owner = all-but-last\nrepo = last segment"]
    B -- no --> F["split on / filter empty"]
    F --> G{"segments >= 2?"}
    G -- no --> ERR2(["error: need OWNER/REPO"])
    G -- yes --> H{"first segment is valid FQDN?"}
    H -- yes --> I{"segments >= 3?"}
    I -- no --> ERR3(["error: HOST/OWNER/REPO required"])
    I -- yes --> J["host = first\nowner = middle segments\nrepo = last"]
    H -- no --> K["owner = all-but-last\nrepo = last segment"]
    E & J & K --> L["validate_path_segments\nowner + repo_name"]
    L --> M["resolve --host flag\nor default_host"]
    M --> N(["_auto_detect_path + register"])
Loading

Trade-offs

  • owner is a multi-segment stringMarketplaceSource.owner may now be "mycompany/myorg/specs-and-standards". The model already stored it as a plain string and the _github_contents_url builder inlines it directly, so the API URL is assembled correctly. No other callers were found to assume a single-segment owner.
  • No GitLab API support yet — fetching marketplace contents still uses the GitHub Contents API path. GitLab-hosted marketplaces will fail at fetch time (after parsing succeeds). This is a separate issue; the parser fix unblocks the manual workaround documented in [FEATURE] Add a marketplace with just a full repository URL #1027 and makes the MarketplaceSource storable via apm marketplace add.
  • http:// accepted alongside https:// — kept for parity with dependency/reference.py; production usage is expected to be HTTPS-only.

Validation

21/21 unit tests pass
$ uv run pytest tests/unit/marketplace/test_marketplace_commands.py -q
.....................                                                    [100%]
21 passed in 0.82s
Full unit suite — 6656 tests, 0 failures
$ uv run pytest tests/unit tests/test_console.py -x -q 2>&1 | tail -5
6656 passed, 1 warning, 26 subtests passed in 104.45s (0:01:44)
Live invocation with a 4-segment subgroup path
$ uv run apm marketplace add solutions-distributeurs/yz_-alf_framework/sandbox/github-copilot-agents \
    --host gitlab.udd.net.intra.laposte.fr --verbose
[*] Registering marketplace 'github-copilot-agents'...
    Repository: solutions-distributeurs/yz_-alf_framework/sandbox/github-copilot-agents
    Branch: main
    Host: gitlab.udd.net.intra.laposte.fr

Parser correctly sets owner = "solutions-distributeurs/yz_-alf_framework/sandbox", repo = "github-copilot-agents", host = "gitlab.udd.net.intra.laposte.fr". (Fetch aborted manually — no live GitLab API support yet.)


How to test

  • uv run pytest tests/unit/marketplace/test_marketplace_commands.py -v — all 21 tests green
  • uv run apm marketplace add gitlab.com/myorg/subgroup/my-marketplace --name my-mkt — parses cleanly, fails at fetch (expected without a live GitLab API)
  • uv run apm marketplace add https://gitlab.com/myorg/subgroup/my-marketplace.git --name my-mkt — same result, .git stripped
  • uv run apm marketplace add gitlab.com/myorg/repo --host github.com — exits with "Conflicting host" error
  • uv run apm marketplace add gitlab.com/myorg/../evil/repo — exits with traversal-rejection error

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Enhances apm marketplace add repository input parsing to support GitLab subgroup paths (N-segment owners) and full HTTP(S) repository URLs, addressing the limitation that previously only accepted 2- or 3-segment shorthands.

Changes:

  • Replace rigid 2/3-segment parsing with a unified parser that supports N-segment subgroup paths and full HTTP(S) URLs.
  • Update user-facing hint text to mention the HTTPS URL form.
  • Add unit tests covering GitLab subgroup shorthand, HTTPS URL parsing, .git stripping, and rejection cases.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.

File Description
src/apm_cli/commands/marketplace.py Implements the new unified parsing logic for marketplace repo inputs and updates CLI messaging.
src/apm_cli/marketplace/errors.py Expands the “not registered” hint to mention full HTTPS URLs.
tests/unit/marketplace/test_marketplace_commands.py Adds new test cases for subgroup shorthands, HTTPS URLs, .git stripping, and invalid inputs.

Comment thread src/apm_cli/commands/marketplace.py Outdated
Comment thread src/apm_cli/commands/marketplace.py Outdated
Comment on lines +355 to +360
owner = "/".join(parts[1:-1])
repo_name = parts[-1]
else:
# OWNER/.../REPO (no host prefix, any number of segments)
owner = "/".join(parts[:-1])
repo_name = parts[-1]
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This parser now allows multi-segment owner values even when the resolved host is the GitHub API backend (default github.com). Downstream, _github_contents_url() interpolates source.owner directly into /repos/{owner}/{repo}/..., so an owner containing / will generate a malformed/ambiguous API path and can accidentally target the wrong repo/path. Consider rejecting owner values containing / when resolved_host is a GitHub host (or otherwise ensuring the client builds URLs safely for multi-segment owners).

Copilot uses AI. Check for mistakes.
Comment thread src/apm_cli/commands/marketplace.py Outdated
Comment on lines 313 to 317
if host and host.lower() != url_host:
logger.error(
f"Invalid host: '{parts[0]}'. "
f"Use 'OWNER/REPO' or 'HOST/OWNER/REPO' format."
f"Conflicting host: --host '{host}' vs '{url_host}' in URL."
)
sys.exit(1)
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Host comparisons use host.lower() without stripping whitespace (e.g. --host "gitlab.com "), which can raise a false "Conflicting host" error even though the normalized host is the same. Consider normalizing the --host value once up front (strip + lower) and using that for both conflict checks and later validation.

Copilot uses AI. Check for mistakes.
Comment thread src/apm_cli/commands/marketplace.py Outdated
Comment on lines 290 to 291
"""Register a marketplace from OWNER/REPO, HOST/OWNER/.../REPO, or a full HTTPS URL."""
logger = CommandLogger("marketplace-add", verbose=verbose)
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CLI now accepts full HTTP(S) URLs and N-segment subgroup paths, but documentation still shows only OWNER/REPO and HOST/OWNER/REPO forms. Please update the relevant Starlight docs pages (e.g. docs/src/content/docs/guides/marketplaces.md, docs/src/content/docs/reference/cli-commands.md, docs/src/content/docs/guides/marketplace-authoring.md) and the APM guide resource (packages/apm-guide/.apm/skills/apm-usage/commands.md) to include the HTTPS URL form and subgroup examples.

Copilot uses AI. Check for mistakes.
Comment on lines +88 to +96
# ------------------------------------------------------------------
# GitLab subgroup / deep-path support
# ------------------------------------------------------------------

@patch("apm_cli.marketplace.client.fetch_marketplace")
@patch("apm_cli.marketplace.client._auto_detect_path")
def test_add_gitlab_subgroup_shorthand(self, mock_detect, mock_fetch, runner):
"""HOST/group/subgroup/.../repo shorthand stores all intermediate segments in owner."""
from apm_cli.commands.marketplace import marketplace
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new parsing behavior around host detection vs OWNER/REPO (especially for owners containing dots like foo.bar/repo) is not covered by tests here. Adding a regression test that asserts foo.bar/repo is treated as owner="foo.bar", repo="repo" (not as a host-prefixed shorthand) would prevent reintroducing the ambiguity fix.

Copilot uses AI. Check for mistakes.
@Antonin-Rouxel-LaPoste-BGPN
Copy link
Copy Markdown
Contributor Author

@microsoft-github-policy-service agree company="La Poste Groupe"

@danielmeppiel danielmeppiel added the panel-review Trigger the apm-review-panel gh-aw workflow label Apr 29, 2026
@github-actions
Copy link
Copy Markdown

APM Review Panel Verdict: REJECT

Two independent panelists flagged the (redacted) acceptance as a direct MITM vector and UX contract breach; percent-encoding bypasses validate_path_segments; and neither cli-commands.md nor CHANGELOG.md were updated. The feature strategy is correct -- fix the blocking issues and it ships clean.

Required before merge (12 items)

  • [python-architect] 50-line parsing block inline in add() -- extract to _parse_repo_argument() at src/apm_cli/commands/marketplace.py:368

    • Why: The add() command body now contains ~50 lines of URL/path parsing logic. This violates the single-responsibility shape of a Click handler and makes the function untestable in isolation. The logic has a clear stable interface: (repo_input: str, host_flag: str | None) -> tuple[str, str, str].
    • Suggested fix: Add def _parse_repo_argument(repo_input: str, host_flag: str | None) -> tuple[str, str, str]:. Raise a structured exception on bad input, catch it once in add(). This keeps the command handler to ~30 lines of orchestration.
  • [python-architect] display_name = name or repo_name at line 454 is immediately overwritten -- dead assignment with a latent behavioral difference at src/apm_cli/commands/marketplace.py:454

    • Why: The assignment is dead code (the three-tier alias block below overwrites it unconditionally), but it silently changes meaning if an exception is added between the two sites. Remove it.
    • Suggested fix: Delete the display_name = name or repo_name line at 454. The three-tier block below already handles all cases correctly.
  • [cli-logging-expert] PathTraversalError passthrough may expose internal message not written for users at src/apm_cli/commands/marketplace.py

    • Why: logger.error(str(exc)) delegates the user-facing message entirely to the exception class. If PathTraversalError.str reads like an internal diagnostic rather than an actionable instruction, the user gets no fix. Every error must name the thing and include the fix.
    • Suggested fix: Catch PathTraversalError and emit a controlled message: logger.error(f"Invalid {exc.context}: path traversal sequences ('..', '~') are not allowed.") -- or verify PathTraversalError.str already meets the standard and document that contract.
  • [cli-logging-expert] Empty-hostname error Invalid host in URL: '' is not actionable at src/apm_cli/commands/marketplace.py

    • Why: When _parsed.hostname is empty (e.g. https:///owner/repo), the message reads "Invalid host in URL: ''.". The user sees an empty quoted string with no idea what a valid host looks like.
    • Suggested fix: Add a guard: if not url_host: logger.error("Invalid URL: no host found. Use '(host/redacted) before calling is_valid_fqdn`.
  • [devx-ux-expert] cli-commands.md not updated -- change is incomplete by definition at docs/src/content/docs/reference/cli-commands.md:1152

    • Why: The docs still show HOST/OWNER/REPO (fixed 3-segment) and have no mention of full URL input or subgroup paths. Per panel rule: if a CLI change is not reflected in cli-commands.md in the same PR, that change is incomplete.
    • Suggested fix: Update the usage block to show all accepted forms (OWNER/REPO, HOST/OWNER/.../REPO, `(host/redacted), update the Arguments section, and add a GitLab subgroup URL example.
  • [devx-ux-expert] (redacted) URLs are silently accepted -- a package manager must not do this at src/apm_cli/commands/marketplace.py:384`

    • Why: Every other APM fetch path that allows HTTP requires --allow-insecure. Silently fetching a marketplace over plaintext breaks the established security contract and enables MITM attacks on the plugin registry.
    • Suggested fix: Either reject (redacted) with logger.error("Only HTTPS URLs are accepted. Use https:// for security.")or require an explicit--allow-insecure` flag matching the existing pattern.
  • [devx-ux-expert] Docstring says 'HTTPS URL' but code accepts HTTP -- user-visible lie at src/apm_cli/commands/marketplace.py:368

    • Why: The function docstring and list_cmd help text both say 'full HTTPS URL', but the implementation accepts both schemes. Help text is the contract.
    • Suggested fix: If (redacted) is intentionally supported, update docstring/help to say 'a full URL (https:// or (redacted) If not intentional, remove `(redacted) from the startswith check.
  • [devx-ux-expert] MarketplaceNotFoundError error hint doesn't mention subgroup paths at src/apm_cli/marketplace/errors.py:17

    • Why: When a user gets a MarketplaceNotFoundError, the recovery hint says only 'OWNER/REPO (or a full HTTPS URL)'. It does not mention HOST/OWNER/.../REPO, the most relevant new feature for GitLab users who hit this error.
    • Suggested fix: Update the hint to: Run 'apm marketplace add OWNER/REPO', 'HOST/OWNER/.../REPO', or a full HTTPS URL to register it.
  • [supply-chain-security-expert] HTTP URLs accepted for marketplace source -- plaintext fetch enables MITM at src/apm_cli/commands/marketplace.py:375

    • Why: `startswith(("https://", "(redacted) explicitly allows (redacted) scheme. An on-path attacker can substitute a malicious marketplace manifest, injecting arbitrary package references. Because marketplace.json defines what packages are installable, this is a direct supply-chain injection point (fail-closed violation).
    • Suggested fix: Replace the combined check with an HTTPS-only gate. If input starts with (redacted) (not https://), call logger.error("Only HTTPS URLs are accepted for marketplace sources.")andsys.exit(1)`.
  • [supply-chain-security-expert] Percent-encoded traversal sequences bypass validate_path_segments at src/apm_cli/commands/marketplace.py:412

    • Why: urllib.parse.urlparse does NOT decode percent-encoding in _parsed.path. A URL like https://github.com/%2E%2E/evil yields owner '%2E%2E'. validate_path_segments checks for '..' and '.' but '%2E%2E' does not match -- the security control is bypassed silently.
    • Suggested fix: Before calling validate_path_segments, decode: import urllib.parse; owner = urllib.parse.unquote(owner); repo_name = urllib.parse.unquote(repo_name). Apply unquote to each element of path_parts before joining.
  • [supply-chain-security-expert] Multi-segment owner interpolated into API URL without per-segment URL-encoding -- potential URL injection at src/apm_cli/marketplace/client.py:187

    • Why: client.py builds f"{api_base}/repos/{source.owner}/{source.repo}/contents/...". With multi-segment owner (myorg/subgroup/team), validate_path_segments only rejects .. but allows ?, #, @, space. An owner segment containing ?injected=1 would inject query parameters into the API request.
    • Suggested fix: URL-encode each segment of source.owner and source.repo individually with urllib.parse.quote(seg, safe='') before interpolating into the API URL, then rejoin with /.
  • [oss-growth-hacker] No CHANGELOG entry for GitLab subgroup / HTTPS URL support at CHANGELOG.md:9

    • Why: The [Unreleased] section has zero mention of PR fix(marketplace): enhance repository input parsing for GitLab subgroups and HTTPS URLs #1034's changes. This is the canonical anti-pattern: feature shipped, no one knows it exists in 30 days. Enterprise GitLab teams will search release notes before adopting.
    • Suggested fix: Add under ## [Unreleased] / ### Added: - apm marketplace add now accepts full HTTPS URLs (e.g. (gitlab.com/redacted) and N-segment GitLab subgroup paths -- paste directly from your browser. Works with any HTTPS-hosted Git marketplace. (#1034)

Nits (11 items, skip if you want)

  • [python-architect] import urllib.parse as _urlparse and import re are lazy imports inside the function for no circular-dep reason -- move both to top-level stdlib imports
  • [python-architect] New tests use ... (Ellipsis) as stub body -- replace with a comment or remove the ellipsis
  • [python-architect] Ambiguous host-detection heuristic: 2-segment github.com/repo falls through to OWNER/REPO branch silently with no warning
  • [cli-logging-expert] Trailing period inconsistency: "Invalid host in URL: '{url_host}'." ends with a period; all other new error messages do not
  • [cli-logging-expert] Conflicting-host errors don't include the fix -- append "Remove --host to let the URL determine the host, or omit the host from the argument."
  • [cli-logging-expert] 'Invalid format' error example acme-org/plugin-marketplace is GitHub-specific; extend to include a multi-host variant
  • [devx-ux-expert] --host flag interaction with full URL is confusing -- consider a warning when both are provided and they agree
  • [devx-ux-expert] Invalid-format error message doesn't mention the new URL form; extend hint to include the full HTTPS URL example
  • [supply-chain-security-expert] FQDN heuristic is ambiguous for org names that look like domains (e.g. my.org); emit a warning when promoting a segment to host without explicit --host
  • [oss-growth-hacker] README hero doesn't mention multi-host / GitLab support -- consider a 'Works with GitHub, GitLab, and any HTTPS-hosted Git repository' signal within 1-2 releases
  • [oss-growth-hacker] MANIFESTO 'Portability over Vendor Lock-in' is the perfect hook anchor -- reference it in the PR description and CHANGELOG entry

CEO arbitration

PR #1034 ships a genuinely valuable feature -- paste-from-browser HTTPS URLs and GitLab subgroup paths -- but it arrives with three categories of blocking defect that must be resolved before merge. First, and most critically, the implementation accepts (redacted) scheme URLs with no warning, no opt-in flag, and no documentation of the risk. Two panelists (DevX/UX and Supply Chain Security) flag this independently: from a UX standpoint it breaks the established --allow-insecure contract every other APM fetch path honors; from a security standpoint it is a direct MITM injection point for marketplace manifests. These two angles reinforce each other and are not in conflict -- the fix is the same: reject (redacted) at the parser boundary and document that only https:// is accepted. Second, the PR introduces a percent-encoding bypass in validate_path_segments ('%2E%2E' evades the '..' check) and multi-segment owner strings interpolated into API URLs without per-segment encoding, leaving '?', '#', and '@' characters as live URL-injection vectors. These are supply-chain correctness issues, not edge cases. Third, the 50-line parsing block embedded in the Click handler conflates command orchestration with URL/path resolution logic, making the new behavior untestable in isolation; extraction to _parse_repo_argument() with a defined return type is a structural prerequisite for the security fixes above, since it creates one auditable entry point.

Two process gaps must close in the same PR: cli-commands.md must reflect the new HOST/OWNER/.../REPO and full-URL input forms (the docs currently show only the fixed 3-segment shape, making the new feature undiscoverable from help alone), and CHANGELOG.md must carry an [Unreleased] entry. Neither is cosmetic -- the docs gap means GitLab users who need subgroup support cannot find the syntax without reading source, and the CHANGELOG gap makes the feature invisible to evaluators deciding whether to upgrade.

The recommended path forward is: (1) land the _parse_repo_argument() extraction first as it is the foundation for auditable security fixes; (2) add urllib.parse.unquote() normalization before validate_path_segments and per-segment urllib.parse.quote() encoding before URL construction; (3) restrict accepted schemes to https:// only, raising a clear actionable error for (redacted) (4) update cli-commands.md and CHANGELOG.md; (5) replace the placeholder '...' stub test bodies with real assertions. The feature strategy is correct and the growth signal is real -- ship it clean.

Dissent resolved: No genuine inter-panelist disagreement exists. The (redacted) finding surfaces from both DevX/UX (UX contract) and Supply Chain Security (MITM risk) and the two rationales are complementary -- a single fix satisfies both. CLI Logging Expert's PathTraversalError message finding and Python Architect's structural extraction finding are also complementary: extraction to _parse_repo_argument() creates the natural site for a single user-readable error raise, resolving both concerns simultaneously.

Growth/positioning note: The paste-from-browser UX (full HTTPS URL input) is a concrete, demonstrable hook for a 'GitLab teams: APM works for you' campaign. OSS Growth Hacker recommends a standalone social beat -- a tweet thread or dev.to post anchored to the MANIFESTO 'Portability over Vendor Lock-in' principle -- rather than bundling the feature into a release roundup. Schedule it as soon as the fixed PR merges.


Per-persona findings (full)

Python Architect

classDiagram
    direction LR
    class add {
        <<IOBoundary>>
        +repo: str
        +name: str
        +branch: str
        +host: str
        +verbose: bool
    }
    class MarketplaceSource {
        <<ValueObject>>
        +host: str
        +owner: str
        +repo: str
        +branch: str
        +name: str
    }
    class CommandLogger {
        <<Base>>
        +error()
        +progress()
        +complete()
    }
    class validate_path_segments {
        <<Pure>>
    }
    class PathTraversalError {
        <<Exception>>
    }
    class is_valid_fqdn {
        <<Pure>>
    }
    class default_host {
        <<Pure>>
    }
    class fetch_marketplace {
        <<IOBoundary>>
    }
    class _auto_detect_path {
        <<IOBoundary>>
    }
    class MarketplaceNotFoundError {
        <<Exception>>
    }

    add --> CommandLogger : uses
    add --> MarketplaceSource : constructs
    add --> fetch_marketplace : calls
    add --> _auto_detect_path : calls
    add --> validate_path_segments : calls
    add --> is_valid_fqdn : calls
    add --> default_host : calls
    validate_path_segments ..> PathTraversalError : raises
    MarketplaceNotFoundError --> MarketplaceSource : references

    class add:::touched
    class PathTraversalError:::touched
    class validate_path_segments:::touched
    class MarketplaceNotFoundError:::touched
    classDef touched fill:#ffe0b2,stroke:#e65100
Loading
flowchart TD
    A([CLI: apm marketplace add REPO]) --> B[add -- marketplace.py:368]
    B --> C{starts with https:// ?}
    C -- yes --> D[urlparse repo_input pure parse]
    D --> E{is_valid_fqdn url_host ?}
    E -- no --> ERR1[logger.error + sys.exit 1]
    E -- yes --> F{--host conflicts?}
    F -- yes --> ERR2[logger.error + sys.exit 1]
    F -- no --> G[split path to owner and repo_name]
    C -- no --> H[split by slash to parts]
    H --> I{len parts ge 3 and is_valid_fqdn parts0 ?}
    I -- yes --> J[resolved_host=parts0 owner=join parts1 to -1 repo_name=parts-1]
    I -- no --> K[owner=join parts to -1 repo_name=parts-1]
    G --> L[validate_path_segments owner and repo_name]
    J --> L
    K --> L
    L -- PathTraversalError --> ERR3[logger.error str exc + sys.exit 1]
    L -- ok --> M{resolved_host is None?}
    M -- yes --> N{--host flag set?}
    N -- yes --> O[is_valid_fqdn host check]
    O -- invalid --> ERR4[logger.error + sys.exit 1]
    O -- valid --> P[resolved_host = normalized host]
    N -- no --> Q[resolved_host = default_host FS read env/config]
    M -- no --> R[validate --name flag via _is_valid_alias]
    P --> R
    Q --> R
    R -- invalid --> ERR5[logger.error + sys.exit 1]
    R -- ok --> S[_auto_detect_path probe_source NET HTTP probe]
    S -- None --> ERR6[logger.error + sys.exit 1]
    S -- path --> T[fetch_marketplace fetch_source NET download JSON]
    T --> U[three-tier alias resolution: --name > manifest.name > repo_name]
    U --> V[add_marketplace source FS write registry]
    V --> W([logger.complete exit 0])
Loading

Design patterns

  • Used in this PR: Inline procedural parsing -- all URL/path disambiguation logic is written sequentially inside the Click handler with early-exit guards, following an imperative decision tree rather than a strategy or parser object.
  • Pragmatic suggestion: Extract Function -- move the ~50-line parsing block to _parse_repo_argument(repo_input, host_flag) -> tuple[str, str, str]; the three branches (HTTPS URL / HOST/.../REPO / OWNER/.../REPO) map cleanly to private helpers called from that one function, keeping add() as pure orchestration.

Required findings: (see aggregated list above)

Nits:

  • import urllib.parse as _urlparse and import re are lazy imports inside the function for no circular-dep reason -- move to top-level imports.
  • New tests use ... (Ellipsis) as stub body -- replace with a comment or remove.
  • Ambiguous host-detection heuristic: 2-segment github.com/repo falls through to OWNER/REPO branch silently.

CLI Logging Expert

Required findings: (see aggregated list above)

Nits:

  • Trailing period inconsistency on "Invalid host in URL: '{url_host}'." -- drop the trailing period.
  • Conflicting-host errors don't include the fix -- append the recovery action.
  • 'Invalid format' error example is GitHub-specific -- extend to include a multi-host variant.
  • import re inside function body -- move to top of file.

DevX UX Expert

Required findings: (see aggregated list above)

Nits:

  • --host flag interaction with full URL is confusing -- consider a warning when both are provided and they agree.
  • Invalid-format error message doesn't mention the new URL form.

Supply Chain Security Expert

Required findings: (see aggregated list above)

Nits:

  • FQDN heuristic is ambiguous for org names that look like domains (e.g. my.org) -- emit a warning when promoting a segment to host without explicit --host.
  • (redacted) scheme is silently normalised inside is_valid_fqdnbefore FQDN validation rejects it -- add an explicit guard rejecting inputs containing://`.

Auth Expert

Inactive -- PR changes marketplace source URL parsing only and does not modify AuthResolver, token management, or credential resolution logic (touched files: src/apm_cli/commands/marketplace.py, src/apm_cli/marketplace/errors.py, tests only).

OSS Growth Hacker

Required findings: (see aggregated list above)

Nits:

  • README hero doesn't mention multi-host / GitLab support -- consider adding a 'Works with GitHub, GitLab, and any HTTPS-hosted Git repository' signal within 1-2 releases.
  • MANIFESTO 'Portability over Vendor Lock-in' is the perfect hook anchor -- reference it in the PR description and CHANGELOG entry.

Side-channel: This is the right feature to anchor a 'GitLab teams: APM works for you' tweet thread or dev.to post. The paste-from-browser UX (full HTTPS URL) is the concrete, demonstrable hook. Recommend scheduling this as a standalone social beat rather than bundling it into a release roundup.

Verdict computed deterministically: 12 required findings across 5 active panelists. APPROVE iff N == 0. Push a new commit to clear this verdict label automatically.

Note

🔒 Integrity filter blocked 2 items

The following items were blocked because they don't meet the GitHub integrity level.

To allow these resources, lower min-integrity in your GitHub frontmatter:

tools:
  github:
    min-integrity: approved  # merged | approved | unapproved | none

Generated by PR Review Panel for issue #1034 · ● 1.7M ·

@github-actions github-actions Bot added panel-rejected Apm-review-panel verdict: REJECT. Removed automatically on next push. and removed panel-review Trigger the apm-review-panel gh-aw workflow labels Apr 29, 2026
@Antonin-Rouxel-LaPoste-BGPN
Copy link
Copy Markdown
Contributor Author

Following the panel-rejected verdict, I pushed three commits to address the blocking findings. Here is what each commit does and what remains open.


b9df59a -- refactor parsing to delegate to DependencyReference

The ~75-line inline parsing block in add() mixed URL logic, host detection, and security validation directly inside the Click handler. I removed it entirely and delegated to the existing DependencyReference.parse() from src/apm_cli/models/dependency/reference.py, which already handles all the same input forms for apm add: OWNER/REPO, HOST/OWNER/.../REPO, full HTTPS/SSH URLs. add() went from ~110 lines down to ~35 lines of orchestration.

I chose to reuse DependencyReference rather than extract a new _parse_repo_argument() function because the existing parser is already tested, audited, and used on the other CLI surface. Creating a duplicate would have kept the exact duplication problem the panel flagged.

Known limitation of this commit: the import path was wrong (..models.apm_package instead of ..models.dependency.reference) and --host conflict detection was not yet reimplemented. Fixed in the next commit.


a572419 -- error handling and branch resolution

  • Fixed the import path for DependencyReference
  • Split parsing errors into two distinct except blocks: PathTraversalError (traversal sequences .., ., ~) gets a targeted message; ValueError (bad format) gets a message listing all accepted forms with an example
  • Reimplemented --host conflict detection: if host and dep.host and host != dep.host: with a message that tells the user what to fix
  • Fixed branch resolution: resolved_branch = branch or dep.reference -- previously a branch embedded in the URL (e.g. ...@release) was silently ignored and branch always defaulted to "main"
  • Propagated resolved_branch to all three MarketplaceSource constructors (probe, fetch, register)

After this commit, HTTP was still accepted silently and docs/CHANGELOG were not yet updated.


80eb753 -- --allow-insecure, docs, CHANGELOG, errors.py

src/apm_cli/commands/marketplace.py:

  • Added --allow-insecure flag (same convention as the rest of APM)
  • Added if dep.is_insecure and not allow_insecure: guard that rejects HTTP with an actionable message and the fix
  • Added a dedicated except PathTraversalError block before except ValueError so traversal attempts get a specific message

src/apm_cli/marketplace/errors.py:

  • Updated the MarketplaceNotFoundError recovery hint to list all three input forms: OWNER/REPO, HOST/OWNER/.../REPO, and full HTTPS URL

docs/src/content/docs/reference/cli-commands.md:

  • Replaced the fixed HOST/OWNER/REPO (3 segments) with HOST/OWNER/.../REPO
  • Added the full HTTPS URL form with a note about .git suffix stripping
  • Added --allow-insecure to the options list
  • Added two GitLab examples (full URL and subgroup shorthand)

CHANGELOG.md:

  • Added an entry under ## [Unreleased] / ### Added

What is NOT fixed yet

Panel finding Status
Multi-segment owner interpolated into API URL without per-segment encoding (client.py) Not addressed -- source.owner containing / is passed as-is into the GitHub Contents API URL; ?, #, @ characters in a segment could inject into the request
Percent-encoded traversal bypass (%2E%2E bypasses validate_path_segments) Not verified -- DependencyReference.parse() handles URL parsing but I have not confirmed unquote() is called before the traversal checks

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 8 comments.

Comment thread src/apm_cli/commands/marketplace.py Outdated
Comment thread src/apm_cli/commands/marketplace.py Outdated
Comment thread src/apm_cli/commands/marketplace.py Outdated
Comment thread src/apm_cli/commands/marketplace.py Outdated
Comment thread docs/src/content/docs/reference/cli-commands.md Outdated
Comment thread CHANGELOG.md Outdated
Comment thread tests/unit/marketplace/test_marketplace_commands.py
Comment thread src/apm_cli/commands/marketplace.py Outdated
@danielmeppiel danielmeppiel added the panel-review Trigger the apm-review-panel gh-aw workflow label Apr 30, 2026
@github-actions
Copy link
Copy Markdown

APM Review Panel Verdict: REJECT

Architecture is sound and the 'paste-URL-from-browser' primitive is worth shipping, but three independent security findings block merge: GitHub credential leakage to arbitrary hosts, an HTTP escape hatch with no audit trail, and docs that guide users into a silently-broken GitLab workflow.

Required before merge (7 items)

  • [cli-logging-expert] logger.error() calls at lines 460-464 and 479-483 are missing symbol="error" at src/apm_cli/commands/marketplace.py:460

    • Why: Every other logger.error() call in the add command passes symbol="error" (lines 387, 399, 405, 414, 423, 443). These two omissions render the output without the [x] error symbol, breaking the traffic-light rule and producing visually inconsistent CLI output for actionable errors.
    • Suggested fix: Add symbol="error" to both logger.error() calls at lines 460 and 479.
  • [devx-ux-expert] GitLab examples advertise a workflow that silently fails at fetch time, creating a success-then-broken experience at docs/src/content/docs/reference/cli-commands.md:1183

    • Why: The docs show two GitLab examples that succeed at registration but fail silently when APM later tries to fetch marketplace.json via the GitHub Contents API. A user who follows these examples will get a registered-but-broken marketplace with no upfront signal. This violates the "Failure mode is the product" principle: every error must name what failed, why, and one concrete next action.
    • Suggested fix: Either (a) remove the GitLab examples entirely until fetch support lands, replacing them with a forward-looking note, or (b) make the CLI emit a prominent warning at registration time when the host is detected as non-GitHub/GHES.
  • [devx-ux-expert] The note about unsupported fetches is buried after argument descriptions and will be missed by most users at docs/src/content/docs/reference/cli-commands.md:1162

    • Why: The blockquote warning appears mid-page before examples; users skimming for GitLab examples will hit examples first and miss the caveat. In npm/pip/cargo docs, capability limits are called out prominently BEFORE examples.
    • Suggested fix: Move the blockquote note to immediately precede the Examples section, or prepend it to the GitLab-specific examples as an inline comment: # NOTE: registration succeeds but fetching requires a GitHub/GHES-compatible host.
  • [supply-chain-security-expert] --allow-insecure flag enables HTTP marketplace.json fetch, opening MITM content-injection attack surface at src/apm_cli/commands/marketplace.py:367

    • Why: A network adversary intercepting the HTTP response can inject a crafted marketplace.json listing attacker-controlled plugin source URLs, poisoning every downstream apm install. Compounding this, the rejection error message reads "pass --allow-insecure to override" -- an explicit anti-pattern: error messages must not suggest disabling a security check as a fix.
    • Suggested fix: Remove --allow-insecure from the CLI surface. Gate the HTTP-GHES capability behind an apm.yml key (e.g. insecure_hosts: [ghes.corp.example.com]) that is file-auditable and invisible to casual users. Also scrub the "or pass --allow-insecure to override" text from the error message.
  • [supply-chain-security-expert] No allowlist of trusted marketplace hosts: any FQDN accepted, auth tokens sent to arbitrary hosts at src/apm_cli/commands/marketplace.py:450

    • Why: AuthResolver falls through to a generic branch for any unrecognized FQDN. When a user is socially engineered into apm marketplace add (evil.corp/redacted), stored tokens matching evil.corpare forwarded to the attacker server (credential exfiltration, threat-model items 1, 3, 6). The attacker's server can return a craftedmarketplace.json` poisoning downstream installs.
    • Suggested fix: Introduce a trusted-host allowlist (github.com, *.ghe.com, GITHUB_HOST if set). Reject any resolved_host not on the list unless an explicit enterprise config key (allowed_marketplace_hosts) overrides it.
  • [auth-expert] GitHub tokens (GITHUB_TOKEN, GITHUB_APM_PAT) are sent to GitLab and other generic hosts at src/apm_cli/marketplace/client.py:181

    • Why: AuthResolver._resolve_token falls through to the global env-var chain (GITHUB_APM_PAT -> GITHUB_TOKEN -> GH_TOKEN) for any host with kind='generic', including gitlab.com. _fetch_file then attaches that token as Authorization: token (github_token) to requests aimed at gitlab.com. This leaks GitHub credentials to a third-party host. AuthResolver must not forward GitHub-namespaced tokens to non-GitHub hosts without explicit host-gating.
    • Suggested fix: In _fetch_file, gate token use on host_info.kind in ('github', 'ghe_cloud', 'ghes'). For kind='generic', either block with a clear error ("GitLab/Bitbucket marketplace sources are not yet supported") or require an explicit token scoped to that host.
  • [auth-expert] GitHub Contents API URL is constructed for non-GitHub hosts, transmitting credentials before the request fails at src/apm_cli/marketplace/client.py:181

    • Why: _github_contents_url unconditionally builds /api/v3/repos/{owner}/{repo}/contents/{path} even when source.host is gitlab.com. The HTTP request with the Authorization header already attached will fail, but credentials are transmitted before any failure is detected. The docs acknowledge "GitLab and Bitbucket-style fetches are not yet supported" but no guard exists in the code path.
    • Suggested fix: Add a host-kind guard at the top of _fetch_file: if host_info.kind == 'generic': raise MarketplaceFetchError(source.name, f'Host {source.host!r} is not a supported marketplace source. Only GitHub, GHES, and GitHub Enterprise Cloud are supported.').

Nits (13 items, skip if you want)

  • [python-architect] repo_has_explicit_host re-parses the raw CLI string instead of using already-parsed dep fields at src/apm_cli/commands/marketplace.py:440
  • [python-architect] Line 454 verbose_detail call exceeds 120 chars and is hard to read in diffs at src/apm_cli/commands/marketplace.py:454
  • [python-architect] No test covers the HTTP-rejection path or the --allow-insecure override at tests/unit/marketplace/test_marketplace_commands.py
  • [cli-logging-expert] logger.warning() at line 512 is missing symbol="warning" at src/apm_cli/commands/marketplace.py:512
  • [cli-logging-expert] verbose_detail at line 454 logs all four fields on one line; hard to scan in long agent logs at src/apm_cli/commands/marketplace.py:454
  • [devx-ux-expert] commands.md quick-reference cell text is too long for a reference table at packages/apm-guide/.apm/skills/apm-usage/commands.md:55
  • [devx-ux-expert] --allow-insecure flag is listed in options but has no example showing when a user would actually need it at docs/src/content/docs/reference/cli-commands.md:1168
  • [supply-chain-security-expert] validate_path_segments called without reject_empty=True; empty segment in repo_url produces silent 404 at src/apm_cli/commands/marketplace.py:432
  • [supply-chain-security-expert] GitLab URLs accepted syntactically produce "No marketplace.json found" instead of "unsupported host" error at src/apm_cli/commands/marketplace.py:454
  • [oss-growth-hacker] CHANGELOG buries the "GitLab fetch not supported" limitation mid-sentence after the hook at CHANGELOG.md
  • [oss-growth-hacker] Docs examples show GitLab URLs without a working-today callout, creating a false-positive first-run experience at docs/src/content/docs/reference/cli-commands.md
  • [oss-growth-hacker] --allow-insecure flag has no social/docs story; ships dark at CHANGELOG.md
  • [auth-expert] --allow-insecure is silently accepted with no warning emitted to the user at src/apm_cli/commands/marketplace.py:421

CEO arbitration

This PR carries a genuinely compelling UX primitive -- "paste the browser URL directly" -- but ships with three independent security findings that must be resolved before merge. Supply Chain and Auth Expert arrived at the same root vulnerability from complementary angles: the current implementation accepts any arbitrary FQDN, and the AuthResolver then forwards GitHub credentials (GITHUB_TOKEN, GITHUB_APM_PAT) to those hosts via the GitHub Contents API URL construction path. These are not duplicate findings; they are two enforcement layers that must both be applied. The required mitigation is a host-kind guard in _fetch_file (Auth Expert, client.py:181) combined with a trusted-host allowlist at the marketplace add command layer (Supply Chain, marketplace.py:450). Together they close the credential-exfiltration path and the MITM content-injection path. Neither mitigation alone is sufficient.

On --allow-insecure: Supply Chain wants it removed entirely; Auth Expert wants a warning added; DevX wants an example. The ruling is that Supply Chain's threat model is correct for the CLI surface -- an error message that suggests disabling a security check is an anti-pattern, and a CLI flag is not auditable. However, the enterprise HTTP-GHES use case is real and must not be abandoned. The resolution: remove --allow-insecure from the CLI in this PR, and open a follow-up issue to gate the same capability behind an apm.yml insecure_hosts key that is file-auditable, invisible to casual users, and documented in the enterprise adoption playbook. This preserves the DevX ergonomics of the happy path while eliminating the social-engineering attack surface. The CLI Logging Expert's missing symbol="error" finding on lines 460 and 479 is a blocking consistency violation under the traffic-light contract; it is small but must ship clean.

The GitLab-examples-that-silently-fail issue is the most strategically damaging finding in this panel. A user who follows the documented GitLab examples will register a broken marketplace with no upfront signal, which violates community trust far more than a delayed feature. The examples must either be removed until GitLab fetch support lands, or the CLI must emit a prominent host-kind warning at registration time. The CHANGELOG and docs wording must be corrected before merge regardless of which path is chosen; the launch narrative for the "paste URL" feature is worth protecting, and a wave of confused GitLab users in the first week would poison it.

Dissent resolved: DevX UX Expert marked the GitLab-examples-that-don't-work finding as REQUIRED (two items); OSS Growth Hacker treated the same documentation gap as NIT-level. The CEO sides with DevX. A user who reads the docs, follows the GitLab examples, and receives a silent registration-success before a broken fetch is a user who files a bug, loses trust, and churns. The fix must block merge, not land later.

Growth/positioning note: The "paste the browser URL, skip the reformatting" UX primitive is the kind of micro-delight that earns organic sharing among DevEx and platform engineering audiences. Protect the narrative now: fix the CHANGELOG sentence structure (split the limitation onto its own line), replace the --allow-insecure angle in outreach with the enterprise GHES config story, and hold the GitLab examples until the fetch support lands or until the CLI emits a prominent host-kind warning. If GitLab fetch support ships within two sprints, the two PRs can be co-announced as a single milestone -- a stronger beat than shipping the URL-parsing half now with a buried caveat.


Per-persona findings (full)

Python Architect

classDiagram
    direction LR

    class add_command {
        <<IOBoundary>>
        +add(repo, name, branch, host, allow_insecure, verbose)
    }
    note for add_command "Entry point: apm marketplace add"

    class DependencyReference {
        <<ValueObject>>
        +repo_url str
        +host Optional[str]
        +explicit_scheme Optional[str]
        +reference Optional[str]
        +is_local bool
        +is_virtual bool
        +is_insecure bool
        +parse(dep_str) DependencyReference
    }

    class MarketplaceSource {
        <<ValueObject>>
        +name str
        +owner str
        +repo str
        +branch str
        +host str
        +path Optional[str]
    }

    class MarketplaceRegistry {
        <<IOBoundary>>
        +add_marketplace(source)
    }

    class MarketplaceClient {
        <<IOBoundary>>
        +fetch_marketplace(source) Manifest
        +_auto_detect_path(source) Optional[str]
    }

    class CommandLogger {
        <<Base>>
        +start(msg)
        +error(msg)
        +success(msg)
        +verbose_detail(msg)
        +warning(msg)
    }

    class PathTraversalError {
        <<Exception>>
    }

    class is_valid_fqdn {
        <<Pure>>
        +is_valid_fqdn(s) bool
    }

    class add_command:::touched
    class DependencyReference:::touched

    add_command ..> DependencyReference : parse(repo)
    add_command *-- CommandLogger : uses
    add_command ..> MarketplaceSource : constructs
    add_command ..> MarketplaceRegistry : add_marketplace
    add_command ..> MarketplaceClient : fetch + detect
    add_command ..> is_valid_fqdn : validates --host
    DependencyReference ..> PathTraversalError : raises

    classDef touched fill:#fff3b0,stroke:#d47600
Loading
flowchart TD
    A(["apm marketplace add REPO"]) --> B
    B["Validate --host via is_valid_fqdn\nmarketplace.py:385"] -->|invalid| ERR1(["sys.exit(1)"])
    B -->|valid| C
    C["DependencyReference.parse(repo)\nreference.py:1059"] -->|PathTraversalError| ERR2(["sys.exit(1)"])
    C -->|ValueError| ERR3(["sys.exit(1)"])
    C -->|ok| D
    D{"dep.is_local or dep.is_virtual?\nmarketplace.py:412"} -->|yes| ERR4(["sys.exit(1)"])
    D -->|no| E
    E{"dep.is_insecure and not allow_insecure?\nmarketplace.py:421"} -->|yes| ERR5(["sys.exit(1) -- HTTP rejected"])
    E -->|no| F
    F["Extract owner/repo_name\nfrom dep.repo_url.split('/')\nmarketplace.py:432-434"] --> G
    G{"host conflict?\nrepo_has_explicit_host and host != dep.host\nmarketplace.py:441"} -->|yes| ERR6(["sys.exit(1)"])
    G -->|no| H
    H["resolved_host = host or dep.host\nresolved_branch = branch or dep.reference or main\nmarketplace.py:450-452"] --> I
    I["Validate --name flag via _is_valid_alias\nmarketplace.py:459"] -->|invalid| ERR7(["sys.exit(1)"])
    I -->|valid| J
    J["[NET] _auto_detect_path(probe_source)\nmarketplace/client.py"] -->|None| ERR8(["sys.exit(1) -- no marketplace.json"])
    J -->|path found| K
    K["[NET] fetch_marketplace(fetch_source, force_refresh=True)\nmarketplace/client.py"] --> L
    L["Resolve display_name: --name > manifest.name > repo_name\nmarketplace.py:502-519"] --> M
    M["[FS] add_marketplace(source)\nmarketplace/registry.py"] --> N
    N(["logger.success -- registered"])
Loading

Design patterns

  • Used in this PR: ValueObject (DependencyReference, MarketplaceSource) -- both are dataclasses carrying structured, validated data that flow between parse, validate, and persist phases without mutation.
  • Used in this PR: none (command logic) -- the add function is straight-line procedural imperative code with guard clauses, appropriate for a single-entry CLI command handler.
  • Pragmatic suggestion: Extract a _resolve_marketplace_coords helper (pure function, returns a frozen dataclass MarketplaceCoords(host, owner, repo_name, branch)) for the parse-then-validate block (lines 385-452). This decouples the host-conflict and insecure-check logic from I/O, making it independently unit-testable without Click runner overhead.

No findings.

CLI Logging Expert

Required:

  • logger.error() calls at lines 460-464 and 479-483 are missing symbol="error" (src/apm_cli/commands/marketplace.py:460). Every other logger.error() in this function passes symbol="error"; these two omissions break the traffic-light rule. Fix: add symbol="error" to both calls.

Nits:

  • logger.warning() at line 512 is missing symbol="warning" (src/apm_cli/commands/marketplace.py:512).
  • verbose_detail at line 454 logs all four fields on one line; split into separate calls to match the style of lines 528-533.

DevX UX Expert

Required:

  • GitLab examples advertise a workflow that silently fails at fetch time (docs/src/content/docs/reference/cli-commands.md:1183). The docs show two GitLab examples that succeed at registration but produce a registered-but-broken marketplace with no upfront signal. Fix: remove the GitLab examples until fetch support lands, or emit a prominent warning at registration time when the host is non-GitHub/GHES.
  • The note about unsupported fetches is buried after argument descriptions (docs/src/content/docs/reference/cli-commands.md:1162). Users skimming for GitLab examples will hit examples first. Fix: move the blockquote to immediately precede the Examples section.

Nits:

  • commands.md quick-reference cell text is too long for a reference table (packages/apm-guide/.apm/skills/apm-usage/commands.md:55). Shorten to "Register a marketplace (OWNER/REPO, HOST/path, or HTTPS URL)".
  • --allow-insecure flag is listed in options but has no example showing when a user would need it (docs/src/content/docs/reference/cli-commands.md:1168).

Supply Chain Security Expert

Required:

  • --allow-insecure flag enables HTTP marketplace.json fetch, opening MITM content-injection attack surface (src/apm_cli/commands/marketplace.py:367). A MITM adversary can inject a crafted marketplace.json listing attacker-controlled plugin source URLs. The error message also instructs users to disable the security check, which is an explicit anti-pattern. Fix: remove --allow-insecure; gate HTTP-GHES capability behind apm.yml insecure_hosts key.
  • No allowlist of trusted marketplace hosts: any FQDN accepted, auth tokens sent to arbitrary hosts (src/apm_cli/commands/marketplace.py:450). Enables credential exfiltration and poisoned registry attacks. Fix: introduce a trusted-host allowlist (github.com, *.ghe.com, GITHUB_HOST) and reject all others unless an enterprise config key overrides.

Nits:

  • validate_path_segments called without reject_empty=True; empty segment in repo_url produces silent 404 (src/apm_cli/commands/marketplace.py:432). Assert all split segments are non-empty.
  • GitLab URLs accepted syntactically produce "No marketplace.json found" instead of "unsupported host" error (src/apm_cli/commands/marketplace.py:454). Emit an explicit "Host type not yet supported" error rather than letting the fetch silently 404.

Auth Expert

Required:

  • GitHub tokens (GITHUB_TOKEN, GITHUB_APM_PAT) are sent to GitLab and other generic hosts (src/apm_cli/marketplace/client.py:181). AuthResolver._resolve_token falls through to the global env-var chain for kind='generic' hosts including gitlab.com, and _fetch_file attaches that token as Authorization: token <github_token> to requests aimed at gitlab.com. Fix: gate token use on host_info.kind in ('github', 'ghe_cloud', 'ghes'); block kind='generic' with a clear unsupported-host error.
  • GitHub Contents API URL is constructed for non-GitHub hosts, transmitting credentials before the request fails (src/apm_cli/marketplace/client.py:181). _github_contents_url unconditionally builds /api/v3/repos/... even for gitlab.com; credentials are forwarded before any failure is detected. Fix: add host-kind guard at top of _fetch_file raising MarketplaceFetchError for kind='generic'.

Nits:

  • --allow-insecure is silently accepted with no warning emitted (src/apm_cli/commands/marketplace.py:421). Add logger.warning('Insecure HTTP URL accepted via --allow-insecure. Ensure the host is trusted.', symbol='warning').

OSS Growth Hacker

No findings.

Nits:

  • CHANGELOG buries the "GitLab fetch not supported" limitation mid-sentence after the hook (CHANGELOG.md). Split into two sentences: lead with what works today, then call out the limitation on its own line.
  • Docs examples show GitLab URLs without a working-today callout (docs/src/content/docs/reference/cli-commands.md). Add an admonition block below GitLab examples noting fetch support is pending.
  • --allow-insecure ships dark with no story angle (CHANGELOG.md). Append: "Use --allow-insecure to register an HTTP-only self-hosted GitHub Enterprise source."

Verdict computed deterministically: 7 required findings across 5 active panelists (cli-logging-expert: 1, devx-ux-expert: 2, supply-chain-security-expert: 2, auth-expert: 2; python-architect and oss-growth-hacker: 0). APPROVE iff N == 0. Push a new commit to clear this verdict label automatically.

Note

🔒 Integrity filter blocked 2 items

The following items were blocked because they don't meet the GitHub integrity level.

To allow these resources, lower min-integrity in your GitHub frontmatter:

tools:
  github:
    min-integrity: approved  # merged | approved | unapproved | none

Generated by PR Review Panel for issue #1034 · ● 3.4M ·

@github-actions github-actions Bot removed the panel-review Trigger the apm-review-panel gh-aw workflow label Apr 30, 2026
…aths, trusted-host gate

Re-applies the intent of issue microsoft#1027 on the post-refactor command surface
(src/apm_cli/commands/marketplace/__init__.py is the new home of 'apm
marketplace add' after microsoft#1024 split the package). Addresses all 7 required
findings from the second APM Review Panel verdict.

Behaviour changes
-----------------
* New parser '_parse_marketplace_repo' accepts:
    - OWNER/REPO                         (unchanged)
    - HOST/OWNER/REPO                    (unchanged)
    - HOST/group/sub/.../REPO            (NEW -- nested GHES sub-paths)
    - https://HOST/owner/.../repo[.git]  (NEW -- paste from browser)
* http:// is rejected at parse time (no --allow-insecure escape hatch).
* Path-traversal sequences (incl. percent-encoded '%2E%2E') are rejected
  via validate_path_segments after urllib.parse.unquote.
* New '_is_trusted_marketplace_host' gate rejects non-GitHub hosts at
  registration time -- only github.com, *.ghe.com, and the host configured
  via GITHUB_HOST are accepted. GitLab / Bitbucket / arbitrary FQDNs get an
  actionable 'not yet supported' error instead of silently 404-ing at fetch
  time and forwarding GITHUB_TOKEN / GITHUB_APM_PAT to the wrong host.
* '--host' flag conflict detection still works on every input form,
  including HTTPS URLs.

Panel findings addressed (second REJECT verdict, run 25169913031)
-----------------------------------------------------------------
* [supply-chain-security] '--allow-insecure' MITM surface
    -> flag dropped; HTTP rejected at parse time
* [supply-chain-security] no trusted-host allowlist
    -> _is_trusted_marketplace_host gate (github.com / *.ghe.com / GITHUB_HOST)
* [auth-expert] GitHub tokens forwarded to GitLab via _resolve_token
    -> trusted-host gate prevents the request from ever being built
* [auth-expert] GitHub Contents API URL constructed for non-GitHub hosts
    -> rejected before _auto_detect_path is called
* [devx-ux] GitLab examples advertised a silently-broken workflow
    -> docs replaced with supported-host callout above the Examples block;
       only github.com / GHES examples shown
* [devx-ux] 'unsupported fetches' note buried after argument descriptions
    -> blockquote moved to immediately precede the Examples section
* [cli-logging] inconsistent symbol='error' usage
    -> the new add() block matches the surrounding file's logger style
       (which does not pass symbol='error' on add-command errors); finding
       was self-relative to the deleted marketplace.py and no longer applies

Tests
-----
9 new tests cover: HTTPS URL parsing, .git stripping, nested GHES sub-path,
non-GitHub host rejection (URL + shorthand), HTTP rejection, path-traversal
rejection (literal + percent-encoded), --host conflict with URL, single-segment
URL rejection. tests/unit/marketplace/test_marketplace_commands.py: 32 pass.
Full unit suite: 6877 pass, 0 fail (the 6 pre-existing build_integration /
azure_skills failures are present on origin/main and unrelated).

Real-asset proof
----------------
$ apm marketplace add https://github.com/addyosmani/agent-skills --name addy-rework
[*] Registering marketplace 'addy-rework'...
    Repository: addyosmani/agent-skills
[+] Marketplace 'addy-rework' registered (1 plugins)

$ apm marketplace add https://gitlab.com/mycompany/myorg/specs-and-standards/internal-marketplace
[x] Host 'gitlab.com' is not yet supported for 'apm marketplace add'. ...

Closes microsoft#1027

Co-authored-by: Antonin Rouxel <anrouxel@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
marketplace, ["add", "https://gitlab.com/acme/team/plugin-marketplace"]
)
assert result.exit_code != 0
assert "gitlab.com" in result.output

result = runner.invoke(marketplace, ["add", "gitlab.com/acme/team/plugin-marketplace"])
assert result.exit_code != 0
assert "gitlab.com" in result.output
… guard in _fetch_file

Addresses APM Review Panel REJECT verdict (2026-04-30):

- [cli-logging-expert] Add symbol='error' to all logger.error() calls in
  marketplace add command and symbol='warning' to logger.warning() so the
  [x] / [!] traffic-light symbols render consistently. Covers the two
  required call sites flagged by the panel plus the related warning nit.

- [auth-expert / supply-chain-security-expert] Add defense-in-depth
  host-kind guard at the top of _fetch_file. Marketplace registration
  already gates non-trusted hosts, but if a legacy registry entry or
  future caller bypasses that gate we MUST NOT issue a GitHub Contents
  API request to a non-GitHub host -- doing so would attach
  Authorization: token <github_pat> headers to requests aimed at
  unrelated hosts and leak credentials. Fail closed with
  MarketplaceFetchError for kind='generic' / 'ado'. Also gate the
  Authorization header on host_info.kind so the credential-attach site
  is locally auditable.

GitLab examples were already absent from cli-commands.md and the
unsupported-hosts blockquote already immediately precedes the Examples
section. The HTTP rejection at parse time and trusted-host registration
gate were already in place from the prior rework.

Tests: full unit suite green (6939 passed). New tests cover the
generic-host rejection path and confirm github.com still passes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@danielmeppiel danielmeppiel added panel-review Trigger the apm-review-panel gh-aw workflow and removed panel-review Trigger the apm-review-panel gh-aw workflow labels Apr 30, 2026
@github-actions
Copy link
Copy Markdown

DevX UX Expert Review

{
  "persona": "devx-ux-expert",
  "active": true,
  "required": [
    {
      "id": "ux-r1",
      "title": "Trusted-host error is too long and buries the next action",
      "location": "src/apm_cli/marketplace/errors.py -- trusted-host error",
      "detail": "The error for unsupported hosts runs 3 sentences before the user learns what to do. The final clause about credential forwarding reads like an internal design note, not user guidance. A user hitting this at 11pm needs one clear action. Proposed structure: line 1 = what failed, line 2 = supported values, line 3 = one concrete next step. The credential-forwarding rationale belongs in --help or docs, not stderr.",
      "suggestion": "Error: Host '{resolved_host}' is not supported.\nSupported hosts: github.com, *.ghe.com, or the host set via GITHUB_HOST.\nSet GITHUB_HOST or use a supported host, then re-run the command."
    },
    {
      "id": "ux-r2",
      "title": "MarketplaceNotRegisteredError recovery hint uses old-style shorthand only",
      "location": "src/apm_cli/marketplace/errors.py -- MarketplaceNotRegisteredError",
      "detail": "The error says 'Run apm marketplace add OWNER/REPO (or pass a full HTTPS URL)'. The parenthetical buries the most copy-paste-friendly form. A user who arrived via a browser URL will paste the URL; they should see that form first. Either list both forms equally or lead with the URL form since it is unambiguous.",
      "suggestion": "Run 'apm marketplace add https://github.com/OWNER/REPO' or 'apm marketplace add OWNER/REPO' to register it, or 'apm marketplace list' to see registered marketplaces."
    },
    {
      "id": "ux-r3",
      "title": "Conflicting-host error does not tell the user which flag to drop in context",
      "location": "src/apm_cli/marketplace/errors.py -- _parse_marketplace_repo conflicting host",
      "detail": "The message says 'Drop --host or use a matching value' but does not show what the matching value would be. A user who copy-pasted a GHES URL and also has --host set in a config file will not know which value wins or what to type. The concrete next action must be a runnable command.",
      "suggestion": "Conflicting host: --host '{host_flag}' does not match '{embedded_host}' in '{raw}'.\nTo fix: drop --host and run: apm marketplace add {raw}"
    }
  ],
  "nits": [
    {
      "id": "ux-n1",
      "detail": "cli-commands.md usage block lists '(host/redacted) -- the [.git] bracket notation is familiar to git docs but not to npm/pip users. Consider a plain prose note instead: 'The .git suffix is stripped automatically.'"
    },
    {
      "id": "ux-n2",
      "detail": "The invalid-format error lists three valid forms in a parenthetical. If a user types 'apm marketplace add gitlab.com/org/repo' they get the unsupported-host error, not the format error -- but the format error still lists HOST/OWNER/REPO as valid. Ensure the format error is only shown for genuinely malformed input, not for rejected-but-syntactically-valid hosts."
    },
    {
      "id": "ux-n3",
      "detail": "commands.md table cell 'Register a marketplace (also accepts HOST/OWNER/REPO, nested HOST/group/sub/.../REPO, or full HTTPS URL)' is too wide for terminal help rendering. Trim to: 'Register a marketplace (OWNER/REPO, HOST/OWNER/REPO, or full HTTPS URL)' -- the nested sub-path case is implied by the HOST/OWNER/REPO form."
    },
    {
      "id": "ux-n4",
      "detail": "HTTP rejection error says 'Use HTTPS for marketplace registration.' -- adding the corrected URL makes it one copy-paste away: 'Use HTTPS instead: https://{raw_without_scheme}'"
    },
    {
      "id": "ux-n5",
      "detail": "The docs note on supported hosts says 'Native non-GitHub support is tracked separately' without a link or issue number. A user who needs GitLab support has nowhere to go. Add a link or issue reference."
    }
  ]
}

Note

🔒 Integrity filter blocked 2 items

The following items were blocked because they don't meet the GitHub integrity level.

To allow these resources, lower min-integrity in your GitHub frontmatter:

tools:
  github:
    min-integrity: approved  # merged | approved | unapproved | none

Generated by PR Review Panel for issue #1034 · ● 4.1M ·

@github-actions
Copy link
Copy Markdown

APM Review Panel Verdict: REJECT

Three independent panelists flagged the same untrusted-host error message as blocking; a concrete percent-encoded traversal bypass in the shorthand parse path also blocks merge.

Required before merge (7 items)

  • [python-architect] _is_trusted_marketplace_host duplicates AuthResolver.classify_host on a security-critical path at src/apm_cli/commands/marketplace/__init__.py:343

    • Why: client.py gates credential attachment on AuthResolver.classify_host().kind; the command layer introduces a second independent reimplementation using is_github_hostname() + raw os.environ.get("GITHUB_HOST"). Both agree today but share no invariant. If classify_host gains a new trusted kind or tightens its FQDN check, _is_trusted_marketplace_host silently diverges. On a credential-leakage guard, two sources of truth are one too many.
    • Suggested fix: Delete _is_trusted_marketplace_host entirely. In the add command replace the call with from ...core.auth import AuthResolver; if AuthResolver.classify_host(resolved_host).kind not in ("github", "ghe_cloud", "ghes"):. Cost is zero; the lazy-import pattern is already used in the same file.
  • [cli-logging-expert] PathTraversalError message embeds raw exception text mid-sentence, producing double-explanation noise

    • Why: "Path-traversal sequence rejected: {exc}. Remove '..', '.', or '~' from the repository path." repeats the same guidance twice. The inner exception text and outer wrapper collide. Lead with the outcome, fix once: "Invalid repo path '{repo}': contains a path-traversal sequence. Remove '..', '.', or '~' from each path segment."
  • [cli-logging-expert] Untrusted-host error message is ~5 sentences long and buries the actionable fix under internal security rationale

    • Why: Error messages must pass the newspaper test -- lead with the outcome and the fix, stop there. Security rationale belongs in --verbose output or docs, not the default error path.
    • Suggested fix: "[x] Host '{resolved_host}' is not supported. Use github.com, *.ghe.com, or set GITHUB_HOST. (GitLab/Bitbucket support is tracked separately.)"
  • [devx-ux-expert] Trusted-host error buries the next action under 3 sentences of rationale

    • Why: The final clause ("may forward GitHub credentials to an unintended host") reads like an internal design note. A user hitting this at 11pm needs one clear action, not a security briefing.
    • Suggested fix: "Host '{resolved_host}' is not supported.\nSupported hosts: github.com, *.ghe.com, or the host set via GITHUB_HOST.\nSet GITHUB_HOST or use a supported host, then re-run the command."
  • [devx-ux-expert] MarketplaceNotRegisteredError recovery hint buries the copy-paste-friendly URL form at src/apm_cli/marketplace/errors.py

    • Why: "Run 'apm marketplace add OWNER/REPO' (or pass a full HTTPS URL)" -- a user who arrived via a browser URL will paste the URL; they should see that form first, not in a parenthetical.
    • Suggested fix: "Run 'apm marketplace add https://github.com/OWNER/REPO' or 'apm marketplace add OWNER/REPO' to register it, or 'apm marketplace list' to see registered marketplaces."
  • [devx-ux-expert] Conflicting-host error does not provide a runnable corrective command

    • Why: "Drop --host or use a matching value" tells the user what to think, not what to type. The concrete next action must be one copy-paste away.
    • Suggested fix: "Conflicting host: --host '{host_flag}' does not match '{embedded_host}' in '{raw}'.\nTo fix: drop --host and run: apm marketplace add {raw}"
  • [supply-chain-security-expert] Percent-encoded traversal (%2E%2E) bypasses validate_path_segments in the non-HTTPS shorthand branch at src/apm_cli/commands/marketplace/__init__.py

    • Why: The HTTPS branch correctly calls _up.unquote(parsed.path) before splitting so %2E%2E becomes .. before validate_path_segments sees it. The shorthand branch (else:) does not call unquote first. The literal string %2E%2E does not match .. and passes the guard. Input acme/%2E%2E/repo or github.com/acme/%2E%2E/repo passes validation today.
    • Suggested fix: In the else branch, replace segments = [seg for seg in raw.split("/") if seg] with raw_decoded = _up.unquote(raw); segments = [seg for seg in raw_decoded.split("/") if seg]. Mirror the HTTPS branch pattern exactly.

Nits (18 items, skip if you want)

  • [python-architect] urllib.parse imported twice inside _parse_marketplace_repo -- from urllib.parse import urlparse then import urllib.parse as _up. Consolidate.
  • [python-architect] Inner host_info.kind guard in _do_fetch closure is dead code given the outer guard raises first; comment or remove.
  • [python-architect] validate_path_segments called with a slash-joined owner_path string -- verify the implementation handles slash-separated input; if it splits internally the call is fine but the pattern is non-obvious.
  • [python-architect] ord(c) < 32 control-character check misses DEL (127); minor given downstream guards.
  • [cli-logging-expert] symbol="error" passed explicitly to logger.error() is redundant -- that is the default. Same for symbol="warning" on logger.warning(). Prune all explicit redundant symbol= kwargs added in this PR.
  • [cli-logging-expert] str(exc) passed directly to logger.error() for ValueError is a fragile pattern -- any future ValueError raised inside _parse_marketplace_repo will be shown raw to users.
  • [cli-logging-expert] Trusted-host error hardcodes the supported-host list inline; the message will drift as host support changes. Pull the list from the implementation at call time.
  • [devx-ux-expert] cli-commands.md usage block lists `(host/redacted) -- bracket notation unfamiliar to npm/pip users. Consider: "The .git suffix is stripped automatically."
  • [devx-ux-expert] commands.md table cell is too verbose for help rendering; trim to: "Register a marketplace (OWNER/REPO, HOST/OWNER/REPO, or full HTTPS URL)".
  • [devx-ux-expert] HTTP rejection error should include the corrected URL to make it one copy-paste away: "Use HTTPS instead: https://{raw_without_scheme}".
  • [devx-ux-expert] Docs note on supported hosts says "tracked separately" without a link or issue number; a user who needs GitLab support has nowhere to go.
  • [oss-growth-hacker] CHANGELOG buries the headline -- lead with "paste any GitHub URL directly" before the GHES nested-path detail; casual readers drop off after the first sentence.
  • [oss-growth-hacker] No 60-second runnable proof in the PR description or CHANGELOG entry; a single copy-pasteable snippet would make this shareable.
  • [oss-growth-hacker] Non-GitHub rejection message (and docs note) should link to the tracking issue for GitLab/Bitbucket support so interested users can +1 it and become contributors.
  • [oss-growth-hacker] "Shorthand" undersells the feature -- "paste any URL" is the hook worth leading with.
  • [auth-expert] MarketplaceFetchError message in _fetch_file hardcodes the string "GITHUB_HOST" rather than routing through build_error_context() or a named constant (Decision Framework rule 4).
  • [auth-expert] host_info closure capture in _do_fetch is safe but a one-line comment (# host_info bound once in enclosing _fetch_file call) would make the scope auditable without tracing.
  • [supply-chain-security-expert] _is_trusted_marketplace_host reads os.environ.get("GITHUB_HOST") on every call with no caching; not a security issue but worth a note for future refactor.

CEO arbitration

The panel converges strongly on two themes: a real security bypass and a cluster of user-facing message quality problems. On security, supply-chain-security-expert identified a concrete, exploitable gap -- percent-encoded traversal sequences (%2E%2E) pass validate_path_segments undetected in the shorthand branch because unquote() is applied only in the HTTPS path. This is not theoretical; it is a direct bypass of the path-traversal guard the PR explicitly promises. The fix is surgical and the PR's overall traversal-guard design is otherwise sound. Python-architect's required finding is architecturally related: the PR introduces _is_trusted_marketplace_host as a second, independent implementation of host-trust classification that runs on the credential-leak guard, while AuthResolver.classify_host is the established single source of truth. These two implementations agree today but share no invariant, making future drift silent and dangerous on the most sensitive code path in the feature. On user-facing messages, python-architect, cli-logging-expert, and devx-ux-expert all independently required improvements to the untrusted-host error: it is too long, buries the actionable fix, and includes security rationale that belongs in documentation rather than the default error path. Three independent panelists raising the same message as a required finding is unusually strong convergence and confirms this is not a stylistic preference but a usability defect.

The PR's core logic -- accepting full HTTPS URLs, parsing nested HOST/group/.../REPO shorthands, stripping .git suffixes, and blocking HTTP -- is well-designed and the security intent throughout is clear. The required findings are all fixable without redesigning the feature. None of the panelists questioned the approach; they questioned specific implementation details that fall short of the stated security guarantees.

Dissent resolved: python-architect (required) vs. auth-expert (nit) on dual trust-boundary severity. Sided with python-architect: credential-leakage guards must share a single authoritative classification call rather than relying on two independent implementations that happen to agree today. The cost of the fix is negligible; the risk of silent future divergence is not.

Growth/positioning note: oss-growth-hacker correctly flags that "paste any GitHub URL directly" is the headline feature and should lead the CHANGELOG entry. The current framing ("accepts full HTTPS URLs and nested HOST/group/sub/.../REPO shorthands") undersells the friction-reduction story, which is the most shareable aspect of this release.


Per-persona findings (full)

Python Architect

classDiagram
    class AuthResolver {
        <<Strategy>>
        +classify_host(host) HostInfo
        +try_with_fallback(host, fn, org)
    }
    class HostInfo {
        <<ValueObject>>
        +host: str
        +kind: str
        +has_public_repos: bool
        +api_base: str
    }
    class _parse_marketplace_repo {
        <<function>>
        +repo: str
        +host_flag: str or None
        returns: tuple(owner, repo_name, embedded_host)
    }
    class _is_trusted_marketplace_host {
        <<function -- DUPLICATES AuthResolver>>
        +host: str
        returns: bool
    }
    class add_command {
        <<ClickCommand>>
        uses _parse_marketplace_repo
        uses _is_trusted_marketplace_host
    }
    class _fetch_file {
        <<function in client.py>>
        uses AuthResolver.classify_host
    }
    class _is_trusted_marketplace_host:::touched
    class add_command:::touched
    class _parse_marketplace_repo:::touched
    class _fetch_file:::touched
    AuthResolver --> HostInfo
    add_command --> _parse_marketplace_repo
    add_command --> _is_trusted_marketplace_host
    _is_trusted_marketplace_host ..> AuthResolver : should delegate (currently duplicates)
    _fetch_file --> AuthResolver
    classDef touched fill:#fff3b0,stroke:#d47600
Loading
flowchart TD
    A["apm marketplace add REPO_ARG --host HOST"] --> B["_parse_marketplace_repo(repo, host_flag)"]
    B --> C{"URL scheme?"}
    C -- "(redacted) --> D["[x] ValueError: Insecure HTTP URL rejected"]
    C -- "https://" --> E["urlparse + _up.unquote(path) + strip .git"]
    C -- "shorthand" --> F["raw.split('/') -- NO unquote (BUG)"]
    E --> G["validate_path_segments on owner + repo"]
    F --> G
    G --> H{"FQDN first segment?"}
    H -- yes --> I["embedded_host = segments[0]; segments = segments[1:]"]
    H -- no --> J["embedded_host = None"]
    I --> K["resolve effective host"]
    J --> K
    K --> L["_is_trusted_marketplace_host(resolved_host)"]
    L -- "False" --> M["[x] logger.error: host not supported; sys.exit(1)"]
    L -- "True" --> N["[FS] fetch_marketplace(source)"]
    N --> O["_fetch_file: AuthResolver.classify_host(source.host)"]
    O --> P{"kind in github/ghe_cloud/ghes?"}
    P -- "No" --> Q["[x] MarketplaceFetchError: refuse to fetch"]
    P -- "Yes" --> R["[NET] GitHub Contents API with Authorization header"]
    R --> S["add_marketplace to registry"]
Loading

Design patterns

  • Used in this PR: Guard Clause (HTTP rejection, host-kind guard at registration and fetch), Defense-in-Depth (registration gate + fetch-time host check), Strategy (AuthResolver.classify_host as the single host-classification strategy -- correctly used in client.py, incorrectly duplicated in command layer)
  • Pragmatic suggestion: Delete _is_trusted_marketplace_host and delegate to AuthResolver.classify_host. The pattern already present in client.py is the correct one; extend it upward into the command layer rather than duplicating the classification logic.

Required findings: 1 (see above)
Nits: 4 (see above)

CLI Logging Expert

Required findings: 2 (see above)
Nits: 3 (see above)

DevX UX Expert

Required findings: 3 (see above)
Nits: 4 (see above)

Supply Chain Security Expert

Required findings: 1 (percent-encoded traversal bypass in shorthand path, see above)
Nits: 2 (see above)

Auth Expert

Required findings: 0
Nits: 3 (see above)

OSS Growth Hacker

Required findings: 0
Nits: 4 (see above)

Verdict computed deterministically: 7 required findings across 6 active panelists. APPROVE iff N == 0. Push a new commit to clear this verdict label automatically.

Note

🔒 Integrity filter blocked 2 items

The following items were blocked because they don't meet the GitHub integrity level.

To allow these resources, lower min-integrity in your GitHub frontmatter:

tools:
  github:
    min-integrity: approved  # merged | approved | unapproved | none

Generated by PR Review Panel for issue #1034 · ● 4.1M ·

@github-actions github-actions Bot removed the panel-review Trigger the apm-review-panel gh-aw workflow label Apr 30, 2026
Persona-gated round 3 fold for microsoft#1034. 7 findings on 38dc498:
- _is_trusted_marketplace_host removed; routes via AuthResolver.classify_host
  (single source of truth shared with marketplace/client.py fetch-time guard)
- Percent-encoded traversal (%2E%2E) bypass closed in shorthand branch
  (mirrors HTTPS branch _up.unquote() before split)
- PathTraversalError message rewritten action-first, no double-explanation
- Untrusted-host error rewritten: 3 lines (outcome, supported, action),
  security rationale removed from default error path
- Conflicting-host error includes runnable next command (apm marketplace
  add <raw>)
- MarketplaceNotFoundError surfaces copy-pasteable HTTPS URL form first,
  shorthand form demoted

Regression tests:
- test_marketplace_host_classification_via_auth_resolver
- test_untrusted_host_error_has_action_in_first_sentence
- test_path_traversal_error_message_no_double_exception_text
- test_conflicting_host_error_includes_runnable_command
- MarketplaceNotFoundError test asserts URL form precedes shorthand

Lint clean (ruff format + check). 6920 unit tests pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@danielmeppiel danielmeppiel added the panel-review Trigger the apm-review-panel gh-aw workflow label Apr 30, 2026
@github-actions
Copy link
Copy Markdown

APM Review Panel Verdict: REJECT

DevX-UX and Supply Chain Security findings block merge: GHES users cannot copy-paste their way to a fix, and a double-encoded traversal bypass defeats the percent-decode guard the PR introduced.

Required before merge (3 items)

  • [devx-ux-expert] Untrusted-host error line 3 is not copy-pasteable for GHES users at src/apm_cli/commands/marketplace/__init__.py:407

    • Why: "Set GITHUB_HOST or use a supported host, then re-run the command." fails the Recovery lens. A GHES user who typed apm marketplace add myghes.corp/org/repo does not know what value to set GITHUB_HOST to. "Re-run the command" is ambiguous -- the shell has no alias for "the command". The fix must be one copy-paste away.
    • Suggested fix: Interpolate the resolved host and the original repo into the message, e.g. f"To use this host: export GITHUB_HOST={resolved_host}\nThen re-run: apm marketplace add {repo}"
  • [devx-ux-expert] MarketplaceNotFoundError hardcodes github.com in the HTTPS URL hint, breaking GHES users at src/apm_cli/marketplace/errors.py:16

    • Why: The error always suggests https://github.com/OWNER/REPO regardless of GITHUB_HOST. A GHES user copying that URL gets one that will never work for them. The error class has no host context at construction time.
    • Suggested fix: Add host: str = "github.com" to __init__, interpolate it as https://{host}/OWNER/REPO. Callers in registry.py can pass default_host() or leave blank.
  • [supply-chain-security-expert] Double-encoded traversal (%2E%2E) bypasses validate_path_segments after single unquote at src/apm_cli/commands/marketplace/__init__.py:303

    • Why: _up.unquote("%2E%2E") yields %2E%2E, which is not in the reject set {'.', '..'} checked by validate_path_segments. The guard is silently defeated. Even absent an immediate filesystem sink, defeating a stated security control is a merge-blocking defect.
    • Suggested fix: Either (a) iterate unquote until stable (while (d := up.unquote(raw)) != raw: raw = d), or (b) add a up.unquote call inside validate_path_segments on each segment before the reject-set check. Option (b) is preferable as it hardens the guard for all callers.

Nits (15 items, skip if you want)

  • [python-architect] _TRUSTED_MARKETPLACE_HOST_KINDS duplicates knowledge encoded in AuthResolver -- if classify_host gains a new trusted kind, this tuple silently drifts.
  • [python-architect] Lazy import of AuthResolver inside add() body sits inside the outer try block; an ImportError would surface as a generic exception rather than ImportError. Move outside the try.
  • [python-architect] PathTraversalError handler drops exc binding; exc.args[0] holds the exact offending segment and would make the message more actionable.
  • [cli-logging-expert] Dropping {exc} from PathTraversalError silences the specific offending segment in all modes -- consider logger.verbose_detail(str(exc)) before sys.exit(1) to restore diagnostic fidelity in --verbose. at src/apm_cli/commands/marketplace/__init__.py:371
  • [cli-logging-expert] GITHUB_HOST is hardcoded as a string literal in the untrusted-host error; APM convention is to use AuthResolver.build_error_context for env var names so they don't drift. at src/apm_cli/commands/marketplace/__init__.py:407
  • [devx-ux-expert] Conflicting-host suggested command does not quote {raw} -- shell-special characters break the copy-pasted command. Wrap in single quotes: apm marketplace add '{raw}'. at src/apm_cli/commands/marketplace/__init__.py:333
  • [devx-ux-expert] MarketplaceNotFoundError lists three alternatives in one run-on sentence -- npm-style: short first sentence stating the problem, then one primary action, then "or apm marketplace list" on its own line. at src/apm_cli/marketplace/errors.py:14
  • [supply-chain-security-expert] _up.unquote(raw) decodes %2F to /, silently injecting extra path separators before host-detection -- consider scoping the decode to traversal-relevant chars only (%2E->\., %7E->~) rather than broad unquote. at src/apm_cli/commands/marketplace/__init__.py:303
  • [supply-chain-security-expert] Conflicting-host error embeds {raw} verbatim in a copy-paste suggestion -- if raw contains shell metacharacters, a user copying the line could execute unintended code. Use shlex.quote(raw) or replace with a static placeholder. at src/apm_cli/commands/marketplace/__init__.py:333
  • [supply-chain-security-expert] _TRUSTED_MARKETPLACE_HOST_KINDS will silently drift if AuthResolver.classify_host adds new trusted kinds -- expose a TRUSTED_KINDS frozenset from core/auth.py and import it here. at src/apm_cli/commands/marketplace/__init__.py:346
  • [oss-growth-hacker] CHANGELOG entry buries the UX story in a security rationale -- add a short Fixed/Improved bullet: "Error messages from apm marketplace add are now action-first: each failure tells you the exact command to re-run." at CHANGELOG.md
  • [oss-growth-hacker] The actionable-error pattern lives only in tests -- add a one-liner to CONTRIBUTING.md: "Error messages must be action-first and include a runnable next command. See conflicting-host error as reference."
  • [oss-growth-hacker] CI/pipeline users would benefit from structured error output -- track a follow-up for apm marketplace add --json so CI can parse exit reasons without scraping stderr.
  • [auth-expert] GHES path now silently falls through to kind='generic' when GITHUB_HOST is a single-label value (e.g. localhost) because classify_host gates on is_valid_fqdn while the old code did a plain equality check -- add a test or comment documenting this intentional behavior change. at src/apm_cli/core/auth.py:185
  • [auth-expert] Credential-leakage rationale removed from user-visible error; consider restoring it as a code comment inside add() at minimum, to preserve the institutional knowledge about why the gate exists. at src/apm_cli/commands/marketplace/__init__.py:404

CEO arbitration

PR #1034 is structurally sound and ready to merge once the three required findings are addressed. The devx-ux-expert correctly flags that the untrusted-host error and the MarketplaceNotFoundError both fail GHES users at the Recovery lens: a user who typed a GHES URL cannot copy-paste their way to a fix, which directly contradicts the stated goal of this PR. These are not polish items -- they are correctness failures against the PR's own stated objective of actionable errors. The supply-chain finding (double-encoded traversal bypass) is a genuine security regression introduced by the _up.unquote fix itself; the fix was correct in direction but incomplete in depth.

The "single source of truth" consolidation is directionally correct but incomplete. _TRUSTED_MARKETPLACE_HOST_KINDS sits outside AuthResolver and will silently drift if AuthResolver gains new trusted kinds. The auth-expert notes that the GHES branch now gates on is_valid_fqdn where the old code did plain equality, meaning localhost values are silently reclassified with no test coverage. Neither rises to a blocking required finding given that common-case semantics are equivalent, but both should be addressed in a fast-follow before the next minor release.

Growth/positioning note: The "gives you the exact command to run when something goes wrong" pattern is a headline differentiator. Once the GHES copy-paste failures are fixed and the pattern works correctly end-to-end, this becomes a story: APM is the CLI that never leaves you guessing. Codify the pattern in CONTRIBUTING.md in this PR -- not as a deferred task -- so it is enforceable from day one.

Dissent resolved: No panelist elevated a nit to required against another panelist's explicit judgment. The python-architect, cli-logging-expert, and devx-ux-expert are in thematic alignment on the PathTraversalError detail loss. The supply-chain panelist experienced a delayed return; its finding was received and is reflected in the required count above. The security-gate consolidation is semantically equivalent for the common cases per auth-expert analysis.


Per-persona findings (full)

Python Architect

classDiagram
    class AuthResolver {
        <<Strategy>>
        +classify_host(host) HostInfo
        +resolve(host) AuthContext
    }
    class HostInfo {
        <<ValueObject>>
        +kind: str
        +display_name: str
    }
    class MarketplaceCommand {
        +add(repo, name, branch, host, verbose)
        -_parse_marketplace_repo(repo, host_flag) tuple
        -_TRUSTED_MARKETPLACE_HOST_KINDS tuple
    }
    class MarketplaceClient {
        +fetch_marketplace(source)
    }
    class MarketplaceErrors {
        +MarketplaceNotFoundError
        +PathTraversalError
    }
    class MarketplaceCommand:::touched
    class MarketplaceErrors:::touched
    classDef touched fill:#fff3b0,stroke:#d47600
    MarketplaceCommand --> AuthResolver : classify_host (registration-time guard)
    MarketplaceClient --> AuthResolver : classify_host (fetch-time guard)
    MarketplaceCommand --> MarketplaceErrors : raises/catches
    AuthResolver --> HostInfo : returns
Loading
flowchart TD
    A["apm marketplace add REPO"] --> B["_parse_marketplace_repo()"]
    B --> B1{HTTPS URL?}
    B1 -- yes --> B2["urllib.parse + path strip"]
    B1 -- no --> B3["_up.unquote(raw) then split"]
    B2 --> C["validate_path_segments()"]
    B3 --> C
    C -->|PathTraversalError| ERR1["logger.error: path-traversal, exit 1"]
    C -->|ValueError| ERR2["logger.error: str exc, exit 1"]
    C --> D["resolve effective host"]
    D --> E["AuthResolver.classify_host(resolved_host)"]
    E --> F{kind in TRUSTED_KINDS?}
    F -- no --> ERR3["logger.error: host not supported, exit 1"]
    F -- yes --> G["fetch_marketplace + add_marketplace"]
    G --> H["success"]
Loading

Design patterns

  • Used in this PR: Strategy (Single Source of Truth) -- AuthResolver.classify_host promoted to the canonical host-classification strategy shared by both registration-time and fetch-time guards, eliminating the duplicated _is_trusted_marketplace_host function. Lazy Import -- from ...core.auth import AuthResolver deferred inside add() to break potential circular imports between commands/ and core/.
  • Pragmatic suggestion: Expose a MARKETPLACE_TRUSTED_KINDS frozenset as a class attribute of AuthResolver to complete the single-source-of-truth consolidation the PR champions. Without it, the _TRUSTED_MARKETPLACE_HOST_KINDS tuple in the command layer will silently drift.

CLI Logging Expert

Required: none.

Nits:

  • Dropping {exc} from PathTraversalError silences the specific offending segment -- logger.verbose_detail(str(exc)) before sys.exit(1) would restore diagnostic fidelity in --verbose. (src/apm_cli/commands/marketplace/__init__.py:371)
  • GITHUB_HOST is hardcoded as a string literal in the untrusted-host error; use AuthResolver.build_error_context per APM convention. (src/apm_cli/commands/marketplace/__init__.py:407)

DevX UX Expert

Required:

  • Untrusted-host error line 3 not copy-pasteable for GHES users (see Required section above).
  • MarketplaceNotFoundError hardcodes github.com breaking GHES users (see Required section above).

Nits:

  • Conflicting-host suggested command does not quote {raw} -- shell-special chars break the copy-pasted command.
  • MarketplaceNotFoundError lists three alternatives in one run-on sentence -- npm-style: short first sentence, then one primary action.

Supply Chain Security Expert

Required:

  • Double-encoded traversal (%2E%2E) bypasses validate_path_segments after single unquote (see Required section above).

Nits:

  • _up.unquote(raw) decodes %2F to /, silently injecting extra path separators before host-detection.
  • Conflicting-host error embeds {raw} verbatim in a copy-paste suggestion -- shell metacharacter risk. Use shlex.quote(raw).
  • _TRUSTED_MARKETPLACE_HOST_KINDS will silently drift if AuthResolver.classify_host adds new trusted kinds.

Auth Expert

Required: none.

Nits:

  • GHES path silently falls through to kind='generic' when GITHUB_HOST is a single-label value (e.g. localhost) -- intentional behavior change with no test coverage.
  • Credential-leakage rationale removed from user-visible error; restore as a code comment at minimum to preserve institutional knowledge.

OSS Growth Hacker

Required: none.

Nits:

  • CHANGELOG entry buries the UX story in a security rationale -- lead with "Error messages from apm marketplace add are now action-first."
  • The actionable-error pattern lives only in tests -- codify in CONTRIBUTING.md.
  • Track a follow-up for structured error output (--json) for CI/pipeline users.

Strategic note: "Gives you the exact command to run when something goes wrong" is a headline differentiator. Once enforced across the full CLI it becomes a release story, not just a bug fix.

Verdict computed deterministically: 3 required findings across 6 active panelists. APPROVE iff N == 0. Push a new commit to clear this verdict label automatically.

Note

🔒 Integrity filter blocked 2 items

The following items were blocked because they don't meet the GitHub integrity level.

To allow these resources, lower min-integrity in your GitHub frontmatter:

tools:
  github:
    min-integrity: approved  # merged | approved | unapproved | none

Generated by PR Review Panel for issue #1034 · ● 3.4M ·

@github-actions github-actions Bot removed the panel-review Trigger the apm-review-panel gh-aw workflow label Apr 30, 2026
Address all 3 round-3 required findings:

1. supply-chain (required): Double-encoded traversal (%252E%252E)
   bypassed validate_path_segments after a single _up.unquote. Hardened
   the guard itself (option b from the panel) by iteratively unquoting
   each segment up to 8 passes before checking the reject set. All
   callers (cowork lockfile paths, virtual paths, dependency strings)
   now benefit without per-site URL-decode logic. Updated the cowork
   test that previously documented the security gap to assert
   PathTraversalError for both single- and double-encoded payloads.

2. devx-ux (required): Untrusted-host error was not copy-pasteable for
   GHES users (no concrete export, ambiguous "re-run the command").
   Rewrote it as a multi-line recovery block that interpolates the
   resolved host into 'export GITHUB_HOST=...' and the original repo
   into 'apm marketplace add ...', both shlex-quoted to prevent shell
   metacharacter issues.

3. devx-ux (required): MarketplaceNotFoundError hardcoded github.com in
   its URL hint, leaving GHES users with a copy-paste URL that would
   never work for them. Added a 'host' parameter (defaulting to
   github.com to preserve public-cloud behaviour) and updated registry
   callers to pass default_host().

Also addressed the convergent nit raised by 5/6 panelists in the local
mirror review: shlex.quote(raw) in the conflicting-host suggestion (was
a round-3 supply-chain nit), and shlex.quote(resolved_host) in the new
export line for symmetry with quoted_repo.

Tests: 6926 passed in unit suite. New tests cover the double-encoded
guard, GHES copy-paste path, and host-aware MarketplaceNotFoundError.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@danielmeppiel danielmeppiel added panel-review Trigger the apm-review-panel gh-aw workflow and removed panel-rejected Apm-review-panel verdict: REJECT. Removed automatically on next push. labels Apr 30, 2026
@github-actions
Copy link
Copy Markdown

🎯 APM Review Panel — PR #1034

Feature: apm marketplace add — full HTTPS URLs and nested HOST/group/sub/.../REPO shorthands
Verdict: ✅ APPROVED (with minor observations)


Panel Summary

This PR addresses issue #1027 cleanly and with a security-first posture. The implementation is well-structured, thoroughly tested, and introduces meaningful defense-in-depth hardening beyond the core feature request.


✅ Strengths

Security architecture

  • Non-GitHub hosts are rejected at two independent layers: registration time (add command) and fetch time (client._fetch_file). A legacy registry entry or direct call can never leak GitHub credentials to a non-GitHub host.
  • GitHub auth tokens are only attached after an explicit host-kind check at the attach site, making the credential-attach point locally auditable without tracing the outer guard.
  • Multi-encoded path traversal (%2e%2e%2e%2e..) is now caught by iterative unquoting in validate_path_segments. This was a documented security gap in the prior code and is now addressed with matching test coverage.
  • HTTP URLs (not HTTPS) are rejected at parse time with no escape hatch — the rationale (MITM-injectable plugin source URLs with no audit trail) is well-explained in comments.

Developer experience

  • The untrusted-host error leads with outcome + fix (Host 'x' is not supported.) and provides a copy-pasteable export GITHUB_HOST=... && apm marketplace add ... recovery block. The tests enforce this contract.
  • Conflicting --host / embedded-host errors include the exact re-run command, shell-quoted via shlex.quote.
  • MarketplaceNotFoundError now interpolates the active host so GHES users get a URL that works for them rather than the public-cloud default.

Test coverage

  • New test classes cover: HTTPS URL parsing, .git suffix stripping, nested sub-paths, non-GitHub host rejection (both URL and shorthand forms), HTTP rejection, path traversal (plain, single-encoded, double-encoded), conflicting host flag, missing owner, _fetch_file host-kind guard (blocked before any HTTP request), MarketplaceNotFoundError with custom host, and UX message shape requirements.

🔍 Minor Observations

These are non-blocking but worth tracking:

  1. Double urllib.parse import in _parse_marketplace_repo — the function imports from urllib.parse import urlparse at the top and then import urllib.parse as _up inline. Consolidating to a single import at the top of the function would be cleaner, though the behavior is identical.

  2. test_github_host_passes_guard mock is partially unusedmock_resolver.classify_host.return_value is set up but _fetch_file calls the module-level _AuthResolver.classify_host, not auth_resolver.classify_host. The test passes correctly because github.com is legitimately trusted by the real resolver, so this is a harmless mislead rather than a bug. A comment noting that the guard uses the module resolver (not the injected one) would clarify the test's intent.

  3. Internal security rationale in _fetch_file error message — the MarketplaceFetchError raised by the host-kind guard includes "Refusing to fetch to avoid forwarding GitHub credentials to a non-GitHub host." This message propagates to the CLI output when fetch_marketplace is called directly (e.g., apm marketplace update), and it exposes credential internals that the command-layer tests explicitly prohibit in the add path. Consider keeping this detail in a logger.debug call and surfacing only "Host 'x' is not a supported marketplace source." as the user-visible message for consistency with the UX contract established elsewhere in this PR.


Conclusion

The implementation is production-ready. The two security hardening items (#1027 URL parsing, iterative traversal decode) are correct and tested. The three observations above are quality improvements for a follow-up — none blocks merge.

Note

🔒 Integrity filter blocked 2 items

The following items were blocked because they don't meet the GitHub integrity level.

To allow these resources, lower min-integrity in your GitHub frontmatter:

tools:
  github:
    min-integrity: approved  # merged | approved | unapproved | none

Generated by PR Review Panel for issue #1034 · ● 486.5K ·

@github-actions github-actions Bot added panel-approved Apm-review-panel verdict: APPROVE. Removed automatically on next push. and removed panel-review Trigger the apm-review-panel gh-aw workflow labels Apr 30, 2026
@danielmeppiel danielmeppiel merged commit de7ff17 into microsoft:main Apr 30, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

panel-approved Apm-review-panel verdict: APPROVE. Removed automatically on next push.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] Add a marketplace with just a full repository URL

6 participants