219 files, 2.0 MB, untracked in souveraine/docs and existing nowhere else. The volume is at 100% with no snapshots.
131 lines
6.5 KiB
Markdown
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.
|