diff --git a/crates/agentkeys-backend-client/src/client.rs b/crates/agentkeys-backend-client/src/client.rs index 318ed0bf..5a8303fd 100644 --- a/crates/agentkeys-backend-client/src/client.rs +++ b/crates/agentkeys-backend-client/src/client.rs @@ -13,8 +13,9 @@ use reqwest::Client; use crate::protocol::{ AuditAppendInput, AuditAppendResult, AuditAppendV2, AuditAppendV2Resp, BrokerCapRequest, CapMintOp, CapMintRequest, CapToken, CredFetchBody, CredFetchInput, CredFetchResp, - CredFetchResult, MemoryGetBody, MemoryGetInput, MemoryGetResp, MemoryGetResult, MemoryPutBody, - MemoryPutInput, MemoryPutResp, MemoryPutResult, RevokeResult, ENVELOPE_VERSION, + CredFetchResult, CredStoreBody, CredStoreInput, CredStoreResp, CredStoreResult, MemoryGetBody, + MemoryGetInput, MemoryGetResp, MemoryGetResult, MemoryPutBody, MemoryPutInput, MemoryPutResp, + MemoryPutResult, RevokeResult, ENVELOPE_VERSION, }; #[derive(thiserror::Error, Debug)] @@ -40,8 +41,8 @@ pub struct BackendClient { pub broker_url: Option, pub memory_url: Option, pub audit_url: Option, - /// Cred worker base URL (#216 agent-side vaulted-key fetch). `None` → no - /// cred-fetch available. + /// Cred worker base URL — backs `/v1/cred/{store,fetch}` (the + /// `agentkeys.cred.*` tools). `None` → cred store/fetch unavailable. pub cred_url: Option, /// Agent session JWT (omni == the actor). Used to mint per-actor STS creds /// for the worker S3 relay (issue #90). `None` → no relay (worker falls @@ -278,6 +279,42 @@ impl BackendClient { }) } + /// `POST /v1/cred/store` — encrypt + store a credential's plaintext. The + /// `cap` (a cred-store cap with the `service` signed inside) is minted + /// separately via [`Self::cap_mint`]; this forwards per-actor STS creds + /// under the VAULT role so the cred worker's S3 PUT is scoped to + /// `bots//credentials/.enc`. + pub async fn cred_store(&self, input: CredStoreInput) -> Result { + let url = format!("{}/v1/cred/store", self.cred()?); + let mut req = self.client.post(&url).json(&CredStoreBody { + cap: input.cap, + plaintext_b64: input.plaintext_b64, + }); + if let Some(headers) = self.sts_headers(self.vault_role_arn.as_ref()).await? { + for (k, v) in headers { + req = req.header(k, v); + } + } + let resp = req + .send() + .await + .map_err(|e| BackendError::Transport(e.to_string()))?; + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(BackendError::Http { status, body }); + } + let parsed: CredStoreResp = resp + .json() + .await + .map_err(|e| BackendError::Parse(e.to_string()))?; + Ok(CredStoreResult { + ok: parsed.ok, + s3_key: parsed.s3_key, + envelope_size: parsed.envelope_size, + }) + } + /// `POST /v1/cred/fetch` — fetch + decrypt a stored credential's plaintext /// (#216 agent-side vaulted-key fetch). The `cap` (a cred-fetch cap with the /// `service` signed inside) is minted separately via [`Self::cap_mint`]; this diff --git a/crates/agentkeys-backend-client/src/fixtures.rs b/crates/agentkeys-backend-client/src/fixtures.rs index 18ea0044..1c2c64e5 100644 --- a/crates/agentkeys-backend-client/src/fixtures.rs +++ b/crates/agentkeys-backend-client/src/fixtures.rs @@ -18,8 +18,8 @@ use serde_json::{json, Value}; use crate::protocol::{ - AuditAppendV2, BrokerCapRequest, ConfigGetBody, ConfigPutBody, MemoryGetBody, MemoryPutBody, - ENVELOPE_VERSION, + AuditAppendV2, BrokerCapRequest, ConfigGetBody, ConfigPutBody, CredFetchBody, CredStoreBody, + MemoryGetBody, MemoryPutBody, ENVELOPE_VERSION, }; /// One canonical fixture: the on-disk file stem + the sample body. @@ -55,6 +55,13 @@ pub fn canonical_fixtures() -> Vec { let config_get = ConfigGetBody { cap: json!(""), }; + let cred_store = CredStoreBody { + cap: json!(""), + plaintext_b64: "".into(), + }; + let cred_fetch = CredFetchBody { + cap: json!(""), + }; let audit = AuditAppendV2 { version: ENVELOPE_VERSION, ts_unix: 0, @@ -87,6 +94,14 @@ pub fn canonical_fixtures() -> Vec { name: "config_get_body", body: serde_json::to_value(&config_get).expect("config_get serializes"), }, + Fixture { + name: "cred_store_body", + body: serde_json::to_value(&cred_store).expect("cred_store serializes"), + }, + Fixture { + name: "cred_fetch_body", + body: serde_json::to_value(&cred_fetch).expect("cred_fetch serializes"), + }, Fixture { name: "audit_append_v2", body: serde_json::to_value(&audit).expect("audit serializes"), @@ -157,6 +172,16 @@ mod tests { assert_eq!(keys_of("config_get_body"), vec!["cap"]); } + #[test] + fn cred_store_body_keys_frozen() { + assert_eq!(keys_of("cred_store_body"), vec!["cap", "plaintext_b64"]); + } + + #[test] + fn cred_fetch_body_keys_frozen() { + assert_eq!(keys_of("cred_fetch_body"), vec!["cap"]); + } + #[test] fn audit_append_v2_keys_frozen() { assert_eq!( diff --git a/crates/agentkeys-backend-client/src/lib.rs b/crates/agentkeys-backend-client/src/lib.rs index f9e5ab01..2929719d 100644 --- a/crates/agentkeys-backend-client/src/lib.rs +++ b/crates/agentkeys-backend-client/src/lib.rs @@ -20,6 +20,7 @@ pub use protocol::{ normalize_omni_0x, service_memory, AuditAppendInput, AuditAppendResult, AuditAppendV2, AuditAppendV2Resp, BrokerCapRequest, CapMintOp, CapMintRequest, CapToken, ConfigGetBody, ConfigGetResp, ConfigPutBody, CredFetchBody, CredFetchInput, CredFetchResp, CredFetchResult, - MemoryGetBody, MemoryGetInput, MemoryGetResp, MemoryGetResult, MemoryPutBody, MemoryPutInput, - MemoryPutResp, MemoryPutResult, RevokeResult, ENVELOPE_VERSION, + CredStoreBody, CredStoreInput, CredStoreResp, CredStoreResult, MemoryGetBody, MemoryGetInput, + MemoryGetResp, MemoryGetResult, MemoryPutBody, MemoryPutInput, MemoryPutResp, MemoryPutResult, + RevokeResult, ENVELOPE_VERSION, }; diff --git a/crates/agentkeys-backend-client/src/protocol.rs b/crates/agentkeys-backend-client/src/protocol.rs index 19006237..83f715ea 100644 --- a/crates/agentkeys-backend-client/src/protocol.rs +++ b/crates/agentkeys-backend-client/src/protocol.rs @@ -173,7 +173,23 @@ pub struct ConfigGetResp { pub plaintext_b64: String, } -// ── cred worker (`/v1/cred/fetch`) — #216 agent-side vaulted-key fetch ──────── +// ── cred worker (`/v1/cred/{store,fetch}`) — agent-side vaulted-key ops ────── + +/// Cred-worker `/v1/cred/store` request body. Mirrors +/// `agentkeys_worker_creds::handlers::StoreRequest` — the credential `service` +/// rides INSIDE the cap payload (it can't be spoofed at the body level). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CredStoreBody { + pub cap: CapToken, + pub plaintext_b64: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct CredStoreResp { + pub ok: bool, + pub s3_key: String, + pub envelope_size: usize, +} /// Cred-worker `/v1/cred/fetch` request body. Mirrors /// `agentkeys_worker_creds::handlers::FetchRequest` — just the signed cap; the @@ -249,6 +265,19 @@ pub struct MemoryGetResult { pub namespace: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CredStoreInput { + pub cap: CapToken, + pub plaintext_b64: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CredStoreResult { + pub ok: bool, + pub s3_key: String, + pub envelope_size: usize, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CredFetchInput { pub cap: CapToken, diff --git a/crates/agentkeys-cli/src/hook.rs b/crates/agentkeys-cli/src/hook.rs index b8ea72ae..f00e5591 100644 --- a/crates/agentkeys-cli/src/hook.rs +++ b/crates/agentkeys-cli/src/hook.rs @@ -359,6 +359,54 @@ pub async fn memory_put( Ok(result.to_string()) } +/// `agentkeys cred store --service --content ` — write an +/// agent-owned credential via `agentkeys.cred.store`. The MCP tool mints a +/// cred-store cap as the agent itself (`operator_omni == actor_omni`), then +/// sends the plaintext to the cred worker through the vault-role STS relay. +pub async fn cred_store( + service: &str, + content: &str, + mcp_url: Option, + vendor_token: Option, + actor: Option, +) -> Result { + let client = HookClient::resolve(mcp_url, vendor_token, actor, None); + let mut args = json!({"service": service, "content": content}); + if !client.actor.is_empty() { + args["actor"] = json!(client.actor); + } + let result = client + .call_tool("agentkeys.cred.store", args) + .await + .context("cred.store")?; + Ok(result.to_string()) +} + +/// `agentkeys cred fetch --service ` — fetch an agent-owned credential via +/// `agentkeys.cred.fetch`. Plaintext is returned to stdout to support shell +/// roundtrip checks; callers should avoid logging real secrets. +pub async fn cred_fetch( + service: &str, + mcp_url: Option, + vendor_token: Option, + actor: Option, +) -> Result { + let client = HookClient::resolve(mcp_url, vendor_token, actor, None); + let mut args = json!({"service": service}); + if !client.actor.is_empty() { + args["actor"] = json!(client.actor); + } + let result = client + .call_tool("agentkeys.cred.fetch", args) + .await + .context("cred.fetch")?; + if let Some(content) = result.get("content").and_then(|v| v.as_str()) { + Ok(content.to_string()) + } else { + Ok(result.to_string()) + } +} + /// Extract the `content` field of an `agentkeys.memory.get` result. The /// MCP tool layer already base64-decodes the worker's `plaintext_b64` /// into a UTF-8 `content` string (see diff --git a/crates/agentkeys-cli/src/main.rs b/crates/agentkeys-cli/src/main.rs index cb0f3839..249f0bac 100644 --- a/crates/agentkeys-cli/src/main.rs +++ b/crates/agentkeys-cli/src/main.rs @@ -387,6 +387,14 @@ enum Commands { #[command(subcommand)] action: MemoryAction, }, + #[command( + about = "Agent-owned credential helpers (real cred worker)", + long_about = "Direct agent-owned credential operations against the AgentKeys MCP server. `store` writes a service credential using the sandbox-held agent session; `fetch` reads it back. This is separate from the legacy master-side `store/read` commands." + )] + Cred { + #[command(subcommand)] + action: CredAction, + }, /// Agent-side device bootstrap (interim §10.2 — full ceremony: issue #144). Agent { #[command(subcommand)] @@ -750,6 +758,39 @@ enum MemoryAction { }, } +#[derive(Subcommand)] +enum CredAction { + /// Store this agent's own service credential through the real cred worker. + #[command(about = "Store an agent-owned service credential via agentkeys.cred.store")] + Store { + /// Service name to store (e.g. `openrouter`). + #[arg(long)] + service: String, + /// Plaintext credential value to store. + #[arg(long)] + content: String, + #[arg(long, env = "AGENTKEYS_MCP_URL")] + mcp_url: Option, + #[arg(long, env = "AGENTKEYS_MCP_VENDOR_TOKEN")] + vendor_token: Option, + #[arg(long, env = "AGENTKEYS_ACTOR_OMNI")] + actor: Option, + }, + /// Fetch this agent's own service credential through the real cred worker. + #[command(about = "Fetch an agent-owned service credential via agentkeys.cred.fetch")] + Fetch { + /// Service name to fetch (e.g. `openrouter`). + #[arg(long)] + service: String, + #[arg(long, env = "AGENTKEYS_MCP_URL")] + mcp_url: Option, + #[arg(long, env = "AGENTKEYS_MCP_VENDOR_TOKEN")] + vendor_token: Option, + #[arg(long, env = "AGENTKEYS_ACTOR_OMNI")] + actor: Option, + }, +} + #[derive(Subcommand)] enum AgentAction { /// Generate (or reuse) THIS machine's secp256k1 device key, mint a broker @@ -1355,6 +1396,38 @@ async fn main() { .await } }, + Commands::Cred { action } => match action { + CredAction::Store { + service, + content, + mcp_url, + vendor_token, + actor, + } => { + agentkeys_cli::hook::cred_store( + service, + content, + mcp_url.clone(), + vendor_token.clone(), + actor.clone(), + ) + .await + } + CredAction::Fetch { + service, + mcp_url, + vendor_token, + actor, + } => { + agentkeys_cli::hook::cred_fetch( + service, + mcp_url.clone(), + vendor_token.clone(), + actor.clone(), + ) + .await + } + }, Commands::Agent { action } => match action { AgentAction::DeviceSession { broker_url, diff --git a/crates/agentkeys-mcp-server/src/backend/mod.rs b/crates/agentkeys-mcp-server/src/backend/mod.rs index 0f09fb11..d4b4497b 100644 --- a/crates/agentkeys-mcp-server/src/backend/mod.rs +++ b/crates/agentkeys-mcp-server/src/backend/mod.rs @@ -24,7 +24,8 @@ use agentkeys_backend_client::BackendClient; // existing `crate::backend::CapMintOp` / `BackendError` / … paths keep working. pub use agentkeys_backend_client::{ AuditAppendInput, AuditAppendResult, BackendError, CapMintOp, CapMintRequest, CapToken, - MemoryGetInput, MemoryGetResult, MemoryPutInput, MemoryPutResult, RevokeResult, + CredFetchInput, CredFetchResult, CredStoreInput, CredStoreResult, MemoryGetInput, + MemoryGetResult, MemoryPutInput, MemoryPutResult, RevokeResult, }; #[async_trait] @@ -42,6 +43,10 @@ pub trait Backend: Send + Sync { async fn memory_get(&self, input: MemoryGetInput) -> Result; + async fn cred_store(&self, input: CredStoreInput) -> Result; + + async fn cred_fetch(&self, input: CredFetchInput) -> Result; + async fn audit_append( &self, input: AuditAppendInput, @@ -81,6 +86,14 @@ impl Backend for BackendClient { self.memory_get(input).await } + async fn cred_store(&self, input: CredStoreInput) -> Result { + self.cred_store(input).await + } + + async fn cred_fetch(&self, input: CredFetchInput) -> Result { + self.cred_fetch(input).await + } + async fn audit_append( &self, input: AuditAppendInput, diff --git a/crates/agentkeys-mcp-server/src/config.rs b/crates/agentkeys-mcp-server/src/config.rs index cfa9989b..d095d84d 100644 --- a/crates/agentkeys-mcp-server/src/config.rs +++ b/crates/agentkeys-mcp-server/src/config.rs @@ -53,6 +53,11 @@ pub struct Cli { #[arg(long, env = "AGENTKEYS_AUDIT_URL")] pub audit_url: Option, + /// Credentials worker base URL — backs `agentkeys.cred.{store,fetch}`. + /// `None` → those tools fail with `NotConfigured("cred_url")`. + #[arg(long, env = "AGENTKEYS_CRED_URL")] + pub cred_url: Option, + /// Comma-separated `:` pairs that the HTTP /// transport will accept. Empty = HTTP refuses every request with 401. /// Format intentionally simple — vendor onboarding portal in M2 will @@ -122,6 +127,7 @@ pub struct Config { pub broker_url: Option, pub memory_url: Option, pub audit_url: Option, + pub cred_url: Option, /// vendor_id → bearer_token pub vendor_tokens: HashMap, pub default_daily_spend_cap_rmb: u64, @@ -178,7 +184,7 @@ impl Config { "in-memory" | "in_memory" => anyhow::bail!( "the in-memory backend was removed (real-data-only). The MCP server \ only supports `--backend http` — point it at a real broker + workers \ - via --broker-url / --memory-url / --audit-url." + via --broker-url / --memory-url / --audit-url / --cred-url." ), other => anyhow::bail!("unknown backend `{other}` (expected http)"), }; @@ -232,6 +238,7 @@ impl Config { broker_url: cli.broker_url, memory_url: cli.memory_url, audit_url: cli.audit_url, + cred_url: cli.cred_url, vendor_tokens, default_daily_spend_cap_rmb: cli.default_daily_spend_cap_rmb, default_actor, @@ -254,6 +261,7 @@ impl Config { broker_url: None, memory_url: None, audit_url: None, + cred_url: None, vendor_tokens: HashMap::new(), default_daily_spend_cap_rmb: 500, default_actor: None, diff --git a/crates/agentkeys-mcp-server/src/main.rs b/crates/agentkeys-mcp-server/src/main.rs index bf3603e1..b4ddf39c 100644 --- a/crates/agentkeys-mcp-server/src/main.rs +++ b/crates/agentkeys-mcp-server/src/main.rs @@ -11,6 +11,25 @@ use agentkeys_mcp_server::{ transport, }; +/// Build the real HTTP backend from config. Extracted so a unit test can assert +/// every worker URL is plumbed through — `cred_url` regressed to a hardcoded +/// `None` once (PR #228 review): the server advertised `agentkeys.cred.{store, +/// fetch}` while the client had no cred URL, so every real call failed with +/// `NotConfigured("cred_url")` before reaching the worker. A `MockBackend` test +/// can't catch that; only asserting the wiring here can. +fn build_http_backend(config: &Config) -> BackendClient { + BackendClient::new( + config.broker_url.clone(), + config.memory_url.clone(), + config.audit_url.clone(), + config.cred_url.clone(), + config.agent_session_bearer.clone(), + config.memory_role_arn.clone(), + config.vault_role_arn.clone(), + config.aws_region.clone(), + ) +} + #[tokio::main] async fn main() -> anyhow::Result<()> { // rustls 0.23 requires a process-level CryptoProvider. tokio-tungstenite @@ -38,16 +57,7 @@ async fn main() -> anyhow::Result<()> { // `HttpBackend` delegate; `Backend` is impl'd directly on `BackendClient`). // The in-memory fixture backend was removed. let backend: Arc = match config.backend { - BackendKind::Http => Arc::new(BackendClient::new( - config.broker_url.clone(), - config.memory_url.clone(), - config.audit_url.clone(), - None, // cred_url — no MCP cred tool yet; #216 cred-fetch is the CLI path - config.agent_session_bearer.clone(), - config.memory_role_arn.clone(), - config.vault_role_arn.clone(), - config.aws_region.clone(), - )), + BackendKind::Http => Arc::new(build_http_backend(&config)), }; let server = Arc::new(Server::new(config.clone(), backend)); @@ -80,3 +90,31 @@ async fn main() -> anyhow::Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn http_backend_plumbs_every_worker_url_from_config() { + let mut config = Config::for_tests(); + config.broker_url = Some("https://broker.example".into()); + config.memory_url = Some("https://memory.example".into()); + config.audit_url = Some("https://audit.example".into()); + config.cred_url = Some("https://cred.example".into()); + + let backend = build_http_backend(&config); + + assert_eq!( + backend.broker_url.as_deref(), + Some("https://broker.example") + ); + assert_eq!( + backend.memory_url.as_deref(), + Some("https://memory.example") + ); + assert_eq!(backend.audit_url.as_deref(), Some("https://audit.example")); + // Regression guard: cred_url must flow from config, never a hardcoded None. + assert_eq!(backend.cred_url.as_deref(), Some("https://cred.example")); + } +} diff --git a/crates/agentkeys-mcp-server/src/server.rs b/crates/agentkeys-mcp-server/src/server.rs index 4c5bd7ed..ee77fd45 100644 --- a/crates/agentkeys-mcp-server/src/server.rs +++ b/crates/agentkeys-mcp-server/src/server.rs @@ -176,6 +176,26 @@ impl Server { ) .await } + tools::TOOL_CRED_STORE => { + tools::cred::store( + caller, + self.backend.clone(), + &self.config, + session_bearer, + &args, + ) + .await + } + tools::TOOL_CRED_FETCH => { + tools::cred::fetch( + caller, + self.backend.clone(), + &self.config, + session_bearer, + &args, + ) + .await + } tools::TOOL_AUDIT_APPEND => { tools::audit::call(caller, self.backend.clone(), &args).await } diff --git a/crates/agentkeys-mcp-server/src/tools/cred.rs b/crates/agentkeys-mcp-server/src/tools/cred.rs new file mode 100644 index 00000000..1b322dbf --- /dev/null +++ b/crates/agentkeys-mcp-server/src/tools/cred.rs @@ -0,0 +1,172 @@ +//! `agentkeys.cred.store` + `agentkeys.cred.fetch` — agent-owned vaulted +//! credential access. Internally: mint a credentials cap -> call the cred +//! worker with the vault-role STS relay. + +use base64::Engine; +use serde_json::{json, Value}; +use std::sync::Arc; + +use crate::auth::CallerContext; +use crate::backend::{Backend, CapMintOp, CapMintRequest, CredFetchInput, CredStoreInput}; +use crate::config::Config; +use crate::errors::{McpError, McpResult}; + +const DEFAULT_TTL_SECONDS: u64 = 300; + +fn resolve_ident<'a>( + params: &'a Value, + key: &str, + fallback: Option<&'a str>, +) -> McpResult<&'a str> { + params + .get(key) + .and_then(|v| v.as_str()) + .or(fallback) + .ok_or_else(|| { + McpError::InvalidParams(format!( + "missing `{key}` and no MCP_DEFAULT_{} configured \ + — set it in /etc/agentkeys/mcp.env or pass via --{}", + key.to_uppercase(), + key.replace('_', "-") + )) + }) +} + +fn resolve_actor<'a>(params: &'a Value, config: &'a Config) -> McpResult<&'a str> { + resolve_ident(params, "actor", config.default_actor.as_deref()) +} + +fn resolve_operator<'a>(params: &'a Value, actor: &'a str) -> &'a str { + params + .get("operator_omni") + .and_then(|v| v.as_str()) + .unwrap_or(actor) +} + +fn resolve_common<'a>( + caller: &CallerContext, + config: &'a Config, + params: &'a Value, +) -> McpResult<(&'a str, &'a str, &'a str, &'a str, u64)> { + let actor = resolve_actor(params, config)?; + if caller.actor_omni != "*" { + crate::auth::check_actor_param(&caller.actor_omni, actor)?; + } + let operator_omni = resolve_operator(params, actor); + let service = params + .get("service") + .and_then(|v| v.as_str()) + .ok_or_else(|| McpError::InvalidParams("missing `service`".into()))?; + let device_key_hash = resolve_ident( + params, + "device_key_hash", + config.default_device_key_hash.as_deref(), + )?; + let ttl_seconds = params + .get("ttl_seconds") + .and_then(|v| v.as_u64()) + .unwrap_or(DEFAULT_TTL_SECONDS); + Ok((actor, operator_omni, service, device_key_hash, ttl_seconds)) +} + +pub async fn store( + caller: &CallerContext, + backend: Arc, + config: &Config, + session_bearer: &str, + params: &Value, +) -> McpResult { + let (actor, operator_omni, service, device_key_hash, ttl_seconds) = + resolve_common(caller, config, params)?; + let content = params + .get("content") + .and_then(|v| v.as_str()) + .ok_or_else(|| McpError::InvalidParams("missing `content`".into()))?; + + let cap = backend + .cap_mint( + CapMintOp::CredStore, + CapMintRequest { + operator_omni: operator_omni.to_string(), + actor_omni: actor.to_string(), + service: service.to_string(), + device_key_hash: device_key_hash.to_string(), + ttl_seconds, + }, + session_bearer, + ) + .await + .map_err(|e| McpError::Backend(format!("cap_mint failed: {e}")))?; + + let plaintext_b64 = base64::engine::general_purpose::STANDARD.encode(content.as_bytes()); + let result = backend + .cred_store(CredStoreInput { cap, plaintext_b64 }) + .await + .map_err(|e| McpError::Backend(format!("cred_store failed: {e}")))?; + + tracing::info!( + op = "cred.store", + actor = %actor, + service = %service, + bytes = content.len(), + s3_key = %result.s3_key, + "credential write" + ); + + Ok(json!({ + "ok": result.ok, + "service": service, + "s3_key": result.s3_key, + "envelope_size": result.envelope_size, + })) +} + +pub async fn fetch( + caller: &CallerContext, + backend: Arc, + config: &Config, + session_bearer: &str, + params: &Value, +) -> McpResult { + let (actor, operator_omni, service, device_key_hash, ttl_seconds) = + resolve_common(caller, config, params)?; + + let cap = backend + .cap_mint( + CapMintOp::CredFetch, + CapMintRequest { + operator_omni: operator_omni.to_string(), + actor_omni: actor.to_string(), + service: service.to_string(), + device_key_hash: device_key_hash.to_string(), + ttl_seconds, + }, + session_bearer, + ) + .await + .map_err(|e| McpError::Backend(format!("cap_mint failed: {e}")))?; + + let result = backend + .cred_fetch(CredFetchInput { cap }) + .await + .map_err(|e| McpError::Backend(format!("cred_fetch failed: {e}")))?; + let plaintext = base64::engine::general_purpose::STANDARD + .decode(&result.plaintext_b64) + .map_err(|e| McpError::Internal(format!("plaintext_b64 decode: {e}")))?; + let content = String::from_utf8(plaintext) + .map_err(|e| McpError::Internal(format!("plaintext utf8: {e}")))?; + + tracing::info!( + op = "cred.fetch", + actor = %actor, + service = %service, + bytes = content.len(), + "credential read" + ); + + Ok(json!({ + "ok": result.ok, + "service": service, + "content": content, + })) +} diff --git a/crates/agentkeys-mcp-server/src/tools/mod.rs b/crates/agentkeys-mcp-server/src/tools/mod.rs index 911cb281..34eb807a 100644 --- a/crates/agentkeys-mcp-server/src/tools/mod.rs +++ b/crates/agentkeys-mcp-server/src/tools/mod.rs @@ -1,4 +1,5 @@ -//! Tool registry — the 7 active + 3 schema-only tools listed in issue #107. +//! Tool registry — active tools plus the M4 schema-only stubs listed in issue +//! #107. //! //! Tool naming follows the issue verbatim: dotted `agentkeys..`. //! Each handler returns a `Value` that gets wrapped in the MCP `tools/call` @@ -6,6 +7,7 @@ pub mod audit; pub mod cap; +pub mod cred; pub mod identity; pub mod memory; pub mod permission; @@ -17,6 +19,8 @@ use serde_json::json; pub const TOOL_IDENTITY_WHOAMI: &str = "agentkeys.identity.whoami"; pub const TOOL_MEMORY_GET: &str = "agentkeys.memory.get"; pub const TOOL_MEMORY_PUT: &str = "agentkeys.memory.put"; +pub const TOOL_CRED_FETCH: &str = "agentkeys.cred.fetch"; +pub const TOOL_CRED_STORE: &str = "agentkeys.cred.store"; pub const TOOL_PERMISSION_CHECK: &str = "agentkeys.permission.check"; pub const TOOL_CAP_MINT: &str = "agentkeys.cap.mint"; pub const TOOL_CAP_REVOKE: &str = "agentkeys.cap.revoke"; @@ -88,6 +92,33 @@ Group by topic via `namespace`.".into(), "required": ["namespace", "content"] }), }, + ToolDescriptor { + name: TOOL_CRED_STORE.into(), + description: "Store this agent's own service credential in the AgentKeys vault. \ +保存当前智能体自己的服务凭证。Use only for agent-owned credentials; the credential is scoped by actor identity and service.".into(), + input_schema: json!({ + "type": "object", + "properties": { + "service": {"type": "string", "description": "Service id, e.g. 'openrouter'."}, + "content": {"type": "string", "description": "Credential plaintext to store."}, + "actor": {"type": "string", "description": "Optional. Server uses configured default."} + }, + "required": ["service", "content"] + }), + }, + ToolDescriptor { + name: TOOL_CRED_FETCH.into(), + description: "Fetch this agent's own service credential from the AgentKeys vault. \ +读取当前智能体自己的服务凭证。Returns plaintext under `content` after worker-side cap verification.".into(), + input_schema: json!({ + "type": "object", + "properties": { + "service": {"type": "string", "description": "Service id, e.g. 'openrouter'."}, + "actor": {"type": "string", "description": "Optional. Server uses configured default."} + }, + "required": ["service"] + }), + }, ToolDescriptor { name: TOOL_PERMISSION_CHECK.into(), description: "ALWAYS use this tool BEFORE any action that spends money — orders, purchases, payments — to verify the amount is within the user's daily cap. \ diff --git a/crates/agentkeys-mcp-server/tests/common/mod.rs b/crates/agentkeys-mcp-server/tests/common/mod.rs index d0237117..f9ebe44e 100644 --- a/crates/agentkeys-mcp-server/tests/common/mod.rs +++ b/crates/agentkeys-mcp-server/tests/common/mod.rs @@ -8,7 +8,8 @@ use std::sync::Mutex; use agentkeys_mcp_server::backend::{ AuditAppendInput, AuditAppendResult, Backend, BackendError, CapMintOp, CapMintRequest, - CapToken, MemoryGetInput, MemoryGetResult, MemoryPutInput, MemoryPutResult, RevokeResult, + CapToken, CredFetchInput, CredFetchResult, CredStoreInput, CredStoreResult, MemoryGetInput, + MemoryGetResult, MemoryPutInput, MemoryPutResult, RevokeResult, }; #[derive(Default)] @@ -20,6 +21,8 @@ pub struct MockBackend { struct MockInner { /// (actor_omni, namespace) → plaintext memory: HashMap<(String, String), String>, + /// (actor_omni, service) → plaintext + credentials: HashMap<(String, String), String>, cap_mints: Vec<(CapMintOp, CapMintRequest)>, audit: Vec, revokes: Vec, @@ -147,6 +150,69 @@ impl Backend for MockBackend { }) } + async fn cred_store(&self, input: CredStoreInput) -> Result { + let payload = input.cap.get("payload").unwrap_or(&Value::Null); + let actor = payload + .get("actor_omni") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let service = payload + .get("service") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let plaintext = String::from_utf8( + base64::Engine::decode( + &base64::engine::general_purpose::STANDARD, + &input.plaintext_b64, + ) + .map_err(|e| BackendError::Parse(e.to_string()))?, + ) + .map_err(|e| BackendError::Parse(e.to_string()))?; + + let mut g = self.inner.lock().unwrap(); + g.credentials + .insert((actor.clone(), service.clone()), plaintext); + Ok(CredStoreResult { + ok: true, + s3_key: format!("bots/{actor}/credentials/{service}.enc"), + envelope_size: input.plaintext_b64.len(), + }) + } + + async fn cred_fetch(&self, input: CredFetchInput) -> Result { + let payload = input.cap.get("payload").unwrap_or(&Value::Null); + let actor = payload + .get("actor_omni") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let service = payload + .get("service") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + + let g = self.inner.lock().unwrap(); + let content = g + .credentials + .get(&(actor, service.clone())) + .cloned() + .ok_or_else(|| BackendError::Http { + status: 404, + body: format!("no credential for service `{service}`"), + })?; + + Ok(CredFetchResult { + ok: true, + plaintext_b64: base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + content.as_bytes(), + ), + }) + } + async fn audit_append( &self, input: AuditAppendInput, diff --git a/crates/agentkeys-mcp-server/tests/http_auth.rs b/crates/agentkeys-mcp-server/tests/http_auth.rs index 297823c0..56475d77 100644 --- a/crates/agentkeys-mcp-server/tests/http_auth.rs +++ b/crates/agentkeys-mcp-server/tests/http_auth.rs @@ -135,8 +135,8 @@ async fn tools_list_works_through_http() { let tools = body["result"]["tools"].as_array().expect("tools array"); assert_eq!( tools.len(), - 7, - "should expose 7 active tools (M4 schema-only stubs are dispatchable via tools/call but not advertised in tools/list — see tools/mod.rs)" + 9, + "should expose 9 active tools (M4 schema-only stubs are dispatchable via tools/call but not advertised in tools/list — see tools/mod.rs)" ); let names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect(); @@ -144,6 +144,8 @@ async fn tools_list_works_through_http() { "agentkeys.identity.whoami", "agentkeys.memory.get", "agentkeys.memory.put", + "agentkeys.cred.fetch", + "agentkeys.cred.store", "agentkeys.permission.check", "agentkeys.cap.mint", "agentkeys.cap.revoke", diff --git a/crates/agentkeys-mcp-server/tests/three_acts.rs b/crates/agentkeys-mcp-server/tests/three_acts.rs index 52a56ef0..92ed5b7c 100644 --- a/crates/agentkeys-mcp-server/tests/three_acts.rs +++ b/crates/agentkeys-mcp-server/tests/three_acts.rs @@ -199,6 +199,102 @@ async fn act_3_revoke_then_audit_append_records_event() { .starts_with("0x")); } +#[tokio::test] +async fn agent_owned_cred_store_then_fetch_roundtrips() { + let backend = Arc::new(MockBackend::new()); + let server = server_with(backend.clone()); + + let resp = server + .dispatch( + &caller(), + "agent-session-bearer", + call_tool( + "agentkeys.cred.store", + json!({ + "actor": ACTOR, + "service": "openrouter", + "content": "sk-agent-owned", + "device_key_hash": DEVICE_KEY_HASH + }), + ), + ) + .await; + assert!(resp.error.is_none(), "cred.store err: {:?}", resp.error); + + let resp = server + .dispatch( + &caller(), + "agent-session-bearer", + call_tool( + "agentkeys.cred.fetch", + json!({ + "actor": ACTOR, + "service": "openrouter", + "device_key_hash": DEVICE_KEY_HASH + }), + ), + ) + .await; + assert!(resp.error.is_none(), "cred.fetch err: {:?}", resp.error); + let inner = &resp.result.unwrap()["structuredContent"]; + assert_eq!(inner["service"], "openrouter"); + assert_eq!(inner["content"], "sk-agent-owned"); + + let mints = backend.cap_mints(); + assert!( + mints.iter().any(|(op, req)| { + matches!(op, agentkeys_mcp_server::backend::CapMintOp::CredStore) + && req.operator_omni == ACTOR + && req.actor_omni == ACTOR + && req.service == "openrouter" + }), + "expected self-owned CredStore cap mint, got {mints:?}" + ); + assert!( + mints.iter().any(|(op, req)| { + matches!(op, agentkeys_mcp_server::backend::CapMintOp::CredFetch) + && req.operator_omni == ACTOR + && req.actor_omni == ACTOR + && req.service == "openrouter" + }), + "expected self-owned CredFetch cap mint, got {mints:?}" + ); +} + +#[tokio::test] +async fn agent_owned_cred_store_rejects_cross_actor_param() { + let backend = Arc::new(MockBackend::new()); + let server = server_with(backend.clone()); + + // caller() authenticates as ACTOR; asking to store under a DIFFERENT actor + // must be refused by the MCP per-actor gate (check_actor_param) BEFORE any + // cap is minted — an agent can only write its OWN credentials/ prefix. + let resp = server + .dispatch( + &caller(), + "agent-session-bearer", + call_tool( + "agentkeys.cred.store", + json!({ + "actor": "O_mallory_999", + "service": "openrouter", + "content": "sk-not-yours", + "device_key_hash": DEVICE_KEY_HASH + }), + ), + ) + .await; + assert!( + resp.error.is_some(), + "cross-actor cred.store must be rejected, got ok" + ); + assert!( + backend.cap_mints().is_empty(), + "no cap may be minted for a rejected cross-actor cred.store, got {:?}", + backend.cap_mints() + ); +} + #[tokio::test] async fn cap_mint_memory_get_returns_cap_for_worker() { let backend = Arc::new(MockBackend::new()); diff --git a/docs/operator-runbook-harness.md b/docs/operator-runbook-harness.md index d54fb39b..a48eb7d0 100644 --- a/docs/operator-runbook-harness.md +++ b/docs/operator-runbook-harness.md @@ -50,7 +50,7 @@ and run the real-agent proof (it signs with the agent's **sandbox-held** key): ```bash # in the SANDBOX shell: -bash "$HOME/sandbox-agent-isolation.sh" # the REAL agent: the deferred roundtrip (steps 11-12 / 14-15), sandbox-held key +bash "$HOME/sandbox-agent-isolation.sh" # the REAL agent: deferred memory + credential roundtrips (steps 11-12 / 14-15), sandbox-held key ``` (If `v2-demo.sh` reported the wire phase **skipped — no aiosandbox**, the agent wasn't paired: diff --git a/harness/fixtures/backend-protocol/README.md b/harness/fixtures/backend-protocol/README.md index a5751282..e9e9d3f8 100644 --- a/harness/fixtures/backend-protocol/README.md +++ b/harness/fixtures/backend-protocol/README.md @@ -25,10 +25,13 @@ both the fixture `--check` (these files match the Rust types) and the bash gate | `memory_get_body.json` | `MemoryGetBody` | `POST /v1/memory/get` | | `config_put_body.json` | `ConfigPutBody` | `POST /v1/config/put` (#201) | | `config_get_body.json` | `ConfigGetBody` | `POST /v1/config/get` (#201) | +| `cred_store_body.json` | `CredStoreBody` | `POST /v1/cred/store` | +| `cred_fetch_body.json` | `CredFetchBody` | `POST /v1/cred/fetch` | | `audit_append_v2.json` | `AuditAppendV2` | `POST /v1/audit/append/v2` | -> **Gate note:** `config_put_body` (`{cap, plaintext_b64}`) and `config_get_body` -> (`{cap}`) are key-set-identical to the cred-worker store/fetch bodies, so the +> **Gate note:** `config_put_body` / `cred_store_body` (`{cap, plaintext_b64}`) +> and `config_get_body` / `cred_fetch_body` (`{cap}`) share key sets, so the > `check-backend-fixture-drift.sh` **pass-2** auto-detector deliberately excludes -> them (it would false-positive on every cred `{cap}` body). Config bodies are -> gated via **explicit `# @backend-fixture: config_*` annotation** (pass 1) only. +> config bodies (it would false-positive on every cred `{cap}` body). Config bodies +> are gated via **explicit `# @backend-fixture: config_*` annotation** (pass 1) +> only. diff --git a/harness/fixtures/backend-protocol/cred_fetch_body.json b/harness/fixtures/backend-protocol/cred_fetch_body.json new file mode 100644 index 00000000..9abe1431 --- /dev/null +++ b/harness/fixtures/backend-protocol/cred_fetch_body.json @@ -0,0 +1,3 @@ +{ + "cap": "" +} diff --git a/harness/fixtures/backend-protocol/cred_store_body.json b/harness/fixtures/backend-protocol/cred_store_body.json new file mode 100644 index 00000000..3ccad522 --- /dev/null +++ b/harness/fixtures/backend-protocol/cred_store_body.json @@ -0,0 +1,4 @@ +{ + "cap": "", + "plaintext_b64": "" +} diff --git a/harness/phase1-wire-demo.sh b/harness/phase1-wire-demo.sh index 54bd1bb5..e52cc5d9 100755 --- a/harness/phase1-wire-demo.sh +++ b/harness/phase1-wire-demo.sh @@ -725,7 +725,7 @@ phase1_sandbox() { mcp_session_arg="--agent-session-bearer-file $reuse_sf" fi [[ -n "$mcp_session_arg" ]] && mcp_relayarg="$mcp_session_arg --memory-role-arn ${MEMORY_ROLE_ARN:-} --vault-role-arn ${VAULT_ROLE_ARN:-} --aws-region ${REGION:-us-east-1}" - cmd="$MCP_BIN_DST --backend http --transport http --listen 127.0.0.1:$MCP_PORT --vendor-tokens $mcp_vendor --broker-url ${BROKER_URL:-} --memory-url ${AGENTKEYS_WORKER_MEMORY_URL:-} --audit-url ${AGENTKEYS_WORKER_AUDIT_URL:-} --default-actor $ACTOR_OMNI --default-operator-omni $OPERATOR_OMNI --default-device-key-hash $DEVICE_KEY_HASH $mcp_relayarg" + cmd="$MCP_BIN_DST --backend http --transport http --listen 127.0.0.1:$MCP_PORT --vendor-tokens $mcp_vendor --broker-url ${BROKER_URL:-} --memory-url ${AGENTKEYS_WORKER_MEMORY_URL:-} --audit-url ${AGENTKEYS_WORKER_AUDIT_URL:-} --cred-url ${AGENTKEYS_WORKER_CRED_URL:-} --default-actor $ACTOR_OMNI --default-operator-omni $OPERATOR_OMNI --default-device-key-hash $DEVICE_KEY_HASH $mcp_relayarg" # Reuse only if a live server's argv carries the intended backend + token AND # the intended --broker-url — else a stale server pointed at the wrong broker # (e.g. the signer) is silently reused. With the STS relay, never reuse: the diff --git a/harness/scripts/sandbox-agent-isolation.sh b/harness/scripts/sandbox-agent-isolation.sh index cb2bed45..fa45fe67 100755 --- a/harness/scripts/sandbox-agent-isolation.sh +++ b/harness/scripts/sandbox-agent-isolation.sh @@ -3,9 +3,10 @@ # # The REAL §10.2 agent proof that stage-3 step 11/12 cannot do from the master: the # agent's OWN binary, signing with the device key that LIVES IN THE SANDBOX (never -# on the master), does a live memory roundtrip — cap-mint → STS (SIWE as the AGENT) -# → memory worker → S3 bots//memory/. The stage-3 MOCK uses a master-held -# key (worker plumbing only); THIS uses the genuine sandbox-held key (the real agent). +# on the master), does live memory + credential roundtrips — cap-mint → STS +# (SIWE as the AGENT) → worker → S3 bots//{memory,credentials}/. The +# stage-3 MOCK uses a master-held key (worker plumbing only); THIS uses the +# genuine sandbox-held key (the real agent). # # Prereqs — set up by `bash harness/phase1-wire-demo.sh --real` (run on the operator # host first): the `agentkeys` binary + the §10.2-paired agent device-session + the @@ -20,8 +21,44 @@ AGENT_BIN="${AGENT_BIN:-$(command -v agentkeys 2>/dev/null || echo "$HOME/.local [ -x "$AGENT_BIN" ] || { echo "FAIL: no agentkeys binary in the sandbox ($AGENT_BIN) — run 'phase1-wire-demo.sh --real' on the operator host first." >&2; exit 1; } content="sandbox-isolation-proof-$$-$(date +%s 2>/dev/null || echo n)" +cred_service="${SANDBOX_CRED_SERVICE:-sandbox-isolation-proof}" +cred_secret="sandbox-cred-proof-$$-$(date +%s 2>/dev/null || echo n)" echo "== §10.2 agent isolation — the agent signs with its SANDBOX-held key (not the master) ==" >&2 +# Resolve the agent's session JWT and export AGENTKEYS_SESSION_BEARER BEFORE +# either roundtrip. BOTH the memory and credential paths cap-mint AS the agent +# (operator_omni == actor_omni), and the broker validates the bearer's +# `agentkeys.omni_account` claim against operator_omni — so a missing bearer +# fails the FIRST (memory) roundtrip, not just cred. The CLI forwards this env +# var to the in-sandbox MCP server as the x-agentkeys-session-bearer header. +# Already set in the environment? keep it; otherwise load the paired session file. +agent_session_file="" +if [ -n "${SANDBOX_AGENT_SESSION_FILE:-}" ] && [ -r "$SANDBOX_AGENT_SESSION_FILE" ]; then + agent_session_file="$SANDBOX_AGENT_SESSION_FILE" +elif [ -n "${AGENTKEYS_ACTOR_OMNI:-}" ]; then + actor_no0x="${AGENTKEYS_ACTOR_OMNI#0x}" + for candidate in "$HOME/.agentkeys/agent-session-$actor_no0x.jwt" "$HOME/.agentkeys/agent-session-$AGENTKEYS_ACTOR_OMNI.jwt"; do + if [ -r "$candidate" ]; then + agent_session_file="$candidate" + break + fi + done +fi +if [ -z "$agent_session_file" ] && [ -z "${AGENTKEYS_SESSION_BEARER:-}" ]; then + set -- "$HOME"/.agentkeys/agent-session-*.jwt + if [ "$#" -eq 1 ] && [ -r "$1" ]; then + agent_session_file="$1" + fi +fi +if [ -n "$agent_session_file" ]; then + AGENTKEYS_SESSION_BEARER="$(tr -d '\r\n' < "$agent_session_file")" + export AGENTKEYS_SESSION_BEARER +fi +if [ -z "${AGENTKEYS_SESSION_BEARER:-}" ]; then + echo "FAIL: no agent session bearer for self-owned cap-mint. Set AGENTKEYS_SESSION_BEARER or SANDBOX_AGENT_SESSION_FILE, or place ~/.agentkeys/agent-session-.jwt — run 'phase1-wire-demo.sh --real' on the operator host first." >&2 + exit 1 +fi + # POSITIVE: the agent stores + reads back its OWN memory namespace. This is the real # cap-mint → STS-signed-as-the-agent → worker → S3 path; success proves the # sandbox-resident key is a valid, scoped actor. @@ -37,6 +74,22 @@ else exit 1 fi +# POSITIVE: the same sandbox-held agent identity stores + fetches an OWN +# credential through the credentials worker (bearer already exported above). The +# service name is deliberately a synthetic proof key so this never overwrites a +# real provider credential. +if ! "$AGENT_BIN" cred store --service "$cred_service" --content "$cred_secret" >&2; then + echo "FAIL: agent cred store (own prefix) — check in-sandbox MCP + broker/cred-worker/vault-role reachability." >&2 + exit 1 +fi +got_cred="$("$AGENT_BIN" cred fetch --service "$cred_service" 2>/dev/null || true)" +if [ "$got_cred" = "$cred_secret" ]; then + echo "OK: agent stored + fetched credential:$cred_service in ITS OWN prefix — real §10.2 agent, key never left the sandbox." >&2 +else + echo "FAIL: agent could not fetch back the credential:$cred_service it just wrote." >&2 + exit 1 +fi + # NOTE on cross-actor isolation: the agent's STS creds are tagged with ITS actor_omni, # so the worker physically scopes it to bots// — the cross-actor DENIAL is # enforced at the IAM layer and is already proven by stage-3 steps 4-9. The agent CLI diff --git a/scripts/setup-mcp-host.sh b/scripts/setup-mcp-host.sh index 4a66ca22..072e76ff 100755 --- a/scripts/setup-mcp-host.sh +++ b/scripts/setup-mcp-host.sh @@ -376,6 +376,7 @@ MCP_ENDPOINT=${XIAOZHI_ENDPOINT} AGENTKEYS_BROKER_URL=https://broker.litentry.org AGENTKEYS_MEMORY_URL=https://memory.litentry.org AGENTKEYS_AUDIT_URL=https://audit.litentry.org +AGENTKEYS_CRED_URL=https://cred.litentry.org EOF ) else @@ -385,11 +386,12 @@ else MCP_TRANSPORT=mcp-endpoint MCP_BACKEND=http MCP_ENDPOINT=ws://127.0.0.1:${RELAY_PORT}/mcp_endpoint/mcp/?token=${TOKEN} -# These three are placeholders — paste the live broker / worker URLs in +# These four are placeholders — paste the live broker / worker URLs in # after running setup-broker-host.sh on the same host. AGENTKEYS_BROKER_URL=https://broker.litentry.org AGENTKEYS_MEMORY_URL=https://memory.litentry.org AGENTKEYS_AUDIT_URL=https://audit.litentry.org +AGENTKEYS_CRED_URL=https://cred.litentry.org EOF ) fi