hermes: isolate Atlas SCM write authority

This commit is contained in:
jenkins 2026-08-16 20:41:14 -03:00
parent b0812bbe9f
commit 86e9fbbfec
44 changed files with 3766 additions and 362 deletions

View File

@ -0,0 +1,18 @@
# 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
dependsOn:
- name: hermes

View File

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

View File

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

View File

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

View File

@ -60,3 +60,4 @@ spec:
- name: keycloak
- name: longhorn
- name: vault
- name: hermes-scm-broker

View File

@ -27,7 +27,11 @@ 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-scm-broker/kustomization.yaml
- hermes-chat/kustomization.yaml
- hermes-triage-demo/kustomization.yaml
- game-stream/kustomization.yaml

View File

@ -2,7 +2,7 @@
apiVersion: batch/v1
kind: Job
metadata:
name: gitea-atlas-identity-bootstrap-3
name: gitea-atlas-identity-bootstrap-4
namespace: gitea
labels:
app.kubernetes.io/name: gitea

View File

@ -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,63 @@ transfer_managed_repositories() {
trap - EXIT HUP INT TERM
}
ensure_default_branch_protection() {
repository=$1
repo_file=$(mktemp)
protections_file=$(mktemp)
protection_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"
tr -d '[:space:]' <"${protections_file}" | sed 's/},{/}\
{/g' | grep -F "\"rule_name\":\"${default_branch}\"" >"${protection_file}" || true
matching_rules=$(grep -c . "${protection_file}" || true)
[ "${matching_rules}" -le 1 ] || die "${repository} has duplicate ${default_branch} protection rules"
if [ "${matching_rules}" -eq 0 ]; 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"
tr -d '[:space:]' <"${protections_file}" | sed 's/},{/}\
{/g' | grep -F "\"rule_name\":\"${default_branch}\"" >"${protection_file}" || true
fi
compact=$(tr -d '[:space:]' <"${protection_file}")
for required in \
"\"rule_name\":\"${default_branch}\"" \
'"enable_push":true' \
'"enable_push_whitelist":true' \
"\"push_whitelist_usernames\":[\"${protected_reviewer}\"]" \
'"push_whitelist_deploy_keys":false' \
'"enable_force_push":false' \
'"enable_merge_whitelist":true' \
"\"merge_whitelist_usernames\":[\"${protected_reviewer}\"]" \
'"enable_approvals_whitelist":true' \
"\"approvals_whitelist_username\":[\"${protected_reviewer}\"]" \
'"required_approvals":1' \
'"block_on_rejected_reviews":true' \
'"block_on_outdated_branch":true' \
'"dismiss_stale_approvals":true' \
'"block_admin_merge_override":true'
do
printf '%s' "${compact}" | grep -Fq "${required}" || \
die "${repository} ${default_branch} protection differs from the human-review policy"
done
rm -f "${repo_file}" "${protections_file}" "${protection_file}" "${response_file}"
}
wait_for_gitea
ensure_user "${reconciler_user}" "${reconciler_email}" true
ensure_user "${hermes_user}" "${hermes_email}" false
@ -318,6 +376,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}\"}}"

View File

@ -0,0 +1,6 @@
# services/hermes-observer-rbac/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- rbac.yaml
- rolebindings.yaml

View 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

View File

@ -0,0 +1,204 @@
# services/hermes-observer-rbac/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}

View File

@ -0,0 +1,97 @@
# 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
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

View 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

View 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}

View 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

View File

@ -0,0 +1,5 @@
# services/hermes-scm-namespace/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml

View 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

View File

@ -189,9 +189,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
@ -295,21 +295,21 @@ 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
@ -318,11 +318,13 @@ data:
`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
authenticated Git for clone, fetch, and non-force push. For bounded
brokered Git for clone, fetch, and creation of a new namespaced feature
branch. Existing-ref updates, protected refs, deletion, and force-push are
rejected. For bounded
repository/pull-request evidence or to create a review-ready draft PR, load
`$manage-atlas-pull-requests` and use `/opt/coordinator/gitea_api.py`. The
client injects the runtime Vault token without exposing it to the command
line, environment, output, or transcript. It permits only Atlas-scoped
`$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
@ -330,14 +332,10 @@ data:
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 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. If Brad separately reports that he merged the PR, observe
`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.
@ -349,19 +347,21 @@ data:
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
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 routine Flux reconciliation; perform either only under
a separate explicit operator request. Direct `kubectl` mutations are for
explicit incident recovery or ephemeral verification, not ordinary
delivery.
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
@ -398,7 +398,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.

View File

@ -46,16 +46,6 @@ 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 }}
{{- 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: |
{{- with secret "kv/data/atlas/hermes/developer-ssh" -}}
@ -185,9 +175,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 +652,7 @@ spec:
- {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:
@ -827,6 +817,7 @@ spec:
- {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}
@ -896,6 +887,7 @@ spec:
- {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}
resources:
requests: {cpu: 100m, memory: 256Mi}
@ -1196,6 +1188,10 @@ spec:
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

View File

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

View File

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

View File

@ -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
@ -69,8 +69,6 @@ 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
@ -104,6 +102,12 @@ 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
options:
disableNameSuffixHash: true
- name: hermes-auto-router-plugin
namespace: hermes
files:

View File

@ -119,11 +119,65 @@ spec:
app: server
ports:
- {protocol: TCP, port: 9010}
# 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

View File

@ -45,32 +45,15 @@ spec:
- operator: Exists
containers:
- name: key-reconciler
image: busybox:1.37
image: registry.bstein.dev/bstein/hermes-agent
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
/opt/hermes/.venv/bin/python \
/opt/node-hardener/node_account_hardening.py \
--public-key-file /vault/secrets/node-ssh-public-key
sleep 300
done
securityContext:
@ -83,8 +66,18 @@ spec:
volumeMounts:
- name: host-home
mountPath: /host-home
- name: host-passwd
mountPath: /host-etc/passwd
- name: host-etc
mountPath: /host-etc
- 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 +87,39 @@ 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-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

View File

@ -0,0 +1,12 @@
# services/hermes/scm-common/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
configMapGenerator:
- name: hermes-scm-boundary
files:
- gitea_api.py=scripts/gitea_api.py
- gitea_api_policy.py=scripts/gitea_api_policy.py
- scm_broker.py=scripts/scm_broker.py
- scm_broker_client.py=scripts/scm_broker_client.py
options:
disableNameSuffixHash: true

View File

@ -4,10 +4,10 @@
from __future__ import annotations
import argparse
import base64
import json
import os
import re
import subprocess
import sys
import urllib.error
import urllib.parse
@ -15,28 +15,29 @@ import urllib.request
from collections.abc import Callable
from pathlib import Path
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("/runtime-access/gitea-token")
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")
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
DRAFT_TITLE_PREFIX = "WIP: "
GIT_BIN = "/usr/bin/git"
SENSITIVE_BODY_PATTERNS = (
re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----"),
re.compile(r"(?i)\b(?:password|passwd|secret|token|api[_-]?key)\s*[:=]"),
re.compile(r"(?i)\bauthorization\s*:\s*(?:bearer|token|basic)\s+\S+"),
re.compile(r"\b(?:gh[pousr]_[A-Za-z0-9]{20,}|sk-[A-Za-z0-9]{20,})\b"),
re.compile(r"\b(?:AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{35})\b"),
re.compile(r"\beyJ[A-Za-z0-9_-]{12,}\.[A-Za-z0-9_-]{12,}\.[A-Za-z0-9_-]{8,}\b"),
)
class PolicyError(ValueError):
"""Raised when a requested Forgejo operation exceeds the safe boundary."""
MAX_API_TARGET_LENGTH = 768
MAX_API_PATH_LENGTH = 512
RAW_API_TARGET_RE = re.compile(r"[A-Za-z0-9/_.?&=-]+\Z")
class RejectRedirectHandler(urllib.request.HTTPRedirectHandler):
@ -64,114 +65,48 @@ def read_token(path: Path = DEFAULT_TOKEN_FILE) -> str:
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).rstrip("/")
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 _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 _validate_ref(value: object, name: str) -> str:
"""Validate the complete Git ref grammar using Git itself."""
if not isinstance(value, str) or not value or value.startswith("-"):
raise PolicyError(f"{name} must be a same-repository branch name")
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_text(value: object, name: str, maximum: 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")
if len(value) > maximum or "\x00" in value:
raise PolicyError(f"{name} exceeds the safe request limit")
return value
def _validate_body(value: object, *, forbidden: tuple[str, ...] = ()) -> str:
body = _validate_text(value, "body", 16384, required=False)
if any(secret and secret in body for secret in forbidden):
raise PolicyError("pull-request body contains runtime credential material")
if any(pattern.search(body) for pattern in SENSITIVE_BODY_PATTERNS):
raise PolicyError("pull-request body resembles credential material")
return body
def _draft_title(value: object) -> str:
"""Return a bounded title using Gitea's configured default draft prefix."""
title = _validate_text(value, "title", 251, required=True).strip()
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 _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 "%" in target.path or "//" in target.path or "/../" in f"{target.path}/":
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 _validate_query(target: urllib.parse.SplitResult, allowed: set[str]) -> None:
try:
pairs = urllib.parse.parse_qsl(
target.query, keep_blank_values=True, strict_parsing=True
)
except ValueError as exc:
raise PolicyError("invalid API query") from exc
if len({key for key, _ in pairs}) != len(pairs):
raise PolicyError("duplicate API query parameters are not allowed")
if any(key not in allowed for key, _ in pairs):
raise PolicyError("API query parameter is outside the read allowlist")
values = dict(pairs)
for name in ("page", "limit"):
if name not in values:
continue
if not values[name].isdigit() or int(values[name]) < 1:
raise PolicyError(f"{name} must be a positive integer")
if "limit" in values and int(values["limit"]) > 50:
raise PolicyError("read limit cannot exceed 50")
if "state" in values and values["state"] not in {"open", "closed", "all"}:
raise PolicyError("pull-request state is invalid")
def _authorize_read(target: urllib.parse.SplitResult) -> str:
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:
@ -180,10 +115,18 @@ def _authorize_read(target: urllib.parse.SplitResult) -> str:
if suffix == "pulls":
_validate_query(target, {"page", "limit", "state"})
return "pull-list"
if re.fullmatch(r"pulls/[1-9][0-9]*", suffix):
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"
if re.fullmatch(r"pulls/[1-9][0-9]*/(?:commits|files)", suffix):
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":
@ -211,7 +154,7 @@ def _authorize_read(target: urllib.parse.SplitResult) -> str:
def api_url(base_url: str, path: str) -> str:
"""Return a canonical same-origin URL without forwarding credentials."""
if base_url.rstrip("/") != CANONICAL_BASE_URL:
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(
@ -219,14 +162,20 @@ def api_url(base_url: str, path: str) -> str:
)
def authorize_request(method: str, path: str, data: object | None) -> str:
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)
return _authorize_read(target, forbidden=forbidden)
if target.query:
raise PolicyError("mutating operations cannot include query parameters")
if normalized_method != "POST":
@ -234,14 +183,17 @@ def authorize_request(method: str, path: str, data: object | None) -> str:
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")
_validate_ref(data["base"], "base")
_validate_ref(data["head"], "head")
if data["title"] != _draft_title(data["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"])
_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"
@ -254,7 +206,19 @@ def build_request(
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)
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")
@ -281,6 +245,19 @@ def redact_bytes(value: bytes, token: str) -> bytes:
)
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,
@ -288,14 +265,23 @@ def _request(
*,
token: str,
opener: Callable[..., object] = _safe_urlopen,
authorized: bool = False,
expected_status: int,
) -> bytes:
request = build_request(
method, path, base_url=configured_base_url(), token=token, data=data
)
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)
@ -303,7 +289,9 @@ 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)
return _request(
"GET", path, None, token=token, opener=opener, expected_status=200
)
def _nested(document: dict[str, object], *keys: str) -> object:
@ -332,9 +320,7 @@ def _require_create_response(
raise PolicyError("Forgejo returned invalid pull-request metadata") from exc
if not isinstance(document, dict):
raise PolicyError("Forgejo returned invalid pull-request metadata")
number = document.get("number")
if not isinstance(number, int) or isinstance(number, bool) or number < 1:
raise PolicyError("Forgejo omitted a valid pull-request number")
number = _validate_pr_number(document.get("number"))
full_name = f"{ALLOWED_OWNER}/{repo}"
expected_html = f"{CANONICAL_BASE_URL}/{full_name}/pulls/{number}"
checks = (
@ -368,12 +354,15 @@ def create_draft(
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)
base = _validate_ref(base, "base")
head = _validate_ref(head, "head")
head_sha = _validate_sha(head_sha)
title = _draft_title(title)
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",
@ -381,6 +370,8 @@ def create_draft(
data,
token=token,
opener=opener,
authorized=True,
expected_status=201,
)
_require_create_response(
result,
@ -417,16 +408,24 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
return parser.parse_args(argv)
def _operation(args: argparse.Namespace) -> tuple[str, str, object | None]:
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": _validate_ref(args.base, "base"),
"body": _validate_body(args.body),
"head": _validate_ref(args.head, "head"),
"title": _draft_title(args.title),
"base": base,
"body": body,
"head": head,
"title": title,
}
return "POST", f"/api/v1/repos/{ALLOWED_OWNER}/{repo}/pulls", data
@ -440,12 +439,11 @@ def _write_body(body: bytes) -> None:
def main(argv: list[str] | None = None) -> int:
"""Validate, optionally describe, and execute one safe Forgejo operation."""
token = ""
try:
args = parse_args(argv)
method, path, data = _operation(args)
operation = authorize_request(method, path, data)
if args.dry_run:
method, path, data = _operation(args, validate_refs=False)
operation = authorize_request(method, path, data)
print(
json.dumps(
{
@ -462,29 +460,27 @@ def main(argv: list[str] | None = None) -> int:
)
)
return 0
token = read_token()
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 = read(path, token=token)
body = broker_read(path)
else:
assert isinstance(data, dict)
body = create_draft(
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"]),
token=token,
)
_write_body(body)
return 0
except urllib.error.HTTPError as exc:
body = redact_bytes(exc.read(MAX_ERROR_BYTES), token) if token else b""
print(f"Forgejo request failed with 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")
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(

View 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")

View File

@ -0,0 +1,317 @@
#!/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 urllib.error
import urllib.parse
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from gitea_api import (
CANONICAL_BASE_URL,
PolicyError,
create_draft,
read,
read_token,
)
from gitea_api_policy import _reject_forbidden, _validate_ref, _validate_repo
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_PUSH_COMMANDS = 16
ZERO_SHA = b"0" * 40
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"
)
FEATURE_REF_RE = re.compile(
r"refs/heads/(?:(?:feature|fix|hermes|handoff)/[A-Za-z0-9][A-Za-z0-9._/-]{0,190})\Z"
)
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")
UPSTREAM_OPENER = urllib.request.build_opener(RejectRedirect())
def _status(response: object) -> int | None:
value = getattr(response, "status", None)
if value is None and hasattr(response, "getcode"):
value = response.getcode() # type: ignore[attr-defined]
return value
def _read_bounded(stream, maximum: int, length: int | None = None) -> bytes:
if length is not None and not 0 <= length <= maximum:
raise PolicyError("SCM request exceeds the safe size limit")
value = stream.read(maximum + 1 if length is None else length)
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 _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)
value = json.loads(_read_bounded(handler.rfile, MAX_CONTROL_BODY, length))
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("utf-8"))
return token.encode("utf-8"), basic, b"Basic " + basic
def _reject_credential_bytes(value: bytes, token: str, context: str) -> None:
if any(form in value for form in _credential_forms(token)):
raise PolicyError(f"{context} contains runtime credential material")
def _validate_receive_pack(body: bytes, token: str) -> None:
"""Permit only creation of new, namespaced feature branches."""
_reject_credential_bytes(body, token, "Git request")
position = 0
commands = 0
while position + 4 <= len(body):
header = body[position : position + 4]
if not re.fullmatch(rb"[0-9a-f]{4}", header):
raise PolicyError("Git receive-pack command framing is invalid")
size = int(header, 16)
if size == 0:
if commands == 0:
raise PolicyError("Git receive-pack request has no ref command")
return
if size < 4 or position + size > len(body):
raise PolicyError("Git receive-pack command framing is invalid")
command = body[position + 4 : position + size].rstrip(b"\n")
command = command.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,))
commands += 1
if commands > MAX_PUSH_COMMANDS:
raise PolicyError("Git push contains too many ref commands")
position += size
raise PolicyError("Git receive-pack request omitted its command terminator")
def _upstream_git_request(
target: str,
*,
method: str,
body: bytes | None,
content_type: str | None,
expected_type: str,
token: str,
opener=UPSTREAM_OPENER.open,
) -> bytes:
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
request = urllib.request.Request(
CANONICAL_BASE_URL + target,
data=body,
method=method,
headers=headers,
)
with opener(request, timeout=120) 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 = _read_bounded(response, MAX_GIT_RESPONSE)
_reject_credential_bytes(result, token, "Git upstream response")
return result
class BrokerHandler(BaseHTTPRequestHandler):
"""Expose only bounded metadata, draft creation, and smart-HTTP Git."""
server_version = "HermesSCMBroker/1"
sys_version = ""
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:
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"
body = _upstream_git_request(
f"/atlas/{repo}.git/info/refs?service={service}",
method="GET",
body=None,
content_type=None,
expected_type=expected,
token=token,
)
self.send_response(200)
self.send_header("Content-Type", expected)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
except (OSError, PolicyError, urllib.error.URLError, ValueError):
self._reject()
def do_POST(self) -> None:
try:
if self.path in {"/v1/metadata", "/v1/drafts"}:
self._control()
else:
self._git_rpc()
except (OSError, PolicyError, 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)
body = _read_bounded(self.rfile, MAX_GIT_REQUEST, length)
token = read_token()
_reject_forbidden(repo, "repository", (token,))
if service == "git-receive-pack":
_validate_receive_pack(body, token)
else:
_reject_credential_bytes(body, token, "Git request")
expected = f"application/x-{service}-result"
result = _upstream_git_request(
f"/atlas/{repo}.git/{service}",
method="POST",
body=body,
content_type=expected_request,
expected_type=expected,
token=token,
)
self.send_response(200)
self.send_header("Content-Type", expected)
self.send_header("Content-Length", str(len(result)))
self.end_headers()
self.wfile.write(result)
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()
ThreadingHTTPServer((args.listen, args.port), BrokerHandler).serve_forever()
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,95 @@
#!/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
from gitea_api_policy import PolicyError
BROKER_ORIGIN = "http://hermes-scm-broker.hermes-scm.svc.cluster.local:9081"
MAX_RESPONSE_BYTES = 2 * 1024 * 1024
class RejectRedirectHandler(urllib.request.HTTPRedirectHandler):
"""Keep every broker request on its fixed in-cluster origin."""
def redirect_request(self, req, fp, code, msg, headers, newurl):
raise PolicyError("SCM broker redirects are not allowed")
_OPENER = urllib.request.build_opener(RejectRedirectHandler())
def _open(request: urllib.request.Request, timeout: int):
return _OPENER.open(request, 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,
)

View File

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

View File

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

View File

@ -0,0 +1,456 @@
#!/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 stat
import struct
from pathlib import Path
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")
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"
class HardeningError(RuntimeError):
"""Raised before an unsafe or ambiguous host-account change."""
def _read_regular(path: Path, maximum: int = MAX_ACCOUNT_FILE) -> tuple[bytes, os.stat_result]:
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)
finally:
os.close(descriptor)
if len(value) != metadata.st_size:
raise HardeningError(f"short file read: {path.name}")
return value, metadata
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 _backup_once(path: Path, value: bytes, metadata: os.stat_result) -> Path:
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, stat.S_IMODE(metadata.st_mode))
except FileExistsError:
_read_regular(backup)
return backup
try:
if os.write(descriptor, value) != len(value):
raise HardeningError(f"short backup write: {path.name}")
os.fchown(descriptor, metadata.st_uid, metadata.st_gid)
os.fsync(descriptor)
finally:
os.close(descriptor)
return backup
def _atomic_write(
path: Path, value: bytes, *, mode: int, uid: int, gid: int
) -> None:
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, mode)
try:
if os.write(descriptor, value) != len(value):
raise HardeningError(f"short atomic write: {path.name}")
os.fchmod(descriptor, mode)
os.fchown(descriptor, uid, gid)
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)
try:
os.fsync(directory)
finally:
os.close(directory)
def _reconcile_databases() -> None:
expected = _expected_records()
definitions = (
("passwd", 7, 2),
("group", 4, 2),
("shadow", 9, 0),
("gshadow", 4, 0),
)
planned = []
account_present = False
for name, fields, identity_index in definitions:
path = HOST_ETC / name
value, metadata = _read_regular(path)
records = _records(value, fields, name)
updated = _reconcile_record(
records, expected[name], identity_index=identity_index
)
if name == "passwd":
account_present = expected[name] in records
planned.append((path, value, metadata, _encode(updated)))
try:
(HOST_HOME / ACCOUNT).lstat()
except FileNotFoundError:
pass
else:
if not account_present:
raise HardeningError("dedicated Hermes account home already exists")
for path, value, metadata, _updated in planned:
_backup_once(path, value, metadata)
try:
for path, _old, metadata, updated in planned:
_atomic_write(
path,
updated,
mode=stat.S_IMODE(metadata.st_mode),
uid=metadata.st_uid,
gid=metadata.st_gid,
)
for path, _old, _metadata, _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:
for path, old, metadata, _updated in planned:
_atomic_write(
path,
old,
mode=stat.S_IMODE(metadata.st_mode),
uid=metadata.st_uid,
gid=metadata.st_gid,
)
raise
def _validated_public_key(path: Path) -> 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")
fields = line.split()
if len(fields) not in {2, 3} or fields[0] not in {
b"ssh-ed25519",
b"ecdsa-sha2-nistp256",
b"ssh-rsa",
}:
raise HardeningError("Hermes public key format is unsupported")
try:
base64.b64decode(fields[1], validate=True)
except ValueError as exc:
raise HardeningError("Hermes public key payload is invalid") from exc
return line
def _directory(path: Path, *, mode: int, uid: int, gid: int) -> None:
try:
path.mkdir(mode=mode)
except FileExistsError:
pass
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, key: bytes) -> bytes:
return b"".join(
line
for line in value.splitlines(keepends=True)
if line.strip(b"\r\n") != key
)
def _move_key(public_key: Path) -> None:
key = _validated_public_key(public_key)
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, metadata = _read_regular(target, MAX_AUTHORIZED_KEYS)
if current != key + b"\n":
_backup_once(target, current, metadata)
_atomic_write(target, key + b"\n", mode=0o600, uid=ACCOUNT_UID, gid=ACCOUNT_GID)
for legacy in LEGACY_ACCOUNTS:
authorized = HOST_HOME / legacy / ".ssh" / "authorized_keys"
try:
value, metadata = _read_regular(authorized, MAX_AUTHORIZED_KEYS)
except FileNotFoundError:
continue
updated = _without_key(value, key)
if updated == value:
continue
_backup_once(authorized, value, metadata)
_atomic_write(
authorized,
updated,
mode=stat.S_IMODE(metadata.st_mode),
uid=metadata.st_uid,
gid=metadata.st_gid,
)
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) -> 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"{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) -> 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)
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 in (HOST_K3S, HOST_KUBELET, HOST_RUN_K3S, HOST_RUN_CONTAINERD):
_deny_sensitive_root(path)
def reconcile(public_key: Path) -> None:
"""Create the locked account and move only the Hermes authorization key."""
_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())

View File

@ -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,11 @@ def stage_agent() -> None:
for name in (
"agent-api-key",
"chat-relay-key",
"gitea-token",
"gitea-username",
"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",

View File

@ -5,17 +5,21 @@ description: Read bounded private Atlas repository and pull-request metadata or
# Manage Atlas pull requests
Use `/opt/coordinator/gitea_api.py` for Forgejo API access. It reads its token
from the pod-lifetime Vault projection; never read that file, copy the token,
put it in an argument or environment variable, or replace this client with
`curl`.
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/coordinator/gitea_api.py read /api/v1/repos/atlas/REPO/pulls/NUMBER
/opt/scm/gitea_api.py read /api/v1/repos/atlas/REPO/pulls/NUMBER
```
The client exposes only repository metadata, PRs and PR evidence, branches,
@ -29,10 +33,16 @@ evidence, not authorization to change it.
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. Validate without reading a credential or using the network:
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/coordinator/gitea_api.py --dry-run create-draft REPO \
/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"
```

View File

@ -3,7 +3,7 @@
apiVersion: batch/v1
kind: Job
metadata:
name: vault-k8s-auth-hermes-8
name: vault-k8s-auth-hermes-9
namespace: vault
spec:
backoffLimit: 2

View File

@ -256,7 +256,9 @@ write_policy_and_role "game-stream" "game-stream" "game-stream-vault" \
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" ""
"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-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" \

View File

@ -92,13 +92,14 @@ 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 "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
@ -560,13 +561,15 @@ def test_chat_image_generation_uses_private_owner_broker():
"app": "hermes-chat-tenant"
}
vault_policy = (VAULT / "scripts" / "vault_k8s_auth_configure.sh").read_text()
assert (
'"hermes/agent-oidc hermes/agent-tokens hermes/chat-telegram '
'hermes/developer-keycloak hermes/developer-gitea '
'hermes/developer-harbor hermes/developer-jenkins '
'hermes/developer-ssh"'
in vault_policy
)
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

View File

@ -15,7 +15,8 @@ import pytest
import yaml
SCRIPTS = Path(__file__).parents[2] / "services/hermes/scripts"
ROOT = Path(__file__).parents[2]
SCRIPTS = ROOT / "services/hermes/scripts"
sys.path.insert(0, str(SCRIPTS))
HERMES = Path(__file__).parents[2] / "services/hermes"
KEYCLOAK = Path(__file__).parents[2] / "services/keycloak"
@ -1353,20 +1354,27 @@ def test_agent_network_boundary_allows_only_authenticated_and_metrics_surfaces()
"ports": [{"protocol": "TCP", "port": 9010}],
},
]
assert isolation["spec"]["egress"] == [{}]
egress = isolation["spec"]["egress"]
assert any(
rule.get("ports") == [{"protocol": "TCP", "port": 9081}]
for rule in egress
)
assert "hermes-scm" in yaml.safe_dump(egress)
assert "gitea" in yaml.safe_dump(egress)
assert egress != [{}]
def test_owner_agent_has_cluster_admin_kubernetes_context():
def test_owner_agent_has_scoped_read_only_kubernetes_context():
config = yaml.safe_load((HERMES / "agent-kubeconfig.yaml").read_text())
assert config["current-context"] == "atlas-owner"
assert config["current-context"] == "atlas-observer"
assert config["contexts"][0]["context"]["namespace"] == "default"
rbac_path = HERMES / "agent-rbac.yaml"
rbac_path = ROOT / "services/hermes-observer-rbac/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",
"name": "hermes-agent-cluster-observer-v2",
}
assert binding["subjects"] == [
{"kind": "ServiceAccount", "name": "hermes-agent", "namespace": "hermes"}
@ -1441,15 +1449,16 @@ def test_owner_agent_has_pinned_dedicated_node_ssh_access():
"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"}
assert "/opt/node-hardener/node_account_hardening.py" in reconciler
assert "--public-key-file /vault/secrets/node-ssh-public-key" in reconciler
assert "sleep 300" in reconciler
host_etc = next(item for item in pod["volumes"] if item["name"] == "host-etc")
assert host_etc["hostPath"] == {"path": "/etc", "type": "Directory"}
hardener = (HERMES / "scripts/node_account_hardening.py").read_text()
assert 'ACCOUNT = "hermes-agent"' in hardener
assert 'LEGACY_ACCOUNTS = ("atlas", "oceanus")' in hardener
assert 'ACCOUNT_UID = 1200' in hardener
assert 'ACCOUNT_GID = 1200' in hardener
def test_owner_agent_tracks_no_ssh_identity_or_host_key_material():
@ -1518,7 +1527,9 @@ def test_switchyard_has_a_dedicated_non_owner_identity_and_read_only_catalog():
rbac = [
item
for item in yaml.safe_load_all((HERMES / "agent-rbac.yaml").read_text())
for item in yaml.safe_load_all(
(ROOT / "services/hermes-observer-rbac/rbac.yaml").read_text()
)
if item
]
binding = next(item for item in rbac if item["kind"] == "ClusterRoleBinding")

View File

@ -445,8 +445,10 @@ def test_cassandra_sync_repairs_existing_worktree(
assert all("GITEA_TOKEN" not in environment for environment in environments)
def test_cassandra_sync_repairs_origin_without_token(tmp_path: Path, monkeypatch):
"""Missing credentials skip only the fetch, not the local origin repair."""
def test_cassandra_sync_repairs_origin_through_broker_without_token(
tmp_path: Path, monkeypatch
):
"""Repository sync uses the credential-isolated broker remote."""
workspace = tmp_path / "cassandra"
(workspace / ".git").mkdir(parents=True)
monkeypatch.setattr(coordinator, "CASSANDRA_BASE_PATH", workspace)
@ -461,8 +463,13 @@ def test_cassandra_sync_repairs_origin_without_token(tmp_path: Path, monkeypatch
monkeypatch.setattr(coordinator.subprocess, "run", run)
state = coordinator.sync_cassandra_repo({})
assert state == "ready; fetch skipped until Gitea token is configured"
assert [command[-3] for command in commands] == ["remote", "set-url"]
assert state == "ready"
assert any(
"hermes-scm-broker.hermes-scm.svc.cluster.local" in " ".join(cmd)
for cmd in commands
)
assert all("token" not in " ".join(cmd).lower() for cmd in commands)
assert commands[-1][-4:] == ["fetch", "--quiet", "--prune", "origin"]
def test_migrate_open_cassandra_tasks_preserves_running_and_done_tasks():

View File

@ -2,18 +2,22 @@
from __future__ import annotations
import base64
import copy
import importlib.util
import json
import sys
import urllib.request
from email.message import Message
from pathlib import Path
import pytest
ROOT = Path(__file__).parents[2]
CLIENT_PATH = ROOT / "services/hermes/scripts/gitea_api.py"
CLIENT_PATH = ROOT / "services/hermes/scm-common/scripts/gitea_api.py"
HEAD_SHA = "465cf9146b05c174a2a8d310aff6c64be58277b6"
if str(CLIENT_PATH.parent) not in sys.path:
sys.path.insert(0, str(CLIENT_PATH.parent))
def _load():
@ -58,8 +62,11 @@ def _draft_response(**updates):
class Response:
def __init__(self, body: object):
def __init__(self, body: object, status: int | None = None):
self.body = body if isinstance(body, bytes) else json.dumps(body).encode()
self.status = status if status is not None else (201 if isinstance(body, dict) else 200)
self.headers = Message()
self.headers["Content-Type"] = "application/json"
def __enter__(self):
return self
@ -101,6 +108,10 @@ def test_redirect_handler_rejects_cross_origin_with_sentinel_authorization():
("https://evil.example", "/api/v1/repos/atlas/cassandra"),
("http://scm.bstein.dev", "/api/v1/repos/atlas/cassandra"),
("https://scm.bstein.dev:443", "/api/v1/repos/atlas/cassandra"),
("https://scm.bstein.dev/", "/api/v1/repos/atlas/cassandra"),
("HTTPS://scm.bstein.dev", "/api/v1/repos/atlas/cassandra"),
("https://SCM.bstein.dev", "/api/v1/repos/atlas/cassandra"),
("https://user@scm.bstein.dev", "/api/v1/repos/atlas/cassandra"),
("https://scm.bstein.dev", "https://evil.example/api/v1/repos/atlas/cassandra"),
("https://scm.bstein.dev", "/api/v1/repos/evil/cassandra"),
("https://scm.bstein.dev", "/api/v1/repos/%61tlas/cassandra"),
@ -114,6 +125,72 @@ def test_host_owner_and_path_escape_attempts_are_rejected(base_url: str, path: s
client.build_request("GET", path, base_url=base_url, token="secret")
@pytest.mark.parametrize(
"path",
[
"/api/v1/repos/atlas/cassandra/pulls/1\nHost: evil.example",
"/api/v1/repos/atlas/cassandra/pulls/1\r\nX-Test: value",
"/api/v1/repos/atlas/cassandra/pulls/1\tignored",
"/api/v1/repos/atlas/cassandra/pulls/1\x00ignored",
"/api/v1/repos/atlas/cassandra/pulls/1\x1fignored",
"/api/v1/repos/atlas/cassandra/pulls/1\x7fignored",
"/api/v1/repos/atlas/cassandra\\pulls\\1",
"/api/v1/repos/atlas/cassandra/pulls/%31",
"/api/v1/repos/atlas/cassandra/pulls/",
" https://scm.bstein.dev/api/v1/repos/atlas/cassandra",
"https://scm.bstein.dev/api/v1/repos/atlas/cassandra",
],
)
def test_raw_noncanonical_target_is_rejected_before_urlsplit_and_opener(
path: str, monkeypatch
):
client = _load()
split_called = False
opener_called = False
original_urlsplit = client.urllib.parse.urlsplit
def urlsplit(*args, **kwargs):
nonlocal split_called
split_called = True
return original_urlsplit(*args, **kwargs)
def opener(*_args, **_kwargs):
nonlocal opener_called
opener_called = True
return Response(b"{}")
monkeypatch.setattr(client.urllib.parse, "urlsplit", urlsplit)
with pytest.raises(client.PolicyError):
client.read(path, token="runtime", opener=opener)
assert split_called is False
assert opener_called is False
@pytest.mark.parametrize(
"path",
[
"/api/v1/repos/atlas/cassandra/pulls/1?",
"//scm.bstein.dev/api/v1/repos/atlas/cassandra",
"/api/v1/repos/atlas/cassandra/./pulls/1",
"/api/v1/repos/atlas/cassandra/../admin",
"/api/v1/repos/atlas/cassandra//pulls/1",
"/api/v1/repos/atlas/cassandra/pulls/1?limit=01",
],
)
def test_noncanonical_round_trip_or_segments_never_reach_opener(path: str):
client = _load()
opener_called = False
def opener(*_args, **_kwargs):
nonlocal opener_called
opener_called = True
return Response(b"{}")
with pytest.raises(client.PolicyError):
client.read(path, token="runtime", opener=opener)
assert opener_called is False
@pytest.mark.parametrize(
"path",
[
@ -179,6 +256,109 @@ def test_read_query_is_bounded(path: str):
client.authorize_request("GET", path, None)
@pytest.mark.parametrize(
"query",
[
"page=0",
"page=10001",
"page=01",
"limit=0",
"limit=51",
"limit=01",
"page=" + "9" * 4000,
"page=",
"page=%EF%BC%90",
"state=%6fpen",
"p%61ge=1",
"page=1&page=2",
"page=1&" + "x" * 17 + "=1",
"state=" + "x" * 17,
"page=1&limit=2&state=open&extra=3",
],
)
def test_read_query_requires_canonical_bounded_ascii(query: str):
client = _load()
with pytest.raises(client.PolicyError):
client.authorize_request(
"GET", f"/api/v1/repos/atlas/cassandra/pulls?{query}", None
)
@pytest.mark.parametrize(
"suffix",
[
"pulls/{number}",
"pulls/{number}.patch",
"pulls/{number}.diff",
"pulls/{number}/commits",
"pulls/{number}/files",
"commits/{number}/status",
"commits/{number}/statuses",
"statuses/{number}",
"branches/{number}",
],
)
def test_oversized_numeric_or_captured_path_never_reaches_opener(suffix: str):
client = _load()
called = False
def opener(*_args, **_kwargs):
nonlocal called
called = True
return Response(b"{}")
path = "/api/v1/repos/atlas/cassandra/" + suffix.format(number="9" * 4000)
with pytest.raises(client.PolicyError):
client.read(path, token="runtime", opener=opener)
assert called is False
@pytest.mark.parametrize(
"number",
["0", "01", "2147483648", "", "%31", "12345678901"],
)
@pytest.mark.parametrize("tail", ["", ".patch", ".diff", "/commits", "/files"])
def test_noncanonical_or_out_of_range_pr_number_never_reaches_opener(
number: str, tail: str
):
client = _load()
called = False
def opener(*_args, **_kwargs):
nonlocal called
called = True
return Response(b"{}")
with pytest.raises(client.PolicyError):
client.read(
f"/api/v1/repos/atlas/cassandra/pulls/{number}{tail}",
token="runtime",
opener=opener,
)
assert called is False
def test_maximum_bounded_pr_number_is_readable():
client = _load()
called = False
def opener(*_args, **_kwargs):
nonlocal called
called = True
return Response(b"{}")
assert (
client.read(
"/api/v1/repos/atlas/cassandra/pulls/2147483647",
token="runtime",
opener=opener,
)
== b"{}"
)
assert called is True
@pytest.mark.parametrize(
("method", "path", "data"),
[
@ -230,10 +410,108 @@ def test_complete_git_ref_validation_rejects_invalid_names(ref: str):
def test_git_ref_validation_uses_fixed_trusted_binary():
client = _load()
assert client.GIT_BIN == "/usr/bin/git"
assert client._validate_ref.__globals__["GIT_BIN"] == "/usr/bin/git"
assert client._validate_ref("hermes/valid-fix", "head") == "hermes/valid-fix"
@pytest.mark.parametrize("field", ["base", "head"])
@pytest.mark.parametrize("oversized", ["r" * 100_000, "🧪" * 128])
def test_oversized_ref_never_invokes_git_request_or_opener(
field: str, oversized: str, monkeypatch
):
client = _load()
git_called = False
request_built = False
opener_called = False
original_build_request = client.build_request
def git_run(*_args, **_kwargs):
nonlocal git_called
git_called = True
raise AssertionError("Git must not receive an oversized ref")
def build_request(*args, **kwargs):
nonlocal request_built
request_built = True
return original_build_request(*args, **kwargs)
def opener(*_args, **_kwargs):
nonlocal opener_called
opener_called = True
return Response(_draft_response())
monkeypatch.setattr(client._validate_ref.__globals__["subprocess"], "run", git_run)
client.build_request = build_request
refs = {"base": "main", "head": "hermes/fix"}
refs[field] = oversized
with pytest.raises(client.PolicyError, match="branch-name limit"):
client.create_draft(
"cassandra",
base=refs["base"],
head=refs["head"],
head_sha=HEAD_SHA,
title="Focused fix",
body="Review evidence",
token="runtime",
opener=opener,
)
assert git_called is False
assert request_built is False
assert opener_called is False
@pytest.mark.parametrize(
("field", "oversized"),
[("repo", "r" * 101), ("title", "🧪" * 200), ("body", "🧪" * 9_000)],
)
def test_other_text_bounds_fail_before_git_request_or_opener(
field: str, oversized: str, monkeypatch
):
client = _load()
git_called = False
request_built = False
opener_called = False
original_build_request = client.build_request
def git_run(*_args, **_kwargs):
nonlocal git_called
git_called = True
raise AssertionError("Git must not run before cheap input bounds")
def build_request(*args, **kwargs):
nonlocal request_built
request_built = True
return original_build_request(*args, **kwargs)
def opener(*_args, **_kwargs):
nonlocal opener_called
opener_called = True
return Response(_draft_response())
monkeypatch.setattr(client._validate_ref.__globals__["subprocess"], "run", git_run)
client.build_request = build_request
values = {
"repo": "cassandra",
"title": "Focused fix",
"body": "Review evidence",
}
values[field] = oversized
with pytest.raises(client.PolicyError):
client.create_draft(
values["repo"],
base="main",
head="hermes/fix",
head_sha=HEAD_SHA,
title=values["title"],
body=values["body"],
token="runtime",
opener=opener,
)
assert git_called is False
assert request_built is False
assert opener_called is False
def test_create_forces_draft_title_and_same_repository_branch_names():
client = _load()
assert (
@ -272,24 +550,289 @@ def test_runtime_token_is_only_an_authorization_header():
@pytest.mark.parametrize(
"body",
"sensitive",
[
"pass" + "word=not-a-real-credential",
"client_" + "secret=not-a-real-value",
'{"client_' + 'secret":\n"synthetic-value"}',
'{"client_' + 'secret"\n:\n"synthetic-value"}',
'{"client\\u005f' + 'secret":"synthetic-value"}',
"client_" + "secret:\n synthetic-value",
'{"client\\q' + 'secret":"synthetic-value"}',
'{"client\n' + 'secret":"synthetic-value"}',
'"client_' + 'secret"\x0b:\n"synthetic-value"',
"ACCESS_" + "TOKEN = 'not-a-real-value'",
'{"refresh_' + 'token": "not-a-real-value"}',
"private_" + "key: not-a-real-value",
"AWS_SECRET_ACCESS_" + "KEY=not-a-real-value",
"aws_access_key_" + "id: not-a-real-value",
"AWS_SESSION_" + "TOKEN = 'not-a-real-value'",
'{"AccessKey' + 'Id":"not-a-real-value"}',
'{"SecretAccess' + 'Key":"not-a-real-value"}',
'{"Session' + 'Token":"not-a-real-value"}',
"aws-security-" + "token: not-a-real-value",
"Account" + "Key=not-a-real-value",
"SharedAccess" + "Signature: not-a-real-value",
"AZURE_STORAGE_CONNECTION_" + "STRING='not-a-real-value'",
"DefaultEndpointsProtocol=https;AccountName=fake;Account"
+ "Key=not-a-real-value;EndpointSuffix=example",
"?sv=2024-11-04&ss=b&srt=sco&sp=rwdlac&se=2099-01-01&sig=" + "not-a-real-value",
"DOCKER_AUTH_" + "CONFIG='not-a-real-value'",
'{"auths":{"registry.example":{"auth":"bm90LXJlYWw="}}}',
'{"identity' + 'token":"not-a-real-value"}',
'{"type":"service_' + 'account","client_email":"fake@example.test"}',
'{"private_key_' + 'id":"not-a-real-value"}',
'{"client_' + 'email":"fake@example.test"}',
"GOOGLE_CREDENTIALS" + "=not-a-real-value",
"personal_access_" + "token: not-a-real-value",
"GITEA_" + "TOKEN=not-a-real-value",
"gitlab-token" + ": not-a-real-value",
"pat" + "=not-a-real-value",
"Authorization: " + "Bearer not-a-real-credential-value",
"token: " + "ghp_" + "notarealcredentialvalue123456",
"authorization = " + '"Basic not-a-real-credential-value"',
"Bearer" + "=not-a-real-credential-value",
"Basic" + ": not-a-real-credential-value",
"pass" + "word=not-a-real-credential",
"ghp_" + "notarealcredentialvalue123456",
"github_pat_" + "notarealcredentialvalue123456",
"glpat-" + "notarealcredentialvalue123456",
"xoxb-" + "not-a-real-credential-value-123456",
"sk-ant-" + "notarealcredentialvalue123456",
"sk-proj-" + "notarealcredentialvalue123456",
"sk_live_" + "notarealcredentialvalue123456",
"ya29." + "notarealcredentialvalue123456",
"gta_" + "notarealcredentialvalue123456",
"whsec_" + "notarealcredentialvalue123456",
"npm_" + "notarealcredentialvalue123456",
"pypi-" + "notarealcredentialvalue123456789012345",
"hf_" + "notarealcredentialvalue123456",
"SG." + "notarealvalue1234" + ".notarealcredentialvalue123456",
"SK" + "a" * 32,
"https://hooks.slack.com/services/" + "T000/B000/notarealvalue123456",
"https://discord.com/api/webhooks/123456789/" + "notarealcredentialvalue123456",
"https://fake.webhook.office.com/" + "notarealcredentialvalue123456",
"webhook_" + "url=https://example.test/not-real",
"FutureCloudSigning" + "Credential=not-a-real-value",
"future-client-signing-" + "key: not-a-real-value",
"future_client_signing_" + "key='not-a-real-value'",
'{"serviceAccountPrivate' + 'Key":"not-a-real-value"}',
"CONTAINER_REGISTRY_" + "CREDENTIAL=not-a-real-value",
"someWebhookSigning" + "Secret: not-a-real-value",
"client" + "Key=not-a-real-value",
"session" + "Key: not-a-real-value",
"access" + "Id=not-a-real-value",
"credentials" + ": {user: fake}",
"private" + "Key: |",
"nuget_api_" + "key=not-a-real-value",
"oy2" + "a" * 44,
"sk_test_" + "notarealcredentialvalue123456",
"A1b2C3d4E5f6G7h8I9j0K_l-M+n/O=pQ2rS3tU4vW5xY6zZ7aB8cC9d",
"AKIA" + "A" * 16,
"AIza" + "a" * 35,
"-----BEGIN OPENSSH " + "PRIVATE KEY-----",
"ssh-ed25519 " + "bm90YXJlYWxjcmVkZW50aWFsdmFsdWU=",
"eyJnotarealheader." + "notarealpayloadvalue." + "notarealsignature",
],
)
def test_body_rejects_common_credential_shapes(body: str):
@pytest.mark.parametrize("field", ["title", "body"])
def test_create_rejects_expanded_credential_shapes_before_network(
sensitive: str, field: str
):
client = _load()
called = False
request_built = False
original_build_request = client.build_request
def build_request(*args, **kwargs):
nonlocal request_built
request_built = True
return original_build_request(*args, **kwargs)
def opener(*_args, **_kwargs):
nonlocal called
called = True
return Response(_draft_response())
client.build_request = build_request
values = {"title": "Focused fix", "body": "Review evidence"}
values[field] = sensitive
with pytest.raises(client.PolicyError, match="credential material"):
client._validate_body(body)
client.create_draft(
"cassandra",
base="main",
head="hermes/fix",
head_sha=HEAD_SHA,
title=values["title"],
body=values["body"],
token="runtime-sentinel",
opener=opener,
)
assert request_built is False
assert called is False
def test_create_rejects_exact_runtime_token_before_network():
def test_very_long_compact_body_is_rejected_before_request_or_network():
client = _load()
request_built = False
opener_called = False
original_build_request = client.build_request
def build_request(*args, **kwargs):
nonlocal request_built
request_built = True
return original_build_request(*args, **kwargs)
def opener(*_args, **_kwargs):
nonlocal opener_called
opener_called = True
return Response(_draft_response())
client.build_request = build_request
with pytest.raises(client.PolicyError, match="credential material"):
client.create_draft(
"cassandra",
base="main",
head="hermes/fix",
head_sha=HEAD_SHA,
title="Focused fix",
body="a" * 300,
token="runtime-sentinel",
opener=opener,
)
assert request_built is False
assert opener_called is False
@pytest.mark.parametrize(
"safe_text",
[
"AWS_SECRET_ACCESS_KEY is injected at runtime",
"Token: reject empty values",
"Authorization = preserve header behavior",
"Password: add regression",
"Review the Authorization header behavior",
"Bearer authentication is required for this route",
"Document Docker auths payload rejection",
"The client_email field belongs to service accounts",
"AccountKey assignments must be blocked",
"This patch changes token validation without including a value",
"FutureCloudSigningCredential handling needs a regression test",
"The clientKey name is documented without an assigned value",
"A SHA-256 digest 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef is evidence",
],
)
def test_credential_policy_keeps_normal_engineering_prose_usable(safe_text: str):
client = _load()
assert client._validate_body(safe_text) == safe_text
assert client._draft_title(safe_text) == f"WIP: {safe_text}"
@pytest.mark.parametrize("field", ["title", "body"])
def test_create_rejects_exact_runtime_token_before_network(field: str):
client = _load()
called = False
request_built = False
original_build_request = client.build_request
def build_request(*args, **kwargs):
nonlocal request_built
request_built = True
return original_build_request(*args, **kwargs)
def opener(*_args, **_kwargs):
nonlocal called
called = True
return Response(_draft_response())
client.build_request = build_request
runtime_token = "exact-random-runtime-sentinel-7b73ac61"
values = {"title": "Focused fix", "body": "Review evidence"}
values[field] = f"Accidental {runtime_token} value"
with pytest.raises(client.PolicyError, match="runtime credential"):
client.create_draft(
"cassandra",
base="main",
head="hermes/fix",
head_sha=HEAD_SHA,
title=values["title"],
body=values["body"],
token=runtime_token,
opener=opener,
)
assert request_built is False
assert called is False
@pytest.mark.parametrize("field", ["repo", "base", "head", "title", "body"])
def test_every_public_field_rejects_exact_runtime_token_before_git_or_network(
field: str, monkeypatch
):
client = _load()
git_called = False
request_built = False
opener_called = False
runtime_token = "runtime-sentinel"
values = {
"repo": "cassandra",
"base": "main",
"head": "hermes/fix",
"title": "Focused fix",
"body": "Review evidence",
}
values[field] = runtime_token
def git_run(*_args, **_kwargs):
nonlocal git_called
git_called = True
raise AssertionError("Git must not run for a credential-bearing field")
def build_request(*_args, **_kwargs):
nonlocal request_built
request_built = True
raise AssertionError("a request must not be built")
def opener(*_args, **_kwargs):
nonlocal opener_called
opener_called = True
raise AssertionError("the opener must not be called")
monkeypatch.setattr(client._validate_ref.__globals__["subprocess"], "run", git_run)
monkeypatch.setattr(client, "build_request", build_request)
with pytest.raises(client.PolicyError, match="runtime credential"):
client.create_draft(
values["repo"],
base=values["base"],
head=values["head"],
head_sha=HEAD_SHA,
title=values["title"],
body=values["body"],
token=runtime_token,
opener=opener,
)
assert git_called is False
assert request_built is False
assert opener_called is False
@pytest.mark.parametrize(
"sensitive",
[
" ".join(["{}"] * 32) + ' {"client_secret":"synthetic-value"}',
"prefix_ghp_notarealcredentialvalue123456_suffix",
"client_secret: correct horse battery staple",
"'client_secret':\n synthetic-value",
],
)
@pytest.mark.parametrize("field", ["title", "body"])
def test_structured_scanner_closes_bounded_and_wrapped_token_bypasses(
sensitive: str, field: str
):
client = _load()
values = {"title": "Focused fix", "body": "Review evidence"}
values[field] = sensitive
called = False
def opener(*_args, **_kwargs):
@ -297,18 +840,73 @@ def test_create_rejects_exact_runtime_token_before_network():
called = True
return Response(_draft_response())
with pytest.raises(client.PolicyError, match="runtime credential"):
with pytest.raises(client.PolicyError, match="credential material"):
client.create_draft(
"cassandra",
base="main",
head="hermes/fix",
head_sha=HEAD_SHA,
title=values["title"],
body=values["body"],
token="runtime-sentinel",
opener=opener,
)
assert called is False
@pytest.mark.parametrize("status", [200, 202, 204, 206])
def test_create_accepts_only_http_201(status: int):
client = _load()
with pytest.raises(client.PolicyError, match="unexpected HTTP status"):
client.create_draft(
"cassandra",
base="main",
head="hermes/fix",
head_sha=HEAD_SHA,
title="Focused fix",
body="accidental runtime-sentinel value",
token="runtime-sentinel",
opener=opener,
body="Review evidence",
token="runtime",
opener=lambda *_a, **_k: Response(_draft_response(), status=status),
)
assert called is False
@pytest.mark.parametrize("status", [201, 202, 204, 206])
def test_read_accepts_only_http_200(status: int):
client = _load()
with pytest.raises(client.PolicyError, match="unexpected HTTP status"):
client.read(
"/api/v1/repos/atlas/cassandra",
token="runtime",
opener=lambda *_a, **_k: Response(b"{}", status=status),
)
def test_successful_create_validates_each_ref_once(monkeypatch):
client = _load()
calls = []
def git_run(command, **_kwargs):
calls.append(command)
return type("Result", (), {"returncode": 0})()
monkeypatch.setattr(client._validate_ref.__globals__["subprocess"], "run", git_run)
client.create_draft(
"cassandra",
base="main",
head="hermes/fix",
head_sha=HEAD_SHA,
title="Focused fix",
body="Review evidence",
token="runtime",
opener=lambda *_a, **_k: Response(_draft_response()),
)
assert [command[-1] for command in calls] == [
"refs/heads/main",
"refs/heads/hermes/fix",
]
def test_create_verifies_every_server_postcondition():
@ -345,6 +943,7 @@ def test_create_postcondition_rejects_every_material_mismatch():
client = _load()
mutations = [
("number", 0),
("number", 2_147_483_648),
("state", "closed"),
("draft", False),
("merged", True),
@ -396,6 +995,19 @@ def test_read_response_is_bounded():
)
def test_direct_api_rejects_unexpected_success_content_type():
client = _load()
response = Response(b"{}")
response.headers.replace_header("Content-Type", "text/html")
with pytest.raises(client.PolicyError, match="unexpected response type"):
client.read(
"/api/v1/repos/atlas/cassandra",
token="runtime",
opener=lambda *_a, **_k: response,
)
def test_output_redaction_covers_exact_token_and_authorization_header():
client = _load()
raw = b'{"message":"do-not-leak","debug":"Authorization: token do-not-leak"}'
@ -403,3 +1015,24 @@ def test_output_redaction_covers_exact_token_and_authorization_header():
assert b"do-not-leak" not in redacted
assert redacted.count(b"[REDACTED]") >= 1
@pytest.mark.parametrize(
"reflected",
[
b"runtime-sentinel",
base64.b64encode(b"runtime-sentinel"),
base64.b64encode(b"hermes-automation:runtime-sentinel"),
],
)
def test_direct_api_rejects_credential_reflection(reflected: bytes):
client = _load()
with pytest.raises(client.PolicyError, match="credential material"):
client.read(
"/api/v1/repos/atlas/cassandra",
token="runtime-sentinel",
opener=lambda *_a, **_k: Response(
b'{"unexpected":"' + reflected + b'"}'
),
)

View File

@ -13,8 +13,10 @@ import pytest
import yaml
ROOT = Path(__file__).parents[2]
CLIENT_PATH = ROOT / "services/hermes/scripts/gitea_api.py"
CLIENT_PATH = ROOT / "services/hermes/scm-common/scripts/gitea_api.py"
HEAD_SHA = "465cf9146b05c174a2a8d310aff6c64be58277b6"
if str(CLIENT_PATH.parent) not in sys.path:
sys.path.insert(0, str(CLIENT_PATH.parent))
def _load():
@ -96,12 +98,12 @@ def test_http_error_path_redacts_token(monkeypatch, capsys):
assert client.main(["read", "/api/v1/repos/atlas/cassandra"]) == 1
captured = capsys.readouterr()
assert "do-not-leak" not in captured.err
assert "HTTP 403" in captured.err
assert "credential was disclosed" in captured.err
def test_flux_manifest_projects_runtime_vault_token_and_skill_only():
def test_flux_manifest_isolates_vault_token_in_separate_broker_only():
client_source = CLIENT_PATH.read_text(encoding="utf-8")
assert "/runtime-access/gitea-token" in client_source
assert "scm_broker_client" in client_source
assert "GITEA_TOKEN" not in client_source
deployment = yaml.safe_load(
@ -109,9 +111,7 @@ def test_flux_manifest_projects_runtime_vault_token_and_skill_only():
)
template = deployment["spec"]["template"]
annotations = template["metadata"]["annotations"]
assert annotations["vault.hashicorp.com/agent-inject-secret-gitea-token"] == (
"kv/data/atlas/hermes/developer-gitea"
)
assert not any("gitea" in key.lower() for key in annotations)
runtime = next(
volume
for volume in template["spec"]["volumes"]
@ -136,6 +136,17 @@ def test_flux_manifest_projects_runtime_vault_token_and_skill_only():
kustomization = yaml.safe_load(
(ROOT / "services/hermes/kustomization.yaml").read_text(encoding="utf-8")
)
common = yaml.safe_load(
(ROOT / "services/hermes/scm-common/kustomization.yaml").read_text(
encoding="utf-8"
)
)
boundary = common["configMapGenerator"][0]
assert boundary["name"] == "hermes-scm-boundary"
assert "gitea_api.py=scripts/gitea_api.py" in boundary["files"]
assert "gitea_api_policy.py=scripts/gitea_api_policy.py" in boundary["files"]
assert "scm_broker_client.py=scripts/scm_broker_client.py" in boundary["files"]
assert not any("gitea_askpass" in item for item in boundary["files"])
generator = next(
item
for item in kustomization["configMapGenerator"]
@ -145,3 +156,19 @@ def test_flux_manifest_projects_runtime_vault_token_and_skill_only():
"SKILL.md=skills/manage-atlas-pull-requests/SKILL.md",
"openai.yaml=skills/manage-atlas-pull-requests/agents/openai.yaml",
]
broker = yaml.safe_load(
(ROOT / "services/hermes-scm-broker/deployment.yaml").read_text(
encoding="utf-8"
)
)
assert broker["metadata"]["namespace"] == "hermes-scm"
pod = broker["spec"]["template"]
assert pod["spec"]["serviceAccountName"] == "hermes-scm-broker"
assert pod["metadata"]["annotations"][
"vault.hashicorp.com/agent-inject-secret-gitea-token"
] == "kv/data/atlas/hermes/developer-gitea"
assert not any(
volume.get("hostPath") or volume.get("persistentVolumeClaim")
for volume in pod["spec"]["volumes"]
)

View File

@ -0,0 +1,305 @@
"""Contracts for the dedicated, unprivileged Hermes node SSH identity."""
from __future__ import annotations
import base64
import importlib.util
import os
import sys
from pathlib import Path
import pytest
import yaml
ROOT = Path(__file__).parents[2]
SCRIPT = ROOT / "services/hermes/scripts/node_account_hardening.py"
def _load():
spec = importlib.util.spec_from_file_location("node_account_hardening_test", SCRIPT)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def _fixture(tmp_path: Path, monkeypatch):
module = _load()
host_etc = tmp_path / "etc"
host_home = tmp_path / "home"
host_etc.mkdir()
host_home.mkdir()
originals = {
"passwd": (
"root:x:0:0:root:/root:/bin/bash\n"
"atlas:x:2000:2000:Atlas:/home/atlas:/bin/bash\n"
"oceanus:x:2001:2001:Oceanus:/home/oceanus:/bin/bash\n"
),
"group": (
"root:x:0:\n"
"atlas:x:2000:\n"
"oceanus:x:2001:\n"
"disk:x:6:atlas\n"
"sudo:x:27:oceanus\n"
),
"shadow": (
"root:!:1:0:99999:7:::\n"
"atlas:!:1:0:99999:7:::\n"
"oceanus:!:1:0:99999:7:::\n"
),
"gshadow": (
"root:!::\n"
"atlas:!::\n"
"oceanus:!::\n"
"disk:!::atlas\n"
"sudo:!::oceanus\n"
),
}
for name, value in originals.items():
(host_etc / name).write_text(value, encoding="utf-8")
key = "ssh-ed25519 " + base64.b64encode(b"synthetic-hermes-key").decode()
other = "ssh-ed25519 " + base64.b64encode(b"human-operator-key").decode()
for user in ("atlas", "oceanus"):
ssh = host_home / user / ".ssh"
ssh.mkdir(parents=True)
(ssh / "authorized_keys").write_text(
f"{other} {user}\n{key}\n", encoding="utf-8"
)
public_key = tmp_path / "public-key"
public_key.write_text(key + "\n", encoding="utf-8")
monkeypatch.setattr(module, "HOST_ETC", host_etc)
monkeypatch.setattr(module, "HOST_HOME", host_home)
monkeypatch.setattr(module, "ACCOUNT_UID", os.getuid())
monkeypatch.setattr(module, "ACCOUNT_GID", os.getgid())
# ACL behavior has its own executable test below. These account-database
# tests must not depend on the host running pytest with CAP_FOWNER.
monkeypatch.setattr(module, "_deny_sensitive_roots", lambda: None)
return module, originals, key, other, public_key
def test_reconciler_creates_locked_groupless_account_and_moves_only_exact_key(
tmp_path: Path, monkeypatch
):
module, originals, key, other, public_key = _fixture(tmp_path, monkeypatch)
module.reconcile(public_key)
first = {
name: (module.HOST_ETC / name).read_text(encoding="utf-8")
for name in originals
}
module.reconcile(public_key)
second = {
name: (module.HOST_ETC / name).read_text(encoding="utf-8")
for name in originals
}
assert first == second
expected = module._expected_records()
for name, original in originals.items():
assert first[name] == original + ":".join(expected[name]) + "\n"
backup = module.HOST_ETC / f"{name}.hermes-boundary-backup"
assert backup.read_text(encoding="utf-8") == original
assert expected["shadow"][1] == "!"
assert expected["group"][-1] == ""
assert "disk:x:6:atlas\n" in first["group"]
assert "sudo:x:27:oceanus\n" in first["group"]
hermes_keys = (
module.HOST_HOME / module.ACCOUNT / ".ssh/authorized_keys"
).read_text(encoding="utf-8")
assert hermes_keys == key + "\n"
for user in module.LEGACY_ACCOUNTS:
legacy = (
module.HOST_HOME / user / ".ssh/authorized_keys"
).read_text(encoding="utf-8")
assert legacy == f"{other} {user}\n"
backup = module.HOST_HOME / user / ".ssh/authorized_keys.hermes-boundary-backup"
assert backup.read_text(encoding="utf-8") == f"{other} {user}\n{key}\n"
def test_identity_conflict_fails_closed_without_editing_account_databases(
tmp_path: Path, monkeypatch
):
module, originals, _key, _other, public_key = _fixture(tmp_path, monkeypatch)
conflict = originals["passwd"] + (
f"unrelated:x:{module.ACCOUNT_UID}:{module.ACCOUNT_GID}:Other:/home/other:/bin/bash\n"
)
(module.HOST_ETC / "passwd").write_text(conflict, encoding="utf-8")
with pytest.raises(module.HardeningError, match="identity conflicts"):
module.reconcile(public_key)
assert (module.HOST_ETC / "passwd").read_text(encoding="utf-8") == conflict
for name in ("group", "shadow", "gshadow"):
assert (module.HOST_ETC / name).read_text(encoding="utf-8") == originals[name]
assert not (module.HOST_HOME / module.ACCOUNT).exists()
def test_malformed_account_file_fails_closed(tmp_path: Path, monkeypatch):
module, originals, _key, _other, public_key = _fixture(tmp_path, monkeypatch)
(module.HOST_ETC / "group").write_text("malformed\n", encoding="utf-8")
with pytest.raises(module.HardeningError, match="invalid record"):
module.reconcile(public_key)
assert (module.HOST_ETC / "passwd").read_text(encoding="utf-8") == originals[
"passwd"
]
assert (module.HOST_ETC / "group").read_text(encoding="utf-8") == "malformed\n"
def test_preexisting_home_fails_before_account_database_or_key_changes(
tmp_path: Path, monkeypatch
):
module, originals, _key, _other, public_key = _fixture(tmp_path, monkeypatch)
home = module.HOST_HOME / module.ACCOUNT
home.mkdir()
(home / "unrelated").write_text("preserve\n", encoding="utf-8")
with pytest.raises(module.HardeningError, match="home already exists"):
module.reconcile(public_key)
for name, original in originals.items():
assert (module.HOST_ETC / name).read_text(encoding="utf-8") == original
assert not (module.HOST_ETC / f"{name}.hermes-boundary-backup").exists()
assert (home / "unrelated").read_text(encoding="utf-8") == "preserve\n"
def test_flux_daemonset_reconciles_every_node_without_mutating_human_groups():
documents = list(
yaml.safe_load_all(
(ROOT / "services/hermes/node-ssh-access.yaml").read_text(encoding="utf-8")
)
)
daemonset = next(item for item in documents if item["kind"] == "DaemonSet")
spec = daemonset["spec"]["template"]["spec"]
assert spec["tolerations"] == [{"operator": "Exists"}]
command = spec["containers"][0]["args"][0]
assert "/opt/node-hardener/node_account_hardening.py" in command
assert "sleep 300" in command
mounts = {item["name"]: item for item in spec["volumes"]}
assert mounts["host-home"]["hostPath"]["path"] == "/home"
assert mounts["host-etc"]["hostPath"]["path"] == "/etc"
assert mounts["host-k3s"]["hostPath"]["path"] == "/var/lib/rancher/k3s"
assert mounts["host-kubelet"]["hostPath"]["path"] == "/var/lib/kubelet"
assert mounts["host-run-k3s"]["hostPath"]["path"] == "/run/k3s"
assert mounts["host-run-containerd"]["hostPath"]["path"] == "/run/containerd"
assert "usermod" not in command
assert "groupmod" not in command
policies = list(
yaml.safe_load_all(
(ROOT / "services/hermes/networkpolicy.yaml").read_text(encoding="utf-8")
)
)
isolation = next(
item
for item in policies
if item["metadata"]["name"] == "hermes-node-ssh-access-isolation"
)
assert isolation["spec"]["ingress"] == []
assert isolation["spec"]["egress"] == []
source = SCRIPT.read_text(encoding="utf-8")
assert "ACCOUNT = \"hermes-agent\"" in source
assert "ACCOUNT_UID = 1200" in source
assert "ACCOUNT_GID = 1200" in source
assert 'LEGACY_ACCOUNTS = ("atlas", "oceanus")' in source
assert "supplementary" not in source.lower()
def test_sensitive_roots_get_explicit_zero_permission_acl_for_hermes(
tmp_path: Path, monkeypatch
):
module = _load()
sensitive = tmp_path / "k3s"
sensitive.mkdir(mode=0o755)
host_etc = tmp_path / "etc"
host_etc.mkdir()
monkeypatch.setattr(module, "ACCOUNT_UID", 1200)
monkeypatch.setattr(module, "HOST_ETC", host_etc)
monkeypatch.setattr(module, "HOST_ROOT_UID", os.getuid())
monkeypatch.setattr(module, "HOST_ROOT_GID", os.getgid())
# Production requires uid 0. The test process owns its fixture but runs the
# real xattr/ACL implementation using the test owner's uid as that boundary.
module._deny_sensitive_root(sensitive)
first = module._read_acl(sensitive)
module._deny_sensitive_root(sensitive)
second = module._read_acl(sensitive)
assert first == second
entries = module._decode_acl(first, 0o755)
assert (module.ACL_USER, 0, 1200) in entries
assert (module.ACL_USER_OBJ, 0o7, module.ACL_UNDEFINED_ID) in entries
assert (module.ACL_GROUP_OBJ, 0o5, module.ACL_UNDEFINED_ID) in entries
assert (module.ACL_OTHER, 0o5, module.ACL_UNDEFINED_ID) in entries
backup = host_etc / "hermes-node-boundary/k3s.acl"
assert backup.read_bytes() == b"N"
assert backup.stat().st_mode & 0o777 == 0o600
def test_sensitive_root_rejects_non_directory_and_non_root_owner(
tmp_path: Path, monkeypatch
):
module = _load()
regular = tmp_path / "not-a-directory"
regular.write_text("preserve", encoding="utf-8")
with pytest.raises(module.HardeningError, match="unsafe sensitive directory"):
module._deny_sensitive_root(regular)
directory = tmp_path / "not-root-owned"
directory.mkdir()
if directory.stat().st_uid == 0:
pytest.skip("cannot construct a non-root-owned fixture as root")
with pytest.raises(module.HardeningError, match="not root-owned"):
module._deny_sensitive_root(directory)
def test_acl_parser_rejects_malformed_or_incomplete_values():
module = _load()
with pytest.raises(module.HardeningError, match="malformed"):
module._decode_acl(b"too short", 0o755)
incomplete = module._encode_acl(
[(module.ACL_USER_OBJ, 0o7, module.ACL_UNDEFINED_ID)]
)
with pytest.raises(module.HardeningError, match="incomplete"):
module._decode_acl(incomplete, 0o755)
def test_acl_failure_stops_before_installing_the_ssh_key(tmp_path: Path, monkeypatch):
module = _load()
public_key = tmp_path / "public-key"
public_key.write_text(
"ssh-ed25519 " + base64.b64encode(b"synthetic-key").decode() + "\n",
encoding="utf-8",
)
moved = False
monkeypatch.setattr(module, "_reconcile_databases", lambda: None)
def fail_acl():
raise module.HardeningError("ACL unavailable")
def move_key(_path):
nonlocal moved
moved = True
monkeypatch.setattr(module, "_deny_sensitive_roots", fail_acl)
monkeypatch.setattr(module, "_move_key", move_key)
with pytest.raises(module.HardeningError, match="ACL unavailable"):
module.reconcile(public_key)
assert moved is False
def test_runtime_ssh_config_forces_dedicated_account_for_every_titan():
stage = (
ROOT / "services/hermes/scripts/stage_runtime_access.py"
).read_text(encoding="utf-8")
assert '"Host titan-*\\n User hermes-agent\\n"' in stage
agent = yaml.safe_load(
(ROOT / "services/hermes/agent-deployment.yaml").read_text(encoding="utf-8")
)
assert "User atlas" not in yaml.safe_dump(agent)
assert "User oceanus" not in yaml.safe_dump(agent)

View File

@ -14,10 +14,16 @@ import yaml
ROOT = Path(__file__).parents[2]
HERMES = ROOT / "services" / "hermes"
SCRIPTS = HERMES / "scripts"
SCM_SCRIPTS = HERMES / "scm-common" / "scripts"
if str(SCRIPTS) not in sys.path:
sys.path.insert(0, str(SCRIPTS))
if str(SCM_SCRIPTS) not in sys.path:
sys.path.insert(0, str(SCM_SCRIPTS))
def _load(name: str):
spec = importlib.util.spec_from_file_location(name, SCRIPTS / f"{name}.py")
root = SCM_SCRIPTS if name == "gitea_api" else SCRIPTS
spec = importlib.util.spec_from_file_location(name, root / f"{name}.py")
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
@ -167,8 +173,6 @@ def test_agent_runtime_stage_keeps_credentials_in_memory(tmp_path: Path, monkeyp
values = {
"agent-api-key": "agent-key",
"chat-relay-key": "relay-key",
"gitea-token": "gitea-key",
"gitea-username": "hermes-automation",
"node-ssh-private-key": "private-key",
"node-ssh-config": "host-config",
"node-ssh-known-hosts": "known-hosts",
@ -192,6 +196,10 @@ def test_agent_runtime_stage_keeps_credentials_in_memory(tmp_path: Path, monkeyp
assert (runtime / "codex/skills").is_symlink()
assert not (runtime / "claude/backups").exists()
assert not (runtime / "codex/sessions").exists()
assert not (runtime / "gitea-token").exists()
assert not (runtime / "gitea-username").exists()
ssh_config = (runtime / "node-ssh-config").read_text(encoding="utf-8")
assert ssh_config.startswith("Host titan-*\n User hermes-agent\n")
auth = json.loads((runtime / "hermes-auth.json").read_text(encoding="utf-8"))
assert auth == {"version": 1, "providers": {}, "credential_pool": {}}
@ -355,12 +363,12 @@ def test_manifests_never_seed_access_material_into_persistent_env():
assert triage_annotations[
"vault.hashicorp.com/agent-inject-secret-triage-api-key"
] == "kv/data/atlas/hermes/triage-api"
askpass = (SCRIPTS / "gitea_askpass.sh").read_text(encoding="utf-8")
assert "/runtime-access/gitea-token" in askpass
assert "GITEA_TOKEN" not in askpass
gitea_api = (SCRIPTS / "gitea_api.py").read_text(encoding="utf-8")
assert "/runtime-access/gitea-token" in gitea_api
gitea_api = (
ROOT / "services/hermes/scm-common/scripts/gitea_api.py"
).read_text(encoding="utf-8")
assert "scm_broker_client" in gitea_api
assert "GITEA_TOKEN" not in gitea_api
assert "gitea-token" not in annotations
def test_chat_media_reads_one_raw_runtime_secret(tmp_path: Path):

View File

@ -0,0 +1,352 @@
"""Executable contracts for the credential-isolated Hermes SCM broker."""
from __future__ import annotations
import importlib.util
import json
import sys
from email.message import Message
from pathlib import Path
import pytest
import yaml
ROOT = Path(__file__).parents[2]
SCRIPTS = ROOT / "services/hermes/scm-common/scripts"
sys.path.insert(0, str(SCRIPTS))
def _load(name: str):
path = SCRIPTS / f"{name}.py"
spec = importlib.util.spec_from_file_location(f"test_{name}", path)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
class Response:
def __init__(self, body: bytes, *, status: int = 200, content_type: str):
self.body = body
self.status = status
self.headers = Message()
self.headers["Content-Type"] = content_type
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def read(self, limit=-1):
return self.body if limit < 0 else self.body[:limit]
def _receive_command(old: bytes, new: bytes, ref: bytes) -> bytes:
command = old + b" " + new + b" " + ref + b"\x00report-status\n"
return f"{len(command) + 4:04x}".encode() + command + b"0000PACK"
@pytest.mark.parametrize(
"target",
[
"/api/v1/repos/atlas/cassandra/pulls/1/merge",
"/api/v1/repos/atlas/cassandra/pulls/1/reviews",
"/api/v1/admin/users",
"/git/evil/cassandra.git/info/refs?service=git-upload-pack",
"/git/atlas/cassandra.git/hooks",
"/git/atlas/cassandra.git/git-receive-pack?force=true",
"/git/atlas/cassandra.git/info/refs?service=git-upload-pack\nignored",
"/git/atlas/cassandra.git/info/refs?service=git-upload-pack\tignored",
"/git/atlas/cassandra.git/info/refs?service=git-upload-pack#fragment",
],
)
def test_broker_rejects_privileged_or_non_atlas_routes(target: str):
broker = _load("scm_broker")
with pytest.raises(broker.PolicyError):
broker._git_target(target)
def test_broker_metadata_boundary_reuses_explicit_read_allowlist():
api = _load("gitea_api")
assert (
api.authorize_request("GET", "/api/v1/repos/atlas/cassandra/pulls/1", None)
== "pull"
)
for path in (
"/api/v1/repos/atlas/cassandra/pulls/1/merge",
"/api/v1/repos/atlas/cassandra/pulls/1/reviews",
"/api/v1/repos/atlas/cassandra/hooks",
"/api/v1/admin/users",
):
with pytest.raises(api.PolicyError):
api.authorize_request("GET", path, None)
def test_receive_pack_allows_only_new_namespaced_feature_branch():
broker = _load("scm_broker")
zero = b"0" * 40
commit = b"1" * 40
broker._validate_receive_pack(
_receive_command(zero, commit, b"refs/heads/hermes/focused-fix"),
"runtime-sentinel",
)
for old, new, ref in (
(commit, b"2" * 40, b"refs/heads/hermes/focused-fix"),
(commit, zero, b"refs/heads/hermes/focused-fix"),
(zero, commit, b"refs/heads/main"),
(zero, commit, b"refs/heads/master"),
(zero, commit, b"refs/tags/release"),
):
with pytest.raises(broker.PolicyError):
broker._validate_receive_pack(
_receive_command(old, new, ref), "runtime-sentinel"
)
def test_git_proxy_uses_fixed_origin_and_never_reflects_credential():
broker = _load("scm_broker")
seen = []
def opener(request, timeout):
seen.append((request, timeout))
return Response(
b"git-result",
content_type="application/x-git-upload-pack-result",
)
result = broker._upstream_git_request(
"/atlas/cassandra.git/git-upload-pack",
method="POST",
body=b"request",
content_type="application/x-git-upload-pack-request",
expected_type="application/x-git-upload-pack-result",
token="runtime-sentinel",
opener=opener,
)
assert result == b"git-result"
request = seen[0][0]
assert request.full_url == (
"https://scm.bstein.dev/atlas/cassandra.git/git-upload-pack"
)
assert b"runtime-sentinel" not in request.data
assert "runtime-sentinel" not in request.full_url
with pytest.raises(broker.PolicyError, match="credential material"):
broker._upstream_git_request(
"/atlas/cassandra.git/git-upload-pack",
method="POST",
body=b"request",
content_type="application/x-git-upload-pack-request",
expected_type="application/x-git-upload-pack-result",
token="runtime-sentinel",
opener=lambda *_a, **_k: Response(
b"runtime-sentinel",
content_type="application/x-git-upload-pack-result",
),
)
encoded = broker._credential_forms("runtime-sentinel")[1]
with pytest.raises(broker.PolicyError, match="credential material"):
broker._upstream_git_request(
"/atlas/cassandra.git/git-upload-pack",
method="POST",
body=b"request",
content_type="application/x-git-upload-pack-request",
expected_type="application/x-git-upload-pack-result",
token="runtime-sentinel",
opener=lambda *_a, **_k: Response(
b"Authorization: Basic " + encoded,
content_type="application/x-git-upload-pack-result",
),
)
@pytest.mark.parametrize("status", [201, 202, 204, 206, 301, 302, 307, 308])
def test_git_proxy_requires_exact_http_200(status: int):
broker = _load("scm_broker")
with pytest.raises(broker.PolicyError, match="unexpected HTTP status"):
broker._upstream_git_request(
"/atlas/cassandra.git/git-upload-pack",
method="POST",
body=b"request",
content_type="application/x-git-upload-pack-request",
expected_type="application/x-git-upload-pack-result",
token="runtime-sentinel",
opener=lambda *_a, **_k: Response(
b"result",
status=status,
content_type="application/x-git-upload-pack-result",
),
)
def test_agent_broker_client_sends_no_credential_or_authorization_header():
client = _load("scm_broker_client")
seen = []
def opener(request, timeout):
seen.append((request, timeout))
return Response(b"{}", content_type="application/json")
assert client.read(
"/api/v1/repos/atlas/cassandra", opener=opener
) == b"{}"
request = seen[0][0]
assert request.full_url == client.BROKER_ORIGIN + "/v1/metadata"
assert request.get_header("Authorization") is None
assert json.loads(request.data) == {
"path": "/api/v1/repos/atlas/cassandra"
}
@pytest.mark.parametrize("raw", ["", "01", "9" * 4000, "134217729", ""])
def test_broker_content_length_is_canonical_and_bounded(raw: str):
broker = _load("scm_broker")
headers = Message()
headers["Content-Length"] = raw
with pytest.raises(broker.PolicyError, match="request length"):
broker._content_length(headers, broker.MAX_GIT_REQUEST)
def _can_i(role: dict, api_group: str, resource: str, verb: str) -> bool:
return any(
(api_group in rule["apiGroups"] or "*" in rule["apiGroups"])
and (resource in rule["resources"] or "*" in rule["resources"])
and (verb in rule["verbs"] or "*" in rule["verbs"])
for rule in role["rules"]
)
def test_agent_rbac_preserves_read_diagnostics_without_administrator_authority():
documents = list(
yaml.safe_load_all(
(ROOT / "services/hermes-observer-rbac/rbac.yaml").read_text(
encoding="utf-8"
)
)
)
cluster_role, namespaced_role, binding = documents
assert binding["roleRef"]["name"] == "hermes-agent-cluster-observer-v2"
assert _can_i(cluster_role, "", "nodes", "get")
assert _can_i(namespaced_role, "", "pods", "get")
assert _can_i(namespaced_role, "", "pods/log", "get")
assert _can_i(
namespaced_role,
"kustomize.toolkit.fluxcd.io",
"kustomizations",
"list",
)
denied = (
("", "secrets", "get"),
("", "serviceaccounts/token", "create"),
("", "pods", "create"),
("", "pods/exec", "create"),
("", "pods/attach", "create"),
("", "pods/portforward", "create"),
("rbac.authorization.k8s.io", "clusterrolebindings", "create"),
("authorization.k8s.io", "selfsubjectaccessreviews", "create"),
("apps", "deployments", "patch"),
)
assert all(
not _can_i(role, *request)
for request in denied
for role in (cluster_role, namespaced_role)
)
bindings = yaml.safe_load(
(ROOT / "services/hermes-observer-rbac/rolebindings.yaml").read_text(
encoding="utf-8"
)
)["items"]
namespaces = {item["metadata"]["namespace"] for item in bindings}
assert "hermes-scm" not in namespaces
assert {"cassandra", "flux-system", "hermes", "kube-system"} <= namespaces
assert all(item["roleRef"]["name"].endswith("namespaced-observer-v2") for item in bindings)
def test_flux_boundary_keeps_broker_secret_and_network_separate_from_agent():
agent = yaml.safe_load(
(ROOT / "services/hermes/agent-deployment.yaml").read_text(encoding="utf-8")
)
annotations = agent["spec"]["template"]["metadata"]["annotations"]
assert not any("gitea" in key.lower() for key in annotations)
assert "GIT_ASKPASS" not in json.dumps(agent)
broker = yaml.safe_load(
(ROOT / "services/hermes-scm-broker/deployment.yaml").read_text(
encoding="utf-8"
)
)
assert broker["metadata"]["namespace"] == "hermes-scm"
assert broker["spec"]["template"]["spec"]["serviceAccountName"] == (
"hermes-scm-broker"
)
assert "gitea-token" in json.dumps(broker)
policy = yaml.safe_load(
(ROOT / "services/hermes-scm-broker/networkpolicy.yaml").read_text(
encoding="utf-8"
)
)
ingress = policy["spec"]["ingress"]
assert ingress[0]["from"][0]["namespaceSelector"]["matchLabels"] == {
"kubernetes.io/metadata.name": "hermes"
}
assert ingress[0]["from"][0]["podSelector"]["matchLabels"] == {
"app": "hermes-agent"
}
vault = (
ROOT / "services/vault/scripts/vault_k8s_auth_configure.sh"
).read_text(encoding="utf-8")
assert 'write_policy_and_role "hermes-scm-broker" "hermes-scm"' in vault
assert '"hermes/developer-gitea" ""' in vault
agent_start = vault.index('write_policy_and_role "hermes-agent"')
agent_end = vault.index("write_policy_and_role", agent_start + 1)
assert "developer-gitea" not in vault[agent_start:agent_end]
def test_flux_bootstrap_has_no_agent_namespace_dependency_cycle():
hermes_resources = yaml.safe_load(
(ROOT / "services/hermes/kustomization.yaml").read_text(encoding="utf-8")
)["resources"]
assert "namespace.yaml" in hermes_resources
assert "scm-common" in hermes_resources
applications = (
ROOT / "clusters/atlas/flux-system/applications/kustomization.yaml"
).read_text(encoding="utf-8")
assert "hermes-scm-agent-code" not in applications
broker_code = yaml.safe_load(
(
ROOT
/ "clusters/atlas/flux-system/applications/hermes-scm-broker-code/kustomization.yaml"
).read_text(encoding="utf-8")
)
assert broker_code["spec"]["path"] == "./services/hermes/scm-common"
assert broker_code["spec"]["targetNamespace"] == "hermes-scm"
assert broker_code["spec"]["dependsOn"] == [{"name": "hermes-scm-namespace"}]
def test_gitea_bootstrap_enforces_human_review_without_overwriting_drift():
script = (
ROOT / "services/gitea/scripts/gitea_atlas_identity_ensure.sh"
).read_text(encoding="utf-8")
assert 'required_approvals\\\":1' in script
assert 'enable_push_whitelist\\\":true' in script
assert 'push_whitelist_usernames\\\":[\\\"${protected_reviewer}\\\"]' in script
assert 'enable_merge_whitelist\\\":true' in script
assert 'merge_whitelist_usernames\\\":[\\\"${protected_reviewer}\\\"]' in script
assert "protection differs from the human-review policy" in script
assert "api_request PATCH" not in script