use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::Result;
use clap::Args;
use slugify::slugify;
use mati_core::mcp::protocol::PolicyWriteOp;
use mati_core::store::{
Category, ConfidenceScore, PolicyFreshness, PolicyMode, PolicyRecord, PolicyRequires,
PolicyStage, PolicyTrigger, Priority, QualityScore, ReceiptSource, Record, RecordLifecycle,
RecordSource, RecordVersion, StalenessScore,
};
use crate::cli::proxy::StoreProxy;
#[derive(Args)]
pub struct ProtectArgs {
glob: String,
#[arg(long)]
reason: Option<String>,
#[arg(long)]
steer: bool,
#[arg(long)]
enable: bool,
#[arg(long, default_value_t = 900)]
ttl: u64,
}
pub async fn run(args: ProtectArgs) -> Result<()> {
let glob = args.glob.trim();
if glob.is_empty() {
anyhow::bail!("protect glob cannot be empty");
}
let slug = slugify!(glob);
if slug.is_empty() {
anyhow::bail!("could not derive a policy slug from glob '{glob}'");
}
let target_glob = protect_glob(glob);
let decision_key = format!("decision:protect-{slug}");
let policy_key = format!("policy:protect-{slug}");
let mode = if args.steer {
PolicyMode::Steer
} else {
PolicyMode::Block
};
let stage = if args.enable {
PolicyStage::Enforce
} else {
PolicyStage::Shadow
};
let reason = args.reason.unwrap_or_else(|| {
format!(
"{glob} is protected; consult this record before editing or deleting a matching path."
)
});
let decision = decision_record(&decision_key, glob, &reason);
let policy = PolicyRecord {
name: format!("Protect {glob}"),
rule: format!("Consult {decision_key} before editing or deleting {glob}."),
reason: reason.clone(),
scope: "repo".into(),
mode,
trigger: PolicyTrigger {
tool: Some("path".into()),
host_glob: None,
target_path_glob: Some(target_glob.clone()),
command_glob: None,
},
requires: PolicyRequires {
key: decision_key.clone(),
via: vec![ReceiptSource::MemGet],
freshness: PolicyFreshness {
ttl_secs: args.ttl,
fingerprint: false,
},
},
stage,
severity: Priority::High,
created_by: "developer".into(),
};
mati_core::store::policy_ops::validate_trigger(&policy.trigger)?;
let cwd = std::env::current_dir()?;
let proxy = StoreProxy::open(&cwd).await?;
proxy.put(&decision_key, &decision).await?;
for warning in mati_core::store::policy_ops::author_warnings(&policy, true) {
eprintln!("{warning}");
}
let op = if proxy.get(&policy_key).await?.is_some() {
PolicyWriteOp::Edit
} else {
PolicyWriteOp::Create
};
let result = proxy.policy_write(op, &policy_key, Some(&policy)).await;
proxy.close_with_result(result).await?;
let stage_label = format!("{stage:?}").to_ascii_lowercase();
let mode_label = format!("{mode:?}").to_ascii_lowercase();
println!("Protected {glob} (glob: {target_glob})");
println!(" {policy_key} ({mode_label}, {stage_label})");
println!(" {decision_key} authored (consult to unlock)");
if matches!(stage, PolicyStage::Shadow) {
println!("Run `mati protect {glob} --enable` to enforce, or `mati policy stage protect-{slug} enforce`.");
}
Ok(())
}
fn protect_glob(input: &str) -> String {
let trimmed = input.trim_end_matches('/');
let base = trimmed.strip_suffix("/**").unwrap_or(trimmed);
if base.contains(['*', '?', '[', ']', '{', '}']) {
return input.to_string();
}
format!("{{{base},{base}/**}}")
}
pub(crate) fn decision_record(key: &str, glob: &str, reason: &str) -> Record {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
Record {
key: key.to_string(),
value: format!("Protected path: {glob}\n\n{reason}"),
category: Category::Decision,
priority: Priority::High,
tags: vec!["protect".into()],
created_at: now,
updated_at: now,
ref_url: None,
staleness: StalenessScore::fresh(),
lifecycle: RecordLifecycle::Active,
version: RecordVersion {
device_id: mati_core::store::stable_device_id(),
logical_clock: 1,
wall_clock: now,
},
quality: QualityScore::layer0_default(),
access_count: 0,
last_accessed: 0,
source: RecordSource::DeveloperManual,
confidence: ConfidenceScore::for_new_record(&RecordSource::DeveloperManual),
gap_analysis_score: 0.0,
payload: None,
}
}
#[cfg(test)]
mod tests {
use super::protect_glob;
#[test]
fn plain_path_covers_node_and_subtree() {
assert_eq!(
protect_glob("src/migrations"),
"{src/migrations,src/migrations/**}"
);
}
#[test]
fn trailing_slash_and_star_star_normalize_to_same_glob() {
let expected = "{src/migrations,src/migrations/**}";
assert_eq!(protect_glob("src/migrations/"), expected);
assert_eq!(protect_glob("src/migrations/**"), expected);
}
#[test]
fn explicit_glob_is_verbatim() {
assert_eq!(protect_glob("src/**/*.sql"), "src/**/*.sql");
}
}