From 101c1ecbe8a4409ac1e62c9bb6316694b15f70a5 Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Thu, 28 May 2026 21:14:58 +0800 Subject: [PATCH] =?UTF-8?q?m1:=20memory=20namespace=20model=20=E2=80=94=20?= =?UTF-8?q?signed=20cap=20claim=20+=20worker=20filter=20+=20audit=20(close?= =?UTF-8?q?s=20#108)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire memory namespaces (personal/family/work/travel) through the full stack so a device's cap-token can read/write one life-context but not others — the semantic dimension that makes the M1 demo's "toy sees the trip, not the allergy" legible. - agentkeys-types: new `Namespace` enum (v0 set + parse/validate). - broker: `namespaces_allowed` is a SIGNED field in CapPayload; the /v1/cap/memory-* endpoints validate + sign it (unknown name → 400); cred caps always get []. Field order matches the worker mirror so the signature verifies byte-for-byte. - worker-memory: re-verifies the signature then filters by deterministic string-set membership. Cross-namespace read → empty result (never the data); write → ok:false. Unknown namespace → 400. Blob keyed bots//memory//.enc so the four namespaces coexist under the agent-facing tools (which don't expose `service`). - MCP server: threads the operator-configured allowlist into cap-mint (agent can't self-widen) and emits a `memory.namespace_violation` audit row (op_kind 13) on a violation. - audit: new canonical op_kind 13 (MemoryNamespaceViolation) + body. - docs: arch.md §17.6 + op_kind table; strategy §3.5 status; memory design wire format. harness stage-3 + stdio demo updated for the required `namespace` field, plus a live cross-namespace negative test. Tests: namespace gate positive+negative (worker verify, broker validate, types), three-act Act 1 now asserts the cross-namespace audit row. Full workspace test + clippy + fmt green. --- Cargo.lock | 1 + .../src/handlers/cap.rs | 80 ++++++++++ crates/agentkeys-core/src/audit/bodies.rs | 8 + crates/agentkeys-core/src/audit/mod.rs | 8 +- crates/agentkeys-core/src/audit/op_kind.rs | 13 +- .../src/backend/broker.rs | 6 + .../src/backend/http_backend.rs | 3 + .../src/backend/in_memory.rs | 94 +++++++++--- .../src/backend/memory.rs | 10 +- .../agentkeys-mcp-server/src/backend/mod.rs | 15 ++ crates/agentkeys-mcp-server/src/config.rs | 91 ++++++++++++ crates/agentkeys-mcp-server/src/tools/cap.rs | 4 + .../agentkeys-mcp-server/src/tools/memory.rs | 79 +++++++++- crates/agentkeys-mcp-server/src/tools/mod.rs | 6 +- .../agentkeys-mcp-server/tests/common/mod.rs | 81 +++++++--- .../agentkeys-mcp-server/tests/three_acts.rs | 58 ++++++-- crates/agentkeys-types/src/lib.rs | 79 ++++++++++ crates/agentkeys-worker-creds/src/verify.rs | 77 ++++++++++ crates/agentkeys-worker-memory/Cargo.toml | 1 + .../agentkeys-worker-memory/src/handlers.rs | 140 ++++++++++++++++-- docs/agent-iam-strategy.md | 2 + docs/arch.md | 21 ++- docs/plan/agentkeys-memory-design.md | 4 +- harness/v2-stage3-demo.sh | 54 ++++++- scripts/mcp-demo-mode-e-stdio.sh | 6 +- 25 files changed, 860 insertions(+), 81 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e2407cb7..c58d74f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -358,6 +358,7 @@ dependencies = [ name = "agentkeys-worker-memory" version = "0.1.0" dependencies = [ + "agentkeys-types", "agentkeys-worker-creds", "anyhow", "aws-config", diff --git a/crates/agentkeys-broker-server/src/handlers/cap.rs b/crates/agentkeys-broker-server/src/handlers/cap.rs index 01cde9b7..cef05c4f 100644 --- a/crates/agentkeys-broker-server/src/handlers/cap.rs +++ b/crates/agentkeys-broker-server/src/handlers/cap.rs @@ -35,6 +35,8 @@ use p256::ecdsa::{signature::Signer, Signature, SigningKey}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use agentkeys_types::Namespace; + use crate::jwt::verify::verify_session_jwt; use crate::state::SharedState; @@ -83,6 +85,16 @@ pub struct CapPayload { /// Data class binding (issue #90 followup). REQUIRED; workers reject /// caps whose data_class doesn't match their bucket. pub data_class: DataClass, + /// Memory namespaces this cap may read/write (issue #108, + /// agent-iam-strategy.md §3.5). The broker SIGNS this list into the + /// payload; the memory worker filters by deterministic string-set + /// membership. Empty for credential caps (namespaces are a + /// memory-data-class concept only). MUST stay at this field position + /// in both the broker and `agentkeys_worker_creds::verify::CapPayload` + /// — the signature is over the serialized struct, so field order must + /// match byte-for-byte across the sign/verify boundary. + #[serde(default)] + pub namespaces_allowed: Vec, pub device_key_hash: String, pub k3_epoch: u64, pub issued_at: u64, @@ -104,6 +116,14 @@ pub struct CapRequest { pub device_key_hash: String, #[serde(default = "default_ttl_seconds")] pub ttl_seconds: u64, + /// Memory namespaces the minted cap should grant (issue #108). Only + /// honored for the `/v1/cap/memory-*` endpoints; ignored for cred + /// caps. Each entry MUST be one of the v0 namespaces or the mint is + /// rejected with 400 (a typo'd namespace must not silently grant + /// nothing). Empty/absent → cap grants no namespaces (worker denies + /// every namespaced read/write). + #[serde(default)] + pub namespaces_allowed: Vec, } fn default_ttl_seconds() -> u64 { @@ -290,6 +310,15 @@ async fn mint_cap( // 3. K3EpochCounter.currentEpoch → embed. let k3_epoch = call_current_epoch(&state.http, &chain.rpc_url, &chain.epoch).await?; + // Namespace claim (issue #108): only memory caps carry namespaces; + // credential caps always get an empty list. Validate + normalize so a + // typo'd namespace is rejected at mint time (not silently signed into + // a cap that grants nothing). + let namespaces_allowed = match data_class { + DataClass::Memory => normalize_namespaces(&req.namespaces_allowed)?, + DataClass::Credentials => Vec::new(), + }; + // 4. Build payload + sign. let now = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -305,6 +334,7 @@ async fn mint_cap( service: req.service.to_lowercase(), op, data_class, + namespaces_allowed, device_key_hash: format!("0x{}", strip_0x_lc(&req.device_key_hash)), k3_epoch, issued_at: now, @@ -538,6 +568,31 @@ fn strip_0x_lc(s: &str) -> String { s.strip_prefix("0x").unwrap_or(s).to_lowercase() } +/// Validate + normalize a requested namespace list for a memory cap. +/// Lowercases, rejects any name outside the v0 set (issue #108), and +/// de-duplicates while preserving first-seen order. An unknown name is a +/// 400 so a typo can't silently mint a cap that grants nothing. +fn normalize_namespaces(requested: &[String]) -> Result, CapError> { + let mut out: Vec = Vec::with_capacity(requested.len()); + for raw in requested { + let lc = raw.trim().to_lowercase(); + if !Namespace::is_valid(&lc) { + return Err(CapError::InvalidInput(format!( + "unknown namespace `{raw}` — must be one of: {}", + Namespace::ALL + .iter() + .map(|n| n.as_str()) + .collect::>() + .join(", ") + ))); + } + if !out.contains(&lc) { + out.push(lc); + } + } + Ok(out) +} + fn parse_bool_result(s: &str) -> bool { s.trim_start_matches("0x") .trim_start_matches('0') @@ -628,6 +683,29 @@ mod tests { assert_eq!(h1, h2); } + #[test] + fn normalize_namespaces_lowercases_dedupes_and_orders() { + let got = normalize_namespaces(&[ + "Travel".into(), + "personal".into(), + "travel".into(), // dup after lowercasing + ]) + .unwrap(); + assert_eq!(got, vec!["travel".to_string(), "personal".to_string()]); + } + + #[test] + fn normalize_namespaces_rejects_unknown() { + // `profile` is a memory TYPE, not a namespace — must 400. + let err = normalize_namespaces(&["profile".into()]).unwrap_err(); + assert!(matches!(err, CapError::InvalidInput(_))); + } + + #[test] + fn normalize_namespaces_empty_is_ok() { + assert_eq!(normalize_namespaces(&[]).unwrap(), Vec::::new()); + } + #[test] fn validate_hex32_accepts_well_formed() { let valid = "0x".to_string() + &"a".repeat(64); @@ -720,6 +798,7 @@ mod tests { service: "openrouter".into(), op: CapOp::Store, data_class: DataClass::Credentials, + namespaces_allowed: vec![], device_key_hash: format!("0x{}", "c".repeat(64)), k3_epoch: 1, issued_at: 1, @@ -747,6 +826,7 @@ mod tests { service: "openrouter".into(), op: CapOp::Store, data_class: dc, + namespaces_allowed: vec![], device_key_hash: format!("0x{}", "c".repeat(64)), k3_epoch: 1, issued_at: 1, diff --git a/crates/agentkeys-core/src/audit/bodies.rs b/crates/agentkeys-core/src/audit/bodies.rs index a7cb601b..92eae1e4 100644 --- a/crates/agentkeys-core/src/audit/bodies.rs +++ b/crates/agentkeys-core/src/audit/bodies.rs @@ -61,6 +61,14 @@ pub struct MemoryTeardownBody { pub actor_target: String, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct MemoryNamespaceViolationBody { + /// The namespace the cap tried to access but wasn't granted (issue #108). + pub namespace: String, + /// `"get"` or `"put"` — which op was refused. + pub op: String, +} + // ── 20..29 — signs family ────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] diff --git a/crates/agentkeys-core/src/audit/mod.rs b/crates/agentkeys-core/src/audit/mod.rs index 21d970bb..5ab87d5a 100644 --- a/crates/agentkeys-core/src/audit/mod.rs +++ b/crates/agentkeys-core/src/audit/mod.rs @@ -55,8 +55,8 @@ use thiserror::Error; pub use bodies::{ CredFetchBody, CredStoreBody, CredTeardownBody, DeviceAddBody, DeviceRevokeBody, EmailReceiveBody, EmailSendBody, K10RotateBody, K3EpochAdvanceBody, MemoryGetBody, - MemoryPutBody, MemoryTeardownBody, PaymentDirectBody, PaymentEscrowRedeemBody, ScopeGrantBody, - ScopeRevokeBody, SignEip191Body, SignEip712Body, + MemoryNamespaceViolationBody, MemoryPutBody, MemoryTeardownBody, PaymentDirectBody, + PaymentEscrowRedeemBody, ScopeGrantBody, ScopeRevokeBody, SignEip191Body, SignEip712Body, }; pub use op_kind::AuditOpKind; @@ -180,6 +180,7 @@ pub enum TypedAuditBody { MemoryPut(MemoryPutBody), MemoryGet(MemoryGetBody), MemoryTeardown(MemoryTeardownBody), + MemoryNamespaceViolation(MemoryNamespaceViolationBody), SignEip191(SignEip191Body), SignEip712(SignEip712Body), PaymentEscrowRedeem(PaymentEscrowRedeemBody), @@ -210,6 +211,9 @@ impl TypedAuditBody { AuditOpKind::MemoryTeardown => { Self::MemoryTeardown(serde_json::from_value(value).ok()?) } + AuditOpKind::MemoryNamespaceViolation => { + Self::MemoryNamespaceViolation(serde_json::from_value(value).ok()?) + } AuditOpKind::SignEip191 => Self::SignEip191(serde_json::from_value(value).ok()?), AuditOpKind::SignEip712 => Self::SignEip712(serde_json::from_value(value).ok()?), AuditOpKind::PaymentEscrowRedeem => { diff --git a/crates/agentkeys-core/src/audit/op_kind.rs b/crates/agentkeys-core/src/audit/op_kind.rs index 6ad1cec4..12524b2f 100644 --- a/crates/agentkeys-core/src/audit/op_kind.rs +++ b/crates/agentkeys-core/src/audit/op_kind.rs @@ -7,7 +7,7 @@ //! Byte ranges with reserved slots: //! //! - 0-9 creds family (CredStore=0, CredFetch=1, CredTeardown=2; 3-9 reserved) -//! - 10-19 memory family (MemoryPut=10, MemoryGet=11, MemoryTeardown=12; 13-19 reserved) +//! - 10-19 memory family (MemoryPut=10, MemoryGet=11, MemoryTeardown=12, MemoryNamespaceViolation=13; 14-19 reserved) //! - 20-29 signs family (SignEip191=20, SignEip712=21; 22-29 reserved) //! - 30-39 payments family (PaymentEscrowRedeem=30, PaymentDirect=31; 32-39 reserved) //! - 40-49 scope family (ScopeGrant=40, ScopeRevoke=41; 42-49 reserved) @@ -31,6 +31,11 @@ pub enum AuditOpKind { MemoryPut = 10, MemoryGet = 11, MemoryTeardown = 12, + /// A cap-token tried to read/write a memory namespace not in its + /// `namespaces_allowed` claim (issue #108). The worker denies and the + /// MCP server records this audit row so a parent/operator can see the + /// over-reach attempt. + MemoryNamespaceViolation = 13, SignEip191 = 20, SignEip712 = 21, PaymentEscrowRedeem = 30, @@ -56,6 +61,7 @@ impl AuditOpKind { 10 => Self::MemoryPut, 11 => Self::MemoryGet, 12 => Self::MemoryTeardown, + 13 => Self::MemoryNamespaceViolation, 20 => Self::SignEip191, 21 => Self::SignEip712, 30 => Self::PaymentEscrowRedeem, @@ -83,6 +89,7 @@ impl AuditOpKind { Self::MemoryPut => "memory.put", Self::MemoryGet => "memory.get", Self::MemoryTeardown => "memory.teardown", + Self::MemoryNamespaceViolation => "memory.namespace_violation", Self::SignEip191 => "sign.eip191", Self::SignEip712 => "sign.eip712", Self::PaymentEscrowRedeem => "payment.escrow_redeem", @@ -115,6 +122,7 @@ mod tests { AuditOpKind::MemoryPut, AuditOpKind::MemoryGet, AuditOpKind::MemoryTeardown, + AuditOpKind::MemoryNamespaceViolation, AuditOpKind::SignEip191, AuditOpKind::SignEip712, AuditOpKind::PaymentEscrowRedeem, @@ -142,7 +150,7 @@ mod tests { /// invariant #1 (open enum). 250 is the reserved-future canary. #[test] fn unknown_bytes_return_none() { - for byte in [3u8, 9, 13, 19, 22, 32, 42, 53, 62, 71, 80, 200, 250, 255] { + for byte in [3u8, 9, 14, 19, 22, 32, 42, 53, 62, 71, 80, 200, 250, 255] { assert_eq!( AuditOpKind::from_u8(byte), None, @@ -163,6 +171,7 @@ mod tests { AuditOpKind::MemoryPut as u8, AuditOpKind::MemoryGet as u8, AuditOpKind::MemoryTeardown as u8, + AuditOpKind::MemoryNamespaceViolation as u8, AuditOpKind::SignEip191 as u8, AuditOpKind::SignEip712 as u8, AuditOpKind::PaymentEscrowRedeem as u8, diff --git a/crates/agentkeys-mcp-server/src/backend/broker.rs b/crates/agentkeys-mcp-server/src/backend/broker.rs index d7adbec3..aeced160 100644 --- a/crates/agentkeys-mcp-server/src/backend/broker.rs +++ b/crates/agentkeys-mcp-server/src/backend/broker.rs @@ -13,4 +13,10 @@ pub struct BrokerCapRequest { pub service: String, pub device_key_hash: String, pub ttl_seconds: u64, + /// Memory namespaces to grant (issue #108). The broker validates each + /// against the v0 set and signs them into the cap; ignored for cred + /// caps. Skipped on the wire when empty so cred cap-mint bodies are + /// byte-identical to pre-#108. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub namespaces_allowed: Vec, } diff --git a/crates/agentkeys-mcp-server/src/backend/http_backend.rs b/crates/agentkeys-mcp-server/src/backend/http_backend.rs index 47b4d22e..4f38c5b7 100644 --- a/crates/agentkeys-mcp-server/src/backend/http_backend.rs +++ b/crates/agentkeys-mcp-server/src/backend/http_backend.rs @@ -69,6 +69,7 @@ impl Backend for HttpBackend { service: req.service, device_key_hash: req.device_key_hash, ttl_seconds: req.ttl_seconds, + namespaces_allowed: req.namespaces_allowed, }; let resp = self @@ -138,6 +139,7 @@ impl Backend for HttpBackend { s3_key: parsed.s3_key, envelope_size: parsed.envelope_size, namespace: input.namespace, + namespace_violation: parsed.namespace_violation, }) } @@ -169,6 +171,7 @@ impl Backend for HttpBackend { ok: parsed.ok, plaintext_b64: parsed.plaintext_b64, namespace: input.namespace, + namespace_violation: parsed.namespace_violation, }) } diff --git a/crates/agentkeys-mcp-server/src/backend/in_memory.rs b/crates/agentkeys-mcp-server/src/backend/in_memory.rs index 50adcfd9..965079ef 100644 --- a/crates/agentkeys-mcp-server/src/backend/in_memory.rs +++ b/crates/agentkeys-mcp-server/src/backend/in_memory.rs @@ -80,7 +80,7 @@ impl InMemoryBackend { ); backend.seed( DEMO_ACTOR, - "profile", + "personal", "Allergic to shellfish. Prefers windowed flights.", ); backend @@ -109,6 +109,22 @@ impl InMemoryBackend { .and_then(Value::as_str) .map(str::to_string) } + + /// Extract the signed `namespaces_allowed` claim from a cap-token JSON + /// value (issue #108). Mirrors what the real worker reads off the + /// deserialized `CapPayload`. + fn namespaces_allowed_of(cap: &Value) -> Vec { + cap.get("payload") + .and_then(|p| p.get("namespaces_allowed")) + .and_then(Value::as_array) + .map(|arr| { + arr.iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect() + }) + .unwrap_or_default() + } } #[async_trait] @@ -134,6 +150,13 @@ impl Backend for InMemoryBackend { ); } + // Mirror the broker: memory caps carry namespaces_allowed; cred + // caps get an empty list (issue #108). + let namespaces_allowed = match op { + CapMintOp::MemoryPut | CapMintOp::MemoryGet => req.namespaces_allowed.clone(), + CapMintOp::CredStore | CapMintOp::CredFetch => Vec::new(), + }; + Ok(json!({ "payload": { "operator_omni": req.operator_omni, @@ -141,6 +164,7 @@ impl Backend for InMemoryBackend { "service": req.service, "op": format!("{op:?}"), "data_class": op.data_class(), + "namespaces_allowed": namespaces_allowed, "device_key_hash": req.device_key_hash, "k3_epoch": 1, "issued_at": issued_at, @@ -197,6 +221,21 @@ impl Backend for InMemoryBackend { minted.actor.clone() }; + // Namespace gate (issue #108): refuse a write outside the cap's + // signed namespaces_allowed claim. + if !Self::namespaces_allowed_of(&input.cap) + .iter() + .any(|n| n == &input.namespace) + { + return Ok(MemoryPutResult { + ok: false, + s3_key: String::new(), + envelope_size: 0, + namespace: input.namespace, + namespace_violation: true, + }); + } + let plaintext = String::from_utf8( base64::Engine::decode( &base64::engine::general_purpose::STANDARD, @@ -212,9 +251,10 @@ impl Backend for InMemoryBackend { Ok(MemoryPutResult { ok: true, - s3_key: format!("bots/{actor}/{}/in-memory.bin", input.namespace), + s3_key: format!("bots/{actor}/memory/{}/in-memory.bin", input.namespace), envelope_size: input.plaintext_b64.len(), namespace: input.namespace, + namespace_violation: false, }) } @@ -244,24 +284,40 @@ impl Backend for InMemoryBackend { minted.actor.clone() }; - let g = self.inner.lock().unwrap(); - let content = g - .memory - .get(&(actor, input.namespace.clone())) - .cloned() - .ok_or_else(|| BackendError::Http { - status: 404, - body: format!("no memory in namespace `{}`", input.namespace), - })?; + // Namespace gate (issue #108): a read outside the cap's signed + // namespaces_allowed returns an empty violation result, never data. + if !Self::namespaces_allowed_of(&input.cap) + .iter() + .any(|n| n == &input.namespace) + { + return Ok(MemoryGetResult { + ok: false, + plaintext_b64: String::new(), + namespace: input.namespace, + namespace_violation: true, + }); + } - Ok(MemoryGetResult { - ok: true, - plaintext_b64: base64::Engine::encode( - &base64::engine::general_purpose::STANDARD, - content.as_bytes(), - ), - namespace: input.namespace, - }) + let g = self.inner.lock().unwrap(); + match g.memory.get(&(actor, input.namespace.clone())).cloned() { + Some(content) => Ok(MemoryGetResult { + ok: true, + plaintext_b64: base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + content.as_bytes(), + ), + namespace: input.namespace, + namespace_violation: false, + }), + // Allowed namespace but nothing stored — a legitimate empty + // read, not a violation. + None => Ok(MemoryGetResult { + ok: false, + plaintext_b64: String::new(), + namespace: input.namespace, + namespace_violation: false, + }), + } } async fn audit_append( diff --git a/crates/agentkeys-mcp-server/src/backend/memory.rs b/crates/agentkeys-mcp-server/src/backend/memory.rs index 9aaa2f56..76063d88 100644 --- a/crates/agentkeys-mcp-server/src/backend/memory.rs +++ b/crates/agentkeys-mcp-server/src/backend/memory.rs @@ -1,8 +1,10 @@ //! Memory-worker request shapes. //! //! Mirrors `agentkeys_worker_memory::handlers::{PutRequest, GetRequest}`. -//! Namespace is passed at the request body level for Phase 1 (per the PR -//! plan §8.2: lifting it into a SIGNED CapPayload field is M4 follow-up). +//! The wire envelope carries the requested `namespace`; the cap's +//! `namespaces_allowed` claim (signed by the broker) is what the worker +//! filters against (issue #108). A request for a namespace outside the +//! claim comes back with `namespace_violation: true` and no data. use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -25,10 +27,14 @@ pub struct MemoryPutResp { pub ok: bool, pub s3_key: String, pub envelope_size: usize, + #[serde(default)] + pub namespace_violation: bool, } #[derive(Debug, Deserialize)] pub struct MemoryGetResp { pub ok: bool, pub plaintext_b64: String, + #[serde(default)] + pub namespace_violation: bool, } diff --git a/crates/agentkeys-mcp-server/src/backend/mod.rs b/crates/agentkeys-mcp-server/src/backend/mod.rs index 85e7c2f2..859ba6d2 100644 --- a/crates/agentkeys-mcp-server/src/backend/mod.rs +++ b/crates/agentkeys-mcp-server/src/backend/mod.rs @@ -67,6 +67,12 @@ pub struct CapMintRequest { pub service: String, pub device_key_hash: String, pub ttl_seconds: u64, + /// Memory namespaces to grant in the minted cap (issue #108). Sourced + /// from operator-provisioned server config — NOT from the agent's + /// per-call request — so the agent can't widen its own namespace + /// scope. Empty for credential caps (the broker ignores it there). + #[serde(default)] + pub namespaces_allowed: Vec, } /// Opaque cap-token blob — we never inspect the inside on this side; the @@ -93,6 +99,11 @@ pub struct MemoryPutResult { pub s3_key: String, pub envelope_size: usize, pub namespace: String, + /// Worker refused the write — `namespace` was outside the cap's + /// `namespaces_allowed` (issue #108). The memory tool emits a + /// `memory.namespace_violation` audit row when this is true. + #[serde(default)] + pub namespace_violation: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -100,6 +111,10 @@ pub struct MemoryGetResult { pub ok: bool, pub plaintext_b64: String, pub namespace: String, + /// Worker refused the read — `namespace` was outside the cap's + /// `namespaces_allowed` (issue #108). Paired with empty plaintext. + #[serde(default)] + pub namespace_violation: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/agentkeys-mcp-server/src/config.rs b/crates/agentkeys-mcp-server/src/config.rs index 0047f74a..e968d5de 100644 --- a/crates/agentkeys-mcp-server/src/config.rs +++ b/crates/agentkeys-mcp-server/src/config.rs @@ -4,6 +4,7 @@ //! built once at startup, cloned into every request handler via shared state, //! and treated as immutable from then on. +use agentkeys_types::Namespace; use clap::Parser; use std::collections::HashMap; use std::net::SocketAddr; @@ -79,6 +80,17 @@ pub struct Cli { /// agent runs on for cap-mint binding. #[arg(long, env = "MCP_DEFAULT_DEVICE_KEY_HASH")] pub default_device_key_hash: Option, + + /// Comma-separated memory namespaces this server's caps may access + /// (issue #108). Sourced server-side — the agent cannot widen its own + /// namespace scope by asking. Each entry must be one of: personal, + /// family, work, travel. Empty → all four namespaces (permissive), so + /// existing deploys + the dev walkthrough keep working; `--backend=http` + /// also logs a startup WARN. To exercise the gate in the dev binary set + /// e.g. `MCP_DEFAULT_NAMESPACES_ALLOWED=travel`. The three-act + /// integration test scopes a cap to `travel` only to prove enforcement. + #[arg(long, env = "MCP_DEFAULT_NAMESPACES_ALLOWED", default_value = "")] + pub default_namespaces_allowed: String, } #[derive(Debug, Clone)] @@ -99,6 +111,10 @@ pub struct Config { pub default_actor: Option, pub default_operator_omni: Option, pub default_device_key_hash: Option, + /// Memory namespaces the agent's caps may access (issue #108). Threaded + /// into every memory cap-mint so the broker signs them into the cap and + /// the worker filters reads/writes by membership. + pub default_namespaces_allowed: Vec, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -185,6 +201,9 @@ impl Config { ) }; + let default_namespaces_allowed = + resolve_namespaces_allowed(&cli.default_namespaces_allowed, backend)?; + Ok(Self { transport, backend, @@ -198,6 +217,7 @@ impl Config { default_actor, default_operator_omni, default_device_key_hash, + default_namespaces_allowed, }) } @@ -216,6 +236,13 @@ impl Config { default_actor: None, default_operator_omni: None, default_device_key_hash: None, + // Tests default to the full v0 set so existing memory tests are + // unaffected; namespace-gate tests opt into a narrower set via + // `with_namespaces_allowed`. + default_namespaces_allowed: Namespace::ALL + .iter() + .map(|n| n.as_str().to_string()) + .collect(), } } @@ -224,4 +251,68 @@ impl Config { .insert(vendor.to_string(), token.to_string()); self } + + /// Test builder — scope the server's caps to a specific namespace set + /// (issue #108). Used by the three-act demo to give the toy a + /// `travel`-only cap so the cross-namespace read is denied. + pub fn with_namespaces_allowed(mut self, namespaces: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.default_namespaces_allowed = namespaces.into_iter().map(Into::into).collect(); + self + } +} + +/// Resolve + validate the configured namespace allowlist (issue #108). +/// Explicit list → validate every entry against the v0 set (`bail` on an +/// unknown name so a typo fails fast at startup). Empty → backend-specific +/// default (see the `MCP_DEFAULT_NAMESPACES_ALLOWED` CLI doc). +fn resolve_namespaces_allowed(raw: &str, backend: BackendKind) -> anyhow::Result> { + let explicit: Vec = raw + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_lowercase()) + .collect(); + + if !explicit.is_empty() { + let mut out: Vec = Vec::with_capacity(explicit.len()); + for ns in explicit { + if !Namespace::is_valid(&ns) { + anyhow::bail!( + "MCP_DEFAULT_NAMESPACES_ALLOWED has unknown namespace `{ns}` \ + — must be one of: personal, family, work, travel" + ); + } + if !out.contains(&ns) { + out.push(ns); + } + } + return Ok(out); + } + + let all: Vec = Namespace::ALL + .iter() + .map(|n| n.as_str().to_string()) + .collect(); + match backend { + // Dev binary: permissive so the in-memory walkthrough can exercise + // every namespace. The gate is proven by the three-act integration + // test (which scopes a cap to `travel` only). To see enforcement in + // the binary, set MCP_DEFAULT_NAMESPACES_ALLOWED=travel. + BackendKind::InMemory => Ok(all), + // Production: still permissive (non-breaking) but LOUD — operators + // must scope per-device for the namespace filter to mean anything. + BackendKind::Http => { + eprintln!( + "==> ⚠️ WARN [issue #108]: MCP_DEFAULT_NAMESPACES_ALLOWED unset — \ + defaulting to ALL namespaces (personal, family, work, travel). \ + The namespace filter is permissive. Set it per-device to scope \ + the agent's memory access." + ); + Ok(all) + } + } } diff --git a/crates/agentkeys-mcp-server/src/tools/cap.rs b/crates/agentkeys-mcp-server/src/tools/cap.rs index 479b9318..699205e6 100644 --- a/crates/agentkeys-mcp-server/src/tools/cap.rs +++ b/crates/agentkeys-mcp-server/src/tools/cap.rs @@ -76,6 +76,10 @@ pub async fn mint( service, device_key_hash, ttl_seconds, + // The broker only honors this for memory caps (it forces an empty + // list for credential caps), so passing the server's configured + // allowlist unconditionally is safe (issue #108). + namespaces_allowed: config.default_namespaces_allowed.clone(), }; let cap = backend diff --git a/crates/agentkeys-mcp-server/src/tools/memory.rs b/crates/agentkeys-mcp-server/src/tools/memory.rs index e2f9a14c..1fa5a0a5 100644 --- a/crates/agentkeys-mcp-server/src/tools/memory.rs +++ b/crates/agentkeys-mcp-server/src/tools/memory.rs @@ -1,21 +1,67 @@ //! `agentkeys.memory.get` + `agentkeys.memory.put` — namespace-scoped //! memory access. Internally: mint a cap → call the memory worker. //! -//! Per Phase 1 namespace scope (issue #108 partial): the namespace is -//! a request-body field, not yet a signed CapPayload field. M4 follow-up -//! lifts it into the cap so the worker can enforce cryptographically. +//! Namespace enforcement (issue #108): the cap-mint carries the server's +//! configured `namespaces_allowed` (sourced from config, NOT the agent's +//! request, so the agent can't self-widen). The broker SIGNS that claim +//! into the cap; the memory worker filters reads/writes by string-set +//! membership. When the worker reports a `namespace_violation`, this tool +//! emits a `memory.namespace_violation` audit row and returns an empty / +//! refused result so the agent sees nothing it isn't entitled to. use base64::Engine; use serde_json::{json, Value}; use std::sync::Arc; use crate::auth::CallerContext; -use crate::backend::{Backend, CapMintOp, CapMintRequest, MemoryGetInput, MemoryPutInput}; +use crate::backend::{ + AuditAppendInput, Backend, CapMintOp, CapMintRequest, MemoryGetInput, MemoryPutInput, +}; use crate::config::Config; use crate::errors::{McpError, McpResult}; const DEFAULT_TTL_SECONDS: u64 = 300; +/// Canonical `op_kind` byte for a cross-namespace access attempt. Mirrors +/// `agentkeys_core::audit::AuditOpKind::MemoryNamespaceViolation` (= 13) — +/// hand-mirrored to avoid pulling the heavy core crate, same convention +/// as `backend::audit::ENVELOPE_VERSION`. +const OP_KIND_MEMORY_NAMESPACE_VIOLATION: u8 = 13; +/// `agentkeys_core::audit::AuditResult::NotPermitted` (= 2). +const AUDIT_RESULT_NOT_PERMITTED: u8 = 2; + +/// Emit a `memory.namespace_violation` audit row. Best-effort: a failure +/// to record the audit must NOT fail the request (the worker already +/// denied access — the audit is the observability record of that denial). +async fn emit_namespace_violation( + backend: &Arc, + operator_omni: &str, + actor: &str, + namespace: &str, + op_label: &str, +) { + let appended = backend + .audit_append(AuditAppendInput { + operator_omni: operator_omni.to_string(), + actor_omni: actor.to_string(), + op_kind: OP_KIND_MEMORY_NAMESPACE_VIOLATION, + op_body: json!({ "namespace": namespace, "op": op_label }), + result: AUDIT_RESULT_NOT_PERMITTED, + intent_text: Some(format!( + "agent attempted {op_label} on namespace `{namespace}` outside its cap" + )), + }) + .await; + if let Err(e) = appended { + tracing::warn!( + namespace, + op = op_label, + error = %e, + "failed to record namespace_violation audit row" + ); + } +} + /// Resolve an identity field — LLM-supplied param wins, else config default, /// else a precise error so the operator can fix the env. fn resolve_ident<'a>( @@ -83,6 +129,7 @@ pub async fn put( service, device_key_hash: device_key_hash.to_string(), ttl_seconds, + namespaces_allowed: config.default_namespaces_allowed.clone(), }; let cap = backend .cap_mint(CapMintOp::MemoryPut, cap_req, session_bearer) @@ -100,6 +147,16 @@ pub async fn put( .await .map_err(|e| McpError::Backend(format!("memory_put failed: {e}")))?; + if result.namespace_violation { + emit_namespace_violation(&backend, operator_omni, actor, namespace, "put").await; + return Ok(json!({ + "ok": false, + "namespace": result.namespace, + "namespace_violation": true, + "reason": "namespace_not_allowed", + })); + } + Ok(json!({ "ok": result.ok, "namespace": result.namespace, @@ -150,6 +207,7 @@ pub async fn get( service, device_key_hash: device_key_hash.to_string(), ttl_seconds, + namespaces_allowed: config.default_namespaces_allowed.clone(), }; let cap = backend .cap_mint(CapMintOp::MemoryGet, cap_req, session_bearer) @@ -164,6 +222,19 @@ pub async fn get( .await .map_err(|e| McpError::Backend(format!("memory_get failed: {e}")))?; + if result.namespace_violation { + emit_namespace_violation(&backend, operator_omni, actor, namespace, "get").await; + // Empty result — the agent sees nothing for a namespace its cap + // doesn't grant (NOT an error that would leak the memory's + // existence). + return Ok(json!({ + "ok": false, + "namespace": result.namespace, + "namespace_violation": true, + "content": "", + })); + } + let plaintext = base64::engine::general_purpose::STANDARD .decode(&result.plaintext_b64) .map_err(|e| McpError::Internal(format!("plaintext_b64 decode: {e}")))?; diff --git a/crates/agentkeys-mcp-server/src/tools/mod.rs b/crates/agentkeys-mcp-server/src/tools/mod.rs index c83ecf08..dbcb8a04 100644 --- a/crates/agentkeys-mcp-server/src/tools/mod.rs +++ b/crates/agentkeys-mcp-server/src/tools/mod.rs @@ -63,7 +63,8 @@ Returns the saved note as a plain-text string under `content`.".into(), "properties": { "namespace": { "type": "string", - "description": "Topic of the memory. Pick: 'travel' (trips, destinations, plans / 旅行、行程、计划); 'family' (relatives, birthdays / 家人、生日); 'profile' (preferences, allergies, dietary / 偏好、过敏、饮食). Default to 'travel' when the user asks about places or trips." + "enum": ["personal", "family", "work", "travel"], + "description": "Topic of the memory. Pick: 'travel' (trips, destinations, plans / 旅行、行程、计划); 'family' (relatives, birthdays / 家人、生日); 'work' (projects, deadlines, work contacts / 工作、项目、截止); 'personal' (preferences, allergies, health, dietary / 偏好、过敏、健康、饮食). Default to 'travel' when the user asks about places or trips." } }, "required": ["namespace"] @@ -81,7 +82,8 @@ Group by topic via `namespace`.".into(), "properties": { "namespace": { "type": "string", - "description": "Topic: 'travel', 'family', or 'profile'. 主题: 旅行 / 家人 / 偏好." + "enum": ["personal", "family", "work", "travel"], + "description": "Topic: 'travel', 'family', 'work', or 'personal'. 主题: 旅行 / 家人 / 工作 / 个人." }, "content": {"type": "string", "description": "The note in natural language. 笔记内容。"} }, diff --git a/crates/agentkeys-mcp-server/tests/common/mod.rs b/crates/agentkeys-mcp-server/tests/common/mod.rs index d0237117..c579b1bf 100644 --- a/crates/agentkeys-mcp-server/tests/common/mod.rs +++ b/crates/agentkeys-mcp-server/tests/common/mod.rs @@ -11,6 +11,22 @@ use agentkeys_mcp_server::backend::{ CapToken, MemoryGetInput, MemoryGetResult, MemoryPutInput, MemoryPutResult, RevokeResult, }; +/// Read the signed `namespaces_allowed` claim off a cap-token JSON value +/// (issue #108) — the mock filters reads/writes against it exactly as the +/// real memory worker does. +fn namespaces_allowed_of(cap: &Value) -> Vec { + cap.get("payload") + .and_then(|p| p.get("namespaces_allowed")) + .and_then(Value::as_array) + .map(|arr| { + arr.iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect() + }) + .unwrap_or_default() +} + #[derive(Default)] pub struct MockBackend { inner: Mutex, @@ -71,6 +87,7 @@ impl Backend for MockBackend { "service": req.service, "op": format!("{op:?}"), "data_class": op.data_class(), + "namespaces_allowed": req.namespaces_allowed, "device_key_hash": req.device_key_hash, "k3_epoch": 1, "issued_at": 0, @@ -107,14 +124,28 @@ impl Backend for MockBackend { ) .map_err(|e| BackendError::Parse(e.to_string()))?; + if !namespaces_allowed_of(&input.cap) + .iter() + .any(|n| n == &input.namespace) + { + return Ok(MemoryPutResult { + ok: false, + s3_key: String::new(), + envelope_size: 0, + namespace: input.namespace, + namespace_violation: true, + }); + } + let mut g = self.inner.lock().unwrap(); g.memory .insert((actor.clone(), input.namespace.clone()), plaintext); Ok(MemoryPutResult { ok: true, - s3_key: format!("bots/{actor}/{}/mock.bin", input.namespace), + s3_key: format!("bots/{actor}/memory/{}/mock.bin", input.namespace), envelope_size: input.plaintext_b64.len(), namespace: input.namespace, + namespace_violation: false, }) } @@ -127,24 +158,38 @@ impl Backend for MockBackend { .unwrap_or("") .to_string(); + // Namespace gate (issue #108): the worker the mock stands in for + // filters by the cap's signed namespaces_allowed claim. + if !namespaces_allowed_of(&input.cap) + .iter() + .any(|n| n == &input.namespace) + { + return Ok(MemoryGetResult { + ok: false, + plaintext_b64: String::new(), + namespace: input.namespace, + namespace_violation: true, + }); + } + let g = self.inner.lock().unwrap(); - let content = g - .memory - .get(&(actor, input.namespace.clone())) - .cloned() - .ok_or_else(|| BackendError::Http { - status: 404, - body: format!("no memory in namespace `{}`", input.namespace), - })?; - - Ok(MemoryGetResult { - ok: true, - plaintext_b64: base64::Engine::encode( - &base64::engine::general_purpose::STANDARD, - content.as_bytes(), - ), - namespace: input.namespace, - }) + match g.memory.get(&(actor, input.namespace.clone())).cloned() { + Some(content) => Ok(MemoryGetResult { + ok: true, + plaintext_b64: base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + content.as_bytes(), + ), + namespace: input.namespace, + namespace_violation: false, + }), + None => Ok(MemoryGetResult { + ok: false, + plaintext_b64: String::new(), + namespace: input.namespace, + namespace_violation: false, + }), + } } async fn audit_append( diff --git a/crates/agentkeys-mcp-server/tests/three_acts.rs b/crates/agentkeys-mcp-server/tests/three_acts.rs index 52a56ef0..2cf63129 100644 --- a/crates/agentkeys-mcp-server/tests/three_acts.rs +++ b/crates/agentkeys-mcp-server/tests/three_acts.rs @@ -49,10 +49,18 @@ async fn act_1_permissioned_memory_returns_travel_namespace_only() { "Chengdu trip — Apr 12 to 16, hotpot at Yulin.", ); backend.seed_memory(ACTOR, "family", "Wife's bday Aug 3"); - backend.seed_memory(ACTOR, "profile", "Allergic to shellfish"); + backend.seed_memory(ACTOR, "personal", "Allergic to shellfish"); - let server = server_with(backend.clone()); + // The toy's cap is scoped to `travel` only (operator-provisioned via + // server config). This is the issue #108 enforcement boundary: the + // agent can't widen it because namespaces_allowed comes from config, + // not the request. + let config = Config::for_tests() + .with_vendor_token("magiclick", "demo-tok") + .with_namespaces_allowed(["travel"]); + let server = Server::new(config, backend.clone()); + // travel → in the cap's namespaces_allowed → returns the Chengdu trip. let resp = server .dispatch( &caller(), @@ -71,7 +79,7 @@ async fn act_1_permissioned_memory_returns_travel_namespace_only() { assert!( resp.error.is_none(), - "act 1 unexpected error: {:?}", + "act 1 travel read errored: {:?}", resp.error ); let result = resp.result.expect("result"); @@ -82,7 +90,39 @@ async fn act_1_permissioned_memory_returns_travel_namespace_only() { assert!(!content.contains("Wife")); assert!(!content.contains("shellfish")); - // Try the wrong namespace — the mock returns 404 → Backend error. + // personal → NOT in namespaces_allowed → empty result + violation flag, + // and a namespace_violation audit row is recorded. The toy "sees + // nothing", not an error that would leak that the memory exists. + let resp = server + .dispatch( + &caller(), + "session-bearer", + call_tool( + "agentkeys.memory.get", + json!({ + "actor": ACTOR, + "namespace": "personal", + "operator_omni": OPERATOR, + "device_key_hash": DEVICE_KEY_HASH + }), + ), + ) + .await; + assert!( + resp.error.is_none(), + "cross-namespace read should be a clean empty result, not an error: {:?}", + resp.error + ); + let inner = &resp.result.expect("result")["structuredContent"]; + assert_eq!(inner["namespace_violation"], true); + assert_eq!(inner["content"], ""); + assert_eq!( + backend.audit_count(), + 1, + "cross-namespace access must emit exactly one audit row" + ); + + // family → also denied; emits a second audit row. let resp = server .dispatch( &caller(), @@ -98,13 +138,9 @@ async fn act_1_permissioned_memory_returns_travel_namespace_only() { ), ) .await; - // M1 namespace enforcement happens at the worker (mocked); we - // expect the call to succeed when the actor IS bound to family. - // The point of Act 1's storyboard is that the cap-scoped read - // returns only what the actor's cap is bound to — the MCP server - // forwards the namespace and the worker enforces. Confirm the - // forwarded namespace by inspecting the cap mints. - assert!(resp.error.is_none() || resp.result.is_some()); + let inner = &resp.result.expect("result")["structuredContent"]; + assert_eq!(inner["namespace_violation"], true); + assert_eq!(backend.audit_count(), 2); let mints = backend.cap_mints(); assert!( diff --git a/crates/agentkeys-types/src/lib.rs b/crates/agentkeys-types/src/lib.rs index 74a134ea..e6be96fb 100644 --- a/crates/agentkeys-types/src/lib.rs +++ b/crates/agentkeys-types/src/lib.rs @@ -191,6 +191,61 @@ pub struct SpendEvent { pub timestamp: u64, } +/// v0 memory namespace (issue #108, `docs/agent-iam-strategy.md` §3.5). +/// +/// Namespaces are the ORTHOGONAL semantic dimension layered over the +/// 4 structural memory types — they scope which life-context a memory +/// item belongs to. A cap-token carries a `namespaces_allowed` claim; +/// the memory worker filters reads/writes by deterministic string-set +/// membership (no LLM, no fuzzy matching). The list is intentionally +/// small in v0 (4 fixed); user-defined namespaces land in a later phase +/// with the delegation/ACL work. +/// +/// The serde rename keeps the wire form a lowercase string so the cap +/// payload's `namespaces_allowed: ["travel"]` and the put/get envelope's +/// `namespace: "travel"` are plain strings, not tagged enums. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "snake_case")] +pub enum Namespace { + Personal, + Family, + Work, + Travel, +} + +impl Namespace { + /// The fixed v0 set, in canonical order. + pub const ALL: [Namespace; 4] = [ + Namespace::Personal, + Namespace::Family, + Namespace::Work, + Namespace::Travel, + ]; + + /// Lowercase wire spelling — matches the serde rename so callers can + /// build the cap claim / envelope field without serializing. + pub fn as_str(self) -> &'static str { + match self { + Namespace::Personal => "personal", + Namespace::Family => "family", + Namespace::Work => "work", + Namespace::Travel => "travel", + } + } + + /// Parse a wire string into a known namespace. Returns `None` for any + /// name outside the v0 set so callers can reject typos with a 400 + /// (a typo'd namespace must NOT silently filter everything). + pub fn parse(name: &str) -> Option { + Self::ALL.into_iter().find(|ns| ns.as_str() == name) + } + + /// True when `name` is one of the v0 namespaces. + pub fn is_valid(name: &str) -> bool { + Self::parse(name).is_some() + } +} + #[cfg(test)] mod tests { use super::*; @@ -238,4 +293,28 @@ mod tests { assert_eq!(variant, &back); } } + + #[test] + fn namespace_serializes_lowercase() { + assert_eq!( + serde_json::to_string(&Namespace::Personal).unwrap(), + "\"personal\"" + ); + assert_eq!( + serde_json::to_string(&Namespace::Travel).unwrap(), + "\"travel\"" + ); + } + + #[test] + fn namespace_parse_accepts_v0_set_rejects_typos() { + for ns in Namespace::ALL { + assert_eq!(Namespace::parse(ns.as_str()), Some(ns)); + assert!(Namespace::is_valid(ns.as_str())); + } + // `profile` is a memory TYPE, not a namespace — must be rejected. + assert_eq!(Namespace::parse("profile"), None); + assert!(!Namespace::is_valid("Travel")); // case-sensitive + assert!(!Namespace::is_valid("")); + } } diff --git a/crates/agentkeys-worker-creds/src/verify.rs b/crates/agentkeys-worker-creds/src/verify.rs index d1b32a3f..59c28376 100644 --- a/crates/agentkeys-worker-creds/src/verify.rs +++ b/crates/agentkeys-worker-creds/src/verify.rs @@ -57,6 +57,13 @@ pub struct CapPayload { /// Data class the cap is bound to. REQUIRED — workers reject caps /// whose data_class doesn't match the URL's bucket. pub data_class: DataClass, + /// Memory namespaces the cap may read/write (issue #108). MUST stay at + /// this field position — identical to the broker's + /// `CapPayload::namespaces_allowed` so the re-serialized struct matches + /// the bytes the broker signed. `#[serde(default)]` lets a credential + /// cap (which omits the field upstream of this rollout) still verify. + #[serde(default)] + pub namespaces_allowed: Vec, pub device_key_hash: String, pub k3_epoch: u64, pub issued_at: u64, @@ -92,6 +99,11 @@ pub enum VerifyError { OpMismatch { expected: CapOp, got: CapOp }, #[error("cap data_class {got:?} does not match endpoint {expected:?}")] DataClassMismatch { expected: DataClass, got: DataClass }, + #[error("namespace `{requested}` not in cap's namespaces_allowed {allowed:?}")] + NamespaceNotAllowed { + requested: String, + allowed: Vec, + }, #[error("chain RPC error: {0}")] ChainRpc(String), #[error("requested service not in agent's on-chain scope")] @@ -147,6 +159,29 @@ pub fn check_data_class(token: &CapToken, expected: DataClass) -> Result<(), Ver Ok(()) } +/// Namespace membership gate (issue #108, agent-iam-strategy.md §3.5). +/// Deterministic string-set membership — NO LLM, NO fuzzy matching. The +/// `requested` namespace MUST already be lowercased + validated against +/// the v0 set by the caller (the memory worker rejects unknown names with +/// 400 before this point). Returns `NamespaceNotAllowed` when the cap's +/// signed `namespaces_allowed` claim does not contain the request's +/// namespace — the worker turns that into an empty read / refused write. +pub fn check_namespace_allowed(token: &CapToken, requested: &str) -> Result<(), VerifyError> { + if token + .payload + .namespaces_allowed + .iter() + .any(|allowed| allowed == requested) + { + Ok(()) + } else { + Err(VerifyError::NamespaceNotAllowed { + requested: requested.to_string(), + allowed: token.payload.namespaces_allowed.clone(), + }) + } +} + pub fn check_freshness(token: &CapToken) -> Result<(), VerifyError> { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -374,6 +409,7 @@ mod tests { service: "openrouter".into(), op, data_class, + namespaces_allowed: vec![], device_key_hash: format!("0x{}", "c".repeat(64)), k3_epoch: 1, issued_at: 1, @@ -486,6 +522,47 @@ mod tests { )); } + #[test] + fn check_namespace_allowed_accepts_member() { + let mut t = sample_token_with_class(CapOp::Fetch, DataClass::Memory); + t.payload.namespaces_allowed = vec!["travel".into()]; + assert!(check_namespace_allowed(&t, "travel").is_ok()); + } + + #[test] + fn check_namespace_allowed_rejects_non_member() { + // Toy's cap is travel-only; a personal read must be denied. + let mut t = sample_token_with_class(CapOp::Fetch, DataClass::Memory); + t.payload.namespaces_allowed = vec!["travel".into()]; + match check_namespace_allowed(&t, "personal") { + Err(VerifyError::NamespaceNotAllowed { requested, allowed }) => { + assert_eq!(requested, "personal"); + assert_eq!(allowed, vec!["travel".to_string()]); + } + other => panic!("expected NamespaceNotAllowed, got {other:?}"), + } + } + + #[test] + fn check_namespace_allowed_empty_claim_denies_everything() { + // A cap minted with no namespaces grants nothing — every read is + // a violation (fail-closed at the cap layer). + let t = sample_token_with_class(CapOp::Fetch, DataClass::Memory); + assert!(matches!( + check_namespace_allowed(&t, "travel"), + Err(VerifyError::NamespaceNotAllowed { .. }) + )); + } + + #[test] + fn check_namespace_allowed_multi_member() { + let mut t = sample_token_with_class(CapOp::Store, DataClass::Memory); + t.payload.namespaces_allowed = vec!["travel".into(), "family".into()]; + assert!(check_namespace_allowed(&t, "family").is_ok()); + assert!(check_namespace_allowed(&t, "travel").is_ok()); + assert!(check_namespace_allowed(&t, "work").is_err()); + } + #[test] fn check_op_rejects_mismatch() { let t = sample_token(CapOp::Store); diff --git a/crates/agentkeys-worker-memory/Cargo.toml b/crates/agentkeys-worker-memory/Cargo.toml index 01591a3a..146a6eda 100644 --- a/crates/agentkeys-worker-memory/Cargo.toml +++ b/crates/agentkeys-worker-memory/Cargo.toml @@ -17,6 +17,7 @@ path = "src/lib.rs" # worker. Per arch.md §15.2 the memory worker has the same cap-mint / # AES-GCM / S3-PUT flow; only the S3 path prefix + bucket differ. agentkeys-worker-creds = { path = "../agentkeys-worker-creds" } +agentkeys-types = { workspace = true } axum = { version = "0.7", features = ["json"] } tokio = { workspace = true } serde = { workspace = true } diff --git a/crates/agentkeys-worker-memory/src/handlers.rs b/crates/agentkeys-worker-memory/src/handlers.rs index b11997b9..5cda3634 100644 --- a/crates/agentkeys-worker-memory/src/handlers.rs +++ b/crates/agentkeys-worker-memory/src/handlers.rs @@ -9,6 +9,7 @@ use axum::{ use serde::{Deserialize, Serialize}; use crate::state::SharedMemoryWorkerState; +use agentkeys_types::Namespace; use agentkeys_worker_creds::aws_creds::{s3_for_request, OptionalStsCreds}; use agentkeys_worker_creds::envelope; use agentkeys_worker_creds::errors::{err_400, err_403, err_500, err_502, ApiError}; @@ -44,6 +45,9 @@ async fn healthz(State(state): State) -> Json, ) -> Result, ApiError> { + let namespace = normalize_namespace(&req.namespace)?; verify_cap(&state, &req.cap, CapOp::Store).await?; + // Namespace gate (issue #108): refuse a write to a namespace the cap + // wasn't granted. Returns a structured `namespace_violation` verdict + // (HTTP 200) so the MCP server can emit the audit row + surface a + // clean "refused" to the agent — symmetric with the empty-read path. + if verify::check_namespace_allowed(&req.cap, &namespace).is_err() { + return Ok(Json(PutResponse { + ok: false, + s3_key: String::new(), + envelope_size: 0, + namespace, + namespace_violation: true, + })); + } + use base64::{engine::general_purpose::STANDARD, Engine as _}; let plaintext = STANDARD .decode(&req.plaintext_b64) @@ -96,7 +130,11 @@ async fn memory_put( let env_bytes = envelope::encrypt(&state.config.kek_hex_stage1, &plaintext, &aad) .map_err(|e| err_500(e.to_string(), "envelope_encrypt"))?; - let key = s3_key(&req.cap.payload.actor_omni, &req.cap.payload.service); + let key = s3_key( + &req.cap.payload.actor_omni, + &namespace, + &req.cap.payload.service, + ); let s3 = s3_for_request(&state.s3, &state.config.region, creds.as_ref()).await; s3.put_object() .bucket(&state.config.memory_bucket) @@ -109,6 +147,8 @@ async fn memory_put( ok: true, s3_key: key, envelope_size: env_bytes.len(), + namespace, + namespace_violation: false, })) } @@ -117,9 +157,27 @@ async fn memory_get( OptionalStsCreds(creds): OptionalStsCreds, Json(req): Json, ) -> Result, ApiError> { + let namespace = normalize_namespace(&req.namespace)?; verify_cap(&state, &req.cap, CapOp::Fetch).await?; - let key = s3_key(&req.cap.payload.actor_omni, &req.cap.payload.service); + // Namespace gate (issue #108): a read for a namespace the cap wasn't + // granted returns an EMPTY result (never the data) + a violation flag. + // The toy "sees nothing" rather than an error that would leak whether + // the memory exists. The MCP server emits the audit row off this flag. + if verify::check_namespace_allowed(&req.cap, &namespace).is_err() { + return Ok(Json(GetResponse { + ok: false, + plaintext_b64: String::new(), + namespace, + namespace_violation: true, + })); + } + + let key = s3_key( + &req.cap.payload.actor_omni, + &namespace, + &req.cap.payload.service, + ); let s3 = s3_for_request(&state.s3, &state.config.region, creds.as_ref()).await; let resp = s3 .get_object() @@ -148,6 +206,8 @@ async fn memory_get( Ok(Json(GetResponse { ok: true, plaintext_b64: STANDARD.encode(&plaintext), + namespace, + namespace_violation: false, })) } @@ -243,13 +303,47 @@ fn err_403_or_502(e: verify::VerifyError) -> ApiError { } } -/// S3 key prefix per arch.md §15.2: `bots//memory/.enc`. -/// Distinct from creds worker's `credentials/` prefix; same bucket-relative -/// shape so a single audit pass covers both data classes. -fn s3_key(actor_omni: &str, service: &str) -> String { +/// Normalize + validate a wire namespace (issue #108). Lowercases, then +/// rejects any name outside the v0 set with HTTP 400 — a typo'd namespace +/// must fail loud, not silently filter everything (the risk-table +/// mitigation in the issue). The returned string is the lowercase +/// canonical form used for the cap-membership check AND the S3 path +/// component. +fn normalize_namespace(raw: &str) -> Result { + let lc = raw.trim().to_lowercase(); + if !Namespace::is_valid(&lc) { + return Err(err_400( + format!( + "unknown namespace `{raw}` — must be one of: {}", + Namespace::ALL + .iter() + .map(|n| n.as_str()) + .collect::>() + .join(", ") + ), + "unknown_namespace", + )); + } + Ok(lc) +} + +/// S3 key for a namespaced memory blob (issue #108): +/// `bots//memory//.enc`. +/// +/// The `/` path component is what lets the four v0 namespaces +/// coexist for one actor under the legacy single-blob primitive — the +/// agent-facing `memory.get/put` tools don't expose `service` (it defaults +/// to "memory"), so without this component every namespace would collide +/// on one key. This is the `bots//memory//…` migration +/// target sketched in agent-iam-strategy.md §3.5; arch.md §17 documents +/// why M1 ships it now (the metadata-only filter presupposes the 4-type +/// LIST retrieval, which isn't built yet). Still distinct from the creds +/// worker's `credentials/` subtree, preserving per-data-class separation. +fn s3_key(actor_omni: &str, namespace: &str, service: &str) -> String { format!( - "bots/{}/memory/{}.enc", + "bots/{}/memory/{}/{}.enc", actor_omni.trim_start_matches("0x").to_lowercase(), + namespace.to_lowercase(), service.to_lowercase() ) } @@ -271,14 +365,40 @@ mod tests { // NOT bots//credentials/... A drift here would collapse the // per-data-class blast-radius. assert_eq!( - s3_key("0xABCDEF", "chat-history"), - "bots/abcdef/memory/chat-history.enc" + s3_key("0xABCDEF", "travel", "chat-history"), + "bots/abcdef/memory/travel/chat-history.enc" ); - assert!(!s3_key("0xabc", "x").contains("credentials")); + assert!(!s3_key("0xabc", "travel", "x").contains("credentials")); + } + + #[test] + fn s3_key_partitions_by_namespace() { + // Issue #108: the namespace path component lets distinct namespaces + // coexist for one actor under the default service. + let travel = s3_key("0xabc", "travel", "memory"); + let personal = s3_key("0xabc", "personal", "memory"); + assert_ne!(travel, personal); + assert_eq!(travel, "bots/abc/memory/travel/memory.enc"); + assert_eq!(personal, "bots/abc/memory/personal/memory.enc"); } #[test] fn s3_prefix_uses_memory_path() { + // The teardown prefix still covers every namespace (they're + // sub-paths under memory/). assert_eq!(s3_prefix("0xABCDEF"), "bots/abcdef/memory/"); } + + #[test] + fn normalize_namespace_lowercases_known() { + assert_eq!(normalize_namespace("Travel").unwrap(), "travel"); + assert_eq!(normalize_namespace(" personal ").unwrap(), "personal"); + } + + #[test] + fn normalize_namespace_rejects_unknown_with_400() { + // `profile` is a memory TYPE, not a namespace. + let err = normalize_namespace("profile").unwrap_err(); + assert_eq!(err.0, axum::http::StatusCode::BAD_REQUEST); + } } diff --git a/docs/agent-iam-strategy.md b/docs/agent-iam-strategy.md index a9cf4ccb..593fb9da 100644 --- a/docs/agent-iam-strategy.md +++ b/docs/agent-iam-strategy.md @@ -211,6 +211,8 @@ A device's cap-token scopes which namespaces it can read AND write. The MagicLic - ✅ K3 epoch rotation ([arch.md §16](./arch.md), memory-design §8.3) unchanged — namespaces are envelope metadata, not part of the keying material - ✅ Architecture-as-source-of-truth (CLAUDE.md policy) — once v0 namespaces ship, arch.md §17 gets an additive paragraph + memory-design §3 adds the namespace field to the wire format. No conflicting canonical names introduced. +**Implementation status (M1, issue #108): SHIPPED.** Cap-token `namespaces_allowed` is a signed claim ([`handlers/cap.rs`](../crates/agentkeys-broker-server/src/handlers/cap.rs)); the memory worker filters by string-set membership and a cross-namespace attempt emits a `memory.namespace_violation` audit row (op_kind 13). As-built details + one deliberate divergence from this section are in **[`arch.md` §17.6](./arch.md)**: M1 keys the legacy memory blob at `bots//memory//.enc` — a path component, NOT the metadata-only layout sketched above. Reason: the metadata-only filter presupposes the 4-type LIST-and-filter retrieval (`/v1/memory/append` + per-line namespace metadata), which isn't built yet, and the agent-facing `memory.get/put` tools don't expose `service` (so all namespaces would otherwise collide on one key). The AAD / keying material is unchanged; this is the §3.5 "Future evolution" migration target brought forward. + ### 3.6 IAM tool vs IAM guarantee — and how AgentKeys delivers each Added 2026-05-28 to crystallize the distinction that drives the Phase 3 architecture choice. diff --git a/docs/arch.md b/docs/arch.md index d1f69cdc..3681783b 100644 --- a/docs/arch.md +++ b/docs/arch.md @@ -979,6 +979,7 @@ and never reordered**. Grouped by 10s leaves room for related ops. | `MemoryPut` | 10 | `{key: string, payload_hash: [u8;32]}` | memory-service | | `MemoryGet` | 11 | `{key: string, cap_hash: [u8;32]}` | memory-service | | `MemoryTeardown` | 12 | `{actor_target: [u8;32]}` | memory-service | +| `MemoryNamespaceViolation` | 13 | `{namespace: string, op: string}` | MCP server (off the worker's `namespace_violation` flag, issue #108) | | `SignEip191` | 20 | `{message_digest: [u8;32], wallet: [u8;20]}` | signer (via daemon callback) | | `SignEip712` | 21 | `{chain_id: u64, verifying_contract: [u8;20], primary_type: string, type_hash: [u8;32], domain_separator: [u8;32], digest: [u8;32]}` | signer (via daemon callback) | | `PaymentEscrowRedeem` | 30 | `{escrow_addr: [u8;20], amount: U256, recipient: [u8;20], chain_id: u64}` | payment-service (P-2 mode) | @@ -992,7 +993,7 @@ and never reordered**. Grouped by 10s leaves room for related ops. | `EmailReceive` | 61 | `{from_hash: [u8;32], message_id: string, payload_hash: [u8;32]}` | email-service | | `K3EpochAdvance` | 70 | `{old_epoch: u64, new_epoch: u64, gov_tx: [u8;32]}` | K3EpochCounter hook | -Byte ranges `8-9`, `13-19`, `22-29`, `32-39`, `42-49`, `53-59`, `62-69`, `71-79`, `80-255` are reserved for future extensions in the same family. +Byte ranges `8-9`, `14-19`, `22-29`, `32-39`, `42-49`, `53-59`, `62-69`, `71-79`, `80-255` are reserved for future extensions in the same family. #### Forward-compat / non-break design @@ -1383,7 +1384,7 @@ Separate buckets → separate roles → independent policy surfaces. `agentkeys- ``` $VAULT_BUCKET bots//credentials/.enc -$MEMORY_BUCKET bots//memory/ +$MEMORY_BUCKET bots//memory//.enc (legacy blob primitive, §17.6) $AUDIT_BUCKET bots//audit/ $EMAIL_BUCKET bots//inbound/ bots//sent// @@ -1409,6 +1410,22 @@ The cap-token carries a signed `data_class: Credentials | Memory` field. The bro Each worker rejects caps whose `data_class` doesn't match its bucket with HTTP 403 `cap_data_class_mismatch`. This is the cap-layer isolation gate — symmetric with the AWS IAM cross-bucket gate (§17.2) but enforced at the broker-signed capability layer, **before** the worker touches AWS at all. +### 17.6 Memory namespaces (issue #108) + +Inside the `memory` data class, the cap-token carries a second signed claim — `namespaces_allowed: ["travel", ...]` — orthogonal to `data_class`. Namespaces are the semantic dimension that makes scoped memory legible: a device's cap can read/write the `travel` namespace but see *nothing* in `personal`, `family`, or `work` for the same actor. The v0 set is fixed at four (`personal`, `family`, `work`, `travel`); the enum lives in [`agentkeys-types`](../crates/agentkeys-types/src/lib.rs) (`Namespace`). Strategic rationale: [`agent-iam-strategy.md` §3.5](agent-iam-strategy.md). + +Enforcement is a chain of three gates, all on the deterministic membership test `requested_namespace ∈ cap.namespaces_allowed` (no LLM, no fuzzy match): + +| Where | What | +|---|---| +| **Broker mint** | The memory cap-mint endpoints (`/v1/cap/memory-*`) validate each requested namespace against the v0 set (unknown → 400) and **sign** `namespaces_allowed` into the payload. Credential caps always get `[]`. ([`handlers/cap.rs`](../crates/agentkeys-broker-server/src/handlers/cap.rs)) | +| **Worker filter** | The memory worker re-verifies the broker signature, then refuses any read/write whose `namespace` is outside the claim. A refused **read returns an empty result** (never the data — the device can't even tell the memory exists); a refused **write returns `ok:false`**. Unknown namespace name → 400. ([`agentkeys-worker-memory`](../crates/agentkeys-worker-memory/src/handlers.rs) via [`verify::check_namespace_allowed`](../crates/agentkeys-worker-creds/src/verify.rs)) | +| **Audit** | On a violation the MCP server emits a `memory.namespace_violation` audit row (op_kind 13, §15.3a) so a parent/operator sees the over-reach attempt. | + +**The namespace allowlist is operator-sourced, not agent-sourced.** The MCP server threads its configured `MCP_DEFAULT_NAMESPACES_ALLOWED` into every memory cap-mint — the agent cannot widen its own scope by asking. (M1 trust model: the operator configures the server correctly. On-chain per-namespace scope is a later-phase hardening so even a compromised MCP server can't exceed an on-chain grant.) + +**Storage layout — M1 path component, NOT metadata-only.** The legacy memory blob is keyed `bots//memory//.enc` (§17.3). The agent-facing `memory.get/put` tools don't expose `service` (it defaults to `memory`), so without the `/` path component the four namespaces would collide on one S3 key. This is the `bots//memory//…` migration target sketched in [`agent-iam-strategy.md` §3.5](agent-iam-strategy.md); M1 ships it now because §3.5's metadata-only filter presupposes the 4-type LIST-and-filter retrieval (`/v1/memory/append` + per-line namespace metadata), which isn't built yet. The AES-GCM AAD is unchanged (namespace is **not** part of the keying material — K3 rotation is unaffected). Per-actor PrincipalTag scoping (§17.5) and per-data-class bucket separation are unchanged: namespaces live *inside* the actor's memory prefix. + **Four-layer defense in depth:** | Layer | Invariant | Enforced by | Canonical test | diff --git a/docs/plan/agentkeys-memory-design.md b/docs/plan/agentkeys-memory-design.md index 8389203a..b11780cd 100644 --- a/docs/plan/agentkeys-memory-design.md +++ b/docs/plan/agentkeys-memory-design.md @@ -242,8 +242,8 @@ All new endpoints under `/v1/memory/`. Cap-token gating unchanged — every endp | `POST /v1/memory/export` | `Fetch` | `{ cap, types?: [...], since_ts? }` | `{ ok, presigned_url, expires_at }` | enumerate keys; stream a multipart tar on the fly via presigned URL | | `POST /v1/memory/rebuild-index` | `Store` | `{ cap, embedding_model, vectors_b64, if_match_etag? }` where `vectors_b64` is the operator-built embedding bundle | `{ ok, manifest_etag }` or 412 | overwrite `index/*` atomically (PUT to `.tmp` keys, CopyObject to canonical); If-Match guards concurrent rebuilders | | `POST /v1/memory/teardown` | `Teardown` | unchanged | unchanged | unchanged | -| `POST /v1/memory/put` | `Store` | unchanged (legacy blob KV) | unchanged | unchanged — `bots//memory/.enc`. **Rejects reserved service names per §3.1.** | -| `POST /v1/memory/get` | `Fetch` | unchanged | unchanged | unchanged. **Rejects reserved service names per §3.1.** | +| `POST /v1/memory/put` | `Store` | legacy blob KV + `namespace` field (issue #108) | `{ ok, s3_key, envelope_size, namespace, namespace_violation }` | `bots//memory//.enc` — namespace path component per [arch.md §17.6](../arch.md). Refuses a namespace outside the cap's `namespaces_allowed` (`ok:false, namespace_violation:true`). **Rejects reserved service names per §3.1.** | +| `POST /v1/memory/get` | `Fetch` | legacy + `namespace` field (issue #108) | `{ ok, plaintext_b64, namespace, namespace_violation }` | reads `bots//memory//.enc`; a namespace outside the cap returns an **empty** result + `namespace_violation:true` (never the data). **Rejects reserved service names per §3.1.** | **Notes on the new shape:** diff --git a/harness/v2-stage3-demo.sh b/harness/v2-stage3-demo.sh index 87865547..7113a481 100755 --- a/harness/v2-stage3-demo.sh +++ b/harness/v2-stage3-demo.sh @@ -516,6 +516,10 @@ fi # never landed stage-1 step 13's setScopeWithWebauthn). SMOKE_SERVICE="${SMOKE_TEST_SERVICE:-openrouter}" SMOKE_PLAINTEXT="${SMOKE_TEST_SECRET:-stage3-roundtrip-secret-$(date +%s)}" +# Memory worker requires a namespace (issue #108). Caps are minted granting +# this namespace; the put/get bodies carry it. Cred caps ignore it (the +# broker forces an empty list for data_class=Credentials). +SMOKE_NAMESPACE="${SMOKE_TEST_NAMESPACE:-personal}" # Resolve the demo agent's actor_omni + device_key_hash. Prefer the # agent file (created by stage-1 step 12) so the cap binds to a real @@ -629,16 +633,20 @@ cred_memory_roundtrip() { agent_dkh=$(cast keccak "$(printf '%s' "$agent_addr" | tr '[:upper:]' '[:lower:]')") fi + # namespaces_allowed grants the smoke namespace (issue #108). The broker + # signs it into memory caps and ignores it for cred caps (forces []). local cap_body cap_body=$(jq -n \ --arg op "0x$OWN_ACTOR_OMNI" \ --arg actor "$agent_actor" \ --arg svc "$SMOKE_SERVICE" \ - --arg dkh "$agent_dkh" '{ + --arg dkh "$agent_dkh" \ + --arg ns "$SMOKE_NAMESPACE" '{ operator_omni: $op, actor_omni: $actor, service: $svc, - device_key_hash: $dkh + device_key_hash: $dkh, + namespaces_allowed: [$ns] }') # Mint Store cap @@ -677,7 +685,8 @@ EOF plaintext_b64=$(printf '%s' "$SMOKE_PLAINTEXT" | base64 | tr -d '\n') local store_body store_body=$(jq -n --argjson cap "$store_cap" --arg pt "$plaintext_b64" \ - '{cap: $cap, plaintext_b64: $pt}') + --arg ns "$SMOKE_NAMESPACE" \ + '{cap: $cap, plaintext_b64: $pt, namespace: $ns}') info "POST ${worker_url}${store_route} (with agent-side X-Aws-* headers)" rc=$(curl -sS -o /tmp/store.$$.json -w '%{http_code}' \ -X POST "${worker_url}${store_route}" \ @@ -704,7 +713,8 @@ EOF # GET plaintext back from worker (with the same agent-side STS creds) local fetch_body - fetch_body=$(jq -n --argjson cap "$fetch_cap" '{cap: $cap}') + fetch_body=$(jq -n --argjson cap "$fetch_cap" --arg ns "$SMOKE_NAMESPACE" \ + '{cap: $cap, namespace: $ns}') info "POST ${worker_url}${fetch_route} (with agent-side X-Aws-* headers)" rc=$(curl -sS -o /tmp/fetch.$$.json -w '%{http_code}' \ -X POST "${worker_url}${fetch_route}" \ @@ -726,6 +736,37 @@ EOF else die "$kind roundtrip FAILED: expected '$SMOKE_PLAINTEXT', got '$fetched'" fi + + # NEGATIVE (issue #108): the fetch cap grants only $SMOKE_NAMESPACE. A GET + # for a DIFFERENT namespace MUST return an empty result + namespace_violation + # (the worker filters by the cap's signed namespaces_allowed). Memory only — + # cred caps carry no namespaces. + if [ "$kind" = "memory" ]; then + local violation_ns="travel" + [ "$SMOKE_NAMESPACE" = "travel" ] && violation_ns="personal" + local neg_body neg_rc neg_resp + neg_body=$(jq -n --argjson cap "$fetch_cap" --arg ns "$violation_ns" \ + '{cap: $cap, namespace: $ns}') + info "POST ${worker_url}${fetch_route} (cross-namespace probe: cap grants '$SMOKE_NAMESPACE', requesting '$violation_ns')" + neg_rc=$(curl -sS -o /tmp/negns.$$.json -w '%{http_code}' \ + -X POST "${worker_url}${fetch_route}" \ + -H 'content-type: application/json' \ + -H "x-aws-access-key-id: $aki" \ + -H "x-aws-secret-access-key: $sak" \ + -H "x-aws-session-token: $sst" \ + -d "$neg_body" 2>&1 || echo "000") + neg_resp=$(cat /tmp/negns.$$.json 2>/dev/null || true); rm -f /tmp/negns.$$.json + if [ "$neg_rc" != "200" ]; then + die "cross-namespace GET returned HTTP $neg_rc (expected 200 empty-violation) — body: $neg_resp" + fi + if [ "$(echo "$neg_resp" | jq -r '.namespace_violation // false')" = "true" ] \ + && [ -z "$(echo "$neg_resp" | jq -r '.plaintext_b64 // empty')" ]; then + ok "namespace gate: '$violation_ns' read with a '$SMOKE_NAMESPACE'-only cap → empty + namespace_violation ✓" + record_ok "memory worker namespace gate denies cross-namespace read (issue #108)" + else + die "namespace gate FAILED: cross-namespace GET should be empty + namespace_violation, got: $neg_resp" + fi + fi } if should_run_step 11; then @@ -825,9 +866,12 @@ post_cross_class() { local aki="$4" sak="$5" sst="$6" local plaintext_b64 plaintext_b64=$(printf 'cross-class probe' | base64 | tr -d '\n') + # Include a valid namespace so the memory worker reaches the data_class + # guard (issue #108: missing namespace would 400 at deserialization, + # before verify_cap fires). Harmless for the cred worker (ignored). local body body=$(jq -n --argjson cap "$cap_blob" --arg pt "$plaintext_b64" \ - '{cap: $cap, plaintext_b64: $pt}') + '{cap: $cap, plaintext_b64: $pt, namespace: "personal"}') rc=$(curl -sS -o "$out_file" -w '%{http_code}' \ -X POST "$worker_route" \ -H 'content-type: application/json' \ diff --git a/scripts/mcp-demo-mode-e-stdio.sh b/scripts/mcp-demo-mode-e-stdio.sh index 304408d5..a15f8b93 100755 --- a/scripts/mcp-demo-mode-e-stdio.sh +++ b/scripts/mcp-demo-mode-e-stdio.sh @@ -110,10 +110,12 @@ async def main(): # — Memory round-trip with unicode (regression test for the # '有痛风' test the user ran live during issue #107) — + # Uses the `personal` namespace (a v0 namespace per issue + # #108; `profile` is a memory TYPE, not a namespace). await session.call_tool("agentkeys.memory.put", { - "namespace": "profile", "content": "有痛风 — gout, no shellfish" + "namespace": "personal", "content": "有痛风 — gout, no shellfish" }) - res = await session.call_tool("agentkeys.memory.get", {"namespace": "profile"}) + res = await session.call_tool("agentkeys.memory.get", {"namespace": "personal"}) assert "有痛风" in res.content[0].text, res.content[0].text assert "gout" in res.content[0].text print(" ✓ Memory round-trip — Chinese + English unicode preserved through put→get")