With turn checkpoints (#865), several live OpenCode sessions in one directory each publish a baton. Each checkpoint retired the other live sessions' batons, and SessionStart handed a new session the baton of a session still in use, with another conversation's context. A checkpoint now spares other open sessions' batons. Startup delivery (startup_handoff) skips an open source that captured anything in the last ten minutes, and the claim re-checks it in its own transaction. The claim's sweep retires older batons of quiet open sessions but spares a busy one; an explicit accept keeps sparing every open session.
12 KiB
Security & isolation boundaries — inventory and adversarial-test map
ai-memory is single-tenant wiki data with optional multi-user attribution, run by parallel harnesses and shared teams. A handful of guards keep one project, workspace, operator, or untrusted input from crossing into another. Each guard is only as good as a test that actively tries to break it — a happy-path or single-tenant test cannot see an isolation defect, so a regression that removes the guard would pass CI silently.
This file is the source of truth for which boundaries exist, where each is enforced, and the adversarial test that would fail if the guard were removed. Keep it current (see the protocol at the end) — it is referenced by the AGENTS.md "security-boundary tests" rule.
An adversarial test = attempt the violation and assert refusal, plus a legitimate control case (so a blanket deny is not mistaken for a working guard). Coverage verdicts: STRONG = a test would fail if the guard were deleted; PARTIAL = only some paths of the guard are probed; FUTURE = boundary not yet built.
Boundary map
| # | Boundary | Enforcing code | Adversarial test(s) | Coverage |
|---|---|---|---|---|
| 1 | Per-project isolation (3-tuple) | ai-memory-store/src/scope.rs ScopeResolver::resolve_read_args/resolve_write_args, no-create lookup_existing_scope; reader queries filter by (workspace_id, project_id) |
store scope.rs read-resolution table tests; tests/suite/multi_session.rs |
STRONG |
| 2 | Workspace isolation | same as #1; same-named project → distinct ids per workspace | scope.rs cross-workspace resolution rows; multi_scope dedup/validate |
STRONG |
| 3 | Multi-user auth ladder | ai-memory-core/src/actor.rs AuthLevel::authorize; ai-memory-mcp/src/auth.rs middleware; admin.rs require_root_for_multiuser_admin / require_root |
admin.rs multiuser_admin_routes_reject_db_user_tier / …reject_anonymous / create_user_as_user_tier_returns_403; auth.rs unknown-bearer 401; actor.rs skip_admission_chain_rejects_db_users |
STRONG |
| 4 | Handoff single-claim / no-steal | ai-memory-store/src/ops.rs accept_handoff_in_transaction (metadata state='open' guard + atomic CAS) |
multi_session.rs a_second_accept_cannot_steal_an_accepted_handoff; handoff_ownership.rs another_operator_cannot_claim_the_handoff |
STRONG |
| 4b | Handoff owner-scoped recovery (any_owner admin gate) |
ai-memory-mcp/src/server.rs require_admin_capability on memory_handoff_accept/_cancel any_owner |
handoff_admission.rs — cancel gate + accept gate (adversarial: non-admin any_owner accept refused) |
STRONG |
| 4c | Turn-checkpoint baton is owner+scope-bound | ai-memory-store/src/ops.rs checkpoint_session_handoff — reads the session's own (workspace, project, owner_user) from sessions WHERE id=?1 AND ended_at IS NULL, refuses on scope/owner mismatch, refreshes only that session's own state='open' baton (id preserved), returns None when the session already ended |
ops.rs checkpoint_session_handoff_refreshes_only_the_live_sessions_own_baton (#865) |
STRONG |
| 4d | Native session rebind / drifted-end is owner+agent+cwd CAS | ai-memory-store/src/ops.rs — session.moved rebind gated on agent==OpenCode + ended_at IS NULL + lexical normalize_cwd(stored)==normalize_cwd(from); expire_same_cwd_auto_handoffs AND-gated on owner_user IS ?4 AND id IS NOT ?5; drifted SessionEnd ends only same owner+agent+cwd |
ops.rs native_move_rebinds_live_session_only_from_its_current_cwd, native_move_rejects_foreign_owner_and_ignores_completed_replay, drifted_session_end_ends_only_the_same_actor_agent_and_cwd (#865) |
STRONG |
| 4e | A live session's baton stays its own until the session is quiet | ai-memory-store/src/ops.rs - expire_same_cwd_auto_handoffs spares batons of other open sessions; accept_handoff_in_transaction(.., busy_since) re-checks inside the claim that the source is not an open session with observations after the cutoff, and its sweep expire_superseded_auto_handoffs(.., busy_since) spares a busy open session's baton (every open session's when None, the explicit MCP accept); reader.rs startup_handoff selects with the same cutoff; ai-memory-hooks/src/router.rs LIVE_BATON_QUIET_PERIOD |
multi_session.rs parallel_live_sessions_keep_their_own_checkpoint_batons, startup_claim_rechecks_the_source_and_sweeps_only_quiet_open_batons; router.rs opencode_parallel_live_batons_are_owned_and_wait_for_quiet - each fails with its guard removed |
STRONG |
| 5a | Pages shared: author_id is never a read filter (invariant #16) |
ai-memory-store/src/reader.rs search_pages/page_body_by_ids — author_id is an attribution JOIN only, never a WHERE term |
multi_session.rs — a page with a non-null author_id (operator A) is readable by operator B in the same project |
STRONG |
| 5b | Page supersession (loser stays reachable) | ai-memory-store/src/ops.rs upsert_page_in_tx — demote is_latest=0 (never delete) + supersedes chain |
multi_session.rs concurrent_writes_to_one_path_supersede_rather_than_destroy; retrieval_superseded.rs |
STRONG |
| 6 | Active-project pointer (PerActor, no clobber) | ai-memory-core/src/active_project.rs set_for/lookup_for (fail-closed on Mismatch) |
active_project.rs parallel_harnesses_of_one_user_keep_separate_pointers, two_operators_never_read_each_others_pointer, a_session_mismatch_fails_closed_once_anything_has_been_keyed |
STRONG |
| 6b | Native _meta routing coordinate is routing-only (never grants identity) |
ai-memory-mcp/src/server.rs attach_native_session reads only _meta["ai.opencode/sessionID"] into ActorKey.session_id (trimmed, non-empty-string-validated); user still comes from the authenticated ActorContext, so a forged _meta cannot cross a user/scope boundary — it only selects a session slot behind the existing active_project guard |
server.rs native_session_metadata_is_routing_only_and_preserves_precedence, native_session_metadata_validates_strings_without_granting_identity; autoscope_multiuser.rs native_metadata_routes_concurrent_reads_and_writes_to_hook_workspaces (#864) |
STRONG |
| 7 | Sanitizer trust boundary (invariant #6) | ai-memory-core/src/sanitize.rs Sanitized<T> (private field, only sanitize() ctor); WriterHandle::insert_observation requires Sanitized; ops crate-private |
Structural (compile-time) + store/src/lib.rs insert_observation_boundary_scrubs_before_disk + sanitizer scrub unit tests |
STRONG (structural) |
| 8a | Messaging: recipient-only visibility | ai-memory-store/src/ops.rs pop target-select + reader.rs list_messages (to_* predicate) |
agent_messages.rs a_message_is_only_visible_to_its_recipient; agent_messages_tools.rs non-recipient cannot pop |
STRONG |
| 8b | Messaging: cancel-own-only | ai-memory-store/src/ops.rs cancel_messages (AND-gated on from_* sender coordinate) |
agent_messages.rs — whole-outbox and a foreign specific-id cancel refused |
STRONG |
| 8c | Messaging: pop-exactly-once | ai-memory-store/src/ops.rs pop_message_in_transaction atomic CAS WHERE state='pending' |
agent_messages.rs — sequential and tokio::join! concurrent double-pop yields exactly one Some |
STRONG |
| 8d | Messaging: inferred-scope read is diagnosed (#854) | scope.rs is_inferred; server.rs inferred_scope_hint on empty pop/list |
agent_messages_briefing.rs no_scope_pop_that_misses_the_mail_is_diagnosed_not_a_silent_null |
STRONG |
| 9 | Scope resolution fail-closed | ai-memory-store/src/scope.rs no-create lookup_existing_*; create only via create_explicit_scope |
scope.rs no-auto-create + unscoped_write_with_unresolvable_coordinate_errors |
STRONG |
| 10 | Destructive-op live-process refusal + confirm flags (invariant #9) | ai-memory-cli/src/commands/process_guard.rs sibling_processes + confirm flags in reset/restore/reindex/uninstall --purge-data/purge_project |
admin_purge.rs confirm→400; removal.rs — injected live-sibling makes each destructive command bail before touching the data dir |
STRONG |
| 10b | Session purge is scope+owner-bound (no cross-session/project over-delete) | ai-memory-store/src/ops.rs purge_session — selection scoped to (workspace_id, project_id) and keyed on this session's own summary_page_id or path='sessions/<sid>.md' + json_extract(frontmatter_json,'$.session_id')=<sid> (frontmatter owner, not the recursive latest-chain); in_scope==0 → NotFound fail-closed; whole op in one transaction |
ops.rs purge_session_leaves_a_sibling_session_in_the_same_project_intact, …refuses_a_session_from_another_project_and_deletes_nothing, …refuses_a_session_from_another_workspace, …does_not_delete_an_identically_pathed_page_in_another_project, …removes_older_summary_versions_without_deleting_prior_manual_page (#862) |
STRONG |
| 11a | Hook backpressure (202/429) + bounded fan-out (invariant #5) | ai-memory-hooks/src/router.rs semaphore→429, 202 immediately, MAX_HOOK_BATCH_ITEMS, bounded LRU limiter |
router.rs handle_hook_returns_429_when_ingest_saturated, ingest_rate_limiter_is_bounded |
STRONG |
| 11b | Capture exclusions drop before storage | ai-memory-hooks capture_policy.rs inspect→Drop (before semaphore/spawn) |
capture_policy.rs per-agent …honors_exclusions tests |
STRONG |
| 11c | Capture hook ≤200ms budget (invariant #5) | hooks/_lib.sh capture path curl --max-time 0.2 (context-fetch 1.0s and background drain 2.0s are separate, larger-budget paths) |
none (shell-script timeout; hard to unit-test) — watch on any capture-path change | WATCH |
| 12 | Network/auth posture | config.rs loopback DEFAULT_BIND; serve.rs validate_http_exposure, require_allowed_host; auth.rs require_bearer |
serve.rs host-guard (missing→400 / forged→403), non-loopback-requires-token; auth.rs wrong-token 401 |
STRONG |
| — | Per-project authorization (#708) | proposal only — docs/design-per-project-authz.md (authorize_project choke point + unscoped-read/raw-id bypass classes) |
none yet — the design's "Verification plan" tests (authz matrix, unscoped-read-leak, raw-id-authz, ship-inert) land WITH the code | FUTURE |
Keeping this current (the standing protocol)
- Touch a guard, add/extend its adversarial test. Any change to code in the "Enforcing code" column — or that adds a new read/write/admin/hook entry point past one of these guards — must add or extend an adversarial test that would fail if the guard were removed, and update this file's row.
- New boundary → new row + tests before merge. Adding an isolation dimension (a new tenancy axis, a new capability, a new cross-scope surface) means a new row here and its adversarial tests in the same change.
- A raw-id or unscoped entry point is guilty until tested. Any handler that
takes a bare
session_id/run_id/page_id/message id, or fans out across projects (global=true, globalrecent, search), bypasses scope resolution by construction — it must resolve→authorize (or filter-before-LIMIT) and carry an adversarial test proving a foreign id/scope is refused. - Prove the test bites. An adversarial test that still passes when the guard is deleted is not a guard test. Confirm fail-without-guard / pass-with-guard.
- Unit tests exercise one session/tenant at a time and cannot see these
defects — the guards live at integration level (
multi_session.rs,handoff_ownership.rs,agent_messages.rs,active_projectpointer tests, the MCP permission suites). Put boundary tests there.