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

40 KiB
Raw Permalink Blame History

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 §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.

/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:

[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 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:

[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.

$ 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

$ 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.

$ 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:

$ 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>
$ 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 401ed 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.

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:

    [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:

$ 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:

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.