hermes: harden Atlas forge workflow

This commit is contained in:
jenkins 2026-08-16 07:59:35 -03:00
parent f35e89777b
commit c7ac16d206
7 changed files with 472 additions and 1 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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())

View File

@ -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())

View File

@ -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():

View File

@ -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(
"""
<flow-build>
<actions><revision><hash>abc123</hash></revision></actions>
<timestamp>1000</timestamp><duration>0</duration>
<execution><result>SUCCESS</result></execution>
</flow-build>
""",
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(
"""
<flow-build>
<actions><revision><hash>abc123</hash></revision></actions>
<timestamp>1000</timestamp><duration>250</duration>
<result>FAILURE</result>
<execution><result>SUCCESS</result></execution>
</flow-build>
""",
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):