Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -760,6 +760,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Security

- **JWT HTTP fact storage is tenant-isolated by default.** The daemon freezes
`CORECRUXD_TENANT_WRITE_STAMP` at startup, defaults it to `on` for JWT auth,
rejects invalid configuration and ambiguous/missing tenant authority, and
keeps `off`/`shadow` only as explicit shared-`default` migration modes.
Generic and console facts, context recall, engram overlays, memory
candidates, result envelopes, replay, and paired audit reads now preserve
one authorized tenant end to end. MCP and stores with
independent tenant contracts are explicitly outside this flag.

- **`.mcp.json` is gitignored.** MCP clients write the daemon's agent bearer
token into that file at the repository root, where it was previously
committable.
Expand Down
10 changes: 10 additions & 0 deletions config.example.env
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ CORECRUXD_ROUTE_AUTH=enforce
# CORECRUXD_JWT_HS256_SECRET=
# CORECRUXD_ALLOW_WEAK_HS256_SECRET=false

# HTTP fact-backed surfaces derive their storage tenant from verified JWT
# tenant claims by default. On tenant-implicit routes, multi-tenant/wildcard
# JWTs must select one with X-Corecrux-Tenant-Id; an explicit route/body tenant
# is also a selector and must agree with the header when both are present.
# Invalid values abort startup. `off` is an explicit legacy migration override
# that shares the `default` tenant; `shadow` logs would-change decisions while
# retaining that legacy behaviour.
# This does not tenant-enable MCP or unrelated entity/session/control stores.
# CORECRUXD_TENANT_WRITE_STAMP=on

# ── Storage ────────────────────────────────────────────────────────

# Data directory for segments, indexes, and control state.
Expand Down
54 changes: 53 additions & 1 deletion crates/corecrux-memory/src/engrams.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,10 +139,24 @@ pub fn validate_local_engram(engram: &LocalEngram) -> Result<(), String> {
/// Built-in catalog merged with fact-backed overlays under `__engram__::*`.
/// An overlay replaces a builtin with the same `(name, version)`.
pub fn local_catalog_with_overlays(store: &FactStore) -> Vec<LocalEngram> {
local_catalog_with_overlays_inner(store, Some("default"))
}

/// Built-in catalog merged only with overlays owned by `tenant_hash`.
///
/// HTTP callers use this form so an overlay from one JWT tenant cannot replace
/// a same-name overlay in another tenant. The unscoped function remains for the
/// local MCP compatibility plane, which is confined to the shared `default`
/// tenant because it has no per-request JWT tenant claim.
pub fn local_catalog_with_overlays_for_tenant(store: &FactStore, tenant_hash: &str) -> Vec<LocalEngram> {
local_catalog_with_overlays_inner(store, Some(tenant_hash))
}

fn local_catalog_with_overlays_inner(store: &FactStore, tenant_hash: Option<&str>) -> Vec<LocalEngram> {
let mut out = builtin_engrams();
let result = store.query(&FactQuery {
min_effective_confidence: None,
tenant_hash: None,
tenant_hash: tenant_hash.map(str::to_string),
query: None,
entity: None,
entity_prefix: Some(ENGRAM_ENTITY_PREFIX.trim_end_matches("::").to_string() + "::"),
Expand Down Expand Up @@ -509,6 +523,44 @@ mod tests {
assert_eq!(served[0].content, "operator-tuned ladder");
}

#[test]
fn tenant_scoped_catalog_never_merges_another_tenants_overlay() {
let mut store = FactStore::new();
for (tenant, content) in [
("tenant-a", "tenant A tuned ladder"),
("tenant-b", "tenant B tuned ladder"),
] {
let mut custom = builtin_engrams()
.into_iter()
.find(|engram| engram.name == "code-minimalism")
.unwrap();
custom.content = content.to_string();
store.store(StoreFact {
tenant_hash: tenant.to_string(),
entity: format!("{ENGRAM_ENTITY_PREFIX}code-minimalism"),
key: "engram".to_string(),
value: serde_json::to_string(&custom).unwrap(),
source_receipt: None,
confidence: 1.0,
private: true,
horizon_class: None,
actor: None,
});
}

let tenant_a = local_catalog_with_overlays_for_tenant(&store, "tenant-a");
let served = tenant_a.iter().find(|engram| engram.name == "code-minimalism").unwrap();
assert_eq!(served.content, "tenant A tuned ladder");
assert!(!tenant_a.iter().any(|engram| engram.content == "tenant B tuned ladder"));
let mcp_default = local_catalog_with_overlays(&store);
let served = mcp_default
.iter()
.find(|engram| engram.name == "code-minimalism")
.unwrap();
assert_ne!(served.content, "tenant A tuned ladder");
assert_ne!(served.content, "tenant B tuned ladder");
}

#[test]
fn overlay_validation_rejects_malformed_or_unbounded_control_content() {
let mut overlay = builtin_engrams().remove(0);
Expand Down
26 changes: 26 additions & 0 deletions crates/corecrux-memory/src/fact_privacy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,32 @@ pub fn is_internal_namespace(entity: &str) -> bool {
entity.starts_with(INTERNAL_NAMESPACE_MARKER)
}

/// The one internal namespace that is *seeded documentation* rather than
/// somebody's private state: the first-run docs the daemon ships about itself.
pub const SEEDED_DOCUMENTATION_PREFIX: &str = "__bootstrap__::";

/// Whether an entity may be returned to a caller that named it explicitly but
/// carries no authenticated agent identity.
///
/// This exists because two correct rules met and disagreed. Reserved
/// namespaces are born private, so `fact_visible_to_agent` hides them from a
/// caller with no passport. Separately, daemon-internal namespaces are kept out
/// of *undirected* recall but deliberately stay reachable when addressed, so
/// that nothing becomes unreachable. On an auth-off local daemon `passport_id`
/// is `None` and the operator is nonetheless the owner, so applying the first
/// rule everywhere made the daemon's own manual unreadable through
/// `/v1/context` — the exact outcome the second rule was written to prevent.
///
/// The distinction that resolves it is content, not prefix shape:
/// `__bootstrap__::` is documentation the daemon seeded about itself and is
/// safe to hand to anyone who can already reach the port, whereas `__agent::`
/// and `__ops::` are private state whose disclosure would be a real leak. So
/// the exemption is deliberately one namespace wide, and addressed-only —
/// undirected recall still excludes every internal namespace.
pub fn is_addressable_without_agent_identity(entity: &str) -> bool {
entity.starts_with(SEEDED_DOCUMENTATION_PREFIX)
}

/// Reserved entity namespaces owned exclusively by daemon governance flows.
///
/// Client-facing fact-create and target-mutation handlers must reject these
Expand Down
Loading
Loading