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