From c7ac16d206153411ec7e520f5a5d4231ab33bbf6 Mon Sep 17 00:00:00 2001 From: jenkins Date: Sun, 16 Aug 2026 07:59:35 -0300 Subject: [PATCH] hermes: harden Atlas forge workflow --- services/hermes/agent-configmap.yaml | 27 +++ services/hermes/agent-deployment.yaml | 9 +- services/hermes/kustomization.yaml | 2 + services/hermes/scripts/gitea_api.py | 125 ++++++++++ .../hermes/scripts/jenkins_build_evidence.py | 213 ++++++++++++++++++ testing/tests/test_hermes_chat_quality.py | 8 + testing/tests/test_hermes_runtime_access.py | 89 ++++++++ 7 files changed, 472 insertions(+), 1 deletion(-) create mode 100644 services/hermes/scripts/gitea_api.py create mode 100644 services/hermes/scripts/jenkins_build_evidence.py diff --git a/services/hermes/agent-configmap.yaml b/services/hermes/agent-configmap.yaml index 0966f3ed..bb3490ca 100644 --- a/services/hermes/agent-configmap.yaml +++ b/services/hermes/agent-configmap.yaml @@ -315,6 +315,33 @@ data: real branch only when the requested implementation is review-ready. Never force-push. + `scm.bstein.dev` is Gitea, not GitHub. Never load or follow a GitHub/`gh` + skill for an Atlas remote, and do not interpret an unauthenticated Gitea + HTTP 404 as a missing private repository. Use authenticated Git for clone, + fetch, and push. For pull-request metadata, comments, and merges, call + `/opt/coordinator/gitea_api.py METHOD /api/v1/...`; it injects the runtime + Vault token without exposing it to the command line, environment, output, + or transcript. Before a consequential merge, independently verify the + exact base/head ancestry and diff, run the relevant tests, and confirm the + candidate Jenkins result. Prefer the internal endpoint in + `JENKINS_BASE_URL`; when Jenkins API authorization prevents a read, use the + existing cluster access to inspect the controller's job/build files and + logs rather than guessing a public hostname. The preferred read-only path + is `/opt/coordinator/jenkins_build_evidence.py JOB [--branch BRANCH] + [--commit SHA] --wait`; it distinguishes a genuinely terminal build from + nested Jenkins execution metadata and returns bounded JSON/log evidence. + After merging, observe 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 + 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 + file as user state. Use the Gitea pull-request merge endpoint for an open + PR so both Git history and PR state remain auditable. If an authorized + 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 with `kubectl config current-context`, read-only status/events/logs, and the relevant `titan-iac` manifests. Put durable desired-state changes on a diff --git a/services/hermes/agent-deployment.yaml b/services/hermes/agent-deployment.yaml index e3107a56..9050fa63 100644 --- a/services/hermes/agent-deployment.yaml +++ b/services/hermes/agent-deployment.yaml @@ -25,7 +25,7 @@ spec: ai.bstein.dev/execution: Hermes Kanban with durable direct Codex and Claude Code CLI workers ai.bstein.dev/model-policy: Jetson-assisted AUTO routing, low through xhigh, cross-provider fallback ai.bstein.dev/placement: rpi5 preferred; Jetson deferred until state storage is available - ai.bstein.dev/config-rev: "20260816-routed-vision-v4" + ai.bstein.dev/config-rev: "20260816-atlas-forge-workflow-v1" prometheus.io/scrape: "true" prometheus.io/path: /metrics prometheus.io/port: "9010" @@ -187,6 +187,11 @@ spec: done upsert_env GIT_ASKPASS /opt/coordinator/gitea_askpass.sh upsert_env GIT_TERMINAL_PROMPT 0 + upsert_env GITEA_BASE_URL https://scm.bstein.dev + upsert_env JENKINS_BASE_URL http://jenkins.jenkins.svc.cluster.local:8080 + upsert_env ARIADNE_BASE_URL http://ariadne.maintenance.svc.cluster.local + upsert_env VICTORIA_METRICS_URL http://victoria-metrics-single-server.monitoring.svc.cluster.local:8428 + upsert_env GRAFANA_BASE_URL https://metrics.bstein.dev chmod 0600 "${env_file}" touch "${profile_file}" if ! grep -qxF '# Hermes managed operator PATH.' "${profile_file}"; then @@ -294,6 +299,8 @@ spec: mv "${tools}/bin/kubectl.tmp" "${tools}/bin/kubectl" fi /bin/sh /opt/coordinator/install_agent_tools.sh + HOME=/opt/data/home git config --global user.name "Hermes Agent" + HOME=/opt/data/home git config --global user.email "hermes@bstein.dev" securityContext: allowPrivilegeEscalation: false runAsUser: 10000 diff --git a/services/hermes/kustomization.yaml b/services/hermes/kustomization.yaml index 8ec736e3..f3c45d92 100644 --- a/services/hermes/kustomization.yaml +++ b/services/hermes/kustomization.yaml @@ -68,12 +68,14 @@ configMapGenerator: - classifier_broker.py=scripts/classifier_broker.py - claude_oauth_broker.py=scripts/claude_oauth_broker.py - worker_route_broker.py=scripts/worker_route_broker.py + - gitea_api.py=scripts/gitea_api.py - gitea_askpass.sh=scripts/gitea_askpass.sh - hermes_coordinator.py=scripts/hermes_coordinator.py - hermes_model_routing.py=scripts/hermes_model_routing.py - hermes_stt_client.py=scripts/hermes_stt_client.py - image_broker.py=scripts/image_broker.py - install_agent_tools.sh=scripts/install_agent_tools.sh + - jenkins_build_evidence.py=scripts/jenkins_build_evidence.py - kanban_status_recovery.py=scripts/kanban_status_recovery.py - migrate_herdr_state.py=scripts/migrate_herdr_state.py - migrate_api_session_lineage.py=scripts/migrate_api_session_lineage.py diff --git a/services/hermes/scripts/gitea_api.py b/services/hermes/scripts/gitea_api.py new file mode 100644 index 00000000..a2188ba6 --- /dev/null +++ b/services/hermes/scripts/gitea_api.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Call the private Atlas Gitea API through a runtime-only token boundary.""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + + +DEFAULT_BASE_URL = "https://scm.bstein.dev" +DEFAULT_TOKEN_FILE = Path("/runtime-access/gitea-token") +ALLOWED_METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE") + + +def read_token(path: Path = DEFAULT_TOKEN_FILE) -> str: + """Read and validate the token from its in-memory Vault projection.""" + token = path.read_text(encoding="utf-8").strip() + if not token: + raise ValueError(f"runtime credential is empty: {path}") + return token + + +def api_url(base_url: str, path: str) -> str: + """Return a same-origin Gitea API URL for a validated API path.""" + base = urllib.parse.urlsplit(base_url.rstrip("/")) + target = urllib.parse.urlsplit(path) + if base.scheme not in {"http", "https"} or not base.netloc: + raise ValueError("GITEA_BASE_URL must be an absolute HTTP(S) URL") + if target.scheme or target.netloc or target.fragment: + raise ValueError("API path must be relative to the configured Gitea origin") + if not target.path.startswith("/api/v1/"): + raise ValueError("API path must start with /api/v1/") + return urllib.parse.urlunsplit( + (base.scheme, base.netloc, target.path, target.query, "") + ) + + +def build_request( + method: str, + path: str, + *, + base_url: str, + token: str, + data: object | None = None, +) -> urllib.request.Request: + """Build one authenticated request without placing the token in its URL.""" + 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, + headers={ + "Accept": "application/json", + "Authorization": f"token {token}", + "Content-Type": "application/json", + "User-Agent": "hermes-atlas-operator/1", + }, + ) + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + """Parse a bounded method, API path, and optional JSON request body.""" + parser = argparse.ArgumentParser( + description="Call the Atlas Gitea API using the runtime Vault token." + ) + parser.add_argument("method", choices=ALLOWED_METHODS) + parser.add_argument("path", help="Gitea path beginning with /api/v1/") + data_group = parser.add_mutually_exclusive_group() + data_group.add_argument("--data-json", help="JSON object/array request body") + data_group.add_argument( + "--data-file", type=Path, help="path to a JSON request body" + ) + return parser.parse_args(argv) + + +def load_data(args: argparse.Namespace) -> object | None: + """Decode the optional JSON body without involving a shell expansion.""" + if args.data_json is not None: + return json.loads(args.data_json) + if args.data_file is not None: + return json.loads(args.data_file.read_text(encoding="utf-8")) + return None + + +def main(argv: list[str] | None = None) -> int: + """Execute the request, print only its response body, and return HTTP status.""" + args = parse_args(argv) + try: + request = build_request( + args.method, + args.path, + base_url=os.environ.get("GITEA_BASE_URL", DEFAULT_BASE_URL), + token=read_token(), + data=load_data(args), + ) + with urllib.request.urlopen(request, timeout=30) as response: + body = response.read() + if body: + sys.stdout.buffer.write(body) + if not body.endswith(b"\n"): + sys.stdout.buffer.write(b"\n") + return 0 + except urllib.error.HTTPError as exc: + body = exc.read(65536) + print(f"Gitea API returned HTTP {exc.code}", file=sys.stderr) + if body: + sys.stderr.buffer.write(body) + if not body.endswith(b"\n"): + sys.stderr.buffer.write(b"\n") + return 1 + except (OSError, ValueError, json.JSONDecodeError) as exc: + print(f"Gitea API request failed: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/services/hermes/scripts/jenkins_build_evidence.py b/services/hermes/scripts/jenkins_build_evidence.py new file mode 100644 index 00000000..8a7e621d --- /dev/null +++ b/services/hermes/scripts/jenkins_build_evidence.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +"""Read bounded Jenkins build evidence through Hermes' Kubernetes access.""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +import time +import xml.etree.ElementTree as ET +from dataclasses import asdict, dataclass + + +JENKINS_HOME = "/var/jenkins_home/jobs" +SAFE_JOB = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") +SAFE_BRANCH = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]*$") + + +@dataclass(frozen=True) +class BuildEvidence: + """One Jenkins build's terminal state and bounded supporting evidence.""" + + job: str + branch: str | None + number: int + revision: str | None + result: str | None + building: bool + timestamp_ms: int | None + duration_ms: int | None + log_tail: str + + +def _validate_job(value: str) -> str: + """Accept one literal Jenkins job directory segment.""" + if not SAFE_JOB.fullmatch(value): + raise ValueError("job must contain only letters, digits, dot, underscore, or dash") + return value + + +def _validate_branch(value: str) -> str: + """Accept a branch name while rejecting traversal and absolute paths.""" + if not SAFE_BRANCH.fullmatch(value) or value.startswith("/"): + raise ValueError("branch contains unsupported characters") + if any(part in {"", ".", ".."} for part in value.split("/")): + raise ValueError("branch contains an unsafe path component") + return value + + +def _kubectl(*command: str) -> str: + """Run one read-only command in the Jenkins controller container.""" + result = subprocess.run( + [ + "kubectl", + "-n", + "jenkins", + "exec", + "deployment/jenkins", + "-c", + "jenkins", + "--", + *command, + ], + check=True, + capture_output=True, + text=True, + timeout=30, + ) + return result.stdout + + +def _read(path: str) -> str: + """Read one known Jenkins metadata file without shell interpolation.""" + return _kubectl("cat", path) + + +def _directories(path: str) -> list[str]: + """List immediate Jenkins directories using a fixed find invocation.""" + output = _kubectl( + "find", path, "-mindepth", "1", "-maxdepth", "1", "-type", "d", "-printf", "%f\n" + ) + return [line for line in output.splitlines() if line] + + +def _head_name(config_xml: str) -> str | None: + """Extract the logical branch name from a multibranch job config.""" + root = ET.fromstring(config_xml) + for head in root.findall(".//head"): + name = head.findtext("name") + if name: + return name + return root.findtext("displayName") + + +def resolve_job_path(job: str, branch: str | None) -> str: + """Resolve a job and optional logical branch to its controller path.""" + base = f"{JENKINS_HOME}/{_validate_job(job)}" + if branch is None: + return base + wanted = _validate_branch(branch) + branch_root = f"{base}/branches" + for encoded in _directories(branch_root): + config_path = f"{branch_root}/{encoded}/config.xml" + try: + if _head_name(_read(config_path)) == wanted: + return f"{branch_root}/{encoded}" + except (ET.ParseError, subprocess.SubprocessError): + continue + raise ValueError(f"Jenkins branch not found: {job}/{branch}") + + +def parse_build_xml( + xml_text: str, + *, + job: str, + branch: str | None, + number: int, + log_tail: str = "", +) -> BuildEvidence: + """Parse top-level build state without mistaking nested CPS state as final.""" + root = ET.fromstring(xml_text) + revision = None + for tag in ("hash", "sha1"): + candidate = root.findtext(f".//revision/{tag}") + if candidate: + revision = candidate + break + result = root.findtext("result") + timestamp = root.findtext("timestamp") + duration = root.findtext("duration") + return BuildEvidence( + job=job, + branch=branch, + number=number, + revision=revision, + result=result, + building=result is None, + timestamp_ms=int(timestamp) if timestamp else None, + duration_ms=int(duration) if duration else None, + log_tail=log_tail, + ) + + +def find_build( + job: str, + branch: str | None, + commit: str | None, + log_lines: int, +) -> BuildEvidence | None: + """Return the newest build matching an optional exact/prefix revision.""" + job_path = resolve_job_path(job, branch) + numbers = sorted( + (int(value) for value in _directories(f"{job_path}/builds") if value.isdigit()), + reverse=True, + ) + for number in numbers: + build_path = f"{job_path}/builds/{number}" + try: + evidence = parse_build_xml( + _read(f"{build_path}/build.xml"), + job=job, + branch=branch, + number=number, + ) + except (ET.ParseError, subprocess.SubprocessError): + continue + if commit and not (evidence.revision or "").startswith(commit): + continue + log_tail = _kubectl("tail", "-n", str(log_lines), f"{build_path}/log") + return BuildEvidence(**{**asdict(evidence), "log_tail": log_tail}) + return None + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + """Parse the bounded Jenkins evidence request.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("job") + parser.add_argument("--branch") + parser.add_argument("--commit", help="full or unambiguous leading commit SHA") + parser.add_argument("--wait", action="store_true", help="wait for a terminal result") + parser.add_argument("--timeout", type=int, default=900) + parser.add_argument("--poll", type=int, default=10) + parser.add_argument("--log-lines", type=int, default=40) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + """Print one JSON evidence object, optionally waiting until it is terminal.""" + args = parse_args(argv) + if args.timeout < 1 or args.poll < 1 or not 0 <= args.log_lines <= 200: + print("timeout/poll must be positive and log-lines must be 0..200", file=sys.stderr) + return 2 + deadline = time.monotonic() + args.timeout + try: + while True: + evidence = find_build(args.job, args.branch, args.commit, args.log_lines) + if evidence and (not args.wait or not evidence.building): + print(json.dumps(asdict(evidence), indent=2, sort_keys=True)) + return 0 + if not args.wait or time.monotonic() >= deadline: + value = asdict(evidence) if evidence else None + print(json.dumps({"timed_out": args.wait, "evidence": value}, indent=2)) + return 2 if args.wait else 1 + time.sleep(args.poll) + except (OSError, ValueError, subprocess.SubprocessError, ET.ParseError) as exc: + print(f"Jenkins evidence read failed: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/testing/tests/test_hermes_chat_quality.py b/testing/tests/test_hermes_chat_quality.py index 01cd33ea..f4a9a64f 100644 --- a/testing/tests/test_hermes_chat_quality.py +++ b/testing/tests/test_hermes_chat_quality.py @@ -99,6 +99,14 @@ def test_agent_config_keeps_delegated_reviewers_from_owning_task_lifecycle(): assert "Never call `kanban_show` without\na known, non-empty task ID" in soul assert "must load a skill only when its workflow\nmaterially applies" in soul assert "runtime-only `GIT_ASKPASS`" in soul + assert "`scm.bstein.dev` is Gitea, not GitHub" in instructions + assert "Never load or follow a GitHub/`gh`" in instructions + assert "/opt/coordinator/gitea_api.py METHOD /api/v1/..." in instructions + assert "JENKINS_BASE_URL" in instructions + assert "Do not\nuse `git reset --hard`" in instructions + rendered = (HERMES / "agent-deployment.yaml").read_text() + assert 'git config --global user.name "Hermes Agent"' in rendered + assert 'git config --global user.email "hermes@bstein.dev"' in rendered def test_agent_image_completes_parked_kanban_tasks_atomically(): diff --git a/testing/tests/test_hermes_runtime_access.py b/testing/tests/test_hermes_runtime_access.py index ff1e9c6e..d7be46ff 100644 --- a/testing/tests/test_hermes_runtime_access.py +++ b/testing/tests/test_hermes_runtime_access.py @@ -5,8 +5,10 @@ from __future__ import annotations import importlib.util import json import sys +import urllib.request from pathlib import Path +import pytest import yaml @@ -24,6 +26,90 @@ def _load(name: str): return module +def test_gitea_api_builds_runtime_authenticated_same_origin_requests(): + gitea_api = _load("gitea_api") + + request = gitea_api.build_request( + "POST", + "/api/v1/repos/atlas/cassandra/issues/1/comments", + base_url="https://scm.bstein.dev", + token="runtime-only-value", + data={"body": "reviewed"}, + ) + + assert isinstance(request, urllib.request.Request) + assert request.full_url == ( + "https://scm.bstein.dev/api/v1/repos/atlas/cassandra/issues/1/comments" + ) + assert request.method == "POST" + assert request.get_header("Authorization") == "token runtime-only-value" + assert json.loads(request.data) == {"body": "reviewed"} + + +@pytest.mark.parametrize( + "path", + [ + "https://evil.example/api/v1/repos/atlas/cassandra", + "/repos/atlas/cassandra", + "/api/v1/repos/atlas/cassandra#fragment", + ], +) +def test_gitea_api_rejects_foreign_or_non_api_targets(path: str): + gitea_api = _load("gitea_api") + + with pytest.raises(ValueError): + gitea_api.api_url("https://scm.bstein.dev", path) + + +def test_jenkins_evidence_ignores_nested_execution_result(): + evidence_reader = _load("jenkins_build_evidence") + evidence = evidence_reader.parse_build_xml( + """ + + abc123 + 10000 + SUCCESS + + """, + job="demo", + branch="master", + number=15, + ) + + assert evidence.revision == "abc123" + assert evidence.result is None + assert evidence.building is True + + +def test_jenkins_evidence_reads_only_top_level_terminal_result(): + evidence_reader = _load("jenkins_build_evidence") + evidence = evidence_reader.parse_build_xml( + """ + + abc123 + 1000250 + FAILURE + SUCCESS + + """, + job="demo", + branch=None, + number=4, + ) + + assert evidence.result == "FAILURE" + assert evidence.building is False + assert evidence.duration_ms == 250 + + +@pytest.mark.parametrize("branch", ["../master", "/master", "feature//unsafe"]) +def test_jenkins_evidence_rejects_unsafe_branch_paths(branch: str): + evidence_reader = _load("jenkins_build_evidence") + + with pytest.raises(ValueError): + evidence_reader._validate_branch(branch) + + def test_agent_runtime_stage_keeps_credentials_in_memory(tmp_path: Path, monkeypatch): stage = _load("stage_runtime_access") vault = tmp_path / "vault" @@ -232,6 +318,9 @@ def test_manifests_never_seed_access_material_into_persistent_env(): askpass = (SCRIPTS / "gitea_askpass.sh").read_text(encoding="utf-8") assert "/runtime-access/gitea-token" in askpass assert "GITEA_TOKEN" not in askpass + gitea_api = (SCRIPTS / "gitea_api.py").read_text(encoding="utf-8") + assert "/runtime-access/gitea-token" in gitea_api + assert "GITEA_TOKEN" not in gitea_api def test_chat_media_reads_one_raw_runtime_secret(tmp_path: Path):