use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use crate::permission::engine::types::{Claim, Resource};
pub struct ApprovalRequest {
pub tool: String,
pub command: String,
pub working_dir: String,
pub resources: Vec<String>,
}
pub enum ApprovalDecision {
Allow,
Deny(String),
}
pub type ApprovalFn = Arc<
dyn Fn(
ApprovalRequest,
) -> Pin<Box<dyn Future<Output = anyhow::Result<ApprovalDecision>> + Send>>
+ Send
+ Sync,
>;
pub const EVALUATOR_PREAMBLE: &str = "\
You are the security gate for an autonomous coding agent. For each operation \
you receive, decide whether it is SAFE AND REASONABLE for an agent working on \
a software project to run WITHOUT a human confirming first.
Be conservative: when in doubt, DENY. It is fine to deny — a human will be \
asked instead. Only ALLOW operations that are clearly safe and scoped to the \
agent's work.
DENY operations such as:
- Deleting files outside the project, or recursive/forced deletes (rm -rf, \
del /s) anywhere outside the project directory or a temp directory.
- Creating, modifying, or moving files OUTSIDE the project directory or a \
system temp directory (/tmp, $TMPDIR).
- Committing, pushing, or otherwise mutating a git repository OUTSIDE the \
project directory; or `git push` to a remote.
- Fetching and executing remote code from untrusted sources (curl|sh, \
installing packages, running remote npx packages) when the intent is unclear.
- Privilege escalation (sudo, doas), disk/device operations (dd, mkfs, \
fdisk), changing system configuration, or editing another user's files.
- Reading or transmitting credentials/secrets (~/.ssh, ~/.aws, .env with \
tokens, keychains) to the network.
ALLOW operations such as:
- Reading, listing, searching files within or near the project.
- Building, testing, linting, or running the project's OWN code inside the \
project directory.
- Creating or editing files inside the project directory or a temp directory.
Reply with EXACTLY ONE line, nothing else:
ALLOW
or
DENY: <short reason>";
pub fn build_evaluator_prompt(req: &ApprovalRequest) -> String {
let mut s = String::new();
s.push_str("Project (working) directory: ");
s.push_str(&req.working_dir);
s.push_str("\n\nOperation:\n tool: ");
s.push_str(&req.tool);
s.push_str("\n command/input: ");
s.push_str(&req.command);
if !req.resources.is_empty() {
s.push_str("\n resources it would touch:");
for r in &req.resources {
s.push_str("\n - ");
s.push_str(r);
}
}
s.push_str("\n\nReply with ALLOW or DENY: <reason>.");
s
}
pub fn parse_decision(response: &str) -> ApprovalDecision {
for line in response.lines() {
let t = line.trim();
if t.is_empty() {
continue;
}
let upper = t.to_ascii_uppercase();
if upper.starts_with("ALLOW") {
return ApprovalDecision::Allow;
}
if upper.starts_with("DENY") {
let reason = t[4..].trim_start_matches([':', '-', ' ', '\t']).trim();
let reason = if reason.is_empty() {
"no reason given"
} else {
reason
};
return ApprovalDecision::Deny(reason.to_string());
}
}
let preview: String = response.trim().chars().take(120).collect();
ApprovalDecision::Deny(format!("unclear evaluator response: {preview:?}"))
}
pub fn summarize_claims(claims: &[Claim]) -> Vec<String> {
claims
.iter()
.map(|c| {
let op = format!("{:?}", c.op).to_uppercase();
match &c.resource {
Resource::Command { raw, .. } => format!("{op} a command: {raw}"),
Resource::Path {
resolved,
in_cwd,
dev_null,
..
} => {
let loc = if *dev_null {
"/dev/null (discarded)".to_string()
} else if *in_cwd {
"INSIDE the project".to_string()
} else {
"OUTSIDE the project".to_string()
};
format!("{op} a file {} — {}", resolved.display(), loc)
}
Resource::Url(u) => format!("{op} a network URL: {u}"),
Resource::Mcp { server, name, .. } => {
format!("{op} an MCP tool {server}:{name}")
}
Resource::Bareword(b) => format!("{op}: {b}"),
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_allow_variants() {
assert!(matches!(parse_decision("ALLOW"), ApprovalDecision::Allow));
assert!(matches!(parse_decision("allow"), ApprovalDecision::Allow));
assert!(matches!(
parse_decision(" ALLOW "),
ApprovalDecision::Allow
));
assert!(matches!(
parse_decision("Thinking...\nALLOW"),
ApprovalDecision::Allow
));
}
#[test]
fn parse_deny_with_reason() {
match parse_decision("DENY: writes outside the project") {
ApprovalDecision::Deny(r) => assert_eq!(r, "writes outside the project"),
_ => panic!("expected deny"),
}
match parse_decision("deny - rm -rf on system path") {
ApprovalDecision::Deny(r) => assert_eq!(r, "rm -rf on system path"),
_ => panic!("expected deny"),
}
match parse_decision("DENY") {
ApprovalDecision::Deny(r) => assert_eq!(r, "no reason given"),
_ => panic!("expected deny"),
}
}
#[test]
fn unparseable_response_fails_safe_to_deny() {
assert!(matches!(
parse_decision("I'm not sure about this one"),
ApprovalDecision::Deny(_)
));
assert!(matches!(parse_decision(""), ApprovalDecision::Deny(_)));
}
#[test]
fn evaluator_prompt_includes_command_and_resources() {
let req = ApprovalRequest {
tool: "bash".into(),
command: "rm -rf /tmp/x && npx foo".into(),
working_dir: "/work/proj".into(),
resources: vec!["EXECUTE a command: npx foo".into()],
};
let p = build_evaluator_prompt(&req);
assert!(p.contains("/work/proj"));
assert!(p.contains("rm -rf /tmp/x && npx foo"));
assert!(p.contains("EXECUTE a command: npx foo"));
assert!(p.contains("ALLOW") && p.contains("DENY"));
}
}