agentguard/commands/
authorize.rs

1use agentguard_core::{
2    authorize::build_entities, decode_chain_secret, AgentRequest, Authorizer, DecisionLog,
3    PolicyStore,
4};
5use anyhow::{anyhow, Result};
6use std::path::Path;
7use tokio::io::AsyncReadExt;
8
9/// A policy decision returned to the caller.
10#[derive(Debug)]
11pub struct AuthorizeOutcome {
12    /// The decision effect.
13    #[allow(dead_code)]
14    pub effect: agentguard_core::authorize::Effect,
15    /// Whether the decision was an Allow.
16    pub decision_was_allow: bool,
17}
18
19#[allow(clippy::too_many_arguments)]
20pub async fn run(
21    store: impl AsRef<Path>,
22    audit: impl AsRef<Path>,
23    request: impl AsRef<Path>,
24    entities_path: Option<impl AsRef<Path>>,
25    no_audit: bool,
26    output: &str,
27    secret_file: Option<&Path>,
28) -> Result<AuthorizeOutcome> {
29    let mut req: AgentRequest = if request.as_ref().as_os_str() == "-" {
30        let mut buf = String::new();
31        tokio::io::stdin().read_to_string(&mut buf).await?;
32        serde_json::from_str(&buf)?
33    } else {
34        let text = tokio::fs::read_to_string(request.as_ref()).await?;
35        serde_json::from_str(&text)?
36    };
37
38    // The TS SDK (and any other wrapper that spawns the CLI as a
39    // subprocess) forwards W3C trace context via AGENTGUARD_TRACEPARENT
40    // so distributed traces stitch across the process boundary.
41    // Apply it on top of whatever the request file carried — the env
42    // var wins when present because the wrapper knew its caller.
43    if let Ok(tp) = std::env::var("AGENTGUARD_TRACEPARENT") {
44        match tp.parse::<agentguard_core::observability::TraceContext>() {
45            Ok(ctx) => req.trace = Some(ctx),
46            Err(e) => tracing::warn!(
47                tp = %tp,
48                error = %e,
49                "AGENTGUARD_TRACEPARENT is not a valid trace context; ignoring"
50            ),
51        }
52    }
53
54    // AGENTGUARD_BEARER has no in-process use today (the CLI does not
55    // contact a remote PDP), but the TS SDK still forwards it. Warn
56    // once at startup so a misconfigured deployment gets a hint
57    // instead of a silent no-op.
58    if std::env::var_os("AGENTGUARD_BEARER").is_some() {
59        tracing::debug!(
60            "AGENTGUARD_BEARER is set; the CLI evaluates in-process and does \
61             not currently forward this token. Use it when running an \
62             agentguard-server deployment instead."
63        );
64    }
65
66    let entities = load_entities(entities_path.as_ref().map(|p| p.as_ref())).await?;
67
68    // Cedar engine evaluation + filesystem reads are CPU/IO heavy —
69    // offload to a blocking thread so the tokio runtime stays
70    // responsive.
71    let store_path = store.as_ref().to_path_buf();
72    let audit_path = audit.as_ref().to_path_buf();
73    let secret_path_buf = secret_file.map(|p| p.to_path_buf());
74    let req_for_blocking = req.clone();
75    let entities_for_blocking = entities.clone();
76    let decision =
77        tokio::task::spawn_blocking(move || -> Result<agentguard_core::authorize::Decision> {
78            let store = PolicyStore::open(&store_path)?;
79            let engine = Authorizer::new(store)?;
80            let decision = engine.authorize(&req_for_blocking, &entities_for_blocking)?;
81            Ok(decision)
82        })
83        .await
84        .map_err(|e| anyhow!("blocking task: {e}"))??;
85
86    if !no_audit {
87        // Open hash-chained log if a secret file is provided via
88        // --secret-file / AGENTGUARD_CHAIN_SECRET. Read errors
89        // surface (no silent plain-mode downgrade). Audit writes
90        // are sync IO and stay on the blocking thread.
91        let audit_path_for_blocking = audit_path.clone();
92        let secret_path_for_blocking = secret_path_buf.clone();
93        let decision_for_blocking = decision.clone();
94        tokio::task::spawn_blocking(move || -> Result<()> {
95            let log = match &secret_path_for_blocking {
96                Some(path) => {
97                    let key = std::fs::read(path)
98                        .map_err(|e| anyhow!("read chain secret {:?}: {}", path, e))?;
99                    let key = decode_chain_secret(&key)
100                        .ok_or_else(|| anyhow!("chain secret file {:?} is empty", path))?;
101                    DecisionLog::open_with_chain(&audit_path_for_blocking, &key)?
102                }
103                None => DecisionLog::open(&audit_path_for_blocking)?,
104            };
105            log.append_decision(&decision_for_blocking)?;
106            Ok(())
107        })
108        .await
109        .map_err(|e| anyhow!("blocking task: {e}"))??;
110    }
111
112    // Do not expose an Allow (or any decision) to stdout until required audit
113    // persistence succeeds. Subprocess callers may consume stdout separately
114    // from the exit status, so emitting first could turn an audit failure into
115    // an apparent successful authorization for an incautious adapter.
116    if output == "json" {
117        println!("{}", serde_json::to_string_pretty(&decision)?);
118    } else {
119        let color = match decision.effect {
120            agentguard_core::authorize::Effect::Allow => "\x1b[32m",
121            _ => "\x1b[31m",
122        };
123        println!(
124            "{} {}\x1b[0m",
125            color,
126            format!("{:?}", decision.effect).to_uppercase()
127        );
128        println!("principal: {}", req.principal);
129        println!("action:    {}", req.action);
130        println!("resource:  {}", req.resource);
131        if !decision.policies.is_empty() {
132            println!("policies:  {}", decision.policies.join(", "));
133        }
134        for r in &decision.reasons {
135            println!("  - {}", r);
136        }
137    }
138
139    let outcome = AuthorizeOutcome {
140        effect: decision.effect,
141        decision_was_allow: matches!(decision.effect, agentguard_core::authorize::Effect::Allow),
142    };
143    Ok(outcome)
144}
145
146async fn load_entities(path: Option<&Path>) -> Result<cedar_policy::Entities> {
147    let path = path.unwrap_or_else(|| Path::new(".agentguard/entities.json"));
148    if !path.exists() {
149        return Ok(cedar_policy::Entities::empty());
150    }
151    let path = path.to_path_buf();
152    tokio::task::spawn_blocking(move || -> Result<cedar_policy::Entities> {
153        let text = std::fs::read_to_string(&path)?;
154        let arr: Vec<serde_json::Value> = serde_json::from_str(&text).map_err(|e| {
155            anyhow!(
156                "entities file must be a JSON array of entity objects: {}",
157                e
158            )
159        })?;
160        build_entities(arr).map_err(Into::into)
161    })
162    .await
163    .map_err(|e| anyhow!("blocking task: {e}"))?
164}