feat(policy): walk GitLab subgroups for org policy discovery (#2753) - #2933
feat(policy): walk GitLab subgroups for org policy discovery (#2753)#2933Rafael Azevedo (rrazvd) wants to merge 14 commits into
Conversation
…icrosoft#2753) GitLab org-policy discovery previously probed only the top-level group (`<top-level-group>/apm-policy`), so a project under nested subgroups could not scope its own governance without changing the whole org. Walk the subgroup tree from the project's own group up to the top-level group and apply the closest `apm-policy` (closest wins); `absent` at an inner level ascends to the parent group, while any error/malformed outcome still fails closed. A flat `<group>/<project>` remote is unchanged. `extends:` now accepts nested-namespace references (`acme/dept-a/apm-policy`), and the pre-fetch host-pin guard no longer misreads a bare nested namespace's first segment as a cross-host FQDN (a real attacker FQDN is still rejected). Remote-URL splitting is centralized in a single `_remote_url_parts` owner (consumed by both `_parse_remote_url` and the new `_gitlab_namespace_descending`), and the `git remote get-url origin` subprocess in a single `_git_remote_origin_url` owner. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical host-pin and moderate GitLab discovery findings block approval.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds subgroup-aware GitLab policy discovery, selecting the closest policy and supporting nested extends: references.
Changes:
- Walks GitLab namespaces from deepest subgroup to top-level.
- Updates nested inheritance parsing and host validation.
- Adds tests, conformance updates, documentation, and changelog entries.
File summaries
| File | Summary |
|---|---|
tests/unit/policy/test_discovery.py |
Adds subgroup discovery and inheritance tests. |
tests/spec_conformance/test_policy_reqs.py |
Adds conformance coverage. |
src/apm_cli/policy/discovery.py |
Implements namespace walking and shared remote parsing. Critical (1 vote): single-label host prefixes can bypass the host-pin guard for non-GitLab leaves. Moderate (3 votes): origin is read twice during GitLab discovery. |
src/apm_cli/policy/_gitlab.py |
Supports nested GitLab policy references. Moderate (1 vote): mismatched host-like prefixes and malformed ports are not safely rejected. |
packages/apm-guide/.apm/skills/apm-usage/governance.md |
Updates usage guidance. Nit (1 vote): publishing checklist still describes top-level-only policies. |
docs/src/content/docs/enterprise/policy-reference.md |
Updates policy reference behavior. |
docs/src/content/docs/enterprise/governance-guide.md |
Updates governance guidance. |
docs/src/content/docs/enterprise/apm-policy.md |
Documents subgroup policy resolution. |
CONFORMANCE.md |
Updates generated conformance count. |
CONFORMANCE.json |
Updates generated conformance mapping. |
CHANGELOG.md |
Adds the feature entry. |
Review details
Suppressed comments (2)
packages/apm-guide/.apm/skills/apm-usage/governance.md:431
- The packaged usage guide still has a publishing checklist at
governance.md:681-682that says GitLab policy belongs under the top-level group only. With the subgroup walk described here, that omits the primary team-scoped deployment and leaves this user-facing guide incomplete; update the checklist to mention any ancestor namespace and closest-wins behavior.
to inherit it. A flat `<group>/<project>` remote probes only `<group>/apm-policy`.
src/apm_cli/policy/_gitlab.py:155
- For nested host-qualified refs, this branch now reaches
explicit.portfor any 3+ segment ref. On a non-default-port leaf,gitlab.example.test/acme/...passes hostname-only validation but is not stripped and is then fetched as namespacegitlab.example.test/acme/...; a malformed port such asgitlab.example.test:not-a-port/...instead raisesValueErrorbecause onlyurlsplitis caught. Treat host-like first segments whose host/port does not exactly match the leaf as invalid (and safely catch port parsing) rather than falling through.
and explicit.port == port
):
parts = parts[1:]
- Files reviewed: 11/11 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
- host-pin (F1): scope the "single-label first segment is a namespace" relaxation to GitLab leaves only. On GitHub/ADO leaves a 3-segment ref is host/owner/repo, so `extends: "evil/org/repo"` is again rejected as cross-host instead of routing a credential to `evil`. - GitLab extends: a host-like first segment must match the leaf host+port exactly or the ref is rejected (never folded into the namespace); a malformed port now fails closed instead of raising ValueError. - discovery: read `git remote get-url origin` once and reuse it for both identity and the namespace walk (`_gitlab_namespace_descending` takes the URL), removing the double subprocess and the TOCTOU on a changing remote. - docs: publishing checklist now says GitLab policy may live under any ancestor namespace (closest wins), not the top-level group only. - tests: cover GitHub-leaf cross-host rejection, port-mismatch and malformed-port rejection, and the pure namespace helper. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks for the review — addressed all four findings in 🔴 Critical — host-pin bypass for single-label prefixes ( 🟠 Moderate — host-like prefix / malformed port ( 🟠 Moderate — origin read twice ( ⚪ Nit — publishing checklist ( Local gates green: |
There was a problem hiding this comment.
🟡 Changes recommended
The canonical-owner guard is incomplete, conformance coverage is misclassified, and dotted GitLab namespace guidance is missing.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
tests/spec_conformance/test_policy_reqs.py:150
- This marker overstates conformance coverage.
req-pl-011requires a registered, ordered provider list and per-project provider selection; this test only checks the implementation-specific namespace order inside one GitLab provider, which is already covered by the unit test. Remove this requirement marker/test from the conformance suite and regenerateCONFORMANCE.*, or replace it with an assertion that exercises the normative provider-registration contract.
- Files reviewed: 11/11 changed files
- Comments generated: 2
- Review effort level: Balanced
| _UNSET = _Unset() | ||
|
|
||
|
|
||
| def _git_remote_origin_url(project_root: Path) -> str | None: |
- architecture: add a registered static delegation guard (install-deployment-policy-remote-origin-owner) enforcing that only discovery.py reads `git remote get-url origin` and defines the remote-URL splitter/parsers; no other policy-tree module may re-read or re-parse the remote. Backs the single-owner claim the refactor makes. - docs: document that a bare nested `extends:` ref whose first segment contains a dot (group named e.g. `acme.tools`) must be host-qualified, since a dotted first segment is read as a host. - conformance: drop the req-pl-011 test that only re-asserted the GitLab provider's namespace order. Per req-pl-011 that order is implementation-defined, so it is not a normative conformance property; the unit suite already covers it. Regenerated CONFORMANCE.*. apm-spec-waiver: GitLab subgroup walk refines the existing req-pl-011 discovery provider (intra-provider search order is implementation-defined per spec 6.1.1); no new normative requirement, so no new anchor/manifest row/marker. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Addressed the second review in Canonical-owner guard (incomplete) — Dotted GitLab namespace guidance (missing) — Conformance coverage misclassified — Local gates: |
There was a problem hiding this comment.
🟡 Changes recommended
Architecture mutation checks will fail or pass vacuously, and policy documentation remains internally inconsistent.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 10/10 changed files
- Comments generated: 4
- Review effort level: Balanced
| for namespace in namespaces: | ||
| result = _gitlab._fetch_from_gitlab_repo( | ||
| org=namespace, | ||
| repo=candidate_repo, | ||
| host=host, |
- gitlab-facade-orchestration guard: the branch was renamed to `elif is_gitlab:` and the adapter call moved into `_gitlab_walk_candidate`, so the check scanned a stale `elif is_gitlab_hostname(host):` region and passed vacuously (its mutation case no longer produced a violation). Scope the orchestration scan to `_gitlab_walk_candidate`'s body instead; mutation case fires again. - policy-remote-origin-owner guard: give it teeth the mutation matrix can exercise -- require discovery.py to define all three canonical read/parse helpers -- and add its guard-less mutation case so the frozen matrix set-equality holds. - docs: reconcile policy-reference.md's `extends` table and the Discovery-vs-extends note with GitLab nested refs + closest-subgroup discovery; document that a host-qualified GitLab `extends:` ref must carry the exact `host:port` authority when the origin uses a port. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Addressed the third review in Vacuous facade guard + failing mutation ( Guard-less rule without a mutation case ( policy-reference.md internal inconsistency Dotted-group workaround incomplete for explicit ports (apm-policy.md) Local gates: |
The frozen semantic-contract inventory in test_architecture_runner.py (_EXPECTED_RULE_ID_TEXT) must list every registered rule id; add install-deployment-policy-remote-origin-owner so the set-equality holds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Pre-emptive hardening in
Full local runs now green:
|
There was a problem hiding this comment.
🔵 Needs a closer look
The new canonical-owner rule is not registered and bound through the architecture owner registry.
Review details
Suppressed comments (1)
scripts/architecture_linter/checks/install_policy_intent.py:110
- This new canonical-owner check is registered through
_semantic_rule, which assignsguard_ids=(), and there is no matching owner record in.apm/architecture/owners/install-deployment.json. As a result, the owner registry cannot validate this guard bidirectionally or ensure it executes exactly once. Register the remote-origin decision in the owner registry and bind this rule to that guard ID rather than treating it as guard-less.
_semantic_rule(
RULE_REMOTE_ORIGIN_OWNER,
"Policy discovery reads and parses the git remote through one owner (discovery.py).",
check_policy_remote_origin_owner,
),
- Files reviewed: 12/12 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Copilot review: a canonical-owner rule should be an owner-guard bound to an owner-registry record, not a guard-less semantic rule, so the registry can validate it bidirectionally and enforce single execution. - Register `install-deployment-policy-remote-origin-owner` with `guard_ids=(<id>,)` (a `Rule(...)`, not `_semantic_rule`), making it a known owner-guard that executes exactly once. - Reference it from discovery.py's existing owner record (`cached-policy-shape` in contracts-tooling.json); discovery.py can hold only one owner record, and a guard's shard is independent of its id prefix, so the remote-origin decision joins that record's guard list. - Move its mutation case from the guard-less matrix to the owner-guard matrix (test_architecture_owner_rule_mutations.py); both matrices' set-equality and the frozen inventory now hold. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Addressed in What changed
Verification — full architecture suite green:
|
There was a problem hiding this comment.
🟡 Changes recommended
Concealed GitLab 404s can select a weaker ancestor policy, and the ownership guard does not fully enforce single-reader delegation.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 13/13 changed files
- Comments generated: 2
- Review effort level: Balanced
…uard Copilot review: - Concealed-404 fail-open (discovery.py): GitLab returns 404 both for a missing project and for a private one the token cannot read, so the subgroup walk could silently apply a weaker ancestor policy over a closer team policy the token was denied. Before applying an ancestor over skipped closer levels, verify via authenticated Git (`_gitlab.first_concealed_closer_policy`) that no skipped closer `apm-policy` project exists; if one is confirmed, fail closed. The residual case (token blind to both REST and Git) is indistinguishable from genuinely absent and still ascends -- documented. - Origin-read guard: the guard only required the three helper names to exist, so a second `git remote get-url origin` elsewhere in discovery.py would pass. Add an exact-count clause (one origin-read argv in the owner) and switch the owner-guard mutation to introduce a duplicate read, so the single-reader invariant is enforced and proven. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Addressed both findings in Concealed GitLab 404 -> weaker ancestor (fail-open) Origin-read guard didn't enforce single delegation Verification (full local runs): owner-guard + guard-less mutation matrices, runner, |
Pre-existing verification sweep caught the CI file-length guard: this PR's additions pushed discovery.py from 2068 to 2252 lines, over the 2100-line ratchet. Extract the cohesive git-remote identity family -- _git_remote_origin_url, _remote_url_parts, _parse_remote_url, _extract_org_host_port_from_git_remote, _extract_org_from_git_remote -- into a new policy/_remote.py (discovery.py back to ~2091, re-exports the names so callers/tests are unchanged). This also makes the remote-origin owner-guard cleaner: _remote.py is a dedicated, single-decision module, so the guard binds to its own owner-registry record (selector free) instead of piggy-backing on discovery.py's cached-policy-shape record. Guard, mutation case, and the tests that patch the moved internals (subprocess/urlparse/_parse_remote_url) are re-pointed at _remote. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ache probe Local apm-review-panel dogfood (supply-chain, auth, perf, test-coverage, devx, doc-writer, architect) surfaced: - Fail-OPEN by default (devx): the concealed-closer result returned `cache_miss_fetch_fail`, which honours `policy.fetch_failure_default` (default `warn`) -- so a concealed private closer policy silently proceeded with no enforcement, contradicting the doc's "fails closed." Return `incomplete_chain` instead, which ALWAYS fails closed regardless of the knob; the CLI now surfaces the specific "closer policy concealed" message (not the generic "check connectivity" copy). - Perf (perf persona): the concealment `git ls-remote` probe ran uncached on every install in the common "team inherits org policy" path (N-1 round-trips). Cache each probe verdict per `(host, namespace, repo)` under the policy cache (same TTL); `no_cache` bypasses it. - Coverage (test-coverage): add closest-first tests for a middle/among-many confirmed level, the fail-closed-under-warn gate assertion, and the cache hit / no-cache-bypass paths. - Docs (devx, doc-writer): reword the concealed-policy caution to state the unconditional block; add a governance-guide "sharp edges" bullet (closest-wins shadowing + concealed-blocks-install) and the skill note. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Ran the repo's Fail-open by default (devx, blocking) — the concealed-closer result returned Uncached git probe on the hot path (performance, blocking) — the concealment Closest-first coverage gap (test-coverage, blocking) — Docs (devx + doc-writer) — reworded the concealed-policy caution to state the unconditional block; added a governance-guide "sharp edges" bullet covering both the closest-wins shadowing risk (a closer subgroup policy can weaken a stricter ancestor — restrict Panel also confirmed no blocking issues in: the leaf-aware F1 host-pin, the Local gates green: |
There was a problem hiding this comment.
🟡 Changes recommended
Concealment handling can bypass checks for stale policies, perform network access in cache-only mode, and cache indeterminate authentication failures as absence.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
src/apm_cli/policy/discovery.py:1017
- The concealment check is skipped when an ancestor policy is returned as
cached_stale. Both cache-only lookup and an online refresh failure can return that outcome with a usable policy, so this path applies the stale ancestor after closer 404s without checking whether a closer policy was concealed. Gate this on the presence of a policy rather than onlyfound/empty.
if skipped and result.outcome in {"found", "empty"}:
- Files reviewed: 16/16 changed files
- Comments generated: 3
- Review effort level: Balanced
…gate Fourth Copilot review on the concealment mechanism: - Cache only the DEFINITIVE `present` verdict. `_gitlab_project_state_via_git` returns `None` for a missing project AND for auth/network/timeout failures alike, so caching an `absent` verdict let a transient failure suppress re-probing for the whole TTL and apply a weaker ancestor. Absence is never definitive, so the common inherit-from-org path re-probes each install (cost accepted; documented in the PR trade-offs). - Cache-only (offline local-bundle) never issues a network probe: a cached `present` fails closed, and an unverifiable level fails closed deterministically instead of reaching out to the network. - Gate the concealment check on a usable policy (`result.policy is not None`), not just `found`/`empty` -- a `cached_stale` ancestor also carries a policy and must be checked for a concealed closer before it is applied. - Align the owner-guard rule description with the canonical owner (`policy/_remote.py`, not `discovery.py`). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Addressed all four findings in Caching indeterminate as absent (blocking, Network access in cache-only mode (blocking,
Rule description named the wrong owner — updated the Verification: |
There was a problem hiding this comment.
🟡 Changes recommended
Concealment detection can falsely block valid inheritance, miss fallback credentials, and diverges from documented fail-closed behavior.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
scripts/architecture_linter/checks/install_policy_gitlab_and_bundle.py:126
- The owner registry names
_extract_org_host_port_from_git_remoteas part of the canonical remote-identity owner, but this guard neither requires that definition in_remote.pynor bans a duplicate definition elsewhere. As written, that durable identity decision can be split without the architecture linter noticing. Include this helper in the guarded definition set/count and add a mutation case proving duplicate ownership is rejected.
- Files reviewed: 16/16 changed files
- Comments generated: 6
- Review effort level: Balanced
| except (ValueError, subprocess.TimeoutExpired, FileNotFoundError): | ||
| return None | ||
|
|
||
| if result.outcome != "absent": |
…ascends The concealed-private-policy detection (git ls-remote existence probe + fail-closed + verdict cache) was scope creep beyond microsoft#2753 and, across several review rounds, proved not worth it: it only caught the narrow "project git-readable but policy file 404s" case, could not be soundly negative-cached (git can't tell a missing project from a transient failure), and false-positived on an apm-policy project that exists with only a README -- blocking inheritance from a valid ancestor. Remove it entirely. A GitLab `absent` (404) now simply ascends to the next ancestor -- the same `404 == no policy at this level` behaviour GitHub and ADO discovery already have, and strictly better than the pre-PR single-level behaviour (an ancestor policy applies where nothing did before). The GitLab 404-conceals-private-projects ambiguity is documented as a platform limitation (grant the token read access). The subgroup walk, nested `extends:`, leaf-aware host-pin, and the `_remote.py` extraction + its owner-guard are unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Rather than fix the fifth round of findings on the concealed-policy detection, I removed the mechanism entirely ( Why it's gone
What replaces it This resolves all four findings from the last review by removal, and drops ~290 lines (net diff 1135 → 844 insertions). The core of the PR is unchanged: subgroup walk, nested Local gates green: |
There was a problem hiding this comment.
🔵 Needs a closer look
The canonical-owner guard omits one function named by its registry contract, leaving the architecture boundary partially unenforced.
Review details
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
scripts/architecture_linter/checks/install_policy_gitlab_and_bundle.py:129
- The owner registry names
_extract_org_host_port_from_git_remoteas part of this canonical decision, but this guard neither counts nor bans that definition. A second identity extractor in another policy module would therefore pass the static boundary check, leaving the registered owner only partially enforced. Include the extractor in the guarded definition set and update the expected count.
src/apm_cli/policy/_remote.py:8 _git_remote_origin_urlis defined in this module, not owned bydiscovery.py. Saying thatdiscovery.pyhas its own reader contradicts both the implementation and the new owner registry, making the canonical ownership documentation ambiguous.
- Files reviewed: 16/16 changed files
- Comments generated: 0 new
- Review effort level: Balanced
The owner registry for policy/_remote.py names _extract_org_host_port_from_git_remote as part of the canonical git-remote decision, but the static guard only counted/banned three defs -- a second identity extractor elsewhere would have passed. Add it to _REMOTE_PARSER_DEFS and bump the expected count to 4 so the guard matches the owner record (Copilot microsoft#2933). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Addressed the latest Copilot review. Owner-guard vs owner-registry mismatch ( The three other inline comments in the previous round referenced the concealed-policy detection code, which was removed entirely in the simplification pass -- they're moot. |
…iew) Address the review panel's one REQUIRED finding plus coverage gaps for the subgroup walk (microsoft#2753). REQUIRED (devx-ux): at default verbosity in warn mode the closest-wins walk was invisible -- a project silently picking up a subgroup policy instead of the top-level one saw no output. Add a `subgroup_scoped` flag to PolicyFetchResult, set it in the walk when the winning namespace is below the top-level group, thread it through outcome_routing, and show the `Policy: <source>` line at info even in non-verbose warn mode when it is set. Top-level resolutions keep the pre-existing silence. Tests (test-coverage): add the positive host-pin port branch (host-qualified extends ref with a matching non-null port strips and fetches), an `empty`-outcome short-circuit at a deep subgroup (README- only apm-policy must shadow a real ancestor, not fall through), and an end-to-end wiring test that runs the real `_gitlab_namespace_descending` from a nested origin so the read-origin-once -> derive-namespaces -> walk seam is exercised. Extend the existing walk tests to assert the subgroup_scoped flag. Note: the panel's advisory to narrow the two `except Exception` clauses in _remote.py was NOT applied -- `test_https_url_parse_exception_returns_none` pins the broad catch as intentional cross-version defensiveness, so the premise (urlparse only raises ValueError) does not hold. Clarified the comment instead. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
🏛️ apm-review-panel (local dogfood, working tree)Ran the multi-persona panel on the branch diff. Consolidated recommendation: APPROVE-WITH-NITS — unanimous, no REQUEST-CHANGES.
Addressed in
|
There was a problem hiding this comment.
🟡 Changes recommended
The remote-reader extraction drops existing exception handling, and the new tests need URL-assertion and chain-level coverage corrections.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
src/apm_cli/policy/_remote.py:50
- Preserve the extracted reader's previous
ValueErrorhandling here. The old implementation convertedValueErrorfrom the git subprocess setup into a clean “no remote” result;_auto_discovernow calls this helper directly, so the same exception can escape policy discovery instead of returningno_git_remote.
tests/unit/policy/test_discovery.py:1646 - This calls the guard directly, so it still does not cover the production chain path that previously let tests bypass
_validate_extends_host. Add adiscover_policy_with_chainregression test with a nested GitLabextends:leaf and mocked adapter fetches, verifying that validation derives the GitLab leaf host and routes the nested namespace without attempting a foreign host.
- Files reviewed: 19/19 changed files
- Comments generated: 1
- Review effort level: Balanced
| msg = mock_info.call_args[0][0] | ||
| assert "org:gitlab.com/acme/dept-a/apm-policy" in msg | ||
| assert "enforcement=warn" in msg |
TL;DR
GitLab org-policy auto-discovery only ever probed the top-level group (
<top-level-group>/apm-policy), so a project nested under subgroups could not enforce its own governance without changing the whole organization. This PR walks the GitLab subgroup tree from the project's own group up to the top-level group and applies the closestapm-policy(closest wins), and letsextends:compose across nested namespaces. Flat<group>/<project>remotes are unchanged. Closes #2753.Problem (WHY)
For a project at
gitlab.com/acme/dept-a/team-x/my-project, discovery resolved onlyacme/apm-policyand discarded every intermediate subgroup:status/accepted.Root cause:
_parse_remote_urlreturned onlypath_parts[0]as the org, and_auto_discoverpassed that single org to the GitLab fetch — the full namespace was never derived.Approach (WHAT)
absentwins (closest)absent(404) ascends to the parent groupapm-policyis skipped in favour of the next ancestor (404 == no policy, like GitHub/ADO) — a platform limitation, documented (grant the token read access).extends:; now accepts nested-namespace refs<group>/apm-policy— identical to prior behaviorpolicy/_remote.pyowner (no split authority; architecture-guard enforced)Implementation (HOW)
src/apm_cli/policy/_remote.py(new)discovery.py(which grew past the 2100-line file-length ratchet):_git_remote_origin_url(singlegit remote get-url originowner),_remote_url_parts(single host+segments splitter),_parse_remote_url(refactored to consume the splitter — behavior-preserving for GitHub/GHE/ADO/visualstudio), plus_extract_org_host_port_from_git_remote/_extract_org_from_git_remote.discovery.pyre-exports them, so call sites/tests are unchanged.src/apm_cli/policy/discovery.py_gitlab_namespace_descending(deepest→top namespaces from the origin remote) and_gitlab_walk_candidate(the walk: first non-absentwins,absentascends). Readsoriginonce and reuses it for identity + the walk.src/apm_cli/policy/discovery.py(_extract_extends_host)acme/dept-a/apm-policy); on GitHub/ADO leaves it is still parsed as a (cross-)host, soextends: "evil/org/repo"remains rejected (Security Finding F1 preserved).src/apm_cli/policy/_gitlab.py_fetch_gitlab_chain_parentaccepts nested-namespaceextends:refs (namespace = parts[:-1],repo = parts[-1]) after an optional leaf-host-prefix strip.scripts/architecture_linter/checks/install_policy_gitlab_and_bundle.py,install_policy_intent.py,.apm/architecture/owners/install-deployment.jsoninstall-deployment-policy-remote-origin-owner), bound to a dedicated_remote.pyowner-registry record: only_remote.pymay readgit remote get-url originor define the remote-URL splitter/parsers. Also re-point the existinggitlab-facade-orchestrationguard at_gitlab_walk_candidate.test_discovery.pycoverage for the walk, nestedextends:, and F1; an owner-guard mutation-break case intest_architecture_owner_rule_mutations.pyand the frozen-inventory entry intest_architecture_runner.pyfor the new guard.CHANGELOG.mdapm-policy.md,governance-guide.md,policy-reference.md,apm-usage/governance.md; added theUnreleasedchangelog entry.Note
The GitLab REST transport and
AuthResolverauth already URL-encode a full multi-segmentproject_path, so no transport/auth changes were needed —org="acme/dept-a"was already fetchable.Diagram
The GitLab branch of
_auto_discover: each namespace level is probed closest-first; only a cleanabsentascends, every other outcome returns immediately.flowchart TD A[origin remote] --> B[_gitlab_namespace_descending] B --> C["deepest: acme/dept-a/team-x"] C -->|found / error| R[return result] C -->|absent| D["acme/dept-a"] D -->|found / error| R D -->|absent| E["acme (top-level)"] E -->|found / error| R E -->|absent| Z[outcome = absent, clean no-op]Trade-offs
debugso--verboseis not spammed. Absent REST results are served from the existing per-namespace policy cache on warm runs; caching the absent REST outcome itself is a possible follow-up, out of scope. (The concealment Git probe, a separate path, is cached — see below.)apm-policythe token cannot read is indistinguishable from a missing one, so it is skipped in favour of the next ancestor — the same404 == no policybehaviour GitHub/ADO discovery already have. An earlier revision tried to detect this via an authenticatedgit ls-remoteprobe; it was removed after review as scope creep (it caught only a narrow case, could not be soundly cached, and false-positived on anapm-policyproject containing only a README). Mitigation: grant the CI token read access to everyapm-policyit should honour.apm-policyproject in an intermediate subgroup can publish a weaker policy that shadows a stricter ancestor for projects under it (onlyextends:chains enforce tighten-only). This is intrinsic to team-scoped governance; the mitigation (restrictapm-policyproject creation via GitLab RBAC) is documented in the governance guide's sharp-edges list.extends: "org"resolves to the top-level group, not the nearest ancestor — kept consistent with GitHub/ADO semantics and documented explicitly, rather than silently redefining the shorthand per host.extends:refs is resolved by an FQDN heuristic (a host has a.or:port); a GitLab group whose name literally contains a dot would fail closed (rejected). This is safe and rare, and the docs prescribe the host-qualified spelling (gitlab.com/acme.tools/..., withhost:portwhen the origin uses a port) as the workaround.Benefits
apm-policywithout touching org-wide governance.acme/apm-policystill applies to every project with no closer policy — zero migration for existing setups.extends:(including nested-namespace parents).Validation
All CI-mirror gates pass locally on the merge base.
Lint, duplication, tests, architecture, conformance
Scenario evidence
test_gitlab_subgroup_closest_winstest_gitlab_subgroup_absent_ascends_to_parentabsenttest_gitlab_subgroup_all_absent_is_clean_absenttest_gitlab_subgroup_error_fails_closed,..._error_at_inner_level_fails_closedtest_gitlab_flat_project_falls_back_to_top_level_orgextends:allowed, cross-host FQDN rejectedTestValidateExtendsHostNestedNamespace_parse_remote_urlrefactor is behavior-preservingTestParseRemoteUrlADO/GHE/visualstudio casesImportant
The nested-
extends:host-pin fix was surfaced by the localapm-review-paneldogfood pass (supply-chain + auth personas, independently): the original tests exercised_fetch_chain_parentdirectly and bypassed_validate_extends_host, masking the reject. Regression tests now drive the real validation path.How to test
originat a nested GitLab remote (gitlab.com/acme/dept-a/team-x/my-project) and publishapm-policy.ymlinacme/dept-a/apm-policy.apm audit --ci(orapm install) and confirm theacme/dept-apolicy is applied (closest wins), not the top-level one.acme/apm-policy.absent(no warning, no fetch-failure).uv run python -m pytest tests/unit/policy/test_discovery.py.Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com