From 338cdd62e2888945c8908271aba482e78d73bb8a Mon Sep 17 00:00:00 2001 From: YASSERRMD Date: Tue, 9 Jun 2026 17:57:54 +0400 Subject: [PATCH 01/16] phase 5: add security policy model --- backend/controlplane/src/gateway/mod.rs | 5 ++ backend/controlplane/src/gateway/policy.rs | 71 ++++++++++++++++++++++ backend/controlplane/src/lib.rs | 1 + 3 files changed, 77 insertions(+) create mode 100644 backend/controlplane/src/gateway/mod.rs create mode 100644 backend/controlplane/src/gateway/policy.rs diff --git a/backend/controlplane/src/gateway/mod.rs b/backend/controlplane/src/gateway/mod.rs new file mode 100644 index 0000000..b7e831b --- /dev/null +++ b/backend/controlplane/src/gateway/mod.rs @@ -0,0 +1,5 @@ +//! Security Gateway — pre-execution checks on every agent action. + +pub mod policy; + +pub use policy::SecurityPolicy; diff --git a/backend/controlplane/src/gateway/policy.rs b/backend/controlplane/src/gateway/policy.rs new file mode 100644 index 0000000..290bbdb --- /dev/null +++ b/backend/controlplane/src/gateway/policy.rs @@ -0,0 +1,71 @@ +//! Security policy model. +//! +//! A [`SecurityPolicy`] expresses the capabilities an agent is *permitted* to +//! use at execution time. The gateway evaluates each [`ActionRequest`] against +//! the policy before the runtime is allowed to proceed. + +use serde::{Deserialize, Serialize}; + +use crate::constants::DataAccessLevel; + +/// Capability and limit policy applied by the security gateway. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SecurityPolicy { + /// Require a human approval gate for high/critical-risk actions. + pub require_human_approval: bool, + /// Allow the action to reach the external network. + pub allow_external_network: bool, + /// Allow exporting files out of the environment. + pub allow_file_export: bool, + /// Allow writes to databases. + pub allow_database_write: bool, + /// Allow access to PII / regulated data. + pub allow_pii_access: bool, + /// Highest data sensitivity any action may touch. + pub max_data_access_level: DataAccessLevel, + /// Daily spend ceiling, in whole currency units. + pub budget_limit: f64, +} + +impl Default for SecurityPolicy { + /// A conservative, deny-by-default-ish posture suitable for government use. + fn default() -> Self { + SecurityPolicy { + require_human_approval: true, + allow_external_network: false, + allow_file_export: false, + allow_database_write: false, + allow_pii_access: false, + max_data_access_level: DataAccessLevel::Internal, + budget_limit: 100.0, + } + } +} + +impl SecurityPolicy { + /// A permissive policy for trusted, low-risk internal automation. + pub fn permissive() -> Self { + SecurityPolicy { + require_human_approval: false, + allow_external_network: true, + allow_file_export: true, + allow_database_write: true, + allow_pii_access: true, + max_data_access_level: DataAccessLevel::Restricted, + budget_limit: 10_000.0, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_policy_is_conservative() { + let p = SecurityPolicy::default(); + assert!(p.require_human_approval); + assert!(!p.allow_pii_access); + assert!(!p.allow_external_network); + } +} diff --git a/backend/controlplane/src/lib.rs b/backend/controlplane/src/lib.rs index 554f921..da5cef6 100644 --- a/backend/controlplane/src/lib.rs +++ b/backend/controlplane/src/lib.rs @@ -17,6 +17,7 @@ pub mod logging; pub mod config; pub mod constants; pub mod error; +pub mod gateway; pub mod governance; pub mod observability; pub mod registry; From 52afcf5a5982dd3101465947564aea52808e014e Mon Sep 17 00:00:00 2001 From: YASSERRMD Date: Tue, 9 Jun 2026 17:58:14 +0400 Subject: [PATCH 02/16] phase 5: add action request model --- backend/controlplane/src/gateway/mod.rs | 2 + backend/controlplane/src/gateway/request.rs | 56 +++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 backend/controlplane/src/gateway/request.rs diff --git a/backend/controlplane/src/gateway/mod.rs b/backend/controlplane/src/gateway/mod.rs index b7e831b..0ca6571 100644 --- a/backend/controlplane/src/gateway/mod.rs +++ b/backend/controlplane/src/gateway/mod.rs @@ -1,5 +1,7 @@ //! Security Gateway — pre-execution checks on every agent action. pub mod policy; +pub mod request; pub use policy::SecurityPolicy; +pub use request::ActionRequest; diff --git a/backend/controlplane/src/gateway/request.rs b/backend/controlplane/src/gateway/request.rs new file mode 100644 index 0000000..843b5ca --- /dev/null +++ b/backend/controlplane/src/gateway/request.rs @@ -0,0 +1,56 @@ +//! Action request model — what the gateway is asked to authorise. +//! +//! An [`ActionRequest`] is everything the gateway needs to make an allow/deny +//! decision without calling back into other stores: the agent record itself +//! plus the specifics of the action being attempted. + +use serde::{Deserialize, Serialize}; + +use crate::constants::DataAccessLevel; +use crate::registry::AgentRecord; + +/// A single action an agent wishes to perform, presented for authorisation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ActionRequest { + /// The agent attempting the action (its current registry record). + pub agent: AgentRecord, + /// Tool the action would invoke, if any. + pub tool: Option, + /// MCP server the action would call, if any. + pub mcp_server: Option, + /// Model the action would use, if any. + pub model: Option, + /// Sensitivity of the data this action touches. + pub data_access_level: DataAccessLevel, + /// Estimated cost of this action. + pub estimated_cost: f64, + /// Spend already consumed by this agent in the budget window. + pub spent_so_far: f64, + /// Whether the action reaches the external network. + pub requires_external_network: bool, + /// Whether the action exports files out of the environment. + pub is_file_export: bool, + /// Whether the action writes to a database. + pub is_database_write: bool, + /// Whether the action touches PII / regulated data. + pub touches_pii: bool, +} + +impl ActionRequest { + /// Build a minimal request for the given agent with no special capabilities. + pub fn for_agent(agent: AgentRecord) -> Self { + ActionRequest { + agent, + tool: None, + mcp_server: None, + model: None, + data_access_level: DataAccessLevel::None, + estimated_cost: 0.0, + spent_so_far: 0.0, + requires_external_network: false, + is_file_export: false, + is_database_write: false, + touches_pii: false, + } + } +} From d077709480e6d8cc865ec68cdcae8f97902934fa Mon Sep 17 00:00:00 2001 From: YASSERRMD Date: Tue, 9 Jun 2026 17:58:54 +0400 Subject: [PATCH 03/16] phase 5: add pre-execution check function --- backend/controlplane/src/gateway/decision.rs | 31 +++++++ backend/controlplane/src/gateway/engine.rs | 89 ++++++++++++++++++++ backend/controlplane/src/gateway/mod.rs | 4 + 3 files changed, 124 insertions(+) create mode 100644 backend/controlplane/src/gateway/decision.rs create mode 100644 backend/controlplane/src/gateway/engine.rs diff --git a/backend/controlplane/src/gateway/decision.rs b/backend/controlplane/src/gateway/decision.rs new file mode 100644 index 0000000..ea0f6ad --- /dev/null +++ b/backend/controlplane/src/gateway/decision.rs @@ -0,0 +1,31 @@ +//! Security decision response. +//! +//! A [`SecurityDecision`] is the gateway's verdict for an [`ActionRequest`]: +//! whether it is allowed, every reason it would be denied, and a risk score. + +use serde::{Deserialize, Serialize}; + +/// The gateway's verdict for a single action. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SecurityDecision { + /// Whether the action may proceed. + pub allowed: bool, + /// All reasons the action was denied (empty when allowed). + pub denials: Vec, + /// Aggregate risk score for the action (0 = none). + pub risk_score: u32, + /// Evaluation time (unix seconds). + pub evaluated_at: i64, +} + +impl SecurityDecision { + /// Build a decision from collected denials and a risk score. + pub fn new(denials: Vec, risk_score: u32, evaluated_at: i64) -> Self { + SecurityDecision { + allowed: denials.is_empty(), + denials, + risk_score, + evaluated_at, + } + } +} diff --git a/backend/controlplane/src/gateway/engine.rs b/backend/controlplane/src/gateway/engine.rs new file mode 100644 index 0000000..82e1011 --- /dev/null +++ b/backend/controlplane/src/gateway/engine.rs @@ -0,0 +1,89 @@ +//! The security gateway engine. +//! +//! `evaluate` runs every pre-execution check against an [`ActionRequest`] and +//! returns a [`SecurityDecision`]. Checks are additive: each one may append a +//! denial reason; the action is allowed only if no check objected. + +use chrono::Utc; + +use crate::constants::LifecycleStatus; + +use super::decision::SecurityDecision; +use super::policy::SecurityPolicy; +use super::request::ActionRequest; + +/// Stateless policy evaluator. Construct once with a [`SecurityPolicy`] and +/// reuse across requests. +pub struct SecurityGateway { + policy: SecurityPolicy, +} + +impl SecurityGateway { + /// Create a gateway enforcing the given policy. + pub fn new(policy: SecurityPolicy) -> Self { + Self { policy } + } + + /// The policy this gateway enforces. + pub fn policy(&self) -> &SecurityPolicy { + &self.policy + } + + /// Run all pre-execution checks and return the decision. + pub fn evaluate(&self, req: &ActionRequest) -> SecurityDecision { + let mut denials: Vec = Vec::new(); + + self.check_agent_state(req, &mut denials); + + SecurityDecision::new(denials, 0, Utc::now().timestamp()) + } + + /// Base check: the agent must be active and not blocked/deactivated. + fn check_agent_state(&self, req: &ActionRequest, denials: &mut Vec) { + match req.agent.status { + LifecycleStatus::Active => {} + LifecycleStatus::Blocked => denials.push("agent is blocked".into()), + LifecycleStatus::Deactivated => denials.push("agent is deactivated".into()), + other => denials.push(format!("agent is not active (status: {other:?})")), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::constants::{DataAccessLevel, RiskLevel}; + use crate::registry::{AgentRecord, NewAgent}; + + pub(super) fn agent(status: LifecycleStatus) -> AgentRecord { + let mut a = AgentRecord::from_new(NewAgent { + name: "Bot".into(), + description: "d".into(), + owner: "o".into(), + department: "IT".into(), + framework: "openclaw".into(), + model_provider: "anthropic".into(), + model_name: "claude-opus-4-8".into(), + tools_allowed: vec!["search".into()], + mcp_servers_allowed: vec!["records-mcp".into()], + data_access_level: DataAccessLevel::Internal, + risk_level: RiskLevel::Medium, + }); + a.status = status; + a + } + + #[test] + fn inactive_agent_is_denied() { + let gw = SecurityGateway::new(SecurityPolicy::default()); + let decision = gw.evaluate(&ActionRequest::for_agent(agent(LifecycleStatus::Draft))); + assert!(!decision.allowed); + } + + #[test] + fn active_agent_passes_base_check() { + let gw = SecurityGateway::new(SecurityPolicy::permissive()); + let decision = gw.evaluate(&ActionRequest::for_agent(agent(LifecycleStatus::Active))); + assert!(decision.allowed); + } +} diff --git a/backend/controlplane/src/gateway/mod.rs b/backend/controlplane/src/gateway/mod.rs index 0ca6571..3cc4f3f 100644 --- a/backend/controlplane/src/gateway/mod.rs +++ b/backend/controlplane/src/gateway/mod.rs @@ -1,7 +1,11 @@ //! Security Gateway — pre-execution checks on every agent action. +pub mod decision; +pub mod engine; pub mod policy; pub mod request; +pub use decision::SecurityDecision; +pub use engine::SecurityGateway; pub use policy::SecurityPolicy; pub use request::ActionRequest; From ecc3ca7d94936424ee82645abc7430fe4b0da39c Mon Sep 17 00:00:00 2001 From: YASSERRMD Date: Tue, 9 Jun 2026 17:59:24 +0400 Subject: [PATCH 04/16] phase 5: add allowed tool check --- backend/controlplane/src/gateway/engine.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/backend/controlplane/src/gateway/engine.rs b/backend/controlplane/src/gateway/engine.rs index 82e1011..9673498 100644 --- a/backend/controlplane/src/gateway/engine.rs +++ b/backend/controlplane/src/gateway/engine.rs @@ -34,10 +34,20 @@ impl SecurityGateway { let mut denials: Vec = Vec::new(); self.check_agent_state(req, &mut denials); + self.check_tool(req, &mut denials); SecurityDecision::new(denials, 0, Utc::now().timestamp()) } + /// The tool, if any, must be on the agent's allow-list. + fn check_tool(&self, req: &ActionRequest, denials: &mut Vec) { + if let Some(tool) = &req.tool { + if !req.agent.tools_allowed.iter().any(|t| t == tool) { + denials.push(format!("tool '{tool}' is not allowed for this agent")); + } + } + } + /// Base check: the agent must be active and not blocked/deactivated. fn check_agent_state(&self, req: &ActionRequest, denials: &mut Vec) { match req.agent.status { From d9fc700daba43e92deacc35bb2d2209eb5a6c691 Mon Sep 17 00:00:00 2001 From: YASSERRMD Date: Tue, 9 Jun 2026 17:59:38 +0400 Subject: [PATCH 05/16] phase 5: add allowed MCP check --- backend/controlplane/src/gateway/engine.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/backend/controlplane/src/gateway/engine.rs b/backend/controlplane/src/gateway/engine.rs index 9673498..f125b3f 100644 --- a/backend/controlplane/src/gateway/engine.rs +++ b/backend/controlplane/src/gateway/engine.rs @@ -35,6 +35,7 @@ impl SecurityGateway { self.check_agent_state(req, &mut denials); self.check_tool(req, &mut denials); + self.check_mcp(req, &mut denials); SecurityDecision::new(denials, 0, Utc::now().timestamp()) } @@ -48,6 +49,15 @@ impl SecurityGateway { } } + /// The MCP server, if any, must be on the agent's allow-list. + fn check_mcp(&self, req: &ActionRequest, denials: &mut Vec) { + if let Some(server) = &req.mcp_server { + if !req.agent.mcp_servers_allowed.iter().any(|s| s == server) { + denials.push(format!("MCP server '{server}' is not allowed for this agent")); + } + } + } + /// Base check: the agent must be active and not blocked/deactivated. fn check_agent_state(&self, req: &ActionRequest, denials: &mut Vec) { match req.agent.status { From 62c35ac345d29a857c46e61318b8976b9dc21254 Mon Sep 17 00:00:00 2001 From: YASSERRMD Date: Tue, 9 Jun 2026 17:59:52 +0400 Subject: [PATCH 06/16] phase 5: add model permission check --- backend/controlplane/src/gateway/engine.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/backend/controlplane/src/gateway/engine.rs b/backend/controlplane/src/gateway/engine.rs index f125b3f..6b10fbb 100644 --- a/backend/controlplane/src/gateway/engine.rs +++ b/backend/controlplane/src/gateway/engine.rs @@ -36,6 +36,7 @@ impl SecurityGateway { self.check_agent_state(req, &mut denials); self.check_tool(req, &mut denials); self.check_mcp(req, &mut denials); + self.check_model(req, &mut denials); SecurityDecision::new(denials, 0, Utc::now().timestamp()) } @@ -58,6 +59,18 @@ impl SecurityGateway { } } + /// The model, if specified, must match the agent's approved model. + fn check_model(&self, req: &ActionRequest, denials: &mut Vec) { + if let Some(model) = &req.model { + if model != &req.agent.model_name { + denials.push(format!( + "model '{model}' is not the agent's approved model '{}'", + req.agent.model_name + )); + } + } + } + /// Base check: the agent must be active and not blocked/deactivated. fn check_agent_state(&self, req: &ActionRequest, denials: &mut Vec) { match req.agent.status { From 04c92a49228a960d22dd76f7de3b7cb95bdf5320 Mon Sep 17 00:00:00 2001 From: YASSERRMD Date: Tue, 9 Jun 2026 18:00:12 +0400 Subject: [PATCH 07/16] phase 5: add data access level check --- backend/controlplane/src/gateway/engine.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/backend/controlplane/src/gateway/engine.rs b/backend/controlplane/src/gateway/engine.rs index 6b10fbb..cd6d680 100644 --- a/backend/controlplane/src/gateway/engine.rs +++ b/backend/controlplane/src/gateway/engine.rs @@ -37,6 +37,7 @@ impl SecurityGateway { self.check_tool(req, &mut denials); self.check_mcp(req, &mut denials); self.check_model(req, &mut denials); + self.check_data_access(req, &mut denials); SecurityDecision::new(denials, 0, Utc::now().timestamp()) } @@ -71,6 +72,23 @@ impl SecurityGateway { } } + /// The action's data sensitivity must not exceed the agent's clearance nor + /// the policy's ceiling (`DataAccessLevel` is ordered). + fn check_data_access(&self, req: &ActionRequest, denials: &mut Vec) { + if req.data_access_level > req.agent.data_access_level { + denials.push(format!( + "action data access {:?} exceeds agent clearance {:?}", + req.data_access_level, req.agent.data_access_level + )); + } + if req.data_access_level > self.policy.max_data_access_level { + denials.push(format!( + "action data access {:?} exceeds policy ceiling {:?}", + req.data_access_level, self.policy.max_data_access_level + )); + } + } + /// Base check: the agent must be active and not blocked/deactivated. fn check_agent_state(&self, req: &ActionRequest, denials: &mut Vec) { match req.agent.status { From c4061903babec3ff36dec8cfeda2c86b56ff4cb7 Mon Sep 17 00:00:00 2001 From: YASSERRMD Date: Tue, 9 Jun 2026 18:00:28 +0400 Subject: [PATCH 08/16] phase 5: add capability checks for network, file export, db write, and PII --- backend/controlplane/src/gateway/engine.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/backend/controlplane/src/gateway/engine.rs b/backend/controlplane/src/gateway/engine.rs index cd6d680..1dc5945 100644 --- a/backend/controlplane/src/gateway/engine.rs +++ b/backend/controlplane/src/gateway/engine.rs @@ -38,6 +38,7 @@ impl SecurityGateway { self.check_mcp(req, &mut denials); self.check_model(req, &mut denials); self.check_data_access(req, &mut denials); + self.check_capabilities(req, &mut denials); SecurityDecision::new(denials, 0, Utc::now().timestamp()) } @@ -89,6 +90,22 @@ impl SecurityGateway { } } + /// Sensitive capabilities must each be enabled by policy. + fn check_capabilities(&self, req: &ActionRequest, denials: &mut Vec) { + if req.requires_external_network && !self.policy.allow_external_network { + denials.push("external network access is not permitted by policy".into()); + } + if req.is_file_export && !self.policy.allow_file_export { + denials.push("file export is not permitted by policy".into()); + } + if req.is_database_write && !self.policy.allow_database_write { + denials.push("database writes are not permitted by policy".into()); + } + if req.touches_pii && !self.policy.allow_pii_access { + denials.push("PII access is not permitted by policy".into()); + } + } + /// Base check: the agent must be active and not blocked/deactivated. fn check_agent_state(&self, req: &ActionRequest, denials: &mut Vec) { match req.agent.status { From ebddfb11268d27da6ac7390cc615ef0d4f66e768 Mon Sep 17 00:00:00 2001 From: YASSERRMD Date: Tue, 9 Jun 2026 18:00:43 +0400 Subject: [PATCH 09/16] phase 5: add budget check --- backend/controlplane/src/gateway/engine.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/backend/controlplane/src/gateway/engine.rs b/backend/controlplane/src/gateway/engine.rs index 1dc5945..86203bf 100644 --- a/backend/controlplane/src/gateway/engine.rs +++ b/backend/controlplane/src/gateway/engine.rs @@ -39,6 +39,7 @@ impl SecurityGateway { self.check_model(req, &mut denials); self.check_data_access(req, &mut denials); self.check_capabilities(req, &mut denials); + self.check_budget(req, &mut denials); SecurityDecision::new(denials, 0, Utc::now().timestamp()) } @@ -106,6 +107,17 @@ impl SecurityGateway { } } + /// Projected spend (already spent + this action) must stay within budget. + fn check_budget(&self, req: &ActionRequest, denials: &mut Vec) { + let projected = req.spent_so_far + req.estimated_cost; + if projected > self.policy.budget_limit { + denials.push(format!( + "budget exceeded: projected {:.2} > limit {:.2}", + projected, self.policy.budget_limit + )); + } + } + /// Base check: the agent must be active and not blocked/deactivated. fn check_agent_state(&self, req: &ActionRequest, denials: &mut Vec) { match req.agent.status { From e11ac0ccf46e6057b933b06ca7e52aa9ca7f40be Mon Sep 17 00:00:00 2001 From: YASSERRMD Date: Tue, 9 Jun 2026 18:00:59 +0400 Subject: [PATCH 10/16] phase 5: add human approval required check --- backend/controlplane/src/gateway/engine.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/backend/controlplane/src/gateway/engine.rs b/backend/controlplane/src/gateway/engine.rs index 86203bf..b84e1ed 100644 --- a/backend/controlplane/src/gateway/engine.rs +++ b/backend/controlplane/src/gateway/engine.rs @@ -40,6 +40,7 @@ impl SecurityGateway { self.check_data_access(req, &mut denials); self.check_capabilities(req, &mut denials); self.check_budget(req, &mut denials); + self.check_human_approval(req, &mut denials); SecurityDecision::new(denials, 0, Utc::now().timestamp()) } @@ -118,6 +119,17 @@ impl SecurityGateway { } } + /// High/critical-risk actions cannot auto-execute when policy mandates a + /// human approval gate. + fn check_human_approval(&self, req: &ActionRequest, denials: &mut Vec) { + if self.policy.require_human_approval && req.agent.risk_level.requires_approval() { + denials.push(format!( + "human approval required for {:?}-risk action", + req.agent.risk_level + )); + } + } + /// Base check: the agent must be active and not blocked/deactivated. fn check_agent_state(&self, req: &ActionRequest, denials: &mut Vec) { match req.agent.status { From 6919319a0d00f4f2e4c0c9ae56143c1854bca7df Mon Sep 17 00:00:00 2001 From: YASSERRMD Date: Tue, 9 Jun 2026 18:01:38 +0400 Subject: [PATCH 11/16] phase 5: add blocked execution log --- backend/controlplane/src/gateway/log.rs | 119 ++++++++++++++++++++++++ backend/controlplane/src/gateway/mod.rs | 2 + 2 files changed, 121 insertions(+) create mode 100644 backend/controlplane/src/gateway/log.rs diff --git a/backend/controlplane/src/gateway/log.rs b/backend/controlplane/src/gateway/log.rs new file mode 100644 index 0000000..5175bd3 --- /dev/null +++ b/backend/controlplane/src/gateway/log.rs @@ -0,0 +1,119 @@ +//! Blocked execution log. +//! +//! When the gateway denies an action, the attempt is recorded here for the +//! audit trail and for the observability `blocked_executions` metric. The log +//! is append-only and follows the workspace storage pattern. + +use std::sync::Mutex; + +use rusqlite::{params, Connection}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::Result; + +use super::decision::SecurityDecision; + +/// A persisted record of a blocked (denied) action attempt. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BlockedExecution { + pub id: String, + pub agent_id: String, + /// Joined denial reasons. + pub reasons: String, + pub risk_score: u32, + pub at: i64, +} + +/// Append-only store of blocked executions. +pub struct BlockedExecutionLog { + conn: Mutex, +} + +const SCHEMA: &str = " + CREATE TABLE IF NOT EXISTS blocked_executions ( + id TEXT PRIMARY KEY, + agent_id TEXT NOT NULL, + reasons TEXT NOT NULL, + risk_score INTEGER NOT NULL, + at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_blocked_agent ON blocked_executions(agent_id); +"; + +impl BlockedExecutionLog { + /// Open (creating if needed) a log backed by a file. + pub fn open(path: &str) -> Result { + let conn = Connection::open(path)?; + conn.execute_batch(&format!("PRAGMA journal_mode=WAL;{SCHEMA}"))?; + Ok(Self { conn: Mutex::new(conn) }) + } + + /// Open an ephemeral in-memory log (used by tests). + pub fn in_memory() -> Result { + let conn = Connection::open_in_memory()?; + conn.execute_batch(SCHEMA)?; + Ok(Self { conn: Mutex::new(conn) }) + } + + /// Record a denied decision. No-op (returns `None`) if the decision was + /// actually allowed, so callers can pass every decision unconditionally. + pub fn record(&self, agent_id: &str, decision: &SecurityDecision) -> Result> { + if decision.allowed { + return Ok(None); + } + let entry = BlockedExecution { + id: Uuid::new_v4().to_string(), + agent_id: agent_id.to_string(), + reasons: decision.denials.join("; "), + risk_score: decision.risk_score, + at: decision.evaluated_at, + }; + let conn = self.conn.lock().expect("blocked-log mutex poisoned"); + conn.execute( + "INSERT INTO blocked_executions (id, agent_id, reasons, risk_score, at) + VALUES (?1,?2,?3,?4,?5)", + params![entry.id, entry.agent_id, entry.reasons, entry.risk_score, entry.at], + )?; + cp_blocked!("gateway.blocked", agent_id = %entry.agent_id, reasons = %entry.reasons); + Ok(Some(entry)) + } + + /// List blocked executions, newest first. + pub fn list(&self) -> Result> { + let conn = self.conn.lock().expect("blocked-log mutex poisoned"); + let mut stmt = conn.prepare( + "SELECT id, agent_id, reasons, risk_score, at FROM blocked_executions ORDER BY at DESC", + )?; + let rows = stmt.query_map([], |row| { + Ok(BlockedExecution { + id: row.get(0)?, + agent_id: row.get(1)?, + reasons: row.get(2)?, + risk_score: row.get(3)?, + at: row.get(4)?, + }) + })?; + let mut out = Vec::new(); + for r in rows { + out.push(r?); + } + Ok(out) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn records_only_denied_decisions() { + let log = BlockedExecutionLog::in_memory().unwrap(); + let allowed = SecurityDecision::new(vec![], 0, 1); + assert!(log.record("a", &allowed).unwrap().is_none()); + + let denied = SecurityDecision::new(vec!["tool not allowed".into()], 3, 2); + assert!(log.record("a", &denied).unwrap().is_some()); + assert_eq!(log.list().unwrap().len(), 1); + } +} diff --git a/backend/controlplane/src/gateway/mod.rs b/backend/controlplane/src/gateway/mod.rs index 3cc4f3f..2aef1d7 100644 --- a/backend/controlplane/src/gateway/mod.rs +++ b/backend/controlplane/src/gateway/mod.rs @@ -2,10 +2,12 @@ pub mod decision; pub mod engine; +pub mod log; pub mod policy; pub mod request; pub use decision::SecurityDecision; pub use engine::SecurityGateway; +pub use log::{BlockedExecution, BlockedExecutionLog}; pub use policy::SecurityPolicy; pub use request::ActionRequest; From fe71c8affd5225f0694dff06d56ebe3fd87e581e Mon Sep 17 00:00:00 2001 From: YASSERRMD Date: Tue, 9 Jun 2026 18:01:57 +0400 Subject: [PATCH 12/16] phase 5: add risk score calculation --- backend/controlplane/src/gateway/engine.rs | 30 +++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/backend/controlplane/src/gateway/engine.rs b/backend/controlplane/src/gateway/engine.rs index b84e1ed..6e4082e 100644 --- a/backend/controlplane/src/gateway/engine.rs +++ b/backend/controlplane/src/gateway/engine.rs @@ -42,7 +42,8 @@ impl SecurityGateway { self.check_budget(req, &mut denials); self.check_human_approval(req, &mut denials); - SecurityDecision::new(denials, 0, Utc::now().timestamp()) + let risk_score = Self::risk_score(req, denials.len()); + SecurityDecision::new(denials, risk_score, Utc::now().timestamp()) } /// The tool, if any, must be on the agent's allow-list. @@ -130,6 +131,33 @@ impl SecurityGateway { } } + /// Compute an aggregate risk score (0–100) for an action. + /// + /// Combines the agent's inherent risk, the data sensitivity touched, the + /// sensitive capabilities requested, and how many checks objected. + fn risk_score(req: &ActionRequest, denial_count: usize) -> u32 { + let mut score = req.agent.risk_level.weight() as u32 * 10; // 10..=40 + score += match req.data_access_level { + crate::constants::DataAccessLevel::Restricted => 20, + crate::constants::DataAccessLevel::Confidential => 12, + crate::constants::DataAccessLevel::Internal => 6, + crate::constants::DataAccessLevel::Public => 2, + crate::constants::DataAccessLevel::None => 0, + }; + for flag in [ + req.requires_external_network, + req.is_file_export, + req.is_database_write, + req.touches_pii, + ] { + if flag { + score += 5; + } + } + score += denial_count as u32 * 5; + score.min(100) + } + /// Base check: the agent must be active and not blocked/deactivated. fn check_agent_state(&self, req: &ActionRequest, denials: &mut Vec) { match req.agent.status { From ca0723f3cb502d601c31da0e316cfe56cc34b18a Mon Sep 17 00:00:00 2001 From: YASSERRMD Date: Tue, 9 Jun 2026 18:02:13 +0400 Subject: [PATCH 13/16] phase 5: add security decision response helpers --- backend/controlplane/src/gateway/decision.rs | 29 ++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/backend/controlplane/src/gateway/decision.rs b/backend/controlplane/src/gateway/decision.rs index ea0f6ad..fbdf86c 100644 --- a/backend/controlplane/src/gateway/decision.rs +++ b/backend/controlplane/src/gateway/decision.rs @@ -28,4 +28,33 @@ impl SecurityDecision { evaluated_at, } } + + /// The first denial reason, if any (useful for terse responses). + pub fn primary_reason(&self) -> Option<&str> { + self.denials.first().map(|s| s.as_str()) + } + + /// Coarse risk band derived from the score: `low` / `medium` / `high` / `critical`. + pub fn risk_band(&self) -> &'static str { + match self.risk_score { + 0..=24 => "low", + 25..=49 => "medium", + 50..=74 => "high", + _ => "critical", + } + } + + /// One-line, human-readable verdict suitable for logs and API responses. + pub fn summary(&self) -> String { + if self.allowed { + format!("ALLOW (risk: {}, score: {})", self.risk_band(), self.risk_score) + } else { + format!( + "DENY (risk: {}, score: {}) — {}", + self.risk_band(), + self.risk_score, + self.denials.join("; ") + ) + } + } } From cc684ef76acaaed0e99a6f4b35ad44dca62e0f6f Mon Sep 17 00:00:00 2001 From: YASSERRMD Date: Tue, 9 Jun 2026 18:02:37 +0400 Subject: [PATCH 14/16] phase 5: add tests for allowed execution --- backend/controlplane/src/gateway/engine.rs | 38 ++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/backend/controlplane/src/gateway/engine.rs b/backend/controlplane/src/gateway/engine.rs index 6e4082e..6e7bb11 100644 --- a/backend/controlplane/src/gateway/engine.rs +++ b/backend/controlplane/src/gateway/engine.rs @@ -206,4 +206,42 @@ mod tests { let decision = gw.evaluate(&ActionRequest::for_agent(agent(LifecycleStatus::Active))); assert!(decision.allowed); } + + /// A fully valid request under a permissive policy. + fn valid_request() -> ActionRequest { + let mut req = ActionRequest::for_agent(agent(LifecycleStatus::Active)); + req.tool = Some("search".into()); + req.mcp_server = Some("records-mcp".into()); + req.model = Some("claude-opus-4-8".into()); + req.data_access_level = DataAccessLevel::Internal; + req.estimated_cost = 1.0; + req.spent_so_far = 2.0; + req + } + + #[test] + fn fully_valid_action_is_allowed() { + let gw = SecurityGateway::new(SecurityPolicy::permissive()); + let decision = gw.evaluate(&valid_request()); + assert!(decision.allowed, "denials: {:?}", decision.denials); + assert!(decision.denials.is_empty()); + assert!(decision.primary_reason().is_none()); + } + + #[test] + fn allowed_action_carries_a_risk_score() { + let gw = SecurityGateway::new(SecurityPolicy::permissive()); + let decision = gw.evaluate(&valid_request()); + // Medium agent (20) + internal data (6) = 26 => "medium" band. + assert!(decision.risk_score > 0); + assert_eq!(decision.risk_band(), "medium"); + } + + #[test] + fn unspecified_optional_action_is_allowed() { + // No tool/mcp/model and no sensitive capabilities under permissive policy. + let gw = SecurityGateway::new(SecurityPolicy::permissive()); + let decision = gw.evaluate(&ActionRequest::for_agent(agent(LifecycleStatus::Active))); + assert!(decision.allowed); + } } From 8468bf8ca0b82306d3a62a6a339c17a6d090fd88 Mon Sep 17 00:00:00 2001 From: YASSERRMD Date: Tue, 9 Jun 2026 18:02:58 +0400 Subject: [PATCH 15/16] phase 5: add tests for blocked execution --- backend/controlplane/src/gateway/engine.rs | 72 ++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/backend/controlplane/src/gateway/engine.rs b/backend/controlplane/src/gateway/engine.rs index 6e7bb11..a804202 100644 --- a/backend/controlplane/src/gateway/engine.rs +++ b/backend/controlplane/src/gateway/engine.rs @@ -244,4 +244,76 @@ mod tests { let decision = gw.evaluate(&ActionRequest::for_agent(agent(LifecycleStatus::Active))); assert!(decision.allowed); } + + #[test] + fn disallowed_tool_is_blocked() { + let gw = SecurityGateway::new(SecurityPolicy::permissive()); + let mut req = valid_request(); + req.tool = Some("shell".into()); // not in allow-list + let d = gw.evaluate(&req); + assert!(!d.allowed); + assert!(d.denials.iter().any(|r| r.contains("tool 'shell'"))); + } + + #[test] + fn disallowed_mcp_and_model_are_blocked() { + let gw = SecurityGateway::new(SecurityPolicy::permissive()); + let mut req = valid_request(); + req.mcp_server = Some("rogue-mcp".into()); + req.model = Some("gpt-4".into()); + let d = gw.evaluate(&req); + assert!(!d.allowed); + assert!(d.denials.iter().any(|r| r.contains("MCP server 'rogue-mcp'"))); + assert!(d.denials.iter().any(|r| r.contains("model 'gpt-4'"))); + } + + #[test] + fn data_access_above_clearance_is_blocked() { + let gw = SecurityGateway::new(SecurityPolicy::permissive()); + let mut req = valid_request(); + req.data_access_level = DataAccessLevel::Restricted; // agent clearance is Internal + let d = gw.evaluate(&req); + assert!(!d.allowed); + assert!(d.denials.iter().any(|r| r.contains("exceeds agent clearance"))); + } + + #[test] + fn over_budget_is_blocked() { + let gw = SecurityGateway::new(SecurityPolicy::default()); // limit 100 + let mut req = valid_request(); + req.spent_so_far = 99.0; + req.estimated_cost = 50.0; + let d = gw.evaluate(&req); + assert!(d.denials.iter().any(|r| r.contains("budget exceeded"))); + } + + #[test] + fn sensitive_capabilities_blocked_by_default_policy() { + let gw = SecurityGateway::new(SecurityPolicy::default()); + let mut req = valid_request(); + req.touches_pii = true; + req.is_database_write = true; + let d = gw.evaluate(&req); + assert!(d.denials.iter().any(|r| r.contains("PII access"))); + assert!(d.denials.iter().any(|r| r.contains("database writes"))); + } + + #[test] + fn high_risk_requires_human_approval() { + let gw = SecurityGateway::new(SecurityPolicy::default()); + let mut a = agent(LifecycleStatus::Active); + a.risk_level = RiskLevel::Critical; + let mut req = ActionRequest::for_agent(a); + req.tool = Some("search".into()); + let d = gw.evaluate(&req); + assert!(d.denials.iter().any(|r| r.contains("human approval required"))); + } + + #[test] + fn blocked_agent_is_denied_with_high_risk_band() { + let gw = SecurityGateway::new(SecurityPolicy::default()); + let d = gw.evaluate(&ActionRequest::for_agent(agent(LifecycleStatus::Blocked))); + assert!(!d.allowed); + assert!(d.denials.iter().any(|r| r.contains("blocked"))); + } } From faae4f1f2183249534b7c88aaa547a8c8e39cc93 Mon Sep 17 00:00:00 2001 From: YASSERRMD Date: Tue, 9 Jun 2026 18:03:19 +0400 Subject: [PATCH 16/16] phase 5: add security gateway documentation --- docs/security-gateway.md | 78 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 docs/security-gateway.md diff --git a/docs/security-gateway.md b/docs/security-gateway.md new file mode 100644 index 0000000..6124d16 --- /dev/null +++ b/docs/security-gateway.md @@ -0,0 +1,78 @@ +# Security Gateway + +The Security Gateway (`clawforge_controlplane::gateway`) is the enforcement +point: **every agent action is checked before execution**. It is deny-by-reason +— an action is allowed only if no check objects. + +## Inputs + +- **`SecurityPolicy`** — the capabilities and limits in force: human-approval + requirement, external network / file export / database write / PII toggles, a + data-sensitivity ceiling, and a budget limit. `SecurityPolicy::default()` is a + conservative government-grade posture; `::permissive()` suits trusted internal + automation. +- **`ActionRequest`** — the agent's current registry record plus the specifics + of the attempted action (tool, MCP server, model, data sensitivity, estimated + cost, spend so far, and capability flags). + +## Checks performed + +| Check | Denies when … | +|-------|---------------| +| Agent state | agent is not `Active` (draft / blocked / deactivated) | +| Tool | tool is not on the agent's allow-list | +| MCP | MCP server is not on the agent's allow-list | +| Model | model differs from the agent's approved model | +| Data access | sensitivity exceeds agent clearance or policy ceiling | +| Capabilities | external network / file export / db write / PII not permitted | +| Budget | projected spend exceeds the budget limit | +| Human approval | high/critical-risk action under a mandated approval gate | + +## Output + +`SecurityDecision { allowed, denials, risk_score, evaluated_at }`. All failing +reasons are collected (not just the first), so an operator sees everything wrong +at once. Helpers: `primary_reason()`, `risk_band()` (low/medium/high/critical), +and `summary()` for a one-line verdict. + +The **risk score** (0–100) combines the agent's inherent risk, the data +sensitivity touched, sensitive capabilities requested, and the number of failing +checks. + +## Example + +```rust +use clawforge_controlplane::gateway::{SecurityGateway, SecurityPolicy, ActionRequest, BlockedExecutionLog}; +use clawforge_controlplane::constants::DataAccessLevel; + +let gw = SecurityGateway::new(SecurityPolicy::default()); +let log = BlockedExecutionLog::open("clawforge-controlplane.db")?; + +let mut req = ActionRequest::for_agent(agent_record); +req.tool = Some("search".into()); +req.data_access_level = DataAccessLevel::Internal; +req.estimated_cost = 0.02; + +let decision = gw.evaluate(&req); +log.record(&req.agent.id, &decision)?; // no-op if allowed + +if decision.allowed { + // proceed with execution +} else { + eprintln!("{}", decision.summary()); +} +``` + +## Blocked execution log + +Denied attempts are persisted to `BlockedExecutionLog` (append-only SQLite), +feeding the audit trail and the observability `blocked_executions` metric. The +`record` call is a no-op for allowed decisions, so callers can pass every +decision unconditionally. + +## Relationship to the rest of the control plane + +The gateway is intentionally **stateless** about other stores: the +`ActionRequest` carries the agent record, so evaluation is pure and trivially +testable. Governance decides *whether an agent should exist*; the gateway +decides *whether a specific action may run right now*.