diff --git a/apps/parent-control/app/_components/pairing.tsx b/apps/parent-control/app/_components/pairing.tsx index b3eea5a0..2ca27f09 100644 --- a/apps/parent-control/app/_components/pairing.tsx +++ b/apps/parent-control/app/_components/pairing.tsx @@ -125,6 +125,8 @@ export function PairingPage({ .map(([ns, v]) => `${ns}:${v.write ? 'rw' : 'r'}`) .join(' · ') || 'none'} +
path policy
+
{(a.pathPolicy?.active ?? a.status !== 'bad') ? 'active · default deny' : 'suspended · jwt denied'}
active
{a.lastActive}
diff --git a/apps/parent-control/app/_components/permissions.tsx b/apps/parent-control/app/_components/permissions.tsx index 55cc2efb..ae0b6574 100644 --- a/apps/parent-control/app/_components/permissions.tsx +++ b/apps/parent-control/app/_components/permissions.tsx @@ -112,9 +112,34 @@ export function PermissionList({ const vaultForActor = vaultItems.filter((v) => v.actor === actor.id); const hasEmail = services.includes('email'); const hasPay = (actor.paymentCap?.perTx ?? 0) > 0; + const pathPolicy = actor.pathPolicy ?? ( + actor.role === 'agent' + ? { + active: actor.status !== 'bad', + derivationPath: actor.derivation, + scope: services.join(', ') || memGranted.join(', ') || 'paired', + defaultDeny: true, + } + : undefined + ); return (
+ {/* TEE CHILD PATH POLICY */} + {pathPolicy && ( + + {pathPolicy.active ? 'jwt:on' : 'jwt:deny'}} + /> + + )} + {/* MEMORY */} {NAMESPACES.map((ns) => { diff --git a/apps/parent-control/app/_components/types.ts b/apps/parent-control/app/_components/types.ts index 5b367470..607b0991 100644 --- a/apps/parent-control/app/_components/types.ts +++ b/apps/parent-control/app/_components/types.ts @@ -25,6 +25,12 @@ export interface Actor { timeWindow?: { start: string; end: string; tz: string }; services?: string[]; justPaired?: boolean; + pathPolicy?: { + active: boolean; + derivationPath: string; + scope: string; + defaultDeny: boolean; + }; } export type ChipKind = diff --git a/apps/parent-control/lib/client/core.ts b/apps/parent-control/lib/client/core.ts index 830890e7..a85ac846 100644 --- a/apps/parent-control/lib/client/core.ts +++ b/apps/parent-control/lib/client/core.ts @@ -3,19 +3,32 @@ import { EmptyBackend } from './empty'; import type { ConnectionStatus } from './types'; +interface LoadedCore { + capMemoryPut(bearer: string, req: unknown): Promise; + capMemoryGet(bearer: string, req: unknown): Promise; + pairingClaim(bearer: string, req: unknown): Promise; + pendingBindings(bearer: string): Promise; + ackBinding(bearer: string, requestId: string): Promise; +} + +interface WasmCoreModule { + default: (wasmUrl: string) => Promise; + WebCore: new (brokerUrl: string) => LoadedCore; +} + // Lazy, client-only load of the WASM master-plane core (agentkeys-web-core), // memoized per broker URL. The dynamic import keeps the wasm glue out of the // server bundle; init() fetches the .wasm from /wasm/ (served from public/, // written by dev.sh's build_wasm). Keying by URL means a second CoreBackend with // a different broker gets its own instance; on failure the entry is evicted so // the next call retries (a transient load/broker failure must not poison it). -type LoadedCore = import('@/lib/wasm/agentkeys-web-core/agentkeys_web_core').WebCore; const coreByUrl = new Map>(); function loadCore(brokerUrl: string): Promise { let p = coreByUrl.get(brokerUrl); if (!p) { p = (async () => { - const wasm = await import('@/lib/wasm/agentkeys-web-core/agentkeys_web_core.js'); + const wasmGlueUrl = '/wasm/agentkeys_web_core.js'; + const wasm = await import(/* webpackIgnore: true */ wasmGlueUrl) as WasmCoreModule; await wasm.default('/wasm/agentkeys_web_core_bg.wasm'); return new wasm.WebCore(brokerUrl); })(); diff --git a/apps/parent-control/lib/client/daemon.ts b/apps/parent-control/lib/client/daemon.ts index 387ccc8e..91e94c38 100644 --- a/apps/parent-control/lib/client/daemon.ts +++ b/apps/parent-control/lib/client/daemon.ts @@ -356,6 +356,12 @@ interface ApiActor { payment_cap?: { per_tx: number; daily: number; currency: string }; time_window?: { start: string; end: string; tz: string }; services?: string[]; + path_policy?: { + active: boolean; + derivation_path: string; + scope: string; + default_deny: boolean; + }; } interface ApiAuditEvent { @@ -403,6 +409,14 @@ function apiToActor(a: ApiActor): Actor { : undefined, timeWindow: a.time_window, services: a.services, + pathPolicy: a.path_policy + ? { + active: a.path_policy.active, + derivationPath: a.path_policy.derivation_path, + scope: a.path_policy.scope, + defaultDeny: a.path_policy.default_deny, + } + : undefined, }; } diff --git a/crates/agentkeys-broker-server/src/handlers/agent/claim.rs b/crates/agentkeys-broker-server/src/handlers/agent/claim.rs index 5ccbe3a7..64ce8f5e 100644 --- a/crates/agentkeys-broker-server/src/handlers/agent/claim.rs +++ b/crates/agentkeys-broker-server/src/handlers/agent/claim.rs @@ -91,6 +91,17 @@ pub async fn pairing_claim( // round-tripped through /request) degrades to empty rather than failing. let device_key_hash = agentkeys_core::device_crypto::device_key_hash(&device_pubkey).unwrap_or_default(); + let derivation_path = format!("//{}", body.label); + state + .grant_store + .activate_child_path( + &master_omni, + &child_omni, + &derivation_path, + &requested_scope, + now, + ) + .map_err(|e| BrokerError::Internal(format!("activate child path policy: {e}")))?; tracing::info!( operator_omni = %master_omni, @@ -108,6 +119,11 @@ pub async fn pairing_claim( "operator_omni": master_omni, "label": body.label, "requested_scope": requested_scope, + "path_policy": { + "derivation_path": derivation_path, + "active": true, + "default": "deny", + }, "device_pubkey": device_pubkey, "pop_sig": pop_sig, "device_key_hash": device_key_hash, diff --git a/crates/agentkeys-broker-server/src/handlers/grant/mod.rs b/crates/agentkeys-broker-server/src/handlers/grant/mod.rs index 04f98957..92dc9924 100644 --- a/crates/agentkeys-broker-server/src/handlers/grant/mod.rs +++ b/crates/agentkeys-broker-server/src/handlers/grant/mod.rs @@ -8,6 +8,7 @@ pub mod create; pub mod list; +pub mod path_policy; pub mod revoke; use axum::http::HeaderMap; diff --git a/crates/agentkeys-broker-server/src/handlers/grant/path_policy.rs b/crates/agentkeys-broker-server/src/handlers/grant/path_policy.rs new file mode 100644 index 00000000..c9964314 --- /dev/null +++ b/crates/agentkeys-broker-server/src/handlers/grant/path_policy.rs @@ -0,0 +1,99 @@ +//! TEE-side child derivation path policy. +//! +//! Pairing creates an explicit active row for the approved HDKD path. Paths with +//! no row are denied by default; suspend/resume flips the active bit without +//! pretending the underlying child key derivation can be destroyed. + +use std::time::{SystemTime, UNIX_EPOCH}; + +use axum::{ + extract::State, + http::{HeaderMap, StatusCode}, + response::IntoResponse, + Json, +}; +use serde::Deserialize; +use serde_json::json; + +use crate::error::BrokerError; +use crate::state::SharedState; + +#[derive(Debug, Deserialize)] +pub struct PathPolicyBody { + pub derivation_path: String, +} + +pub async fn path_policy_list( + State(state): State, + headers: HeaderMap, +) -> Result { + let session = super::require_session_jwt(&headers, &state)?; + let master = session.agentkeys.omni_account; + let policies = state + .grant_store + .list_child_path_policies(&master) + .map_err(|e| BrokerError::Internal(format!("list child path policies: {}", e)))?; + + Ok(( + StatusCode::OK, + Json(json!({ + "owner": master, + "default": "deny", + "policies": policies, + })), + )) +} + +pub async fn path_policy_suspend( + State(state): State, + headers: HeaderMap, + Json(body): Json, +) -> Result { + set_active(state, headers, body, false).await +} + +pub async fn path_policy_resume( + State(state): State, + headers: HeaderMap, + Json(body): Json, +) -> Result { + set_active(state, headers, body, true).await +} + +async fn set_active( + state: SharedState, + headers: HeaderMap, + body: PathPolicyBody, + active: bool, +) -> Result { + let session = super::require_session_jwt(&headers, &state)?; + let master = session.agentkeys.omni_account; + let derivation_path = body.derivation_path.trim(); + if derivation_path.is_empty() { + return Err(BrokerError::BadRequest("derivation_path required".into())); + } + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + + let changed = state + .grant_store + .set_child_path_active(&master, derivation_path, active, now) + .map_err(|e| BrokerError::Internal(format!("set child path policy: {}", e)))?; + if !changed { + return Err(BrokerError::BadRequest(format!( + "derivation_path {:?} has no policy for this master", + derivation_path + ))); + } + + Ok(( + StatusCode::OK, + Json(json!({ + "derivation_path": derivation_path, + "active": active, + "updated_at": now, + })), + )) +} diff --git a/crates/agentkeys-broker-server/src/handlers/oidc.rs b/crates/agentkeys-broker-server/src/handlers/oidc.rs index 6f3b1ad0..238d3e39 100644 --- a/crates/agentkeys-broker-server/src/handlers/oidc.rs +++ b/crates/agentkeys-broker-server/src/handlers/oidc.rs @@ -120,6 +120,7 @@ pub async fn mint_oidc_jwt( // Same on-chain check the cap-mint path uses (SidecarRegistry.getDevice). if let Some(device_pubkey) = session_claims.agentkeys.device_pubkey.as_deref() { use crate::handlers::cap::{call_get_device, ChainContracts, ROLE_CAP_MINT}; + use crate::storage::PathPolicyCheck; // Mirror the FULL cap-mint invariant (cap.rs verify_chain): the device must // be active AND bound to BOTH the session's operator (parent_omni) and actor // (omni_account), with the CAP_MINT role. Checking only actor would let any @@ -130,6 +131,44 @@ pub async fn mint_oidc_jwt( .parent_omni .as_deref() .unwrap_or(""); + let derivation_path = session_claims + .agentkeys + .derivation_path + .as_deref() + .unwrap_or(""); + let policy_denied = if parent_omni.is_empty() || derivation_path.is_empty() { + Some("agent session missing parent_omni or derivation_path lineage — cannot verify child path policy") + } else { + match state + .grant_store + .check_child_path_policy(parent_omni, &actor_omni, derivation_path) + .map_err(|e| BrokerError::Internal(format!("child path policy read: {e}")))? + { + PathPolicyCheck::Active => None, + PathPolicyCheck::Suspended => { + Some("child derivation path is suspended by TEE-side path policy") + } + PathPolicyCheck::Missing => { + Some("child derivation path has no active policy — default deny") + } + } + }; + if let Some(reason) = policy_denied { + let _ = state.audit.record_mint( + MintRecord { + requester_token: token, + requester_wallet: &report_id, + requested_role: "oidc_jwt", + session_duration_seconds: state.config.oidc_jwt_ttl_seconds as i32, + sts_session_name: "(path-policy-deny)", + outcome: MintOutcome::AuthFailed, + }, + Some(reason), + ); + tracing::Span::current().record("outcome", "path_policy_deny"); + return Err(BrokerError::Forbidden(reason.into())); + } + let chain = ChainContracts::from_state(&state) .map_err(|e| BrokerError::Internal(format!("chain config for agent gate: {e:?}")))?; let dkh = agentkeys_core::device_crypto::device_key_hash(device_pubkey).map_err(|e| { diff --git a/crates/agentkeys-broker-server/src/lib.rs b/crates/agentkeys-broker-server/src/lib.rs index bcb528d8..e9fa940a 100644 --- a/crates/agentkeys-broker-server/src/lib.rs +++ b/crates/agentkeys-broker-server/src/lib.rs @@ -96,6 +96,18 @@ pub fn create_router(state: SharedState) -> Router { post(handlers::grant::revoke::grant_revoke), ) .route("/v1/grant/list", get(handlers::grant::list::grant_list)) + .route( + "/v1/path-policy/list", + get(handlers::grant::path_policy::path_policy_list), + ) + .route( + "/v1/path-policy/suspend", + post(handlers::grant::path_policy::path_policy_suspend), + ) + .route( + "/v1/path-policy/resume", + post(handlers::grant::path_policy::path_policy_resume), + ) // Phase B wallet endpoints (US-028). .route("/v1/wallet/link", post(handlers::wallet::link::wallet_link)) .route( diff --git a/crates/agentkeys-broker-server/src/storage/grants.rs b/crates/agentkeys-broker-server/src/storage/grants.rs index 08863aa7..a9dc9d19 100644 --- a/crates/agentkeys-broker-server/src/storage/grants.rs +++ b/crates/agentkeys-broker-server/src/storage/grants.rs @@ -40,6 +40,17 @@ pub enum GrantConsumeOutcome { Exhausted, } +/// Result of checking the TEE-side child-path access-control policy. +#[derive(Debug, PartialEq, Eq)] +pub enum PathPolicyCheck { + /// Path has an explicit active policy; JWT issuance may continue. + Active, + /// Path exists but is suspended. + Suspended, + /// No path policy exists. This is deny-by-default. + Missing, +} + /// Public-shape grant row. Used by `list` and the audit-proof verifier. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct Grant { @@ -56,6 +67,19 @@ pub struct Grant { pub audit_proof: String, } +/// Security-group-like policy attached to an HDKD child derivation path. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ChildPathPolicy { + pub master_omni_account: String, + pub child_omni: String, + pub derivation_path: String, + pub active: bool, + pub scope: String, + pub rate_limit_per_min: Option, + pub valid_until_block: Option, + pub updated_at: i64, +} + pub struct GrantStore { conn: Mutex, } @@ -111,7 +135,22 @@ impl GrantStore { ); CREATE INDEX IF NOT EXISTS idx_grants_master ON grants(master_omni_account); CREATE INDEX IF NOT EXISTS idx_grants_daemon ON grants(daemon_address); - CREATE INDEX IF NOT EXISTS idx_grants_service ON grants(service);", + CREATE INDEX IF NOT EXISTS idx_grants_service ON grants(service); + CREATE TABLE IF NOT EXISTS child_path_policies ( + master_omni_account TEXT NOT NULL, + child_omni TEXT NOT NULL, + derivation_path TEXT NOT NULL, + active INTEGER NOT NULL DEFAULT 0, + scope TEXT NOT NULL DEFAULT '', + rate_limit_per_min INTEGER, + valid_until_block INTEGER, + updated_at INTEGER NOT NULL, + PRIMARY KEY (master_omni_account, child_omni, derivation_path) + ); + CREATE INDEX IF NOT EXISTS idx_child_path_policies_master + ON child_path_policies(master_omni_account); + CREATE INDEX IF NOT EXISTS idx_child_path_policies_path + ON child_path_policies(master_omni_account, derivation_path);", ) .map_err(|e| AuthError::Internal(format!("init grants schema: {}", e)))?; Ok(()) @@ -154,6 +193,125 @@ impl GrantStore { Ok(()) } + /// Activate a child path during the pair-approval flow. This is the explicit + /// approval point that turns the default-deny policy into an allowed path. + pub fn activate_child_path( + &self, + master_omni_account: &str, + child_omni: &str, + derivation_path: &str, + scope: &str, + updated_at: i64, + ) -> Result<(), AuthError> { + let conn = self.lock()?; + conn.execute( + "INSERT INTO child_path_policies + (master_omni_account, child_omni, derivation_path, active, scope, updated_at) + VALUES (?1, ?2, ?3, 1, ?4, ?5) + ON CONFLICT(master_omni_account, child_omni, derivation_path) + DO UPDATE SET active = 1, scope = excluded.scope, updated_at = excluded.updated_at", + params![ + master_omni_account, + child_omni, + derivation_path, + scope, + updated_at, + ], + ) + .map_err(|e| AuthError::Internal(format!("activate child path policy: {}", e)))?; + Ok(()) + } + + /// Suspend or resume a path. Missing rows remain denied-by-default and return + /// `false` so callers can surface a non-enumerating owner-scoped error. + pub fn set_child_path_active( + &self, + master_omni_account: &str, + derivation_path: &str, + active: bool, + updated_at: i64, + ) -> Result { + let conn = self.lock()?; + let n = conn + .execute( + "UPDATE child_path_policies + SET active = ?1, updated_at = ?2 + WHERE master_omni_account = ?3 AND derivation_path = ?4", + params![ + if active { 1 } else { 0 }, + updated_at, + master_omni_account, + derivation_path, + ], + ) + .map_err(|e| AuthError::Internal(format!("set child path active: {}", e)))?; + Ok(n > 0) + } + + /// Check whether JWT issuance is currently allowed for a child path. Absence + /// is intentionally a hard deny: knowing a derivable path is not enough. + pub fn check_child_path_policy( + &self, + master_omni_account: &str, + child_omni: &str, + derivation_path: &str, + ) -> Result { + let conn = self.lock()?; + let active: Option = conn + .query_row( + "SELECT active + FROM child_path_policies + WHERE master_omni_account = ?1 + AND child_omni = ?2 + AND derivation_path = ?3", + params![master_omni_account, child_omni, derivation_path], + |row| row.get(0), + ) + .optional() + .map_err(|e| AuthError::Internal(format!("check child path policy: {}", e)))?; + match active { + Some(1) => Ok(PathPolicyCheck::Active), + Some(_) => Ok(PathPolicyCheck::Suspended), + None => Ok(PathPolicyCheck::Missing), + } + } + + pub fn list_child_path_policies( + &self, + master_omni_account: &str, + ) -> Result, AuthError> { + let conn = self.lock()?; + let mut stmt = conn + .prepare( + "SELECT master_omni_account, child_omni, derivation_path, active, scope, + rate_limit_per_min, valid_until_block, updated_at + FROM child_path_policies + WHERE master_omni_account = ?1 + ORDER BY updated_at DESC", + ) + .map_err(|e| AuthError::Internal(format!("prepare list child path policies: {}", e)))?; + let rows = stmt + .query_map(params![master_omni_account], |row| { + let active: i64 = row.get(3)?; + Ok(ChildPathPolicy { + master_omni_account: row.get(0)?, + child_omni: row.get(1)?, + derivation_path: row.get(2)?, + active: active == 1, + scope: row.get(4)?, + rate_limit_per_min: row.get(5)?, + valid_until_block: row.get(6)?, + updated_at: row.get(7)?, + }) + }) + .map_err(|e| AuthError::Internal(format!("query child path policies: {}", e)))?; + let mut out = Vec::new(); + for r in rows { + out.push(r.map_err(|e| AuthError::Internal(format!("row child path policy: {}", e)))?); + } + Ok(out) + } + /// Mark a grant `revoked` (sets `revoked_at`). Idempotent — re-revoke /// is a no-op (no-op = 0 rows updated, surfaces to caller). pub fn revoke( @@ -363,6 +521,49 @@ mod tests { assert!(g.revoked_at.is_none()); } + #[test] + fn child_path_policy_defaults_to_deny_until_pair_activation() { + let s = store(); + assert_eq!( + s.check_child_path_policy("om", "child", "//agent-a") + .unwrap(), + PathPolicyCheck::Missing + ); + + s.activate_child_path("om", "child", "//agent-a", "memory,openrouter", 200) + .unwrap(); + assert_eq!( + s.check_child_path_policy("om", "child", "//agent-a") + .unwrap(), + PathPolicyCheck::Active + ); + } + + #[test] + fn child_path_policy_suspend_resume_controls_jwt_gate() { + let s = store(); + s.activate_child_path("om", "child", "//agent-a", "memory", 200) + .unwrap(); + + assert!(s + .set_child_path_active("om", "//agent-a", false, 300) + .unwrap()); + assert_eq!( + s.check_child_path_policy("om", "child", "//agent-a") + .unwrap(), + PathPolicyCheck::Suspended + ); + + assert!(s + .set_child_path_active("om", "//agent-a", true, 400) + .unwrap()); + assert_eq!( + s.check_child_path_policy("om", "child", "//agent-a") + .unwrap(), + PathPolicyCheck::Active + ); + } + #[test] fn try_consume_increments_used_count_and_returns_id() { let s = store(); diff --git a/crates/agentkeys-broker-server/src/storage/mod.rs b/crates/agentkeys-broker-server/src/storage/mod.rs index 93f0d596..7a02f599 100644 --- a/crates/agentkeys-broker-server/src/storage/mod.rs +++ b/crates/agentkeys-broker-server/src/storage/mod.rs @@ -30,7 +30,7 @@ pub use auth_nonces::{AuthNonceStore, ConsumeOutcome}; pub use email_rate_limits::{EmailRateLimitStore, RateLimitOutcome}; #[cfg(feature = "auth-email-link")] pub use email_tokens::{EmailConsumeOutcome, EmailRequestStatus, EmailTokenStore}; -pub use grants::{Grant, GrantConsumeOutcome, GrantStore}; +pub use grants::{ChildPathPolicy, Grant, GrantConsumeOutcome, GrantStore, PathPolicyCheck}; pub use identity_links::{IdentityLink, IdentityLinkStore}; #[cfg(feature = "auth-oauth2")] pub use oauth_pending::{OAuth2PendingConsume, OAuth2PendingStatus, OAuth2PendingStore};