Files
ai-memory/docs/security-boundaries.md
T
João Carlos Magalhães 8b0ce55411 fix(handoff): keep parallel OpenCode sessions' batons owned and deliver only quiet ones
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.
2026-09-24 20:22:45 -03:00

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)

  1. 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.
  2. 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.
  3. 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, global recent, 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.
  4. 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.
  5. 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_project pointer tests, the MCP permission suites). Put boundary tests there.