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
14/// Name a submitted prompt is audited under, standing where a tool name would.
15pub const PROMPT_TARGET_NAME: &str = "user_prompt";
16
17/// Name an unidentifiable target is audited under.
18pub const UNKNOWN_TARGET_NAME: &str = "unknown";
19
20/// Untyped MCP tool input wrapped at the protocol boundary.
21///
22/// The MCP protocol mandates schema-less JSON for tool arguments — every tool
23/// defines its own input shape. This wrapper is the single point where
24/// governance reaches into that JSON; everywhere else the typed path is
25/// preferred. Callers extract fields via [`Self::as_str`] / [`Self::as_path`].
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(transparent)]
28pub struct McpToolInput(
29    // JSON: MCP-protocol boundary — schema-less tool arguments mandated by the spec.
30    serde_json::Value,
31);
32
33impl McpToolInput {
34    #[must_use]
35    pub const fn new(value: serde_json::Value) -> Self {
36        Self(value)
37    }
38
39    #[must_use]
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 { text: String },
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct GovernedString<'a> {
100    pub path: String,
101    pub value: &'a str,
102}
103
104impl GovernedInput {
105    #[must_use]
106    pub const fn tool_arguments(arguments: McpToolInput) -> Self {
107        Self::ToolArguments { arguments }
108    }
109
110    #[must_use]
111    pub const fn prompt(text: String) -> Self {
112        Self::Prompt { text }
113    }
114
115    #[must_use]
116    pub const fn location_kind(&self) -> &'static str {
117        match self {
118            Self::ToolArguments { .. } => "tool_input",
119            Self::Prompt { .. } => "prompt",
120        }
121    }
122
123    #[must_use]
124    pub const fn arguments(&self) -> Option<&McpToolInput> {
125        match self {
126            Self::ToolArguments { arguments } => Some(arguments),
127            Self::Prompt { .. } => None,
128        }
129    }
130
131    /// Every string the payload contains, paired with its dotted path.
132    ///
133    /// Scanners walk this rather than the raw JSON so that the path a finding
134    /// reports is defined once here, not reconstructed by each scanner.
135    #[must_use]
136    pub fn strings(&self) -> Vec<GovernedString<'_>> {
137        match self {
138            Self::ToolArguments { arguments } => {
139                let mut out = Vec::new();
140                collect_strings(arguments.as_value(), &mut String::new(), &mut out);
141                out
142            },
143            Self::Prompt { text } => vec![GovernedString {
144                path: PROMPT_PATH.to_owned(),
145                value: text,
146            }],
147        }
148    }
149}
150
151const PROMPT_PATH: &str = "text";
152
153fn collect_strings<'a>(
154    value: &'a serde_json::Value,
155    path: &mut String,
156    out: &mut Vec<GovernedString<'a>>,
157) {
158    match value {
159        serde_json::Value::String(s) => out.push(GovernedString {
160            path: path.clone(),
161            value: s,
162        }),
163        serde_json::Value::Array(items) => {
164            for (index, item) in items.iter().enumerate() {
165                let parent = path.len();
166                path.push_str(&format!("[{index}]"));
167                collect_strings(item, path, out);
168                path.truncate(parent);
169            }
170        },
171        serde_json::Value::Object(map) => {
172            for (key, item) in map {
173                let parent = path.len();
174                if !path.is_empty() {
175                    path.push('.');
176                }
177                path.push_str(key);
178                collect_strings(item, path, out);
179                path.truncate(parent);
180            }
181        },
182        _ => {},
183    }
184}