Compare commits
4 Commits
86e9fbbfec
...
8b5f26b273
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b5f26b273 | ||
|
|
49b2fa00e4 | ||
|
|
80793a8f63 | ||
|
|
465cf9146b |
@ -1,18 +0,0 @@
|
|||||||
# 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
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
# 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
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
# 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
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
# 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,4 +60,3 @@ spec:
|
|||||||
- name: keycloak
|
- name: keycloak
|
||||||
- name: longhorn
|
- name: longhorn
|
||||||
- name: vault
|
- name: vault
|
||||||
- name: hermes-scm-broker
|
|
||||||
|
|||||||
@ -27,11 +27,7 @@ resources:
|
|||||||
- jenkins/kustomization.yaml
|
- jenkins/kustomization.yaml
|
||||||
- ai-llm/kustomization.yaml
|
- ai-llm/kustomization.yaml
|
||||||
- openclaw/kustomization.yaml
|
- openclaw/kustomization.yaml
|
||||||
- hermes-scm-namespace/kustomization.yaml
|
|
||||||
- hermes-scm-broker-code/kustomization.yaml
|
|
||||||
- hermes/kustomization.yaml
|
- hermes/kustomization.yaml
|
||||||
- hermes-observer-rbac/kustomization.yaml
|
|
||||||
- hermes-scm-broker/kustomization.yaml
|
|
||||||
- hermes-chat/kustomization.yaml
|
- hermes-chat/kustomization.yaml
|
||||||
- hermes-triage-demo/kustomization.yaml
|
- hermes-triage-demo/kustomization.yaml
|
||||||
- game-stream/kustomization.yaml
|
- game-stream/kustomization.yaml
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
apiVersion: batch/v1
|
apiVersion: batch/v1
|
||||||
kind: Job
|
kind: Job
|
||||||
metadata:
|
metadata:
|
||||||
name: gitea-atlas-identity-bootstrap-4
|
name: gitea-atlas-identity-bootstrap-3
|
||||||
namespace: gitea
|
namespace: gitea
|
||||||
labels:
|
labels:
|
||||||
app.kubernetes.io/name: gitea
|
app.kubernetes.io/name: gitea
|
||||||
|
|||||||
@ -19,7 +19,6 @@ source_owner="${GITEA_SOURCE_OWNER:-bstein}"
|
|||||||
managed_repositories="${GITEA_MANAGED_REPOSITORIES:-hermes-code-demo cassandra soteria pegasus metis ananke ariadne typhon titan-iac}"
|
managed_repositories="${GITEA_MANAGED_REPOSITORIES:-hermes-code-demo cassandra soteria pegasus metis ananke ariadne typhon titan-iac}"
|
||||||
contributor_team=Contributors
|
contributor_team=Contributors
|
||||||
contributor_units='["repo.actions","repo.packages","repo.code","repo.issues","repo.wiki","repo.pulls","repo.releases","repo.projects"]'
|
contributor_units='["repo.actions","repo.packages","repo.code","repo.issues","repo.wiki","repo.pulls","repo.releases","repo.projects"]'
|
||||||
protected_reviewer=bstein
|
|
||||||
|
|
||||||
die() {
|
die() {
|
||||||
echo "Gitea Atlas identity bootstrap failed: $*" >&2
|
echo "Gitea Atlas identity bootstrap failed: $*" >&2
|
||||||
@ -273,63 +272,6 @@ transfer_managed_repositories() {
|
|||||||
trap - EXIT HUP INT TERM
|
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
|
wait_for_gitea
|
||||||
ensure_user "${reconciler_user}" "${reconciler_email}" true
|
ensure_user "${reconciler_user}" "${reconciler_email}" true
|
||||||
ensure_user "${hermes_user}" "${hermes_email}" false
|
ensure_user "${hermes_user}" "${hermes_email}" false
|
||||||
@ -376,10 +318,6 @@ expect_status "${status}" 204 "add Hermes to Atlas contributors"
|
|||||||
|
|
||||||
transfer_managed_repositories
|
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 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}\"}}"
|
vault_write hermes/developer-gitea "{\"data\":{\"username\":\"${hermes_user}\",\"email\":\"${hermes_email}\",\"token\":\"${hermes_token}\",\"base_url\":\"${public_url}\"}}"
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +0,0 @@
|
|||||||
# services/hermes-observer-rbac/kustomization.yaml
|
|
||||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
|
||||||
kind: Kustomization
|
|
||||||
resources:
|
|
||||||
- rbac.yaml
|
|
||||||
- rolebindings.yaml
|
|
||||||
@ -1,112 +0,0 @@
|
|||||||
# 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
|
|
||||||
@ -1,204 +0,0 @@
|
|||||||
# 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}
|
|
||||||
@ -1,97 +0,0 @@
|
|||||||
# 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
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
# 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
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
# 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}
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
# 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
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
# services/hermes-scm-namespace/kustomization.yaml
|
|
||||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
|
||||||
kind: Kustomization
|
|
||||||
resources:
|
|
||||||
- namespace.yaml
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
# 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
|
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
|
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
|
need a synthetic Kanban lookup, and must load a skill only when its workflow
|
||||||
materially applies. Atlas Git access is supplied by the isolated SCM
|
materially applies. Atlas HTTPS Git authentication is already supplied by
|
||||||
broker; no repository token is present in this pod. Use only configured
|
the runtime-only `GIT_ASKPASS`; use it without reading or exposing the
|
||||||
broker remotes and clients. Coordinator guidance lives at
|
credential. Coordinator guidance lives at
|
||||||
`/opt/data/workspace/AGENTS.md` when more detail is needed.
|
`/opt/data/workspace/AGENTS.md` when more detail is needed.
|
||||||
|
|
||||||
The Jetson classifier is mandatory for AUTO selection. Switchyard may use
|
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
|
objective's difficulty warrants it; otherwise synthesize at the original
|
||||||
effort.
|
effort.
|
||||||
|
|
||||||
This owner-only pod has scoped read-only Kubernetes diagnostics across
|
This owner-only pod has cluster-admin access across Atlas, including logs,
|
||||||
Atlas, including resource status, events, logs, and Flux/Helm evidence. It
|
Secrets, exec, port-forwarding, rollout operations, and Flux reconciliation.
|
||||||
cannot read Secrets, exec or attach to pods, create service-account tokens,
|
Prefer the titan-iac Git/Flux workflow for every durable cluster change;
|
||||||
mutate workloads or RBAC, or reconcile Flux. Put every durable cluster
|
direct operations are available for explicit operator requests, incident
|
||||||
change on a reviewed titan-iac branch. Never expose credentials in chat or
|
recovery, and verification, and must be followed by a matching source-of-
|
||||||
logs. Triage belongs at triage.hermes.bstein.dev.
|
truth change when they alter desired state. Never expose credentials in
|
||||||
|
chat or logs. Triage belongs at triage.hermes.bstein.dev.
|
||||||
|
|
||||||
## Atlas engineering access
|
## Atlas engineering access
|
||||||
|
|
||||||
The Atlas organization has private visibility. Repository visibility is
|
The Atlas organization has private visibility. Repository visibility is
|
||||||
preserved per project and may be public or private; do not infer a
|
preserved per project and may be public or private; do not infer a
|
||||||
repository's visibility from the organization setting. Repositories are
|
repository's visibility from the organization setting. Repositories are
|
||||||
canonical at `https://scm.bstein.dev/atlas/<repo>.git`; worker Git traffic
|
canonical at `https://scm.bstein.dev/atlas/<repo>.git`. HTTPS Git
|
||||||
uses `http://hermes-scm-broker.hermes-scm.svc.cluster.local:9081/git/atlas/<repo>.git`.
|
authentication is already supplied through `GIT_ASKPASS`. Verify the remote
|
||||||
Verify the broker remote
|
|
||||||
and cleanly separate pre-existing changes, create a task branch, run the
|
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
|
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
|
real branch only when the requested implementation is review-ready. Never
|
||||||
@ -318,13 +318,11 @@ data:
|
|||||||
`scm.bstein.dev` is Forgejo/Gitea, not GitHub. Never load or follow a
|
`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
|
GitHub/`gh` skill for an Atlas remote, and do not interpret an
|
||||||
unauthenticated Gitea HTTP 404 as a missing private repository. Use
|
unauthenticated Gitea HTTP 404 as a missing private repository. Use
|
||||||
brokered Git for clone, fetch, and creation of a new namespaced feature
|
authenticated Git for clone, fetch, and non-force push. For bounded
|
||||||
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
|
repository/pull-request evidence or to create a review-ready draft PR, load
|
||||||
`$manage-atlas-pull-requests` and use `/opt/scm/gitea_api.py`. The
|
`$manage-atlas-pull-requests` and use `/opt/coordinator/gitea_api.py`. The
|
||||||
client carries no repository credential; a separate least-authority
|
client injects the runtime Vault token without exposing it to the command
|
||||||
workload performs the allowed operation. It permits only Atlas-scoped
|
line, environment, output, or transcript. It permits only Atlas-scoped
|
||||||
metadata reads and verified same-repository draft creation. Updates, merge,
|
metadata reads and verified same-repository draft creation. Updates, merge,
|
||||||
approve, close, delete, comments, repository administration, and force-push
|
approve, close, delete, comments, repository administration, and force-push
|
||||||
are unavailable; never bypass the client with raw HTTP. Leave every PR
|
are unavailable; never bypass the client with raw HTTP. Leave every PR
|
||||||
@ -332,10 +330,14 @@ data:
|
|||||||
verify the exact base/head ancestry and diff, run the relevant tests, and
|
verify the exact base/head ancestry and diff, run the relevant tests, and
|
||||||
confirm the candidate Jenkins result before presenting a handoff. Prefer
|
confirm the candidate Jenkins result before presenting a handoff. Prefer
|
||||||
the internal endpoint in
|
the internal endpoint in
|
||||||
`JENKINS_BASE_URL`; when Jenkins API authorization prevents a read, use
|
`JENKINS_BASE_URL`; when Jenkins API authorization prevents a read, use the
|
||||||
Kubernetes pod logs and status evidence without exec or secret access.
|
existing cluster access to inspect the controller's job/build files and
|
||||||
Do not bypass the boundary to inspect controller files. If Brad separately
|
logs rather than guessing a public hostname. The preferred read-only path
|
||||||
reports that he merged the PR, observe
|
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
|
||||||
the default-branch build to a terminal result and report exact commit,
|
the default-branch build to a terminal result and report exact commit,
|
||||||
build, and test evidence.
|
build, and test evidence.
|
||||||
|
|
||||||
@ -347,21 +349,19 @@ data:
|
|||||||
|
|
||||||
The terminal PATH contains the pinned operator tools. Start cluster work
|
The terminal PATH contains the pinned operator tools. Start cluster work
|
||||||
with `kubectl config current-context`, read-only status/events/logs, and the
|
with `kubectl config current-context`, read-only status/events/logs, and the
|
||||||
relevant `titan-iac` manifests. The broker namespace is deliberately
|
relevant `titan-iac` manifests. Put durable desired-state changes on a
|
||||||
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.
|
reviewable `titan-iac` branch and validate Kustomize and client dry-run.
|
||||||
Brad owns merge and Flux reconciliation. Direct `kubectl` mutation is not
|
Brad owns merge and routine Flux reconciliation; perform either only under
|
||||||
available to the agent; hand an explicit incident mutation back to Brad.
|
a separate explicit operator request. Direct `kubectl` mutations are for
|
||||||
|
explicit incident recovery or ephemeral verification, not ordinary
|
||||||
|
delivery.
|
||||||
|
|
||||||
Node SSH uses the dedicated locked-password `hermes-agent` OS account:
|
Node SSH uses a dedicated audited Hermes identity: `ssh titan-04` (or any
|
||||||
`ssh titan-04` (or any current Kubernetes node name). Host keys are pinned,
|
current Kubernetes node name). Host keys are pinned and password fallback
|
||||||
password fallback is disabled, and the account has no sudo, disk, runtime,
|
is disabled. Use SSH only for host-level evidence or repairs that cannot be
|
||||||
or Kubernetes-storage authority. Use it for unprivileged host evidence;
|
performed through Kubernetes. Read first, identify the exact node and
|
||||||
hand privileged host repair back to Brad and reflect persistent changes in
|
impact, avoid fleet-wide destructive commands, and reflect persistent host
|
||||||
the tracked provisioning source.
|
configuration in the appropriate tracked provisioning source.
|
||||||
|
|
||||||
Treat every credential, SSH identity, host-trust record, and
|
Treat every credential, SSH identity, host-trust record, and
|
||||||
credential-bearing access-client state as runtime-only Vault data. Keep
|
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
|
<low|medium|high|xhigh> [model]` for a persistent override. The first native
|
||||||
Codex worker requires one device-code login; refreshed provider credentials
|
Codex worker requires one device-code login; refreshed provider credentials
|
||||||
persist through Vault while client caches remain disposable. The owner
|
persist through Vault while client caches remain disposable. The owner
|
||||||
workspace includes scoped read-only Kubernetes diagnostics plus `kubectl`,
|
workspace includes cluster-admin Kubernetes
|
||||||
`flux`, `helm`, `kustomize`, `vault`, `sops`, `age`,
|
access plus `kubectl`, `flux`, `helm`, `kustomize`, `vault`, `sops`, `age`,
|
||||||
`terraform`, `k9s`, `jq`, `yq`, `gh`, Git, SSH, Python, Node, the
|
`terraform`, `k9s`, `jq`, `yq`, `gh`, Git, SSH, Python, Node, the
|
||||||
browser/computer tools, and the native provider CLIs.
|
browser/computer tools, and the native provider CLIs.
|
||||||
|
|||||||
@ -46,6 +46,16 @@ spec:
|
|||||||
{{- with secret "kv/data/atlas/hermes/agent-tokens" -}}
|
{{- with secret "kv/data/atlas/hermes/agent-tokens" -}}
|
||||||
{{ .Data.data.codex_auth_json }}
|
{{ .Data.data.codex_auth_json }}
|
||||||
{{- end }}
|
{{- 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-secret-node-ssh-private-key: kv/data/atlas/hermes/developer-ssh
|
||||||
vault.hashicorp.com/agent-inject-template-node-ssh-private-key: |
|
vault.hashicorp.com/agent-inject-template-node-ssh-private-key: |
|
||||||
{{- with secret "kv/data/atlas/hermes/developer-ssh" -}}
|
{{- with secret "kv/data/atlas/hermes/developer-ssh" -}}
|
||||||
@ -175,8 +185,9 @@ spec:
|
|||||||
chmod 0600 "${profile_env}"
|
chmod 0600 "${profile_env}"
|
||||||
chown 10000:10000 "${profile_env}"
|
chown 10000:10000 "${profile_env}"
|
||||||
done
|
done
|
||||||
|
upsert_env GIT_ASKPASS /opt/coordinator/gitea_askpass.sh
|
||||||
upsert_env GIT_TERMINAL_PROMPT 0
|
upsert_env GIT_TERMINAL_PROMPT 0
|
||||||
upsert_env HERMES_SCM_BROKER_URL http://hermes-scm-broker.hermes-scm.svc.cluster.local:9081
|
upsert_env GITEA_BASE_URL https://scm.bstein.dev
|
||||||
upsert_env JENKINS_BASE_URL http://jenkins.jenkins.svc.cluster.local:8080
|
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 ARIADNE_BASE_URL http://ariadne.maintenance.svc.cluster.local
|
||||||
upsert_env VICTORIA_METRICS_URL http://victoria-metrics-single-server.monitoring.svc.cluster.local:8428
|
upsert_env VICTORIA_METRICS_URL http://victoria-metrics-single-server.monitoring.svc.cluster.local:8428
|
||||||
@ -652,7 +663,6 @@ spec:
|
|||||||
- {name: kubeconfig, mountPath: /opt/data/home/.kube/config, subPath: config, readOnly: true}
|
- {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: 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: 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: routing-catalog, mountPath: /routing-catalog, readOnly: true}
|
||||||
- {name: tmp, mountPath: /tmp}
|
- {name: tmp, mountPath: /tmp}
|
||||||
startupProbe:
|
startupProbe:
|
||||||
@ -817,7 +827,6 @@ spec:
|
|||||||
- {name: kubeconfig, mountPath: /opt/data/home/.kube/config, subPath: config, readOnly: true}
|
- {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: 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: 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: routing-catalog, mountPath: /routing-catalog, readOnly: true}
|
||||||
- {name: tmp, mountPath: /tmp}
|
- {name: tmp, mountPath: /tmp}
|
||||||
- {name: ttyd-index, mountPath: /ttyd-index, readOnly: true}
|
- {name: ttyd-index, mountPath: /ttyd-index, readOnly: true}
|
||||||
@ -887,7 +896,6 @@ spec:
|
|||||||
- {name: kubeconfig, mountPath: /opt/data/home/.kube/config, subPath: config, readOnly: true}
|
- {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: 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: 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}
|
- {name: tmp, mountPath: /tmp}
|
||||||
resources:
|
resources:
|
||||||
requests: {cpu: 100m, memory: 256Mi}
|
requests: {cpu: 100m, memory: 256Mi}
|
||||||
@ -1188,10 +1196,6 @@ spec:
|
|||||||
items:
|
items:
|
||||||
- {key: SKILL.md, path: SKILL.md}
|
- {key: SKILL.md, path: SKILL.md}
|
||||||
- {key: openai.yaml, path: agents/openai.yaml}
|
- {key: openai.yaml, path: agents/openai.yaml}
|
||||||
- name: scm-boundary
|
|
||||||
configMap:
|
|
||||||
name: hermes-scm-boundary
|
|
||||||
defaultMode: 0555
|
|
||||||
- name: image-policy
|
- name: image-policy
|
||||||
configMap:
|
configMap:
|
||||||
name: hermes-image-policy
|
name: hermes-image-policy
|
||||||
|
|||||||
@ -11,9 +11,9 @@ users:
|
|||||||
user:
|
user:
|
||||||
tokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token
|
tokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token
|
||||||
contexts:
|
contexts:
|
||||||
- name: atlas-observer
|
- name: atlas-owner
|
||||||
context:
|
context:
|
||||||
cluster: atlas
|
cluster: atlas
|
||||||
user: hermes-agent
|
user: hermes-agent
|
||||||
namespace: default
|
namespace: default
|
||||||
current-context: atlas-observer
|
current-context: atlas-owner
|
||||||
|
|||||||
16
services/hermes/agent-rbac.yaml
Normal file
16
services/hermes/agent-rbac.yaml
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# 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
|
digest: sha256:37ebf720c783ae908a602916ffccf88d43d205a157957f5dc4b487867aee45e7
|
||||||
resources:
|
resources:
|
||||||
- namespace.yaml
|
- namespace.yaml
|
||||||
- scm-common
|
|
||||||
- vault-serviceaccount.yaml
|
- vault-serviceaccount.yaml
|
||||||
- configmap.yaml
|
- configmap.yaml
|
||||||
- agent-configmap.yaml
|
- agent-configmap.yaml
|
||||||
- chat-configmap.yaml
|
- chat-configmap.yaml
|
||||||
- switchyard-configmap.yaml
|
- switchyard-configmap.yaml
|
||||||
- rbac.yaml
|
- rbac.yaml
|
||||||
|
- agent-rbac.yaml
|
||||||
- node-ssh-access.yaml
|
- node-ssh-access.yaml
|
||||||
- pvc.yaml
|
- pvc.yaml
|
||||||
- switchyard-pvc.yaml
|
- switchyard-pvc.yaml
|
||||||
@ -69,6 +69,9 @@ configMapGenerator:
|
|||||||
- classifier_broker.py=scripts/classifier_broker.py
|
- classifier_broker.py=scripts/classifier_broker.py
|
||||||
- claude_oauth_broker.py=scripts/claude_oauth_broker.py
|
- claude_oauth_broker.py=scripts/claude_oauth_broker.py
|
||||||
- worker_route_broker.py=scripts/worker_route_broker.py
|
- worker_route_broker.py=scripts/worker_route_broker.py
|
||||||
|
- gitea_api.py=scripts/gitea_api.py
|
||||||
|
- gitea_api_policy.py=scripts/gitea_api_policy.py
|
||||||
|
- gitea_askpass.sh=scripts/gitea_askpass.sh
|
||||||
- hermes_coordinator.py=scripts/hermes_coordinator.py
|
- hermes_coordinator.py=scripts/hermes_coordinator.py
|
||||||
- hermes_model_routing.py=scripts/hermes_model_routing.py
|
- hermes_model_routing.py=scripts/hermes_model_routing.py
|
||||||
- hermes_stt_client.py=scripts/hermes_stt_client.py
|
- hermes_stt_client.py=scripts/hermes_stt_client.py
|
||||||
@ -102,12 +105,6 @@ configMapGenerator:
|
|||||||
- config=agent-kubeconfig.yaml
|
- config=agent-kubeconfig.yaml
|
||||||
options:
|
options:
|
||||||
disableNameSuffixHash: true
|
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
|
- name: hermes-auto-router-plugin
|
||||||
namespace: hermes
|
namespace: hermes
|
||||||
files:
|
files:
|
||||||
|
|||||||
@ -119,65 +119,11 @@ spec:
|
|||||||
app: server
|
app: server
|
||||||
ports:
|
ports:
|
||||||
- {protocol: TCP, port: 9010}
|
- {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:
|
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
|
apiVersion: networking.k8s.io/v1
|
||||||
kind: NetworkPolicy
|
kind: NetworkPolicy
|
||||||
|
|||||||
@ -45,15 +45,32 @@ spec:
|
|||||||
- operator: Exists
|
- operator: Exists
|
||||||
containers:
|
containers:
|
||||||
- name: key-reconciler
|
- name: key-reconciler
|
||||||
image: registry.bstein.dev/bstein/hermes-agent
|
image: busybox:1.37
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
command: [/bin/sh, -ec]
|
command: [/bin/sh, -ec]
|
||||||
args:
|
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
|
while true; do
|
||||||
/opt/hermes/.venv/bin/python \
|
reconcile
|
||||||
/opt/node-hardener/node_account_hardening.py \
|
|
||||||
--public-key-file /vault/secrets/node-ssh-public-key
|
|
||||||
sleep 300
|
sleep 300
|
||||||
done
|
done
|
||||||
securityContext:
|
securityContext:
|
||||||
@ -66,18 +83,8 @@ spec:
|
|||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: host-home
|
- name: host-home
|
||||||
mountPath: /host-home
|
mountPath: /host-home
|
||||||
- name: host-etc
|
- name: host-passwd
|
||||||
mountPath: /host-etc
|
mountPath: /host-etc/passwd
|
||||||
- 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
|
readOnly: true
|
||||||
- name: vault-secrets
|
- name: vault-secrets
|
||||||
mountPath: /vault/secrets
|
mountPath: /vault/secrets
|
||||||
@ -87,39 +94,19 @@ spec:
|
|||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
cpu: 5m
|
cpu: 5m
|
||||||
memory: 32Mi
|
memory: 8Mi
|
||||||
limits:
|
limits:
|
||||||
cpu: 50m
|
cpu: 50m
|
||||||
memory: 128Mi
|
memory: 32Mi
|
||||||
volumes:
|
volumes:
|
||||||
- name: host-home
|
- name: host-home
|
||||||
hostPath:
|
hostPath:
|
||||||
path: /home
|
path: /home
|
||||||
type: Directory
|
type: Directory
|
||||||
- name: host-etc
|
- name: host-passwd
|
||||||
hostPath:
|
hostPath:
|
||||||
path: /etc
|
path: /etc/passwd
|
||||||
type: Directory
|
type: File
|
||||||
- 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
|
- name: vault-secrets
|
||||||
csi:
|
csi:
|
||||||
driver: secrets-store.csi.k8s.io
|
driver: secrets-store.csi.k8s.io
|
||||||
|
|||||||
@ -1,12 +0,0 @@
|
|||||||
# 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
|
|
||||||
@ -1,317 +0,0 @@
|
|||||||
#!/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())
|
|
||||||
@ -1,95 +0,0 @@
|
|||||||
#!/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,
|
|
||||||
)
|
|
||||||
126
services/hermes/scm-common/scripts/gitea_api.py → services/hermes/scripts/gitea_api.py
Normal file → Executable file
126
services/hermes/scm-common/scripts/gitea_api.py → services/hermes/scripts/gitea_api.py
Normal file → Executable file
@ -4,7 +4,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import base64
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@ -18,7 +17,6 @@ from pathlib import Path
|
|||||||
from gitea_api_policy import (
|
from gitea_api_policy import (
|
||||||
PolicyError,
|
PolicyError,
|
||||||
_draft_title,
|
_draft_title,
|
||||||
_reject_forbidden,
|
|
||||||
_validate_body,
|
_validate_body,
|
||||||
_validate_pr_number,
|
_validate_pr_number,
|
||||||
_validate_pr_number_segment,
|
_validate_pr_number_segment,
|
||||||
@ -31,7 +29,7 @@ from gitea_api_policy import (
|
|||||||
|
|
||||||
CANONICAL_BASE_URL = "https://scm.bstein.dev"
|
CANONICAL_BASE_URL = "https://scm.bstein.dev"
|
||||||
ALLOWED_OWNER = "atlas"
|
ALLOWED_OWNER = "atlas"
|
||||||
DEFAULT_TOKEN_FILE = Path("/vault/secrets/gitea-token")
|
DEFAULT_TOKEN_FILE = Path("/runtime-access/gitea-token")
|
||||||
CREATE_PATH_RE = re.compile(r"/api/v1/repos/atlas/([^/]+)/pulls\Z")
|
CREATE_PATH_RE = re.compile(r"/api/v1/repos/atlas/([^/]+)/pulls\Z")
|
||||||
MAX_ERROR_BYTES = 8192
|
MAX_ERROR_BYTES = 8192
|
||||||
MAX_RESPONSE_BYTES = 2 * 1024 * 1024
|
MAX_RESPONSE_BYTES = 2 * 1024 * 1024
|
||||||
@ -97,16 +95,13 @@ def _split_api_path(path: str) -> urllib.parse.SplitResult:
|
|||||||
return target
|
return target
|
||||||
|
|
||||||
|
|
||||||
def _authorize_read(
|
def _authorize_read(target: urllib.parse.SplitResult) -> str:
|
||||||
target: urllib.parse.SplitResult, *, forbidden: tuple[str, ...] = ()
|
|
||||||
) -> str:
|
|
||||||
"""Allow only repository, PR, branch, commit, and status metadata reads."""
|
"""Allow only repository, PR, branch, commit, and status metadata reads."""
|
||||||
prefix = "/api/v1/repos/atlas/"
|
prefix = "/api/v1/repos/atlas/"
|
||||||
remainder = target.path.removeprefix(prefix)
|
remainder = target.path.removeprefix(prefix)
|
||||||
if remainder == target.path:
|
if remainder == target.path:
|
||||||
raise PolicyError("reads are limited to explicit Atlas repository metadata")
|
raise PolicyError("reads are limited to explicit Atlas repository metadata")
|
||||||
repo, separator, suffix = remainder.partition("/")
|
repo, separator, suffix = remainder.partition("/")
|
||||||
_reject_forbidden(repo, "repository", forbidden)
|
|
||||||
_validate_repo(repo)
|
_validate_repo(repo)
|
||||||
|
|
||||||
if not separator:
|
if not separator:
|
||||||
@ -162,20 +157,14 @@ def api_url(base_url: str, path: str) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def authorize_request(
|
def authorize_request(method: str, path: str, data: object | None) -> str:
|
||||||
method: str,
|
|
||||||
path: str,
|
|
||||||
data: object | None,
|
|
||||||
*,
|
|
||||||
forbidden: tuple[str, ...] = (),
|
|
||||||
) -> str:
|
|
||||||
"""Validate one request and return its bounded operation name."""
|
"""Validate one request and return its bounded operation name."""
|
||||||
normalized_method = method.upper()
|
normalized_method = method.upper()
|
||||||
target = _split_api_path(path)
|
target = _split_api_path(path)
|
||||||
if normalized_method == "GET":
|
if normalized_method == "GET":
|
||||||
if data is not None:
|
if data is not None:
|
||||||
raise PolicyError("read operations cannot include a request body")
|
raise PolicyError("read operations cannot include a request body")
|
||||||
return _authorize_read(target, forbidden=forbidden)
|
return _authorize_read(target)
|
||||||
if target.query:
|
if target.query:
|
||||||
raise PolicyError("mutating operations cannot include query parameters")
|
raise PolicyError("mutating operations cannot include query parameters")
|
||||||
if normalized_method != "POST":
|
if normalized_method != "POST":
|
||||||
@ -183,17 +172,16 @@ def authorize_request(
|
|||||||
match = CREATE_PATH_RE.fullmatch(target.path)
|
match = CREATE_PATH_RE.fullmatch(target.path)
|
||||||
if not match:
|
if not match:
|
||||||
raise PolicyError("POST is limited to creating an Atlas draft pull request")
|
raise PolicyError("POST is limited to creating an Atlas draft pull request")
|
||||||
_reject_forbidden(match.group(1), "repository", forbidden)
|
|
||||||
_validate_repo(match.group(1))
|
_validate_repo(match.group(1))
|
||||||
if not isinstance(data, dict) or set(data) != {"base", "body", "head", "title"}:
|
if not isinstance(data, dict) or set(data) != {"base", "body", "head", "title"}:
|
||||||
raise PolicyError("draft creation accepts only base, body, head, and title")
|
raise PolicyError("draft creation accepts only base, body, head, and title")
|
||||||
if data["title"] != _draft_title(data["title"], forbidden=forbidden):
|
if data["title"] != _draft_title(data["title"]):
|
||||||
raise PolicyError("new pull requests must use the Gitea draft-title prefix")
|
raise PolicyError("new pull requests must use the Gitea draft-title prefix")
|
||||||
_validate_body(data["body"], forbidden=forbidden)
|
_validate_body(data["body"])
|
||||||
_validate_ref_bounds(data["base"], "base", forbidden=forbidden)
|
_validate_ref_bounds(data["base"], "base")
|
||||||
_validate_ref_bounds(data["head"], "head", forbidden=forbidden)
|
_validate_ref_bounds(data["head"], "head")
|
||||||
_validate_ref(data["base"], "base", forbidden=forbidden)
|
_validate_ref(data["base"], "base")
|
||||||
_validate_ref(data["head"], "head", forbidden=forbidden)
|
_validate_ref(data["head"], "head")
|
||||||
return "create-draft"
|
return "create-draft"
|
||||||
|
|
||||||
|
|
||||||
@ -206,19 +194,11 @@ def build_request(
|
|||||||
data: object | None = None,
|
data: object | None = None,
|
||||||
) -> urllib.request.Request:
|
) -> urllib.request.Request:
|
||||||
"""Build an authorized request without putting the token in its URL or body."""
|
"""Build an authorized request without putting the token in its URL or body."""
|
||||||
authorize_request(method, path, data, forbidden=(token,))
|
authorize_request(method, path, data)
|
||||||
return _build_request(method, path, base_url=base_url, token=token, data=data)
|
if method.upper() == "POST":
|
||||||
|
assert isinstance(data, dict)
|
||||||
|
_draft_title(data["title"], forbidden=(token,))
|
||||||
def _build_request(
|
_validate_body(data["body"], forbidden=(token,))
|
||||||
method: str,
|
|
||||||
path: str,
|
|
||||||
*,
|
|
||||||
base_url: str,
|
|
||||||
token: str,
|
|
||||||
data: object | None = None,
|
|
||||||
) -> urllib.request.Request:
|
|
||||||
"""Build one already-authorized upstream request."""
|
|
||||||
payload = None
|
payload = None
|
||||||
if data is not None:
|
if data is not None:
|
||||||
payload = json.dumps(data, separators=(",", ":")).encode("utf-8")
|
payload = json.dumps(data, separators=(",", ":")).encode("utf-8")
|
||||||
@ -245,19 +225,6 @@ 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(
|
def _request(
|
||||||
method: str,
|
method: str,
|
||||||
path: str,
|
path: str,
|
||||||
@ -265,23 +232,14 @@ def _request(
|
|||||||
*,
|
*,
|
||||||
token: str,
|
token: str,
|
||||||
opener: Callable[..., object] = _safe_urlopen,
|
opener: Callable[..., object] = _safe_urlopen,
|
||||||
authorized: bool = False,
|
|
||||||
expected_status: int,
|
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
builder = _build_request if authorized else build_request
|
request = build_request(
|
||||||
request = builder(method, path, base_url=configured_base_url(), token=token, data=data)
|
method, path, base_url=configured_base_url(), token=token, data=data
|
||||||
|
)
|
||||||
with opener(request, timeout=30) as response: # type: ignore[attr-defined]
|
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]
|
body = response.read(MAX_RESPONSE_BYTES + 1) # type: ignore[attr-defined]
|
||||||
if len(body) > MAX_RESPONSE_BYTES:
|
if len(body) > MAX_RESPONSE_BYTES:
|
||||||
raise PolicyError("Forgejo response exceeds the safe size limit")
|
raise PolicyError("Forgejo response exceeds the safe size limit")
|
||||||
_reject_response_credential(body, token)
|
|
||||||
return redact_bytes(body, token)
|
return redact_bytes(body, token)
|
||||||
|
|
||||||
|
|
||||||
@ -289,9 +247,7 @@ def read(
|
|||||||
path: str, *, token: str, opener: Callable[..., object] = _safe_urlopen
|
path: str, *, token: str, opener: Callable[..., object] = _safe_urlopen
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""Read one explicitly allowed Atlas repository metadata resource."""
|
"""Read one explicitly allowed Atlas repository metadata resource."""
|
||||||
return _request(
|
return _request("GET", path, None, token=token, opener=opener)
|
||||||
"GET", path, None, token=token, opener=opener, expected_status=200
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _nested(document: dict[str, object], *keys: str) -> object:
|
def _nested(document: dict[str, object], *keys: str) -> object:
|
||||||
@ -354,15 +310,14 @@ def create_draft(
|
|||||||
opener: Callable[..., object] = _safe_urlopen,
|
opener: Callable[..., object] = _safe_urlopen,
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""Create and verify a same-repository draft pull request for human review."""
|
"""Create and verify a same-repository draft pull request for human review."""
|
||||||
_reject_forbidden(repo, "repository", (token,))
|
|
||||||
repo = _validate_repo(repo)
|
repo = _validate_repo(repo)
|
||||||
title = _draft_title(title, forbidden=(token,))
|
title = _draft_title(title, forbidden=(token,))
|
||||||
body = _validate_body(body, forbidden=(token,))
|
body = _validate_body(body, forbidden=(token,))
|
||||||
head_sha = _validate_sha(head_sha)
|
head_sha = _validate_sha(head_sha)
|
||||||
_validate_ref_bounds(base, "base", forbidden=(token,))
|
_validate_ref_bounds(base, "base")
|
||||||
_validate_ref_bounds(head, "head", forbidden=(token,))
|
_validate_ref_bounds(head, "head")
|
||||||
base = _validate_ref(base, "base", forbidden=(token,))
|
base = _validate_ref(base, "base")
|
||||||
head = _validate_ref(head, "head", forbidden=(token,))
|
head = _validate_ref(head, "head")
|
||||||
data = {"base": base, "body": body, "head": head, "title": title}
|
data = {"base": base, "body": body, "head": head, "title": title}
|
||||||
result = _request(
|
result = _request(
|
||||||
"POST",
|
"POST",
|
||||||
@ -370,8 +325,6 @@ def create_draft(
|
|||||||
data,
|
data,
|
||||||
token=token,
|
token=token,
|
||||||
opener=opener,
|
opener=opener,
|
||||||
authorized=True,
|
|
||||||
expected_status=201,
|
|
||||||
)
|
)
|
||||||
_require_create_response(
|
_require_create_response(
|
||||||
result,
|
result,
|
||||||
@ -408,9 +361,7 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|||||||
return parser.parse_args(argv)
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
def _operation(
|
def _operation(args: argparse.Namespace) -> tuple[str, str, object | None]:
|
||||||
args: argparse.Namespace, *, validate_refs: bool = True
|
|
||||||
) -> tuple[str, str, object | None]:
|
|
||||||
if args.operation == "read":
|
if args.operation == "read":
|
||||||
return "GET", args.path, None
|
return "GET", args.path, None
|
||||||
repo = _validate_repo(args.repo)
|
repo = _validate_repo(args.repo)
|
||||||
@ -419,12 +370,10 @@ def _operation(
|
|||||||
title = _draft_title(args.title)
|
title = _draft_title(args.title)
|
||||||
_validate_ref_bounds(args.base, "base")
|
_validate_ref_bounds(args.base, "base")
|
||||||
_validate_ref_bounds(args.head, "head")
|
_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 = {
|
data = {
|
||||||
"base": base,
|
"base": _validate_ref(args.base, "base"),
|
||||||
"body": body,
|
"body": body,
|
||||||
"head": head,
|
"head": _validate_ref(args.head, "head"),
|
||||||
"title": title,
|
"title": title,
|
||||||
}
|
}
|
||||||
return "POST", f"/api/v1/repos/{ALLOWED_OWNER}/{repo}/pulls", data
|
return "POST", f"/api/v1/repos/{ALLOWED_OWNER}/{repo}/pulls", data
|
||||||
@ -439,11 +388,12 @@ def _write_body(body: bytes) -> None:
|
|||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
"""Validate, optionally describe, and execute one safe Forgejo operation."""
|
"""Validate, optionally describe, and execute one safe Forgejo operation."""
|
||||||
|
token = ""
|
||||||
try:
|
try:
|
||||||
args = parse_args(argv)
|
args = parse_args(argv)
|
||||||
|
method, path, data = _operation(args)
|
||||||
|
operation = authorize_request(method, path, data)
|
||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
method, path, data = _operation(args, validate_refs=False)
|
|
||||||
operation = authorize_request(method, path, data)
|
|
||||||
print(
|
print(
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
@ -460,27 +410,29 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
return 0
|
return 0
|
||||||
method, path, data = _operation(args, validate_refs=False)
|
token = read_token()
|
||||||
from scm_broker_client import create_draft as broker_create_draft
|
|
||||||
from scm_broker_client import read as broker_read
|
|
||||||
|
|
||||||
if args.operation == "read":
|
if args.operation == "read":
|
||||||
body = broker_read(path)
|
body = read(path, token=token)
|
||||||
else:
|
else:
|
||||||
assert isinstance(data, dict)
|
assert isinstance(data, dict)
|
||||||
body = broker_create_draft(
|
body = create_draft(
|
||||||
args.repo,
|
args.repo,
|
||||||
base=str(data["base"]),
|
base=str(data["base"]),
|
||||||
head=str(data["head"]),
|
head=str(data["head"]),
|
||||||
head_sha=args.head_sha,
|
head_sha=args.head_sha,
|
||||||
title=str(data["title"]),
|
title=str(data["title"]),
|
||||||
body=str(data["body"]),
|
body=str(data["body"]),
|
||||||
|
token=token,
|
||||||
)
|
)
|
||||||
_write_body(body)
|
_write_body(body)
|
||||||
return 0
|
return 0
|
||||||
except urllib.error.HTTPError as exc:
|
except urllib.error.HTTPError as exc:
|
||||||
exc.read(MAX_ERROR_BYTES)
|
body = redact_bytes(exc.read(MAX_ERROR_BYTES), token) if token else b""
|
||||||
print(f"SCM broker request failed with HTTP {exc.code}", file=sys.stderr)
|
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")
|
||||||
return 1
|
return 1
|
||||||
except (OSError, PolicyError, ValueError, json.JSONDecodeError):
|
except (OSError, PolicyError, ValueError, json.JSONDecodeError):
|
||||||
print(
|
print(
|
||||||
@ -57,9 +57,7 @@ JSON_KEY_RE = re.compile(r'"(?P<key>[^"]{1,512})"(?P<spacing>[\x00-\x20\x7f]{0,2
|
|||||||
MULTILINE_ASSIGNMENT_RE = re.compile(
|
MULTILINE_ASSIGNMENT_RE = re.compile(
|
||||||
r"""(?mx)
|
r"""(?mx)
|
||||||
(?<![A-Za-z0-9_.-])
|
(?<![A-Za-z0-9_.-])
|
||||||
(?P<key_quote>["']?)
|
|
||||||
(?P<key>\.?[A-Za-z][A-Za-z0-9_.-]{0,127})
|
(?P<key>\.?[A-Za-z][A-Za-z0-9_.-]{0,127})
|
||||||
(?P=key_quote)
|
|
||||||
[ \t]{0,64}(?:\r?\n[ \t]{0,64}){0,4}
|
[ \t]{0,64}(?:\r?\n[ \t]{0,64}){0,4}
|
||||||
(?P<separator>[:=])
|
(?P<separator>[:=])
|
||||||
[ \t]{0,64}(?:\r?\n[ \t]{0,64}){1,4}
|
[ \t]{0,64}(?:\r?\n[ \t]{0,64}){1,4}
|
||||||
@ -75,24 +73,24 @@ CREDENTIAL_SCHEME_RE = re.compile(
|
|||||||
STANDALONE_SECRET_PATTERNS = tuple(
|
STANDALONE_SECRET_PATTERNS = tuple(
|
||||||
re.compile(pattern, re.IGNORECASE)
|
re.compile(pattern, re.IGNORECASE)
|
||||||
for pattern in (
|
for pattern in (
|
||||||
r"(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{20,}(?![A-Za-z0-9])",
|
r"\bgh[pousr]_[A-Za-z0-9]{20,}\b",
|
||||||
r"(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{20,}(?![A-Za-z0-9])",
|
r"\bgithub_pat_[A-Za-z0-9_]{20,}\b",
|
||||||
r"(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{20,}(?![A-Za-z0-9])",
|
r"\bglpat-[A-Za-z0-9_-]{20,}\b",
|
||||||
r"(?<![A-Za-z0-9])xox[baprs]-[A-Za-z0-9-]{20,}(?![A-Za-z0-9])",
|
r"\bxox[baprs]-[A-Za-z0-9-]{20,}\b",
|
||||||
r"(?<![A-Za-z0-9])sk-(?:ant-|proj-)?[A-Za-z0-9_-]{20,}(?![A-Za-z0-9])",
|
r"\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{20,}\b",
|
||||||
r"(?<![A-Za-z0-9])[rs]k_(?:test|live)_[A-Za-z0-9]{20,}(?![A-Za-z0-9])",
|
r"\b[rs]k_(?:test|live)_[A-Za-z0-9]{20,}\b",
|
||||||
r"(?<![A-Za-z0-9])whsec_[A-Za-z0-9]{20,}(?![A-Za-z0-9])",
|
r"\bwhsec_[A-Za-z0-9]{20,}\b",
|
||||||
r"(?<![A-Za-z0-9])npm_[A-Za-z0-9]{20,}(?![A-Za-z0-9])",
|
r"\bnpm_[A-Za-z0-9]{20,}\b",
|
||||||
r"(?<![A-Za-z0-9])pypi-[A-Za-z0-9_-]{30,}(?![A-Za-z0-9])",
|
r"\bpypi-[A-Za-z0-9_-]{30,}\b",
|
||||||
r"(?<![A-Za-z0-9])hf_[A-Za-z0-9]{20,}(?![A-Za-z0-9])",
|
r"\bhf_[A-Za-z0-9]{20,}\b",
|
||||||
r"(?<![A-Za-z0-9])(?:gta|gto|gitea|forgejo)_[A-Za-z0-9_-]{20,}(?![A-Za-z0-9])",
|
r"\b(?:gta|gto|gitea|forgejo)_[A-Za-z0-9_-]{20,}\b",
|
||||||
r"(?<![A-Za-z0-9])ya29\.[A-Za-z0-9_-]{20,}(?![A-Za-z0-9])",
|
r"\bya29\.[A-Za-z0-9_-]{20,}\b",
|
||||||
r"(?<![A-Za-z0-9])oy2[A-Za-z0-9]{40,}(?![A-Za-z0-9])",
|
r"\boy2[A-Za-z0-9]{40,}\b",
|
||||||
r"(?<![A-Za-z0-9])SG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{20,}(?![A-Za-z0-9])",
|
r"\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{20,}\b",
|
||||||
r"(?<![A-Za-z0-9])SK[0-9a-f]{32}(?![A-Za-z0-9])",
|
r"\bSK[0-9a-f]{32}\b",
|
||||||
r"(?<![A-Za-z0-9])(?:AKIA|ASIA)[0-9A-Z]{16}(?![A-Za-z0-9])",
|
r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b",
|
||||||
r"(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}(?![A-Za-z0-9])",
|
r"\bAIza[0-9A-Za-z_-]{35}\b",
|
||||||
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"\beyJ[A-Za-z0-9_-]{12,}\.[A-Za-z0-9_-]{12,}\.[A-Za-z0-9_-]{8,}\b",
|
||||||
r"-----BEGIN\s+[A-Z0-9 ][A-Z0-9 -]{1,62}-----",
|
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"\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://hooks\.slack\.com/services/[A-Za-z0-9/_-]{20,}",
|
||||||
@ -138,18 +136,9 @@ def _validate_repo(repo: str) -> str:
|
|||||||
return repo
|
return repo
|
||||||
|
|
||||||
|
|
||||||
def _reject_forbidden(value: object, name: str, forbidden: tuple[str, ...]) -> None:
|
def _validate_ref_bounds(value: object, name: str) -> str:
|
||||||
"""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 oversized or non-encodable refs before invoking Git."""
|
||||||
_reject_forbidden(value, name, forbidden)
|
if not isinstance(value, str) or not value or len(value) > MAX_REF_CHARACTERS:
|
||||||
if not value or len(value) > MAX_REF_CHARACTERS:
|
|
||||||
raise PolicyError(f"{name} exceeds the safe branch-name limit")
|
raise PolicyError(f"{name} exceeds the safe branch-name limit")
|
||||||
try:
|
try:
|
||||||
encoded = value.encode("utf-8", errors="strict")
|
encoded = value.encode("utf-8", errors="strict")
|
||||||
@ -160,11 +149,9 @@ def _validate_ref_bounds(
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _validate_ref(
|
def _validate_ref(value: object, name: str) -> str:
|
||||||
value: object, name: str, *, forbidden: tuple[str, ...] = ()
|
|
||||||
) -> str:
|
|
||||||
"""Validate the complete Git ref grammar using Git itself."""
|
"""Validate the complete Git ref grammar using Git itself."""
|
||||||
value = _validate_ref_bounds(value, name, forbidden=forbidden)
|
value = _validate_ref_bounds(value, name)
|
||||||
if value.startswith("-"):
|
if value.startswith("-"):
|
||||||
raise PolicyError(f"{name} must be a same-repository branch name")
|
raise PolicyError(f"{name} must be a same-repository branch name")
|
||||||
if any(ord(character) < 32 or ord(character) == 127 for character in value):
|
if any(ord(character) < 32 or ord(character) == 127 for character in value):
|
||||||
@ -274,7 +261,7 @@ def _looks_like_prose(value: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
if re.search(r"[$`{}\[\]\\@/:=+_]", value):
|
if re.search(r"[$`{}\[\]\\@/:=+_]", value):
|
||||||
return False
|
return False
|
||||||
return words[0].lower() in PROSE_LEAD_WORDS
|
return words[0].lower() in PROSE_LEAD_WORDS or len(words) >= 4
|
||||||
|
|
||||||
|
|
||||||
def _looks_sensitive_assignment_value(value: str) -> bool:
|
def _looks_sensitive_assignment_value(value: str) -> bool:
|
||||||
@ -363,8 +350,6 @@ def _decoded_json_documents(value: str):
|
|||||||
continue
|
continue
|
||||||
yield document
|
yield document
|
||||||
index = max(end, start + 1)
|
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:
|
def _has_structured_sensitive_assignment(value: str) -> bool:
|
||||||
19
services/hermes/scripts/gitea_askpass.sh
Executable file
19
services/hermes/scripts/gitea_askpass.sh
Executable file
@ -0,0 +1,19 @@
|
|||||||
|
#!/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,10 +24,8 @@ from hermes_model_routing import (
|
|||||||
|
|
||||||
|
|
||||||
CASSANDRA_BASE_PATH = Path("/opt/data/workspace/projects/cassandra")
|
CASSANDRA_BASE_PATH = Path("/opt/data/workspace/projects/cassandra")
|
||||||
CASSANDRA_REMOTE = (
|
CASSANDRA_REMOTE = "https://scm.bstein.dev/atlas/cassandra.git"
|
||||||
"http://hermes-scm-broker.hermes-scm.svc.cluster.local:9081/"
|
DEFAULT_GITEA_TOKEN_PATH = Path("/runtime-access/gitea-token")
|
||||||
"git/atlas/cassandra.git"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def cassandra_workspace() -> Path:
|
def cassandra_workspace() -> Path:
|
||||||
@ -141,8 +139,24 @@ def bootstrap_cassandra_state(root: Path) -> dict[str, str]:
|
|||||||
return {"state": "ready"}
|
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:
|
def sync_cassandra_repo(env_values: dict[str, str]) -> str:
|
||||||
"""Clone or fetch Cassandra through the credential-isolated SCM broker."""
|
"""Clone or fetch Cassandra through the runtime-only askpass credential."""
|
||||||
|
token_available = _gitea_token_available()
|
||||||
if shutil.which("git") is None:
|
if shutil.which("git") is None:
|
||||||
return "git-unavailable"
|
return "git-unavailable"
|
||||||
child_env = os.environ.copy()
|
child_env = os.environ.copy()
|
||||||
@ -159,7 +173,9 @@ def sync_cassandra_repo(env_values: dict[str, str]) -> str:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
child_env.pop("GIT_ASKPASS", None)
|
child_env["GIT_ASKPASS"] = env_values.get(
|
||||||
|
"GIT_ASKPASS", "/opt/coordinator/gitea_askpass.sh"
|
||||||
|
)
|
||||||
child_env["GIT_TERMINAL_PROMPT"] = "0"
|
child_env["GIT_TERMINAL_PROMPT"] = "0"
|
||||||
if (CASSANDRA_BASE_PATH / ".git").exists():
|
if (CASSANDRA_BASE_PATH / ".git").exists():
|
||||||
try:
|
try:
|
||||||
@ -216,6 +232,8 @@ def sync_cassandra_repo(env_values: dict[str, str]) -> str:
|
|||||||
return f"remote-repair-failed-{repaired.returncode}"
|
return f"remote-repair-failed-{repaired.returncode}"
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
return "remote-repair-failed"
|
return "remote-repair-failed"
|
||||||
|
if not token_available:
|
||||||
|
return "ready; fetch skipped until Gitea token is configured"
|
||||||
command = [
|
command = [
|
||||||
"git",
|
"git",
|
||||||
"-C",
|
"-C",
|
||||||
@ -229,6 +247,8 @@ def sync_cassandra_repo(env_values: dict[str, str]) -> str:
|
|||||||
CASSANDRA_BASE_PATH.mkdir(parents=True, exist_ok=True)
|
CASSANDRA_BASE_PATH.mkdir(parents=True, exist_ok=True)
|
||||||
if any(CASSANDRA_BASE_PATH.iterdir()):
|
if any(CASSANDRA_BASE_PATH.iterdir()):
|
||||||
return "unmanaged-nonempty-directory"
|
return "unmanaged-nonempty-directory"
|
||||||
|
if not token_available:
|
||||||
|
return "awaiting-gitea-token"
|
||||||
command = [
|
command = [
|
||||||
"git",
|
"git",
|
||||||
"clone",
|
"clone",
|
||||||
|
|||||||
@ -1,456 +0,0 @@
|
|||||||
#!/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,21 +86,6 @@ def _write_empty_auth_store() -> None:
|
|||||||
os.chown(path, OWNER_UID, OWNER_GID)
|
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:
|
def stage_agent() -> None:
|
||||||
"""Stage the owner agent's complete runtime access set."""
|
"""Stage the owner agent's complete runtime access set."""
|
||||||
for path in (RUNTIME_ROOT, RUNTIME_ROOT / "claude", RUNTIME_ROOT / "codex"):
|
for path in (RUNTIME_ROOT, RUNTIME_ROOT / "claude", RUNTIME_ROOT / "codex"):
|
||||||
@ -108,11 +93,13 @@ def stage_agent() -> None:
|
|||||||
for name in (
|
for name in (
|
||||||
"agent-api-key",
|
"agent-api-key",
|
||||||
"chat-relay-key",
|
"chat-relay-key",
|
||||||
|
"gitea-token",
|
||||||
|
"gitea-username",
|
||||||
"node-ssh-private-key",
|
"node-ssh-private-key",
|
||||||
|
"node-ssh-config",
|
||||||
"node-ssh-known-hosts",
|
"node-ssh-known-hosts",
|
||||||
):
|
):
|
||||||
_copy_secret(name, RUNTIME_ROOT / name)
|
_copy_secret(name, RUNTIME_ROOT / name)
|
||||||
_stage_node_ssh_config()
|
|
||||||
_validated_json(
|
_validated_json(
|
||||||
"claude-credentials",
|
"claude-credentials",
|
||||||
RUNTIME_ROOT / "claude" / ".credentials.json",
|
RUNTIME_ROOT / "claude" / ".credentials.json",
|
||||||
|
|||||||
@ -5,21 +5,17 @@ description: Read bounded private Atlas repository and pull-request metadata or
|
|||||||
|
|
||||||
# Manage Atlas pull requests
|
# Manage Atlas pull requests
|
||||||
|
|
||||||
Use `/opt/scm/gitea_api.py` for Forgejo API access. The agent pod has no
|
Use `/opt/coordinator/gitea_api.py` for Forgejo API access. It reads its token
|
||||||
repository credential. The client calls a separate least-authority broker;
|
from the pod-lifetime Vault projection; never read that file, copy the token,
|
||||||
never bypass it with `curl` or direct Gitea HTTP.
|
put it in an argument or environment variable, or replace this client with
|
||||||
|
`curl`.
|
||||||
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
|
## Read repository or PR state
|
||||||
|
|
||||||
Pass one allowed Atlas metadata API path to the read operation:
|
Pass one allowed Atlas metadata API path to the read operation:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
/opt/scm/gitea_api.py read /api/v1/repos/atlas/REPO/pulls/NUMBER
|
/opt/coordinator/gitea_api.py read /api/v1/repos/atlas/REPO/pulls/NUMBER
|
||||||
```
|
```
|
||||||
|
|
||||||
The client exposes only repository metadata, PRs and PR evidence, branches,
|
The client exposes only repository metadata, PRs and PR evidence, branches,
|
||||||
@ -42,7 +38,7 @@ blobs do not belong in these fields and may be rejected. Validate without
|
|||||||
reading a credential or using the network:
|
reading a credential or using the network:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
/opt/scm/gitea_api.py --dry-run create-draft REPO \
|
/opt/coordinator/gitea_api.py --dry-run create-draft REPO \
|
||||||
--base main --head hermes/TASK --head-sha FULL_PUSHED_SHA \
|
--base main --head hermes/TASK --head-sha FULL_PUSHED_SHA \
|
||||||
--title "Focused change" --body "Tests and review evidence"
|
--title "Focused change" --body "Tests and review evidence"
|
||||||
```
|
```
|
||||||
|
|||||||
@ -15,7 +15,7 @@ data:
|
|||||||
METIS_MAX_DEVICE_BYTES: "1000000000000"
|
METIS_MAX_DEVICE_BYTES: "1000000000000"
|
||||||
METIS_NAMESPACE: maintenance
|
METIS_NAMESPACE: maintenance
|
||||||
METIS_REMOTE_POD_TIMEOUT_SEC: "14400"
|
METIS_REMOTE_POD_TIMEOUT_SEC: "14400"
|
||||||
METIS_RUNNER_IMAGE_AMD64: registry.bstein.dev/bstein/metis:0.1.0-296-amd64 # {"$imagepolicy": "maintenance:metis-amd64"}
|
METIS_RUNNER_IMAGE_AMD64: registry.bstein.dev/bstein/metis:0.1.0-295-amd64 # {"$imagepolicy": "maintenance:metis-amd64"}
|
||||||
METIS_RUNNER_IMAGE_ARM64: registry.bstein.dev/bstein/metis:0.1.0-295-arm64 # {"$imagepolicy": "maintenance:metis-arm64"}
|
METIS_RUNNER_IMAGE_ARM64: registry.bstein.dev/bstein/metis:0.1.0-295-arm64 # {"$imagepolicy": "maintenance:metis-arm64"}
|
||||||
METIS_HARBOR_REGISTRY: registry.bstein.dev
|
METIS_HARBOR_REGISTRY: registry.bstein.dev
|
||||||
METIS_HARBOR_PROJECT: metis
|
METIS_HARBOR_PROJECT: metis
|
||||||
|
|||||||
@ -32,7 +32,7 @@ spec:
|
|||||||
kubernetes.io/arch: amd64
|
kubernetes.io/arch: amd64
|
||||||
containers:
|
containers:
|
||||||
- name: metis-sentinel
|
- name: metis-sentinel
|
||||||
image: registry.bstein.dev/bstein/metis-sentinel:0.1.0-296-amd64 # {"$imagepolicy": "maintenance:metis-sentinel-amd64"}
|
image: registry.bstein.dev/bstein/metis-sentinel:0.1.0-295-amd64 # {"$imagepolicy": "maintenance:metis-sentinel-amd64"}
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
envFrom:
|
envFrom:
|
||||||
- configMapRef:
|
- configMapRef:
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
apiVersion: batch/v1
|
apiVersion: batch/v1
|
||||||
kind: Job
|
kind: Job
|
||||||
metadata:
|
metadata:
|
||||||
name: vault-k8s-auth-hermes-9
|
name: vault-k8s-auth-hermes-8
|
||||||
namespace: vault
|
namespace: vault
|
||||||
spec:
|
spec:
|
||||||
backoffLimit: 2
|
backoffLimit: 2
|
||||||
|
|||||||
@ -256,9 +256,7 @@ write_policy_and_role "game-stream" "game-stream" "game-stream-vault" \
|
|||||||
write_policy_and_role "hermes" "hermes" "hermes-vault,hermes-triage" \
|
write_policy_and_role "hermes" "hermes" "hermes-vault,hermes-triage" \
|
||||||
"hermes/triage-oidc hermes/agent-tokens hermes/triage-api" ""
|
"hermes/triage-oidc hermes/agent-tokens hermes/triage-api" ""
|
||||||
write_policy_and_role "hermes-agent" "hermes" "hermes-agent,hermes-switchyard" \
|
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-harbor hermes/developer-jenkins hermes/developer-ssh" ""
|
"hermes/agent-oidc hermes/agent-tokens hermes/chat-telegram hermes/developer-keycloak hermes/developer-gitea 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" \
|
write_policy_and_role "hermes-credential-sync" "hermes" "hermes-agent" \
|
||||||
"" "hermes/agent-tokens"
|
"" "hermes/agent-tokens"
|
||||||
write_policy_and_role "hermes-node-ssh" "hermes" "hermes-node-ssh-access" \
|
write_policy_and_role "hermes-node-ssh" "hermes" "hermes-node-ssh-access" \
|
||||||
|
|||||||
@ -92,14 +92,13 @@ def test_agent_config_keeps_delegated_reviewers_from_owning_task_lifecycle():
|
|||||||
assert "Atlas organization has private visibility" in instructions
|
assert "Atlas organization has private visibility" in instructions
|
||||||
assert "may be public or private" in instructions
|
assert "may be public or private" in instructions
|
||||||
assert "do not infer a\nrepository's visibility" in instructions
|
assert "do not infer a\nrepository's visibility" in instructions
|
||||||
assert "brokered Git for clone, fetch" in instructions
|
assert "already supplied through `GIT_ASKPASS`" 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 "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 "bounded ad-hoc inspection and acceptance checks may" in instructions
|
||||||
assert "load implementation or TDD skills" 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 "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 "must load a skill only when its workflow\nmaterially applies" in soul
|
||||||
assert "no repository token is present in this pod" in soul
|
assert "runtime-only `GIT_ASKPASS`" in soul
|
||||||
assert "`scm.bstein.dev` is Forgejo/Gitea, not GitHub" in instructions
|
assert "`scm.bstein.dev` is Forgejo/Gitea, not GitHub" in instructions
|
||||||
assert "GitHub/`gh` skill for an Atlas remote" in instructions
|
assert "GitHub/`gh` skill for an Atlas remote" in instructions
|
||||||
assert "load\n`$manage-atlas-pull-requests`" in instructions
|
assert "load\n`$manage-atlas-pull-requests`" in instructions
|
||||||
@ -561,15 +560,13 @@ def test_chat_image_generation_uses_private_owner_broker():
|
|||||||
"app": "hermes-chat-tenant"
|
"app": "hermes-chat-tenant"
|
||||||
}
|
}
|
||||||
vault_policy = (VAULT / "scripts" / "vault_k8s_auth_configure.sh").read_text()
|
vault_policy = (VAULT / "scripts" / "vault_k8s_auth_configure.sh").read_text()
|
||||||
agent_role = vault_policy[
|
assert (
|
||||||
vault_policy.index('write_policy_and_role "hermes-agent"') :
|
'"hermes/agent-oidc hermes/agent-tokens hermes/chat-telegram '
|
||||||
vault_policy.index(
|
'hermes/developer-keycloak hermes/developer-gitea '
|
||||||
"write_policy_and_role",
|
'hermes/developer-harbor hermes/developer-jenkins '
|
||||||
vault_policy.index('write_policy_and_role "hermes-agent"') + 1,
|
'hermes/developer-ssh"'
|
||||||
)
|
in vault_policy
|
||||||
]
|
)
|
||||||
assert "hermes/developer-gitea" not in agent_role
|
|
||||||
assert 'write_policy_and_role "hermes-scm-broker" "hermes-scm"' in vault_policy
|
|
||||||
assert (
|
assert (
|
||||||
'write_policy_and_role "hermes-node-ssh" "hermes" '
|
'write_policy_and_role "hermes-node-ssh" "hermes" '
|
||||||
'"hermes-node-ssh-access"' in vault_policy
|
'"hermes-node-ssh-access"' in vault_policy
|
||||||
|
|||||||
@ -15,8 +15,7 @@ import pytest
|
|||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).parents[2]
|
SCRIPTS = Path(__file__).parents[2] / "services/hermes/scripts"
|
||||||
SCRIPTS = ROOT / "services/hermes/scripts"
|
|
||||||
sys.path.insert(0, str(SCRIPTS))
|
sys.path.insert(0, str(SCRIPTS))
|
||||||
HERMES = Path(__file__).parents[2] / "services/hermes"
|
HERMES = Path(__file__).parents[2] / "services/hermes"
|
||||||
KEYCLOAK = Path(__file__).parents[2] / "services/keycloak"
|
KEYCLOAK = Path(__file__).parents[2] / "services/keycloak"
|
||||||
@ -1354,27 +1353,20 @@ def test_agent_network_boundary_allows_only_authenticated_and_metrics_surfaces()
|
|||||||
"ports": [{"protocol": "TCP", "port": 9010}],
|
"ports": [{"protocol": "TCP", "port": 9010}],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
egress = isolation["spec"]["egress"]
|
assert 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_scoped_read_only_kubernetes_context():
|
def test_owner_agent_has_cluster_admin_kubernetes_context():
|
||||||
config = yaml.safe_load((HERMES / "agent-kubeconfig.yaml").read_text())
|
config = yaml.safe_load((HERMES / "agent-kubeconfig.yaml").read_text())
|
||||||
assert config["current-context"] == "atlas-observer"
|
assert config["current-context"] == "atlas-owner"
|
||||||
assert config["contexts"][0]["context"]["namespace"] == "default"
|
assert config["contexts"][0]["context"]["namespace"] == "default"
|
||||||
rbac_path = ROOT / "services/hermes-observer-rbac/rbac.yaml"
|
rbac_path = HERMES / "agent-rbac.yaml"
|
||||||
documents = [item for item in yaml.safe_load_all(rbac_path.read_text()) if item]
|
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")
|
binding = next(item for item in documents if item["kind"] == "ClusterRoleBinding")
|
||||||
assert binding["roleRef"] == {
|
assert binding["roleRef"] == {
|
||||||
"apiGroup": "rbac.authorization.k8s.io",
|
"apiGroup": "rbac.authorization.k8s.io",
|
||||||
"kind": "ClusterRole",
|
"kind": "ClusterRole",
|
||||||
"name": "hermes-agent-cluster-observer-v2",
|
"name": "cluster-admin",
|
||||||
}
|
}
|
||||||
assert binding["subjects"] == [
|
assert binding["subjects"] == [
|
||||||
{"kind": "ServiceAccount", "name": "hermes-agent", "namespace": "hermes"}
|
{"kind": "ServiceAccount", "name": "hermes-agent", "namespace": "hermes"}
|
||||||
@ -1449,16 +1441,15 @@ def test_owner_agent_has_pinned_dedicated_node_ssh_access():
|
|||||||
"secretProviderClass": "hermes-node-ssh-access"
|
"secretProviderClass": "hermes-node-ssh-access"
|
||||||
}
|
}
|
||||||
reconciler = pod["containers"][0]["args"][0]
|
reconciler = pod["containers"][0]["args"][0]
|
||||||
assert "/opt/node-hardener/node_account_hardening.py" in reconciler
|
assert "cat /vault/secrets/node-ssh-public-key" in reconciler
|
||||||
assert "--public-key-file /vault/secrets/node-ssh-public-key" in reconciler
|
assert "grep -qxF" in reconciler
|
||||||
assert "sleep 300" in reconciler
|
assert "for user in atlas oceanus" in reconciler
|
||||||
host_etc = next(item for item in pod["volumes"] if item["name"] == "host-etc")
|
assert "/host-etc/passwd" in reconciler
|
||||||
assert host_etc["hostPath"] == {"path": "/etc", "type": "Directory"}
|
assert "chown \"${uid}:${gid}\"" in reconciler
|
||||||
hardener = (HERMES / "scripts/node_account_hardening.py").read_text()
|
host_passwd = next(
|
||||||
assert 'ACCOUNT = "hermes-agent"' in hardener
|
item for item in pod["volumes"] if item["name"] == "host-passwd"
|
||||||
assert 'LEGACY_ACCOUNTS = ("atlas", "oceanus")' in hardener
|
)
|
||||||
assert 'ACCOUNT_UID = 1200' in hardener
|
assert host_passwd["hostPath"] == {"path": "/etc/passwd", "type": "File"}
|
||||||
assert 'ACCOUNT_GID = 1200' in hardener
|
|
||||||
|
|
||||||
|
|
||||||
def test_owner_agent_tracks_no_ssh_identity_or_host_key_material():
|
def test_owner_agent_tracks_no_ssh_identity_or_host_key_material():
|
||||||
@ -1527,9 +1518,7 @@ def test_switchyard_has_a_dedicated_non_owner_identity_and_read_only_catalog():
|
|||||||
|
|
||||||
rbac = [
|
rbac = [
|
||||||
item
|
item
|
||||||
for item in yaml.safe_load_all(
|
for item in yaml.safe_load_all((HERMES / "agent-rbac.yaml").read_text())
|
||||||
(ROOT / "services/hermes-observer-rbac/rbac.yaml").read_text()
|
|
||||||
)
|
|
||||||
if item
|
if item
|
||||||
]
|
]
|
||||||
binding = next(item for item in rbac if item["kind"] == "ClusterRoleBinding")
|
binding = next(item for item in rbac if item["kind"] == "ClusterRoleBinding")
|
||||||
|
|||||||
@ -445,10 +445,8 @@ def test_cassandra_sync_repairs_existing_worktree(
|
|||||||
assert all("GITEA_TOKEN" not in environment for environment in environments)
|
assert all("GITEA_TOKEN" not in environment for environment in environments)
|
||||||
|
|
||||||
|
|
||||||
def test_cassandra_sync_repairs_origin_through_broker_without_token(
|
def test_cassandra_sync_repairs_origin_without_token(tmp_path: Path, monkeypatch):
|
||||||
tmp_path: Path, monkeypatch
|
"""Missing credentials skip only the fetch, not the local origin repair."""
|
||||||
):
|
|
||||||
"""Repository sync uses the credential-isolated broker remote."""
|
|
||||||
workspace = tmp_path / "cassandra"
|
workspace = tmp_path / "cassandra"
|
||||||
(workspace / ".git").mkdir(parents=True)
|
(workspace / ".git").mkdir(parents=True)
|
||||||
monkeypatch.setattr(coordinator, "CASSANDRA_BASE_PATH", workspace)
|
monkeypatch.setattr(coordinator, "CASSANDRA_BASE_PATH", workspace)
|
||||||
@ -463,13 +461,8 @@ def test_cassandra_sync_repairs_origin_through_broker_without_token(
|
|||||||
monkeypatch.setattr(coordinator.subprocess, "run", run)
|
monkeypatch.setattr(coordinator.subprocess, "run", run)
|
||||||
state = coordinator.sync_cassandra_repo({})
|
state = coordinator.sync_cassandra_repo({})
|
||||||
|
|
||||||
assert state == "ready"
|
assert state == "ready; fetch skipped until Gitea token is configured"
|
||||||
assert any(
|
assert [command[-3] for command in commands] == ["remote", "set-url"]
|
||||||
"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():
|
def test_migrate_open_cassandra_tasks_preserves_running_and_done_tasks():
|
||||||
|
|||||||
@ -2,19 +2,17 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
|
||||||
import copy
|
import copy
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from email.message import Message
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
ROOT = Path(__file__).parents[2]
|
ROOT = Path(__file__).parents[2]
|
||||||
CLIENT_PATH = ROOT / "services/hermes/scm-common/scripts/gitea_api.py"
|
CLIENT_PATH = ROOT / "services/hermes/scripts/gitea_api.py"
|
||||||
HEAD_SHA = "465cf9146b05c174a2a8d310aff6c64be58277b6"
|
HEAD_SHA = "465cf9146b05c174a2a8d310aff6c64be58277b6"
|
||||||
if str(CLIENT_PATH.parent) not in sys.path:
|
if str(CLIENT_PATH.parent) not in sys.path:
|
||||||
sys.path.insert(0, str(CLIENT_PATH.parent))
|
sys.path.insert(0, str(CLIENT_PATH.parent))
|
||||||
@ -62,11 +60,8 @@ def _draft_response(**updates):
|
|||||||
|
|
||||||
|
|
||||||
class Response:
|
class Response:
|
||||||
def __init__(self, body: object, status: int | None = None):
|
def __init__(self, body: object):
|
||||||
self.body = body if isinstance(body, bytes) else json.dumps(body).encode()
|
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):
|
def __enter__(self):
|
||||||
return self
|
return self
|
||||||
@ -766,149 +761,6 @@ def test_create_rejects_exact_runtime_token_before_network(field: str):
|
|||||||
assert called 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):
|
|
||||||
nonlocal called
|
|
||||||
called = True
|
|
||||||
return Response(_draft_response())
|
|
||||||
|
|
||||||
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="Review evidence",
|
|
||||||
token="runtime",
|
|
||||||
opener=lambda *_a, **_k: Response(_draft_response(), status=status),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@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():
|
def test_create_verifies_every_server_postcondition():
|
||||||
client = _load()
|
client = _load()
|
||||||
calls = []
|
calls = []
|
||||||
@ -995,19 +847,6 @@ 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():
|
def test_output_redaction_covers_exact_token_and_authorization_header():
|
||||||
client = _load()
|
client = _load()
|
||||||
raw = b'{"message":"do-not-leak","debug":"Authorization: token do-not-leak"}'
|
raw = b'{"message":"do-not-leak","debug":"Authorization: token do-not-leak"}'
|
||||||
@ -1015,24 +854,3 @@ def test_output_redaction_covers_exact_token_and_authorization_header():
|
|||||||
|
|
||||||
assert b"do-not-leak" not in redacted
|
assert b"do-not-leak" not in redacted
|
||||||
assert redacted.count(b"[REDACTED]") >= 1
|
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,7 +13,7 @@ import pytest
|
|||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
ROOT = Path(__file__).parents[2]
|
ROOT = Path(__file__).parents[2]
|
||||||
CLIENT_PATH = ROOT / "services/hermes/scm-common/scripts/gitea_api.py"
|
CLIENT_PATH = ROOT / "services/hermes/scripts/gitea_api.py"
|
||||||
HEAD_SHA = "465cf9146b05c174a2a8d310aff6c64be58277b6"
|
HEAD_SHA = "465cf9146b05c174a2a8d310aff6c64be58277b6"
|
||||||
if str(CLIENT_PATH.parent) not in sys.path:
|
if str(CLIENT_PATH.parent) not in sys.path:
|
||||||
sys.path.insert(0, str(CLIENT_PATH.parent))
|
sys.path.insert(0, str(CLIENT_PATH.parent))
|
||||||
@ -98,12 +98,12 @@ def test_http_error_path_redacts_token(monkeypatch, capsys):
|
|||||||
assert client.main(["read", "/api/v1/repos/atlas/cassandra"]) == 1
|
assert client.main(["read", "/api/v1/repos/atlas/cassandra"]) == 1
|
||||||
captured = capsys.readouterr()
|
captured = capsys.readouterr()
|
||||||
assert "do-not-leak" not in captured.err
|
assert "do-not-leak" not in captured.err
|
||||||
assert "credential was disclosed" in captured.err
|
assert "HTTP 403" in captured.err
|
||||||
|
|
||||||
|
|
||||||
def test_flux_manifest_isolates_vault_token_in_separate_broker_only():
|
def test_flux_manifest_projects_runtime_vault_token_and_skill_only():
|
||||||
client_source = CLIENT_PATH.read_text(encoding="utf-8")
|
client_source = CLIENT_PATH.read_text(encoding="utf-8")
|
||||||
assert "scm_broker_client" in client_source
|
assert "/runtime-access/gitea-token" in client_source
|
||||||
assert "GITEA_TOKEN" not in client_source
|
assert "GITEA_TOKEN" not in client_source
|
||||||
|
|
||||||
deployment = yaml.safe_load(
|
deployment = yaml.safe_load(
|
||||||
@ -111,7 +111,9 @@ def test_flux_manifest_isolates_vault_token_in_separate_broker_only():
|
|||||||
)
|
)
|
||||||
template = deployment["spec"]["template"]
|
template = deployment["spec"]["template"]
|
||||||
annotations = template["metadata"]["annotations"]
|
annotations = template["metadata"]["annotations"]
|
||||||
assert not any("gitea" in key.lower() for key in annotations)
|
assert annotations["vault.hashicorp.com/agent-inject-secret-gitea-token"] == (
|
||||||
|
"kv/data/atlas/hermes/developer-gitea"
|
||||||
|
)
|
||||||
runtime = next(
|
runtime = next(
|
||||||
volume
|
volume
|
||||||
for volume in template["spec"]["volumes"]
|
for volume in template["spec"]["volumes"]
|
||||||
@ -136,17 +138,13 @@ def test_flux_manifest_isolates_vault_token_in_separate_broker_only():
|
|||||||
kustomization = yaml.safe_load(
|
kustomization = yaml.safe_load(
|
||||||
(ROOT / "services/hermes/kustomization.yaml").read_text(encoding="utf-8")
|
(ROOT / "services/hermes/kustomization.yaml").read_text(encoding="utf-8")
|
||||||
)
|
)
|
||||||
common = yaml.safe_load(
|
coordinator = next(
|
||||||
(ROOT / "services/hermes/scm-common/kustomization.yaml").read_text(
|
item
|
||||||
encoding="utf-8"
|
for item in kustomization["configMapGenerator"]
|
||||||
)
|
if item["name"] == "hermes-coordinator"
|
||||||
)
|
)
|
||||||
boundary = common["configMapGenerator"][0]
|
assert "gitea_api.py=scripts/gitea_api.py" in coordinator["files"]
|
||||||
assert boundary["name"] == "hermes-scm-boundary"
|
assert "gitea_api_policy.py=scripts/gitea_api_policy.py" in coordinator["files"]
|
||||||
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(
|
generator = next(
|
||||||
item
|
item
|
||||||
for item in kustomization["configMapGenerator"]
|
for item in kustomization["configMapGenerator"]
|
||||||
@ -156,19 +154,3 @@ def test_flux_manifest_isolates_vault_token_in_separate_broker_only():
|
|||||||
"SKILL.md=skills/manage-atlas-pull-requests/SKILL.md",
|
"SKILL.md=skills/manage-atlas-pull-requests/SKILL.md",
|
||||||
"openai.yaml=skills/manage-atlas-pull-requests/agents/openai.yaml",
|
"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"]
|
|
||||||
)
|
|
||||||
|
|||||||
@ -1,305 +0,0 @@
|
|||||||
"""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,16 +14,12 @@ import yaml
|
|||||||
ROOT = Path(__file__).parents[2]
|
ROOT = Path(__file__).parents[2]
|
||||||
HERMES = ROOT / "services" / "hermes"
|
HERMES = ROOT / "services" / "hermes"
|
||||||
SCRIPTS = HERMES / "scripts"
|
SCRIPTS = HERMES / "scripts"
|
||||||
SCM_SCRIPTS = HERMES / "scm-common" / "scripts"
|
|
||||||
if str(SCRIPTS) not in sys.path:
|
if str(SCRIPTS) not in sys.path:
|
||||||
sys.path.insert(0, str(SCRIPTS))
|
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):
|
def _load(name: str):
|
||||||
root = SCM_SCRIPTS if name == "gitea_api" else SCRIPTS
|
spec = importlib.util.spec_from_file_location(name, SCRIPTS / f"{name}.py")
|
||||||
spec = importlib.util.spec_from_file_location(name, root / f"{name}.py")
|
|
||||||
assert spec and spec.loader
|
assert spec and spec.loader
|
||||||
module = importlib.util.module_from_spec(spec)
|
module = importlib.util.module_from_spec(spec)
|
||||||
sys.modules[spec.name] = module
|
sys.modules[spec.name] = module
|
||||||
@ -173,6 +169,8 @@ def test_agent_runtime_stage_keeps_credentials_in_memory(tmp_path: Path, monkeyp
|
|||||||
values = {
|
values = {
|
||||||
"agent-api-key": "agent-key",
|
"agent-api-key": "agent-key",
|
||||||
"chat-relay-key": "relay-key",
|
"chat-relay-key": "relay-key",
|
||||||
|
"gitea-token": "gitea-key",
|
||||||
|
"gitea-username": "hermes-automation",
|
||||||
"node-ssh-private-key": "private-key",
|
"node-ssh-private-key": "private-key",
|
||||||
"node-ssh-config": "host-config",
|
"node-ssh-config": "host-config",
|
||||||
"node-ssh-known-hosts": "known-hosts",
|
"node-ssh-known-hosts": "known-hosts",
|
||||||
@ -196,10 +194,6 @@ def test_agent_runtime_stage_keeps_credentials_in_memory(tmp_path: Path, monkeyp
|
|||||||
assert (runtime / "codex/skills").is_symlink()
|
assert (runtime / "codex/skills").is_symlink()
|
||||||
assert not (runtime / "claude/backups").exists()
|
assert not (runtime / "claude/backups").exists()
|
||||||
assert not (runtime / "codex/sessions").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"))
|
auth = json.loads((runtime / "hermes-auth.json").read_text(encoding="utf-8"))
|
||||||
assert auth == {"version": 1, "providers": {}, "credential_pool": {}}
|
assert auth == {"version": 1, "providers": {}, "credential_pool": {}}
|
||||||
|
|
||||||
@ -363,12 +357,12 @@ def test_manifests_never_seed_access_material_into_persistent_env():
|
|||||||
assert triage_annotations[
|
assert triage_annotations[
|
||||||
"vault.hashicorp.com/agent-inject-secret-triage-api-key"
|
"vault.hashicorp.com/agent-inject-secret-triage-api-key"
|
||||||
] == "kv/data/atlas/hermes/triage-api"
|
] == "kv/data/atlas/hermes/triage-api"
|
||||||
gitea_api = (
|
askpass = (SCRIPTS / "gitea_askpass.sh").read_text(encoding="utf-8")
|
||||||
ROOT / "services/hermes/scm-common/scripts/gitea_api.py"
|
assert "/runtime-access/gitea-token" in askpass
|
||||||
).read_text(encoding="utf-8")
|
assert "GITEA_TOKEN" not in askpass
|
||||||
assert "scm_broker_client" in gitea_api
|
gitea_api = (SCRIPTS / "gitea_api.py").read_text(encoding="utf-8")
|
||||||
|
assert "/runtime-access/gitea-token" in gitea_api
|
||||||
assert "GITEA_TOKEN" not in gitea_api
|
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):
|
def test_chat_media_reads_one_raw_runtime_secret(tmp_path: Path):
|
||||||
|
|||||||
@ -1,352 +0,0 @@
|
|||||||
"""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