Skip to main content

hessra_context_token/
inspect.rs

1extern crate biscuit_auth as biscuit;
2
3use biscuit::macros::{authorizer, rule};
4use chrono::Utc;
5use hessra_token_core::{Biscuit, PublicKey, TokenError};
6
7/// Result of inspecting a context token.
8#[derive(Debug, Clone)]
9pub struct ContextInspectResult {
10    /// The subject (session owner) in the context token.
11    pub subject: String,
12    /// The exposure labels accumulated in the context token (issuer-attested).
13    pub exposure_labels: Vec<String>,
14    /// The exposure sources that contributed exposure labels (issuer-attested).
15    pub exposure_sources: Vec<String>,
16    /// Unix timestamp when the token expires (if extractable).
17    pub expiry: Option<i64>,
18    /// Whether the token is currently expired.
19    pub is_expired: bool,
20    /// Number of exposure blocks: every block past the authority block is
21    /// an issuer exposure block under the third-party scheme.
22    pub exposure_block_count: usize,
23}
24
25/// Inspects a context token to extract session and exposure information.
26///
27/// All exposure data is fetched via Datalog queries scoped with
28/// `trusting authority, {public_key}`, so only facts attested by the issuer
29/// surface here.
30///
31/// This is a diagnostic method. No authorization decision should flow from
32/// inspect output -- use `ContextVerifier::excludes(...).verify()` for that.
33pub fn inspect_context_token(
34    token: String,
35    public_key: PublicKey,
36) -> Result<ContextInspectResult, TokenError> {
37    let biscuit = Biscuit::from_base64(&token, public_key)?;
38    let now = Utc::now().timestamp();
39
40    let authz = authorizer!(
41        r#"
42            time({now});
43            allow if true;
44        "#
45    );
46
47    let mut authorizer = authz
48        .build(&biscuit)
49        .map_err(|e| TokenError::internal(format!("failed to build authorizer: {e}")))?;
50
51    let subjects: Vec<(String,)> = authorizer
52        .query("data($name) <- context($name)")
53        .map_err(|e| TokenError::internal(format!("failed to query context subject: {e}")))?;
54    let subject = subjects.first().map(|(s,)| s.clone()).unwrap_or_default();
55
56    let pk = public_key;
57    let label_results: Vec<(String,)> = authorizer
58        .query_all(rule!(
59            r#"data($l) <- exposed_label($l) trusting authority, {pk}"#
60        ))
61        .map_err(|e| TokenError::internal(format!("failed to query exposure labels: {e}")))?;
62
63    let mut exposure_labels: Vec<String> = Vec::new();
64    for (label,) in label_results {
65        if !exposure_labels.contains(&label) {
66            exposure_labels.push(label);
67        }
68    }
69
70    let source_results: Vec<(String,)> = authorizer
71        .query_all(rule!(
72            r#"data($s) <- exposure_source($s) trusting authority, {pk}"#
73        ))
74        .map_err(|e| TokenError::internal(format!("failed to query exposure sources: {e}")))?;
75
76    let mut exposure_sources: Vec<String> = Vec::new();
77    for (source,) in source_results {
78        if !exposure_sources.contains(&source) {
79            exposure_sources.push(source);
80        }
81    }
82
83    // Under the third-party scheme, every block past the authority block is
84    // an exposure block. (Initial exposures, if any, live in the authority
85    // block itself and are not counted here.)
86    let block_count = biscuit.block_count();
87    let exposure_block_count = block_count.saturating_sub(1);
88
89    let token_content = biscuit.print();
90    let expiry = extract_expiry_from_content(&token_content);
91    let is_expired = expiry.is_some_and(|exp| exp < now);
92
93    Ok(ContextInspectResult {
94        subject,
95        exposure_labels,
96        exposure_sources,
97        expiry,
98        is_expired,
99        exposure_block_count,
100    })
101}
102
103/// Extracts expiry timestamp from token content.
104fn extract_expiry_from_content(content: &str) -> Option<i64> {
105    let mut earliest_expiry: Option<i64> = None;
106
107    for line in content.lines() {
108        if line.contains("check if")
109            && line.contains("time")
110            && line.contains("<")
111            && let Some(pos) = line.find("$time <")
112        {
113            let after_lt = &line[pos + 8..].trim();
114            let number_str = after_lt
115                .chars()
116                .take_while(|c| c.is_ascii_digit() || *c == '-')
117                .collect::<String>();
118
119            if let Ok(timestamp) = number_str.parse::<i64>() {
120                earliest_expiry = Some(earliest_expiry.map_or(timestamp, |e| e.min(timestamp)));
121            }
122        }
123    }
124
125    earliest_expiry
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use crate::exposure::add_exposure;
132    use crate::mint::HessraContext;
133    use hessra_token_core::{KeyPair, TokenTimeConfig};
134
135    #[test]
136    fn test_inspect_fresh_context() {
137        let keypair = KeyPair::new();
138        let public_key = keypair.public();
139
140        let token = HessraContext::new("agent:openclaw".to_string(), TokenTimeConfig::default())
141            .issue(&keypair)
142            .expect("Failed to create context token");
143
144        let result =
145            inspect_context_token(token, public_key).expect("Failed to inspect context token");
146
147        assert_eq!(result.subject, "agent:openclaw");
148        assert!(result.exposure_labels.is_empty());
149        assert!(result.exposure_sources.is_empty());
150        assert!(!result.is_expired);
151        assert!(result.expiry.is_some());
152        assert_eq!(result.exposure_block_count, 0);
153    }
154
155    #[test]
156    fn test_inspect_exposed_context() {
157        let keypair = KeyPair::new();
158        let public_key = keypair.public();
159
160        let token = HessraContext::new("agent:openclaw".to_string(), TokenTimeConfig::default())
161            .issue(&keypair)
162            .expect("Failed to create context token");
163
164        let exposed = add_exposure(
165            &token,
166            &keypair,
167            &["PII:SSN".to_string()],
168            "data:user-ssn".to_string(),
169        )
170        .expect("Failed to add exposure");
171
172        let result =
173            inspect_context_token(exposed, public_key).expect("Failed to inspect exposed context");
174
175        assert_eq!(result.subject, "agent:openclaw");
176        assert_eq!(result.exposure_labels, vec!["PII:SSN".to_string()]);
177        assert_eq!(result.exposure_sources, vec!["data:user-ssn".to_string()]);
178        assert_eq!(result.exposure_block_count, 1);
179    }
180
181    #[test]
182    fn test_inspect_multi_exposed_context() {
183        let keypair = KeyPair::new();
184        let public_key = keypair.public();
185
186        let token = HessraContext::new("agent:openclaw".to_string(), TokenTimeConfig::default())
187            .issue(&keypair)
188            .expect("Failed to create context token");
189
190        let exposed = add_exposure(
191            &token,
192            &keypair,
193            &["PII:email".to_string(), "PII:address".to_string()],
194            "data:user-profile".to_string(),
195        )
196        .expect("Failed to add profile exposure");
197
198        let more_exposed = add_exposure(
199            &exposed,
200            &keypair,
201            &["PII:SSN".to_string()],
202            "data:user-ssn".to_string(),
203        )
204        .expect("Failed to add SSN exposure");
205
206        let result = inspect_context_token(more_exposed, public_key)
207            .expect("Failed to inspect multi-exposed context");
208
209        assert_eq!(result.subject, "agent:openclaw");
210        assert_eq!(result.exposure_labels.len(), 3);
211        assert!(result.exposure_labels.contains(&"PII:email".to_string()));
212        assert!(result.exposure_labels.contains(&"PII:address".to_string()));
213        assert!(result.exposure_labels.contains(&"PII:SSN".to_string()));
214        assert_eq!(result.exposure_sources.len(), 2);
215        assert_eq!(result.exposure_block_count, 2);
216    }
217
218    #[test]
219    fn test_inspect_initial_exposures_in_authority_block() {
220        let keypair = KeyPair::new();
221        let public_key = keypair.public();
222
223        let token = HessraContext::new("agent:openclaw".to_string(), TokenTimeConfig::default())
224            .with_initial_exposures(
225                &["PII:email".to_string(), "PII:address".to_string()],
226                "data:user-profile",
227            )
228            .issue(&keypair)
229            .expect("Failed to create context token");
230
231        let result = inspect_context_token(token, public_key)
232            .expect("Failed to inspect token with initial exposures");
233
234        assert_eq!(result.subject, "agent:openclaw");
235        assert_eq!(result.exposure_labels.len(), 2);
236        assert!(result.exposure_labels.contains(&"PII:email".to_string()));
237        assert!(result.exposure_labels.contains(&"PII:address".to_string()));
238        assert_eq!(
239            result.exposure_sources,
240            vec!["data:user-profile".to_string()]
241        );
242        // Initial exposures live in the authority block; no third-party blocks added.
243        assert_eq!(result.exposure_block_count, 0);
244    }
245
246    #[test]
247    fn test_inspect_expired_context() {
248        let keypair = KeyPair::new();
249        let public_key = keypair.public();
250
251        let expired_config = TokenTimeConfig {
252            start_time: Some(0),
253            duration: 1,
254        };
255
256        let token = HessraContext::new("agent:test".to_string(), expired_config)
257            .issue(&keypair)
258            .expect("Failed to create expired context token");
259
260        let result = inspect_context_token(token, public_key)
261            .expect("Should be able to inspect expired token");
262
263        assert_eq!(result.subject, "agent:test");
264        assert!(result.is_expired);
265        assert_eq!(result.expiry, Some(1));
266    }
267}