Warrant: Approval-Gated Privileged Runs
The broker is now horkos (2026-08-20): the Warrant service was renamed Horkos (Ὅρκος, the oath daimon) and extracted to its own repo,
eblume/horkos, with auto-release on merge. The program documented here, the minted warrant artifact,warrant-policy.yaml, andwarrant-botall keep their names — a warrant is still what Horkos mints. Historical prose below says “Warrant” where it meant the service; read it as Horkos.
Status: BUILT AND RUNNING (2026-08-04). Proposed 2026-08-01, accepted after review (PR #455), and delivered across phases 0-4 in the days that followed. Agents now request privileged actions with
mise run request-run; Erich approves in horkos;warrant-botdispatches. First self-dispatched run: 713 (argocd-deployon ntfy, 2026-08-03). This doc remains the program’s north star and its record of why; per-phase state is below.
Phase State 0 — fences, settings done (squash-merge off, sshTests applied; forge MFA turned out to predate the phase — see Phase 0 item 3) 1 — request loop, argocd-deploy done and proven 2 — priv runner, audit sweep, vault tier runner + verify-runs+blumeops-cidone; item migration outstanding (blumeops-ci-item-migration)3 — agent identity, Warrant broker done; Warrant at v0.3.2, armed 4 — policy-as-code warrant-policy.yamlenforced at request and dispatch
The problem
The agent-isolation program (agent-workspaces, agent-containerization,
agents-forgejo-bot) has landed a working fence: agents author blumeops
changes from a pod with its own tag:agent identity, push to a fork, and open
PRs; the bot is read-only on canonical, so it cannot dispatch CI or reach the
deploy-credentialed Actions secrets; the blumeops 1Password vault is
unreachable by construction.
That fence is correct — and it makes every deploy a human-shaped bottleneck with no request channel. Today the loop is: agent opens PR → agent tells Erich in chat → Erich finds gilbert → reviews → merges → dispatches builds → syncs apps → resets revisions. The agent can neither run a privileged verification step (“build this container”, “sync this app from my branch”) nor formally request one. Work stalls in ways that are invisible unless a session happens to mention them, and Erich is losing track of the in-flight pieces.
Goal: agents make as much progress as possible without human intervention, against two firm boundary classes:
- (A) Ask-first actions — things an agent may cause to happen, but only
after an explicit, authenticated human approval bound to the exact change.
This class is bigger than it first looks: deploys, container builds, and
infrastructure applies like
tailnet-uporprovision-*all belong here — “human approves, agent-demanded machinery executes” is the target shape. - (B) Never-agent actions — credentials and control surfaces no agent path may reach at all, with or without approval, because no execution context should ever hold them: blumeops-vault browse access from the harness, Forgejo admin/settings, the approval-granting credentials themselves (invariant 4), NAS/backup keys. Class B is about credentials, not scary-sounding operations — an operation sits in class B only until a reviewed script exists to express it as a class-A run. The A/B line itself is policy, not architecture; Phase 4 turns it into a reviewed artifact.
The mechanism must put secrets in the execution context only (a specific CI run of a specific script at a specific commit), never in the agent harness.
Invariants (the load-bearing rules)
Every phase below preserves all five. A change that breaks one is wrong even if it is convenient.
-
Harness access stops at the
agentsvault. “Holds” is broad: a secret sitting in plain text in a transcript counts, and so does anythingopcan read with the session’s service credentials. Note theagentsvault already contains some operational secrets (service API tokens curated in deliberately, one by one) — so the invariant is not “no operational secrets”, it is that the blumeops-tier vaults stay unreachable: no approval flow ever widens what the harness’sopcan read, and no blumeops/blumeops-cisecret ever lands in a session — approval unlocks an execution context, not the agent. -
Execution binds to an immutable SHA. Approvals name a full 40-char commit, never a branch (branches move after review — TOCTOU). The
build-containershort-SHA guard is the precedent. Corollary: canonical must stop squash-merging — squash rewrites every approved SHA into a new commit, so nothing a human approved ever lands onmainas itself, forcing perpetual follow-up-PR churn. Switch the repo to merge-commit (or fast-forward) merges; this also dissolves the squash-merge container-tag orphaning in agent-change-process §Build artifacts. -
Privileged workflows are a structurally separate class. Three markers, all required, so privilege never blends into ordinary CI:
workflow_dispatch-only triggers — nothing event-driven can start one;runs-on: priv— a dedicated runner label (and, per Phase 2, a dedicated runner host) that only privileged workflows use and PR-check CI never touches. The label split lands with the first privileged workflow, even while it temporarily points at the same physical runner;- definitions run from
mainonly — the approved SHA is a payload input (what to check out / build / sync); the executing definition is main’s, already reviewed. Agents change privileged workflow definitions only via ordinary merged PRs.
This keeps “the thing reviewed” and “the thing executed” honest without the reviewer re-auditing workflow files per request, and never mixes privileged and unprivileged jobs in one workflow file or on one runner.
-
Approval is multi-factor human auth no agent can satisfy. Whatever grants an approval (Forgejo login, Authentik session) must require a credential no agent can reach plus a second factor. No token an agent can read may approve anything — otherwise a prompt-injected agent approves itself. Current factor (decided 2026-08-03): 1Password-managed TOTP via Authentik’s MFA stage — which gates both approval surfaces, Warrant and the forge dispatch button alike, since the forge’s own login is Authentik SSO (federated-login). Accepted with eyes open that both factors then live in the 1Password basket. The upgrade path to hardware-backed factors is preserved structurally: Warrant gates decisions on an Authentik flow slug, so adding a WebAuthn/hardware-key stage is authentik configuration, never Warrant code (see §Hardware-backed approval).
-
Every privileged run leaves an audit trail — who approved, what SHA, which secrets were exposed, what happened — somewhere agents can append to but not rewrite (Forgejo run history + heph log).
What already exists (the underappreciated primitive)
The read-only-bot fence has a flip side that is easy to miss: because
workflow_dispatch is write-gated, a human dispatching a workflow in the forge
UI already is an approval gate. And a multi-factor one: the forge sits
behind authentik SSO with TOTP enforced, so that button satisfies
invariant 4 with nothing new built. Canonical CI already holds privileged
Actions secrets (ARGOCD_AUTH_TOKEN, FLY_DEPLOY_TOKEN, ZOT_CI_API_KEY,
MAIN_PUSH_TOKEN, FORGE_REPO_WRITE_TOKEN) that agents provably cannot reach
(fence verified from in-pod 2026-07-31), and the runner executes them on human
dispatch. mise run container-build-and-release is exactly this shape.
Two clarifications on that secret surface. Those named Actions secrets are the
only blumeops-vault material CI can see — the rest of the vault never
leaves 1Password. And widening that set is itself a human-gated ceremony:
Actions secrets are provisioned by the forgejo ansible role, run from gilbert
with biometric op. That clumsiness is load-bearing, not incidental — the
provisioning friction is the class-B gate on new CI credentials, and nothing
in this design removes it; Warrant only streamlines the per-run
request/approve loop in front of already-provisioned secrets.
So the foundation is not missing — it is unfinished. What’s missing:
| Gap | Consequence today |
|---|---|
| No request channel | Agents ask in chat; requests evaporate when sessions end |
| No notification | Erich discovers pending work by logging in |
| No mobile-friendly approve | Approvals wait for a gilbert session |
| Actions secrets are repo-wide | Any dispatched workflow can read all secrets — no per-action scoping (Forgejo v15 has no environments/required-reviewers; verify each upgrade) |
| Runner blast radius | CI runs host-mode as erichblume on indri — a compromised run is an owner-account compromise |
| Vault is all-or-nothing | 1Password scoping is per-vault; “CI needs the argocd token” currently implies a token that can read every operational secret |
| No audit beyond forge | Run history exists but nothing ties request → approval → outcome → heph |
The design, in phases
Each phase is independently valuable and independently stoppable. Phases 1–2 use only deployed services (Forgejo, ntfy, 1Password, ArgoCD). Phase 3 builds the one new service. Phase 4 is the autonomy dividend.
Phase 0 — Housekeeping: clear the decks
-
Dispose of the three open PRs — mostly done during review, see §Open PR dispositions.
-
Disable squash-merge on canonical (forge repo settings; invariant 2’s corollary) before the first approval-gated run, or approved SHAs never land on
mainas themselves. -
2FA on the approval surface — satisfied on arrival. The forge is behind authentik SSO, and has been since 2026-02-20 (PR #228), five months before this doc was written. Authentik enforces TOTP MFA on its default authentication flow, so the dispatch button — Phase 1’s entire approval gate — was already multi-factor; the break-glass local login carries Forgejo’s own TOTP on top of that, so there is no password-only route to a dispatch. See federated-login §MFA.
No Phase 0 action was required. What remains is not a gap but an upgrade: both approval surfaces rest on a single 1Password-managed TOTP, and that “all eggs in one basket” concern — TOTP secrets living beside the passwords they second-factor — is real and is addressed head-on in §Hardware-backed approval.
Phase 0 mints no credentials.
Phase 1 — Request loop MVP (“dispatch-as-approval”), no new services
The smallest thing that closes the loop end-to-end:
-
mise run request-run <workflow> <full-sha> [-- key=val …](agent-side, works with agents-vault creds only). It:- validates the SHA is the head of an open PR (or a commit on one),
- enforces the SHA binding: every warrant-class action in
warrant-policy.yamlcarriesbinds_sha: <input>, naming the dispatch input that must receive the approved commit. A request whose bound SHA does not reach that input is refused rather than filed — see §The binding was decorative below, - posts a structured PR comment: requested workflow, full SHA, dispatch
inputs ready to copy, why, a diff link, and a link to the Warrant queue
entry itself (a bare
#Nin--whyis wrapped in a code span, because Forgejo otherwise autolinks it to a PR of this repo), - flags loudly if the diff touches
.forgejo/workflows/**(per invariant 3 those changes only take effect after merge — the reviewer should know), - files a heph task (
Approve: <workflow> @ <sha7> (PR #N), project Blumeops, attention orange) whose body carries the same links and inputs. heph is the system of record for pending approvals — the information plane, never executive (heph holds no credentials and can trigger nothing; invariant 4 untouched). The audit step (Phase 2) closes the task when the run lands, so an unactioned request is a visible orange task, not a lost chat message. - optionally pushes a phone notification — see §Notification channel below.
-
Approval = the human dispatches the named workflow from the forge UI (phone browser works — forge is behind the fly proxy) or via
mise run approve-runon gilbert, passing the full SHA as the payload input. The auth factor is the Authentik SSO login — session plus TOTP — that fronts the forge (federated-login). Known friction, Phase-1-temporary: the dispatch form means copy-pasting the SHA/inputs by hand. The request comment and heph task both carry a direct link to the workflow’s dispatch page plus a single copy-paste block; during implementation, check whether Forgejo can prefill dispatch inputs from URL query params (if yes, the link becomes one-click-then-confirm). Removing this form entirely — a true approve button — is Warrant’s headline UX win and the main reason Phase 3 exists. -
New privileged workflows (definitions on
main,workflow_dispatchonly, payload-SHA inputs), in rough order of value:argocd-deploy.yaml—argocd app set <app> --revision <sha> && sync(+ a--revision mainreset mode). Uses the existingARGOCD_AUTH_TOKEN. This is the big unlock: the “deploy from branch” step becomes agent-requestable instead of gilbert-only.services-check.yaml— post-deploy verification an agent can request (or a human can chain after a deploy), pending #439’s HTTP-leg rework. Interim by design: service verification’s destiny is Grafana (the agent-health /auth.jwtread path, heph01KXREAB…); the CI wrapper is the stopgap that makes it requestable today.provision-indri.yaml/provision-ringtail.yaml— the high-value targets, promoted from “later, maybe” to first Phase-2 deliverables: they are also the largest-blast-radius scripts in the repo (every ansible secret, root on both hosts), so they gate on theblumeops-civault split and theprivrunner, not the MVP. provision-indri can run on the indri runner (host-modeop+ SSH-to-self already work); provision-ringtail needs a runner that can drivenixos-rebuildon ringtail — likely the ringtail-side priv runner itself, and it must survive the network-restart hang (heph01KTKW8VD3…).
-
A third vault tier (the only new credential). The two existing vaults have clear meanings; the gap between them is exactly where privileged execution contexts live:
Vault Meaning Who reads it agentswhat a session needs to be a useful agent — including some deliberately-curated operational API tokens (invariant 1’s nuance) the harness, always blumeops-ci(new)what privileged execution contexts read at runtime: argocd token, fly token, zot key, main-push PAT approved CI runs only, via a read-only SA token stored as one new Actions secret ( BLUMEOPS_CI_OP_TOKEN)blumeopsthe whole keys to the kingdom: break-glass admin, every ansible pre_task secret, backup/NAS keys humans with biometric op, forever (class B)Why a middle layer at all: CI-runtime items are too hot for
agents(invariant 1), but a CI-readable service account onblumeopswould hand every ansible secret to any approved run — 1Password scoping is all-or-nothing per vault, so the blast-radius cut needs its own vault. The harness gets no grant onblumeops-ci; the op-shim fence extends unchanged. Migrate the static Actions secrets intoblumeops-ciitems over time so rotation is oneop item edit, not a re-provision (subsumes heph01KT5Q9HDJ…zot-key cycling). -
Pilot: PR #440 (mealie/miniflux). Merge, then run its container build + argocd sync through the request→notify→dispatch loop as the acceptance test.
What Phase 1 does not fix: repo-wide secret visibility (every dispatched
workflow can still read all Actions secrets — bounded by invariant 3 and the
blumeops-ci split), and the runner blast radius. Those are Phase 2.
Notification channel: ntfy vs heph
ntfy is deployed and reaches the phone, but deepening it deserves eyes open rather than default momentum:
- What ntfy can do well here:
X-Clickdeep-links a notification straight to the PR/dispatch page, andX-Actionssupports up to three tap buttons per message — so “Review diff” / “Open dispatch page” buttons are cheap. True approve/deny buttons need an endpoint to receive the tap; that endpoint is Warrant’s API, so before Phase 3 buttons can only deep-link. - The costs: iOS delivery rides the upstream
ntfy.shrelay, and NVR detection volume already sits near the free tier — approvals traffic risks the paid tier and deepens a dependency that isn’t loved. The topic ACL story also needs hardening before approval traffic uses it (topics are currently open-on-tailnet; lockops-approvalsso only agents publish). - The push-free fallback is genuinely fine: the heph task is the request record, and pending approvals are just an attention-orange query away in the heph PWA / TUI / CLI. No push, no ntfy dependency, nothing lost but latency.
Decision: Phase 1 is heph-first — the task is mandatory, ntfy is an
optional flag (--notify) that can be turned off or ripped out without
touching the flow. The deeper ambition threaded through Erich’s review —
heph as the information plane for all of this (PRs, CI runs, approvals
mirrored as heph nodes; heph informs, never executes) — is adopted as a
design north star and lands with Warrant, whose queue is the natural thing
to mirror into heph wholesale.
Phase 2 — Scope and blast radius
- Vault tiering done properly. Complete the three-tier taxonomy from
Phase 1: audit which items each workflow actually reads and move only
those into
blumeops-ci; everything else stays inblumeops, human-only. Then landprovision-indri.yaml/provision-ringtail.yaml(promoted per review — see Phase 1’s workflow list for their runner constraints). - Dedicated privileged runner. Move privileged workflows off
erichblume@indrihost-mode onto a purpose-built runner (NixOS container or VM, own uid, owntag:citailnet identity, egress-allowlisted like the agent pod) so a hostile job compromises a sandbox, not the forge owner’s account. Thenix-container-builderrunner on ringtail is the precedent. Label split:runs-on: privfor secret-bearing jobs; keepindrifor the rest (interacts with PR #439’s label rename). - Audit plumbing. A shared post-run step: append run metadata (workflow,
SHA, dispatcher, outcome) as a
heph logentry on a standing “Privileged runs” heph task, via the hub on indri:8787. Also a weekly ntfy digest of runs + pending requests, so nothing silently rots. - Drift verification for the dispatch identity (done).
warrant-botis minted by a human on gilbert precisely so that granting a persistent privileged identity stays a ceremony — but a ceremony’s result can be undone in the forge UI in five seconds, and none of it is version-controlled here, so no diff would ever show it.mise run warrant-bot-driftasserts the four facts that bound the blast radius (exactly write, blumeops only, not a site admin, not onmain’s whitelist) and runs weekly. Read-only by construction: creating the credential and checking it are opposite kinds of operation and should not share a credential, let alone a job.
Phase 3 — The Warrant broker (the go-big destination)
A small self-hosted approval service — warrant — deployed like every
other k8s app (argocd/manifests/warrant/, locally-built Nix image,
warrant.ops.eblu.me behind caddy, SSO via authentik). FastAPI +
SQLite (Litestream/PVC), a few hundred lines, boring on purpose.
agent (pod, agents-vault creds) Erich (phone/laptop)
│ POST /requests {action, sha, inputs, why} │
▼ ▼
warrant.ops.eblu.me ──heph task + push──▶ Authentik login + passkey step-up
│ queue: request → show diff link, action, │
│ secret scopes, agent's dry-run output ◀──approve/deny──┘
│
│ on approve: mint a WARRANT = single-use, ≤1h-TTL signed record
│ {action, sha, inputs, secret-scope, approver, expiry}
▼
dispatches the Forgejo workflow (broker-held PAT agents never see),
passing the warrant id; the job's secret step exchanges warrant-id →
exactly the named secret leases (broker-held op SA tokens), single-use
Why a broker instead of stopping at Phase 1:
- Per-request secret scoping. The job gets the secrets its warrant names, not the repo-wide set — the thing Forgejo cannot express.
- Real approval UX. One queue, mobile passkey approve, request context (diff, class, dry-run output) in one screen — instead of forge-UI dispatch forms with hand-copied SHAs.
- Approvals beyond CI. The same flow can gate “add credential X to the
agents vault”, “run
tailnet-up”, “argocd sync app Y” — any action a script can express, uniformly audited. - Auditability. Append-only decision log (who/what/when/outcome), mirrored to heph, queryable.
Agent-side auth to the broker: the Authentik service-account groundwork in
heph 01KXREABVH2FZAWKP4R0RN30S8 (client-credentials JWT) — that task becomes
a Phase-3 prerequisite instead of a Grafana-only nicety.
Hardware-backed approval (deferred 2026-08-03 — capability preserved)
Status: deferred by decision, not analysis — no hardware key on hand. v0.2 ships with the Authentik session + TOTP step-up. The requirement below stands as the upgrade path; everything in it becomes an authentik-flow configuration change when a YubiKey arrives, because Warrant’s decision endpoint requires a flow slug, not a factor type. The spike task (heph
01KZ0G21KZ…) stays open, dormant.
The requirement, stated precisely: proof that human Erich approved the action, via a physical device that is pocket-portable and works across an iPhone, a macOS laptop, and a Linux (NixOS/Wayland) desktop.
Where today’s setup falls short: Authentik TOTP with the seed stored in 1Password is MFA in form, but both factors collapse into the 1Password basket — a compromised 1Password session (the exact thing an agent-adjacent attacker would chase) holds the password and mints the TOTP. Acceptable interim, wrong end state for the credential that approves privileged runs.
Recommended candidate: a hardware FIDO2 key (e.g. YubiKey 5C NFC — USB-C covers macOS and the Linux desktop, NFC covers the iPhone; WebAuthn is native in all three platforms’ browsers, and NixOS needs only the udev/ libfido2 bits, declarable in this repo). Authentik’s WebAuthn stage can require user verification (PIN/touch) and restrict authenticator attestation, so the approval flow accepts only the hardware key — a device-bound, non-exportable, phishing-resistant credential that lives entirely outside 1Password. A second enrolled key in a drawer is the break-glass. Platform passkeys (Face ID / Secure Enclave) can serve as a convenience tier for lower action classes if wanted — but 1Password-synced passkeys specifically re-centralize into the basket and are excluded from approval flows.
Deliverable: a research/spike task (file on acceptance of this doc)
validating the Authentik stage configuration and the three-platform tap-to-
approve UX before Warrant’s flows are built. Per-class strictness — hardware
key for provision-*, platform passkey for a docs build — then becomes an
Authentik flow-policy choice, which is Phase 4 policy material, not new
infrastructure.
The broker is new attack surface and must itself pass the fence review: it holds dispatch + secret-lease credentials, so it is a class-B system — agents author its code via PR like everything else, never operate it. Tailnet-only first; public exposure via the fly proxy only if phone-off-tailnet approvals turn out to matter (Tailscale on the phone probably makes this moot).
Phase 4 — Policy-as-code and graduated autonomy
Once warrants exist, the approval policy moves into the repo as reviewed
data: a policy.yaml mapping action classes to outcomes — auto-approve
(docs build, ntfy test-post), warrant (deploys, container builds),
deny-always (class B). Auto-approved runs still mint warrants (audit trail),
and a periodic digest keeps post-hoc review honest. This is the payoff: the
boundary between “agent may do” and “agent may request” becomes a diffable,
PR-reviewed artifact instead of tribal knowledge — effectively the control
plane of a “blumeops v2” without rewriting blumeops.
Alternatives considered and rejected
- Full blumeops v2 rewrite. The fences just landed and verify clean (2026-07-31 in-pod check). A rewrite re-opens every one of them for months for zero new capability. v2 emerges incrementally as the warrant control plane instead.
- Separate hardware-FIDO job-runner server. What’s rejected is the separate server, not hardware auth — the hardware requirement is real and taken up in §Hardware-backed approval. Authentik already provides the WebAuthn machinery; a second bespoke auth system would just be a second thing to fence.
- Approval loads blumeops vault into the harness. Violates invariant 1 outright. Execution-context-only is the whole design.
- Forgejo-native fork-PR run approval (the “Approve run” button). Reviewed and rejected as the primary gate: it approves running a workflow, not a specific privileged action; secrets exposure on fork-PR events is version-dependent; and it evaporates the request/audit/scoping layers. Keep PR-check CI on that path; keep privileged runs on dispatch/warrant.
- GitHub environments + required reviewers. The right primitive, but Forgejo v15 doesn’t have it. Re-check at each Forgejo upgrade; if it lands, Phase 2 shrinks and Phase 3’s scoping half shrinks with it.
Open PR dispositions
Actioned by Erich during this doc’s review (2026-08-02); recorded here as history plus the follow-ups they leave behind:
- #439 (honest
indrirunner label + services-check indri leg) — merged. Follow-up: confirm no workflow added since it branched (e.g. Agent Repo Access) still saysruns-on: k8s, then drop the compat label at the next provision. Phase 2’sprivlabel builds on this rename. - #440 (mealie v3.20.1 / miniflux 2.3.2) — still open; merge as-is (it carries its own breaking-change review), then use its container build + argocd sync as the Phase 1 pilot run.
- #441 (remove
tag:homelab → tag:homelabSSH accept) — closed without merging, as proposed, two reasons. (1) It would have broken backups: borgmatic’s k8s SQLite dumps runssh:eblume@ringtailfrom indri (ansible/roles/borgmatic/defaults/main.yml) — homelab→homelab SSH is load-bearing; the PR’s consumer analysis only checked SSH to indri. (2) Its motivating hole is properly closed: the agent no longer carriestag:homelabafter the pod cutover (#445–#448), and agent-containerization §“Why we can’t just fix the ACL” already retracts the patch. Residual follow-up: assert inpolicy.hujson’s ACL tests thattag:agenthas no SSH anywhere, if #445’s tests don’t already.
The binding was decorative
Invariant 3 splits a privileged run in two: the definition comes from main,
and the approved SHA is a payload input — what to check out, build, or sync.
Warrant froze that SHA into every record. Nothing made it reach the run.
The two halves lived in different fields and never met. request-run sends
{action, sha, inputs}; the workflow reads only inputs. build-container.yaml
declares ref as optional, defaulting to the dispatch ref, because a human
dispatching from the UI reasonably means “current HEAD”. So a request could
name one commit and dispatch another, and did:
Warrant #22 bound
bcb2b55(agent-ws 0.15.0). The request passed-i container=agent-wsand noref. Run 742 checked out1cb0a614— main’s tip — rebuilt the v0.14.0 image that already existed, and reported success.verify-runsthen closed the tracking task, and closed superseded #21’s task from the same run for good measure. Two requests bound to two different commits, both marked satisfied by one build of a third.
Every signal was green. The operator error was real, but the system offered no resistance at any point on the path, which is the part worth fixing.
The repair names the join in the reviewed artifact rather than in code:
binds_sha: <input> in warrant-policy.yaml says this input carries the
approved commit. request-run refuses a request that omits it, that passes a
different SHA, or that passes a mutable ref like main — a value the workflows
accept and the policy pattern still admits, but which resolves at dispatch time
and so is exactly the moving target an approval exists to pin down. A
warrant-class action with no binds_sha at all is refused too, so the hole
cannot reopen by omission when the next privileged workflow lands.
verify-runs audits the same property after the fact, and it compares the
record against itself — the bound sha versus the inputs actually
dispatched — never the run’s head_sha. A dispatched run’s head_sha is
main’s tip at dispatch time, not the payload; comparing against it would fail
every honest approval filed before main moved on. That distinction is the whole
substance of the run-attribution fix in PR #523, and it is easy to lose, because
“compare the run’s SHA to the approved SHA” is the obvious wrong answer.
The general shape, which recurs: a value is not a constraint until something refuses on it. Warrant recorded the SHA faithfully from day one, displayed it in the UI, and put it in the PR comment. It still bound nothing.
Verify during implementation (assumptions to prove, not facts)
- ntfy topic auth: lock
ops-approvalsbefore trusting it for approvals traffic (server currently treats topics as open); confirm whether approvals volume plus NVR volume crosses thentfy.shrelay’s free tier. - 1Password: a service account can be granted read-only on a single vault (believed yes); minting is a gilbert/biometric step.
- Forgejo v15.0.3: exact
workflow_dispatchsemantics for “definition ref vs payload ref” (invariant 3 assumes definition-from-dispatched-ref; the Phase-1 workflows must therefore only be reachable onmain— confirm a branch on the fork can’t shadow them, which read-only-on-canonical should already guarantee). - Forgejo dispatch-page URL: can inputs be prefilled via query params? (Turns the Phase-1 approval link into one-click-then-confirm.)
- Forgejo repo settings: disable squash-merge without disrupting the existing
release workflows’ push-to-main behavior (
MAIN_PUSH_TOKENpath). - Authentik: client-credentials for the agent SA (heph
01KXREAB…), and the WebAuthn stage’s user-verification + attestation-restriction options for the hardware-key flow.
One-off scripts (eblume/horkos#6)
The largest-blast-radius class of action — run this arbitrary script — gets
its own warrant class rather than an exception to the model: run-script.yaml.
The artifact being approved is the script itself. It travels in the request
inputs as the (script, script_sha256) pair; Horkos binds the pair at filing
time (the hash must match the body — a 422 otherwise) and freezes both in the
warrant, so what the approver reads on the confirm page is exactly what the
executor will run. The executor workflow re-verifies the hash before running,
as defense in depth: a body/hash mismatch mid-flight is a hard abort, not a
silent divergence.
The executor reports the full run back into Horkos (durable, on the warrant detail page): exit code, complete stdout and stderr, start/finish epochs, and the blumeops checkout SHA it actually ran. One run record per warrant; the report authenticates with the dispatch token as Bearer. The report finds its warrant by the forge run number Horkos stamps on dispatch.
The blumeops-ci vault carries horkos-dispatch (field token), a mirror of
op://blumeops/warrant-dispatch-token/token (the warrant-bot PAT). That is
per the one-CI-trust-tier decision above: the tier already includes push to
blumeops main via forge-main-push, so the token adds no new tier — and it
lets CI do the one thing its jobs must do, prove the run to Horkos.
One credential has to exist in two vaults because its two readers sit on
opposite sides of a fence: 1Password Connect (external-secrets, feeding the
pod) is provisioned --vaults blumeops, and the CI service account can read
only blumeops-ci. Neither can be widened without handing one side the
other’s whole vault. So the mirror is written by mise run warrant-bot-provision — the same ceremony that mints the PAT — on every run,
not just on --rotate, which makes that task the drift check as well. A
hand-copied mirror survives exactly until the first rotation, and it fails as
a 401 in a release job that points nowhere near the vault.
The same PAT was also, until eblume/talos#55 and eblume/horkos#9, a
RELEASE_FORGE_TOKEN Actions secret in both of those repos — a third and
fourth copy, each rotating independently. Their release workflows now read the
mirror at job time.
Deploying horkos
horkos is a manual-sync ArgoCD application (Sync Policy) — one
of five, and the only one that is manual because of what it is rather than
what it tracks. Its manifest states the reason: the approval gate for
privileged runs must not redeploy itself from a merge alone. So merging a
change to argocd/manifests/horkos/ is not the deploy; someone must sync it.
The deadlock. The documented way to sync a manual app is mise run request-run argocd-deploy.yaml …, which horkos must dispatch. When the
merged change is the one that repairs dispatch, that route cannot run: the
request files, a human approves it, and consume_and_dispatch fails for the
same reason it was failing before. Approval succeeds and nothing happens.
The escape hatch, which is intended rather than a workaround:
argocd login argocd.ops.eblu.me --sso # your own identity, not admin
argocd app sync horkosThis does not weaken the gate. Syncing an ArgoCD app from gilbert is already a human act behind authentik SSO, and the change being deployed already passed PR review and merge. The warrant path exists to gate agent-initiated privileged runs, and an agent has no path to either command.
Encountered for real on 2026-08-26: the CoreDNS rewrite broke horkos’s
in-cluster HTTPS calls to the forge (eblume/blumeops#708), which made every
approval a silent no-op — including any approval that would have deployed the
fix. Two related failure modes worth knowing, both filed against horkos: a
dispatch that fails before _dispatch persists nothing and shows the
approver nothing, and a denial records no decided_by at all.
Related
- agent-containerization — the isolation substrate this builds on
- agents-forgejo-bot — the identity whose read-only fence is the gate
- agent-workspaces — isolation model and history
- security-model — vault and tailnet posture
- heph:
01KXBNMYGHGDVSR5VTRWYXDRGN(containerization),01KXREABVH…(Authentik SA),01KY57XB…(SSH fence break),01KT5Q9HDJ…(zot-ci key cycling — subsumed byblumeops-civault rotation)