Skip to main content

systemprompt_security/policy/
governed.rs

1//! What a governed call asks for, and what it carries.
2//!
3//! The governance chain sees two kinds of call: an MCP tool invocation and a
4//! prompt the user submitted. Both reach the model and both are enforced, but
5//! they differ in what a policy may key on — a prompt names no tool — and in
6//! how a finding must be reported.
7//!
8//! Copyright (c) systemprompt.io — Business Source License 1.1.
9//! See <https://systemprompt.io> for licensing details.
10
11use serde::{Deserialize, Serialize};
12use systemprompt_identifiers::McpToolName;
13
14pub const PROMPT_TARGET_NAME: &str = "user_prompt";
15
16pub const UNKNOWN_TARGET_NAME: &str = "unknown";
17
18/// Untyped MCP tool input wrapped at the protocol boundary.
19///
20/// The MCP protocol mandates schema-less JSON for tool arguments — every tool
21/// defines its own input shape. This wrapper is the single point where
22/// governance reaches into that JSON; everywhere else the typed path is
23/// preferred. Callers extract fields via [`Self::as_str`] / [`Self::as_path`].
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(transparent)]
26pub struct McpToolInput(
27    // JSON: MCP-protocol boundary — schema-less tool arguments mandated by the spec.
28    serde_json::Value,
29);
30
31impl McpToolInput {
32    #[must_use]
33    // JSON: MCP tool arguments — schema-less by the MCP spec.
34    pub const fn new(value: serde_json::Value) -> Self {
35        Self(value)
36    }
37
38    #[must_use]
39    // JSON: MCP tool arguments — schema-less by the MCP spec.
40    pub const fn as_value(&self) -> &serde_json::Value {
41        &self.0
42    }
43
44    #[must_use]
45    pub fn as_str(&self, field: &str) -> Option<&str> {
46        self.0.get(field).and_then(serde_json::Value::as_str)
47    }
48
49    #[must_use]
50    pub fn as_path(&self, field: &str) -> Option<&str> {
51        self.as_str(field)
52    }
53}
54
55/// What a governed call is asking the platform to do.
56///
57/// A prompt is a distinct variant rather than a reserved tool name, which would
58/// collide with any tool a deployment happened to name the same.
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(tag = "kind", rename_all = "snake_case")]
61pub enum GovernedTarget {
62    Tool { tool: McpToolName },
63    Prompt,
64    Unknown,
65}
66
67impl GovernedTarget {
68    #[must_use]
69    pub fn as_str(&self) -> &str {
70        match self {
71            Self::Tool { tool } => tool.as_str(),
72            Self::Prompt => PROMPT_TARGET_NAME,
73            Self::Unknown => UNKNOWN_TARGET_NAME,
74        }
75    }
76
77    #[must_use]
78    pub const fn tool(&self) -> Option<&McpToolName> {
79        match self {
80            Self::Tool { tool } => Some(tool),
81            Self::Prompt | Self::Unknown => None,
82        }
83    }
84}
85
86/// The payload a governance policy inspects.
87///
88/// A finding is reported against the surface it was found on, so arguments and
89/// prompt text stay separate variants rather than one JSON blob under a
90/// conventional key.
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(tag = "kind", rename_all = "snake_case")]
93pub enum GovernedInput {
94    ToolArguments { arguments: McpToolInput },
95    Prompt { parts: Vec<PromptPart> },
96}
97
98/// One text surface of a governed prompt submission, named by its source.
99///
100/// The path is where the text came from — `system`, `messages[2].user`,
101/// `forwarded.tools[0].description` — so a finding is reported against its
102/// true source, not an anonymous blob.
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104pub struct PromptPart {
105    pub path: String,
106    pub value: String,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct GovernedString<'a> {
111    pub path: String,
112    pub value: &'a str,
113}
114
115/// One non-container JSON value of a governed call's arguments, with the path
116/// it was found at.
117#[derive(Debug, Clone, PartialEq)]
118pub struct GovernedScalar<'a> {
119    pub path: String,
120    // JSON: MCP tool-call arguments walked for governed scalar values.
121    pub value: &'a serde_json::Value,
122}
123
124impl GovernedInput {
125    #[must_use]
126    pub const fn tool_arguments(arguments: McpToolInput) -> Self {
127        Self::ToolArguments { arguments }
128    }
129
130    #[must_use]
131    pub fn prompt_parts(parts: impl IntoIterator<Item = (String, String)>) -> Self {
132        Self::Prompt {
133            parts: parts
134                .into_iter()
135                .map(|(path, value)| PromptPart { path, value })
136                .collect(),
137        }
138    }
139
140    #[must_use]
141    pub fn prompt_text(text: String) -> Self {
142        Self::Prompt {
143            parts: vec![PromptPart {
144                path: PROMPT_PATH.to_owned(),
145                value: text,
146            }],
147        }
148    }
149
150    #[must_use]
151    pub const fn location_kind(&self) -> &'static str {
152        match self {
153            Self::ToolArguments { .. } => "tool_input",
154            Self::Prompt { .. } => "prompt",
155        }
156    }
157
158    #[must_use]
159    pub const fn arguments(&self) -> Option<&McpToolInput> {
160        match self {
161            Self::ToolArguments { arguments } => Some(arguments),
162            Self::Prompt { .. } => None,
163        }
164    }
165
166    #[must_use]
167    pub fn strings(&self) -> Vec<GovernedString<'_>> {
168        match self {
169            Self::ToolArguments { .. } => self
170                .scalars()
171                .into_iter()
172                .filter_map(|scalar| {
173                    scalar.value.as_str().map(|value| GovernedString {
174                        path: scalar.path,
175                        value,
176                    })
177                })
178                .collect(),
179            Self::Prompt { parts } => parts
180                .iter()
181                .map(|part| GovernedString {
182                    path: part.path.clone(),
183                    value: &part.value,
184                })
185                .collect(),
186        }
187    }
188
189    #[must_use]
190    pub fn scalars(&self) -> Vec<GovernedScalar<'_>> {
191        match self {
192            Self::ToolArguments { arguments } => {
193                let mut out = Vec::new();
194                collect_scalars(arguments.as_value(), &mut String::new(), &mut out);
195                out
196            },
197            Self::Prompt { .. } => Vec::new(),
198        }
199    }
200}
201
202const PROMPT_PATH: &str = "text";
203
204// JSON: MCP tool-call arguments walked for governed scalar values.
205fn collect_scalars<'a>(
206    value: &'a serde_json::Value,
207    path: &mut String,
208    out: &mut Vec<GovernedScalar<'a>>,
209) {
210    match value {
211        serde_json::Value::Array(items) => {
212            for (index, item) in items.iter().enumerate() {
213                let parent = path.len();
214                path.push_str(&format!("[{index}]"));
215                collect_scalars(item, path, out);
216                path.truncate(parent);
217            }
218        },
219        serde_json::Value::Object(map) => {
220            for (key, item) in map {
221                let parent = path.len();
222                if !path.is_empty() {
223                    path.push('.');
224                }
225                path.push_str(key);
226                collect_scalars(item, path, out);
227                path.truncate(parent);
228            }
229        },
230        scalar => out.push(GovernedScalar {
231            path: path.clone(),
232            value: scalar,
233        }),
234    }
235}