treeship-core 0.20.0

Portable trust receipts for agent workflows - core library
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
//! 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)
}

/// Generic dispatch labels: an `action` field whose value is one of these is a
/// placeholder ("the agent called *a* tool"), and the concrete tool name lives
/// in `meta.tool`. Only for these is `meta.tool` allowed to contribute scope.
///
/// This is the security boundary for the cross-check. `meta` is part of the
/// statement the audited agent signs itself; if `meta.tool` could match for an
/// action whose `action` field is already a CONCRETE label, a dishonest agent
/// would attach a benign `meta.tool` (e.g. `file.read`) to a concrete
/// out-of-scope action (e.g. `payments.charge`) and have it counted in-scope,
/// silently defeating the very check `verify-capability` exists to run.
const GENERIC_DISPATCH_LABELS: &[&str] = &[
    "tool.call",
    "tool.use",
    "tool.invoke",
    "mcp.call",
    "mcp.tool",
];

/// The scope-matching candidates for an action. The action label is always
/// authoritative. `meta.tool` is consulted ONLY when the action label is a
/// generic dispatch placeholder, so a concrete out-of-scope action cannot be
/// rescued by an attacker-supplied `meta.tool`.
fn scope_candidates(action: &ActionStatement) -> Vec<&str> {
    let label = action.action.as_str();
    let mut candidates: Vec<&str> = vec![label];
    if GENERIC_DISPATCH_LABELS.contains(&label) {
        if let Some(tool) = action
            .meta
            .as_ref()
            .and_then(|m| m.get("tool"))
            .and_then(|v| v.as_str())
        {
            candidates.push(tool);
        }
    }
    candidates
}

/// Is an action within a declared capability set? The action label is
/// authoritative; `meta.tool` counts only when the label is a generic
/// dispatch placeholder (see [`scope_candidates`]).
pub fn action_in_scope(action: &ActionStatement, declared_tools: &[String]) -> bool {
    scope_candidates(action)
        .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 candidates = scope_candidates(action);
    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()
}

// --- Selective disclosure of the capability set -------------------------------
//
// For a card to be presented without revealing its full tool set, the signed
// payload must not contain the raw tools -- only their salted digests. Each
// tool is modeled as a disclosure `[salt, tool, true]`; the signed card carries
// the sorted digest list (`capabilities.tools_sd`), and the disclosures travel
// alongside the card. A holder reveals the subset it chooses; the verifier
// reconstructs exactly the revealed tools and nothing else.
//
// Consumers (next slice): `attest card` mint computes the commitment;
// `present --disclose` selects a subset of disclosures; `verify-presentation`
// and the disclosure-aware replacement for `declared_tools` reconstruct the
// revealed tools via `disclosed_tools`.

/// Commit a tool set for selective disclosure. Returns the sorted `tools_sd`
/// digests (which go into the signed card) and the encoded disclosure strings
/// (which the holder stores and reveals selectively). Order of `tools` does not
/// affect `tools_sd` (it is sorted), so the digest list never leaks the set's
/// authoring order.
pub fn commit_tools(tools: &[String]) -> (Vec<String>, Vec<String>) {
    let claims: Vec<(String, serde_json::Value)> = tools
        .iter()
        .map(|t| (t.clone(), serde_json::Value::Bool(true)))
        .collect();
    let c = crate::disclosure::commit(&claims);
    let disclosures = c.disclosures.iter().map(|d| d.encode()).collect();
    (c.sd, disclosures)
}

/// Reconstruct the revealed tools from presented disclosures, checked against
/// the signed `tools_sd` digest set. Only disclosures whose digest is in the
/// set and whose value is `true` count; a tampered, foreign, or malformed
/// disclosure contributes nothing (fail-closed). The result is exactly the
/// subset the holder chose to reveal -- for a full presentation, every
/// disclosure; for a selective one, fewer.
pub fn disclosed_tools(tools_sd: &[String], presented_disclosures: &[String]) -> Vec<String> {
    let sd_set: std::collections::BTreeSet<String> = tools_sd.iter().cloned().collect();
    let mut out = Vec::new();
    for d in presented_disclosures {
        if let Some((name, value)) = crate::disclosure::verify_disclosure(d, &sd_set) {
            if value == serde_json::Value::Bool(true) {
                out.push(name);
            }
        }
    }
    out
}

/// Read the signed `capabilities.tools_sd` digest list from a card payload.
pub fn committed_tool_digests(card_payload: &serde_json::Value) -> Vec<String> {
    card_payload
        .get("capabilities")
        .and_then(|c| c.get("tools_sd"))
        .and_then(|t| t.as_array())
        .map(|a| {
            a.iter()
                .filter_map(|t| t.as_str().map(str::to_string))
                .collect()
        })
        .unwrap_or_default()
}

/// Transform a full capability card payload into a *disclosed* one: replace
/// `capabilities.tools` (the raw list) with `capabilities.tools_sd` (the salted
/// digests), and return the disclosures for exactly the tools in `reveal`.
///
/// The caller re-signs the returned payload (as an `agent_card.v1` receipt) with
/// the agent's own key and bundles the returned disclosures. Because the raw
/// tools are gone from the signed bytes, the presentation reveals only the
/// chosen capabilities; the rest are opaque digests. This is the pure core of
/// `present --disclose` (the CLI orchestration and re-signing is the consumer).
pub fn disclose_capabilities(
    card_payload: &serde_json::Value,
    reveal: &[String],
) -> (serde_json::Value, Vec<String>) {
    let all_tools = declared_tools(card_payload);
    // One commit call: tools_sd and disclosures share salts and are consistent.
    // disclosures[i] corresponds to all_tools[i] (commit preserves input order).
    let (tools_sd, disclosures) = commit_tools(&all_tools);

    let reveal_set: std::collections::BTreeSet<&str> = reveal.iter().map(String::as_str).collect();
    let selected: Vec<String> = all_tools
        .iter()
        .zip(disclosures.iter())
        .filter(|(t, _)| reveal_set.contains(t.as_str()))
        .map(|(_, d)| d.clone())
        .collect();

    let mut disclosed = card_payload.clone();
    if let Some(caps) = disclosed
        .get_mut("capabilities")
        .and_then(|c| c.as_object_mut())
    {
        caps.remove("tools");
        caps.insert("tools_sd".into(), serde_json::json!(tools_sd));
    }
    (disclosed, selected)
}

/// Reconstruct the revealed capabilities from a disclosed card payload and the
/// disclosures presented with it. Returns exactly the tools whose disclosure
/// opens a digest in the signed `capabilities.tools_sd`; a tampered or foreign
/// disclosure contributes nothing. This is the verify side of
/// `disclose_capabilities`.
pub fn reconstruct_capabilities(
    card_payload: &serde_json::Value,
    disclosures: &[String],
) -> Vec<String> {
    let tools_sd = committed_tool_digests(card_payload);
    disclosed_tools(&tools_sd, disclosures)
}

#[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 counts ONLY when the action label is a generic dispatch
        // placeholder (the legitimate MCP case): tool.call + meta.tool=db.query.
        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 meta_tool_cannot_rescue_a_concrete_out_of_scope_action() {
        // The audited-agent bypass: a CONCRETE out-of-scope action with a
        // benign meta.tool must still be out of scope. If this regresses, a
        // dishonest agent hides every off-card action behind meta.tool.
        let mut a = ActionStatement::new("agent://x", "payments.charge");
        a.meta = Some(serde_json::json!({ "tool": "file.read" }));
        let declared = vec!["file.*".to_string()]; // payments NOT declared
        assert!(
            !action_in_scope(&a, &declared),
            "meta.tool must not pull a concrete out-of-scope action in-scope"
        );
        assert_eq!(
            matched_capability(&a, &declared),
            None,
            "no declared capability should match the concrete out-of-scope action"
        );
        // And the concrete action IS matched when it is actually declared.
        assert!(action_in_scope(&a, &["payments.*".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);
    }

    #[test]
    fn full_disclosure_reconstructs_the_whole_tool_set() {
        let tools = vec![
            "payments.charge".to_string(),
            "email.send".to_string(),
            "file.read".to_string(),
        ];
        let (tools_sd, disclosures) = commit_tools(&tools);
        assert_eq!(tools_sd.len(), 3);
        assert!(
            tools_sd.windows(2).all(|w| w[0] <= w[1]),
            "tools_sd is sorted"
        );

        // Presenting every disclosure reveals exactly the original set.
        let revealed = disclosed_tools(&tools_sd, &disclosures);
        let got: std::collections::BTreeSet<_> = revealed.into_iter().collect();
        let want: std::collections::BTreeSet<_> = tools.iter().cloned().collect();
        assert_eq!(got, want);
    }

    #[test]
    fn selective_disclosure_reveals_only_the_chosen_tool() {
        let tools = vec![
            "payments.charge".to_string(),
            "email.send".to_string(),
            "admin.delete".to_string(),
        ];
        let (tools_sd, disclosures) = commit_tools(&tools);

        // Reveal ONLY the disclosure for "payments.charge". The verifier must
        // learn that tool and nothing about the other two.
        let chosen: Vec<String> = disclosures
            .iter()
            .filter(|d| d.contains("payments.charge"))
            .cloned()
            .collect();
        assert_eq!(chosen.len(), 1);
        let revealed = disclosed_tools(&tools_sd, &chosen);
        assert_eq!(revealed, vec!["payments.charge".to_string()]);
    }

    #[test]
    fn tampered_or_foreign_disclosure_reveals_nothing() {
        let tools = vec!["payments.charge".to_string()];
        let (tools_sd, disclosures) = commit_tools(&tools);

        // A disclosure whose value was flipped to a different tool string is
        // not in the signed digest set -> contributes nothing.
        let tampered = disclosures[0].replace("payments.charge", "admin.root");
        assert!(disclosed_tools(&tools_sd, &[tampered]).is_empty());

        // A disclosure minted against a different card entirely -> rejected.
        let (_other_sd, other_disclosures) = commit_tools(&["admin.root".to_string()]);
        assert!(disclosed_tools(&tools_sd, &other_disclosures).is_empty());
    }

    // End-to-end: build a real card, disclose one capability, sign the
    // digests-only card exactly as the mint does (a receipt with
    // kind=agent_card.v1), verify the signature, recover the signed payload,
    // and reconstruct only the revealed capability. Proves the full
    // present --disclose / verify-presentation crypto with a real Ed25519
    // signature, independent of the CLI plumbing.
    #[test]
    fn disclosed_card_round_trip_with_real_signature() {
        use crate::attestation::sign::sign;
        use crate::attestation::signer::Ed25519Signer;
        use crate::attestation::verify::Verifier;
        use crate::statements::{payload_type, ReceiptStatement};
        use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};

        let card = serde_json::json!({
            "schema": "agent_card.v1",
            "agent": "agent://deployer",
            "keyid": "key_test_01",
            "version": "1",
            "capabilities": { "tools": ["payments.charge", "email.send", "admin.delete"] }
        });

        let (disclosed, revealed) = disclose_capabilities(&card, &["payments.charge".to_string()]);
        // The raw tool list is gone from what gets signed; only digests remain.
        assert!(disclosed["capabilities"].get("tools").is_none());
        assert!(disclosed["capabilities"].get("tools_sd").is_some());

        // Sign the digests-only card the way the mint signs a card.
        let signer = Ed25519Signer::generate("key_test_01").unwrap();
        let mut stmt = ReceiptStatement::new("system://registry", "agent_card.v1");
        stmt.payload = Some(disclosed);
        let result = sign(&payload_type("receipt"), &stmt, &signer).unwrap();

        // The signature over the digests-only card verifies.
        let verifier = Verifier::from_signer(&signer);
        assert!(verifier.verify(&result.envelope).is_ok());

        // Recover the SIGNED payload (not the local copy) and reconstruct.
        let payload_bytes = URL_SAFE_NO_PAD.decode(&result.envelope.payload).unwrap();
        let signed_stmt: ReceiptStatement = serde_json::from_slice(&payload_bytes).unwrap();
        let signed_card = signed_stmt.payload.expect("card payload present");

        let got = reconstruct_capabilities(&signed_card, &revealed);
        assert_eq!(got, vec!["payments.charge".to_string()]);

        // A tampered disclosure reveals nothing.
        let tampered: Vec<String> = revealed
            .iter()
            .map(|d| d.replace("payments.charge", "admin.delete"))
            .collect();
        assert!(reconstruct_capabilities(&signed_card, &tampered).is_empty());
    }
}