Watch
1
0
Fork
You've already forked SouveraineOS
0
SouveraineOS/docs/substrate/CRON_API_AUTH.md
Fimeg e480809c70 docs: rescue the agent-substrate tree out of a gitignored directory
219 files, 2.0 MB, untracked in souveraine/docs and existing nowhere else.
The volume is at 100% with no snapshots.
2026-07-26 12:11:50 -04:00

131 lines
6.5 KiB
Markdown

---
description: Auth model for the memfs HTTP write path (POST/PATCH /v1/agents/:id/memory/*)
status: Draft
date: 2026-05-08
---
# Cron-API Auth — Token-Based Per-Agent Access
## Why
`docs/MEMORY_BLOCKS_DECISION.md` § "Implementation Implications" item 4 calls for an HTTP write path replacing Letta's `PATCH /v1/blocks/{id}` for the cron-into-memfs pattern (Fimeg's daily writes: weather, fastfetch, fs tree, daemon-branch). The endpoints landed in this session:
```
GET /v1/agents/:id/memory[?prefix=…]
GET /v1/agents/:id/memory/*path
PUT /v1/agents/:id/memory/*path
PATCH /v1/agents/:id/memory/*path
DELETE /v1/agents/:id/memory/*path
```
These are unauthenticated. That is fine for a same-host loopback bind (`127.0.0.1`) but unsafe the moment Souveraine listens on a non-loopback interface or behind a reverse proxy. Auth was deferred when the endpoints landed; this doc fixes it.
## Threat model
- **Inadvertent exposure** — operator binds to `0.0.0.0` and forgets the LAN can reach memfs. Highest-frequency mistake.
- **Malicious LAN tenant** — someone on the same network attempts to read or modify another agent's memfs.
- **Rogue cron job on the host** — a process with the agent's token mutates memfs. Out of scope for v1; trust boundary is the host.
- **Token leakage via logs** — bearer tokens written to access logs / shell history. Mitigated by header-only delivery and "do not log" tracing rule (see § Operational rules).
## Decision
Per-agent bearer tokens, sent as `Authorization: Bearer <token>` on every request. No basic auth, no query-string tokens (logs eat them).
### Where tokens live
Tokens are generated at agent creation and stored alongside the agent on disk. Fits the existing layout:
```
~/.souveraine/server/agents/<agent-id>/
├── agent.json (existing — public metadata)
├── memory.git/ (existing — agent's MemFS)
└── api_token (new — file mode 0600, single line, the token)
```
Stored as a flat file (not in agent.json) so:
- File mode 0600 is enforceable (the rest of `agent.json` is fine to be world-readable).
- Tokens never accidentally leak through `souveraine agents` JSON output.
- Rotation is a single-file operation.
### Token format
UUIDv4 prefixed with `souv_` so a leak in a log or pastebin is searchable: `souv_3f2b8c44-…`. 32 random bytes hex-encoded would also work; v4 is simpler and we already pull `uuid` as a dep.
### Validation
A tower middleware on the memory routes (`/v1/agents/:id/memory/...`) extracts the path's `:id`, reads `~/.souveraine/server/agents/<id>/api_token`, and constant-time compares against the bearer header. On mismatch / missing header / missing file, return `401 Unauthorized` with a generic body — do not differentiate "no such agent" from "bad token" (avoid agent-id enumeration).
```rust
// pseudocode
async fn auth_layer(
Path(agent_id): Path<String>,
headers: HeaderMap,
State(server): State<Arc<SouveraineServer>>,
next: Next,
) -> Result<Response, StatusCode> {
let token = bearer(&headers).ok_or(StatusCode::UNAUTHORIZED)?;
let expected = server.agents.read_token(&agent_id).map_err(|_| StatusCode::UNAUTHORIZED)?;
if !subtle::ConstantTimeEq::ct_eq(token.as_bytes(), expected.as_bytes()).into() {
return Err(StatusCode::UNAUTHORIZED);
}
Ok(next.run(req).await)
}
```
The `subtle` crate is already a transitive dep via reqwest/rustls, so no new direct dependency.
### Bypass for loopback (opt-in)
`[server.auth]` config block:
```toml
[server.auth]
# Require Authorization on memory routes. Default: true once agents have tokens.
required = true
# When `required = true`, allow loopback (127.0.0.1 / ::1) requests to skip auth.
# Useful for local cron jobs that already have filesystem access.
allow_loopback = true
```
Loopback bypass is a deliberate convenience: a cron job running as the same user already has read/write to the memfs git repo on disk. Forcing it through HTTP+auth doesn't add a real security boundary on the same host. Document this clearly so operators don't confuse it with general "auth disabled."
## Tooling
### Token retrieval CLI
```
souveraine agents token <agent-id> # print the token (warns if writing to TTY of a non-interactive shell)
souveraine agents token <agent-id> --rotate # generate new, write to disk, print
```
### Token in agent creation response
`POST /v1/agents` returns the new agent's token **once** in the JSON response. After that, it can only be retrieved via `souveraine agents token` (filesystem read) or rotation. This matches GitHub's PAT model.
## Operational rules
1. Never log the bearer token. Tracing instrumentation on the auth middleware logs only `agent_id` + `outcome=ok|denied`, never the header.
2. The token file is mode 0600. Validate on read; reject if mode is broader (someone tampered).
3. Rotation invalidates immediately — there is no grace period. Downstream cron jobs must re-fetch.
4. Token in the request body is rejected (only header is accepted). Avoids `?token=…` in proxy logs.
## Out of scope (deferred)
- **Token scopes** (read-only vs read-write) — useful for "I want this cron job to write only to `system/dynamic/weather.md`." Future extension; v1 is "have the token, do anything to this agent's memfs."
- **OAuth / OIDC** — overkill for a self-hosted single-user box. Revisit if Souveraine grows multi-tenant.
- **TLS** — operator's responsibility (reverse proxy, Tailscale, etc). Souveraine binds plaintext HTTP today.
- **Audit log of HTTP writes** — the git history already captures every write with a commit. Adding a separate access log is duplicative until/unless we need request-IP / user-agent forensics.
## Migration
Existing agents (created before this lands) won't have a token file. On first request to a memory route for such an agent:
1. If `[server.auth].required = true` and no token file exists, return `401` with body `{"error":"missing_token","message":"Run 'souveraine agents token <id> --rotate' to generate one."}`.
2. The `--rotate` command is idempotent — runs the same on existing-or-missing token files.
This avoids silently auto-creating tokens (which could mask a corrupted agent dir).
## Open questions
1. **Rotation hooks** — should rotation broadcast a SIGHUP or SSE event so long-running clients can re-fetch? Defer; v1 is rotate + clients-restart-themselves.
2. **Per-IP rate limit** — protects against credential-stuffing if tokens are weak. Defer; UUIDv4 is 122 bits of entropy, brute force is not the threat.