Files
AkitaOnRailsandClaude Opus 4.8 85ddc1f450 fix(security): derive capture-exclusion path flavor from the host (GHSA-vh98)
A capture-exclusion candidate or shell argument spelled with a leading `//`
(e.g. `//repo/secret/token.txt`) self-classified as a Windows UNC path
regardless of the actual host. On a POSIX host `match_paths` then filtered it
against zero POSIX `ignore_paths` patterns (a flavor mismatch), so the file was
captured instead of dropped — a fail-open in the capture trust boundary.

Path flavor for an untrusted candidate is now derived from the host (the cwd):
on a POSIX host a leading `//` collapses to a single `/` before classification,
so it matches POSIX `ignore_paths` as intended. A genuine Windows/UNC host's
UNC candidates are unaffected. Fixed in both front doors — the native hook
(`ai-memory-hooks` `capture_policy.rs`) and the generated TypeScript
integrations (`ai-memory-cli` `render_shared.rs`, `captureHostWindows` /
`windowsHost`) — with a shared regression fixture and an adversarial test per
surface (violation + POSIX control + Windows-UNC control).

Also documents (docs/users.md) that a trusted-proxy non-root user is
`AuthLevel::User` with no DB identity, so `restricted`/grant enforcement does
not apply to them — the proxy is the authz boundary — and records that boundary
and its pinning test in docs/security-boundaries.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDbhmszrjG9s5MrPrTuNtm
2026-09-30 21:47:41 -03:00

782 lines
40 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Multi-user attribution
> **Status:** Introduced in v0.8; human password login, web sessions, and
> native `aim_` API credentials are the current shipped contract.
ai-memory is **single-tenant wiki data** with **optional multi-user
attribution**. Every authenticated request sees the same wiki pages —
there is no per-page RBAC or group permission model. Operational handoffs and
open-session recovery are owner-scoped so one operator cannot accidentally
consume or finalize another's live context. What multi-user mode also adds is
*who-did-this*: every write
attributes to a named user, audit-log rows carry that identity, and the
web UI can show "Last edited by Alice Smith" instead of the anonymous
default. Every `/admin/*` endpoint stays root-only once the deployment has a
DB user or trusted-proxy identities, including read-only
status/search/read-page helpers.
Hook session identifiers are also owner-bound. A hook authenticated as one
operator cannot reuse another operator's UUID to append events or trigger a
summary/handoff/end transition. Shared legacy sessions remain available to all
callers. Cross-owner recovery is limited to root's explicit
`finalize-session --all-owners` path.
Authenticated clients sharing one server must emit a distinct agent-run id for
each run and forward that same id on their MCP requests when using session-aware
auto-scope. Legacy sessions whose stored owner is `NULL` remain shared.
If you run ai-memory alone, you can skip this page — your install
keeps working unchanged.
## When to enable it
You probably want multi-user mode when:
- More than one human shares a single ai-memory server (a household,
a small team's homelab).
- You want the audit log to record *who* made each write (e.g. to
trace `Codex` writes vs `Claude Code` writes vs hand-rolled CLI
calls).
- You're planning to use the admission webhook chain —
webhooks receive the actor identity in their payload.
You probably **don't** need it when:
- You're the sole human user of your install. Single-user mode (no user rows)
remains compatible whether or not `init` has generated `[auth].token_pepper`.
- You need permissions / access control. v1 of ai-memory does
not implement RBAC by design (see
[`design-decisions.md`](design-decisions.md) §13). Attribution
records *who* did a write; it does not gate *whether* they
could.
## The four resolution rungs
Every HTTP request is resolved to one of four authentication tiers:
| Rung | Trigger | What the request gets |
|---|---|---|
| **0 — Anonymous** | No `[auth].bearer_token` set. | Allowed, no identity. Same as pre-multi-user defaults. |
| **1 — Root** | Bearer matches `[auth].bearer_token`. | Allowed as **root**. When `[auth].root_username` is set, writes attribute to that name; otherwise attribution stays anonymous. |
| **1b — Proxy-asserted user** | Bearer matches the distinct `[auth].actor_proxy_bearer_token`. | Identity is taken from trusted `X-Memory-Actor-*` headers. The request is a **user** unless its OIDC issuer/subject pair exactly matches the configured root pair. Missing or malformed identity is rejected. |
| **2 — DB user** | Bearer doesn't match root, matches an active `api_credentials.token_hash` (SHA-256 of the secret + `[auth].token_pepper`). Native keys use the `aim_` prefix. | Allowed as **that user** for normal read/write APIs. Native keys always resolve as `AuthLevel::User`, even when the owning identity is `role=root`. All `/admin/*` endpoints are root-only in multi-user mode. The audit log records the username/email/name. |
| **3 — 401** | Bearer present but matches nothing. | Rejected. Closes the bypass — unknown bearers can't slip through as anonymous. |
The rungs are sticky: a request is matched at the first credential that
applies, never escalates. Startup rejects equal root and proxy credentials;
root and proxy credentials take precedence over any accidental DB-token
collision.
Human password login is **not** a bearer rung. A browser session cookie never
authenticates `/mcp`, `/hook`, `/handoff`, or `/workstream/*`, and a password
never authenticates as `Authorization: Bearer`.
## Human login (password + web session)
The console signs in with username and password. The engine issues an
`HttpOnly` `ai_memory_session` cookie (`ams_` secret, hash only in SQLite)
plus a separate CSRF cookie that the SPA must send on cookie-authenticated
`POST`/`PUT`/`PATCH`/`DELETE`. Machine Bearers skip CSRF.
| Endpoint | Class | Notes |
|---|---|---|
| `POST /auth/login` | public | Rate-limited. Returns the `/auth/me` snapshot plus `Set-Cookie`. |
| `GET /auth/me` | session | Identity, `role`, `must_change_password`, capabilities. Anonymous only in zero-config loopback. |
| `POST /auth/password` | session + CSRF | Changes password, revokes other sessions, rotates this one. |
| `POST /auth/logout` | session + CSRF | Revokes the session and expires cookies. |
| `POST /auth/recovery` | public | Break-glass for the configured `root_username`. Never issues a session. |
Passwords are Argon2id PHC strings, 12–1024 UTF-8 bytes, hashed off the writer
actor. The last enabled root with a password cannot be disabled or demoted.
Greenfield bootstrap consumes `AI_MEMORY_AUTH__INITIAL_ROOT_PASSWORD` once
(`human_auth_state.bootstrap_completed`). Later restarts ignore it — unset the
env var. Lost root uses `AI_MEMORY_AUTH__RECOVERY_TOKEN` (at least 32
characters), compared constant-time and never stored in SQLite. Public
recovery failures — wrong token, recovery unset, or password policy —
share one 401 `{"error":"invalid credentials"}` body. `Config::load()`
rejects equality among the initial password, recovery token, root bearer,
and actor-proxy bearer without logging the values. Operator runbook:
[Human password bootstrap and recovery](install.md#human-password-bootstrap-and-recovery).
`/admin/*` and `/api/v1/*` accept a machine Bearer **or** a web session.
Custom SPA HTML at `/web` is public static; the builtin wiki browser stays
behind auth. With human auth active and no session, a browser GET to the
builtin wiki redirects to `{web_slug}/login` (HTML form → `POST /auth/login`);
`must_change_password` redirects to `{web_slug}/change-password`. JSON clients
and `/api/v1` still receive JSON 401/403. Once human auth becomes active, the
engine expires the deprecated `ai_memory_auth` compatibility cookie.
## Trusted proxy identity
A deployment that terminates SSO validates the end user's credential, then
authenticates upstream with a proxy-only bearer and describes the human in
`X-Memory-Actor-*` headers. Those headers are **ignored by default** on root
and DB-user requests because anything that can reach the port could otherwise
claim any identity.
Configure a distinct proxy credential:
```toml
[auth]
bearer_token = "<root-token>" # direct administration
actor_proxy_bearer_token = "<different-token>" # SSO proxy only
secure_cookie = true # required beyond loopback
# Optional stable identity for the root human behind an OIDC proxy.
root_issuer = "https://idp.example"
root_subject = "<root-subject>"
```
- The proxy token **is** the switch. A blank value counts as unset. It must
differ from `bearer_token`, and `serve` refuses startup otherwise or when the
root bearer is absent.
- Only set it when the server is reachable *only* through that proxy.
- `secure_cookie` is independent of proxy identity. Human auth requires it on
every non-loopback listener; ai-memory treats it as the explicit signal that
a trusted reverse proxy terminates HTTPS for `/web` and never trusts
forwarded protocol headers to infer that fact. Direct HTTP browsers will not
send a Secure cookie.
- **The proxy MUST strip client-supplied `X-Memory-Actor-*` headers before
setting its own.** Use a directive that *replaces* the header rather than
appending to it (nginx `proxy_set_header`, Traefik `customRequestHeaders`) —
with an appending ingress the client's value arrives first and would be the
one read. Repeated headers and comma-folded values are rejected with `400`
rather than resolved to one identity.
- Every proxy request must assert `X-Memory-Actor-User`, or both
`X-Memory-Actor-Issuer` and `X-Memory-Actor-Sub`. The OIDC fields are a pair:
the standard guarantees subject uniqueness only within an issuer. A partial
pair or a request that names nobody is rejected with `400`.
- Proxy callers are users by default. A username-only assertion can never
become root, including one equal to `root_username`, because an OIDC display
username is not a stable unique identifier. Proxied root access requires an
exact match with `root_issuer` plus `root_subject`.
- Origin health checks or maintenance calls that need root should use the root
bearer, not the proxy bearer. Raw actor headers on the root rung are ignored.
- **A proxied non-root end-user is not subject to per-project `restricted`
grants.** The proxy branch authenticates the request as `AuthLevel::User`
with an `ActorContext`, but — unlike a database user — never maps it to a
database `UserId`/`AuthorizedViewer`: grants are keyed on `UserId`, and a
proxied identity has none to key on. A missing `AuthorizedViewer` reads as
"no per-project check applies" (the same as root, or an install with no
database users at all), so a `restricted` project is readable by any
proxied user with no grant. This is by design, not a gap: in a trusted-proxy
deployment the proxy itself is the authorization boundary. If you need
per-project `restricted` enforcement for individual end users, issue them
database-user tokens (see above) instead of relying on the proxy headers.
## Identity keys
Identity-sensitive routing uses a *qualified* identity, never a bare string.
`ActorContext::identity_key()` resolves a request to the OIDC
`(issuer, subject)` pair when both are present, or to `user:<name>` for a
username-only identity. The namespaces are disjoint, and identical subjects
from different issuers stay different.
The OIDC pair outranks `user` because OIDC defines `(iss, sub)` as the stable
identifier and explicitly forbids relying on `preferred_username` for
uniqueness. Configure the proxy to forward both values from day one. Adding a
display username later then preserves the same identity; moving from a
username-only assertion to the OIDC pair deliberately changes it once.
## Ownership of handoffs and sessions
Handoffs and sessions record the operator they belong to (`owner_user` /
`actor_user`, holding the qualified `IdentityKey::storage_key()` TEXT). On a
shared server this stops one operator's pending handoff from being delivered
to — and consumed by — the next session to start, whoever it belongs to.
- A `NULL` owner means **shared with the project**: every row written before
ownership existed, and anything written without an authenticated actor, stays
visible to everyone.
- **An owner is only stamped where the deployment distinguishes operators.**
Single-operator servers are unaffected even when they name their operator via
`[auth].root_username`: with no `users` rows and no proxy bearer there is
nobody to separate, and stamping the one name would separate that operator's
*transports* instead — HTTP requests carry the name, while the stdio /
in-process MCP transport and the local CLI carry no actor at all and would
stop seeing what the HTTP side wrote. Reads are deliberately **not** gated the
same way, so a row stamped while the deployment did distinguish operators
stays readable by that operator afterwards.
- The owner is the qualified identity the request names —
`ActorContext::identity_key()`, so the issuer-qualified OIDC key when a
complete issuer/subject pair is asserted and `user:<name>` otherwise. It is
the same rule that decides the auth tier, so the proxy path gets real
per-operator isolation rather than one shared bucket.
- `memory_handoff_begin` takes `shared: true` to publish a baton deliberately.
- `memory_handoff_list` / `memory_handoff_accept` / `memory_handoff_cancel` take `any_owner: true` to
act on somebody else's baton; that opt-out requires admin authority in
multi-user mode.
- `ai-memory finalize-session --all-owners` does the same for sessions, and
`GET /admin/open-sessions?all_owners=true` is the underlying switch.
`--session-id <uuid>` / `session_id=<uuid>` narrows the same owner-scoped
lookup to one exact open session; it cannot be combined with `--all` /
`all=true`.
- `GET /admin/sessions/by-agent` reports how many sessions each agent CLI
opened in a scope. It follows the same rule: the caller's own sessions
plus the unowned ones, with `all_owners=true` to see every operator's. Pass
the required `workspace` and `project` query parameters and optionally
`since_days=N`; zero or omission means all history. Results use the stable
shape `{"by_agent":[{"agent":"codex","sessions":3}]}`, ordered by count
descending and then agent name. An unknown scope returns 404 without creating
it. Like every `/admin/*` route, this endpoint is root-only when the
deployment distinguishes operators.
- The read-only handoff listing (`GET
/api/v1/workspaces/{ws}/projects/{p}/handoffs`) serves its prompt-derived
fields — `summary`, `open_questions`, `next_steps` — to a caller the server
can name (their own rows plus the shared ones) and to the root operator, who
reads every page body through the wiki API anyway. A caller an authenticating
server can place as neither gets the metadata with `redacted: true`. A server
with no auth configured is unaffected: it already serves every page body
unauthenticated. The default listing remains own plus shared; root may request
the explicit recovery view with `?all_owners=true`, while user and anonymous
tiers receive `403`.
- Handoff lifecycle events raise admission ops (`handoff_begin`,
`handoff_accept`, `handoff_cancel`), so an admission webhook can observe or —
with `failure_policy = "reject"` — refuse them. Only reject-policy hooks are
awaited on these ops; observers are notified after the operation is durable.
"Multi-user mode" here means *the deployment distinguishes operators*: either
`users` rows exist, or `[auth].actor_proxy_bearer_token` is configured. A trusted
proxy never writes a `users` row, so counting only rows would leave every
proxied caller on the single-operator escape hatch that waves admin through.
One question, every gate: the MCP admin tools, the `/admin/*` route layer, and
the ownership stamped on handoffs and sessions all ask it.
## MCP client activity
`GET /admin/activity/by-client` reports server-wide MCP tool calls rather than
lifecycle sessions or project-owned data. Stateful HTTP and stdio use the
sanitized MCP `clientInfo.name`; stateless requests fall back to an
authenticated proxy's actor-agent label and then `unknown`. Results have the
stable shape
`{"by_client":[{"client":"claude-code","reads":12,"writes":3}]}` and are
ordered by total calls, then client name.
`since_days=N` includes every UTC day bucket intersecting that lookback; zero
or omission means all history. Calls flush on a one-minute background interval
even if no later request arrives, and retry failed batches once per interval;
process exit can lose the current interval. Each UTC day stores at most 128
distinct labels and folds additional labels into `other`. The endpoint takes
no workspace or project because many MCP-only clients do not provide reliable
per-call scope. Like every `/admin/*` route, it is root-only when the deployment
distinguishes operators.
## Per-operator memory slots
The "absent means shared" rule extends to memory slots, so a single-operator
server behaves exactly as it always has. `_slots/current-focus.md` is injected
into every operator's context; `_slots/<segment>/current-focus.md` is injected
only into the operator whose `path_segment()` is `<segment>` (`u-alice`,
`uh-<uuid>` for a mixed-case, trailing-period, or otherwise path-hostile
username, or `o-<uuid>` for a complete OIDC issuer/subject pair). What the
feature scopes is injection, not access: a slot is an ordinary wiki page, so
exact reads and searches remain project-wide like every other page. Every slot
written before this is unnamespaced, therefore shared.
`[slots] per_user` (default off) is the switch for the whole regime. With it
ON:
- session briefs and consolidation prompts show you the shared slots plus your
own — including the pointer list of recently touched pages, so another
operator's slot path and title stay out of your brief too;
- the engine namespaces the slots it writes: a consolidation run that targets
the shared slot lands in the session operator's own namespace instead, and a
path the model aims at somebody else's namespace is skipped rather than
written or re-homed — that path comes from the model, and
anything reaching your observations can dictate it;
- a `memory_write_page` call naming the SHARED slot is namespaced into your
own prefix, exactly as the engine would (the response reports the path the
page actually got), and writing into another operator's namespace is refused
(admins may still curate any namespace, the shared slot included).
With it OFF a nested slot path means nothing in particular — every slot goes
into every brief, exactly as before the feature existed — so turning it back
off makes personal slots visible to everyone again rather than stranding them.
The `<segment>` is derived from the qualified identity on this server: a short,
lowercase, path-safe ASCII username stays readable, while a mixed-case,
trailing-period, or otherwise path-hostile username or complete OIDC
issuer/subject pair becomes a bounded deterministic identifier. Restricting
readable segments to lowercase without trailing periods prevents distinct
identities from producing pathnames that compare alike on supported
case-insensitive filesystems. The OIDC pair
outranks the username; see "Identity keys" above. Every named operator owns a
working namespace, and long or path-hostile values never fall back onto the
shared slot. One consequence of qualified segments is worth stating: a nested
path written before the feature
(`_slots/backend/…`) spells a segment no qualified identity can produce, so
with the flag ON it belongs to nobody and reaches no brief until the flag is
turned back off or an admin re-homes it.
Before the case-insensitive namespace fix, a mixed-case username such as
`Alice` used the readable segment `u-Alice`, and a username ending in a period
kept that period; both now use deterministic `uh-<uuid>` segments. ai-memory
cannot safely move the old directory automatically because a case-insensitive
filesystem may already have combined it with another identity. Administrators
upgrading a shared deployment with `[slots] per_user = true` must inspect any
affected `u-…` slot directories and re-home confirmed content into the owning
operator's new namespace. Preserve the old pages until ownership is
established; do not infer it from filename casing alone.
One gap is deliberate and documented rather than closed: `ai-memory bootstrap`
writes pages at paths the model picks from the repository's own README, docs
and code, with no operator to attribute them to, so a repo carrying injected
instructions can make it write a `_slots/…` page. It is an admin-only
operation on a repository the admin chose to ingest, and the behaviour is the
same with the flag off; review `bootstrap.md` — it lists every path written.
## Other per-operator state
Beyond attribution, some engine state is recorded per operator. "Absent means
shared" is the rule throughout — a row with no recorded operator behaves
exactly as it did before the column existed — so a single-operator server
keeps its historical behaviour:
- **Auto-improvement proposals.** Each records the operator who staged it (the
qualified identity key — username or complete OIDC issuer/subject pair — so
proxy-asserted humans count too, and it shows up on the proposal detail),
and the "one
pending proposal per page" rule applies per operator, so operators stop
blocking each other. Only where the deployment distinguishes operators,
though: elsewhere proposals stay unattributed and the original one-per-page
rule holds unchanged. A scheduled run has no caller and stages unattributed;
the telemetry report and the curator describe the project rather than a
person and stay unattributed too, so they neither block nor are blocked by
any named operator's pending proposal for the same page.
A proposal that does collide with one already pending is skipped on its own
— the run's other proposals still stage — and every staging surface reports
the skip with the target path and the reason (the `skipped` list in the MCP
and `/admin` responses, the CLI output, and the scheduler's log), so a run
of N-1 proposals is never silently indistinguishable from a clean run of
N-1.
- **Page reinforcement.** The first reinforced read by each identified
operator is recorded per page alongside the existing shared access counter.
`[decay] breadth_weight` (default `0.0`) optionally lets a
page reinforced by many different people outrank one read repeatedly by a
single person — the forget sweep reads the per-page count of distinct
operators and feeds it into the retention score. At the default, and for
pages with fewer than two distinct readers at any weight, retention scores
are unchanged.
## Implementation contract
Request identity and authorization are separate:
- `ActorContext` carries who made the request and is used for attribution,
frontmatter, audit payloads, and active-project keys.
- `AuthLevel` carries what auth tier the middleware resolved.
- `AuthLevel::authorize(Capability::...)` is the shared permission check for
admin routes, user-management routes, normal read/write surfaces, and the
admission-chain skip header.
Handlers should not compare usernames, infer root from `ActorContext`, or add
ad hoc root-only branches. PRs that touch auth behavior should cover root,
DB-user, and anonymous callers, including the single-user compatibility mode
where `[auth].token_pepper` is absent.
## Quick start
> Prerequisite: a fresh `ai-memory init`. Pre-v0.8 installs need
> the [migration step](#migrating-an-existing-single-user-install)
> below before any of these commands work.
### 1. Set the root identity
Edit your `config.toml` (typically `<data_dir>/config.toml` or
`/etc/ai-memory/config.toml`) and uncomment the `root_*` lines in
the `[auth]` block:
```toml
[auth]
bearer_token = "<your-existing-token-or-a-fresh-one>"
token_pepper = "<auto-generated-by-ai-memory-init>"
root_username = "boss" # required for root attribution
root_email = "boss@example.com" # optional, surfaced in UIs
root_name = "Boss" # optional, surfaced in UIs
```
`token_pepper` was auto-generated by `ai-memory init`; **do not
change it after issuing native API keys** — rotating the pepper invalidates
every `aim_` credential. The pepper makes copied
`api_credentials.token_hash` rows useless to an offline attacker; human
passwords and sessions do not use it.
`init` creates the pepper before any keys exist. Human users and web
sessions can operate without a machine-root bearer; native API-key lookup
still requires the original pepper. If `api_credentials` contains rows and
the pepper is missing or blank, `serve` refuses startup before applying
human bootstrap. Restore the original pepper from configuration backup
rather than deleting identities or keys.
### 2. Add another human
Each `ai-memory user add-human` creates a person with a temporary password,
printed **exactly once**. The new user must change it on next login.
This does **not** issue an API key.
```console
$ AI_MEMORY_AUTH_TOKEN=<root-token> \
ai-memory user add-human --username alice --email alice@home --name "Alice Smith"
✓ created user 'alice'
name: Alice Smith
email: alice@home
role: user
Store this temporary password now — it will NOT be shown again.
The user must change it on next login.
xK7mPq...<temp password>
```
stderr carries the human chrome; stdout is the bare password so you can
pipe it. Use `--role root` to create a second recoverable root.
### 3. List users
```console
$ AI_MEMORY_AUTH_TOKEN=<root-token> ai-memory user list
USERNAME NAME ROLE STATUS
alice Alice Smith user must-change
bob - user active
carol - user disabled
dave - user active
erin - user expired
```
An `active` row without a password can be a deprecated 1.x compatibility-token
identity. Grant human login with `user reset-password`; that does not reveal or
rotate the machine secret. The list never surfaces passwords, hashes, or API
secrets.
### 4. Disable human login (without losing attribution history)
`ai-memory user disable <username>` stamps `disabled_at` and revokes web
sessions. Historical `author_id` references keep resolving. Native API
credentials stay valid — revoke those separately with `api-key revoke`.
```console
$ ai-memory user disable alice
Disable human login for user 'alice'? (y/N) y
✓ disabled user 'alice'
```
Pass `--yes` to skip the prompt (CI / scripts). Re-enable with
`ai-memory user enable alice`. The last enabled root with a password
cannot be disabled or demoted.
### 5. Issue, rotate, or revoke a native API key
Machine clients use `aim_` credentials, not passwords:
```console
$ ai-memory api-key add --username alice --label codex-laptop
✓ created API key '…' (codex-laptop)
Store this token now — it will NOT be shown again.
aim_mYi3pq...<secret>
```
```console
$ ai-memory api-key rotate <id>
$ ai-memory api-key revoke <id>
```
Rotation 401s the previous plaintext immediately. A revoked secret does
not come back; issue a new key instead. Native keys authenticate as
`AuthLevel::User` even when attached to a `role=root` identity —
root automation still uses `[auth].bearer_token`. External `amk_` keys
stay in `mcp-auth`; they are not listed here.
#### Where the token is stored
`install-hooks --apply --auth-token …` writes the bearer to two files inside
the data dir, both `0600` (the data dir itself is `0700`):
| file | read by |
|---|---|
| `<data_dir>/auth-token` | the native `ai-memory hook` command |
| `<data_dir>/auth-header` | the shell hooks, via `curl -H @<file>` |
`ai-memory uninstall` deletes both files when it removes the hooks (a full
uninstall or `--only hooks`); `--only mcp`, `--only instructions` and `--only
skills` keep them for the hooks still installed.
It is deliberately **not** written into the agent's own config any more. Before
#552 it went onto the hook's command line — `--auth-token <token>` for native
hooks, an `AI_MEMORY_AUTH_TOKEN=` shell prefix for the script hooks — which put
it in the agent's config file *and* in `/proc/<pid>/cmdline` for the lifetime of
every hook and every `curl`, readable by any local user, on every tool call.
The second file exists for exactly that reason: building the header inline
would put the credential straight back on a command line.
An explicit `--auth-token` on a hook command, or `AI_MEMORY_AUTH_TOKEN` in the
environment, still takes precedence — so configs written before this keep
working unchanged. Re-run `install-hooks --apply` to move an existing install
onto the stored form.
#### What rotation does to already-spooled hook events
A lifecycle hook that cannot reach the server writes the event to the local
spool **together with the bearer it was holding at the time**. Rotating a token
therefore strands every spooled event captured under the old one: each is
rejected with `401` on the next drain.
Since #542 the drain recovers them. Re-run `install-hooks --apply` with the new
token, and the next drain retries any `401`ed entry with the current bearer —
the hook that spawns the drain hands it down in the child's environment. The
retry is bounded: it only runs for an entry the server has already rejected,
only for a static bearer (an OIDC token is re-resolved and refreshed every pass
anyway), and only when the current token actually differs from the one that
just failed.
Until you re-run `install-hooks`, the hooks still hold the old token and there
is nothing newer to retry with — those events stay queued and age out on the
normal retry budget rather than blocking the rest of the spool (#493).
## Running for a team
Everything above concerns *who* a request is, which is the identity half. The
other half is *which project* it lands in, and it matters most once more than
one person shares a server.
**Knowledge is shared; batons are owned.** A page written in a project is
readable by every operator in that project — `pages.author_id` records who
wrote it and is never a read filter. That is the point of a shared server: what
one person learns, the next person retrieves. Handoffs are the opposite: a
handoff carries an owner, and only that operator receives it.
**Concurrent edits supersede rather than collide.** Two people editing the same
page produce a version chain, not a conflict; the later write becomes latest and
the earlier one stays reachable. There is no merge, and none is claimed — but
nothing is destroyed either.
**Isolation of the "current project" pointer is automatic** since v1.39. Unscoped
calls (the normal case — the MCP tools default to the current project) resolve
through a pointer keyed by the caller's identity and session, so two operators,
or one operator with two harnesses, do not overwrite each other. Confirm what a
server is running with the startup line:
```
active-project isolation mode mode=PerActor session_ttl_secs=3600 max_entries=4096
```
If that says `Single` on a shared server, unscoped reads **and writes** from
concurrent sessions share one last-write-wins slot. See
[auto-scope.md](auto-scope.md).
## Backward compatibility
If you're upgrading a machine-bearer-only install:
- **No immediate action is required.** Existing
`[auth].bearer_token` authentication continues to work as before.
Configure human bootstrap/recovery only when enabling password login.
- Migration V52 extends the existing `users` table with human role/password
state and creates `web_sessions`; V53 copies every valid legacy
`users.token_hash` into `api_credentials` without changing its digest or
owner id.
- Deprecated 1.x `user add`, `user expire`, `user revive`, and
`user rotate-token` commands remain available for compatibility. They manage
the migrated `legacy-user-token` credential; new automation should use
`api-key` commands, and new people should use `user add-human`.
The V53 mirror triggers are load-bearing for those shims and remain until the
shims are removed in 2.0.
- Before any human password or completed bootstrap exists, GET-only browser
routes continue to accept the root bearer through HTTP Basic and the
HttpOnly `ai_memory_auth` cookie. Human activation disables that path
immediately, without a restart. Machine routes remain Bearer-only.
- Native API credentials require `[auth].token_pepper`. Human user creation,
password reset, disable/enable, and session login do not create or depend
on API keys.
- In human mode, `/admin/*` is available to a root web session or the
machine-root bearer. Native `aim_` credentials always remain
`AuthLevel::User` and receive **403** on root-only operations.
### Migrating an existing single-user install
`ai-memory init` is idempotent and won't overwrite a config it
finds. To populate `token_pepper` without losing your current
config:
1. **Back up the existing config** (`cp config.toml config.toml.bak`).
2. **Generate a pepper**: `ai-memory generate-auth-token 32` — this
prints a hex string of the same shape `init` would have
generated.
3. **Add the `[auth]` block** to your `config.toml`:
```toml
[auth]
# ... your existing settings (bearer_token, etc.) ...
token_pepper = "<paste-the-generated-pepper-here>"
root_username = "boss" # optional; enables root-token attribution
root_email = "boss@..." # optional
root_name = "Boss" # optional
```
4. Restart `ai-memory serve`. The new fields are picked up; existing
behaviour is unchanged.
You can defer steps 3-4 indefinitely — `bearer_token` alone keeps
working as it always has.
## How credentials are stored
**Passwords** are Argon2id PHC strings on `users.password_hash` with a
random per-password salt. They never land in `api_credentials` and are
never compared as Bearer.
**Native API keys** (`aim_` + 43 URL-safe characters from 32 CSPRNG
bytes) store `SHA-256(token || ":" || token_pepper)` in
`api_credentials.token_hash`. The per-server `token_pepper` makes a
DB-only theft (a copied SQLite file) useless offline. Constant-time
comparison on the hash avoids timing leaks on the lookup path.
Argon2id is the wrong KDF here: 256-bit CSPRNG secrets are not
brute-forceable, and a per-hash salt would force O(N) scans on every
auth request. See `crates/ai-memory-store/src/api_credentials.rs`.
**Web sessions** hash a 256-bit `ams_` secret with SHA-256 (no pepper)
into `web_sessions`. The cookie is `HttpOnly`, `SameSite=Strict`,
`Path=/`. CSRF is a separate cookie compared to the session's CSRF
hash.
Brownfield `users.token_hash` values were copied losslessly into
`api_credentials` (same 32-byte digest, label `legacy-user-token`).
Runtime lookup no longer reads the old columns.
## Where attribution shows up
| Surface | Status |
|---|---|
| Auth middleware injects `Extension<ActorContext>` on every request | ✓ P1.3 |
| All `/admin/*` routes gate on `Extension<AuthLevel>::Root` in multi-user mode | ✓ P1.4 |
| `ai-memory user add-human/list/reset-password/disable/enable/patch`, deprecated `user add/expire/revive/rotate-token`, and `ai-memory api-key add/list/rotate/revoke` | ✓ |
| `pages.author_id` populated, frontmatter `last_modified_by` block | ✓ P1.6 |
| `/api/v1` page responses include `author: { username, name?, email? }` | ✓ P1.7 |
| ETag invalidation on author change (so caches refresh attribution) | ✓ P1.7 |
| `install-hooks --as-user <name>` metadata + flag validation | ✓ P1.8 |
| Web UI shows author on the page view | ✓ shipped |
| Attributed mutation audit rows carry `audit_log.author_id` | ✓ shipped |
Commit ids for each milestone are recorded in `CHANGELOG.md`.
## Wiring agent hooks to a specific user
After `ai-memory api-key add` prints a native secret, point that user's
agent install at it via `install-hooks`:
```console
$ ai-memory api-key add --username alice --label claude-hooks
✓ created API key '…' (claude-hooks)
aim_XGq...<secret> # stdout only
$ ai-memory install-hooks --apply --agent claude-code \
--as-user alice --auth-token aim_XGq...<secret>
[ai-memory] hooks installing for user: alice
✓ staged 5 hook script(s) → ...
```
`--as-user` is **metadata only**: it labels the install for the
operator's records and prints a confirmation line so you can verify
which identity the next session's writes will attribute to. The
actual token wired into the hook env block is whatever you pass via
`--auth-token`. Mismatching the two (e.g. `--as-user alice
--auth-token <bob's token>`) is permitted at the CLI layer; the
server will resolve to bob at runtime. The flag is there to keep the
operator honest, not to enforce.
Without `--as-user`, hooks install the same way they always have —
the bearer authenticates, attribution flows from the token's owner
(root user or DB user) at write time.
## Per-project access
Authentication says *who* is asking; each project's **access mode** decides
what they may reach (#708):
| Mode | Who reaches the project |
|---|---|
| `open` (default) | Every authenticated user — what every project was before access modes existed. |
| `restricted` | The root operator, the user who created it, and users holding a grant on it. |
Every existing project is `open` after upgrading, so nothing changes until an
operator restricts one:
```sh
ai-memory project access --workspace acme --project checkout-api --mode restricted
ai-memory user grant --user alice --workspace acme --project checkout-api --level write
```
Both are root-only. Restricting prints the users who have written to the
project, hold no grant and did not create it — the people it now refuses — so you can grant the
ones who should keep access; nothing is granted automatically.
- **Grants** are `read` or `write`, per project; `write` includes `read`.
There is no per-project administrator: granting, revoking and restricting
are the root operator's, like every other administrative act.
`ai-memory user grant | revoke` manages them; `ai-memory user grants
[--user NAME]` and `ai-memory project grants --workspace W --project P` list
them (REST: `POST /admin/users/{name}/grant|revoke`,
`GET /admin/users/{name}/grants`, `GET /admin/projects/grants`). A user holds one level
per project; revoking deletes the grant, and purging a project or deleting
its workspace takes its grants with it. Every grant, level change and revoke
is recorded in the audit log (`grant_access` / `revoke_access`, with who did
it), so "who could reach this, and since when" stays answerable.
- **Cross-project messages** respect access too: delivering into a restricted
project's inbox needs `write` on it (otherwise the mailbox would be a way
around its grants), and so do popping its inbox and cancelling its outbox,
which change its queues. Listing them needs `read`.
- **Whoever creates a project** is recorded as its creator
(`projects.created_by`) and keeps full access if it is later restricted,
without holding a grant. Projects created before this existed, or by the
root token, have no recorded creator.
- **New projects** are `open` unless `[auth] new_projects_restricted = true`
(`AI_MEMORY_AUTH__NEW_PROJECTS_RESTRICTED=true`), which makes every project created from then on start `restricted`. The
reserved `scratch` project and the global preferences scope always start
open, and the global scope can never be restricted: it is shared by
construction.
- **Gates entry, never rows.** Access decides which projects a user reaches;
inside a project pages stay shared exactly as before (see below).
- **Refusals are explicit.** A user outside a restricted project gets a 403
naming it and the level needed, never an empty result; search, listings and the graph leave it
out rather than hinting at it. A hook capture into a project the user may
not write is dropped and counted (`dropped_unauthorized` in status), not
retried.
- **No database users, no checks.** A single-operator install, and the root
bearer token, are never subject to access modes.
## Limitations
- **No per-page RBAC *within* a project.** This one is deliberate and separate
from the above: pages are shared inside a project because multi-session and
multi-user collaboration is a core capability. `pages.author_id` is
attribution, never a read filter.
- **Native keys are not passwords.** `user add-human` never issues an `aim_`
secret; `api-key add` never issues a login session. The deprecated `user add`
command is the exception: it issues one compatibility token backed by the
reserved `legacy-user-token` credential. Disable/reset of a human leaves API
credentials untouched, and revoke/rotate of a native key leaves the
password/session untouched. One identity may hold several native keys with
distinct labels.
- **Root token is single.** `[auth].bearer_token` is the programmatic
admin credential for `/admin/*`. A native key attached to a
`role=root` identity still authenticates as `AuthLevel::User`. Human
roots operate the console with a web session after password login.
- **OIDC is request authentication, not page authorization.** Native hooks and
thin-client CLI commands can send a per-developer OIDC bearer for an external
OIDC-aware gateway/bridge. Native ai-memory server auth still uses static root
bearer / native `aim_` keys / web sessions, and `/admin/*` stays root-only unless a gateway
translates accepted OIDC auth into upstream auth that ai-memory accepts.
ai-memory still has one shared wiki per server and no
per-page RBAC. Per-project access applies to the database user a gateway
authenticates as, like any other. The Keycloak/OIDC `sid` claim is also not an ai-memory agent
session id; session auto-scope needs the lifecycle-hook session id or explicit
`workspace` + `project` / `scopes`.