Skip to main content

trustee_api/
lib.rs

1//! Trustee API — REST + WebSocket server for the Trustee agent.
2//!
3//! Wraps a [`trustee_core::session::Session`] and exposes it over HTTP.
4//! Static frontend files are served from [`trustee_web`].
5//!
6//! Authentication is optional. When `[oidc]` or `[dev]` sections are present
7//! in the config TOML, all `/api/v1/*` endpoints require a valid JWT or dev
8//! token. Otherwise, all endpoints are open.
9
10pub mod auth;
11pub mod identity;
12mod materialize;
13mod routes;
14mod state;
15mod thq_dispatch;
16mod thq_register;
17pub mod tls;
18pub mod xagent;
19
20/// The BIN's version, injected by the `trustee` binary at startup
21/// ([`set_bin_version`]) — the web UI's burger-menu footer displays it.
22/// Falls back to this crate's version when unset (trustee-api embedded
23/// elsewhere without a bin).
24static BIN_VERSION: std::sync::OnceLock<String> = std::sync::OnceLock::new();
25
26/// Called by the `trustee` binary so the web UI can show the real product
27/// version (the binary's — what `trustee --version` prints), not
28/// trustee-api's crate version. First call wins; a restart is a new process.
29pub fn set_bin_version(v: &str) {
30    let _ = BIN_VERSION.set(v.to_string());
31}
32
33/// The version label for the web UI: the bin's version if injected, else
34/// this crate's.
35pub fn bin_version() -> &'static str {
36    BIN_VERSION
37        .get()
38        .map(|s| s.as_str())
39        .unwrap_or(env!("CARGO_PKG_VERSION"))
40}
41
42// Embedded Cedar policy defaults (compiled into binary)
43const EMBEDDED_CEDAR_POLICY: &str = include_str!("../policies/trustee_default.cedar");
44const EMBEDDED_CEDAR_SCHEMA: &str = include_str!("../policies/trustee_schema.cedarschema");
45
46use std::net::SocketAddr;
47use std::sync::Arc;
48
49use anyhow::Result;
50use axum::routing::{get, post};
51use tower_http::cors::CorsLayer;
52
53pub use auth::{AuthConfig, AuthState};
54pub use state::ServerState;
55
56/// Run the API server.
57///
58/// Creates a `Session` with the given config, starts a background task to
59/// drain workflow messages and broadcast them to WebSocket clients, then
60/// serves the REST + WebSocket + static files on `addr`.
61///
62/// If `[oidc]` or `[dev]` sections are found in the config TOML, auth is
63/// enabled — all `/api/v1/*` endpoints (except health) require a valid token.
64///
65/// By default serves over HTTPS using a self-signed certificate from
66/// `~/.trustee/certs/`. If `use_tls` is false, serves plain HTTP.
67pub async fn run(
68    config_toml: String,
69    secrets: std::collections::HashMap<String, String>,
70    build_info: trustee_core::types::BuildInfo,
71    addr: SocketAddr,
72    use_tls: bool,
73) -> Result<()> {
74    // Parse auth config from TOML (returns None if no [oidc] or [dev] sections)
75    let auth_state = if let Some(cfg) = AuthConfig::from_toml(&config_toml) {
76        let is_dev = cfg.dev_config.local_dev_mode;
77        tracing::info!(
78            "Auth enabled: {} mode, issuer={}",
79            if is_dev { "development" } else { "production" },
80            cfg.issuer_url
81        );
82
83        // Parse Cedar authorization config (P2: fail-closed on init failure)
84        let cedar_boot = parse_cedar_config(&config_toml)
85            .await
86            .map_err(|e| anyhow::anyhow!("{e}"))?;
87        cedar_boot_decision(
88            true,
89            cedar_boot.authorizer.is_some(),
90            cedar_boot.allow_disabled,
91        )
92        .map_err(|e| anyhow::anyhow!("{e}"))?;
93
94        // 16F: teach auth about the service-account issuer candidates so
95        // agent tokens (minted on service vhosts) validate in check_auth.
96        let mut issuer_fallbacks = crate::state::service_issuers_from_config(&config_toml);
97        for si in crate::thq_dispatch::discover_service_issuers() {
98            if !issuer_fallbacks.contains(&si) {
99                issuer_fallbacks.push(si);
100            }
101        }
102        issuer_fallbacks.retain(|si| *si != cfg.issuer_url);
103        if !issuer_fallbacks.is_empty() {
104            tracing::info!("Auth issuer fallbacks armed: {:?}", issuer_fallbacks);
105        }
106        Some(Arc::new(
107            AuthState::with_cedar(cfg, cedar_boot.authorizer)
108                .with_issuer_fallbacks(issuer_fallbacks),
109        ))
110    } else {
111        // Open mode — loud, by design (local/dev posture preserved).
112        tracing::warn!("AUTH NOT CONFIGURED: trustee-web is running WITHOUT authentication or Cedar authorization (no [oidc]/[dev] section). Never expose this to a network.");
113        None
114    };
115
116    // THQ enrollment (v0.3 runtime lane): parse before config_toml is
117    // moved into the session. No [thq] = lane off; malformed = LOUD +
118    // lane off (never a silent skip; no compat translation of old keys).
119    let thq_enrollment = match thq_register::ThqConfig::from_toml(&config_toml) {
120        Ok(None) => None,
121        Err(e) => {
122            tracing::error!("THQ enrollment DISABLED — [thq] is malformed: {e}");
123            None
124        }
125        Ok(Some(cfg)) => Some(thq_register::Enrollment::new(cfg)),
126    };
127
128    // Build the session — keep copies of secrets/build_info for per-user sessions
129    let config_toml_for_state = config_toml.clone();
130    let secrets_for_state = secrets.clone();
131    let build_info_for_state = build_info.clone();
132    let (mut session, workflow_rx) = trustee_core::session::Session::new();
133    session.config_toml = Some(config_toml);
134    session.secrets = Some(secrets);
135    session.build_info = Some(build_info);
136    session.parse_auto_handoff_config();
137
138    // Extract agent name from config TOML for stateless operation
139    if let Some(ref config_toml_str) = session.config_toml {
140        if let Ok(table) = config_toml_str.parse::<toml::Value>() {
141            if let Some(name) = table
142                .get("agent")
143                .and_then(|a| a.get("name"))
144                .and_then(|n| n.as_str())
145            {
146                session.agent_name = name.to_string();
147            }
148        }
149    }
150
151    // Create the broadcast channel for WebSocket fan-out
152    let (ws_tx, _ws_rx) = tokio::sync::broadcast::channel::<String>(256);
153
154    // Wrap session in shared state (with shared config/secrets/build_info for per-user sessions)
155    // Parse knobs: [web].max_sessions_per_user, [users].allow_llm_overlay
156    let (max_sessions, allow_llm_overlay) = {
157        let config_str: &str = &config_toml_for_state;
158        match toml::from_str::<toml::Value>(config_str) {
159            Ok(v) => {
160                let max_sessions = v
161                    .get("web")
162                    .and_then(|w| w.as_table())
163                    .and_then(|w| w.get("max_sessions_per_user").and_then(|v| v.as_integer()))
164                    .map(|v| v as usize)
165                    .unwrap_or(4);
166                let allow_llm_overlay = v
167                    .get("users")
168                    .and_then(|u| u.as_table())
169                    .and_then(|u| u.get("allow_llm_overlay").and_then(|v| v.as_bool()))
170                    .unwrap_or(false);
171                (max_sessions, allow_llm_overlay)
172            }
173            Err(_) => (4, false),
174        }
175    };
176
177    let state = ServerState::new(session, ws_tx, auth_state)
178        .with_config_toml(config_toml_for_state)
179        .with_secrets(secrets_for_state)
180        .with_build_info(build_info_for_state)
181        .with_max_sessions_per_user(max_sessions)
182        .with_allow_llm_overlay(allow_llm_overlay);
183
184    // Start background message drain task (owns workflow_rx directly — no deadlock)
185    state.clone().spawn_drain_task(workflow_rx);
186
187    // 16F: per-agent dispatch table from per-user [thq] overlays — the
188    // dispatch lane is UNCHANGED by the 0.15.0 enrollment rewrite
189    // (different concept: agents-as-users vs the runtime-the-process).
190    thq_dispatch::populate(state.clone());
191
192    // Build router
193    //
194    // Auth middleware approach: since axum 0.8's from_fn_with_state has
195    // trait bound issues with nested routers, we apply auth checking at
196    // the handler level via a helper. Each protected route's handler
197    // calls auth::check_auth() first. This is simpler and avoids type
198    // complexity.
199    let app = axum::Router::new()
200        // Public routes
201        .route("/api/v1/health", get(routes::health))
202        .nest("/auth", auth::auth_routes())
203        // Protected API routes
204        .route("/api/v1/models", get(routes::list_models))
205        // LEGACY ACTIVE-SESSION ROUTES RETIRED (2026-10-27, owner order):
206        // /api/v1/session, /session/command, /session/cancel, /session/handoff,
207        // /session/stream, /session/name, /session/new, /project/name — the
208        // mutable active-pointer surface. Every consumer is session-scoped
209        // (THQ console, trustee-web, Android app post-4801f26); the pointer
210        // machinery (ensure_active_session / active_session_id) is removed
211        // with them — it was the root of the resumed-session bug class
212        // (22f8745e).
213        // Session discovery & resume
214        // Session discovery & resume (checkpoint-based, existing)
215        .route(
216            "/api/v1/sessions",
217            get(routes::list_sessions).post(routes::create_session),
218        )
219        .route("/api/v1/sessions/live", get(routes::list_live_sessions))
220        .route(
221            "/api/v1/sessions/{id}",
222            get(routes::get_session_detail).delete(routes::destroy_session),
223        )
224        .route("/api/v1/sessions/{id}/live", get(routes::get_live_session))
225        .route("/api/v1/sessions/{id}/resume", post(routes::resume_session))
226        .route("/api/v1/sessions/{id}/fork", post(routes::fork_session))
227        .route(
228            "/api/v1/sessions/{id}/history",
229            get(routes::get_session_history),
230        )
231        // MSU: session-scoped live routes
232        .route(
233            "/api/v1/sessions/{id}/command",
234            post(routes::post_command_session).layer(axum::extract::DefaultBodyLimit::max(
235                routes::ATTACH_BODY_LIMIT,
236            )),
237        )
238        .route(
239            "/api/v1/sessions/{id}/cancel",
240            post(routes::post_cancel_session),
241        )
242        .route(
243            "/api/v1/sessions/{id}/handoff",
244            post(routes::post_handoff_session),
245        )
246        .route(
247            "/api/v1/sessions/{id}/name",
248            post(routes::set_session_name_session),
249        )
250        .route(
251            "/api/v1/sessions/{id}/stream",
252            get(routes::ws_session_handler),
253        )
254        // Static files from trustee-web
255        .route("/", get(routes::serve_index))
256        .route("/{file}", get(routes::serve_static))
257        // 16F: per-agent THQ dispatch surface (impersonation by Bearer swap)
258        .merge(crate::xagent::router());
259    // THQ v0.3 enrollment callback lane: THQ POSTs the runtime secret
260    // here (register → /thq/enroll → pull/state loop). No [thq] config
261    // → no route (404 for would-be enroll callers).
262    let app = match &thq_enrollment {
263        Some(enr) => app.merge(thq_register::enroll_route(enr.clone())),
264        None => app,
265    };
266    // state clone for the enrollment loop (with_state below consumes it)
267    let enrollment_state = state.clone();
268    let app = app
269        .layer(CorsLayer::permissive())
270        .layer(axum::extract::DefaultBodyLimit::max(10 * 1024 * 1024))
271        .with_state(state);
272
273    // Bind BEFORE starting the enrollment loop: register's callback must
274    // land on a live /thq/enroll route (otherwise the first register
275    // wastes its callback and the loop self-heals a cycle later).
276    let listener = tokio::net::TcpListener::bind(addr).await?;
277    if let Some(enr) = thq_enrollment {
278        enr.spawn(enrollment_state);
279    }
280
281    if use_tls {
282        // Install ring as the process-level crypto provider (required when
283        // rustls is built with default-features=false to avoid ambiguity
284        // with aws-lc-rs pulled in transitively by other crates).
285        let _ = rustls::crypto::ring::default_provider().install_default();
286
287        // Ensure self-signed certs exist
288        let cert_dir = tls::default_cert_dir();
289        let (cert_path, key_path) = tls::ensure_certs(&cert_dir)?;
290
291        // Load TLS config
292        let tls_config = tls::load_tls_config(&cert_path, &key_path)?;
293        let acceptor = tokio_rustls::TlsAcceptor::from(std::sync::Arc::new(tls_config));
294
295        tracing::info!("Trustee API listening on https://{}", addr);
296
297        // Manual accept loop — spawn hyper-util auto connection per TLS stream
298        loop {
299            let (tcp_stream, peer_addr) = match listener.accept().await {
300                Ok(stream) => stream,
301                Err(e) => {
302                    tracing::warn!("TCP accept failed: {}", e);
303                    continue;
304                }
305            };
306
307            let acceptor = acceptor.clone();
308            let app = app.clone();
309
310            tokio::spawn(async move {
311                let tls_stream = match acceptor.accept(tcp_stream).await {
312                    Ok(s) => s,
313                    Err(e) => {
314                        tracing::debug!("TLS accept failed from {}: {}", peer_addr, e);
315                        return;
316                    }
317                };
318
319                // Use hyper-util auto builder with the tower service from axum.
320                // serve_connection_with_upgrades is required for WebSocket support.
321                let io = hyper_util::rt::TokioIo::new(tls_stream);
322                let svc = hyper_util::service::TowerToHyperService::new(app);
323
324                let _ = hyper_util::server::conn::auto::Builder::new(
325                    hyper_util::rt::TokioExecutor::new(),
326                )
327                .serve_connection_with_upgrades(io, svc)
328                .await;
329            });
330        }
331    } else {
332        tracing::info!("Trustee API listening on http://{}", addr);
333        axum::serve(listener, app).await?;
334    }
335
336    Ok(())
337}
338
339/// Parse [cedar] section from config TOML and create a CedarAuthorizer if enabled.
340///
341/// Configuration:
342/// - `[cedar] enabled = true/false` (default: false)
343/// - `[cedar] policy_path = "/path/to/policies.cedar"` (filesystem override)
344/// - `[cedar] schema_path = "/path/to/schema.cedarschema"` (filesystem override)
345/// - `[cedar] policy_store_url = "https://..."` (remote policy store)
346///
347/// When enabled without filesystem paths, uses embedded defaults.
348/// P2 boot result for Cedar (nghr 645809c3).
349struct CedarBoot {
350    authorizer: Option<Arc<pep::cedar::CedarAuthorizer>>,
351    /// Explicit per-environment escape hatch: `[cedar] allow_disabled = true`
352    /// opts THIS deployment into identity-only mode (Cedar absent). The
353    /// DEFAULT is fail-closed: web mode with auth configured refuses to
354    /// boot without a working Cedar authorizer.
355    allow_disabled: bool,
356}
357
358/// Pure decision for the P2 fail-closed posture — unit-tested.
359pub(crate) fn cedar_boot_decision(
360    auth_configured: bool,
361    cedar_present: bool,
362    allow_disabled: bool,
363) -> Result<(), String> {
364    if !auth_configured {
365        // Open mode (no [oidc]/[dev]) — preserved for local/dev usage; the
366        // absence of auth is logged loudly at boot.
367        return Ok(());
368    }
369    if cedar_present || allow_disabled {
370        Ok(())
371    } else {
372        Err(
373            "Cedar authorization is REQUIRED in web mode (fail-closed, nghr 645809c3). \
374             Either configure it: [cedar] enabled = true (policies ship embedded), \
375             or explicitly opt out per environment: [cedar] allow_disabled = true."
376                .to_string(),
377        )
378    }
379}
380
381/// THE POLICY/BINARY SKEW GUARD (the eynak boot-1 refusal class, 2026-10-31):
382/// a deployed filesystem policy override can reference actions the binary's
383/// schema doesn't declare — the binary updated its action set, the deployed
384/// policy files didn't, and Cedar's validate_on_load refused the boot
385/// fail-closed with a validation error that names the FAILURE, not the SKEW
386/// (the operator's fix was 26s of manual archaeology; the next binary bump
387/// would re-trigger it). This audit names the skew BEFORE the boot dies:
388/// every `Action::"X"` referenced by the policy but not declared by the
389/// schema — loud, one line each, at boot.
390pub(crate) fn audit_policy_action_skew(policy_text: &str, schema_text: &str) -> Vec<String> {
391    let referenced = policy_action_refs(policy_text);
392    let declared = schema_action_decls(schema_text);
393    referenced.difference(&declared).cloned().collect()
394}
395
396/// Every `Action::"Name"` referenced anywhere in a Cedar policy text
397/// (permit/forbid action clauses and condition clauses alike). `//` comments
398/// are stripped first — a comment may NAME a removed action (the shipped
399/// policy's migration note does) and that is not a reference.
400fn policy_action_refs(policy_text: &str) -> std::collections::BTreeSet<String> {
401    let code: String = policy_text
402        .lines()
403        .map(|l| match l.find("//") {
404            Some(pos) => &l[..pos],
405            None => l,
406        })
407        .collect::<Vec<_>>()
408        .join("\n");
409    let mut out = std::collections::BTreeSet::new();
410    let mut rest = code.as_str();
411    while let Some(pos) = rest.find("Action::\"") {
412        rest = &rest[pos + "Action::\"".len()..];
413        match rest.find('"') {
414            Some(end) => {
415                out.insert(rest[..end].to_string());
416                rest = &rest[end..];
417            }
418            None => break,
419        }
420    }
421    out
422}
423
424/// Every action DECLARED by a Cedar schema text (`action Name appliesTo …;`
425/// / `action Name;` — the empty-namespace form this repo ships).
426fn schema_action_decls(schema_text: &str) -> std::collections::BTreeSet<String> {
427    let mut out = std::collections::BTreeSet::new();
428    for line in schema_text.lines() {
429        let line = line.trim_start();
430        if let Some(rest) = line.strip_prefix("action ") {
431            let name: String = rest
432                .chars()
433                .take_while(|c| !c.is_whitespace() && *c != ';')
434                .collect();
435            if !name.is_empty() {
436                out.insert(name);
437            }
438        }
439    }
440    out
441}
442
443/// Run the skew audit on a policy/schema pair and log LOUD per unknown
444/// action (the guard's disclosure — never silent). Returns the unknown list
445/// for tests.
446pub(crate) fn log_policy_action_skew(source: &str, policy_text: &str, schema_text: &str) -> Vec<String> {
447    let unknown = audit_policy_action_skew(policy_text, schema_text);
448    if !unknown.is_empty() {
449        // One line per unknown action, naming the source — the operator sees
450        // the skew in seconds, not archaeology. Fail-closed still applies
451        // downstream (validate_on_load refuses the boot); this is the
452        // disclosure that makes the refusal ACTIONABLE.
453        for action in &unknown {
454            tracing::error!(
455                "Cedar POLICY/BINARY SKEW ({source}): policy references action Action::\"{action}\" which this binary's schema does NOT declare — the deployed policy files predate (or postdate) this binary's action set. Fix: align the policy files with the binary's schema (the embedded pair is authoritative), or update the binary."
456            );
457        }
458        tracing::error!(
459            "Cedar POLICY/BINARY SKEW ({source}): {} unknown action(s) total: {:?}",
460            unknown.len(),
461            unknown
462        );
463    }
464    unknown
465}
466
467async fn parse_cedar_config(config_toml: &str) -> Result<CedarBoot, String> {
468    let parsed: Option<toml::Table> = toml::from_str(config_toml).ok();
469    let cedar_table = parsed.as_ref().and_then(|t| t.get("cedar"));
470    let allow_disabled = cedar_table
471        .and_then(|c| c.get("allow_disabled"))
472        .and_then(|v| v.as_bool())
473        .unwrap_or(false);
474
475    let Some(cedar_section) = cedar_table.and_then(|c| c.as_table().cloned()) else {
476        return Ok(CedarBoot {
477            authorizer: None,
478            allow_disabled,
479        });
480    };
481    let enabled = cedar_section
482        .get("enabled")
483        .and_then(|v| v.as_bool())
484        .unwrap_or(false);
485
486    if !enabled {
487        tracing::debug!("Cedar authorization disabled (default)");
488        return Ok(CedarBoot {
489            authorizer: None,
490            allow_disabled,
491        });
492    }
493
494    tracing::info!("Cedar authorization enabled — initializing authorizer");
495
496    // Default policy/schema paths point to ~/{agent_name}/policies/ (created by trustee init).
497    // Agent name is read from [agent] name in config, defaulting to "trustee".
498    let agent_name = parsed
499        .as_ref()
500        .and_then(|t| t.get("agent"))
501        .and_then(|a| a.as_table())
502        .and_then(|a| a.get("name"))
503        .and_then(|n| n.as_str())
504        .unwrap_or("trustee");
505
506    let home_policies_dir = dirs::home_dir()
507        .map(|h| h.join(format!(".{}", agent_name)).join("policies"))
508        .unwrap_or_else(|| std::path::PathBuf::from("/nonexistent"));
509
510    let default_policy_path = home_policies_dir.join("trustee_default.cedar");
511    let default_schema_path = home_policies_dir.join("trustee_schema.cedarschema");
512
513    let policy_path = cedar_section
514        .get("policy_path")
515        .and_then(|v| v.as_str())
516        .filter(|s| !s.is_empty())
517        .map(std::path::PathBuf::from)
518        .unwrap_or(default_policy_path);
519
520    let schema_path = cedar_section
521        .get("schema_path")
522        .and_then(|v| v.as_str())
523        .filter(|s| !s.is_empty())
524        .map(std::path::PathBuf::from)
525        .or_else(|| Some(default_schema_path));
526
527    let policy_store_url = cedar_section
528        .get("policy_store_url")
529        .and_then(|v| v.as_str())
530        .map(String::from);
531
532    let policy_store_token = cedar_section
533        .get("policy_store_token")
534        .and_then(|v| v.as_str())
535        .map(String::from);
536
537    // THE POLICY/BINARY SKEW GUARD (the eynak boot-1 refusal class): audit
538    // BEFORE the authorizer constructs — the embedded pair always (atomic
539    // ship drift), and the resolved filesystem pair when the files exist
540    // (the deployed-override skew). validate_on_load still refuses the boot
541    // fail-closed downstream; this names the skew so the refusal is
542    // actionable in seconds.
543    log_policy_action_skew("embedded", EMBEDDED_CEDAR_POLICY, EMBEDDED_CEDAR_SCHEMA);
544    if policy_path.exists() && schema_path.as_ref().is_some_and(|p| p.exists()) {
545        match (
546            std::fs::read_to_string(&policy_path),
547            schema_path.as_ref().and_then(|p| std::fs::read_to_string(p).ok()),
548        ) {
549            (Ok(policy_text), Some(schema_text)) => {
550                log_policy_action_skew(
551                    &format!("filesystem {}", policy_path.display()),
552                    &policy_text,
553                    &schema_text,
554                );
555            }
556            _ => {
557                tracing::warn!(
558                    "Cedar skew guard: policy file {} exists but the schema file is unreadable — the audit ran on the embedded pair only",
559                    policy_path.display()
560                );
561            }
562        }
563    }
564
565    let cedar_config = pep::cedar::CedarConfig {
566        policy_path,
567        schema_path,
568        entities_path: None,
569        default_decision: pep::cedar::DefaultDecision::Deny,
570        validate_on_load: true,
571        policy_store_url,
572        policy_store_token,
573        embedded_policy: Some(EMBEDDED_CEDAR_POLICY),
574        embedded_schema: Some(EMBEDDED_CEDAR_SCHEMA),
575    };
576
577    match pep::cedar::CedarAuthorizer::new_with_policy_store(cedar_config).await {
578        Ok(auth) => {
579            tracing::info!("Cedar authorizer initialized successfully");
580            Ok(CedarBoot {
581                authorizer: Some(Arc::new(auth)),
582                allow_disabled,
583            })
584        }
585        // FAIL-CLOSED (nghr 645809c3): the v0.1.0–0.1.1 fame bug class —
586        // enabled-but-broken Cedar used to silently disable authorization.
587        // Now the boot dies loudly instead.
588        Err(e) => {
589            let msg = format!(
590                "Cedar authorization is enabled but FAILED to initialize: {e}. \
591                 Refusing to boot (fail-closed). Fix the policy/schema configuration \
592                 or explicitly set [cedar] allow_disabled = true to run identity-only."
593            );
594            tracing::error!("{msg}");
595            Err(msg)
596        }
597    }
598}