Skip to main content

perspt_sdk/toolset/
external.rs

1//! External tool providers under the kernel (PSP-9 system 13).
2//!
3//! A platform must accept tools it did not write — and doing that *after*
4//! the kernel exists is the difference between an extension point and a
5//! hole. Three properties external tools break by default in naive
6//! implementations are enforced here:
7//!
8//! * **Registration never mints authority.** The session must already hold a
9//!   capability for the declared effect; an external server can never exceed
10//!   the user's own grant (Theorem 1, and Theorem 2 for a server that
11//!   rewrites its own manifest).
12//! * **An undeclared footprint fails closed**: the tool is classified as
13//!   `RunShell` with an opaque footprint, so it serializes against
14//!   everything and requires an explicit capability.
15//! * Tool descriptions are untrusted text; they enter conversations as
16//!   observations and cannot alter capabilities, policy, or the catalog's
17//!   effect mapping — the caller records them, this module only validates.
18
19use super::entry::{ToolEntry, ToolOrigin};
20use super::footprint::FootprintSpec;
21use crate::capability::{Capability, EffectKind, RiskClass};
22use crate::error::{Result, SdkError};
23
24/// A tool declaration received from an external server, before admission.
25#[derive(Debug, Clone, PartialEq)]
26pub struct ExternalToolDeclaration {
27    pub name: String,
28    pub description: String,
29    pub effect: EffectKind,
30    pub risk: RiskClass,
31    pub schema: serde_json::Value,
32    /// `None` means the server declared nothing: fail closed.
33    pub footprint: Option<FootprintSpec>,
34}
35
36/// Admit one external declaration into catalog entries the kernel governs.
37///
38/// The session's held capabilities bound what may be registered: a
39/// declaration whose effect no capability grants is rejected outright —
40/// there is no "register now, ask later" path.
41pub fn admit_external_tool(
42    server_id: &str,
43    declaration: ExternalToolDeclaration,
44    session_capabilities: &[Capability],
45) -> Result<ToolEntry> {
46    // Fail closed on an undeclared footprint: RunShell + opaque.
47    let (effect, risk, footprint) = match declaration.footprint {
48        Some(footprint) => (declaration.effect, declaration.risk, footprint),
49        None => (
50            EffectKind::RunShell,
51            RiskClass::High,
52            FootprintSpec::opaque(),
53        ),
54    };
55
56    let granted = session_capabilities
57        .iter()
58        .any(|c| c.effects.contains(&effect));
59    if !granted {
60        return Err(SdkError::Domain(format!(
61            "external server {server_id:?} declared tool {:?} with effect {effect:?}, \
62             which no session capability grants; registration never mints authority",
63            declaration.name
64        )));
65    }
66
67    let entry = ToolEntry {
68        name: declaration.name,
69        description: declaration.description,
70        discovery_summary: String::new(),
71        description_templates: None,
72        effect,
73        risk,
74        schema: declaration.schema,
75        footprint,
76        proposal_bindings: Vec::new(),
77        // External calls are external effects: bracketed in the
78        // write-ahead external-effect log (R5).
79        durable: true,
80        origin: ToolOrigin::External(server_id.to_string()),
81        // External servers stay behind discovery — the hot set is the
82        // domain's own operating loop.
83        hot: false,
84    };
85    entry.validate()?;
86    Ok(entry)
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92    use crate::capability::ActorId;
93    use crate::scheduler::Resource;
94    use crate::toolset::footprint::{AccessMode, ResourceSelector};
95
96    fn declaration(
97        effect: EffectKind,
98        footprint: Option<FootprintSpec>,
99    ) -> ExternalToolDeclaration {
100        ExternalToolDeclaration {
101            name: "company_search".into(),
102            description: "Search the internal index".into(),
103            effect,
104            risk: RiskClass::Low,
105            schema: serde_json::json!({
106                "type": "object",
107                "properties": {"query": {"type": "string"}},
108                "required": ["query"],
109            }),
110            footprint,
111        }
112    }
113
114    fn session_with(effects: Vec<EffectKind>) -> Vec<Capability> {
115        vec![Capability::new(ActorId::new("session"), effects)]
116    }
117
118    #[test]
119    fn a_declared_tool_within_session_authority_registers() {
120        let entry = admit_external_tool(
121            "mcp-1",
122            declaration(EffectKind::Search, Some(FootprintSpec::default())),
123            &session_with(vec![EffectKind::Search]),
124        )
125        .unwrap();
126        assert_eq!(entry.origin, ToolOrigin::External("mcp-1".into()));
127        assert_eq!(entry.effect, EffectKind::Search);
128    }
129
130    #[test]
131    fn registration_never_exceeds_session_authority() {
132        // The server declares a network effect the session does not hold.
133        let result = admit_external_tool(
134            "mcp-1",
135            declaration(EffectKind::NetworkFetch, Some(FootprintSpec::opaque())),
136            &session_with(vec![EffectKind::Search]),
137        );
138        assert!(result.is_err(), "an external server cannot widen authority");
139    }
140
141    #[test]
142    fn an_undeclared_footprint_classifies_as_run_shell_and_opaque() {
143        // Even though the server *claims* Search, no footprint means the
144        // declaration fails closed to RunShell — which this session does not
145        // hold, so it is rejected.
146        let rejected = admit_external_tool(
147            "mcp-1",
148            declaration(EffectKind::Search, None),
149            &session_with(vec![EffectKind::Search]),
150        );
151        assert!(rejected.is_err());
152
153        // A session explicitly holding RunShell may register it, and the
154        // footprint serializes against everything.
155        let entry = admit_external_tool(
156            "mcp-1",
157            declaration(EffectKind::Search, None),
158            &session_with(vec![EffectKind::RunShell]),
159        )
160        .unwrap();
161        assert_eq!(entry.effect, EffectKind::RunShell);
162        let resolved = entry.footprint.resolve(&serde_json::json!({}), "p");
163        assert!(resolved.writes.contains(&Resource::OpaqueWorkspace));
164    }
165
166    #[test]
167    fn an_invalid_declared_footprint_is_rejected_at_admission() {
168        let bad_footprint = FootprintSpec::new(vec![ResourceSelector::PathArgument {
169            field: "undeclared_arg".into(),
170            access: AccessMode::Write,
171        }]);
172        let result = admit_external_tool(
173            "mcp-1",
174            declaration(EffectKind::Search, Some(bad_footprint)),
175            &session_with(vec![EffectKind::Search]),
176        );
177        assert!(result.is_err());
178    }
179}