hermes: isolate Atlas SCM write authority
This commit is contained in:
parent
b0812bbe9f
commit
86e9fbbfec
@ -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
|
||||
@ -0,0 +1,19 @@
|
||||
# clusters/atlas/flux-system/applications/hermes-scm-broker-code/kustomization.yaml
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||
kind: Kustomization
|
||||
metadata:
|
||||
name: hermes-scm-broker-code
|
||||
namespace: flux-system
|
||||
spec:
|
||||
interval: 10m
|
||||
path: ./services/hermes/scm-common
|
||||
targetNamespace: hermes-scm
|
||||
prune: true
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
name: flux-system
|
||||
namespace: flux-system
|
||||
wait: true
|
||||
timeout: 5m
|
||||
dependsOn:
|
||||
- name: hermes-scm-namespace
|
||||
@ -0,0 +1,27 @@
|
||||
# clusters/atlas/flux-system/applications/hermes-scm-broker/kustomization.yaml
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||
kind: Kustomization
|
||||
metadata:
|
||||
name: hermes-scm-broker
|
||||
namespace: flux-system
|
||||
spec:
|
||||
interval: 10m
|
||||
path: ./services/hermes-scm-broker
|
||||
targetNamespace: hermes-scm
|
||||
prune: true
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
name: flux-system
|
||||
namespace: flux-system
|
||||
wait: true
|
||||
timeout: 10m
|
||||
healthChecks:
|
||||
- apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: hermes-scm-broker
|
||||
namespace: hermes-scm
|
||||
dependsOn:
|
||||
- name: vault
|
||||
- name: gitea
|
||||
- name: hermes-scm-namespace
|
||||
- name: hermes-scm-broker-code
|
||||
@ -0,0 +1,16 @@
|
||||
# clusters/atlas/flux-system/applications/hermes-scm-namespace/kustomization.yaml
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||
kind: Kustomization
|
||||
metadata:
|
||||
name: hermes-scm-namespace
|
||||
namespace: flux-system
|
||||
spec:
|
||||
interval: 10m
|
||||
path: ./services/hermes-scm-namespace
|
||||
prune: true
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
name: flux-system
|
||||
namespace: flux-system
|
||||
wait: true
|
||||
timeout: 5m
|
||||
@ -60,3 +60,4 @@ spec:
|
||||
- name: keycloak
|
||||
- name: longhorn
|
||||
- name: vault
|
||||
- name: hermes-scm-broker
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}\"}}"
|
||||
|
||||
|
||||
6
services/hermes-observer-rbac/kustomization.yaml
Normal file
6
services/hermes-observer-rbac/kustomization.yaml
Normal file
@ -0,0 +1,6 @@
|
||||
# services/hermes-observer-rbac/kustomization.yaml
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- rbac.yaml
|
||||
- rolebindings.yaml
|
||||
112
services/hermes-observer-rbac/rbac.yaml
Normal file
112
services/hermes-observer-rbac/rbac.yaml
Normal file
@ -0,0 +1,112 @@
|
||||
# services/hermes-observer-rbac/rbac.yaml
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: hermes-agent-cluster-observer-v2
|
||||
labels:
|
||||
app.kubernetes.io/name: hermes-agent
|
||||
app.kubernetes.io/part-of: hermes
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources:
|
||||
- namespaces
|
||||
- nodes
|
||||
- persistentvolumes
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: ["networking.k8s.io"]
|
||||
resources: ["ingressclasses"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: ["storage.k8s.io"]
|
||||
resources:
|
||||
- csidrivers
|
||||
- csinodes
|
||||
- storageclasses
|
||||
- volumeattachments
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: ["metrics.k8s.io"]
|
||||
resources: ["nodes"]
|
||||
verbs: ["get", "list"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: hermes-agent-namespaced-observer-v2
|
||||
labels:
|
||||
app.kubernetes.io/name: hermes-agent
|
||||
app.kubernetes.io/part-of: hermes
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources:
|
||||
- configmaps
|
||||
- endpoints
|
||||
- events
|
||||
- persistentvolumeclaims
|
||||
- pods
|
||||
- pods/log
|
||||
- replicationcontrollers
|
||||
- resourcequotas
|
||||
- services
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: ["apps"]
|
||||
resources:
|
||||
- controllerrevisions
|
||||
- daemonsets
|
||||
- deployments
|
||||
- replicasets
|
||||
- statefulsets
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: ["batch"]
|
||||
resources: ["cronjobs", "jobs"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: ["autoscaling"]
|
||||
resources: ["horizontalpodautoscalers"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: ["networking.k8s.io"]
|
||||
resources: ["ingresses", "networkpolicies"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: ["discovery.k8s.io"]
|
||||
resources: ["endpointslices"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: ["policy"]
|
||||
resources: ["poddisruptionbudgets"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: ["storage.k8s.io"]
|
||||
resources: ["csistoragecapacities"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: ["metrics.k8s.io"]
|
||||
resources: ["pods"]
|
||||
verbs: ["get", "list"]
|
||||
- apiGroups:
|
||||
- helm.toolkit.fluxcd.io
|
||||
- image.toolkit.fluxcd.io
|
||||
- kustomize.toolkit.fluxcd.io
|
||||
- notification.toolkit.fluxcd.io
|
||||
- source.toolkit.fluxcd.io
|
||||
resources: ["*"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: ["longhorn.io"]
|
||||
resources:
|
||||
- backingimages
|
||||
- engines
|
||||
- instancemanagers
|
||||
- nodes
|
||||
- replicas
|
||||
- settings
|
||||
- volumes
|
||||
verbs: ["get", "list", "watch"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: hermes-agent-cluster-observer-v2
|
||||
labels:
|
||||
app.kubernetes.io/name: hermes-agent
|
||||
app.kubernetes.io/part-of: hermes
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: hermes-agent
|
||||
namespace: hermes
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: hermes-agent-cluster-observer-v2
|
||||
204
services/hermes-observer-rbac/rolebindings.yaml
Normal file
204
services/hermes-observer-rbac/rolebindings.yaml
Normal 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}
|
||||
97
services/hermes-scm-broker/deployment.yaml
Normal file
97
services/hermes-scm-broker/deployment.yaml
Normal 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
|
||||
11
services/hermes-scm-broker/kustomization.yaml
Normal file
11
services/hermes-scm-broker/kustomization.yaml
Normal file
@ -0,0 +1,11 @@
|
||||
# services/hermes-scm-broker/kustomization.yaml
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
namespace: hermes-scm
|
||||
resources:
|
||||
- service.yaml
|
||||
- deployment.yaml
|
||||
- networkpolicy.yaml
|
||||
images:
|
||||
- name: registry.bstein.dev/bstein/hermes-agent
|
||||
digest: sha256:37ebf720c783ae908a602916ffccf88d43d205a157957f5dc4b487867aee45e7
|
||||
46
services/hermes-scm-broker/networkpolicy.yaml
Normal file
46
services/hermes-scm-broker/networkpolicy.yaml
Normal file
@ -0,0 +1,46 @@
|
||||
# services/hermes-scm-broker/networkpolicy.yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: hermes-scm-broker-isolation
|
||||
namespace: hermes-scm
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app: hermes-scm-broker
|
||||
policyTypes: [Ingress, Egress]
|
||||
ingress:
|
||||
- from:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: hermes
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app: hermes-agent
|
||||
ports:
|
||||
- {protocol: TCP, port: 9081}
|
||||
egress:
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: kube-system
|
||||
podSelector:
|
||||
matchLabels:
|
||||
k8s-app: kube-dns
|
||||
ports:
|
||||
- {protocol: UDP, port: 53}
|
||||
- {protocol: TCP, port: 53}
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: vault
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app: vault
|
||||
ports:
|
||||
- {protocol: TCP, port: 8200}
|
||||
- to:
|
||||
- ipBlock:
|
||||
cidr: 192.168.22.9/32
|
||||
ports:
|
||||
- {protocol: TCP, port: 443}
|
||||
13
services/hermes-scm-broker/service.yaml
Normal file
13
services/hermes-scm-broker/service.yaml
Normal file
@ -0,0 +1,13 @@
|
||||
# services/hermes-scm-broker/service.yaml
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: hermes-scm-broker
|
||||
namespace: hermes-scm
|
||||
spec:
|
||||
selector:
|
||||
app: hermes-scm-broker
|
||||
ports:
|
||||
- name: http
|
||||
port: 9081
|
||||
targetPort: http
|
||||
5
services/hermes-scm-namespace/kustomization.yaml
Normal file
5
services/hermes-scm-namespace/kustomization.yaml
Normal file
@ -0,0 +1,5 @@
|
||||
# services/hermes-scm-namespace/kustomization.yaml
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- namespace.yaml
|
||||
14
services/hermes-scm-namespace/namespace.yaml
Normal file
14
services/hermes-scm-namespace/namespace.yaml
Normal file
@ -0,0 +1,14 @@
|
||||
# services/hermes-scm-namespace/namespace.yaml
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: hermes-scm
|
||||
labels:
|
||||
app.kubernetes.io/name: hermes-scm-broker
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: hermes-scm-broker
|
||||
namespace: hermes-scm
|
||||
automountServiceAccountToken: true
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -11,9 +11,9 @@ users:
|
||||
user:
|
||||
tokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token
|
||||
contexts:
|
||||
- name: atlas-owner
|
||||
- name: atlas-observer
|
||||
context:
|
||||
cluster: atlas
|
||||
user: hermes-agent
|
||||
namespace: default
|
||||
current-context: atlas-owner
|
||||
current-context: atlas-observer
|
||||
|
||||
@ -1,16 +0,0 @@
|
||||
# services/hermes/agent-rbac.yaml
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: hermes-agent-cluster-admin
|
||||
labels:
|
||||
app.kubernetes.io/name: hermes-agent
|
||||
app.kubernetes.io/part-of: hermes
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: hermes-agent
|
||||
namespace: hermes
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: cluster-admin
|
||||
@ -7,13 +7,13 @@ images:
|
||||
digest: sha256:37ebf720c783ae908a602916ffccf88d43d205a157957f5dc4b487867aee45e7
|
||||
resources:
|
||||
- namespace.yaml
|
||||
- scm-common
|
||||
- vault-serviceaccount.yaml
|
||||
- configmap.yaml
|
||||
- agent-configmap.yaml
|
||||
- chat-configmap.yaml
|
||||
- switchyard-configmap.yaml
|
||||
- rbac.yaml
|
||||
- agent-rbac.yaml
|
||||
- node-ssh-access.yaml
|
||||
- pvc.yaml
|
||||
- switchyard-pvc.yaml
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
12
services/hermes/scm-common/kustomization.yaml
Normal file
12
services/hermes/scm-common/kustomization.yaml
Normal 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
|
||||
284
services/hermes/scripts/gitea_api.py → services/hermes/scm-common/scripts/gitea_api.py
Executable file → Normal file
284
services/hermes/scripts/gitea_api.py → services/hermes/scm-common/scripts/gitea_api.py
Executable file → Normal 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(
|
||||
490
services/hermes/scm-common/scripts/gitea_api_policy.py
Normal file
490
services/hermes/scm-common/scripts/gitea_api_policy.py
Normal file
@ -0,0 +1,490 @@
|
||||
"""Validation policy for Hermes' least-authority Atlas Forgejo client.
|
||||
|
||||
The screening catches structured credential assignments, known token formats,
|
||||
and long high-entropy values. It is a fail-closed accident barrier, not proof
|
||||
that text is secret-free: a short or multiword secret under an innocuous key is
|
||||
not reliably distinguishable from prose and must never be supplied by callers.
|
||||
JSON depth/nodes/documents and multiline gaps are deliberately bounded; large
|
||||
or unusual configuration blobs are not a supported pull-request field format.
|
||||
"""
|
||||
|
||||
# ruff: noqa: SIM905
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import urllib.parse
|
||||
from collections import Counter
|
||||
from math import log2
|
||||
|
||||
REPO_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,99}\Z")
|
||||
SHA_RE = re.compile(r"[0-9a-fA-F]{40}\Z")
|
||||
DRAFT_TITLE_PREFIX = "WIP: "
|
||||
GIT_BIN = "/usr/bin/git"
|
||||
MAX_QUERY_LENGTH = 128
|
||||
MAX_QUERY_ITEMS = 3
|
||||
MAX_QUERY_KEY_LENGTH = 16
|
||||
MAX_QUERY_VALUE_LENGTH = 16
|
||||
MAX_PAGE = 10_000
|
||||
MAX_LIMIT = 50
|
||||
MAX_PR_NUMBER = 2_147_483_647
|
||||
MAX_PR_NUMBER_DIGITS = 10
|
||||
MAX_REF_CHARACTERS = 200
|
||||
MAX_REF_UTF8_BYTES = 255
|
||||
MAX_TITLE_UTF8_BYTES = 512
|
||||
MAX_BODY_UTF8_BYTES = 32_768
|
||||
MAX_JSON_DOCUMENTS = 32
|
||||
MAX_JSON_NODES = 2_048
|
||||
MAX_JSON_DEPTH = 32
|
||||
CANONICAL_QUERY_RE = re.compile(r"[A-Za-z0-9_=&-]*\Z")
|
||||
ASSIGNMENT_RE = re.compile(
|
||||
r"""(?mx)
|
||||
(?<![A-Za-z0-9_.-])
|
||||
(?P<key_quote>[\"']?)
|
||||
(?P<key>\.?[A-Za-z][A-Za-z0-9_.-]{0,127})
|
||||
(?P=key_quote)
|
||||
[ \t]*(?P<separator>[:=])[ \t]*
|
||||
(?P<value>
|
||||
\"(?:\\.|[^\"\\\r\n])*\" |
|
||||
'(?:\\.|[^'\\\r\n])*' |
|
||||
[^\r\n,;}&]{0,2048}
|
||||
)
|
||||
"""
|
||||
)
|
||||
JSON_KEY_RE = re.compile(r'"(?P<key>[^"]{1,512})"(?P<spacing>[\x00-\x20\x7f]{0,256}):')
|
||||
MULTILINE_ASSIGNMENT_RE = re.compile(
|
||||
r"""(?mx)
|
||||
(?<![A-Za-z0-9_.-])
|
||||
(?P<key_quote>["']?)
|
||||
(?P<key>\.?[A-Za-z][A-Za-z0-9_.-]{0,127})
|
||||
(?P=key_quote)
|
||||
[ \t]{0,64}(?:\r?\n[ \t]{0,64}){0,4}
|
||||
(?P<separator>[:=])
|
||||
[ \t]{0,64}(?:\r?\n[ \t]{0,64}){1,4}
|
||||
(?P<value>[^\r\n,;}&]{1,2048})
|
||||
"""
|
||||
)
|
||||
CAMEL_BOUNDARY_RE = re.compile(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])")
|
||||
CREDENTIAL_SCHEME_RE = re.compile(
|
||||
r"(?i)\b(?:bearer|basic|token)\s+"
|
||||
r"(?=[A-Za-z0-9+/_=.:-]{8,}\b)(?=[A-Za-z0-9+/_=.:-]*[0-9+/_=.-])"
|
||||
r"[A-Za-z0-9+/_=.:-]{8,}"
|
||||
)
|
||||
STANDALONE_SECRET_PATTERNS = tuple(
|
||||
re.compile(pattern, re.IGNORECASE)
|
||||
for pattern in (
|
||||
r"(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{20,}(?![A-Za-z0-9])",
|
||||
r"(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{20,}(?![A-Za-z0-9])",
|
||||
r"(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{20,}(?![A-Za-z0-9])",
|
||||
r"(?<![A-Za-z0-9])xox[baprs]-[A-Za-z0-9-]{20,}(?![A-Za-z0-9])",
|
||||
r"(?<![A-Za-z0-9])sk-(?:ant-|proj-)?[A-Za-z0-9_-]{20,}(?![A-Za-z0-9])",
|
||||
r"(?<![A-Za-z0-9])[rs]k_(?:test|live)_[A-Za-z0-9]{20,}(?![A-Za-z0-9])",
|
||||
r"(?<![A-Za-z0-9])whsec_[A-Za-z0-9]{20,}(?![A-Za-z0-9])",
|
||||
r"(?<![A-Za-z0-9])npm_[A-Za-z0-9]{20,}(?![A-Za-z0-9])",
|
||||
r"(?<![A-Za-z0-9])pypi-[A-Za-z0-9_-]{30,}(?![A-Za-z0-9])",
|
||||
r"(?<![A-Za-z0-9])hf_[A-Za-z0-9]{20,}(?![A-Za-z0-9])",
|
||||
r"(?<![A-Za-z0-9])(?:gta|gto|gitea|forgejo)_[A-Za-z0-9_-]{20,}(?![A-Za-z0-9])",
|
||||
r"(?<![A-Za-z0-9])ya29\.[A-Za-z0-9_-]{20,}(?![A-Za-z0-9])",
|
||||
r"(?<![A-Za-z0-9])oy2[A-Za-z0-9]{40,}(?![A-Za-z0-9])",
|
||||
r"(?<![A-Za-z0-9])SG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{20,}(?![A-Za-z0-9])",
|
||||
r"(?<![A-Za-z0-9])SK[0-9a-f]{32}(?![A-Za-z0-9])",
|
||||
r"(?<![A-Za-z0-9])(?:AKIA|ASIA)[0-9A-Z]{16}(?![A-Za-z0-9])",
|
||||
r"(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}(?![A-Za-z0-9])",
|
||||
r"(?<![A-Za-z0-9])eyJ[A-Za-z0-9_-]{12,}\.[A-Za-z0-9_-]{12,}\.[A-Za-z0-9_-]{8,}(?![A-Za-z0-9])",
|
||||
r"-----BEGIN\s+[A-Z0-9 ][A-Z0-9 -]{1,62}-----",
|
||||
r"\bssh-(?:rsa|ed25519|dss|ecdsa-[A-Za-z0-9-]+)\s+[A-Za-z0-9+/]{16,}={0,3}",
|
||||
r"https://hooks\.slack\.com/services/[A-Za-z0-9/_-]{20,}",
|
||||
r"https://(?:discord(?:app)?\.com)/api/webhooks/[0-9]+/[A-Za-z0-9._-]{20,}",
|
||||
r"https://[^\s/]*webhook\.office\.com/[^\s]{20,}",
|
||||
)
|
||||
)
|
||||
HIGH_ENTROPY_TOKEN_RE = re.compile(
|
||||
r"(?<![A-Za-z0-9+/_=-])[A-Za-z0-9+/_=-]{48,16384}(?![A-Za-z0-9+/_=-])"
|
||||
)
|
||||
PROSE_LEAD_WORDS = frozenset(
|
||||
"add allow change check describe document ensure explain fix handle keep preserve "
|
||||
"prevent reject remove review support test validate".split()
|
||||
)
|
||||
SENSITIVE_KEY_WORDS = frozenset(
|
||||
"auth authentication authorization auths basic bearer credential credentials "
|
||||
"passwd password pat private sas secret secrets sig signature signing token tokens "
|
||||
"webhook".split()
|
||||
)
|
||||
KEY_MODIFIER_WORDS = frozenset(
|
||||
"access account api auth client encryption identity private registry secret service "
|
||||
"session signing ssh".split()
|
||||
)
|
||||
SENSITIVE_COMPACT_KEYS = frozenset(
|
||||
"accessid accesskeyid accountkey clientemail clientid connectionstring "
|
||||
"dockerconfigjson privatekeyid serviceaccountkey sharedaccesssignature".split()
|
||||
)
|
||||
SENSITIVE_COMPACT_SUFFIXES = tuple(
|
||||
"accesskey accesskeyid accountkey apikey authkey clientemail clientid clientkey "
|
||||
"connectionstring credential credentials encryptionkey identitykey password passwd "
|
||||
"privatekeyid privatekey secret secretkey servicekey sessionkey signature signingkey "
|
||||
"sshkey token webhook".split()
|
||||
)
|
||||
|
||||
|
||||
class PolicyError(ValueError):
|
||||
"""Raised when a requested Forgejo operation exceeds the safe boundary."""
|
||||
|
||||
|
||||
def _validate_repo(repo: str) -> str:
|
||||
if not REPO_RE.fullmatch(repo) or repo in {".", ".."}:
|
||||
raise PolicyError("repository name is outside the Atlas allowlist")
|
||||
return repo
|
||||
|
||||
|
||||
def _reject_forbidden(value: object, name: str, forbidden: tuple[str, ...]) -> None:
|
||||
"""Reject exact runtime credentials before invoking helpers or the network."""
|
||||
if not isinstance(value, str) or any(secret and secret in value for secret in forbidden):
|
||||
raise PolicyError(f"{name} contains runtime credential material")
|
||||
|
||||
|
||||
def _validate_ref_bounds(
|
||||
value: object, name: str, *, forbidden: tuple[str, ...] = ()
|
||||
) -> str:
|
||||
"""Reject oversized or non-encodable refs before invoking Git."""
|
||||
_reject_forbidden(value, name, forbidden)
|
||||
if not value or len(value) > MAX_REF_CHARACTERS:
|
||||
raise PolicyError(f"{name} exceeds the safe branch-name limit")
|
||||
try:
|
||||
encoded = value.encode("utf-8", errors="strict")
|
||||
except UnicodeEncodeError as exc:
|
||||
raise PolicyError(f"{name} is not valid UTF-8 text") from exc
|
||||
if len(encoded) > MAX_REF_UTF8_BYTES:
|
||||
raise PolicyError(f"{name} exceeds the safe UTF-8 branch-name limit")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_ref(
|
||||
value: object, name: str, *, forbidden: tuple[str, ...] = ()
|
||||
) -> str:
|
||||
"""Validate the complete Git ref grammar using Git itself."""
|
||||
value = _validate_ref_bounds(value, name, forbidden=forbidden)
|
||||
if value.startswith("-"):
|
||||
raise PolicyError(f"{name} must be a same-repository branch name")
|
||||
if any(ord(character) < 32 or ord(character) == 127 for character in value):
|
||||
raise PolicyError(f"{name} must be a safe same-repository branch name")
|
||||
result = subprocess.run(
|
||||
[GIT_BIN, "check-ref-format", f"refs/heads/{value}"],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise PolicyError(f"{name} must be a safe same-repository branch name")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_sha(value: object, name: str = "head SHA") -> str:
|
||||
if not isinstance(value, str) or not SHA_RE.fullmatch(value):
|
||||
raise PolicyError(f"{name} must be a full Git SHA-1")
|
||||
return value.lower()
|
||||
|
||||
|
||||
def _validate_pr_number_segment(value: object) -> int:
|
||||
"""Validate a canonical bounded pull-request number from an API path."""
|
||||
if (
|
||||
not isinstance(value, str)
|
||||
or len(value) > MAX_PR_NUMBER_DIGITS
|
||||
or not re.fullmatch(r"[1-9][0-9]*", value)
|
||||
):
|
||||
raise PolicyError("pull-request number must use bounded canonical ASCII digits")
|
||||
number = int(value)
|
||||
if number > MAX_PR_NUMBER:
|
||||
raise PolicyError("pull-request number is outside its allowed range")
|
||||
return number
|
||||
|
||||
|
||||
def _validate_pr_number(value: object) -> int:
|
||||
"""Validate a bounded pull-request number returned by Forgejo."""
|
||||
if (
|
||||
not isinstance(value, int)
|
||||
or isinstance(value, bool)
|
||||
or not 1 <= value <= MAX_PR_NUMBER
|
||||
):
|
||||
raise PolicyError("Forgejo omitted a valid pull-request number")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_text(
|
||||
value: object,
|
||||
name: str,
|
||||
maximum: int,
|
||||
maximum_bytes: int,
|
||||
*,
|
||||
required: bool,
|
||||
) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise PolicyError(f"{name} must be text")
|
||||
if required and not value.strip():
|
||||
raise PolicyError(f"{name} must not be empty")
|
||||
try:
|
||||
encoded = value.encode("utf-8", errors="strict")
|
||||
except UnicodeEncodeError as exc:
|
||||
raise PolicyError(f"{name} is not valid UTF-8 text") from exc
|
||||
if len(value) > maximum or len(encoded) > maximum_bytes or "\x00" in value:
|
||||
raise PolicyError(f"{name} exceeds the safe request limit")
|
||||
return value
|
||||
|
||||
|
||||
def _normalized_key(value: str) -> tuple[tuple[str, ...], str]:
|
||||
"""Split camelCase and separator-based assignment keys into semantics."""
|
||||
separated = CAMEL_BOUNDARY_RE.sub(" ", value.strip(".\"'"))
|
||||
words = tuple(re.findall(r"[A-Za-z0-9]+", separated.lower()))
|
||||
return words, "".join(words)
|
||||
|
||||
|
||||
def _is_sensitive_key(value: str) -> bool:
|
||||
words, compact = _normalized_key(value)
|
||||
word_set = set(words)
|
||||
if word_set & SENSITIVE_KEY_WORDS:
|
||||
return True
|
||||
if compact in SENSITIVE_COMPACT_KEYS:
|
||||
return True
|
||||
if compact.endswith(SENSITIVE_COMPACT_SUFFIXES):
|
||||
return True
|
||||
if "key" in word_set and word_set & KEY_MODIFIER_WORDS:
|
||||
return True
|
||||
if {"client", "email"} <= word_set or {"client", "id"} <= word_set:
|
||||
return True
|
||||
if {"access", "id"} <= word_set or {"connection", "string"} <= word_set:
|
||||
return True
|
||||
return compact.endswith("configjson")
|
||||
|
||||
|
||||
def _strip_assignment_value(value: str) -> tuple[str, bool]:
|
||||
stripped = value.strip()
|
||||
quoted = (
|
||||
len(stripped) >= 2 and stripped[0] in {'"', "'"} and stripped[-1] == stripped[0]
|
||||
)
|
||||
if quoted:
|
||||
stripped = stripped[1:-1].strip()
|
||||
return stripped, quoted
|
||||
|
||||
|
||||
def _looks_like_prose(value: str) -> bool:
|
||||
words = re.findall(r"[A-Za-z]+(?:[-'][A-Za-z]+)?", value)
|
||||
if len(words) < 2:
|
||||
return False
|
||||
if re.search(r"[$`{}\[\]\\@/:=+_]", value):
|
||||
return False
|
||||
return words[0].lower() in PROSE_LEAD_WORDS
|
||||
|
||||
|
||||
def _looks_sensitive_assignment_value(value: str) -> bool:
|
||||
stripped, quoted = _strip_assignment_value(value)
|
||||
if not stripped:
|
||||
return False
|
||||
if quoted or stripped.startswith(("{", "[", "|", ">", "!!", "&", "*")):
|
||||
return True
|
||||
if CREDENTIAL_SCHEME_RE.search(stripped):
|
||||
return True
|
||||
if any(pattern.search(stripped) for pattern in STANDALONE_SECRET_PATTERNS):
|
||||
return True
|
||||
if re.search(r"(?:https?://|[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+)", stripped):
|
||||
return True
|
||||
if re.search(r"(?:\$\{|\$\(|\\[nrt]|[A-Za-z0-9+/]{16,}={0,3}\Z)", stripped):
|
||||
return True
|
||||
return not _looks_like_prose(stripped)
|
||||
|
||||
|
||||
def _is_structural_credential_assignment(key: str, value: str) -> bool:
|
||||
words, compact_key = _normalized_key(key)
|
||||
stripped, _quoted = _strip_assignment_value(value)
|
||||
_value_words, compact_value = _normalized_key(stripped)
|
||||
return (
|
||||
compact_key == "type" and compact_value in {"serviceaccount", "credential"}
|
||||
) or ("service" in words and "account" in words and bool(stripped))
|
||||
|
||||
|
||||
def _decode_json_key(raw: str) -> tuple[str, bool]:
|
||||
"""Decode one bounded JSON key without silently accepting bad escapes."""
|
||||
try:
|
||||
decoded = json.loads(f'"{raw}"')
|
||||
except (json.JSONDecodeError, UnicodeError):
|
||||
approximate = re.sub(r"[^A-Za-z0-9]+", "_", raw.replace("\\", ""))
|
||||
return approximate, False
|
||||
return decoded if isinstance(decoded, str) else "", True
|
||||
|
||||
|
||||
def _json_value_has_sensitive_assignment(
|
||||
value: object, *, depth: int = 0, nodes: list[int] | None = None
|
||||
) -> bool:
|
||||
"""Walk one decoded JSON value with explicit depth and node ceilings."""
|
||||
if nodes is None:
|
||||
nodes = [0]
|
||||
nodes[0] += 1
|
||||
if nodes[0] > MAX_JSON_NODES or depth > MAX_JSON_DEPTH:
|
||||
raise PolicyError("structured pull-request text exceeds the scan limit")
|
||||
if isinstance(value, dict):
|
||||
for key, assigned in value.items():
|
||||
if not isinstance(key, str):
|
||||
continue
|
||||
if _is_sensitive_key(key):
|
||||
return True
|
||||
assigned_text = assigned if isinstance(assigned, str) else ""
|
||||
if _is_structural_credential_assignment(key, assigned_text):
|
||||
return True
|
||||
if _json_value_has_sensitive_assignment(
|
||||
assigned, depth=depth + 1, nodes=nodes
|
||||
):
|
||||
return True
|
||||
elif isinstance(value, list):
|
||||
return any(
|
||||
_json_value_has_sensitive_assignment(item, depth=depth + 1, nodes=nodes)
|
||||
for item in value
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _decoded_json_documents(value: str):
|
||||
"""Yield bounded embedded JSON object or array fragments."""
|
||||
decoder = json.JSONDecoder()
|
||||
index = 0
|
||||
attempts = 0
|
||||
while attempts < MAX_JSON_DOCUMENTS:
|
||||
positions = [
|
||||
position for token in "{[" if (position := value.find(token, index)) >= 0
|
||||
]
|
||||
if not positions:
|
||||
return
|
||||
start = min(positions)
|
||||
attempts += 1
|
||||
try:
|
||||
document, end = decoder.raw_decode(value, start)
|
||||
except (json.JSONDecodeError, RecursionError, ValueError):
|
||||
index = start + 1
|
||||
continue
|
||||
yield document
|
||||
index = max(end, start + 1)
|
||||
if "{" in value[index:] or "[" in value[index:]:
|
||||
raise PolicyError("structured pull-request text exceeds the scan limit")
|
||||
|
||||
|
||||
def _has_structured_sensitive_assignment(value: str) -> bool:
|
||||
"""Detect bounded JSON and multiline YAML/env credential assignments."""
|
||||
for candidate in JSON_KEY_RE.finditer(value):
|
||||
decoded, valid = _decode_json_key(candidate.group("key"))
|
||||
if _is_sensitive_key(decoded):
|
||||
return True
|
||||
if not valid and _is_sensitive_key(candidate.group("key").replace("\\", "")):
|
||||
return True
|
||||
for candidate in MULTILINE_ASSIGNMENT_RE.finditer(value):
|
||||
key = candidate.group("key")
|
||||
assigned = candidate.group("value")
|
||||
if _is_structural_credential_assignment(key, assigned) or (
|
||||
_is_sensitive_key(key) and _looks_sensitive_assignment_value(assigned)
|
||||
):
|
||||
return True
|
||||
return any(
|
||||
_json_value_has_sensitive_assignment(item)
|
||||
for item in _decoded_json_documents(value)
|
||||
)
|
||||
|
||||
|
||||
def _has_high_entropy_token(value: str) -> bool:
|
||||
for match in HIGH_ENTROPY_TOKEN_RE.finditer(value):
|
||||
token = match.group(0)
|
||||
if len(token) > 256:
|
||||
return True
|
||||
groups = sum(
|
||||
bool(re.search(pattern, token))
|
||||
for pattern in (r"[a-z]", r"[A-Z]", r"[0-9]", r"[+/_=-]")
|
||||
)
|
||||
counts = Counter(token)
|
||||
entropy = -sum(
|
||||
(count / len(token)) * log2(count / len(token)) for count in counts.values()
|
||||
)
|
||||
if groups >= 3 and entropy >= 4.0:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _reject_sensitive(value: str, name: str, forbidden: tuple[str, ...]) -> None:
|
||||
if any(secret and secret in value for secret in forbidden):
|
||||
raise PolicyError(f"pull-request {name} contains runtime credential material")
|
||||
if CREDENTIAL_SCHEME_RE.search(value):
|
||||
raise PolicyError(f"pull-request {name} resembles credential material")
|
||||
if any(pattern.search(value) for pattern in STANDALONE_SECRET_PATTERNS):
|
||||
raise PolicyError(f"pull-request {name} resembles credential material")
|
||||
if _has_structured_sensitive_assignment(value):
|
||||
raise PolicyError(f"pull-request {name} resembles credential material")
|
||||
for candidate in ASSIGNMENT_RE.finditer(value):
|
||||
key = candidate.group("key")
|
||||
assigned = candidate.group("value")
|
||||
if _is_structural_credential_assignment(key, assigned) or (
|
||||
_is_sensitive_key(key) and _looks_sensitive_assignment_value(assigned)
|
||||
):
|
||||
raise PolicyError(f"pull-request {name} resembles credential material")
|
||||
if _has_high_entropy_token(value):
|
||||
raise PolicyError(f"pull-request {name} resembles credential material")
|
||||
|
||||
|
||||
def _validate_body(value: object, *, forbidden: tuple[str, ...] = ()) -> str:
|
||||
body = _validate_text(value, "body", 16384, MAX_BODY_UTF8_BYTES, required=False)
|
||||
_reject_sensitive(body, "body", forbidden)
|
||||
return body
|
||||
|
||||
|
||||
def _draft_title(value: object, *, forbidden: tuple[str, ...] = ()) -> str:
|
||||
"""Return a bounded, secret-screened Gitea draft title."""
|
||||
title = _validate_text(
|
||||
value, "title", 251, MAX_TITLE_UTF8_BYTES, required=True
|
||||
).strip()
|
||||
_reject_sensitive(title, "title", forbidden)
|
||||
for prefix in ("WIP:", "[WIP]"):
|
||||
if title.upper().startswith(prefix):
|
||||
title = title[len(prefix) :].lstrip()
|
||||
break
|
||||
if not title:
|
||||
raise PolicyError("title must contain text after the draft prefix")
|
||||
return DRAFT_TITLE_PREFIX + title
|
||||
|
||||
|
||||
def _validate_query(target: urllib.parse.SplitResult, allowed: set[str]) -> None:
|
||||
"""Accept only short canonical ASCII query strings with bounded pagination."""
|
||||
raw = target.query
|
||||
if (
|
||||
len(raw) > MAX_QUERY_LENGTH
|
||||
or not raw.isascii()
|
||||
or not CANONICAL_QUERY_RE.fullmatch(raw)
|
||||
or "%" in raw
|
||||
):
|
||||
raise PolicyError("API query must use short canonical ASCII form")
|
||||
try:
|
||||
pairs = urllib.parse.parse_qsl(raw, keep_blank_values=True, strict_parsing=True)
|
||||
except ValueError as exc:
|
||||
raise PolicyError("invalid API query") from exc
|
||||
if len(pairs) > MAX_QUERY_ITEMS:
|
||||
raise PolicyError("too many API query parameters")
|
||||
if len({key for key, _ in pairs}) != len(pairs):
|
||||
raise PolicyError("duplicate API query parameters are not allowed")
|
||||
if any(
|
||||
not key
|
||||
or len(key) > MAX_QUERY_KEY_LENGTH
|
||||
or len(value) > MAX_QUERY_VALUE_LENGTH
|
||||
for key, value in pairs
|
||||
):
|
||||
raise PolicyError("API query key or value exceeds its safe limit")
|
||||
if any(key not in allowed for key, _ in pairs):
|
||||
raise PolicyError("API query parameter is outside the read allowlist")
|
||||
values = dict(pairs)
|
||||
for name in ("page", "limit"):
|
||||
if name not in values:
|
||||
continue
|
||||
if not re.fullmatch(r"[0-9]+", values[name]):
|
||||
raise PolicyError(f"{name} must use ASCII decimal digits")
|
||||
number = int(values[name])
|
||||
if values[name] != str(number):
|
||||
raise PolicyError(f"{name} must use canonical ASCII decimal form")
|
||||
ceiling = MAX_PAGE if name == "page" else MAX_LIMIT
|
||||
if not 1 <= number <= ceiling:
|
||||
raise PolicyError(f"{name} is outside its allowed range")
|
||||
if "state" in values and values["state"] not in {"open", "closed", "all"}:
|
||||
raise PolicyError("pull-request state is invalid")
|
||||
317
services/hermes/scm-common/scripts/scm_broker.py
Normal file
317
services/hermes/scm-common/scripts/scm_broker.py
Normal 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())
|
||||
95
services/hermes/scm-common/scripts/scm_broker_client.py
Normal file
95
services/hermes/scm-common/scripts/scm_broker_client.py
Normal 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,
|
||||
)
|
||||
@ -1,19 +0,0 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
case "${1:-}" in
|
||||
*Username*)
|
||||
if [ -s /runtime-access/gitea-username ]; then
|
||||
tr -d '\r\n' </runtime-access/gitea-username
|
||||
else
|
||||
printf '%s' hermes-automation
|
||||
fi
|
||||
printf '\n'
|
||||
;;
|
||||
*Password*)
|
||||
test -s /runtime-access/gitea-token
|
||||
tr -d '\r\n' </runtime-access/gitea-token
|
||||
printf '\n'
|
||||
;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
@ -24,8 +24,10 @@ from hermes_model_routing import (
|
||||
|
||||
|
||||
CASSANDRA_BASE_PATH = Path("/opt/data/workspace/projects/cassandra")
|
||||
CASSANDRA_REMOTE = "https://scm.bstein.dev/atlas/cassandra.git"
|
||||
DEFAULT_GITEA_TOKEN_PATH = Path("/runtime-access/gitea-token")
|
||||
CASSANDRA_REMOTE = (
|
||||
"http://hermes-scm-broker.hermes-scm.svc.cluster.local:9081/"
|
||||
"git/atlas/cassandra.git"
|
||||
)
|
||||
|
||||
|
||||
def cassandra_workspace() -> Path:
|
||||
@ -139,24 +141,8 @@ def bootstrap_cassandra_state(root: Path) -> dict[str, str]:
|
||||
return {"state": "ready"}
|
||||
|
||||
|
||||
def _gitea_token_path() -> Path:
|
||||
"""Return the runtime-only Gitea credential path without reading it."""
|
||||
configured = os.environ.get("HERMES_GITEA_TOKEN_FILE", "").strip()
|
||||
return Path(configured) if configured else DEFAULT_GITEA_TOKEN_PATH
|
||||
|
||||
|
||||
def _gitea_token_available() -> bool:
|
||||
"""Check credential readiness without loading it into process memory."""
|
||||
try:
|
||||
path = _gitea_token_path()
|
||||
return path.is_file() and path.stat().st_size > 0
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def sync_cassandra_repo(env_values: dict[str, str]) -> str:
|
||||
"""Clone or fetch Cassandra through the runtime-only askpass credential."""
|
||||
token_available = _gitea_token_available()
|
||||
"""Clone or fetch Cassandra through the credential-isolated SCM broker."""
|
||||
if shutil.which("git") is None:
|
||||
return "git-unavailable"
|
||||
child_env = os.environ.copy()
|
||||
@ -173,9 +159,7 @@ def sync_cassandra_repo(env_values: dict[str, str]) -> str:
|
||||
}
|
||||
}
|
||||
)
|
||||
child_env["GIT_ASKPASS"] = env_values.get(
|
||||
"GIT_ASKPASS", "/opt/coordinator/gitea_askpass.sh"
|
||||
)
|
||||
child_env.pop("GIT_ASKPASS", None)
|
||||
child_env["GIT_TERMINAL_PROMPT"] = "0"
|
||||
if (CASSANDRA_BASE_PATH / ".git").exists():
|
||||
try:
|
||||
@ -232,8 +216,6 @@ def sync_cassandra_repo(env_values: dict[str, str]) -> str:
|
||||
return f"remote-repair-failed-{repaired.returncode}"
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return "remote-repair-failed"
|
||||
if not token_available:
|
||||
return "ready; fetch skipped until Gitea token is configured"
|
||||
command = [
|
||||
"git",
|
||||
"-C",
|
||||
@ -247,8 +229,6 @@ def sync_cassandra_repo(env_values: dict[str, str]) -> str:
|
||||
CASSANDRA_BASE_PATH.mkdir(parents=True, exist_ok=True)
|
||||
if any(CASSANDRA_BASE_PATH.iterdir()):
|
||||
return "unmanaged-nonempty-directory"
|
||||
if not token_available:
|
||||
return "awaiting-gitea-token"
|
||||
command = [
|
||||
"git",
|
||||
"clone",
|
||||
|
||||
456
services/hermes/scripts/node_account_hardening.py
Normal file
456
services/hermes/scripts/node_account_hardening.py
Normal 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())
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
```
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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" \
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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/1",
|
||||
" 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=0",
|
||||
"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", "12", "%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'"}'
|
||||
),
|
||||
)
|
||||
|
||||
@ -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"]
|
||||
)
|
||||
|
||||
305
testing/tests/test_hermes_node_account_hardening.py
Normal file
305
testing/tests/test_hermes_node_account_hardening.py
Normal 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)
|
||||
@ -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):
|
||||
|
||||
352
testing/tests/test_hermes_scm_broker.py
Normal file
352
testing/tests/test_hermes_scm_broker.py
Normal 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", "12"])
|
||||
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
|
||||
Loading…
x
Reference in New Issue
Block a user