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 { text: String },
94}
95
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct GovernedString<'a> {
98    pub path: String,
99    pub value: &'a str,
100}
101
102impl GovernedInput {
103    #[must_use]
104    pub const fn tool_arguments(arguments: McpToolInput) -> Self {
105        Self::ToolArguments { arguments }
106    }
107
108    #[must_use]
109    pub const fn prompt(text: String) -> Self {
110        Self::Prompt { text }
111    }
112
113    #[must_use]
114    pub const fn location_kind(&self) -> &'static str {
115        match self {
116            Self::ToolArguments { .. } => "tool_input",
117            Self::Prompt { .. } => "prompt",
118        }
119    }
120
121    #[must_use]
122    pub const fn arguments(&self) -> Option<&McpToolInput> {
123        match self {
124            Self::ToolArguments { arguments } => Some(arguments),
125            Self::Prompt { .. } => None,
126        }
127    }
128
129    #[must_use]
130    pub fn strings(&self) -> Vec<GovernedString<'_>> {
131        match self {
132            Self::ToolArguments { arguments } => {
133                let mut out = Vec::new();
134                collect_strings(arguments.as_value(), &mut String::new(), &mut out);
135                out
136            },
137            Self::Prompt { text } => vec![GovernedString {
138                path: PROMPT_PATH.to_owned(),
139                value: text,
140            }],
141        }
142    }
143}
144
145const PROMPT_PATH: &str = "text";
146
147fn collect_strings<'a>(
148    value: &'a serde_json::Value,
149    path: &mut String,
150    out: &mut Vec<GovernedString<'a>>,
151) {
152    match value {
153        serde_json::Value::String(s) => out.push(GovernedString {
154            path: path.clone(),
155            value: s,
156        }),
157        serde_json::Value::Array(items) => {
158            for (index, item) in items.iter().enumerate() {
159                let parent = path.len();
160                path.push_str(&format!("[{index}]"));
161                collect_strings(item, path, out);
162                path.truncate(parent);
163            }
164        },
165        serde_json::Value::Object(map) => {
166            for (key, item) in map {
167                let parent = path.len();
168                if !path.is_empty() {
169                    path.push('.');
170                }
171                path.push_str(key);
172                collect_strings(item, path, out);
173                path.truncate(parent);
174            }
175        },
176        _ => {},
177    }
178}