Compare commits
26 Commits
a5d0abb678
...
603524ba6a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
603524ba6a | ||
|
|
a3077224e0 | ||
| be1ff33d4b | |||
|
|
8c7074ae16 | ||
|
|
6b11331520 | ||
|
|
376cc14dd1 | ||
|
|
77df54e3ba | ||
|
|
9d42ff33f5 | ||
|
|
31439f096a | ||
|
|
6c1123201e | ||
|
|
6e9e8c6ab5 | ||
| 41d10ad3f7 | |||
|
|
6f993c049e | ||
|
|
5020e49057 | ||
|
|
b8f2163af0 | ||
|
|
340ec58b68 | ||
|
|
578239a496 | ||
|
|
2019b08276 | ||
|
|
f77119b238 | ||
|
|
64272f52d2 | ||
|
|
3a57371989 | ||
|
|
e8c26ecf85 | ||
|
|
8c6e3acdac | ||
|
|
2c1d97e551 | ||
|
|
cf3e46c2b6 | ||
|
|
762784da6b |
288
ci/Jenkinsfile.hermes-agent-image
Normal file
288
ci/Jenkinsfile.hermes-agent-image
Normal file
@ -0,0 +1,288 @@
|
||||
pipeline {
|
||||
agent {
|
||||
kubernetes {
|
||||
defaultContainer 'python'
|
||||
yaml """
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
labels:
|
||||
atlas.bstein.dev/workload: hermes-agent-image-builder
|
||||
spec:
|
||||
serviceAccountName: hermes-image-builder
|
||||
automountServiceAccountToken: false
|
||||
enableServiceLinks: false
|
||||
restartPolicy: Never
|
||||
securityContext:
|
||||
fsGroup: 1000
|
||||
fsGroupChangePolicy: OnRootMismatch
|
||||
nodeSelector:
|
||||
kubernetes.io/arch: arm64
|
||||
node-role.kubernetes.io/worker: "true"
|
||||
affinity:
|
||||
nodeAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: kubernetes.io/hostname
|
||||
operator: NotIn
|
||||
values:
|
||||
- titan-04
|
||||
- titan-14
|
||||
- titan-18
|
||||
- titan-19
|
||||
- titan-22
|
||||
- titan-24
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
- weight: 100
|
||||
preference:
|
||||
matchExpressions:
|
||||
- key: hardware
|
||||
operator: In
|
||||
values:
|
||||
- rpi5
|
||||
imagePullSecrets:
|
||||
- name: harbor-bstein-robot
|
||||
containers:
|
||||
- name: jnlp
|
||||
image: jenkins/inbound-agent@sha256:8eda4fe2a66bcf6a5e43436d9918fc14c306204dc8fcd75f4e15e0e6e5dc759a
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
resources:
|
||||
requests:
|
||||
cpu: 25m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
- name: python
|
||||
image: registry.bstein.dev/bstein/python@sha256:269541d3387baae008df4608ead893dba2b5cdaad1a5a380731a88992d34b808
|
||||
command: ["sleep"]
|
||||
args: ["99d"]
|
||||
tty: true
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
resources:
|
||||
requests:
|
||||
cpu: 25m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
cpu: 250m
|
||||
memory: 256Mi
|
||||
- name: kaniko
|
||||
image: gcr.io/kaniko-project/executor@sha256:c3109d5926a997b100c4343944e06c6b30a6804b2f9abe0994d3de6ef92b028e
|
||||
command: ["/busybox/sh", "-c"]
|
||||
args: ["/busybox/sleep 99d"]
|
||||
tty: true
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
add: ["CHOWN", "FOWNER", "DAC_OVERRIDE", "SETGID", "SETUID"]
|
||||
privileged: false
|
||||
runAsUser: 0
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 1Gi
|
||||
limits:
|
||||
cpu: "2"
|
||||
memory: 4Gi
|
||||
"""
|
||||
}
|
||||
}
|
||||
parameters {
|
||||
booleanParam(
|
||||
name: 'PUBLISH_IMAGE',
|
||||
defaultValue: false,
|
||||
description: 'Publish the reviewed main revision to Harbor.'
|
||||
)
|
||||
string(
|
||||
name: 'EXPECTED_SOURCE_REVISION',
|
||||
defaultValue: '',
|
||||
description: 'Exact 40-character commit on atlas/titan-iac main.'
|
||||
)
|
||||
string(
|
||||
name: 'CONFIRM_PUBLISH',
|
||||
defaultValue: '',
|
||||
description: 'Enter PUBLISH HERMES AGENT to confirm the release.'
|
||||
)
|
||||
}
|
||||
environment {
|
||||
HERMES_IMAGE = 'registry.bstein.dev/bstein/hermes-agent'
|
||||
}
|
||||
options {
|
||||
disableConcurrentBuilds()
|
||||
buildDiscarder(logRotator(daysToKeepStr: '30', numToKeepStr: '100', artifactDaysToKeepStr: '30', artifactNumToKeepStr: '100'))
|
||||
skipDefaultCheckout(true)
|
||||
timeout(time: 90, unit: 'MINUTES')
|
||||
}
|
||||
stages {
|
||||
stage('Checkout reviewed source') {
|
||||
steps {
|
||||
checkout scm
|
||||
}
|
||||
}
|
||||
stage('Enforce release boundary') {
|
||||
steps {
|
||||
sh '''
|
||||
set -eu
|
||||
mkdir -p build
|
||||
test "${PUBLISH_IMAGE}" = "true"
|
||||
test "${CONFIRM_PUBLISH}" = "PUBLISH HERMES AGENT"
|
||||
case "${EXPECTED_SOURCE_REVISION}" in
|
||||
*[!0-9a-f]*|'')
|
||||
echo "EXPECTED_SOURCE_REVISION must be a lowercase full commit" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
test "${#EXPECTED_SOURCE_REVISION}" -eq 40
|
||||
actual_revision="$(git rev-parse HEAD)"
|
||||
test "${actual_revision}" = "${EXPECTED_SOURCE_REVISION}"
|
||||
test "${actual_revision}" = "$(git rev-parse origin/main)"
|
||||
test -z "$(git status --porcelain)"
|
||||
test -f dockerfiles/Dockerfile.hermes-agent
|
||||
case "${BUILD_NUMBER}" in
|
||||
''|0*|*[!0-9]*)
|
||||
echo "BUILD_NUMBER must be a positive decimal integer" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
printf '%s\n' \
|
||||
"${HERMES_IMAGE}:git-${actual_revision}-build-${BUILD_NUMBER}" \
|
||||
> build/hermes-agent.destination
|
||||
'''
|
||||
}
|
||||
}
|
||||
stage('Reject replay before publish') {
|
||||
steps {
|
||||
withCredentials([usernamePassword(
|
||||
credentialsId: 'harbor-robot',
|
||||
usernameVariable: 'HARBOR_USER',
|
||||
passwordVariable: 'HARBOR_PASSWORD'
|
||||
)]) {
|
||||
sh '''
|
||||
set -eu
|
||||
set +x
|
||||
destination="$(cat build/hermes-agent.destination)"
|
||||
python3 ci/scripts/hermes_image_release.py assert-absent \
|
||||
--source-revision "${EXPECTED_SOURCE_REVISION}" \
|
||||
--build-number "${BUILD_NUMBER}" \
|
||||
--destination "${destination}"
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
stage('Build and publish without a daemon') {
|
||||
steps {
|
||||
container('kaniko') {
|
||||
withCredentials([usernamePassword(
|
||||
credentialsId: 'harbor-robot',
|
||||
usernameVariable: 'HARBOR_USER',
|
||||
passwordVariable: 'HARBOR_PASSWORD'
|
||||
)]) {
|
||||
sh '''#!/busybox/sh
|
||||
set -eu
|
||||
set +x
|
||||
config_path=/kaniko/.docker/config.json
|
||||
destination="$(cat build/hermes-agent.destination)"
|
||||
umask 077
|
||||
auth="$(printf '%s:%s' "${HARBOR_USER}" "${HARBOR_PASSWORD}" | /busybox/base64 | /busybox/tr -d '\n')"
|
||||
/busybox/mkdir -p /kaniko/.docker
|
||||
/busybox/printf '{"auths":{"registry.bstein.dev":{"auth":"%s"}}}\n' "${auth}" > "${config_path}"
|
||||
unset HARBOR_USER HARBOR_PASSWORD auth
|
||||
trap '/busybox/rm -f "${config_path}"' EXIT HUP INT TERM
|
||||
/kaniko/executor \
|
||||
--context="dir://${WORKSPACE}" \
|
||||
--dockerfile="${WORKSPACE}/dockerfiles/Dockerfile.hermes-agent" \
|
||||
--destination="${destination}" \
|
||||
--digest-file="${WORKSPACE}/build/hermes-agent.digest" \
|
||||
--image-name-tag-with-digest-file="${WORKSPACE}/build/hermes-agent.image" \
|
||||
--build-arg=HERMES_KANIKO_HEREDOC_COMPAT=1 \
|
||||
--label="org.opencontainers.image.revision=${EXPECTED_SOURCE_REVISION}" \
|
||||
--cleanup \
|
||||
--push-retry=3
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
stage('Render reviewed Flux handoff') {
|
||||
steps {
|
||||
withCredentials([usernamePassword(
|
||||
credentialsId: 'harbor-robot',
|
||||
usernameVariable: 'HARBOR_USER',
|
||||
passwordVariable: 'HARBOR_PASSWORD'
|
||||
)]) {
|
||||
sh '''
|
||||
set -eu
|
||||
set +x
|
||||
destination="$(cat build/hermes-agent.destination)"
|
||||
python3 ci/scripts/hermes_image_release.py render \
|
||||
--digest-file build/hermes-agent.digest \
|
||||
--image-file build/hermes-agent.image \
|
||||
--source-revision "${EXPECTED_SOURCE_REVISION}" \
|
||||
--build-number "${BUILD_NUMBER}" \
|
||||
--destination "${destination}" \
|
||||
--kustomization services/hermes/kustomization.yaml \
|
||||
--output-dir build/hermes-agent-release
|
||||
test -s build/hermes-agent-release/hermes-image-update.patch
|
||||
test -s build/hermes-agent-release/hermes-agent-image.json
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
post {
|
||||
success {
|
||||
sh '''
|
||||
set -eu
|
||||
expected_files="$(printf '%s\n' \
|
||||
build/hermes-agent.destination \
|
||||
build/hermes-agent.digest \
|
||||
build/hermes-agent.image \
|
||||
build/hermes-agent-release/hermes-agent-image.json \
|
||||
build/hermes-agent-release/hermes-image-update.patch \
|
||||
build/hermes-agent-release/hermes-kustomization.yaml \
|
||||
| LC_ALL=C sort)"
|
||||
actual_files="$(find build -type f -print | LC_ALL=C sort)"
|
||||
test "${actual_files}" = "${expected_files}"
|
||||
destination="$(cat build/hermes-agent.destination)"
|
||||
python3 ci/scripts/hermes_image_release.py verify-evidence \
|
||||
--digest-file build/hermes-agent.digest \
|
||||
--image-file build/hermes-agent.image \
|
||||
--source-revision "${EXPECTED_SOURCE_REVISION}" \
|
||||
--build-number "${BUILD_NUMBER}" \
|
||||
--destination "${destination}" \
|
||||
--kustomization services/hermes/kustomization.yaml \
|
||||
--output-dir build/hermes-agent-release
|
||||
'''
|
||||
archiveArtifacts(
|
||||
artifacts: 'build/hermes-agent.destination,build/hermes-agent.digest,build/hermes-agent.image,build/hermes-agent-release/hermes-agent-image.json,build/hermes-agent-release/hermes-image-update.patch,build/hermes-agent-release/hermes-kustomization.yaml',
|
||||
allowEmptyArchive: false,
|
||||
fingerprint: true
|
||||
)
|
||||
}
|
||||
cleanup {
|
||||
container('kaniko') {
|
||||
sh '''#!/busybox/sh
|
||||
/busybox/rm -f /kaniko/.docker/config.json
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
500
ci/scripts/hermes_image_release.py
Executable file
500
ci/scripts/hermes_image_release.py
Executable file
@ -0,0 +1,500 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify and render a reviewable Hermes agent image release."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import difflib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
DEFAULT_IMAGE = "registry.bstein.dev/bstein/hermes-agent"
|
||||
HARBOR_API_ORIGIN = "https://registry.bstein.dev/api/v2.0"
|
||||
HARBOR_PROJECT = "bstein"
|
||||
HARBOR_REPOSITORY = "hermes-agent"
|
||||
IMMUTABLE_REPOSITORY_PATTERN = "hermes-agent"
|
||||
IMMUTABLE_TAG_PATTERN = "git-*-build-*"
|
||||
DIGEST_PATTERN = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||
REVISION_PATTERN = re.compile(r"^[0-9a-f]{40}$")
|
||||
BUILD_PATTERN = re.compile(r"^[1-9][0-9]*$")
|
||||
DESTINATION_PATTERN = re.compile(
|
||||
r"^registry\.bstein\.dev/bstein/hermes-agent:"
|
||||
r"git-([0-9a-f]{40})-build-([1-9][0-9]*)$"
|
||||
)
|
||||
|
||||
|
||||
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
"""Never send registry credentials to a redirect target."""
|
||||
|
||||
def redirect_request(self, _request, _file, _code, _message, _headers, _url):
|
||||
return None
|
||||
|
||||
|
||||
def _validated(value: str, pattern: re.Pattern[str], label: str) -> str:
|
||||
"""Return a normalized value when it matches the release contract."""
|
||||
normalized = value.strip()
|
||||
if not pattern.fullmatch(normalized):
|
||||
raise ValueError(f"invalid {label}: expected {pattern.pattern}")
|
||||
return normalized
|
||||
|
||||
|
||||
def validate_destination(
|
||||
destination: str, source_revision: str, build_number: str
|
||||
) -> tuple[str, str]:
|
||||
"""Bind one unique build tag to the reviewed revision and Jenkins build."""
|
||||
revision = _validated(source_revision, REVISION_PATTERN, "source revision")
|
||||
build = _validated(build_number, BUILD_PATTERN, "build number")
|
||||
match = DESTINATION_PATTERN.fullmatch(destination.strip())
|
||||
if not match or match.groups() != (revision, build):
|
||||
raise ValueError(
|
||||
"destination must bind the reviewed revision and unique Jenkins build"
|
||||
)
|
||||
return revision, build
|
||||
|
||||
|
||||
def validate_kaniko_evidence(
|
||||
*, digest_text: str, image_text: str, destination: str
|
||||
) -> str:
|
||||
"""Cross-check both independent Kaniko output files against the destination."""
|
||||
digest_lines = digest_text.splitlines()
|
||||
image_lines = image_text.splitlines()
|
||||
if len(digest_lines) != 1:
|
||||
raise ValueError("Kaniko digest evidence must contain exactly one line")
|
||||
if len(image_lines) != 1:
|
||||
raise ValueError("Kaniko image evidence must contain exactly one line")
|
||||
digest = _validated(digest_lines[0], DIGEST_PATTERN, "image digest")
|
||||
if image_lines[0].strip() != f"{destination}@{digest}":
|
||||
raise ValueError("Kaniko image evidence does not match destination and digest")
|
||||
return digest
|
||||
|
||||
|
||||
def _registry_request(request: urllib.request.Request, timeout: int) -> Any:
|
||||
"""Make a registry request without following redirects."""
|
||||
opener = urllib.request.build_opener(_NoRedirect())
|
||||
try:
|
||||
return opener.open(request, timeout=timeout)
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc
|
||||
|
||||
|
||||
def _artifact_response(
|
||||
destination: str,
|
||||
*,
|
||||
username: str,
|
||||
password: str,
|
||||
opener: Callable[[urllib.request.Request, int], Any] = _registry_request,
|
||||
) -> tuple[int, bytes]:
|
||||
"""Read one exact Harbor artifact by tag with bounded response size."""
|
||||
match = DESTINATION_PATTERN.fullmatch(destination)
|
||||
if not match:
|
||||
raise ValueError("invalid destination")
|
||||
if not username or not password:
|
||||
raise RuntimeError("Harbor credentials are empty")
|
||||
tag = destination.rsplit(":", 1)[1]
|
||||
encoded_tag = urllib.parse.quote(tag, safe="")
|
||||
auth = base64.b64encode(f"{username}:{password}".encode()).decode("ascii")
|
||||
request = urllib.request.Request(
|
||||
f"{HARBOR_API_ORIGIN}/projects/{HARBOR_PROJECT}/repositories/"
|
||||
f"{HARBOR_REPOSITORY}/artifacts/{encoded_tag}"
|
||||
"?with_immutable_status=true",
|
||||
headers={"Accept": "application/json", "Authorization": f"Basic {auth}"},
|
||||
method="GET",
|
||||
)
|
||||
with opener(request, 20) as response:
|
||||
body = response.read(1_048_577)
|
||||
if len(body) > 1_048_576:
|
||||
raise RuntimeError("Harbor artifact response exceeded the size limit")
|
||||
return int(response.status), body
|
||||
|
||||
|
||||
def _immutable_rules_response(
|
||||
*,
|
||||
username: str,
|
||||
password: str,
|
||||
opener: Callable[[urllib.request.Request, int], Any] = _registry_request,
|
||||
) -> tuple[int, bytes, dict[str, str]]:
|
||||
"""Read the project policy with the same least-privilege publish identity."""
|
||||
if not username or not password:
|
||||
raise RuntimeError("Harbor credentials are empty")
|
||||
auth = base64.b64encode(f"{username}:{password}".encode()).decode("ascii")
|
||||
request = urllib.request.Request(
|
||||
f"{HARBOR_API_ORIGIN}/projects/{HARBOR_PROJECT}/immutabletagrules"
|
||||
"?page=1&page_size=100",
|
||||
headers={"Accept": "application/json", "Authorization": f"Basic {auth}"},
|
||||
method="GET",
|
||||
)
|
||||
with opener(request, 20) as response:
|
||||
body = response.read(1_048_577)
|
||||
if len(body) > 1_048_576:
|
||||
raise RuntimeError("Harbor immutable rule response exceeded the size limit")
|
||||
return int(response.status), body, dict(response.headers)
|
||||
|
||||
|
||||
def _require_complete_rule_page(
|
||||
rules: list[dict[str, Any]], headers: dict[str, str]
|
||||
) -> None:
|
||||
"""Require proof that the bounded first page contains every rule."""
|
||||
raw_total = next(
|
||||
(value for key, value in headers.items() if key.lower() == "x-total-count"),
|
||||
None,
|
||||
)
|
||||
if raw_total is None or not str(raw_total).isdecimal():
|
||||
raise RuntimeError("Harbor immutable rule list omitted a valid total count")
|
||||
if int(raw_total) != len(rules):
|
||||
raise RuntimeError("Harbor immutable rule list was truncated")
|
||||
|
||||
|
||||
def _normalized_immutable_rule(rule: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Select only fields that bind the server-side build-tag policy."""
|
||||
return {
|
||||
"disabled": bool(rule.get("disabled", False)),
|
||||
"action": rule.get("action"),
|
||||
"template": rule.get("template"),
|
||||
"tag_selectors": [
|
||||
{
|
||||
"kind": item.get("kind"),
|
||||
"decoration": item.get("decoration"),
|
||||
"pattern": item.get("pattern"),
|
||||
}
|
||||
for item in rule.get("tag_selectors") or []
|
||||
if isinstance(item, dict)
|
||||
],
|
||||
"scope_selectors": {
|
||||
"repository": [
|
||||
{
|
||||
"kind": item.get("kind"),
|
||||
"decoration": item.get("decoration"),
|
||||
"pattern": item.get("pattern"),
|
||||
}
|
||||
for item in (rule.get("scope_selectors") or {}).get(
|
||||
"repository", []
|
||||
)
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def verify_immutable_policy(
|
||||
*,
|
||||
username: str,
|
||||
password: str,
|
||||
opener: Callable[[urllib.request.Request, int], Any] = _registry_request,
|
||||
) -> None:
|
||||
"""Fail closed before build unless the exact Harbor rule is active."""
|
||||
status, body, headers = _immutable_rules_response(
|
||||
username=username, password=password, opener=opener
|
||||
)
|
||||
if status != 200:
|
||||
raise RuntimeError(f"Harbor immutable policy preflight returned HTTP {status}")
|
||||
try:
|
||||
rules = json.loads(body.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise RuntimeError("Harbor returned invalid immutable rule JSON") from exc
|
||||
if not isinstance(rules, list) or not all(isinstance(item, dict) for item in rules):
|
||||
raise RuntimeError("Harbor immutable rule list has an invalid shape")
|
||||
_require_complete_rule_page(rules, headers)
|
||||
expected = {
|
||||
"disabled": False,
|
||||
"action": "immutable",
|
||||
"template": "immutable_template",
|
||||
"tag_selectors": [
|
||||
{
|
||||
"kind": "doublestar",
|
||||
"decoration": "matches",
|
||||
"pattern": IMMUTABLE_TAG_PATTERN,
|
||||
}
|
||||
],
|
||||
"scope_selectors": {
|
||||
"repository": [
|
||||
{
|
||||
"kind": "doublestar",
|
||||
"decoration": "repoMatches",
|
||||
"pattern": IMMUTABLE_REPOSITORY_PATTERN,
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
matches = [
|
||||
_normalized_immutable_rule(item)
|
||||
for item in rules
|
||||
if _normalized_immutable_rule(item)["tag_selectors"]
|
||||
== expected["tag_selectors"]
|
||||
and _normalized_immutable_rule(item)["scope_selectors"]
|
||||
== expected["scope_selectors"]
|
||||
]
|
||||
if matches != [expected]:
|
||||
raise RuntimeError("Harbor immutable build-tag policy is absent or not exact")
|
||||
|
||||
|
||||
def assert_tag_absent(
|
||||
destination: str,
|
||||
*,
|
||||
username: str,
|
||||
password: str,
|
||||
opener: Callable[[urllib.request.Request, int], Any] = _registry_request,
|
||||
) -> None:
|
||||
"""Reject replay before Kaniko can push an already-used immutable identity."""
|
||||
status, _body = _artifact_response(
|
||||
destination, username=username, password=password, opener=opener
|
||||
)
|
||||
if status == 404:
|
||||
return
|
||||
if status == 200:
|
||||
raise RuntimeError("Harbor destination tag already exists; refusing overwrite")
|
||||
raise RuntimeError(f"Harbor destination preflight returned HTTP {status}")
|
||||
|
||||
|
||||
def verify_registry_digest(
|
||||
destination: str,
|
||||
digest: str,
|
||||
*,
|
||||
username: str,
|
||||
password: str,
|
||||
opener: Callable[[urllib.request.Request, int], Any] = _registry_request,
|
||||
) -> None:
|
||||
"""Verify Harbor independently resolves the pushed tag to Kaniko's digest."""
|
||||
digest = _validated(digest, DIGEST_PATTERN, "image digest")
|
||||
status, body = _artifact_response(
|
||||
destination, username=username, password=password, opener=opener
|
||||
)
|
||||
if status != 200:
|
||||
raise RuntimeError(f"Harbor manifest verification returned HTTP {status}")
|
||||
try:
|
||||
artifact = json.loads(body.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise RuntimeError("Harbor returned invalid artifact JSON") from exc
|
||||
harbor_digest = str(artifact.get("digest") or "").strip()
|
||||
if not DIGEST_PATTERN.fullmatch(harbor_digest):
|
||||
raise RuntimeError("Harbor response omitted a valid artifact digest")
|
||||
if harbor_digest != digest:
|
||||
raise RuntimeError("Harbor digest does not match Kaniko evidence")
|
||||
expected_tag = destination.rsplit(":", 1)[1]
|
||||
matching_tags = [
|
||||
item
|
||||
for item in artifact.get("tags") or []
|
||||
if isinstance(item, dict) and item.get("name") == expected_tag
|
||||
]
|
||||
if len(matching_tags) != 1:
|
||||
raise RuntimeError("Harbor artifact does not contain the expected tag")
|
||||
if matching_tags[0].get("immutable") is not True:
|
||||
raise RuntimeError("Harbor did not enforce the expected tag as immutable")
|
||||
|
||||
|
||||
def render_kustomization(source: str, digest: str, image: str = DEFAULT_IMAGE) -> str:
|
||||
"""Replace exactly one matching Kustomize image digest without reformatting."""
|
||||
digest = _validated(digest, DIGEST_PATTERN, "image digest")
|
||||
lines = source.splitlines(keepends=True)
|
||||
matches: list[int] = []
|
||||
|
||||
for index, line in enumerate(lines):
|
||||
if line.strip() != f"- name: {image}":
|
||||
continue
|
||||
name_indent = len(line) - len(line.lstrip())
|
||||
for candidate_index in range(index + 1, len(lines)):
|
||||
candidate = lines[candidate_index]
|
||||
stripped = candidate.strip()
|
||||
candidate_indent = len(candidate) - len(candidate.lstrip())
|
||||
if stripped.startswith("- name:") and candidate_indent == name_indent:
|
||||
break
|
||||
if stripped.startswith("digest:") and candidate_indent > name_indent:
|
||||
matches.append(candidate_index)
|
||||
break
|
||||
|
||||
if len(matches) != 1:
|
||||
raise ValueError(
|
||||
f"expected exactly one digest for image {image!r}; found {len(matches)}"
|
||||
)
|
||||
|
||||
index = matches[0]
|
||||
newline = "\n" if lines[index].endswith("\n") else ""
|
||||
prefix = lines[index][: len(lines[index]) - len(lines[index].lstrip())]
|
||||
lines[index] = f"{prefix}digest: {digest}{newline}"
|
||||
return "".join(lines)
|
||||
|
||||
|
||||
def write_release_artifacts(
|
||||
*,
|
||||
digest: str,
|
||||
source_revision: str,
|
||||
build_number: str,
|
||||
destination: str,
|
||||
kustomization: Path,
|
||||
output_dir: Path,
|
||||
) -> dict[str, str]:
|
||||
"""Write a rendered manifest, patch, and credential-free release metadata."""
|
||||
digest = _validated(digest, DIGEST_PATTERN, "image digest")
|
||||
source_revision, build_number = validate_destination(
|
||||
destination, source_revision, build_number
|
||||
)
|
||||
source = kustomization.read_text(encoding="utf-8")
|
||||
rendered = render_kustomization(source, digest)
|
||||
relative_name = kustomization.name
|
||||
patch = "".join(
|
||||
difflib.unified_diff(
|
||||
source.splitlines(keepends=True),
|
||||
rendered.splitlines(keepends=True),
|
||||
fromfile=f"a/services/hermes/{relative_name}",
|
||||
tofile=f"b/services/hermes/{relative_name}",
|
||||
)
|
||||
)
|
||||
if not patch:
|
||||
raise ValueError("published digest already matches the Flux manifest")
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
(output_dir / "hermes-kustomization.yaml").write_text(rendered, encoding="utf-8")
|
||||
(output_dir / "hermes-image-update.patch").write_text(patch, encoding="utf-8")
|
||||
metadata = {
|
||||
"build_number": build_number,
|
||||
"digest": digest,
|
||||
"flux_image": f"{DEFAULT_IMAGE}@{digest}",
|
||||
"image": DEFAULT_IMAGE,
|
||||
"published_tag": destination,
|
||||
"source_revision": source_revision,
|
||||
}
|
||||
(output_dir / "hermes-agent-image.json").write_text(
|
||||
json.dumps(metadata, indent=2, sort_keys=True) + "\n", encoding="utf-8"
|
||||
)
|
||||
return metadata
|
||||
|
||||
|
||||
def validate_release_artifacts(
|
||||
*,
|
||||
digest_file: Path,
|
||||
image_file: Path,
|
||||
source_revision: str,
|
||||
build_number: str,
|
||||
destination: str,
|
||||
kustomization: Path,
|
||||
output_dir: Path,
|
||||
) -> None:
|
||||
"""Revalidate the exact successful-build evidence without rewriting it."""
|
||||
digest = validate_kaniko_evidence(
|
||||
digest_text=digest_file.read_text(encoding="utf-8"),
|
||||
image_text=image_file.read_text(encoding="utf-8"),
|
||||
destination=destination,
|
||||
)
|
||||
source_revision, build_number = validate_destination(
|
||||
destination, source_revision, build_number
|
||||
)
|
||||
expected_names = {
|
||||
"hermes-agent-image.json",
|
||||
"hermes-image-update.patch",
|
||||
"hermes-kustomization.yaml",
|
||||
}
|
||||
entries = list(output_dir.iterdir())
|
||||
if {entry.name for entry in entries} != expected_names or not all(
|
||||
entry.is_file() and not entry.is_symlink() for entry in entries
|
||||
):
|
||||
raise ValueError("release output must contain exactly three evidence files")
|
||||
source = kustomization.read_text(encoding="utf-8")
|
||||
rendered = render_kustomization(source, digest)
|
||||
relative_name = kustomization.name
|
||||
patch = "".join(
|
||||
difflib.unified_diff(
|
||||
source.splitlines(keepends=True),
|
||||
rendered.splitlines(keepends=True),
|
||||
fromfile=f"a/services/hermes/{relative_name}",
|
||||
tofile=f"b/services/hermes/{relative_name}",
|
||||
)
|
||||
)
|
||||
metadata = {
|
||||
"build_number": build_number,
|
||||
"digest": digest,
|
||||
"flux_image": f"{DEFAULT_IMAGE}@{digest}",
|
||||
"image": DEFAULT_IMAGE,
|
||||
"published_tag": destination,
|
||||
"source_revision": source_revision,
|
||||
}
|
||||
expected = {
|
||||
"hermes-agent-image.json": json.dumps(metadata, indent=2, sort_keys=True) + "\n",
|
||||
"hermes-image-update.patch": patch,
|
||||
"hermes-kustomization.yaml": rendered,
|
||||
}
|
||||
for name, expected_text in expected.items():
|
||||
if (output_dir / name).read_text(encoding="utf-8") != expected_text:
|
||||
raise ValueError(f"release evidence is incomplete or mismatched: {name}")
|
||||
|
||||
|
||||
def _credentials() -> tuple[str, str]:
|
||||
"""Read the masked, runtime-only Jenkins credential environment."""
|
||||
username = os.environ.get("HARBOR_USER", "")
|
||||
password = os.environ.get("HARBOR_PASSWORD", "")
|
||||
if not username or not password:
|
||||
raise RuntimeError("Harbor credentials are unavailable")
|
||||
return username, password
|
||||
|
||||
|
||||
def _common_arguments(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument("--source-revision", required=True)
|
||||
parser.add_argument("--build-number", required=True)
|
||||
parser.add_argument("--destination", required=True)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Fail closed around the unique tag, then verify and render after push."""
|
||||
parser = argparse.ArgumentParser()
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
absent = commands.add_parser("assert-absent")
|
||||
_common_arguments(absent)
|
||||
render = commands.add_parser("render")
|
||||
_common_arguments(render)
|
||||
render.add_argument("--digest-file", required=True, type=Path)
|
||||
render.add_argument("--image-file", required=True, type=Path)
|
||||
render.add_argument("--kustomization", required=True, type=Path)
|
||||
render.add_argument("--output-dir", required=True, type=Path)
|
||||
verify = commands.add_parser("verify-evidence")
|
||||
_common_arguments(verify)
|
||||
verify.add_argument("--digest-file", required=True, type=Path)
|
||||
verify.add_argument("--image-file", required=True, type=Path)
|
||||
verify.add_argument("--kustomization", required=True, type=Path)
|
||||
verify.add_argument("--output-dir", required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
|
||||
validate_destination(args.destination, args.source_revision, args.build_number)
|
||||
if args.command == "verify-evidence":
|
||||
validate_release_artifacts(
|
||||
digest_file=args.digest_file,
|
||||
image_file=args.image_file,
|
||||
source_revision=args.source_revision,
|
||||
build_number=args.build_number,
|
||||
destination=args.destination,
|
||||
kustomization=args.kustomization,
|
||||
output_dir=args.output_dir,
|
||||
)
|
||||
return 0
|
||||
|
||||
username, password = _credentials()
|
||||
if args.command == "assert-absent":
|
||||
verify_immutable_policy(username=username, password=password)
|
||||
assert_tag_absent(args.destination, username=username, password=password)
|
||||
return 0
|
||||
|
||||
digest = validate_kaniko_evidence(
|
||||
digest_text=args.digest_file.read_text(encoding="utf-8"),
|
||||
image_text=args.image_file.read_text(encoding="utf-8"),
|
||||
destination=args.destination,
|
||||
)
|
||||
verify_registry_digest(
|
||||
args.destination, digest, username=username, password=password
|
||||
)
|
||||
write_release_artifacts(
|
||||
digest=digest,
|
||||
source_revision=args.source_revision,
|
||||
build_number=args.build_number,
|
||||
destination=args.destination,
|
||||
kustomization=args.kustomization,
|
||||
output_dir=args.output_dir,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - exercised through main()
|
||||
raise SystemExit(main())
|
||||
@ -93,7 +93,7 @@ def _sonar_issue_from_finding(finding: dict[str, Any]) -> dict[str, Any] | None:
|
||||
start = finding.get("start") if isinstance(finding.get("start"), dict) else {}
|
||||
end = finding.get("end") if isinstance(finding.get("end"), dict) else {}
|
||||
start_line = _line_number(start.get("line") if isinstance(start, dict) else None)
|
||||
# _line_number never returns below its default, so end_line >= start_line.
|
||||
# _line_number floors the end line at start_line, so the range never inverts.
|
||||
end_line = _line_number(end.get("line") if isinstance(end, dict) else None, start_line)
|
||||
return {
|
||||
"engineId": "semgrep",
|
||||
|
||||
@ -15,7 +15,13 @@ spec:
|
||||
kind: GitRepository
|
||||
name: flux-system
|
||||
namespace: flux-system
|
||||
wait: false
|
||||
wait: true
|
||||
timeout: 10m
|
||||
healthChecks:
|
||||
- apiVersion: batch/v1
|
||||
kind: Job
|
||||
name: harbor-hermes-agent-immutability-ensure-1
|
||||
namespace: harbor
|
||||
dependsOn:
|
||||
- name: core
|
||||
- name: longhorn
|
||||
|
||||
@ -0,0 +1,19 @@
|
||||
# clusters/atlas/flux-system/applications/hermes-observer-bindings/kustomization.yaml
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||
kind: Kustomization
|
||||
metadata:
|
||||
name: hermes-observer-bindings
|
||||
namespace: flux-system
|
||||
spec:
|
||||
interval: 10m
|
||||
path: ./services/hermes-observer-bindings
|
||||
prune: true
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
name: flux-system
|
||||
namespace: flux-system
|
||||
wait: true
|
||||
timeout: 5m
|
||||
dependsOn:
|
||||
- name: hermes
|
||||
- name: hermes-observer-rbac
|
||||
@ -0,0 +1,16 @@
|
||||
# clusters/atlas/flux-system/applications/hermes-observer-rbac/kustomization.yaml
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||
kind: Kustomization
|
||||
metadata:
|
||||
name: hermes-observer-rbac
|
||||
namespace: flux-system
|
||||
spec:
|
||||
interval: 10m
|
||||
path: ./services/hermes-observer-rbac
|
||||
prune: true
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
name: flux-system
|
||||
namespace: flux-system
|
||||
wait: true
|
||||
timeout: 5m
|
||||
@ -0,0 +1,19 @@
|
||||
# clusters/atlas/flux-system/applications/hermes-scm-broker-code/kustomization.yaml
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||
kind: Kustomization
|
||||
metadata:
|
||||
name: hermes-scm-broker-code
|
||||
namespace: flux-system
|
||||
spec:
|
||||
interval: 10m
|
||||
path: ./services/hermes/scm-common
|
||||
targetNamespace: hermes-scm
|
||||
prune: true
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
name: flux-system
|
||||
namespace: flux-system
|
||||
wait: true
|
||||
timeout: 5m
|
||||
dependsOn:
|
||||
- name: hermes-scm-namespace
|
||||
@ -0,0 +1,27 @@
|
||||
# clusters/atlas/flux-system/applications/hermes-scm-broker/kustomization.yaml
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||
kind: Kustomization
|
||||
metadata:
|
||||
name: hermes-scm-broker
|
||||
namespace: flux-system
|
||||
spec:
|
||||
interval: 10m
|
||||
path: ./services/hermes-scm-broker
|
||||
targetNamespace: hermes-scm
|
||||
prune: true
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
name: flux-system
|
||||
namespace: flux-system
|
||||
wait: true
|
||||
timeout: 10m
|
||||
healthChecks:
|
||||
- apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: hermes-scm-broker
|
||||
namespace: hermes-scm
|
||||
dependsOn:
|
||||
- name: vault
|
||||
- name: gitea
|
||||
- name: hermes-scm-namespace
|
||||
- name: hermes-scm-broker-code
|
||||
@ -0,0 +1,16 @@
|
||||
# clusters/atlas/flux-system/applications/hermes-scm-namespace/kustomization.yaml
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||
kind: Kustomization
|
||||
metadata:
|
||||
name: hermes-scm-namespace
|
||||
namespace: flux-system
|
||||
spec:
|
||||
interval: 10m
|
||||
path: ./services/hermes-scm-namespace
|
||||
prune: true
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
name: flux-system
|
||||
namespace: flux-system
|
||||
wait: true
|
||||
timeout: 5m
|
||||
@ -60,3 +60,6 @@ spec:
|
||||
- name: keycloak
|
||||
- name: longhorn
|
||||
- name: vault
|
||||
- name: jenkins
|
||||
- name: hermes-scm-broker
|
||||
- name: hermes-observer-rbac
|
||||
|
||||
@ -6,10 +6,9 @@ metadata:
|
||||
namespace: flux-system
|
||||
annotations:
|
||||
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
|
||||
atlas.bstein.dev/suspend-reason: "CI controller changes are applied only during planned maintenance."
|
||||
spec:
|
||||
interval: 10m
|
||||
suspend: true
|
||||
suspend: false
|
||||
path: ./services/jenkins
|
||||
prune: true
|
||||
sourceRef:
|
||||
@ -18,6 +17,8 @@ spec:
|
||||
targetNamespace: jenkins
|
||||
dependsOn:
|
||||
- name: helm
|
||||
- name: harbor
|
||||
- name: vault-hermes-jenkins-token-seed
|
||||
healthChecks:
|
||||
- apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
|
||||
@ -4,6 +4,7 @@ kind: Kustomization
|
||||
resources:
|
||||
- gitea/kustomization.yaml
|
||||
- vault/kustomization.yaml
|
||||
- vault-hermes-jenkins-token-seed/kustomization.yaml
|
||||
- vaultwarden/kustomization.yaml
|
||||
- comms/kustomization.yaml
|
||||
- crypto/kustomization.yaml
|
||||
@ -27,7 +28,12 @@ resources:
|
||||
- jenkins/kustomization.yaml
|
||||
- ai-llm/kustomization.yaml
|
||||
- openclaw/kustomization.yaml
|
||||
- hermes-scm-namespace/kustomization.yaml
|
||||
- hermes-scm-broker-code/kustomization.yaml
|
||||
- hermes/kustomization.yaml
|
||||
- hermes-observer-rbac/kustomization.yaml
|
||||
- hermes-observer-bindings/kustomization.yaml
|
||||
- hermes-scm-broker/kustomization.yaml
|
||||
- hermes-chat/kustomization.yaml
|
||||
- hermes-triage-demo/kustomization.yaml
|
||||
- game-stream/kustomization.yaml
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
# clusters/atlas/flux-system/applications/vault-hermes-jenkins-token-seed/kustomization.yaml
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||
kind: Kustomization
|
||||
metadata:
|
||||
name: vault-hermes-jenkins-token-seed
|
||||
namespace: flux-system
|
||||
annotations:
|
||||
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
|
||||
spec:
|
||||
interval: 10m
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
name: flux-system
|
||||
namespace: flux-system
|
||||
path: ./services/vault-hermes-jenkins-token-seed
|
||||
targetNamespace: vault
|
||||
prune: true
|
||||
wait: true
|
||||
timeout: 5m
|
||||
dependsOn:
|
||||
- name: vault
|
||||
healthChecks:
|
||||
- apiVersion: batch/v1
|
||||
kind: Job
|
||||
name: vault-hermes-jenkins-build-token-seed-2
|
||||
namespace: vault
|
||||
@ -16,6 +16,11 @@ spec:
|
||||
targetNamespace: vault
|
||||
prune: true
|
||||
wait: true
|
||||
healthChecks:
|
||||
- apiVersion: batch/v1
|
||||
kind: Job
|
||||
name: vault-k8s-auth-hermes-10
|
||||
namespace: vault
|
||||
dependsOn:
|
||||
- name: longhorn
|
||||
- name: helm
|
||||
|
||||
@ -15,6 +15,10 @@ RUN apt-get update \
|
||||
# Paid/provider-backed search remains selectable through normal Hermes config.
|
||||
RUN uv pip install --python /opt/hermes/.venv/bin/python ddgs==9.14.4
|
||||
|
||||
ARG HERMES_KANIKO_HEREDOC_COMPAT=0
|
||||
COPY dockerfiles/Dockerfile.hermes-agent /tmp/hermes-agent.Dockerfile
|
||||
COPY dockerfiles/hermes-kaniko-heredoc-runner.py /tmp/hermes-kaniko-heredoc-runner.py
|
||||
|
||||
# Keep dashboard chat sockets tied to the intended React mount and conversation.
|
||||
# A resumed conversation needs a different PTY attachment key from a fresh chat;
|
||||
# reconnects to that same conversation must keep using the same key.
|
||||
@ -252,6 +256,7 @@ for (const [before, after, label] of [
|
||||
}
|
||||
fs.writeFileSync(sidebarPath, sidebar);
|
||||
NODE
|
||||
RUN case "${HERMES_KANIKO_HEREDOC_COMPAT}" in 0) ;; 1) python /tmp/hermes-kaniko-heredoc-runner.py --dockerfile /tmp/hermes-agent.Dockerfile --block-index 1 ;; *) exit 2 ;; esac
|
||||
|
||||
# The upstream OIDC gate authenticates users but deliberately treats the
|
||||
# dashboard as one shared workstation. Allow a deployment to narrow that
|
||||
@ -324,6 +329,7 @@ for before, after, label in (
|
||||
source = source.replace(before, after, 1)
|
||||
path.write_text(source)
|
||||
PY
|
||||
RUN case "${HERMES_KANIKO_HEREDOC_COMPAT}" in 0) ;; 1) python /tmp/hermes-kaniko-heredoc-runner.py --dockerfile /tmp/hermes-agent.Dockerfile --block-index 2 ;; *) exit 2 ;; esac
|
||||
|
||||
# Give trusted plugins a pre-turn routing hook. It runs after fallback runtime
|
||||
# restoration but before Hermes builds its provider-specific system prompt.
|
||||
@ -493,6 +499,7 @@ if oneshot.count(oneshot_before) != 1:
|
||||
)
|
||||
oneshot_path.write_text(oneshot.replace(oneshot_before, oneshot_after, 1))
|
||||
PY
|
||||
RUN case "${HERMES_KANIKO_HEREDOC_COMPAT}" in 0) ;; 1) python /tmp/hermes-kaniko-heredoc-runner.py --dockerfile /tmp/hermes-agent.Dockerfile --block-index 3 ;; *) exit 2 ;; esac
|
||||
|
||||
# Hermes WebUI sends its model/provider/reasoning selection on /v1/runs.
|
||||
# Upstream currently applies only statically declared model_routes there, so
|
||||
@ -628,6 +635,7 @@ runs_source = runs_source.replace(runs_agent_before, runs_agent_after, 1)
|
||||
source = source[:runs_start] + runs_source
|
||||
path.write_text(source)
|
||||
PY
|
||||
RUN case "${HERMES_KANIKO_HEREDOC_COMPAT}" in 0) ;; 1) python /tmp/hermes-kaniko-heredoc-runner.py --dockerfile /tmp/hermes-agent.Dockerfile --block-index 4 ;; *) exit 2 ;; esac
|
||||
|
||||
# ``create_task(initial_status="blocked")`` is an explicit operator park, but
|
||||
# upstream only treats later block_task() events as sticky. Recognize the
|
||||
@ -779,6 +787,7 @@ if cli_source.count(help_before) != 1:
|
||||
)
|
||||
cli_path.write_text(cli_source.replace(help_before, help_after, 1))
|
||||
PY
|
||||
RUN case "${HERMES_KANIKO_HEREDOC_COMPAT}" in 0) ;; 1) python /tmp/hermes-kaniko-heredoc-runner.py --dockerfile /tmp/hermes-agent.Dockerfile --block-index 5 ;; *) exit 2 ;; esac
|
||||
|
||||
COPY dockerfiles/hermes-kanban-blocked-regression.py /tmp/hermes-kanban-blocked-regression.py
|
||||
RUN /opt/hermes/.venv/bin/python /tmp/hermes-kanban-blocked-regression.py \
|
||||
@ -816,6 +825,7 @@ if source.count(before) != 1:
|
||||
)
|
||||
path.write_text(source.replace(before, after, 1))
|
||||
PY
|
||||
RUN case "${HERMES_KANIKO_HEREDOC_COMPAT}" in 0) ;; 1) python /tmp/hermes-kaniko-heredoc-runner.py --dockerfile /tmp/hermes-agent.Dockerfile --block-index 6 ;; *) exit 2 ;; esac
|
||||
|
||||
# Keep the dashboard's account cards aligned with the managed provider lanes.
|
||||
# Switchyard receives Claude Code subscription OAuth through the environment;
|
||||
@ -882,6 +892,7 @@ for before, after, label in replacements:
|
||||
|
||||
path.write_text(source)
|
||||
PY
|
||||
RUN case "${HERMES_KANIKO_HEREDOC_COMPAT}" in 0) ;; 1) python /tmp/hermes-kaniko-heredoc-runner.py --dockerfile /tmp/hermes-agent.Dockerfile --block-index 7 ;; *) exit 2 ;; esac
|
||||
|
||||
# Make parent-linked API workers first-class live dashboard sessions. They stay
|
||||
# active until the API runner closes them, even during a long final model call;
|
||||
@ -975,6 +986,7 @@ source = source.replace(row_before, row_after)
|
||||
|
||||
path.write_text(source)
|
||||
PY
|
||||
RUN case "${HERMES_KANIKO_HEREDOC_COMPAT}" in 0) ;; 1) python /tmp/hermes-kaniko-heredoc-runner.py --dockerfile /tmp/hermes-agent.Dockerfile --block-index 8 ;; *) exit 2 ;; esac
|
||||
|
||||
# Keep the browser terminal reliable across GPU context loss, make durable
|
||||
# worker lineage visible, expose an accessible API-worker transcript, and
|
||||
@ -1543,6 +1555,7 @@ function RootRedirect() {
|
||||
fs.writeFileSync(path, source);
|
||||
}
|
||||
NODE
|
||||
RUN case "${HERMES_KANIKO_HEREDOC_COMPAT}" in 0) ;; 1) python /tmp/hermes-kaniko-heredoc-runner.py --dockerfile /tmp/hermes-agent.Dockerfile --block-index 9 ;; *) exit 2 ;; esac
|
||||
|
||||
COPY dockerfiles/patch-hermes-execution-safety.py /tmp/patch-hermes-execution-safety.py
|
||||
COPY dockerfiles/hermes-execution-safety-regression.py /tmp/hermes-execution-safety-regression.py
|
||||
@ -1576,6 +1589,8 @@ RUN HERMES_CLI_LANE_SOURCE=/tmp/hermes-lane-regression \
|
||||
|
||||
COPY dockerfiles/hermes-session-migrate.py /opt/hermes/bin/hermes-session-migrate
|
||||
|
||||
RUN rm -f /tmp/hermes-agent.Dockerfile /tmp/hermes-kaniko-heredoc-runner.py
|
||||
|
||||
RUN cd /opt/hermes/web \
|
||||
&& npm run build \
|
||||
&& grep -Fq 'await api.getSessions(1, 0' src/pages/ChatPage.tsx \
|
||||
|
||||
@ -5,6 +5,8 @@
|
||||
!dockerfiles/hermes-public-extract/**
|
||||
!dockerfiles/hermes-session-activity-panel.tsx
|
||||
!dockerfiles/hermes-session-migrate.py
|
||||
!dockerfiles/Dockerfile.hermes-agent
|
||||
!dockerfiles/hermes-kaniko-heredoc-runner.py
|
||||
!dockerfiles/patch-hermes-execution-safety.py
|
||||
!dockerfiles/hermes-execution-safety-regression.py
|
||||
!dockerfiles/hermes_execution_patch_support.py
|
||||
|
||||
146
dockerfiles/hermes-kaniko-heredoc-runner.py
Normal file
146
dockerfiles/hermes-kaniko-heredoc-runner.py
Normal file
@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Replay the reviewed Hermes Dockerfile heredocs for pinned Kaniko v1.23.2.
|
||||
|
||||
Kaniko v1.23.2 parses shell heredoc bodies into ``RunCommand.Files`` but its RUN
|
||||
implementation executes only ``CmdLine``. The interpreters therefore receive
|
||||
empty stdin and exit successfully. Docker and BuildKit execute these blocks
|
||||
normally, so this compatibility runner is enabled only by the Kaniko pipeline.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MAX_DOCKERFILE_BYTES = 2_000_000
|
||||
BLOCKS = (
|
||||
("RUN node <<'NODE'", "NODE", ("node",)),
|
||||
("RUN python - <<'PY'", "PY", ("python", "-")),
|
||||
)
|
||||
EXPECTED_COMMANDS = ("node", *("python",) * 7, "node")
|
||||
PARSER_DIRECTIVE = re.compile(
|
||||
r"^\s*#\s*([a-z]+)\s*=\s*(.*?)\s*$", re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
def _escape_character(lines: list[str]) -> str:
|
||||
"""Return the one Dockerfile parser escape directive, or its default."""
|
||||
escape = "\\"
|
||||
directives: set[str] = set()
|
||||
for line in lines:
|
||||
if not line.strip():
|
||||
break
|
||||
match = PARSER_DIRECTIVE.fullmatch(line)
|
||||
if not match:
|
||||
break
|
||||
name, value = match.group(1).lower(), match.group(2)
|
||||
if name in directives:
|
||||
raise ValueError(f"multiple Dockerfile {name} directives")
|
||||
directives.add(name)
|
||||
if name == "escape":
|
||||
if value not in {"\\", "`"}:
|
||||
raise ValueError("unsupported Dockerfile escape directive")
|
||||
escape = value
|
||||
return escape
|
||||
|
||||
|
||||
def _continued(line: str, escape: str) -> bool:
|
||||
"""Match Docker's unescaped continuation marker at physical line end."""
|
||||
stripped = line.rstrip()
|
||||
count = len(stripped) - len(stripped.rstrip(escape))
|
||||
return count % 2 == 1
|
||||
|
||||
|
||||
def _logical_instruction(
|
||||
lines: list[str], index: int, escape: str
|
||||
) -> tuple[str, int]:
|
||||
"""Normalize escape-newline pairs, including split opcodes/operators."""
|
||||
logical = lines[index]
|
||||
while _continued(logical.split("\n")[-1], escape):
|
||||
logical = logical.rstrip()
|
||||
logical = logical[:-1]
|
||||
index += 1
|
||||
while index < len(lines) and lines[index].lstrip().startswith("#"):
|
||||
index += 1
|
||||
if index >= len(lines) or not lines[index].strip():
|
||||
raise ValueError("unterminated Dockerfile line continuation")
|
||||
logical += lines[index].lstrip()
|
||||
return logical, index
|
||||
|
||||
|
||||
def extract_blocks(source: str) -> list[tuple[tuple[str, ...], str]]:
|
||||
"""Inventory every RUN heredoc, then return only the reviewed contract."""
|
||||
markers = {start: (end, command) for start, end, command in BLOCKS}
|
||||
lines = source.splitlines()
|
||||
escape = _escape_character(lines)
|
||||
blocks: list[tuple[tuple[str, ...], str]] = []
|
||||
index = 0
|
||||
while index < len(lines):
|
||||
marker = markers.get(lines[index])
|
||||
if marker is None:
|
||||
if not lines[index].strip() or lines[index].lstrip().startswith("#"):
|
||||
index += 1
|
||||
continue
|
||||
logical, index = _logical_instruction(lines, index, escape)
|
||||
stripped = logical.lstrip()
|
||||
if (
|
||||
stripped[:3].upper() == "RUN"
|
||||
and (len(stripped) == 3 or stripped[3].isspace())
|
||||
and "<<" in stripped
|
||||
):
|
||||
raise ValueError(
|
||||
"unsupported RUN heredoc outside the exact reviewed contract"
|
||||
)
|
||||
index += 1
|
||||
continue
|
||||
end, command = marker
|
||||
body_start = index + 1
|
||||
index = body_start
|
||||
while index < len(lines) and lines[index] != end:
|
||||
index += 1
|
||||
if index == len(lines):
|
||||
raise ValueError(f"unterminated {command[0]} heredoc")
|
||||
body = "\n".join(lines[body_start:index]) + "\n"
|
||||
if not body.strip():
|
||||
raise ValueError(f"empty {command[0]} heredoc")
|
||||
blocks.append((command, body))
|
||||
index += 1
|
||||
|
||||
commands = tuple(command[0] for command, _body in blocks)
|
||||
if commands != EXPECTED_COMMANDS:
|
||||
raise ValueError(
|
||||
"Hermes Dockerfile heredoc contract changed: "
|
||||
f"expected {EXPECTED_COMMANDS!r}, received {commands!r}"
|
||||
)
|
||||
return blocks
|
||||
|
||||
|
||||
def replay(dockerfile: Path, block_index: int) -> None:
|
||||
"""Execute one exact heredoc after inventorying the complete Dockerfile."""
|
||||
size = dockerfile.stat().st_size
|
||||
if size < 1 or size > MAX_DOCKERFILE_BYTES:
|
||||
raise ValueError("Hermes Dockerfile size is outside the reviewed boundary")
|
||||
source = dockerfile.read_text(encoding="utf-8")
|
||||
blocks = extract_blocks(source)
|
||||
if block_index < 1 or block_index > len(blocks):
|
||||
raise ValueError("heredoc block index is outside the reviewed contract")
|
||||
command, body = blocks[block_index - 1]
|
||||
print(f"replaying reviewed Dockerfile heredoc {block_index}/{len(blocks)}")
|
||||
subprocess.run(command, input=body, text=True, check=True)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Parse the one explicit Dockerfile path and replay its reviewed patches."""
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--dockerfile", required=True, type=Path)
|
||||
parser.add_argument("--block-index", required=True, type=int)
|
||||
args = parser.parse_args()
|
||||
replay(args.dockerfile, args.block_index)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@ -232,6 +232,24 @@ def test_retry_db_connect_reraises_final_error(monkeypatch):
|
||||
sync.retry_db_connect(attempts=1)
|
||||
|
||||
|
||||
def test_retry_helpers_return_none_without_attempts(monkeypatch):
|
||||
sync = load_sync_module(monkeypatch)
|
||||
|
||||
assert sync.retry_request("request", lambda: "never", attempts=0) is None
|
||||
assert sync.retry_db_connect(attempts=0) is None
|
||||
|
||||
|
||||
def test_kc_update_attributes_replaces_non_dict_attributes(monkeypatch):
|
||||
sync = load_sync_module(monkeypatch)
|
||||
current_resp = _FakeResponse({"attributes": "not-a-dict"})
|
||||
ok_resp = _FakeResponse({"attributes": {"mailu_app_password": ["abc"]}})
|
||||
sync.SESSION = _FakeSession(_FakeResponse({}), [current_resp, ok_resp])
|
||||
|
||||
sync.kc_update_attributes("token", {"id": "u1", "username": "u1"}, {"mailu_app_password": "abc"})
|
||||
|
||||
assert sync.SESSION.put_called
|
||||
|
||||
|
||||
def test_ensure_mailu_user_skips_foreign_domain(monkeypatch):
|
||||
sync = load_sync_module(monkeypatch)
|
||||
executed = []
|
||||
@ -405,3 +423,62 @@ def test_main_generates_password_and_upserts(monkeypatch):
|
||||
# Only mail-enabled users (or legacy users with a mailbox) are synced and backfilled.
|
||||
assert len(updated) == 3
|
||||
assert conns and len(conns[0]._cursor.executions) == 3
|
||||
|
||||
|
||||
def test_main_skips_disabled_users_and_provisioned_users_without_update(monkeypatch):
|
||||
sync = load_sync_module(monkeypatch)
|
||||
monkeypatch.setattr(sync.bcrypt_sha256, "hash", lambda password: f"hash:{password}")
|
||||
users = [
|
||||
{
|
||||
"id": "u1",
|
||||
"username": "disabled",
|
||||
"email": "disabled@example.com",
|
||||
"enabled": False,
|
||||
"attributes": {"mailu_enabled": ["true"]},
|
||||
},
|
||||
{
|
||||
"id": "u2",
|
||||
"username": "settled",
|
||||
"email": "settled@example.com",
|
||||
"attributes": {
|
||||
"mailu_enabled": ["true"],
|
||||
"mailu_email": ["settled@example.com"],
|
||||
"mailu_app_password": ["existing"],
|
||||
},
|
||||
},
|
||||
]
|
||||
updated = []
|
||||
|
||||
class _Cursor:
|
||||
def __init__(self):
|
||||
self.executions = []
|
||||
|
||||
def execute(self, sql, params):
|
||||
self.executions.append(params)
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
class _Conn:
|
||||
def __init__(self):
|
||||
self.autocommit = False
|
||||
self._cursor = _Cursor()
|
||||
|
||||
def cursor(self, cursor_factory=None):
|
||||
return self._cursor
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
conn = _Conn()
|
||||
monkeypatch.setattr(sync, "get_kc_token", lambda: "tok")
|
||||
monkeypatch.setattr(sync, "kc_get_users", lambda token: users)
|
||||
monkeypatch.setattr(sync, "kc_update_attributes", lambda token, user, attrs: updated.append(user["id"]))
|
||||
monkeypatch.setattr(sync.psycopg2, "connect", lambda **kwargs: conn)
|
||||
|
||||
sync.main()
|
||||
|
||||
# The already-provisioned user is synced without a Keycloak write; the
|
||||
# disabled user never reaches the mailbox upsert.
|
||||
assert updated == []
|
||||
assert len(conn._cursor.executions) == 1
|
||||
|
||||
@ -127,6 +127,18 @@ def test_listener_post_wait_keeps_running_request_successful(monkeypatch):
|
||||
assert handler.responses == [200]
|
||||
|
||||
|
||||
def test_listener_post_treats_non_object_json_as_plain_trigger(monkeypatch):
|
||||
listener = load_listener_module(monkeypatch)
|
||||
called = []
|
||||
monkeypatch.setattr(listener, "_trigger_sync_async", lambda force=False: called.append(force) or True)
|
||||
handler = _handler_for(listener, "[1, 2]")
|
||||
|
||||
handler.do_POST()
|
||||
|
||||
assert called == [False]
|
||||
assert handler.responses == [202]
|
||||
|
||||
|
||||
def test_listener_log_message_is_quiet(monkeypatch):
|
||||
listener = load_listener_module(monkeypatch)
|
||||
handler = listener.Handler.__new__(listener.Handler)
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: gitea-atlas-identity-bootstrap-3
|
||||
name: gitea-atlas-identity-bootstrap-5
|
||||
namespace: gitea
|
||||
labels:
|
||||
app.kubernetes.io/name: gitea
|
||||
@ -34,6 +34,20 @@ spec:
|
||||
nodeSelector:
|
||||
node-role.kubernetes.io/worker: "true"
|
||||
hardware: rpi5
|
||||
initContainers:
|
||||
- name: python-runtime
|
||||
image: python@sha256:6d43704baacd1bfbe7c295d7f13079d5d8104ed33568873133f8fc69980419df
|
||||
command: [/bin/sh, -ec]
|
||||
args: ["cp -R /usr/local/. /python/"]
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities: {drop: [ALL]}
|
||||
readOnlyRootFilesystem: true
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
runAsGroup: 1000
|
||||
volumeMounts:
|
||||
- {name: python-runtime, mountPath: /python}
|
||||
containers:
|
||||
- name: bootstrap
|
||||
image: gitea/gitea:1.23
|
||||
@ -80,6 +94,7 @@ spec:
|
||||
- name: bootstrap
|
||||
mountPath: /opt/bootstrap
|
||||
readOnly: true
|
||||
- {name: python-runtime, mountPath: /opt/python, readOnly: true}
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
@ -88,3 +103,6 @@ spec:
|
||||
configMap:
|
||||
name: gitea-atlas-identity-bootstrap
|
||||
defaultMode: 0555
|
||||
- name: python-runtime
|
||||
emptyDir:
|
||||
sizeLimit: 128Mi
|
||||
|
||||
@ -15,6 +15,7 @@ configMapGenerator:
|
||||
- name: gitea-atlas-identity-bootstrap
|
||||
files:
|
||||
- gitea_atlas_identity_ensure.sh=scripts/gitea_atlas_identity_ensure.sh
|
||||
- gitea_branch_protection_check.py=scripts/gitea_branch_protection_check.py
|
||||
|
||||
generatorOptions:
|
||||
disableNameSuffixHash: true
|
||||
|
||||
@ -19,6 +19,7 @@ source_owner="${GITEA_SOURCE_OWNER:-bstein}"
|
||||
managed_repositories="${GITEA_MANAGED_REPOSITORIES:-hermes-code-demo cassandra soteria pegasus metis ananke ariadne typhon titan-iac}"
|
||||
contributor_team=Contributors
|
||||
contributor_units='["repo.actions","repo.packages","repo.code","repo.issues","repo.wiki","repo.pulls","repo.releases","repo.projects"]'
|
||||
protected_reviewer=bstein
|
||||
|
||||
die() {
|
||||
echo "Gitea Atlas identity bootstrap failed: $*" >&2
|
||||
@ -272,6 +273,46 @@ transfer_managed_repositories() {
|
||||
trap - EXIT HUP INT TERM
|
||||
}
|
||||
|
||||
ensure_default_branch_protection() {
|
||||
repository=$1
|
||||
repo_file=$(mktemp)
|
||||
protections_file=$(mktemp)
|
||||
response_file=$(mktemp)
|
||||
|
||||
status=$(api_request GET "/repos/${organization}/${repository}" '' "${repo_file}")
|
||||
expect_status "${status}" 200 "read ${repository} metadata for branch protection"
|
||||
default_branch=$(sed -n 's/.*"default_branch":"\([^"]*\)".*/\1/p' "${repo_file}")
|
||||
case "${default_branch}" in
|
||||
main|master) ;;
|
||||
*) die "${repository} has unsupported default branch ${default_branch:-missing}" ;;
|
||||
esac
|
||||
|
||||
status=$(api_request GET "/repos/${organization}/${repository}/branch_protections" '' "${protections_file}")
|
||||
expect_status "${status}" 200 "list ${repository} branch protections"
|
||||
if ! protection_state=$(
|
||||
/opt/python/bin/python3 /opt/bootstrap/gitea_branch_protection_check.py \
|
||||
"${protections_file}" "${default_branch}" "${protected_reviewer}"
|
||||
); then
|
||||
die "${repository} ${default_branch} protection differs from the human-review policy"
|
||||
fi
|
||||
|
||||
if [ "${protection_state}" = ABSENT ]; then
|
||||
payload="{\"rule_name\":\"${default_branch}\",\"enable_push\":true,\"enable_push_whitelist\":true,\"push_whitelist_usernames\":[\"${protected_reviewer}\"],\"push_whitelist_teams\":[],\"push_whitelist_deploy_keys\":false,\"enable_force_push\":false,\"enable_force_push_allowlist\":false,\"force_push_allowlist_usernames\":[],\"force_push_allowlist_teams\":[],\"force_push_allowlist_deploy_keys\":false,\"enable_merge_whitelist\":true,\"merge_whitelist_usernames\":[\"${protected_reviewer}\"],\"merge_whitelist_teams\":[],\"enable_approvals_whitelist\":true,\"approvals_whitelist_username\":[\"${protected_reviewer}\"],\"approvals_whitelist_teams\":[],\"required_approvals\":1,\"block_on_rejected_reviews\":true,\"block_on_outdated_branch\":true,\"dismiss_stale_approvals\":true,\"block_admin_merge_override\":true}"
|
||||
status=$(api_request POST "/repos/${organization}/${repository}/branch_protections" "${payload}" "${response_file}")
|
||||
expect_status "${status}" 201 "protect ${repository} ${default_branch}"
|
||||
status=$(api_request GET "/repos/${organization}/${repository}/branch_protections" '' "${protections_file}")
|
||||
expect_status "${status}" 200 "read back ${repository} branch protections"
|
||||
protection_state=$(
|
||||
/opt/python/bin/python3 /opt/bootstrap/gitea_branch_protection_check.py \
|
||||
"${protections_file}" "${default_branch}" "${protected_reviewer}"
|
||||
) || die "${repository} ${default_branch} protection differs from the human-review policy"
|
||||
fi
|
||||
[ "${protection_state}" = PRESENT ] || \
|
||||
die "${repository} ${default_branch} protection did not become effective"
|
||||
|
||||
rm -f "${repo_file}" "${protections_file}" "${response_file}"
|
||||
}
|
||||
|
||||
wait_for_gitea
|
||||
ensure_user "${reconciler_user}" "${reconciler_email}" true
|
||||
ensure_user "${hermes_user}" "${hermes_email}" false
|
||||
@ -318,6 +359,10 @@ expect_status "${status}" 204 "add Hermes to Atlas contributors"
|
||||
|
||||
transfer_managed_repositories
|
||||
|
||||
for repository in ${managed_repositories}; do
|
||||
ensure_default_branch_protection "${repository}"
|
||||
done
|
||||
|
||||
vault_write gitea/atlas-reconciler "{\"data\":{\"username\":\"${reconciler_user}\",\"token\":\"${reconciler_token}\",\"base_url\":\"${public_url}\"}}"
|
||||
vault_write hermes/developer-gitea "{\"data\":{\"username\":\"${hermes_user}\",\"email\":\"${hermes_email}\",\"token\":\"${hermes_token}\",\"base_url\":\"${public_url}\"}}"
|
||||
|
||||
|
||||
146
services/gitea/scripts/gitea_branch_protection_check.py
Normal file
146
services/gitea/scripts/gitea_branch_protection_check.py
Normal file
@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate the first effective Gitea protection for an Atlas primary branch."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
MAX_INPUT = 1024 * 1024
|
||||
MAX_RULES = 100
|
||||
MAX_RULE_NAME = 255
|
||||
SPECIAL = frozenset("*?\\[]{}")
|
||||
|
||||
|
||||
class PolicyError(RuntimeError):
|
||||
"""The protection response is ambiguous or violates human review policy."""
|
||||
|
||||
|
||||
def _read_bounded(path: Path) -> bytes:
|
||||
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
||||
try:
|
||||
metadata = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > MAX_INPUT:
|
||||
raise PolicyError("branch protection response exceeds the safe limit")
|
||||
value = os.read(descriptor, MAX_INPUT + 1)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
if len(value) != metadata.st_size:
|
||||
raise PolicyError("branch protection response changed while reading")
|
||||
return value
|
||||
|
||||
|
||||
def _is_plain(pattern: str) -> bool:
|
||||
return not any(character in SPECIAL for character in pattern)
|
||||
|
||||
|
||||
def _literal_rule(pattern: str) -> str:
|
||||
"""Return one exact rule name or reject Gitea glob interpretation.
|
||||
|
||||
Gitea 1.23.8 delegates matching to gobwas/glob. Reimplementing that
|
||||
grammar and its error quoting rules here would create a second policy
|
||||
engine. The reconciler instead accepts only plain ASCII literals. This
|
||||
deliberately rejects braces, ranges, escapes, malformed globs, and even
|
||||
special rules that appear unrelated: an operator must remove ambiguity
|
||||
before this job may attest the primary-branch boundary.
|
||||
"""
|
||||
if not pattern.isascii() or not 1 <= len(pattern) <= MAX_RULE_NAME:
|
||||
raise PolicyError("branch protection rule name is invalid")
|
||||
if not _is_plain(pattern):
|
||||
raise PolicyError("glob branch protection rules require human review")
|
||||
return pattern
|
||||
|
||||
|
||||
def _created(value: object) -> datetime.datetime:
|
||||
if not isinstance(value, str) or len(value) > 64:
|
||||
raise PolicyError("branch protection creation time is invalid")
|
||||
try:
|
||||
result = datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise PolicyError("branch protection creation time is invalid") from exc
|
||||
if result.tzinfo is None:
|
||||
raise PolicyError("branch protection creation time is invalid")
|
||||
return result
|
||||
|
||||
|
||||
def _required(reviewer: str) -> dict[str, object]:
|
||||
return {
|
||||
"enable_push": True,
|
||||
"enable_push_whitelist": True,
|
||||
"push_whitelist_usernames": [reviewer],
|
||||
"push_whitelist_deploy_keys": False,
|
||||
"enable_force_push": False,
|
||||
"enable_merge_whitelist": True,
|
||||
"merge_whitelist_usernames": [reviewer],
|
||||
"enable_approvals_whitelist": True,
|
||||
"approvals_whitelist_username": [reviewer],
|
||||
"required_approvals": 1,
|
||||
"block_on_rejected_reviews": True,
|
||||
"block_on_outdated_branch": True,
|
||||
"dismiss_stale_approvals": True,
|
||||
"block_admin_merge_override": True,
|
||||
}
|
||||
|
||||
|
||||
def evaluate(value: bytes, branch: str, reviewer: str) -> str:
|
||||
"""Return PRESENT/ABSENT or fail on ambiguous/effective policy drift."""
|
||||
if len(value) > MAX_INPUT or branch not in {"main", "master"}:
|
||||
raise PolicyError("branch protection input is invalid")
|
||||
data = json.loads(value)
|
||||
if not isinstance(data, list) or len(data) > MAX_RULES:
|
||||
raise PolicyError("branch protection response is invalid")
|
||||
ordered: list[tuple[int, datetime.datetime, int, dict[str, object]]] = []
|
||||
for index, raw in enumerate(data):
|
||||
if not isinstance(raw, dict):
|
||||
raise PolicyError("branch protection entry is invalid")
|
||||
priority = raw.get("priority")
|
||||
rule_name = raw.get("rule_name")
|
||||
if (
|
||||
not isinstance(priority, int)
|
||||
or isinstance(priority, bool)
|
||||
or not 0 <= priority <= 1_000_000
|
||||
or not isinstance(rule_name, str)
|
||||
):
|
||||
raise PolicyError("branch protection priority is ambiguous")
|
||||
created = _created(raw.get("created_at"))
|
||||
literal = _literal_rule(rule_name)
|
||||
if literal == branch:
|
||||
# With every rule constrained to a plain literal, Gitea's glob
|
||||
# comparator cannot affect this result. Preserve API ordering:
|
||||
# priority first (including legacy zero), creation time, then the
|
||||
# stable response order for an otherwise exact tie.
|
||||
ordered.append((priority, created, index, raw))
|
||||
if not ordered:
|
||||
return "ABSENT"
|
||||
if len(ordered) != 1:
|
||||
raise PolicyError("duplicate primary-branch protections are ambiguous")
|
||||
effective = ordered[0][3]
|
||||
for key, expected in _required(reviewer).items():
|
||||
if effective.get(key) != expected:
|
||||
raise PolicyError(
|
||||
f"effective {branch} protection differs from human-review policy"
|
||||
)
|
||||
return "PRESENT"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("response", type=Path)
|
||||
parser.add_argument("branch", choices=("main", "master"))
|
||||
parser.add_argument("reviewer")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
value = _read_bounded(args.response)
|
||||
print(evaluate(value, args.branch, args.reviewer))
|
||||
except (OSError, json.JSONDecodeError, PolicyError) as exc:
|
||||
print(f"branch protection check failed: {exc}", file=__import__("sys").stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
79
services/harbor/hermes-agent-immutability-job.yaml
Normal file
79
services/harbor/hermes-agent-immutability-job.yaml
Normal file
@ -0,0 +1,79 @@
|
||||
# services/harbor/hermes-agent-immutability-job.yaml
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: harbor-hermes-agent-immutability-ensure-1
|
||||
namespace: harbor
|
||||
spec:
|
||||
backoffLimit: 2
|
||||
activeDeadlineSeconds: 600
|
||||
template:
|
||||
metadata:
|
||||
annotations:
|
||||
vault.hashicorp.com/agent-inject: "true"
|
||||
vault.hashicorp.com/agent-pre-populate-only: "true"
|
||||
vault.hashicorp.com/agent-run-as-user: "65532"
|
||||
vault.hashicorp.com/agent-run-as-group: "65532"
|
||||
vault.hashicorp.com/role: harbor-policy-bootstrap
|
||||
vault.hashicorp.com/agent-inject-secret-harbor-admin-password: kv/data/atlas/harbor/harbor-core
|
||||
vault.hashicorp.com/agent-inject-template-harbor-admin-password: |
|
||||
{{- with secret "kv/data/atlas/harbor/harbor-core" -}}
|
||||
{{ .Data.data.harbor_admin_password }}
|
||||
{{- end -}}
|
||||
spec:
|
||||
serviceAccountName: harbor-policy-bootstrap
|
||||
enableServiceLinks: false
|
||||
restartPolicy: Never
|
||||
nodeSelector:
|
||||
hardware: rpi5
|
||||
kubernetes.io/arch: arm64
|
||||
node-role.kubernetes.io/worker: "true"
|
||||
affinity:
|
||||
nodeAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: kubernetes.io/hostname
|
||||
operator: NotIn
|
||||
values: [titan-04, titan-14, titan-18, titan-19, titan-24]
|
||||
securityContext:
|
||||
fsGroup: 65532
|
||||
fsGroupChangePolicy: OnRootMismatch
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: ensure
|
||||
image: docker.io/library/python@sha256:efcdfa6a6b2fd2afb9c7dfa9a5b288a6f68338b5cfdebe6b637d986067d85757
|
||||
imagePullPolicy: IfNotPresent
|
||||
command: [python3, /scripts/harbor_hermes_agent_immutability_ensure.py]
|
||||
env:
|
||||
- name: HARBOR_API_ORIGIN
|
||||
value: https://registry.bstein.dev/api/v2.0
|
||||
- name: HARBOR_ADMIN_PASSWORD_FILE
|
||||
value: /vault/secrets/harbor-admin-password
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
readOnlyRootFilesystem: true
|
||||
runAsGroup: 65532
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65532
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
volumeMounts:
|
||||
- name: scripts
|
||||
mountPath: /scripts
|
||||
readOnly: true
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
resources:
|
||||
requests: {cpu: 25m, memory: 32Mi}
|
||||
limits: {cpu: 250m, memory: 128Mi}
|
||||
volumes:
|
||||
- name: scripts
|
||||
configMap:
|
||||
name: harbor-hermes-agent-immutability-script
|
||||
defaultMode: 0555
|
||||
- name: tmp
|
||||
emptyDir: {}
|
||||
@ -12,9 +12,14 @@ resources:
|
||||
- certificate.yaml
|
||||
- helmrelease.yaml
|
||||
- vault-sync-deployment.yaml
|
||||
- policy-bootstrap-serviceaccount.yaml
|
||||
- hermes-agent-immutability-job.yaml
|
||||
- bootstrap-jobs/cassandra-registry-ensure-job.yaml
|
||||
- image.yaml
|
||||
configMapGenerator:
|
||||
- name: harbor-vault-entrypoint
|
||||
files:
|
||||
- scripts/vault-entrypoint.sh
|
||||
- name: harbor-hermes-agent-immutability-script
|
||||
files:
|
||||
- harbor_hermes_agent_immutability_ensure.py=scripts/harbor_hermes_agent_immutability_ensure.py
|
||||
|
||||
7
services/harbor/policy-bootstrap-serviceaccount.yaml
Normal file
7
services/harbor/policy-bootstrap-serviceaccount.yaml
Normal file
@ -0,0 +1,7 @@
|
||||
# services/harbor/policy-bootstrap-serviceaccount.yaml
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: harbor-policy-bootstrap
|
||||
namespace: harbor
|
||||
automountServiceAccountToken: true
|
||||
@ -0,0 +1,378 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create and verify the narrowly scoped Hermes agent immutable-tag rule."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
PROJECT = "bstein"
|
||||
REPOSITORY_PATTERN = "hermes-agent"
|
||||
TAG_PATTERN = "git-*-build-*"
|
||||
PUBLISH_ROBOT = "robot$jenkins-pipelines"
|
||||
EXPECTED_ORIGIN = "https://registry.bstein.dev/api/v2.0"
|
||||
MAX_RESPONSE = 1_048_576
|
||||
TRANSIENT_STATUSES = {429, 502, 503, 504}
|
||||
EXPECTED_RULE = {
|
||||
"disabled": False,
|
||||
"action": "immutable",
|
||||
"template": "immutable_template",
|
||||
"tag_selectors": [
|
||||
{
|
||||
"kind": "doublestar",
|
||||
"decoration": "matches",
|
||||
"pattern": TAG_PATTERN,
|
||||
}
|
||||
],
|
||||
"scope_selectors": {
|
||||
"repository": [
|
||||
{
|
||||
"kind": "doublestar",
|
||||
"decoration": "repoMatches",
|
||||
"pattern": REPOSITORY_PATTERN,
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
"""Prevent Basic credentials from following an unexpected redirect."""
|
||||
|
||||
def redirect_request(self, _request, _file, _code, _message, _headers, _url):
|
||||
return None
|
||||
|
||||
|
||||
class HarborUnavailable(RuntimeError):
|
||||
"""Harbor is not ready yet, rather than returning a policy decision."""
|
||||
|
||||
|
||||
def normalized_rule(rule: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return only the immutable contract fields Harbor must preserve."""
|
||||
return {
|
||||
"disabled": bool(rule.get("disabled", False)),
|
||||
"action": rule.get("action"),
|
||||
"template": rule.get("template"),
|
||||
"tag_selectors": [
|
||||
{
|
||||
"kind": selector.get("kind"),
|
||||
"decoration": selector.get("decoration"),
|
||||
"pattern": selector.get("pattern"),
|
||||
}
|
||||
for selector in rule.get("tag_selectors") or []
|
||||
if isinstance(selector, dict)
|
||||
],
|
||||
"scope_selectors": {
|
||||
"repository": [
|
||||
{
|
||||
"kind": selector.get("kind"),
|
||||
"decoration": selector.get("decoration"),
|
||||
"pattern": selector.get("pattern"),
|
||||
}
|
||||
for selector in (rule.get("scope_selectors") or {}).get(
|
||||
"repository", []
|
||||
)
|
||||
if isinstance(selector, dict)
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def targets_hermes_builds(rule: dict[str, Any]) -> bool:
|
||||
"""Detect a rule that claims this exact repository and tag selector."""
|
||||
normalized = normalized_rule(rule)
|
||||
return (
|
||||
normalized["tag_selectors"] == EXPECTED_RULE["tag_selectors"]
|
||||
and normalized["scope_selectors"] == EXPECTED_RULE["scope_selectors"]
|
||||
)
|
||||
|
||||
|
||||
class HarborClient:
|
||||
"""Bounded same-origin client for Harbor's immutable-tag API."""
|
||||
|
||||
def __init__(self, origin: str, username: str, password: str) -> None:
|
||||
normalized_origin = origin.rstrip("/")
|
||||
if normalized_origin != EXPECTED_ORIGIN:
|
||||
raise ValueError("Harbor API origin is not the pinned production API")
|
||||
self.origin = normalized_origin
|
||||
token = base64.b64encode(f"{username}:{password}".encode()).decode("ascii")
|
||||
self.headers = {"Authorization": f"Basic {token}"}
|
||||
self.opener = urllib.request.build_opener(NoRedirect())
|
||||
|
||||
def request(
|
||||
self, method: str, path: str, payload: dict[str, Any] | None = None
|
||||
) -> tuple[int, bytes, dict[str, str]]:
|
||||
"""Issue one request, returning even non-2xx responses for strict checks."""
|
||||
data = None
|
||||
headers = dict(self.headers)
|
||||
if payload is not None:
|
||||
data = json.dumps(payload, separators=(",", ":")).encode()
|
||||
headers["Content-Type"] = "application/json"
|
||||
request = urllib.request.Request(
|
||||
f"{self.origin}{path}", data=data, headers=headers, method=method
|
||||
)
|
||||
try:
|
||||
response = self.opener.open(request, timeout=20)
|
||||
except urllib.error.HTTPError as exc:
|
||||
response = exc
|
||||
except (urllib.error.URLError, TimeoutError) as exc:
|
||||
raise HarborUnavailable("Harbor policy API is unavailable") from exc
|
||||
with response:
|
||||
body = response.read(MAX_RESPONSE + 1)
|
||||
if len(body) > MAX_RESPONSE:
|
||||
raise RuntimeError("Harbor response exceeded the size limit")
|
||||
return int(response.status), body, dict(response.headers)
|
||||
|
||||
|
||||
def list_rules(client: HarborClient) -> list[dict[str, Any]]:
|
||||
"""Read and validate the complete small rule set for the project."""
|
||||
path = f"/projects/{PROJECT}/immutabletagrules?page=1&page_size=100"
|
||||
status, body, headers = client.request("GET", path)
|
||||
if status in TRANSIENT_STATUSES:
|
||||
raise HarborUnavailable(f"Harbor immutable rule list returned HTTP {status}")
|
||||
if status != 200:
|
||||
raise RuntimeError(f"Harbor immutable rule list returned HTTP {status}")
|
||||
values = _json_list(body, "immutable rule")
|
||||
_require_complete_page(values, headers, "immutable rule")
|
||||
return values
|
||||
|
||||
|
||||
def _json_list(body: bytes, label: str) -> list[dict[str, Any]]:
|
||||
"""Decode one bounded Harbor list without accepting a partial shape."""
|
||||
try:
|
||||
value = json.loads(body.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise RuntimeError(f"Harbor returned invalid {label} JSON") from exc
|
||||
if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
|
||||
raise RuntimeError(f"Harbor {label} list has an invalid shape")
|
||||
return value
|
||||
|
||||
|
||||
def _require_complete_page(
|
||||
values: list[dict[str, Any]], headers: dict[str, str], label: str
|
||||
) -> None:
|
||||
"""Reject a truncated first page or a proxy that strips count evidence."""
|
||||
raw_total = next(
|
||||
(value for key, value in headers.items() if key.lower() == "x-total-count"),
|
||||
None,
|
||||
)
|
||||
if raw_total is None or not str(raw_total).isdecimal():
|
||||
raise RuntimeError(f"Harbor {label} list omitted a valid total count")
|
||||
if int(raw_total) != len(values):
|
||||
raise RuntimeError(f"Harbor {label} list was truncated")
|
||||
|
||||
|
||||
def _allowed(access: dict[str, Any], resource: str, action: str) -> bool:
|
||||
"""Match one positive robot permission, accepting Harbor's default effect."""
|
||||
return (
|
||||
access.get("resource") == resource
|
||||
and access.get("action") == action
|
||||
and access.get("effect") in (None, "", "allow")
|
||||
)
|
||||
|
||||
|
||||
def _publisher_scope(robot: dict[str, Any]) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
||||
"""Return the existing bstein scope after validating its push boundary."""
|
||||
permissions = robot.get("permissions")
|
||||
if not isinstance(permissions, list) or not all(
|
||||
isinstance(item, dict) for item in permissions
|
||||
):
|
||||
raise RuntimeError("Jenkins Harbor robot permissions have an invalid shape")
|
||||
matches = [
|
||||
item
|
||||
for item in permissions
|
||||
if item.get("kind") == "project" and item.get("namespace") == PROJECT
|
||||
]
|
||||
if len(matches) != 1:
|
||||
raise RuntimeError("Jenkins Harbor robot must have one existing bstein scope")
|
||||
access = matches[0].get("access")
|
||||
if not isinstance(access, list) or not all(isinstance(item, dict) for item in access):
|
||||
raise RuntimeError("Jenkins Harbor robot bstein access has an invalid shape")
|
||||
for required in (("repository", "pull"), ("repository", "push")):
|
||||
if not any(_allowed(item, *required) for item in access):
|
||||
raise RuntimeError("Jenkins Harbor robot lacks its existing pull/push boundary")
|
||||
immutable_access = [
|
||||
item for item in access if item.get("resource") == "immutable-tag"
|
||||
]
|
||||
if immutable_access and not (
|
||||
len(immutable_access) == 1
|
||||
and _allowed(immutable_access[0], "immutable-tag", "list")
|
||||
):
|
||||
raise RuntimeError("Jenkins Harbor robot has broader immutable-tag access")
|
||||
return matches[0], access
|
||||
|
||||
|
||||
def _verified_publisher(robot: dict[str, Any]) -> bool:
|
||||
"""Check the exact identity and least-privilege policy after persistence."""
|
||||
if (
|
||||
robot.get("name") != PUBLISH_ROBOT
|
||||
or robot.get("level") != "system"
|
||||
or robot.get("editable") is not True
|
||||
or robot.get("disable") is not False
|
||||
):
|
||||
return False
|
||||
try:
|
||||
_scope, access = _publisher_scope(robot)
|
||||
except RuntimeError:
|
||||
return False
|
||||
return any(_allowed(item, "immutable-tag", "list") for item in access)
|
||||
|
||||
|
||||
def ensure_publisher_can_read_rule(client: HarborClient) -> int:
|
||||
"""Grant only immutable-tag:list to the existing Jenkins push robot."""
|
||||
status, body, headers = client.request("GET", "/robots?page=1&page_size=100")
|
||||
if status in TRANSIENT_STATUSES:
|
||||
raise HarborUnavailable(f"Harbor robot list returned HTTP {status}")
|
||||
if status != 200:
|
||||
raise RuntimeError(f"Harbor robot list returned HTTP {status}")
|
||||
robots = _json_list(body, "robot")
|
||||
_require_complete_page(robots, headers, "robot")
|
||||
matches = [
|
||||
item
|
||||
for item in robots
|
||||
if item.get("name") == PUBLISH_ROBOT
|
||||
]
|
||||
if len(matches) != 1:
|
||||
raise RuntimeError("expected exactly one Jenkins Harbor publisher robot")
|
||||
robot_id = matches[0].get("id")
|
||||
if not isinstance(robot_id, int) or robot_id < 1:
|
||||
raise RuntimeError("Jenkins Harbor robot omitted a valid ID")
|
||||
status, body, _headers = client.request("GET", f"/robots/{robot_id}")
|
||||
if status in TRANSIENT_STATUSES:
|
||||
raise HarborUnavailable(f"Harbor robot read returned HTTP {status}")
|
||||
if status != 200:
|
||||
raise RuntimeError(f"Harbor robot read returned HTTP {status}")
|
||||
try:
|
||||
robot = json.loads(body.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise RuntimeError("Harbor returned invalid robot JSON") from exc
|
||||
if not isinstance(robot, dict):
|
||||
raise RuntimeError("Harbor robot response has an invalid shape")
|
||||
if (
|
||||
robot.get("name") != PUBLISH_ROBOT
|
||||
or robot.get("level") != "system"
|
||||
or robot.get("editable") is not True
|
||||
or robot.get("disable") is not False
|
||||
):
|
||||
raise RuntimeError("Jenkins Harbor publisher identity is not active and exact")
|
||||
scope, access = _publisher_scope(robot)
|
||||
if any(_allowed(item, "immutable-tag", "list") for item in access):
|
||||
return robot_id
|
||||
|
||||
# Harbor updates permissions through PUT /robots/{id}; its separate PATCH
|
||||
# endpoint is the only secret-rotation operation. Preserve every current
|
||||
# scope and field while adding the one read-only action.
|
||||
access.append({"resource": "immutable-tag", "action": "list"})
|
||||
payload = {
|
||||
"name": robot["name"],
|
||||
"description": robot.get("description") or "",
|
||||
"level": robot["level"],
|
||||
"disable": robot["disable"],
|
||||
"permissions": robot["permissions"],
|
||||
}
|
||||
duration = robot.get("duration")
|
||||
if duration is not None:
|
||||
if not isinstance(duration, int):
|
||||
raise RuntimeError("Jenkins Harbor robot duration has an invalid shape")
|
||||
payload["duration"] = duration
|
||||
# Keep the validated object reference live in the preserved permission list.
|
||||
if scope.get("access") is not access:
|
||||
raise RuntimeError("Jenkins Harbor robot permission normalization drifted")
|
||||
status, _body, _headers = client.request(
|
||||
"PUT", f"/robots/{robot_id}", payload
|
||||
)
|
||||
if status in TRANSIENT_STATUSES:
|
||||
raise HarborUnavailable(f"Harbor robot update returned HTTP {status}")
|
||||
if status != 200:
|
||||
raise RuntimeError(f"Harbor robot policy update returned HTTP {status}")
|
||||
|
||||
for attempt in range(1, 6):
|
||||
status, body, _headers = client.request("GET", f"/robots/{robot_id}")
|
||||
if status == 200:
|
||||
try:
|
||||
persisted = json.loads(body.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
persisted = None
|
||||
if isinstance(persisted, dict) and _verified_publisher(persisted):
|
||||
return robot_id
|
||||
if attempt < 5:
|
||||
time.sleep(attempt)
|
||||
raise RuntimeError("Jenkins Harbor robot policy did not verify exactly")
|
||||
|
||||
|
||||
def ensure_rule(client: HarborClient) -> int:
|
||||
"""Create once, or validate the one exact enabled rule already present."""
|
||||
rules = list_rules(client)
|
||||
matches = [rule for rule in rules if targets_hermes_builds(rule)]
|
||||
if len(matches) > 1:
|
||||
raise RuntimeError("multiple Hermes agent immutable rules exist")
|
||||
if matches:
|
||||
if normalized_rule(matches[0]) != EXPECTED_RULE:
|
||||
raise RuntimeError("Hermes agent immutable rule exists but is not enabled/exact")
|
||||
rule_id = matches[0].get("id")
|
||||
if not isinstance(rule_id, int) or rule_id < 1:
|
||||
raise RuntimeError("Harbor immutable rule omitted a valid ID")
|
||||
return rule_id
|
||||
|
||||
path = f"/projects/{PROJECT}/immutabletagrules"
|
||||
status, _body, headers = client.request("POST", path, EXPECTED_RULE)
|
||||
if status in TRANSIENT_STATUSES:
|
||||
raise HarborUnavailable(f"Harbor immutable rule create returned HTTP {status}")
|
||||
if status != 201:
|
||||
raise RuntimeError(f"Harbor immutable rule create returned HTTP {status}")
|
||||
location = headers.get("Location") or headers.get("location") or ""
|
||||
api_path = urllib.parse.urlsplit(client.origin).path.rstrip("/")
|
||||
expected_prefix = f"{api_path}{path}/"
|
||||
if not location.startswith(expected_prefix):
|
||||
raise RuntimeError("Harbor immutable rule create omitted the exact Location")
|
||||
suffix = location[len(expected_prefix) :]
|
||||
if not suffix.isdecimal() or int(suffix) < 1:
|
||||
raise RuntimeError("Harbor immutable rule Location has an invalid ID")
|
||||
|
||||
# Harbor persists synchronously, but bounded retries distinguish a stale
|
||||
# read from accepting an unverified policy.
|
||||
for attempt in range(1, 6):
|
||||
matches = [rule for rule in list_rules(client) if targets_hermes_builds(rule)]
|
||||
if len(matches) == 1 and normalized_rule(matches[0]) == EXPECTED_RULE:
|
||||
rule_id = matches[0].get("id")
|
||||
if rule_id == int(suffix):
|
||||
return rule_id
|
||||
if attempt < 5:
|
||||
time.sleep(attempt)
|
||||
raise RuntimeError("created Harbor immutable rule did not verify exactly")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Load the runtime-only admin credential and enforce the tracked policy."""
|
||||
origin = os.environ.get("HARBOR_API_ORIGIN", "")
|
||||
password_file = Path(os.environ.get("HARBOR_ADMIN_PASSWORD_FILE", ""))
|
||||
password = password_file.read_text(encoding="utf-8").strip()
|
||||
if not password:
|
||||
raise RuntimeError("Harbor admin password is empty")
|
||||
client = HarborClient(origin, "admin", password)
|
||||
for attempt in range(1, 13):
|
||||
try:
|
||||
rule_id = ensure_rule(client)
|
||||
robot_id = ensure_publisher_can_read_rule(client)
|
||||
break
|
||||
except HarborUnavailable:
|
||||
if attempt == 12:
|
||||
raise
|
||||
time.sleep(min(attempt * 2, 15))
|
||||
print(
|
||||
"Hermes agent immutable build-tag rule is active "
|
||||
f"(id={rule_id}); publisher preflight access is active (robot={robot_id})"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - exercised through main()
|
||||
raise SystemExit(main())
|
||||
5
services/hermes-observer-bindings/kustomization.yaml
Normal file
5
services/hermes-observer-bindings/kustomization.yaml
Normal file
@ -0,0 +1,5 @@
|
||||
# services/hermes-observer-bindings/kustomization.yaml
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- rolebindings.yaml
|
||||
204
services/hermes-observer-bindings/rolebindings.yaml
Normal file
204
services/hermes-observer-bindings/rolebindings.yaml
Normal file
@ -0,0 +1,204 @@
|
||||
# services/hermes-observer-bindings/rolebindings.yaml
|
||||
apiVersion: v1
|
||||
kind: List
|
||||
items:
|
||||
- apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata: {name: hermes-agent-observer, namespace: ai}
|
||||
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||
- apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata: {name: hermes-agent-observer, namespace: bstein-dev-home}
|
||||
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||
- apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata: {name: hermes-agent-observer, namespace: cassandra}
|
||||
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||
- apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata: {name: hermes-agent-observer, namespace: cert-manager}
|
||||
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||
- apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata: {name: hermes-agent-observer, namespace: climate}
|
||||
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||
- apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata: {name: hermes-agent-observer, namespace: comms}
|
||||
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||
- apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata: {name: hermes-agent-observer, namespace: crypto}
|
||||
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||
- apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata: {name: hermes-agent-observer, namespace: default}
|
||||
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||
- apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata: {name: hermes-agent-observer, namespace: finance}
|
||||
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||
- apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata: {name: hermes-agent-observer, namespace: flux-system}
|
||||
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||
- apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata: {name: hermes-agent-observer, namespace: game-stream}
|
||||
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||
- apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata: {name: hermes-agent-observer, namespace: gitea}
|
||||
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||
- apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata: {name: hermes-agent-observer, namespace: harbor}
|
||||
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||
- apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata: {name: hermes-agent-observer, namespace: health}
|
||||
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||
- apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata: {name: hermes-agent-observer, namespace: hermes}
|
||||
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||
- apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata: {name: hermes-agent-observer, namespace: hermes-chat}
|
||||
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||
- apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata: {name: hermes-agent-observer, namespace: hermes-triage-demo}
|
||||
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||
- apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata: {name: hermes-agent-observer, namespace: jellyfin}
|
||||
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||
- apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata: {name: hermes-agent-observer, namespace: jenkins}
|
||||
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||
- apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata: {name: hermes-agent-observer, namespace: kube-node-lease}
|
||||
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||
- apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata: {name: hermes-agent-observer, namespace: kube-public}
|
||||
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||
- apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata: {name: hermes-agent-observer, namespace: kube-system}
|
||||
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||
- apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata: {name: hermes-agent-observer, namespace: logging}
|
||||
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||
- apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata: {name: hermes-agent-observer, namespace: longhorn-system}
|
||||
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||
- apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata: {name: hermes-agent-observer, namespace: mailu-mailserver}
|
||||
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||
- apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata: {name: hermes-agent-observer, namespace: maintenance}
|
||||
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||
- apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata: {name: hermes-agent-observer, namespace: metallb-system}
|
||||
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||
- apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata: {name: hermes-agent-observer, namespace: monitoring}
|
||||
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||
- apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata: {name: hermes-agent-observer, namespace: nextcloud}
|
||||
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||
- apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata: {name: hermes-agent-observer, namespace: openclaw}
|
||||
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||
- apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata: {name: hermes-agent-observer, namespace: outline}
|
||||
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||
- apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata: {name: hermes-agent-observer, namespace: planka}
|
||||
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||
- apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata: {name: hermes-agent-observer, namespace: postgres}
|
||||
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||
- apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata: {name: hermes-agent-observer, namespace: quality}
|
||||
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||
- apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata: {name: hermes-agent-observer, namespace: sso}
|
||||
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||
- apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata: {name: hermes-agent-observer, namespace: sui-metrics}
|
||||
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||
- apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata: {name: hermes-agent-observer, namespace: traefik}
|
||||
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||
- apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata: {name: hermes-agent-observer, namespace: vault}
|
||||
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||
- apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata: {name: hermes-agent-observer, namespace: vaultwarden}
|
||||
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||
- apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata: {name: hermes-agent-observer, namespace: veles}
|
||||
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||
5
services/hermes-observer-rbac/kustomization.yaml
Normal file
5
services/hermes-observer-rbac/kustomization.yaml
Normal file
@ -0,0 +1,5 @@
|
||||
# services/hermes-observer-rbac/kustomization.yaml
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- rbac.yaml
|
||||
112
services/hermes-observer-rbac/rbac.yaml
Normal file
112
services/hermes-observer-rbac/rbac.yaml
Normal file
@ -0,0 +1,112 @@
|
||||
# services/hermes-observer-rbac/rbac.yaml
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: hermes-agent-cluster-observer-v2
|
||||
labels:
|
||||
app.kubernetes.io/name: hermes-agent
|
||||
app.kubernetes.io/part-of: hermes
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources:
|
||||
- namespaces
|
||||
- nodes
|
||||
- persistentvolumes
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: ["networking.k8s.io"]
|
||||
resources: ["ingressclasses"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: ["storage.k8s.io"]
|
||||
resources:
|
||||
- csidrivers
|
||||
- csinodes
|
||||
- storageclasses
|
||||
- volumeattachments
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: ["metrics.k8s.io"]
|
||||
resources: ["nodes"]
|
||||
verbs: ["get", "list"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: hermes-agent-namespaced-observer-v2
|
||||
labels:
|
||||
app.kubernetes.io/name: hermes-agent
|
||||
app.kubernetes.io/part-of: hermes
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources:
|
||||
- configmaps
|
||||
- endpoints
|
||||
- events
|
||||
- persistentvolumeclaims
|
||||
- pods
|
||||
- pods/log
|
||||
- replicationcontrollers
|
||||
- resourcequotas
|
||||
- services
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: ["apps"]
|
||||
resources:
|
||||
- controllerrevisions
|
||||
- daemonsets
|
||||
- deployments
|
||||
- replicasets
|
||||
- statefulsets
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: ["batch"]
|
||||
resources: ["cronjobs", "jobs"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: ["autoscaling"]
|
||||
resources: ["horizontalpodautoscalers"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: ["networking.k8s.io"]
|
||||
resources: ["ingresses", "networkpolicies"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: ["discovery.k8s.io"]
|
||||
resources: ["endpointslices"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: ["policy"]
|
||||
resources: ["poddisruptionbudgets"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: ["storage.k8s.io"]
|
||||
resources: ["csistoragecapacities"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: ["metrics.k8s.io"]
|
||||
resources: ["pods"]
|
||||
verbs: ["get", "list"]
|
||||
- apiGroups:
|
||||
- helm.toolkit.fluxcd.io
|
||||
- image.toolkit.fluxcd.io
|
||||
- kustomize.toolkit.fluxcd.io
|
||||
- notification.toolkit.fluxcd.io
|
||||
- source.toolkit.fluxcd.io
|
||||
resources: ["*"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: ["longhorn.io"]
|
||||
resources:
|
||||
- backingimages
|
||||
- engines
|
||||
- instancemanagers
|
||||
- nodes
|
||||
- replicas
|
||||
- settings
|
||||
- volumes
|
||||
verbs: ["get", "list", "watch"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: hermes-agent-cluster-observer-v2
|
||||
labels:
|
||||
app.kubernetes.io/name: hermes-agent
|
||||
app.kubernetes.io/part-of: hermes
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: hermes-agent
|
||||
namespace: hermes
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: hermes-agent-cluster-observer-v2
|
||||
98
services/hermes-scm-broker/deployment.yaml
Normal file
98
services/hermes-scm-broker/deployment.yaml
Normal file
@ -0,0 +1,98 @@
|
||||
# services/hermes-scm-broker/deployment.yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: hermes-scm-broker
|
||||
namespace: hermes-scm
|
||||
labels:
|
||||
app: hermes-scm-broker
|
||||
spec:
|
||||
replicas: 1
|
||||
revisionHistoryLimit: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: hermes-scm-broker
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: hermes-scm-broker
|
||||
annotations:
|
||||
vault.hashicorp.com/agent-inject: "true"
|
||||
vault.hashicorp.com/role: hermes-scm-broker
|
||||
vault.hashicorp.com/agent-inject-secret-gitea-token: kv/data/atlas/hermes/developer-gitea
|
||||
vault.hashicorp.com/agent-inject-template-gitea-token: |
|
||||
{{- with secret "kv/data/atlas/hermes/developer-gitea" -}}
|
||||
{{ .Data.data.token }}
|
||||
{{- end }}
|
||||
vault.hashicorp.com/agent-pre-populate-only: "true"
|
||||
vault.hashicorp.com/agent-init-first: "true"
|
||||
vault.hashicorp.com/agent-requests-cpu: 10m
|
||||
vault.hashicorp.com/agent-requests-mem: 32Mi
|
||||
vault.hashicorp.com/agent-limits-cpu: 50m
|
||||
vault.hashicorp.com/agent-limits-mem: 64Mi
|
||||
spec:
|
||||
serviceAccountName: hermes-scm-broker
|
||||
automountServiceAccountToken: true
|
||||
securityContext:
|
||||
fsGroup: 10000
|
||||
fsGroupChangePolicy: OnRootMismatch
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
affinity:
|
||||
nodeAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: kubernetes.io/arch
|
||||
operator: In
|
||||
values: [arm64]
|
||||
- key: node-role.kubernetes.io/worker
|
||||
operator: In
|
||||
values: ["true"]
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
- weight: 100
|
||||
preference:
|
||||
matchExpressions:
|
||||
- key: hardware
|
||||
operator: In
|
||||
values: [rpi5]
|
||||
containers:
|
||||
- name: broker
|
||||
image: registry.bstein.dev/bstein/hermes-agent
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- /opt/hermes/.venv/bin/python
|
||||
- /opt/broker/scm_broker.py
|
||||
ports:
|
||||
- {name: http, containerPort: 9081, protocol: TCP}
|
||||
readinessProbe:
|
||||
httpGet: {path: /healthz, port: http}
|
||||
initialDelaySeconds: 3
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
httpGet: {path: /healthz, port: http}
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 30
|
||||
failureThreshold: 10
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: [ALL]
|
||||
readOnlyRootFilesystem: true
|
||||
runAsNonRoot: true
|
||||
runAsUser: 10000
|
||||
runAsGroup: 10000
|
||||
volumeMounts:
|
||||
- {name: broker-code, mountPath: /opt/broker, readOnly: true}
|
||||
- {name: tmp, mountPath: /tmp}
|
||||
resources:
|
||||
requests: {cpu: 50m, memory: 128Mi}
|
||||
limits: {cpu: "1", memory: 768Mi}
|
||||
volumes:
|
||||
- name: broker-code
|
||||
configMap:
|
||||
name: hermes-scm-boundary
|
||||
defaultMode: 0555
|
||||
- name: tmp
|
||||
emptyDir:
|
||||
sizeLimit: 1Gi
|
||||
11
services/hermes-scm-broker/kustomization.yaml
Normal file
11
services/hermes-scm-broker/kustomization.yaml
Normal file
@ -0,0 +1,11 @@
|
||||
# services/hermes-scm-broker/kustomization.yaml
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
namespace: hermes-scm
|
||||
resources:
|
||||
- service.yaml
|
||||
- deployment.yaml
|
||||
- networkpolicy.yaml
|
||||
images:
|
||||
- name: registry.bstein.dev/bstein/hermes-agent
|
||||
digest: sha256:37ebf720c783ae908a602916ffccf88d43d205a157957f5dc4b487867aee45e7
|
||||
46
services/hermes-scm-broker/networkpolicy.yaml
Normal file
46
services/hermes-scm-broker/networkpolicy.yaml
Normal file
@ -0,0 +1,46 @@
|
||||
# services/hermes-scm-broker/networkpolicy.yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: hermes-scm-broker-isolation
|
||||
namespace: hermes-scm
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app: hermes-scm-broker
|
||||
policyTypes: [Ingress, Egress]
|
||||
ingress:
|
||||
- from:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: hermes
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app: hermes-agent
|
||||
ports:
|
||||
- {protocol: TCP, port: 9081}
|
||||
egress:
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: kube-system
|
||||
podSelector:
|
||||
matchLabels:
|
||||
k8s-app: kube-dns
|
||||
ports:
|
||||
- {protocol: UDP, port: 53}
|
||||
- {protocol: TCP, port: 53}
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: vault
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app: vault
|
||||
ports:
|
||||
- {protocol: TCP, port: 8200}
|
||||
- to:
|
||||
- ipBlock:
|
||||
cidr: 192.168.22.9/32
|
||||
ports:
|
||||
- {protocol: TCP, port: 443}
|
||||
13
services/hermes-scm-broker/service.yaml
Normal file
13
services/hermes-scm-broker/service.yaml
Normal file
@ -0,0 +1,13 @@
|
||||
# services/hermes-scm-broker/service.yaml
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: hermes-scm-broker
|
||||
namespace: hermes-scm
|
||||
spec:
|
||||
selector:
|
||||
app: hermes-scm-broker
|
||||
ports:
|
||||
- name: http
|
||||
port: 9081
|
||||
targetPort: http
|
||||
5
services/hermes-scm-namespace/kustomization.yaml
Normal file
5
services/hermes-scm-namespace/kustomization.yaml
Normal file
@ -0,0 +1,5 @@
|
||||
# services/hermes-scm-namespace/kustomization.yaml
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- namespace.yaml
|
||||
14
services/hermes-scm-namespace/namespace.yaml
Normal file
14
services/hermes-scm-namespace/namespace.yaml
Normal file
@ -0,0 +1,14 @@
|
||||
# services/hermes-scm-namespace/namespace.yaml
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: hermes-scm
|
||||
labels:
|
||||
app.kubernetes.io/name: hermes-scm-broker
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: hermes-scm-broker
|
||||
namespace: hermes-scm
|
||||
automountServiceAccountToken: true
|
||||
@ -102,6 +102,110 @@ console tails and named artifact contents in its deterministic bundle. A report
|
||||
must say `retained Ariadne evidence` when that fallback is used; it must not
|
||||
pretend direct Jenkins access succeeded.
|
||||
|
||||
## Publishing an agent image after review
|
||||
|
||||
The `hermes-agent-image` Jenkins job is the only supported agent image builder.
|
||||
It runs daemonless Kaniko v1.23.2 under the unbound `hermes-image-builder`
|
||||
ServiceAccount without a service-account token, host socket,
|
||||
privileged container, or writable Git credential. It accepts only an exact
|
||||
40-character revision that is both the checked-out commit and current
|
||||
`atlas/titan-iac` `main`, so a human must merge the source PR first.
|
||||
|
||||
The build pod requires an ARM64 worker and gives `hardware=rpi5` the maximum
|
||||
scheduler preference. Healthy rpi4 workers remain valid fallback capacity when
|
||||
the rpi5 pool cannot fit the full request. Cordoned, unhealthy, amd64, and
|
||||
reserved last-resort nodes remain excluded by the required placement boundary.
|
||||
|
||||
Kaniko runs as UID 0 because it must unpack an image root filesystem and enter
|
||||
that filesystem for Dockerfile `RUN` instructions. Its capability set is the
|
||||
minimum proven by an exact ARM64 no-push build of this Dockerfile:
|
||||
`CHOWN`, `FOWNER`, `DAC_OVERRIDE`, `SETGID`, and `SETUID`. `SETGID` is required
|
||||
because pinned Kaniko applies the base image's supplementary group list before
|
||||
each Dockerfile `RUN`; `SETUID` lets apt drop privileges to its `_apt` account
|
||||
while downloading package indexes. It still has no privilege
|
||||
escalation, service-account token, host path, daemon socket, or Docker/BuildKit
|
||||
TCP endpoint and uses the runtime-default seccomp profile. This is residual
|
||||
root-in-the-build-pod risk, bounded to disposable `emptyDir` storage and a
|
||||
human-reviewed `main` revision; Dockerfile changes require the same scrutiny as
|
||||
executable cluster code.
|
||||
|
||||
Pinned Kaniko v1.23.2 parses Dockerfile `RUN` heredocs but does not materialize
|
||||
their inline files when executing a command. The Jenkins lane therefore enables
|
||||
a bounded compatibility replay after copying the reviewed Dockerfile and runner
|
||||
from the same checked-out commit. Before every replay, the runner inventories
|
||||
the entire Dockerfile as logical instructions, including split opcodes,
|
||||
operators, and either Dockerfile escape character. It rejects every heredoc
|
||||
form outside the exact nine Node/Python blocks before launching an interpreter.
|
||||
Each accepted body is then replayed immediately after its corresponding Kaniko
|
||||
`RUN`, preserving source order before any dependent regression. Docker and
|
||||
BuildKit keep their native behavior because the compatibility argument defaults
|
||||
off. The final TypeScript build, Python compile, and source assertions remain
|
||||
mandatory, so an omitted or drifted replay fails before any image can pass
|
||||
release verification.
|
||||
|
||||
From agent.hermes, trigger that one fixed job with:
|
||||
|
||||
```sh
|
||||
jenkins_image_build_trigger.py '<reviewed full main commit>'
|
||||
```
|
||||
|
||||
The helper has no general Jenkins credential or caller-selectable job name. Its
|
||||
Vault-projected token is bound by Jenkins only to `hermes-agent-image`. The job
|
||||
also requires its fixed publish confirmation. Each run claims the unique tag
|
||||
`git-<reviewed-sha>-build-<jenkins-build-number>` and refuses to overwrite an
|
||||
existing tag. It cross-checks Kaniko's digest and tagged-image evidence, then
|
||||
independently reads the pushed tag from Harbor before archiving a JSON record
|
||||
and Flux digest patch. A successful post-condition then revalidates the exact
|
||||
six-file evidence set and archives those six explicit paths with empty archives
|
||||
forbidden. Apply that patch on a new branch and submit it for human review; the
|
||||
build never changes Git, reconciles Flux, or deploys by itself.
|
||||
The helper posts only to the fixed HTTPS Build Token Root endpoint and accepts
|
||||
only its real queue responses: HTTP 201 or a non-followed HTTP 303 with an exact
|
||||
same-origin `/queue/item/<number>/` location. An unauthenticated request is
|
||||
denied, and the token cannot select, configure, read, or administer another
|
||||
Jenkins job. The pipeline independently rejects any commit that is not the
|
||||
current `origin/main`, preserving the human merge/review boundary.
|
||||
|
||||
Harbor independently enforces an enabled immutable-tag rule scoped to only the
|
||||
`bstein/hermes-agent` repository and `git-*-build-*` tags. A second manifest PUT,
|
||||
retag, or deletion is rejected by Harbor even if a caller bypasses the Jenkins
|
||||
preflight. A revisioned, Flux-tracked policy Job creates or verifies that exact
|
||||
rule with a runtime-only Vault credential; it refuses to alter a conflicting
|
||||
rule and retries only explicit transport/readiness failures while Harbor
|
||||
starts. The same Job grants the existing Jenkins publisher only
|
||||
`immutable-tag:list` on `bstein`, preserving its other project scopes and never
|
||||
calling Harbor's separate secret-rotation endpoint. The pipeline uses that
|
||||
read-only permission to require the exact enabled rule before Kaniko starts.
|
||||
That check also makes an already-running Jenkins controller fail closed during
|
||||
rollout; Jenkins cannot become Ready on the new revision before the policy Job
|
||||
succeeds.
|
||||
|
||||
The tracked rollout order is deliberate: the revisioned Vault role Job must
|
||||
complete before the Vault Kustomization becomes Ready. A separate Flux
|
||||
Kustomization then runs the seed Job under the dedicated
|
||||
`hermes-jenkins-token-seed` identity, which can only create/read the exact
|
||||
`kv/data/atlas/hermes/developer-jenkins` path and request random bytes. Jenkins
|
||||
depends on both that seed and the Harbor policy Job; Hermes depends on Jenkins.
|
||||
The seeder uses KV-v2 CAS create-only semantics. It never changes an existing
|
||||
token, never fills a missing field in an existing secret, and fails closed when
|
||||
it cannot distinguish absence from a read error. The recurring Vault
|
||||
configuration job reconciles the same narrow role and policy.
|
||||
|
||||
Rotate this fixed-job token only as a coordinated operator action:
|
||||
|
||||
1. Stop new `hermes-agent-image` triggers and wait for its queue and executor to
|
||||
drain.
|
||||
2. Read the current KV-v2 metadata version for
|
||||
`kv/atlas/hermes/developer-jenkins`.
|
||||
3. Generate a fresh value from Vault and patch only `build_token` with
|
||||
`vault kv patch -cas=<current-version>`. Keep the value in a mode-0600
|
||||
temporary file or standard input, never a command argument or log.
|
||||
4. Roll Jenkins first and wait until it is Ready, then roll `hermes-agent` and
|
||||
wait until it is Ready. Do not resume triggers between those two consumers.
|
||||
5. Run one reviewed-main canary. If rollback is required, repeat the CAS patch
|
||||
with the previous value as another coordinated rotation; never delete the
|
||||
secret to make the seeder recreate it.
|
||||
|
||||
## The actual supervised triage algorithm
|
||||
|
||||
1. Classify the request as test/build triage, service health, or alert tuning.
|
||||
|
||||
@ -197,9 +197,9 @@ data:
|
||||
not replace these coordinator-wide rules. Never call `kanban_show` without
|
||||
a known, non-empty task ID. Ad-hoc inspection and acceptance checks do not
|
||||
need a synthetic Kanban lookup, and must load a skill only when its workflow
|
||||
materially applies. Atlas HTTPS Git authentication is already supplied by
|
||||
the runtime-only `GIT_ASKPASS`; use it without reading or exposing the
|
||||
credential. Coordinator guidance lives at
|
||||
materially applies. Atlas Git access is supplied by the isolated SCM
|
||||
broker; no repository token is present in this pod. Use only configured
|
||||
broker remotes and clients. Coordinator guidance lives at
|
||||
`/opt/data/workspace/AGENTS.md` when more detail is needed.
|
||||
|
||||
The Jetson classifier is mandatory for AUTO selection. Switchyard may use
|
||||
@ -303,70 +303,75 @@ data:
|
||||
objective's difficulty warrants it; otherwise synthesize at the original
|
||||
effort.
|
||||
|
||||
This owner-only pod has cluster-admin access across Atlas, including logs,
|
||||
Secrets, exec, port-forwarding, rollout operations, and Flux reconciliation.
|
||||
Prefer the titan-iac Git/Flux workflow for every durable cluster change;
|
||||
direct operations are available for explicit operator requests, incident
|
||||
recovery, and verification, and must be followed by a matching source-of-
|
||||
truth change when they alter desired state. Never expose credentials in
|
||||
chat or logs. Triage belongs at triage.hermes.bstein.dev.
|
||||
This owner-only pod has scoped read-only Kubernetes diagnostics across
|
||||
Atlas, including resource status, events, logs, and Flux/Helm evidence. It
|
||||
cannot read Secrets, exec or attach to pods, create service-account tokens,
|
||||
mutate workloads or RBAC, or reconcile Flux. Put every durable cluster
|
||||
change on a reviewed titan-iac branch. Never expose credentials in chat or
|
||||
logs. Triage belongs at triage.hermes.bstein.dev.
|
||||
|
||||
## Atlas engineering access
|
||||
|
||||
The Atlas organization has private visibility. Repository visibility is
|
||||
preserved per project and may be public or private; do not infer a
|
||||
repository's visibility from the organization setting. Repositories are
|
||||
canonical at `https://scm.bstein.dev/atlas/<repo>.git`. HTTPS Git
|
||||
authentication is already supplied through `GIT_ASKPASS`. Verify the remote
|
||||
canonical at `https://scm.bstein.dev/atlas/<repo>.git`; worker Git traffic
|
||||
uses `http://hermes-scm-broker.hermes-scm.svc.cluster.local:9081/git/atlas/<repo>.git`.
|
||||
Verify the broker remote
|
||||
and cleanly separate pre-existing changes, create a task branch, run the
|
||||
repository's tests, use `git push --dry-run` when proving access, and push a
|
||||
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.
|
||||
With a branch, the helper also tries the conventional `JOB-branches`
|
||||
multibranch name. For JSON Gitea mutations, prefer repeatable shell-safe
|
||||
`--field KEY=VALUE` arguments over hand-quoted JSON.
|
||||
After merging, observe the default-branch build to a terminal result and
|
||||
report exact commit, build, and test evidence.
|
||||
`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
|
||||
unauthenticated Gitea HTTP 404 as a missing private repository. Use
|
||||
brokered Git for clone, fetch, and creation of a new namespaced feature
|
||||
branch. Existing-ref updates, protected refs, deletion, and force-push are
|
||||
rejected. The broker inflates and scans every pushed object, so thin
|
||||
packs are rejected; always push with `git push --no-thin` so the pack
|
||||
is self-contained. For bounded
|
||||
repository/pull-request evidence or to create a review-ready draft PR, load
|
||||
`$manage-atlas-pull-requests` and use `/opt/scm/gitea_api.py`. The
|
||||
client carries no repository credential; a separate least-authority
|
||||
workload performs the allowed operation. It permits only Atlas-scoped
|
||||
metadata reads and verified same-repository draft creation. Updates, merge,
|
||||
approve, close, delete, comments, repository administration, and force-push
|
||||
are unavailable; never bypass the client with raw HTTP. Leave every PR
|
||||
unmerged for Brad's review. Independently
|
||||
verify the exact base/head ancestry and diff, run the relevant tests, and
|
||||
confirm the candidate Jenkins result before presenting a handoff. Prefer
|
||||
the internal endpoint in
|
||||
`JENKINS_BASE_URL`; when Jenkins API authorization prevents a read, use
|
||||
Kubernetes pod logs and status evidence without exec or secret access.
|
||||
Do not bypass the boundary to inspect controller files. If Brad separately
|
||||
reports that he merged the PR, 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.
|
||||
file as user state. PR publication ends at the verified open draft; Brad
|
||||
owns review and merge authority.
|
||||
|
||||
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
|
||||
reviewable `titan-iac` branch, validate Kustomize and client dry-run, then
|
||||
use Flux reconciliation after the tracked change is published. Direct
|
||||
`kubectl` mutations are for explicit incident recovery or ephemeral
|
||||
verification, not ordinary delivery.
|
||||
relevant `titan-iac` manifests. The broker namespace is deliberately
|
||||
excluded from agent RBAC. Kubernetes cannot express a deny on one namespace
|
||||
inside an all-namespace list, so enumerate namespaces and run namespaced
|
||||
diagnostics instead of relying on `kubectl ... --all-namespaces`. Put
|
||||
durable desired-state changes on a
|
||||
reviewable `titan-iac` branch and validate Kustomize and client dry-run.
|
||||
Brad owns merge and Flux reconciliation. Direct `kubectl` mutation is not
|
||||
available to the agent; hand an explicit incident mutation back to Brad.
|
||||
|
||||
Node SSH uses a dedicated audited Hermes identity: `ssh titan-04` (or any
|
||||
current Kubernetes node name). Host keys are pinned and password fallback
|
||||
is disabled. Use SSH only for host-level evidence or repairs that cannot be
|
||||
performed through Kubernetes. Read first, identify the exact node and
|
||||
impact, avoid fleet-wide destructive commands, and reflect persistent host
|
||||
configuration in the appropriate tracked provisioning source.
|
||||
Node SSH uses the dedicated locked-password `hermes-agent` OS account:
|
||||
`ssh titan-04` (or any current Kubernetes node name). Host keys are pinned,
|
||||
password fallback is disabled, and the account has no sudo, disk, runtime,
|
||||
or Kubernetes-storage authority. Use it for unprivileged host evidence;
|
||||
hand privileged host repair back to Brad and reflect persistent changes in
|
||||
the tracked provisioning source.
|
||||
|
||||
Treat every credential, SSH identity, host-trust record, and
|
||||
credential-bearing access-client state as runtime-only Vault data. Keep
|
||||
@ -403,7 +408,7 @@ data:
|
||||
<low|medium|high|xhigh> [model]` for a persistent override. The first native
|
||||
Codex worker requires one device-code login; refreshed provider credentials
|
||||
persist through Vault while client caches remain disposable. The owner
|
||||
workspace includes cluster-admin Kubernetes
|
||||
access plus `kubectl`, `flux`, `helm`, `kustomize`, `vault`, `sops`, `age`,
|
||||
workspace includes scoped read-only Kubernetes diagnostics plus `kubectl`,
|
||||
`flux`, `helm`, `kustomize`, `vault`, `sops`, `age`,
|
||||
`terraform`, `k9s`, `jq`, `yq`, `gh`, Git, SSH, Python, Node, the
|
||||
browser/computer tools, and the native provider CLIs.
|
||||
|
||||
@ -46,15 +46,10 @@ spec:
|
||||
{{- with secret "kv/data/atlas/hermes/agent-tokens" -}}
|
||||
{{ .Data.data.codex_auth_json }}
|
||||
{{- end }}
|
||||
vault.hashicorp.com/agent-inject-secret-gitea-token: kv/data/atlas/hermes/developer-gitea
|
||||
vault.hashicorp.com/agent-inject-template-gitea-token: |
|
||||
{{- with secret "kv/data/atlas/hermes/developer-gitea" -}}
|
||||
{{ .Data.data.token }}
|
||||
{{- end }}
|
||||
vault.hashicorp.com/agent-inject-secret-gitea-username: kv/data/atlas/hermes/developer-gitea
|
||||
vault.hashicorp.com/agent-inject-template-gitea-username: |
|
||||
{{- with secret "kv/data/atlas/hermes/developer-gitea" -}}
|
||||
{{ .Data.data.username }}
|
||||
vault.hashicorp.com/agent-inject-secret-jenkins-image-build-token: kv/data/atlas/hermes/developer-jenkins
|
||||
vault.hashicorp.com/agent-inject-template-jenkins-image-build-token: |
|
||||
{{- with secret "kv/data/atlas/hermes/developer-jenkins" -}}
|
||||
{{ .Data.data.build_token }}
|
||||
{{- end }}
|
||||
vault.hashicorp.com/agent-inject-secret-node-ssh-private-key: kv/data/atlas/hermes/developer-ssh
|
||||
vault.hashicorp.com/agent-inject-template-node-ssh-private-key: |
|
||||
@ -186,9 +181,8 @@ spec:
|
||||
chmod 0600 "${profile_env}"
|
||||
chown 10000:10000 "${profile_env}"
|
||||
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 HERMES_SCM_BROKER_URL http://hermes-scm-broker.hermes-scm.svc.cluster.local:9081
|
||||
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
|
||||
@ -663,6 +657,8 @@ spec:
|
||||
- {name: subprocess-secret-patch, mountPath: /opt/hermes/tools/process_registry.py, subPath: process_registry.py}
|
||||
- {name: kubeconfig, mountPath: /opt/data/home/.kube/config, subPath: config, readOnly: true}
|
||||
- {name: auto-router-plugin, mountPath: /opt/data/plugins/auto-router, readOnly: true}
|
||||
- {name: atlas-pr-skill, mountPath: /opt/data/workspace/skills/manage-atlas-pull-requests, readOnly: true}
|
||||
- {name: scm-boundary, mountPath: /opt/scm, readOnly: true}
|
||||
- {name: routing-catalog, mountPath: /routing-catalog, readOnly: true}
|
||||
- {name: tmp, mountPath: /tmp}
|
||||
startupProbe:
|
||||
@ -826,6 +822,8 @@ spec:
|
||||
- {name: subprocess-secret-patch, mountPath: /opt/hermes/tools/process_registry.py, subPath: process_registry.py}
|
||||
- {name: kubeconfig, mountPath: /opt/data/home/.kube/config, subPath: config, readOnly: true}
|
||||
- {name: auto-router-plugin, mountPath: /opt/data/plugins/auto-router, readOnly: true}
|
||||
- {name: atlas-pr-skill, mountPath: /opt/data/workspace/skills/manage-atlas-pull-requests, readOnly: true}
|
||||
- {name: scm-boundary, mountPath: /opt/scm, readOnly: true}
|
||||
- {name: routing-catalog, mountPath: /routing-catalog, readOnly: true}
|
||||
- {name: tmp, mountPath: /tmp}
|
||||
- {name: ttyd-index, mountPath: /ttyd-index, readOnly: true}
|
||||
@ -899,6 +897,8 @@ spec:
|
||||
- {name: auth-patch, mountPath: /opt/hermes/hermes_cli/auth.py, subPath: auth.py}
|
||||
- {name: kubeconfig, mountPath: /opt/data/home/.kube/config, subPath: config, readOnly: true}
|
||||
- {name: auto-router-plugin, mountPath: /opt/data/plugins/auto-router, readOnly: true}
|
||||
- {name: atlas-pr-skill, mountPath: /opt/data/workspace/skills/manage-atlas-pull-requests, readOnly: true}
|
||||
- {name: scm-boundary, mountPath: /opt/scm, readOnly: true}
|
||||
- {name: tmp, mountPath: /tmp}
|
||||
readinessProbe:
|
||||
exec:
|
||||
@ -907,8 +907,8 @@ spec:
|
||||
- /opt/coordinator/cli_lane_capabilities.py
|
||||
initialDelaySeconds: 2
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 2
|
||||
failureThreshold: 1
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
resources:
|
||||
requests: {cpu: 100m, memory: 256Mi}
|
||||
limits: {cpu: "2", memory: 6Gi}
|
||||
@ -1202,6 +1202,16 @@ spec:
|
||||
- {key: dashboard-api.py, path: dashboard/plugin_api.py}
|
||||
- {key: dashboard-index.js, path: dashboard/dist/index.js}
|
||||
- {key: dashboard-style.css, path: dashboard/dist/style.css}
|
||||
- name: atlas-pr-skill
|
||||
configMap:
|
||||
name: hermes-atlas-pr-skill
|
||||
items:
|
||||
- {key: SKILL.md, path: SKILL.md}
|
||||
- {key: openai.yaml, path: agents/openai.yaml}
|
||||
- name: scm-boundary
|
||||
configMap:
|
||||
name: hermes-scm-boundary
|
||||
defaultMode: 0555
|
||||
- name: image-policy
|
||||
configMap:
|
||||
name: hermes-image-policy
|
||||
|
||||
@ -11,9 +11,9 @@ users:
|
||||
user:
|
||||
tokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token
|
||||
contexts:
|
||||
- name: atlas-owner
|
||||
- name: atlas-observer
|
||||
context:
|
||||
cluster: atlas
|
||||
user: hermes-agent
|
||||
namespace: default
|
||||
current-context: atlas-owner
|
||||
current-context: atlas-observer
|
||||
|
||||
@ -1,16 +0,0 @@
|
||||
# services/hermes/agent-rbac.yaml
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: hermes-agent-cluster-admin
|
||||
labels:
|
||||
app.kubernetes.io/name: hermes-agent
|
||||
app.kubernetes.io/part-of: hermes
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: hermes-agent
|
||||
namespace: hermes
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: cluster-admin
|
||||
@ -7,13 +7,13 @@ images:
|
||||
digest: sha256:37ebf720c783ae908a602916ffccf88d43d205a157957f5dc4b487867aee45e7
|
||||
resources:
|
||||
- namespace.yaml
|
||||
- scm-common
|
||||
- vault-serviceaccount.yaml
|
||||
- configmap.yaml
|
||||
- agent-configmap.yaml
|
||||
- chat-configmap.yaml
|
||||
- switchyard-configmap.yaml
|
||||
- rbac.yaml
|
||||
- agent-rbac.yaml
|
||||
- node-ssh-access.yaml
|
||||
- pvc.yaml
|
||||
- switchyard-pvc.yaml
|
||||
@ -84,14 +84,13 @@ 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
|
||||
- jenkins_image_build_trigger.py=scripts/jenkins_image_build_trigger.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
|
||||
@ -119,6 +118,15 @@ configMapGenerator:
|
||||
- config=agent-kubeconfig.yaml
|
||||
options:
|
||||
disableNameSuffixHash: true
|
||||
- name: hermes-node-account-hardener
|
||||
namespace: hermes
|
||||
files:
|
||||
- node_account_hardening.py=scripts/node_account_hardening.py
|
||||
- node_account_audit.py=scripts/node_account_audit.py
|
||||
- node_account_io.py=scripts/node_account_io.py
|
||||
- node_polkit_audit.py=scripts/node_polkit_audit.py
|
||||
options:
|
||||
disableNameSuffixHash: true
|
||||
- name: hermes-auto-router-plugin
|
||||
namespace: hermes
|
||||
files:
|
||||
@ -173,3 +181,10 @@ configMapGenerator:
|
||||
- alert-review.md=skills/tune-atlas-alerts/references/alert-review.md
|
||||
options:
|
||||
disableNameSuffixHash: true
|
||||
- name: hermes-atlas-pr-skill
|
||||
namespace: hermes
|
||||
files:
|
||||
- SKILL.md=skills/manage-atlas-pull-requests/SKILL.md
|
||||
- openai.yaml=skills/manage-atlas-pull-requests/agents/openai.yaml
|
||||
options:
|
||||
disableNameSuffixHash: true
|
||||
|
||||
@ -120,11 +120,65 @@ spec:
|
||||
ports:
|
||||
- {protocol: TCP, port: 9010}
|
||||
- {protocol: TCP, port: 9011}
|
||||
# agent.hermes.bstein.dev is an owner-only engineering workstation. The
|
||||
# browser boundary remains OAuth-protected, while its workers need to reach
|
||||
# every cluster namespace, Atlas LAN service, and hosted provider endpoint.
|
||||
egress:
|
||||
- {}
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: kube-system
|
||||
podSelector:
|
||||
matchLabels:
|
||||
k8s-app: kube-dns
|
||||
ports:
|
||||
- {protocol: UDP, port: 53}
|
||||
- {protocol: TCP, port: 53}
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchExpressions:
|
||||
- key: kubernetes.io/metadata.name
|
||||
operator: NotIn
|
||||
values: [gitea, hermes-scm]
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: hermes-scm
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app: hermes-scm-broker
|
||||
ports:
|
||||
- {protocol: TCP, port: 9081}
|
||||
- to:
|
||||
- ipBlock:
|
||||
cidr: 10.43.0.1/32
|
||||
ports:
|
||||
- {protocol: TCP, port: 443}
|
||||
- to:
|
||||
- ipBlock:
|
||||
cidr: 192.168.0.0/16
|
||||
except:
|
||||
- 192.168.22.9/32
|
||||
- to:
|
||||
- ipBlock:
|
||||
cidr: 0.0.0.0/0
|
||||
except:
|
||||
- 10.0.0.0/8
|
||||
- 100.64.0.0/10
|
||||
- 127.0.0.0/8
|
||||
- 169.254.0.0/16
|
||||
- 172.16.0.0/12
|
||||
- 192.168.0.0/16
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: hermes-node-ssh-access-isolation
|
||||
namespace: hermes
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app: hermes-node-ssh-access
|
||||
policyTypes: [Ingress, Egress]
|
||||
ingress: []
|
||||
egress: []
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
|
||||
@ -45,32 +45,16 @@ spec:
|
||||
- operator: Exists
|
||||
containers:
|
||||
- name: key-reconciler
|
||||
image: busybox:1.37
|
||||
# The pinned index supports the cluster's amd64 and arm64 nodes.
|
||||
image: python@sha256:6d43704baacd1bfbe7c295d7f13079d5d8104ed33568873133f8fc69980419df
|
||||
imagePullPolicy: IfNotPresent
|
||||
command: [/bin/sh, -ec]
|
||||
args:
|
||||
- |
|
||||
reconcile() {
|
||||
key="$(cat /vault/secrets/node-ssh-public-key)"
|
||||
found=0
|
||||
for user in atlas oceanus; do
|
||||
home="/host-home/${user}"
|
||||
[ -d "${home}" ] || continue
|
||||
found=1
|
||||
identity="$(awk -F: -v name="${user}" '$1 == name {print $3 ":" $4; exit}' /host-etc/passwd)"
|
||||
[ -n "${identity}" ] || identity="$(stat -c %u:%g "${home}")"
|
||||
uid="${identity%%:*}"
|
||||
gid="${identity##*:}"
|
||||
install -d -m 0700 -o "${uid}" -g "${gid}" "${home}/.ssh"
|
||||
touch "${home}/.ssh/authorized_keys"
|
||||
grep -qxF "${key}" "${home}/.ssh/authorized_keys" || printf '%s\n' "${key}" >> "${home}/.ssh/authorized_keys"
|
||||
chown "${uid}:${gid}" "${home}/.ssh/authorized_keys"
|
||||
chmod 0600 "${home}/.ssh/authorized_keys"
|
||||
done
|
||||
[ "${found}" = 1 ] || { echo "no supported node SSH account found" >&2; return 1; }
|
||||
}
|
||||
while true; do
|
||||
reconcile
|
||||
/usr/local/bin/python \
|
||||
/opt/node-hardener/node_account_hardening.py \
|
||||
--public-key-file /vault/secrets/node-ssh-public-key
|
||||
sleep 300
|
||||
done
|
||||
securityContext:
|
||||
@ -83,8 +67,21 @@ spec:
|
||||
volumeMounts:
|
||||
- name: host-home
|
||||
mountPath: /host-home
|
||||
- name: host-passwd
|
||||
mountPath: /host-etc/passwd
|
||||
- name: host-etc
|
||||
mountPath: /host-etc
|
||||
- name: host-polkit-share
|
||||
mountPath: /host-polkit-share
|
||||
readOnly: true
|
||||
- name: host-k3s
|
||||
mountPath: /host-k3s
|
||||
- name: host-kubelet
|
||||
mountPath: /host-kubelet
|
||||
- name: host-run-k3s
|
||||
mountPath: /host-run-k3s
|
||||
- name: host-run-containerd
|
||||
mountPath: /host-run-containerd
|
||||
- name: coordinator
|
||||
mountPath: /opt/node-hardener
|
||||
readOnly: true
|
||||
- name: vault-secrets
|
||||
mountPath: /vault/secrets
|
||||
@ -94,19 +91,43 @@ spec:
|
||||
resources:
|
||||
requests:
|
||||
cpu: 5m
|
||||
memory: 8Mi
|
||||
memory: 32Mi
|
||||
limits:
|
||||
cpu: 50m
|
||||
memory: 32Mi
|
||||
memory: 128Mi
|
||||
volumes:
|
||||
- name: host-home
|
||||
hostPath:
|
||||
path: /home
|
||||
type: Directory
|
||||
- name: host-passwd
|
||||
- name: host-etc
|
||||
hostPath:
|
||||
path: /etc/passwd
|
||||
type: File
|
||||
path: /etc
|
||||
type: Directory
|
||||
- name: host-polkit-share
|
||||
hostPath:
|
||||
path: /usr/share/polkit-1
|
||||
type: Directory
|
||||
- name: host-k3s
|
||||
hostPath:
|
||||
path: /var/lib/rancher/k3s
|
||||
type: Directory
|
||||
- name: host-kubelet
|
||||
hostPath:
|
||||
path: /var/lib/kubelet
|
||||
type: Directory
|
||||
- name: host-run-k3s
|
||||
hostPath:
|
||||
path: /run/k3s
|
||||
type: Directory
|
||||
- name: host-run-containerd
|
||||
hostPath:
|
||||
path: /run/containerd
|
||||
type: Directory
|
||||
- name: coordinator
|
||||
configMap:
|
||||
name: hermes-node-account-hardener
|
||||
defaultMode: 0555
|
||||
- name: vault-secrets
|
||||
csi:
|
||||
driver: secrets-store.csi.k8s.io
|
||||
|
||||
@ -16,6 +16,9 @@ metadata:
|
||||
spec:
|
||||
selector:
|
||||
app: hermes-agent
|
||||
# The agent dashboard and terminal must stay reachable while the
|
||||
# cli-lane-runner readiness probe holds the shared pod NotReady.
|
||||
publishNotReadyAddresses: true
|
||||
ports:
|
||||
- {name: http, port: 80, targetPort: auth-http}
|
||||
---
|
||||
|
||||
17
services/hermes/scm-common/kustomization.yaml
Normal file
17
services/hermes/scm-common/kustomization.yaml
Normal file
@ -0,0 +1,17 @@
|
||||
# services/hermes/scm-common/kustomization.yaml
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
configMapGenerator:
|
||||
- name: hermes-scm-boundary
|
||||
files:
|
||||
- deadline_http.py=scripts/deadline_http.py
|
||||
- git_pack_objects.py=scripts/git_pack_objects.py
|
||||
- gitea_api.py=scripts/gitea_api.py
|
||||
- gitea_api_policy.py=scripts/gitea_api_policy.py
|
||||
- receive_pack_scan.py=scripts/receive_pack_scan.py
|
||||
- scm_broker.py=scripts/scm_broker.py
|
||||
- scm_broker_io.py=scripts/scm_broker_io.py
|
||||
- scm_broker_server.py=scripts/scm_broker_server.py
|
||||
- scm_broker_client.py=scripts/scm_broker_client.py
|
||||
options:
|
||||
disableNameSuffixHash: true
|
||||
344
services/hermes/scm-common/scripts/deadline_http.py
Normal file
344
services/hermes/scm-common/scripts/deadline_http.py
Normal file
@ -0,0 +1,344 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Absolute wall-clock deadlines for every outbound Hermes HTTP exchange.
|
||||
|
||||
Small control exchanges run inside a killable helper process, so connect,
|
||||
send, read, and DNS all share one hard bound that no blocking socket state
|
||||
can outlive. Large streaming Git exchanges cannot cross a process boundary;
|
||||
they instead register their connection with a watchdog that force-closes the
|
||||
socket when the same kind of absolute deadline expires.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import contextlib
|
||||
import email.message
|
||||
import http.client
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
|
||||
from gitea_api_policy import PolicyError
|
||||
|
||||
MAX_CONTROL_BYTES = 128 * 1024
|
||||
MAX_PROTOCOL_BYTES = 4 * 1024 * 1024
|
||||
MAX_STREAM_SECONDS = 900.0
|
||||
READ_CHUNK = 64 * 1024
|
||||
|
||||
|
||||
class _RejectRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
raise urllib.error.URLError("redirect rejected")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Result:
|
||||
"""Minimal HTTP evidence returned across the process boundary."""
|
||||
|
||||
status: int
|
||||
content_type: str
|
||||
body: bytes
|
||||
|
||||
|
||||
def _read_response(response, maximum: int, deadline: float) -> bytes:
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
while True:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise TimeoutError
|
||||
raw = getattr(getattr(response, "fp", None), "raw", None)
|
||||
sock = getattr(raw, "_sock", raw)
|
||||
if hasattr(sock, "settimeout"):
|
||||
sock.settimeout(remaining)
|
||||
chunk = response.read(min(READ_CHUNK, maximum + 1 - total))
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
total += len(chunk)
|
||||
if total > maximum:
|
||||
raise ValueError("response too large")
|
||||
return b"".join(chunks)
|
||||
|
||||
|
||||
def _child_exchange(control: dict[str, object]) -> Result:
|
||||
url = control.get("url")
|
||||
method = control.get("method")
|
||||
headers = control.get("headers")
|
||||
encoded = control.get("body")
|
||||
maximum = control.get("maximum")
|
||||
timeout = control.get("timeout")
|
||||
if (
|
||||
not isinstance(url, str)
|
||||
or not isinstance(method, str)
|
||||
or not isinstance(headers, dict)
|
||||
or not all(isinstance(key, str) and isinstance(value, str) for key, value in headers.items())
|
||||
or not isinstance(encoded, str)
|
||||
or not isinstance(maximum, int)
|
||||
or not 0 <= maximum <= 2 * 1024 * 1024
|
||||
or not isinstance(timeout, (int, float))
|
||||
or not 0 < timeout <= 120
|
||||
):
|
||||
raise ValueError("invalid control")
|
||||
body = base64.b64decode(encoded, validate=True) if encoded else None
|
||||
if body is not None and len(body) > MAX_CONTROL_BYTES:
|
||||
raise ValueError("request too large")
|
||||
deadline = time.monotonic() + float(timeout)
|
||||
request = urllib.request.Request(
|
||||
url, data=body, method=method, headers=headers # type: ignore[arg-type]
|
||||
)
|
||||
opener = urllib.request.build_opener(_RejectRedirect())
|
||||
try:
|
||||
response = opener.open(request, timeout=max(deadline - time.monotonic(), 0.001))
|
||||
except urllib.error.HTTPError as error:
|
||||
response = error
|
||||
with response:
|
||||
status = getattr(response, "status", response.getcode())
|
||||
content_type = response.headers.get_content_type()
|
||||
value = _read_response(response, maximum, deadline)
|
||||
return Result(int(status), str(content_type), value)
|
||||
|
||||
|
||||
def _child_main() -> int:
|
||||
try:
|
||||
raw = sys.stdin.buffer.read(MAX_CONTROL_BYTES + 1)
|
||||
if len(raw) > MAX_CONTROL_BYTES:
|
||||
raise ValueError("control too large")
|
||||
control = json.loads(raw)
|
||||
if not isinstance(control, dict):
|
||||
raise ValueError("invalid control")
|
||||
result = _child_exchange(control)
|
||||
output = {
|
||||
"ok": True,
|
||||
"status": result.status,
|
||||
"content_type": result.content_type,
|
||||
"body": base64.b64encode(result.body).decode("ascii"),
|
||||
}
|
||||
except Exception:
|
||||
output = {"ok": False}
|
||||
sys.stdout.write(json.dumps(output, separators=(",", ":")))
|
||||
return 0 if output["ok"] else 1
|
||||
|
||||
|
||||
def _terminate(process: subprocess.Popen[bytes]) -> None:
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
process.communicate()
|
||||
|
||||
|
||||
def exchange(
|
||||
request: urllib.request.Request,
|
||||
*,
|
||||
maximum: int,
|
||||
timeout: float,
|
||||
popen=subprocess.Popen,
|
||||
) -> Result:
|
||||
"""Execute a whole request under one monotonic deadline and size limit."""
|
||||
if not 0 < timeout <= 120 or not 0 <= maximum <= 2 * 1024 * 1024:
|
||||
raise PolicyError("HTTP control request bounds are invalid")
|
||||
body = request.data or b""
|
||||
if not isinstance(body, bytes) or len(body) > MAX_CONTROL_BYTES:
|
||||
raise PolicyError("HTTP control request exceeds the safe size limit")
|
||||
control = json.dumps(
|
||||
{
|
||||
"url": request.full_url,
|
||||
"method": request.get_method(),
|
||||
"headers": dict(request.header_items()),
|
||||
"body": base64.b64encode(body).decode("ascii"),
|
||||
"maximum": maximum,
|
||||
"timeout": timeout,
|
||||
},
|
||||
separators=(",", ":"),
|
||||
).encode()
|
||||
deadline = time.monotonic() + timeout
|
||||
process = popen(
|
||||
[sys.executable, os.path.realpath(__file__), "--child"],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
env={"LANG": "C.UTF-8", "PATH": "/usr/bin:/bin"},
|
||||
)
|
||||
try:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise subprocess.TimeoutExpired(process.args, timeout)
|
||||
output, _error = process.communicate(control, timeout=remaining)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
_terminate(process)
|
||||
raise PolicyError("HTTP control request deadline exceeded") from exc
|
||||
if process.returncode != 0 or len(output) > MAX_PROTOCOL_BYTES:
|
||||
raise PolicyError("HTTP control request failed")
|
||||
try:
|
||||
result = json.loads(output)
|
||||
if not isinstance(result, dict) or result.get("ok") is not True:
|
||||
raise ValueError
|
||||
status = result["status"]
|
||||
content_type = result["content_type"]
|
||||
encoded = result["body"]
|
||||
if not isinstance(status, int) or not isinstance(content_type, str) or not isinstance(encoded, str):
|
||||
raise ValueError
|
||||
value = base64.b64decode(encoded, validate=True)
|
||||
except (KeyError, ValueError, TypeError, json.JSONDecodeError) as exc:
|
||||
raise PolicyError("HTTP control helper returned invalid evidence") from exc
|
||||
if len(value) > maximum:
|
||||
raise PolicyError("HTTP control response exceeds the safe size limit")
|
||||
return Result(status, content_type, value)
|
||||
|
||||
|
||||
class _AdaptedResponse:
|
||||
"""Present one finished exchange through the urlopen reader interface."""
|
||||
|
||||
def __init__(self, result: Result):
|
||||
self.status = result.status
|
||||
self.headers = email.message.Message()
|
||||
self.headers["Content-Type"] = result.content_type
|
||||
self._stream = io.BytesIO(result.body)
|
||||
|
||||
def read(self, limit: int = -1) -> bytes:
|
||||
return self._stream.read(limit)
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_excinfo: object) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def open_bounded(
|
||||
request: urllib.request.Request,
|
||||
*,
|
||||
maximum: int,
|
||||
timeout: float,
|
||||
popen=subprocess.Popen,
|
||||
) -> _AdaptedResponse:
|
||||
"""Open one control exchange whose whole lifetime shares one deadline."""
|
||||
return _AdaptedResponse(
|
||||
exchange(request, maximum=maximum, timeout=timeout, popen=popen)
|
||||
)
|
||||
|
||||
|
||||
def _resolve_within(host: str, port: int, timeout: float) -> None:
|
||||
"""Resolve one host under a hard timeout the socket layer never covers.
|
||||
|
||||
``getaddrinfo`` ignores socket timeouts and runs before any socket
|
||||
exists, so a slow or hostile resolver would otherwise outlive the whole
|
||||
stream deadline. Running it in a joinable worker bounds resolution by the
|
||||
same wall clock; a stuck lookup leaks only one daemon thread.
|
||||
"""
|
||||
outcome: dict[str, object] = {}
|
||||
|
||||
def run() -> None:
|
||||
try:
|
||||
outcome["value"] = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
|
||||
except OSError as exc:
|
||||
outcome["error"] = exc
|
||||
|
||||
worker = threading.Thread(target=run, daemon=True)
|
||||
worker.start()
|
||||
worker.join(timeout)
|
||||
if worker.is_alive():
|
||||
raise PolicyError("HTTP stream DNS resolution deadline exceeded")
|
||||
if "error" in outcome:
|
||||
raise outcome["error"] # type: ignore[misc]
|
||||
|
||||
|
||||
class StreamDeadline:
|
||||
"""Force-close tracked connections once an absolute deadline passes."""
|
||||
|
||||
def __init__(self, timeout: float, *, timer=threading.Timer):
|
||||
if not 0 < timeout <= MAX_STREAM_SECONDS:
|
||||
raise PolicyError("HTTP stream deadline bounds are invalid")
|
||||
self._deadline = time.monotonic() + timeout
|
||||
self._lock = threading.Lock()
|
||||
self._connections: list[http.client.HTTPConnection] = []
|
||||
self.expired = False
|
||||
self._timer = timer(timeout, self._expire)
|
||||
self._timer.daemon = True
|
||||
self._timer.start()
|
||||
|
||||
def remaining(self) -> float:
|
||||
value = self._deadline - time.monotonic()
|
||||
if value <= 0 or self.expired:
|
||||
raise PolicyError("HTTP stream deadline exceeded")
|
||||
return value
|
||||
|
||||
def _expire(self) -> None:
|
||||
with self._lock:
|
||||
self.expired = True
|
||||
connections = list(self._connections)
|
||||
for connection in connections:
|
||||
# Shut the raw socket down first so a blocked send or recv wakes
|
||||
# immediately; close alone leaves peers waiting on the old fd.
|
||||
sock = getattr(connection, "sock", None)
|
||||
if sock is not None:
|
||||
with contextlib.suppress(OSError):
|
||||
sock.shutdown(socket.SHUT_RDWR)
|
||||
connection.close()
|
||||
|
||||
def _track(self, connection: http.client.HTTPConnection):
|
||||
with self._lock:
|
||||
expired = self.expired
|
||||
if not expired:
|
||||
self._connections.append(connection)
|
||||
if expired:
|
||||
connection.close()
|
||||
raise PolicyError("HTTP stream deadline exceeded")
|
||||
return connection
|
||||
|
||||
def _bounded_connection(self, connection: http.client.HTTPConnection):
|
||||
"""Register one connection and bound its DNS phase by the deadline.
|
||||
|
||||
The watchdog can only close a live socket, but ``connect`` resolves
|
||||
the host before any socket exists. Wrapping ``connect`` runs that
|
||||
resolution under the same absolute deadline first, then hands off to
|
||||
the real connect for the socket phases the watchdog already covers.
|
||||
"""
|
||||
original_connect = connection.connect
|
||||
|
||||
def connect() -> None:
|
||||
_resolve_within(connection.host, connection.port, self.remaining())
|
||||
original_connect()
|
||||
|
||||
connection.connect = connect # type: ignore[method-assign]
|
||||
return self._track(connection)
|
||||
|
||||
def cancel(self) -> None:
|
||||
self._timer.cancel()
|
||||
|
||||
def handlers(self) -> tuple[urllib.request.BaseHandler, ...]:
|
||||
"""Build urllib handlers whose connections obey this deadline."""
|
||||
deadline = self
|
||||
|
||||
class GuardedHTTPHandler(urllib.request.HTTPHandler):
|
||||
def http_open(self, req):
|
||||
return self.do_open(
|
||||
lambda host, **kwargs: deadline._bounded_connection(
|
||||
http.client.HTTPConnection(host, **kwargs)
|
||||
),
|
||||
req,
|
||||
)
|
||||
|
||||
class GuardedHTTPSHandler(urllib.request.HTTPSHandler):
|
||||
def https_open(self, req):
|
||||
return self.do_open(
|
||||
lambda host, **kwargs: deadline._bounded_connection(
|
||||
http.client.HTTPSConnection(host, **kwargs)
|
||||
),
|
||||
req,
|
||||
)
|
||||
|
||||
return (GuardedHTTPHandler(), GuardedHTTPSHandler())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(_child_main() if sys.argv[1:] == ["--child"] else 2)
|
||||
308
services/hermes/scm-common/scripts/git_pack_objects.py
Normal file
308
services/hermes/scm-common/scripts/git_pack_objects.py
Normal file
@ -0,0 +1,308 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bounded Git pack inflation and delta resolution for quarantine scanning.
|
||||
|
||||
Pushed objects arrive zlib-compressed inside a packfile, so any content
|
||||
screening must inflate the real payloads first. This module unpacks one
|
||||
self-contained pack under explicit object, size, and checksum bounds and
|
||||
resolves every delta against bases inside the same pack. Thin packs are
|
||||
rejected outright: a delta against a base the broker cannot see would leave
|
||||
part of the pushed content unscanned.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import tempfile
|
||||
import zlib
|
||||
from typing import BinaryIO
|
||||
|
||||
from gitea_api_policy import PolicyError
|
||||
|
||||
OBJECT_TYPE_NAMES = {1: b"commit", 2: b"tree", 3: b"blob", 4: b"tag"}
|
||||
OFS_DELTA = 6
|
||||
REF_DELTA = 7
|
||||
MAX_PACK_OBJECTS = 50_000
|
||||
MAX_OBJECT_BYTES = 32 * 1024 * 1024
|
||||
MAX_TOTAL_BYTES = 256 * 1024 * 1024
|
||||
MAX_RESOLVE_PASSES = 64
|
||||
SPOOL_MEMORY_LIMIT = 256 * 1024
|
||||
READ_CHUNK = 64 * 1024
|
||||
|
||||
|
||||
def _read_exact(stream: BinaryIO, size: int) -> bytes:
|
||||
value = stream.read(size)
|
||||
if len(value) != size:
|
||||
raise PolicyError("Git pack data ended early")
|
||||
return value
|
||||
|
||||
|
||||
def _object_header(stream: BinaryIO) -> tuple[int, int]:
|
||||
"""Parse one object's type and declared inflated size."""
|
||||
byte = _read_exact(stream, 1)[0]
|
||||
type_code = (byte >> 4) & 0x7
|
||||
size = byte & 0x0F
|
||||
shift = 4
|
||||
while byte & 0x80:
|
||||
if shift > 60:
|
||||
raise PolicyError("Git object header is invalid")
|
||||
byte = _read_exact(stream, 1)[0]
|
||||
size |= (byte & 0x7F) << shift
|
||||
shift += 7
|
||||
return type_code, size
|
||||
|
||||
|
||||
def _delta_distance(stream: BinaryIO) -> int:
|
||||
"""Parse one ofs-delta base distance."""
|
||||
byte = _read_exact(stream, 1)[0]
|
||||
value = byte & 0x7F
|
||||
while byte & 0x80:
|
||||
byte = _read_exact(stream, 1)[0]
|
||||
value = ((value + 1) << 7) | (byte & 0x7F)
|
||||
if value > 1 << 48:
|
||||
raise PolicyError("Git delta base distance is invalid")
|
||||
return value
|
||||
|
||||
|
||||
def _inflate(stream: BinaryIO, declared: int) -> BinaryIO:
|
||||
"""Inflate one object into a quarantine spool, bounded by its header."""
|
||||
spool = tempfile.SpooledTemporaryFile( # noqa: SIM115 - caller owns returned spool
|
||||
max_size=SPOOL_MEMORY_LIMIT, dir="/tmp"
|
||||
)
|
||||
decompressor = zlib.decompressobj()
|
||||
produced = 0
|
||||
try:
|
||||
while not decompressor.eof:
|
||||
data = stream.read(READ_CHUNK)
|
||||
if not data:
|
||||
raise PolicyError("Git pack object data ended early")
|
||||
while True:
|
||||
value = decompressor.decompress(data, READ_CHUNK)
|
||||
produced += len(value)
|
||||
if produced > declared:
|
||||
raise PolicyError("Git object size does not match its header")
|
||||
spool.write(value)
|
||||
data = decompressor.unconsumed_tail
|
||||
if decompressor.eof or not data:
|
||||
break
|
||||
if decompressor.unused_data:
|
||||
stream.seek(-len(decompressor.unused_data), 1)
|
||||
if produced != declared:
|
||||
raise PolicyError("Git object size does not match its header")
|
||||
spool.seek(0)
|
||||
return spool
|
||||
except zlib.error as exc:
|
||||
spool.close()
|
||||
raise PolicyError("Git pack object data is corrupt") from exc
|
||||
except Exception:
|
||||
spool.close()
|
||||
raise
|
||||
|
||||
|
||||
def _delta_size(delta: bytes, position: int) -> tuple[int, int]:
|
||||
size = 0
|
||||
shift = 0
|
||||
while True:
|
||||
if position >= len(delta) or shift > 60:
|
||||
raise PolicyError("Git delta header is invalid")
|
||||
byte = delta[position]
|
||||
position += 1
|
||||
size |= (byte & 0x7F) << shift
|
||||
shift += 7
|
||||
if not byte & 0x80:
|
||||
return size, position
|
||||
|
||||
|
||||
def _apply_delta(base: bytes, delta: bytes) -> bytes:
|
||||
"""Apply one Git delta and require exact declared sizes throughout."""
|
||||
source_size, position = _delta_size(delta, 0)
|
||||
target_size, position = _delta_size(delta, position)
|
||||
if source_size != len(base) or target_size > MAX_OBJECT_BYTES:
|
||||
raise PolicyError("Git delta sizes are invalid")
|
||||
parts: list[bytes] = []
|
||||
produced = 0
|
||||
while position < len(delta):
|
||||
opcode = delta[position]
|
||||
position += 1
|
||||
if opcode & 0x80:
|
||||
operands = [bit for bit in (1, 2, 4, 8, 16, 32, 64) if opcode & bit]
|
||||
if position + len(operands) > len(delta):
|
||||
raise PolicyError("Git delta copy is truncated")
|
||||
offset = 0
|
||||
size = 0
|
||||
for shift, bit in enumerate((1, 2, 4, 8)):
|
||||
if opcode & bit:
|
||||
offset |= delta[position] << (8 * shift)
|
||||
position += 1
|
||||
for shift, bit in enumerate((16, 32, 64)):
|
||||
if opcode & bit:
|
||||
size |= delta[position] << (8 * shift)
|
||||
position += 1
|
||||
size = size or 0x10000
|
||||
if offset + size > len(base):
|
||||
raise PolicyError("Git delta copy is out of bounds")
|
||||
parts.append(base[offset : offset + size])
|
||||
produced += size
|
||||
elif opcode:
|
||||
if position + opcode > len(delta):
|
||||
raise PolicyError("Git delta insert is truncated")
|
||||
parts.append(delta[position : position + opcode])
|
||||
position += opcode
|
||||
produced += opcode
|
||||
else:
|
||||
raise PolicyError("Git delta instruction is invalid")
|
||||
if produced > target_size:
|
||||
raise PolicyError("Git delta result exceeds its declared size")
|
||||
if produced != target_size:
|
||||
raise PolicyError("Git delta result size is invalid")
|
||||
return b"".join(parts)
|
||||
|
||||
|
||||
def _payload_sha(type_code: int, payload: BinaryIO, size: int) -> bytes:
|
||||
digest = hashlib.sha1(OBJECT_TYPE_NAMES[type_code] + b" %d\x00" % size)
|
||||
payload.seek(0)
|
||||
while True:
|
||||
chunk = payload.read(READ_CHUNK)
|
||||
if not chunk:
|
||||
break
|
||||
digest.update(chunk)
|
||||
payload.seek(0)
|
||||
return digest.digest()
|
||||
|
||||
|
||||
def _verify_checksum(stream: BinaryIO, start: int, end: int) -> None:
|
||||
trailer = _read_exact(stream, 20)
|
||||
stream.seek(start)
|
||||
digest = hashlib.sha1()
|
||||
remaining = end - start
|
||||
while remaining:
|
||||
chunk = stream.read(min(READ_CHUNK, remaining))
|
||||
digest.update(chunk)
|
||||
remaining -= len(chunk)
|
||||
if digest.digest() != trailer:
|
||||
raise PolicyError("Git pack checksum is invalid")
|
||||
stream.seek(end + 20)
|
||||
|
||||
|
||||
class _Entry:
|
||||
"""One pack entry: either resolved content or an unresolved delta."""
|
||||
|
||||
def __init__(self, type_code: int, payload: BinaryIO, size: int, base):
|
||||
self.type_code = type_code
|
||||
self.payload = payload
|
||||
self.size = size
|
||||
self.base = base
|
||||
|
||||
|
||||
def _resolve(entries: dict[int, _Entry], total: list[int]) -> None:
|
||||
"""Resolve every delta against in-pack bases only, to a fixed point."""
|
||||
by_sha: dict[bytes, int] = {}
|
||||
for offset, entry in entries.items():
|
||||
if entry.base is None:
|
||||
by_sha.setdefault(
|
||||
_payload_sha(entry.type_code, entry.payload, entry.size), offset
|
||||
)
|
||||
for _ in range(MAX_RESOLVE_PASSES):
|
||||
pending = [item for item in entries.items() if item[1].base is not None]
|
||||
if not pending:
|
||||
return
|
||||
progressed = False
|
||||
for offset, entry in pending:
|
||||
base_key = entry.base
|
||||
base_offset = base_key if isinstance(base_key, int) else by_sha.get(base_key)
|
||||
base = entries.get(base_offset) if base_offset is not None else None
|
||||
if base is None or base.base is not None:
|
||||
if isinstance(base_key, int) and base is None:
|
||||
raise PolicyError("Git delta base offset is invalid")
|
||||
continue
|
||||
base.payload.seek(0)
|
||||
entry.payload.seek(0)
|
||||
value = _apply_delta(base.payload.read(), entry.payload.read())
|
||||
total[0] += len(value)
|
||||
if total[0] > MAX_TOTAL_BYTES:
|
||||
raise PolicyError("Git pack contents exceed the safe size limit")
|
||||
entry.payload.close()
|
||||
spool = tempfile.SpooledTemporaryFile( # noqa: SIM115 - entry owns spool
|
||||
max_size=SPOOL_MEMORY_LIMIT, dir="/tmp"
|
||||
)
|
||||
spool.write(value)
|
||||
spool.seek(0)
|
||||
entry.type_code = base.type_code
|
||||
entry.payload = spool
|
||||
entry.size = len(value)
|
||||
entry.base = None
|
||||
by_sha.setdefault(_payload_sha(entry.type_code, spool, entry.size), offset)
|
||||
progressed = True
|
||||
if not progressed:
|
||||
raise PolicyError("Git delta base is outside the pack; push full packs")
|
||||
raise PolicyError("Git delta chains exceed the safe resolution limit")
|
||||
|
||||
|
||||
def unpack_objects(stream: BinaryIO) -> list[tuple[int, BinaryIO]]:
|
||||
"""Inflate one self-contained pack and return every resolved payload."""
|
||||
start = stream.tell()
|
||||
header = _read_exact(stream, 12)
|
||||
version = int.from_bytes(header[4:8], "big")
|
||||
count = int.from_bytes(header[8:12], "big")
|
||||
if header[:4] != b"PACK" or version not in {2, 3}:
|
||||
raise PolicyError("Git pack header is invalid")
|
||||
if count > MAX_PACK_OBJECTS:
|
||||
raise PolicyError("Git pack contains too many objects")
|
||||
entries: dict[int, _Entry] = {}
|
||||
total = [0]
|
||||
try:
|
||||
for _ in range(count):
|
||||
offset = stream.tell() - start
|
||||
type_code, declared = _object_header(stream)
|
||||
if declared > MAX_OBJECT_BYTES:
|
||||
raise PolicyError("Git object exceeds the safe size limit")
|
||||
total[0] += declared
|
||||
if total[0] > MAX_TOTAL_BYTES:
|
||||
raise PolicyError("Git pack contents exceed the safe size limit")
|
||||
base: int | bytes | None = None
|
||||
if type_code == OFS_DELTA:
|
||||
distance = _delta_distance(stream)
|
||||
base = offset - distance
|
||||
if base < 0:
|
||||
raise PolicyError("Git delta base offset is invalid")
|
||||
elif type_code == REF_DELTA:
|
||||
base = _read_exact(stream, 20)
|
||||
elif type_code not in OBJECT_TYPE_NAMES:
|
||||
raise PolicyError("Git pack object type is invalid")
|
||||
entries[offset] = _Entry(type_code, _inflate(stream, declared), declared, base)
|
||||
_verify_checksum(stream, start, stream.tell())
|
||||
_resolve(entries, total)
|
||||
except Exception:
|
||||
for entry in entries.values():
|
||||
entry.payload.close()
|
||||
raise
|
||||
for entry in entries.values():
|
||||
entry.payload.seek(0)
|
||||
return [
|
||||
(entries[offset].type_code, entries[offset].payload)
|
||||
for offset in sorted(entries)
|
||||
]
|
||||
|
||||
|
||||
def parse_loose_object(value: bytes) -> tuple[int, bytes]:
|
||||
"""Inflate one quarantined loose object and return its typed payload."""
|
||||
if len(value) > MAX_OBJECT_BYTES:
|
||||
raise PolicyError("Git object exceeds the safe size limit")
|
||||
decompressor = zlib.decompressobj()
|
||||
try:
|
||||
inflated = decompressor.decompress(value, MAX_OBJECT_BYTES + 1)
|
||||
except zlib.error as exc:
|
||||
raise PolicyError("Git loose object data is corrupt") from exc
|
||||
if not decompressor.eof or decompressor.unused_data or len(inflated) > MAX_OBJECT_BYTES:
|
||||
raise PolicyError("Git loose object framing is invalid")
|
||||
kind, separator, remainder = inflated.partition(b"\x00")
|
||||
name, space, size = kind.partition(b" ")
|
||||
types = {label: code for code, label in OBJECT_TYPE_NAMES.items()}
|
||||
if (
|
||||
not separator
|
||||
or not space
|
||||
or name not in types
|
||||
or not size.isdigit()
|
||||
or int(size) != len(remainder)
|
||||
):
|
||||
raise PolicyError("Git loose object header is invalid")
|
||||
return types[name], remainder
|
||||
492
services/hermes/scm-common/scripts/gitea_api.py
Normal file
492
services/hermes/scm-common/scripts/gitea_api.py
Normal file
@ -0,0 +1,492 @@
|
||||
#!/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 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("/")
|
||||
_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())
|
||||
490
services/hermes/scm-common/scripts/gitea_api_policy.py
Normal file
490
services/hermes/scm-common/scripts/gitea_api_policy.py
Normal file
@ -0,0 +1,490 @@
|
||||
"""Validation policy for Hermes' least-authority Atlas Forgejo client.
|
||||
|
||||
The screening catches structured credential assignments, known token formats,
|
||||
and long high-entropy values. It is a fail-closed accident barrier, not proof
|
||||
that text is secret-free: a short or multiword secret under an innocuous key is
|
||||
not reliably distinguishable from prose and must never be supplied by callers.
|
||||
JSON depth/nodes/documents and multiline gaps are deliberately bounded; large
|
||||
or unusual configuration blobs are not a supported pull-request field format.
|
||||
"""
|
||||
|
||||
# ruff: noqa: SIM905
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import urllib.parse
|
||||
from collections import Counter
|
||||
from math import log2
|
||||
|
||||
REPO_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,99}\Z")
|
||||
SHA_RE = re.compile(r"[0-9a-fA-F]{40}\Z")
|
||||
DRAFT_TITLE_PREFIX = "WIP: "
|
||||
GIT_BIN = "/usr/bin/git"
|
||||
MAX_QUERY_LENGTH = 128
|
||||
MAX_QUERY_ITEMS = 3
|
||||
MAX_QUERY_KEY_LENGTH = 16
|
||||
MAX_QUERY_VALUE_LENGTH = 16
|
||||
MAX_PAGE = 10_000
|
||||
MAX_LIMIT = 50
|
||||
MAX_PR_NUMBER = 2_147_483_647
|
||||
MAX_PR_NUMBER_DIGITS = 10
|
||||
MAX_REF_CHARACTERS = 200
|
||||
MAX_REF_UTF8_BYTES = 255
|
||||
MAX_TITLE_UTF8_BYTES = 512
|
||||
MAX_BODY_UTF8_BYTES = 32_768
|
||||
MAX_JSON_DOCUMENTS = 32
|
||||
MAX_JSON_NODES = 2_048
|
||||
MAX_JSON_DEPTH = 32
|
||||
CANONICAL_QUERY_RE = re.compile(r"[A-Za-z0-9_=&-]*\Z")
|
||||
ASSIGNMENT_RE = re.compile(
|
||||
r"""(?mx)
|
||||
(?<![A-Za-z0-9_.-])
|
||||
(?P<key_quote>[\"']?)
|
||||
(?P<key>\.?[A-Za-z][A-Za-z0-9_.-]{0,127})
|
||||
(?P=key_quote)
|
||||
[ \t]*(?P<separator>[:=])[ \t]*
|
||||
(?P<value>
|
||||
\"(?:\\.|[^\"\\\r\n])*\" |
|
||||
'(?:\\.|[^'\\\r\n])*' |
|
||||
[^\r\n,;}&]{0,2048}
|
||||
)
|
||||
"""
|
||||
)
|
||||
JSON_KEY_RE = re.compile(r'"(?P<key>[^"]{1,512})"(?P<spacing>[\x00-\x20\x7f]{0,256}):')
|
||||
MULTILINE_ASSIGNMENT_RE = re.compile(
|
||||
r"""(?mx)
|
||||
(?<![A-Za-z0-9_.-])
|
||||
(?P<key_quote>["']?)
|
||||
(?P<key>\.?[A-Za-z][A-Za-z0-9_.-]{0,127})
|
||||
(?P=key_quote)
|
||||
[ \t]{0,64}(?:\r?\n[ \t]{0,64}){0,4}
|
||||
(?P<separator>[:=])
|
||||
[ \t]{0,64}(?:\r?\n[ \t]{0,64}){1,4}
|
||||
(?P<value>[^\r\n,;}&]{1,2048})
|
||||
"""
|
||||
)
|
||||
CAMEL_BOUNDARY_RE = re.compile(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])")
|
||||
CREDENTIAL_SCHEME_RE = re.compile(
|
||||
r"(?i)\b(?:bearer|basic|token)\s+"
|
||||
r"(?=[A-Za-z0-9+/_=.:-]{8,}\b)(?=[A-Za-z0-9+/_=.:-]*[0-9+/_=.-])"
|
||||
r"[A-Za-z0-9+/_=.:-]{8,}"
|
||||
)
|
||||
STANDALONE_SECRET_PATTERNS = tuple(
|
||||
re.compile(pattern, re.IGNORECASE)
|
||||
for pattern in (
|
||||
r"(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{20,}(?![A-Za-z0-9])",
|
||||
r"(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{20,}(?![A-Za-z0-9])",
|
||||
r"(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{20,}(?![A-Za-z0-9])",
|
||||
r"(?<![A-Za-z0-9])xox[baprs]-[A-Za-z0-9-]{20,}(?![A-Za-z0-9])",
|
||||
r"(?<![A-Za-z0-9])sk-(?:ant-|proj-)?[A-Za-z0-9_-]{20,}(?![A-Za-z0-9])",
|
||||
r"(?<![A-Za-z0-9])[rs]k_(?:test|live)_[A-Za-z0-9]{20,}(?![A-Za-z0-9])",
|
||||
r"(?<![A-Za-z0-9])whsec_[A-Za-z0-9]{20,}(?![A-Za-z0-9])",
|
||||
r"(?<![A-Za-z0-9])npm_[A-Za-z0-9]{20,}(?![A-Za-z0-9])",
|
||||
r"(?<![A-Za-z0-9])pypi-[A-Za-z0-9_-]{30,}(?![A-Za-z0-9])",
|
||||
r"(?<![A-Za-z0-9])hf_[A-Za-z0-9]{20,}(?![A-Za-z0-9])",
|
||||
r"(?<![A-Za-z0-9])(?:gta|gto|gitea|forgejo)_[A-Za-z0-9_-]{20,}(?![A-Za-z0-9])",
|
||||
r"(?<![A-Za-z0-9])ya29\.[A-Za-z0-9_-]{20,}(?![A-Za-z0-9])",
|
||||
r"(?<![A-Za-z0-9])oy2[A-Za-z0-9]{40,}(?![A-Za-z0-9])",
|
||||
r"(?<![A-Za-z0-9])SG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{20,}(?![A-Za-z0-9])",
|
||||
r"(?<![A-Za-z0-9])SK[0-9a-f]{32}(?![A-Za-z0-9])",
|
||||
r"(?<![A-Za-z0-9])(?:AKIA|ASIA)[0-9A-Z]{16}(?![A-Za-z0-9])",
|
||||
r"(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}(?![A-Za-z0-9])",
|
||||
r"(?<![A-Za-z0-9])eyJ[A-Za-z0-9_-]{12,}\.[A-Za-z0-9_-]{12,}\.[A-Za-z0-9_-]{8,}(?![A-Za-z0-9])",
|
||||
r"-----BEGIN\s+[A-Z0-9 ][A-Z0-9 -]{1,62}-----",
|
||||
r"\bssh-(?:rsa|ed25519|dss|ecdsa-[A-Za-z0-9-]+)\s+[A-Za-z0-9+/]{16,}={0,3}",
|
||||
r"https://hooks\.slack\.com/services/[A-Za-z0-9/_-]{20,}",
|
||||
r"https://(?:discord(?:app)?\.com)/api/webhooks/[0-9]+/[A-Za-z0-9._-]{20,}",
|
||||
r"https://[^\s/]*webhook\.office\.com/[^\s]{20,}",
|
||||
)
|
||||
)
|
||||
HIGH_ENTROPY_TOKEN_RE = re.compile(
|
||||
r"(?<![A-Za-z0-9+/_=-])[A-Za-z0-9+/_=-]{48,16384}(?![A-Za-z0-9+/_=-])"
|
||||
)
|
||||
PROSE_LEAD_WORDS = frozenset(
|
||||
"add allow change check describe document ensure explain fix handle keep preserve "
|
||||
"prevent reject remove review support test validate".split()
|
||||
)
|
||||
SENSITIVE_KEY_WORDS = frozenset(
|
||||
"auth authentication authorization auths basic bearer credential credentials "
|
||||
"passwd password pat private sas secret secrets sig signature signing token tokens "
|
||||
"webhook".split()
|
||||
)
|
||||
KEY_MODIFIER_WORDS = frozenset(
|
||||
"access account api auth client encryption identity private registry secret service "
|
||||
"session signing ssh".split()
|
||||
)
|
||||
SENSITIVE_COMPACT_KEYS = frozenset(
|
||||
"accessid accesskeyid accountkey clientemail clientid connectionstring "
|
||||
"dockerconfigjson privatekeyid serviceaccountkey sharedaccesssignature".split()
|
||||
)
|
||||
SENSITIVE_COMPACT_SUFFIXES = tuple(
|
||||
"accesskey accesskeyid accountkey apikey authkey clientemail clientid clientkey "
|
||||
"connectionstring credential credentials encryptionkey identitykey password passwd "
|
||||
"privatekeyid privatekey secret secretkey servicekey sessionkey signature signingkey "
|
||||
"sshkey token webhook".split()
|
||||
)
|
||||
|
||||
|
||||
class PolicyError(ValueError):
|
||||
"""Raised when a requested Forgejo operation exceeds the safe boundary."""
|
||||
|
||||
|
||||
def _validate_repo(repo: str) -> str:
|
||||
if not REPO_RE.fullmatch(repo) or repo in {".", ".."}:
|
||||
raise PolicyError("repository name is outside the Atlas allowlist")
|
||||
return repo
|
||||
|
||||
|
||||
def _reject_forbidden(value: object, name: str, forbidden: tuple[str, ...]) -> None:
|
||||
"""Reject exact runtime credentials before invoking helpers or the network."""
|
||||
if not isinstance(value, str) or any(secret and secret in value for secret in forbidden):
|
||||
raise PolicyError(f"{name} contains runtime credential material")
|
||||
|
||||
|
||||
def _validate_ref_bounds(
|
||||
value: object, name: str, *, forbidden: tuple[str, ...] = ()
|
||||
) -> str:
|
||||
"""Reject oversized or non-encodable refs before invoking Git."""
|
||||
_reject_forbidden(value, name, forbidden)
|
||||
if not value or len(value) > MAX_REF_CHARACTERS:
|
||||
raise PolicyError(f"{name} exceeds the safe branch-name limit")
|
||||
try:
|
||||
encoded = value.encode("utf-8", errors="strict")
|
||||
except UnicodeEncodeError as exc:
|
||||
raise PolicyError(f"{name} is not valid UTF-8 text") from exc
|
||||
if len(encoded) > MAX_REF_UTF8_BYTES:
|
||||
raise PolicyError(f"{name} exceeds the safe UTF-8 branch-name limit")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_ref(
|
||||
value: object, name: str, *, forbidden: tuple[str, ...] = ()
|
||||
) -> str:
|
||||
"""Validate the complete Git ref grammar using Git itself."""
|
||||
value = _validate_ref_bounds(value, name, forbidden=forbidden)
|
||||
if value.startswith("-"):
|
||||
raise PolicyError(f"{name} must be a same-repository branch name")
|
||||
if any(ord(character) < 32 or ord(character) == 127 for character in value):
|
||||
raise PolicyError(f"{name} must be a safe same-repository branch name")
|
||||
result = subprocess.run(
|
||||
[GIT_BIN, "check-ref-format", f"refs/heads/{value}"],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise PolicyError(f"{name} must be a safe same-repository branch name")
|
||||
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_pr_number_segment(value: object) -> int:
|
||||
"""Validate a canonical bounded pull-request number from an API path."""
|
||||
if (
|
||||
not isinstance(value, str)
|
||||
or len(value) > MAX_PR_NUMBER_DIGITS
|
||||
or not re.fullmatch(r"[1-9][0-9]*", value)
|
||||
):
|
||||
raise PolicyError("pull-request number must use bounded canonical ASCII digits")
|
||||
number = int(value)
|
||||
if number > MAX_PR_NUMBER:
|
||||
raise PolicyError("pull-request number is outside its allowed range")
|
||||
return number
|
||||
|
||||
|
||||
def _validate_pr_number(value: object) -> int:
|
||||
"""Validate a bounded pull-request number returned by Forgejo."""
|
||||
if (
|
||||
not isinstance(value, int)
|
||||
or isinstance(value, bool)
|
||||
or not 1 <= value <= MAX_PR_NUMBER
|
||||
):
|
||||
raise PolicyError("Forgejo omitted a valid pull-request number")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_text(
|
||||
value: object,
|
||||
name: str,
|
||||
maximum: int,
|
||||
maximum_bytes: int,
|
||||
*,
|
||||
required: bool,
|
||||
) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise PolicyError(f"{name} must be text")
|
||||
if required and not value.strip():
|
||||
raise PolicyError(f"{name} must not be empty")
|
||||
try:
|
||||
encoded = value.encode("utf-8", errors="strict")
|
||||
except UnicodeEncodeError as exc:
|
||||
raise PolicyError(f"{name} is not valid UTF-8 text") from exc
|
||||
if len(value) > maximum or len(encoded) > maximum_bytes or "\x00" in value:
|
||||
raise PolicyError(f"{name} exceeds the safe request limit")
|
||||
return value
|
||||
|
||||
|
||||
def _normalized_key(value: str) -> tuple[tuple[str, ...], str]:
|
||||
"""Split camelCase and separator-based assignment keys into semantics."""
|
||||
separated = CAMEL_BOUNDARY_RE.sub(" ", value.strip(".\"'"))
|
||||
words = tuple(re.findall(r"[A-Za-z0-9]+", separated.lower()))
|
||||
return words, "".join(words)
|
||||
|
||||
|
||||
def _is_sensitive_key(value: str) -> bool:
|
||||
words, compact = _normalized_key(value)
|
||||
word_set = set(words)
|
||||
if word_set & SENSITIVE_KEY_WORDS:
|
||||
return True
|
||||
if compact in SENSITIVE_COMPACT_KEYS:
|
||||
return True
|
||||
if compact.endswith(SENSITIVE_COMPACT_SUFFIXES):
|
||||
return True
|
||||
if "key" in word_set and word_set & KEY_MODIFIER_WORDS:
|
||||
return True
|
||||
if {"client", "email"} <= word_set or {"client", "id"} <= word_set:
|
||||
return True
|
||||
if {"access", "id"} <= word_set or {"connection", "string"} <= word_set:
|
||||
return True
|
||||
return compact.endswith("configjson")
|
||||
|
||||
|
||||
def _strip_assignment_value(value: str) -> tuple[str, bool]:
|
||||
stripped = value.strip()
|
||||
quoted = (
|
||||
len(stripped) >= 2 and stripped[0] in {'"', "'"} and stripped[-1] == stripped[0]
|
||||
)
|
||||
if quoted:
|
||||
stripped = stripped[1:-1].strip()
|
||||
return stripped, quoted
|
||||
|
||||
|
||||
def _looks_like_prose(value: str) -> bool:
|
||||
words = re.findall(r"[A-Za-z]+(?:[-'][A-Za-z]+)?", value)
|
||||
if len(words) < 2:
|
||||
return False
|
||||
if re.search(r"[$`{}\[\]\\@/:=+_]", value):
|
||||
return False
|
||||
return words[0].lower() in PROSE_LEAD_WORDS
|
||||
|
||||
|
||||
def _looks_sensitive_assignment_value(value: str) -> bool:
|
||||
stripped, quoted = _strip_assignment_value(value)
|
||||
if not stripped:
|
||||
return False
|
||||
if quoted or stripped.startswith(("{", "[", "|", ">", "!!", "&", "*")):
|
||||
return True
|
||||
if CREDENTIAL_SCHEME_RE.search(stripped):
|
||||
return True
|
||||
if any(pattern.search(stripped) for pattern in STANDALONE_SECRET_PATTERNS):
|
||||
return True
|
||||
if re.search(r"(?:https?://|[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+)", stripped):
|
||||
return True
|
||||
if re.search(r"(?:\$\{|\$\(|\\[nrt]|[A-Za-z0-9+/]{16,}={0,3}\Z)", stripped):
|
||||
return True
|
||||
return not _looks_like_prose(stripped)
|
||||
|
||||
|
||||
def _is_structural_credential_assignment(key: str, value: str) -> bool:
|
||||
words, compact_key = _normalized_key(key)
|
||||
stripped, _quoted = _strip_assignment_value(value)
|
||||
_value_words, compact_value = _normalized_key(stripped)
|
||||
return (
|
||||
compact_key == "type" and compact_value in {"serviceaccount", "credential"}
|
||||
) or ("service" in words and "account" in words and bool(stripped))
|
||||
|
||||
|
||||
def _decode_json_key(raw: str) -> tuple[str, bool]:
|
||||
"""Decode one bounded JSON key without silently accepting bad escapes."""
|
||||
try:
|
||||
decoded = json.loads(f'"{raw}"')
|
||||
except (json.JSONDecodeError, UnicodeError):
|
||||
approximate = re.sub(r"[^A-Za-z0-9]+", "_", raw.replace("\\", ""))
|
||||
return approximate, False
|
||||
return decoded if isinstance(decoded, str) else "", True
|
||||
|
||||
|
||||
def _json_value_has_sensitive_assignment(
|
||||
value: object, *, depth: int = 0, nodes: list[int] | None = None
|
||||
) -> bool:
|
||||
"""Walk one decoded JSON value with explicit depth and node ceilings."""
|
||||
if nodes is None:
|
||||
nodes = [0]
|
||||
nodes[0] += 1
|
||||
if nodes[0] > MAX_JSON_NODES or depth > MAX_JSON_DEPTH:
|
||||
raise PolicyError("structured pull-request text exceeds the scan limit")
|
||||
if isinstance(value, dict):
|
||||
for key, assigned in value.items():
|
||||
if not isinstance(key, str):
|
||||
continue
|
||||
if _is_sensitive_key(key):
|
||||
return True
|
||||
assigned_text = assigned if isinstance(assigned, str) else ""
|
||||
if _is_structural_credential_assignment(key, assigned_text):
|
||||
return True
|
||||
if _json_value_has_sensitive_assignment(
|
||||
assigned, depth=depth + 1, nodes=nodes
|
||||
):
|
||||
return True
|
||||
elif isinstance(value, list):
|
||||
return any(
|
||||
_json_value_has_sensitive_assignment(item, depth=depth + 1, nodes=nodes)
|
||||
for item in value
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _decoded_json_documents(value: str):
|
||||
"""Yield bounded embedded JSON object or array fragments."""
|
||||
decoder = json.JSONDecoder()
|
||||
index = 0
|
||||
attempts = 0
|
||||
while attempts < MAX_JSON_DOCUMENTS:
|
||||
positions = [
|
||||
position for token in "{[" if (position := value.find(token, index)) >= 0
|
||||
]
|
||||
if not positions:
|
||||
return
|
||||
start = min(positions)
|
||||
attempts += 1
|
||||
try:
|
||||
document, end = decoder.raw_decode(value, start)
|
||||
except (json.JSONDecodeError, RecursionError, ValueError):
|
||||
index = start + 1
|
||||
continue
|
||||
yield document
|
||||
index = max(end, start + 1)
|
||||
if "{" in value[index:] or "[" in value[index:]:
|
||||
raise PolicyError("structured pull-request text exceeds the scan limit")
|
||||
|
||||
|
||||
def _has_structured_sensitive_assignment(value: str) -> bool:
|
||||
"""Detect bounded JSON and multiline YAML/env credential assignments."""
|
||||
for candidate in JSON_KEY_RE.finditer(value):
|
||||
decoded, valid = _decode_json_key(candidate.group("key"))
|
||||
if _is_sensitive_key(decoded):
|
||||
return True
|
||||
if not valid and _is_sensitive_key(candidate.group("key").replace("\\", "")):
|
||||
return True
|
||||
for candidate in MULTILINE_ASSIGNMENT_RE.finditer(value):
|
||||
key = candidate.group("key")
|
||||
assigned = candidate.group("value")
|
||||
if _is_structural_credential_assignment(key, assigned) or (
|
||||
_is_sensitive_key(key) and _looks_sensitive_assignment_value(assigned)
|
||||
):
|
||||
return True
|
||||
return any(
|
||||
_json_value_has_sensitive_assignment(item)
|
||||
for item in _decoded_json_documents(value)
|
||||
)
|
||||
|
||||
|
||||
def _has_high_entropy_token(value: str) -> bool:
|
||||
for match in HIGH_ENTROPY_TOKEN_RE.finditer(value):
|
||||
token = match.group(0)
|
||||
if len(token) > 256:
|
||||
return True
|
||||
groups = sum(
|
||||
bool(re.search(pattern, token))
|
||||
for pattern in (r"[a-z]", r"[A-Z]", r"[0-9]", r"[+/_=-]")
|
||||
)
|
||||
counts = Counter(token)
|
||||
entropy = -sum(
|
||||
(count / len(token)) * log2(count / len(token)) for count in counts.values()
|
||||
)
|
||||
if groups >= 3 and entropy >= 4.0:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _reject_sensitive(value: str, name: str, forbidden: tuple[str, ...]) -> None:
|
||||
if any(secret and secret in value for secret in forbidden):
|
||||
raise PolicyError(f"pull-request {name} contains runtime credential material")
|
||||
if CREDENTIAL_SCHEME_RE.search(value):
|
||||
raise PolicyError(f"pull-request {name} resembles credential material")
|
||||
if any(pattern.search(value) for pattern in STANDALONE_SECRET_PATTERNS):
|
||||
raise PolicyError(f"pull-request {name} resembles credential material")
|
||||
if _has_structured_sensitive_assignment(value):
|
||||
raise PolicyError(f"pull-request {name} resembles credential material")
|
||||
for candidate in ASSIGNMENT_RE.finditer(value):
|
||||
key = candidate.group("key")
|
||||
assigned = candidate.group("value")
|
||||
if _is_structural_credential_assignment(key, assigned) or (
|
||||
_is_sensitive_key(key) and _looks_sensitive_assignment_value(assigned)
|
||||
):
|
||||
raise PolicyError(f"pull-request {name} resembles credential material")
|
||||
if _has_high_entropy_token(value):
|
||||
raise PolicyError(f"pull-request {name} resembles credential material")
|
||||
|
||||
|
||||
def _validate_body(value: object, *, forbidden: tuple[str, ...] = ()) -> str:
|
||||
body = _validate_text(value, "body", 16384, MAX_BODY_UTF8_BYTES, required=False)
|
||||
_reject_sensitive(body, "body", forbidden)
|
||||
return body
|
||||
|
||||
|
||||
def _draft_title(value: object, *, forbidden: tuple[str, ...] = ()) -> str:
|
||||
"""Return a bounded, secret-screened Gitea draft title."""
|
||||
title = _validate_text(
|
||||
value, "title", 251, MAX_TITLE_UTF8_BYTES, required=True
|
||||
).strip()
|
||||
_reject_sensitive(title, "title", forbidden)
|
||||
for prefix in ("WIP:", "[WIP]"):
|
||||
if title.upper().startswith(prefix):
|
||||
title = title[len(prefix) :].lstrip()
|
||||
break
|
||||
if not title:
|
||||
raise PolicyError("title must contain text after the draft prefix")
|
||||
return DRAFT_TITLE_PREFIX + title
|
||||
|
||||
|
||||
def _validate_query(target: urllib.parse.SplitResult, allowed: set[str]) -> None:
|
||||
"""Accept only short canonical ASCII query strings with bounded pagination."""
|
||||
raw = target.query
|
||||
if (
|
||||
len(raw) > MAX_QUERY_LENGTH
|
||||
or not raw.isascii()
|
||||
or not CANONICAL_QUERY_RE.fullmatch(raw)
|
||||
or "%" in raw
|
||||
):
|
||||
raise PolicyError("API query must use short canonical ASCII form")
|
||||
try:
|
||||
pairs = urllib.parse.parse_qsl(raw, keep_blank_values=True, strict_parsing=True)
|
||||
except ValueError as exc:
|
||||
raise PolicyError("invalid API query") from exc
|
||||
if len(pairs) > MAX_QUERY_ITEMS:
|
||||
raise PolicyError("too many API query parameters")
|
||||
if len({key for key, _ in pairs}) != len(pairs):
|
||||
raise PolicyError("duplicate API query parameters are not allowed")
|
||||
if any(
|
||||
not key
|
||||
or len(key) > MAX_QUERY_KEY_LENGTH
|
||||
or len(value) > MAX_QUERY_VALUE_LENGTH
|
||||
for key, value in pairs
|
||||
):
|
||||
raise PolicyError("API query key or value exceeds its safe limit")
|
||||
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 re.fullmatch(r"[0-9]+", values[name]):
|
||||
raise PolicyError(f"{name} must use ASCII decimal digits")
|
||||
number = int(values[name])
|
||||
if values[name] != str(number):
|
||||
raise PolicyError(f"{name} must use canonical ASCII decimal form")
|
||||
ceiling = MAX_PAGE if name == "page" else MAX_LIMIT
|
||||
if not 1 <= number <= ceiling:
|
||||
raise PolicyError(f"{name} is outside its allowed range")
|
||||
if "state" in values and values["state"] not in {"open", "closed", "all"}:
|
||||
raise PolicyError("pull-request state is invalid")
|
||||
143
services/hermes/scm-common/scripts/receive_pack_scan.py
Normal file
143
services/hermes/scm-common/scripts/receive_pack_scan.py
Normal file
@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate Git receive-pack requests and scan pushed object content.
|
||||
|
||||
Ref commands may only create new namespaced feature branches. Every pushed
|
||||
object is then inflated in quarantine and its decompressed payload scanned,
|
||||
so credentials cannot ride through the broker inside compressed pack data.
|
||||
The screening is a fail-closed accident barrier for credential-shaped
|
||||
content: exact runtime-token forms, private keys, SSH key material, and
|
||||
known provider token formats. Entropy heuristics are deliberately excluded
|
||||
so SOPS ciphertext, pinned digests, and lock files remain pushable; a short
|
||||
secret disguised as prose is not reliably detectable and must never be
|
||||
supplied by callers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import re
|
||||
from typing import BinaryIO
|
||||
|
||||
import git_pack_objects
|
||||
from gitea_api_policy import (
|
||||
STANDALONE_SECRET_PATTERNS,
|
||||
PolicyError,
|
||||
_validate_ref,
|
||||
)
|
||||
|
||||
ZERO_SHA = b"0" * 40
|
||||
MAX_PUSH_COMMANDS = 16
|
||||
SCAN_CHUNK = 256 * 1024
|
||||
SCAN_CARRY = 8 * 1024
|
||||
FEATURE_REF_RE = re.compile(
|
||||
r"refs/heads/(?:(?:feature|fix|hermes|handoff)/[A-Za-z0-9][A-Za-z0-9._/-]{0,190})\Z"
|
||||
)
|
||||
PKT_HEADER_RE = re.compile(rb"[0-9a-f]{4}\Z")
|
||||
CONTENT_SECRET_PATTERNS = tuple(
|
||||
re.compile(pattern.pattern.encode("ascii"), re.IGNORECASE)
|
||||
for pattern in STANDALONE_SECRET_PATTERNS
|
||||
) + (
|
||||
re.compile(rb"(?i)(?<![A-Za-z0-9])AGE-SECRET-KEY-1[A-Z0-9]{20,}"),
|
||||
re.compile(rb"(?i)PuTTY-User-Key-File"),
|
||||
re.compile(
|
||||
rb"(?im)^[ \t]*authorization[ \t]*:[ \t]*(?:basic|bearer|token)[ \t]+"
|
||||
rb"(?=[A-Za-z0-9+/_=.:-]*[0-9+/=.-])[A-Za-z0-9+/_=.:-]{8,}"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _read_pkt_line(stream: BinaryIO) -> bytes | None:
|
||||
"""Read one pkt-line; None marks the flush packet before the pack."""
|
||||
header = stream.read(4)
|
||||
if not header:
|
||||
raise PolicyError("Git receive-pack request omitted its command terminator")
|
||||
if not PKT_HEADER_RE.fullmatch(header):
|
||||
raise PolicyError("Git receive-pack command framing is invalid")
|
||||
size = int(header, 16)
|
||||
if size == 0:
|
||||
return None
|
||||
if size < 4:
|
||||
raise PolicyError("Git receive-pack command framing is invalid")
|
||||
value = stream.read(size - 4)
|
||||
if len(value) != size - 4:
|
||||
raise PolicyError("Git receive-pack command framing is invalid")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_command(line: bytes, token: str, forbidden: tuple[bytes, ...]) -> None:
|
||||
"""Permit only creation of one new, namespaced feature branch."""
|
||||
if any(form in line for form in forbidden):
|
||||
raise PolicyError("Git request contains runtime credential material")
|
||||
command = line.rstrip(b"\n").split(b"\x00", 1)[0]
|
||||
fields = command.split(b" ")
|
||||
if len(fields) != 3 or not all(
|
||||
re.fullmatch(rb"[0-9a-f]{40}", item) for item in fields[:2]
|
||||
):
|
||||
raise PolicyError("Git receive-pack ref command is invalid")
|
||||
old_sha, new_sha, raw_ref = fields
|
||||
if old_sha != ZERO_SHA or new_sha == ZERO_SHA:
|
||||
raise PolicyError("Git broker permits only new feature-branch creation")
|
||||
try:
|
||||
ref = raw_ref.decode("ascii")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise PolicyError("Git ref must be canonical ASCII") from exc
|
||||
if not FEATURE_REF_RE.fullmatch(ref):
|
||||
raise PolicyError("Git push is limited to namespaced feature branches")
|
||||
_validate_ref(ref.removeprefix("refs/heads/"), "head", forbidden=(token,))
|
||||
|
||||
|
||||
def _scan_payload(payload: BinaryIO, forbidden: tuple[bytes, ...]) -> None:
|
||||
"""Scan one decompressed object payload across chunk boundaries."""
|
||||
carry = b""
|
||||
while True:
|
||||
chunk = payload.read(SCAN_CHUNK)
|
||||
if not chunk:
|
||||
return
|
||||
window = carry + chunk
|
||||
if any(form in window for form in forbidden):
|
||||
raise PolicyError("Git push contains runtime credential material")
|
||||
if any(pattern.search(window) for pattern in CONTENT_SECRET_PATTERNS):
|
||||
raise PolicyError("Git push contains credential-shaped content")
|
||||
carry = window[-SCAN_CARRY:]
|
||||
|
||||
|
||||
def _scan_pack(stream: BinaryIO, forbidden: tuple[bytes, ...]) -> None:
|
||||
objects = git_pack_objects.unpack_objects(stream)
|
||||
try:
|
||||
if stream.read(1):
|
||||
raise PolicyError("Git request has bytes after its pack")
|
||||
for _type_code, payload in objects:
|
||||
_scan_payload(payload, forbidden)
|
||||
finally:
|
||||
for _type_code, payload in objects:
|
||||
payload.close()
|
||||
|
||||
|
||||
def validate_receive_pack(
|
||||
body: bytes | BinaryIO, token: str, forbidden: tuple[bytes, ...]
|
||||
) -> None:
|
||||
"""Validate one whole receive-pack request: commands, pack, and content."""
|
||||
stream = io.BytesIO(body) if isinstance(body, bytes) else body
|
||||
stream.seek(0)
|
||||
try:
|
||||
commands = 0
|
||||
while True:
|
||||
line = _read_pkt_line(stream)
|
||||
if line is None:
|
||||
break
|
||||
_validate_command(line, token, forbidden)
|
||||
commands += 1
|
||||
if commands > MAX_PUSH_COMMANDS:
|
||||
raise PolicyError("Git push contains too many ref commands")
|
||||
if commands == 0:
|
||||
raise PolicyError("Git receive-pack request has no ref command")
|
||||
_scan_pack(stream, forbidden)
|
||||
finally:
|
||||
stream.seek(0)
|
||||
|
||||
|
||||
def scan_loose_object(value: bytes, forbidden: tuple[bytes, ...]) -> int:
|
||||
"""Scan one quarantined loose object and return its parsed type code."""
|
||||
type_code, payload = git_pack_objects.parse_loose_object(value)
|
||||
_scan_payload(io.BytesIO(payload), forbidden)
|
||||
return type_code
|
||||
455
services/hermes/scm-common/scripts/scm_broker.py
Normal file
455
services/hermes/scm-common/scripts/scm_broker.py
Normal file
@ -0,0 +1,455 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Serve the credential-isolated Atlas API and Git smart-HTTP boundary."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import socket
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from http.server import BaseHTTPRequestHandler
|
||||
from typing import BinaryIO
|
||||
|
||||
import deadline_http
|
||||
from gitea_api import (
|
||||
CANONICAL_BASE_URL,
|
||||
PolicyError,
|
||||
create_draft,
|
||||
read,
|
||||
read_token,
|
||||
)
|
||||
from gitea_api_policy import _reject_forbidden, _validate_repo
|
||||
from receive_pack_scan import validate_receive_pack
|
||||
from scm_broker_io import RejectRedirect, response_status as _status, spool_response
|
||||
from scm_broker_server import AbsoluteHeaderDeadlineMixin, BoundedThreadingHTTPServer
|
||||
|
||||
BROKER_PORT = 9081
|
||||
GIT_USER = "hermes-automation"
|
||||
MAX_CONTROL_BODY = 64 * 1024
|
||||
MAX_GIT_REQUEST = 128 * 1024 * 1024
|
||||
MAX_GIT_RESPONSE = 128 * 1024 * 1024
|
||||
MAX_HEADERS = 32
|
||||
MAX_HEADER_BYTES = 16 * 1024
|
||||
SPOOL_MEMORY_LIMIT = 1024 * 1024
|
||||
STREAM_CHUNK = 64 * 1024
|
||||
INBOUND_HEADER_TIMEOUT = 10.0
|
||||
INBOUND_BODY_TIMEOUT = 120.0
|
||||
UPSTREAM_DEADLINE_SECONDS = 300.0
|
||||
GIT_PATH_RE = re.compile(
|
||||
r"/git/atlas/(?P<repo>[A-Za-z0-9][A-Za-z0-9._-]{0,99})\.git/"
|
||||
r"(?P<operation>info/refs|git-upload-pack|git-receive-pack)\Z"
|
||||
)
|
||||
|
||||
|
||||
def _read_bounded(
|
||||
stream,
|
||||
maximum: int,
|
||||
length: int | None = None,
|
||||
*,
|
||||
deadline: float | None = None,
|
||||
set_timeout=None,
|
||||
) -> bytes:
|
||||
if length is not None and not 0 <= length <= maximum:
|
||||
raise PolicyError("SCM request exceeds the safe size limit")
|
||||
wanted = maximum + 1 if length is None else length
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
while total < wanted:
|
||||
if deadline is not None:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise PolicyError("SCM request body deadline exceeded")
|
||||
if set_timeout is not None:
|
||||
set_timeout(remaining)
|
||||
chunk = stream.read(min(STREAM_CHUNK, wanted - total))
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
total += len(chunk)
|
||||
value = b"".join(chunks)
|
||||
if len(value) > maximum or (length is not None and len(value) != length):
|
||||
raise PolicyError("SCM request exceeds the safe size limit")
|
||||
return value
|
||||
|
||||
|
||||
def _spool_bounded(
|
||||
stream,
|
||||
maximum: int,
|
||||
length: int,
|
||||
*,
|
||||
token: str,
|
||||
context: str,
|
||||
deadline: float,
|
||||
set_timeout=None,
|
||||
) -> tuple[BinaryIO, int]:
|
||||
"""Copy a fixed-length exchange through a bounded-memory disk spool."""
|
||||
if not 0 <= length <= maximum:
|
||||
raise PolicyError("SCM request exceeds the safe size limit")
|
||||
spool = tempfile.SpooledTemporaryFile( # noqa: SIM115 - caller owns returned spool
|
||||
max_size=SPOOL_MEMORY_LIMIT, dir="/tmp"
|
||||
)
|
||||
remaining = length
|
||||
forms = _credential_forms(token)
|
||||
carry = b""
|
||||
try:
|
||||
while remaining:
|
||||
remaining_time = deadline - time.monotonic()
|
||||
if remaining_time <= 0:
|
||||
raise PolicyError("SCM request body deadline exceeded")
|
||||
if set_timeout is not None:
|
||||
set_timeout(remaining_time)
|
||||
chunk = stream.read(min(STREAM_CHUNK, remaining))
|
||||
if not chunk:
|
||||
raise PolicyError("SCM request body ended early")
|
||||
candidate = carry + chunk
|
||||
if any(form in candidate for form in forms):
|
||||
raise PolicyError(f"{context} contains runtime credential material")
|
||||
width = max(len(form) for form in forms) - 1
|
||||
carry = candidate[-width:] if width else b""
|
||||
spool.write(chunk)
|
||||
remaining -= len(chunk)
|
||||
spool.seek(0)
|
||||
return spool, length
|
||||
except Exception:
|
||||
spool.close()
|
||||
raise
|
||||
|
||||
|
||||
def _spool_response(
|
||||
response, maximum: int, token: str, deadline_seconds: float
|
||||
) -> tuple[BinaryIO, int]:
|
||||
return spool_response(
|
||||
response,
|
||||
maximum,
|
||||
_credential_forms(token),
|
||||
memory_limit=SPOOL_MEMORY_LIMIT,
|
||||
chunk_size=STREAM_CHUNK,
|
||||
deadline_seconds=deadline_seconds,
|
||||
)
|
||||
|
||||
|
||||
def _load_json(handler: BaseHTTPRequestHandler) -> dict[str, object]:
|
||||
if handler.headers.get("Transfer-Encoding"):
|
||||
raise PolicyError("chunked broker control requests are not allowed")
|
||||
if handler.headers.get_content_type() != "application/json":
|
||||
raise PolicyError("broker control request must be JSON")
|
||||
length = _content_length(handler.headers, MAX_CONTROL_BODY)
|
||||
deadline = time.monotonic() + INBOUND_BODY_TIMEOUT
|
||||
value = json.loads(
|
||||
_read_bounded(
|
||||
handler.rfile,
|
||||
MAX_CONTROL_BODY,
|
||||
length,
|
||||
deadline=deadline,
|
||||
set_timeout=handler.connection.settimeout,
|
||||
)
|
||||
)
|
||||
if not isinstance(value, dict):
|
||||
raise PolicyError("broker control request must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _git_target(raw: str) -> tuple[str, str, str]:
|
||||
if (
|
||||
not raw.isascii()
|
||||
or any(ord(character) < 32 or ord(character) == 127 for character in raw)
|
||||
or "%" in raw
|
||||
or "\\" in raw
|
||||
or len(raw) > 512
|
||||
):
|
||||
raise PolicyError("Git target is not canonical ASCII")
|
||||
target = urllib.parse.urlsplit(raw)
|
||||
canonical = urllib.parse.urlunsplit(("", "", target.path, target.query, ""))
|
||||
if canonical != raw:
|
||||
raise PolicyError("Git target is not in exact canonical form")
|
||||
if target.scheme or target.netloc or target.fragment:
|
||||
raise PolicyError("Git target must be relative")
|
||||
match = GIT_PATH_RE.fullmatch(target.path)
|
||||
if not match:
|
||||
raise PolicyError("Git target is outside the Atlas allowlist")
|
||||
repo = _validate_repo(match.group("repo"))
|
||||
operation = match.group("operation")
|
||||
query = target.query
|
||||
if operation == "info/refs":
|
||||
if query not in {"service=git-upload-pack", "service=git-receive-pack"}:
|
||||
raise PolicyError("Git discovery service is outside the allowlist")
|
||||
service = query.removeprefix("service=")
|
||||
elif query:
|
||||
raise PolicyError("Git RPC query parameters are not allowed")
|
||||
else:
|
||||
service = operation
|
||||
return repo, operation, service
|
||||
|
||||
|
||||
def _content_length(headers: object, maximum: int) -> int:
|
||||
"""Parse one short canonical bounded Content-Length header."""
|
||||
raw = headers.get("Content-Length", "") # type: ignore[attr-defined]
|
||||
if (
|
||||
not isinstance(raw, str)
|
||||
or not raw.isascii()
|
||||
or len(raw) > 10
|
||||
or not raw.isdigit()
|
||||
):
|
||||
raise PolicyError("SCM request length is invalid")
|
||||
length = int(raw)
|
||||
if raw != str(length) or not 0 <= length <= maximum:
|
||||
raise PolicyError("SCM request length is invalid")
|
||||
return length
|
||||
|
||||
|
||||
def _credential_forms(token: str) -> tuple[bytes, ...]:
|
||||
basic = base64.b64encode(f"{GIT_USER}:{token}".encode())
|
||||
return token.encode("utf-8"), basic, b"Basic " + basic
|
||||
|
||||
|
||||
def _validate_receive_pack(body: bytes | BinaryIO, token: str) -> None:
|
||||
"""Permit only vetted new-feature-branch pushes with scanned contents."""
|
||||
validate_receive_pack(body, token, _credential_forms(token))
|
||||
|
||||
|
||||
def _guarded_opener(guard: deadline_http.StreamDeadline):
|
||||
"""Build an opener whose connection obeys one absolute stream deadline."""
|
||||
return urllib.request.build_opener(RejectRedirect(), *guard.handlers()).open
|
||||
|
||||
|
||||
def _upstream_git_request(
|
||||
target: str,
|
||||
*,
|
||||
method: str,
|
||||
body: bytes | BinaryIO | None,
|
||||
content_type: str | None,
|
||||
expected_type: str,
|
||||
token: str,
|
||||
body_length: int | None = None,
|
||||
stream_result: bool = False,
|
||||
opener=None,
|
||||
) -> bytes | tuple[BinaryIO, int]:
|
||||
credentials = base64.b64encode(f"{GIT_USER}:{token}".encode()).decode("ascii")
|
||||
headers = {"Accept": expected_type, "Authorization": f"Basic {credentials}"}
|
||||
if content_type:
|
||||
headers["Content-Type"] = content_type
|
||||
if body is not None:
|
||||
if body_length is None:
|
||||
if not isinstance(body, bytes):
|
||||
raise PolicyError("Git upstream request length is missing")
|
||||
body_length = len(body)
|
||||
headers["Content-Length"] = str(body_length)
|
||||
request = urllib.request.Request(
|
||||
CANONICAL_BASE_URL + target,
|
||||
data=body,
|
||||
method=method,
|
||||
headers=headers,
|
||||
)
|
||||
# One wall-clock budget covers connect, send, headers, and body read; the
|
||||
# watchdog closes the upstream socket if any phase overstays it.
|
||||
guard = deadline_http.StreamDeadline(UPSTREAM_DEADLINE_SECONDS)
|
||||
try:
|
||||
open_upstream = opener if opener is not None else _guarded_opener(guard)
|
||||
with open_upstream(request, timeout=guard.remaining()) as response:
|
||||
if _status(response) != 200:
|
||||
raise PolicyError("Git upstream returned an unexpected HTTP status")
|
||||
if response.headers.get_content_type() != expected_type:
|
||||
raise PolicyError("Git upstream returned an unexpected response type")
|
||||
result, result_length = _spool_response(
|
||||
response, MAX_GIT_RESPONSE, token, guard.remaining()
|
||||
)
|
||||
finally:
|
||||
guard.cancel()
|
||||
if stream_result:
|
||||
return result, result_length
|
||||
try:
|
||||
return result.read()
|
||||
finally:
|
||||
result.close()
|
||||
|
||||
|
||||
class BrokerHandler(AbsoluteHeaderDeadlineMixin, BaseHTTPRequestHandler):
|
||||
"""Expose only bounded metadata, draft creation, and smart-HTTP Git."""
|
||||
|
||||
server_version = "HermesSCMBroker/1"
|
||||
sys_version = ""
|
||||
header_deadline_seconds = INBOUND_HEADER_TIMEOUT
|
||||
|
||||
def _validate_headers(self) -> None:
|
||||
items = list(self.headers.items())
|
||||
if len(items) > MAX_HEADERS:
|
||||
raise PolicyError("SCM request has too many headers")
|
||||
total = 0
|
||||
for name, value in items:
|
||||
if not name.isascii() or not value.isascii():
|
||||
raise PolicyError("SCM request headers must be ASCII")
|
||||
total += len(name) + len(value) + 4
|
||||
if any(ord(character) < 32 and character != "\t" for character in value):
|
||||
raise PolicyError("SCM request header contains controls")
|
||||
if total > MAX_HEADER_BYTES:
|
||||
raise PolicyError("SCM request headers exceed the safe limit")
|
||||
if len(self.headers.get_all("Content-Length", [])) > 1:
|
||||
raise PolicyError("SCM request has duplicate Content-Length")
|
||||
|
||||
def _stream(
|
||||
self, status: int, content_type: str, body: BinaryIO, length: int
|
||||
) -> None:
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Content-Length", str(length))
|
||||
self.end_headers()
|
||||
deadline = time.monotonic() + INBOUND_BODY_TIMEOUT
|
||||
while True:
|
||||
chunk = body.read(STREAM_CHUNK)
|
||||
if not chunk:
|
||||
break
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise PolicyError("SCM response write deadline exceeded")
|
||||
self.connection.settimeout(remaining)
|
||||
self.wfile.write(chunk)
|
||||
|
||||
def log_message(self, _format: str, *_args: object) -> None:
|
||||
return
|
||||
|
||||
def _json(self, status: int, body: bytes) -> None:
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _reject(self, status: int = 400) -> None:
|
||||
self._json(status, b'{"error":"request rejected"}')
|
||||
|
||||
def do_GET(self) -> None:
|
||||
try:
|
||||
self._validate_headers()
|
||||
if self.path == "/healthz":
|
||||
self._json(200, b'{"status":"ok"}')
|
||||
return
|
||||
repo, operation, service = _git_target(self.path)
|
||||
if operation != "info/refs":
|
||||
raise PolicyError("Git RPC requires POST")
|
||||
token = read_token()
|
||||
_reject_forbidden(repo, "repository", (token,))
|
||||
expected = f"application/x-{service}-advertisement"
|
||||
streamed = _upstream_git_request(
|
||||
f"/atlas/{repo}.git/info/refs?service={service}",
|
||||
method="GET",
|
||||
body=None,
|
||||
content_type=None,
|
||||
expected_type=expected,
|
||||
token=token,
|
||||
stream_result=True,
|
||||
)
|
||||
body, length = streamed
|
||||
try:
|
||||
self._stream(200, expected, body, length)
|
||||
finally:
|
||||
body.close()
|
||||
except (
|
||||
OSError,
|
||||
PolicyError,
|
||||
socket.timeout,
|
||||
urllib.error.URLError,
|
||||
ValueError,
|
||||
):
|
||||
self._reject()
|
||||
|
||||
def do_POST(self) -> None:
|
||||
try:
|
||||
self._validate_headers()
|
||||
self.connection.settimeout(INBOUND_BODY_TIMEOUT)
|
||||
if self.path in {"/v1/metadata", "/v1/drafts"}:
|
||||
self._control()
|
||||
else:
|
||||
self._git_rpc()
|
||||
except (
|
||||
OSError,
|
||||
PolicyError,
|
||||
socket.timeout,
|
||||
urllib.error.URLError,
|
||||
ValueError,
|
||||
json.JSONDecodeError,
|
||||
):
|
||||
self._reject()
|
||||
|
||||
def _control(self) -> None:
|
||||
data = _load_json(self)
|
||||
token = read_token()
|
||||
if self.path == "/v1/metadata":
|
||||
if set(data) != {"path"} or not isinstance(data["path"], str):
|
||||
raise PolicyError("metadata request fields are invalid")
|
||||
result = read(data["path"], token=token)
|
||||
else:
|
||||
expected = {"base", "body", "head", "head_sha", "repo", "title"}
|
||||
if set(data) != expected or not all(
|
||||
isinstance(data[key], str) for key in expected
|
||||
):
|
||||
raise PolicyError("draft request fields are invalid")
|
||||
result = create_draft(token=token, **data) # type: ignore[arg-type]
|
||||
if token.encode("utf-8") in result:
|
||||
raise PolicyError("SCM upstream reflected credential material")
|
||||
self._json(200, result)
|
||||
|
||||
def _git_rpc(self) -> None:
|
||||
repo, operation, service = _git_target(self.path)
|
||||
if (
|
||||
operation not in {"git-upload-pack", "git-receive-pack"}
|
||||
or service != operation
|
||||
):
|
||||
raise PolicyError("Git RPC operation is outside the allowlist")
|
||||
expected_request = f"application/x-{service}-request"
|
||||
if self.headers.get_content_type() != expected_request or self.headers.get(
|
||||
"Transfer-Encoding"
|
||||
):
|
||||
raise PolicyError("Git RPC request type is invalid")
|
||||
length = _content_length(self.headers, MAX_GIT_REQUEST)
|
||||
token = read_token()
|
||||
_reject_forbidden(repo, "repository", (token,))
|
||||
body, body_length = _spool_bounded(
|
||||
self.rfile,
|
||||
MAX_GIT_REQUEST,
|
||||
length,
|
||||
token=token,
|
||||
context="Git request",
|
||||
deadline=time.monotonic() + INBOUND_BODY_TIMEOUT,
|
||||
set_timeout=self.connection.settimeout,
|
||||
)
|
||||
try:
|
||||
if service == "git-receive-pack":
|
||||
_validate_receive_pack(body, token)
|
||||
expected = f"application/x-{service}-result"
|
||||
streamed = _upstream_git_request(
|
||||
f"/atlas/{repo}.git/{service}",
|
||||
method="POST",
|
||||
body=body,
|
||||
body_length=body_length,
|
||||
content_type=expected_request,
|
||||
expected_type=expected,
|
||||
token=token,
|
||||
stream_result=True,
|
||||
)
|
||||
result, result_length = streamed
|
||||
try:
|
||||
self._stream(200, expected, result, result_length)
|
||||
finally:
|
||||
result.close()
|
||||
finally:
|
||||
body.close()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--listen", default="0.0.0.0")
|
||||
parser.add_argument("--port", type=int, default=BROKER_PORT)
|
||||
args = parser.parse_args()
|
||||
BoundedThreadingHTTPServer((args.listen, args.port), BrokerHandler).serve_forever()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
93
services/hermes/scm-common/scripts/scm_broker_client.py
Normal file
93
services/hermes/scm-common/scripts/scm_broker_client.py
Normal file
@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Call the credential-isolated Hermes SCM broker over its fixed cluster origin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import urllib.request
|
||||
from collections.abc import Callable
|
||||
|
||||
import deadline_http
|
||||
from gitea_api_policy import PolicyError
|
||||
|
||||
BROKER_ORIGIN = "http://hermes-scm-broker.hermes-scm.svc.cluster.local:9081"
|
||||
MAX_RESPONSE_BYTES = 2 * 1024 * 1024
|
||||
|
||||
|
||||
def _open(request: urllib.request.Request, timeout: int):
|
||||
"""Open one broker exchange under a killable absolute deadline.
|
||||
|
||||
The helper process keeps every request on its fixed in-cluster origin by
|
||||
rejecting redirects before they are followed.
|
||||
"""
|
||||
return deadline_http.open_bounded(
|
||||
request, maximum=MAX_RESPONSE_BYTES, timeout=timeout
|
||||
)
|
||||
|
||||
|
||||
def request(
|
||||
endpoint: str,
|
||||
payload: dict[str, object],
|
||||
*,
|
||||
opener: Callable[..., object] = _open,
|
||||
) -> bytes:
|
||||
"""Send one bounded broker operation without any repository credential."""
|
||||
if endpoint not in {"/v1/metadata", "/v1/drafts"}:
|
||||
raise PolicyError("SCM broker operation is outside the client allowlist")
|
||||
body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
||||
if len(body) > 64 * 1024:
|
||||
raise PolicyError("SCM broker request exceeds the safe size limit")
|
||||
outgoing = urllib.request.Request(
|
||||
BROKER_ORIGIN + endpoint,
|
||||
data=body,
|
||||
method="POST",
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "hermes-scm-broker-client/1",
|
||||
},
|
||||
)
|
||||
with opener(outgoing, 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 != 200:
|
||||
raise PolicyError("SCM broker returned an unexpected HTTP status")
|
||||
content_type = response.headers.get_content_type() # type: ignore[attr-defined]
|
||||
if content_type != "application/json":
|
||||
raise PolicyError("SCM broker returned an unexpected response type")
|
||||
result = response.read(MAX_RESPONSE_BYTES + 1) # type: ignore[attr-defined]
|
||||
if len(result) > MAX_RESPONSE_BYTES:
|
||||
raise PolicyError("SCM broker response exceeds the safe size limit")
|
||||
json.loads(result)
|
||||
return result
|
||||
|
||||
|
||||
def read(path: str, *, opener: Callable[..., object] = _open) -> bytes:
|
||||
"""Read explicitly allowed Atlas metadata through the broker."""
|
||||
return request("/v1/metadata", {"path": path}, opener=opener)
|
||||
|
||||
|
||||
def create_draft(
|
||||
repo: str,
|
||||
*,
|
||||
base: str,
|
||||
head: str,
|
||||
head_sha: str,
|
||||
title: str,
|
||||
body: str,
|
||||
opener: Callable[..., object] = _open,
|
||||
) -> bytes:
|
||||
"""Create one verified draft through the broker for human review."""
|
||||
return request(
|
||||
"/v1/drafts",
|
||||
{
|
||||
"base": base,
|
||||
"body": body,
|
||||
"head": head,
|
||||
"head_sha": head_sha,
|
||||
"repo": repo,
|
||||
"title": title,
|
||||
},
|
||||
opener=opener,
|
||||
)
|
||||
72
services/hermes/scm-common/scripts/scm_broker_io.py
Normal file
72
services/hermes/scm-common/scripts/scm_broker_io.py
Normal file
@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bounded-memory stream helpers for the Hermes SCM broker."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.request
|
||||
from typing import BinaryIO
|
||||
|
||||
from gitea_api_policy import PolicyError
|
||||
|
||||
|
||||
class RejectRedirect(urllib.request.HTTPRedirectHandler):
|
||||
"""Reject every upstream redirect before credentials can be forwarded."""
|
||||
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
raise PolicyError("SCM upstream redirects are not allowed")
|
||||
|
||||
|
||||
def response_status(response: object) -> int | None:
|
||||
"""Read an HTTP status from either urllib response interface."""
|
||||
value = getattr(response, "status", None)
|
||||
if value is None and hasattr(response, "getcode"):
|
||||
value = response.getcode() # type: ignore[attr-defined]
|
||||
return value
|
||||
|
||||
|
||||
def spool_response(
|
||||
response,
|
||||
maximum: int,
|
||||
forbidden: tuple[bytes, ...],
|
||||
*,
|
||||
memory_limit: int,
|
||||
chunk_size: int,
|
||||
deadline_seconds: float,
|
||||
) -> tuple[BinaryIO, int]:
|
||||
"""Spool an upstream response to disk while scanning chunk boundaries."""
|
||||
spool = tempfile.SpooledTemporaryFile( # noqa: SIM115 - caller owns returned spool
|
||||
max_size=memory_limit, dir="/tmp"
|
||||
)
|
||||
carry = b""
|
||||
total = 0
|
||||
deadline = time.monotonic() + deadline_seconds
|
||||
try:
|
||||
while True:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise PolicyError("SCM upstream response deadline exceeded")
|
||||
raw = getattr(getattr(response, "fp", None), "raw", None)
|
||||
sock = getattr(raw, "_sock", raw)
|
||||
if hasattr(sock, "settimeout"):
|
||||
sock.settimeout(remaining)
|
||||
chunk = response.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > maximum:
|
||||
raise PolicyError("SCM upstream response exceeds the safe size limit")
|
||||
candidate = carry + chunk
|
||||
if any(value in candidate for value in forbidden):
|
||||
raise PolicyError(
|
||||
"Git upstream response contains runtime credential material"
|
||||
)
|
||||
width = max(len(value) for value in forbidden) - 1
|
||||
carry = candidate[-width:] if width else b""
|
||||
spool.write(chunk)
|
||||
spool.seek(0)
|
||||
return spool, total
|
||||
except Exception:
|
||||
spool.close()
|
||||
raise
|
||||
118
services/hermes/scm-common/scripts/scm_broker_server.py
Normal file
118
services/hermes/scm-common/scripts/scm_broker_server.py
Normal file
@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Concurrency-bounded threaded HTTP server for the Hermes SCM broker."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from http.server import ThreadingHTTPServer
|
||||
|
||||
MAX_CONCURRENT_REQUESTS = 3
|
||||
|
||||
|
||||
class _AbsoluteDeadlineReader:
|
||||
"""Read header lines against one wall-clock deadline, not idle timeouts."""
|
||||
|
||||
def __init__(self, stream, connection):
|
||||
self._stream = stream
|
||||
self._connection = connection
|
||||
self._deadline: float | None = None
|
||||
|
||||
def begin(self, timeout: float) -> None:
|
||||
self._deadline = time.monotonic() + timeout
|
||||
|
||||
def end(self) -> None:
|
||||
self._deadline = None
|
||||
|
||||
def readline(self, limit: int = -1) -> bytes:
|
||||
if self._deadline is None:
|
||||
return self._stream.readline(limit)
|
||||
value = bytearray()
|
||||
while limit < 0 or len(value) < limit:
|
||||
remaining = self._deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise TimeoutError("absolute SCM header deadline exceeded")
|
||||
self._connection.settimeout(remaining)
|
||||
character = self._stream.read(1)
|
||||
if not character:
|
||||
break
|
||||
value.extend(character)
|
||||
if character == b"\n":
|
||||
break
|
||||
return bytes(value)
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
return getattr(self._stream, name)
|
||||
|
||||
|
||||
class AbsoluteHeaderDeadlineMixin:
|
||||
"""Apply an absolute request-line plus header deadline to HTTP handlers."""
|
||||
|
||||
header_deadline_seconds = 10.0
|
||||
|
||||
def setup(self) -> None:
|
||||
super().setup()
|
||||
self._header_reader = _AbsoluteDeadlineReader(self.rfile, self.connection)
|
||||
self.rfile = self._header_reader
|
||||
|
||||
def handle_one_request(self) -> None:
|
||||
try:
|
||||
self._header_reader.begin(self.header_deadline_seconds)
|
||||
try:
|
||||
self.raw_requestline = self.rfile.readline(65537)
|
||||
if len(self.raw_requestline) > 65536:
|
||||
self.requestline = ""
|
||||
self.request_version = ""
|
||||
self.command = ""
|
||||
self.send_error(414)
|
||||
return
|
||||
if not self.raw_requestline:
|
||||
self.close_connection = True
|
||||
return
|
||||
if not self.parse_request():
|
||||
return
|
||||
finally:
|
||||
self._header_reader.end()
|
||||
method_name = "do_" + self.command
|
||||
if not hasattr(self, method_name):
|
||||
self.send_error(501, "Unsupported method")
|
||||
return
|
||||
getattr(self, method_name)()
|
||||
self.wfile.flush()
|
||||
except TimeoutError:
|
||||
self.close_connection = True
|
||||
|
||||
|
||||
class BoundedThreadingHTTPServer(ThreadingHTTPServer):
|
||||
"""Cap active handlers so large Git exchanges cannot exhaust memory."""
|
||||
|
||||
daemon_threads = True
|
||||
block_on_close = True
|
||||
request_queue_size = 16
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self._slots = threading.BoundedSemaphore(MAX_CONCURRENT_REQUESTS)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def process_request(self, request, client_address) -> None:
|
||||
if not self._slots.acquire(blocking=False):
|
||||
try:
|
||||
request.sendall(
|
||||
b"HTTP/1.1 503 Service Unavailable\r\n"
|
||||
b"Connection: close\r\nContent-Length: 0\r\n\r\n"
|
||||
)
|
||||
finally:
|
||||
self.shutdown_request(request)
|
||||
return
|
||||
try:
|
||||
super().process_request(request, client_address)
|
||||
except Exception:
|
||||
self._slots.release()
|
||||
self.shutdown_request(request)
|
||||
raise
|
||||
|
||||
def process_request_thread(self, request, client_address) -> None:
|
||||
try:
|
||||
super().process_request_thread(request, client_address)
|
||||
finally:
|
||||
self._slots.release()
|
||||
@ -1,150 +0,0 @@
|
||||
#!/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"
|
||||
)
|
||||
data_group.add_argument(
|
||||
"--field",
|
||||
action="append",
|
||||
metavar="KEY=VALUE",
|
||||
help=(
|
||||
"repeatable JSON field; bare text remains a string while true, false, "
|
||||
"null, numbers, objects, and arrays are decoded as JSON"
|
||||
),
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def parse_fields(values: list[str]) -> dict[str, object]:
|
||||
"""Build a JSON object from shell-safe, repeatable key/value arguments."""
|
||||
result: dict[str, object] = {}
|
||||
for value in values:
|
||||
key, separator, raw = value.partition("=")
|
||||
if not separator or not key or any(char.isspace() for char in key):
|
||||
raise ValueError("each --field must be KEY=VALUE with a non-space key")
|
||||
try:
|
||||
result[key] = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
result[key] = raw
|
||||
return result
|
||||
|
||||
|
||||
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"))
|
||||
if args.field is not None:
|
||||
return parse_fields(args.field)
|
||||
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())
|
||||
@ -1,19 +0,0 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
case "${1:-}" in
|
||||
*Username*)
|
||||
if [ -s /runtime-access/gitea-username ]; then
|
||||
tr -d '\r\n' </runtime-access/gitea-username
|
||||
else
|
||||
printf '%s' hermes-automation
|
||||
fi
|
||||
printf '\n'
|
||||
;;
|
||||
*Password*)
|
||||
test -s /runtime-access/gitea-token
|
||||
tr -d '\r\n' </runtime-access/gitea-token
|
||||
printf '\n'
|
||||
;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
@ -24,8 +24,10 @@ from hermes_model_routing import (
|
||||
|
||||
|
||||
CASSANDRA_BASE_PATH = Path("/opt/data/workspace/projects/cassandra")
|
||||
CASSANDRA_REMOTE = "https://scm.bstein.dev/atlas/cassandra.git"
|
||||
DEFAULT_GITEA_TOKEN_PATH = Path("/runtime-access/gitea-token")
|
||||
CASSANDRA_REMOTE = (
|
||||
"http://hermes-scm-broker.hermes-scm.svc.cluster.local:9081/"
|
||||
"git/atlas/cassandra.git"
|
||||
)
|
||||
|
||||
|
||||
def cassandra_workspace() -> Path:
|
||||
@ -139,24 +141,8 @@ def bootstrap_cassandra_state(root: Path) -> dict[str, str]:
|
||||
return {"state": "ready"}
|
||||
|
||||
|
||||
def _gitea_token_path() -> Path:
|
||||
"""Return the runtime-only Gitea credential path without reading it."""
|
||||
configured = os.environ.get("HERMES_GITEA_TOKEN_FILE", "").strip()
|
||||
return Path(configured) if configured else DEFAULT_GITEA_TOKEN_PATH
|
||||
|
||||
|
||||
def _gitea_token_available() -> bool:
|
||||
"""Check credential readiness without loading it into process memory."""
|
||||
try:
|
||||
path = _gitea_token_path()
|
||||
return path.is_file() and path.stat().st_size > 0
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def sync_cassandra_repo(env_values: dict[str, str]) -> str:
|
||||
"""Clone or fetch Cassandra through the runtime-only askpass credential."""
|
||||
token_available = _gitea_token_available()
|
||||
"""Clone or fetch Cassandra through the credential-isolated SCM broker."""
|
||||
if shutil.which("git") is None:
|
||||
return "git-unavailable"
|
||||
child_env = os.environ.copy()
|
||||
@ -173,9 +159,7 @@ def sync_cassandra_repo(env_values: dict[str, str]) -> str:
|
||||
}
|
||||
}
|
||||
)
|
||||
child_env["GIT_ASKPASS"] = env_values.get(
|
||||
"GIT_ASKPASS", "/opt/coordinator/gitea_askpass.sh"
|
||||
)
|
||||
child_env.pop("GIT_ASKPASS", None)
|
||||
child_env["GIT_TERMINAL_PROMPT"] = "0"
|
||||
if (CASSANDRA_BASE_PATH / ".git").exists():
|
||||
try:
|
||||
@ -232,8 +216,6 @@ def sync_cassandra_repo(env_values: dict[str, str]) -> str:
|
||||
return f"remote-repair-failed-{repaired.returncode}"
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return "remote-repair-failed"
|
||||
if not token_available:
|
||||
return "ready; fetch skipped until Gitea token is configured"
|
||||
command = [
|
||||
"git",
|
||||
"-C",
|
||||
@ -247,8 +229,6 @@ def sync_cassandra_repo(env_values: dict[str, str]) -> str:
|
||||
CASSANDRA_BASE_PATH.mkdir(parents=True, exist_ok=True)
|
||||
if any(CASSANDRA_BASE_PATH.iterdir()):
|
||||
return "unmanaged-nonempty-directory"
|
||||
if not token_available:
|
||||
return "awaiting-gitea-token"
|
||||
command = [
|
||||
"git",
|
||||
"clone",
|
||||
|
||||
112
services/hermes/scripts/jenkins_image_build_trigger.py
Executable file
112
services/hermes/scripts/jenkins_image_build_trigger.py
Executable file
@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Trigger only the reviewed-main Hermes agent image release job."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
JENKINS_ORIGIN = "https://ci.bstein.dev"
|
||||
JENKINS_BUILD_URL = f"{JENKINS_ORIGIN}/buildByToken/buildWithParameters"
|
||||
JOB_NAME = "hermes-agent-image"
|
||||
TOKEN_FILE = Path("/runtime-access/jenkins-image-build-token")
|
||||
REVISION_PATTERN = re.compile(r"^[0-9a-f]{40}$")
|
||||
QUEUE_PATH_PATTERN = re.compile(r"^/queue/item/[0-9]+/?$")
|
||||
|
||||
|
||||
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
"""Keep a queued-build redirect from becoming an unauthorized job read."""
|
||||
|
||||
def redirect_request(self, _request, _file, _code, _message, _headers, _url):
|
||||
return None
|
||||
|
||||
|
||||
def _open_without_redirect(request: urllib.request.Request, timeout: int):
|
||||
"""Return the Build Token Root response, including its expected HTTP 303."""
|
||||
opener = urllib.request.build_opener(_NoRedirect())
|
||||
try:
|
||||
return opener.open(request, timeout=timeout)
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code == 303:
|
||||
return exc
|
||||
raise
|
||||
|
||||
|
||||
def trigger_build(
|
||||
revision: str,
|
||||
*,
|
||||
token_file: Path = TOKEN_FILE,
|
||||
opener=_open_without_redirect,
|
||||
) -> dict[str, str | int]:
|
||||
"""Post the fixed job parameters using its job-scoped build token."""
|
||||
revision = revision.strip()
|
||||
if not REVISION_PATTERN.fullmatch(revision):
|
||||
raise ValueError("revision must be a lowercase full 40-character commit")
|
||||
token = token_file.read_text(encoding="utf-8").strip()
|
||||
if not token:
|
||||
raise RuntimeError("Jenkins image-build token is empty")
|
||||
payload = urllib.parse.urlencode(
|
||||
{
|
||||
"job": JOB_NAME,
|
||||
"token": token,
|
||||
"PUBLISH_IMAGE": "true",
|
||||
"EXPECTED_SOURCE_REVISION": revision,
|
||||
"CONFIRM_PUBLISH": "PUBLISH HERMES AGENT",
|
||||
}
|
||||
).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
JENKINS_BUILD_URL,
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
method="POST",
|
||||
)
|
||||
with opener(request, timeout=20) as response:
|
||||
status = int(response.status)
|
||||
location = response.headers.get("Location", "")
|
||||
if status not in {201, 303}:
|
||||
raise RuntimeError(f"Jenkins trigger returned HTTP {status}")
|
||||
if not location:
|
||||
raise RuntimeError("Jenkins trigger omitted the queue Location")
|
||||
queue_url = urllib.parse.urljoin(f"{JENKINS_ORIGIN}/", location)
|
||||
parsed_queue = urllib.parse.urlsplit(queue_url)
|
||||
expected_origin = urllib.parse.urlsplit(JENKINS_ORIGIN)
|
||||
if (
|
||||
parsed_queue.scheme != expected_origin.scheme
|
||||
or parsed_queue.netloc != expected_origin.netloc
|
||||
or parsed_queue.query
|
||||
or parsed_queue.fragment
|
||||
or not QUEUE_PATH_PATTERN.fullmatch(parsed_queue.path)
|
||||
):
|
||||
raise RuntimeError("Jenkins returned an invalid queue Location")
|
||||
# Never return the submitted URL: its form body contains the job token.
|
||||
queue_path = parsed_queue.path
|
||||
return {
|
||||
"job": JOB_NAME,
|
||||
"queue_path": queue_path,
|
||||
"source_revision": revision,
|
||||
"status": status,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Validate one revision, trigger the bounded job, and print safe metadata."""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("revision", help="reviewed full commit currently on main")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
result = trigger_build(args.revision)
|
||||
except (OSError, ValueError, RuntimeError, urllib.error.URLError) as exc:
|
||||
print(json.dumps({"error": str(exc)}, sort_keys=True))
|
||||
return 1
|
||||
print(json.dumps(result, indent=2, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - exercised through main()
|
||||
raise SystemExit(main())
|
||||
267
services/hermes/scripts/node_account_audit.py
Normal file
267
services/hermes/scripts/node_account_audit.py
Normal file
@ -0,0 +1,267 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail-closed supplementary-group and host privilege-policy audits."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import stat
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
from node_account_io import HardeningError, read_regular
|
||||
from node_polkit_audit import audit_polkit_policy
|
||||
|
||||
|
||||
def _members(field: str, context: str) -> set[str]:
|
||||
if not field:
|
||||
return set()
|
||||
values = field.split(",")
|
||||
if any(not value or value.strip() != value for value in values):
|
||||
raise HardeningError(f"{context} has a malformed member list")
|
||||
if len(values) != len(set(values)):
|
||||
raise HardeningError(f"{context} has duplicate members")
|
||||
return set(values)
|
||||
|
||||
|
||||
def audit_membership(
|
||||
account: str, groups: list[list[str]], gshadow: list[list[str]]
|
||||
) -> None:
|
||||
"""Reject the dedicated account in any supplementary group/admin list."""
|
||||
for record in groups:
|
||||
if account in _members(record[3], f"group {record[0]}"):
|
||||
raise HardeningError("dedicated Hermes account has supplementary group access")
|
||||
for record in gshadow:
|
||||
principals = _members(record[2], f"gshadow {record[0]} admins")
|
||||
principals |= _members(record[3], f"gshadow {record[0]} members")
|
||||
if account in principals:
|
||||
raise HardeningError("dedicated Hermes account has gshadow group access")
|
||||
|
||||
|
||||
def _policy_files(host_etc: Path, polkit_share: Path, root_uid: int) -> list[Path]:
|
||||
files: list[Path] = []
|
||||
sudoers = host_etc / "sudoers"
|
||||
if sudoers.exists():
|
||||
files.append(sudoers)
|
||||
roots = (
|
||||
host_etc / "sudoers.d",
|
||||
host_etc / "polkit-1/rules.d",
|
||||
host_etc / "polkit-1/localauthority",
|
||||
polkit_share / "rules.d",
|
||||
polkit_share / "localauthority",
|
||||
)
|
||||
for root in roots:
|
||||
if not root.exists():
|
||||
continue
|
||||
metadata = root.lstat()
|
||||
if (
|
||||
not stat.S_ISDIR(metadata.st_mode)
|
||||
or stat.S_ISLNK(metadata.st_mode)
|
||||
or metadata.st_uid != root_uid
|
||||
or stat.S_IMODE(metadata.st_mode) & 0o022
|
||||
):
|
||||
raise HardeningError("unsafe sudo/polkit policy directory")
|
||||
files.extend(path for path in root.rglob("*") if path.is_file())
|
||||
if len(files) > 256:
|
||||
raise HardeningError("too many sudo/polkit policy files")
|
||||
return sorted(files)
|
||||
|
||||
|
||||
def _audit_policy_metadata(snapshot, root_uid: int, account_uid: int) -> None:
|
||||
if snapshot.uid != root_uid or snapshot.mode & 0o022:
|
||||
raise HardeningError("sudo/polkit policy metadata permits unsafe mutation")
|
||||
acl = dict(snapshot.xattrs).get("system.posix_acl_access", b"")
|
||||
if not acl:
|
||||
return
|
||||
header = struct.Struct("<I")
|
||||
entry = struct.Struct("<HHI")
|
||||
if len(acl) < header.size or (len(acl) - header.size) % entry.size:
|
||||
raise HardeningError("sudo/polkit policy ACL is malformed")
|
||||
for offset in range(header.size, len(acl), entry.size):
|
||||
tag, permissions, identifier = entry.unpack_from(acl, offset)
|
||||
if tag == 0x02 and identifier == account_uid and permissions & 0o2:
|
||||
raise HardeningError("sudo/polkit policy ACL grants Hermes write access")
|
||||
|
||||
|
||||
def _active_sudo_policy(text: str) -> str:
|
||||
"""Remove comments without discarding sudoers' numeric-UID principals."""
|
||||
active: list[str] = []
|
||||
for line in text.splitlines():
|
||||
stripped = line.lstrip()
|
||||
if not stripped:
|
||||
continue
|
||||
# Detect any include spelling before the comment rule below. sudo
|
||||
# honours both #include and @include; a quoted or space-bearing path
|
||||
# must never fall through the "#" comment branch and leave a second
|
||||
# authority file unaudited. Only the exact bare includedir into the
|
||||
# audited /etc/sudoers.d passes; everything else fails closed.
|
||||
if re.match(r"(?i)[#@]include(?:dir)?(?=\s|$)", stripped):
|
||||
include = re.fullmatch(
|
||||
r"(?i)([#@]include(?:dir)?)\s+([^\s\"'\\]+)", stripped
|
||||
)
|
||||
if include:
|
||||
directive, target = include.groups()
|
||||
if (
|
||||
directive.lower().endswith("includedir")
|
||||
and target == "/etc/sudoers.d"
|
||||
):
|
||||
continue
|
||||
raise HardeningError("sudo policy includes an unaudited authority source")
|
||||
if stripped.startswith("#") and not re.match(r"#\d+(?:\s|$)", stripped):
|
||||
continue
|
||||
active.append(line)
|
||||
return "\n".join(active)
|
||||
|
||||
|
||||
def _mentions_dedicated_identity(text: str, account: str, account_uid: int) -> bool:
|
||||
account_pattern = rf"(?<![A-Za-z0-9_.-])%?{re.escape(account)}(?![A-Za-z0-9_.-])"
|
||||
numeric_pattern = rf"(?<![0-9])#?{account_uid}(?![0-9])"
|
||||
return bool(re.search(account_pattern, text) or re.search(numeric_pattern, text))
|
||||
|
||||
|
||||
SUDO_ALIAS_NAME_RE = re.compile(r"[A-Z][A-Z0-9_]*\Z")
|
||||
|
||||
|
||||
def _joined_sudo_lines(active: str) -> list[str]:
|
||||
"""Merge sudoers backslash continuations into whole logical lines."""
|
||||
lines: list[str] = []
|
||||
pending = ""
|
||||
for line in active.splitlines():
|
||||
if line.endswith("\\"):
|
||||
pending += line[:-1] + " "
|
||||
continue
|
||||
lines.append(pending + line)
|
||||
pending = ""
|
||||
if pending:
|
||||
raise HardeningError("sudo policy ends inside a line continuation")
|
||||
return lines
|
||||
|
||||
|
||||
def _sudo_user_aliases(lines: list[str]) -> dict[str, list[str]]:
|
||||
"""Collect User_Alias definitions so grant principals can be expanded."""
|
||||
aliases: dict[str, list[str]] = {}
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if not stripped.startswith("User_Alias"):
|
||||
continue
|
||||
for definition in stripped.removeprefix("User_Alias").split(":"):
|
||||
name, separator, members = definition.partition("=")
|
||||
name = name.strip()
|
||||
values = [item.strip() for item in members.split(",")]
|
||||
if (
|
||||
not separator
|
||||
or not SUDO_ALIAS_NAME_RE.fullmatch(name)
|
||||
or name in aliases
|
||||
or not all(values)
|
||||
):
|
||||
raise HardeningError("sudo alias definition is not auditable")
|
||||
aliases[name] = values
|
||||
return aliases
|
||||
|
||||
|
||||
def _expand_sudo_principal(
|
||||
principal: str, aliases: dict[str, list[str]], seen: frozenset[str]
|
||||
) -> None:
|
||||
"""Reject grant principals that reach Hermes through aliases or wildcards."""
|
||||
value = principal.strip()
|
||||
while value.startswith("!"):
|
||||
value = value[1:].strip()
|
||||
value = value.strip('"')
|
||||
if not value:
|
||||
raise HardeningError("sudo grant principal is malformed")
|
||||
if value in aliases:
|
||||
if value in seen:
|
||||
raise HardeningError("sudo alias expansion is cyclic")
|
||||
for member in aliases[value]:
|
||||
_expand_sudo_principal(member, aliases, seen | {value})
|
||||
return
|
||||
if value in {"ALL", "%ALL"}:
|
||||
raise HardeningError("broad sudo authority includes Hermes")
|
||||
if value.startswith("+") or value.startswith("%:"):
|
||||
raise HardeningError("sudo netgroup authority cannot be audited")
|
||||
if SUDO_ALIAS_NAME_RE.fullmatch(value):
|
||||
raise HardeningError("sudo alias is undefined")
|
||||
|
||||
|
||||
def _audit_sudo_grants(active: str) -> None:
|
||||
"""Expand every grant's user list; only literal non-Hermes principals pass."""
|
||||
lines = _joined_sudo_lines(active)
|
||||
aliases = _sudo_user_aliases(lines)
|
||||
skip = ("Defaults", "User_Alias", "Runas_Alias", "Host_Alias", "Cmnd_Alias")
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith(skip):
|
||||
continue
|
||||
head, separator, _rest = stripped.partition("=")
|
||||
fields = head.split()
|
||||
if not separator or len(fields) < 2:
|
||||
raise HardeningError("sudo grant line is not auditable")
|
||||
for principal in " ".join(fields[:-1]).split(","):
|
||||
_expand_sudo_principal(principal, aliases, frozenset())
|
||||
|
||||
|
||||
def audit_privilege_policies(
|
||||
account: str,
|
||||
account_uid: int,
|
||||
root_uid: int,
|
||||
host_etc: Path,
|
||||
polkit_share: Path,
|
||||
) -> None:
|
||||
"""Reject direct, group, wildcard sudo, or root-equivalent polkit grants."""
|
||||
dangerous_polkit = (
|
||||
"org.freedesktop.policykit.exec",
|
||||
"org.freedesktop.systemd1.manage-unit",
|
||||
"org.freedesktop.udisks2.modify-device",
|
||||
"org.freedesktop.packagekit",
|
||||
)
|
||||
nsswitch = host_etc / "nsswitch.conf"
|
||||
if nsswitch.exists():
|
||||
try:
|
||||
nss_snapshot = read_regular(nsswitch, 64 * 1024)
|
||||
_audit_policy_metadata(nss_snapshot, root_uid, account_uid)
|
||||
text = nss_snapshot.value.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise HardeningError("nsswitch policy is not UTF-8") from exc
|
||||
for line in text.splitlines():
|
||||
name, separator, sources = line.partition(":")
|
||||
if name.strip() in {
|
||||
"passwd",
|
||||
"group",
|
||||
"initgroups",
|
||||
"shadow",
|
||||
"sudoers",
|
||||
} and separator:
|
||||
active_sources = [item for item in sources.split() if not item.startswith("[")]
|
||||
if not active_sources or any(
|
||||
item not in {"files", "systemd"} for item in active_sources
|
||||
):
|
||||
raise HardeningError("external group/account/sudo authority source is unsafe")
|
||||
total = 0
|
||||
for path in _policy_files(host_etc, polkit_share, root_uid):
|
||||
snapshot = read_regular(path, 256 * 1024)
|
||||
_audit_policy_metadata(snapshot, root_uid, account_uid)
|
||||
value = snapshot.value
|
||||
total += len(value)
|
||||
if total > 2 * 1024 * 1024:
|
||||
raise HardeningError("sudo/polkit policy input exceeds safe limit")
|
||||
try:
|
||||
text = value.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise HardeningError("sudo/polkit policy is not UTF-8") from exc
|
||||
if path.name == "sudoers" or "sudoers.d" in path.parts:
|
||||
active = _active_sudo_policy(text)
|
||||
if _mentions_dedicated_identity(active, account, account_uid):
|
||||
raise HardeningError("dedicated Hermes account has sudo authority")
|
||||
_audit_sudo_grants(active)
|
||||
continue
|
||||
active = "\n".join(
|
||||
line for line in text.splitlines() if not line.lstrip().startswith("#")
|
||||
)
|
||||
grants = "polkit.Result.YES" in active or re.search(
|
||||
r"(?im)^\s*Result(?:Any|Inactive|Active)\s*=\s*yes\s*$", active
|
||||
)
|
||||
if grants and _mentions_dedicated_identity(active, account, account_uid):
|
||||
raise HardeningError("dedicated Hermes account has polkit authority")
|
||||
broad = "unix-user:*" in active or "Identity=unix-user:*" in active
|
||||
if broad and grants and any(item in active for item in dangerous_polkit):
|
||||
raise HardeningError("root-equivalent broad polkit authority includes Hermes")
|
||||
audit_polkit_policy(path, text)
|
||||
496
services/hermes/scripts/node_account_hardening.py
Normal file
496
services/hermes/scripts/node_account_hardening.py
Normal file
@ -0,0 +1,496 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Reconcile a dedicated unprivileged Hermes SSH account on one Atlas node."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import errno
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import stat
|
||||
import struct
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
|
||||
from node_account_audit import audit_membership, audit_privilege_policies
|
||||
from node_account_io import (
|
||||
FileSnapshot,
|
||||
HardeningError,
|
||||
account_lock,
|
||||
assert_unchanged,
|
||||
atomic_write,
|
||||
backup_once,
|
||||
read_regular,
|
||||
)
|
||||
|
||||
HOST_ETC = Path("/host-etc")
|
||||
HOST_HOME = Path("/host-home")
|
||||
HOST_K3S = Path("/host-k3s")
|
||||
HOST_KUBELET = Path("/host-kubelet")
|
||||
HOST_RUN_K3S = Path("/host-run-k3s")
|
||||
HOST_RUN_CONTAINERD = Path("/host-run-containerd")
|
||||
HOST_POLKIT_SHARE = Path("/host-polkit-share")
|
||||
ACCOUNT = "hermes-agent"
|
||||
ACCOUNT_UID = 1200
|
||||
ACCOUNT_GID = 1200
|
||||
ACCOUNT_HOME = "/home/hermes-agent"
|
||||
ACCOUNT_SHELL = "/bin/bash"
|
||||
HOST_ROOT_UID = 0
|
||||
HOST_ROOT_GID = 0
|
||||
LEGACY_ACCOUNTS = ("atlas", "oceanus")
|
||||
MAX_ACCOUNT_FILE = 2 * 1024 * 1024
|
||||
MAX_AUTHORIZED_KEYS = 1024 * 1024
|
||||
ACL_VERSION = 2
|
||||
ACL_UNDEFINED_ID = 0xFFFFFFFF
|
||||
ACL_USER_OBJ = 0x01
|
||||
ACL_USER = 0x02
|
||||
ACL_GROUP_OBJ = 0x04
|
||||
ACL_GROUP = 0x08
|
||||
ACL_MASK = 0x10
|
||||
ACL_OTHER = 0x20
|
||||
ACL_HEADER = struct.Struct("<I")
|
||||
ACL_ENTRY = struct.Struct("<HHI")
|
||||
ACL_XATTR = "system.posix_acl_access"
|
||||
|
||||
|
||||
def _read_regular(
|
||||
path: Path, maximum: int = MAX_ACCOUNT_FILE
|
||||
) -> tuple[bytes, os.stat_result]:
|
||||
snapshot = read_regular(path, maximum)
|
||||
return snapshot.value, path.stat(follow_symlinks=False)
|
||||
|
||||
|
||||
def _records(value: bytes, fields: int, name: str) -> list[list[str]]:
|
||||
try:
|
||||
text = value.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise HardeningError(f"{name} is not UTF-8") from exc
|
||||
if not text.endswith("\n"):
|
||||
raise HardeningError(f"{name} is missing its final newline")
|
||||
records = []
|
||||
names = set()
|
||||
for line in text.splitlines():
|
||||
parts = line.split(":")
|
||||
if len(parts) != fields or not parts[0] or parts[0] in names:
|
||||
raise HardeningError(f"{name} has an invalid record")
|
||||
names.add(parts[0])
|
||||
records.append(parts)
|
||||
return records
|
||||
|
||||
|
||||
def _encode(records: list[list[str]]) -> bytes:
|
||||
return ("\n".join(":".join(record) for record in records) + "\n").encode()
|
||||
|
||||
|
||||
def _expected_records() -> dict[str, list[str]]:
|
||||
return {
|
||||
"passwd": [
|
||||
ACCOUNT,
|
||||
"x",
|
||||
str(ACCOUNT_UID),
|
||||
str(ACCOUNT_GID),
|
||||
"Hermes Agent",
|
||||
ACCOUNT_HOME,
|
||||
ACCOUNT_SHELL,
|
||||
],
|
||||
"group": [ACCOUNT, "x", str(ACCOUNT_GID), ""],
|
||||
"shadow": [ACCOUNT, "!", "1", "0", "99999", "7", "", "", ""],
|
||||
"gshadow": [ACCOUNT, "!", "", ""],
|
||||
}
|
||||
|
||||
|
||||
def _reconcile_record(
|
||||
records: list[list[str]], expected: list[str], *, identity_index: int
|
||||
) -> list[list[str]]:
|
||||
for record in records:
|
||||
same_name = record[0] == expected[0]
|
||||
same_identity = record[identity_index] == expected[identity_index]
|
||||
if same_name or same_identity:
|
||||
if record != expected:
|
||||
raise HardeningError("dedicated Hermes account identity conflicts")
|
||||
return records
|
||||
return [*records, expected]
|
||||
|
||||
|
||||
def _reconcile_databases() -> None:
|
||||
expected = _expected_records()
|
||||
definitions = (
|
||||
("passwd", 7, 2),
|
||||
("group", 4, 2),
|
||||
("shadow", 9, 0),
|
||||
("gshadow", 4, 0),
|
||||
)
|
||||
planned: list[tuple[Path, FileSnapshot, bytes]] = []
|
||||
account_present = False
|
||||
parsed: dict[str, list[list[str]]] = {}
|
||||
for name, fields, identity_index in definitions:
|
||||
path = HOST_ETC / name
|
||||
snapshot = read_regular(path, MAX_ACCOUNT_FILE)
|
||||
records = _records(snapshot.value, fields, name)
|
||||
parsed[name] = records
|
||||
updated = _reconcile_record(
|
||||
records, expected[name], identity_index=identity_index
|
||||
)
|
||||
if name == "passwd":
|
||||
account_present = expected[name] in records
|
||||
planned.append((path, snapshot, _encode(updated)))
|
||||
audit_membership(ACCOUNT, parsed["group"], parsed["gshadow"])
|
||||
audit_privilege_policies(
|
||||
ACCOUNT, ACCOUNT_UID, HOST_ROOT_UID, HOST_ETC, HOST_POLKIT_SHARE
|
||||
)
|
||||
try:
|
||||
(HOST_HOME / ACCOUNT).lstat()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
else:
|
||||
if not account_present:
|
||||
raise HardeningError("dedicated Hermes account home already exists")
|
||||
for path, snapshot, _updated in planned:
|
||||
assert_unchanged(path, snapshot, MAX_ACCOUNT_FILE)
|
||||
for path, snapshot, _updated in planned:
|
||||
backup_once(path, snapshot, MAX_ACCOUNT_FILE)
|
||||
for path, snapshot, _updated in planned:
|
||||
assert_unchanged(path, snapshot, MAX_ACCOUNT_FILE)
|
||||
written: list[tuple[Path, FileSnapshot, bytes]] = []
|
||||
try:
|
||||
for path, snapshot, updated in planned:
|
||||
assert_unchanged(path, snapshot, MAX_ACCOUNT_FILE)
|
||||
atomic_write(path, updated, snapshot)
|
||||
written.append((path, snapshot, updated))
|
||||
for path, _snapshot, _updated in planned:
|
||||
name = path.name
|
||||
fields = next(item[1] for item in definitions if item[0] == name)
|
||||
records = _records(_read_regular(path)[0], fields, name)
|
||||
if expected[name] not in records:
|
||||
raise HardeningError("dedicated Hermes account validation failed")
|
||||
except Exception as error:
|
||||
for path, snapshot, updated in reversed(written):
|
||||
current = read_regular(path, MAX_ACCOUNT_FILE)
|
||||
if (
|
||||
current.value != updated
|
||||
or current.mode != snapshot.mode
|
||||
or current.uid != snapshot.uid
|
||||
or current.gid != snapshot.gid
|
||||
or current.xattrs != snapshot.xattrs
|
||||
):
|
||||
raise HardeningError(
|
||||
f"concurrent host account change prevents rollback: {path.name}"
|
||||
) from error
|
||||
atomic_write(path, snapshot.value, snapshot)
|
||||
raise
|
||||
|
||||
|
||||
SUPPORTED_KEY_TYPES = {"ssh-ed25519", "ecdsa-sha2-nistp256", "ssh-rsa"}
|
||||
KEY_TYPE_RE = re.compile(r"(?:sk-)?(?:ssh|ecdsa)-[A-Za-z0-9@._+-]+\Z")
|
||||
|
||||
|
||||
def _key_identity(line: bytes) -> tuple[str, bytes] | None:
|
||||
"""Return OpenSSH key type/blob, ignoring options and comments."""
|
||||
stripped = line.strip(b"\r\n")
|
||||
if not stripped or stripped.lstrip().startswith(b"#"):
|
||||
return None
|
||||
try:
|
||||
fields = shlex.split(stripped.decode("ascii"), posix=True)
|
||||
except (UnicodeDecodeError, ValueError) as exc:
|
||||
raise HardeningError("authorized key line is malformed") from exc
|
||||
indexes = [
|
||||
index for index, field in enumerate(fields) if KEY_TYPE_RE.fullmatch(field)
|
||||
]
|
||||
if len(indexes) != 1 or indexes[0] + 1 >= len(fields):
|
||||
raise HardeningError("authorized key line has ambiguous key material")
|
||||
key_type = fields[indexes[0]]
|
||||
try:
|
||||
blob = base64.b64decode(fields[indexes[0] + 1], validate=True)
|
||||
except ValueError as exc:
|
||||
raise HardeningError("authorized key payload is invalid") from exc
|
||||
encoded_type = key_type.encode("ascii")
|
||||
if len(blob) < 4 or int.from_bytes(blob[:4], "big") != len(encoded_type):
|
||||
raise HardeningError("authorized key blob is malformed")
|
||||
if blob[4 : 4 + len(encoded_type)] != encoded_type:
|
||||
raise HardeningError("authorized key type does not match its blob")
|
||||
return key_type, blob
|
||||
|
||||
|
||||
def _validated_public_key(path: Path) -> tuple[bytes, tuple[str, bytes]]:
|
||||
value, _ = _read_regular(path, 16 * 1024)
|
||||
line = value.strip()
|
||||
if b"\n" in line or b"\r" in line:
|
||||
raise HardeningError("Hermes public key must contain one line")
|
||||
identity = _key_identity(line)
|
||||
fields = line.split()
|
||||
if (
|
||||
identity is None
|
||||
or identity[0] not in SUPPORTED_KEY_TYPES
|
||||
or len(fields) not in {2, 3}
|
||||
or fields[0].decode("ascii") != identity[0]
|
||||
):
|
||||
raise HardeningError("Hermes public key format is unsupported")
|
||||
return line, identity
|
||||
|
||||
|
||||
def _directory(path: Path, *, mode: int, uid: int, gid: int) -> None:
|
||||
with suppress(FileExistsError):
|
||||
path.mkdir(mode=mode)
|
||||
metadata = path.lstat()
|
||||
if not stat.S_ISDIR(metadata.st_mode) or stat.S_ISLNK(metadata.st_mode):
|
||||
raise HardeningError(f"unsafe account directory: {path.name}")
|
||||
if metadata.st_uid not in {0, uid} or metadata.st_gid not in {0, gid}:
|
||||
raise HardeningError(f"account directory ownership conflicts: {path.name}")
|
||||
os.chown(path, uid, gid)
|
||||
path.chmod(mode)
|
||||
|
||||
|
||||
def _without_key(value: bytes, identity: tuple[str, bytes]) -> bytes:
|
||||
kept = []
|
||||
for line in value.splitlines(keepends=True):
|
||||
if _key_identity(line) != identity:
|
||||
kept.append(line)
|
||||
return b"".join(kept)
|
||||
|
||||
|
||||
def _move_key(public_key: Path) -> None:
|
||||
key, identity = _validated_public_key(public_key)
|
||||
target = HOST_HOME / ACCOUNT / ".ssh" / "authorized_keys"
|
||||
legacy_plan: list[tuple[Path, FileSnapshot, bytes]] = []
|
||||
for legacy in LEGACY_ACCOUNTS:
|
||||
authorized = HOST_HOME / legacy / ".ssh" / "authorized_keys"
|
||||
try:
|
||||
snapshot = read_regular(authorized, MAX_AUTHORIZED_KEYS)
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
updated = _without_key(snapshot.value, identity)
|
||||
if updated != snapshot.value:
|
||||
legacy_plan.append((authorized, snapshot, updated))
|
||||
if legacy_plan and target.exists():
|
||||
target_snapshot = read_regular(target, MAX_AUTHORIZED_KEYS)
|
||||
target_without_key = _without_key(target_snapshot.value, identity)
|
||||
if target_without_key != target_snapshot.value:
|
||||
backup_once(target, target_snapshot, MAX_AUTHORIZED_KEYS)
|
||||
assert_unchanged(target, target_snapshot, MAX_AUTHORIZED_KEYS)
|
||||
atomic_write(target, target_without_key, target_snapshot)
|
||||
verified_target = read_regular(target, MAX_AUTHORIZED_KEYS).value
|
||||
if _without_key(verified_target, identity) != verified_target:
|
||||
raise HardeningError("preexisting Hermes authorization removal failed")
|
||||
for authorized, snapshot, _updated in legacy_plan:
|
||||
assert_unchanged(authorized, snapshot, MAX_AUTHORIZED_KEYS)
|
||||
backup_once(authorized, snapshot, MAX_AUTHORIZED_KEYS)
|
||||
for authorized, snapshot, updated in legacy_plan:
|
||||
assert_unchanged(authorized, snapshot, MAX_AUTHORIZED_KEYS)
|
||||
atomic_write(authorized, updated, snapshot)
|
||||
for legacy in LEGACY_ACCOUNTS:
|
||||
authorized = HOST_HOME / legacy / ".ssh" / "authorized_keys"
|
||||
try:
|
||||
value = read_regular(authorized, MAX_AUTHORIZED_KEYS).value
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
if _without_key(value, identity) != value:
|
||||
raise HardeningError("legacy Hermes authorization key removal failed")
|
||||
|
||||
# Installation happens only after every legacy authorization is absent. A
|
||||
# crash can therefore remove access temporarily, but can never duplicate
|
||||
# the machine credential across privileged and unprivileged accounts.
|
||||
home = HOST_HOME / ACCOUNT
|
||||
ssh = home / ".ssh"
|
||||
_directory(home, mode=0o700, uid=ACCOUNT_UID, gid=ACCOUNT_GID)
|
||||
_directory(ssh, mode=0o700, uid=ACCOUNT_UID, gid=ACCOUNT_GID)
|
||||
target = ssh / "authorized_keys"
|
||||
if target.exists():
|
||||
current = read_regular(target, MAX_AUTHORIZED_KEYS)
|
||||
if current.value != key + b"\n":
|
||||
backup_once(target, current, MAX_AUTHORIZED_KEYS)
|
||||
else:
|
||||
current = FileSnapshot(
|
||||
value=b"",
|
||||
device=0,
|
||||
inode=0,
|
||||
mode=0o600,
|
||||
uid=ACCOUNT_UID,
|
||||
gid=ACCOUNT_GID,
|
||||
size=0,
|
||||
mtime_ns=0,
|
||||
ctime_ns=0,
|
||||
xattrs=(),
|
||||
)
|
||||
atomic_write(target, key + b"\n", current)
|
||||
if _read_regular(target, MAX_AUTHORIZED_KEYS)[0] != key + b"\n":
|
||||
raise HardeningError("dedicated Hermes authorized key validation failed")
|
||||
|
||||
|
||||
def _decode_acl(value: bytes, mode: int) -> list[tuple[int, int, int]]:
|
||||
"""Decode a bounded POSIX ACL or derive one from ordinary mode bits."""
|
||||
if not value:
|
||||
group = (mode >> 3) & 0o7
|
||||
return [
|
||||
(ACL_USER_OBJ, (mode >> 6) & 0o7, ACL_UNDEFINED_ID),
|
||||
(ACL_GROUP_OBJ, group, ACL_UNDEFINED_ID),
|
||||
(ACL_MASK, group, ACL_UNDEFINED_ID),
|
||||
(ACL_OTHER, mode & 0o7, ACL_UNDEFINED_ID),
|
||||
]
|
||||
if len(value) < ACL_HEADER.size or (len(value) - ACL_HEADER.size) % ACL_ENTRY.size:
|
||||
raise HardeningError("sensitive directory ACL is malformed")
|
||||
if ACL_HEADER.unpack_from(value)[0] != ACL_VERSION:
|
||||
raise HardeningError("sensitive directory ACL version is unsupported")
|
||||
entries = [
|
||||
ACL_ENTRY.unpack_from(value, offset)
|
||||
for offset in range(ACL_HEADER.size, len(value), ACL_ENTRY.size)
|
||||
]
|
||||
if any(permission > 0o7 for _tag, permission, _identifier in entries):
|
||||
raise HardeningError("sensitive directory ACL permission is malformed")
|
||||
required = {ACL_USER_OBJ, ACL_GROUP_OBJ, ACL_OTHER}
|
||||
if not required <= {tag for tag, _permission, _identifier in entries}:
|
||||
raise HardeningError("sensitive directory ACL is incomplete")
|
||||
return entries
|
||||
|
||||
|
||||
def _encode_acl(entries: list[tuple[int, int, int]]) -> bytes:
|
||||
return ACL_HEADER.pack(ACL_VERSION) + b"".join(
|
||||
ACL_ENTRY.pack(*entry) for entry in entries
|
||||
)
|
||||
|
||||
|
||||
def _acl_with_deny(value: bytes, mode: int) -> bytes:
|
||||
entries = _decode_acl(value, mode)
|
||||
entries = [
|
||||
entry
|
||||
for entry in entries
|
||||
if not (entry[0] == ACL_USER and entry[2] == ACCOUNT_UID)
|
||||
]
|
||||
entries.append((ACL_USER, 0, ACCOUNT_UID))
|
||||
if not any(tag == ACL_MASK for tag, _permission, _identifier in entries):
|
||||
entries.append((ACL_MASK, (mode >> 3) & 0o7, ACL_UNDEFINED_ID))
|
||||
order = {
|
||||
ACL_USER_OBJ: 0,
|
||||
ACL_USER: 1,
|
||||
ACL_GROUP_OBJ: 2,
|
||||
ACL_GROUP: 3,
|
||||
ACL_MASK: 4,
|
||||
ACL_OTHER: 5,
|
||||
}
|
||||
entries.sort(key=lambda entry: (order.get(entry[0], 99), entry[2]))
|
||||
return _encode_acl(entries)
|
||||
|
||||
|
||||
def _read_acl(path: Path) -> bytes:
|
||||
try:
|
||||
return os.getxattr(path, ACL_XATTR, follow_symlinks=False)
|
||||
except OSError as exc:
|
||||
if exc.errno in {errno.ENODATA, getattr(errno, "ENOATTR", errno.ENODATA)}:
|
||||
return b""
|
||||
raise
|
||||
|
||||
|
||||
def _validate_acl_backup(value: bytes) -> None:
|
||||
if value[:1] not in {b"A", b"N"}:
|
||||
raise HardeningError("sensitive directory ACL backup is malformed")
|
||||
if value[:1] == b"A":
|
||||
if len(value) == 1:
|
||||
raise HardeningError("sensitive directory ACL backup is malformed")
|
||||
_decode_acl(value[1:], 0)
|
||||
elif value != b"N":
|
||||
raise HardeningError("sensitive directory ACL backup is malformed")
|
||||
|
||||
|
||||
def _acl_backup_once(path: Path, value: bytes, backup_name: str | None = None) -> None:
|
||||
"""Persist the original ACL as a private, durable host recovery file."""
|
||||
root = HOST_ETC / "hermes-node-boundary"
|
||||
_directory(root, mode=0o700, uid=HOST_ROOT_UID, gid=HOST_ROOT_GID)
|
||||
backup = root / f"{backup_name or path.name}.acl"
|
||||
encoded = b"A" + value if value else b"N"
|
||||
try:
|
||||
existing, _metadata = _read_regular(backup, 64 * 1024)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
else:
|
||||
_validate_acl_backup(existing)
|
||||
return
|
||||
|
||||
temporary = root / f".{path.name}.acl.hermes-{os.getpid()}"
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
||||
descriptor = os.open(temporary, flags, 0o600)
|
||||
try:
|
||||
if os.write(descriptor, encoded) != len(encoded):
|
||||
raise HardeningError("short sensitive directory ACL backup write")
|
||||
os.fchmod(descriptor, 0o600)
|
||||
os.fchown(descriptor, HOST_ROOT_UID, HOST_ROOT_GID)
|
||||
os.fsync(descriptor)
|
||||
except Exception:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
try:
|
||||
os.link(temporary, backup, follow_symlinks=False)
|
||||
except FileExistsError:
|
||||
# Another reconciler won the race; never overwrite the first backup.
|
||||
pass
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
stored, _metadata = _read_regular(backup, 64 * 1024)
|
||||
_validate_acl_backup(stored)
|
||||
directory = os.open(root, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(directory)
|
||||
finally:
|
||||
os.close(directory)
|
||||
|
||||
|
||||
def _deny_sensitive_root(path: Path, backup_name: str | None = None) -> None:
|
||||
metadata = path.lstat()
|
||||
if not stat.S_ISDIR(metadata.st_mode) or stat.S_ISLNK(metadata.st_mode):
|
||||
raise HardeningError(f"unsafe sensitive directory: {path.name}")
|
||||
if metadata.st_uid != HOST_ROOT_UID:
|
||||
raise HardeningError(f"sensitive directory is not root-owned: {path.name}")
|
||||
current = _read_acl(path)
|
||||
_acl_backup_once(path, current, backup_name)
|
||||
updated = _acl_with_deny(current, stat.S_IMODE(metadata.st_mode))
|
||||
try:
|
||||
os.setxattr(path, ACL_XATTR, updated, follow_symlinks=False)
|
||||
verified = _decode_acl(_read_acl(path), stat.S_IMODE(metadata.st_mode))
|
||||
if (ACL_USER, 0, ACCOUNT_UID) not in verified:
|
||||
raise HardeningError("sensitive directory ACL validation failed")
|
||||
except Exception:
|
||||
if current:
|
||||
os.setxattr(path, ACL_XATTR, current, follow_symlinks=False)
|
||||
else:
|
||||
try:
|
||||
os.removexattr(path, ACL_XATTR, follow_symlinks=False)
|
||||
except OSError as exc:
|
||||
if exc.errno not in {
|
||||
errno.ENODATA,
|
||||
getattr(errno, "ENOATTR", errno.ENODATA),
|
||||
}:
|
||||
raise
|
||||
raise
|
||||
|
||||
|
||||
def _deny_sensitive_roots() -> None:
|
||||
for path, backup_name in (
|
||||
(HOST_K3S, "var-lib-rancher-k3s"),
|
||||
(HOST_KUBELET, "var-lib-kubelet"),
|
||||
(HOST_RUN_K3S, "run-k3s"),
|
||||
(HOST_RUN_CONTAINERD, "run-containerd"),
|
||||
):
|
||||
_deny_sensitive_root(path, backup_name)
|
||||
|
||||
|
||||
def reconcile(public_key: Path) -> None:
|
||||
"""Create the locked account and move only the Hermes authorization key."""
|
||||
with account_lock(HOST_ETC, expected_uid=HOST_ROOT_UID):
|
||||
_reconcile_databases()
|
||||
_deny_sensitive_roots()
|
||||
_move_key(public_key)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--public-key-file", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
reconcile(args.public_key_file)
|
||||
print("Dedicated Hermes node account reconciled.", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
205
services/hermes/scripts/node_account_io.py
Normal file
205
services/hermes/scripts/node_account_io.py
Normal file
@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Crash-safe, metadata-preserving host account database I/O."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import os
|
||||
import stat
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
MAX_XATTRS = 64
|
||||
MAX_XATTR_NAME = 255
|
||||
MAX_XATTR_VALUE = 256 * 1024
|
||||
MAX_XATTR_TOTAL = 1024 * 1024
|
||||
LOCK_TIMEOUT_SECONDS = 15.0
|
||||
|
||||
|
||||
class HardeningError(RuntimeError):
|
||||
"""Raised before an unsafe or ambiguous host-account change."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FileSnapshot:
|
||||
"""Bounded file contents and all security-relevant inode metadata."""
|
||||
|
||||
value: bytes
|
||||
device: int
|
||||
inode: int
|
||||
mode: int
|
||||
uid: int
|
||||
gid: int
|
||||
size: int
|
||||
mtime_ns: int
|
||||
ctime_ns: int
|
||||
xattrs: tuple[tuple[str, bytes], ...]
|
||||
|
||||
|
||||
def _stat_identity(item: os.stat_result) -> tuple[int, ...]:
|
||||
return (
|
||||
item.st_dev,
|
||||
item.st_ino,
|
||||
item.st_mode,
|
||||
item.st_uid,
|
||||
item.st_gid,
|
||||
item.st_size,
|
||||
item.st_mtime_ns,
|
||||
item.st_ctime_ns,
|
||||
)
|
||||
|
||||
|
||||
def _xattrs(path_or_fd: Path | int) -> tuple[tuple[str, bytes], ...]:
|
||||
try:
|
||||
names = sorted(os.listxattr(path_or_fd, follow_symlinks=False))
|
||||
except (TypeError, ValueError):
|
||||
names = sorted(os.listxattr(path_or_fd))
|
||||
if len(names) > MAX_XATTRS:
|
||||
raise HardeningError("host account file has too many extended attributes")
|
||||
values: list[tuple[str, bytes]] = []
|
||||
total = 0
|
||||
for name in names:
|
||||
if len(name.encode("utf-8")) > MAX_XATTR_NAME:
|
||||
raise HardeningError("host account file xattr name is too long")
|
||||
try:
|
||||
value = os.getxattr(path_or_fd, name, follow_symlinks=False)
|
||||
except (TypeError, ValueError):
|
||||
value = os.getxattr(path_or_fd, name)
|
||||
total += len(value)
|
||||
if len(value) > MAX_XATTR_VALUE or total > MAX_XATTR_TOTAL:
|
||||
raise HardeningError("host account file xattrs exceed the safe limit")
|
||||
values.append((name, value))
|
||||
return tuple(values)
|
||||
|
||||
|
||||
def _restore_xattrs(descriptor: int, expected: tuple[tuple[str, bytes], ...]) -> None:
|
||||
expected_names = {name for name, _value in expected}
|
||||
for name, _value in _xattrs(descriptor):
|
||||
if name not in expected_names:
|
||||
os.removexattr(descriptor, name)
|
||||
for name, value in expected:
|
||||
os.setxattr(descriptor, name, value)
|
||||
|
||||
|
||||
def read_regular(path: Path, maximum: int) -> FileSnapshot:
|
||||
"""Read one bounded, non-symlink regular file and capture its metadata."""
|
||||
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
||||
descriptor = os.open(path, flags)
|
||||
try:
|
||||
metadata = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > maximum:
|
||||
raise HardeningError(f"unsafe regular file: {path.name}")
|
||||
value = os.read(descriptor, maximum + 1)
|
||||
attrs = _xattrs(descriptor)
|
||||
after = os.fstat(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
if len(value) != metadata.st_size or _stat_identity(metadata) != _stat_identity(after):
|
||||
raise HardeningError(f"short file read: {path.name}")
|
||||
return FileSnapshot(
|
||||
value=value,
|
||||
device=metadata.st_dev,
|
||||
inode=metadata.st_ino,
|
||||
mode=stat.S_IMODE(metadata.st_mode),
|
||||
uid=metadata.st_uid,
|
||||
gid=metadata.st_gid,
|
||||
size=metadata.st_size,
|
||||
mtime_ns=metadata.st_mtime_ns,
|
||||
ctime_ns=metadata.st_ctime_ns,
|
||||
xattrs=attrs,
|
||||
)
|
||||
|
||||
|
||||
def assert_unchanged(path: Path, expected: FileSnapshot, maximum: int) -> None:
|
||||
"""Fail if an account file changed since planning began."""
|
||||
current = read_regular(path, maximum)
|
||||
if current != expected:
|
||||
raise HardeningError(f"concurrent host account change detected: {path.name}")
|
||||
|
||||
|
||||
def atomic_write(path: Path, value: bytes, metadata: FileSnapshot) -> None:
|
||||
"""Atomically replace a file while retaining ACLs, labels, and xattrs."""
|
||||
temporary = path.with_name(f".{path.name}.hermes-{os.getpid()}")
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
||||
descriptor = os.open(temporary, flags, metadata.mode)
|
||||
try:
|
||||
if os.write(descriptor, value) != len(value):
|
||||
raise HardeningError(f"short atomic write: {path.name}")
|
||||
os.fchmod(descriptor, metadata.mode)
|
||||
os.fchown(descriptor, metadata.uid, metadata.gid)
|
||||
_restore_xattrs(descriptor, metadata.xattrs)
|
||||
os.fsync(descriptor)
|
||||
except Exception:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
os.replace(temporary, path)
|
||||
directory = os.open(path.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||
try:
|
||||
os.fsync(directory)
|
||||
finally:
|
||||
os.close(directory)
|
||||
|
||||
|
||||
def backup_once(path: Path, snapshot: FileSnapshot, maximum: int) -> Path:
|
||||
"""Create the first durable recovery copy without replacing an older one."""
|
||||
backup = path.with_name(path.name + ".hermes-boundary-backup")
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
||||
try:
|
||||
descriptor = os.open(backup, flags, snapshot.mode)
|
||||
except FileExistsError:
|
||||
read_regular(backup, maximum)
|
||||
return backup
|
||||
try:
|
||||
if os.write(descriptor, snapshot.value) != len(snapshot.value):
|
||||
raise HardeningError(f"short backup write: {path.name}")
|
||||
os.fchmod(descriptor, snapshot.mode)
|
||||
os.fchown(descriptor, snapshot.uid, snapshot.gid)
|
||||
_restore_xattrs(descriptor, snapshot.xattrs)
|
||||
os.fsync(descriptor)
|
||||
except Exception:
|
||||
backup.unlink(missing_ok=True)
|
||||
raise
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
directory = os.open(path.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||
try:
|
||||
os.fsync(directory)
|
||||
finally:
|
||||
os.close(directory)
|
||||
return backup
|
||||
|
||||
|
||||
@contextmanager
|
||||
def account_lock(host_etc: Path, *, expected_uid: int = 0) -> Iterator[None]:
|
||||
"""Hold the standard shadow-utils account lock for the full transaction."""
|
||||
path = host_etc / ".pwd.lock"
|
||||
flags = os.O_WRONLY | os.O_CREAT | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
||||
descriptor = os.open(path, flags, 0o600)
|
||||
try:
|
||||
metadata = os.fstat(descriptor)
|
||||
if (
|
||||
not stat.S_ISREG(metadata.st_mode)
|
||||
or metadata.st_uid != expected_uid
|
||||
or stat.S_IMODE(metadata.st_mode) != 0o600
|
||||
):
|
||||
raise HardeningError("unsafe standard account lock")
|
||||
deadline = time.monotonic() + LOCK_TIMEOUT_SECONDS
|
||||
while True:
|
||||
try:
|
||||
fcntl.lockf(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
break
|
||||
except BlockingIOError as exc:
|
||||
if time.monotonic() >= deadline:
|
||||
raise HardeningError("standard account lock is busy") from exc
|
||||
time.sleep(0.05)
|
||||
yield
|
||||
finally:
|
||||
try:
|
||||
fcntl.lockf(descriptor, fcntl.LOCK_UN)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
168
services/hermes/scripts/node_polkit_audit.py
Normal file
168
services/hermes/scripts/node_polkit_audit.py
Normal file
@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Literal-identity proof for polkit policies on hardened Atlas nodes.
|
||||
|
||||
Polkit rules are JavaScript, so a grant can name its subject indirectly:
|
||||
concatenated strings, escape sequences, bracket lookups, or arithmetic can
|
||||
compute an identity that plain text matching never sees. Rather than model
|
||||
the language, every grant must scope itself through exact literal identity
|
||||
comparisons. Anything the audit cannot prove literal fails closed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from node_account_io import HardeningError
|
||||
|
||||
_ALLOWED_ESCAPES = {'"', "'", "\\", "/", "n", "r", "t"}
|
||||
_STRING_MARK = r"\x00[0-9]+\x00"
|
||||
_GRANT_RE = re.compile(r"\bResult\s*\.\s*YES\b")
|
||||
_PKLA_GRANT_RE = re.compile(r"(?im)^\s*Result(?:Any|Inactive|Active)\s*=\s*yes\s*$")
|
||||
_PKLA_IDENTITY_RE = re.compile(r"unix-(?:user|group):[A-Za-z0-9][A-Za-z0-9._-]{0,99}\Z")
|
||||
_IDENTITY_USE_RE = re.compile(r"subject\s*(\[|\.\s*(?:user|uid|isInGroup)\b)")
|
||||
_COMPUTED_TOKEN_RE = re.compile(
|
||||
r"\b(?:eval|Function|arguments|fromCharCode|fromCodePoint|atob|btoa|unescape"
|
||||
r"|escape|decodeURI[A-Za-z]*|encodeURI[A-Za-z]*|charCodeAt|codePointAt"
|
||||
r"|normalize|constructor|prototype|globalThis|Reflect|Proxy)\b"
|
||||
)
|
||||
# Logical && and || stay legal; every value-building operator next to a
|
||||
# string or number literal is rejected so comparisons stay whole literals.
|
||||
_OPERATOR_ADJACENT_RE = re.compile(
|
||||
rf"(?:{_STRING_MARK}|[0-9])\s*[+\-*/%<>^~](?![&|=])"
|
||||
rf"|(?<![&|=!<>])[+\-*/%<>^~]\s*(?:{_STRING_MARK}|[0-9])"
|
||||
)
|
||||
_USER_BEFORE_RE = re.compile(rf"{_STRING_MARK}\s*[=!]==?\s*\Z")
|
||||
_USER_AFTER_RE = re.compile(rf"\s*[=!]==?\s*{_STRING_MARK}")
|
||||
_UID_BEFORE_RE = re.compile(r"(?<![0-9A-Za-z_.$])[0-9]{1,10}\s*[=!]==?\s*\Z")
|
||||
_UID_AFTER_RE = re.compile(r"\s*[=!]==?\s*[0-9]{1,10}(?![0-9A-Za-z_.$])")
|
||||
_GROUP_AFTER_RE = re.compile(rf"\s*\(\s*{_STRING_MARK}\s*\)")
|
||||
_BARE_SUBJECT_RE = re.compile(r"\bsubject\b(?!\s*[.\[])")
|
||||
_PARAMETER_BEFORE_RE = re.compile(r"function\s*\(\s*[A-Za-z_$][A-Za-z0-9_$]*\s*,\s*\Z")
|
||||
_RESULT_USE_RE = re.compile(r"\bResult\b(?!\s*\.\s*[A-Z_]{2,32}\b)")
|
||||
|
||||
|
||||
def _split_strings(text: str) -> tuple[str, list[str]]:
|
||||
"""Replace string literals with markers and drop JavaScript comments."""
|
||||
code: list[str] = []
|
||||
strings: list[str] = []
|
||||
index = 0
|
||||
length = len(text)
|
||||
while index < length:
|
||||
character = text[index]
|
||||
if character == "`":
|
||||
raise HardeningError("polkit rule uses computed template strings")
|
||||
if character in {'"', "'"}:
|
||||
quote = character
|
||||
index += 1
|
||||
value: list[str] = []
|
||||
while True:
|
||||
if index >= length or text[index] in "\r\n":
|
||||
raise HardeningError("polkit rule string is unterminated")
|
||||
character = text[index]
|
||||
if character == "\\":
|
||||
if index + 1 >= length or text[index + 1] not in _ALLOWED_ESCAPES:
|
||||
raise HardeningError("polkit rule string uses computed escapes")
|
||||
escaped = text[index + 1]
|
||||
value.append({"n": "\n", "r": "\r", "t": "\t"}.get(escaped, escaped))
|
||||
index += 2
|
||||
continue
|
||||
index += 1
|
||||
if character == quote:
|
||||
break
|
||||
value.append(character)
|
||||
strings.append("".join(value))
|
||||
code.append(f"\x00{len(strings) - 1}\x00")
|
||||
continue
|
||||
if text.startswith("//", index):
|
||||
newline = text.find("\n", index)
|
||||
index = length if newline < 0 else newline
|
||||
continue
|
||||
if text.startswith("/*", index):
|
||||
closing = text.find("*/", index + 2)
|
||||
if closing < 0:
|
||||
raise HardeningError("polkit rule comment is unterminated")
|
||||
code.append(" ")
|
||||
index = closing + 2
|
||||
continue
|
||||
if character == "/":
|
||||
# Any remaining slash is a regex literal or a division operator.
|
||||
# A regex literal may embed a quote character, which would desync
|
||||
# the string scanner and swallow an unconditional grant. Neither
|
||||
# regex nor division has a place in an identity-scoped grant, so
|
||||
# both fail closed before they can hide a Result.YES.
|
||||
raise HardeningError("polkit rule uses a regex or division operator")
|
||||
code.append(character)
|
||||
index += 1
|
||||
return "".join(code), strings
|
||||
|
||||
|
||||
def _require_literal_identity_use(code: str, match: re.Match[str]) -> None:
|
||||
"""Reject one identity reference unless it is an exact literal comparison."""
|
||||
form = match.group(1)
|
||||
if form.startswith("["):
|
||||
raise HardeningError("polkit rule reads identity through a computed lookup")
|
||||
member = form.removeprefix(".").strip()
|
||||
before = code[max(match.start() - 48, 0) : match.start()]
|
||||
after = code[match.end() : match.end() + 48]
|
||||
if member == "user":
|
||||
literal = _USER_BEFORE_RE.search(before) or _USER_AFTER_RE.match(after)
|
||||
elif member == "uid":
|
||||
literal = _UID_BEFORE_RE.search(before) or _UID_AFTER_RE.match(after)
|
||||
else:
|
||||
literal = _GROUP_AFTER_RE.match(after)
|
||||
if not literal:
|
||||
raise HardeningError("polkit rule identity test is not an exact literal")
|
||||
|
||||
|
||||
def _audit_rules_javascript(text: str) -> None:
|
||||
"""Require granting rules to gate on exact literal subject identities.
|
||||
|
||||
With computed tokens, bracket lookups, and subject aliasing rejected in
|
||||
every rule file, a YES decision can only be written as the literal grant
|
||||
token, so the grant test below cannot be evaded by construction.
|
||||
"""
|
||||
code, _strings = _split_strings(text)
|
||||
if _COMPUTED_TOKEN_RE.search(code):
|
||||
raise HardeningError("polkit rule computes values the audit cannot prove")
|
||||
if _RESULT_USE_RE.search(code):
|
||||
raise HardeningError("polkit rule reads results through a computed lookup")
|
||||
for bare in _BARE_SUBJECT_RE.finditer(code):
|
||||
if not _PARAMETER_BEFORE_RE.search(code[: bare.start()]):
|
||||
raise HardeningError("polkit rule passes the subject to computed code")
|
||||
if not _GRANT_RE.search(code):
|
||||
return
|
||||
if _OPERATOR_ADJACENT_RE.search(code):
|
||||
raise HardeningError("polkit grant builds values with operators")
|
||||
uses = 0
|
||||
for match in _IDENTITY_USE_RE.finditer(code):
|
||||
uses += 1
|
||||
_require_literal_identity_use(code, match)
|
||||
if not uses:
|
||||
raise HardeningError("polkit grant is not scoped to an explicit identity")
|
||||
|
||||
|
||||
def _audit_pkla(text: str) -> None:
|
||||
"""Require granting localauthority entries to name exact literal identities."""
|
||||
if not _PKLA_GRANT_RE.search(text):
|
||||
return
|
||||
identities: list[str] = []
|
||||
for line in text.splitlines():
|
||||
name, separator, value = line.strip().partition("=")
|
||||
if name.strip() != "Identity" or not separator:
|
||||
continue
|
||||
entries = [entry.strip() for entry in value.split(";") if entry.strip()]
|
||||
identities.extend(entries)
|
||||
if not identities:
|
||||
raise HardeningError("polkit authority grant lacks an identity scope")
|
||||
for entry in identities:
|
||||
if not _PKLA_IDENTITY_RE.fullmatch(entry):
|
||||
raise HardeningError("polkit authority identity is not an exact literal")
|
||||
|
||||
|
||||
def audit_polkit_policy(path: Path, text: str) -> None:
|
||||
"""Prove one polkit policy file cannot grant through a computed identity."""
|
||||
if "localauthority" in path.parts or path.suffix == ".pkla":
|
||||
_audit_pkla(text)
|
||||
else:
|
||||
_audit_rules_javascript(text)
|
||||
@ -86,6 +86,21 @@ def _write_empty_auth_store() -> None:
|
||||
os.chown(path, OWNER_UID, OWNER_GID)
|
||||
|
||||
|
||||
def _stage_node_ssh_config() -> None:
|
||||
"""Force every Atlas node alias onto the dedicated Hermes OS account."""
|
||||
destination = RUNTIME_ROOT / "node-ssh-config"
|
||||
source = _copy_secret("node-ssh-config", destination)
|
||||
if "\x00" in source:
|
||||
destination.unlink(missing_ok=True)
|
||||
raise RuntimeError("node SSH config contains invalid control data")
|
||||
destination.write_text(
|
||||
"Host titan-*\n User hermes-agent\n" + source.rstrip() + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
destination.chmod(0o600)
|
||||
os.chown(destination, OWNER_UID, OWNER_GID)
|
||||
|
||||
|
||||
def stage_agent() -> None:
|
||||
"""Stage the owner agent's complete runtime access set."""
|
||||
for path in (RUNTIME_ROOT, RUNTIME_ROOT / "claude", RUNTIME_ROOT / "codex"):
|
||||
@ -93,13 +108,12 @@ def stage_agent() -> None:
|
||||
for name in (
|
||||
"agent-api-key",
|
||||
"chat-relay-key",
|
||||
"gitea-token",
|
||||
"gitea-username",
|
||||
"jenkins-image-build-token",
|
||||
"node-ssh-private-key",
|
||||
"node-ssh-config",
|
||||
"node-ssh-known-hosts",
|
||||
):
|
||||
_copy_secret(name, RUNTIME_ROOT / name)
|
||||
_stage_node_ssh_config()
|
||||
_validated_json(
|
||||
"claude-credentials",
|
||||
RUNTIME_ROOT / "claude" / ".credentials.json",
|
||||
|
||||
64
services/hermes/skills/manage-atlas-pull-requests/SKILL.md
Normal file
64
services/hermes/skills/manage-atlas-pull-requests/SKILL.md
Normal file
@ -0,0 +1,64 @@
|
||||
---
|
||||
name: manage-atlas-pull-requests
|
||||
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
|
||||
|
||||
Use `/opt/scm/gitea_api.py` for Forgejo API access. The agent pod has no
|
||||
repository credential. The client calls a separate least-authority broker;
|
||||
never bypass it with `curl` or direct Gitea HTTP.
|
||||
|
||||
Use the configured broker Git remote for clone, fetch, and creation of a new
|
||||
namespaced feature branch. Existing-ref updates, protected refs, deletion,
|
||||
and force-push are rejected by the broker. Never replace that remote with a
|
||||
credential-bearing URL.
|
||||
|
||||
## Read repository or PR state
|
||||
|
||||
Pass one allowed Atlas metadata API path to the read operation:
|
||||
|
||||
```sh
|
||||
/opt/scm/gitea_api.py read /api/v1/repos/atlas/REPO/pulls/NUMBER
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Create a review handoff
|
||||
|
||||
Before opening a PR, verify the remote, branch, clean worktree, diff, tests, and
|
||||
exact pushed commit. Never force-push. Pass that full 40-character pushed head
|
||||
SHA explicitly. Keep the short body free of credentials and credential-like
|
||||
text. The client screens structured assignments, known token formats, and long
|
||||
high-entropy values as an accidental-disclosure barrier. It cannot prove text
|
||||
is secret-free: short or multiword secrets under innocuous names may resemble
|
||||
ordinary prose, so never place credential material in either field. Structured
|
||||
inspection is deliberately bounded; large, deeply nested, or unusual config
|
||||
blobs do not belong in these fields and may be rejected. Validate without
|
||||
reading a credential or using the network:
|
||||
|
||||
```sh
|
||||
/opt/scm/gitea_api.py --dry-run create-draft REPO \
|
||||
--base main --head hermes/TASK --head-sha FULL_PUSHED_SHA \
|
||||
--title "Focused change" --body "Tests and review evidence"
|
||||
```
|
||||
|
||||
Run the same command without `--dry-run` only when the branch is review-ready.
|
||||
The client accepts success only when Forgejo returns the exact requested
|
||||
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
|
||||
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
|
||||
change.
|
||||
|
||||
## Stop at the authority boundary
|
||||
|
||||
Update, merge, approve, close, delete, branch mutation, comments, releases,
|
||||
repository administration, and force-push are intentionally unavailable. Do
|
||||
not bypass the client with a raw HTTP request. If one of those actions is
|
||||
needed, present the tested commit and evidence to Brad and stop for human
|
||||
review.
|
||||
@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Manage Atlas Pull Requests"
|
||||
short_description: "Create safe draft PR handoffs for human review"
|
||||
default_prompt: "Use $manage-atlas-pull-requests to inspect the requested Atlas repository or prepare a tested branch as an unmerged draft pull request. Preserve the human review boundary."
|
||||
@ -27,7 +27,7 @@ spec:
|
||||
prometheus.io/port: "9005"
|
||||
prometheus.io/path: /metrics
|
||||
vault.hashicorp.com/agent-inject: "true"
|
||||
vault.hashicorp.com/role: hermes-agent
|
||||
vault.hashicorp.com/role: hermes-switchyard
|
||||
vault.hashicorp.com/agent-inject-secret-relay-key: kv/data/atlas/hermes/chat-telegram
|
||||
vault.hashicorp.com/agent-inject-template-relay-key: |
|
||||
{{- with secret "kv/data/atlas/hermes/chat-telegram" -}}
|
||||
|
||||
@ -652,6 +652,25 @@ data:
|
||||
}
|
||||
}
|
||||
}
|
||||
pipelineJob('hermes-agent-image') {
|
||||
disabled(false)
|
||||
description('Human-gated, daemonless Kaniko build for the reviewed atlas/titan-iac main revision. Publishes a content-addressed Hermes agent image and archives a Flux digest patch; it never mutates Git or deploys.')
|
||||
authenticationToken(System.getenv('HERMES_AGENT_IMAGE_BUILD_TOKEN'))
|
||||
definition {
|
||||
cpsScm {
|
||||
scm {
|
||||
git {
|
||||
remote {
|
||||
url('https://scm.bstein.dev/atlas/titan-iac.git')
|
||||
credentials('gitea-pat')
|
||||
}
|
||||
branches('*/main')
|
||||
}
|
||||
}
|
||||
scriptPath('ci/Jenkinsfile.hermes-agent-image')
|
||||
}
|
||||
}
|
||||
}
|
||||
multibranchPipelineJob('titan-iac-quality-gate') {
|
||||
branchSources {
|
||||
branchSource {
|
||||
|
||||
@ -20,6 +20,7 @@ data:
|
||||
gitea:268.v75e47974c01d
|
||||
gitea-checks:603.621.vc708da_fb_371d
|
||||
multibranch-scan-webhook-trigger:1.0.11
|
||||
build-token-root:365.v717f8685a_09e
|
||||
# Structured test evidence. Without junit the `junit` step throws
|
||||
# NoSuchMethodError, jenkins.failed_tests is always empty, and triage
|
||||
# has only raw console text to work from. Pinned to the newest release
|
||||
|
||||
@ -65,6 +65,9 @@ spec:
|
||||
ARIADNE_JENKINS_API_USER={{ .Data.data.username }}
|
||||
ARIADNE_JENKINS_API_TOKEN={{ .Data.data.token }}
|
||||
{{ end }}
|
||||
{{ with secret "kv/data/atlas/hermes/developer-jenkins" }}
|
||||
HERMES_AGENT_IMAGE_BUILD_TOKEN={{ .Data.data.build_token }}
|
||||
{{ end }}
|
||||
bstein.dev/restarted-at: "2026-05-20T09:40:31Z"
|
||||
spec:
|
||||
serviceAccountName: jenkins
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
# services/jenkins/hermes-image-builder-serviceaccount.yaml
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: hermes-image-builder
|
||||
namespace: jenkins
|
||||
automountServiceAccountToken: false
|
||||
@ -5,6 +5,7 @@ namespace: jenkins
|
||||
resources:
|
||||
- namespace.yaml
|
||||
- serviceaccount.yaml
|
||||
- hermes-image-builder-serviceaccount.yaml
|
||||
- vault-serviceaccount.yaml
|
||||
- pvc.yaml
|
||||
- cache-pvc.yaml
|
||||
|
||||
@ -8,7 +8,7 @@ spec:
|
||||
provider: vault
|
||||
parameters:
|
||||
vaultAddress: "http://vault.vault.svc.cluster.local:8200"
|
||||
roleName: "jenkins"
|
||||
roleName: "jenkins-vault-sync"
|
||||
objects: |
|
||||
- objectName: "harbor-pull__dockerconfigjson"
|
||||
secretPath: "kv/data/atlas/shared/harbor-pull"
|
||||
|
||||
@ -314,7 +314,7 @@ def main():
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if __name__ == "__main__": # pragma: no cover - exercised through main()
|
||||
try:
|
||||
main()
|
||||
except Exception as exc:
|
||||
|
||||
@ -100,6 +100,6 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
return
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if __name__ == "__main__": # pragma: no cover - server loop
|
||||
server = http.server.ThreadingHTTPServer(("", 8080), Handler)
|
||||
server.serve_forever()
|
||||
|
||||
@ -15,8 +15,8 @@ data:
|
||||
METIS_MAX_DEVICE_BYTES: "1000000000000"
|
||||
METIS_NAMESPACE: maintenance
|
||||
METIS_REMOTE_POD_TIMEOUT_SEC: "14400"
|
||||
METIS_RUNNER_IMAGE_AMD64: registry.bstein.dev/bstein/metis:0.1.0-296-amd64 # {"$imagepolicy": "maintenance:metis-amd64"}
|
||||
METIS_RUNNER_IMAGE_ARM64: registry.bstein.dev/bstein/metis:0.1.0-295-arm64 # {"$imagepolicy": "maintenance:metis-arm64"}
|
||||
METIS_RUNNER_IMAGE_AMD64: registry.bstein.dev/bstein/metis:0.1.0-298-amd64 # {"$imagepolicy": "maintenance:metis-amd64"}
|
||||
METIS_RUNNER_IMAGE_ARM64: registry.bstein.dev/bstein/metis:0.1.0-298-arm64 # {"$imagepolicy": "maintenance:metis-arm64"}
|
||||
METIS_HARBOR_REGISTRY: registry.bstein.dev
|
||||
METIS_HARBOR_PROJECT: metis
|
||||
METIS_HARBOR_API_BASE: https://registry.bstein.dev/api/v2.0
|
||||
|
||||
@ -13,6 +13,6 @@ images:
|
||||
- name: registry.bstein.dev/bstein/ariadne
|
||||
newTag: 0.1.0-461 # {"$imagepolicy": "maintenance:ariadne:tag"}
|
||||
- name: registry.bstein.dev/bstein/metis
|
||||
newTag: 0.1.0-295-arm64 # {"$imagepolicy": "maintenance:metis-arm64:tag"}
|
||||
newTag: 0.1.0-298-arm64 # {"$imagepolicy": "maintenance:metis-arm64:tag"}
|
||||
- name: registry.bstein.dev/bstein/soteria
|
||||
newTag: 0.1.0-120 # {"$imagepolicy": "maintenance:soteria:tag"}
|
||||
|
||||
@ -32,7 +32,7 @@ spec:
|
||||
kubernetes.io/arch: amd64
|
||||
containers:
|
||||
- name: metis-sentinel
|
||||
image: registry.bstein.dev/bstein/metis-sentinel:0.1.0-296-amd64 # {"$imagepolicy": "maintenance:metis-sentinel-amd64"}
|
||||
image: registry.bstein.dev/bstein/metis-sentinel:0.1.0-298-amd64 # {"$imagepolicy": "maintenance:metis-sentinel-amd64"}
|
||||
imagePullPolicy: Always
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
|
||||
@ -32,7 +32,7 @@ spec:
|
||||
kubernetes.io/arch: arm64
|
||||
containers:
|
||||
- name: metis-sentinel
|
||||
image: registry.bstein.dev/bstein/metis-sentinel:0.1.0-295-arm64 # {"$imagepolicy": "maintenance:metis-sentinel-arm64"}
|
||||
image: registry.bstein.dev/bstein/metis-sentinel:0.1.0-298-arm64 # {"$imagepolicy": "maintenance:metis-sentinel-arm64"}
|
||||
imagePullPolicy: Always
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
|
||||
68
services/vault-hermes-jenkins-token-seed/job.yaml
Normal file
68
services/vault-hermes-jenkins-token-seed/job.yaml
Normal file
@ -0,0 +1,68 @@
|
||||
# services/vault-hermes-jenkins-token-seed/job.yaml
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: vault-hermes-jenkins-build-token-seed-2
|
||||
namespace: vault
|
||||
spec:
|
||||
backoffLimit: 2
|
||||
template:
|
||||
spec:
|
||||
serviceAccountName: hermes-jenkins-token-seed
|
||||
enableServiceLinks: false
|
||||
restartPolicy: Never
|
||||
nodeSelector:
|
||||
hardware: rpi5
|
||||
kubernetes.io/arch: arm64
|
||||
node-role.kubernetes.io/worker: "true"
|
||||
affinity:
|
||||
nodeAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: kubernetes.io/hostname
|
||||
operator: NotIn
|
||||
values: [titan-04, titan-14, titan-18, titan-19, titan-24]
|
||||
securityContext:
|
||||
fsGroup: 1000
|
||||
fsGroupChangePolicy: OnRootMismatch
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: seed
|
||||
image: docker.io/hashicorp/vault@sha256:4e33b126a59c0c333b76fb4e894722462659a6bec7c48c9ee8cea56fccfd2569
|
||||
imagePullPolicy: IfNotPresent
|
||||
command: [sh, /scripts/vault_hermes_jenkins_build_token_ensure.sh]
|
||||
env:
|
||||
- name: HOME
|
||||
value: /tmp
|
||||
- name: VAULT_ADDR
|
||||
value: http://vault.vault.svc.cluster.local:8200
|
||||
- name: VAULT_K8S_ROLE
|
||||
value: hermes-jenkins-token-seed
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
readOnlyRootFilesystem: true
|
||||
runAsGroup: 1000
|
||||
runAsNonRoot: true
|
||||
runAsUser: 100
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
volumeMounts:
|
||||
- name: scripts
|
||||
mountPath: /scripts
|
||||
readOnly: true
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
resources:
|
||||
requests: {cpu: 25m, memory: 32Mi}
|
||||
limits: {cpu: 250m, memory: 128Mi}
|
||||
volumes:
|
||||
- name: scripts
|
||||
configMap:
|
||||
name: vault-hermes-jenkins-token-seed-script
|
||||
defaultMode: 0555
|
||||
- name: tmp
|
||||
emptyDir: {}
|
||||
13
services/vault-hermes-jenkins-token-seed/kustomization.yaml
Normal file
13
services/vault-hermes-jenkins-token-seed/kustomization.yaml
Normal file
@ -0,0 +1,13 @@
|
||||
# services/vault-hermes-jenkins-token-seed/kustomization.yaml
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
namespace: vault
|
||||
resources:
|
||||
- serviceaccount.yaml
|
||||
- job.yaml
|
||||
generatorOptions:
|
||||
disableNameSuffixHash: true
|
||||
configMapGenerator:
|
||||
- name: vault-hermes-jenkins-token-seed-script
|
||||
files:
|
||||
- vault_hermes_jenkins_build_token_ensure.sh=scripts/vault_hermes_jenkins_build_token_ensure.sh
|
||||
@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
secret_path="kv/atlas/hermes/developer-jenkins"
|
||||
secret_api_path="kv/data/atlas/hermes/developer-jenkins"
|
||||
jwt_file="${VAULT_K8S_JWT_FILE:-/var/run/secrets/kubernetes.io/serviceaccount/token}"
|
||||
vault_role="${VAULT_K8S_ROLE:-hermes-jenkins-token-seed}"
|
||||
payload_file="${TMPDIR:-/tmp}/hermes-jenkins-token.json"
|
||||
|
||||
log() { printf '[hermes-jenkins-token] %s\n' "$*" >&2; }
|
||||
cleanup() { rm -f "${payload_file}"; }
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
|
||||
retry_command() {
|
||||
attempt=1
|
||||
while [ "${attempt}" -le 5 ]; do
|
||||
set +e
|
||||
command_output="$("$@" 2>&1)"
|
||||
command_status=$?
|
||||
set -e
|
||||
if [ "${command_status}" -eq 0 ]; then
|
||||
printf '%s' "${command_output}"
|
||||
return 0
|
||||
fi
|
||||
if [ "${attempt}" -lt 5 ]; then
|
||||
sleep "${attempt}"
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
return "${command_status}"
|
||||
}
|
||||
|
||||
ensure_token() {
|
||||
if [ -n "${VAULT_TOKEN:-}" ]; then
|
||||
return
|
||||
fi
|
||||
jwt="$(cat "${jwt_file}")"
|
||||
VAULT_TOKEN="$(retry_command vault write -field=token \
|
||||
auth/kubernetes/login role="${vault_role}" jwt="${jwt}")" || {
|
||||
log "Vault Kubernetes login failed after retries"
|
||||
exit 1
|
||||
}
|
||||
unset jwt
|
||||
if [ -z "${VAULT_TOKEN}" ]; then
|
||||
log "Vault Kubernetes login returned an empty token"
|
||||
exit 1
|
||||
fi
|
||||
export VAULT_TOKEN
|
||||
}
|
||||
|
||||
# Return 0 for the complete field, 10 when absent, 11 when present but
|
||||
# incomplete, and 12 for a persistent authorization/transport failure.
|
||||
read_build_token() {
|
||||
attempt=1
|
||||
while [ "${attempt}" -le 5 ]; do
|
||||
set +e
|
||||
secret_json="$(vault read -format=json "${secret_api_path}" 2>&1)"
|
||||
secret_status=$?
|
||||
set -e
|
||||
if [ "${secret_status}" -eq 0 ]; then
|
||||
unset secret_json
|
||||
set +e
|
||||
build_token="$(vault kv get -field=build_token "${secret_path}" 2>&1)"
|
||||
field_status=$?
|
||||
set -e
|
||||
if [ "${field_status}" -eq 0 ]; then
|
||||
case "${build_token}" in
|
||||
''|*[!0-9a-f]*) unset build_token; return 11 ;;
|
||||
esac
|
||||
if [ "${#build_token}" -eq 64 ]; then
|
||||
unset build_token
|
||||
return 0
|
||||
fi
|
||||
unset build_token
|
||||
return 11
|
||||
fi
|
||||
if printf '%s' "${build_token}" | grep -q 'No value found'; then
|
||||
unset build_token
|
||||
return 11
|
||||
fi
|
||||
unset build_token
|
||||
elif printf '%s' "${secret_json}" | grep -Eq 'Code: 404|No value found at'; then
|
||||
unset secret_json
|
||||
return 10
|
||||
fi
|
||||
if [ "${attempt}" -lt 5 ]; then
|
||||
sleep "${attempt}"
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
unset secret_json
|
||||
return 12
|
||||
}
|
||||
|
||||
ensure_token
|
||||
|
||||
if read_build_token; then
|
||||
read_status=0
|
||||
else
|
||||
read_status=$?
|
||||
fi
|
||||
case "${read_status}" in
|
||||
0)
|
||||
log "job-scoped token already present; no write performed"
|
||||
exit 0
|
||||
;;
|
||||
11)
|
||||
log "secret exists without a valid build_token; refusing to overwrite existing fields"
|
||||
exit 1
|
||||
;;
|
||||
12)
|
||||
log "secret read failed after retries; refusing to seed"
|
||||
exit 1
|
||||
;;
|
||||
10) ;;
|
||||
*)
|
||||
log "unexpected secret read state ${read_status}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
new_token="$(retry_command vault write -field=random_bytes \
|
||||
sys/tools/random/32 format=hex)" || {
|
||||
log "Vault random token generation failed after retries"
|
||||
exit 1
|
||||
}
|
||||
case "${new_token}" in
|
||||
''|*[!0-9a-f]*)
|
||||
unset new_token
|
||||
log "Vault returned an invalid random token"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
if [ "${#new_token}" -ne 64 ]; then
|
||||
unset new_token
|
||||
log "Vault returned an invalid random token"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
umask 077
|
||||
printf '{"options":{"cas":0},"data":{"build_token":"%s"}}\n' \
|
||||
"${new_token}" > "${payload_file}"
|
||||
unset new_token
|
||||
|
||||
set +e
|
||||
create_error="$(vault write "${secret_api_path}" @"${payload_file}" 2>&1 >/dev/null)"
|
||||
create_status=$?
|
||||
set -e
|
||||
cleanup
|
||||
if [ "${create_status}" -eq 0 ]; then
|
||||
log "job-scoped token created with KV-v2 CAS create-only semantics"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if printf '%s' "${create_error}" | grep -qi 'check-and-set'; then
|
||||
unset create_error
|
||||
if read_build_token; then
|
||||
log "another seeder won CAS; existing token preserved"
|
||||
exit 0
|
||||
fi
|
||||
log "CAS race did not produce a readable build_token; refusing to continue"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
unset create_error
|
||||
log "KV-v2 CAS create failed; refusing to retry a write"
|
||||
exit 1
|
||||
@ -0,0 +1,7 @@
|
||||
# services/vault-hermes-jenkins-token-seed/serviceaccount.yaml
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: hermes-jenkins-token-seed
|
||||
namespace: vault
|
||||
automountServiceAccountToken: true
|
||||
@ -3,7 +3,7 @@
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: vault-k8s-auth-hermes-8
|
||||
name: vault-k8s-auth-hermes-10
|
||||
namespace: vault
|
||||
spec:
|
||||
backoffLimit: 2
|
||||
@ -15,16 +15,26 @@ spec:
|
||||
hardware: rpi5
|
||||
kubernetes.io/arch: arm64
|
||||
node-role.kubernetes.io/worker: "true"
|
||||
affinity:
|
||||
nodeAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: kubernetes.io/hostname
|
||||
operator: NotIn
|
||||
values: [titan-04, titan-14, titan-18, titan-19, titan-24]
|
||||
containers:
|
||||
- name: configure-k8s-auth
|
||||
image: hashicorp/vault:1.21.4
|
||||
image: docker.io/hashicorp/vault@sha256:4e33b126a59c0c333b76fb4e894722462659a6bec7c48c9ee8cea56fccfd2569
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- sh
|
||||
- /scripts/vault_k8s_auth_configure.sh
|
||||
env:
|
||||
- name: HOME
|
||||
value: /tmp
|
||||
- name: VAULT_ADDR
|
||||
value: http://10.43.57.249:8200
|
||||
value: http://vault.vault.svc.cluster.local:8200
|
||||
- name: VAULT_K8S_ROLE
|
||||
value: vault-admin
|
||||
- name: VAULT_K8S_TOKEN_REVIEWER_JWT_FILE
|
||||
@ -38,6 +48,18 @@ spec:
|
||||
- name: token-reviewer
|
||||
mountPath: /var/run/secrets/vault-token-reviewer
|
||||
readOnly: true
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
readOnlyRootFilesystem: true
|
||||
runAsGroup: 1000
|
||||
runAsNonRoot: true
|
||||
runAsUser: 100
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
resources:
|
||||
requests:
|
||||
cpu: 25m
|
||||
@ -53,3 +75,5 @@ spec:
|
||||
- name: token-reviewer
|
||||
secret:
|
||||
secretName: vault-admin-token-reviewer
|
||||
- name: tmp
|
||||
emptyDir: {}
|
||||
|
||||
@ -235,12 +235,26 @@ write_policy_and_role "mailu-mailserver" "mailu-mailserver" "mailu-vault-sync" \
|
||||
"mailu/* shared/postmark-relay shared/harbor-pull" ""
|
||||
write_policy_and_role "harbor" "harbor" "harbor-vault-sync" \
|
||||
"harbor/* shared/harbor-pull" "hermes/developer-harbor"
|
||||
harbor_policy_bootstrap_policy='
|
||||
path "kv/data/atlas/harbor/harbor-core" {
|
||||
capabilities = ["read"]
|
||||
}
|
||||
'
|
||||
write_raw_policy "harbor-policy-bootstrap" "${harbor_policy_bootstrap_policy}"
|
||||
log "writing role harbor-policy-bootstrap"
|
||||
vault_cmd write "auth/kubernetes/role/harbor-policy-bootstrap" \
|
||||
bound_service_account_names="harbor-policy-bootstrap" \
|
||||
bound_service_account_namespaces="harbor" \
|
||||
policies="harbor-policy-bootstrap" \
|
||||
ttl="${role_ttl}"
|
||||
write_policy_and_role "nextcloud" "nextcloud" "nextcloud-vault" \
|
||||
"nextcloud/* shared/keycloak-admin shared/postmark-relay" ""
|
||||
write_policy_and_role "comms" "comms" "comms-vault,atlasbot" \
|
||||
"comms/* shared/chat-ai-keys-runtime shared/harbor-pull" ""
|
||||
write_policy_and_role "jenkins" "jenkins" "jenkins,jenkins-vault-sync" \
|
||||
"jenkins/* shared/harbor-pull quality/sonarqube-oidc" "hermes/developer-jenkins"
|
||||
write_policy_and_role "jenkins" "jenkins" "jenkins" \
|
||||
"jenkins/* shared/harbor-pull quality/sonarqube-oidc hermes/developer-jenkins" ""
|
||||
write_policy_and_role "jenkins-vault-sync" "jenkins" "jenkins-vault-sync" \
|
||||
"shared/harbor-pull" ""
|
||||
write_policy_and_role "monitoring" "monitoring" "monitoring-vault-sync" \
|
||||
"monitoring/* shared/postmark-relay shared/harbor-pull" ""
|
||||
write_policy_and_role "logging" "logging" "logging-vault-sync" \
|
||||
@ -255,8 +269,30 @@ write_policy_and_role "game-stream" "game-stream" "game-stream-vault" \
|
||||
"game-stream/*" ""
|
||||
write_policy_and_role "hermes" "hermes" "hermes-vault,hermes-triage" \
|
||||
"hermes/triage-oidc hermes/agent-tokens hermes/triage-api" ""
|
||||
write_policy_and_role "hermes-agent" "hermes" "hermes-agent,hermes-switchyard" \
|
||||
"hermes/agent-oidc hermes/agent-tokens hermes/chat-telegram hermes/developer-keycloak hermes/developer-gitea hermes/developer-harbor hermes/developer-jenkins hermes/developer-ssh" ""
|
||||
# The agent keeps its own credentials but no longer holds developer-gitea:
|
||||
# the least-authority hermes-scm-broker role (below) carries the Gitea token
|
||||
# so a compromised agent cannot reach the SCM write path directly.
|
||||
write_policy_and_role "hermes-agent" "hermes" "hermes-agent" \
|
||||
"hermes/agent-oidc hermes/agent-tokens hermes/chat-telegram hermes/developer-keycloak hermes/developer-harbor hermes/developer-jenkins hermes/developer-ssh" ""
|
||||
write_policy_and_role "hermes-switchyard" "hermes" "hermes-switchyard" \
|
||||
"hermes/chat-telegram" ""
|
||||
hermes_jenkins_token_seed_policy='
|
||||
path "kv/data/atlas/hermes/developer-jenkins" {
|
||||
capabilities = ["create", "read"]
|
||||
}
|
||||
path "sys/tools/random/32" {
|
||||
capabilities = ["update"]
|
||||
}
|
||||
'
|
||||
write_raw_policy "hermes-jenkins-token-seed" "${hermes_jenkins_token_seed_policy}"
|
||||
log "writing role hermes-jenkins-token-seed"
|
||||
vault_cmd write "auth/kubernetes/role/hermes-jenkins-token-seed" \
|
||||
bound_service_account_names="hermes-jenkins-token-seed" \
|
||||
bound_service_account_namespaces="vault" \
|
||||
policies="hermes-jenkins-token-seed" \
|
||||
ttl="${role_ttl}"
|
||||
write_policy_and_role "hermes-scm-broker" "hermes-scm" "hermes-scm-broker" \
|
||||
"hermes/developer-gitea" ""
|
||||
write_policy_and_role "hermes-credential-sync" "hermes" "hermes-agent" \
|
||||
"" "hermes/agent-tokens"
|
||||
write_policy_and_role "hermes-node-ssh" "hermes" "hermes-node-ssh-access" \
|
||||
|
||||
@ -14,6 +14,10 @@
|
||||
}
|
||||
],
|
||||
"managed_modules": [
|
||||
"ci/scripts/hermes_image_release.py",
|
||||
"dockerfiles/hermes-kaniko-heredoc-runner.py",
|
||||
"services/harbor/scripts/harbor_hermes_agent_immutability_ensure.py",
|
||||
"services/hermes/scripts/jenkins_image_build_trigger.py",
|
||||
"ci/scripts/publish_test_metrics.py",
|
||||
"ci/scripts/publish_test_metrics_quality.py",
|
||||
"ci/scripts/semgrep_report.py",
|
||||
@ -53,9 +57,29 @@
|
||||
"testing/tests/test_supply_chain_report.py",
|
||||
"testing/tests/test_semgrep_report.py",
|
||||
"testing/tests/test_quality_contract.py",
|
||||
"testing/tests/test_quality_gate.py"
|
||||
"testing/tests/test_quality_gate.py",
|
||||
"services/gitea/scripts/gitea_branch_protection_check.py",
|
||||
"services/hermes/scm-common/scripts/gitea_api.py",
|
||||
"services/hermes/scm-common/scripts/gitea_api_policy.py",
|
||||
"services/hermes/scm-common/scripts/scm_broker.py",
|
||||
"services/hermes/scm-common/scripts/scm_broker_client.py",
|
||||
"services/hermes/scm-common/scripts/scm_broker_io.py",
|
||||
"services/hermes/scm-common/scripts/scm_broker_server.py",
|
||||
"services/hermes/scripts/hermes_coordinator.py",
|
||||
"services/hermes/scripts/node_account_audit.py",
|
||||
"services/hermes/scripts/node_account_hardening.py",
|
||||
"services/hermes/scripts/node_account_io.py",
|
||||
"services/hermes/scripts/stage_runtime_access.py",
|
||||
"services/hermes/scm-common/scripts/deadline_http.py",
|
||||
"services/hermes/scm-common/scripts/git_pack_objects.py",
|
||||
"services/hermes/scm-common/scripts/receive_pack_scan.py",
|
||||
"services/hermes/scripts/node_polkit_audit.py"
|
||||
],
|
||||
"lint_paths": [
|
||||
"ci/scripts/hermes_image_release.py",
|
||||
"dockerfiles/hermes-kaniko-heredoc-runner.py",
|
||||
"services/harbor/scripts/harbor_hermes_agent_immutability_ensure.py",
|
||||
"services/hermes/scripts/jenkins_image_build_trigger.py",
|
||||
"ci/scripts/publish_test_metrics.py",
|
||||
"ci/scripts/publish_test_metrics_quality.py",
|
||||
"ci/scripts/semgrep_report.py",
|
||||
@ -86,7 +110,15 @@
|
||||
"services/hermes/scripts/cli_lane_routing.py",
|
||||
"services/hermes/scripts/cli_lane_runner.py",
|
||||
"testing/tests",
|
||||
"testing"
|
||||
"testing",
|
||||
"services/gitea/scripts/gitea_branch_protection_check.py",
|
||||
"services/hermes/scm-common/scripts",
|
||||
"services/hermes/scripts/hermes_coordinator.py",
|
||||
"services/hermes/scripts/node_account_audit.py",
|
||||
"services/hermes/scripts/node_account_hardening.py",
|
||||
"services/hermes/scripts/node_account_io.py",
|
||||
"services/hermes/scripts/stage_runtime_access.py",
|
||||
"services/hermes/scripts/node_polkit_audit.py"
|
||||
],
|
||||
"pytest_suites": {
|
||||
"unit": {
|
||||
@ -99,10 +131,14 @@
|
||||
"junit": "build/junit-unit.xml",
|
||||
"coverage_sources": [
|
||||
"ci/scripts",
|
||||
"dockerfiles",
|
||||
"scripts.render.dashboards_render_atlas",
|
||||
"services/harbor/scripts",
|
||||
"services/hermes/scripts",
|
||||
"services/mailu/scripts",
|
||||
"testing"
|
||||
"testing",
|
||||
"services/gitea/scripts",
|
||||
"services/hermes/scm-common/scripts"
|
||||
],
|
||||
"coverage_xml": "build/coverage-unit.xml"
|
||||
},
|
||||
@ -169,7 +205,15 @@
|
||||
"services/*/scripts/tests/**/*.py",
|
||||
"services/hermes/scripts/cli_lane_*.py",
|
||||
"services/mailu/scripts/mailu_sync.py",
|
||||
"services/mailu/scripts/mailu_sync_listener.py"
|
||||
"services/mailu/scripts/mailu_sync_listener.py",
|
||||
"services/gitea/scripts/gitea_branch_protection_check.py",
|
||||
"services/hermes/scm-common/scripts/*.py",
|
||||
"services/hermes/scripts/hermes_coordinator.py",
|
||||
"services/hermes/scripts/node_account_audit.py",
|
||||
"services/hermes/scripts/node_account_hardening.py",
|
||||
"services/hermes/scripts/node_account_io.py",
|
||||
"services/hermes/scripts/stage_runtime_access.py",
|
||||
"services/hermes/scripts/node_polkit_audit.py"
|
||||
],
|
||||
"naming_rules": [
|
||||
{
|
||||
@ -212,6 +256,10 @@
|
||||
"coverage": {
|
||||
"minimum_percent": 95.0,
|
||||
"tracked_files": [
|
||||
"ci/scripts/hermes_image_release.py",
|
||||
"dockerfiles/hermes-kaniko-heredoc-runner.py",
|
||||
"services/harbor/scripts/harbor_hermes_agent_immutability_ensure.py",
|
||||
"services/hermes/scripts/jenkins_image_build_trigger.py",
|
||||
"ci/scripts/publish_test_metrics.py",
|
||||
"ci/scripts/publish_test_metrics_quality.py",
|
||||
"ci/scripts/semgrep_report.py",
|
||||
@ -243,7 +291,23 @@
|
||||
"testing/quality_docs.py",
|
||||
"testing/quality_hygiene.py",
|
||||
"testing/quality_coverage.py",
|
||||
"testing/quality_gate.py"
|
||||
"testing/quality_gate.py",
|
||||
"services/gitea/scripts/gitea_branch_protection_check.py",
|
||||
"services/hermes/scm-common/scripts/gitea_api.py",
|
||||
"services/hermes/scm-common/scripts/gitea_api_policy.py",
|
||||
"services/hermes/scm-common/scripts/scm_broker.py",
|
||||
"services/hermes/scm-common/scripts/scm_broker_client.py",
|
||||
"services/hermes/scm-common/scripts/scm_broker_io.py",
|
||||
"services/hermes/scm-common/scripts/scm_broker_server.py",
|
||||
"services/hermes/scripts/hermes_coordinator.py",
|
||||
"services/hermes/scripts/node_account_audit.py",
|
||||
"services/hermes/scripts/node_account_hardening.py",
|
||||
"services/hermes/scripts/node_account_io.py",
|
||||
"services/hermes/scripts/stage_runtime_access.py",
|
||||
"services/hermes/scm-common/scripts/deadline_http.py",
|
||||
"services/hermes/scm-common/scripts/git_pack_objects.py",
|
||||
"services/hermes/scm-common/scripts/receive_pack_scan.py",
|
||||
"services/hermes/scripts/node_polkit_audit.py"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,42 +3,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CoverageRates:
|
||||
"""Validated line and branch percentages for one source file."""
|
||||
|
||||
line: float
|
||||
branch: float
|
||||
|
||||
|
||||
def _percentage(class_node: ET.Element, attribute: str) -> float:
|
||||
"""Parse one finite Cobertura rate and fail closed outside its domain."""
|
||||
raw = class_node.attrib.get(attribute)
|
||||
if raw is None:
|
||||
raise ValueError(f"coverage class missing {attribute}")
|
||||
value = float(raw)
|
||||
if not math.isfinite(value) or value < 0.0 or value > 1.0:
|
||||
raise ValueError(f"coverage class has invalid {attribute}: {raw}")
|
||||
return value * 100.0
|
||||
|
||||
|
||||
def _load_rates(xml_path: Path, root: Path) -> dict[str, CoverageRates]:
|
||||
"""Load validated per-file line and branch rates from Cobertura XML."""
|
||||
def _load_rates(xml_path: Path, root: Path) -> dict[str, dict[str, float | None]]:
|
||||
"""Load per-file line and branch percentages from a Cobertura XML report."""
|
||||
tree = ET.parse(xml_path)
|
||||
xml_root = tree.getroot()
|
||||
source_roots = [
|
||||
Path(node.text) for node in xml_root.findall("./sources/source") if node.text
|
||||
Path(node.text)
|
||||
for node in xml_root.findall("./sources/source")
|
||||
if node.text
|
||||
]
|
||||
rates: dict[str, CoverageRates] = {}
|
||||
rates: dict[str, dict[str, float | None]] = {}
|
||||
for class_node in xml_root.findall(".//class"):
|
||||
filename = class_node.attrib.get("filename")
|
||||
if not filename:
|
||||
line_rate = class_node.attrib.get("line-rate")
|
||||
if not filename or line_rate is None:
|
||||
continue
|
||||
normalized = filename.replace("\\", "/")
|
||||
if normalized.startswith("/"):
|
||||
@ -50,50 +32,44 @@ def _load_rates(xml_path: Path, root: Path) -> dict[str, CoverageRates]:
|
||||
if candidate.exists():
|
||||
key = candidate.relative_to(root).as_posix()
|
||||
break
|
||||
rates[key] = CoverageRates(
|
||||
line=_percentage(class_node, "line-rate"),
|
||||
branch=_percentage(class_node, "branch-rate"),
|
||||
)
|
||||
branch_rate = class_node.attrib.get("branch-rate")
|
||||
rates[key] = {
|
||||
"line": float(line_rate) * 100.0,
|
||||
"branch": None if branch_rate is None else float(branch_rate) * 100.0,
|
||||
}
|
||||
return rates
|
||||
|
||||
|
||||
def _load_percentages(xml_path: Path, root: Path) -> dict[str, float]:
|
||||
"""Load per-file line percentages for backward-compatible callers."""
|
||||
return {path: rates.line for path, rates in _load_rates(xml_path, root).items()}
|
||||
|
||||
|
||||
def run_check(contract: dict[str, Any], root: Path, xml_path: Path) -> list[str]:
|
||||
"""Return human-readable issues for tracked files below the coverage floor.
|
||||
|
||||
The report is intentionally per-file so a single weak module cannot hide
|
||||
behind aggregate suite coverage.
|
||||
Line and branch rates are both enforced per file so a single weak module
|
||||
cannot hide behind aggregate suite coverage or line-only reporting.
|
||||
"""
|
||||
if not xml_path.exists():
|
||||
return [f"coverage xml missing: {xml_path.relative_to(root)}"]
|
||||
|
||||
try:
|
||||
rates_by_path = _load_rates(xml_path, root)
|
||||
except (ET.ParseError, OSError, UnicodeError, ValueError) as error:
|
||||
return [f"coverage xml invalid: {error}"]
|
||||
rates = _load_rates(xml_path, root)
|
||||
minimum = float(contract.get("coverage", {}).get("minimum_percent", 95.0))
|
||||
issues: list[str] = []
|
||||
|
||||
for relative_path in contract.get("coverage", {}).get("tracked_files", []):
|
||||
normalized = relative_path.replace("\\", "/")
|
||||
rates = rates_by_path.get(normalized)
|
||||
if rates is None:
|
||||
file_rates = rates.get(normalized)
|
||||
if file_rates is None:
|
||||
issues.append(f"coverage missing for tracked file: {relative_path}")
|
||||
continue
|
||||
if rates.line + 1e-9 < minimum:
|
||||
issues.append(
|
||||
f"line coverage below {minimum:.1f}%: {relative_path} "
|
||||
f"({rates.line:.1f}%)"
|
||||
)
|
||||
if rates.branch + 1e-9 < minimum:
|
||||
issues.append(
|
||||
f"branch coverage below {minimum:.1f}%: {relative_path} "
|
||||
f"({rates.branch:.1f}%)"
|
||||
)
|
||||
for metric in ("line", "branch"):
|
||||
percent = file_rates[metric]
|
||||
if percent is None:
|
||||
issues.append(
|
||||
f"{metric} coverage missing for tracked file: {relative_path}"
|
||||
)
|
||||
elif percent + 1e-9 < minimum:
|
||||
issues.append(
|
||||
f"{metric} coverage below {minimum:.1f}%: "
|
||||
f"{relative_path} ({percent:.1f}%)"
|
||||
)
|
||||
|
||||
return issues
|
||||
|
||||
@ -108,16 +84,13 @@ def compute_workspace_line_coverage(
|
||||
if not xml_path.exists():
|
||||
return 0.0
|
||||
|
||||
try:
|
||||
percentages = _load_percentages(xml_path, root)
|
||||
except (ET.ParseError, OSError, UnicodeError, ValueError):
|
||||
return 0.0
|
||||
rates = _load_rates(xml_path, root)
|
||||
samples: list[float] = []
|
||||
for relative_path in contract.get("coverage", {}).get("tracked_files", []):
|
||||
normalized = relative_path.replace("\\", "/")
|
||||
percent = percentages.get(normalized)
|
||||
if percent is not None:
|
||||
samples.append(percent)
|
||||
file_rates = rates.get(normalized)
|
||||
if file_rates is not None:
|
||||
samples.append(file_rates["line"])
|
||||
if not samples:
|
||||
return 0.0
|
||||
return round(sum(samples) / len(samples), 3)
|
||||
|
||||
@ -332,7 +332,7 @@ def run_profile(
|
||||
results.append(
|
||||
_result(
|
||||
"coverage",
|
||||
"Per-file 95% coverage floor for tracked quality-managed modules.",
|
||||
"Per-file 95% line and branch coverage floor for tracked quality-managed modules.",
|
||||
_status_from_issues(issues),
|
||||
issues=issues,
|
||||
coverage_xml=str(coverage_xml.relative_to(root)),
|
||||
|
||||
@ -1,253 +0,0 @@
|
||||
"""Owner and Switchyard access-boundary contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from testing.tests.test_hermes_cli_support import (
|
||||
HERMES,
|
||||
SCRIPTS,
|
||||
_agent_deployment,
|
||||
yaml,
|
||||
)
|
||||
|
||||
|
||||
def test_owner_agent_has_pinned_dedicated_node_ssh_access():
|
||||
deployment = _agent_deployment()
|
||||
annotations = deployment["spec"]["template"]["metadata"]["annotations"]
|
||||
assert annotations[
|
||||
"vault.hashicorp.com/agent-inject-secret-node-ssh-private-key"
|
||||
] == "kv/data/atlas/hermes/developer-ssh"
|
||||
assert annotations[
|
||||
"vault.hashicorp.com/agent-inject-secret-node-ssh-config"
|
||||
] == "kv/data/atlas/hermes/developer-ssh"
|
||||
assert annotations[
|
||||
"vault.hashicorp.com/agent-inject-secret-node-ssh-known-hosts"
|
||||
] == "kv/data/atlas/hermes/developer-ssh"
|
||||
|
||||
init = next(
|
||||
item
|
||||
for item in deployment["spec"]["template"]["spec"]["initContainers"]
|
||||
if item["name"] == "init-config"
|
||||
)
|
||||
command = init["command"][2]
|
||||
assert "ln -s /runtime-access/node-ssh-config /opt/data/home/.ssh/config" in command
|
||||
assert (
|
||||
"ln -s /runtime-access/node-ssh-known-hosts /opt/data/home/.ssh/known_hosts"
|
||||
in command
|
||||
)
|
||||
assert "ln -s home/.ssh /opt/data/.ssh" in command
|
||||
assert "chmod 0700 /opt/data/home/.ssh" in command
|
||||
assert (
|
||||
"ln -s /runtime-access/node-ssh-private-key "
|
||||
"/opt/data/home/.ssh/id_ed25519_atlas_nodes"
|
||||
) in command
|
||||
|
||||
config = yaml.safe_load((HERMES / "agent-configmap.yaml").read_text())["data"]
|
||||
assert "ssh_config" not in config
|
||||
assert "ssh_known_hosts" not in config
|
||||
assert "ssh-ed25519" not in (HERMES / "agent-configmap.yaml").read_text()
|
||||
|
||||
resources = yaml.safe_load((HERMES / "kustomization.yaml").read_text())[
|
||||
"resources"
|
||||
]
|
||||
assert "node-ssh-access.yaml" in resources
|
||||
access = [
|
||||
item
|
||||
for item in yaml.safe_load_all((HERMES / "node-ssh-access.yaml").read_text())
|
||||
if item
|
||||
]
|
||||
service_account = next(item for item in access if item["kind"] == "ServiceAccount")
|
||||
assert service_account["metadata"]["name"] == "hermes-node-ssh-access"
|
||||
provider = next(item for item in access if item["kind"] == "SecretProviderClass")
|
||||
assert provider["metadata"]["name"] == "hermes-node-ssh-access"
|
||||
assert provider["spec"]["provider"] == "vault"
|
||||
parameters = provider["spec"]["parameters"]
|
||||
assert parameters["roleName"] == "hermes-node-ssh"
|
||||
assert 'secretPath: "kv/data/atlas/hermes/developer-ssh"' in parameters["objects"]
|
||||
assert 'secretKey: "public_key"' in parameters["objects"]
|
||||
daemonset = next(item for item in access if item["kind"] == "DaemonSet")
|
||||
pod = daemonset["spec"]["template"]["spec"]
|
||||
assert pod["serviceAccountName"] == "hermes-node-ssh-access"
|
||||
assert pod["automountServiceAccountToken"] is True
|
||||
host_home = next(item for item in pod["volumes"] if item["name"] == "host-home")
|
||||
assert host_home["hostPath"] == {"path": "/home", "type": "Directory"}
|
||||
vault_secrets = next(
|
||||
item for item in pod["volumes"] if item["name"] == "vault-secrets"
|
||||
)
|
||||
assert vault_secrets["csi"]["driver"] == "secrets-store.csi.k8s.io"
|
||||
assert vault_secrets["csi"]["volumeAttributes"] == {
|
||||
"secretProviderClass": "hermes-node-ssh-access"
|
||||
}
|
||||
reconciler = pod["containers"][0]["args"][0]
|
||||
assert "cat /vault/secrets/node-ssh-public-key" in reconciler
|
||||
assert "grep -qxF" in reconciler
|
||||
assert "for user in atlas oceanus" in reconciler
|
||||
assert "/host-etc/passwd" in reconciler
|
||||
assert "chown \"${uid}:${gid}\"" in reconciler
|
||||
host_passwd = next(
|
||||
item for item in pod["volumes"] if item["name"] == "host-passwd"
|
||||
)
|
||||
assert host_passwd["hostPath"] == {"path": "/etc/passwd", "type": "File"}
|
||||
|
||||
|
||||
def test_owner_agent_tracks_no_ssh_identity_or_host_key_material():
|
||||
"""Vault references may be tracked; SSH identities and trust data may not."""
|
||||
forbidden = (
|
||||
"BEGIN OPENSSH PRIVATE KEY",
|
||||
"ssh-ed25519 AAAA",
|
||||
"ssh-rsa AAAA",
|
||||
"IdentityFile ",
|
||||
"UserKnownHostsFile ",
|
||||
"StrictHostKeyChecking ",
|
||||
"ssh_config:",
|
||||
"ssh_known_hosts:",
|
||||
)
|
||||
text_suffixes = {
|
||||
".conf",
|
||||
".json",
|
||||
".md",
|
||||
".py",
|
||||
".sh",
|
||||
".toml",
|
||||
".yaml",
|
||||
".yml",
|
||||
}
|
||||
tracked = "\n".join(
|
||||
path.read_text(encoding="utf-8")
|
||||
for path in HERMES.rglob("*")
|
||||
if path.is_file() and path.suffix in text_suffixes
|
||||
)
|
||||
for marker in forbidden:
|
||||
assert marker not in tracked
|
||||
|
||||
|
||||
def test_switchyard_has_a_dedicated_non_owner_identity_and_read_only_catalog():
|
||||
"""Routing must not inherit the owner agent's cluster-admin capability."""
|
||||
service_accounts = [
|
||||
item
|
||||
for item in yaml.safe_load_all(
|
||||
(HERMES / "vault-serviceaccount.yaml").read_text()
|
||||
)
|
||||
if item
|
||||
]
|
||||
assert any(
|
||||
item["kind"] == "ServiceAccount"
|
||||
and item["metadata"]["name"] == "hermes-switchyard"
|
||||
for item in service_accounts
|
||||
)
|
||||
|
||||
switchyard = yaml.safe_load(
|
||||
(HERMES / "switchyard-deployment.yaml").read_text()
|
||||
)
|
||||
switchyard_pod = switchyard["spec"]["template"]["spec"]
|
||||
assert switchyard_pod["serviceAccountName"] == "hermes-switchyard"
|
||||
agent_pod = _agent_deployment()["spec"]["template"]["spec"]
|
||||
for pod, container_name in (
|
||||
(switchyard_pod, "worker-route-broker"),
|
||||
(agent_pod, "claude-broker"),
|
||||
):
|
||||
container = next(item for item in pod["containers"] if item["name"] == container_name)
|
||||
catalog = next(
|
||||
item
|
||||
for item in container["volumeMounts"]
|
||||
if item["mountPath"] == "/routing-catalog"
|
||||
)
|
||||
assert catalog["readOnly"] is True
|
||||
|
||||
rbac = [
|
||||
item
|
||||
for item in yaml.safe_load_all((HERMES / "agent-rbac.yaml").read_text())
|
||||
if item
|
||||
]
|
||||
binding = next(item for item in rbac if item["kind"] == "ClusterRoleBinding")
|
||||
assert binding["subjects"] == [
|
||||
{"kind": "ServiceAccount", "name": "hermes-agent", "namespace": "hermes"}
|
||||
]
|
||||
|
||||
|
||||
def test_switchyard_active_state_uses_a_relocatable_rwx_claim():
|
||||
"""A stale node attachment must not strand the routing authority."""
|
||||
claims = [
|
||||
item
|
||||
for item in yaml.safe_load_all((HERMES / "switchyard-pvc.yaml").read_text())
|
||||
if item
|
||||
]
|
||||
active_claim = next(
|
||||
item
|
||||
for item in claims
|
||||
if item["metadata"]["name"] == "hermes-switchyard-state-rwx"
|
||||
)
|
||||
assert active_claim["spec"]["accessModes"] == ["ReadWriteMany"]
|
||||
|
||||
deployment = yaml.safe_load((HERMES / "switchyard-deployment.yaml").read_text())
|
||||
strategy = deployment["spec"]["strategy"]
|
||||
assert strategy == {
|
||||
"type": "RollingUpdate",
|
||||
"rollingUpdate": {"maxSurge": 1, "maxUnavailable": 0},
|
||||
}
|
||||
pod = deployment["spec"]["template"]["spec"]
|
||||
state = next(item for item in pod["volumes"] if item["name"] == "state")
|
||||
assert state["persistentVolumeClaim"]["claimName"] == active_claim["metadata"][
|
||||
"name"
|
||||
]
|
||||
|
||||
|
||||
def test_worker_route_broker_accepts_pod_network_health_checks():
|
||||
"""Kubelet probes the pod IP, so the broker cannot bind to loopback only."""
|
||||
script = (SCRIPTS / "worker_route_broker.py").read_text()
|
||||
assert 'ThreadingHTTPServer(("0.0.0.0", PORT), Handler)' in script
|
||||
|
||||
|
||||
def test_switchyard_network_boundary_allows_vault_bootstrap():
|
||||
"""The pre-populate init container must reach Vault before routing starts."""
|
||||
documents = [
|
||||
item
|
||||
for item in yaml.safe_load_all((HERMES / "networkpolicy.yaml").read_text())
|
||||
if item
|
||||
]
|
||||
isolation = next(
|
||||
item
|
||||
for item in documents
|
||||
if item.get("metadata", {}).get("name") == "hermes-switchyard-isolation"
|
||||
)
|
||||
assert any(
|
||||
rule.get("to")
|
||||
== [
|
||||
{
|
||||
"namespaceSelector": {
|
||||
"matchLabels": {"kubernetes.io/metadata.name": "vault"}
|
||||
},
|
||||
"podSelector": {"matchLabels": {"app": "vault"}},
|
||||
}
|
||||
]
|
||||
and rule.get("ports") == [{"protocol": "TCP", "port": 8200}]
|
||||
for rule in isolation["spec"]["egress"]
|
||||
)
|
||||
|
||||
|
||||
def test_switchyard_network_boundary_allows_metrics_scraping():
|
||||
"""VictoriaMetrics may scrape Switchyard without widening its API boundary."""
|
||||
documents = [
|
||||
item
|
||||
for item in yaml.safe_load_all((HERMES / "networkpolicy.yaml").read_text())
|
||||
if item
|
||||
]
|
||||
isolation = next(
|
||||
item
|
||||
for item in documents
|
||||
if item.get("metadata", {}).get("name") == "hermes-switchyard-isolation"
|
||||
)
|
||||
assert any(
|
||||
rule.get("from")
|
||||
== [
|
||||
{
|
||||
"namespaceSelector": {
|
||||
"matchLabels": {
|
||||
"kubernetes.io/metadata.name": "monitoring"
|
||||
}
|
||||
},
|
||||
"podSelector": {"matchLabels": {"app": "server"}},
|
||||
}
|
||||
]
|
||||
and rule.get("ports") == [{"protocol": "TCP", "port": 9005}]
|
||||
for rule in isolation["spec"]["ingress"]
|
||||
)
|
||||
@ -285,6 +285,19 @@ def test_broker_services_survive_sibling_container_readiness_loss():
|
||||
):
|
||||
assert services[name]["spec"]["publishNotReadyAddresses"] is True
|
||||
|
||||
# The agent web endpoints route through this Service to the same pod the
|
||||
# cli-lane-runner readiness probe gates; a deferred lane image must not
|
||||
# take down the dashboard or terminal.
|
||||
agent_auth = next(
|
||||
item
|
||||
for item in yaml.safe_load_all((HERMES / "oauth2-proxy.yaml").read_text())
|
||||
if item
|
||||
and item["kind"] == "Service"
|
||||
and item["metadata"]["name"] == "oauth2-proxy-hermes-agent"
|
||||
)
|
||||
assert agent_auth["spec"]["selector"] == {"app": "hermes-agent"}
|
||||
assert agent_auth["spec"]["publishNotReadyAddresses"] is True
|
||||
|
||||
|
||||
def test_cli_lane_domain_modules_are_all_mounted_with_the_runner():
|
||||
manifest = yaml.safe_load((HERMES / "kustomization.yaml").read_text())
|
||||
@ -354,8 +367,8 @@ def test_cli_lane_config_refresh_does_not_restart_active_work():
|
||||
},
|
||||
"initialDelaySeconds": 2,
|
||||
"periodSeconds": 5,
|
||||
"timeoutSeconds": 2,
|
||||
"failureThreshold": 1,
|
||||
"timeoutSeconds": 5,
|
||||
"failureThreshold": 3,
|
||||
}
|
||||
environment = {item["name"]: item["value"] for item in lane["env"]}
|
||||
assert environment["HERMES_CLI_HEALTH_MAX_AGE_SECONDS"] == "60"
|
||||
@ -374,3 +387,33 @@ def test_agent_dashboard_reconnects_all_transient_websockets():
|
||||
assert ".then(() => gw.connect())" in dockerfile
|
||||
assert 'api.getSessions(1, 0, profile ?? "")' in dockerfile
|
||||
assert "dashboard token rotated by a server restart" in dockerfile
|
||||
|
||||
|
||||
def test_agent_image_runs_execution_safety_patch_and_regressions():
|
||||
dockerfiles = HERMES.parents[1] / "dockerfiles"
|
||||
dockerfile = (dockerfiles / "Dockerfile.hermes-agent").read_text(encoding="utf-8")
|
||||
dockerignore = (dockerfiles / "Dockerfile.hermes-agent.dockerignore").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
for name in (
|
||||
"patch-hermes-execution-safety.py",
|
||||
"hermes-execution-safety-regression.py",
|
||||
"hermes_execution_patch_support.py",
|
||||
"patch_hermes_run_safety.py",
|
||||
"patch_hermes_decomposition_safety.py",
|
||||
"hermes_execution_regression_support.py",
|
||||
"hermes_run_safety_regression.py",
|
||||
"hermes_decomposition_safety_regression.py",
|
||||
):
|
||||
assert f"COPY dockerfiles/{name}" in dockerfile
|
||||
assert f"!dockerfiles/{name}" in dockerignore
|
||||
assert "/opt/hermes/.venv/bin/python /tmp/patch-hermes-execution-safety.py" in (
|
||||
dockerfile
|
||||
)
|
||||
assert "/opt/hermes/.venv/bin/python /tmp/hermes-execution-safety-regression.py" in (
|
||||
dockerfile
|
||||
)
|
||||
assert "COPY services/hermes/scripts/cli_lane_*.py" in dockerfile
|
||||
assert "!services/hermes/scripts/cli_lane_*.py" in dockerignore
|
||||
assert "HERMES_CLI_LANE_SOURCE=/tmp/hermes-lane-regression" in dockerfile
|
||||
|
||||
@ -1,205 +0,0 @@
|
||||
"""Agent authentication, networking, and Flux health contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from testing.tests.test_hermes_cli_support import (
|
||||
FLUX_HERMES,
|
||||
HERMES,
|
||||
KEYCLOAK,
|
||||
_agent_deployment,
|
||||
yaml,
|
||||
)
|
||||
|
||||
|
||||
def test_agent_image_runs_execution_safety_patch_and_regressions():
|
||||
dockerfiles = HERMES.parents[1] / "dockerfiles"
|
||||
dockerfile = (dockerfiles / "Dockerfile.hermes-agent").read_text(encoding="utf-8")
|
||||
dockerignore = (dockerfiles / "Dockerfile.hermes-agent.dockerignore").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
for name in (
|
||||
"patch-hermes-execution-safety.py",
|
||||
"hermes-execution-safety-regression.py",
|
||||
"hermes_execution_patch_support.py",
|
||||
"patch_hermes_run_safety.py",
|
||||
"patch_hermes_decomposition_safety.py",
|
||||
"hermes_execution_regression_support.py",
|
||||
"hermes_run_safety_regression.py",
|
||||
"hermes_decomposition_safety_regression.py",
|
||||
):
|
||||
assert f"COPY dockerfiles/{name}" in dockerfile
|
||||
assert f"!dockerfiles/{name}" in dockerignore
|
||||
assert "/opt/hermes/.venv/bin/python /tmp/patch-hermes-execution-safety.py" in (
|
||||
dockerfile
|
||||
)
|
||||
assert "/opt/hermes/.venv/bin/python /tmp/hermes-execution-safety-regression.py" in (
|
||||
dockerfile
|
||||
)
|
||||
assert "COPY services/hermes/scripts/cli_lane_*.py" in dockerfile
|
||||
assert "!services/hermes/scripts/cli_lane_*.py" in dockerignore
|
||||
assert "HERMES_CLI_LANE_SOURCE=/tmp/hermes-lane-regression" in dockerfile
|
||||
|
||||
|
||||
def test_agent_refreshes_routes_after_restoring_cli_logins():
|
||||
deployment = _agent_deployment()
|
||||
init_containers = {
|
||||
item["name"]: item
|
||||
for item in deployment["spec"]["template"]["spec"]["initContainers"]
|
||||
}
|
||||
configure = init_containers["configure-agent-clients"]
|
||||
command = configure["command"][-1]
|
||||
assert "configure_agent_clients.py" in command
|
||||
assert command.index("configure_agent_clients.py") < command.index(
|
||||
"hermes_coordinator.py --once"
|
||||
)
|
||||
env = {item["name"]: item["value"] for item in configure["env"]}
|
||||
assert env["HERMES_AUTH_FILE"] == "/runtime-access/hermes-auth.json"
|
||||
assert env["PYTHONPATH"] == "/opt/hermes"
|
||||
for name in ("bootstrap-coordinator", "configure-agent-clients"):
|
||||
route_env = {
|
||||
item["name"]: item["value"]
|
||||
for item in init_containers[name]["env"]
|
||||
}
|
||||
assert route_env["CODEX_HOME"] == "/runtime-access/codex"
|
||||
assert route_env["CLAUDE_CONFIG_DIR"] == "/runtime-access/claude"
|
||||
assert "/opt/data/tools/bin" in route_env["PATH"]
|
||||
|
||||
assert "patch-web-session-activity" in init_containers
|
||||
web_patch = init_containers["patch-web-session-activity"]
|
||||
assert "/opt/coordinator/patch_web_session_activity.py" in web_patch["command"]
|
||||
|
||||
containers = {
|
||||
item["name"]: item
|
||||
for item in deployment["spec"]["template"]["spec"]["containers"]
|
||||
}
|
||||
steward_env = {
|
||||
item["name"]: item["value"]
|
||||
for item in containers["model-steward"]["env"]
|
||||
}
|
||||
assert steward_env["CODEX_HOME"] == "/runtime-access/codex"
|
||||
assert steward_env["CLAUDE_CONFIG_DIR"] == "/runtime-access/claude"
|
||||
assert "/opt/data/tools/bin" in steward_env["PATH"]
|
||||
hermes_mounts = {
|
||||
(item["name"], item["mountPath"], item.get("subPath"))
|
||||
for item in containers["hermes"]["volumeMounts"]
|
||||
}
|
||||
assert (
|
||||
"web-server-patch",
|
||||
"/opt/hermes/hermes_cli/web_server.py",
|
||||
"web_server.py",
|
||||
) in hermes_mounts
|
||||
|
||||
|
||||
def test_flux_health_checks_follow_the_owner_oauth_sidecar():
|
||||
flux = yaml.safe_load(FLUX_HERMES.read_text())
|
||||
checks = {
|
||||
(item["kind"], item["name"])
|
||||
for item in flux["spec"]["healthChecks"]
|
||||
}
|
||||
assert ("Deployment", "hermes-agent") in checks
|
||||
assert ("DaemonSet", "hermes-node-ssh-access") in checks
|
||||
assert ("Deployment", "oauth2-proxy-hermes-agent") not in checks
|
||||
|
||||
|
||||
def test_agent_auth_is_bstein_group_and_email_bounded():
|
||||
deployment = _agent_deployment()
|
||||
oauth = next(
|
||||
item for item in deployment["spec"]["template"]["spec"]["containers"]
|
||||
if item["name"] == "oauth2-proxy"
|
||||
)
|
||||
args = oauth["args"]
|
||||
assert "--user-id-claim=sub" in args
|
||||
assert "--oidc-groups-claim=groups" in args
|
||||
assert "--allowed-group=/hermes-owner" in args
|
||||
assert "--authenticated-emails-file=/etc/oauth2-proxy/allowed-emails" in args
|
||||
script = (KEYCLOAK / "scripts/hermes_access_oidc_ensure.sh").read_text()
|
||||
assert 'group_name="hermes-owner"' in script
|
||||
assert "username=bstein&exact=true" in script
|
||||
assert '"full.path":"true"' in script
|
||||
|
||||
|
||||
def test_agent_network_boundary_allows_only_authenticated_and_metrics_surfaces():
|
||||
documents = [
|
||||
item
|
||||
for item in yaml.safe_load_all((HERMES / "networkpolicy.yaml").read_text())
|
||||
if item
|
||||
]
|
||||
isolation = next(item for item in documents if item.get("metadata", {}).get("name") == "hermes-agent-isolation")
|
||||
assert isolation["spec"]["ingress"] == [
|
||||
{
|
||||
"from": [
|
||||
{
|
||||
"namespaceSelector": {
|
||||
"matchLabels": {
|
||||
"kubernetes.io/metadata.name": "traefik"
|
||||
}
|
||||
},
|
||||
"podSelector": {
|
||||
"matchLabels": {"app.kubernetes.io/name": "traefik"}
|
||||
},
|
||||
}
|
||||
],
|
||||
"ports": [{"protocol": "TCP", "port": 4180}],
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
{
|
||||
"podSelector": {
|
||||
"matchLabels": {"app": "hermes-chat-tenant"}
|
||||
}
|
||||
}
|
||||
],
|
||||
"ports": [
|
||||
{"protocol": "TCP", "port": 9002},
|
||||
{"protocol": "TCP", "port": 9003},
|
||||
],
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
{
|
||||
"podSelector": {
|
||||
"matchLabels": {"app": "hermes-switchyard"}
|
||||
}
|
||||
}
|
||||
],
|
||||
"ports": [
|
||||
{"protocol": "TCP", "port": 9003},
|
||||
{"protocol": "TCP", "port": 9006},
|
||||
],
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
{
|
||||
"namespaceSelector": {
|
||||
"matchLabels": {
|
||||
"kubernetes.io/metadata.name": "monitoring"
|
||||
}
|
||||
},
|
||||
"podSelector": {"matchLabels": {"app": "server"}},
|
||||
}
|
||||
],
|
||||
"ports": [
|
||||
{"protocol": "TCP", "port": 9010},
|
||||
{"protocol": "TCP", "port": 9011},
|
||||
],
|
||||
},
|
||||
]
|
||||
assert isolation["spec"]["egress"] == [{}]
|
||||
|
||||
|
||||
def test_owner_agent_has_cluster_admin_kubernetes_context():
|
||||
config = yaml.safe_load((HERMES / "agent-kubeconfig.yaml").read_text())
|
||||
assert config["current-context"] == "atlas-owner"
|
||||
assert config["contexts"][0]["context"]["namespace"] == "default"
|
||||
rbac_path = HERMES / "agent-rbac.yaml"
|
||||
documents = [item for item in yaml.safe_load_all(rbac_path.read_text()) if item]
|
||||
binding = next(item for item in documents if item["kind"] == "ClusterRoleBinding")
|
||||
assert binding["roleRef"] == {
|
||||
"apiGroup": "rbac.authorization.k8s.io",
|
||||
"kind": "ClusterRole",
|
||||
"name": "cluster-admin",
|
||||
}
|
||||
assert binding["subjects"] == [
|
||||
{"kind": "ServiceAccount", "name": "hermes-agent", "namespace": "hermes"}
|
||||
]
|
||||
@ -1,75 +0,0 @@
|
||||
"""Hermes chat codex broker contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from test_hermes_chat_support import (
|
||||
ModuleType,
|
||||
Path,
|
||||
_load_broker_module,
|
||||
base64,
|
||||
json,
|
||||
sys,
|
||||
time,
|
||||
)
|
||||
|
||||
|
||||
def test_codex_broker_refreshes_and_persists_first_party_oauth(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
"""Expired ChatGPT OAuth refreshes in the canonical Codex CLI store."""
|
||||
module = _load_broker_module(
|
||||
"hermes_codex_refresh_broker", "codex_broker.py", monkeypatch
|
||||
)
|
||||
auth_dir = tmp_path / ".codex"
|
||||
auth_dir.mkdir()
|
||||
|
||||
def jwt(expires_at: float) -> str:
|
||||
payload = base64.urlsafe_b64encode(
|
||||
json.dumps({"exp": expires_at}).encode()
|
||||
).decode().rstrip("=")
|
||||
return f"header.{payload}.signature"
|
||||
|
||||
expired = jwt(time.time() - 60)
|
||||
live = jwt(time.time() + 3600)
|
||||
auth_path = auth_dir / "auth.json"
|
||||
auth_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"auth_mode": "chatgpt",
|
||||
"tokens": {
|
||||
"access_token": expired,
|
||||
"refresh_token": "refresh-old",
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
calls = []
|
||||
auth_module = ModuleType("hermes_cli.auth")
|
||||
|
||||
def refresh(access_token, refresh_token, *, timeout_seconds):
|
||||
calls.append((access_token, refresh_token, timeout_seconds))
|
||||
return {
|
||||
"access_token": live,
|
||||
"refresh_token": "refresh-new",
|
||||
"last_refresh": "2026-08-12T20:00:00Z",
|
||||
}
|
||||
|
||||
auth_module.refresh_codex_oauth_pure = refresh
|
||||
package = ModuleType("hermes_cli")
|
||||
package.auth = auth_module
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", package)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli.auth", auth_module)
|
||||
monkeypatch.setenv("CODEX_HOME", str(auth_dir))
|
||||
|
||||
assert module._access_token() == live
|
||||
persisted = json.loads(auth_path.read_text())
|
||||
assert persisted["tokens"]["access_token"] == live
|
||||
assert persisted["tokens"]["refresh_token"] == "refresh-new"
|
||||
assert persisted["last_refresh"] == "2026-08-12T20:00:00Z"
|
||||
assert calls == [(expired, "refresh-old", 30.0)]
|
||||
assert auth_path.stat().st_mode & 0o777 == 0o600
|
||||
|
||||
# A healthy token is reused, so repeated routed turns do not spend a
|
||||
# refresh token or create a second billing/authentication path.
|
||||
assert module._access_token() == live
|
||||
assert len(calls) == 1
|
||||
@ -1,19 +1,119 @@
|
||||
"""Hermes chat images contracts."""
|
||||
"""Image generation and sandbox-boundary contracts for Hermes chat."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from test_hermes_chat_support import (
|
||||
import importlib.util
|
||||
import sys
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
import yaml
|
||||
|
||||
from testing.tests.test_hermes_chat_support import (
|
||||
HERMES,
|
||||
Path,
|
||||
SimpleNamespace,
|
||||
VAULT,
|
||||
_documents,
|
||||
importlib,
|
||||
sys,
|
||||
time,
|
||||
yaml,
|
||||
)
|
||||
|
||||
|
||||
def test_chat_image_generation_uses_private_owner_broker():
|
||||
"""Family pods get image bytes without receiving the owner's OAuth file."""
|
||||
configmap = _documents(HERMES / "chat-configmap.yaml")[0]
|
||||
assert "shared desktop/Wolf lane" in configmap["data"]["SOUL.md"]
|
||||
assert "local FLUX waits" in configmap["data"]["SOUL.md"]
|
||||
assert "Use `image_generate_local`" in configmap["data"]["SOUL.md"]
|
||||
assert "Use `image_generate_hosted`" in configmap["data"]["SOUL.md"]
|
||||
assert "ComfyUI endpoint" in configmap["data"]["SOUL.md"]
|
||||
assert "`MEDIA:` path" in configmap["data"]["SOUL.md"]
|
||||
assert "`image_edit_latest`" in configmap["data"]["SOUL.md"]
|
||||
assert (
|
||||
"Preserve the most recently selected image lane" in configmap["data"]["SOUL.md"]
|
||||
)
|
||||
config = yaml.safe_load(configmap["data"]["config.yaml"])
|
||||
assert config["image_gen"] == {
|
||||
"provider": "atlas-broker",
|
||||
"model": "atlas-image-auto-high",
|
||||
}
|
||||
assert config["plugins"]["enabled"] == ["atlas-broker", "auto-router"]
|
||||
|
||||
statefulset = _documents(HERMES / "chat-statefulset.yaml")[0]
|
||||
pod = statefulset["spec"]["template"]["spec"]
|
||||
hermes = next(item for item in pod["containers"] if item["name"] == "hermes")
|
||||
mounts = {item["name"]: item for item in hermes["volumeMounts"]}
|
||||
assert mounts["image-plugin"]["mountPath"] == (
|
||||
"/opt/hermes/plugins/image_gen/atlas-broker"
|
||||
)
|
||||
assert mounts["runtime-access"]["mountPath"] == "/runtime-access"
|
||||
assert "provider-auth" not in mounts
|
||||
assert not any(
|
||||
mount["name"] == "home" and "agent" in str(mount)
|
||||
for mount in hermes["volumeMounts"]
|
||||
)
|
||||
env = {item["name"]: item["value"] for item in hermes["env"]}
|
||||
assert env["HERMES_IMAGE_BROKER_URL"].startswith("http://hermes-image-broker.")
|
||||
|
||||
plugin = (HERMES / "plugins" / "image-gen-broker" / "__init__.py").read_text()
|
||||
assert '"local": "flux-2-klein-4b-local"' in plugin
|
||||
assert '"hosted": "gpt-image-2-high"' in plugin
|
||||
assert 'name="image_generate_local"' in plugin
|
||||
assert 'name="image_generate_hosted"' in plugin
|
||||
assert '"name": "image_edit_latest"' in plugin
|
||||
assert '"name": "image_edit_latest_local"' in plugin
|
||||
assert '"name": "image_edit_latest_hosted"' in plugin
|
||||
assert "def _latest_generated_image" in plugin
|
||||
assert "newest MEDIA: path from the conversation" in plugin
|
||||
assert 'candidate.upper().startswith("MEDIA:")' in plugin
|
||||
assert "override=True" not in plugin
|
||||
|
||||
agent = _documents(HERMES / "agent-deployment.yaml")[0]
|
||||
containers = agent["spec"]["template"]["spec"]["containers"]
|
||||
broker = next(item for item in containers if item["name"] == "image-broker")
|
||||
assert broker["ports"] == [
|
||||
{"name": "image-broker", "containerPort": 9002, "protocol": "TCP"}
|
||||
]
|
||||
assert broker["securityContext"]["readOnlyRootFilesystem"] is True
|
||||
assert broker["securityContext"]["runAsNonRoot"] is True
|
||||
|
||||
services = _documents(HERMES / "service.yaml")
|
||||
service = next(
|
||||
item for item in services if item["metadata"]["name"] == "hermes-image-broker"
|
||||
)
|
||||
assert service["spec"]["selector"] == {"app": "hermes-agent"}
|
||||
|
||||
oauth_store = _documents(HERMES / "oauth-session-store.yaml")
|
||||
redis = next(item for item in oauth_store if item["kind"] == "Deployment")
|
||||
assert redis["spec"]["strategy"]["type"] == "Recreate"
|
||||
assert "--appendonly" in redis["spec"]["template"]["spec"]["containers"][0]["args"]
|
||||
|
||||
policies = _documents(HERMES / "networkpolicy.yaml")
|
||||
agent_policy = next(
|
||||
item
|
||||
for item in policies
|
||||
if item["metadata"]["name"] == "hermes-agent-isolation"
|
||||
)
|
||||
broker_ingress = next(
|
||||
rule
|
||||
for rule in agent_policy["spec"]["ingress"]
|
||||
if {port["port"] for port in rule["ports"]} == {9002, 9003}
|
||||
)
|
||||
assert broker_ingress["from"][0]["podSelector"]["matchLabels"] == {
|
||||
"app": "hermes-chat-tenant"
|
||||
}
|
||||
vault_policy = (VAULT / "scripts" / "vault_k8s_auth_configure.sh").read_text()
|
||||
agent_role = vault_policy[
|
||||
vault_policy.index('write_policy_and_role "hermes-agent"') : vault_policy.index(
|
||||
"write_policy_and_role",
|
||||
vault_policy.index('write_policy_and_role "hermes-agent"') + 1,
|
||||
)
|
||||
]
|
||||
assert "hermes/developer-gitea" not in agent_role
|
||||
assert 'write_policy_and_role "hermes-scm-broker" "hermes-scm"' in vault_policy
|
||||
assert (
|
||||
'write_policy_and_role "hermes-node-ssh" "hermes" '
|
||||
'"hermes-node-ssh-access"' in vault_policy
|
||||
)
|
||||
|
||||
|
||||
def test_compact_image_edit_resolves_latest_tenant_artifact(tmp_path, monkeypatch):
|
||||
"""Follow-up edits resolve the source server-side and keep tool JSON small."""
|
||||
provider_module = SimpleNamespace(
|
||||
@ -63,6 +163,7 @@ def test_compact_image_edit_resolves_latest_tenant_artifact(tmp_path, monkeypatc
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_chat_reasoning_uses_switchyard_without_owner_credentials():
|
||||
"""Family pods use AUTO/manual routes without mounting owner credentials."""
|
||||
configmap = _documents(HERMES / "chat-configmap.yaml")[0]
|
||||
@ -126,30 +227,31 @@ def test_chat_reasoning_uses_switchyard_without_owner_credentials():
|
||||
]
|
||||
|
||||
statefulset = _documents(HERMES / "chat-statefulset.yaml")[0]
|
||||
assert statefulset["spec"]["template"]["metadata"]["annotations"][
|
||||
"ai.bstein.dev/config-rev"
|
||||
] == "20260816-telegram-topics"
|
||||
assert (
|
||||
statefulset["spec"]["template"]["metadata"]["annotations"][
|
||||
"ai.bstein.dev/config-rev"
|
||||
]
|
||||
== "20260816-telegram-topics"
|
||||
)
|
||||
pod_spec = statefulset["spec"]["template"]["spec"]
|
||||
patch_init = next(
|
||||
item for item in pod_spec["initContainers"]
|
||||
item
|
||||
for item in pod_spec["initContainers"]
|
||||
if item["name"] == "patch-stream-recovery"
|
||||
)
|
||||
assert patch_init["command"][-2:] == [
|
||||
"/opt/hermes/agent/conversation_loop.py",
|
||||
"/patched/conversation_loop.py",
|
||||
]
|
||||
hermes = next(
|
||||
item
|
||||
for item in pod_spec["containers"]
|
||||
if item["name"] == "hermes"
|
||||
)
|
||||
hermes = next(item for item in pod_spec["containers"] if item["name"] == "hermes")
|
||||
assert {
|
||||
"name": "stream-recovery-patch",
|
||||
"mountPath": "/opt/hermes/agent/conversation_loop.py",
|
||||
"subPath": "conversation_loop.py",
|
||||
} in hermes["volumeMounts"]
|
||||
api_session_init = next(
|
||||
item for item in pod_spec["initContainers"]
|
||||
item
|
||||
for item in pod_spec["initContainers"]
|
||||
if item["name"] == "patch-api-server-sessions"
|
||||
)
|
||||
assert "patch_api_server_sessions.py" in api_session_init["args"][0]
|
||||
@ -159,17 +261,16 @@ def test_chat_reasoning_uses_switchyard_without_owner_credentials():
|
||||
"mountPath": "/opt/hermes/gateway/platforms/api_server.py",
|
||||
"subPath": "api_server.py",
|
||||
} in hermes["volumeMounts"]
|
||||
assert any(
|
||||
volume["name"] == "api-server-patch" for volume in pod_spec["volumes"]
|
||||
)
|
||||
assert any(volume["name"] == "api-server-patch" for volume in pod_spec["volumes"])
|
||||
assert not any(
|
||||
mount["mountPath"].endswith("/.codex")
|
||||
for mount in hermes["volumeMounts"]
|
||||
mount["mountPath"].endswith("/.codex") for mount in hermes["volumeMounts"]
|
||||
)
|
||||
|
||||
policies = _documents(HERMES / "networkpolicy.yaml")
|
||||
agent_policy = next(
|
||||
item for item in policies if item["metadata"]["name"] == "hermes-agent-isolation"
|
||||
item
|
||||
for item in policies
|
||||
if item["metadata"]["name"] == "hermes-agent-isolation"
|
||||
)
|
||||
broker_ingress = next(
|
||||
rule
|
||||
@ -179,106 +280,3 @@ def test_chat_reasoning_uses_switchyard_without_owner_credentials():
|
||||
assert broker_ingress["from"][0]["podSelector"]["matchLabels"] == {
|
||||
"app": "hermes-chat-tenant"
|
||||
}
|
||||
|
||||
def test_image_broker_returns_bytes_and_removes_owner_cache(tmp_path: Path, monkeypatch):
|
||||
"""The broker must not retain a family user's generated image."""
|
||||
broker_path = HERMES / "scripts" / "image_broker.py"
|
||||
spec = importlib.util.spec_from_file_location("hermes_image_broker", broker_path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
generated = tmp_path / "generated.png"
|
||||
generated.write_bytes(b"\x89PNG\r\n\x1a\nprivate-image")
|
||||
|
||||
class Provider:
|
||||
def generate(self, prompt, aspect, **kwargs):
|
||||
assert prompt == "paint a blue sphere"
|
||||
assert aspect == "square"
|
||||
return {
|
||||
"success": True,
|
||||
"image": str(generated),
|
||||
"model": "gpt-image-2-high",
|
||||
"quality": "high",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(module, "_PROVIDER", Provider())
|
||||
result = module._generate(
|
||||
{
|
||||
"prompt": "paint a blue sphere",
|
||||
"aspect_ratio": "square",
|
||||
"model": "gpt-image-2-high",
|
||||
}
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["image_b64"]
|
||||
assert "image" not in result
|
||||
assert not generated.exists()
|
||||
|
||||
def test_image_broker_auto_falls_back_to_local_and_honors_explicit_routes(
|
||||
monkeypatch,
|
||||
):
|
||||
"""AUTO is hosted-first while explicit local never calls the hosted lane."""
|
||||
broker_path = HERMES / "scripts" / "image_broker.py"
|
||||
spec = importlib.util.spec_from_file_location("hermes_image_router", broker_path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
calls = []
|
||||
|
||||
def hosted(payload, model, prompt, aspect):
|
||||
calls.append(("hosted", model, prompt, aspect))
|
||||
return {"success": False, "error": "hosted refusal"}
|
||||
|
||||
def local(payload, timeout=1800.0):
|
||||
calls.append(("local", payload["model"], timeout))
|
||||
return {
|
||||
"success": True,
|
||||
"image_b64": "aW1hZ2U=",
|
||||
"model": "flux-2-klein-4b-local",
|
||||
"route": "local",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(module, "_generate_hosted", hosted)
|
||||
monkeypatch.setattr(module, "_local_request", local)
|
||||
auto = module._generate(
|
||||
{
|
||||
"prompt": "colorize this family photograph",
|
||||
"aspect_ratio": "portrait",
|
||||
"model": "atlas-image-auto-high",
|
||||
}
|
||||
)
|
||||
assert auto["success"] is True
|
||||
assert auto["route"] == "local"
|
||||
assert auto["hosted_fallback_reason"] == "hosted refusal"
|
||||
assert [call[0] for call in calls] == ["hosted", "local"]
|
||||
|
||||
calls.clear()
|
||||
explicit = module._generate(
|
||||
{
|
||||
"prompt": "make a local landscape",
|
||||
"aspect_ratio": "landscape",
|
||||
"model": "flux-2-klein-4b-local",
|
||||
}
|
||||
)
|
||||
assert explicit["route"] == "local"
|
||||
assert [call[0] for call in calls] == ["local"]
|
||||
|
||||
def test_image_broker_policy_is_narrow_and_operator_extensible(tmp_path: Path, monkeypatch):
|
||||
"""Family-photo restoration stays allowed while the hard boundary remains."""
|
||||
broker_path = HERMES / "scripts" / "image_broker.py"
|
||||
spec = importlib.util.spec_from_file_location("hermes_image_policy", broker_path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
policy = tmp_path / "policy.json"
|
||||
policy.write_text('{"additional_blocked_phrases":["site-specific block"]}')
|
||||
monkeypatch.setattr(module, "POLICY_PATH", policy)
|
||||
|
||||
assert module._policy_error(
|
||||
"Colorize my baby photograph with a lighter natural skin tone"
|
||||
) is None
|
||||
assert "minors" in module._policy_error("Create a sexual image of a child")
|
||||
assert module._policy_error("A site-specific block request") == (
|
||||
"request is blocked by the operator image policy"
|
||||
)
|
||||
|
||||
@ -1,295 +0,0 @@
|
||||
"""Hermes chat local compute contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from test_hermes_chat_support import (
|
||||
HERMES,
|
||||
Path,
|
||||
ROOT,
|
||||
SimpleNamespace,
|
||||
_documents,
|
||||
importlib,
|
||||
json,
|
||||
tomllib,
|
||||
)
|
||||
|
||||
|
||||
def test_local_flux_runtime_and_gpu_handoff_are_flux_managed():
|
||||
"""FLUX and Wolf share titan-24 while text stays on titan-20."""
|
||||
deployment = _documents(HERMES / "local-image-deployment.yaml")[0]
|
||||
assert deployment["metadata"]["name"] == "hermes-local-image"
|
||||
pod = deployment["spec"]["template"]["spec"]
|
||||
assert pod["serviceAccountName"] == "hermes-gpu-runtime"
|
||||
local = next(item for item in pod["containers"] if item["name"] == "local-image")
|
||||
assert len(pod["containers"]) == 1
|
||||
assert local["resources"]["requests"]["nvidia.com/gpu.shared"] == 1
|
||||
assert local["ports"] == [{"name": "local-image", "containerPort": 9004}]
|
||||
assert any(mount["mountPath"] == "/models" for mount in local["volumeMounts"])
|
||||
model_env = {item["name"]: item["value"] for item in local["env"]}
|
||||
assert model_env["HERMES_LOCAL_IMAGE_LISTEN_PORT"] == "9004"
|
||||
assert model_env["HERMES_LOCAL_IMAGE_REVISION"] == (
|
||||
"e7b7dc27f91deacad38e78976d1f2b499d76a294"
|
||||
)
|
||||
assert model_env["HERMES_LOCAL_IMAGE_GPU_ACTIVITY_NODE"] == "titan-24"
|
||||
assert model_env["HERMES_LOCAL_IMAGE_GPU_ACTIVE_SM_PERCENT"] == "80"
|
||||
assert model_env["HERMES_LOCAL_IMAGE_GPU_MAX_EXTERNAL_MEMORY_BYTES"] == (
|
||||
"3221225472"
|
||||
)
|
||||
assert model_env["HERMES_LOCAL_IMAGE_OFFLOAD_MODE"] == "sequential"
|
||||
assert "nvidia-process-exporter-local.monitoring.svc.cluster.local" in model_env[
|
||||
"HERMES_LOCAL_IMAGE_GPU_ACTIVITY_URL"
|
||||
]
|
||||
models_volume = next(item for item in pod["volumes"] if item["name"] == "models")
|
||||
assert models_volume["persistentVolumeClaim"]["claimName"] == (
|
||||
"hermes-image-models"
|
||||
)
|
||||
|
||||
services = _documents(HERMES / "service.yaml")
|
||||
image_service = next(
|
||||
item for item in services if item["metadata"]["name"] == "hermes-local-image"
|
||||
)
|
||||
assert image_service["spec"]["selector"] == {"app": "hermes-local-image"}
|
||||
|
||||
handoff_services = _documents(HERMES / "model-gate-deployment.yaml")
|
||||
handoff = next(
|
||||
item
|
||||
for item in handoff_services
|
||||
if item["kind"] == "Service"
|
||||
and item["metadata"]["name"] == "hermes-gpu-handoff"
|
||||
)
|
||||
assert handoff["spec"]["ports"][0]["targetPort"] == "handoff"
|
||||
|
||||
ariadne = _documents(
|
||||
Path(__file__).parents[2]
|
||||
/ "services/maintenance/apps/ariadne-deployment.yaml"
|
||||
)[0]
|
||||
env = {
|
||||
item["name"]: item["value"]
|
||||
for item in ariadne["spec"]["template"]["spec"]["containers"][0]["env"]
|
||||
if "value" in item
|
||||
}
|
||||
assert env["GAME_MODE_OLLAMA_URL"] == (
|
||||
"http://hermes-gpu-handoff.hermes.svc.cluster.local:11434"
|
||||
)
|
||||
assert env["GAME_MODE_OLLAMA_MODEL"] == "flux-2-klein-4b-local"
|
||||
|
||||
for config_name in ("configmap.yaml", "agent-configmap.yaml", "chat-configmap.yaml"):
|
||||
config = _documents(HERMES / config_name)[0]["data"]["config.yaml"]
|
||||
assert "gpt-oss:20b" not in config
|
||||
assert "atlas-switchyard" in config
|
||||
switchyard = _documents(HERMES / "switchyard-configmap.yaml")[0]["data"][
|
||||
"routes.toml"
|
||||
]
|
||||
assert 'id = "qwen2.5:14b-instruct-q4_0"' in switchyard
|
||||
assert "qwen2.5:3b-instruct-q4_0" not in switchyard
|
||||
assert "route/local/qwen2.5-14b/medium" in switchyard
|
||||
assert "Anthropic and Claude name the same provider" in switchyard
|
||||
assert "OpenAI and Codex name the same provider" in switchyard
|
||||
assert "Choose across every configured Codex and Claude family" in switchyard
|
||||
assert "Claude Fable" in switchyard
|
||||
assert switchyard.count('Treat "think hard"') == 4
|
||||
assert switchyard.count("Never choose below the") >= 5
|
||||
switchyard_config = tomllib.loads(switchyard)
|
||||
routes = switchyard_config["routes"]
|
||||
configured_targets = switchyard_config["targets"]
|
||||
for route_name in ("auto_fast", "auto_balanced", "auto_deep", "auto_maximum"):
|
||||
leading_targets = set(routes[route_name]["targets"][:2])
|
||||
assert leading_targets == {"codex_sol_xhigh", "claude_opus_xhigh"}
|
||||
assert "max_output_tokens" not in routes[route_name]
|
||||
for route_name in ("auto_fast", "auto_balanced", "auto_deep", "auto_maximum"):
|
||||
targets = routes[route_name]["targets"]
|
||||
selector_targets = routes[route_name]["response_schema"]
|
||||
assert not any(target.startswith("local_") for target in targets)
|
||||
assert any("fable" in target for target in targets)
|
||||
assert "local_qwen" not in selector_targets
|
||||
assert "not eligible for foreground" in routes[route_name]["prompt"]
|
||||
for route_name in ("auto_deep", "auto_maximum"):
|
||||
targets = routes[route_name]["targets"]
|
||||
selector_targets = routes[route_name]["response_schema"]
|
||||
assert not any(target.endswith("_low") for target in targets)
|
||||
assert "_low" not in selector_targets
|
||||
maximum_targets = routes["auto_maximum"]["targets"]
|
||||
maximum_selector_targets = routes["auto_maximum"]["response_schema"]
|
||||
assert not any(target.endswith("_medium") for target in maximum_targets)
|
||||
assert "_medium" not in maximum_selector_targets
|
||||
assert "absolute high effort floor" in routes["auto_maximum"]["prompt"]
|
||||
assert "quality mark was missed" in routes["auto_balanced"]["prompt"]
|
||||
assert "raises the next boundary to xhigh" in routes["auto_maximum"]["prompt"]
|
||||
assert "Repeated quality misses require xhigh" in routes[
|
||||
"worker_auto_maximum"
|
||||
]["prompt"]
|
||||
assert any(
|
||||
target.startswith("local_")
|
||||
for target in routes["manual_local_qwen"]["targets"]
|
||||
)
|
||||
for route_name in (
|
||||
"manual_codex_luna",
|
||||
"manual_codex_terra",
|
||||
"manual_codex_sol",
|
||||
"manual_claude_haiku",
|
||||
"manual_claude_fable",
|
||||
"manual_claude_sonnet",
|
||||
"manual_claude_opus",
|
||||
):
|
||||
assert not any(
|
||||
target.startswith("local_") for target in routes[route_name]["targets"]
|
||||
)
|
||||
for provider, families in {
|
||||
"codex": ("luna", "terra", "sol"),
|
||||
"claude": ("haiku", "fable", "sonnet", "opus"),
|
||||
}.items():
|
||||
for family in families:
|
||||
for effort in ("low", "medium", "high", "xhigh"):
|
||||
route = routes[f"manual_{provider}_{family}_{effort}"]
|
||||
assert route["id"] == f"atlas/manual/{provider}/{family}/{effort}"
|
||||
assert route["targets"][0] == f"{provider}_{family}_{effort}"
|
||||
worker_target = f"worker_{provider}_{family}_{effort}"
|
||||
assert worker_target in routes["worker_auto_maximum"]["targets"]
|
||||
assert configured_targets[worker_target]["id"] == (
|
||||
f"worker/{provider}/{family}/{effort}"
|
||||
)
|
||||
for route_name in ("auto_fast", "auto_balanced"):
|
||||
prompt = routes[route_name]["prompt"]
|
||||
assert "image tool—not the conversational model" in prompt
|
||||
assert "Do not select a" in prompt
|
||||
assert "local Qwen or Claude target" in prompt
|
||||
assert "max_output_tokens" not in routes["worker_auto_maximum"]
|
||||
model_gate = _documents(HERMES / "model-gate-configmap.yaml")[0]["data"][
|
||||
"model_gate.py"
|
||||
]
|
||||
assert "qwen2.5:14b-instruct-q4_0" in model_gate
|
||||
|
||||
def test_titan20_serializes_classifier_and_local_chat_model_residency():
|
||||
"""Classifier and local chat share one serialized resident Qwen weight."""
|
||||
deployment = _documents(
|
||||
Path(__file__).parents[2] / "services/ai-llm/deployment.yaml"
|
||||
)[0]
|
||||
pod = deployment["spec"]["template"]["spec"]
|
||||
required = pod["affinity"]["nodeAffinity"][
|
||||
"requiredDuringSchedulingIgnoredDuringExecution"
|
||||
]["nodeSelectorTerms"][0]["matchExpressions"][0]
|
||||
assert required["values"] == ["titan-20"]
|
||||
container = pod["containers"][0]
|
||||
env = {item["name"]: item["value"] for item in container["env"]}
|
||||
assert env["OLLAMA_MAX_LOADED_MODELS"] == "1"
|
||||
assert env["OLLAMA_NUM_PARALLEL"] == "1"
|
||||
assert env["OLLAMA_KEEP_ALIVE"] == "-1"
|
||||
assert env["OLLAMA_CONTEXT_LENGTH"] == "8192"
|
||||
warm_command = " ".join(container["command"])
|
||||
assert "--keepalive=-1" not in warm_command
|
||||
models = next(item for item in pod["volumes"] if item["name"] == "models")
|
||||
assert models["persistentVolumeClaim"]["claimName"] == (
|
||||
"ollama-models-titan20"
|
||||
)
|
||||
|
||||
def test_local_image_gpu_guard_distinguishes_background_and_saturated_gpu(monkeypatch):
|
||||
"""Lease-idle desktop spikes may coexist, but saturation still blocks FLUX."""
|
||||
source = ROOT / "dockerfiles" / "hermes-local-image-server.py"
|
||||
spec = importlib.util.spec_from_file_location("hermes_local_image_server", source)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
monkeypatch.setattr(module, "GPU_ACTIVITY_NODE", "titan-24")
|
||||
monkeypatch.setattr(module, "GPU_ACTIVE_SM_PERCENT", 80.0)
|
||||
monkeypatch.setattr(module, "GPU_MAX_EXTERNAL_MEMORY_BYTES", 3 << 30)
|
||||
|
||||
idle = module._parse_gpu_activity(
|
||||
'\n'.join(
|
||||
[
|
||||
'nvidia_process_gpu_sm_util_percent{node="titan-24",namespace="host",process="Xorg"} 0',
|
||||
'nvidia_process_gpu_memory_used_bytes{node="titan-24",namespace="host",process="Xorg"} 1900000000',
|
||||
'nvidia_process_gpu_sm_util_percent{node="titan-24",namespace="game-stream",process="wolf"} 3',
|
||||
'nvidia_process_gpu_memory_used_bytes{node="titan-24",namespace="hermes",process="python"} 9000000000',
|
||||
]
|
||||
)
|
||||
)
|
||||
assert idle["interactive_active"] is False
|
||||
assert idle["external_gpu_memory_bytes"] == 1900000000
|
||||
assert idle["external_gpu_sm_percent"] == 3
|
||||
|
||||
background_spike = module._parse_gpu_activity(
|
||||
'nvidia_process_gpu_sm_util_percent{node="titan-24",namespace="host",process="sway"} 41\n'
|
||||
)
|
||||
assert background_spike["interactive_active"] is False
|
||||
|
||||
active = module._parse_gpu_activity(
|
||||
'nvidia_process_gpu_sm_util_percent{node="titan-24",namespace="host",process="steam"} 91\n'
|
||||
)
|
||||
assert active["interactive_active"] is True
|
||||
assert "91%" in active["gpu_guard_reason"]
|
||||
|
||||
def test_local_flux_renderer_uses_a_disposable_cuda_worker(monkeypatch):
|
||||
"""A completed render must not leave its CUDA context in the API process."""
|
||||
source = ROOT / "dockerfiles" / "hermes-local-image-server.py"
|
||||
spec = importlib.util.spec_from_file_location("hermes_local_image_worker", source)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
calls = []
|
||||
|
||||
def run(command, **kwargs):
|
||||
calls.append((command, kwargs))
|
||||
return SimpleNamespace(
|
||||
returncode=0,
|
||||
stdout=b'{"success":true,"route":"local","image_b64":"cG5n"}',
|
||||
stderr=b"",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(module.subprocess, "run", run)
|
||||
result = module._render({"prompt": "black cat", "aspect_ratio": "square"})
|
||||
|
||||
assert result["route"] == "local"
|
||||
command, options = calls[0]
|
||||
assert command[-1] == "--render-worker"
|
||||
assert json.loads(options["input"]) == {
|
||||
"prompt": "black cat",
|
||||
"aspect_ratio": "square",
|
||||
}
|
||||
assert options["timeout"] == module.RENDER_TIMEOUT_SECONDS
|
||||
assert options["check"] is False
|
||||
|
||||
def test_local_flux_uses_low_vram_offload_without_reducing_resolution():
|
||||
"""The shared 3080 lane must trade time, not image size, for headroom."""
|
||||
source = (ROOT / "dockerfiles" / "hermes-local-image-server.py").read_text()
|
||||
assert 'OFFLOAD_MODE = os.environ.get(' in source
|
||||
assert '"HERMES_LOCAL_IMAGE_OFFLOAD_MODE", "sequential"' in source
|
||||
assert "pipe.enable_sequential_cpu_offload()" in source
|
||||
assert '"square": (1024, 1024)' in source
|
||||
|
||||
def test_chat_auth_and_relay_are_pod_lifetime_only():
|
||||
statefulset = _documents(HERMES / "chat-statefulset.yaml")[0]
|
||||
pod = statefulset["spec"]["template"]["spec"]
|
||||
containers = statefulset["spec"]["template"]["spec"]["containers"]
|
||||
|
||||
for name in ("hermes", "webui"):
|
||||
container = next(item for item in containers if item["name"] == name)
|
||||
env = {item["name"]: item["value"] for item in container["env"]}
|
||||
assert env["HERMES_AUTH_FILE"] == "/runtime-access/hermes-auth.json"
|
||||
mount = next(
|
||||
item for item in container["volumeMounts"] if item["name"] == "runtime-access"
|
||||
)
|
||||
assert mount["mountPath"] == "/runtime-access"
|
||||
assert "subPath" not in mount
|
||||
|
||||
hermes_env = {
|
||||
item["name"]: item["value"]
|
||||
for item in next(item for item in containers if item["name"] == "hermes")["env"]
|
||||
}
|
||||
runtime = next(item for item in pod["volumes"] if item["name"] == "runtime-access")
|
||||
assert runtime["emptyDir"] == {"medium": "Memory", "sizeLimit": "2Mi"}
|
||||
assert not any(item["name"] == "provider-auth" for item in pod["volumes"])
|
||||
init_command = next(
|
||||
item for item in pod["initContainers"] if item["name"] == "init-config"
|
||||
)["command"][2]
|
||||
for key in (
|
||||
"ANTHROPIC_API_KEY",
|
||||
"API_SERVER_KEY",
|
||||
"CLAUDE_CODE_OAUTH_TOKEN",
|
||||
"GITEA_TOKEN",
|
||||
"HERMES_IMAGE_BROKER_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
):
|
||||
assert key in init_command
|
||||
assert "printf 'API_SERVER_KEY=%s" not in init_command
|
||||
assert hermes_env["AGENT_BROWSER_EXECUTABLE_PATH"].endswith("/chrome-linux/headless_shell")
|
||||
assert "--no-sandbox" in hermes_env["AGENT_BROWSER_ARGS"]
|
||||
172
testing/tests/test_hermes_chat_local_runtime.py
Normal file
172
testing/tests/test_hermes_chat_local_runtime.py
Normal file
@ -0,0 +1,172 @@
|
||||
"""Local inference and sandbox execution contracts for Hermes chat."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
from testing.tests.test_hermes_chat_support import (
|
||||
HERMES,
|
||||
ROOT,
|
||||
_documents,
|
||||
)
|
||||
|
||||
|
||||
def test_titan20_serializes_classifier_and_local_chat_model_residency():
|
||||
"""Classifier and local chat share one serialized resident Qwen weight."""
|
||||
deployment = _documents(
|
||||
Path(__file__).parents[2] / "services/ai-llm/deployment.yaml"
|
||||
)[0]
|
||||
pod = deployment["spec"]["template"]["spec"]
|
||||
required = pod["affinity"]["nodeAffinity"][
|
||||
"requiredDuringSchedulingIgnoredDuringExecution"
|
||||
]["nodeSelectorTerms"][0]["matchExpressions"][0]
|
||||
assert required["values"] == ["titan-20"]
|
||||
container = pod["containers"][0]
|
||||
env = {item["name"]: item["value"] for item in container["env"]}
|
||||
assert env["OLLAMA_MAX_LOADED_MODELS"] == "1"
|
||||
assert env["OLLAMA_NUM_PARALLEL"] == "1"
|
||||
assert env["OLLAMA_KEEP_ALIVE"] == "-1"
|
||||
assert env["OLLAMA_CONTEXT_LENGTH"] == "8192"
|
||||
warm_command = " ".join(container["command"])
|
||||
assert "--keepalive=-1" not in warm_command
|
||||
models = next(item for item in pod["volumes"] if item["name"] == "models")
|
||||
assert models["persistentVolumeClaim"]["claimName"] == ("ollama-models-titan20")
|
||||
|
||||
|
||||
def test_local_image_gpu_guard_distinguishes_background_and_saturated_gpu(monkeypatch):
|
||||
"""Lease-idle desktop spikes may coexist, but saturation still blocks FLUX."""
|
||||
source = ROOT / "dockerfiles" / "hermes-local-image-server.py"
|
||||
spec = importlib.util.spec_from_file_location("hermes_local_image_server", source)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
monkeypatch.setattr(module, "GPU_ACTIVITY_NODE", "titan-24")
|
||||
monkeypatch.setattr(module, "GPU_ACTIVE_SM_PERCENT", 80.0)
|
||||
monkeypatch.setattr(module, "GPU_MAX_EXTERNAL_MEMORY_BYTES", 3 << 30)
|
||||
|
||||
idle = module._parse_gpu_activity(
|
||||
"\n".join(
|
||||
[
|
||||
'nvidia_process_gpu_sm_util_percent{node="titan-24",namespace="host",process="Xorg"} 0',
|
||||
'nvidia_process_gpu_memory_used_bytes{node="titan-24",namespace="host",process="Xorg"} 1900000000',
|
||||
'nvidia_process_gpu_sm_util_percent{node="titan-24",namespace="game-stream",process="wolf"} 3',
|
||||
'nvidia_process_gpu_memory_used_bytes{node="titan-24",namespace="hermes",process="python"} 9000000000',
|
||||
]
|
||||
)
|
||||
)
|
||||
assert idle["interactive_active"] is False
|
||||
assert idle["external_gpu_memory_bytes"] == 1900000000
|
||||
assert idle["external_gpu_sm_percent"] == 3
|
||||
|
||||
background_spike = module._parse_gpu_activity(
|
||||
'nvidia_process_gpu_sm_util_percent{node="titan-24",namespace="host",process="sway"} 41\n'
|
||||
)
|
||||
assert background_spike["interactive_active"] is False
|
||||
|
||||
active = module._parse_gpu_activity(
|
||||
'nvidia_process_gpu_sm_util_percent{node="titan-24",namespace="host",process="steam"} 91\n'
|
||||
)
|
||||
assert active["interactive_active"] is True
|
||||
assert "91%" in active["gpu_guard_reason"]
|
||||
|
||||
|
||||
def test_local_flux_renderer_uses_a_disposable_cuda_worker(monkeypatch):
|
||||
"""A completed render must not leave its CUDA context in the API process."""
|
||||
source = ROOT / "dockerfiles" / "hermes-local-image-server.py"
|
||||
spec = importlib.util.spec_from_file_location("hermes_local_image_worker", source)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
calls = []
|
||||
|
||||
def run(command, **kwargs):
|
||||
calls.append((command, kwargs))
|
||||
return SimpleNamespace(
|
||||
returncode=0,
|
||||
stdout=b'{"success":true,"route":"local","image_b64":"cG5n"}',
|
||||
stderr=b"",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(module.subprocess, "run", run)
|
||||
result = module._render({"prompt": "black cat", "aspect_ratio": "square"})
|
||||
|
||||
assert result["route"] == "local"
|
||||
command, options = calls[0]
|
||||
assert command[-1] == "--render-worker"
|
||||
assert json.loads(options["input"]) == {
|
||||
"prompt": "black cat",
|
||||
"aspect_ratio": "square",
|
||||
}
|
||||
assert options["timeout"] == module.RENDER_TIMEOUT_SECONDS
|
||||
assert options["check"] is False
|
||||
|
||||
|
||||
def test_local_flux_uses_low_vram_offload_without_reducing_resolution():
|
||||
"""The shared 3080 lane must trade time, not image size, for headroom."""
|
||||
source = (ROOT / "dockerfiles" / "hermes-local-image-server.py").read_text()
|
||||
assert "OFFLOAD_MODE = os.environ.get(" in source
|
||||
assert '"HERMES_LOCAL_IMAGE_OFFLOAD_MODE", "sequential"' in source
|
||||
assert "pipe.enable_sequential_cpu_offload()" in source
|
||||
assert '"square": (1024, 1024)' in source
|
||||
|
||||
|
||||
def test_chat_auth_and_relay_are_pod_lifetime_only():
|
||||
statefulset = _documents(HERMES / "chat-statefulset.yaml")[0]
|
||||
pod = statefulset["spec"]["template"]["spec"]
|
||||
containers = statefulset["spec"]["template"]["spec"]["containers"]
|
||||
|
||||
for name in ("hermes", "webui"):
|
||||
container = next(item for item in containers if item["name"] == name)
|
||||
env = {item["name"]: item["value"] for item in container["env"]}
|
||||
assert env["HERMES_AUTH_FILE"] == "/runtime-access/hermes-auth.json"
|
||||
mount = next(
|
||||
item
|
||||
for item in container["volumeMounts"]
|
||||
if item["name"] == "runtime-access"
|
||||
)
|
||||
assert mount["mountPath"] == "/runtime-access"
|
||||
assert "subPath" not in mount
|
||||
|
||||
hermes_env = {
|
||||
item["name"]: item["value"]
|
||||
for item in next(item for item in containers if item["name"] == "hermes")["env"]
|
||||
}
|
||||
runtime = next(item for item in pod["volumes"] if item["name"] == "runtime-access")
|
||||
assert runtime["emptyDir"] == {"medium": "Memory", "sizeLimit": "2Mi"}
|
||||
assert not any(item["name"] == "provider-auth" for item in pod["volumes"])
|
||||
init_command = next(
|
||||
item for item in pod["initContainers"] if item["name"] == "init-config"
|
||||
)["command"][2]
|
||||
for key in (
|
||||
"ANTHROPIC_API_KEY",
|
||||
"API_SERVER_KEY",
|
||||
"CLAUDE_CODE_OAUTH_TOKEN",
|
||||
"GITEA_TOKEN",
|
||||
"HERMES_IMAGE_BROKER_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
):
|
||||
assert key in init_command
|
||||
assert "printf 'API_SERVER_KEY=%s" not in init_command
|
||||
assert hermes_env["AGENT_BROWSER_EXECUTABLE_PATH"].endswith(
|
||||
"/chrome-linux/headless_shell"
|
||||
)
|
||||
assert "--no-sandbox" in hermes_env["AGENT_BROWSER_ARGS"]
|
||||
|
||||
|
||||
def test_sandbox_executes_python_with_bounded_output(tmp_path: Path, monkeypatch):
|
||||
source = ROOT / "dockerfiles" / "hermes-chat-sandbox-server.py"
|
||||
spec = importlib.util.spec_from_file_location("hermes_chat_sandbox_server", source)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
monkeypatch.setattr(module, "WORKSPACE", tmp_path)
|
||||
|
||||
result = module._execute("import math\nprint(math.comb(10, 3))")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["stdout"] == "120\n"
|
||||
assert result["stderr"] == ""
|
||||
@ -1,18 +1,458 @@
|
||||
"""Hermes chat provider auth contracts."""
|
||||
"""Provider subscription and OAuth contracts for Hermes chat."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from test_hermes_chat_support import (
|
||||
HERMES,
|
||||
Path,
|
||||
ROOT,
|
||||
_documents,
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
|
||||
import pytest
|
||||
|
||||
from testing.tests.test_hermes_chat_support import (
|
||||
_load_broker_module,
|
||||
importlib,
|
||||
json,
|
||||
)
|
||||
|
||||
|
||||
def test_codex_broker_auth_and_request_contract(tmp_path: Path, monkeypatch):
|
||||
"""The relay is bounded, stateless, and rejects unapproved models."""
|
||||
module = _load_broker_module("hermes_codex_broker", "codex_broker.py", monkeypatch)
|
||||
monkeypatch.setattr(module, "TOKEN", "relay-secret")
|
||||
|
||||
assert module._authorized("Bearer relay-secret") is True
|
||||
assert module._authorized("Bearer wrong") is False
|
||||
assert module._real_model("route/codex/gpt-5.6-sol/xhigh") == "gpt-5.6-sol"
|
||||
payload = module._validate_payload(
|
||||
{
|
||||
"model": "gpt-5.6-terra",
|
||||
"input": "route this chat turn",
|
||||
"store": True,
|
||||
"stream": False,
|
||||
"max_output_tokens": 96,
|
||||
"max_completion_tokens": 96,
|
||||
"max_tokens": 96,
|
||||
"temperature": 0.7,
|
||||
"top_p": 0.9,
|
||||
}
|
||||
)
|
||||
assert payload["store"] is False
|
||||
assert payload["stream"] is True
|
||||
assert "max_output_tokens" not in payload
|
||||
assert "max_completion_tokens" not in payload
|
||||
assert "max_tokens" not in payload
|
||||
assert "temperature" not in payload
|
||||
assert "top_p" not in payload
|
||||
assert payload["input"] == [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "route this chat turn"}],
|
||||
}
|
||||
]
|
||||
response_item = {
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "keep this item"}],
|
||||
}
|
||||
assert module._validate_payload({"model": "gpt-5.6-terra", "input": response_item})[
|
||||
"input"
|
||||
] == [response_item]
|
||||
response_items = [response_item]
|
||||
assert (
|
||||
module._validate_payload({"model": "gpt-5.6-terra", "input": response_items})[
|
||||
"input"
|
||||
]
|
||||
is response_items
|
||||
)
|
||||
image_items = [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "input_text", "text": "What color is this?"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": "data:image/png;base64,cHJpdmF0ZQ==",
|
||||
"detail": "high",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
assert module._validate_payload({"model": "gpt-5.6-terra", "input": image_items})[
|
||||
"input"
|
||||
][0]["content"][1] == {
|
||||
"type": "input_image",
|
||||
"image_url": "data:image/png;base64,cHJpdmF0ZQ==",
|
||||
"detail": "high",
|
||||
}
|
||||
switchyard_image_items = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "input_text", "text": "What color is this?"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": "data:image/png;base64,cHJpdmF0ZQ==",
|
||||
"detail": "auto",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
assert module._validate_payload(
|
||||
{"model": "gpt-5.6-terra", "input": switchyard_image_items}
|
||||
)["input"][0]["content"][1] == {
|
||||
"type": "input_image",
|
||||
"image_url": "data:image/png;base64,cHJpdmF0ZQ==",
|
||||
"detail": "auto",
|
||||
}
|
||||
switchyard_base64_items = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "input_text", "text": "What color is this?"},
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/png",
|
||||
"data": "cHJpdmF0ZQ==",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
normalized_base64 = module._validate_payload(
|
||||
{"model": "gpt-5.6-terra", "input": switchyard_base64_items}
|
||||
)["input"][0]["content"][1]
|
||||
assert normalized_base64 == {
|
||||
"type": "input_image",
|
||||
"image_url": "data:image/png;base64,cHJpdmF0ZQ==",
|
||||
}
|
||||
switchyard_enum_items = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_image",
|
||||
"image_url": {
|
||||
"type": "url",
|
||||
"data": {
|
||||
"url": "data:image/png;base64,cHJpdmF0ZQ==",
|
||||
"detail": "high",
|
||||
},
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
nested_image = module._validate_payload(
|
||||
{"model": "gpt-5.6-terra", "input": switchyard_enum_items}
|
||||
)["input"][0]["content"][0]
|
||||
assert nested_image["image_url"] == "data:image/png;base64,cHJpdmF0ZQ=="
|
||||
assert nested_image["detail"] == "high"
|
||||
with pytest.raises(ValueError, match=r"non-empty Responses image URL.*str\[4\]"):
|
||||
module._validate_payload(
|
||||
{
|
||||
"model": "gpt-5.6-terra",
|
||||
"input": [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_image",
|
||||
"image_url": {"detail": "high"},
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
routed = module._validate_payload(
|
||||
{
|
||||
"model": "route/codex/gpt-5.6-luna/low",
|
||||
"input": "use the low route",
|
||||
"stream": False,
|
||||
}
|
||||
)
|
||||
assert routed["model"] == "gpt-5.6-luna"
|
||||
with pytest.raises(ValueError, match="unsupported Codex model"):
|
||||
module._validate_payload({"model": "unapproved-model", "input": "hello"})
|
||||
with pytest.raises(ValueError, match="non-empty Responses input"):
|
||||
module._validate_payload({"model": "gpt-5.6-terra", "input": ""})
|
||||
with pytest.raises(ValueError, match="non-empty Responses input list"):
|
||||
module._validate_payload({"model": "gpt-5.6-terra", "input": []})
|
||||
|
||||
completed = {
|
||||
"id": "resp_test",
|
||||
"object": "response",
|
||||
"status": "completed",
|
||||
"output": [],
|
||||
}
|
||||
completed_item = {
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": [{"type": "output_text", "text": "done"}],
|
||||
}
|
||||
assert module._completed_response(
|
||||
[
|
||||
"event: response.created",
|
||||
'data: {"type":"response.created","response":{}}',
|
||||
"event: response.output_item.done",
|
||||
"data: "
|
||||
+ json.dumps(
|
||||
{
|
||||
"type": "response.output_item.done",
|
||||
"output_index": 0,
|
||||
"item": completed_item,
|
||||
}
|
||||
),
|
||||
"event: response.completed",
|
||||
"data: "
|
||||
+ json.dumps({"type": "response.completed", "response": completed}),
|
||||
"data: [DONE]",
|
||||
]
|
||||
)["output"] == [completed_item]
|
||||
raw_stream = (
|
||||
"event: response.output_item.done\n"
|
||||
"data: "
|
||||
+ json.dumps(
|
||||
{
|
||||
"type": "response.output_item.done",
|
||||
"output_index": 0,
|
||||
"item": completed_item,
|
||||
}
|
||||
)
|
||||
+ "\n\nevent: response.completed\ndata: "
|
||||
+ json.dumps({"type": "response.completed", "response": completed})
|
||||
+ "\n\n"
|
||||
).encode()
|
||||
normalized = module._normalized_stream(
|
||||
raw_stream, {**completed, "output": [completed_item]}
|
||||
).decode()
|
||||
terminal_data = next(
|
||||
line for line in normalized.splitlines() if '"response.completed"' in line
|
||||
)
|
||||
assert json.loads(terminal_data.removeprefix("data: "))["response"]["output"] == [
|
||||
completed_item
|
||||
]
|
||||
assert normalized.endswith("\n\n")
|
||||
streamed_function_item = {
|
||||
"type": "function_call",
|
||||
"name": "read_file",
|
||||
"status": "completed",
|
||||
"arguments": '{"path":"/tmp"}',
|
||||
}
|
||||
streamed_function_body = (
|
||||
"event: response.function_call_arguments.delta\n"
|
||||
'data: {"type":"response.function_call_arguments.delta",'
|
||||
'"item_id":"call_1","delta":"{\\"path\\":\\"/tmp\\"}"}\n\n'
|
||||
"event: response.function_call_arguments.done\n"
|
||||
'data: {"type":"response.function_call_arguments.done",'
|
||||
'"item_id":"call_1","arguments":"{\\"path\\":\\"/tmp\\"}"}\n\n'
|
||||
"event: response.output_item.done\n"
|
||||
'data: {"type":"response.output_item.done","output_index":0,'
|
||||
'"item":{"type":"function_call","name":"read_file",'
|
||||
'"arguments":"{\\"path\\":\\"/tmp\\"}"}}\n\n'
|
||||
"event: response.completed\n"
|
||||
"data: "
|
||||
+ json.dumps(
|
||||
{
|
||||
"type": "response.completed",
|
||||
"response": {**completed, "output": [streamed_function_item]},
|
||||
}
|
||||
)
|
||||
+ "\n\n"
|
||||
).encode()
|
||||
normalized_function_stream = module._normalized_stream(
|
||||
streamed_function_body, {**completed, "output": [streamed_function_item]}
|
||||
).decode()
|
||||
assert "response.function_call_arguments.delta" in normalized_function_stream
|
||||
assert "response.function_call_arguments.done" not in normalized_function_stream
|
||||
assert "response.output_item.done" not in normalized_function_stream
|
||||
normalized_terminal = next(
|
||||
line
|
||||
for line in normalized_function_stream.splitlines()
|
||||
if '"response.completed"' in line
|
||||
)
|
||||
assert (
|
||||
json.loads(normalized_terminal.removeprefix("data: "))["response"]["output"]
|
||||
== []
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="retryable incomplete response"):
|
||||
module._completed_response(
|
||||
[
|
||||
"event: response.incomplete",
|
||||
'data: {"type":"response.incomplete","response":'
|
||||
'{"status":"incomplete","incomplete_details":'
|
||||
'{"reason":"max_output_tokens"}}}',
|
||||
]
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="provider unavailable"):
|
||||
module._completed_response(
|
||||
[
|
||||
"event: error",
|
||||
'data: {"type":"error","error":{"message":"provider unavailable"}}',
|
||||
]
|
||||
)
|
||||
malformed_tool_item = {
|
||||
"type": "function_call",
|
||||
"name": "search_files",
|
||||
"status": "completed",
|
||||
"arguments": '{"path":"","offset":',
|
||||
}
|
||||
with pytest.raises(RuntimeError, match="malformed function arguments"):
|
||||
module._completed_response(
|
||||
[
|
||||
"event: response.output_item.done",
|
||||
"data: "
|
||||
+ json.dumps(
|
||||
{
|
||||
"type": "response.output_item.done",
|
||||
"output_index": 0,
|
||||
"item": malformed_tool_item,
|
||||
}
|
||||
),
|
||||
"event: response.completed",
|
||||
"data: "
|
||||
+ json.dumps({"type": "response.completed", "response": completed}),
|
||||
]
|
||||
)
|
||||
valid_tool_item = {
|
||||
**malformed_tool_item,
|
||||
"arguments": '{"path":"","offset":0}',
|
||||
}
|
||||
assert module._completed_response(
|
||||
[
|
||||
"event: response.output_item.done",
|
||||
"data: "
|
||||
+ json.dumps(
|
||||
{
|
||||
"type": "response.output_item.done",
|
||||
"output_index": 0,
|
||||
"item": valid_tool_item,
|
||||
}
|
||||
),
|
||||
"event: response.completed",
|
||||
"data: "
|
||||
+ json.dumps({"type": "response.completed", "response": completed}),
|
||||
]
|
||||
)["output"] == [valid_tool_item]
|
||||
with pytest.raises(RuntimeError, match="malformed function arguments"):
|
||||
module._completed_response(
|
||||
[
|
||||
"event: response.function_call_arguments.delta",
|
||||
'data: {"type":"response.function_call_arguments.delta",'
|
||||
'"item_id":"call_1","output_index":0,'
|
||||
'"delta":"{\\"path\\":\\"/tmp\\",\\"offset\\":"}',
|
||||
"event: response.completed",
|
||||
"data: "
|
||||
+ json.dumps({"type": "response.completed", "response": completed}),
|
||||
]
|
||||
)
|
||||
streamed_tool = module._completed_response(
|
||||
[
|
||||
"event: response.function_call_arguments.delta",
|
||||
'data: {"type":"response.function_call_arguments.delta",'
|
||||
'"item_id":"call_2","output_index":0,'
|
||||
'"delta":"{\\"path\\":\\"/tmp\\",\\"offset\\":"}',
|
||||
"event: response.function_call_arguments.done",
|
||||
'data: {"type":"response.function_call_arguments.done",'
|
||||
'"item_id":"call_2","output_index":0,'
|
||||
'"arguments":"{\\"path\\":\\"/tmp\\",\\"offset\\":0}"}',
|
||||
"event: response.completed",
|
||||
"data: "
|
||||
+ json.dumps({"type": "response.completed", "response": completed}),
|
||||
]
|
||||
)
|
||||
assert streamed_tool["status"] == "completed"
|
||||
|
||||
auth_dir = tmp_path / ".codex"
|
||||
auth_dir.mkdir()
|
||||
# The token payload need only prove the broker reads CODEX_HOME directly.
|
||||
encoded = (
|
||||
base64.urlsafe_b64encode(json.dumps({"exp": time.time() + 3600}).encode())
|
||||
.decode()
|
||||
.rstrip("=")
|
||||
)
|
||||
(auth_dir / "auth.json").write_text(
|
||||
json.dumps({"tokens": {"access_token": f"header.{encoded}.signature"}})
|
||||
)
|
||||
monkeypatch.setenv("CODEX_HOME", str(auth_dir))
|
||||
assert module._access_token().startswith("header.")
|
||||
|
||||
|
||||
def test_codex_broker_refreshes_and_persists_first_party_oauth(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
"""Expired ChatGPT OAuth refreshes in the canonical Codex CLI store."""
|
||||
module = _load_broker_module(
|
||||
"hermes_codex_refresh_broker", "codex_broker.py", monkeypatch
|
||||
)
|
||||
auth_dir = tmp_path / ".codex"
|
||||
auth_dir.mkdir()
|
||||
|
||||
def jwt(expires_at: float) -> str:
|
||||
payload = (
|
||||
base64.urlsafe_b64encode(json.dumps({"exp": expires_at}).encode())
|
||||
.decode()
|
||||
.rstrip("=")
|
||||
)
|
||||
return f"header.{payload}.signature"
|
||||
|
||||
expired = jwt(time.time() - 60)
|
||||
live = jwt(time.time() + 3600)
|
||||
auth_path = auth_dir / "auth.json"
|
||||
auth_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"auth_mode": "chatgpt",
|
||||
"tokens": {
|
||||
"access_token": expired,
|
||||
"refresh_token": "refresh-old",
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
calls = []
|
||||
auth_module = ModuleType("hermes_cli.auth")
|
||||
|
||||
def refresh(access_token, refresh_token, *, timeout_seconds):
|
||||
calls.append((access_token, refresh_token, timeout_seconds))
|
||||
return {
|
||||
"access_token": live,
|
||||
"refresh_token": "refresh-new",
|
||||
"last_refresh": "2026-08-12T20:00:00Z",
|
||||
}
|
||||
|
||||
auth_module.refresh_codex_oauth_pure = refresh
|
||||
package = ModuleType("hermes_cli")
|
||||
package.auth = auth_module
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", package)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli.auth", auth_module)
|
||||
monkeypatch.setenv("CODEX_HOME", str(auth_dir))
|
||||
|
||||
assert module._access_token() == live
|
||||
persisted = json.loads(auth_path.read_text())
|
||||
assert persisted["tokens"]["access_token"] == live
|
||||
assert persisted["tokens"]["refresh_token"] == "refresh-new"
|
||||
assert persisted["last_refresh"] == "2026-08-12T20:00:00Z"
|
||||
assert calls == [(expired, "refresh-old", 30.0)]
|
||||
assert auth_path.stat().st_mode & 0o777 == 0o600
|
||||
|
||||
# A healthy token is reused, so repeated routed turns do not spend a
|
||||
# refresh token or create a second billing/authentication path.
|
||||
assert module._access_token() == live
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
def test_claude_broker_uses_native_subscription_without_api_billing(monkeypatch):
|
||||
"""Claude traffic must use the native first-party CLI subscription lane."""
|
||||
module = _load_broker_module(
|
||||
@ -34,159 +474,3 @@ def test_claude_broker_uses_native_subscription_without_api_billing(monkeypatch)
|
||||
assert "ANTHROPIC_API_KEY" not in module._claude_environment()
|
||||
assert "CLAUDE_API_KEY" not in module._claude_environment()
|
||||
assert module.CAPACITY_PATTERN.search("weekly usage limit exhausted")
|
||||
|
||||
def test_codex_native_health_overrides_historical_router_errors(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
"""Fresh first-party health is authoritative over old Switchyard probes."""
|
||||
plugin_path = HERMES / "plugins" / "auto-router" / "provider_status.py"
|
||||
spec = importlib.util.spec_from_file_location("hermes_provider_status", plugin_path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
health_path = tmp_path / "codex.json"
|
||||
health_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"state": "available",
|
||||
"authenticated": True,
|
||||
"transport": "codex-chatgpt-subscription",
|
||||
}
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(module, "CODEX_HEALTH_PATH", health_path)
|
||||
monkeypatch.setattr(module, "CLAUDE_HEALTH_PATH", tmp_path / "missing.json")
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"_get_json",
|
||||
lambda url: {"status": "ok"}
|
||||
if url.endswith("/health")
|
||||
else {
|
||||
"models": {
|
||||
"route/codex/terra/medium": {
|
||||
"calls": 1,
|
||||
"errors": 99,
|
||||
"total_tokens": 12,
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(module, "_codex_account", lambda: {})
|
||||
monkeypatch.setattr(module, "_claude_account", lambda: {})
|
||||
|
||||
codex = module.provider_status_payload()["providers"]["codex"]
|
||||
|
||||
assert codex["errors"] == 99
|
||||
assert codex["state"] == "available"
|
||||
assert codex["native_health"]["transport"] == "codex-chatgpt-subscription"
|
||||
|
||||
def test_api_session_patch_accepts_parent_lineage(tmp_path: Path):
|
||||
"""API-created workers must persist the originating Hermes session."""
|
||||
module_path = HERMES / "scripts" / "patch_api_server_sessions.py"
|
||||
spec = importlib.util.spec_from_file_location("patch_api_sessions", module_path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
source = tmp_path / "api_server.py"
|
||||
destination = tmp_path / "patched.py"
|
||||
source.write_text(
|
||||
"prefix\n"
|
||||
+ module.BEFORE
|
||||
+ "middle\n"
|
||||
+ module.RUNS_BEFORE
|
||||
+ "run body\n"
|
||||
+ module.RUN_CLOSE_BEFORE
|
||||
+ module.RESPONSES_SESSION_BEFORE
|
||||
+ module.EVENT_CALLBACK_SIGNATURE_BEFORE
|
||||
+ "callback docstring and push helper\n"
|
||||
+ module.EVENT_CALLBACK_BODY_BEFORE
|
||||
+ "tool start body\n"
|
||||
+ module.EVENT_CALLBACK_END_BEFORE
|
||||
+ module.EVENT_CALLBACK_CALL_BEFORE
|
||||
+ module.RUN_SWEEP_BEFORE
|
||||
+ "suffix\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
module.patch(source, destination)
|
||||
patched = destination.read_text(encoding="utf-8")
|
||||
|
||||
assert "X-Hermes-Parent-Session-Id" in patched
|
||||
assert "parent_session_id=parent_session_id" in patched
|
||||
assert "Parent session not found" in patched
|
||||
assert "HERMES_API_DEFAULT_PARENT_MATCH_PREFIXES" in patched
|
||||
assert "user_message.startswith(default_prefixes)" in patched
|
||||
assert "session_parent_conflict" in patched
|
||||
assert "X-Hermes-Conversation-Platform" in patched
|
||||
assert "X-Hermes-Conversation-Title" in patched
|
||||
assert 'conversation_platform != "telegram"' in patched
|
||||
assert "db.record_gateway_session_peer(" in patched
|
||||
assert 'display_name="Telegram"' in patched
|
||||
assert "db.reopen_session(session_id)" in patched
|
||||
assert 'db.end_session(session_id, f"api_run_{terminal_status}")' in patched
|
||||
assert "def _record_run_activity(" in patched
|
||||
assert '"_thinking": "Hermes is reasoning"' in patched
|
||||
assert '"run.started": "Worker started"' in patched
|
||||
assert '"run.completed": "Worker completed"' in patched
|
||||
assert '"reasoning.available": "Hermes finished a reasoning step"' in patched
|
||||
assert '"subagent.progress": "Nested worker progress"' in patched
|
||||
assert "redact_sensitive_text" in patched
|
||||
assert 'getattr(os, "O_NOFOLLOW", 0)' in patched
|
||||
assert "os.fchmod(fd, 0o600)" in patched
|
||||
assert "session_id=session_id" in patched
|
||||
assert 'self._record_run_activity(session_id, "run.started")' in patched
|
||||
assert 'detail = tool_name if event_type in {' in patched
|
||||
assert 'if event_type == "subagent.tool"' in patched
|
||||
assert "_RUN_ACTIVITY_HEARTBEAT_SECONDS = 15.0" in patched
|
||||
assert 'heartbeats.get(session_id, 0.0)' in patched
|
||||
assert '"subagent.thinking",' in patched
|
||||
assert "Stream retention and run lifetime are separate" in patched
|
||||
assert 'terminal_status in {"completed", "failed", "cancelled"}' in patched
|
||||
assert patched.index("terminal_status = self._run_statuses") < patched.index(
|
||||
"self._active_run_tasks.pop(run_id, None)"
|
||||
)
|
||||
|
||||
def test_switchyard_brokers_and_native_claude_lane_use_the_right_images():
|
||||
"""Thin brokers stay small while native Claude runs beside owner auth."""
|
||||
dockerfile = (ROOT / "dockerfiles" / "Dockerfile.hermes-switchyard-brokers").read_text()
|
||||
assert "httpx==0.28.1" in dockerfile
|
||||
assert "worker_route_broker.py" in dockerfile
|
||||
assert "routing_catalog.py" in dockerfile
|
||||
|
||||
deployment = _documents(HERMES / "switchyard-deployment.yaml")[0]
|
||||
containers = {
|
||||
container["name"]: container
|
||||
for container in deployment["spec"]["template"]["spec"]["containers"]
|
||||
}
|
||||
expected = (
|
||||
"registry.bstein.dev/bstein/hermes-switchyard-brokers@"
|
||||
"sha256:ee7e95e060ef8083da505162d7e9030daba15fdd828cc047bbcbe6aa409d2083"
|
||||
)
|
||||
assert containers["worker-route-broker"]["image"] == expected
|
||||
assert containers["classifier-broker"]["image"] == expected
|
||||
assert "claude-oauth-broker" not in containers
|
||||
|
||||
agent = _documents(HERMES / "agent-deployment.yaml")[0]
|
||||
agent_containers = {
|
||||
container["name"]: container
|
||||
for container in agent["spec"]["template"]["spec"]["containers"]
|
||||
}
|
||||
for container_name in ("hermes", "terminal"):
|
||||
container = agent_containers[container_name]
|
||||
environment = {item["name"]: item["value"] for item in container["env"]}
|
||||
mounts = {item["name"]: item for item in container["volumeMounts"]}
|
||||
assert environment["HERMES_ROUTING_CATALOG_PATH"] == "/routing-catalog/catalog.json"
|
||||
assert environment["HERMES_CODEX_HEALTH_PATH"] == "/opt/data/provider-health/codex.json"
|
||||
assert environment["HERMES_CLAUDE_HEALTH_PATH"] == "/opt/data/provider-health/claude.json"
|
||||
assert mounts["routing-catalog"]["mountPath"] == "/routing-catalog"
|
||||
assert mounts["routing-catalog"]["readOnly"] is True
|
||||
codex = agent_containers["codex-broker"]
|
||||
codex_environment = {item["name"]: item["value"] for item in codex["env"]}
|
||||
assert codex_environment["HERMES_CODEX_HEALTH_PATH"] == "/opt/data/provider-health/codex.json"
|
||||
claude = agent_containers["claude-broker"]
|
||||
assert claude["image"].startswith("registry.bstein.dev/bstein/hermes-agent@")
|
||||
assert "unset ANTHROPIC_API_KEY CLAUDE_API_KEY" in claude["args"][0]
|
||||
assert any(
|
||||
mount["name"] == "home" and mount["mountPath"] == "/opt/data"
|
||||
for mount in claude["volumeMounts"]
|
||||
)
|
||||
|
||||
@ -1,15 +1,19 @@
|
||||
"""Hermes chat config contracts."""
|
||||
"""Core configuration and workload contracts for Hermes chat."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from test_hermes_chat_support import (
|
||||
import importlib.util
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
|
||||
import yaml
|
||||
|
||||
from testing.tests.test_hermes_chat_support import (
|
||||
HERMES,
|
||||
ROOT,
|
||||
_documents,
|
||||
yaml,
|
||||
)
|
||||
|
||||
|
||||
def test_chat_config_enables_real_research_compute_and_delegation():
|
||||
configmap = _documents(HERMES / "chat-configmap.yaml")[0]
|
||||
config = yaml.safe_load(configmap["data"]["config.yaml"])
|
||||
@ -37,6 +41,7 @@ def test_chat_config_enables_real_research_compute_and_delegation():
|
||||
assert "terminal" not in toolsets
|
||||
assert "code_execution" not in toolsets
|
||||
|
||||
|
||||
def test_agent_config_keeps_delegated_reviewers_from_owning_task_lifecycle():
|
||||
configmap = _documents(HERMES / "agent-configmap.yaml")[0]
|
||||
instructions = configmap["data"]["AGENTS.md"]
|
||||
@ -56,22 +61,30 @@ def test_agent_config_keeps_delegated_reviewers_from_owning_task_lifecycle():
|
||||
assert "Atlas organization has private visibility" in instructions
|
||||
assert "may be public or private" in instructions
|
||||
assert "do not infer a\nrepository's visibility" in instructions
|
||||
assert "already supplied through `GIT_ASKPASS`" in instructions
|
||||
assert "brokered Git for clone, fetch" in instructions
|
||||
assert "client carries no repository credential" in instructions
|
||||
assert "Never call `kanban_show` without a known, non-empty task ID" in instructions
|
||||
assert "bounded ad-hoc inspection and acceptance checks may" in instructions
|
||||
assert "load implementation or TDD skills" in instructions
|
||||
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 "no repository token is present in this pod" in soul
|
||||
assert "`scm.bstein.dev` is Forgejo/Gitea, not GitHub" in instructions
|
||||
assert "GitHub/`gh` skill for an Atlas remote" in instructions
|
||||
assert "load\n`$manage-atlas-pull-requests`" in instructions
|
||||
assert "Updates, merge,\napprove, close, delete, comments" 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 "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():
|
||||
dockerfile = (ROOT / "dockerfiles" / "Dockerfile.hermes-agent").read_text()
|
||||
|
||||
@ -87,6 +100,7 @@ def test_agent_image_completes_parked_kanban_tasks_atomically():
|
||||
assert "payload.get(\"status\") == \"blocked\"" in dockerfile
|
||||
assert "hermes-kanban-blocked-regression.py" in dockerfile
|
||||
|
||||
|
||||
def test_sandbox_shares_only_the_tenant_workspace_without_credentials():
|
||||
sandbox_docs = _documents(HERMES / "chat-sandbox.yaml")
|
||||
deployments = [doc for doc in sandbox_docs if doc["kind"] == "Deployment"]
|
||||
@ -150,6 +164,7 @@ def test_sandbox_shares_only_the_tenant_workspace_without_credentials():
|
||||
f"hermes-chat-tenant-{ordinal}"
|
||||
)
|
||||
|
||||
|
||||
def test_gateway_image_honors_ui_model_and_caps_reasoning():
|
||||
dockerfile = (ROOT / "dockerfiles" / "Dockerfile.hermes-agent").read_text()
|
||||
assert (
|
||||
@ -208,6 +223,7 @@ def test_gateway_image_honors_ui_model_and_caps_reasoning():
|
||||
assert 'params.has("resume")' in dockerfile
|
||||
assert "Open Telegram setup" in dockerfile
|
||||
|
||||
|
||||
def test_telegram_router_local_build_defaults_to_cluster_architecture():
|
||||
dockerfile = (
|
||||
ROOT / "dockerfiles" / "Dockerfile.hermes-chat-router"
|
||||
@ -217,6 +233,7 @@ def test_telegram_router_local_build_defaults_to_cluster_architecture():
|
||||
assert "ARG TARGETARCH=arm64" in dockerfile
|
||||
assert "GOOS=${TARGETOS} GOARCH=${TARGETARCH}" in dockerfile
|
||||
|
||||
|
||||
def test_chat_oauth_allows_stale_service_worker_retirement():
|
||||
documents = _documents(HERMES / "oauth2-proxy.yaml")
|
||||
deployment = next(
|
||||
@ -271,3 +288,169 @@ def test_chat_oauth_allows_stale_service_worker_retirement():
|
||||
assert agent_template_volume["configMap"]["name"] == (
|
||||
"hermes-chat-oauth-templates"
|
||||
)
|
||||
|
||||
|
||||
def test_webui_recovers_auth_and_labels_session_scoped_controls():
|
||||
dockerfile = (ROOT / "dockerfiles" / "Dockerfile.hermes-webui").read_text()
|
||||
|
||||
assert "res.status===401||res.status===403" in dockerfile
|
||||
assert "window.location.assign('/oauth2/start?rd='" in dockerfile
|
||||
assert "childrenExpanded?'▾ ':'▸ '" in dockerfile
|
||||
assert "'atlas/auto/maximum': 'Automatic · Maximum'" in dockerfile
|
||||
assert "hermes-webui-telegram-project-patch.py" in dockerfile
|
||||
telegram_project_patch = (
|
||||
ROOT / "dockerfiles" / "hermes-webui-telegram-project-patch.py"
|
||||
).read_text()
|
||||
assert "TELEGRAM_PROJECT_NAME = 'Telegram'" in telegram_project_patch
|
||||
assert "session_key.startswith('telegram-topic-')" in telegram_project_patch
|
||||
|
||||
router = (ROOT / "dockerfiles" / "hermes-webui-router.js").read_text()
|
||||
assert "'atlas/auto/fast':'AUTO · Fast'" in router
|
||||
assert "'atlas/manual/codex/sol':'Codex · SOL'" in router
|
||||
assert "'atlas/manual/claude/opus':'Claude · Opus'" in router
|
||||
assert "watchModelOptions('modelSelect')" in router
|
||||
assert "watchModelOptions('settingsModel')" in router
|
||||
|
||||
|
||||
def test_chat_voice_uses_private_jetson_services_and_shared_auto_route():
|
||||
statefulset = _documents(HERMES / "chat-statefulset.yaml")[0]
|
||||
containers = statefulset["spec"]["template"]["spec"]["containers"]
|
||||
webui = next(item for item in containers if item["name"] == "webui")
|
||||
env = {item["name"]: item["value"] for item in webui["env"]}
|
||||
|
||||
assert env["HERMES_STT_URL"] == (
|
||||
"http://hermes-stt.hermes.svc.cluster.local:9000/v1/audio/transcriptions"
|
||||
)
|
||||
assert env["HERMES_WEBUI_ATLAS_TTS_URL"] == (
|
||||
"http://hermes-tts.hermes.svc.cluster.local:9001/v1/audio/speech"
|
||||
)
|
||||
assert "hermes_stt_client.py" in env["HERMES_LOCAL_STT_COMMAND"]
|
||||
|
||||
configmap = _documents(HERMES / "chat-configmap.yaml")[0]
|
||||
config = yaml.safe_load(configmap["data"]["config.yaml"])
|
||||
assert config["stt"] == {
|
||||
"enabled": True,
|
||||
"provider": "local_command",
|
||||
"local": {"model": "small", "language": "auto"},
|
||||
}
|
||||
|
||||
dockerfile = (ROOT / "dockerfiles" / "Dockerfile.hermes-webui").read_text()
|
||||
assert "hermes-webui-atlas-patch.py" in dockerfile
|
||||
assert "hermes-webui-atlas-voice.js" in dockerfile
|
||||
voice_script = (ROOT / "dockerfiles" / "hermes-webui-atlas-voice.js").read_text()
|
||||
assert "/api/transcribe/capability" in voice_script
|
||||
assert "/api/transcribe" in voice_script
|
||||
assert "/api/tts" in voice_script
|
||||
assert "speakResponse(generation)" in voice_script
|
||||
assert "window._splitForTTS(text,280)" in voice_script
|
||||
assert "pending=fetchSpeech(chunks[index+1])" in voice_script
|
||||
assert "restartSoon(token,450)" in voice_script
|
||||
assert "constraints.voiceIsolation=true" in voice_script
|
||||
assert "highpass.frequency.value=140" in voice_script
|
||||
assert "Math.max(0.04,noiseFloor*2.4+0.006)" in voice_script
|
||||
assert "while(preRoll.length>3) preRoll.shift()" in voice_script
|
||||
|
||||
stt_server = (ROOT / "dockerfiles" / "hermes-jetson-stt-server.py").read_text()
|
||||
assert "def _repetitive_token" in stt_server
|
||||
assert "compression_ratio_threshold=2.0" in stt_server
|
||||
assert "no_speech_threshold=0.5" in stt_server
|
||||
|
||||
|
||||
def test_voice_transcript_filter_removes_fan_hallucinations(monkeypatch):
|
||||
server_path = ROOT / "dockerfiles" / "hermes-jetson-stt-server.py"
|
||||
spec = importlib.util.spec_from_file_location("hermes_jetson_stt_server", server_path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
monkeypatch.setitem(sys.modules, "cgi", SimpleNamespace())
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"torch",
|
||||
SimpleNamespace(cuda=SimpleNamespace(is_available=lambda: False)),
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "whisper", SimpleNamespace())
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
result = {
|
||||
"segments": [
|
||||
{
|
||||
"text": " ththththththththth Testing.",
|
||||
"no_speech_prob": 0.12,
|
||||
"avg_logprob": -0.2,
|
||||
},
|
||||
{
|
||||
"text": " background hum",
|
||||
"no_speech_prob": 0.82,
|
||||
"avg_logprob": -0.9,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
assert module._clean_transcript(result) == "Testing."
|
||||
|
||||
|
||||
def test_voice_models_are_baked_and_runtime_has_no_public_egress():
|
||||
stt_dockerfile = (ROOT / "dockerfiles" / "Dockerfile.hermes-jetson-stt").read_text()
|
||||
tts_dockerfile = (ROOT / "dockerfiles" / "Dockerfile.hermes-jetson-tts").read_text()
|
||||
assert "ADD --checksum=sha256:aff26ae4" in stt_dockerfile
|
||||
assert "ADD --checksum=sha256:9ecf7799" in stt_dockerfile
|
||||
assert "--chmod=0444" in stt_dockerfile
|
||||
assert "chmod 0555 /opt/models /opt/models/whisper" in stt_dockerfile
|
||||
assert "HERMES_STT_CACHE=/opt/models/whisper" in stt_dockerfile
|
||||
assert "ADD --checksum=sha256:4cabf7c3" in tts_dockerfile
|
||||
assert "ADD --checksum=sha256:db42b97d" in tts_dockerfile
|
||||
assert tts_dockerfile.count("--chmod=0444") == 6
|
||||
assert "chmod 0555 /opt/models /opt/models/piper" in tts_dockerfile
|
||||
assert "HERMES_TTS_CACHE=/opt/models/piper" in tts_dockerfile
|
||||
tts_server = (ROOT / "dockerfiles" / "hermes-jetson-tts-server.py").read_text()
|
||||
assert "download_voice" not in tts_server
|
||||
assert "baked Piper voice is missing" in tts_server
|
||||
assert "session_options.intra_op_num_threads = ONNX_THREADS" in tts_server
|
||||
|
||||
policies = _documents(HERMES / "networkpolicy.yaml")
|
||||
voice_policy = next(
|
||||
item for item in policies if item["metadata"]["name"] == "hermes-private-voice"
|
||||
)
|
||||
assert voice_policy["spec"]["policyTypes"] == ["Ingress", "Egress"]
|
||||
assert not any(
|
||||
"ipBlock" in destination
|
||||
for rule in voice_policy["spec"]["egress"]
|
||||
for destination in rule.get("to", [])
|
||||
)
|
||||
|
||||
|
||||
def test_voice_workloads_have_deliberate_xavier_placement():
|
||||
documents = _documents(HERMES / "voice-deployment.yaml")
|
||||
deployments = {
|
||||
item["metadata"]["name"]: item
|
||||
for item in documents
|
||||
if item["kind"] == "Deployment"
|
||||
}
|
||||
stt = deployments["hermes-stt"]["spec"]["template"]["spec"]
|
||||
tts = deployments["hermes-tts"]["spec"]["template"]["spec"]
|
||||
|
||||
assert "@sha256:" in stt["containers"][0]["image"]
|
||||
assert "@sha256:" in tts["containers"][0]["image"]
|
||||
assert stt["nodeSelector"] == {"kubernetes.io/hostname": "titan-21"}
|
||||
assert tts["nodeSelector"] == {"kubernetes.io/hostname": "titan-21"}
|
||||
assert stt["automountServiceAccountToken"] is False
|
||||
assert tts["automountServiceAccountToken"] is False
|
||||
assert stt["enableServiceLinks"] is False
|
||||
assert tts["enableServiceLinks"] is False
|
||||
assert stt["runtimeClassName"] == "nvidia"
|
||||
assert stt["securityContext"]["supplementalGroups"] == [44]
|
||||
stt_resources = stt["containers"][0]["resources"]
|
||||
stt_env = {
|
||||
item["name"]: item["value"] for item in stt["containers"][0]["env"]
|
||||
}
|
||||
assert stt_env["NVIDIA_DRIVER_CAPABILITIES"] == "compute,utility"
|
||||
assert stt_resources["requests"]["nvidia.com/gpu.shared"] == 1
|
||||
assert stt_resources["limits"]["nvidia.com/gpu.shared"] == 1
|
||||
assert "nvidia.com/gpu.shared" not in tts["containers"][0]["resources"]["requests"]
|
||||
tts_env = {
|
||||
item["name"]: item["value"] for item in tts["containers"][0]["env"]
|
||||
}
|
||||
assert tts_env["HERMES_TTS_VOICE"] == "en_US-lessac-medium"
|
||||
assert tts_env["HERMES_TTS_ONNX_THREADS"] == "2"
|
||||
assert tts["containers"][0]["resources"]["limits"]["cpu"] == "4"
|
||||
assert all("hostPath" not in volume for volume in stt["volumes"])
|
||||
assert all("hostPath" not in volume for volume in tts["volumes"])
|
||||
486
testing/tests/test_hermes_chat_routing_runtime.py
Normal file
486
testing/tests/test_hermes_chat_routing_runtime.py
Normal file
@ -0,0 +1,486 @@
|
||||
"""Routing and runtime workload contracts for Hermes chat."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sqlite3
|
||||
import sys
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
from testing.tests.test_hermes_chat_support import (
|
||||
HERMES,
|
||||
ROOT,
|
||||
_documents,
|
||||
)
|
||||
|
||||
|
||||
def test_stale_parent_linked_api_workers_close_on_startup(tmp_path: Path):
|
||||
"""A previous gateway lifetime cannot leave phantom active workers."""
|
||||
module_path = HERMES / "scripts" / "migrate_api_session_lineage.py"
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"close_stale_api_workers", module_path
|
||||
)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
database = tmp_path / "state.db"
|
||||
with sqlite3.connect(database) as connection:
|
||||
connection.execute(
|
||||
"CREATE TABLE sessions (id TEXT PRIMARY KEY, source TEXT, "
|
||||
"parent_session_id TEXT, title TEXT, started_at REAL, ended_at REAL, "
|
||||
"end_reason TEXT, archived INTEGER DEFAULT 0)"
|
||||
)
|
||||
connection.executemany(
|
||||
"INSERT INTO sessions "
|
||||
"(id, source, parent_session_id, started_at, ended_at, end_reason) "
|
||||
"VALUES (?, ?, ?, 1, ?, ?)",
|
||||
(
|
||||
("stale", "api_server", "parent", None, None),
|
||||
("root", "api_server", None, None, None),
|
||||
("finished", "api_server", "parent", 2.0, "api_run_completed"),
|
||||
("interactive", "tui", "parent", None, None),
|
||||
),
|
||||
)
|
||||
|
||||
assert module.migrate(database) == 1
|
||||
assert module.migrate(database) == 0
|
||||
with sqlite3.connect(database) as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT id, ended_at, end_reason FROM sessions ORDER BY id"
|
||||
).fetchall()
|
||||
by_id = {row[0]: row[1:] for row in rows}
|
||||
assert by_id["stale"][0] is not None
|
||||
assert by_id["stale"][1] == "api_run_recovered_stale"
|
||||
assert by_id["root"] == (None, None)
|
||||
assert by_id["finished"] == (2.0, "api_run_completed")
|
||||
assert by_id["interactive"] == (None, None)
|
||||
|
||||
|
||||
def test_switchyard_brokers_and_native_claude_lane_use_the_right_images():
|
||||
"""Thin brokers stay small while native Claude runs beside owner auth."""
|
||||
dockerfile = (
|
||||
ROOT / "dockerfiles" / "Dockerfile.hermes-switchyard-brokers"
|
||||
).read_text()
|
||||
assert "httpx==0.28.1" in dockerfile
|
||||
assert "worker_route_broker.py" in dockerfile
|
||||
assert "routing_catalog.py" in dockerfile
|
||||
|
||||
deployment = _documents(HERMES / "switchyard-deployment.yaml")[0]
|
||||
containers = {
|
||||
container["name"]: container
|
||||
for container in deployment["spec"]["template"]["spec"]["containers"]
|
||||
}
|
||||
expected = (
|
||||
"registry.bstein.dev/bstein/hermes-switchyard-brokers@"
|
||||
"sha256:ee7e95e060ef8083da505162d7e9030daba15fdd828cc047bbcbe6aa409d2083"
|
||||
)
|
||||
assert containers["worker-route-broker"]["image"] == expected
|
||||
assert containers["classifier-broker"]["image"] == expected
|
||||
assert "claude-oauth-broker" not in containers
|
||||
|
||||
agent = _documents(HERMES / "agent-deployment.yaml")[0]
|
||||
agent_containers = {
|
||||
container["name"]: container
|
||||
for container in agent["spec"]["template"]["spec"]["containers"]
|
||||
}
|
||||
for container_name in ("hermes", "terminal"):
|
||||
container = agent_containers[container_name]
|
||||
environment = {item["name"]: item["value"] for item in container["env"]}
|
||||
mounts = {item["name"]: item for item in container["volumeMounts"]}
|
||||
assert (
|
||||
environment["HERMES_ROUTING_CATALOG_PATH"]
|
||||
== "/routing-catalog/catalog.json"
|
||||
)
|
||||
assert (
|
||||
environment["HERMES_CODEX_HEALTH_PATH"]
|
||||
== "/opt/data/provider-health/codex.json"
|
||||
)
|
||||
assert (
|
||||
environment["HERMES_CLAUDE_HEALTH_PATH"]
|
||||
== "/opt/data/provider-health/claude.json"
|
||||
)
|
||||
assert mounts["routing-catalog"]["mountPath"] == "/routing-catalog"
|
||||
assert mounts["routing-catalog"]["readOnly"] is True
|
||||
codex = agent_containers["codex-broker"]
|
||||
codex_environment = {item["name"]: item["value"] for item in codex["env"]}
|
||||
assert (
|
||||
codex_environment["HERMES_CODEX_HEALTH_PATH"]
|
||||
== "/opt/data/provider-health/codex.json"
|
||||
)
|
||||
claude = agent_containers["claude-broker"]
|
||||
assert claude["image"].startswith("registry.bstein.dev/bstein/hermes-agent@")
|
||||
assert "unset ANTHROPIC_API_KEY CLAUDE_API_KEY" in claude["args"][0]
|
||||
assert any(
|
||||
mount["name"] == "home" and mount["mountPath"] == "/opt/data"
|
||||
for mount in claude["volumeMounts"]
|
||||
)
|
||||
|
||||
|
||||
def test_classifier_broker_bounds_history_without_losing_routing_intent(monkeypatch):
|
||||
"""AUTO classification must fit the Jetson context without losing intent."""
|
||||
broker_path = HERMES / "scripts" / "classifier_broker.py"
|
||||
monkeypatch.setitem(sys.modules, "httpx", SimpleNamespace())
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"hermes_classifier_broker", broker_path
|
||||
)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
payload = {
|
||||
"model": "qwen2.5:14b-instruct-q4_0",
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "routing contract\n" + ("candidate policy " * 1000),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Build and verify the Cassandra release safely.",
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_large",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "large_tool",
|
||||
"arguments": '{"command":"' + ("x" * 5000) + '"}',
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": "unbounded test output " * 10000,
|
||||
"tool_call_id": "call_large",
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "data:image/png;base64,private"},
|
||||
},
|
||||
{"type": "text", "text": "Turn this cat into a cute clown."},
|
||||
],
|
||||
},
|
||||
],
|
||||
"tools": [{"type": "function", "function": {"name": "large_tool"}}],
|
||||
"tool_choice": "auto",
|
||||
"parallel_tool_calls": True,
|
||||
"response_format": {"type": "json_object"},
|
||||
}
|
||||
|
||||
compacted = module.compact_payload(payload)
|
||||
encoded = json.dumps(compacted)
|
||||
|
||||
assert compacted["model"] == payload["model"]
|
||||
assert compacted["response_format"] == payload["response_format"]
|
||||
assert "tools" not in compacted
|
||||
assert "tool_choice" not in compacted
|
||||
assert "parallel_tool_calls" not in compacted
|
||||
assert all(message.get("role") != "tool" for message in compacted["messages"])
|
||||
assert all("tool_call_id" not in message for message in compacted["messages"])
|
||||
assert all("tool_calls" not in message for message in compacted["messages"])
|
||||
assert "[tool evidence]" in encoded
|
||||
assert "[assistant requested an external tool]" in encoded
|
||||
assert "Build and verify the Cassandra release safely." in encoded
|
||||
assert "Turn this cat into a cute clown." in encoded
|
||||
assert "image attachment available to the selected worker" in encoded
|
||||
assert "data:image/png;base64" not in encoded
|
||||
assert "unbounded test output " * 100 not in encoded
|
||||
assert len(encoded) < 18_000
|
||||
|
||||
|
||||
def test_switchyard_classifier_is_bounded_and_fails_open_once():
|
||||
"""A sick local judge must not hold chat through repeated long retries."""
|
||||
config = tomllib.loads(
|
||||
_documents(HERMES / "switchyard-configmap.yaml")[0]["data"]["routes.toml"]
|
||||
)
|
||||
classifier = config["llm_clients"]["classifier"]
|
||||
assert classifier["base_url"] == "http://127.0.0.1:9008/v1"
|
||||
assert classifier["max_retries"] == 0
|
||||
for route in ("auto_fast", "auto_balanced"):
|
||||
assert config["routes"][route]["recent_turn_window"] == 4
|
||||
for route in ("auto_deep", "auto_maximum", "worker_auto_maximum"):
|
||||
assert config["routes"][route]["recent_turn_window"] == 6
|
||||
|
||||
deployment = _documents(HERMES / "switchyard-deployment.yaml")[0]
|
||||
containers = {
|
||||
item["name"]: item
|
||||
for item in deployment["spec"]["template"]["spec"]["containers"]
|
||||
}
|
||||
classifier_container = containers["classifier-broker"]
|
||||
env = {item["name"]: item["value"] for item in classifier_container["env"]}
|
||||
assert env["HERMES_CLASSIFIER_BROKER_READ_TIMEOUT"] == "60"
|
||||
assert classifier_container["readinessProbe"]["httpGet"]["port"] == "classifier"
|
||||
|
||||
|
||||
def test_image_broker_returns_bytes_and_removes_owner_cache(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
"""The broker must not retain a family user's generated image."""
|
||||
broker_path = HERMES / "scripts" / "image_broker.py"
|
||||
spec = importlib.util.spec_from_file_location("hermes_image_broker", broker_path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
generated = tmp_path / "generated.png"
|
||||
generated.write_bytes(b"\x89PNG\r\n\x1a\nprivate-image")
|
||||
|
||||
class Provider:
|
||||
def generate(self, prompt, aspect, **kwargs):
|
||||
assert prompt == "paint a blue sphere"
|
||||
assert aspect == "square"
|
||||
return {
|
||||
"success": True,
|
||||
"image": str(generated),
|
||||
"model": "gpt-image-2-high",
|
||||
"quality": "high",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(module, "_PROVIDER", Provider())
|
||||
result = module._generate(
|
||||
{
|
||||
"prompt": "paint a blue sphere",
|
||||
"aspect_ratio": "square",
|
||||
"model": "gpt-image-2-high",
|
||||
}
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["image_b64"]
|
||||
assert "image" not in result
|
||||
assert not generated.exists()
|
||||
|
||||
|
||||
def test_image_broker_auto_falls_back_to_local_and_honors_explicit_routes(
|
||||
monkeypatch,
|
||||
):
|
||||
"""AUTO is hosted-first while explicit local never calls the hosted lane."""
|
||||
broker_path = HERMES / "scripts" / "image_broker.py"
|
||||
spec = importlib.util.spec_from_file_location("hermes_image_router", broker_path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
calls = []
|
||||
|
||||
def hosted(payload, model, prompt, aspect):
|
||||
calls.append(("hosted", model, prompt, aspect))
|
||||
return {"success": False, "error": "hosted refusal"}
|
||||
|
||||
def local(payload, timeout=1800.0):
|
||||
calls.append(("local", payload["model"], timeout))
|
||||
return {
|
||||
"success": True,
|
||||
"image_b64": "aW1hZ2U=",
|
||||
"model": "flux-2-klein-4b-local",
|
||||
"route": "local",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(module, "_generate_hosted", hosted)
|
||||
monkeypatch.setattr(module, "_local_request", local)
|
||||
auto = module._generate(
|
||||
{
|
||||
"prompt": "colorize this family photograph",
|
||||
"aspect_ratio": "portrait",
|
||||
"model": "atlas-image-auto-high",
|
||||
}
|
||||
)
|
||||
assert auto["success"] is True
|
||||
assert auto["route"] == "local"
|
||||
assert auto["hosted_fallback_reason"] == "hosted refusal"
|
||||
assert [call[0] for call in calls] == ["hosted", "local"]
|
||||
|
||||
calls.clear()
|
||||
explicit = module._generate(
|
||||
{
|
||||
"prompt": "make a local landscape",
|
||||
"aspect_ratio": "landscape",
|
||||
"model": "flux-2-klein-4b-local",
|
||||
}
|
||||
)
|
||||
assert explicit["route"] == "local"
|
||||
assert [call[0] for call in calls] == ["local"]
|
||||
|
||||
|
||||
def test_image_broker_policy_is_narrow_and_operator_extensible(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
"""Family-photo restoration stays allowed while the hard boundary remains."""
|
||||
broker_path = HERMES / "scripts" / "image_broker.py"
|
||||
spec = importlib.util.spec_from_file_location("hermes_image_policy", broker_path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
policy = tmp_path / "policy.json"
|
||||
policy.write_text('{"additional_blocked_phrases":["site-specific block"]}')
|
||||
monkeypatch.setattr(module, "POLICY_PATH", policy)
|
||||
|
||||
assert (
|
||||
module._policy_error(
|
||||
"Colorize my baby photograph with a lighter natural skin tone"
|
||||
)
|
||||
is None
|
||||
)
|
||||
assert "minors" in module._policy_error("Create a sexual image of a child")
|
||||
assert module._policy_error("A site-specific block request") == (
|
||||
"request is blocked by the operator image policy"
|
||||
)
|
||||
|
||||
|
||||
def test_local_flux_runtime_and_gpu_handoff_are_flux_managed():
|
||||
"""FLUX and Wolf share titan-24 while text stays on titan-20."""
|
||||
deployment = _documents(HERMES / "local-image-deployment.yaml")[0]
|
||||
assert deployment["metadata"]["name"] == "hermes-local-image"
|
||||
pod = deployment["spec"]["template"]["spec"]
|
||||
assert pod["serviceAccountName"] == "hermes-gpu-runtime"
|
||||
local = next(item for item in pod["containers"] if item["name"] == "local-image")
|
||||
assert len(pod["containers"]) == 1
|
||||
assert local["resources"]["requests"]["nvidia.com/gpu.shared"] == 1
|
||||
assert local["ports"] == [{"name": "local-image", "containerPort": 9004}]
|
||||
assert any(mount["mountPath"] == "/models" for mount in local["volumeMounts"])
|
||||
model_env = {item["name"]: item["value"] for item in local["env"]}
|
||||
assert model_env["HERMES_LOCAL_IMAGE_LISTEN_PORT"] == "9004"
|
||||
assert model_env["HERMES_LOCAL_IMAGE_REVISION"] == (
|
||||
"e7b7dc27f91deacad38e78976d1f2b499d76a294"
|
||||
)
|
||||
assert model_env["HERMES_LOCAL_IMAGE_GPU_ACTIVITY_NODE"] == "titan-24"
|
||||
assert model_env["HERMES_LOCAL_IMAGE_GPU_ACTIVE_SM_PERCENT"] == "80"
|
||||
assert model_env["HERMES_LOCAL_IMAGE_GPU_MAX_EXTERNAL_MEMORY_BYTES"] == (
|
||||
"3221225472"
|
||||
)
|
||||
assert model_env["HERMES_LOCAL_IMAGE_OFFLOAD_MODE"] == "sequential"
|
||||
assert (
|
||||
"nvidia-process-exporter-local.monitoring.svc.cluster.local"
|
||||
in model_env["HERMES_LOCAL_IMAGE_GPU_ACTIVITY_URL"]
|
||||
)
|
||||
models_volume = next(item for item in pod["volumes"] if item["name"] == "models")
|
||||
assert models_volume["persistentVolumeClaim"]["claimName"] == (
|
||||
"hermes-image-models"
|
||||
)
|
||||
|
||||
services = _documents(HERMES / "service.yaml")
|
||||
image_service = next(
|
||||
item for item in services if item["metadata"]["name"] == "hermes-local-image"
|
||||
)
|
||||
assert image_service["spec"]["selector"] == {"app": "hermes-local-image"}
|
||||
|
||||
handoff_services = _documents(HERMES / "model-gate-deployment.yaml")
|
||||
handoff = next(
|
||||
item
|
||||
for item in handoff_services
|
||||
if item["kind"] == "Service"
|
||||
and item["metadata"]["name"] == "hermes-gpu-handoff"
|
||||
)
|
||||
assert handoff["spec"]["ports"][0]["targetPort"] == "handoff"
|
||||
|
||||
ariadne = _documents(
|
||||
Path(__file__).parents[2] / "services/maintenance/apps/ariadne-deployment.yaml"
|
||||
)[0]
|
||||
env = {
|
||||
item["name"]: item["value"]
|
||||
for item in ariadne["spec"]["template"]["spec"]["containers"][0]["env"]
|
||||
if "value" in item
|
||||
}
|
||||
assert env["GAME_MODE_OLLAMA_URL"] == (
|
||||
"http://hermes-gpu-handoff.hermes.svc.cluster.local:11434"
|
||||
)
|
||||
assert env["GAME_MODE_OLLAMA_MODEL"] == "flux-2-klein-4b-local"
|
||||
|
||||
for config_name in (
|
||||
"configmap.yaml",
|
||||
"agent-configmap.yaml",
|
||||
"chat-configmap.yaml",
|
||||
):
|
||||
config = _documents(HERMES / config_name)[0]["data"]["config.yaml"]
|
||||
assert "gpt-oss:20b" not in config
|
||||
assert "atlas-switchyard" in config
|
||||
switchyard = _documents(HERMES / "switchyard-configmap.yaml")[0]["data"][
|
||||
"routes.toml"
|
||||
]
|
||||
assert 'id = "qwen2.5:14b-instruct-q4_0"' in switchyard
|
||||
assert "qwen2.5:3b-instruct-q4_0" not in switchyard
|
||||
assert "route/local/qwen2.5-14b/medium" in switchyard
|
||||
assert "Anthropic and Claude name the same provider" in switchyard
|
||||
assert "OpenAI and Codex name the same provider" in switchyard
|
||||
assert "Choose across every configured Codex and Claude family" in switchyard
|
||||
assert "Claude Fable" in switchyard
|
||||
assert switchyard.count('Treat "think hard"') == 4
|
||||
assert switchyard.count("Never choose below the") >= 5
|
||||
switchyard_config = tomllib.loads(switchyard)
|
||||
routes = switchyard_config["routes"]
|
||||
configured_targets = switchyard_config["targets"]
|
||||
for route_name in ("auto_fast", "auto_balanced", "auto_deep", "auto_maximum"):
|
||||
leading_targets = set(routes[route_name]["targets"][:2])
|
||||
assert leading_targets == {"codex_sol_xhigh", "claude_opus_xhigh"}
|
||||
assert "max_output_tokens" not in routes[route_name]
|
||||
for route_name in ("auto_fast", "auto_balanced", "auto_deep", "auto_maximum"):
|
||||
targets = routes[route_name]["targets"]
|
||||
selector_targets = routes[route_name]["response_schema"]
|
||||
assert not any(target.startswith("local_") for target in targets)
|
||||
assert any("fable" in target for target in targets)
|
||||
assert "local_qwen" not in selector_targets
|
||||
assert "not eligible for foreground" in routes[route_name]["prompt"]
|
||||
for route_name in ("auto_deep", "auto_maximum"):
|
||||
targets = routes[route_name]["targets"]
|
||||
selector_targets = routes[route_name]["response_schema"]
|
||||
assert not any(target.endswith("_low") for target in targets)
|
||||
assert "_low" not in selector_targets
|
||||
maximum_targets = routes["auto_maximum"]["targets"]
|
||||
maximum_selector_targets = routes["auto_maximum"]["response_schema"]
|
||||
assert not any(target.endswith("_medium") for target in maximum_targets)
|
||||
assert "_medium" not in maximum_selector_targets
|
||||
assert "absolute high effort floor" in routes["auto_maximum"]["prompt"]
|
||||
assert "quality mark was missed" in routes["auto_balanced"]["prompt"]
|
||||
assert "raises the next boundary to xhigh" in routes["auto_maximum"]["prompt"]
|
||||
assert (
|
||||
"Repeated quality misses require xhigh"
|
||||
in routes["worker_auto_maximum"]["prompt"]
|
||||
)
|
||||
assert any(
|
||||
target.startswith("local_") for target in routes["manual_local_qwen"]["targets"]
|
||||
)
|
||||
for route_name in (
|
||||
"manual_codex_luna",
|
||||
"manual_codex_terra",
|
||||
"manual_codex_sol",
|
||||
"manual_claude_haiku",
|
||||
"manual_claude_fable",
|
||||
"manual_claude_sonnet",
|
||||
"manual_claude_opus",
|
||||
):
|
||||
assert not any(
|
||||
target.startswith("local_") for target in routes[route_name]["targets"]
|
||||
)
|
||||
for provider, families in {
|
||||
"codex": ("luna", "terra", "sol"),
|
||||
"claude": ("haiku", "fable", "sonnet", "opus"),
|
||||
}.items():
|
||||
for family in families:
|
||||
for effort in ("low", "medium", "high", "xhigh"):
|
||||
route = routes[f"manual_{provider}_{family}_{effort}"]
|
||||
assert route["id"] == f"atlas/manual/{provider}/{family}/{effort}"
|
||||
assert route["targets"][0] == f"{provider}_{family}_{effort}"
|
||||
worker_target = f"worker_{provider}_{family}_{effort}"
|
||||
assert worker_target in routes["worker_auto_maximum"]["targets"]
|
||||
assert configured_targets[worker_target]["id"] == (
|
||||
f"worker/{provider}/{family}/{effort}"
|
||||
)
|
||||
for route_name in ("auto_fast", "auto_balanced"):
|
||||
prompt = routes[route_name]["prompt"]
|
||||
assert "image tool—not the conversational model" in prompt
|
||||
assert "Do not select a" in prompt
|
||||
assert "local Qwen or Claude target" in prompt
|
||||
assert "max_output_tokens" not in routes["worker_auto_maximum"]
|
||||
model_gate = _documents(HERMES / "model-gate-configmap.yaml")[0]["data"][
|
||||
"model_gate.py"
|
||||
]
|
||||
assert "qwen2.5:14b-instruct-q4_0" in model_gate
|
||||
@ -1,24 +0,0 @@
|
||||
"""Hermes chat sandbox contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from test_hermes_chat_support import (
|
||||
Path,
|
||||
ROOT,
|
||||
importlib,
|
||||
)
|
||||
|
||||
|
||||
def test_sandbox_executes_python_with_bounded_output(tmp_path: Path, monkeypatch):
|
||||
source = ROOT / "dockerfiles" / "hermes-chat-sandbox-server.py"
|
||||
spec = importlib.util.spec_from_file_location("hermes_chat_sandbox_server", source)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
monkeypatch.setattr(module, "WORKSPACE", tmp_path)
|
||||
|
||||
result = module._execute("import math\nprint(math.comb(10, 3))")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["stdout"] == "120\n"
|
||||
assert result["stderr"] == ""
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user