diff --git a/hosts/mars/hermes-agent.nix b/hosts/mars/hermes-agent.nix index d196f33..9a05a0a 100644 --- a/hosts/mars/hermes-agent.nix +++ b/hosts/mars/hermes-agent.nix @@ -1,4 +1,4 @@ -{ config, ... }: +{ config, pkgs, ... }: # Hermes Agent — moved here from jupiter (hosts/jupiter/hermes-agent.nix, # see its git history / b5fa599 / 713d91d for the terra->jupiter->mars @@ -15,10 +15,13 @@ # runtime — no redeploy needed except to bump the pinned digest below. # # Security posture: -# - Only two paths reachable: its own local state dir, and the small -# shared "dropbox" below (via the jupiter samba mount) for darman to -# hand files to Hermes — nothing else on jupiter's array is reachable -# if a command goes wrong or gets injected via Telegram/tool output. +# - Reachable paths: its own local state dir, the small shared "dropbox" +# (via the jupiter samba mount) for darman to hand files to Hermes, and +# — new — a clone of THIS repo at ${workspaceDir}/homelab plus `git`/ +# `tea` (logged in as the `luna` gitea account, PR-tier only — see +# services/dev/gitea.nix). Nothing else on jupiter's array or the host +# is reachable if a command goes wrong or gets injected via +# Telegram/tool output. # - Its own Telegram bot (own token, in secrets.nix) with an EXPLICIT # TELEGRAM_ALLOWED_USERS. # - Runs as a rootful podman container (services/containers.nix) with its @@ -26,6 +29,12 @@ # host-level debugging only (`hermes ...` alias below, needs sudo since # the container itself runs under root's podman, not darman's rootless # one). +# - git/tea access is direct CLI, not a narrow wrapper: darman explicitly +# chose this over a purpose-built MCP server (tried first, scrapped — +# see git history) in favor of simplicity. The backstop is entirely +# server-side: gitea's branch protection on `master` (only darman can +# push/merge/approve there) is what actually keeps a bad or injected +# command from reaching the base branch, not anything client-side here. # # Dashboard (HERMES_DASHBOARD=1) is gated behind Authentik, same setup as on # jupiter. Its default bind (0.0.0.0:9119) fails closed without an auth @@ -69,6 +78,16 @@ let # change if state ever gets migrated over. hermesUid = "986"; hermesGid = "983"; + + # luna's own working copy of this repo (git+PR account provisioned in + # services/dev/gitea.nix). Lives under hermesHome specifically so it falls + # inside HERMES_WRITE_SAFE_ROOT=/opt/data — Hermes's own file-editing + # tools can reach it the same way they reach anything else it manages, + # without a separate bind mount or sandbox root. + workspaceDir = "${hermesHome}/workspace"; + repoDir = "${workspaceDir}/homelab"; + giteaHost = "git.mgaction.town"; + giteaRepo = "darman/homelab"; in { # Browsing convenience (ssh access to the bind-mounted local state) — does @@ -91,15 +110,55 @@ in # podman requires the bind-mount source to already exist (no auto-create), # and the dropbox lives on the CIFS mount below — mkdir there works fine # over cifs, no server-side (jupiter) config needed. + # + # Also provisions luna's git/tea access: writes a git credential-store file + # and runs `tea logins add` INTO hermesHome (i.e. paths that appear at + # /opt/data/... once the container is up), and clones this repo if it + # isn't already there. All of this runs on the HOST as root, before the + # container starts — the container's own entrypoint is what fixes + # ownership to HERMES_UID/HERMES_GID on first boot (same mechanism + # already relied on for the rest of hermesHome; nothing new here). + # + # Delete-then-add for the tea login (not a "does it exist" check): tea can + # leave a login entry behind even when `add` reports failure (e.g. a token + # missing a scope errors out AFTER the entry is written — observed + # directly against the real instance during the first version of this + # setup). Delete-then-add is idempotent either way and picks up a rotated + # token for free. systemd.services.hermes-agent-prepare-dirs = { - description = "Create Hermes state dirs before the container starts"; + description = "Create Hermes state dirs + luna's git/tea access before the container starts"; before = [ "podman-hermes-agent.service" ]; wantedBy = [ "podman-hermes-agent.service" ]; unitConfig.RequiresMountsFor = [ "/mnt/jupiter" ]; + path = [ pkgs.git pkgs.tea ]; serviceConfig.Type = "oneshot"; script = '' mkdir -p ${hermesHome} mkdir -p ${dropboxDir} + mkdir -p ${workspaceDir} + + export HOME=${hermesHome} + export GIT_CONFIG_GLOBAL=${hermesHome}/.gitconfig + export XDG_CONFIG_HOME=${hermesHome}/.config + token_file=${config.sops.secrets.gitea_luna_token.path} + + # Never embed the token in the remote URL (would land in + # repoDir/.git/config in plaintext) — the credential helper reads it + # from this file instead. + install -m 0600 /dev/null ${hermesHome}/.git-credentials + printf 'https://luna:%s@${giteaHost}\n' "$(cat "$token_file")" \ + > ${hermesHome}/.git-credentials + git config --global credential.helper "store --file=${hermesHome}/.git-credentials" + git config --global user.name "luna" + git config --global user.email "luna@${giteaHost}" + + if [ ! -d ${repoDir}/.git ]; then + git clone "https://${giteaHost}/${giteaRepo}.git" ${repoDir} + fi + + tea logins delete luna 2>/dev/null || true + GITEA_SERVER_TOKEN="$(cat "$token_file")" tea logins add \ + --name luna --url "https://${giteaHost}" --no-version-check ''; }; @@ -114,11 +173,30 @@ in volumes = [ "${hermesHome}:/opt/data" "${dropboxDir}:/opt/data/dropbox" + + # git/tea for luna: the image doesn't ship `tea` (and shouldn't be + # trusted to have a known-good `git` either), so both come from this + # host's Nix store instead — mounted read-only at fixed PATH-visible + # locations. /nix/store itself has to come along too since both + # binaries are dynamically linked against paths inside it; the store + # is read-only content-addressed build output, not a source of + # secrets, so mounting the whole thing read-only costs nothing beyond + # the two specific binaries actually being reachable. + "/nix/store:/nix/store:ro" + "${pkgs.git}/bin/git:/usr/local/bin/git:ro" + "${pkgs.tea}/bin/tea:/usr/local/bin/tea:ro" ]; environment = { HERMES_UID = hermesUid; HERMES_GID = hermesGid; TZ = "Europe/Berlin"; + + # Point git/tea at the config the prepare-dirs oneshot wrote into + # hermesHome (visible here as /opt/data/...) — the credential-store + # helper, the luna gitea login, and (implicitly, via HOME not being + # overridden) darman's Hermes state stays wherever it already was. + GIT_CONFIG_GLOBAL = "/opt/data/.gitconfig"; + XDG_CONFIG_HOME = "/opt/data/.config"; # HERMES_TIMEZONE is the highest-priority source hermes_time.py checks # (ahead of config.yaml's `timezone` key) — the container has no host # /etc/localtime bind-mount, so it defaults to UTC otherwise (fixed in diff --git a/hosts/mars/secrets.nix b/hosts/mars/secrets.nix index 470afd7..6825a9b 100644 --- a/hosts/mars/secrets.nix +++ b/hosts/mars/secrets.nix @@ -35,4 +35,16 @@ TELEGRAM_ALLOWED_USERS=15151223 HERMES_DASHBOARD_OIDC_CLIENT_SECRET=${config.sops.placeholder.hermes_dashboard_oidc_client_secret} ''; + + # luna's own gitea push token (services/dev/gitea.nix provisions the + # account + PR-tier repo access on jupiter; this is the per-user token + # generated once via `gitea admin user generate-access-token --username + # luna --scopes write:repository,read:user` on jupiter — read:user is + # required, `tea logins add` fails without it). Read directly by + # hermes-agent.nix's prepare-dirs oneshot (default root:root owner is + # fine — that oneshot already runs as root) to set up a git + # credential-store file and a `tea` login, both written into hermesHome + # so they're visible inside the container at /opt/data/.... + # restartUnits re-provisions both on rotation, without a full mars deploy. + sops.secrets.gitea_luna_token.restartUnits = [ "hermes-agent-prepare-dirs.service" ]; } diff --git a/secrets/mars.yaml b/secrets/mars.yaml index e73f14f..97e2aff 100644 --- a/secrets/mars.yaml +++ b/secrets/mars.yaml @@ -4,6 +4,7 @@ tailscale_authkey: ENC[AES256_GCM,data:An+OPDZF9kmemzoDhZPo7yMljksCz3yE/W9I1EAwt opencode_go_api_key: ENC[AES256_GCM,data:x7V6iRrP6UMvMAYh/25bcrE10MHhL9lasCYRHiQ3PIDI6aL+uXP0/YpfrRPY+60m5Yv+Bd7+9aWTWdAVu1laSNjJGg==,iv:EmEAig+fSMYX+g77UpkiQ0USxUYOfFWX4WjIj9NA9N8=,tag:Pr+EZW6uDTSGjng8iG2SZw==,type:str] telegram_bot_token: ENC[AES256_GCM,data:WX+KFtoqFodkoWNwd7EXUrUJakZ9oaMZgg4OnCeL/JVXcsdQesD1PLmKp6vK9g==,iv:m1oqKlcesvhMLtndyp/XxsUAy0YpEsSulPDK0V+Wh0A=,tag:zvLcxcQ+A4fQUht5GkL2Qw==,type:str] hermes_dashboard_oidc_client_secret: ENC[AES256_GCM,data:IMPNTPMKO+b7eyV4hyGfnvH1/i+W4IPDNjncoyB1oIV8WaB6nOJn0sSEuTUCKB94K+Y7bsVQU0zpbKdIYOdGqgmPzwMCsScxMt4SewTmiiqWxv6SQFf4EzMxgXqjMvH8PWDzLcI2C2tI/KcVS251iqRViOTFe1/tkm+mV8sJmEI=,iv:F/rOUDmJZoGPS9fObAni5ntyOqbbhMWDPdHGLTexwlA=,tag:ALf98DmB0JziGspZMiLCiw==,type:str] +gitea_luna_token: ENC[AES256_GCM,data:EgSgzXFlYHN1yAlpjBBjSxacVYO9mhe1TBtAjNMZDEPxkeizB5O8Bw==,iv:pKN6bz7mBV3HxqBdnJi6ah17bukhd+sXeItojngT0HE=,tag:1wCfPK+MJb+P/S/kq2czeQ==,type:str] sops: age: - enc: | @@ -24,7 +25,7 @@ sops: oyJ7PS3lW+PxH5AZkeeU7gXO/pz2oDku0aDOds7kaD3n0+qSWicQ+Q== -----END AGE ENCRYPTED FILE----- recipient: age1eapjg6tdrr0fuvmgs3q3nlvnjkaxez298qynqqqxt0lpcv0lrsyq7ayxjk - lastmodified: "2026-08-21T23:24:48Z" - mac: ENC[AES256_GCM,data:p/vtMVfVWfgP7+xa8sbbdYLeLCYbjvthHoexmKU0RyIl2jv5bp9ldD1F1G8U+v+8+Re44DClcDtoNXrIHlreFx3hbH9sF2tnL4+oI2os7Yn+nmWZ6J1ngS2Hz+qspWtFt+up/TkP2BsTzoas5ZstJckOW0AkeKEYIT1zQmzcBtE=,iv:O2VoZK1DmRSJIR/bC538Ley1fkZqjI0L80YpjUU9mPo=,tag:xO9wzAocmxoxrIHcnBRGXQ==,type:str] + lastmodified: "2026-08-22T18:28:46Z" + mac: ENC[AES256_GCM,data:Y/QbEoRG2pJ+tz919+kSEfCs6HsjTHmiaO5xWuDhuVXO71Sm+8vx2OQwCnEHWA1FnFoQgBJWJFlAA4yMiFyjtE3Ark9Uxxi07DXYfXJ/B64DBbrxJMQSKVfCxi8KlExbKyL87FSuUwmSeYgE2DIydmOGDv0P+Q0kn5GJ1T6lJOU=,iv:iX9OJMJK3xTsGh8ZLXzZWUj5mZg7jGR3GnvMeR2lXvA=,tag:CvsoVF1vdf4fQmLE3NR9hw==,type:str] unencrypted_suffix: _unencrypted version: 3.13.3 diff --git a/services/dev/gitea.nix b/services/dev/gitea.nix index e7b0981..75c5191 100644 --- a/services/dev/gitea.nix +++ b/services/dev/gitea.nix @@ -14,6 +14,12 @@ let # and whitelisted to push past branch protection. Add a repo here and # redeploy — no manual UI clicking needed. ciBotRepos = [ "darman/hypr-chrome" ]; + + # Repos where luna (Hermes Agent's own gitea identity — see below) gets PR-tier + # access: Write collaborator (so she can push feature branches and open PRs) + # but explicitly walled off `master`'s push/merge/approve whitelists so + # nothing she does lands without darman clicking merge. + lunaRepos = [ "darman/homelab" ]; in { services.gitea = { @@ -166,4 +172,96 @@ in '') ciBotRepos} ''; }; + + # luna: Hermes Agent's own gitea identity (Hermes was renamed L.U.N.A., + # 2026-08-22). Deliberately PR-tier only, not push-tier like ci-bot: + # Hermes runs on mars, takes instructions over Telegram, and can be + # prompt-injected via tool output — a dedicated account with its own + # scoped, revocable token keeps that blast radius off darman's own + # credentials, and the branch-protection whitelists below keep it off + # `master` entirely regardless of what the token can technically do. + # She gets Write collaborator access (needed to push a branch and open a + # PR against the same repo — this instance has no fork workflow), but: + # - enable_push + enable_push_whitelist(darman only): nobody but darman + # can push straight to master; luna can only land on a side branch. + # - enable_merge_whitelist(darman only): opening a PR is not the same + # as merging one — only darman can click merge. + # - required_approvals=1 + enable_approvals_whitelist(darman only): + # an approval has to come from darman specifically, not luna + # rubber-stamping her own PR from a second identity. + # This is provisioning parity with ci-bot only (account + collaborator + + # branch protection) — it does NOT wire a token into mars/hermes-agent.nix + # yet; that's a separate step once luna actually has git tooling to call. + # + # luna's own push token (used by whatever git tooling gets wired into + # hermes-agent.nix later) is generated once, the same way ci-bot's was: + # su gitea -s /bin/sh -c \ + # 'GITEA_WORK_DIR=/mnt/data/AppData/gitea gitea admin user generate-access-token \ + # --username luna --scopes write:repository' + # then stored as a secret (e.g. secrets/mars.yaml's gitea_luna_token) — + # NOT pushed into gitea itself as an Actions secret like ci-bot's is, + # since luna isn't a CI workflow running inside gitea, she's an external + # agent calling out to it. + systemd.services.gitea-luna-provision = { + description = "Provision luna (Hermes Agent) gitea account + PR-tier repo access"; + after = [ "gitea.service" ]; + requires = [ "gitea.service" ]; + wantedBy = [ "multi-user.target" ]; + path = [ pkgs.curl pkgs.jq config.services.gitea.package ]; + environment = { + TOKEN_FILE = config.sops.secrets.gitea_provisioning_token.path; + }; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + User = config.services.gitea.user; + }; + script = '' + set -euo pipefail + api=http://127.0.0.1:${toString config.services.gitea.settings.server.HTTP_PORT}/api/v1 + admin_token="$(cat "$TOKEN_FILE")" + auth=(-H "Authorization: token $admin_token") + + for _ in $(seq 1 30); do + curl -fs "$api/version" >/dev/null 2>&1 && break + sleep 1 + done + + if ! curl -fs "''${auth[@]}" "$api/users/luna" >/dev/null 2>&1; then + GITEA_WORK_DIR=${config.services.gitea.stateDir} gitea admin user create \ + --username luna \ + --email luna@${config.services.gitea.settings.server.DOMAIN} \ + --random-password --must-change-password=false + fi + + ${lib.concatMapStringsSep "\n" (repo: '' + curl -fsS "''${auth[@]}" -H 'Content-Type: application/json' \ + -X PUT "$api/repos/${repo}/collaborators/luna" \ + -d '{"permission":"write"}' + + default_branch="$(curl -fs "''${auth[@]}" "$api/repos/${repo}" | jq -r .default_branch)" + + protect_body="$(jq -n '{ + enable_push: true, + enable_push_whitelist: true, + push_whitelist_usernames: ["darman"], + enable_merge_whitelist: true, + merge_whitelist_usernames: ["darman"], + required_approvals: 1, + enable_approvals_whitelist: true, + approvals_whitelist_username: ["darman"] + }')" + + if curl -fs "''${auth[@]}" "$api/repos/${repo}/branch_protections/$default_branch" >/dev/null 2>&1; then + curl -fsS "''${auth[@]}" -H 'Content-Type: application/json' \ + -X PATCH "$api/repos/${repo}/branch_protections/$default_branch" \ + -d "$protect_body" + else + curl -fsS "''${auth[@]}" -H 'Content-Type: application/json' \ + -X POST "$api/repos/${repo}/branch_protections" \ + -d "$(echo "$protect_body" | jq --arg b "$default_branch" '. + {branch_name: $b}')" + fi + '') lunaRepos} + ''; + }; }