The acceptance harness pinned a forge client that has never existed in any
commit or pod (/opt/coordinator/gitea_api.py, digest f0943db4..., GIT/POST
grammar, an askpass helper). Every Gitea-backed check was therefore
unrunnable as merged. Point the harness at the credential-isolated SCM
broker client that actually ships in the agent pod.
- policy: GITEA_CLIENT=/opt/scm/gitea_api.py; trust /opt/scm/ instead of
the phantom /opt/coordinator/; admit the client's real grammar
(`read <api-path>`, exactly one path) with the same atlas/titan-iac pin
and dot-segment rejection; bare HTTP methods are refused in every mode.
The armed POST/PATCH/DELETE windows remain but are documented as
deferred: the deployed client cannot execute them.
- exec: pin the client digest to the sha256 of
services/hermes/scm-common/scripts/gitea_api.py — the exact file the
hermes-scm-boundary-v2 ConfigMap mounts at /opt/scm/gitea_api.py — so
the pin is derivable from merged source and equal to the deployed
client. gitea_api.py gains a narrow /api/v1/user identity read in
_authorize_read (see below), so the pin is the NEW source hash
76efd16dedbeb74425b12fbbdbfaa391854771292077e0463bf22706855ae6dc.
Drop the dangling GIT_ASKPASS (no helper exists; broker git needs
none) and swap /opt/coordinator for /opt/scm in SAFE_PATH.
- checks: all forge/baseline/lineage probes use (client, "read", path).
The SELF-vantage identity checks now truthfully assert the *broker's*
forge identity (the only one the platform can exercise) is not an
administrator and holds push-scoped, non-administrative repository
authority; the administrative-route check asserts the broker read
allowlist's live refusal of branch_protections. The remote-main step
keeps `origin` (the broker remote exists only in pool workspaces and
the broker origin is cluster-local); its rationale now tells the
operator to ensure origin fetchability.
- gitea_api.py/_authorize_read: allow exactly `/api/v1/user` (no query,
no sibling routes) as operation "identity" so the harness can prove
the broker identity is not an administrator. The broker imports the
same module, so one reviewed edit covers both sides of the boundary.
- rules: DENIAL_MARKERS now match the client's real refusal lines
("SCM broker request failed with HTTP 400/403" and the no-credential
rejection) and drop "gitea api returned http 403", which the client
never emits; a broker 404 is deliberately not denial evidence.
- ephemeral: index/verification reads use the real grammar; manual
cleanup guidance now says close/delete require operator forge
credentials (the client exposes no mutation besides create-draft);
armed mode is documented as deferred until the probes are rebuilt on
the broker's bounded mutation surface.
- docs: broker vantage/evidence section, operator prerequisites (broker
healthy, no /vault/secrets/gitea-token anywhere on the harness path,
current ConfigMap mount, operator-side client + origin fetchability),
armed-mode deferral.
- tests: read-grammar accepted / GET refused in every mode, /opt/scm
attestation pin proven equal to the merged source digest, real
denial-marker matching, /api/v1/user identity route bounds; the
repository-pin mutant probe speaks the new grammar. Full handoff +
gitea + broker families pass (952 tests), mutation gate 13/13, per-file
line+branch coverage >=95%, all touched sources within the 500-line cap.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
500 lines
18 KiB
Python
500 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
"""Use the private Atlas Forgejo API through a least-authority PR boundary."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import base64
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
from collections.abc import Callable
|
|
from pathlib import Path
|
|
|
|
import deadline_http
|
|
from gitea_api_policy import (
|
|
PolicyError,
|
|
_draft_title,
|
|
_reject_forbidden,
|
|
_validate_body,
|
|
_validate_pr_number,
|
|
_validate_pr_number_segment,
|
|
_validate_query,
|
|
_validate_ref,
|
|
_validate_ref_bounds,
|
|
_validate_repo,
|
|
_validate_sha,
|
|
)
|
|
|
|
CANONICAL_BASE_URL = "https://scm.bstein.dev"
|
|
ALLOWED_OWNER = "atlas"
|
|
DEFAULT_TOKEN_FILE = Path("/vault/secrets/gitea-token")
|
|
CREATE_PATH_RE = re.compile(r"/api/v1/repos/atlas/([^/]+)/pulls\Z")
|
|
MAX_ERROR_BYTES = 8192
|
|
MAX_RESPONSE_BYTES = 2 * 1024 * 1024
|
|
MAX_API_TARGET_LENGTH = 768
|
|
MAX_API_PATH_LENGTH = 512
|
|
RAW_API_TARGET_RE = re.compile(r"[A-Za-z0-9/_.?&=-]+\Z")
|
|
|
|
|
|
def _safe_urlopen(request: urllib.request.Request, timeout: int):
|
|
"""Open one exchange under a killable absolute wall-clock deadline.
|
|
|
|
The helper process rejects redirects before urllib can copy
|
|
authentication headers, and its whole connect/send/read lifetime
|
|
shares one hard deadline.
|
|
"""
|
|
return deadline_http.open_bounded(
|
|
request, maximum=MAX_RESPONSE_BYTES, timeout=timeout
|
|
)
|
|
|
|
|
|
def read_token(path: Path = DEFAULT_TOKEN_FILE) -> str:
|
|
"""Read the token from the pod-lifetime Vault projection."""
|
|
token = path.read_text(encoding="utf-8").strip()
|
|
if not token:
|
|
raise ValueError(f"runtime credential is empty: {path}")
|
|
return token
|
|
|
|
|
|
def configured_base_url() -> str:
|
|
"""Reject attempts to redirect credentials away from the canonical origin."""
|
|
configured = os.environ.get("GITEA_BASE_URL", CANONICAL_BASE_URL)
|
|
if configured != CANONICAL_BASE_URL:
|
|
raise PolicyError("Forgejo origin is fixed to the private Atlas SCM service")
|
|
return configured
|
|
|
|
|
|
def _split_api_path(path: str) -> urllib.parse.SplitResult:
|
|
if not isinstance(path, str) or len(path) > MAX_API_TARGET_LENGTH:
|
|
raise PolicyError("API target exceeds the safe size limit")
|
|
if (
|
|
not path.isascii()
|
|
or any(ord(character) < 32 or ord(character) == 127 for character in path)
|
|
or "\\" in path
|
|
or not RAW_API_TARGET_RE.fullmatch(path)
|
|
):
|
|
raise PolicyError("API target contains non-canonical raw characters")
|
|
target = urllib.parse.urlsplit(path)
|
|
canonical = urllib.parse.urlunsplit(("", "", target.path, target.query, ""))
|
|
if canonical != path:
|
|
raise PolicyError("API target is not in exact canonical form")
|
|
if target.scheme or target.netloc or target.fragment:
|
|
raise PolicyError("API path must be relative to the Atlas SCM origin")
|
|
if not target.path.startswith("/api/v1/"):
|
|
raise PolicyError("API path must start with /api/v1/")
|
|
if len(target.path) > MAX_API_PATH_LENGTH:
|
|
raise PolicyError("API path exceeds the safe size limit")
|
|
segments = target.path.split("/")
|
|
if "//" in target.path or any(segment in {".", ".."} for segment in segments):
|
|
raise PolicyError("encoded or non-canonical API paths are not allowed")
|
|
return target
|
|
|
|
|
|
def _authorize_read(
|
|
target: urllib.parse.SplitResult, *, forbidden: tuple[str, ...] = ()
|
|
) -> str:
|
|
"""Allow only identity, repository, PR, branch, commit, and status reads."""
|
|
if target.path == "/api/v1/user":
|
|
# The broker holds the only forge credential, so this answers with the
|
|
# broker identity's own account metadata (name, is_admin) and nothing
|
|
# else; acceptance tooling uses it to prove that identity holds no
|
|
# administrative rights. No query, no other /user route.
|
|
_validate_query(target, set())
|
|
return "identity"
|
|
prefix = "/api/v1/repos/atlas/"
|
|
remainder = target.path.removeprefix(prefix)
|
|
if remainder == target.path:
|
|
raise PolicyError("reads are limited to explicit Atlas repository metadata")
|
|
repo, separator, suffix = remainder.partition("/")
|
|
_reject_forbidden(repo, "repository", forbidden)
|
|
_validate_repo(repo)
|
|
|
|
if not separator:
|
|
_validate_query(target, set())
|
|
return "repository"
|
|
if suffix == "pulls":
|
|
_validate_query(target, {"page", "limit", "state"})
|
|
return "pull-list"
|
|
content_match = re.fullmatch(r"pulls/([^/]+)\.(?:patch|diff)", suffix)
|
|
if content_match:
|
|
_validate_pr_number_segment(content_match.group(1))
|
|
raise PolicyError("repository API route is outside the metadata read allowlist")
|
|
pull_match = re.fullmatch(r"pulls/([^/]+)", suffix)
|
|
if pull_match:
|
|
_validate_pr_number_segment(pull_match.group(1))
|
|
_validate_query(target, set())
|
|
return "pull"
|
|
evidence_match = re.fullmatch(r"pulls/([^/]+)/(?:commits|files)", suffix)
|
|
if evidence_match:
|
|
_validate_pr_number_segment(evidence_match.group(1))
|
|
_validate_query(target, {"page", "limit"})
|
|
return "pull-evidence"
|
|
if suffix == "branches":
|
|
_validate_query(target, {"page", "limit"})
|
|
return "branch-list"
|
|
branch_match = re.fullmatch(r"branches/([^/]+)", suffix)
|
|
if branch_match:
|
|
_validate_ref(branch_match.group(1), "branch")
|
|
_validate_query(target, set())
|
|
return "branch"
|
|
if suffix == "commits":
|
|
_validate_query(target, {"page", "limit"})
|
|
return "commit-list"
|
|
if re.fullmatch(r"git/commits/[0-9a-fA-F]{40}", suffix):
|
|
_validate_query(target, set())
|
|
return "commit"
|
|
if re.fullmatch(
|
|
r"(?:commits/[0-9a-fA-F]{40}/status|commits/[0-9a-fA-F]{40}/statuses|statuses/[0-9a-fA-F]{40})",
|
|
suffix,
|
|
):
|
|
_validate_query(target, {"page", "limit"})
|
|
return "status"
|
|
raise PolicyError("repository API route is outside the metadata read allowlist")
|
|
|
|
|
|
def api_url(base_url: str, path: str) -> str:
|
|
"""Return a canonical same-origin URL without forwarding credentials."""
|
|
if base_url != CANONICAL_BASE_URL:
|
|
raise PolicyError("Forgejo origin is fixed to the private Atlas SCM service")
|
|
target = _split_api_path(path)
|
|
return urllib.parse.urlunsplit(
|
|
("https", "scm.bstein.dev", target.path, target.query, "")
|
|
)
|
|
|
|
|
|
def authorize_request(
|
|
method: str,
|
|
path: str,
|
|
data: object | None,
|
|
*,
|
|
forbidden: tuple[str, ...] = (),
|
|
) -> str:
|
|
"""Validate one request and return its bounded operation name."""
|
|
normalized_method = method.upper()
|
|
target = _split_api_path(path)
|
|
if normalized_method == "GET":
|
|
if data is not None:
|
|
raise PolicyError("read operations cannot include a request body")
|
|
return _authorize_read(target, forbidden=forbidden)
|
|
if target.query:
|
|
raise PolicyError("mutating operations cannot include query parameters")
|
|
if normalized_method != "POST":
|
|
raise PolicyError("only read and create-draft operations are available")
|
|
match = CREATE_PATH_RE.fullmatch(target.path)
|
|
if not match:
|
|
raise PolicyError("POST is limited to creating an Atlas draft pull request")
|
|
_reject_forbidden(match.group(1), "repository", forbidden)
|
|
_validate_repo(match.group(1))
|
|
if not isinstance(data, dict) or set(data) != {"base", "body", "head", "title"}:
|
|
raise PolicyError("draft creation accepts only base, body, head, and title")
|
|
if data["title"] != _draft_title(data["title"], forbidden=forbidden):
|
|
raise PolicyError("new pull requests must use the Gitea draft-title prefix")
|
|
_validate_body(data["body"], forbidden=forbidden)
|
|
_validate_ref_bounds(data["base"], "base", forbidden=forbidden)
|
|
_validate_ref_bounds(data["head"], "head", forbidden=forbidden)
|
|
_validate_ref(data["base"], "base", forbidden=forbidden)
|
|
_validate_ref(data["head"], "head", forbidden=forbidden)
|
|
return "create-draft"
|
|
|
|
|
|
def build_request(
|
|
method: str,
|
|
path: str,
|
|
*,
|
|
base_url: str,
|
|
token: str,
|
|
data: object | None = None,
|
|
) -> urllib.request.Request:
|
|
"""Build an authorized request without putting the token in its URL or body."""
|
|
authorize_request(method, path, data, forbidden=(token,))
|
|
return _build_request(method, path, base_url=base_url, token=token, data=data)
|
|
|
|
|
|
def _build_request(
|
|
method: str,
|
|
path: str,
|
|
*,
|
|
base_url: str,
|
|
token: str,
|
|
data: object | None = None,
|
|
) -> urllib.request.Request:
|
|
"""Build one already-authorized upstream request."""
|
|
payload = None
|
|
if data is not None:
|
|
payload = json.dumps(data, separators=(",", ":")).encode("utf-8")
|
|
return urllib.request.Request(
|
|
api_url(base_url, path),
|
|
data=payload,
|
|
method=method.upper(),
|
|
headers={
|
|
"Accept": "application/json",
|
|
"Authorization": f"token {token}",
|
|
"Content-Type": "application/json",
|
|
"User-Agent": "hermes-atlas-pr-client/2",
|
|
},
|
|
)
|
|
|
|
|
|
def redact_bytes(value: bytes, token: str) -> bytes:
|
|
"""Remove exact runtime-token and authorization-header values from output."""
|
|
redacted = value.replace(token.encode("utf-8"), b"[REDACTED]")
|
|
return re.sub(
|
|
rb"(?i)(authorization\s*[:=]\s*)(?:token|bearer)?\s*[^\s,;\"}]+",
|
|
rb"\1[REDACTED]",
|
|
redacted,
|
|
)
|
|
|
|
|
|
def _reject_response_credential(value: bytes, token: str) -> None:
|
|
"""Fail closed if an upstream body reflects any ordinary token encoding."""
|
|
raw = token.encode("utf-8")
|
|
forms = {
|
|
raw,
|
|
base64.b64encode(raw),
|
|
base64.b64encode(b"hermes-automation:" + raw),
|
|
urllib.parse.quote(token, safe="").encode("ascii"),
|
|
}
|
|
if any(form and form in value for form in forms):
|
|
raise PolicyError("Forgejo response contains runtime credential material")
|
|
|
|
|
|
def _request(
|
|
method: str,
|
|
path: str,
|
|
data: object | None,
|
|
*,
|
|
token: str,
|
|
opener: Callable[..., object] = _safe_urlopen,
|
|
authorized: bool = False,
|
|
expected_status: int,
|
|
) -> bytes:
|
|
builder = _build_request if authorized else build_request
|
|
request = builder(method, path, base_url=configured_base_url(), token=token, data=data)
|
|
with opener(request, timeout=30) as response: # type: ignore[attr-defined]
|
|
status = getattr(response, "status", None)
|
|
if status is None and hasattr(response, "getcode"):
|
|
status = response.getcode() # type: ignore[attr-defined]
|
|
if status != expected_status:
|
|
raise PolicyError("Forgejo returned an unexpected HTTP status")
|
|
if response.headers.get_content_type() != "application/json": # type: ignore[attr-defined]
|
|
raise PolicyError("Forgejo returned an unexpected response type")
|
|
body = response.read(MAX_RESPONSE_BYTES + 1) # type: ignore[attr-defined]
|
|
if len(body) > MAX_RESPONSE_BYTES:
|
|
raise PolicyError("Forgejo response exceeds the safe size limit")
|
|
_reject_response_credential(body, token)
|
|
return redact_bytes(body, token)
|
|
|
|
|
|
def read(
|
|
path: str, *, token: str, opener: Callable[..., object] = _safe_urlopen
|
|
) -> bytes:
|
|
"""Read one explicitly allowed Atlas repository metadata resource."""
|
|
return _request(
|
|
"GET", path, None, token=token, opener=opener, expected_status=200
|
|
)
|
|
|
|
|
|
def _nested(document: dict[str, object], *keys: str) -> object:
|
|
value: object = document
|
|
for key in keys:
|
|
if not isinstance(value, dict) or key not in value:
|
|
raise PolicyError("Forgejo omitted required pull-request evidence")
|
|
value = value[key]
|
|
return value
|
|
|
|
|
|
def _require_create_response(
|
|
value: bytes,
|
|
*,
|
|
repo: str,
|
|
base: str,
|
|
head: str,
|
|
head_sha: str,
|
|
title: str,
|
|
body: str,
|
|
) -> dict[str, object]:
|
|
"""Require exact server evidence for the intended open draft handoff."""
|
|
try:
|
|
document = json.loads(value)
|
|
except json.JSONDecodeError as exc:
|
|
raise PolicyError("Forgejo returned invalid pull-request metadata") from exc
|
|
if not isinstance(document, dict):
|
|
raise PolicyError("Forgejo returned invalid pull-request metadata")
|
|
number = _validate_pr_number(document.get("number"))
|
|
full_name = f"{ALLOWED_OWNER}/{repo}"
|
|
expected_html = f"{CANONICAL_BASE_URL}/{full_name}/pulls/{number}"
|
|
checks = (
|
|
document.get("state") == "open",
|
|
document.get("draft") is True,
|
|
document.get("merged") is False,
|
|
document.get("html_url") == expected_html,
|
|
document.get("url") == expected_html,
|
|
document.get("title") == title,
|
|
document.get("body") == body,
|
|
_nested(document, "base", "ref") == base,
|
|
_nested(document, "base", "repo", "full_name") == full_name,
|
|
_nested(document, "head", "ref") == head,
|
|
_nested(document, "head", "sha") == head_sha,
|
|
_nested(document, "head", "repo", "full_name") == full_name,
|
|
)
|
|
if not all(checks):
|
|
raise PolicyError("Forgejo did not confirm the exact draft pull-request state")
|
|
return document
|
|
|
|
|
|
def create_draft(
|
|
repo: str,
|
|
*,
|
|
base: str,
|
|
head: str,
|
|
head_sha: str,
|
|
title: str,
|
|
body: str,
|
|
token: str,
|
|
opener: Callable[..., object] = _safe_urlopen,
|
|
) -> bytes:
|
|
"""Create and verify a same-repository draft pull request for human review."""
|
|
_reject_forbidden(repo, "repository", (token,))
|
|
repo = _validate_repo(repo)
|
|
title = _draft_title(title, forbidden=(token,))
|
|
body = _validate_body(body, forbidden=(token,))
|
|
head_sha = _validate_sha(head_sha)
|
|
_validate_ref_bounds(base, "base", forbidden=(token,))
|
|
_validate_ref_bounds(head, "head", forbidden=(token,))
|
|
base = _validate_ref(base, "base", forbidden=(token,))
|
|
head = _validate_ref(head, "head", forbidden=(token,))
|
|
data = {"base": base, "body": body, "head": head, "title": title}
|
|
result = _request(
|
|
"POST",
|
|
f"/api/v1/repos/{ALLOWED_OWNER}/{repo}/pulls",
|
|
data,
|
|
token=token,
|
|
opener=opener,
|
|
authorized=True,
|
|
expected_status=201,
|
|
)
|
|
_require_create_response(
|
|
result,
|
|
repo=repo,
|
|
base=base,
|
|
head=head,
|
|
head_sha=head_sha,
|
|
title=title,
|
|
body=body,
|
|
)
|
|
return result
|
|
|
|
|
|
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
"""Parse the intentionally narrow read and create-draft operations."""
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument(
|
|
"--dry-run",
|
|
action="store_true",
|
|
help="validate and describe the request without reading a token or using the network",
|
|
)
|
|
subparsers = parser.add_subparsers(dest="operation", required=True)
|
|
read_parser = subparsers.add_parser("read", help="read allowed Atlas metadata")
|
|
read_parser.add_argument("path")
|
|
create_parser = subparsers.add_parser(
|
|
"create-draft", help="create a same-repository draft pull request"
|
|
)
|
|
create_parser.add_argument("repo")
|
|
create_parser.add_argument("--base", required=True)
|
|
create_parser.add_argument("--head", required=True)
|
|
create_parser.add_argument("--head-sha", required=True)
|
|
create_parser.add_argument("--title", required=True)
|
|
create_parser.add_argument("--body", default="")
|
|
return parser.parse_args(argv)
|
|
|
|
|
|
def _operation(
|
|
args: argparse.Namespace, *, validate_refs: bool = True
|
|
) -> tuple[str, str, object | None]:
|
|
if args.operation == "read":
|
|
return "GET", args.path, None
|
|
repo = _validate_repo(args.repo)
|
|
_validate_sha(args.head_sha)
|
|
body = _validate_body(args.body)
|
|
title = _draft_title(args.title)
|
|
_validate_ref_bounds(args.base, "base")
|
|
_validate_ref_bounds(args.head, "head")
|
|
base = _validate_ref(args.base, "base") if validate_refs else args.base
|
|
head = _validate_ref(args.head, "head") if validate_refs else args.head
|
|
data = {
|
|
"base": base,
|
|
"body": body,
|
|
"head": head,
|
|
"title": title,
|
|
}
|
|
return "POST", f"/api/v1/repos/{ALLOWED_OWNER}/{repo}/pulls", data
|
|
|
|
|
|
def _write_body(body: bytes) -> None:
|
|
if body:
|
|
sys.stdout.buffer.write(body)
|
|
if not body.endswith(b"\n"):
|
|
sys.stdout.buffer.write(b"\n")
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
"""Validate, optionally describe, and execute one safe Forgejo operation."""
|
|
try:
|
|
args = parse_args(argv)
|
|
if args.dry_run:
|
|
method, path, data = _operation(args, validate_refs=False)
|
|
operation = authorize_request(method, path, data)
|
|
print(
|
|
json.dumps(
|
|
{
|
|
"dry_run": True,
|
|
"host": "scm.bstein.dev",
|
|
"operation": operation,
|
|
"owner": ALLOWED_OWNER,
|
|
"path": path,
|
|
"request_fields": sorted(data)
|
|
if isinstance(data, dict)
|
|
else [],
|
|
},
|
|
sort_keys=True,
|
|
)
|
|
)
|
|
return 0
|
|
method, path, data = _operation(args, validate_refs=False)
|
|
from scm_broker_client import create_draft as broker_create_draft
|
|
from scm_broker_client import read as broker_read
|
|
|
|
if args.operation == "read":
|
|
body = broker_read(path)
|
|
else:
|
|
assert isinstance(data, dict)
|
|
body = broker_create_draft(
|
|
args.repo,
|
|
base=str(data["base"]),
|
|
head=str(data["head"]),
|
|
head_sha=args.head_sha,
|
|
title=str(data["title"]),
|
|
body=str(data["body"]),
|
|
)
|
|
_write_body(body)
|
|
return 0
|
|
except urllib.error.HTTPError as exc:
|
|
exc.read(MAX_ERROR_BYTES)
|
|
print(f"SCM broker request failed with HTTP {exc.code}", file=sys.stderr)
|
|
return 1
|
|
except (OSError, PolicyError, ValueError, json.JSONDecodeError):
|
|
print(
|
|
"Forgejo request rejected or unavailable; no credential was disclosed",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|