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}