use std::collections::BTreeSet;
use std::path::Path;
use zeph_config::Config;
use crate::PluginError;
use crate::manager::{validate_overlay_keys, validate_plugin_name};
use crate::manifest::PluginManifest;
#[derive(Debug, Clone, Default)]
pub struct ResolvedOverlay {
pub blocked_commands_add: Vec<String>,
pub allowed_commands_intersect_accum: Option<BTreeSet<String>>,
pub disambiguation_threshold_max: Option<f32>,
pub source_plugins: Vec<String>,
pub skipped_plugins: Vec<String>,
}
pub fn apply_plugin_config_overlays(
config: &mut Config,
plugins_dir: &Path,
) -> Result<ResolvedOverlay, PluginError> {
let integrity_registry_path = crate::integrity::IntegrityRegistry::default_path();
let resolved = resolve_overlays(plugins_dir, &integrity_registry_path)?;
apply_resolved(config, &resolved);
Ok(resolved)
}
#[cfg(test)]
pub(crate) fn apply_plugin_config_overlays_with_registry(
config: &mut Config,
plugins_dir: &Path,
integrity_registry_path: &Path,
) -> Result<ResolvedOverlay, PluginError> {
let resolved = resolve_overlays(plugins_dir, integrity_registry_path)?;
apply_resolved(config, &resolved);
Ok(resolved)
}
fn resolve_overlays(
plugins_dir: &Path,
integrity_registry_path: &Path,
) -> Result<ResolvedOverlay, PluginError> {
let mut out = ResolvedOverlay::default();
if !plugins_dir.exists() {
return Ok(out);
}
let registry = crate::integrity::IntegrityRegistry::load(integrity_registry_path);
let mut entries: Vec<std::fs::DirEntry> = std::fs::read_dir(plugins_dir)
.map_err(|e| PluginError::Io {
path: plugins_dir.to_path_buf(),
source: e,
})?
.flatten()
.collect();
entries.sort_by_key(std::fs::DirEntry::file_name);
let mut blocked_set: BTreeSet<String> = BTreeSet::new();
let mut allowed_accum: Option<BTreeSet<String>> = None;
let mut threshold: Option<f32> = None;
for entry in entries {
process_plugin_entry(
&entry.path(),
®istry,
&mut out,
&mut blocked_set,
&mut allowed_accum,
&mut threshold,
);
}
out.blocked_commands_add = blocked_set.into_iter().collect();
out.allowed_commands_intersect_accum = allowed_accum;
out.disambiguation_threshold_max = threshold;
Ok(out)
}
fn process_plugin_entry(
path: &std::path::Path,
registry: &crate::integrity::IntegrityRegistry,
out: &mut ResolvedOverlay,
blocked_set: &mut BTreeSet<String>,
allowed_accum: &mut Option<BTreeSet<String>>,
threshold: &mut Option<f32>,
) {
let md = match std::fs::symlink_metadata(path) {
Ok(m) => m,
Err(e) => {
tracing::debug!(path = %path.display(), error = %e, "stat failed; skipping");
return;
}
};
if !md.is_dir() || md.file_type().is_symlink() {
return;
}
let manifest_path = path.join(".plugin.toml");
let bytes = match std::fs::read(&manifest_path) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
Err(e) => {
tracing::warn!(path = %manifest_path.display(), kind = ?e.kind(), "cannot read .plugin.toml; skipping");
return;
}
};
let Ok(text) = String::from_utf8(bytes) else {
let name = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("?")
.to_owned();
out.skipped_plugins
.push(format!("{name}: .plugin.toml is not valid UTF-8"));
return;
};
let manifest: PluginManifest = {
let name = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("?")
.to_owned();
match toml::from_str(&text) {
Ok(m) => m,
Err(e) => {
out.skipped_plugins
.push(format!("{name}: malformed .plugin.toml ({e})"));
return;
}
}
};
let plugin_dir_name = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("?")
.to_owned();
match registry.verify(&plugin_dir_name, &manifest_path) {
Ok(crate::integrity::VerifyResult::Match | crate::integrity::VerifyResult::Missing) => {}
Ok(crate::integrity::VerifyResult::Mismatch { expected, actual }) => {
out.skipped_plugins.push(format!(
"{plugin_dir_name}: integrity mismatch (expected {expected}, got {actual})"
));
return;
}
Err(e) => {
out.skipped_plugins
.push(format!("{plugin_dir_name}: integrity check failed: {e}"));
return;
}
}
if let Err(e) = validate_plugin_name(&manifest.plugin.name) {
tracing::warn!(
path = %path.display(),
"plugin overlay skipped: invalid plugin name ({e})"
);
return;
}
if let Err(e) = validate_overlay_keys(&manifest.config) {
out.skipped_plugins.push(format!(
"{}: overlay rejected by safelist ({e})",
manifest.plugin.name
));
return;
}
let contributed = merge_manifest_overlay(&manifest, out, blocked_set, allowed_accum, threshold);
if contributed {
out.source_plugins.push(manifest.plugin.name);
}
}
fn merge_manifest_overlay(
manifest: &PluginManifest,
out: &mut ResolvedOverlay,
blocked_set: &mut BTreeSet<String>,
allowed_accum: &mut Option<BTreeSet<String>>,
threshold: &mut Option<f32>,
) -> bool {
let Some(cfg_table) = manifest.config.as_table() else {
return false;
};
let mut contributed = false;
if let Some(tools) = cfg_table.get("tools").and_then(toml::Value::as_table) {
if let Some(arr) = tools
.get("blocked_commands")
.and_then(toml::Value::as_array)
{
for v in arr {
if let Some(s) = v.as_str() {
blocked_set.insert(s.to_owned());
contributed = true;
}
}
}
if let Some(arr) = tools
.get("allowed_commands")
.and_then(toml::Value::as_array)
{
let plugin_allowed: BTreeSet<String> = arr
.iter()
.filter_map(|v| v.as_str().map(str::to_owned))
.collect();
*allowed_accum = Some(match allowed_accum.take() {
None => plugin_allowed,
Some(prev) => prev.intersection(&plugin_allowed).cloned().collect(),
});
contributed = true;
}
}
if let Some(skills) = cfg_table.get("skills").and_then(toml::Value::as_table)
&& let Some(v) = skills.get("disambiguation_threshold")
{
#[allow(clippy::cast_precision_loss)]
let raw = v.as_float().or_else(|| v.as_integer().map(|i| i as f64));
match raw {
Some(f) if (0.0_f64..=1.0_f64).contains(&f) => {
#[allow(clippy::cast_possible_truncation)]
let f32_val = f as f32;
*threshold = Some(threshold.map_or(f32_val, |cur: f32| cur.max(f32_val)));
contributed = true;
}
Some(f) => {
out.skipped_plugins.push(format!(
"{}: disambiguation_threshold={f} out of [0,1]; ignored",
manifest.plugin.name
));
}
None => {
out.skipped_plugins.push(format!(
"{}: disambiguation_threshold has non-numeric value; ignored",
manifest.plugin.name
));
}
}
}
contributed
}
fn apply_resolved(config: &mut Config, r: &ResolvedOverlay) {
let mut seen: BTreeSet<String> = config
.tools
.shell
.blocked_commands
.iter()
.cloned()
.collect();
for cmd in &r.blocked_commands_add {
if seen.insert(cmd.clone()) {
config.tools.shell.blocked_commands.push(cmd.clone());
}
}
if let Some(ref plugin_allowed) = r.allowed_commands_intersect_accum {
if config.tools.shell.allowed_commands.is_empty() {
tracing::debug!(
"plugin overlay supplied allowed_commands but base is empty; \
ignoring (tighten-only — plugins cannot widen the allowlist)"
);
} else {
let base: BTreeSet<String> = config
.tools
.shell
.allowed_commands
.iter()
.cloned()
.collect();
let narrowed: Vec<String> = base.intersection(plugin_allowed).cloned().collect();
let narrowed_count = narrowed.len();
let prev_count = config.tools.shell.allowed_commands.len();
config.tools.shell.allowed_commands = narrowed;
if narrowed_count < prev_count {
tracing::info!(
from = prev_count,
to = narrowed_count,
"plugin overlay narrowed tools.shell.allowed_commands"
);
}
}
}
if let Some(t) = r.disambiguation_threshold_max
&& t > config.skills.disambiguation_threshold
{
tracing::info!(
from = config.skills.disambiguation_threshold,
to = t,
"plugin overlay raised skills.disambiguation_threshold"
);
config.skills.disambiguation_threshold = t;
}
if !r.source_plugins.is_empty() {
tracing::info!(
plugins = ?r.source_plugins,
blocked_added = r.blocked_commands_add.len(),
threshold = ?r.disambiguation_threshold_max,
"applied plugin config overlays"
);
}
for s in &r.skipped_plugins {
tracing::warn!("plugin overlay skipped: {s}");
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
use zeph_config::Config;
fn write_plugin_overlay(plugins_dir: &Path, name: &str, overlay_toml: &str) {
let entry_dir = plugins_dir.join(name);
fs::create_dir_all(&entry_dir).unwrap();
fs::write(
entry_dir.join(".plugin.toml"),
format!("[plugin]\nname = \"{name}\"\nversion = \"0.1.0\"\n\n{overlay_toml}"),
)
.unwrap();
}
fn base_config() -> Config {
Config::default()
}
#[test]
fn empty_plugins_dir_is_noop() {
let dir = TempDir::new().unwrap();
let absent = dir.path().join("no-such-dir");
let mut cfg = base_config();
let overlay = apply_plugin_config_overlays(&mut cfg, &absent).unwrap();
assert!(overlay.source_plugins.is_empty());
assert!(overlay.skipped_plugins.is_empty());
assert!(cfg.tools.shell.blocked_commands.is_empty());
}
#[test]
fn plugins_dir_without_manifests_is_noop() {
let dir = TempDir::new().unwrap();
fs::create_dir(dir.path().join("myplugin")).unwrap();
let mut cfg = base_config();
let overlay = apply_plugin_config_overlays(&mut cfg, dir.path()).unwrap();
assert!(overlay.source_plugins.is_empty());
assert!(cfg.tools.shell.blocked_commands.is_empty());
}
#[test]
fn single_plugin_blocked_commands_union() {
let dir = TempDir::new().unwrap();
write_plugin_overlay(
dir.path(),
"hardening",
"[config.tools]\nblocked_commands = [\"sudo\"]",
);
let mut cfg = base_config();
cfg.tools.shell.blocked_commands = vec!["rm -rf".to_owned()];
apply_plugin_config_overlays(&mut cfg, dir.path()).unwrap();
assert!(
cfg.tools
.shell
.blocked_commands
.contains(&"rm -rf".to_owned())
);
assert!(
cfg.tools
.shell
.blocked_commands
.contains(&"sudo".to_owned())
);
}
#[test]
fn multi_plugin_blocked_commands_dedup() {
let dir = TempDir::new().unwrap();
write_plugin_overlay(
dir.path(),
"p1",
"[config.tools]\nblocked_commands = [\"sudo\"]",
);
write_plugin_overlay(
dir.path(),
"p2",
"[config.tools]\nblocked_commands = [\"sudo\"]",
);
let mut cfg = base_config();
apply_plugin_config_overlays(&mut cfg, dir.path()).unwrap();
let count = cfg
.tools
.shell
.blocked_commands
.iter()
.filter(|c| c.as_str() == "sudo")
.count();
assert_eq!(count, 1);
}
#[test]
fn non_empty_base_allowed_commands_narrowed() {
let dir = TempDir::new().unwrap();
write_plugin_overlay(
dir.path(),
"narrow",
"[config.tools]\nallowed_commands = [\"a\", \"b\"]",
);
let mut cfg = base_config();
cfg.tools.shell.allowed_commands = vec!["a".to_owned(), "b".to_owned(), "c".to_owned()];
apply_plugin_config_overlays(&mut cfg, dir.path()).unwrap();
let mut result = cfg.tools.shell.allowed_commands.clone();
result.sort();
assert_eq!(result, vec!["a".to_owned(), "b".to_owned()]);
}
#[test]
fn multi_plugin_allowed_commands_intersection() {
let dir = TempDir::new().unwrap();
write_plugin_overlay(
dir.path(),
"p1",
"[config.tools]\nallowed_commands = [\"a\", \"b\"]",
);
write_plugin_overlay(
dir.path(),
"p2",
"[config.tools]\nallowed_commands = [\"b\", \"c\"]",
);
let mut cfg = base_config();
cfg.tools.shell.allowed_commands = vec!["a".to_owned(), "b".to_owned(), "c".to_owned()];
apply_plugin_config_overlays(&mut cfg, dir.path()).unwrap();
assert_eq!(cfg.tools.shell.allowed_commands, vec!["b".to_owned()]);
}
#[test]
fn empty_base_allowed_commands_overlay_ignored() {
let dir = TempDir::new().unwrap();
write_plugin_overlay(
dir.path(),
"widener",
"[config.tools]\nallowed_commands = [\"curl\"]",
);
let mut cfg = base_config();
assert!(cfg.tools.shell.allowed_commands.is_empty());
apply_plugin_config_overlays(&mut cfg, dir.path()).unwrap();
assert!(cfg.tools.shell.allowed_commands.is_empty());
}
#[test]
fn disambiguation_threshold_max_wins() {
let dir = TempDir::new().unwrap();
write_plugin_overlay(
dir.path(),
"strict",
"[config.skills]\ndisambiguation_threshold = 0.25",
);
let mut cfg = base_config();
cfg.skills.disambiguation_threshold = 0.20;
apply_plugin_config_overlays(&mut cfg, dir.path()).unwrap();
assert!((cfg.skills.disambiguation_threshold - 0.25_f32).abs() < 1e-5);
}
#[test]
fn disambiguation_threshold_lower_ignored() {
let dir = TempDir::new().unwrap();
write_plugin_overlay(
dir.path(),
"loose",
"[config.skills]\ndisambiguation_threshold = 0.20",
);
let mut cfg = base_config();
cfg.skills.disambiguation_threshold = 0.30;
apply_plugin_config_overlays(&mut cfg, dir.path()).unwrap();
assert!((cfg.skills.disambiguation_threshold - 0.30_f32).abs() < 1e-5);
}
#[test]
fn threshold_out_of_range_skipped_with_warning() {
let dir = TempDir::new().unwrap();
write_plugin_overlay(
dir.path(),
"bad",
"[config.skills]\ndisambiguation_threshold = 1.5",
);
let mut cfg = base_config();
let orig = cfg.skills.disambiguation_threshold;
let overlay = apply_plugin_config_overlays(&mut cfg, dir.path()).unwrap();
assert!((cfg.skills.disambiguation_threshold - orig).abs() < 1e-5);
assert!(
overlay
.skipped_plugins
.iter()
.any(|s| s.contains("bad") && s.contains("1.5"))
);
}
#[test]
fn threshold_boundary_one_accepted() {
let dir = TempDir::new().unwrap();
write_plugin_overlay(
dir.path(),
"max-strict",
"[config.skills]\ndisambiguation_threshold = 1.0",
);
let mut cfg = base_config();
cfg.skills.disambiguation_threshold = 0.5;
apply_plugin_config_overlays(&mut cfg, dir.path()).unwrap();
assert!((cfg.skills.disambiguation_threshold - 1.0_f32).abs() < 1e-5);
}
#[test]
fn threshold_integer_literal_accepted() {
let dir = TempDir::new().unwrap();
write_plugin_overlay(
dir.path(),
"int-thresh",
"[config.skills]\ndisambiguation_threshold = 0",
);
let mut cfg = base_config();
cfg.skills.disambiguation_threshold = 0.5;
let overlay = apply_plugin_config_overlays(&mut cfg, dir.path()).unwrap();
assert!(
overlay.skipped_plugins.is_empty(),
"unexpected skips: {:?}",
overlay.skipped_plugins
);
}
#[test]
fn malformed_manifest_skipped() {
let dir = TempDir::new().unwrap();
let plugin_dir = dir.path().join("broken");
fs::create_dir(&plugin_dir).unwrap();
fs::write(plugin_dir.join(".plugin.toml"), b"not valid toml ][[[").unwrap();
write_plugin_overlay(
dir.path(),
"good",
"[config.tools]\nblocked_commands = [\"sudo\"]",
);
let mut cfg = base_config();
let overlay = apply_plugin_config_overlays(&mut cfg, dir.path()).unwrap();
assert!(overlay.skipped_plugins.iter().any(|s| s.contains("broken")));
assert!(
cfg.tools
.shell
.blocked_commands
.contains(&"sudo".to_owned())
);
}
#[test]
fn unsafelisted_overlay_key_skipped() {
let dir = TempDir::new().unwrap();
write_plugin_overlay(dir.path(), "tampered", "[config.llm]\nmodel = \"evil\"");
write_plugin_overlay(
dir.path(),
"good",
"[config.tools]\nblocked_commands = [\"sudo\"]",
);
let mut cfg = base_config();
let overlay = apply_plugin_config_overlays(&mut cfg, dir.path()).unwrap();
assert!(
overlay
.skipped_plugins
.iter()
.any(|s| s.contains("tampered"))
);
assert!(
cfg.tools
.shell
.blocked_commands
.contains(&"sudo".to_owned())
);
}
#[cfg(unix)]
#[test]
fn symlinked_plugin_dir_ignored() {
let dir = TempDir::new().unwrap();
let real_dir = TempDir::new().unwrap();
let plugin_in_real = real_dir.path().join("evil");
fs::create_dir(&plugin_in_real).unwrap();
fs::write(
plugin_in_real.join(".plugin.toml"),
"[plugin]\nname = \"evil\"\nversion = \"0.1.0\"\n[config.tools]\nblocked_commands = [\"curl\"]",
)
.unwrap();
std::os::unix::fs::symlink(&plugin_in_real, dir.path().join("evil")).unwrap();
let mut cfg = base_config();
let overlay = apply_plugin_config_overlays(&mut cfg, dir.path()).unwrap();
assert!(overlay.source_plugins.is_empty());
assert!(cfg.tools.shell.blocked_commands.is_empty());
}
#[test]
fn idempotent_merge() {
let dir = TempDir::new().unwrap();
write_plugin_overlay(
dir.path(),
"idem",
"[config.tools]\nblocked_commands = [\"sudo\"]",
);
let mut cfg = base_config();
apply_plugin_config_overlays(&mut cfg, dir.path()).unwrap();
let snap1 = cfg.tools.shell.blocked_commands.clone();
apply_plugin_config_overlays(&mut cfg, dir.path()).unwrap();
let snap2 = cfg.tools.shell.blocked_commands.clone();
assert_eq!(snap1, snap2);
}
#[test]
fn iteration_order_deterministic() {
let dir = TempDir::new().unwrap();
write_plugin_overlay(
dir.path(),
"z-plugin",
"[config.tools]\nblocked_commands = [\"z\"]",
);
write_plugin_overlay(
dir.path(),
"a-plugin",
"[config.tools]\nblocked_commands = [\"a\"]",
);
let mut cfg = base_config();
let overlay = apply_plugin_config_overlays(&mut cfg, dir.path()).unwrap();
assert_eq!(overlay.source_plugins, vec!["a-plugin", "z-plugin"]);
}
#[test]
fn plugin_blocked_wins_over_base_allowed() {
let dir = TempDir::new().unwrap();
write_plugin_overlay(
dir.path(),
"hardening",
"[config.tools]\nblocked_commands = [\"curl\"]",
);
let mut cfg = base_config();
cfg.tools.shell.allowed_commands = vec!["curl".to_owned()];
apply_plugin_config_overlays(&mut cfg, dir.path()).unwrap();
assert!(
cfg.tools
.shell
.blocked_commands
.contains(&"curl".to_owned())
);
}
#[test]
fn tampered_overlay_skipped_but_good_plugin_still_loaded() {
let dir = TempDir::new().unwrap();
write_plugin_overlay(dir.path(), "evil", "[config.llm]\nmodel = \"x\"");
write_plugin_overlay(
dir.path(),
"good",
"[config.skills]\ndisambiguation_threshold = 0.5",
);
let mut cfg = base_config();
cfg.skills.disambiguation_threshold = 0.1;
let overlay = apply_plugin_config_overlays(&mut cfg, dir.path()).unwrap();
assert!(overlay.source_plugins.contains(&"good".to_owned()));
assert!(!overlay.source_plugins.contains(&"evil".to_owned()));
assert!((cfg.skills.disambiguation_threshold - 0.5_f32).abs() < 1e-5);
}
#[test]
fn reload_warns_on_shell_overlay_divergence() {
let dir = TempDir::new().unwrap();
let mut startup_cfg = base_config();
apply_plugin_config_overlays(&mut startup_cfg, dir.path()).unwrap();
let mut startup_blocked = startup_cfg.tools.shell.blocked_commands.clone();
startup_blocked.sort();
let mut startup_allowed = startup_cfg.tools.shell.allowed_commands.clone();
startup_allowed.sort();
write_plugin_overlay(
dir.path(),
"hardening",
"[config.tools]\nblocked_commands = [\"curl\"]",
);
let mut reload_cfg = base_config();
apply_plugin_config_overlays(&mut reload_cfg, dir.path()).unwrap();
let mut reload_blocked = reload_cfg.tools.shell.blocked_commands.clone();
reload_blocked.sort();
let mut reload_allowed = reload_cfg.tools.shell.allowed_commands.clone();
reload_allowed.sort();
assert_ne!(
startup_blocked, reload_blocked,
"reload should produce a different blocked_commands set after plugin install"
);
assert!(
reload_blocked.contains(&"curl".to_owned()),
"reload config must contain plugin-added blocked command"
);
assert_eq!(startup_allowed, reload_allowed);
}
#[test]
fn invalid_plugin_name_in_manifest_skipped() {
let dir = TempDir::new().unwrap();
let plugin_dir = dir.path().join("bad-name-dir");
fs::create_dir(&plugin_dir).unwrap();
fs::write(
plugin_dir.join(".plugin.toml"),
"[plugin]\nname = \"INVALID\"\nversion = \"0.1.0\"\n[config.tools]\nblocked_commands = [\"evil\"]",
)
.unwrap();
let mut cfg = base_config();
let overlay = apply_plugin_config_overlays(&mut cfg, dir.path()).unwrap();
assert!(cfg.tools.shell.blocked_commands.is_empty());
assert!(overlay.source_plugins.is_empty());
}
#[test]
fn overlay_ignores_integrity_registry_file() {
let dir = TempDir::new().unwrap();
let plugins_dir = dir.path();
fs::write(plugins_dir.join(".plugin-integrity.toml"), b"").unwrap();
let mut cfg = base_config();
let overlay = apply_plugin_config_overlays(&mut cfg, plugins_dir).unwrap();
assert!(
overlay.source_plugins.is_empty(),
"registry file must not be treated as a plugin"
);
assert!(
overlay.skipped_plugins.is_empty(),
"no errors expected for a non-dir entry"
);
}
#[test]
fn tampered_manifest_skipped_with_integrity_reason() {
let dir = TempDir::new().unwrap();
let plugins_dir = dir.path();
let registry_path = dir.path().join("registry.toml");
write_plugin_overlay(
plugins_dir,
"myplugin",
"[config.tools]\nblocked_commands = [\"curl\"]",
);
let toml_path = plugins_dir.join("myplugin").join(".plugin.toml");
let mut registry = crate::integrity::IntegrityRegistry::load(®istry_path);
registry.record("myplugin", &toml_path).unwrap();
registry.save(®istry_path).unwrap();
fs::write(&toml_path, "[plugin]\nname = \"myplugin\"\nversion = \"0.1.0\"\n[config.tools]\nblocked_commands = [\"evil\"]").unwrap();
let mut cfg = base_config();
let overlay =
apply_plugin_config_overlays_with_registry(&mut cfg, plugins_dir, ®istry_path)
.unwrap();
assert!(
cfg.tools.shell.blocked_commands.is_empty(),
"tampered plugin must not contribute"
);
let reason = overlay
.skipped_plugins
.iter()
.find(|s| s.contains("integrity mismatch"));
assert!(
reason.is_some(),
"expected integrity mismatch in skipped_plugins; got: {:?}",
overlay.skipped_plugins
);
}
#[test]
fn missing_integrity_record_allowed() {
let dir = TempDir::new().unwrap();
let plugins_dir = dir.path();
let registry_path = dir.path().join("registry.toml");
write_plugin_overlay(
plugins_dir,
"oldplugin",
"[config.tools]\nblocked_commands = [\"nc\"]",
);
let mut cfg = base_config();
let overlay =
apply_plugin_config_overlays_with_registry(&mut cfg, plugins_dir, ®istry_path)
.unwrap();
assert!(
overlay.skipped_plugins.is_empty(),
"pre-integrity plugin must not be skipped"
);
assert!(cfg.tools.shell.blocked_commands.contains(&"nc".to_owned()));
}
}