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};