agentguard/
main.rs

1use clap::{Parser, Subcommand};
2
3mod commands;
4
5#[derive(Parser)]
6#[command(name = "agentguard", version, about = "Cedar-powered authorization for AI agents", long_about = None)]
7struct Cli {
8    /// Path to policy store (default: .agentguard, or $AGENTGUARD_STORE)
9    #[arg(
10        long,
11        global = true,
12        env = "AGENTGUARD_STORE",
13        default_value = ".agentguard"
14    )]
15    store: String,
16
17    /// Path to decision log (default: .audit/decisions.jsonl, or $AGENTGUARD_AUDIT)
18    #[arg(
19        long,
20        global = true,
21        env = "AGENTGUARD_AUDIT",
22        default_value = ".audit/decisions.jsonl"
23    )]
24    audit: String,
25
26    /// Output format (json|pretty)
27    #[arg(long, global = true, default_value = "pretty")]
28    output: String,
29
30    /// Path to the HMAC chain secret file. Subcommands that write to
31    /// the audit log (`authorize`, `audit verify`) consult this when
32    /// `AGENTGUARD_CHAIN_SECRET` is unset. Ignored otherwise.
33    #[arg(long, global = true, env = "AGENTGUARD_CHAIN_SECRET")]
34    secret_file: Option<String>,
35
36    #[command(subcommand)]
37    cmd: Cmd,
38}
39
40#[derive(Subcommand)]
41enum Cmd {
42    /// Initialize a new agentguard store in the current directory.
43    Init {
44        /// Project name (used as default org in policies)
45        #[arg(long, default_value = "myorg")]
46        name: String,
47    },
48    /// Validate policies against the schema.
49    Validate,
50    /// Run a single authorization check from a request file or stdin.
51    Authorize {
52        /// Path to a JSON request, or '-' for stdin
53        #[arg(default_value = "-")]
54        request: String,
55        /// Path to entities.json (default: .agentguard/entities.json)
56        #[arg(long)]
57        entities: Option<String>,
58        /// Skip writing to the audit log for this decision.
59        /// Aliases: --no-audit (deprecated).
60        #[arg(long, alias = "no-audit")]
61        skip_audit: bool,
62    },
63    /// Simulate: same as authorize, but always reads from a file and pretty-prints.
64    Sim {
65        #[arg(default_value = "-")]
66        request: String,
67        #[arg(long)]
68        entities: Option<String>,
69    },
70    /// Mint a delegation token.
71    Delegate {
72        #[arg(long)]
73        from: String,
74        #[arg(long)]
75        to: String,
76        #[arg(long)]
77        actions: Vec<String>,
78        #[arg(long)]
79        resources: Vec<String>,
80        #[arg(long, default_value_t = 900)]
81        ttl: i64,
82        #[arg(long)]
83        key_id: Option<String>,
84        #[arg(long)]
85        key_file: Option<String>,
86        /// Write token to this file instead of stdout
87        #[arg(long)]
88        out: Option<String>,
89    },
90    /// Verify and inspect a delegation token.
91    Verify {
92        /// Compact token string, or path to a file with the token on a single line
93        token: String,
94        /// Public key file (one or more key_id=base64 lines)
95        #[arg(long)]
96        keys: String,
97    },
98    /// Manage standalone PDP API keys.
99    ApiKey {
100        #[command(subcommand)]
101        action: ApiKeyCmd,
102    },
103    /// Inspect the schema.
104    Schema,
105    /// Tail/query the audit log.
106    Log {
107        #[command(subcommand)]
108        action: LogCmd,
109    },
110    /// Audit log operations (verify, export, SAR, erase, notarize).
111    Audit {
112        #[command(subcommand)]
113        action: AuditCmd,
114    },
115    /// Generate Cedar policy from a natural language description (requires OPENAI_API_KEY or ANTHROPIC_API_KEY env).
116    Gen {
117        description: String,
118        /// Write generated policy to this file under policies/
119        #[arg(long)]
120        name: Option<String>,
121        /// LLM provider (openai|anthropic)
122        #[arg(long, default_value = "openai")]
123        provider: String,
124        /// Model name
125        #[arg(long, default_value = "gpt-4o-mini")]
126        model: String,
127        /// Print the generated policy to stdout instead of writing to
128        /// the store. Use this to inspect the LLM output before
129        /// installing it.
130        #[arg(long)]
131        dry_run: bool,
132        /// Prompt for confirmation ('y/N') on stderr before writing
133        /// the policy. Has no effect with --dry-run.
134        #[arg(long)]
135        confirm: bool,
136    },
137    /// Diagnose a deployment: schema, policies, audit log, chain, authorizer.
138    Doctor,
139}
140
141#[derive(Subcommand)]
142enum ApiKeyCmd {
143    /// Create an identity-bound key; the raw secret is printed once.
144    Create {
145        /// JSON key-store file used by the PDP.
146        #[arg(long)]
147        key_store: String,
148        #[arg(long, default_value = "ag_live")]
149        prefix: String,
150        #[arg(long, value_parser = ["User", "Agent"])]
151        subject_type: String,
152        #[arg(long)]
153        subject_id: String,
154        #[arg(long)]
155        tenant_id: Option<String>,
156        /// Capability scope; may be repeated. Defaults to authorize.
157        #[arg(long = "scope", default_value = "authorize")]
158        scopes: Vec<String>,
159        /// Expiry in seconds (must be positive; default 30 days).
160        #[arg(long, default_value_t = 2_592_000)]
161        ttl_seconds: u64,
162    },
163    /// List key metadata without hashes or raw secrets.
164    List {
165        #[arg(long)]
166        key_store: String,
167    },
168    /// Revoke a key by id.
169    Revoke {
170        #[arg(long)]
171        key_store: String,
172        id: String,
173    },
174}
175
176#[derive(Subcommand)]
177enum LogCmd {
178    /// Show last N entries (default 20).
179    Tail {
180        #[arg(long, default_value_t = 20)]
181        n: usize,
182        /// Filter by principal
183        #[arg(long)]
184        principal: Option<String>,
185        /// Filter by action substring
186        #[arg(long)]
187        action: Option<String>,
188    },
189    /// Pretty-print all entries (use sparingly).
190    Dump,
191}
192
193#[derive(Subcommand)]
194enum AuditCmd {
195    /// Walk the HMAC chain and verify every record.
196    Verify {
197        /// Path to the audit log (default: --audit)
198        #[arg(long)]
199        audit: Option<String>,
200        /// Path to the secret file containing the HMAC root key
201        #[arg(long)]
202        secret_file: String,
203    },
204    /// Re-format the audit log for SIEM ingestion.
205    Export {
206        /// Path to the audit log (default: --audit)
207        #[arg(long)]
208        audit: Option<String>,
209        /// Output format: jsonl | cef | leef | ecs
210        #[arg(long, default_value = "ecs")]
211        format: String,
212        /// Output path (default: stdout)
213        #[arg(long)]
214        out: Option<String>,
215    },
216    /// Subject access report — find all decisions about a data subject.
217    Sar {
218        /// Path to the audit log (default: --audit)
219        #[arg(long)]
220        audit: Option<String>,
221        /// Subject ID (principal) to search for
222        subject_id: String,
223    },
224    /// Pseudonymize a subject's records (GDPR Art. 17 erasure).
225    Erase {
226        /// Path to the audit log (default: --audit)
227        #[arg(long)]
228        audit: Option<String>,
229        /// Subject ID to erase
230        subject_id: String,
231        /// Salt file (random bytes) — required to ensure irreversibility
232        #[arg(long)]
233        salt_file: String,
234        /// Output path for the pseudonymized log (default: overwrite input)
235        #[arg(long)]
236        out: Option<String>,
237    },
238    /// Force-rotate the audit log (rename active file to a timestamped
239    /// sibling and open a fresh active file). Pair with
240    /// AGENTGUARD_AUDIT_MAX_BYTES for size-based rotation in the
241    /// server.
242    Rotate {
243        /// Path to the audit log (default: --audit)
244        #[arg(long)]
245        audit: Option<String>,
246        /// Path to the secret file containing the HMAC root key —
247        /// required for chained logs so the chain head is preserved
248        /// across rotations.
249        #[arg(long)]
250        secret_file: Option<String>,
251    },
252}
253
254#[tokio::main(flavor = "current_thread")]
255async fn main() {
256    let exit_code = run().await;
257    // std::process::exit runs after all Drop, including the tracing
258    // subscriber. This is the last thing the program does.
259    std::process::exit(exit_code);
260}
261
262async fn run() -> i32 {
263    tracing_subscriber::fmt()
264        .with_env_filter(
265            tracing_subscriber::EnvFilter::try_from_default_env()
266                .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn,agentguard=info")),
267        )
268        .init();
269
270    let cli = Cli::parse();
271    let out = cli.output.as_str();
272
273    let mut exit_code: i32 = 0;
274    let res: anyhow::Result<()> = match cli.cmd {
275        Cmd::Init { name } => commands::init::run(&cli.store, &name),
276        Cmd::Validate => commands::validate::run(&cli.store, out),
277        Cmd::Authorize {
278            request,
279            entities,
280            skip_audit,
281        } => {
282            let outcome = match commands::authorize::run(
283                &cli.store,
284                &cli.audit,
285                &request,
286                entities.as_deref(),
287                skip_audit,
288                out,
289                cli.secret_file.as_deref().map(std::path::Path::new),
290            )
291            .await
292            {
293                Ok(o) => o,
294                Err(e) => {
295                    eprintln!("error: {e:?}");
296                    return 1;
297                }
298            };
299            // Translate a Deny into an exit code at the end of `main`, after
300            // all Drop runs. This avoids `std::process::exit` skipping
301            // destructors inside the authorize command.
302            if !outcome.decision_was_allow {
303                exit_code = 2;
304            }
305            Ok::<(), anyhow::Error>(())
306        }
307        Cmd::Sim { request, entities } => {
308            commands::sim::run(&cli.store, &request, entities.as_deref(), out).await
309        }
310        Cmd::Delegate {
311            from,
312            to,
313            actions,
314            resources,
315            ttl,
316            key_id,
317            key_file,
318            out: out_path,
319        } => commands::delegate::run(
320            &from,
321            &to,
322            actions,
323            resources,
324            ttl,
325            key_id.as_deref(),
326            key_file.as_deref(),
327            out_path.as_deref(),
328            out,
329        ),
330        Cmd::Verify { token, keys } => commands::delegate::verify(&token, &keys, out),
331        Cmd::ApiKey { action } => match action {
332            ApiKeyCmd::Create {
333                key_store,
334                prefix,
335                subject_type,
336                subject_id,
337                tenant_id,
338                scopes,
339                ttl_seconds,
340            } => commands::apikey::create(
341                commands::apikey::CreateKeyOptions {
342                    path: std::path::Path::new(&key_store),
343                    prefix: &prefix,
344                    subject_type: &subject_type,
345                    subject_id: &subject_id,
346                    tenant_id: tenant_id.as_deref(),
347                    scopes,
348                    ttl_seconds,
349                },
350                out,
351            ),
352            ApiKeyCmd::List { key_store } => commands::apikey::list(&key_store, out),
353            ApiKeyCmd::Revoke { key_store, id } => commands::apikey::revoke(&key_store, &id, out),
354        },
355        Cmd::Schema => commands::schema::run(&cli.store, out),
356        Cmd::Log { action } => match action {
357            LogCmd::Tail {
358                n,
359                principal,
360                action,
361            } => commands::log::tail(&cli.audit, n, principal.as_deref(), action.as_deref(), out),
362            LogCmd::Dump => commands::log::dump(&cli.audit, out),
363        },
364        Cmd::Gen {
365            description,
366            name,
367            provider,
368            model,
369            dry_run,
370            confirm,
371        } => {
372            commands::gen::run(
373                &cli.store,
374                &description,
375                name.as_deref(),
376                &provider,
377                &model,
378                dry_run,
379                confirm,
380                out,
381            )
382            .await
383        }
384        Cmd::Audit { action } => match action {
385            AuditCmd::Verify { audit, secret_file } => {
386                let path = audit.as_deref().unwrap_or(&cli.audit);
387                commands::audit::verify(path, &secret_file, out)
388            }
389            AuditCmd::Export {
390                audit,
391                format,
392                out: out_path,
393            } => {
394                let path = audit.as_deref().unwrap_or(&cli.audit);
395                commands::audit::export(path, &format, out_path.as_deref(), out)
396            }
397            AuditCmd::Sar { audit, subject_id } => {
398                let path = audit.as_deref().unwrap_or(&cli.audit);
399                commands::audit::sar(path, &subject_id, out)
400            }
401            AuditCmd::Erase {
402                audit,
403                subject_id,
404                salt_file,
405                out: out_path,
406            } => {
407                let path = audit.as_deref().unwrap_or(&cli.audit);
408                commands::audit::erase(path, &subject_id, &salt_file, out_path.as_deref())
409            }
410            AuditCmd::Rotate { audit, secret_file } => {
411                let path = audit.as_deref().unwrap_or(&cli.audit);
412                let secret = secret_file
413                    .as_deref()
414                    .or(cli.secret_file.as_deref())
415                    .map(std::path::Path::new);
416                commands::audit::rotate(path, secret)
417            }
418        },
419        Cmd::Doctor => {
420            // Honor --secret-file / AGENTGUARD_CHAIN_SECRET. clap
421            // already wires the env via the global flag, so the path
422            // surfaces here uniformly.
423            let chain_secret = cli.secret_file.as_deref().map(std::path::Path::new);
424            let report = match commands::doctor::run(
425                std::path::Path::new(&cli.store),
426                std::path::Path::new(&cli.audit),
427                chain_secret,
428            ) {
429                Ok(r) => r,
430                Err(e) => {
431                    eprintln!("error: {e:?}");
432                    return 1;
433                }
434            };
435            report.print();
436            exit_code = if report.has_failures() {
437                1
438            } else if report.has_warnings() {
439                2
440            } else {
441                0
442            };
443            Ok::<(), anyhow::Error>(())
444        }
445    };
446
447    if let Err(e) = res {
448        eprintln!("error: {e:?}");
449        if exit_code == 0 {
450            exit_code = 1;
451        }
452    }
453    exit_code
454}