mecha-core 0.1.17

Provider-agnostic agent harness: loop, tools, MCP client, sessions.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
//! A real MCP server, spawned and spoken to.
//!
//! The unit tests in `mcp.rs` inspect the `Command` before it is spawned, which
//! proves what we *intend* to hand a server. This proves what a server actually
//! receives, by asking one that reports everything it can see. The measurement
//! that motivated `env_clear()` was made exactly this way, by hand, once.
//!
//! Set `MECHA_TEST_REQUIRE_BACKENDS=1` to make a missing backend a failure.

mod support;

use mecha_core::config::{CapabilityOverride, McpServerConfig};
use mecha_core::mcp::McpClient;
use mecha_core::sandbox::{Backend, Sandbox, SandboxConfig};
use mecha_core::tool::{Tool, ToolCtx};
use serde_json::{json, Value};
use std::collections::BTreeSet;
use std::path::Path;
use std::sync::Arc;
use support::*;

const IMAGE: &str = "python:3-slim";

/// Always passed through: most runtimes cannot start without them.
const BASE: [&str; 5] = ["PATH", "HOME", "LANG", "LC_ALL", "TZ"];

/// Variables a child process adds to **itself** after exec, which
/// `env_clear()` therefore cannot prevent and which are not evidence of
/// anything crossing the boundary.
///
/// On macOS, CoreFoundation writes `__CF_USER_TEXT_ENCODING` into its own
/// environment during initialization, and the `python3` that runs the fixture
/// links CoreFoundation — so the server reports a variable nobody handed it.
/// It carries the user's numeric uid, which a child already has from
/// `getuid()`, so it discloses nothing the process could not ask for.
///
/// **Exempted by exact name and by target, never by prefix.** "Ignore
/// anything starting with `__`" would be a blanket over a class nobody has
/// enumerated — the silently-degrading-guard shape this test exists to
/// disprove. And the test asserts that each name here is genuinely absent
/// from what we hand over, so the exemption cannot come to cover a real leak
/// without failing.
#[cfg(target_os = "macos")]
const SELF_INFLICTED: [&str; 1] = ["__CF_USER_TEXT_ENCODING"];
#[cfg(not(target_os = "macos"))]
const SELF_INFLICTED: [&str; 0] = [];

fn unconfined() -> Sandbox {
    Sandbox::new(SandboxConfig::default())
}

fn server(command: &str, script: &Path) -> McpServerConfig {
    McpServerConfig {
        name: "nosy".into(),
        command: command.into(),
        args: vec![script.display().to_string()],
        ..Default::default()
    }
}

async fn tool_named(tools: &[Arc<dyn Tool>], name: &str) -> Arc<dyn Tool> {
    tools
        .iter()
        .find(|t| t.name() == name)
        .unwrap_or_else(|| panic!("no tool named {name}"))
        .clone()
}

async fn call(tool: &Arc<dyn Tool>, input: Value, workspace: &Path) -> String {
    let ctx = ToolCtx {
        workspace: workspace.to_path_buf(),
        ..Default::default()
    };
    let out = tool
        .call(input, &ctx)
        .await
        .expect("the call itself failed");
    assert!(
        !out.is_error,
        "the server reported an error: {}",
        out.content
    );
    // Provenance, not capability: taint keys off where a result actually came
    // from, and everything an MCP server returns came from outside.
    assert!(
        out.external,
        "an MCP result was not marked as coming from outside"
    );
    out.content
}

#[tokio::test]
async fn a_real_handshake_yields_the_servers_tools_namespaced_and_annotated() {
    if unavailable("python3", python3_available()) {
        return;
    }
    let dir = tmpdir("mcp-handshake");
    let cfg = server("python3", &fixture_server());

    let client = McpClient::connect(&cfg, &unconfined(), &dir)
        .await
        .expect("handshake failed");
    let tools = client.list_tools().await.expect("tools/list failed");

    // Namespaced, so two servers can each expose a `search`.
    let names: BTreeSet<&str> = tools.iter().map(|t| t.name()).collect();
    assert_eq!(
        names,
        BTreeSet::from(["nosy__environ", "nosy__probe", "nosy__touch"]),
        "the advertised tools did not survive the handshake"
    );

    // The annotations feed the interlock, so their mapping is worth pinning.
    let environ = tool_named(&tools, "nosy__environ").await;
    assert!(environ.read_only(), "readOnlyHint was dropped");
    assert!(
        !environ.capabilities().external_send,
        "an unannotated tool became a send sink"
    );
    assert!(
        !environ.capabilities().untrusted_input,
        "an unannotated tool would arm the interlock on every call"
    );

    let touch = tool_named(&tools, "nosy__touch").await;
    assert!(
        touch.capabilities().destructive,
        "destructiveHint was dropped"
    );
    assert!(!touch.read_only());

    std::fs::remove_dir_all(&dir).ok();
}

/// A third-party server decides how much the interlock distrusts it, which is
/// the wrong way round for anything reaching the open world.
#[tokio::test]
async fn a_servers_own_account_of_itself_can_be_widened_but_never_narrowed() {
    if unavailable("python3", python3_available()) {
        return;
    }
    let dir = tmpdir("mcp-caps");

    // The fixture annotates `environ` as readOnly and declares no open world —
    // exactly the shape of a Google Docs server that reads third-party
    // documents and can write into one an attacker can read.
    let plain = McpClient::connect(&server("python3", &fixture_server()), &unconfined(), &dir)
        .await
        .unwrap();
    let declared = tool_named(&plain.list_tools().await.unwrap(), "nosy__environ").await;
    assert!(!declared.capabilities().untrusted_input);
    assert!(!declared.capabilities().external_send);
    assert!(declared.read_only());

    let cfg = McpServerConfig {
        capabilities: CapabilityOverride {
            untrusted_input: true,
            external_send: true,
            ..Default::default()
        },
        ..server("python3", &fixture_server())
    };
    let forced = McpClient::connect(&cfg, &unconfined(), &dir).await.unwrap();
    let tools = forced.list_tools().await.unwrap();

    let environ = tool_named(&tools, "nosy__environ").await;
    assert!(
        environ.capabilities().untrusted_input,
        "the override did not widen"
    );
    assert!(environ.capabilities().external_send);
    // Widening applies to every tool the server exposes, not just the one that
    // looked risky — the point is that we no longer trust its self-report.
    assert!(
        tool_named(&tools, "nosy__probe")
            .await
            .capabilities()
            .untrusted_input
    );

    // And nothing the server declared for itself was switched off.
    let touch = tool_named(&tools, "nosy__touch").await;
    assert!(
        touch.capabilities().destructive,
        "a declared capability was narrowed"
    );

    // Distrusting what a tool *returns* says nothing about whether it writes.
    // These are orthogonal, and conflating them made every retrieval from a
    // read-only knowledge-graph server prompt for approval.
    assert!(
        environ.read_only(),
        "an untrusted-input override wrongly revoked read-only"
    );

    // A forced `destructive` does contradict it, and there the exemption goes.
    let cfg = McpServerConfig {
        capabilities: CapabilityOverride {
            destructive: true,
            ..Default::default()
        },
        ..server("python3", &fixture_server())
    };
    let strict = McpClient::connect(&cfg, &unconfined(), &dir).await.unwrap();
    let environ = tool_named(&strict.list_tools().await.unwrap(), "nosy__environ").await;
    assert!(
        !environ.read_only(),
        "a tool forced destructive kept its approval exemption"
    );

    std::fs::remove_dir_all(&dir).ok();
}

#[tokio::test]
async fn the_environment_a_server_actually_sees_is_the_allowlist() {
    if unavailable("python3", python3_available()) {
        return;
    }
    let dir = tmpdir("mcp-environ");
    let ours: BTreeSet<String> = std::env::vars().map(|(k, _)| k).collect();

    let Some(passthrough) = ours.iter().find(|k| !BASE.contains(&k.as_str())).cloned() else {
        return; // An environment this bare has nothing to leak.
    };

    let cfg = McpServerConfig {
        env: [("MECHA_EXPLICIT_TOKEN".to_string(), "granted".to_string())]
            .into_iter()
            .collect(),
        env_passthrough: vec![passthrough.clone()],
        ..server("python3", &fixture_server())
    };

    let client = McpClient::connect(&cfg, &unconfined(), &dir)
        .await
        .expect("handshake failed");
    let tools = client.list_tools().await.unwrap();
    let reported = call(&tool_named(&tools, "nosy__environ").await, json!({}), &dir).await;

    let seen: BTreeSet<String> = reported
        .lines()
        .filter_map(|l| l.split_once('=').map(|(k, _)| k.to_string()))
        .collect();

    let allowed: BTreeSet<String> = BASE
        .iter()
        .map(|s| s.to_string())
        .chain([passthrough.clone(), "MECHA_EXPLICIT_TOKEN".to_string()])
        .collect();

    // **First, our side of the boundary, which is the part this project
    // controls.** `mcp.rs` calls `env_clear()` and then hands over exactly
    // `child_env(passthrough) + cfg.env`, so anything self-inflicted below
    // must be absent from *that* — otherwise the exemption would be hiding
    // the very leak this test exists to catch, and the assertion under it
    // would be measuring the exemption rather than the code.
    let handed: BTreeSet<String> = Sandbox::child_env(&cfg.env_passthrough)
        .into_iter()
        .map(|(k, _)| k)
        .chain(cfg.env.keys().cloned())
        .collect();
    for name in SELF_INFLICTED {
        assert!(
            !handed.contains(name),
            "{name} is exempted below as self-inflicted, but we are handing it over"
        );
    }

    // Asserted as a subset rather than against a list of known secrets: the
    // bug was never about one variable. `envs()` layers onto the inherited
    // environment, so *everything* crossed, provider keys included.
    let leaked: Vec<_> = seen
        .difference(&allowed)
        .filter(|k| !SELF_INFLICTED.contains(&k.as_str()))
        .collect();
    assert!(
        leaked.is_empty(),
        "the server was handed variables nobody named: {leaked:?}"
    );

    assert!(
        seen.contains(&passthrough),
        "a named passthrough never arrived"
    );
    assert!(
        seen.contains("MECHA_EXPLICIT_TOKEN"),
        "an explicit value never arrived"
    );
    assert!(
        seen.len() < ours.len(),
        "the server holds as much as we do ({} vs {})",
        seen.len(),
        ours.len()
    );

    std::fs::remove_dir_all(&dir).ok();
}

#[tokio::test]
async fn a_confined_server_loses_the_network_and_your_home_but_keeps_the_workspace() {
    if unavailable("docker", docker_available()) || unavailable(IMAGE, docker_image_present(IMAGE))
    {
        return;
    }
    let dir = tmpdir("mcp-confined");

    // The server has to live inside the workspace: that is the only thing
    // mounted, which is the point being tested.
    let script = dir.join("nosy_mcp_server.py");
    std::fs::copy(fixture_server(), &script).unwrap();

    let cfg = McpServerConfig {
        sandbox: true,
        ..server("python3", &script)
    };
    let sandbox = Sandbox::new(SandboxConfig {
        kind: Backend::Docker,
        image: IMAGE.into(),
        ..Default::default()
    });

    let client = McpClient::connect(&cfg, &sandbox, &dir)
        .await
        .expect("confined handshake failed");
    let tools = client.list_tools().await.unwrap();
    let probe = call(&tool_named(&tools, "nosy__probe").await, json!({}), &dir).await;
    let probe: Value = serde_json::from_str(&probe).expect("probe returned non-JSON");

    assert_eq!(
        probe["network"],
        json!(false),
        "a confined server reached the network"
    );
    assert_eq!(
        probe["home_ssh_exists"],
        json!(false),
        "a confined server can see your ssh keys"
    );
    assert_ne!(probe["uid"], json!(0), "a confined server runs as root");

    // These negatives only mean something on a machine where the positives
    // hold unconfined, so pin the one fact that is unambiguous either way: a
    // confined server does not share the host's UTS namespace.
    let host = std::process::Command::new("hostname").output().unwrap();
    let host = String::from_utf8_lossy(&host.stdout).trim().to_string();
    assert_ne!(
        probe["hostname"],
        json!(host),
        "the server ran outside the sandbox"
    );

    // A confined server sees the *workspace*, which is the documented trade:
    // confined against your home directory, not against your project.
    assert_eq!(probe["cwd"], json!(dir.display().to_string()));

    std::fs::remove_dir_all(&dir).ok();
}

#[tokio::test]
async fn a_confined_server_leaves_files_you_still_own() {
    if unavailable("docker", docker_available()) || unavailable(IMAGE, docker_image_present(IMAGE))
    {
        return;
    }
    let dir = tmpdir("mcp-confined-write");
    let script = dir.join("nosy_mcp_server.py");
    std::fs::copy(fixture_server(), &script).unwrap();

    let cfg = McpServerConfig {
        sandbox: true,
        ..server("python3", &script)
    };
    let sandbox = Sandbox::new(SandboxConfig {
        kind: Backend::Docker,
        image: IMAGE.into(),
        ..Default::default()
    });

    let client = McpClient::connect(&cfg, &sandbox, &dir)
        .await
        .expect("confined handshake failed");
    let tools = client.list_tools().await.unwrap();
    let touch = tool_named(&tools, "nosy__touch").await;
    call(&touch, json!({"name": "from-the-server.txt"}), &dir).await;

    let written = dir.join("from-the-server.txt");
    assert!(
        written.exists(),
        "the confined server's write never reached the workspace"
    );

    #[cfg(unix)]
    {
        use std::os::unix::fs::MetadataExt;
        // Without `--user` the container writes as root and leaves files on
        // your disk you cannot delete.
        assert_eq!(
            std::fs::metadata(&written).unwrap().uid(),
            unsafe { libc::getuid() },
            "the confined server left a file you do not own"
        );
    }

    std::fs::remove_dir_all(&dir).ok();
}

#[tokio::test]
async fn a_server_asking_for_confinement_with_no_backend_never_starts() {
    if unavailable("python3", python3_available()) {
        return;
    }
    let dir = tmpdir("mcp-unconfinable");
    let cfg = McpServerConfig {
        sandbox: true,
        ..server("python3", &fixture_server())
    };

    // Through the real entry point, not just the builder: a server that asked
    // to be confined and quietly was not is the failure this refuses.
    let err = match McpClient::connect(&cfg, &unconfined(), &dir).await {
        Ok(_) => panic!("a server that asked to be confined was started unconfined"),
        Err(e) => e.to_string(),
    };
    assert!(
        err.contains("no sandbox backend is set"),
        "unexpected error: {err}"
    );

    std::fs::remove_dir_all(&dir).ok();
}