Skip to main content

agent_block_core/
sandbox.rs

1//! Sandbox — a generic, process-wide execution boundary (Linux only).
2//!
3//! This is **not** a policy engine. There are no per-bridge rules, no domain
4//! allowlists and no dynamic decisions: the sandbox installs one coarse OS-level
5//! boundary around the whole process, once, at startup. Everything the process
6//! does afterwards — `sh.exec`, `mcp.connect` child processes, Lua `io.*`/`os.*`,
7//! the HTTP client — runs inside that boundary because Landlock rulesets and
8//! seccomp filters are inherited across `fork(2)` / `execve(2)`.
9//!
10//! # What is enforced
11//!
12//! | Axis            | Behaviour |
13//! |-----------------|-----------|
14//! | read / execute  | unrestricted (`/` is granted `ReadFile`/`ReadDir`/`Execute`) |
15//! | write           | denied except for an explicit allowlist (see below) |
16//! | TCP             | unrestricted by default; `tcp = false` denies bind+connect |
17//! | io_uring        | `io_uring_setup` / `io_uring_enter` / `io_uring_register` fail with `EPERM` |
18//!
19//! Reads and executes are deliberately left open so that PATH lookups, shared
20//! library loading and ordinary tooling keep working — the boundary is about
21//! *mutation*, not secrecy.
22//!
23//! The write allowlist is:
24//!
25//! - the project root (`--project`),
26//! - the agent-block state dir (`AGENT_BLOCK_HOME`, default `$HOME/.agent-block`),
27//! - `/tmp`,
28//! - `/dev/null`, `/dev/urandom`, `/dev/tty`,
29//! - every path listed in `AGENT_BLOCK_SANDBOX_FS_RW` (`:`-separated).
30//!
31//! Entries that do not exist are skipped rather than treated as an error, so a
32//! shared config can list paths that are only present on some machines.
33//!
34//! io_uring is blocked because it lets a task submit file and socket operations
35//! through a shared ring, bypassing the syscall-level view a seccomp filter has.
36//! Landlock still covers ring-submitted filesystem operations, but denying the
37//! setup syscall keeps the boundary easy to reason about.
38//!
39//! # Failure model
40//!
41//! Fail-closed: when the sandbox is requested but the kernel enforces *nothing*
42//! (no Landlock support), [`apply`] returns an error and the caller is expected
43//! to abort startup. A partial enforcement of the *default* rights (older
44//! Landlock ABI, e.g. no `Truncate`) logs a `warn!` describing what was dropped
45//! and continues — the filesystem boundary is still real in that case. Two
46//! things are never downgraded to a warning: an unresolvable project root (the
47//! primary write grant) and an explicitly requested TCP denial on a kernel
48//! whose Landlock ABI predates network rights — both abort startup.
49//!
50//! # KNOWN LIMITATIONS
51//!
52//! - **Linux only.** On other platforms [`apply`] returns
53//!   [`SandboxError::Unsupported`]; there is no silent no-op.
54//! - **UDP and DNS are not restricted.** Landlock's network rights cover TCP
55//!   bind/connect only, so `tcp = false` does not stop UDP traffic (including
56//!   DNS resolution) or unix-domain sockets.
57//! - **io_uring is unusable inside the sandbox**, including for dependencies
58//!   that would otherwise opportunistically use it.
59//! - **TCP is a single on/off switch.** There is no per-host or per-port
60//!   granularity, by design — that would be policy, not a boundary.
61//! - **The boundary is process-wide and irreversible.** It cannot be relaxed
62//!   later in the process lifetime, and it must be installed before any thread
63//!   that needs to be covered is spawned (Landlock's `restrict_self` applies to
64//!   the calling thread and its future children).
65//! - **The io_uring deny only exists on x86_64 and aarch64.** On other Linux
66//!   architectures no seccomp filter is compiled and the deny is skipped with a
67//!   `warn!`; the Landlock filesystem boundary still applies there.
68
69use std::path::{Path, PathBuf};
70
71/// Enables the sandbox. Also exposed as the `--sandbox` CLI flag.
72const ENV_ENABLED: &str = "AGENT_BLOCK_SANDBOX";
73/// `:`-separated list of extra writable paths.
74const ENV_FS_RW: &str = "AGENT_BLOCK_SANDBOX_FS_RW";
75/// `0` / `false` / `no` / `off` denies TCP; anything else (or unset) allows it.
76const ENV_TCP: &str = "AGENT_BLOCK_SANDBOX_TCP";
77
78/// Paths that are always writable when the sandbox is on, on top of the project
79/// root and the agent-block state dir.
80#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
81const ALWAYS_WRITABLE: &[&str] = &["/tmp", "/dev/null", "/dev/urandom", "/dev/tty"];
82
83/// Resolved sandbox knobs.
84///
85/// Built with [`SandboxConfig::from_env`]; all knobs are ENV-driven (matching
86/// the `bridge::config` house style) except `enabled`, which is also reachable
87/// through the `--sandbox` CLI flag.
88///
89/// | ENV var                      | Default | Meaning |
90/// |------------------------------|---------|---------|
91/// | `AGENT_BLOCK_SANDBOX`        | off     | enable the sandbox |
92/// | `AGENT_BLOCK_SANDBOX_FS_RW`  | empty   | `:`-separated extra writable paths |
93/// | `AGENT_BLOCK_SANDBOX_TCP`    | `true`  | `0`/`false`/`no`/`off` denies TCP |
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct SandboxConfig {
96    /// Whether the boundary should be installed at all.
97    pub enabled: bool,
98    /// Extra paths granted write access, on top of the built-in allowlist.
99    pub fs_rw: Vec<PathBuf>,
100    /// `true` (default) leaves TCP untouched; `false` denies bind + connect.
101    pub tcp: bool,
102}
103
104impl Default for SandboxConfig {
105    fn default() -> Self {
106        Self {
107            enabled: false,
108            fs_rw: Vec::new(),
109            tcp: true,
110        }
111    }
112}
113
114impl SandboxConfig {
115    /// Resolve the config from the environment.
116    ///
117    /// `cli_enabled` is the `--sandbox` flag; the sandbox is enabled when
118    /// either the flag or a truthy `AGENT_BLOCK_SANDBOX` is present. The env
119    /// var is read here rather than through a clap `env = ...` binding: clap
120    /// parses before the project `.env` is loaded and would accept only the
121    /// literal strings `true`/`false`, while this path supports the documented
122    /// truthy/falsy set.
123    pub fn from_env(cli_enabled: bool) -> Self {
124        Self::from_parts(
125            cli_enabled,
126            std::env::var(ENV_ENABLED).ok().as_deref(),
127            std::env::var(ENV_FS_RW).ok().as_deref(),
128            std::env::var(ENV_TCP).ok().as_deref(),
129        )
130    }
131
132    /// Pure parsing core of [`SandboxConfig::from_env`], split out so it can be
133    /// unit-tested without mutating process-wide environment state.
134    fn from_parts(
135        cli_enabled: bool,
136        enabled_raw: Option<&str>,
137        fs_rw_raw: Option<&str>,
138        tcp_raw: Option<&str>,
139    ) -> Self {
140        Self {
141            enabled: cli_enabled || enabled_raw.is_some_and(is_truthy),
142            fs_rw: fs_rw_raw.map(split_paths).unwrap_or_default(),
143            // Absent = allow: the sandbox must not break networking unless the
144            // operator explicitly asks for it.
145            tcp: tcp_raw.is_none_or(is_truthy),
146        }
147    }
148}
149
150/// Errors returned by [`apply`].
151#[derive(Debug, thiserror::Error)]
152pub enum SandboxError {
153    /// The sandbox was requested on a platform that has no implementation.
154    #[error("sandbox mode is Linux-only (Landlock + seccomp); this build targets '{os}'")]
155    Unsupported {
156        /// `std::env::consts::OS` of the running build.
157        os: &'static str,
158    },
159    /// The kernel accepted nothing at all — the process would run unrestricted.
160    #[error(
161        "sandbox requested but Landlock is not enforced by this kernel \
162         (needs Linux 5.13+ with CONFIG_SECURITY_LANDLOCK and landlock in the active LSM list)"
163    )]
164    NotEnforced,
165    /// The project root — the primary write grant — could not be resolved.
166    #[error("sandbox: project root '{path}' cannot be resolved: {error}")]
167    ProjectRoot {
168        /// The path as given on the command line.
169        path: String,
170        /// The underlying `canonicalize` error.
171        error: String,
172    },
173    /// Building or applying the Landlock ruleset failed.
174    #[error("failed to install Landlock ruleset: {0}")]
175    Landlock(String),
176    /// Building or applying the seccomp filter failed.
177    #[error("failed to install seccomp filter: {0}")]
178    Seccomp(String),
179}
180
181/// Install the execution boundary for this process.
182///
183/// A no-op when `config.enabled` is `false`. Otherwise the boundary is applied
184/// to the calling thread and inherited by every thread and child process
185/// created afterwards, so this must be called **before** any runtime spawns
186/// worker threads.
187///
188/// # Errors
189///
190/// Returns [`SandboxError::Unsupported`] on non-Linux targets,
191/// [`SandboxError::NotEnforced`] when the kernel enforces nothing, and the
192/// `Landlock` / `Seccomp` variants when the respective syscalls fail. Callers
193/// are expected to abort startup on any of these (fail-closed).
194pub fn apply(config: &SandboxConfig, project_root: &Path) -> Result<(), SandboxError> {
195    if !config.enabled {
196        return Ok(());
197    }
198    apply_platform(config, project_root)
199}
200
201#[cfg(target_os = "linux")]
202fn apply_platform(config: &SandboxConfig, project_root: &Path) -> Result<(), SandboxError> {
203    linux::apply(config, project_root)
204}
205
206/// No sandbox implementation exists off Linux, and pretending otherwise would be
207/// worse than refusing: the caller asked for a boundary, so say it is missing.
208#[cfg(not(target_os = "linux"))]
209fn apply_platform(_config: &SandboxConfig, _project_root: &Path) -> Result<(), SandboxError> {
210    Err(SandboxError::Unsupported {
211        os: std::env::consts::OS,
212    })
213}
214
215/// Resolve the set of directories/files that stay writable under the sandbox.
216///
217/// Non-existent entries are skipped (a missing path cannot be granted to
218/// Landlock, and treating it as fatal would make shared configs brittle).
219/// Duplicates — e.g. a project root that is already inside `/tmp` — are removed
220/// so the ruleset stays minimal.
221#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
222fn write_allowlist(config: &SandboxConfig, project_root: &Path) -> Vec<PathBuf> {
223    // `bool` = "the operator named this path", which decides how loudly a
224    // missing entry is reported. `/dev/tty` is routinely absent in containers,
225    // so the built-ins stay at debug level.
226    let mut candidates: Vec<(PathBuf, bool)> = Vec::new();
227    candidates.push((project_root.to_path_buf(), true));
228    if let Some(home) = state_home() {
229        candidates.push((home, true));
230    }
231    candidates.extend(ALWAYS_WRITABLE.iter().map(|p| (PathBuf::from(p), false)));
232    candidates.extend(config.fs_rw.iter().map(|p| (p.clone(), true)));
233
234    let mut out: Vec<PathBuf> = Vec::with_capacity(candidates.len());
235    for (path, explicit) in candidates {
236        let resolved = match path.canonicalize() {
237            Ok(p) => p,
238            Err(err) => {
239                // Skipping is the documented behaviour; report it so an operator
240                // can tell why a write is denied later on.
241                if explicit {
242                    tracing::warn!(
243                        path = %path.display(),
244                        error = %err,
245                        "sandbox: write path not granted (unresolvable) — writes there will fail"
246                    );
247                } else {
248                    tracing::debug!(
249                        path = %path.display(),
250                        error = %err,
251                        "sandbox: built-in write path absent, skipped"
252                    );
253                }
254                continue;
255            }
256        };
257        if !out.contains(&resolved) {
258            out.push(resolved);
259        }
260    }
261    out
262}
263
264/// Base dir for agent-block local state, mirroring `bridge::config::base_dir`
265/// without the `sqlite` feature gate (the sandbox has to know about it even in
266/// builds where the SQLite bridges are compiled out).
267#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
268fn state_home() -> Option<PathBuf> {
269    if let Some(v) = std::env::var_os("AGENT_BLOCK_HOME") {
270        return Some(PathBuf::from(v));
271    }
272    std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".agent-block"))
273}
274
275/// `""` / `0` / `false` / `no` / `off` (any case, surrounding space ignored)
276/// are false; every other value is true.
277fn is_truthy(raw: &str) -> bool {
278    !matches!(
279        raw.trim().to_ascii_lowercase().as_str(),
280        "" | "0" | "false" | "no" | "off"
281    )
282}
283
284/// Split a `:`-separated path list, dropping empty segments so that a trailing
285/// or doubled separator is harmless.
286fn split_paths(raw: &str) -> Vec<PathBuf> {
287    raw.split(':')
288        .filter(|segment| !segment.trim().is_empty())
289        .map(PathBuf::from)
290        .collect()
291}
292
293#[cfg(target_os = "linux")]
294mod linux {
295    use super::{write_allowlist, SandboxConfig, SandboxError};
296    use landlock::{
297        path_beneath_rules, Access, AccessFs, AccessNet, CompatLevel, Compatible, Ruleset,
298        RulesetAttr, RulesetCreatedAttr, RulesetStatus, ABI,
299    };
300    use std::path::Path;
301
302    /// Landlock ABI this ruleset is written against. Older kernels downgrade
303    /// through `CompatLevel::BestEffort`; newer kernels simply leave the access
304    /// rights introduced after V4 unhandled (i.e. unrestricted), which is the
305    /// safe direction for "must not break the default workflow".
306    const FS_ABI: ABI = ABI::V4;
307
308    /// Landlock ABI that introduced TCP bind/connect rights.
309    const NET_ABI: ABI = ABI::V4;
310
311    pub(super) fn apply(config: &SandboxConfig, project_root: &Path) -> Result<(), SandboxError> {
312        // The project root is the primary write grant — a typo'd `--project`
313        // must fail here, not as a distant EACCES once Lua starts writing.
314        // Everything else in the allowlist keeps skip-on-missing semantics.
315        let project_root =
316            project_root
317                .canonicalize()
318                .map_err(|err| SandboxError::ProjectRoot {
319                    path: project_root.display().to_string(),
320                    error: err.to_string(),
321                })?;
322        let writable = write_allowlist(config, &project_root);
323        let granted = writable.len();
324
325        // The landlock crate deliberately exposes no runtime ABI probe: with
326        // `CompatLevel::BestEffort` the kernel silently drops rights it does
327        // not know, and `restrict_self()`'s `RulesetStatus` reports whether
328        // that happened (handled below as the partial-enforcement warning).
329        let mut ruleset = Ruleset::default()
330            .set_compatibility(CompatLevel::BestEffort)
331            .handle_access(AccessFs::from_all(FS_ABI))
332            .map_err(landlock_err)?;
333
334        if !config.tcp {
335            // TCP denial is an explicit operator request, not a best-effort
336            // default: on kernels whose Landlock ABI predates network rights
337            // (< 6.7) this must abort startup instead of silently failing open.
338            ruleset = ruleset
339                .set_compatibility(CompatLevel::HardRequirement)
340                .handle_access(AccessNet::from_all(NET_ABI))
341                .map_err(landlock_err)?;
342        }
343
344        let created = ruleset.create().map_err(landlock_err)?;
345        // Reads and executes stay open everywhere.
346        let created = created
347            .add_rules(path_beneath_rules(["/"], AccessFs::from_read(FS_ABI)))
348            .map_err(landlock_err)?;
349        // Writes are granted only under the allowlist. No `handle_access` for
350        // net rights above means TCP is left untouched when `tcp = true`; when
351        // it is handled and no rule is added, every TCP bind/connect is denied.
352        //
353        // `path_beneath_rules` masks directory-only rights (MakeDir,
354        // RemoveFile, …) down to the file-applicable subset for non-directory
355        // entries like `/dev/null`, so one rule set covers both kinds.
356        let created = created
357            .add_rules(path_beneath_rules(&writable, AccessFs::from_all(FS_ABI)))
358            .map_err(landlock_err)?;
359
360        let status = created.restrict_self().map_err(landlock_err)?;
361
362        match status.ruleset {
363            RulesetStatus::FullyEnforced => {
364                tracing::info!(
365                    writable = granted,
366                    tcp = config.tcp,
367                    "sandbox: filesystem boundary fully enforced"
368                );
369            }
370            RulesetStatus::PartiallyEnforced => {
371                tracing::warn!(
372                    writable = granted,
373                    tcp = config.tcp,
374                    "sandbox: filesystem boundary only partially enforced — this kernel \
375                     dropped some access rights (older Landlock ABI). Writes outside the \
376                     allowlist are still denied; newer rights (e.g. file truncation) may \
377                     not be. An explicit TCP denial is never dropped: it aborts startup \
378                     on kernels that cannot enforce it"
379                );
380            }
381            // `NotEnforced` plus any future variant: treat as no boundary at all.
382            _ => return Err(SandboxError::NotEnforced),
383        }
384
385        super::seccomp::deny_io_uring()?;
386        Ok(())
387    }
388
389    fn landlock_err<E: std::fmt::Debug>(err: E) -> SandboxError {
390        SandboxError::Landlock(format!("{err:?}"))
391    }
392}
393
394#[cfg(target_os = "linux")]
395mod seccomp {
396    use super::SandboxError;
397
398    /// Deny the three io_uring entry points with `EPERM`.
399    ///
400    /// Everything else is allowed: this filter exists to close the ring-based
401    /// bypass around the syscall view, not to enumerate a syscall policy.
402    #[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))]
403    pub(super) fn deny_io_uring() -> Result<(), SandboxError> {
404        use seccompiler::{
405            apply_filter_all_threads, BpfProgram, SeccompAction, SeccompFilter, SeccompRule,
406            TargetArch,
407        };
408        use std::collections::BTreeMap;
409
410        #[cfg(target_arch = "x86_64")]
411        const ARCH: TargetArch = TargetArch::x86_64;
412        #[cfg(target_arch = "aarch64")]
413        const ARCH: TargetArch = TargetArch::aarch64;
414
415        // An empty rule vector means "match this syscall unconditionally".
416        let mut rules: BTreeMap<i64, Vec<SeccompRule>> = BTreeMap::new();
417        // `SYS_*` constants are `c_long`, which is i64 on 64-bit targets and i32
418        // on 32-bit ones — the cast is width-normalising, not redundant.
419        #[allow(clippy::unnecessary_cast)]
420        for syscall in [
421            libc::SYS_io_uring_setup,
422            libc::SYS_io_uring_enter,
423            libc::SYS_io_uring_register,
424        ] {
425            rules.insert(syscall as i64, Vec::new());
426        }
427
428        let filter = SeccompFilter::new(
429            rules,
430            // Mismatch (i.e. every other syscall) is allowed.
431            SeccompAction::Allow,
432            // Match returns EPERM instead of killing the process, so a caller
433            // that probes for io_uring can fall back gracefully.
434            SeccompAction::Errno(libc::EPERM as u32),
435            ARCH,
436        )
437        .map_err(seccomp_err)?;
438
439        let program: BpfProgram = filter.try_into().map_err(seccomp_err)?;
440        apply_filter_all_threads(&program).map_err(seccomp_err)?;
441
442        tracing::info!("sandbox: io_uring syscalls denied (EPERM)");
443        Ok(())
444    }
445
446    #[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
447    pub(super) fn deny_io_uring() -> Result<(), SandboxError> {
448        tracing::warn!(
449            arch = std::env::consts::ARCH,
450            "sandbox: io_uring deny skipped — no seccomp filter is compiled for this \
451             architecture; the Landlock filesystem boundary is unaffected"
452        );
453        Ok(())
454    }
455
456    #[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))]
457    fn seccomp_err<E: std::fmt::Debug>(err: E) -> SandboxError {
458        SandboxError::Seccomp(format!("{err:?}"))
459    }
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465
466    // These exercise the pure parsing core rather than `from_env`, so no test
467    // mutates process-wide environment state (which would race with the other
468    // tests in this crate running on sibling threads).
469
470    #[test]
471    fn defaults_are_off_and_network_open() {
472        let cfg = SandboxConfig::from_parts(false, None, None, None);
473        assert!(!cfg.enabled);
474        assert!(cfg.fs_rw.is_empty());
475        assert!(cfg.tcp, "TCP must default to unrestricted");
476        assert_eq!(cfg, SandboxConfig::default());
477    }
478
479    #[test]
480    fn cli_flag_enables_without_env() {
481        let cfg = SandboxConfig::from_parts(true, None, None, None);
482        assert!(cfg.enabled);
483    }
484
485    #[test]
486    fn env_enables_without_cli_flag() {
487        assert!(SandboxConfig::from_parts(false, Some("1"), None, None).enabled);
488        assert!(SandboxConfig::from_parts(false, Some("true"), None, None).enabled);
489        // An explicitly falsy env var does not enable it...
490        assert!(!SandboxConfig::from_parts(false, Some("0"), None, None).enabled);
491        assert!(!SandboxConfig::from_parts(false, Some(""), None, None).enabled);
492        // ...but it never disables an explicit CLI flag.
493        assert!(SandboxConfig::from_parts(true, Some("0"), None, None).enabled);
494    }
495
496    #[test]
497    fn fs_rw_splits_on_colon() {
498        let cfg = SandboxConfig::from_parts(true, None, Some("/opt/cache:/srv/data"), None);
499        assert_eq!(
500            cfg.fs_rw,
501            vec![PathBuf::from("/opt/cache"), PathBuf::from("/srv/data")]
502        );
503    }
504
505    #[test]
506    fn fs_rw_drops_empty_segments() {
507        let cfg = SandboxConfig::from_parts(true, None, Some(":/a::/b: :"), None);
508        assert_eq!(cfg.fs_rw, vec![PathBuf::from("/a"), PathBuf::from("/b")]);
509    }
510
511    #[test]
512    fn fs_rw_empty_string_yields_no_paths() {
513        let cfg = SandboxConfig::from_parts(true, None, Some(""), None);
514        assert!(cfg.fs_rw.is_empty());
515    }
516
517    #[test]
518    fn fs_rw_single_path_has_no_separator() {
519        let cfg = SandboxConfig::from_parts(true, None, Some("/opt/cache"), None);
520        assert_eq!(cfg.fs_rw, vec![PathBuf::from("/opt/cache")]);
521    }
522
523    #[test]
524    fn tcp_falsy_values_deny() {
525        for raw in ["0", "false", "FALSE", " False ", "no", "off", ""] {
526            let cfg = SandboxConfig::from_parts(true, None, None, Some(raw));
527            assert!(!cfg.tcp, "expected {raw:?} to deny TCP");
528        }
529    }
530
531    #[test]
532    fn tcp_truthy_values_allow() {
533        for raw in ["1", "true", "TRUE", "yes", "on", "anything"] {
534            let cfg = SandboxConfig::from_parts(true, None, None, Some(raw));
535            assert!(cfg.tcp, "expected {raw:?} to allow TCP");
536        }
537    }
538
539    #[test]
540    fn write_allowlist_keeps_existing_and_drops_missing() {
541        let dir = tempfile::tempdir().expect("tempdir");
542        let missing = dir.path().join("does-not-exist");
543        let cfg = SandboxConfig {
544            enabled: true,
545            fs_rw: vec![missing.clone()],
546            tcp: true,
547        };
548
549        let allowed = write_allowlist(&cfg, dir.path());
550
551        let project = dir
552            .path()
553            .canonicalize()
554            .expect("canonicalize project root");
555        assert!(
556            allowed.contains(&project),
557            "project root must stay writable"
558        );
559        assert!(
560            !allowed.iter().any(|p| p.ends_with("does-not-exist")),
561            "missing paths are skipped, not fatal"
562        );
563    }
564
565    #[test]
566    fn write_allowlist_deduplicates() {
567        let dir = tempfile::tempdir().expect("tempdir");
568        let cfg = SandboxConfig {
569            enabled: true,
570            // Same dir listed twice, once via an un-normalised path.
571            fs_rw: vec![dir.path().to_path_buf(), dir.path().join(".")],
572            tcp: true,
573        };
574
575        let allowed = write_allowlist(&cfg, dir.path());
576        let project = dir
577            .path()
578            .canonicalize()
579            .expect("canonicalize project root");
580
581        assert_eq!(
582            allowed.iter().filter(|p| **p == project).count(),
583            1,
584            "duplicate entries must collapse to a single rule"
585        );
586    }
587}