treeship-core 0.17.0

Portable trust receipts for agent workflows - core library
Documentation
//! Pure capability-card verification primitives, shared by the CLI
//! (`treeship verify-capability`) and the WASM verifier (browser receipt
//! viewer) so both agree by construction. No I/O: callers supply the parsed
//! card, the action statements, and the trust roots.
//!
//! See docs/specs/agent-capability-cards.md. The honest contract holds here
//! too: this checks consistency over *captured* evidence (the actions the
//! caller passes in), never completeness.

use crate::statements::ActionStatement;
use crate::trust::{TrustRootKind, TrustRootStore};

/// `family.*` matches `family.write`; otherwise an exact match. A bare `*`
/// matches anything.
///
/// The `*` may sit anywhere in the pattern, not only at the end: harness
/// permission patterns captured by `attest card --from-harness` carry the
/// glob *inside* a delimiter — `Bash(git:*)` — where a trailing-`*`-only
/// matcher silently degrades to an exact match that can never fire, and a
/// card captured from a real config then reports every real action
/// out-of-scope. One wildcard is supported (the first, matching greedily);
/// the text before it must prefix the action and the text after it must
/// suffix the remainder, so `Bash(git:*)` matches `Bash(git:status)` but
/// not `Bash(gh:pr)` or a `Bash(git:` with the closing paren missing.
pub fn tool_matches(declared: &str, actual: &str) -> bool {
    match declared.split_once('*') {
        Some((prefix, suffix)) => {
            actual.len() >= prefix.len() + suffix.len()
                && actual.starts_with(prefix)
                && actual.ends_with(suffix)
        }
        None => declared == actual,
    }
}

/// A card is **key-bound** only when its `keyid` is the envelope signer AND
/// that key is pinned under `AgentCert`. Anything else is self-asserted.
pub fn is_key_bound(card_keyid: &str, signer_keyid: &str, trust: &TrustRootStore) -> bool {
    !card_keyid.is_empty()
        && signer_keyid == card_keyid
        && trust
            .roots()
            .iter()
            .any(|r| r.key_id == card_keyid && r.kind == TrustRootKind::AgentCert)
}

/// Is an action within a declared capability set? Checks the action label and
/// the optional `meta.tool` against each declared capability (exact, or a
/// `family.*` glob).
pub fn action_in_scope(action: &ActionStatement, declared_tools: &[String]) -> bool {
    let mut candidates: Vec<&str> = vec![action.action.as_str()];
    if let Some(tool) = action
        .meta
        .as_ref()
        .and_then(|m| m.get("tool"))
        .and_then(|v| v.as_str())
    {
        candidates.push(tool);
    }
    candidates
        .iter()
        .any(|c| declared_tools.iter().any(|d| tool_matches(d, c)))
}

/// The first declared capability an action matches, if any. Same matching as
/// [`action_in_scope`], but returns *which* capability matched, so callers can
/// grade each declared capability by whether captured receipts exercise it.
pub fn matched_capability(action: &ActionStatement, declared_tools: &[String]) -> Option<String> {
    let mut candidates: Vec<&str> = vec![action.action.as_str()];
    if let Some(tool) = action
        .meta
        .as_ref()
        .and_then(|m| m.get("tool"))
        .and_then(|v| v.as_str())
    {
        candidates.push(tool);
    }
    declared_tools
        .iter()
        .find(|decl| candidates.iter().any(|c| tool_matches(decl, c)))
        .cloned()
}

/// Extract the declared `capabilities.tools` from an agent_card.v1 payload.
pub fn declared_tools(card_payload: &serde_json::Value) -> Vec<String> {
    card_payload
        .get("capabilities")
        .and_then(|c| c.get("tools"))
        .and_then(|t| t.as_array())
        .map(|a| {
            a.iter()
                .filter_map(|t| t.as_str().map(str::to_string))
                .collect()
        })
        .unwrap_or_default()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::trust::{TrustRoot, TrustRootKind, TrustRootStore};

    #[test]
    fn exact_and_glob_matching() {
        assert!(tool_matches("file.write", "file.write"));
        assert!(!tool_matches("file.write", "file.read"));
        assert!(tool_matches("file.*", "file.write"));
        assert!(!tool_matches("file.*", "db.query"));
        assert!(tool_matches("*", "anything.at.all"));
    }

    #[test]
    fn harness_patterns_with_internal_glob_match() {
        // The shape `attest card --from-harness` captures from a Claude Code
        // settings.json permissions.allow list: the `*` sits inside the
        // parenthesized scope, not at the end of the pattern.
        assert!(tool_matches("Bash(git:*)", "Bash(git:status)"));
        assert!(tool_matches("Bash(git:*)", "Bash(git:log --oneline)"));
        assert!(tool_matches("Read(*)", "Read(/etc/hosts)"));
        // prefix and suffix must both hold — no cross-family bleed, no
        // matching a truncated action that drops the closing delimiter
        assert!(!tool_matches("Bash(git:*)", "Bash(gh:pr)"));
        assert!(!tool_matches("Bash(git:*)", "Bash(git:"));
        assert!(!tool_matches("Bash(git:*)", "payments.charge"));
        // the wildcard may match empty: the family root itself is in scope
        assert!(tool_matches("Bash(git:*)", "Bash(git:)"));
        // trailing-glob and exact behavior unchanged
        assert!(tool_matches("file.*", "file.*"));
        assert!(!tool_matches("Bash(git:status)", "Bash(git:log)"));
    }

    fn root(key_id: &str, kind: TrustRootKind) -> TrustRoot {
        TrustRoot {
            key_id: key_id.into(),
            public_key: "ed25519:AAAA".into(),
            kind,
            label: String::new(),
            added_at: String::new(),
        }
    }

    #[test]
    fn key_bound_needs_signer_match_and_agentcert() {
        let agentcert = TrustRootStore::with_roots(vec![root("key_x", TrustRootKind::AgentCert)]);
        assert!(is_key_bound("key_x", "key_x", &agentcert));
        assert!(!is_key_bound("key_x", "key_y", &agentcert));
        assert!(!is_key_bound("", "", &agentcert));
        let ship = TrustRootStore::with_roots(vec![root("key_x", TrustRootKind::Ship)]);
        assert!(!is_key_bound("key_x", "key_x", &ship));
        assert!(!is_key_bound("key_x", "key_x", &TrustRootStore::with_roots(vec![])));
    }

    #[test]
    fn in_scope_checks_action_and_meta_tool() {
        let mut a = ActionStatement::new("agent://x", "file.write");
        assert!(action_in_scope(&a, &["file.*".to_string()]));
        assert!(!action_in_scope(&a, &["db.query".to_string()]));
        // meta.tool also counts
        a.action = "tool.call".into();
        a.meta = Some(serde_json::json!({ "tool": "db.query" }));
        assert!(action_in_scope(&a, &["db.query".to_string()]));
    }

    #[test]
    fn matched_capability_returns_the_declared_glob() {
        let a = ActionStatement::new("agent://x", "file.write");
        let tools = vec!["db.query".to_string(), "file.*".to_string()];
        assert_eq!(matched_capability(&a, &tools).as_deref(), Some("file.*"));
        let b = ActionStatement::new("agent://x", "command.run");
        assert_eq!(matched_capability(&b, &tools), None);
    }
}