Skip to main content

assay_core/mcp/
proxy.rs

1mod client;
2mod decisions;
3mod server;
4mod tools;
5
6use self::client::run_client_to_server;
7use self::server::run_server_to_client;
8use super::decision::{DecisionEmitter, FileDecisionEmitter, NullDecisionEmitter};
9use super::policy::McpPolicy;
10use super::tool_definition::ToolDefinitionBinding;
11use std::{
12    collections::HashMap,
13    io,
14    process::{Child, Command, Stdio},
15    sync::{Arc, Mutex},
16    thread,
17};
18
19/// Validated proxy configuration.
20///
21/// Use `ProxyConfig::try_from_raw()` to create from CLI/config input.
22#[derive(Clone, Debug)]
23pub struct ProxyConfig {
24    pub dry_run: bool,
25    pub verbose: bool,
26    /// NDJSON log for mandate lifecycle events (audit trail)
27    pub audit_log_path: Option<std::path::PathBuf>,
28    pub server_id: String,
29    /// NDJSON log for tool decision events (high volume)
30    pub decision_log_path: Option<std::path::PathBuf>,
31    /// CloudEvents source URI (validated, required when logging enabled)
32    pub event_source: Option<String>,
33    /// EXPERIMENTAL (unstable): opt-in tool-decision-truth carrier producer. `None` disables it. The
34    /// secret HMAC key lives inside [`TdtProducer`], whose manual `Debug` redacts it, so deriving `Debug`
35    /// on this config does not leak the key.
36    pub tdt_producer: Option<TdtProducer>,
37}
38
39/// EXPERIMENTAL (unstable): opt-in producer of `assay.tool_decision_truth.v0` carriers, written
40/// append-only as NDJSON (one carrier per line) to a sink kept separate from — never folded into — the
41/// decision log. The decision log is operational decision output; a carrier is a content-addressed
42/// evidence record with its own HMAC/key semantics and claim ceiling, so the two stay distinct. The
43/// producer is evidence-only: it takes no runtime action on the verdict and never enforces or blocks.
44///
45/// The HMAC key is held in memory only and is never logged or persisted; the manual `Debug` impl redacts
46/// it so it cannot leak through a `{:?}` of the surrounding [`ProxyConfig`].
47#[derive(Clone)]
48pub struct TdtProducer {
49    out_path: std::path::PathBuf,
50    key: Vec<u8>,
51    key_id: String,
52}
53
54impl TdtProducer {
55    /// Build a producer from a sink path and key material. The caller is responsible for failing closed
56    /// (before constructing this) when the producer is enabled but the key/key_id is missing or
57    /// malformed; this type does not re-validate beyond what `tool_decision_truth::args_digest` enforces
58    /// at digest time.
59    pub fn new(out_path: std::path::PathBuf, key: Vec<u8>, key_id: String) -> Self {
60        Self {
61            out_path,
62            key,
63            key_id,
64        }
65    }
66
67    /// Append-only NDJSON sink for minted carriers.
68    pub(crate) fn out_path(&self) -> &std::path::Path {
69        &self.out_path
70    }
71
72    /// HMAC key for `args_digest` (secret; never logged or persisted).
73    pub(crate) fn key(&self) -> &[u8] {
74        &self.key
75    }
76
77    /// Stable key identifier bound into the digest prefix.
78    pub(crate) fn key_id(&self) -> &str {
79        &self.key_id
80    }
81}
82
83impl std::fmt::Debug for TdtProducer {
84    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85        f.debug_struct("TdtProducer")
86            .field("out_path", &self.out_path)
87            .field("key", &"<redacted>")
88            .field("key_id", &self.key_id)
89            .finish()
90    }
91}
92
93/// Raw config as provided by CLI/config files before validation.
94#[derive(Clone, Debug, Default)]
95pub struct ProxyConfigRaw {
96    pub dry_run: bool,
97    pub verbose: bool,
98    pub audit_log_path: Option<std::path::PathBuf>,
99    pub server_id: String,
100    pub decision_log_path: Option<std::path::PathBuf>,
101    pub event_source: Option<String>,
102}
103
104impl ProxyConfig {
105    /// Create validated config from raw input.
106    ///
107    /// Fails if:
108    /// - Logging is enabled but event_source is missing
109    /// - event_source is not a valid absolute URI (scheme://...)
110    pub fn try_from_raw(raw: ProxyConfigRaw) -> anyhow::Result<Self> {
111        let logging_enabled = raw.audit_log_path.is_some() || raw.decision_log_path.is_some();
112
113        let event_source = raw
114            .event_source
115            .map(|s| s.trim().to_string())
116            .filter(|s| !s.is_empty());
117
118        if logging_enabled && event_source.is_none() {
119            anyhow::bail!(
120                "event_source is required when logging is enabled (e.g. --event-source assay://org/app)"
121            );
122        }
123
124        if let Some(ref src) = event_source {
125            validate_event_source(src)?;
126        }
127
128        Ok(ProxyConfig {
129            dry_run: raw.dry_run,
130            verbose: raw.verbose,
131            audit_log_path: raw.audit_log_path,
132            server_id: raw.server_id,
133            decision_log_path: raw.decision_log_path,
134            event_source,
135            // Opt-in producer is wired by the caller after validation (env key, fail-closed), never
136            // from the raw CLI/config struct.
137            tdt_producer: None,
138        })
139    }
140}
141
142/// Validate event_source URI (must be absolute with scheme://).
143fn validate_event_source(s: &str) -> anyhow::Result<()> {
144    let s = s.trim();
145    if s.is_empty() {
146        anyhow::bail!("event_source must be absolute URI with scheme (e.g. assay://org/app)");
147    }
148    if s.chars().any(|c| c.is_whitespace()) {
149        anyhow::bail!("event_source must not contain whitespace");
150    }
151
152    // Require scheme://...
153    let Some(pos) = s.find("://") else {
154        anyhow::bail!("event_source must be absolute URI with scheme (e.g. assay://org/app)");
155    };
156    if pos == 0 {
157        anyhow::bail!("event_source must have scheme before :// (e.g. assay://org/app)");
158    }
159
160    // Validate scheme charset (RFC 3986: ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ))
161    let scheme = &s[..pos];
162    let mut chars = scheme.chars();
163    match chars.next() {
164        Some(c) if c.is_ascii_alphabetic() => {}
165        _ => anyhow::bail!("event_source URI scheme must start with a letter"),
166    }
167    if !chars.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.') {
168        anyhow::bail!("event_source URI scheme contains invalid characters");
169    }
170
171    Ok(())
172}
173
174pub struct McpProxy {
175    child: Child,
176    policy: McpPolicy,
177    config: ProxyConfig,
178    /// Cache of tool identities discovered during tools/list
179    identity_cache: Arc<Mutex<HashMap<String, super::identity::ToolIdentity>>>,
180    /// Cache of bounded tool-definition bindings discovered during tools/list
181    tool_definition_cache: Arc<Mutex<HashMap<String, ToolDefinitionBinding>>>,
182}
183
184impl Drop for McpProxy {
185    fn drop(&mut self) {
186        // Best-effort cleanup
187        let _ = self.child.kill();
188    }
189}
190
191impl McpProxy {
192    pub fn spawn(
193        command: &str,
194        args: &[String],
195        policy: McpPolicy,
196        config: ProxyConfig,
197    ) -> io::Result<Self> {
198        let child = Command::new(command)
199            .args(args)
200            .stdin(Stdio::piped())
201            .stdout(Stdio::piped())
202            .stderr(Stdio::inherit()) // protocol blijft op stdout
203            .spawn()?;
204
205        Ok(Self {
206            child,
207            policy,
208            config,
209            identity_cache: Arc::new(Mutex::new(HashMap::new())),
210            tool_definition_cache: Arc::new(Mutex::new(HashMap::new())),
211        })
212    }
213
214    pub fn run(mut self) -> io::Result<i32> {
215        let child_stdin = self.child.stdin.take().expect("child stdin");
216        let child_stdout = self.child.stdout.take().expect("child stdout");
217
218        let stdout = Arc::new(Mutex::new(io::stdout()));
219        let policy = self.policy.clone();
220        let config = self.config.clone();
221        let identity_cache_a = self.identity_cache.clone();
222        let identity_cache_b = self.identity_cache.clone();
223        let tool_definition_cache_a = self.tool_definition_cache.clone();
224        let tool_definition_cache_b = self.tool_definition_cache.clone();
225
226        // Initialize decision emitter (I1: always emit decision)
227        let decision_emitter: Arc<dyn DecisionEmitter> =
228            if let Some(path) = &config.decision_log_path {
229                Arc::new(FileDecisionEmitter::new(path)?)
230            } else {
231                Arc::new(NullDecisionEmitter)
232            };
233        let event_source = config
234            .event_source
235            .clone()
236            .unwrap_or_else(|| format!("assay://{}", config.server_id));
237
238        // Thread A: server -> client passthrough
239        let stdout_a = stdout.clone();
240        let server_id_a = config.server_id.clone();
241        let t_server_to_client = thread::spawn(move || {
242            run_server_to_client(
243                child_stdout,
244                stdout_a,
245                server_id_a,
246                identity_cache_a,
247                tool_definition_cache_a,
248            )
249        });
250
251        // Thread B: client -> server passthrough with Policy Check
252        let stdout_b = stdout.clone();
253        let emitter_b = decision_emitter.clone();
254        let event_source_b = event_source.clone();
255        let t_client_to_server = thread::spawn(move || {
256            run_client_to_server(
257                child_stdin,
258                stdout_b,
259                policy,
260                config,
261                emitter_b,
262                event_source_b,
263                identity_cache_b,
264                tool_definition_cache_b,
265            )
266        });
267
268        // Wacht tot client->server eindigt (stdin closed)
269        t_client_to_server
270            .join()
271            .map_err(|_| io::Error::other("client->server thread panicked"))??;
272
273        // Server->client thread kan nog even lopen; join best-effort
274        let _ = t_server_to_client.join();
275
276        // Wacht op child exit
277        let status = self.child.wait()?;
278        Ok(status.code().unwrap_or(1))
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    #[test]
287    fn event_source_accepts_assay_uri() {
288        validate_event_source("assay://myorg/myapp").unwrap();
289    }
290
291    #[test]
292    fn event_source_accepts_https_uri() {
293        validate_event_source("https://example.com/agent").unwrap();
294    }
295
296    #[test]
297    fn event_source_rejects_empty() {
298        assert!(validate_event_source("").is_err());
299        assert!(validate_event_source("   ").is_err());
300    }
301
302    #[test]
303    fn event_source_rejects_whitespace() {
304        assert!(validate_event_source("assay://myorg/my app").is_err());
305        assert!(validate_event_source("assay://myorg/\tmyapp").is_err());
306    }
307
308    #[test]
309    fn event_source_rejects_missing_scheme() {
310        assert!(validate_event_source("myorg/myapp").is_err());
311        assert!(validate_event_source("://myorg/myapp").is_err());
312    }
313
314    #[test]
315    fn event_source_rejects_did_and_urn() {
316        // We require scheme:// not just scheme:
317        assert!(validate_event_source("did:example:123").is_err());
318        assert!(validate_event_source("urn:example:foo").is_err());
319    }
320
321    #[test]
322    fn event_source_rejects_scheme_starting_with_non_letter() {
323        assert!(validate_event_source("1assay://myorg/myapp").is_err());
324        assert!(validate_event_source("-assay://myorg/myapp").is_err());
325    }
326
327    #[test]
328    fn event_source_rejects_scheme_with_invalid_chars() {
329        assert!(validate_event_source("as_say://myorg/myapp").is_err());
330        assert!(validate_event_source("as@say://myorg/myapp").is_err());
331    }
332
333    #[test]
334    fn config_requires_event_source_when_logging_enabled() {
335        let raw = ProxyConfigRaw {
336            dry_run: false,
337            verbose: false,
338            audit_log_path: None,
339            decision_log_path: Some(std::path::PathBuf::from("decisions.ndjson")),
340            event_source: None,
341            server_id: "srv".to_string(),
342        };
343
344        let err = ProxyConfig::try_from_raw(raw).unwrap_err();
345        let msg = format!("{err:#}");
346        assert!(msg.contains("event_source is required"));
347    }
348
349    #[test]
350    fn config_allows_no_event_source_when_logging_disabled() {
351        let raw = ProxyConfigRaw {
352            dry_run: false,
353            verbose: false,
354            audit_log_path: None,
355            decision_log_path: None,
356            event_source: None,
357            server_id: "srv".to_string(),
358        };
359
360        ProxyConfig::try_from_raw(raw).unwrap();
361    }
362
363    #[test]
364    fn config_accepts_valid_event_source() {
365        let raw = ProxyConfigRaw {
366            dry_run: false,
367            verbose: false,
368            audit_log_path: None,
369            decision_log_path: Some(std::path::PathBuf::from("decisions.ndjson")),
370            event_source: Some("assay://myorg/myapp".to_string()),
371            server_id: "srv".to_string(),
372        };
373
374        let cfg = ProxyConfig::try_from_raw(raw).unwrap();
375        assert_eq!(cfg.event_source.as_deref(), Some("assay://myorg/myapp"));
376    }
377
378    #[test]
379    fn config_rejects_invalid_event_source_uri() {
380        let raw = ProxyConfigRaw {
381            dry_run: false,
382            verbose: false,
383            audit_log_path: None,
384            decision_log_path: Some(std::path::PathBuf::from("decisions.ndjson")),
385            event_source: Some("not a uri".to_string()),
386            server_id: "srv".to_string(),
387        };
388
389        assert!(ProxyConfig::try_from_raw(raw).is_err());
390    }
391}