219 files, 2.0 MB, untracked in souveraine/docs and existing nowhere else. The volume is at 100% with no snapshots.
6.5 KiB
| description | status | date |
|---|---|---|
| Auth model for the memfs HTTP write path (POST/PATCH /v1/agents/:id/memory/*) | Draft | 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.0and 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.jsonis fine to be world-readable). - Tokens never accidentally leak through
souveraine agentsJSON 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).
// 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:
[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
- Never log the bearer token. Tracing instrumentation on the auth middleware logs only
agent_id+outcome=ok|denied, never the header. - The token file is mode 0600. Validate on read; reject if mode is broader (someone tampered).
- Rotation invalidates immediately — there is no grace period. Downstream cron jobs must re-fetch.
- 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:
- If
[server.auth].required = trueand no token file exists, return401with body{"error":"missing_token","message":"Run 'souveraine agents token <id> --rotate' to generate one."}. - The
--rotatecommand 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
- Rotation hooks — should rotation broadcast a SIGHUP or SSE event so long-running clients can re-fetch? Defer; v1 is rotate + clients-restart-themselves.
- 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.