🔒 Unify the private visibility predicate (3-predicate divergence) + durable ownership + namespace required-scope
Severity: security-high (cross-tenant private-memory leak / ownership-bypass on the recall+search read paths).
Parent: v0.8.0 EPIC #1709 — MUST be 100% completed before multi-agent enforcement is enabled.
§2-property (§3 scope test): strengthens §2.2 (coherent) — scope=private must mean the same thing on every read path — and is a hard prerequisite for safe multi-agent §2.1 (endpoint-resident) operation. Cross-tenant isolation is a release-blocker independent of property mapping.
Provenance. 5-agent adversarial assessment (2026-06-17) run against the codegraph index at /home/fate_two/v07/v07-f5 (release/v0.8.0). Every anchor below was codegraph-confirmed; line numbers may drift — re-verify at HEAD before editing.
🧨 The defect (codegraph-confirmed)
There are three independent implementations of the scope=private visibility check, and two of them are wrong (namespace-keyed instead of owner-keyed):
| # |
Predicate |
Location |
private means |
Status |
| 1 |
SQL visibility_clause |
src/storage/mod.rs:330 (arm at :340) |
scope_idx='private' AND m.namespace = caller_ns |
❌ namespace-keyed — leaks |
| 2 |
Rust is_visible (HNSW branch) |
src/storage/mod.rs:192 (arm at :215) |
&mem.namespace == ns |
❌ namespace-keyed — leaks |
| 3 |
Rust is_visible_to_caller |
src/visibility.rs:46 (owner check :69) |
metadata.agent_id == caller (+ target_agent_id inbox carve-out) |
✅ canonical (#951), owner-keyed |
Predicate #3 is the documented canonical (#951) and matches the postgres list clause (metadata->>'agent_id' = $6, src/store/postgres.rs:10508). Predicates #1 and #2 — on the recall, search, and hybrid (HNSW + linear-SQL) read paths — key "private" on namespace membership, not ownership.
Leak scenario
Agent alice stores a scope=private memory in fortitude/X. Agent bob, also positioned in fortitude/X (as_agent="fortitude/X"), runs memory_recall/memory_search: the clause scope_idx='private' AND m.namespace='fortitude/X' evaluates TRUE → bob reads alice's private memory. Via memory_list (predicate #3) the same row is correctly hidden. Same tool surface, two definitions of "private". The divergence is untested (both is_visible and compute_visibility_prefixes flagged "no covering tests"; the only owner-exclusion test, caller_non_owner_excludes_cross_agent_private, exists for list only).
Compounding gaps (Agent-5 + Agent-4 findings)
- Currently inert / lockout trap.
resolve_read_visibility_caller() (src/identity/mod.rs:238) reads only AI_MEMORY_AGENT_ID; unset → None → trust-all (all filtering off). So scope=private is a no-op on read today. Owner stamps are PID-suffixed (ai:claude-code@host:pid-N, src/identity/mod.rs:184), non-durable across restarts — the moment anyone sets AI_MEMORY_AGENT_ID to enable filtering, every pre-existing private row is owned by a dead PID id → operator self-lockout.
- No namespace default/required scope.
CorePolicy/GovernancePolicy (src/models/namespace.rs:478) has no scope field; namespace_meta stores only (standard_id, parent_namespace). The GOVERNANCE_PRE_WRITE hook is refuse-only (takes &Memory, returns Result<()>; Decision{Allow,Refuse,Warn,Escalate} — no mutate arm, src/daemon_runtime.rs:3229). So "force/default scope" is NOT a config toggle and NOT hangable as a silent default.
- Curator bypass. Curator reads with
CallerContext::for_admin → bypass_visibility=true (src/store/mod.rs:431); it reads private rows and can distill them into consolidations.
✅ Work breakdown (all four workstreams = 100% done)
Workstream A — Unify the private predicate (the bug) · security-high
Workstream B — Durable ownership + safe enablement · security-medium
Workstream C — Namespace required-scope (refuse-only) · enhancement
Workstream D — Curator / leak channel · security-medium
Cross-cutting
🚦 Definition of done (100%)
- Op-0 posture decision recorded (single-operator trust-all vs enforced multi-agent). Enforced ⇒ A+B mandatory before flipping enforcement.
- All three predicates agree (A5 matrix test green); A6/A7 cross-namespace leak tests green on both backends.
- Owner stamps durable;
reown tool ships; lockout guard active.
- Namespace
required_scope (refuse-only) ships + configurable + SDK parity.
- Curator posture (D1/D2) decided and enforced.
- CHANGELOG declares §2.2 with code anchors (§17 gate).
📎 Cross-refs
EPIC #1709 (Phase 1 / release-gate). #951 (canonical is_visible_to_caller). Provenance: 5-agent adversarial assessment 2026-06-17.
🤖 Generated with Claude Code
🔒 Unify the
privatevisibility predicate (3-predicate divergence) + durable ownership + namespace required-scopeSeverity:
security-high(cross-tenant private-memory leak / ownership-bypass on the recall+search read paths).Parent: v0.8.0 EPIC #1709 — MUST be 100% completed before multi-agent enforcement is enabled.
§2-property (§3 scope test): strengthens §2.2 (coherent) —
scope=privatemust mean the same thing on every read path — and is a hard prerequisite for safe multi-agent §2.1 (endpoint-resident) operation. Cross-tenant isolation is a release-blocker independent of property mapping.🧨 The defect (codegraph-confirmed)
There are three independent implementations of the
scope=privatevisibility check, and two of them are wrong (namespace-keyed instead of owner-keyed):privatemeansvisibility_clausesrc/storage/mod.rs:330(arm at:340)scope_idx='private' AND m.namespace = caller_nsis_visible(HNSW branch)src/storage/mod.rs:192(arm at:215)&mem.namespace == nsis_visible_to_callersrc/visibility.rs:46(owner check:69)metadata.agent_id == caller(+target_agent_idinbox carve-out)Predicate #3 is the documented canonical (#951) and matches the postgres
listclause (metadata->>'agent_id' = $6,src/store/postgres.rs:10508). Predicates #1 and #2 — on the recall, search, and hybrid (HNSW + linear-SQL) read paths — key "private" on namespace membership, not ownership.Leak scenario
Agent alice stores a
scope=privatememory infortitude/X. Agent bob, also positioned infortitude/X(as_agent="fortitude/X"), runsmemory_recall/memory_search: the clausescope_idx='private' AND m.namespace='fortitude/X'evaluates TRUE → bob reads alice's private memory. Viamemory_list(predicate #3) the same row is correctly hidden. Same tool surface, two definitions of "private". The divergence is untested (bothis_visibleandcompute_visibility_prefixesflagged "no covering tests"; the only owner-exclusion test,caller_non_owner_excludes_cross_agent_private, exists forlistonly).Compounding gaps (Agent-5 + Agent-4 findings)
resolve_read_visibility_caller()(src/identity/mod.rs:238) reads onlyAI_MEMORY_AGENT_ID; unset →None→ trust-all (all filtering off). Soscope=privateis a no-op on read today. Owner stamps are PID-suffixed (ai:claude-code@host:pid-N,src/identity/mod.rs:184), non-durable across restarts — the moment anyone setsAI_MEMORY_AGENT_IDto enable filtering, every pre-existing private row is owned by a dead PID id → operator self-lockout.CorePolicy/GovernancePolicy(src/models/namespace.rs:478) has no scope field;namespace_metastores only(standard_id, parent_namespace). TheGOVERNANCE_PRE_WRITEhook is refuse-only (takes&Memory, returnsResult<()>;Decision{Allow,Refuse,Warn,Escalate}— no mutate arm,src/daemon_runtime.rs:3229). So "force/default scope" is NOT a config toggle and NOT hangable as a silent default.CallerContext::for_admin→bypass_visibility=true(src/store/mod.rs:431); it reads private rows and can distill them into consolidations.✅ Work breakdown (all four workstreams = 100% done)
Workstream A — Unify the
privatepredicate (the bug) ·security-highagent_id_idxgenerated column (postgres does; sqlite likely does not).src/storage/migrations.rs+ registersrc/storage/migration_meta.rs): add VIRTUAL GENERATEDagent_id_idx+target_agent_id_idxcolumns (mirrorscope_idxat:1364) + partial indexes.ALTER TABLE ADD COLUMN <virtual generated>does not rebuild the table — assert "no rebuild" so triggers survive (per the migration-rebuild-drops-triggers lesson); keep the trigger suite in the gate.visibility_clauseprivatearm (:340) →scope_idx='private' AND (agent_id_idx = ?caller OR target_agent_id_idx = ?caller). Keep team/unit/org namespace-keyed; keep collective open. Add the?callerplaceholder.caller: Option<&str>throughrecall(:2929),search(:2372), hybrid-linear-SQL (:8960) and bind at all call-sites; source fromresolve_read_visibility_caller()at the SAL/handler boundary.caller(agent_id) is distinct fromas_agent(namespace) — both must be plumbed.is_visibleHNSW branch (:215): makePrivateowner-keyed (delegate to canonicalis_visible_to_caller); keep team/unit/org local; threadcaller.visibility_clause, Rustis_visible, andis_visible_to_calleragree across(scope × owner × caller × namespace).caller_non_owner_excludes_cross_agent_privatefor recall / search / hybrid-HNSW / hybrid-linear (bob infortitude/Xmust NOT retrieve alice's private row). Plus: no-scope insert excluded for non-owner (pinsscope_idxdefault-private → visibility contract).listalready is); add bob/alice parity test intests/store_parity_gaps.rs.Workstream B — Durable ownership + safe enablement ·
security-mediumsrc/identity/mod.rs:152-218): stable host-scoped id or mandateAI_MEMORY_AGENT_ID; set stable value in~/.claude.jsonenv + curator unit.ai-memory reown --namespace <ns> --to <id>): rewritemetadata.agent_idon existing rows BEFORE enabling filtering; include a "claim unowned/legacy (empty agent_id)" path (empty_owner_blocks_named_caller).AI_MEMORY_AGENT_IDis set but rows are owned by a different/pid-suffixed id. Hard order: A merged → B2 run → flip env.Workstream C — Namespace required-scope (refuse-only) ·
enhancementscope(+owner) to the governance pre-write payload (src/daemon_runtime.rs:3169+ PG twinsrc/store/postgres.rs:8001).required_scope: Option<MemoryScope>toCorePolicy(src/models/namespace.rs:478) with#[serde(default)]; tolerant deserialize of the namespace-standardmetadata.governanceblob.Decision::Refuse+GOVERNANCE_REFUSED. Accept absent/default-private writes. (Refuse-only; coercion is the deferred large variant below.)set_namespace_standardhandler (src/handlers/hook_subscribers.rs); SDK parity (python/ts namespace-standard body;scopealready onCreateMemoryRequest).scope:sharedinto arequired_scope=privatens ⇒ refused;scope:private/absent ⇒ allowed.src/storage::insert+ both adapters that fills absent scope from resolved namespace policy before persist. Only if transparent inheritance becomes a hard requirement.Workstream D — Curator / leak channel ·
security-mediumbypass_visibility=true,src/curator/reflection_pass.rs:109) may read private namespaces; if not, exclude them from autonomy/consolidation passes or run non-bypass.scope=privateownerai:curator(reflection_pass.rs:290): decide intended owner/scope and stamp explicitly (avoid trust-all distillation leak now; avoid operator-invisible reflections once filtering is on).Cross-cutting
MemoryScope/META_KEY_*SSOTs; literal-gate green.🚦 Definition of done (100%)
reowntool ships; lockout guard active.required_scope(refuse-only) ships + configurable + SDK parity.📎 Cross-refs
EPIC #1709 (Phase 1 / release-gate). #951 (canonical
is_visible_to_caller). Provenance: 5-agent adversarial assessment 2026-06-17.🤖 Generated with Claude Code