use clap::{Parser, Subcommand};
use serde_json::Value;
use std::path::PathBuf;
use std::process;
const VERSION: &str = env!("CARGO_PKG_VERSION");
const REPO: &str = "cyrenei/arbiter-mcp-firewall";
#[derive(Parser)]
#[command(
name = "arbiter",
version,
about = "Arbiter agent lifecycle management CLI"
)]
struct Cli {
#[arg(long, env = "ARBITER_API_URL", default_value = "http://127.0.0.1:3000")]
api_url: String,
#[arg(long, env = "ARBITER_API_KEY", default_value = "", hide = true)]
api_key: String,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
RegisterAgent {
#[arg(long)]
owner: String,
#[arg(long)]
model: String,
#[arg(long, value_delimiter = ',')]
capabilities: Vec<String>,
},
CreateDelegation {
#[arg(long)]
from: String,
#[arg(long)]
to: String,
#[arg(long, value_delimiter = ',')]
scopes: Vec<String>,
},
Revoke {
#[arg(long)]
agent: String,
},
ListAgents,
Doctor {
#[arg(long)]
config: Option<PathBuf>,
},
Policy {
#[command(subcommand)]
action: PolicyAction,
},
Update {
#[arg(long)]
version: Option<String>,
#[arg(long)]
check: bool,
},
}
#[derive(Subcommand)]
enum PolicyAction {
Test {
#[arg(long)]
policy: PathBuf,
#[arg(long)]
request: Option<PathBuf>,
},
}
#[tokio::main]
async fn main() {
let cli = Cli::parse();
if cli.api_url.starts_with("http://")
&& !cli.api_url.contains("127.0.0.1")
&& !cli.api_url.contains("localhost")
&& !cli.api_url.contains("[::1]")
{
eprintln!(
"WARNING: using http:// for non-loopback target '{}'. \
API key will be sent in cleartext. Use https:// for production.",
cli.api_url
);
}
let client = reqwest::Client::new();
let result = match cli.command {
Commands::RegisterAgent {
owner,
model,
capabilities,
} => {
register_agent(
&client,
&cli.api_url,
&cli.api_key,
&owner,
&model,
&capabilities,
)
.await
}
Commands::CreateDelegation { from, to, scopes } => {
create_delegation(&client, &cli.api_url, &cli.api_key, &from, &to, &scopes).await
}
Commands::Revoke { agent } => {
revoke_agent(&client, &cli.api_url, &cli.api_key, &agent).await
}
Commands::ListAgents => list_agents(&client, &cli.api_url, &cli.api_key).await,
Commands::Doctor { config } => doctor(&client, &cli.api_url, &cli.api_key, config).await,
Commands::Policy { action } => match action {
PolicyAction::Test { policy, request } => policy_test(&policy, request.as_deref()),
},
Commands::Update { version, check } => self_update(&client, version, check).await,
};
if let Err(e) = result {
eprintln!("Error: {e}");
process::exit(1);
}
}
async fn register_agent(
client: &reqwest::Client,
base: &str,
api_key: &str,
owner: &str,
model: &str,
capabilities: &[String],
) -> Result<(), String> {
let res = client
.post(format!("{base}/agents"))
.header("x-api-key", api_key)
.json(&serde_json::json!({
"owner": owner,
"model": model,
"capabilities": capabilities,
}))
.send()
.await
.map_err(|e| format!("request failed: {e}"))?;
let status = res.status();
let body: Value = res
.json()
.await
.map_err(|e| format!("invalid response: {e}"))?;
if !status.is_success() {
return Err(format!(
"API error ({}): {}",
status,
body["error"].as_str().unwrap_or("unknown")
));
}
println!("Agent registered:");
println!(" ID: {}", body["agent_id"]);
let token = body["token"].as_str().unwrap_or("");
if token.len() > 12 {
eprintln!(" Token: {}...{}", &token[..6], &token[token.len() - 6..]);
} else {
eprintln!(" Token: {token}");
}
eprintln!(" WARNING: this token is a long-lived credential. Store it securely.");
eprintln!(" Full token written to stdout for programmatic capture.");
println!(" Token: {token}");
Ok(())
}
async fn create_delegation(
client: &reqwest::Client,
base: &str,
api_key: &str,
from: &str,
to: &str,
scopes: &[String],
) -> Result<(), String> {
let from_id: uuid::Uuid = from
.parse()
.map_err(|_| format!("invalid agent UUID: {from}"))?;
let _to_id: uuid::Uuid = to
.parse()
.map_err(|_| format!("invalid agent UUID: {to}"))?;
let res = client
.post(format!("{base}/agents/{from_id}/delegate"))
.header("x-api-key", api_key)
.json(&serde_json::json!({
"to": to,
"scopes": scopes,
}))
.send()
.await
.map_err(|e| format!("request failed: {e}"))?;
let status = res.status();
let body: Value = res
.json()
.await
.map_err(|e| format!("invalid response: {e}"))?;
if !status.is_success() {
return Err(format!(
"API error ({}): {}",
status,
body["error"].as_str().unwrap_or("unknown")
));
}
println!("Delegation created:");
println!(" From: {}", body["from"]);
println!(" To: {}", body["to"]);
println!(" Scopes: {:?}", body["scope_narrowing"]);
Ok(())
}
async fn revoke_agent(
client: &reqwest::Client,
base: &str,
api_key: &str,
agent_id: &str,
) -> Result<(), String> {
let id: uuid::Uuid = agent_id
.parse()
.map_err(|_| format!("invalid agent UUID: {agent_id}"))?;
let res = client
.delete(format!("{base}/agents/{id}"))
.header("x-api-key", api_key)
.send()
.await
.map_err(|e| format!("request failed: {e}"))?;
let status = res.status();
let body: Value = res
.json()
.await
.map_err(|e| format!("invalid response: {e}"))?;
if !status.is_success() {
return Err(format!(
"API error ({}): {}",
status,
body["error"].as_str().unwrap_or("unknown")
));
}
println!(
"Revoked {} agent(s): {:?}",
body["count"], body["deactivated"]
);
Ok(())
}
async fn list_agents(client: &reqwest::Client, base: &str, api_key: &str) -> Result<(), String> {
let res = client
.get(format!("{base}/agents"))
.header("x-api-key", api_key)
.send()
.await
.map_err(|e| format!("request failed: {e}"))?;
let status = res.status();
let body: Value = res
.json()
.await
.map_err(|e| format!("invalid response: {e}"))?;
if !status.is_success() {
return Err(format!(
"API error ({}): {}",
status,
body["error"].as_str().unwrap_or("unknown")
));
}
let empty = vec![];
let agents = body.as_array().unwrap_or(&empty);
if agents.is_empty() {
println!("No agents registered.");
return Ok(());
}
for agent in agents {
println!(
" {} | owner={} model={} trust={} active={}",
agent["id"],
agent["owner"].as_str().unwrap_or("?"),
agent["model"].as_str().unwrap_or("?"),
agent["trust_level"].as_str().unwrap_or("?"),
agent["active"],
);
}
Ok(())
}
async fn doctor(
client: &reqwest::Client,
api_url: &str,
api_key: &str,
config_path: Option<PathBuf>,
) -> Result<(), String> {
let mut all_pass = true;
print_check("Proxy health endpoint");
match client
.get(format!("{api_url}/agents"))
.header("x-api-key", api_key)
.send()
.await
{
Ok(resp) if resp.status().is_success() => {
print_pass("admin API reachable");
}
Ok(resp) => {
if resp.status().as_u16() == 401 || resp.status().as_u16() == 403 {
print_pass("admin API reachable (auth rejected, check api_key)");
} else {
print_fail(&format!(
"admin API returned unexpected status: {}",
resp.status()
));
all_pass = false;
}
}
Err(e) => {
print_fail(&format!("admin API not reachable: {e}"));
all_pass = false;
}
}
print_check("Policy system");
match client
.get(format!("{api_url}/policy/validate"))
.header("x-api-key", api_key)
.send()
.await
{
Ok(resp) => {
let status = resp.status();
let body: Value = resp.json().await.unwrap_or_default();
if status.is_success() {
let count = body["policies_loaded"].as_u64().unwrap_or(0);
print_pass(&format!("{count} policies loaded"));
} else if status.as_u16() == 400 {
print_pass("no policy file configured (inline or absent)");
} else {
print_fail(&format!("policy reload returned {status}: {body}"));
all_pass = false;
}
}
Err(e) => {
print_fail(&format!("could not reach policy endpoint: {e}"));
all_pass = false;
}
}
if let Some(ref path) = config_path {
print_check("Config file exists");
if path.exists() {
print_pass(&format!("{}", path.display()));
} else {
print_fail(&format!("{} not found", path.display()));
all_pass = false;
}
if path.exists() {
print_check("Config file parses");
match std::fs::read_to_string(path) {
Ok(contents) => {
let parsed: Result<toml::Value, _> = toml::from_str(&contents);
match parsed {
Ok(val) => {
print_pass("valid TOML");
if let Some(policy_file) = val
.get("policy")
.and_then(|p| p.get("file"))
.and_then(|f| f.as_str())
{
print_check("Policy file");
let policy_path = std::path::Path::new(policy_file);
let resolved = if policy_path.is_absolute() {
policy_path.to_path_buf()
} else if let Some(parent) = path.parent() {
parent.join(policy_path)
} else {
policy_path.to_path_buf()
};
if resolved.exists() {
match std::fs::read_to_string(&resolved) {
Ok(policy_contents) => {
match arbiter_policy::PolicyConfig::from_toml(
&policy_contents,
) {
Ok(pc) => print_pass(&format!(
"{} policies parsed from {}",
pc.policies.len(),
resolved.display()
)),
Err(e) => {
print_fail(&format!("parse error: {e}"));
all_pass = false;
}
}
}
Err(e) => {
print_fail(&format!(
"cannot read {}: {e}",
resolved.display()
));
all_pass = false;
}
}
} else {
print_fail(&format!("{} does not exist", resolved.display()));
all_pass = false;
}
}
if let Some(audit_path) = val
.get("audit")
.and_then(|a| a.get("file_path"))
.and_then(|f| f.as_str())
{
print_check("Audit log path");
let audit_file = std::path::Path::new(audit_path);
if let Some(parent) = audit_file.parent() {
if parent.exists() {
let meta = std::fs::metadata(parent);
if meta.is_ok() && meta.unwrap().is_dir() {
print_pass(&format!(
"parent dir {} exists",
parent.display()
));
} else {
print_fail(&format!(
"parent dir {} is not a directory",
parent.display()
));
all_pass = false;
}
} else {
print_fail(&format!(
"parent dir {} does not exist",
parent.display()
));
all_pass = false;
}
}
}
if let Some(storage) = val.get("storage") {
let backend = storage
.get("backend")
.and_then(|b| b.as_str())
.unwrap_or("memory");
if backend == "sqlite" {
print_check("SQLite database");
let db_path = storage
.get("sqlite_path")
.and_then(|p| p.as_str())
.unwrap_or("arbiter.db");
let db_file = std::path::Path::new(db_path);
if db_file.exists() {
print_pass(&format!("{db_path} exists and is readable"));
} else {
print_pass(&format!(
"{db_path} does not exist yet (will be created on startup)"
));
}
}
}
}
Err(e) => {
print_fail(&format!("TOML parse error: {e}"));
all_pass = false;
}
}
}
Err(e) => {
print_fail(&format!("cannot read file: {e}"));
all_pass = false;
}
}
}
}
println!();
if all_pass {
println!("All checks passed.");
Ok(())
} else {
Err("one or more checks failed".into())
}
}
fn print_check(name: &str) {
print!(" {name} ... ");
}
fn print_pass(detail: &str) {
println!("PASS ({detail})");
}
fn print_fail(detail: &str) {
println!("FAIL ({detail})");
}
async fn self_update(
client: &reqwest::Client,
target_version: Option<String>,
check_only: bool,
) -> Result<(), String> {
if std::env::var("ARBITER_NO_SELF_UPDATE").is_ok() {
return Err("self-update is disabled (ARBITER_NO_SELF_UPDATE is set)".into());
}
let current = VERSION;
println!("Current version: v{current}");
let target = match target_version {
Some(v) => {
if v.starts_with('v') {
v
} else {
format!("v{v}")
}
}
None => resolve_latest_version(client).await?,
};
let target_semver = target.strip_prefix('v').unwrap_or(&target);
if target_semver == current {
println!("Already up to date.");
return Ok(());
}
println!("Available: {target}");
if check_only {
println!("Update available: v{current} -> {target}");
return Ok(());
}
let (os, arch) = detect_platform()?;
let target_name = format!("arbiter-{os}-{arch}");
println!("Downloading {target_name}.tar.gz...");
let tarball_url =
format!("https://github.com/{REPO}/releases/download/{target}/{target_name}.tar.gz");
let checksum_url =
format!("https://github.com/{REPO}/releases/download/{target}/checksums-sha256.txt");
let tarball_bytes = client
.get(&tarball_url)
.send()
.await
.map_err(|e| format!("download failed: {e}"))?
.error_for_status()
.map_err(|e| format!("download failed: {e}"))?
.bytes()
.await
.map_err(|e| format!("download failed: {e}"))?;
let checksums_text = client
.get(&checksum_url)
.send()
.await
.map_err(|e| format!("checksum download failed: {e}"))?
.error_for_status()
.map_err(|e| format!("checksum download failed: {e}"))?
.text()
.await
.map_err(|e| format!("checksum download failed: {e}"))?;
println!("Verifying SHA256 checksum...");
verify_sha256(
&tarball_bytes,
&format!("{target_name}.tar.gz"),
&checksums_text,
)?;
let tmpdir = tempfile::tempdir().map_err(|e| format!("cannot create temp dir: {e}"))?;
let tarball_path = tmpdir.path().join("arbiter.tar.gz");
std::fs::write(&tarball_path, &tarball_bytes)
.map_err(|e| format!("cannot write tarball: {e}"))?;
let status = std::process::Command::new("tar")
.args([
"xzf",
&tarball_path.to_string_lossy(),
"-C",
&tmpdir.path().to_string_lossy(),
"--no-same-owner",
"--no-same-permissions",
"--strip-components=1",
])
.status()
.map_err(|e| format!("tar extraction failed: {e}"))?;
if !status.success() {
return Err("tar extraction failed".into());
}
let mut new_binary = None;
for entry in std::fs::read_dir(tmpdir.path()).map_err(|e| format!("read dir: {e}"))? {
let entry = entry.map_err(|e| format!("read dir entry: {e}"))?;
if entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
let candidate = entry.path().join("arbiter");
if candidate.exists() {
new_binary = Some(candidate);
break;
}
}
}
let new_binary = new_binary.ok_or("could not find arbiter binary in archive")?;
let new_ctl = new_binary.parent().unwrap().join("arbiter-ctl");
let install_dir = std::env::var("ARBITER_INSTALL_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| dirs_or_home().join(".arbiter").join("bin"));
println!("Installing to {}...", install_dir.display());
std::fs::create_dir_all(&install_dir)
.map_err(|e| format!("cannot create {}: {e}", install_dir.display()))?;
atomic_install(&new_binary, &install_dir.join("arbiter"))?;
if new_ctl.exists() {
atomic_install(&new_ctl, &install_dir.join("arbiter-ctl"))?;
println!("Updated arbiter + arbiter-ctl to {target}");
} else {
println!("Updated arbiter to {target}");
}
println!("Restart the arbiter proxy for changes to take effect.");
Ok(())
}
async fn resolve_latest_version(client: &reqwest::Client) -> Result<String, String> {
let url = format!("https://api.github.com/repos/{REPO}/releases/latest");
let resp = client
.get(&url)
.header("User-Agent", "arbiter-ctl")
.send()
.await
.map_err(|e| format!("cannot fetch latest release: {e}"))?
.error_for_status()
.map_err(|e| format!("cannot fetch latest release: {e}"))?;
let body: Value = resp
.json()
.await
.map_err(|e| format!("invalid response: {e}"))?;
body["tag_name"]
.as_str()
.map(String::from)
.ok_or_else(|| "no tag_name in release response".into())
}
fn detect_platform() -> Result<(&'static str, &'static str), String> {
let os = if cfg!(target_os = "linux") {
"linux"
} else if cfg!(target_os = "macos") {
"macos"
} else {
return Err("unsupported OS".to_string());
};
let arch = if cfg!(target_arch = "x86_64") {
"amd64"
} else if cfg!(target_arch = "aarch64") {
"arm64"
} else {
return Err("unsupported architecture".to_string());
};
Ok((os, arch))
}
fn verify_sha256(data: &[u8], filename: &str, checksums: &str) -> Result<(), String> {
use sha2::{Digest, Sha256};
let expected = checksums
.lines()
.find(|line| line.contains(filename))
.and_then(|line| line.split_whitespace().next())
.ok_or_else(|| format!("no checksum found for {filename}"))?;
let actual = {
let mut hasher = Sha256::new();
hasher.update(data);
format!("{:x}", hasher.finalize())
};
if expected != actual {
return Err(format!(
"checksum mismatch!\n expected: {expected}\n actual: {actual}"
));
}
println!("Checksum verified: {actual}");
Ok(())
}
fn atomic_install(src: &std::path::Path, dest: &std::path::Path) -> Result<(), String> {
let tmp = dest.with_extension("update.tmp");
std::fs::copy(src, &tmp).map_err(|e| format!("cannot copy binary: {e}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755))
.map_err(|e| format!("cannot set permissions: {e}"))?;
}
std::fs::rename(&tmp, dest).map_err(|e| format!("cannot replace {}: {e}", dest.display()))?;
Ok(())
}
fn dirs_or_home() -> PathBuf {
std::env::var("HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("."))
}
#[derive(serde::Deserialize)]
struct SampleRequest {
#[serde(default)]
agent_id: Option<String>,
#[serde(default = "default_trust_level")]
trust_level: String,
#[serde(default)]
capabilities: Vec<String>,
#[serde(default = "default_principal")]
principal_sub: String,
#[serde(default)]
groups: Vec<String>,
#[serde(default)]
declared_intent: String,
#[serde(default)]
tool_name: Option<String>,
#[serde(default)]
arguments: Option<serde_json::Value>,
#[serde(default)]
intent_keywords: Vec<String>,
}
fn default_trust_level() -> String {
"basic".to_string()
}
fn default_principal() -> String {
"user:test".to_string()
}
fn policy_test(
policy_path: &std::path::Path,
request_path: Option<&std::path::Path>,
) -> Result<(), String> {
let policy_contents = std::fs::read_to_string(policy_path)
.map_err(|e| format!("cannot read policy file '{}': {e}", policy_path.display()))?;
let policy_config = arbiter_policy::PolicyConfig::from_toml(&policy_contents)
.map_err(|e| format!("cannot parse policy file: {e}"))?;
println!(
"Loaded {} policies from {}",
policy_config.policies.len(),
policy_path.display()
);
let request_json: String = if let Some(path) = request_path {
std::fs::read_to_string(path)
.map_err(|e| format!("cannot read request file '{}': {e}", path.display()))?
} else {
use std::io::Read;
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
.map_err(|e| format!("cannot read stdin: {e}"))?;
buf
};
let sample: SampleRequest = serde_json::from_str(&request_json)
.map_err(|e| format!("cannot parse request JSON: {e}"))?;
let trust_level = match sample.trust_level.to_lowercase().as_str() {
"untrusted" => arbiter_identity::TrustLevel::Untrusted,
"basic" => arbiter_identity::TrustLevel::Basic,
"verified" => arbiter_identity::TrustLevel::Verified,
"trusted" => arbiter_identity::TrustLevel::Trusted,
other => return Err(format!("unknown trust_level: '{other}'")),
};
let agent_id = if let Some(ref id_str) = sample.agent_id {
uuid::Uuid::parse_str(id_str).map_err(|e| format!("invalid agent_id UUID: {e}"))?
} else {
uuid::Uuid::new_v4()
};
let agent = arbiter_identity::Agent {
id: agent_id,
owner: sample.principal_sub.clone(),
model: "cli-test".into(),
capabilities: sample.capabilities,
trust_level,
created_at: chrono::Utc::now(),
expires_at: None,
active: true,
};
let declared_intent = if sample.declared_intent.is_empty() {
sample.intent_keywords.join(" ")
} else {
sample.declared_intent
};
let eval_ctx = arbiter_policy::EvalContext {
agent,
delegation_chain: vec![],
declared_intent,
principal_sub: sample.principal_sub,
principal_groups: sample.groups,
};
let mcp_request = arbiter_mcp::context::McpRequest {
id: None,
method: "tools/call".into(),
tool_name: sample.tool_name,
arguments: sample.arguments,
resource_uri: None,
};
let result = arbiter_policy::evaluate_explained(&policy_config, &eval_ctx, &mcp_request);
println!();
println!(
"Decision: {}",
match &result.decision {
arbiter_policy::Decision::Allow { policy_id } =>
format!("ALLOW (matched: {policy_id})"),
arbiter_policy::Decision::Deny { reason } => format!("DENY ({reason})"),
arbiter_policy::Decision::Escalate { reason } => format!("ESCALATE ({reason})"),
arbiter_policy::Decision::Annotate { policy_id, reason } =>
format!("ANNOTATE (matched: {policy_id}, {reason})"),
}
);
println!();
println!("Trace:");
for trace in &result.trace {
let status = if trace.matched { "MATCH" } else { "SKIP " };
let reason = trace
.skip_reason
.as_deref()
.map(|r| format!(" -- {r}"))
.unwrap_or_default();
println!(
" [{status}] {} (effect={}, specificity={}){}",
trace.policy_id, trace.effect, trace.specificity, reason
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn policy_test_evaluates_allow() {
let dir = std::env::temp_dir().join(format!("arbiter-cli-test-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let policy_file = dir.join("policies.toml");
std::fs::write(
&policy_file,
r#"
[[policies]]
id = "allow-read"
effect = "allow"
allowed_tools = ["read_file"]
[policies.agent_match]
trust_level = "basic"
[policies.intent_match]
keywords = ["read"]
"#,
)
.unwrap();
let request_file = dir.join("request.json");
std::fs::write(
&request_file,
r#"{
"trust_level": "basic",
"declared_intent": "read the config",
"tool_name": "read_file"
}"#,
)
.unwrap();
let result = policy_test(&policy_file, Some(&request_file));
assert!(result.is_ok());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn policy_test_evaluates_deny() {
let dir = std::env::temp_dir().join(format!("arbiter-cli-deny-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let policy_file = dir.join("policies.toml");
std::fs::write(
&policy_file,
r#"
[[policies]]
id = "allow-read-only"
effect = "allow"
allowed_tools = ["read_file"]
[policies.agent_match]
trust_level = "verified"
"#,
)
.unwrap();
let request_file = dir.join("request.json");
std::fs::write(
&request_file,
r#"{
"trust_level": "basic",
"tool_name": "delete_file"
}"#,
)
.unwrap();
let result = policy_test(&policy_file, Some(&request_file));
assert!(result.is_ok());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn doctor_config_parse_succeeds() {
let dir = std::env::temp_dir().join(format!("arbiter-cli-doctor-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let config_file = dir.join("arbiter.toml");
std::fs::write(
&config_file,
r#"
[proxy]
upstream_url = "http://localhost:9000"
[admin]
api_key = "test-key"
signing_secret = "test-secret"
"#,
)
.unwrap();
let parsed: Result<toml::Value, _> =
toml::from_str(&std::fs::read_to_string(&config_file).unwrap());
assert!(parsed.is_ok());
let _ = std::fs::remove_dir_all(&dir);
}
}