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
113impl GovernedInput {
114    #[must_use]
115    pub const fn tool_arguments(arguments: McpToolInput) -> Self {
116        Self::ToolArguments { arguments }
117    }
118
119    #[must_use]
120    pub fn prompt_parts(parts: impl IntoIterator<Item = (String, String)>) -> Self {
121        Self::Prompt {
122            parts: parts
123                .into_iter()
124                .map(|(path, value)| PromptPart { path, value })
125                .collect(),
126        }
127    }
128
129    #[must_use]
130    pub fn prompt_text(text: String) -> Self {
131        Self::Prompt {
132            parts: vec![PromptPart {
133                path: PROMPT_PATH.to_owned(),
134                value: text,
135            }],
136        }
137    }
138
139    #[must_use]
140    pub const fn location_kind(&self) -> &'static str {
141        match self {
142            Self::ToolArguments { .. } => "tool_input",
143            Self::Prompt { .. } => "prompt",
144        }
145    }
146
147    #[must_use]
148    pub const fn arguments(&self) -> Option<&McpToolInput> {
149        match self {
150            Self::ToolArguments { arguments } => Some(arguments),
151            Self::Prompt { .. } => None,
152        }
153    }
154
155    #[must_use]
156    pub fn strings(&self) -> Vec<GovernedString<'_>> {
157        match self {
158            Self::ToolArguments { arguments } => {
159                let mut out = Vec::new();
160                collect_strings(arguments.as_value(), &mut String::new(), &mut out);
161                out
162            },
163            Self::Prompt { parts } => parts
164                .iter()
165                .map(|part| GovernedString {
166                    path: part.path.clone(),
167                    value: &part.value,
168                })
169                .collect(),
170        }
171    }
172}
173
174const PROMPT_PATH: &str = "text";
175
176fn collect_strings<'a>(
177    value: &'a serde_json::Value,
178    path: &mut String,
179    out: &mut Vec<GovernedString<'a>>,
180) {
181    match value {
182        serde_json::Value::String(s) => out.push(GovernedString {
183            path: path.clone(),
184            value: s,
185        }),
186        serde_json::Value::Array(items) => {
187            for (index, item) in items.iter().enumerate() {
188                let parent = path.len();
189                path.push_str(&format!("[{index}]"));
190                collect_strings(item, path, out);
191                path.truncate(parent);
192            }
193        },
194        serde_json::Value::Object(map) => {
195            for (key, item) in map {
196                let parent = path.len();
197                if !path.is_empty() {
198                    path.push('.');
199                }
200                path.push_str(key);
201                collect_strings(item, path, out);
202                path.truncate(parent);
203            }
204        },
205        _ => {},
206    }
207}