Skip to main content

agentmux_srv/backend/storage/
conversation_trust_grants.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Allowlist for a responding agent's `conversation_visibility: trusted_peers`
5//! mode. See `db_conversation_trust_grants` in migrations.rs
6//! (OBJECT_SCHEMA_VERSION v26) and
7//! docs/specs/SPEC_MUXSPECT_CROSS_TIER_CONVERSATION_VISIBILITY_2026_08_21.md
8//! Phase B.
9//!
10//! Mirrors `lan_peer_pubkey_pins.rs`'s exact shape (own module, get/set-style
11//! methods, case-insensitive agent_id lookups) — the explicit precedent this
12//! table is modeled on. Distinct concern, though: pubkey pins record an
13//! OBSERVED fact (trust-on-first-use) that must never silently change;
14//! trust grants record an EXPLICIT, revocable decision an agent's owner
15//! makes about who may read that agent's conversation content. A grant can
16//! be added or removed at any time — there is no "first write wins"
17//! invariant here.
18
19use rusqlite::params;
20
21use super::error::StoreError;
22use super::store::Store;
23
24use agentmux_common::time::now_secs;
25
26impl Store {
27    /// True if `agent_id` has explicitly granted `requester_agent_id` access
28    /// to its conversation content on `tier` ("lan" or "wan"). A grant for
29    /// one tier's cryptographic identity guarantee is never assumed to
30    /// cover another — a caller must check the grant for the ACTUAL tier
31    /// the incoming `transcript_request` arrived on.
32    pub fn conversation_trust_grant_check(
33        &self,
34        agent_id: &str,
35        requester_agent_id: &str,
36        tier: &str,
37    ) -> Result<bool, StoreError> {
38        // W3-S (SPEC_WAN_JEKT_VERIFICATION_2026_09_24.md §2.6): a `wan` grant
39        // is never honoured yet. The grant is keyed by bare peer name, and on
40        // WAN one name can be several instances; honouring it needs a key
41        // that includes the instance (a table-rebuild migration — the current
42        // primary key plus `INSERT OR REPLACE` would silently rebind to
43        // whichever instance was granted last) and a way to create grants.
44        // Nothing is lost: no production path creates grants today.
45        if tier.eq_ignore_ascii_case("wan") {
46            return Ok(false);
47        }
48        let agent_key = agent_id.to_lowercase();
49        let requester_key = requester_agent_id.to_lowercase();
50        let conn = self.conn.lock().unwrap();
51        let count: i64 = conn.query_row(
52            "SELECT COUNT(*) FROM db_conversation_trust_grants \
53             WHERE agent_id = ?1 AND granted_peer_agent_id = ?2 AND tier = ?3",
54            params![agent_key, requester_key, tier],
55            |row| row.get(0),
56        )?;
57        Ok(count > 0)
58    }
59
60    /// Grant `requester_agent_id` access to `agent_id`'s conversation
61    /// content on `tier`. Idempotent — granting an already-granted peer is
62    /// a no-op, not an error or a duplicate row (the compound primary key
63    /// on (agent_id, granted_peer_agent_id, tier) enforces this at the
64    /// schema level too; `INSERT OR REPLACE` keeps this call site simple
65    /// rather than requiring callers to distinguish "insert" from
66    /// "already exists").
67    #[allow(dead_code)] // No grant UI/CLI yet: SPEC_MUXSPECT_PHASE_B_POLICY_AND_TIER_ENFORCEMENT_2026_08_22.md §3.
68    pub fn conversation_trust_grant_add(
69        &self,
70        agent_id: &str,
71        requester_agent_id: &str,
72        tier: &str,
73    ) -> Result<(), StoreError> {
74        let agent_key = agent_id.to_lowercase();
75        let requester_key = requester_agent_id.to_lowercase();
76        let conn = self.conn.lock().unwrap();
77        conn.execute(
78            "INSERT OR REPLACE INTO db_conversation_trust_grants \
79             (agent_id, granted_peer_agent_id, tier, granted_at) VALUES (?1, ?2, ?3, ?4)",
80            params![agent_key, requester_key, tier, now_secs()],
81        )?;
82        Ok(())
83    }
84
85    /// Revoke a previously-granted peer. A no-op (not an error) if no such
86    /// grant exists — revoking is idempotent, same as granting.
87    #[allow(dead_code)] // No grant UI/CLI yet: SPEC_MUXSPECT_PHASE_B_POLICY_AND_TIER_ENFORCEMENT_2026_08_22.md §3.
88    pub fn conversation_trust_grant_revoke(
89        &self,
90        agent_id: &str,
91        requester_agent_id: &str,
92        tier: &str,
93    ) -> Result<(), StoreError> {
94        let agent_key = agent_id.to_lowercase();
95        let requester_key = requester_agent_id.to_lowercase();
96        let conn = self.conn.lock().unwrap();
97        conn.execute(
98            "DELETE FROM db_conversation_trust_grants \
99             WHERE agent_id = ?1 AND granted_peer_agent_id = ?2 AND tier = ?3",
100            params![agent_key, requester_key, tier],
101        )?;
102        Ok(())
103    }
104
105    /// List every peer `agent_id` has granted access to, across all tiers —
106    /// for a settings UI/CLI to show the current allowlist.
107    #[allow(dead_code)] // No grant UI/CLI yet: SPEC_MUXSPECT_PHASE_B_POLICY_AND_TIER_ENFORCEMENT_2026_08_22.md §3.
108    pub fn conversation_trust_grant_list(
109        &self,
110        agent_id: &str,
111    ) -> Result<Vec<(String, String, i64)>, StoreError> {
112        let agent_key = agent_id.to_lowercase();
113        let conn = self.conn.lock().unwrap();
114        let mut stmt = conn.prepare(
115            "SELECT granted_peer_agent_id, tier, granted_at FROM db_conversation_trust_grants \
116             WHERE agent_id = ?1 ORDER BY granted_at DESC",
117        )?;
118        let rows = stmt.query_map(params![agent_key], |row| {
119            Ok((row.get(0)?, row.get(1)?, row.get(2)?))
120        })?;
121        let mut out = Vec::new();
122        for r in rows {
123            out.push(r?);
124        }
125        Ok(out)
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    fn object_store() -> Store {
134        Store::open(std::path::Path::new(":memory:")).unwrap()
135    }
136
137    #[test]
138    fn check_returns_false_when_ungranted() {
139        let store = object_store();
140        assert!(!store.conversation_trust_grant_check("korp", "loap", "lan").unwrap());
141    }
142
143    #[test]
144    fn add_then_check_returns_true() {
145        let store = object_store();
146        store.conversation_trust_grant_add("korp", "loap", "lan").unwrap();
147        assert!(store.conversation_trust_grant_check("korp", "loap", "lan").unwrap());
148    }
149
150    /// W3-S §2.6: a `wan` grant is stored but never honoured until grants
151    /// are keyed by instance.
152    #[test]
153    fn a_wan_grant_is_never_honoured() {
154        let store = object_store();
155        store.conversation_trust_grant_add("korp", "loap", "wan").unwrap();
156        assert!(!store.conversation_trust_grant_check("korp", "loap", "wan").unwrap());
157        assert!(!store.conversation_trust_grant_check("korp", "loap", "WAN").unwrap());
158    }
159
160    #[test]
161    fn grant_is_scoped_to_its_own_tier() {
162        let store = object_store();
163        store.conversation_trust_grant_add("korp", "loap", "lan").unwrap();
164        assert!(
165            !store.conversation_trust_grant_check("korp", "loap", "wan").unwrap(),
166            "a LAN grant must not be assumed to cover WAN — different cryptographic identity guarantee"
167        );
168    }
169
170    #[test]
171    fn grant_is_scoped_to_its_own_responding_agent() {
172        let store = object_store();
173        store.conversation_trust_grant_add("korp", "loap", "lan").unwrap();
174        assert!(
175            !store.conversation_trust_grant_check("agentx", "loap", "lan").unwrap(),
176            "a grant made by one agent must not apply to a different agent's own visibility check"
177        );
178    }
179
180    #[test]
181    fn revoke_removes_the_grant() {
182        let store = object_store();
183        store.conversation_trust_grant_add("korp", "loap", "lan").unwrap();
184        store.conversation_trust_grant_revoke("korp", "loap", "lan").unwrap();
185        assert!(!store.conversation_trust_grant_check("korp", "loap", "lan").unwrap());
186    }
187
188    #[test]
189    fn revoke_of_a_never_granted_peer_is_a_harmless_no_op() {
190        let store = object_store();
191        store.conversation_trust_grant_revoke("korp", "loap", "lan").unwrap();
192        assert!(!store.conversation_trust_grant_check("korp", "loap", "lan").unwrap());
193    }
194
195    #[test]
196    fn granting_twice_is_idempotent() {
197        let store = object_store();
198        store.conversation_trust_grant_add("korp", "loap", "lan").unwrap();
199        store.conversation_trust_grant_add("korp", "loap", "lan").unwrap();
200        let list = store.conversation_trust_grant_list("korp").unwrap();
201        assert_eq!(list.len(), 1, "granting the same peer/tier twice must not duplicate the row");
202    }
203
204    #[test]
205    fn agent_id_lookup_is_case_insensitive() {
206        let store = object_store();
207        store.conversation_trust_grant_add("Korp", "Loap", "lan").unwrap();
208        assert!(store.conversation_trust_grant_check("korp", "loap", "lan").unwrap());
209        assert!(store.conversation_trust_grant_check("KORP", "LOAP", "lan").unwrap());
210    }
211
212    #[test]
213    fn list_returns_every_grant_for_the_agent_across_tiers() {
214        let store = object_store();
215        store.conversation_trust_grant_add("korp", "loap", "lan").unwrap();
216        store.conversation_trust_grant_add("korp", "agentx", "wan").unwrap();
217        let list = store.conversation_trust_grant_list("korp").unwrap();
218        assert_eq!(list.len(), 2);
219        let peers: std::collections::HashSet<_> = list.iter().map(|(p, _, _)| p.clone()).collect();
220        assert_eq!(peers, ["loap", "agentx"].into_iter().map(String::from).collect());
221    }
222
223    #[test]
224    fn list_never_returns_a_different_agents_grants() {
225        let store = object_store();
226        store.conversation_trust_grant_add("korp", "loap", "lan").unwrap();
227        store.conversation_trust_grant_add("agentx", "loap", "lan").unwrap();
228        let list = store.conversation_trust_grant_list("korp").unwrap();
229        assert_eq!(list.len(), 1);
230    }
231}