use super::payload::Payload;
use crate::app::App;
use cyberbrain_core::config::{GovernanceConfig, GovernanceMode};
use cyberbrain_core::{EgressPurpose, Error, Result};
use cyberbrain_policy::Actor;
use cyberbrain_policy::egress::Outcome;
use serde_json::{Value, json};
use std::path::PathBuf;
use std::time::Duration;
pub const TOOLS: [&str; 6] = [
"Bash",
"Edit",
"Write",
"MultiEdit",
"NotebookEdit",
"WebFetch",
];
pub const KEY_ENV: &str = "CYBERBRAIN_AGENTGUARD_KEY";
#[derive(Debug, PartialEq, Eq)]
pub enum Verdict {
Proceed(String),
Decide(&'static str, String),
}
fn key_path() -> Option<PathBuf> {
crate::identity::path().map(|p| p.with_file_name("agentguard.key"))
}
fn key() -> Option<String> {
std::env::var(KEY_ENV)
.ok()
.or_else(|| std::fs::read_to_string(key_path()?).ok())
.map(|k| k.trim().to_string())
.filter(|k| !k.is_empty())
}
pub fn check(app: &App, payload: &Payload, actor: &Actor) -> Verdict {
let cfg = &app.config().governance;
let Some(url) = cfg.url.as_deref() else {
return Verdict::Proceed("governance: not configured".into());
};
let Some(tool) = payload.tool_name.as_deref().filter(|t| TOOLS.contains(t)) else {
return Verdict::Proceed("governance: tool not sent".into());
};
let answer = key()
.ok_or_else(|| {
Error::Config(format!(
"no key: set {KEY_ENV} or put one in ~/.config/cyberbrain/agentguard.key"
))
})
.and_then(|k| ask(app, cfg, url, &k, tool, payload, actor));
match (answer, cfg.mode) {
(Ok(a), GovernanceMode::Shadow) => Verdict::Proceed(shadow_line(tool, &a)),
(Ok(a), GovernanceMode::Enforce) => match a.permission.as_str() {
"allow" => Verdict::Proceed(format!("governance: allow ({})", a.summary)),
"deny" => Verdict::Decide("deny", format!("AgentGuard refused this: {}", a.summary)),
_ => Verdict::Decide(
"ask",
format!("AgentGuard wants a person to decide: {}", a.summary),
),
},
(Err(e), GovernanceMode::Shadow) => {
Verdict::Proceed(format!("governance (shadow): not reachable, allowed: {e}"))
}
(Err(e), GovernanceMode::Enforce) => {
if reads_only(tool, payload) {
Verdict::Proceed(format!("governance: not reachable, a read goes ahead: {e}"))
} else {
Verdict::Decide(
"ask",
format!(
"AgentGuard is not reachable ({e}); this call changes something, so a \
person decides"
),
)
}
}
}
}
#[derive(Debug, PartialEq, Eq)]
struct Answer {
permission: String,
summary: String,
shadow: Option<Shadow>,
}
#[derive(Debug, PartialEq, Eq)]
struct Shadow {
permission: String,
reason: Option<String>,
}
fn shadow_line(tool: &str, a: &Answer) -> String {
match &a.shadow {
Some(Shadow {
permission,
reason: Some(reason),
}) => format!("governance (shadow): {tool} would be {permission} ({reason})"),
Some(Shadow {
permission,
reason: None,
}) => format!(
"governance (shadow): {tool} would be {permission} ({})",
a.summary
),
None => format!(
"governance (shadow): {tool} would be {} ({})",
a.permission, a.summary
),
}
}
fn answer_of(v: &Value) -> Answer {
let permission = v["permission"].as_str().unwrap_or("ask").to_string();
let mut summary = v["action_type"].as_str().unwrap_or("?").to_string();
if let Some(gate) = v["deciding_gate"].as_str() {
summary.push_str(&format!(", gate {gate}"));
}
if let Some(reason) = v["reason"].as_str() {
summary.push_str(&format!(": {reason}"));
}
let shadow = v["shadow_permission"]
.as_str()
.filter(|p| !p.is_empty())
.map(|p| Shadow {
permission: p.to_string(),
reason: v["shadow_reason"]
.as_str()
.filter(|r| !r.is_empty())
.map(str::to_string),
});
Answer {
permission,
summary,
shadow,
}
}
fn ask(
app: &App,
cfg: &GovernanceConfig,
url: &str,
key: &str,
tool: &str,
payload: &Payload,
actor: &Actor,
) -> Result<Answer> {
let endpoint = format!("{}/v1/tool-calls", url.trim_end_matches('/'));
let ticket = app
.policy()
.egress()
.open(actor, EgressPurpose::Governance, &endpoint)?;
let body = json!({
"tenant_id": cfg.tenant,
"agent_id": cfg.agent_id,
"agent_name": "Claude Code",
"session_id": payload.session_id,
"tool": tool,
"tool_input": payload.tool_input,
})
.to_string();
let bearer = format!("Bearer {key}");
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| Error::Index(format!("cannot start the async runtime: {e}")))?;
let bytes_out = body.len() as u64;
let sent = rt
.block_on(async {
tokio::time::timeout(
Duration::from_millis(cfg.timeout_ms.max(1)),
cyberbrain_policy::egress::transport::post_json(
&ticket,
&endpoint,
&[("authorization", bearer.as_str())],
body,
None,
),
)
.await
})
.map_err(|_| Error::Index(format!("no answer within {} ms", cfg.timeout_ms)))
.and_then(|r| r);
let resp = match sent {
Ok(resp) => {
let outcome = if resp.status == 200 {
Outcome::ok(Some(resp.status))
} else {
Outcome::failed(&format!("HTTP {}", resp.status))
};
ticket.close(outcome.bytes(bytes_out, resp.body.len() as u64))?;
resp
}
Err(e) => {
ticket.close(Outcome::failed(&e.to_string()))?;
return Err(e);
}
};
let v: Value = serde_json::from_slice(&resp.body).unwrap_or(Value::Null);
if resp.status != 200 {
let detail = v["detail"].as_str().unwrap_or("no detail");
return Err(Error::Index(format!("HTTP {}: {detail}", resp.status)));
}
Ok(answer_of(&v))
}
const READERS: [&str; 32] = [
"ls", "cat", "head", "tail", "less", "grep", "rg", "egrep", "wc", "echo", "printf", "pwd",
"stat", "file", "du", "df", "ps", "ss", "date", "which", "whoami", "id", "uname", "printenv",
"sort", "uniq", "cut", "tr", "diff", "jq", "tree", "realpath",
];
fn reads_only(tool: &str, payload: &Payload) -> bool {
if tool != "Bash" {
return false; }
let Some(cmd) = payload.bash_command() else {
return false;
};
let redirects = cmd.match_indices('>').any(|(i, _)| {
let rest = cmd[i + 1..].trim_start_matches(['>', '&']).trim_start();
!(rest.starts_with("/dev/null") || rest.starts_with(|c: char| c.is_ascii_digit()))
});
if redirects {
return false;
}
cmd.split(|c: char| ";|&()\n".contains(c))
.map(|s| s.split_whitespace().collect::<Vec<_>>())
.filter(|w| !w.is_empty())
.all(|w| {
let prog = w[0].rsplit('/').next().unwrap_or(w[0]);
match prog {
"git" => matches!(
w.get(1).copied(),
Some("status" | "log" | "diff" | "show" | "branch" | "rev-parse")
),
"sed" => w.contains(&"-n") && !w.iter().any(|a| a.starts_with("-i")),
"find" => !w
.iter()
.any(|a| matches!(*a, "-delete" | "-exec" | "-execdir")),
"cyberbrain" => matches!(
w.get(1).copied(),
Some("recall" | "find" | "status" | "doctor")
),
p => READERS.contains(&p),
}
})
}
#[cfg(test)]
mod tests {
use super::*;
fn bash(cmd: &str) -> Payload {
Payload::parse(&json!({"tool_name": "Bash", "tool_input": {"command": cmd}}).to_string())
}
#[test]
fn only_looking_counts_as_a_read() {
for cmd in [
"ls -la",
"cat a | grep b | wc -l",
"git status && git diff",
"find . -name '*.rs'",
"sed -n 1,20p x",
"grep x y 2>/dev/null",
"cyberbrain recall wolfpack",
] {
assert!(reads_only("Bash", &bash(cmd)), "{cmd}");
}
for cmd in [
"rm -rf x",
"echo x > f",
"cat a >> b",
"git push",
"sed -i s/a/b/ f",
"find . -delete",
"curl https://x",
"ls; docker compose down",
"cyberbrain write --ring 2 --name x --body y",
"env rm -rf x",
"xargs rm < list",
] {
assert!(!reads_only("Bash", &bash(cmd)), "{cmd}");
}
let write = Payload::parse(r#"{"tool_name":"Write","tool_input":{"file_path":"/x"}}"#);
assert!(!reads_only("Write", &write));
}
#[test]
fn an_answer_without_shadow_fields_is_logged_as_before() {
let a =
answer_of(&json!({"permission": "allow", "action_type": "shell", "mode": "shadow"}));
assert_eq!(a.shadow, None);
assert_eq!(
shadow_line("Bash", &a),
"governance (shadow): Bash would be allow (shell)"
);
}
#[test]
fn an_answer_with_shadow_fields_says_what_would_have_happened() {
let a = answer_of(&json!({
"permission": "allow", "action_type": "file_delete", "mode": "shadow",
"shadow_permission": "deny", "shadow_outcome": "block",
"shadow_reason": "scope_nicht_mandatiert",
}));
assert_eq!(
a.permission, "allow",
"what the harness is told does not change"
);
assert_eq!(
shadow_line("Bash", &a),
"governance (shadow): Bash would be deny (scope_nicht_mandatiert)"
);
let a = answer_of(&json!({
"permission": "allow", "action_type": "file_write", "shadow_permission": "ask",
}));
assert_eq!(
shadow_line("Write", &a),
"governance (shadow): Write would be ask (file_write)"
);
}
}