assay-cli 5.3.0

Policy-as-code gate for MCP agent tool calls, with verifiable evidence and Linux kernel enforcement.
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
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
use super::profile::maybe_profile_finish;
use crate::cli::args::SandboxArgs;
use crate::env_filter::EnvFilterResult;
use crate::exit_codes;
use crate::metrics;
use crate::profile::{events::ProfileEvent, ProfileCollector};
use std::path::Path;
use std::process::Stdio;
use tokio::time::Duration;

pub(super) async fn run_child(
    args: &SandboxArgs,
    policy: &crate::policy::Policy,
    env_result: &EnvFilterResult,
    tmp_dir: &Path,
    cwd: &Path,
    profiler: Option<ProfileCollector>,
    actual_enforcement: bool,
) -> anyhow::Result<i32> {
    let cmd_name = &args.command[0];
    let cmd_args = &args.command[1..];

    if let Some(p) = &profiler {
        let home = std::env::var("HOME").ok().map(std::path::PathBuf::from);
        let resolved_cmd = resolve_command_path(cmd_name);
        let g = crate::profile::generalize::generalize_path(
            &resolved_cmd,
            cwd,
            home.as_deref(),
            Some(tmp_dir),
        );
        p.record(ProfileEvent::ExecObserved { argv0: g.rendered });
    }

    #[cfg(not(target_os = "linux"))]
    let _ = actual_enforcement;

    let mut cmd = tokio::process::Command::new(cmd_name);

    cmd.args(cmd_args)
        .stdin(Stdio::inherit())
        .stdout(Stdio::inherit())
        .stderr(Stdio::inherit())
        .current_dir(cwd);

    cmd.env_clear();
    for (key, value) in &env_result.filtered_env {
        cmd.env(key, value);
        if let Some(p) = &profiler {
            p.record(ProfileEvent::EnvProvidedKeys {
                key: key.clone(),
                scrubbed: false,
            });
        }
    }
    cmd.env("TMPDIR", tmp_dir);
    cmd.env("TMP", tmp_dir);
    cmd.env("TEMP", tmp_dir);

    // Landlock-net enforcement plan. `Some(ports)` builds a combined FS+NET ruleset; a rejected
    // policy fails closed BEFORE spawn with a `failed` enforcement_health.v1 artifact.
    #[cfg(target_os = "linux")]
    let net_allow_ports: Option<Vec<u16>> = if actual_enforcement && args.enforce_net {
        let abi = crate::backend::detect_backend().1.abi_version;
        match crate::landlock_net::plan_landlock_net_ports(&policy.net) {
            Ok(ports) => Some(ports),
            Err(rejects) => {
                let reason = net_reject_to_reason_code(&rejects);
                let detail = rejects
                    .iter()
                    .map(|r| format!("{}: {}", r.reason.as_str(), r.entry))
                    .collect::<Vec<_>>()
                    .join("; ");
                let health = crate::enforcement_health_v1::EnforcementHealthV1::landlock_failed(
                    abi,
                    reason,
                    detail,
                    abi >= 4,
                    false,
                );
                write_enforcement_health_v1(args, &health)?;
                if !args.quiet {
                    eprintln!(
                        "ERROR: network policy is not Landlock-net enforceable (fail-closed)"
                    );
                }
                return Ok(exit_codes::WOULD_BLOCK);
            }
        }
    } else {
        None
    };

    #[cfg(target_os = "linux")]
    let enforcer_opt = if actual_enforcement {
        Some(crate::backend::prepare_landlock(
            policy,
            tmp_dir,
            net_allow_ports.as_deref(),
        )?)
    } else {
        None
    };

    #[cfg(all(target_os = "linux", target_family = "unix"))]
    {
        if let Some(mut enforcer) = enforcer_opt {
            unsafe {
                cmd.pre_exec(move || {
                    enforcer.enforce()?;
                    Ok(())
                });
            }
        }
    }

    // The child→parent ack is std's pre_exec error channel: if `enforce()` (no_new_privs +
    // restrict_self) returns an error in the child, the closure fails and `spawn()` returns that
    // error, so we never record `restrict_self_confirmed` on an unenforced child.
    let spawn_result = cmd.spawn();

    #[cfg(target_os = "linux")]
    if actual_enforcement && args.enforce_net {
        let abi = crate::backend::detect_backend().1.abi_version;
        match &spawn_result {
            Ok(_) => {
                let ports = net_allow_ports.clone().unwrap_or_default();
                // Optional self-probe: only a proven real block (EACCES + listener-not-reached)
                // writes the probe; a non-proving probe is reported, never silently dropped, and
                // never fails the run (restrict_self on the workload child was confirmed).
                let probe = if args.probe_enforcement {
                    run_enforcement_self_probe(&ports, args.quiet)
                } else {
                    None
                };
                // Measured only when a seal is being asked for. The probe forks twice and runs the
                // CVE-2024-42318 sequence against a throwaway pair; there is no reason to pay for
                // it on a run that will not carry the answer, and a field nobody reads is a field
                // that drifts.
                let shedding = if args.aee_seal.is_some() {
                    match crate::backend::landlock_shedding_probe() {
                        Ok(outcome) => {
                            if !args.quiet
                                && outcome != crate::backend::SheddingProbeOutcome::RestrictionsHeld
                            {
                                eprintln!(
                                    "WARN: aee-seal: restriction-shedding probe returned {:?}; this run cannot claim still-armed",
                                    outcome
                                );
                            }
                            Some(outcome.label().to_string())
                        }
                        Err(e) => {
                            if !args.quiet {
                                eprintln!(
                                    "WARN: aee-seal: restriction-shedding probe could not run: {e}"
                                );
                            }
                            Some(
                                crate::backend::SheddingProbeOutcome::Inconclusive("probe_error")
                                    .label()
                                    .to_string(),
                            )
                        }
                    }
                } else {
                    None
                };
                let health = crate::enforcement_health_v1::EnforcementHealthV1::landlock_active(
                    abi,
                    ports.clone(),
                    probe,
                    shedding,
                );
                write_enforcement_health_v1(args, &health)?;
                maybe_emit_aee_seal(args, &health, &ports);
            }
            Err(_) => {
                let health = crate::enforcement_health_v1::EnforcementHealthV1::landlock_failed(
                    abi,
                    crate::enforcement_health_v1::ReasonCode::RestrictSelfFailed,
                    "landlock restrict_self failed in the enforcing child",
                    abi >= 4,
                    true,
                );
                write_enforcement_health_v1(args, &health)?;
            }
        }
    }

    let mut child = spawn_result.map_err(|e| anyhow::anyhow!("failed to spawn child: {}", e))?;

    let status_res = if let Some(sec) = args.timeout {
        match tokio::time::timeout(Duration::from_secs(sec), child.wait()).await {
            Ok(res) => res,
            Err(_) => {
                let _ = child.start_kill();
                let _ = child.wait().await;
                eprintln!("\nTIMEOUT: Process exceeded {}s limit", sec);
                metrics::increment("sandbox_timeout");
                return Ok(exit_codes::COMMAND_FAILED);
            }
        }
    } else {
        child.wait().await
    };

    let status = status_res?;

    #[cfg(any(test, feature = "profile-test-hook"))]
    if let Some(events) = crate::profile::events::try_load_test_events() {
        if let Some(p) = &profiler {
            p.note("injected_test_events: true");
            for ev in events {
                p.record(ev);
            }
        }
    }

    if let Some(p) = profiler {
        let report = p.finish();
        let suggestions = report.to_suggestion(crate::profile::suggest::SuggestConfig {
            widen_dirs_to_glob: false,
        });

        if args.dry_run {
            let mut violations = 0;
            for path in &suggestions.fs.allow {
                if !policy.fs.allow.iter().any(|p| p == path) {
                    violations += 1;
                    if !args.quiet {
                        eprintln!(
                            "DRY-RUN VIOLATION: Would have blocked FS access to: {}",
                            path
                        );
                    }
                }
            }

            if violations > 0 {
                if !args.quiet {
                    eprintln!("──────────────────");
                    eprintln!("DRY-RUN: Found {} potential violations.", violations);
                }
                maybe_profile_finish(report, args)?;
                return Ok(exit_codes::WOULD_BLOCK);
            }
        }

        maybe_profile_finish(report, args)?;
    }

    match status.code() {
        Some(code) => Ok(code),
        None => {
            eprintln!("sandbox error: child terminated by signal");
            Ok(exit_codes::INTERNAL_ERROR)
        }
    }
}

/// Write the `assay.enforcement_health.v1` artifact when `--enforcement-health` is set. Fail-closed:
/// a requested artifact that cannot be written is an error so the caller does not exit successfully
/// in a state where the evidence is absent on disk (the same rule v0 enforces).
/// Build the network-posture object the seal binds to, with its declared digest.
///
/// The order is the trap ADR-045 names under *Field interpretation*: `aeePostureDigest` must equal
/// the digest the posture object *declares*, which is computed over the object **before** the
/// `digest` member is inserted. `run_binding` then hashes the whole object *including* that member.
/// Two different digests over the same object, and building them in the wrong order yields a seal
/// that is refused for a reason bearing no resemblance to its cause.
#[cfg(target_os = "linux")]
fn build_network_posture(allowed_ports: &[u16]) -> serde_json::Value {
    let mut posture = serde_json::json!({
        "mode": "deny-default",
        "mechanism": "landlock",
        "scope": crate::enforcement_health_v1::SCOPE_TCP_CONNECT_LANDLOCK_PORT,
        "allowedConnectTcpPorts": allowed_ports,
    });
    let declared = crate::aee_seal::digest_json_public(&posture);
    posture["digest"] = serde_json::json!({ "sha256": declared });
    posture
}

/// Emit the substrate-signed run-end seal, if the caller asked for one.
///
/// Called from the enforcing process at the moment the health record is written, which is the only
/// place `aeeStillArmed` can mean anything: a later command signing a health artifact off disk would
/// be claiming a property of a moment that has passed.
///
/// Every failure here is loud and non-fatal to the workload. A run that could not seal is a run
/// without a seal, never a run with a weaker one.
#[cfg(target_os = "linux")]
fn maybe_emit_aee_seal(
    args: &SandboxArgs,
    health: &crate::enforcement_health_v1::EnforcementHealthV1,
    allowed_ports: &[u16],
) {
    let (Some(ctx_path), Some(key_path), Some(out_path)) = (
        args.aee_run_context.as_ref(),
        args.aee_seal_key.as_ref(),
        args.aee_seal.as_ref(),
    ) else {
        return;
    };

    let warn = |what: &str| {
        if !args.quiet {
            eprintln!("WARN: aee-seal: {what}; no seal was written");
        }
    };

    let ctx_raw = match std::fs::read_to_string(ctx_path) {
        Ok(r) => r,
        Err(e) => return warn(&format!("run context unreadable: {e}")),
    };
    let context = match crate::aee_run_context::AeeRunContext::parse(&ctx_raw) {
        Ok(c) => c,
        Err(e) => return warn(&e.to_string()),
    };

    let key_raw = match std::fs::read_to_string(key_path) {
        Ok(r) => r,
        Err(e) => return warn(&format!("key descriptor unreadable: {e}")),
    };
    let key = match crate::aee_seal_key::load(key_path, &key_raw) {
        Ok(k) => k,
        Err(e) => return warn(&e.to_string()),
    };

    let env = context.into_environment(build_network_posture(allowed_ports));
    let sealed_at = crate::aee_seal::now_rfc3339_utc();

    // `SynchronousProbe`: the only sealed observation is the run-end probe itself, obtained with no
    // queue between capture and the seal builder. Its basis is `declared`, not `checked`, and the
    // payload says so -- the zero is this producer asserting its topology has no lossy channel.
    let run = match crate::aee_seal::build_sealed_run(
        crate::aee_seal::Vantage::Landlock(health),
        &env,
        &[],
        &sealed_at,
        &crate::aee_seal::DropAccounting::SynchronousProbe,
        // The one path this command observes from. Passed rather than assumed inside the builder,
        // because the consumer has always treated the path as one of a set -- a second vantage is
        // now another argument here, not an edit to the seal library.
        crate::aee_seal::COLLECTION_PATH_LANDLOCK_TCP_CONNECT,
    ) {
        Ok(r) => r,
        Err(e) => return warn(&format!("not seal-eligible: {e}")),
    };

    let envelope = match crate::aee_seal_envelope::sign_seal(
        &run.seal,
        key.signing_key(),
        key.keyid(),
        key.role(),
    ) {
        Ok(e) => e,
        Err(e) => return warn(&format!("signing failed: {e}")),
    };

    // Written before the seal, so a reader that finds a seal always finds its inputs beside it. The
    // other order leaves a window where the commitment exists and the committed-to material does not.
    if let Some(records_path) = args.aee_records.as_ref() {
        let lines = match crate::aee_seal::records_ndjson(&run.records) {
            Ok(l) => l,
            Err(e) => return warn(&format!("observation record not serializable: {e}")),
        };
        if let Some(parent) = records_path.parent() {
            let _ = std::fs::create_dir_all(parent);
        }
        if let Err(e) = std::fs::write(records_path, lines) {
            return warn(&format!("could not write {}: {e}", records_path.display()));
        }
    }

    let doc = match serde_json::to_string_pretty(&envelope) {
        Ok(d) => d,
        Err(e) => return warn(&format!("envelope not serializable: {e}")),
    };
    if let Some(parent) = out_path.parent() {
        let _ = std::fs::create_dir_all(parent);
    }
    match std::fs::write(out_path, doc + "\n") {
        Ok(()) => {
            if !args.quiet {
                eprintln!(
                    "aee-seal: wrote {} (keyid {})",
                    out_path.display(),
                    key.keyid()
                );
            }
        }
        Err(e) => warn(&format!("could not write {}: {e}", out_path.display())),
    }
}

#[cfg(target_os = "linux")]
fn write_enforcement_health_v1(
    args: &SandboxArgs,
    health: &crate::enforcement_health_v1::EnforcementHealthV1,
) -> anyhow::Result<()> {
    use anyhow::Context;
    if let Some(path) = args.enforcement_health.as_ref() {
        health.write_to(path).with_context(|| {
            format!(
                "failed to write enforcement_health.v1 to {}",
                path.display()
            )
        })?;
    }
    Ok(())
}

/// All policy-not-expressible rejections collapse to a single reason code; the specific entries and
/// their per-entry reasons travel in the artifact's `detail` string.
#[cfg(target_os = "linux")]
fn net_reject_to_reason_code(
    _rejects: &[crate::landlock_net::NetReject],
) -> crate::enforcement_health_v1::ReasonCode {
    crate::enforcement_health_v1::ReasonCode::PolicyNotExpressible
}

/// Run the enforcement self-probe. Returns `Some(probe)` ONLY when a real block is proven: the
/// denied connect failed with EACCES AND the harness listener was never reached. Any other outcome
/// (connect succeeded, weak errno, listener reached, or the probe could not run) returns `None` and
/// is reported to stderr, never silently dropped, and never fails the run.
#[cfg(target_os = "linux")]
fn run_enforcement_self_probe(
    allowed_ports: &[u16],
    quiet: bool,
) -> Option<crate::enforcement_health_v1::Probe> {
    use crate::backend::SelfProbeOutcome;

    let listener = match bind_denied_listener(allowed_ports) {
        Some(l) => l,
        None => {
            if !quiet {
                eprintln!("WARN: probe-enforcement: could not bind a denied probe port; no probe");
            }
            return None;
        }
    };
    let deny_port = listener.local_addr().ok().map(|a| a.port()).unwrap_or(0);

    let outcome = match crate::backend::self_probe_denied_connect(allowed_ports, deny_port) {
        Ok(o) => o,
        Err(e) => {
            if !quiet {
                eprintln!("WARN: probe-enforcement: self-probe could not run: {e}");
            }
            return None;
        }
    };

    // Independent ground truth: did any connection actually reach the listener?
    let _ = listener.set_nonblocking(true);
    let listener_reached = listener.accept().is_ok();

    if outcome == SelfProbeOutcome::BlockedEacces && !listener_reached {
        return Some(crate::enforcement_health_v1::Probe {
            kind: "real_block".to_string(),
            transport: "ipv4".to_string(),
            blocked_action: "tcp_connect".to_string(),
            blocked_port: deny_port,
            blocked_errno: "EACCES".to_string(),
            listener_reached: false,
        });
    }
    if !quiet {
        eprintln!(
            "WARN: probe-enforcement: self-probe did not prove a block ({}); the enforcement \
             ruleset was still applied",
            describe_probe_nonblock(outcome, listener_reached)
        );
    }
    None
}

/// Bind an ephemeral loopback listener on a port that is NOT in the allowlist (so a connect to it is
/// expected to be denied). Retries a few times in the unlikely event the kernel hands back an
/// allowlisted ephemeral port.
#[cfg(target_os = "linux")]
fn bind_denied_listener(allowed_ports: &[u16]) -> Option<std::net::TcpListener> {
    for _ in 0..8 {
        let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).ok()?;
        let port = listener.local_addr().ok()?.port();
        if !allowed_ports.contains(&port) {
            return Some(listener);
        }
    }
    None
}

#[cfg(target_os = "linux")]
fn describe_probe_nonblock(
    outcome: crate::backend::SelfProbeOutcome,
    listener_reached: bool,
) -> String {
    use crate::backend::SelfProbeOutcome;
    if listener_reached {
        return "the denied connect reached the listener".to_string();
    }
    match outcome {
        SelfProbeOutcome::Connected => "the denied connect succeeded".to_string(),
        SelfProbeOutcome::OtherErrno(e) => {
            format!("the denied connect failed with errno {e}, not EACCES")
        }
        SelfProbeOutcome::ProbeInfraError(r) => format!("the probe could not run: {r}"),
        SelfProbeOutcome::BlockedEacces => "blocked, but the listener was reached".to_string(),
    }
}

fn resolve_command_path(cmd_name: &str) -> std::path::PathBuf {
    if std::path::Path::new(cmd_name).is_absolute() {
        return std::path::PathBuf::from(cmd_name);
    }

    std::env::var_os("PATH")
        .and_then(|paths| {
            std::env::split_paths(&paths).find_map(|dir| {
                let full_path = dir.join(cmd_name);
                if full_path.exists() {
                    Some(full_path)
                } else {
                    None
                }
            })
        })
        .unwrap_or_else(|| std::path::PathBuf::from(cmd_name))
}