use serde_json::{Map, Value};
use crate::core::policy::runtime;
use crate::server::{policy_guard, role_guard};
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum EnforceOutcome {
DeniedRole(String),
DeniedPolicy(String),
BlockedEgress(String),
BlockedFilter(String),
Allowed {
redactions: usize,
filtered: Vec<(String, usize)>,
},
DispatchError(String),
}
impl EnforceOutcome {
fn status(&self) -> &'static str {
match self {
EnforceOutcome::DeniedRole(_) => "denied-role",
EnforceOutcome::DeniedPolicy(_) => "denied-policy",
EnforceOutcome::BlockedEgress(_) => "blocked-egress",
EnforceOutcome::BlockedFilter(_) => "blocked-filter",
EnforceOutcome::Allowed { .. } => "allowed",
EnforceOutcome::DispatchError(_) => "dispatch-error",
}
}
}
struct EnforceArgs {
tool: String,
project_root: String,
json: String,
as_json: bool,
}
pub(crate) fn cmd_enforce(args: &[String]) {
let parsed = match parse_args(args) {
Ok(p) => p,
Err(msg) => {
eprintln!("policy enforce: {msg}\n");
print_help();
std::process::exit(2);
}
};
let map = match parse_json_object(&parsed.json) {
Ok(m) => m,
Err(msg) => {
eprintln!("policy enforce: {msg}");
std::process::exit(2);
}
};
let outcome = run_enforce(&parsed.tool, &parsed.project_root, &parsed.json, &map);
if parsed.as_json {
print_json(&parsed.tool, &outcome);
} else {
print_text(&parsed.tool, &outcome);
}
}
fn print_help() {
println!(
"lean-ctx policy enforce — evaluate a tool call against the active policy\n\n\
USAGE:\n \
lean-ctx policy enforce <tool> --project-root <path> [--json '<args>'] [--as-json]\n\n\
Applies the exact agent-pipeline guards (role + context policy deny/allow,\n\
egress DLP on writes/actions, output redaction + input filters) WITHOUT\n\
starting the MCP server, and records the same audit-trail entries. The active\n\
policy is the project pack folded under any installed, trusted org-policy floor.\n\n\
EXAMPLES:\n \
lean-ctx policy enforce ctx_url_read --project-root .\n \
lean-ctx policy enforce ctx_shell --project-root . --json '{{\"command\":\"echo hi\"}}'\n \
lean-ctx policy enforce ctx_read --project-root . --json '{{\"path\":\"file.txt\"}}' --as-json"
);
}
fn parse_args(args: &[String]) -> Result<EnforceArgs, String> {
let mut tool: Option<String> = None;
let mut project_root: Option<String> = None;
let mut json: Option<String> = None;
let mut as_json = false;
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"-h" | "--help" => {
print_help();
std::process::exit(0);
}
"--project-root" => {
i += 1;
project_root = Some(args.get(i).ok_or("--project-root needs a value")?.clone());
}
"--json" => {
i += 1;
json = Some(args.get(i).ok_or("--json needs a value")?.clone());
}
"--as-json" => as_json = true,
other if other.starts_with("--") => {
return Err(format!("unknown flag '{other}'"));
}
_ => {
if tool.is_none() {
tool = Some(args[i].clone());
} else {
return Err(format!("unexpected argument '{}'", args[i]));
}
}
}
i += 1;
}
Ok(EnforceArgs {
tool: tool.ok_or("missing <tool>")?,
project_root: project_root.ok_or("missing --project-root")?,
json: json.unwrap_or_else(|| "{}".to_string()),
as_json,
})
}
fn parse_json_object(raw: &str) -> Result<Map<String, Value>, String> {
match serde_json::from_str::<Value>(raw).map_err(|e| format!("invalid --json: {e}"))? {
Value::Object(m) => Ok(m),
_ => Err("invalid --json: expected a JSON object".to_string()),
}
}
pub(crate) fn run_enforce(
tool: &str,
project_root: &str,
args_json: &str,
map: &Map<String, Value>,
) -> EnforceOutcome {
let role = role_guard::check_tool_access(tool);
if role.blocked {
return EnforceOutcome::DeniedRole(role.message.unwrap_or_else(|| "role denied".into()));
}
let policy = policy_guard::check_tool_access(tool);
if policy.blocked {
return EnforceOutcome::DeniedPolicy(
policy.message.unwrap_or_else(|| "policy denied".into()),
);
}
if let Some(active) = runtime::active()
&& active.egress.is_active()
{
let payload = match tool {
"ctx_edit" => get_str(map, "new_string"),
"ctx_shell" | "ctx_execute" => get_str(map, "command"),
_ => None,
};
if let Some(payload) = payload {
if let Some(reason) = active.egress.check_content(&payload, &active.redaction) {
policy_guard::audit_egress(tool, &reason);
return EnforceOutcome::BlockedEgress(reason);
}
if let Some(max) = active.egress.max_writes_per_min
&& !crate::core::egress::check_rate(max)
{
policy_guard::audit_egress(tool, "rate-limit");
return EnforceOutcome::BlockedEgress("rate-limit".to_string());
}
}
}
let call_args = vec![
tool.to_string(),
"--project-root".to_string(),
project_root.to_string(),
"--json".to_string(),
args_json.to_string(),
];
let mut text = match super::call_cmd::run_call(&call_args) {
Ok(t) => t,
Err(e) => return EnforceOutcome::DispatchError(e.to_string()),
};
let mut redactions = 0;
if runtime::is_active() {
let (red, hits) = policy_guard::redact_result(&text);
if hits > 0 {
text = red;
redactions = hits;
}
}
let mut filtered = Vec::new();
if let Some(active) = runtime::active()
&& active.filters.is_active()
{
let outcome = crate::core::input_filters::apply(&text, &active.filters);
if outcome.blocked {
policy_guard::audit_filter(tool, &outcome.audit, true);
return EnforceOutcome::BlockedFilter(
outcome.block_reason.unwrap_or_else(|| "policy".into()),
);
}
if !outcome.audit.is_empty() {
policy_guard::audit_filter(tool, &outcome.audit, false);
filtered = outcome.audit;
}
}
EnforceOutcome::Allowed {
redactions,
filtered,
}
}
fn get_str(map: &Map<String, Value>, key: &str) -> Option<String> {
map.get(key)
.and_then(Value::as_str)
.map(std::string::ToString::to_string)
}
fn print_text(tool: &str, outcome: &EnforceOutcome) {
match outcome {
EnforceOutcome::DeniedRole(msg) | EnforceOutcome::DeniedPolicy(msg) => {
println!("DENIED {tool}\n{msg}");
}
EnforceOutcome::BlockedEgress(reason) => {
println!("BLOCKED {tool} egress DLP ({reason}) — write/action withheld; audited");
}
EnforceOutcome::BlockedFilter(reason) => {
println!("BLOCKED {tool} input filter ({reason}) — output withheld; audited");
}
EnforceOutcome::Allowed {
redactions,
filtered,
} => {
let filt = if filtered.is_empty() {
"none".to_string()
} else {
filtered
.iter()
.map(|(c, n)| format!("{c}×{n}"))
.collect::<Vec<_>>()
.join(", ")
};
println!("ALLOWED {tool} redactions={redactions} filters=[{filt}]");
}
EnforceOutcome::DispatchError(e) => {
println!("ERROR {tool} tool handler failed: {e}");
}
}
}
fn print_json(tool: &str, outcome: &EnforceOutcome) {
let detail = match outcome {
EnforceOutcome::DeniedRole(m)
| EnforceOutcome::DeniedPolicy(m)
| EnforceOutcome::BlockedEgress(m)
| EnforceOutcome::BlockedFilter(m)
| EnforceOutcome::DispatchError(m) => Value::String(m.clone()),
EnforceOutcome::Allowed {
redactions,
filtered,
} => serde_json::json!({
"redactions": redactions,
"filters": filtered
.iter()
.map(|(c, n)| serde_json::json!({ "class": c, "count": n }))
.collect::<Vec<_>>(),
}),
};
let v = serde_json::json!({
"tool": tool,
"status": outcome.status(),
"detail": detail,
});
println!(
"{}",
serde_json::to_string_pretty(&v).unwrap_or_else(|_| v.to_string())
);
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::Map;
#[test]
fn parse_requires_tool_and_root() {
assert!(parse_args(&["ctx_read".into()]).is_err());
let ok =
parse_args(&["ctx_read".into(), "--project-root".into(), ".".into()]).expect("valid");
assert_eq!(ok.tool, "ctx_read");
assert_eq!(ok.json, "{}");
assert!(!ok.as_json);
}
#[test]
fn json_must_be_object() {
assert!(parse_json_object("[]").is_err());
assert!(parse_json_object("{\"a\":1}").is_ok());
}
#[test]
fn status_labels_are_stable() {
assert_eq!(
EnforceOutcome::DeniedRole(String::new()).status(),
"denied-role"
);
assert_eq!(
EnforceOutcome::BlockedEgress(String::new()).status(),
"blocked-egress"
);
assert_eq!(
EnforceOutcome::Allowed {
redactions: 0,
filtered: vec![]
}
.status(),
"allowed"
);
}
#[test]
fn exempt_meta_tool_is_never_policy_denied() {
let map = Map::new();
let outcome = run_enforce("ctx_session", ".", "{}", &map);
assert_ne!(outcome.status(), "denied-policy");
}
}