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    pub const fn new(value: serde_json::Value) -> Self {
34        Self(value)
35    }
36
37    #[must_use]
38    pub const fn as_value(&self) -> &serde_json::Value {
39        &self.0
40    }
41
42    #[must_use]
43    pub fn as_str(&self, field: &str) -> Option<&str> {
44        self.0.get(field).and_then(serde_json::Value::as_str)
45    }
46
47    #[must_use]
48    pub fn as_path(&self, field: &str) -> Option<&str> {
49        self.as_str(field)
50    }
51}
52
53/// What a governed call is asking the platform to do.
54///
55/// A prompt is a distinct variant rather than a reserved tool name, which would
56/// collide with any tool a deployment happened to name the same.
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(tag = "kind", rename_all = "snake_case")]
59pub enum GovernedTarget {
60    Tool { tool: McpToolName },
61    Prompt,
62    Unknown,
63}
64
65impl GovernedTarget {
66    #[must_use]
67    pub fn as_str(&self) -> &str {
68        match self {
69            Self::Tool { tool } => tool.as_str(),
70            Self::Prompt => PROMPT_TARGET_NAME,
71            Self::Unknown => UNKNOWN_TARGET_NAME,
72        }
73    }
74
75    #[must_use]
76    pub const fn tool(&self) -> Option<&McpToolName> {
77        match self {
78            Self::Tool { tool } => Some(tool),
79            Self::Prompt | Self::Unknown => None,
80        }
81    }
82}
83
84/// The payload a governance policy inspects.
85///
86/// A finding is reported against the surface it was found on, so arguments and
87/// prompt text stay separate variants rather than one JSON blob under a
88/// conventional key.
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(tag = "kind", rename_all = "snake_case")]
91pub enum GovernedInput {
92    ToolArguments { arguments: McpToolInput },
93    Prompt { parts: Vec<PromptPart> },
94}
95
96/// One text surface of a governed prompt submission, named by its source.
97///
98/// The path is where the text came from — `system`, `messages[2].user`,
99/// `forwarded.tools[0].description` — so a finding is reported against its
100/// true source, not an anonymous blob.
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102pub struct PromptPart {
103    pub path: String,
104    pub value: String,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct GovernedString<'a> {
109    pub path: String,
110    pub value: &'a str,
111}
112
113/// One non-container JSON value of a governed call's arguments, with the path
114/// it was found at.
115///
116/// Why: `strings()` cannot serve a policy that compares numbers, and a second
117/// traversal to reach them would be a second path grammar to keep in step with
118/// the first. This is the one walk; `strings()` is a filter over it.
119#[derive(Debug, Clone, PartialEq)]
120pub struct GovernedScalar<'a> {
121    pub path: String,
122    pub value: &'a serde_json::Value,
123}
124
125impl GovernedInput {
126    #[must_use]
127    pub const fn tool_arguments(arguments: McpToolInput) -> Self {
128        Self::ToolArguments { arguments }
129    }
130
131    #[must_use]
132    pub fn prompt_parts(parts: impl IntoIterator<Item = (String, String)>) -> Self {
133        Self::Prompt {
134            parts: parts
135                .into_iter()
136                .map(|(path, value)| PromptPart { path, value })
137                .collect(),
138        }
139    }
140
141    #[must_use]
142    pub fn prompt_text(text: String) -> Self {
143        Self::Prompt {
144            parts: vec![PromptPart {
145                path: PROMPT_PATH.to_owned(),
146                value: text,
147            }],
148        }
149    }
150
151    #[must_use]
152    pub const fn location_kind(&self) -> &'static str {
153        match self {
154            Self::ToolArguments { .. } => "tool_input",
155            Self::Prompt { .. } => "prompt",
156        }
157    }
158
159    #[must_use]
160    pub const fn arguments(&self) -> Option<&McpToolInput> {
161        match self {
162            Self::ToolArguments { arguments } => Some(arguments),
163            Self::Prompt { .. } => None,
164        }
165    }
166
167    #[must_use]
168    pub fn strings(&self) -> Vec<GovernedString<'_>> {
169        match self {
170            Self::ToolArguments { .. } => self
171                .scalars()
172                .into_iter()
173                .filter_map(|scalar| {
174                    scalar.value.as_str().map(|value| GovernedString {
175                        path: scalar.path,
176                        value,
177                    })
178                })
179                .collect(),
180            Self::Prompt { parts } => parts
181                .iter()
182                .map(|part| GovernedString {
183                    path: part.path.clone(),
184                    value: &part.value,
185                })
186                .collect(),
187        }
188    }
189
190    // Why: a prompt has no argument structure to address, so a condition that
191    // names a field can never be satisfied by one. Returning empty rather than
192    // the prompt's text keeps a path-addressed policy from matching a prompt on
193    // a coincidence of naming.
194    #[must_use]
195    pub fn scalars(&self) -> Vec<GovernedScalar<'_>> {
196        match self {
197            Self::ToolArguments { arguments } => {
198                let mut out = Vec::new();
199                collect_scalars(arguments.as_value(), &mut String::new(), &mut out);
200                out
201            },
202            Self::Prompt { .. } => Vec::new(),
203        }
204    }
205}
206
207const PROMPT_PATH: &str = "text";
208
209fn collect_scalars<'a>(
210    value: &'a serde_json::Value,
211    path: &mut String,
212    out: &mut Vec<GovernedScalar<'a>>,
213) {
214    match value {
215        serde_json::Value::Array(items) => {
216            for (index, item) in items.iter().enumerate() {
217                let parent = path.len();
218                path.push_str(&format!("[{index}]"));
219                collect_scalars(item, path, out);
220                path.truncate(parent);
221            }
222        },
223        serde_json::Value::Object(map) => {
224            for (key, item) in map {
225                let parent = path.len();
226                if !path.is_empty() {
227                    path.push('.');
228                }
229                path.push_str(key);
230                collect_scalars(item, path, out);
231                path.truncate(parent);
232            }
233        },
234        scalar => out.push(GovernedScalar {
235            path: path.clone(),
236            value: scalar,
237        }),
238    }
239}