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