Skip to main content

systemprompt_security/policy/
types.rs

1//! Shared types for the unified governance plane.
2//!
3//! These types support the tool-use governance chain
4//! ([`super::GovernancePolicy`]) and feed into the typed deny variants in
5//! [`crate::authz::types::DenyReason`]. They live here (and not in
6//! `authz/types.rs`) because they describe the *tool-call* enforcement plane
7//! — secret scans, scope checks, blocklists, rate limits — which is
8//! orthogonal to the user→entity allow/deny resolver.
9//!
10//! Copyright (c) systemprompt.io — Business Source License 1.1.
11//! See <https://systemprompt.io> for licensing details.
12
13use std::fmt;
14use std::str::FromStr;
15use std::sync::Arc;
16
17use serde::{Deserialize, Serialize};
18use systemprompt_identifiers::{McpToolName, PolicyId, SessionId, UserId};
19
20use crate::authz::error::AuthzError;
21use crate::authz::types::Decision;
22
23/// Where in a tool-call payload a secret-scanner finding was located.
24///
25/// `kind` identifies the field family (e.g. `"arg"`, `"env"`); `path` is the
26/// JSON-pointer-like dotted path within the tool input.
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct SecretLocation {
29    pub kind: String,
30    pub path: String,
31}
32
33impl SecretLocation {
34    pub fn new(kind: impl Into<String>, path: impl Into<String>) -> Self {
35        Self {
36            kind: kind.into(),
37            path: path.into(),
38        }
39    }
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct RateLimitWindow {
44    pub name: String,
45    pub seconds: u64,
46    pub limit: u64,
47}
48
49/// Scope of an agent invocation for governance evaluation. Agents may run
50/// either inside an authenticated user session or under a system/service
51/// identity (cron, replay, internal scheduler).
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(tag = "kind", rename_all = "snake_case")]
54pub enum AgentScope {
55    User { user_id: UserId },
56    System,
57}
58
59impl AgentScope {
60    #[must_use]
61    pub const fn user_id(&self) -> Option<&UserId> {
62        match self {
63            Self::User { user_id } => Some(user_id),
64            Self::System => None,
65        }
66    }
67}
68
69/// Permission tier carried alongside [`AgentScope`] in [`PolicyContext`].
70///
71/// `AgentScope` answers "who is acting" (user vs system process identity);
72/// `AccessScope` answers "what permission tier is granted to this invocation"
73/// (admin, plain user, unknown). The two are orthogonal — a system actor may
74/// have any tier, a user actor may be admin or plain — so they live as
75/// separate fields rather than a cartesian enum. `Unknown` is the fallback when
76/// an agent card declares no `oauth.scopes` entry.
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
78#[sqlx(type_name = "TEXT", rename_all = "lowercase")]
79#[serde(rename_all = "lowercase")]
80pub enum AccessScope {
81    Admin,
82    User,
83    Unknown,
84}
85
86impl AccessScope {
87    #[must_use]
88    pub const fn as_str(self) -> &'static str {
89        match self {
90            Self::Admin => "admin",
91            Self::User => "user",
92            Self::Unknown => "unknown",
93        }
94    }
95}
96
97impl fmt::Display for AccessScope {
98    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99        f.write_str(self.as_str())
100    }
101}
102
103impl FromStr for AccessScope {
104    type Err = AuthzError;
105
106    fn from_str(s: &str) -> Result<Self, Self::Err> {
107        match s {
108            "admin" => Ok(Self::Admin),
109            "user" => Ok(Self::User),
110            "unknown" | "" => Ok(Self::Unknown),
111            other => Err(AuthzError::Validation(format!(
112                "unknown access scope: {other}"
113            ))),
114        }
115    }
116}
117
118/// Untyped MCP tool input wrapped at the protocol boundary.
119///
120/// The MCP protocol mandates schema-less JSON for tool arguments — every tool
121/// defines its own input shape. This wrapper is the single point where
122/// governance reaches into that JSON; everywhere else the typed path is
123/// preferred. Callers extract fields via [`Self::as_str`] / [`Self::as_path`].
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125#[serde(transparent)]
126pub struct McpToolInput(
127    // JSON: MCP-protocol boundary — schema-less tool arguments mandated by the
128    // spec. Governance is the only consumer that reaches into this blob.
129    serde_json::Value,
130);
131
132impl McpToolInput {
133    #[must_use]
134    pub const fn new(value: serde_json::Value) -> Self {
135        Self(value)
136    }
137
138    #[must_use]
139    pub const fn as_value(&self) -> &serde_json::Value {
140        &self.0
141    }
142
143    #[must_use]
144    pub fn as_str(&self, field: &str) -> Option<&str> {
145        self.0.get(field).and_then(serde_json::Value::as_str)
146    }
147
148    #[must_use]
149    pub fn as_path(&self, field: &str) -> Option<&str> {
150        self.as_str(field)
151    }
152}
153
154#[derive(Debug)]
155pub struct PolicyContext<'a> {
156    pub tool: McpToolName,
157    pub agent_scope: AgentScope,
158    pub access_scope: AccessScope,
159    pub session_id: &'a SessionId,
160    pub user_id: &'a UserId,
161    pub tool_input: &'a McpToolInput,
162}
163
164/// A unit of governance evaluation for an MCP tool call.
165///
166/// Implementations are pure-sync and side-effect free; auditing happens
167/// outside the chain. First-deny-wins composition is provided by
168/// [`super::GovernanceChain`].
169pub trait GovernancePolicy: Send + Sync + fmt::Debug {
170    fn id(&self) -> PolicyId;
171    fn name(&self) -> &'static str;
172    fn description(&self) -> &'static str;
173    fn evaluate(&self, ctx: &PolicyContext<'_>) -> Decision;
174}
175
176/// Ordered chain of [`GovernancePolicy`] evaluated first-deny-wins.
177#[derive(Debug, Clone, Default)]
178pub struct GovernanceChain {
179    entries: Vec<Arc<dyn GovernancePolicy>>,
180}
181
182impl GovernanceChain {
183    #[must_use]
184    pub const fn new(entries: Vec<Arc<dyn GovernancePolicy>>) -> Self {
185        Self { entries }
186    }
187
188    pub fn push(&mut self, policy: Arc<dyn GovernancePolicy>) {
189        self.entries.push(policy);
190    }
191
192    #[must_use]
193    pub fn entries(&self) -> &[Arc<dyn GovernancePolicy>] {
194        &self.entries
195    }
196
197    #[must_use]
198    pub fn evaluate(&self, ctx: &PolicyContext<'_>) -> Decision {
199        for policy in &self.entries {
200            if let deny @ Decision::Deny { .. } = policy.evaluate(ctx) {
201                return deny;
202            }
203        }
204        Decision::Allow {
205            matched_by: crate::authz::types::MatchedBy::DefaultIncluded,
206        }
207    }
208}