ai-jail integration (`ai-memory run --yolo`):
- The re-exec built `ai-jail <flags> <exe> run …` with no `--`. ai-jail
rejects one of its own flags after the command and `run` shares flag names
with it, so `run claude --yolo --env GH_TOKEN=…` aborted. The invocation now
emits `--` before the wrapped exe (forwarding a colliding flag additionally
needs ai-jail >= 2.4.2, whose guard honors the separator; the cross-tool
test gates on that version).
- The offer only checked for a file named ai-jail: Windows could show it, a
host without bwrap/sandbox-exec was offered a jail that cannot start, and a
~/.local/bin-only install was offered and then not found by the bare
`Command::new("ai-jail")` re-exec after the run was already cancelled.
usable_ai_jail(os, lookup) now returns the exact binary to exec only on
Linux/macOS with the backend present; otherwise no question is asked.
--true-yolo:
- It now implies --yolo (warning, ai-jail offer, harness dangerous mode):
alone it used to apply Claude's bypassPermissions with no warning. It is
interchangeable with --yolo for non-Claude harnesses, and recognized after
native arguments (`run claude --model opus --true-yolo`), where clap leaves
it in the native argv and it was forwarded to Claude as an unknown option.
- The claude_true_yolo config key only upgrades an explicit yolo launch, as
its doc comment stated, instead of bypassing permissions on every run.
- Removed what never worked: three CLAUDE_CODE_DISABLE_*RM* env vars Claude
Code does not read (absent from the 2.1.280 binary and its env reference),
and an empty permissions.ask array that cannot clear ask rules from other
scopes (Claude unions them). Docs now state that Claude honors explicit ask
rules and its command-safety checks in every permission mode.
Relaunch after an interrupted run:
- A launcher killed before releasing its lease (terminal closed, ai-jail
torn down) left the workstream held for up to 90s and the next launch failed
after a 5s retry. An interactive launch now parses the holder and expiry from
the 409, waits for that lease to lapse (bounded by one lease; Ctrl-C aborts),
then proceeds. A holder that renews meanwhile is reported as live and never
displaced; the server's busy check stays the only arbiter (security
inventory row 13b). Non-interactive launches keep the short window.
Tests: usable_ai_jail OS/backend/Windows/exact-path, `--` placement and the
colliding-flag regression (unit + real ai-jail --dry-run), the yolo_modes
table, --true-yolo in both argv positions via real clap parses, the reduced
true-yolo argv, and HTTP-level held-lease wait / renewed-owner / Ctrl-C cases.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDbhmszrjG9s5MrPrTuNtm
38 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 |
| 1b | Reserved _global scope union never leaks a foreign project (#930) |
ai-memory-mcp/src/server.rs memory_query — the union runs for single-project queries (scopes empty) and searches only lookup_global_scope's reserved (workspace_id, project_id), never an arbitrary project; the double-search guard resolves the queried project (named or active) so it can't be tricked into skipping |
server.rs global_union_never_leaks_a_foreign_projects_pages (a third real project's page must not surface via the union; reserved page must), single_project_query_unions_global_scope_and_multi_scope_skips_it |
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 |
| 3b | Browser triage of pending writes (#855) | ai-memory-web/src/routes/pending.rs require_admin (AuthLevel::authorize(Capability::Admin, distinguishes_operators), trusted-proxy flag from WebMountSpec) gates GET {web_slug}/pending; html_auth.rs AdminRequired keeps a non-root 403 off the change-password redirect; decisions post to the existing /admin/pending-writes/{id}/approve|reject behind require_dual_auth (session + CSRF) and require_root_for_multiuser_admin. ai-memory-web has no write route |
ai-memory-web tests/suite/pending.rs — anonymous refused (401, login redirect), DB user refused (403, no proposal text, no redirect), trusted-proxy flag reaches the gate, single-operator control admits, POSTs to /pending* refused, project filter keys do not collide on / in names, projects past the 500-row read cap stay counted and reachable; ai-memory-mcp tests/suite/admin_pending_writes_session.rs — anonymous and DB-user sessions refused, root session without a matching CSRF header refused (each leaves the proposal pending and no file), root approval keeps auto_improve_proposal_id/auto_improve_run_id and records the approver, root reject writes no file (each proven to fail with its guard removed) |
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 and on the source not being another still-open session (row 4e, #883); 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 |
| 4f | Finalize-on-exit closes only a session the run provably owns (invariant #16) | ai-memory-cli/src/commands/run.rs finalize_hookless_session runs only for harness.lacks_session_end_hook() harnesses (Command Code, Kiro v2/v3, Antigravity); own_native_session returns a session only when it is named, chosen before the spawn, or linked under this run's own id in this checkout — a merely discovered native session (possibly a concurrent launch's) is never finalized |
run.rs a_session_linked_during_the_run_wins_over_discovery — own_native_session returns None for the unlinked and other-checkout (nested) cases, so a foreign/concurrent session is refused; the named/linked control is finalized (#944) |
STRONG |
| 4g | memory_handoff_accept status never points a caller at another session's baton |
ai-memory-store/src/reader.rs handoff_claimed_by_live_session — the caller-supplied session id (header or native _meta, routing-only per row 6b) selects which claim to check, but the answer is bounded by the resolved (workspace_id, project_id), the authenticated handoff_owner_sql owner filter, state='accepted', and a still-open receiving session; ai-memory-mcp/src/server.rs unclaimed_handoff reports consumed_by_hook only on that match (and, for a requested handoff_id, only when it is the matched row), none_pending otherwise |
handoff_ownership.rs a_session_start_claim_is_confirmed_only_to_its_own_live_session (forged session id under another operator, unattributed reader, foreign project, sibling session and ended session all empty; owner and root controls see the claim); ai-memory-mcp tests/suite/handoff_identity.rs consumed_by_hook_is_reported_only_to_the_session_that_claimed_it (over the production transport: a colleague forwarding the session id, the owner from another session, and a request with no session id all get none_pending; the claiming session gets consumed_by_hook) |
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); Sanitized::new scrubs the title before the 80-char display cap; 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 + ai-memory-core::sanitize title_is_scrubbed_before_the_80_char_cap_runs + ai-memory-hooks::router issue_980_user_prompt_title_is_sanitized_before_truncated (a secret straddling the 80-char boundary is redacted, not stored truncated; #980) |
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. Also ai-memory-hooks/src/router.rs workspace_override_is_rescope — the hook router's session-sticky gate resolves a marker's workspace override via this same no-create lookup_existing_workspace (never a hand-rolled lookup) and fails closed (treats it as a rescope, disqualifying sticky) on a different or unresolvable workspace; only an override that resolves to the session's OWN workspace is not a rescope (#976) |
scope.rs no-auto-create + unscoped_write_with_unresolvable_coordinate_errors; router.rs workspace_override_is_rescope_only_on_a_genuine_workspace_change (same/different/unresolvable workspace cases), sticky_routing_keeps_the_session_project_across_a_sibling_checkout (regression: same workspace forwarded on every event + project_src=repo-root from a nested checkout stays sticky), sticky_routing_still_rescopes_when_marker_names_a_different_workspace_mid_session (opposite direction: a genuinely different workspace still rescopes even under sticky) |
STRONG |
| 10 | Destructive-op live-process refusal + confirm flags (invariant #9); purge-project's dry_run always wins over confirm so a preview request can never become destructive |
ai-memory-cli/src/commands/process_guard.rs sibling_processes + confirm flags in reset/restore/reindex/uninstall --purge-data/purge_project; admin.rs handle_purge_project checks req.dry_run unconditionally, before req.confirm, mirroring reclaim-ledger-versions |
admin_purge.rs confirm→400; removal.rs — injected live-sibling makes each destructive command bail before touching the data dir; admin_purge.rs purge_project_confirm_true_and_dry_run_true_still_only_previews — {"confirm": true, "dry_run": true} deletes nothing (the regression this row now also covers); ops.rs purge_project_dry_run_changes_nothing — every project-scoped row count, the purged_scopes tombstone, and the audit_log row are identical before/after a PurgeMode::Preview run; ops.rs purge_project_counts_collateral_damage_in_another_project and admin_purge.rs purge_project_dry_run_and_confirmed_purge_both_report_collateral_damage_in_another_project — a purge of project P collaterally deletes an observation, and orphans a handoff's session reference, in a different project Q (via sessions cascading out of P), and both the preview and the confirmed report count it |
STRONG |
| 10b | Session purge is scope+owner-bound (no cross-session/project over-delete); purge-session's dry_run always wins over confirm, same as purge-project |
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; admin.rs handle_purge_session checks req.dry_run unconditionally, before req.confirm |
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); admin_purge.rs purge_session_confirm_true_and_dry_run_true_still_only_previews — {"confirm": true, "dry_run": true} deletes nothing; ops.rs purge_session_dry_run_changes_nothing — every table a session purge touches, the purged_sessions tombstone, and the audit_log row are identical before/after a PurgeMode::Preview run; ops.rs purge_session_counts_collateral_damage_in_another_project and admin_purge.rs purge_session_dry_run_and_confirmed_purge_both_report_collateral_damage_in_another_project — purging session S collaterally deletes an observation, and orphans a handoff's session reference, in a different project (via S's own id cascading), and both the preview and the confirmed report count it; admin_purge.rs purge_session_dry_run_refuses_a_session_outside_its_named_scope — the same session previewed under a different real project (same workspace) or a different real workspace whose project shares the name both 404 with no counts in the body, with the session's own scope as the 200 control |
STRONG |
| 10c | Ledger reclaim is content-gated, not filename-gated (invariant #16) | ai-memory-store/src/ops.rs reclaim_ledger_versions — a candidate row must pass ai_memory_core::log_ledger::body_opens_with_log_ledger on its own body, and only is_latest=0 AND superseded_at IS NULL rows are eligible, so decay-owned rows stay with forget-sweep; confirm gate in admin.rs handle_reclaim_ledger_versions |
reclaim_ledger_versions.rs a_prose_page_wearing_the_ledger_name_keeps_its_whole_version_chain, an_ordinary_page_chain_in_another_project_is_untouched, a_decay_tombstone_stays_with_the_sweep_that_owns_it; admin_reclaim_ledger_versions.rs deleting_without_confirm_is_refused_and_changes_nothing (with dry-run and confirmed controls) |
STRONG |
| 10d | Workspace delete's own row counts are scope-bound to workspace_id (a workspace's own rows are never over- or under-counted by a sibling workspace's), its cross-workspace observation/handoff cascade is reported by both preview and confirm rather than prevented (same as purge-project one level down), the non-empty guard fails closed without force, and delete-workspace's dry_run always wins |
ai-memory-store/src/ops.rs delete_workspace — every direct count and the DELETE FROM workspaces itself are scoped by workspace_id = ?1; the two collateral_* counts are scoped by workspace_id != ?1 so a row is counted as one or the other but never both; projects_deleted > 0 && !force → WorkspaceNotEmpty fail-closed, and a missing workspace is NotFound, both checked identically under PurgeMode::Preview and PurgeMode::Commit; whole op in one transaction; admin.rs handle_delete_workspace checks req.dry_run unconditionally, before building the confirmed path, mirroring reclaim-ledger-versions/purge-project; root-only gate is the shared /admin/* multi-user auth ladder, not a separate check |
admin_move.rs delete_workspace_dry_run_never_counts_a_different_workspaces_rows — previewing workspace A counts only its own project/page/session/observation/handoff rows, not workspace B's (which holds two like-named projects and its own full row set), and a session living in B that stamps an observation directly into A is counted in A's own observations_deleted, never in collateral_observations_deleted (the two counts are mutually exclusive by construction); admin_move.rs delete_workspace_force_true_and_dry_run_true_still_only_previews — {"force": true, "dry_run": true} deletes nothing; ops.rs delete_workspace_dry_run_changes_nothing — every table's row count, including workspaces itself and the purged_scopes tombstone (the two tables a confirmed delete's own transaction writes to), is identical before/after a PurgeMode::Preview run; ops.rs delete_workspace_dry_run_and_confirmed_delete_both_report_collateral_damage_in_another_workspace and admin_move.rs of the same name — deleting workspace W collaterally deletes an observation, and orphans a handoff's session reference, in a different workspace (via sessions cascading out of W), and both the preview and the confirmed response count it identically — the cascade itself is reported, not blocked, by either; admin_move.rs delete_workspace_dry_run_refuses_non_empty_without_force / delete_workspace_dry_run_nonexistent_workspace_returns_404 — same 409/404 a confirmed delete would give; admin.rs multiuser_delete_workspace_dry_run_rejects_db_user_and_anonymous — root reaches the preview handler (404 control), DB user gets 403, anonymous gets 401 |
STRONG |
| 10e | Reconcile-tombstone of a disk-deleted page is opt-in, soft, scope-bound, and false-positive-guarded (invariant #16, #929 delete-half) | ai-memory-wiki/src/watcher.rs — off by default ([maintenance] reconcile_tombstones_deleted_pages); a page is tombstoned only after its file is observed missing on two consecutive passes (streak resets if the path's PageId changed), re-verified against the live filesystem immediately before acting, with reserved/indexed-but-unwalked paths (bootstrap.md, _meta.md, _pending/, sessions/*.md) excluded and a circuit breaker that skips a whole scope when > max(3, 50%) of its pages look missing or a non-partial walk finds nothing; ai-memory-store/src/ops.rs soft_delete_for_reconcile_if_latest is a soft tombstone (is_latest=0 + superseded_at, decay's shape) through the single writer actor in one transaction — never a filesystem write — scoped to the page's own (workspace_id, project_id) |
watcher.rs reconcile_delete_disabled_by_default_never_tombstones_a_missing_page, reconcile_delete_cross_project_isolation (project A's deletion never tombstones project B's same-path page), reconcile_tombstone_refuses_a_stale_latest_id, reconcile_tombstone_refuses_when_the_file_still_exists, reconcile_tombstone_never_touches_the_filesystem; ops.rs reconcile_tombstone_resurrects_instead_of_orphaning_on_recreate |
STRONG |
| 10f | Session-time repair is scope-bound (no cross-project timestamp rewrite) | ai-memory-store/src/ops.rs repair_session_times — every candidate is read back and updated with WHERE id=? AND workspace_id=? AND project_id=?; a row outside that 3-tuple is reported NotFound and untouched, same code path as an id that does not exist at all; ai-memory-mcp/src/admin.rs handle_repair_session_times passes the caller's (workspace, project) straight through, never the candidate's own |
ops.rs repair_session_times_does_not_touch_a_session_of_another_project (control: sibling session in-scope repaired in the same batch); admin_repair_session_times.rs cross_project_session_is_not_found_and_untouched; both bite-checked by dropping the project_id filter |
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), including shell commands whose arguments name an ignored path (match_command; argv elements matched whole and tokenized); an invalid marker makes file and shell calls metadata-only, and the server admits a metadata-only shell body only under an invalid marker (router.rs metadata_protocol_is_legal); generated OpenCode/OMP/Pi/OpenClaw integrations mirror it in render_shared.rs ts_capture_policy_v1 (captureMatchCommand). Candidate/argument flavor (Flavor::Posix vs Flavor::Windows, used to pick which ignore_paths patterns even apply) is derived from the host (the cwd), never from the candidate string alone — a POSIX-host candidate spelled with a leading // is collapsed to a single / before flavor detection (normalize_candidate; TS captureNormalize's windowsHost parameter, sourced from captureHostWindows(cwd)), fixing GHSA-vh98 (a //-prefixed candidate used to self-classify as Flavor::Windows regardless of host, matching zero POSIX patterns and being captured instead of dropped); a genuine Windows/UNC host (cwd itself windows-flavored) is unaffected |
capture_policy.rs per-agent …honors_exclusions tests, shell_fixture_vectors (shared capture-policy.json shell drop/keep vectors: tool aliases incl. OpenClaw/Devin exec, workdir, glob directories, Windows paths, and a POSIX-host leading-// command via /{root}/docs/adr/x.md), shell_tool_shapes_of_every_adapter_honor_exclusions, fixture_vectors (incl. TS-adapter bash/exec vectors, and normalization's leading-// POSIX vectors alongside the kept Windows-cwd UNC vectors as the no-regression control); shell_matching_is_off_when_inactive_and_fails_closed_when_invalid_or_over_budget; router.rs capture_protocol_shell_decisions_survive_server_reinspection, capture_protocol_invalid_marker_shell_is_metadata_only (active metadata-only shell refused, older client's invalid-marker keep stripped, commandless control kept), capture_protocol_unparseable_marker_strips_shell_events (server fallback for an unparseable marker), capture_protocol_invalid_shell_metadata_claim_must_be_canonical (a stripped shell claim with a path count or non-extracted state is refused); capture_policy.rs invalid_marker_strips_shell_calls_with_unparseable_commands, long_bash_lc_script_in_argv_is_not_dropped_by_the_match_budget, a_leading_double_slash_candidate_does_not_escape_posix_ignore_paths_via_flavor_mismatch (GHSA-vh98 adversarial: attempts the //repo/secret/... violation on a POSIX host, proves it now drops, with a plain-single-slash control and a Windows-hosted genuine-UNC control both still correct — fails on the pre-fix code); hook.rs shell_command_reading_an_ignored_path_is_dropped_before_spool; render_shared.rs generated_capture_policy_v1_node_runtime_evidence (runs the same fixture sections, including the GHSA-vh98 vectors, against the emitted TypeScript; #[ignore] locally, run with --ignored under Node 24 by the Linux CI test job) |
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 |
| 11d | Hook server-profile routing: a marker-selected server gets only its own capture and its own token (#992) | ai-memory-cli/src/server_profiles.rs resolve (validated ProfileName, strict servers.toml parse, roots required once two profiles exist, component-wise root match) and marker.rs find_server_selection (inherited down the tree, any value shape counts); commands/hook.rs resolve_hook_route drops a Rejected route before spool, handoff and backfill, and hands the drainer no live token for a profile route; commands/hook_spool.rs static_retry_token (a profile entry retries only with its own stored token), the loopback reroot skip, and chunk splitting on profile; generated TS captureServerRouted drops a routed repository and gates fetchHandoff; hooks/_lib.sh ai_memory_server_routed (flag refused by ai_memory_post_hook/ai_memory_get_handoff) and hooks/lib/ai-memory-hook.ps1 Test-AiMemoryServerRouted drop it in the script hooks |
hook.rs each_repository_spools_to_its_own_profile_with_its_own_token, a_selection_that_does_not_resolve_emits_nothing (unknown / tokenless / outside roots / roots required / invalid name, plus a resolving control), session_start_handoff_comes_from_the_profile_server_only, a_repository_without_a_server_key_keeps_the_install_default; hook_spool.rs a_profile_entry_is_never_retried_with_the_install_live_token (server B accepts exactly the install's live token and must still not get it), a_profile_entry_recovers_with_its_own_rotated_token (control), a_profile_entry_on_a_dead_loopback_port_is_not_rerouted_to_the_default, profile_and_default_entries_at_one_address_ride_separate_batches; server_profiles.rs roots/registry/name tests; marker.rs nested_markers_without_server_inherit_the_ancestor_selection; install_hooks.rs generated_integrations_fail_closed_on_a_server_profile_marker, openclaw_plugin.rs openclaw_plugin_fails_closed_on_a_server_profile_marker, and the server-routed-* checks in generated_capture_policy_v1_node_runtime_evidence; hook.rs an_event_without_a_payload_cwd_routes_by_the_process_cwd, a_refused_route_prints_nothing_for_kimi_user_prompts; hook_spool.rs a_profile_entry_is_not_retried_with_a_token_issued_for_a_new_url; server_profiles.rs changing_the_url_without_a_token_discards_the_old_token, omitted_roots_keep_the_registered_ones; marker.rs outside_home_the_walk_reaches_a_marker_above_the_checkout_root, encoding_noise_cannot_hide_a_server_key, an_unreadable_marker_is_a_refused_selection; backfill.rs a_spawned_backfill_authenticates_like_the_hook_that_spawned_it; tests/hooks/test_lib.sh "server profiles (#992)" section; hook.rs a_mixed_spool_drains_each_event_only_to_its_own_server (two token-gated servers, one spool, one drain: each server receives exactly its own event with exactly its own bearer); tests/suite/server_profiles.rs (the built binary: server add → hook spools to the profile with its token, unknown profile spools nothing, uninstall removes the tokens; two_real_servers_each_receive_only_their_own_repository runs two real ai-memory serve children with different root tokens and checks on each server which repository landed there); ai-memory-hooks powershell_server_routed.rs server_routed_guard_mirrors_the_native_walk (runs Test-AiMemoryServerRouted under real PowerShell: inherited, BOM, look-alike keys, the $HOME boundary with its control, past a checkout root outside home, current directory) |
STRONG for native hooks, generated TS, .sh and .ps1 hooks. Known gap: an older binary draining a shared spool ignores profile |
| 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 |
| 13 | Managed-run transcript attribution (concurrent launches in one checkout, invariant #16) | ai-memory-store/src/workstream.rs link_native_session stamps native_session_linked_at on its own run only, both finish updates drop the stamp when the session changes, run_status reports it; ai-memory-cli/src/commands/run.rs resolve_native_session_after_run takes a linked session only when ai-memory-workstream native_session_in_checkout holds it for this checkout (OpenCode by recorded directory), never falls back to a link it set aside, and turns an AmbiguousNativeSession into a warning with nothing imported; ai-memory-workstream/src/transcript.rs discover_crush claims only the one top-level session created (or, with --continue, touched) during the run, and in a data directory outside the project only one that edited a file in it |
multi_session.rs a_session_linked_by_one_managed_run_is_not_another_runs; run.rs a_session_linked_during_the_run_wins_over_discovery (concurrent newer session, another checkout's link refused, no fallback to it, unlinked control); transcript.rs native_session_in_checkout_checks_the_opencode_directory; store/src/lib.rs managed_run_status_reports_a_link_made_during_the_run; run.rs ambiguous_crush_discovery_keeps_the_run; transcript.rs crush_discovery_claims_only_the_session_the_run_created, crush_discovery_in_a_shared_store_claims_only_an_edit_here |
PARTIAL: a run whose child links nothing still falls back to discovering the newest session in the checkout; Crush, which has no hooks, relies on that discovery alone and claims nothing when it is ambiguous |
| 13b | Managed-run lease exclusivity (one active run per workstream, invariant #16) | ai-memory-store/src/workstream.rs prepare_run expires lapsed leases and refuses any other active run on the workstream inside one transaction (StoreError::WorkstreamBusy), regardless of the lease_owner label; heartbeat renews only active rows. ai-memory-cli/src/commands/run.rs wait_out_held_lease (interactive relaunch) only waits for a reported expiry and retries — it never cancels or claims another run, so the server's busy check stays the sole arbiter |
store/src/lib.rs managed_workstream_batches_are_idempotent_and_release_the_lease (second prepare refused while active); run.rs a_renewed_lease_is_reported_as_a_live_owner_not_taken_over (renewing holder is reported, never displaced), with controls interactive_launch_waits_out_a_lapsing_lease_then_proceeds and ctrl_c_aborts_the_held_lease_wait_immediately |
STRONG for exclusivity. The lease_owner label (host:pid) is informational only and not unique inside ai-jail (every jailed launcher reports ai-sandbox:<ns-pid>), so it must never become an ownership key |
| 14 | Per-project authorization (#708) | ai-memory-store/src/project_authz.rs authorize_project / ProjectAuthz::authorize choke point (V68 project_grants + projects.access_mode, default open; V69 projects.created_by feeds is_creator); scope.rs ScopeResolver::with_project_authz (reader pool for reads, writer actor for writes) and its free forms authorize_scope_for / *_guarded, attached for every DB user by ai-memory-mcp scope_resolver_as, ai-memory-web authorize_read / lookup_project, and ai-memory-hooks (grants.rs authorize_resolved for run/workstream ids, the capture check in router.rs); read-shaped mutations resolve at ProjectAccess::Write (resolve_existing_args(.., need)); unscoped reads filtered before LIMIT by reader.rs readable_repository_sql; WriterHandle::authorize_project as defense in depth. Page ids are never taken from a caller (only derived from already-authorized hits), so there is no page-id entry point to guard |
tests/suite/project_authz.rs (decision matrix, ship-inert, resolver gate); tests/suite/access_mode.rs every_caller_against_both_modes, a_restricted_project_admits_the_team_and_refuses_the_outsider, new_projects_follow_the_server_default_and_admit_their_creator; scope.rs the_argument_shape_does_not_decide_the_level, a_user_reaches_only_what_they_were_granted, creating_authorizes_against_a_project_that_already_exists, a_refused_scope_fails_the_search_instead_of_shortening_it; grants.rs search_finds_only_what_the_viewer_may_read, the_limit_counts_only_what_the_viewer_may_see, the_workspace_handoff_comes_only_from_readable_repositories; ai-memory-mcp server.rs a_reader_may_read_everything_and_change_nothing, bob_cannot_read_alices_page_in_a_restricted_project, bob_cannot_find_alices_page_by_searching_in_a_restricted_project, bob_cannot_consolidate_a_session_in_alices_repository, a_restricted_projects_queues_need_write; ai-memory-hooks a_capture_needs_writer_on_the_repository_it_lands_in, session_start_delivers_nothing_from_a_repository_the_viewer_cannot_read, run_and_workstream_ids_only_answer_someone_who_may_reach_the_repository; ai-memory-web web_reads_honour_grants_in_a_restricted_project, metadata_shows_only_what_the_viewer_may_read — each with a granted or open-project control, and proven to fail with the choke point (18 tests) or the SQL filter (9 tests) neutralized |
STRONG — slice 3 closed both bypass classes (unscoped reads, raw-id entry points) and added the root-only management surface. Out of scope by design: per-project administrators (granting/restricting is root-only), and access modes, creators and grants live only in SQLite, so reindex resets them |
| 14b | Repository identity routing (#708) | ai-memory-store/src/ops.rs resolve_project_by_identity — one transaction; an identity already on a project is never overwritten; an unclaimed project is claimed only when the capturing user may write it (the choke point's resolve_project_authz on the same transaction), otherwise it is returned unclaimed; a different identity under the same name splits into a new project with no shared repo_path; ai-memory-hooks/src/router.rs cache_key_for keys the path cache by identity too; ai-memory-core/src/repository_identity.rs strips credentials from remote URLs client-side, and the server accepts only the routing rungs (explicit, git_remote) from the wire |
tests/suite/identity_resolution.rs an_outsider_cannot_take_an_unclaimed_projects_identity (control: an_existing_project_is_claimed_in_place), two_unrelated_repositories_with_one_folder_name_stay_apart, a_created_or_split_project_admits_its_creator; router.rs one_path_with_two_remotes_is_two_projects, two_api_checkouts_with_different_remotes_get_two_projects (a manifest rung on the wire is ignored); repository_identity.rs credential and normalisation tables — the claim guard and the cache key each proven to fail their test when removed |
STRONG — first claimant wins: in a restricted workspace a user who creates a project under a remote identity first holds it until root grants others |
| 14c | Client-supplied event time is bounded and self-scoped (#919) | ai-memory-hooks/src/payload.rs HookEnvelope::occurred_at_micros — a /hook caller's optional occurred_at must be > 0 and <= now + 5min; it only sets the caller's own admitted session's started_at/ended_at/created_at, never another session/project/user, and the ingest dedup seen_at/TTL stays on now |
payload.rs occurred_at_micros_rejects_a_far_future_timestamp, …_rejects_non_positive_values, …_rejects_garbage_strings |
MEDIUM — self-scoped numeric bound; no cross-tenant surface (a client already controls its own content) |
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.