hermes: harden Forgejo client boundary

This commit is contained in:
jenkins 2026-08-16 20:18:49 -03:00
parent 36031958a4
commit bf777066fb
6 changed files with 671 additions and 363 deletions

View File

@ -318,15 +318,15 @@ data:
`scm.bstein.dev` is Forgejo/Gitea, not GitHub. Never load or follow a `scm.bstein.dev` is Forgejo/Gitea, not GitHub. Never load or follow a
GitHub/`gh` skill for an Atlas remote, and do not interpret an GitHub/`gh` skill for an Atlas remote, and do not interpret an
unauthenticated Gitea HTTP 404 as a missing private repository. Use unauthenticated Gitea HTTP 404 as a missing private repository. Use
authenticated Git for clone, fetch, and non-force push. For repository and authenticated Git for clone, fetch, and non-force push. For bounded
pull-request reads, or to create/update a review-ready draft PR, load repository/pull-request evidence or to create a review-ready draft PR, load
`$manage-atlas-pull-requests` and use `/opt/coordinator/gitea_api.py`. The `$manage-atlas-pull-requests` and use `/opt/coordinator/gitea_api.py`. The
client injects the runtime Vault token without exposing it to the command client injects the runtime Vault token without exposing it to the command
line, environment, output, or transcript. It permits only Atlas-scoped line, environment, output, or transcript. It permits only Atlas-scoped
reads, same-repository draft creation, and title/body updates while the PR metadata reads and verified same-repository draft creation. Updates, merge,
remains draft. Merge, approve, close, delete, comments, repository approve, close, delete, comments, repository administration, and force-push
administration, and force-push are unavailable; never bypass the client are unavailable; never bypass the client with raw HTTP. Leave every PR
with raw HTTP. Leave every PR unmerged for Brad's review. Independently unmerged for Brad's review. Independently
verify the exact base/head ancestry and diff, run the relevant tests, and verify the exact base/head ancestry and diff, run the relevant tests, and
confirm the candidate Jenkins result before presenting a handoff. Prefer confirm the candidate Jenkins result before presenting a handoff. Prefer
the internal endpoint in the internal endpoint in
@ -337,25 +337,24 @@ data:
[--commit SHA] --wait`; it distinguishes a genuinely terminal build from [--commit SHA] --wait`; it distinguishes a genuinely terminal build from
nested Jenkins execution metadata and returns bounded JSON/log evidence. nested Jenkins execution metadata and returns bounded JSON/log evidence.
With a branch, the helper also tries the conventional `JOB-branches` With a branch, the helper also tries the conventional `JOB-branches`
multibranch name. After Brad merges, observe the default-branch build to a multibranch name. If Brad separately reports that he merged the PR, observe
terminal result and report exact commit, build, and test evidence. the default-branch build to a terminal result and report exact commit,
build, and test evidence.
The workspace already has a non-secret Hermes Git author identity. Do not The workspace already has a non-secret Hermes Git author identity. Do not
use `git reset --hard`, even in a fresh clone; use a detached worktree or a use `git reset --hard`, even in a fresh clone; use a detached worktree or a
clean branch switch when comparing revisions, and preserve any unexpected clean branch switch when comparing revisions, and preserve any unexpected
file as user state. Use the Gitea pull-request merge endpoint for an open file as user state. PR publication ends at the verified open draft; Brad
PR so both Git history and PR state remain auditable. If an authorized owns review and merge authority.
manual merge already reached the base branch, reconcile the open PR with
`Do=manually-merged` and its exact merge commit instead of leaving stale
review state.
The terminal PATH contains the pinned operator tools. Start cluster work The terminal PATH contains the pinned operator tools. Start cluster work
with `kubectl config current-context`, read-only status/events/logs, and the with `kubectl config current-context`, read-only status/events/logs, and the
relevant `titan-iac` manifests. Put durable desired-state changes on a relevant `titan-iac` manifests. Put durable desired-state changes on a
reviewable `titan-iac` branch, validate Kustomize and client dry-run, then reviewable `titan-iac` branch and validate Kustomize and client dry-run.
use Flux reconciliation after the tracked change is published. Direct Brad owns merge and routine Flux reconciliation; perform either only under
`kubectl` mutations are for explicit incident recovery or ephemeral a separate explicit operator request. Direct `kubectl` mutations are for
verification, not ordinary delivery. explicit incident recovery or ephemeral verification, not ordinary
delivery.
Node SSH uses a dedicated audited Hermes identity: `ssh titan-04` (or any Node SSH uses a dedicated audited Hermes identity: `ssh titan-04` (or any
current Kubernetes node name). Host keys are pinned and password fallback current Kubernetes node name). Host keys are pinned and password fallback

View File

@ -7,6 +7,7 @@ import argparse
import json import json
import os import os
import re import re
import subprocess
import sys import sys
import urllib.error import urllib.error
import urllib.parse import urllib.parse
@ -18,18 +19,41 @@ CANONICAL_BASE_URL = "https://scm.bstein.dev"
ALLOWED_OWNER = "atlas" ALLOWED_OWNER = "atlas"
DEFAULT_TOKEN_FILE = Path("/runtime-access/gitea-token") DEFAULT_TOKEN_FILE = Path("/runtime-access/gitea-token")
REPO_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,99}\Z") REPO_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,99}\Z")
PR_PATH_RE = re.compile(r"/api/v1/repos/atlas/([^/]+)/pulls/([1-9][0-9]*)\Z") SHA_RE = re.compile(r"[0-9a-fA-F]{40}\Z")
CREATE_PATH_RE = re.compile(r"/api/v1/repos/atlas/([^/]+)/pulls\Z") CREATE_PATH_RE = re.compile(r"/api/v1/repos/atlas/([^/]+)/pulls\Z")
READ_PATH_RE = re.compile(r"/api/v1/repos/atlas/([^/]+)(?:/.*)?\Z")
SAFE_REF_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._/-]{0,199}\Z")
MAX_ERROR_BYTES = 8192 MAX_ERROR_BYTES = 8192
MAX_RESPONSE_BYTES = 2 * 1024 * 1024
DRAFT_TITLE_PREFIX = "WIP: " DRAFT_TITLE_PREFIX = "WIP: "
GIT_BIN = "/usr/bin/git"
SENSITIVE_BODY_PATTERNS = (
re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----"),
re.compile(r"(?i)\b(?:password|passwd|secret|token|api[_-]?key)\s*[:=]"),
re.compile(r"(?i)\bauthorization\s*:\s*(?:bearer|token|basic)\s+\S+"),
re.compile(r"\b(?:gh[pousr]_[A-Za-z0-9]{20,}|sk-[A-Za-z0-9]{20,})\b"),
re.compile(r"\b(?:AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{35})\b"),
re.compile(r"\beyJ[A-Za-z0-9_-]{12,}\.[A-Za-z0-9_-]{12,}\.[A-Za-z0-9_-]{8,}\b"),
)
class PolicyError(ValueError): class PolicyError(ValueError):
"""Raised when a requested Forgejo operation exceeds the safe boundary.""" """Raised when a requested Forgejo operation exceeds the safe boundary."""
class RejectRedirectHandler(urllib.request.HTTPRedirectHandler):
"""Reject every redirect before urllib can copy authentication headers."""
def redirect_request(self, req, fp, code, msg, headers, newurl):
raise PolicyError("Forgejo redirects are not allowed")
_SAFE_OPENER = urllib.request.build_opener(RejectRedirectHandler())
def _safe_urlopen(request: urllib.request.Request, timeout: int):
"""Open one request using a handler that never follows redirects."""
return _SAFE_OPENER.open(request, timeout=timeout)
def read_token(path: Path = DEFAULT_TOKEN_FILE) -> str: def read_token(path: Path = DEFAULT_TOKEN_FILE) -> str:
"""Read the token from the pod-lifetime Vault projection.""" """Read the token from the pod-lifetime Vault projection."""
token = path.read_text(encoding="utf-8").strip() token = path.read_text(encoding="utf-8").strip()
@ -53,19 +77,29 @@ def _validate_repo(repo: str) -> str:
def _validate_ref(value: object, name: str) -> str: def _validate_ref(value: object, name: str) -> str:
if not isinstance(value, str) or not SAFE_REF_RE.fullmatch(value): """Validate the complete Git ref grammar using Git itself."""
if not isinstance(value, str) or not value or value.startswith("-"):
raise PolicyError(f"{name} must be a same-repository branch name") raise PolicyError(f"{name} must be a same-repository branch name")
if ( if any(ord(character) < 32 or ord(character) == 127 for character in value):
value.startswith(".") raise PolicyError(f"{name} must be a safe same-repository branch name")
or value.endswith(("/", ".", ".lock")) result = subprocess.run(
or "//" in value [GIT_BIN, "check-ref-format", f"refs/heads/{value}"],
or ".." in value stdin=subprocess.DEVNULL,
or "@{" in value stdout=subprocess.DEVNULL,
): stderr=subprocess.DEVNULL,
check=False,
)
if result.returncode != 0:
raise PolicyError(f"{name} must be a safe same-repository branch name") raise PolicyError(f"{name} must be a safe same-repository branch name")
return value return value
def _validate_sha(value: object, name: str = "head SHA") -> str:
if not isinstance(value, str) or not SHA_RE.fullmatch(value):
raise PolicyError(f"{name} must be a full Git SHA-1")
return value.lower()
def _validate_text(value: object, name: str, maximum: int, *, required: bool) -> str: def _validate_text(value: object, name: str, maximum: int, *, required: bool) -> str:
if not isinstance(value, str): if not isinstance(value, str):
raise PolicyError(f"{name} must be text") raise PolicyError(f"{name} must be text")
@ -76,6 +110,15 @@ def _validate_text(value: object, name: str, maximum: int, *, required: bool) ->
return value return value
def _validate_body(value: object, *, forbidden: tuple[str, ...] = ()) -> str:
body = _validate_text(value, "body", 16384, required=False)
if any(secret and secret in body for secret in forbidden):
raise PolicyError("pull-request body contains runtime credential material")
if any(pattern.search(body) for pattern in SENSITIVE_BODY_PATTERNS):
raise PolicyError("pull-request body resembles credential material")
return body
def _draft_title(value: object) -> str: def _draft_title(value: object) -> str:
"""Return a bounded title using Gitea's configured default draft prefix.""" """Return a bounded title using Gitea's configured default draft prefix."""
title = _validate_text(value, "title", 251, required=True).strip() title = _validate_text(value, "title", 251, required=True).strip()
@ -99,6 +142,73 @@ def _split_api_path(path: str) -> urllib.parse.SplitResult:
return target return target
def _validate_query(target: urllib.parse.SplitResult, allowed: set[str]) -> None:
try:
pairs = urllib.parse.parse_qsl(
target.query, keep_blank_values=True, strict_parsing=True
)
except ValueError as exc:
raise PolicyError("invalid API query") from exc
if len({key for key, _ in pairs}) != len(pairs):
raise PolicyError("duplicate API query parameters are not allowed")
if any(key not in allowed for key, _ in pairs):
raise PolicyError("API query parameter is outside the read allowlist")
values = dict(pairs)
for name in ("page", "limit"):
if name not in values:
continue
if not values[name].isdigit() or int(values[name]) < 1:
raise PolicyError(f"{name} must be a positive integer")
if "limit" in values and int(values["limit"]) > 50:
raise PolicyError("read limit cannot exceed 50")
if "state" in values and values["state"] not in {"open", "closed", "all"}:
raise PolicyError("pull-request state is invalid")
def _authorize_read(target: urllib.parse.SplitResult) -> str:
"""Allow only repository, PR, branch, commit, and status metadata reads."""
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("/")
_validate_repo(repo)
if not separator:
_validate_query(target, set())
return "repository"
if suffix == "pulls":
_validate_query(target, {"page", "limit", "state"})
return "pull-list"
if re.fullmatch(r"pulls/[1-9][0-9]*", suffix):
_validate_query(target, set())
return "pull"
if re.fullmatch(r"pulls/[1-9][0-9]*/(?:commits|files)", suffix):
_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: def api_url(base_url: str, path: str) -> str:
"""Return a canonical same-origin URL without forwarding credentials.""" """Return a canonical same-origin URL without forwarding credentials."""
if base_url.rstrip("/") != CANONICAL_BASE_URL: if base_url.rstrip("/") != CANONICAL_BASE_URL:
@ -116,44 +226,23 @@ def authorize_request(method: str, path: str, data: object | None) -> str:
if normalized_method == "GET": if normalized_method == "GET":
if data is not None: if data is not None:
raise PolicyError("read operations cannot include a request body") raise PolicyError("read operations cannot include a request body")
match = READ_PATH_RE.fullmatch(target.path) return _authorize_read(target)
if not match:
raise PolicyError("reads are limited to repositories owned by atlas")
_validate_repo(match.group(1))
return "read"
if target.query: if target.query:
raise PolicyError("mutating operations cannot include query parameters") raise PolicyError("mutating operations cannot include query parameters")
if normalized_method == "POST": if normalized_method != "POST":
match = CREATE_PATH_RE.fullmatch(target.path) raise PolicyError("only read and create-draft operations are available")
if not match: match = CREATE_PATH_RE.fullmatch(target.path)
raise PolicyError("POST is limited to creating an Atlas draft pull request") if not match:
_validate_repo(match.group(1)) raise PolicyError("POST is limited to creating an Atlas draft pull request")
if not isinstance(data, dict) or set(data) != {"base", "body", "head", "title"}: _validate_repo(match.group(1))
raise PolicyError("draft creation accepts only base, body, head, and title") if not isinstance(data, dict) or set(data) != {"base", "body", "head", "title"}:
_validate_ref(data["base"], "base") raise PolicyError("draft creation accepts only base, body, head, and title")
_validate_ref(data["head"], "head") _validate_ref(data["base"], "base")
if data["title"] != _draft_title(data["title"]): _validate_ref(data["head"], "head")
raise PolicyError("new pull requests must use the Gitea draft-title prefix") if data["title"] != _draft_title(data["title"]):
_validate_text(data["body"], "body", 65536, required=False) raise PolicyError("new pull requests must use the Gitea draft-title prefix")
return "create-draft" _validate_body(data["body"])
return "create-draft"
if normalized_method == "PATCH":
match = PR_PATH_RE.fullmatch(target.path)
if not match:
raise PolicyError("PATCH is limited to Atlas draft pull-request metadata")
_validate_repo(match.group(1))
if not isinstance(data, dict) or not data or not set(data) <= {"body", "title"}:
raise PolicyError("draft updates accept only title and body")
if "title" in data and data["title"] != _draft_title(data["title"]):
raise PolicyError("updated pull-request titles must preserve draft state")
if "body" in data:
_validate_text(data["body"], "body", 65536, required=False)
return "update-draft"
raise PolicyError(
"method is not available: merge, approve, close, delete, and force-push are outside this client"
)
def build_request( def build_request(
@ -177,7 +266,7 @@ def build_request(
"Accept": "application/json", "Accept": "application/json",
"Authorization": f"token {token}", "Authorization": f"token {token}",
"Content-Type": "application/json", "Content-Type": "application/json",
"User-Agent": "hermes-atlas-pr-client/1", "User-Agent": "hermes-atlas-pr-client/2",
}, },
) )
@ -198,102 +287,116 @@ def _request(
data: object | None, data: object | None,
*, *,
token: str, token: str,
opener: Callable[..., object] = urllib.request.urlopen, opener: Callable[..., object] = _safe_urlopen,
) -> bytes: ) -> bytes:
request = build_request( request = build_request(
method, method, path, base_url=configured_base_url(), token=token, data=data
path,
base_url=configured_base_url(),
token=token,
data=data,
) )
with opener(request, timeout=30) as response: # type: ignore[attr-defined] with opener(request, timeout=30) as response: # type: ignore[attr-defined]
return redact_bytes(response.read(), token) # type: ignore[attr-defined] 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")
return redact_bytes(body, token)
def read( def read(
path: str, *, token: str, opener: Callable[..., object] = urllib.request.urlopen path: str, *, token: str, opener: Callable[..., object] = _safe_urlopen
) -> bytes: ) -> bytes:
"""Read one Atlas repository API resource.""" """Read one explicitly allowed Atlas repository metadata resource."""
return _request("GET", path, None, token=token, opener=opener) return _request("GET", path, None, token=token, opener=opener)
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 = document.get("number")
if not isinstance(number, int) or isinstance(number, bool) or number < 1:
raise PolicyError("Forgejo omitted a valid pull-request number")
full_name = f"{ALLOWED_OWNER}/{repo}"
expected_html = f"{CANONICAL_BASE_URL}/{full_name}/pulls/{number}"
expected_api = f"{CANONICAL_BASE_URL}/api/v1/repos/{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_api,
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( def create_draft(
repo: str, repo: str,
*, *,
base: str, base: str,
head: str, head: str,
head_sha: str,
title: str, title: str,
body: str, body: str,
token: str, token: str,
opener: Callable[..., object] = urllib.request.urlopen, opener: Callable[..., object] = _safe_urlopen,
) -> bytes: ) -> bytes:
"""Create a same-repository draft pull request for human review.""" """Create and verify a same-repository draft pull request for human review."""
data = { repo = _validate_repo(repo)
"base": base, base = _validate_ref(base, "base")
"body": body, head = _validate_ref(head, "head")
"head": head, head_sha = _validate_sha(head_sha)
"title": _draft_title(title), title = _draft_title(title)
} body = _validate_body(body, forbidden=(token,))
data = {"base": base, "body": body, "head": head, "title": title}
result = _request( result = _request(
"POST", "POST",
f"/api/v1/repos/{ALLOWED_OWNER}/{_validate_repo(repo)}/pulls", f"/api/v1/repos/{ALLOWED_OWNER}/{repo}/pulls",
data, data,
token=token, token=token,
opener=opener, opener=opener,
) )
_require_draft_response(result) _require_create_response(
result,
repo=repo,
base=base,
head=head,
head_sha=head_sha,
title=title,
body=body,
)
return result return result
def _require_draft_response(value: bytes) -> dict[str, object]:
"""Fail closed unless Forgejo confirms the resulting PR remains a draft."""
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) or document.get("draft") is not True:
raise PolicyError(
"Forgejo did not confirm draft state; human review is required"
)
return document
def update_draft(
repo: str,
number: int,
updates: dict[str, str],
*,
token: str,
opener: Callable[..., object] = urllib.request.urlopen,
) -> bytes:
"""Update title/body only after Forgejo confirms the PR is still a draft."""
if not isinstance(number, int) or isinstance(number, bool) or number < 1:
raise PolicyError("pull-request number must be positive")
path = f"/api/v1/repos/{ALLOWED_OWNER}/{_validate_repo(repo)}/pulls/{number}"
current_raw = _request("GET", path, None, token=token, opener=opener)
try:
current = json.loads(current_raw)
except json.JSONDecodeError as exc:
raise PolicyError("Forgejo returned invalid pull-request metadata") from exc
if not isinstance(current, dict) or current.get("draft") is not True:
raise PolicyError("pull request is not a draft; human review now owns it")
safe_updates = dict(updates)
if "title" in safe_updates:
safe_updates["title"] = _draft_title(safe_updates["title"])
result = _request("PATCH", path, safe_updates, token=token, opener=opener)
_require_draft_response(result)
return result
def _body(args: argparse.Namespace) -> str:
if args.body_file is None:
return ""
return args.body_file.read_text(encoding="utf-8")
def parse_args(argv: list[str] | None = None) -> argparse.Namespace: def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
"""Parse the three intentionally narrow client operations.""" """Parse the intentionally narrow read and create-draft operations."""
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument( parser.add_argument(
"--dry-run", "--dry-run",
@ -301,28 +404,17 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
help="validate and describe the request without reading a token or using the network", help="validate and describe the request without reading a token or using the network",
) )
subparsers = parser.add_subparsers(dest="operation", required=True) subparsers = parser.add_subparsers(dest="operation", required=True)
read_parser = subparsers.add_parser("read", help="read allowed Atlas metadata")
read_parser = subparsers.add_parser(
"read", help="read an Atlas repository API path"
)
read_parser.add_argument("path") read_parser.add_argument("path")
create_parser = subparsers.add_parser( create_parser = subparsers.add_parser(
"create-draft", help="create a same-repository draft pull request" "create-draft", help="create a same-repository draft pull request"
) )
create_parser.add_argument("repo") create_parser.add_argument("repo")
create_parser.add_argument("--base", required=True) create_parser.add_argument("--base", required=True)
create_parser.add_argument("--head", 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("--title", required=True)
create_parser.add_argument("--body-file", type=Path) create_parser.add_argument("--body", default="")
update_parser = subparsers.add_parser(
"update-draft", help="update title/body of an existing draft pull request"
)
update_parser.add_argument("repo")
update_parser.add_argument("number", type=int)
update_parser.add_argument("--title")
update_parser.add_argument("--body-file", type=Path)
return parser.parse_args(argv) return parser.parse_args(argv)
@ -330,22 +422,14 @@ def _operation(args: argparse.Namespace) -> tuple[str, str, object | None]:
if args.operation == "read": if args.operation == "read":
return "GET", args.path, None return "GET", args.path, None
repo = _validate_repo(args.repo) repo = _validate_repo(args.repo)
if args.operation == "create-draft": _validate_sha(args.head_sha)
data = { data = {
"base": args.base, "base": _validate_ref(args.base, "base"),
"body": _body(args), "body": _validate_body(args.body),
"head": args.head, "head": _validate_ref(args.head, "head"),
"title": _draft_title(args.title), "title": _draft_title(args.title),
} }
return "POST", f"/api/v1/repos/{ALLOWED_OWNER}/{repo}/pulls", data return "POST", f"/api/v1/repos/{ALLOWED_OWNER}/{repo}/pulls", data
if args.number < 1:
raise PolicyError("pull-request number must be positive")
data = {}
if args.title is not None:
data["title"] = _draft_title(args.title)
if args.body_file is not None:
data["body"] = _body(args)
return "PATCH", f"/api/v1/repos/{ALLOWED_OWNER}/{repo}/pulls/{args.number}", data
def _write_body(body: bytes) -> None: def _write_body(body: bytes) -> None:
@ -374,7 +458,6 @@ def main(argv: list[str] | None = None) -> int:
"request_fields": sorted(data) "request_fields": sorted(data)
if isinstance(data, dict) if isinstance(data, dict)
else [], else [],
"requires_draft_preflight": operation == "update-draft",
}, },
sort_keys=True, sort_keys=True,
) )
@ -383,19 +466,17 @@ def main(argv: list[str] | None = None) -> int:
token = read_token() token = read_token()
if args.operation == "read": if args.operation == "read":
body = read(path, token=token) body = read(path, token=token)
elif args.operation == "create-draft": else:
assert isinstance(data, dict) assert isinstance(data, dict)
body = create_draft( body = create_draft(
args.repo, args.repo,
base=str(data["base"]), base=str(data["base"]),
head=str(data["head"]), head=str(data["head"]),
head_sha=args.head_sha,
title=str(data["title"]), title=str(data["title"]),
body=str(data["body"]), body=str(data["body"]),
token=token, token=token,
) )
else:
assert isinstance(data, dict)
body = update_draft(args.repo, args.number, data, token=token)
_write_body(body) _write_body(body)
return 0 return 0
except urllib.error.HTTPError as exc: except urllib.error.HTTPError as exc:
@ -407,8 +488,6 @@ def main(argv: list[str] | None = None) -> int:
sys.stderr.buffer.write(b"\n") sys.stderr.buffer.write(b"\n")
return 1 return 1
except (OSError, PolicyError, ValueError, json.JSONDecodeError): except (OSError, PolicyError, ValueError, json.JSONDecodeError):
# Deliberately omit exception details. File contents, HTTP request
# objects, and provider errors can carry credential material.
print( print(
"Forgejo request rejected or unavailable; no credential was disclosed", "Forgejo request rejected or unavailable; no credential was disclosed",
file=sys.stderr, file=sys.stderr,

View File

@ -1,6 +1,6 @@
--- ---
name: manage-atlas-pull-requests name: manage-atlas-pull-requests
description: Read private Atlas repository and pull-request metadata, create a draft pull request after a tested branch is pushed, or update the title/body of an existing draft through Hermes' least-authority Forgejo client. Use for PR handoff in scm.bstein.dev/atlas repositories. Never use it to merge, approve, close, delete, or force-push. description: Read bounded private Atlas repository and pull-request metadata or create a verified draft pull request after a tested branch is pushed through Hermes' least-authority Forgejo client. Use for PR handoff in scm.bstein.dev/atlas repositories. Never use it to update, merge, approve, close, delete, or force-push.
--- ---
# Manage Atlas pull requests # Manage Atlas pull requests
@ -12,47 +12,43 @@ put it in an argument or environment variable, or replace this client with
## Read repository or PR state ## Read repository or PR state
Pass an Atlas repository API path to the read operation: Pass one allowed Atlas metadata API path to the read operation:
```sh ```sh
/opt/coordinator/gitea_api.py read /api/v1/repos/atlas/REPO/pulls/NUMBER /opt/coordinator/gitea_api.py read /api/v1/repos/atlas/REPO/pulls/NUMBER
``` ```
Reads outside `scm.bstein.dev/atlas` are rejected. Treat returned state as The client exposes only repository metadata, PRs and PR evidence, branches,
commits, and commit status. Hooks, Actions secrets/variables, collaborators,
protections, reviews, merge endpoints, releases, and administration are
rejected even as reads. Responses are size-bounded. Treat returned state as
evidence, not authorization to change it. evidence, not authorization to change it.
## Create a review handoff ## Create a review handoff
Before opening a PR, verify the remote, branch, clean worktree, diff, tests, and Before opening a PR, verify the remote, branch, clean worktree, diff, tests, and
exact pushed commit. Never force-push. Write the PR description to a workspace exact pushed commit. Never force-push. Pass that full 40-character pushed head
file so multiline Markdown is not shell-quoted, then validate without reading a SHA explicitly. Keep the short body free of credentials and credential-like
credential or using the network: text. Validate without reading a credential or using the network:
```sh ```sh
/opt/coordinator/gitea_api.py --dry-run create-draft REPO \ /opt/coordinator/gitea_api.py --dry-run create-draft REPO \
--base main --head hermes/TASK --title "Focused change" \ --base main --head hermes/TASK --head-sha FULL_PUSHED_SHA \
--body-file /tmp/pr-body.md --title "Focused change" --body "Tests and review evidence"
``` ```
Run the same command without `--dry-run` only when the branch is review-ready. Run the same command without `--dry-run` only when the branch is review-ready.
The client always creates a draft in the same repository. Report its URL and The client accepts success only when Forgejo returns the exact requested
exact head commit to Brad. Leave it unmerged for human review. repository, base/head refs, head SHA, title/body, canonical URLs, positive PR
number, and open/unmerged/draft state. Report its URL and exact head commit to
## Update an existing draft Brad. Leave it unmerged for human review. PR updates are intentionally absent;
publish a corrected tested commit and ask Brad how to proceed if metadata must
Use only a title, a body file, or both: change.
```sh
/opt/coordinator/gitea_api.py update-draft REPO NUMBER \
--body-file /tmp/pr-body.md
```
The client first reads the PR from Forgejo and refuses the update unless it is
still a draft. It never changes head/base, review state, or merge state.
## Stop at the authority boundary ## Stop at the authority boundary
Merge, approve, close, delete, branch mutation, comments, releases, repository Update, merge, approve, close, delete, branch mutation, comments, releases,
administration, and force-push are intentionally unavailable. Do not bypass the repository administration, and force-push are intentionally unavailable. Do
client with a raw HTTP request. If one of those actions is needed, present the not bypass the client with a raw HTTP request. If one of those actions is
tested commit and evidence to Brad and stop for human review. needed, present the tested commit and evidence to Brad and stop for human
review.

View File

@ -102,8 +102,12 @@ def test_agent_config_keeps_delegated_reviewers_from_owning_task_lifecycle():
assert "`scm.bstein.dev` is Forgejo/Gitea, not GitHub" in instructions assert "`scm.bstein.dev` is Forgejo/Gitea, not GitHub" in instructions
assert "GitHub/`gh` skill for an Atlas remote" in instructions assert "GitHub/`gh` skill for an Atlas remote" in instructions
assert "load\n`$manage-atlas-pull-requests`" in instructions assert "load\n`$manage-atlas-pull-requests`" in instructions
assert "Merge, approve, close, delete, comments" in instructions assert "Updates, merge,\napprove, close, delete, comments" in instructions
assert "Leave every PR unmerged for Brad's review" in instructions assert "Leave every PR\nunmerged for Brad's review" in instructions
assert "Use the Gitea pull-request merge endpoint" not in instructions
assert "reconcile the open PR" not in instructions
assert "use Flux reconciliation after" not in instructions
assert "PR publication ends at the verified open draft" in instructions
assert "JENKINS_BASE_URL" in instructions assert "JENKINS_BASE_URL" in instructions
assert "Do not\nuse `git reset --hard`" in instructions assert "Do not\nuse `git reset --hard`" in instructions
rendered = (HERMES / "agent-deployment.yaml").read_text() rendered = (HERMES / "agent-deployment.yaml").read_text()

View File

@ -2,18 +2,18 @@
from __future__ import annotations from __future__ import annotations
import copy
import importlib.util import importlib.util
import io
import json import json
import sys import sys
import urllib.error import urllib.request
from pathlib import Path from pathlib import Path
import pytest import pytest
import yaml
ROOT = Path(__file__).parents[2] ROOT = Path(__file__).parents[2]
CLIENT_PATH = ROOT / "services/hermes/scripts/gitea_api.py" CLIENT_PATH = ROOT / "services/hermes/scripts/gitea_api.py"
HEAD_SHA = "465cf9146b05c174a2a8d310aff6c64be58277b6"
def _load(): def _load():
@ -36,6 +36,27 @@ def _draft_payload(**updates):
return payload return payload
def _draft_response(**updates):
response = {
"number": 3,
"state": "open",
"draft": True,
"merged": False,
"html_url": "https://scm.bstein.dev/atlas/cassandra/pulls/3",
"url": "https://scm.bstein.dev/api/v1/repos/atlas/cassandra/pulls/3",
"title": "WIP: Focused fix",
"body": "Review evidence",
"base": {"ref": "main", "repo": {"full_name": "atlas/cassandra"}},
"head": {
"ref": "hermes/fix",
"sha": HEAD_SHA,
"repo": {"full_name": "atlas/cassandra"},
},
}
response.update(updates)
return response
class Response: class Response:
def __init__(self, body: object): def __init__(self, body: object):
self.body = body if isinstance(body, bytes) else json.dumps(body).encode() self.body = body if isinstance(body, bytes) else json.dumps(body).encode()
@ -46,8 +67,32 @@ class Response:
def __exit__(self, *_args): def __exit__(self, *_args):
return False return False
def read(self): def read(self, limit=-1):
return self.body return self.body if limit < 0 else self.body[:limit]
def test_redirect_handler_rejects_cross_origin_with_sentinel_authorization():
client = _load()
source = urllib.request.Request(
"https://scm.bstein.dev/api/v1/repos/atlas/cassandra",
headers={"Authorization": "token redirect-sentinel"},
)
with pytest.raises(client.PolicyError, match="redirects are not allowed") as exc:
client.RejectRedirectHandler().redirect_request(
source,
None,
302,
"Found",
{},
"https://evil.example/collect",
)
assert "redirect-sentinel" not in str(exc.value)
assert any(
isinstance(handler, client.RejectRedirectHandler)
for handler in client._SAFE_OPENER.handlers
)
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -69,6 +114,71 @@ def test_host_owner_and_path_escape_attempts_are_rejected(base_url: str, path: s
client.build_request("GET", path, base_url=base_url, token="secret") client.build_request("GET", path, base_url=base_url, token="secret")
@pytest.mark.parametrize(
"path",
[
"/api/v1/repos/atlas/cassandra",
"/api/v1/repos/atlas/cassandra/pulls?state=open&limit=20&page=1",
"/api/v1/repos/atlas/cassandra/pulls/7",
"/api/v1/repos/atlas/cassandra/pulls/7/commits?limit=20",
"/api/v1/repos/atlas/cassandra/pulls/7/files?page=1",
"/api/v1/repos/atlas/cassandra/branches",
"/api/v1/repos/atlas/cassandra/branches/main",
"/api/v1/repos/atlas/cassandra/commits?limit=10",
f"/api/v1/repos/atlas/cassandra/git/commits/{HEAD_SHA}",
f"/api/v1/repos/atlas/cassandra/commits/{HEAD_SHA}/status",
f"/api/v1/repos/atlas/cassandra/commits/{HEAD_SHA}/statuses?limit=10",
f"/api/v1/repos/atlas/cassandra/statuses/{HEAD_SHA}?page=1",
],
)
def test_explicit_read_allowlist_accepts_only_engineering_metadata(path: str):
client = _load()
request = client.build_request(
"GET", path, base_url=client.CANONICAL_BASE_URL, token="runtime"
)
assert request.method == "GET"
@pytest.mark.parametrize(
"path",
[
"/api/v1/repos/atlas/cassandra/hooks",
"/api/v1/repos/atlas/cassandra/actions/secrets",
"/api/v1/repos/atlas/cassandra/actions/variables",
"/api/v1/repos/atlas/cassandra/collaborators",
"/api/v1/repos/atlas/cassandra/branch_protections",
"/api/v1/repos/atlas/cassandra/keys",
"/api/v1/repos/atlas/cassandra/pulls/7/reviews",
"/api/v1/repos/atlas/cassandra/pulls/7/merge",
"/api/v1/repos/atlas/cassandra/pulls/7.diff",
"/api/v1/repos/atlas/cassandra/releases",
],
)
def test_privileged_or_content_routes_are_denied_even_for_get(path: str):
client = _load()
with pytest.raises(client.PolicyError, match="outside the metadata read allowlist"):
client.authorize_request("GET", path, None)
@pytest.mark.parametrize(
"path",
[
"/api/v1/repos/atlas/cassandra/pulls?limit=51",
"/api/v1/repos/atlas/cassandra/pulls?state=merged",
"/api/v1/repos/atlas/cassandra/pulls?private=true",
"/api/v1/repos/atlas/cassandra/pulls?limit=1&limit=2",
"/api/v1/repos/atlas/cassandra?p=1",
],
)
def test_read_query_is_bounded(path: str):
client = _load()
with pytest.raises(client.PolicyError):
client.authorize_request("GET", path, None)
@pytest.mark.parametrize( @pytest.mark.parametrize(
("method", "path", "data"), ("method", "path", "data"),
[ [
@ -79,12 +189,11 @@ def test_host_owner_and_path_escape_attempts_are_rejected(base_url: str, path: s
"/api/v1/repos/atlas/cassandra/pulls/4/reviews", "/api/v1/repos/atlas/cassandra/pulls/4/reviews",
{"event": "APPROVED"}, {"event": "APPROVED"},
), ),
("PATCH", "/api/v1/repos/atlas/cassandra/pulls/4", {"state": "closed"}), ("PATCH", "/api/v1/repos/atlas/cassandra/pulls/4", {"title": "WIP: x"}),
("PATCH", "/api/v1/repos/atlas/cassandra/pulls/4", {"draft": False}),
("PUT", "/api/v1/repos/atlas/cassandra/branches/main", {}), ("PUT", "/api/v1/repos/atlas/cassandra/branches/main", {}),
], ],
) )
def test_merge_approve_close_delete_and_other_mutations_are_rejected( def test_merge_approve_close_delete_update_and_other_mutations_are_rejected(
method: str, path: str, data: object method: str, path: str, data: object
): ):
client = _load() client = _load()
@ -95,6 +204,36 @@ def test_merge_approve_close_delete_and_other_mutations_are_rejected(
) )
@pytest.mark.parametrize(
"ref",
[
"foo/.bar",
"foo/bar.lock/baz",
"foo..bar",
"foo@{bar",
"foo//bar",
"-danger",
"danger.",
"danger~one",
"danger^one",
"danger:one",
"danger one",
],
)
def test_complete_git_ref_validation_rejects_invalid_names(ref: str):
client = _load()
with pytest.raises(client.PolicyError):
client._validate_ref(ref, "head")
def test_git_ref_validation_uses_fixed_trusted_binary():
client = _load()
assert client.GIT_BIN == "/usr/bin/git"
assert client._validate_ref("hermes/valid-fix", "head") == "hermes/valid-fix"
def test_create_forces_draft_title_and_same_repository_branch_names(): def test_create_forces_draft_title_and_same_repository_branch_names():
client = _load() client = _load()
assert ( assert (
@ -103,7 +242,6 @@ def test_create_forces_draft_title_and_same_repository_branch_names():
) )
== "create-draft" == "create-draft"
) )
with pytest.raises(client.PolicyError, match="draft-title prefix"): with pytest.raises(client.PolicyError, match="draft-title prefix"):
client.authorize_request( client.authorize_request(
"POST", "POST",
@ -118,44 +256,6 @@ def test_create_forces_draft_title_and_same_repository_branch_names():
) )
def test_update_checks_server_draft_state_before_patching():
client = _load()
calls = []
responses = iter(
[
Response({"number": 7, "draft": True}),
Response({"number": 7, "draft": True}),
]
)
def opener(request, timeout):
calls.append((request.method, request.full_url, request.data, timeout))
return next(responses)
result = client.update_draft(
"cassandra", 7, {"title": "Narrowed repair"}, token="runtime", opener=opener
)
assert json.loads(result) == {"number": 7, "draft": True}
assert [call[0] for call in calls] == ["GET", "PATCH"]
assert json.loads(calls[1][2]) == {"title": "WIP: Narrowed repair"}
def test_update_rejects_non_draft_without_sending_patch():
client = _load()
calls = []
def opener(request, timeout):
calls.append((request.method, timeout))
return Response({"number": 7, "draft": False})
with pytest.raises(client.PolicyError, match="human review now owns"):
client.update_draft(
"cassandra", 7, {"body": "new"}, token="runtime", opener=opener
)
assert calls == [("GET", 30)]
def test_runtime_token_is_only_an_authorization_header(): def test_runtime_token_is_only_an_authorization_header():
client = _load() client = _load()
request = client.build_request( request = client.build_request(
@ -171,41 +271,128 @@ def test_runtime_token_is_only_an_authorization_header():
assert request.get_header("Authorization") == "token do-not-leak" assert request.get_header("Authorization") == "token do-not-leak"
def test_create_uses_live_gitea_schema_and_verifies_draft_response(): @pytest.mark.parametrize(
"body",
[
"pass" + "word=not-a-real-credential",
"Authorization: " + "Bearer not-a-real-credential-value",
"token: " + "ghp_" + "notarealcredentialvalue123456",
"-----BEGIN OPENSSH " + "PRIVATE KEY-----",
"eyJnotarealheader." + "notarealpayloadvalue." + "notarealsignature",
],
)
def test_body_rejects_common_credential_shapes(body: str):
client = _load()
with pytest.raises(client.PolicyError, match="credential material"):
client._validate_body(body)
def test_create_rejects_exact_runtime_token_before_network():
client = _load()
called = False
def opener(*_args, **_kwargs):
nonlocal called
called = True
return Response(_draft_response())
with pytest.raises(client.PolicyError, match="runtime credential"):
client.create_draft(
"cassandra",
base="main",
head="hermes/fix",
head_sha=HEAD_SHA,
title="Focused fix",
body="accidental runtime-sentinel value",
token="runtime-sentinel",
opener=opener,
)
assert called is False
def test_create_verifies_every_server_postcondition():
client = _load() client = _load()
calls = [] calls = []
def opener(request, timeout): def opener(request, timeout):
calls.append((json.loads(request.data), timeout)) calls.append((request, timeout))
return Response({"number": 3, "draft": True}) return Response(_draft_response())
result = client.create_draft( result = client.create_draft(
"cassandra", "cassandra",
base="main", base="main",
head="hermes/fix", head="hermes/fix",
head_sha=HEAD_SHA,
title="Focused fix", title="Focused fix",
body="Evidence", body="Review evidence",
token="runtime", token="runtime",
opener=opener, opener=opener,
) )
assert json.loads(result)["draft"] is True assert json.loads(result) == _draft_response()
assert calls[0][0]["title"] == "WIP: Focused fix" payload = json.loads(calls[0][0].data)
assert "draft" not in calls[0][0] assert payload == {
"base": "main",
"body": "Review evidence",
"head": "hermes/fix",
"title": "WIP: Focused fix",
}
assert calls[0][1] == 30
def test_create_fails_closed_when_server_does_not_confirm_draft(): def test_create_postcondition_rejects_every_material_mismatch():
client = _load()
mutations = [
("number", 0),
("state", "closed"),
("draft", False),
("merged", True),
("html_url", "https://evil.example/pulls/3"),
("url", "https://evil.example/api/pulls/3"),
("title", "Focused fix"),
("body", "different"),
]
documents = []
for key, value in mutations:
document = _draft_response()
document[key] = value
documents.append(document)
for path, value in [
(("base", "ref"), "master"),
(("base", "repo", "full_name"), "evil/cassandra"),
(("head", "ref"), "other"),
(("head", "sha"), "0" * 40),
(("head", "repo", "full_name"), "evil/cassandra"),
]:
document = copy.deepcopy(_draft_response())
target = document
for key in path[:-1]:
target = target[key]
target[path[-1]] = value
documents.append(document)
for document in documents:
with pytest.raises(client.PolicyError):
client._require_create_response(
json.dumps(document).encode(),
repo="cassandra",
base="main",
head="hermes/fix",
head_sha=HEAD_SHA,
title="WIP: Focused fix",
body="Review evidence",
)
def test_read_response_is_bounded():
client = _load() client = _load()
with pytest.raises(client.PolicyError, match="did not confirm draft"): with pytest.raises(client.PolicyError, match="safe size limit"):
client.create_draft( client.read(
"cassandra", "/api/v1/repos/atlas/cassandra",
base="main",
head="hermes/fix",
title="Focused fix",
body="Evidence",
token="runtime", token="runtime",
opener=lambda *_a, **_k: Response({"number": 3, "draft": False}), opener=lambda *_a, **_k: Response(b"x" * (client.MAX_RESPONSE_BYTES + 1)),
) )
@ -216,107 +403,3 @@ def test_output_redaction_covers_exact_token_and_authorization_header():
assert b"do-not-leak" not in redacted assert b"do-not-leak" not in redacted
assert redacted.count(b"[REDACTED]") >= 1 assert redacted.count(b"[REDACTED]") >= 1
def test_dry_run_does_not_read_token_or_use_network(
tmp_path: Path, monkeypatch, capsys
):
client = _load()
body = tmp_path / "body.md"
body.write_text("Evidence only\n", encoding="utf-8")
monkeypatch.setattr(client, "read_token", lambda: pytest.fail("read token"))
monkeypatch.setattr(
client.urllib.request, "urlopen", lambda *_a, **_k: pytest.fail("network")
)
assert (
client.main(
[
"--dry-run",
"create-draft",
"cassandra",
"--base",
"main",
"--head",
"hermes/fix",
"--title",
"Repair",
"--body-file",
str(body),
]
)
== 0
)
output = json.loads(capsys.readouterr().out)
assert output["operation"] == "create-draft"
assert output["owner"] == "atlas"
assert "Evidence only" not in json.dumps(output)
def test_http_error_path_redacts_token(monkeypatch, capsys):
client = _load()
monkeypatch.setattr(client, "read_token", lambda: "do-not-leak")
def fail(*_args, **_kwargs):
raise urllib.error.HTTPError(
"https://scm.bstein.dev/api/v1/repos/atlas/cassandra",
403,
"forbidden",
{},
io.BytesIO(b"Authorization: token do-not-leak"),
)
monkeypatch.setattr(client, "read", lambda *_a, **_k: fail())
assert client.main(["read", "/api/v1/repos/atlas/cassandra"]) == 1
captured = capsys.readouterr()
assert "do-not-leak" not in captured.err
assert "HTTP 403" in captured.err
def test_flux_manifest_projects_runtime_vault_token_and_skill_only():
client_source = CLIENT_PATH.read_text(encoding="utf-8")
assert "/runtime-access/gitea-token" in client_source
assert "GITEA_TOKEN" not in client_source
deployment = yaml.safe_load(
(ROOT / "services/hermes/agent-deployment.yaml").read_text(encoding="utf-8")
)
template = deployment["spec"]["template"]
annotations = template["metadata"]["annotations"]
assert annotations["vault.hashicorp.com/agent-inject-secret-gitea-token"] == (
"kv/data/atlas/hermes/developer-gitea"
)
runtime = next(
volume
for volume in template["spec"]["volumes"]
if volume["name"] == "runtime-access"
)
assert runtime["emptyDir"]["medium"] == "Memory"
expected = {"hermes", "terminal", "cli-lane-runner"}
mounted = {
container["name"]
for container in template["spec"]["containers"]
if any(
mount["name"] == "atlas-pr-skill"
and mount["mountPath"]
== "/opt/data/workspace/skills/manage-atlas-pull-requests"
and mount.get("readOnly") is True
for mount in container.get("volumeMounts", [])
)
}
assert mounted == expected
kustomization = yaml.safe_load(
(ROOT / "services/hermes/kustomization.yaml").read_text(encoding="utf-8")
)
generator = next(
item
for item in kustomization["configMapGenerator"]
if item["name"] == "hermes-atlas-pr-skill"
)
assert generator["files"] == [
"SKILL.md=skills/manage-atlas-pull-requests/SKILL.md",
"openai.yaml=skills/manage-atlas-pull-requests/agents/openai.yaml",
]

View File

@ -0,0 +1,147 @@
"""Runtime and Flux integration contracts for the safe Atlas PR client."""
from __future__ import annotations
import importlib.util
import io
import json
import sys
import urllib.error
from pathlib import Path
import pytest
import yaml
ROOT = Path(__file__).parents[2]
CLIENT_PATH = ROOT / "services/hermes/scripts/gitea_api.py"
HEAD_SHA = "465cf9146b05c174a2a8d310aff6c64be58277b6"
def _load():
spec = importlib.util.spec_from_file_location(
"safe_gitea_api_integration", CLIENT_PATH
)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def test_dry_run_has_no_file_input_and_uses_no_token_or_network(monkeypatch, capsys):
client = _load()
monkeypatch.setattr(client, "read_token", lambda: pytest.fail("read token"))
monkeypatch.setattr(
client, "_safe_urlopen", lambda *_a, **_k: pytest.fail("network")
)
assert (
client.main(
[
"--dry-run",
"create-draft",
"cassandra",
"--base",
"main",
"--head",
"hermes/fix",
"--head-sha",
HEAD_SHA,
"--title",
"Repair",
"--body",
"Evidence only",
]
)
== 0
)
output = json.loads(capsys.readouterr().out)
assert output["operation"] == "create-draft"
assert output["owner"] == "atlas"
assert "Evidence only" not in json.dumps(output)
with pytest.raises(SystemExit):
client.parse_args(
[
"create-draft",
"cassandra",
"--base",
"main",
"--head",
"hermes/fix",
"--head-sha",
HEAD_SHA,
"--title",
"Repair",
"--body-file",
"/runtime-access/gitea-token",
]
)
def test_http_error_path_redacts_token(monkeypatch, capsys):
client = _load()
monkeypatch.setattr(client, "read_token", lambda: "do-not-leak")
def fail(*_args, **_kwargs):
raise urllib.error.HTTPError(
"https://scm.bstein.dev/api/v1/repos/atlas/cassandra",
403,
"forbidden",
{},
io.BytesIO(b"Authorization: token do-not-leak"),
)
monkeypatch.setattr(client, "read", lambda *_a, **_k: fail())
assert client.main(["read", "/api/v1/repos/atlas/cassandra"]) == 1
captured = capsys.readouterr()
assert "do-not-leak" not in captured.err
assert "HTTP 403" in captured.err
def test_flux_manifest_projects_runtime_vault_token_and_skill_only():
client_source = CLIENT_PATH.read_text(encoding="utf-8")
assert "/runtime-access/gitea-token" in client_source
assert "GITEA_TOKEN" not in client_source
deployment = yaml.safe_load(
(ROOT / "services/hermes/agent-deployment.yaml").read_text(encoding="utf-8")
)
template = deployment["spec"]["template"]
annotations = template["metadata"]["annotations"]
assert annotations["vault.hashicorp.com/agent-inject-secret-gitea-token"] == (
"kv/data/atlas/hermes/developer-gitea"
)
runtime = next(
volume
for volume in template["spec"]["volumes"]
if volume["name"] == "runtime-access"
)
assert runtime["emptyDir"]["medium"] == "Memory"
expected = {"hermes", "terminal", "cli-lane-runner"}
mounted = {
container["name"]
for container in template["spec"]["containers"]
if any(
mount["name"] == "atlas-pr-skill"
and mount["mountPath"]
== "/opt/data/workspace/skills/manage-atlas-pull-requests"
and mount.get("readOnly") is True
for mount in container.get("volumeMounts", [])
)
}
assert mounted == expected
kustomization = yaml.safe_load(
(ROOT / "services/hermes/kustomization.yaml").read_text(encoding="utf-8")
)
generator = next(
item
for item in kustomization["configMapGenerator"]
if item["name"] == "hermes-atlas-pr-skill"
)
assert generator["files"] == [
"SKILL.md=skills/manage-atlas-pull-requests/SKILL.md",
"openai.yaml=skills/manage-atlas-pull-requests/agents/openai.yaml",
]