use anyhow::{bail, Context, Result};
use clap::{Args, Subcommand};
use serde_json::{Map, Value};
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use toml_edit::{value, Array, DocumentMut, Item, Table};
use mati_core::store::{
GotchaRecord, PolicyMode, PolicyRecord, PolicyStage, Record, RecordLifecycle,
};
use super::proxy::StoreProxy;
pub(super) const TAG_DENY_WRITE: &str = "crown-jewel";
pub(super) const TAG_DENY_READ: &str = "sandbox-deny-read";
pub(super) const TAG_SECRET_DENY: &str = "secret-deny";
pub(super) const TAG_SECRET_MASK: &str = "secret-mask";
#[derive(Args, Debug)]
pub struct SandboxArgs {
#[command(subcommand)]
pub command: SandboxCommand,
}
#[derive(Subcommand, Debug)]
pub enum SandboxCommand {
Compile(CompileArgs),
Protect(ProtectArgs),
Unprotect(ProtectArgs),
Clear,
}
#[derive(Args, Debug)]
pub struct CompileArgs {
#[arg(long)]
pub codex: bool,
#[arg(long)]
pub apply: bool,
#[arg(long)]
pub force: bool,
#[arg(long)]
pub acknowledge_codex_non_enforcement: bool,
}
#[derive(Args, Debug)]
pub struct ProtectArgs {
pub file: String,
#[arg(long)]
pub read: bool,
#[arg(long, value_parser = ["deny", "mask"])]
pub secret: Option<String>,
#[arg(long)]
pub yes: bool,
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct SandboxRules {
pub deny_read: BTreeSet<String>,
pub deny_write: BTreeSet<String>,
pub credentials_deny: BTreeSet<String>,
pub credentials_mask: BTreeSet<String>,
pub denied_domains: BTreeSet<String>,
}
impl SandboxRules {
pub fn is_empty(&self) -> bool {
self.deny_read.is_empty()
&& self.deny_write.is_empty()
&& self.credentials_deny.is_empty()
&& self.credentials_mask.is_empty()
&& self.denied_domains.is_empty()
}
}
pub struct GotchaSel<'a> {
pub tags: &'a [String],
pub active: bool,
pub confirmed: bool,
pub files: &'a [String],
}
pub fn compile_relative<'a>(gotchas: impl Iterator<Item = GotchaSel<'a>>) -> SandboxRules {
let mut rules = SandboxRules::default();
for g in gotchas {
if !g.active || !g.confirmed {
continue;
}
let deny_write = g.tags.iter().any(|t| t == TAG_DENY_WRITE);
let deny_read = g.tags.iter().any(|t| t == TAG_DENY_READ);
let secret_deny = g.tags.iter().any(|t| t == TAG_SECRET_DENY);
let secret_mask = g.tags.iter().any(|t| t == TAG_SECRET_MASK);
if !deny_write && !deny_read && !secret_deny && !secret_mask {
continue;
}
for f in g.files {
let rel = normalize_rel(f);
if rel.is_empty() {
continue;
}
if deny_write {
rules.deny_write.insert(rel.clone());
}
if deny_read {
rules.deny_read.insert(rel.clone());
}
if secret_deny {
rules.credentials_deny.insert(rel.clone());
}
if secret_mask {
rules.credentials_mask.insert(rel);
}
}
}
rules
}
type PolicyItem = (
bool,
PolicyStage,
PolicyMode,
Option<String>,
Option<String>,
);
pub struct PolicyDomainSel<'a> {
pub active: bool,
pub stage: PolicyStage,
pub mode: PolicyMode,
pub tool: Option<&'a str>,
pub host_glob: Option<&'a str>,
}
pub fn compile_domains_relative<'a>(
policies: impl Iterator<Item = PolicyDomainSel<'a>>,
) -> BTreeSet<String> {
let mut domains = BTreeSet::new();
for p in policies {
if !p.active
|| !matches!(p.stage, PolicyStage::Enforce)
|| !matches!(p.mode, PolicyMode::Block)
{
continue;
}
if p.tool != Some("db_client") {
continue;
}
if let Some(glob) = p.host_glob {
let g = glob.trim();
if !g.is_empty() {
domains.insert(g.to_string());
}
}
}
domains
}
pub fn db_client_host_glob_universe<'a>(
policies: impl Iterator<Item = (Option<&'a str>, Option<&'a str>)>,
) -> BTreeSet<String> {
let mut universe = BTreeSet::new();
for (tool, host_glob) in policies {
if tool != Some("db_client") {
continue;
}
if let Some(glob) = host_glob {
let g = glob.trim();
if !g.is_empty() {
universe.insert(g.to_string());
}
}
}
universe
}
fn normalize_rel(p: &str) -> String {
p.replace('\\', "/")
.trim_start_matches("./")
.trim_start_matches('/')
.to_string()
}
fn blast_radius(target: &str, affected_files: &[String]) -> Vec<String> {
affected_files
.iter()
.map(|f| normalize_rel(f))
.filter(|f| f != target)
.collect()
}
fn resolve_under_repo(repo_root: &Path, rel: &str) -> Option<PathBuf> {
let resolved = canonicalize_lenient(&repo_root.join(rel))?;
resolved.starts_with(repo_root).then_some(resolved)
}
pub(super) fn canonicalize_lenient(path: &Path) -> Option<PathBuf> {
if let Ok(c) = std::fs::canonicalize(path) {
return Some(c);
}
let mut tail: Vec<std::ffi::OsString> = Vec::new();
let mut cur = path;
loop {
let parent = cur.parent()?;
tail.push(cur.file_name()?.to_os_string());
if let Ok(cp) = std::fs::canonicalize(parent) {
let mut out = cp;
for comp in tail.iter().rev() {
out.push(comp);
}
return Some(out);
}
cur = parent;
}
}
fn resolve_rules(repo_root: &Path, rel: &SandboxRules) -> (SandboxRules, BTreeSet<String>) {
let mut abs = SandboxRules::default();
let mut skipped = BTreeSet::new();
let mut resolve_into = |rels: &BTreeSet<String>, into: &mut BTreeSet<String>| {
for r in rels {
match resolve_under_repo(repo_root, r) {
Some(p) => {
into.insert(p.to_string_lossy().into_owned());
}
None => {
skipped.insert(r.clone());
}
}
}
};
resolve_into(&rel.deny_write, &mut abs.deny_write);
resolve_into(&rel.deny_read, &mut abs.deny_read);
resolve_into(&rel.credentials_deny, &mut abs.credentials_deny);
resolve_into(&rel.credentials_mask, &mut abs.credentials_mask);
abs.denied_domains = rel.denied_domains.clone();
(abs, skipped)
}
fn is_mati_owned(entry: &str, repo_root: &Path) -> bool {
Path::new(entry).starts_with(repo_root)
}
fn apply_into_settings(
mut root: Value,
repo_root: &Path,
abs: &SandboxRules,
domain_universe: &BTreeSet<String>,
) -> Value {
{
let sandbox = ensure_child(&mut root, "sandbox");
let fs = ensure_child(sandbox, "filesystem");
set_owned_array(fs, "denyWrite", repo_root, &abs.deny_write);
set_owned_array(fs, "denyRead", repo_root, &abs.deny_read);
}
{
let sandbox = ensure_child(&mut root, "sandbox");
let creds = ensure_child(sandbox, "credentials");
set_owned_credential_files(
creds,
repo_root,
&abs.credentials_deny,
&abs.credentials_mask,
);
}
{
let sandbox = ensure_child(&mut root, "sandbox");
let net = ensure_child(sandbox, "network");
set_owned_domains(net, domain_universe, &abs.denied_domains);
}
root
}
fn clear_from_settings(
mut root: Value,
repo_root: &Path,
domain_universe: &BTreeSet<String>,
) -> Value {
if let Some(fs) = nav_mut(&mut root, &["sandbox", "filesystem"]) {
set_owned_array(fs, "denyWrite", repo_root, &BTreeSet::new());
set_owned_array(fs, "denyRead", repo_root, &BTreeSet::new());
}
if let Some(creds) = nav_mut(&mut root, &["sandbox", "credentials"]) {
set_owned_credential_files(creds, repo_root, &BTreeSet::new(), &BTreeSet::new());
}
if let Some(net) = nav_mut(&mut root, &["sandbox", "network"]) {
set_owned_domains(net, domain_universe, &BTreeSet::new());
}
root
}
fn set_owned_array(fs: &mut Value, key: &str, repo_root: &Path, mati: &BTreeSet<String>) {
let Value::Object(map) = fs else {
return;
};
let mut kept: Vec<Value> = Vec::new();
if let Some(Value::Array(existing)) = map.get(key) {
for v in existing {
match v.as_str() {
Some(s) if is_mati_owned(s, repo_root) => {} _ => kept.push(v.clone()), }
}
}
kept.extend(mati.iter().map(|m| Value::String(m.clone())));
if kept.is_empty() {
map.remove(key);
} else {
map.insert(key.to_string(), Value::Array(kept));
}
}
fn set_owned_credential_files(
creds: &mut Value,
repo_root: &Path,
deny: &BTreeSet<String>,
mask: &BTreeSet<String>,
) {
let Value::Object(map) = creds else {
return;
};
let mut kept: Vec<Value> = Vec::new();
if let Some(Value::Array(existing)) = map.get("files") {
for v in existing {
let owned = v
.get("path")
.and_then(Value::as_str)
.is_some_and(|p| is_mati_owned(p, repo_root));
if !owned {
kept.push(v.clone());
}
}
}
for (mode, paths) in [("deny", deny), ("mask", mask)] {
for p in paths {
kept.push(serde_json::json!({ "mode": mode, "path": p }));
}
}
if kept.is_empty() {
map.remove("files");
} else {
map.insert("files".to_string(), Value::Array(kept));
}
if map.is_empty() {
*creds = Value::Object(Map::new());
}
}
fn set_owned_domains(net: &mut Value, domain_universe: &BTreeSet<String>, mati: &BTreeSet<String>) {
let Value::Object(map) = net else {
return;
};
let mut kept: Vec<Value> = Vec::new();
if let Some(Value::Array(existing)) = map.get("deniedDomains") {
for v in existing {
match v.as_str() {
Some(s) if domain_universe.contains(s) => {} _ => kept.push(v.clone()),
}
}
}
kept.extend(mati.iter().map(|d| Value::String(d.clone())));
if kept.is_empty() {
map.remove("deniedDomains");
} else {
map.insert("deniedDomains".to_string(), Value::Array(kept));
}
}
fn ensure_child<'a>(v: &'a mut Value, key: &str) -> &'a mut Value {
if !v.is_object() {
*v = Value::Object(Map::new());
}
match v {
Value::Object(map) => map
.entry(key.to_string())
.or_insert_with(|| Value::Object(Map::new())),
_ => unreachable!("v was just coerced to an object"),
}
}
fn nav_mut<'a>(root: &'a mut Value, keys: &[&str]) -> Option<&'a mut Value> {
let mut cur = root;
for k in keys {
cur = cur.as_object_mut()?.get_mut(*k)?;
}
Some(cur)
}
fn read_settings(path: &Path) -> Result<Value> {
if !path.exists() {
return Ok(Value::Object(Map::new()));
}
let s = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
if s.trim().is_empty() {
return Ok(Value::Object(Map::new()));
}
let v: Value = serde_json::from_str(&s).with_context(|| {
format!(
"{} is not valid JSON — fix or remove it (refusing to overwrite)",
path.display()
)
})?;
if !v.is_object() {
bail!("{} is not a JSON object", path.display());
}
validate_sandbox_shape(&v)?;
Ok(v)
}
fn validate_sandbox_shape(root: &Value) -> Result<()> {
let Some(sb) = root.get("sandbox") else {
return Ok(());
};
if !sb.is_object() {
bail!("settings `sandbox` is not an object");
}
let Some(fs) = sb.get("filesystem") else {
return Ok(());
};
if !fs.is_object() {
bail!("settings `sandbox.filesystem` is not an object");
}
for k in ["denyRead", "denyWrite"] {
if let Some(a) = fs.get(k) {
if !a.is_array() {
bail!("settings `sandbox.filesystem.{k}` is not an array");
}
}
}
if let Some(creds) = sb.get("credentials") {
if !creds.is_object() {
bail!("settings `sandbox.credentials` is not an object");
}
if let Some(files) = creds.get("files") {
if !files.is_array() {
bail!("settings `sandbox.credentials.files` is not an array");
}
}
}
if let Some(net) = sb.get("network") {
if !net.is_object() {
bail!("settings `sandbox.network` is not an object");
}
if let Some(dd) = net.get("deniedDomains") {
if !dd.is_array() {
bail!("settings `sandbox.network.deniedDomains` is not an array");
}
}
}
Ok(())
}
fn write_settings_atomic(path: &Path, v: &Value) -> Result<()> {
let dir = path.parent().context("settings path has no parent")?;
std::fs::create_dir_all(dir)?;
let body = serde_json::to_string_pretty(v)? + "\n";
let name = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("settings.local.json");
let tmp = path.with_file_name(format!(".{name}.mati-tmp"));
std::fs::write(&tmp, body.as_bytes()).with_context(|| format!("write {}", tmp.display()))?;
std::fs::rename(&tmp, path).with_context(|| format!("rename into {}", path.display()))?;
Ok(())
}
pub async fn run(args: SandboxArgs) -> Result<()> {
match args.command {
SandboxCommand::Compile(a) => run_compile(a).await,
SandboxCommand::Protect(a) => run_protect(a, true).await,
SandboxCommand::Unprotect(a) => run_protect(a, false).await,
SandboxCommand::Clear => run_clear().await,
}
}
pub(crate) fn repo_root_for(cwd: &Path) -> Result<PathBuf> {
let start = std::fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf());
let mut dir: &Path = &start;
loop {
if dir.join(".claude").is_dir() || dir.join(".git").exists() {
return Ok(dir.to_path_buf());
}
match dir.parent() {
Some(p) => dir = p,
None => break,
}
}
Ok(start)
}
fn settings_local_path(repo_root: &Path) -> PathBuf {
repo_root.join(".claude").join("settings.local.json")
}
async fn run_compile(args: CompileArgs) -> Result<()> {
let cwd = std::env::current_dir()?;
let repo_root = repo_root_for(&cwd)?;
if args.codex {
println!(
"{CODEX_NON_ENFORCEMENT_WARNING}They are written only in preparation for a future runtime that enforces them; they are not active protection."
);
if args.apply && !args.acknowledge_codex_non_enforcement {
bail!(
"Codex does not currently enforce these entries; refusing --codex --apply without --acknowledge-codex-non-enforcement"
);
}
}
let store = StoreProxy::open(&cwd).await?;
let (abs, skipped, domain_universe, warnings) = compute_rules(&store, &repo_root).await?;
for w in &warnings {
eprintln!("warning: {w}");
}
for s in &skipped {
eprintln!(
"warning: {s} resolves outside the repo — skipped (denies are clamped to the repo)"
);
}
if args.codex {
return run_compile_codex(&args, &repo_root, &abs, &skipped, &store).await;
}
let path = settings_local_path(&repo_root);
if args.apply {
materialize(&path, &repo_root, &abs, &domain_universe, true, args.force)?;
audit_sandbox(&store, "apply", &abs).await;
println!(
"Applied {} denyWrite + {} denyRead + {} credentials + {} deniedDomains entries to {}",
abs.deny_write.len(),
abs.deny_read.len(),
abs.credentials_deny.len() + abs.credentials_mask.len(),
abs.denied_domains.len(),
path.display()
);
enablement_hint();
} else {
if let Ok(existing) = read_settings(&path) {
for r in drifted_removals(&existing, &repo_root, &abs, &domain_universe) {
eprintln!("drift: {r} is in your sandbox config but no longer has a confirmed crown-jewel gotcha, secret tag, or enforcing db_client policy — `--apply` would remove it");
}
}
print_preview(&abs, &path);
}
Ok(())
}
const CODEX_PROFILE: &str = "mati";
const CODEX_NON_ENFORCEMENT_WARNING: &str =
"WARNING: Codex does not currently enforce filesystem deny_read/deny_write entries. ";
async fn run_compile_codex(
args: &CompileArgs,
repo_root: &Path,
abs: &SandboxRules,
skipped: &BTreeSet<String>,
store: &StoreProxy,
) -> Result<()> {
let path = codex_config_path(repo_root);
let codex = codex_rules(abs);
if args.apply {
materialize_codex(&path, repo_root, &codex, args.force)?;
audit_sandbox(store, "apply-codex-preparation", &codex).await;
println!(
"Prepared Codex profile [permissions.{CODEX_PROFILE}] with {} deny_write + {} deny_read entries in {} (not enforced by Codex).",
codex.deny_write.len(),
codex.deny_read.len(),
path.display()
);
} else {
if let Ok(existing) = read_codex_config(&path) {
for r in drifted_codex_removals(&existing, repo_root, &codex) {
eprintln!(
"drift: {r} is in the Codex preparation profile but no longer has a confirmed explicit sandbox tag — `--apply` would remove it"
);
}
}
print_codex_preview(&codex, &path, skipped);
}
Ok(())
}
fn codex_config_path(repo_root: &Path) -> PathBuf {
repo_root.join(".codex").join("config.toml")
}
fn codex_rules(abs: &SandboxRules) -> SandboxRules {
let mut rules = SandboxRules {
deny_read: abs.deny_read.clone(),
deny_write: abs.deny_write.clone(),
..SandboxRules::default()
};
rules.deny_read.extend(abs.credentials_deny.iter().cloned());
rules.deny_read.extend(abs.credentials_mask.iter().cloned());
rules
}
fn read_codex_config(path: &Path) -> Result<DocumentMut> {
if !path.exists() {
return Ok(DocumentMut::new());
}
let body = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
if body.trim().is_empty() {
return Ok(DocumentMut::new());
}
let doc = body.parse::<DocumentMut>().with_context(|| {
format!(
"{} is not valid TOML — fix or remove it (refusing to overwrite)",
path.display()
)
})?;
validate_codex_shape(&doc)?;
Ok(doc)
}
fn validate_codex_shape(doc: &DocumentMut) -> Result<()> {
let Some(permissions) = doc.get("permissions") else {
return Ok(());
};
let permissions = permissions
.as_table()
.ok_or_else(|| anyhow::anyhow!("Codex config `permissions` is not a table"))?;
let Some(profile) = permissions.get(CODEX_PROFILE) else {
return Ok(());
};
let profile = profile.as_table().ok_or_else(|| {
anyhow::anyhow!("Codex config `permissions.{CODEX_PROFILE}` is not a table")
})?;
for key in ["deny_read", "deny_write"] {
let Some(item) = profile.get(key) else {
continue;
};
let array = item.as_array().ok_or_else(|| {
anyhow::anyhow!("Codex config `permissions.{CODEX_PROFILE}.{key}` is not an array")
})?;
for entry in array.iter() {
if entry.as_str().is_none() {
bail!(
"Codex config `permissions.{CODEX_PROFILE}.{key}` contains a non-string entry"
);
}
}
}
Ok(())
}
fn codex_profile_array(doc: &DocumentMut, key: &str) -> Result<Vec<String>> {
let Some(permissions) = doc.get("permissions") else {
return Ok(Vec::new());
};
let permissions = permissions
.as_table()
.ok_or_else(|| anyhow::anyhow!("Codex config `permissions` is not a table"))?;
let Some(profile) = permissions.get(CODEX_PROFILE) else {
return Ok(Vec::new());
};
let profile = profile.as_table().ok_or_else(|| {
anyhow::anyhow!("Codex config `permissions.{CODEX_PROFILE}` is not a table")
})?;
let Some(item) = profile.get(key) else {
return Ok(Vec::new());
};
Ok(item
.as_array()
.ok_or_else(|| {
anyhow::anyhow!("Codex config `permissions.{CODEX_PROFILE}.{key}` is not an array")
})?
.iter()
.filter_map(|v| v.as_str().map(ToOwned::to_owned))
.collect())
}
fn set_codex_owned_array(
profile: &mut Table,
key: &str,
repo_root: &Path,
entries: &BTreeSet<String>,
) {
let mut kept = Array::new();
if let Some(existing) = profile.get(key).and_then(Item::as_array) {
for entry in existing.iter() {
if let Some(path) = entry.as_str() {
if !is_mati_owned(path, repo_root) {
kept.push(path);
}
}
}
}
for entry in entries {
kept.push(entry.as_str());
}
profile.insert(key, value(kept));
}
fn materialize_codex(
path: &Path,
repo_root: &Path,
rules: &SandboxRules,
force: bool,
) -> Result<()> {
let mut doc = read_codex_config(path)?;
let removals = drifted_codex_removals(&doc, repo_root, rules);
if !removals.is_empty() && !force {
eprintln!(
"Refusing to remove {} Codex preparation entr(y/ies) whose explicit tag is gone:",
removals.len()
);
for removal in &removals {
eprintln!(" {removal}");
}
bail!(
"re-add the explicit tag, or pass --force to remove them; Codex still does not enforce these entries"
);
}
for removal in removals {
eprintln!("note: removing Codex preparation entry for {removal}");
}
let permissions = doc
.entry("permissions")
.or_insert(Item::Table(Table::new()))
.as_table_mut()
.ok_or_else(|| anyhow::anyhow!("Codex config `permissions` is not a table"))?;
let profile = permissions
.entry(CODEX_PROFILE)
.or_insert(Item::Table(Table::new()))
.as_table_mut()
.ok_or_else(|| {
anyhow::anyhow!("Codex config `permissions.{CODEX_PROFILE}` is not a table")
})?;
set_codex_owned_array(profile, "deny_read", repo_root, &rules.deny_read);
set_codex_owned_array(profile, "deny_write", repo_root, &rules.deny_write);
write_toml_atomic(path, &doc)
}
fn drifted_codex_removals(
doc: &DocumentMut,
repo_root: &Path,
rules: &SandboxRules,
) -> BTreeSet<String> {
let mut removed = BTreeSet::new();
for (key, current) in [
("deny_read", &rules.deny_read),
("deny_write", &rules.deny_write),
] {
if let Ok(existing) = codex_profile_array(doc, key) {
for entry in existing {
if is_mati_owned(&entry, repo_root) && !current.contains(&entry) {
removed.insert(entry);
}
}
}
}
removed
}
fn write_toml_atomic(path: &Path, doc: &DocumentMut) -> Result<()> {
let dir = path.parent().context("Codex config path has no parent")?;
std::fs::create_dir_all(dir)?;
let tmp = path.with_file_name(".config.toml.mati-tmp");
std::fs::write(&tmp, doc.to_string().as_bytes())
.with_context(|| format!("write {}", tmp.display()))?;
std::fs::rename(&tmp, path).with_context(|| format!("rename into {}", path.display()))?;
Ok(())
}
fn print_codex_preview(rules: &SandboxRules, path: &Path, skipped: &BTreeSet<String>) {
println!(
"Codex preparation preview — nothing written. `--codex --apply` writes to {} only with --acknowledge-codex-non-enforcement.",
path.display()
);
if rules.deny_write.is_empty() && rules.deny_read.is_empty() {
println!("No explicitly tagged confirmed paths resolve to in-repo Codex deny entries.");
}
if !rules.deny_write.is_empty() {
println!(" [permissions.{CODEX_PROFILE}].deny_write (not enforced by Codex):");
for path in &rules.deny_write {
println!(" {path}");
}
}
if !rules.deny_read.is_empty() {
println!(" [permissions.{CODEX_PROFILE}].deny_read (not enforced by Codex):");
for path in &rules.deny_read {
println!(" {path}");
}
}
if !skipped.is_empty() {
println!("Out-of-repo paths were skipped; Codex preparation remains repo-clamped.");
}
}
pub(crate) async fn compute_rules(
store: &StoreProxy,
repo_root: &Path,
) -> Result<(
SandboxRules,
BTreeSet<String>,
BTreeSet<String>,
Vec<String>,
)> {
let records = store.scan_prefix("gotcha:").await?;
let mut warnings = Vec::new();
let mut items: Vec<(Vec<String>, bool, bool, Vec<String>)> = Vec::new();
for r in &records {
let active = matches!(r.lifecycle, RecordLifecycle::Active);
let (confirmed, files) = match r.payload_as::<GotchaRecord>() {
Some(g) => (g.confirmed, g.affected_files),
None => (false, Vec::new()),
};
let tagged = r.tags.iter().any(|t| {
t == TAG_DENY_WRITE
|| t == TAG_DENY_READ
|| t == TAG_SECRET_DENY
|| t == TAG_SECRET_MASK
});
if tagged && active && !confirmed {
warnings.push(format!(
"{} is tagged for the sandbox floor but not confirmed — not enforced (run `mati gotcha confirm`)",
r.key
));
} else if tagged && active && files.is_empty() {
warnings.push(format!(
"{} is tagged for the sandbox floor but has no affected_files",
r.key
));
}
items.push((r.tags.clone(), active, confirmed, files));
}
let rel = compile_relative(items.iter().map(|(t, a, c, f)| GotchaSel {
tags: t,
active: *a,
confirmed: *c,
files: f,
}));
let (mut abs, skipped) = resolve_rules(repo_root, &rel);
let policies = store.scan_prefix("policy:").await?;
let mut policy_items: Vec<PolicyItem> = Vec::new();
for r in &policies {
let active = matches!(r.lifecycle, RecordLifecycle::Active);
if let Some(p) = r.payload_as::<PolicyRecord>() {
let db_client = p.trigger.tool.as_deref() == Some("db_client");
if db_client
&& matches!(p.stage, PolicyStage::Enforce)
&& matches!(p.mode, PolicyMode::Block)
&& p.trigger.host_glob.is_none()
{
warnings.push(format!(
"{} is an enforcing db_client block policy but has no host_glob — no domain compiled",
r.key
));
}
policy_items.push((active, p.stage, p.mode, p.trigger.tool, p.trigger.host_glob));
}
}
abs.denied_domains = compile_domains_relative(policy_items.iter().map(
|(active, stage, mode, tool, host_glob)| PolicyDomainSel {
active: *active,
stage: *stage,
mode: *mode,
tool: tool.as_deref(),
host_glob: host_glob.as_deref(),
},
));
let domain_universe = db_client_host_glob_universe(
policy_items
.iter()
.map(|(_, _, _, tool, host_glob)| (tool.as_deref(), host_glob.as_deref())),
);
Ok((abs, skipped, domain_universe, warnings))
}
async fn scan_domain_universe(store: &StoreProxy) -> BTreeSet<String> {
let policies = store.scan_prefix("policy:").await.unwrap_or_default();
let pairs: Vec<(Option<String>, Option<String>)> = policies
.iter()
.filter_map(|r| r.payload_as::<PolicyRecord>())
.map(|p| (p.trigger.tool, p.trigger.host_glob))
.collect();
db_client_host_glob_universe(pairs.iter().map(|(t, h)| (t.as_deref(), h.as_deref())))
}
fn materialize(
path: &Path,
repo_root: &Path,
abs: &SandboxRules,
domain_universe: &BTreeSet<String>,
guarded: bool,
force: bool,
) -> Result<()> {
let existing = read_settings(path)?;
let removals = drifted_removals(&existing, repo_root, abs, domain_universe);
if guarded && !removals.is_empty() && !force {
eprintln!(
"Refusing to remove {} sandbox protection(s) whose tag or enforcing policy is gone:",
removals.len()
);
for r in &removals {
eprintln!(" {r}");
}
bail!("re-tag via `mati sandbox protect <file>`, or pass --force to remove them");
}
for r in &removals {
eprintln!("note: removing sandbox protection for {r}");
}
let merged = apply_into_settings(existing, repo_root, abs, domain_universe);
write_settings_atomic(path, &merged)
}
fn drifted_removals(
existing: &Value,
repo_root: &Path,
abs: &SandboxRules,
domain_universe: &BTreeSet<String>,
) -> BTreeSet<String> {
let mut removed = BTreeSet::new();
for (key, new_set) in [("denyWrite", &abs.deny_write), ("denyRead", &abs.deny_read)] {
let Some(arr) = settings_array(existing, &["sandbox", "filesystem"], key) else {
continue;
};
for s in arr.iter().filter_map(|v| v.as_str()) {
if is_mati_owned(s, repo_root) && !new_set.contains(s) {
removed.insert(s.to_string());
}
}
}
if let Some(arr) = settings_array(existing, &["sandbox", "credentials"], "files") {
let current: BTreeSet<&String> = abs
.credentials_deny
.iter()
.chain(&abs.credentials_mask)
.collect();
for v in arr {
if let Some(p) = v.get("path").and_then(Value::as_str) {
if is_mati_owned(p, repo_root) && !current.contains(&p.to_string()) {
removed.insert(p.to_string());
}
}
}
}
if let Some(arr) = settings_array(existing, &["sandbox", "network"], "deniedDomains") {
for s in arr.iter().filter_map(|v| v.as_str()) {
if domain_universe.contains(s) && !abs.denied_domains.contains(s) {
removed.insert(s.to_string());
}
}
}
removed
}
fn settings_array<'a>(root: &'a Value, path: &[&str], key: &str) -> Option<&'a Vec<Value>> {
let mut cur = root;
for p in path {
cur = cur.get(p)?;
}
cur.get(key)?.as_array()
}
fn add_tags(tags: &mut Vec<String>, add: &[&str]) {
for t in add {
if !tags.iter().any(|x| x == t) {
tags.push((*t).to_string());
}
}
}
fn remove_tags(tags: &mut Vec<String>, rm: &[&str]) {
tags.retain(|t| !rm.iter().any(|r| r == t));
}
async fn run_protect(args: ProtectArgs, add: bool) -> Result<()> {
let verb = if add { "protect" } else { "unprotect" };
let cwd = std::env::current_dir()?;
let repo_root = repo_root_for(&cwd)?;
let store = StoreProxy::open(&cwd).await?;
let file = normalize_rel(&args.file);
let matched: Vec<Record> = store
.scan_prefix("gotcha:")
.await?
.into_iter()
.filter(|r| {
matches!(r.lifecycle, RecordLifecycle::Active)
&& r.payload_as::<GotchaRecord>()
.map(|g| {
g.confirmed && g.affected_files.iter().any(|af| normalize_rel(af) == file)
})
.unwrap_or(false)
})
.collect();
if matched.is_empty() {
bail!(
"no confirmed gotcha covers `{file}` — add one first:\n \
mati gotcha add {file} -r \"<rule>\" then mati gotcha confirm <key>"
);
}
for r in &matched {
if let Some(g) = r.payload_as::<GotchaRecord>() {
let others = blast_radius(&file, &g.affected_files);
if !others.is_empty() && !args.yes {
eprintln!("`{}` also covers: {}", r.key, others.join(", "));
bail!("the crown-jewel tag is per-gotcha, so this would {verb} those too — re-run with --yes to confirm, or split the gotcha");
}
}
}
let to_add: Vec<&str> = match args.secret.as_deref() {
Some("deny") => vec![TAG_SECRET_DENY],
Some("mask") => vec![TAG_SECRET_MASK],
_ if args.read => vec![TAG_DENY_WRITE, TAG_DENY_READ],
_ => vec![TAG_DENY_WRITE],
};
let mut n = 0;
for r in &matched {
if let Some(mut rec) = store.get(&r.key).await? {
if add {
add_tags(&mut rec.tags, &to_add);
} else {
remove_tags(
&mut rec.tags,
&[
TAG_DENY_WRITE,
TAG_DENY_READ,
TAG_SECRET_DENY,
TAG_SECRET_MASK,
],
);
}
store.put(&r.key, &rec).await?;
n += 1;
}
}
let (abs, _skipped, domain_universe, _warnings) = compute_rules(&store, &repo_root).await?;
let path = settings_local_path(&repo_root);
materialize(&path, &repo_root, &abs, &domain_universe, false, true)?;
audit_sandbox(&store, if add { "protect" } else { "unprotect" }, &abs).await;
println!(
"{}ed `{file}` ({n} gotcha(s) updated); synced {}.",
if add { "Protect" } else { "Unprotect" },
path.display()
);
if add {
enablement_hint();
}
Ok(())
}
async fn audit_sandbox(store: &StoreProxy, action: &str, abs: &SandboxRules) {
let new_value = format!(
"{} denyWrite + {} denyRead + {} credentials + {} deniedDomains",
abs.deny_write.len(),
abs.deny_read.len(),
abs.credentials_deny.len() + abs.credentials_mask.len(),
abs.denied_domains.len()
);
store
.record_sandbox_audit(&new_value, &format!("sandbox_{action}"))
.await;
}
async fn run_clear() -> Result<()> {
let cwd = std::env::current_dir()?;
let repo_root = repo_root_for(&cwd)?;
let path = settings_local_path(&repo_root);
if !path.exists() {
println!("Nothing to clear: {} does not exist.", path.display());
return Ok(());
}
let existing = read_settings(&path)?;
let store = StoreProxy::open(&cwd).await.ok();
let domain_universe = match &store {
Some(s) => scan_domain_universe(s).await,
None => BTreeSet::new(),
};
let cleared = clear_from_settings(existing, &repo_root, &domain_universe);
write_settings_atomic(&path, &cleared)?;
if let Some(store) = &store {
audit_sandbox(store, "clear", &SandboxRules::default()).await;
}
let unresolved_domains = if store.is_none() {
settings_array(&cleared, &["sandbox", "network"], "deniedDomains").map_or(0, Vec::len)
} else {
0
};
if unresolved_domains > 0 {
println!(
"Partial clear: removed mati-managed (in-repo) sandbox deny/credentials rules from {}",
path.display()
);
eprintln!(
"store unavailable — {unresolved_domains} sandbox.network.deniedDomains entr{} left \
in place; deciding ownership needs the policy corpus. Re-run with the store \
reachable (`mati daemon stop` clears a wedged daemon) to finish.",
if unresolved_domains == 1 { "y" } else { "ies" }
);
std::process::exit(1);
}
println!(
"Cleared mati-managed (in-repo) sandbox deny/credentials/network rules from {}",
path.display()
);
Ok(())
}
fn enablement_hint() {
println!(
"\nThese OS-level denies cover the agent's shell and every subprocess it spawns;\n\
the agent can still reach the files through the consultation-gated Read/Edit\n\
tools (L1). They take effect only once the Claude Code sandbox is enabled\n\
(`/sandbox`, or `sandbox.enabled` in settings) on macOS / Linux / WSL2.\n\
Run `mati sandbox clear` to remove them."
);
}
fn print_preview(abs: &SandboxRules, path: &Path) {
if abs.is_empty() {
println!("No crown-jewel/secret gotchas or enforcing db_client policies resolve to in-repo rules.");
println!(
"Tag a confirmed gotcha with `{TAG_DENY_WRITE}` (deny shell writes), `{TAG_DENY_READ}` \
(deny shell reads), `{TAG_SECRET_DENY}`/`{TAG_SECRET_MASK}` (credentials), or enforce a \
db_client block policy with a host_glob, then `mati sandbox compile --apply`."
);
return;
}
println!(
"Sandbox floor preview — nothing written. `--apply` writes to {}.\n",
path.display()
);
if !abs.deny_write.is_empty() {
println!(" denyWrite (shell / subprocess cannot modify):");
for p in &abs.deny_write {
println!(" {p}");
}
}
if !abs.deny_read.is_empty() {
println!(" denyRead (shell / subprocess cannot read):");
for p in &abs.deny_read {
println!(" {p}");
}
}
if !abs.credentials_deny.is_empty() {
println!(" credentials.files mode=deny:");
for p in &abs.credentials_deny {
println!(" {p}");
}
}
if !abs.credentials_mask.is_empty() {
println!(" credentials.files mode=mask:");
for p in &abs.credentials_mask {
println!(" {p}");
}
}
if !abs.denied_domains.is_empty() {
println!(" network.deniedDomains (from enforcing db_client policies):");
for d in &abs.denied_domains {
println!(" {d}");
}
}
enablement_hint();
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn sv(v: &[&str]) -> Vec<String> {
v.iter().map(|x| x.to_string()).collect()
}
fn sel<'a>(tags: &'a [String], confirmed: bool, files: &'a [String]) -> GotchaSel<'a> {
GotchaSel {
tags,
active: true,
confirmed,
files,
}
}
#[test]
fn crown_jewel_maps_to_deny_write_relative() {
let t = sv(&["crown-jewel"]);
let f = sv(&["./src/payments/fraud.rs"]);
let r = compile_relative([sel(&t, true, &f)].into_iter());
assert!(r.deny_write.contains("src/payments/fraud.rs"));
assert!(r.deny_read.is_empty());
}
#[test]
fn deny_read_tag_and_compose() {
let t = sv(&["crown-jewel", "sandbox-deny-read"]);
let f = sv(&["secrets/key.pem"]);
let r = compile_relative([sel(&t, true, &f)].into_iter());
assert!(r.deny_write.contains("secrets/key.pem"));
assert!(r.deny_read.contains("secrets/key.pem"));
}
#[test]
fn unconfirmed_inactive_and_untagged_contribute_nothing() {
let t = sv(&["crown-jewel"]);
let f = sv(&["src/x.rs"]);
assert!(compile_relative([sel(&t, false, &f)].into_iter()).is_empty());
let inactive = GotchaSel {
tags: &t,
active: false,
confirmed: true,
files: &f,
};
assert!(compile_relative([inactive].into_iter()).is_empty());
let untagged = sv(&["enriched", "depth:deep"]);
assert!(compile_relative([sel(&untagged, true, &f)].into_iter()).is_empty());
}
#[test]
fn secret_tags_map_to_credentials_deny_and_mask() {
let t = sv(&["secret-deny"]);
let f = sv(&["vault/prod.pem"]);
let r = compile_relative([sel(&t, true, &f)].into_iter());
assert!(r.credentials_deny.contains("vault/prod.pem"));
assert!(r.credentials_mask.is_empty());
let t = sv(&["secret-mask"]);
let r = compile_relative([sel(&t, true, &f)].into_iter());
assert!(r.credentials_mask.contains("vault/prod.pem"));
assert!(r.credentials_deny.is_empty());
}
fn psel<'a>(
active: bool,
stage: PolicyStage,
mode: PolicyMode,
tool: Option<&'a str>,
host_glob: Option<&'a str>,
) -> PolicyDomainSel<'a> {
PolicyDomainSel {
active,
stage,
mode,
tool,
host_glob,
}
}
#[test]
fn enforcing_db_client_block_with_host_glob_compiles_domain() {
let d = compile_domains_relative(
[psel(
true,
PolicyStage::Enforce,
PolicyMode::Block,
Some("db_client"),
Some("*.prod.internal"),
)]
.into_iter(),
);
assert!(d.contains("*.prod.internal"));
}
#[test]
fn shadow_steer_wrong_tool_or_missing_glob_compile_nothing() {
assert!(compile_domains_relative(
[psel(
true,
PolicyStage::Shadow,
PolicyMode::Block,
Some("db_client"),
Some("*.prod")
)]
.into_iter()
)
.is_empty());
assert!(compile_domains_relative(
[psel(
true,
PolicyStage::Enforce,
PolicyMode::Steer,
Some("db_client"),
Some("*.prod")
)]
.into_iter()
)
.is_empty());
assert!(compile_domains_relative(
[psel(
true,
PolicyStage::Enforce,
PolicyMode::Block,
Some("path"),
Some("*.prod")
)]
.into_iter()
)
.is_empty());
assert!(compile_domains_relative(
[psel(
true,
PolicyStage::Enforce,
PolicyMode::Block,
Some("db_client"),
None
)]
.into_iter()
)
.is_empty());
assert!(compile_domains_relative(
[psel(
false,
PolicyStage::Enforce,
PolicyMode::Block,
Some("db_client"),
Some("*.prod")
)]
.into_iter()
)
.is_empty());
}
#[test]
fn domain_universe_includes_non_enforcing_db_client_globs() {
let universe = db_client_host_glob_universe(
[
(Some("db_client"), Some("*.staging")),
(Some("path"), Some("ignored")),
]
.into_iter(),
);
assert!(universe.contains("*.staging"));
assert!(!universe.contains("ignored"));
}
#[test]
fn is_mati_owned_only_under_repo() {
let repo = Path::new("/work/repo");
assert!(is_mati_owned("/work/repo/src/x.rs", repo));
assert!(!is_mati_owned("/work/other/x.rs", repo));
assert!(!is_mati_owned("~/.ssh/id_rsa", repo));
assert!(!is_mati_owned("./src/x.rs", repo));
}
#[test]
fn apply_preserves_user_entries_and_owns_in_repo() {
let repo = Path::new("/work/repo");
let existing = json!({
"sandbox": { "filesystem": { "denyWrite": ["~/.ssh", "/work/repo/OLD.rs"] } },
"env": { "X": "1" }
});
let mut abs = SandboxRules::default();
abs.deny_write.insert("/work/repo/src/new.rs".to_string());
let out = apply_into_settings(existing, repo, &abs, &BTreeSet::new());
let dw = out["sandbox"]["filesystem"]["denyWrite"]
.as_array()
.unwrap();
let set: BTreeSet<&str> = dw.iter().filter_map(|v| v.as_str()).collect();
assert!(set.contains("~/.ssh"), "user entry preserved");
assert!(
set.contains("/work/repo/src/new.rs"),
"new mati entry present"
);
assert!(
!set.contains("/work/repo/OLD.rs"),
"stale in-repo entry dropped"
);
assert_eq!(out["env"]["X"], "1", "unrelated settings untouched");
}
#[test]
fn apply_is_idempotent() {
let repo = Path::new("/work/repo");
let mut abs = SandboxRules::default();
abs.deny_read.insert("/work/repo/.env".to_string());
let once = apply_into_settings(json!({}), repo, &abs, &BTreeSet::new());
let twice = apply_into_settings(once.clone(), repo, &abs, &BTreeSet::new());
assert_eq!(once, twice);
}
#[test]
fn clear_removes_only_in_repo_entries() {
let repo = Path::new("/work/repo");
let existing = json!({
"sandbox": { "filesystem": {
"denyWrite": ["/work/repo/a.rs", "~/.aws"],
"denyRead": ["/work/repo/.env"]
} }
});
let out = clear_from_settings(existing, repo, &BTreeSet::new());
let dw: Vec<&str> = out["sandbox"]["filesystem"]["denyWrite"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str())
.collect();
assert_eq!(dw, vec!["~/.aws"], "user entry kept, mati entry removed");
assert!(out["sandbox"]["filesystem"].get("denyRead").is_none());
}
#[test]
fn credential_files_merge_owns_by_path_preserves_foreign_entries() {
let repo = Path::new("/work/repo");
let existing = json!({
"sandbox": { "credentials": { "files": [
{ "mode": "deny", "path": "/work/repo/OLD.pem" },
{ "mode": "mask", "path": "~/.aws/credentials" }
] } }
});
let mut abs = SandboxRules::default();
abs.credentials_deny
.insert("/work/repo/new.pem".to_string());
let out = apply_into_settings(existing, repo, &abs, &BTreeSet::new());
let files = out["sandbox"]["credentials"]["files"].as_array().unwrap();
let paths: BTreeSet<&str> = files.iter().map(|e| e["path"].as_str().unwrap()).collect();
assert!(
paths.contains("~/.aws/credentials"),
"foreign entry preserved"
);
assert!(
paths.contains("/work/repo/new.pem"),
"new mati entry present"
);
assert!(
!paths.contains("/work/repo/OLD.pem"),
"stale in-repo entry dropped"
);
let new_entry = files
.iter()
.find(|e| e["path"] == "/work/repo/new.pem")
.unwrap();
assert_eq!(new_entry["mode"], "deny");
}
#[test]
fn denied_domains_merge_owns_by_universe_membership() {
let repo = Path::new("/work/repo");
let existing = json!({
"sandbox": { "network": { "deniedDomains": ["*.old-policy.internal", "user-added.example.com"] } }
});
let mut abs = SandboxRules::default();
abs.denied_domains
.insert("*.new-policy.internal".to_string());
let universe: BTreeSet<String> = [
"*.old-policy.internal".to_string(),
"*.new-policy.internal".to_string(),
]
.into_iter()
.collect();
let out = apply_into_settings(existing, repo, &abs, &universe);
let domains: BTreeSet<&str> = out["sandbox"]["network"]["deniedDomains"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str())
.collect();
assert!(
domains.contains("user-added.example.com"),
"non-mati domain preserved"
);
assert!(
domains.contains("*.new-policy.internal"),
"new mati domain present"
);
assert!(
!domains.contains("*.old-policy.internal"),
"policy no longer enforcing — dropped"
);
}
#[test]
fn drifted_removals_covers_credentials_and_domains() {
let repo = Path::new("/work/repo");
let existing = json!({ "sandbox": {
"credentials": { "files": [{ "mode": "deny", "path": "/work/repo/dropped.pem" }] },
"network": { "deniedDomains": ["*.dropped.internal"] }
} });
let abs = SandboxRules::default();
let universe: BTreeSet<String> = ["*.dropped.internal".to_string()].into_iter().collect();
let drift = drifted_removals(&existing, repo, &abs, &universe);
assert!(drift.contains("/work/repo/dropped.pem"));
assert!(drift.contains("*.dropped.internal"));
}
#[test]
fn validate_rejects_malformed_shape() {
assert!(validate_sandbox_shape(&json!({"sandbox": "on"})).is_err());
assert!(validate_sandbox_shape(&json!({"sandbox": {"filesystem": []}})).is_err());
assert!(
validate_sandbox_shape(&json!({"sandbox": {"filesystem": {"denyRead": "x"}}})).is_err()
);
assert!(
validate_sandbox_shape(&json!({"sandbox": {"filesystem": {"denyRead": ["x"]}}}))
.is_ok()
);
assert!(validate_sandbox_shape(&json!({})).is_ok());
}
#[test]
fn add_and_remove_tags_dedupe() {
let mut tags = sv(&["enriched"]);
add_tags(&mut tags, &["crown-jewel", "sandbox-deny-read"]);
add_tags(&mut tags, &["crown-jewel"]); assert_eq!(tags.iter().filter(|t| *t == "crown-jewel").count(), 1);
assert!(tags.contains(&"sandbox-deny-read".to_string()));
remove_tags(&mut tags, &["crown-jewel", "sandbox-deny-read"]);
assert_eq!(tags, sv(&["enriched"]), "only the sandbox tags are removed");
}
#[test]
fn drifted_removals_flags_dropped_tag_only() {
let repo = Path::new("/work/repo");
let existing = json!({ "sandbox": { "filesystem": {
"denyWrite": ["/work/repo/still.rs", "/work/repo/dropped.rs", "~/.ssh"]
} } });
let mut abs = SandboxRules::default();
abs.deny_write.insert("/work/repo/still.rs".to_string());
let drift = drifted_removals(&existing, repo, &abs, &BTreeSet::new());
assert!(
drift.contains("/work/repo/dropped.rs"),
"tag-dropped entry flagged"
);
assert!(
!drift.contains("/work/repo/still.rs"),
"still-protected not flagged"
);
assert!(!drift.contains("~/.ssh"), "user entry never flagged");
}
#[test]
fn resolve_clamps_to_repo_and_handles_missing_leaf() {
let dir = std::env::temp_dir().join(format!("mati-sbx-test-{}", std::process::id()));
let repo = dir.join("repo");
std::fs::create_dir_all(repo.join("src")).unwrap();
std::fs::write(repo.join("src/exists.rs"), "x").unwrap();
let repo = std::fs::canonicalize(&repo).unwrap();
assert!(resolve_under_repo(&repo, "src/exists.rs").is_some());
let missing = resolve_under_repo(&repo, "src/not_yet.rs");
assert!(missing.is_some());
assert!(missing.unwrap().starts_with(&repo));
assert!(resolve_under_repo(&repo, "../escape.rs").is_none());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn blast_radius_lists_only_extra_files() {
assert!(blast_radius("src/a.rs", &sv(&["src/a.rs"])).is_empty());
let extra = blast_radius("src/a.rs", &sv(&["src/a.rs", "./src/b.rs", "src/c.rs"]));
assert_eq!(
extra,
sv(&["src/b.rs", "src/c.rs"]),
"normalized, target excluded"
);
}
#[test]
fn materialize_guard_blocks_drift_unless_forced() {
let dir = std::env::temp_dir().join(format!("mati-sbx-mat-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let repo = Path::new("/work/repo");
let path = dir.join("settings.local.json");
std::fs::write(
&path,
r#"{"sandbox":{"filesystem":{"denyWrite":["/work/repo/x.rs"]}}}"#,
)
.unwrap();
let empty = SandboxRules::default();
assert!(materialize(&path, repo, &empty, &BTreeSet::new(), true, false).is_err());
assert!(std::fs::read_to_string(&path)
.unwrap()
.contains("/work/repo/x.rs"));
assert!(materialize(&path, repo, &empty, &BTreeSet::new(), true, true).is_ok());
assert!(!std::fs::read_to_string(&path)
.unwrap()
.contains("/work/repo/x.rs"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn repo_root_walks_up_to_project_marker() {
let base = std::env::temp_dir().join(format!("mati-sbx-root-{}", std::process::id()));
let repo = base.join("repo");
let deep = repo.join("a/b/c");
std::fs::create_dir_all(&deep).unwrap();
std::fs::create_dir_all(repo.join(".git")).unwrap();
let repo_c = std::fs::canonicalize(&repo).unwrap();
assert_eq!(repo_root_for(&deep).unwrap(), repo_c);
assert_eq!(repo_root_for(&repo).unwrap(), repo_c);
std::fs::remove_dir_all(&base).ok();
}
#[test]
fn codex_rules_folds_credentials_into_deny_read_never_write() {
let mut abs = SandboxRules::default();
abs.deny_write.insert("/repo/src/fraud.rs".to_string());
abs.deny_read.insert("/repo/src/secret.rs".to_string());
abs.credentials_deny
.insert("/repo/vault/prod.pem".to_string());
abs.credentials_mask
.insert("/repo/vault/mask.pem".to_string());
let codex = codex_rules(&abs);
assert_eq!(
codex.deny_write,
BTreeSet::from(["/repo/src/fraud.rs".to_string()])
);
assert_eq!(
codex.deny_read,
BTreeSet::from([
"/repo/src/secret.rs".to_string(),
"/repo/vault/prod.pem".to_string(),
"/repo/vault/mask.pem".to_string(),
]),
"Codex has no mask mode — both secret tags fold into deny_read"
);
assert!(
codex.credentials_deny.is_empty() && codex.credentials_mask.is_empty(),
"codex rules never carry a credentials field"
);
}
#[test]
fn codex_rules_inherit_out_of_repo_skip_from_shared_resolution() {
let base = std::env::temp_dir().join(format!("mati-sbx-codex-skip-{}", std::process::id()));
let repo = base.join("repo");
std::fs::create_dir_all(repo.join("src")).unwrap();
let repo = std::fs::canonicalize(&repo).unwrap();
let mut rel = SandboxRules::default();
rel.deny_write.insert("../escape.rs".to_string());
rel.deny_write.insert("src/in.rs".to_string());
let (abs, skipped) = resolve_rules(&repo, &rel);
let codex = codex_rules(&abs);
assert!(
skipped.contains("../escape.rs"),
"out-of-repo path recorded as skipped, same as the Claude target"
);
assert_eq!(codex.deny_write.len(), 1);
assert!(
codex
.deny_write
.iter()
.all(|p| Path::new(p).starts_with(&repo)),
"codex rules only ever see repo-clamped paths"
);
std::fs::remove_dir_all(&base).ok();
}
#[test]
fn validate_codex_shape_rejects_malformed_and_accepts_valid() {
let ok = "[permissions.mati]\ndeny_read = [\"x\"]\n"
.parse::<DocumentMut>()
.unwrap();
assert!(validate_codex_shape(&ok).is_ok());
let no_permissions = "".parse::<DocumentMut>().unwrap();
assert!(validate_codex_shape(&no_permissions).is_ok());
let bad_permissions = "permissions = \"x\"\n".parse::<DocumentMut>().unwrap();
assert!(validate_codex_shape(&bad_permissions).is_err());
let bad_profile = "[permissions]\nmati = \"x\"\n"
.parse::<DocumentMut>()
.unwrap();
assert!(validate_codex_shape(&bad_profile).is_err());
let bad_array = "[permissions.mati]\ndeny_read = \"x\"\n"
.parse::<DocumentMut>()
.unwrap();
assert!(validate_codex_shape(&bad_array).is_err());
let bad_entry = "[permissions.mati]\ndeny_read = [1]\n"
.parse::<DocumentMut>()
.unwrap();
assert!(validate_codex_shape(&bad_entry).is_err());
}
#[test]
fn read_codex_config_rejects_unparsable_toml_on_disk() {
let dir = std::env::temp_dir().join(format!("mati-sbx-codex-parse-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("config.toml");
std::fs::write(&path, "not valid toml [[[").unwrap();
assert!(read_codex_config(&path).is_err());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn drifted_codex_removals_flags_dropped_tag_only() {
let repo = Path::new("/work/repo");
let doc =
"[permissions.mati]\ndeny_write = [\"/work/repo/still.rs\", \"/work/repo/dropped.rs\", \"~/.ssh\"]\n"
.parse::<DocumentMut>()
.unwrap();
let mut rules = SandboxRules::default();
rules.deny_write.insert("/work/repo/still.rs".to_string());
let drift = drifted_codex_removals(&doc, repo, &rules);
assert!(
drift.contains("/work/repo/dropped.rs"),
"tag-dropped entry flagged"
);
assert!(
!drift.contains("/work/repo/still.rs"),
"still-protected not flagged"
);
assert!(!drift.contains("~/.ssh"), "user entry never flagged");
}
#[test]
fn materialize_codex_preserves_foreign_keys_and_out_of_repo_entries() {
let dir = std::env::temp_dir().join(format!("mati-sbx-codex-mat-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let repo = Path::new("/work/repo");
let path = dir.join("config.toml");
std::fs::write(
&path,
"[permissions.mati]\ncustom_note = \"keep me\"\ndeny_read = [\"~/.ssh/id_rsa\", \"/work/repo/OLD.pem\"]\n",
)
.unwrap();
let mut rules = SandboxRules::default();
rules.deny_write.insert("/work/repo/src/new.rs".to_string());
materialize_codex(&path, repo, &rules, true).unwrap();
let body = std::fs::read_to_string(&path).unwrap();
let doc = body.parse::<DocumentMut>().unwrap();
let profile = doc["permissions"]["mati"].as_table().unwrap();
assert_eq!(
profile.get("custom_note").and_then(Item::as_str),
Some("keep me"),
"a pre-existing, unrelated key in the profile table survives a write"
);
let deny_read: Vec<&str> = profile
.get("deny_read")
.and_then(Item::as_array)
.unwrap()
.iter()
.filter_map(|v| v.as_str())
.collect();
assert!(
deny_read.contains(&"~/.ssh/id_rsa"),
"out-of-repo entry preserved"
);
assert!(
!deny_read.contains(&"/work/repo/OLD.pem"),
"stale in-repo entry dropped (tag gone)"
);
let deny_write: Vec<&str> = profile
.get("deny_write")
.and_then(Item::as_array)
.unwrap()
.iter()
.filter_map(|v| v.as_str())
.collect();
assert_eq!(deny_write, vec!["/work/repo/src/new.rs"]);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn materialize_codex_guard_blocks_drift_unless_forced() {
let dir = std::env::temp_dir().join(format!("mati-sbx-codex-guard-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let repo = Path::new("/work/repo");
let path = dir.join("config.toml");
std::fs::write(
&path,
"[permissions.mati]\ndeny_write = [\"/work/repo/x.rs\"]\n",
)
.unwrap();
let empty = SandboxRules::default();
assert!(materialize_codex(&path, repo, &empty, false).is_err());
assert!(std::fs::read_to_string(&path)
.unwrap()
.contains("/work/repo/x.rs"));
assert!(materialize_codex(&path, repo, &empty, true).is_ok());
assert!(!std::fs::read_to_string(&path)
.unwrap()
.contains("/work/repo/x.rs"));
std::fs::remove_dir_all(&dir).ok();
}
}