# jarvis — design spec
Status: draft. This document describes the target design for jarvis as an
incident-enrichment service. The [README] documents what exists today; this spec
documents where it is going and why the pieces fit together.
## 1. Vision
When something breaks in the **Charlie** homelab, the first job is not fixing —
it is *understanding*. jarvis is the on-call teammate that does the boring first
pass for you.
An alert fires. Before you have opened a laptop, jarvis has already:
- read the alert and restated it in plain language,
- checked the relevant dashboards, metrics, and logs in Grafana,
- looked at the offending stack's repo, recent deploys, and failed jobs in
Gitea,
- optionally poked the affected host read-only over the SSH gateway,
- grounded all of it in the Charlie docs, skills, and troubleshooting guides,
and written a short, skimmable triage that lands in your RSS reader as one more
item to read over coffee. You skim it and decide: **act, watch, or ignore**.
jarvis never pages you awake to go spelunking. It hands you a conclusion and its
evidence, and lets *you* stay the decision-maker. It is an enrichment and triage
tool, not an auto-remediation system: it reads, it reasons, it reports. It does
not change anything.
The end state feels like a competent junior SRE who reads every alert, does the
homework, and leaves you a tidy note — except it is instant, tireless, and
already knows the whole estate.
## 2. Product behaviour
### 2.1 Lifecycle
```mermaid
flowchart LR
VM[vmalert / Grafana] -->|webhook| WH[POST /webhook/grafana]
WH --> Q[(queue)]
Q --> AG[copilot agent
headless, RO tools]
AG --> DB[(SQLite /data)]
DB --> FE[GET /feed.atom]
FE -->|Basic auth| MF[miniflux]
```
An alert batch arrives on the authenticated webhook, is queued, and processed
**serially** (one heavyweight agent run at a time). Each alert becomes one feed
entry: the AI triage plus the raw alert for audit. miniflux polls the
authenticated Atom feed and surfaces new entries in the reader.
Only **firing** alerts are enriched. Resolved notifications are acknowledged and
dropped — the value is in the first-pass triage of an active problem, and the
reader already carries the firing entry as history.
The feed is a **rolling daily window**: it serves entries from the current day
and resets each day. miniflux is the durable store — it caches every entry it
has fetched — so jarvis keeps only what a fresh reader needs to catch up, not a
long archive.
### 2.2 Endpoints
| Method | Path | Auth | Consumer |
| ------ | ------------------ | ------------ | ------------------- |
| POST | `/webhook/grafana` | Bearer token | Grafana contact pt. |
| GET | `/feed.atom` | HTTP Basic | miniflux |
| GET | `/healthz` | none | healthcheck |
### 2.3 Feed entry contract
Every entry is Markdown and skimmable in a reader in under a minute. It always
carries enough for a triage decision without opening anything else:
| Field | Purpose |
| -------------- | --------------------------------------------------------- |
| Title | `[status/severity] ` for at-a-glance scanning. |
| Verdict | `act` / `watch` / `ignore` — the triage call, up top. |
| What fired | One plain-language sentence. |
| Findings | What jarvis actually observed (live metrics, logs, jobs). |
| Likely cause | Most probable first, tied to concrete config or evidence. |
| Suggested next | The single most useful next step, if any. |
| Confidence | How much to trust the above; what jarvis could not check. |
| Links | Dashboard, generator, silence URLs from the alert. |
On diagnostic failure the entry still publishes with the raw alert and the
error, so an outage in jarvis never silently swallows an alert.
## 3. Architecture
jarvis is a single Go service plus a headless agent, in one container.
```mermaid
flowchart TB
subgraph C[jarvis container]
S[jarvis server
Go: webhook, queue, store, feed]
subgraph agent[sherlock copilot -p ...]
CO[copilot CLI
--yolo, RO]
G[grafana-mcp]
H[gssh-mcp]
GT[gitea-mcp]
end
S -->|spawns per alert| agent
CTX[/context: cloned Charlie repos
skills + TSG + docs/]
CO -.grounds in.-> CTX
end
CO --> G --> GR[(Grafana)]
CO --> H --> GS[(gssh gateway)]
CO --> GT --> GI[(Gitea API)]
```
| Component | Repo | Role |
| ---------------- | ------------------------ | --------------------------------------- |
| jarvis service | [amacocian/jarvis] | Webhook → queue → store → Atom feed. |
| diagnostic agent | Copilot CLI + [sherlock] | Headless reasoning with RO tool access. |
| MCP servers | [sherlock] | `grafana-mcp`, `gssh-mcp`, `gitea-mcp`. |
| deploy stack | [Charlie/jarvis] | Compose, secrets, host pin, config. |
| context repos | [Charlie] org | Cloned into `/context` for grounding. |
The build change is small: the diagnoser invokes **`sherlock copilot`** instead
of `copilot` directly, so sherlock renders the `.mcp.json` and injects the
service MCPs. jarvis never touches a service token itself.
## 4. Identity and access
### 4.1 One identity, LDAP as source of truth
jarvis is **a single LDAP account** — nothing more. LDAP is the source of truth;
Authentik syncs from it. Every service resolves that one identity through its
own front door:
- **Gitea** logs the account in through Authentik.
- **Grafana** authenticates the account through Authentik.
- **gssh / SSH** resolves the account through LDAP directly.
So one account, defined once in LDAP, is the whole identity surface. Its
permissions are its LDAP group memberships, scoped **read-only**, mirroring what
a human on-call operator may see. jarvis is not a bag of static per-service
tokens; it is a directory principal. Static tokens appear only where a service
cannot speak machine SSO (see §4.4).
### 4.2 No blast radius
On the hosts, passwordless sudo is granted solely by the LDAP `admins` group
(`/etc/sudoers.d/90-ldap-admins`: `%admins ALL=(ALL) NOPASSWD:ALL`). The jarvis
account is **deliberately not in `admins`**, so it can never `sudo` and cannot
mutate a deployment, restart a service, or read a root-owned secret. Combined
with read-only tool sets and the gssh host allow-list, the worst jarvis can do
is look. This is what makes `--yolo` acceptable (§6.4).
### 4.3 The headless problem
sherlock's only login path today is an interactive **PKCE browser flow** on
`127.0.0.1:6990` (see [sherlock auth-model]). A headless container has no
browser and no operator to click. That flow cannot run in jarvis.
The SSO-native fix is the **OAuth2 client-credentials grant**
(machine-to-machine): sherlock presents the jarvis account's credentials
directly to Authentik's token endpoint and receives a JWT — no redirect, no
browser. So the JWT can represent the jarvis LDAP account (and carry its group
claims) rather than an anonymous Authentik service account, the grant uses the
account's app-password form (`client_id` + `username` + app password) rather
than a bare `client_id` + `client_secret`. Grafana's `[auth.jwt]` integration
and the gssh gateway's JWT bearer scheme already validate exactly this token, so
the permission model is unchanged; only the grant differs.
### 4.4 Per-service model
| Service | Grant | SSO? | Identity |
| ------- | -------------------------- | ------- | -------------------------------------------------------------- |
| Grafana | `client_credentials` (JWT) | full | jarvis LDAP account → Authentik → `[auth.jwt]`. |
| gssh | unchanged (JWT bearer) | full | jarvis LDAP account → Authentik → JWT bearer + host allow-list + ephemeral SSH cert. |
| Gitea | scoped read-only PAT | partial | jarvis Gitea user (Authentik login); PAT is the API credential. |
**gssh needs no change.** It already validates an Authentik JWT, mints an
ephemeral 5-minute SSH certificate for the principal, and enforces a per-account
host allow-list; the host `sshd` trusts that CA (`TrustedUserCAKeys`). Once
sherlock can mint the JWT headlessly (§4.3), `gssh-mcp` works as-is.
**Gitea is the unavoidable exception.** Its OAuth2 server is
authorization-code-only; it has no client-credentials or other headless grant,
so machine access to the Gitea *API* must use a Personal Access Token. The
identity is still the same account (its Gitea login resolves through Authentik);
only the API *token* is static. It is stored as SOPS ciphertext, scoped
read-only, and rotated like any other Charlie secret. The same PAT already backs
the read-only repo clone at container start.
### 4.5 Least privilege
- No service registers write tools; `grafana-mcp` and `gssh-mcp` run their
read-only tool subsets as today.
- gssh access is bounded by the account's **host allow-list**. gssh command
execution runs arbitrary single commands, but with no sudo (§4.2) and
read-only group membership the reachable surface is inspection only; the
prompt reinforces read-only intent, and enforcement lives in gssh, not jarvis.
- The Gitea PAT is scoped to read on repositories, organizations, issues, and
actions — enough for config, deploy status, and failed-job triage.
- Read access to a stack's runtime data (where needed) is a matter of adding the
jarvis account to that stack's `-rw` LDAP group, never `admins`.
### 4.6 Token lifecycle
client-credentials access tokens are short-lived and not refreshable in the
usual sense; renewal is simply re-minting from the token endpoint. sherlock's
token source keeps the bearer fresh transparently, the same way it does for the
refresh-token path today — the MCP request path never sees expiry.
## 5. Required changes in sherlock
The headless grant is the one true blocker and lives entirely in [sherlock].
gssh needs no change (§4.4).
| Area | Change |
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `internal/authn/` | Non-interactive `client_credentials` acquisition (app-password form, representing the jarvis account); token-source renewal re-mints instead of using `refresh_token`. No loopback, no browser. |
| `internal/config/` | Per-service `grant = "client_credentials"` plus the account `username` + app password (from secret). Default grant stays the browser flow. |
| `cmd/grafana-mcp/` | Select the grant from config; skip the `OnAuthURL` browser path when the grant is non-interactive. |
| `cmd/gitea-mcp/` | Accept a static read-only PAT (`token = "…"`) and send Gitea's `Authorization: token ` scheme, bypassing OAuth for this service only. |
| `docs/` | Document the machine grant in `auth-model.md` and `configuration.md`. |
Config shape (contract, not implementation):
```toml
[services.grafana]
provider = "sherlock-cli" # existing Authentik provider (client id)
grant = "client_credentials"
username = "jarvis" # the LDAP account; app password injected from secret
base_url = "https://grafana.example"
[services.gitea]
base_url = "https://gitea.example"
token = "…" # scoped read-only PAT, injected from SOPS
```
## 6. Diagnostic agent
### 6.1 Grounding
The agent runs with its working directory set to the cloned **project-charlie**
checkout so the Copilot CLI auto-loads Charlie's custom instructions and skills
(`charlie-deployment-status`, `charlie-grafana-logs`, `charlie-stack-gitea`) and
can consult the [TSG TOC][tsg-toc] before probing live systems. Sibling repos
cloned under `/context` are made readable via `--add-dir` so the agent can grep
any stack's compose files, scrape configs, and alert rules.
### 6.2 Tool access
Live read access comes from the sherlock MCPs, giving the agent the same
surfaces an operator would open:
- **Grafana** — dashboards, Prometheus/Loki queries, alert rules, annotations.
- **gssh** — host allow-list and read-only command execution on affected hosts.
- **Gitea** — repo contents, recent commits, workflow runs, failed job logs,
releases.
### 6.3 Prompt contract
The prompt keeps jarvis' role (on-call SRE producing a short triage) but defers
process to the loaded Charlie instructions and skills rather than restating
them. It requires the section structure from §2.3, an explicit
`act`/`watch`/`ignore` verdict, and read-only behaviour. Each run is bounded by
`JARVIS_DIAG_TIMEOUT`.
### 6.4 Safety posture
The container is a throwaway diagnostic sandbox with read-only credentials.
`--yolo` auto-approves tools, which is acceptable precisely because every tool
and credential is read-only and the blast radius is bounded by SSO scope. jarvis
publishes conclusions; it never remediates.
## 7. Deployment
Unchanged shape from today (see [Charlie/jarvis]): built by project-charlie's
`build-image` scoped workflow into [amacocian/jarvis], deployed by the
`Charlie/jarvis` stack via `deploy-stack.yml`, pinned to host `morgott`, state
under `/mnt/nas/data/jarvis`.
New deploy inputs, all SOPS-encrypted:
| Secret / config | Purpose |
| ---------------------- | -------------------------------------------------------------------------------- |
| jarvis app password | The LDAP account's Authentik app password, for the Grafana `client_credentials` grant. |
| Gitea read-only PAT | `gitea-mcp` and repo clone. |
| sherlock `config.toml` | Service targets and grants (non-secret targets; secret credentials injected). |
The image additionally ships the sherlock CLI and its MCP binaries
(`grafana-mcp`, `gssh-mcp`, `gitea-mcp`) so `sherlock copilot` works headless.
### 7.1 Manual provisioning (outside the repos)
Two identity artifacts are configured by hand, matching how Charlie manages its
IdP (Authentik providers and accounts are edited in the UI, not GitOps
blueprints; the [authentik] stack is on 2025.8, which supports the app-password
`client_credentials` flow):
- the **LDAP `jarvis` account** (synced into Authentik; not a member of
`admins`), plus the `-rw` groups it needs to read;
- its **Authentik app password** and the Grafana provider's scope mappings, so
the minted JWT carries the claims `[auth.jwt]` maps onto a Grafana identity.
The account's Gitea read-only PAT is minted once from its Gitea login (which
itself resolves through Authentik).
## 8. Phased delivery
1. **sherlock client-credentials grant** — §5. Unblocks headless auth for
Grafana; add the Gitea PAT mode. gssh is unchanged. Validate each MCP with
`--probe`.
2. **jarvis build** — ship sherlock + MCP binaries in the image, invoke
`sherlock copilot`, set the workdir/`--add-dir` grounding, drop resolved
alerts, roll the feed daily, and update the prompt and feed-entry contract
(verdict + findings + confidence).
3. **jarvis deploy** — one LDAP `jarvis` account (synced to Authentik), new
secrets and sherlock config, Grafana `[auth.jwt]` wiring, docs. No `admins`
membership.
## 9. Open questions
- Which `-rw` LDAP groups (if any) does the jarvis account need for
reading runtime data, versus API/log access being enough?
- Is a single Authentik provider fine for both Grafana and gssh, or is a
separate audit trail per service worth the extra config?
- Prompt tuning: how much live probing is worthwhile before the triage's value
per run stops justifying the agent runtime and token cost?
[amacocian/jarvis]: https://gitea.alexandru.macocian.me/amacocian/jarvis
[authentik]: https://gitea.alexandru.macocian.me/Charlie/authentik
[charlie]: https://gitea.alexandru.macocian.me/Charlie
[charlie/jarvis]: https://gitea.alexandru.macocian.me/Charlie/jarvis
[readme]: ../README.md
[sherlock]: https://gitea.alexandru.macocian.me/amacocian/sherlock
[sherlock auth-model]: https://gitea.alexandru.macocian.me/amacocian/sherlock/src/branch/main/docs/auth-model.md
[tsg-toc]: https://gitea.alexandru.macocian.me/Charlie/project-charlie/src/branch/main/tsg/toc.md