Skip to main content

lean_ctx/core/context_admission/
withheld.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Withheld egress blocks: stable ids, a local explanation and the human
3//! override (`lean-ctx gateway why|list|allow-once|revoke`).
4//!
5//! A block the gateway withholds from a model request is replaced in place by
6//! a notice naming its id. The id is `w-` plus the first 12 hex digits of the
7//! keyed object digest (HMAC-SHA256 under the installation's receipt key, the
8//! receipt v2 object digest): the same bytes get the same id on every re-send
9//! of a conversation, and the remote side cannot confirm a guess with it.
10//!
11//! What is stored here is metadata only — reason codes, position class,
12//! destination, mode — never content and never a timestamp in a body. It
13//! lives outside the receipt store, so a full evidence store cannot take the
14//! explanation away. A grant lets exactly one block go to the provider it was
15//! withheld from, until it expires; only the destination rule is waived and
16//! only in developer mode. Grants are written by the CLI after a human
17//! confirmed on a terminal and are bound to the receipt key by a MAC.
18
19use std::path::PathBuf;
20
21use lean_ctx_protocol::Sha256Digest;
22use serde::{Deserialize, Serialize};
23
24/// The reason recorded when a grant let a restricted block go remote.
25pub(crate) const ALLOWED_ONCE: &str = "destination.remote_allowed_once";
26/// At most this many explanations are kept; the oldest files go first.
27const MAX_RECORDS: usize = 1024;
28/// Id length in hex digits after `w-`.
29const ID_HEX: usize = 12;
30
31/// The id of a withheld block, from its plain object digest. `None` without a
32/// usable receipt key: the notice then names no id.
33pub(crate) fn block_id(object: &Sha256Digest) -> Option<String> {
34    let key = super::receipt_store::keyed::object_key().ok()?;
35    let keyed = key.object(object);
36    Some(format!("w-{}", &keyed.hex()[..ID_HEX]))
37}
38
39/// Whether `id` has the shape of a block id (`w-` + 12 lowercase hex).
40#[must_use]
41pub fn valid_id(id: &str) -> bool {
42    id.strip_prefix("w-").is_some_and(|hex| {
43        hex.len() == ID_HEX && hex.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
44    })
45}
46
47/// What the gateway knows about one withheld block. Content-free.
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49pub struct WithheldBlock {
50    pub id: String,
51    pub reason_codes: Vec<String>,
52    /// `system`, `message 3`, `tools`, … — where in the request it was.
53    pub position: String,
54    /// Destination provider and locality it was withheld from.
55    pub provider: String,
56    pub locality: String,
57    pub mode: String,
58}
59
60/// A human's decision to let one block go to one provider.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct Grant {
63    pub id: String,
64    pub provider: String,
65    pub expires_unix: u64,
66    /// MAC under the receipt key over id, provider and expiry.
67    mac: String,
68}
69
70fn dir() -> Option<PathBuf> {
71    crate::core::data_dir::lean_ctx_data_dir()
72        .ok()
73        .map(|dir| dir.join("gateway").join("withheld"))
74}
75
76fn grants_dir() -> Option<PathBuf> {
77    dir().map(|dir| dir.join("grants"))
78}
79
80fn now_unix() -> u64 {
81    std::time::SystemTime::now()
82        .duration_since(std::time::UNIX_EPOCH)
83        .map_or(0, |elapsed| elapsed.as_secs())
84}
85
86fn write_private(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
87    let parent = path.parent().ok_or(std::io::ErrorKind::NotFound)?;
88    std::fs::create_dir_all(parent)?;
89    let name = path
90        .file_name()
91        .and_then(|name| name.to_str())
92        .unwrap_or("record");
93    let staged = parent.join(format!(".{name}.{}.tmp", std::process::id()));
94    {
95        let mut options = std::fs::OpenOptions::new();
96        options.write(true).create(true).truncate(true);
97        #[cfg(unix)]
98        {
99            use std::os::unix::fs::OpenOptionsExt as _;
100            options.mode(0o600);
101        }
102        let mut file = options.open(&staged)?;
103        std::io::Write::write_all(&mut file, bytes)?;
104    }
105    std::fs::rename(&staged, path)
106}
107
108/// Record the explanation of a withheld block (best effort: an unwritable
109/// store never changes the decision). Unchanged records are not rewritten.
110pub(crate) fn record(block: &WithheldBlock) {
111    let Some(dir) = dir() else { return };
112    let path = dir.join(format!("{}.json", block.id));
113    let Ok(bytes) = serde_json::to_vec_pretty(block) else {
114        return;
115    };
116    let existed = match std::fs::read(&path) {
117        Ok(current) if current == bytes => return,
118        Ok(_) => true,
119        Err(_) => false,
120    };
121    if write_private(&path, &bytes).is_err() {
122        tracing::warn!("lean-ctx gateway: withheld block explanation not stored");
123        return;
124    }
125    if !existed {
126        prune(&dir);
127    }
128}
129
130fn prune(dir: &std::path::Path) {
131    let Ok(entries) = std::fs::read_dir(dir) else {
132        return;
133    };
134    let mut records: Vec<(std::time::SystemTime, PathBuf)> = entries
135        .filter_map(Result::ok)
136        .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "json"))
137        .filter_map(|entry| {
138            let modified = entry.metadata().ok()?.modified().ok()?;
139            Some((modified, entry.path()))
140        })
141        .collect();
142    if records.len() <= MAX_RECORDS {
143        return;
144    }
145    records.sort();
146    let excess = records.len() - MAX_RECORDS;
147    for (_, path) in records.into_iter().take(excess) {
148        let _ = std::fs::remove_file(path);
149    }
150}
151
152/// The recorded explanation of `id`.
153#[must_use]
154pub fn lookup(id: &str) -> Option<WithheldBlock> {
155    if !valid_id(id) {
156        return None;
157    }
158    let bytes = std::fs::read(dir()?.join(format!("{id}.json"))).ok()?;
159    serde_json::from_slice(&bytes).ok()
160}
161
162/// Every recorded explanation, ordered by id (deterministic output).
163#[must_use]
164pub fn list() -> Vec<WithheldBlock> {
165    let Some(dir) = dir() else {
166        return Vec::new();
167    };
168    let Ok(entries) = std::fs::read_dir(dir) else {
169        return Vec::new();
170    };
171    let mut blocks: Vec<WithheldBlock> = entries
172        .filter_map(Result::ok)
173        .filter_map(|entry| {
174            let name = entry.file_name().into_string().ok()?;
175            lookup(name.strip_suffix(".json")?)
176        })
177        .collect();
178    blocks.sort_by(|a, b| a.id.cmp(&b.id));
179    blocks
180}
181
182fn grant_mac(id: &str, provider: &str, expires_unix: u64) -> Option<String> {
183    super::receipt_store::keyed::grant_ref(&[
184        id.as_bytes(),
185        provider.as_bytes(),
186        &expires_unix.to_be_bytes(),
187    ])
188    .ok()
189}
190
191/// Store a grant for `block` lasting `hours`. Called only by the human path
192/// of the CLI, after its presence checks.
193pub fn write_grant(block: &WithheldBlock, hours: u64) -> Result<Grant, String> {
194    store_grant(block, now_unix().saturating_add(hours.saturating_mul(3600)))
195}
196
197pub(crate) fn store_grant(block: &WithheldBlock, expires_unix: u64) -> Result<Grant, String> {
198    let mac = grant_mac(&block.id, &block.provider, expires_unix)
199        .ok_or("the gateway receipt key is unavailable; nothing was allowed")?;
200    let grant = Grant {
201        id: block.id.clone(),
202        provider: block.provider.clone(),
203        expires_unix,
204        mac,
205    };
206    let path = grants_dir()
207        .ok_or("no lean-ctx data directory")?
208        .join(format!("{}.json", block.id));
209    let bytes = serde_json::to_vec_pretty(&grant).map_err(|e| e.to_string())?;
210    write_private(&path, &bytes).map_err(|e| format!("grant not stored: {e}"))?;
211    Ok(grant)
212}
213
214/// Withdraw the grant of `id`; `Ok(false)` when there was none.
215pub fn revoke(id: &str) -> Result<bool, String> {
216    if !valid_id(id) {
217        return Err(format!("not a block id: {id}"));
218    }
219    let path = grants_dir()
220        .ok_or("no lean-ctx data directory")?
221        .join(format!("{id}.json"));
222    match std::fs::remove_file(path) {
223        Ok(()) => Ok(true),
224        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
225        Err(error) => Err(error.to_string()),
226    }
227}
228
229/// The current, authentic grant of `id`, if any.
230#[must_use]
231pub fn grant(id: &str) -> Option<Grant> {
232    if !valid_id(id) {
233        return None;
234    }
235    let bytes = std::fs::read(grants_dir()?.join(format!("{id}.json"))).ok()?;
236    let grant: Grant = serde_json::from_slice(&bytes).ok()?;
237    let authentic = grant.id == id
238        && grant_mac(&grant.id, &grant.provider, grant.expires_unix).as_deref()
239            == Some(grant.mac.as_str());
240    (authentic && grant.expires_unix > now_unix()).then_some(grant)
241}
242
243/// Whether a human allowed block `id` to go to `provider`.
244pub(crate) fn allowed(id: &str, provider: &str) -> bool {
245    grant(id).is_some_and(|grant| grant.provider == provider)
246}
247
248#[cfg(any(test, feature = "test-support"))]
249pub mod tests_support {
250    use super::{WithheldBlock, block_id, record};
251
252    /// An isolated data directory (and with it a fresh receipt key).
253    pub fn isolated() -> crate::core::data_dir::IsolatedDataDir {
254        crate::core::data_dir::isolated_data_dir()
255    }
256
257    /// Record a withheld block with `codes` and return its id.
258    pub fn seed_block(codes: &[&str]) -> String {
259        let object = lean_ctx_protocol::Sha256Digest::new(format!("sha256:{}", "ab".repeat(32)))
260            .expect("digest");
261        let id = block_id(&object).expect("receipt key");
262        record(&WithheldBlock {
263            id: id.clone(),
264            reason_codes: codes.iter().map(|code| (*code).to_owned()).collect(),
265            position: "message 1".into(),
266            provider: "anthropic".into(),
267            locality: "remote".into(),
268            mode: "developer".into(),
269        });
270        id
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    #[test]
279    fn ids_are_stable_keyed_and_well_formed() {
280        let _env = tests_support::isolated();
281        let object = lean_ctx_protocol::Sha256Digest::new(format!("sha256:{}", "cd".repeat(32)))
282            .expect("digest");
283        let id = block_id(&object).expect("id");
284        assert!(valid_id(&id), "{id}");
285        assert_eq!(block_id(&object).as_deref(), Some(id.as_str()), "stable");
286        assert!(
287            !id.contains(&"cd".repeat(6)),
288            "keyed, not a prefix of the plain digest"
289        );
290        assert!(!valid_id("w-0123"));
291        assert!(!valid_id("w-0123456789AB"));
292        assert!(!valid_id("../../etc/passwd"));
293    }
294
295    #[test]
296    fn records_are_content_free_and_listed_in_id_order() {
297        let _env = tests_support::isolated();
298        let id = tests_support::seed_block(&["destination.remote_restricted"]);
299        let block = lookup(&id).expect("recorded");
300        assert_eq!(block.position, "message 1");
301        assert_eq!(list(), vec![block]);
302        assert!(lookup("w-000000000000").is_none());
303    }
304
305    #[test]
306    fn a_grant_holds_for_its_provider_until_it_expires_or_is_revoked() {
307        let _env = tests_support::isolated();
308        let id = tests_support::seed_block(&["destination.remote_restricted"]);
309        let block = lookup(&id).expect("recorded");
310        assert!(!allowed(&id, "anthropic"));
311        write_grant(&block, 1).expect("grant");
312        assert!(allowed(&id, "anthropic"));
313        assert!(!allowed(&id, "openai"), "bound to the provider");
314        assert_eq!(revoke(&id), Ok(true));
315        assert!(!allowed(&id, "anthropic"));
316        assert_eq!(revoke(&id), Ok(false));
317        store_grant(&block, 1).expect("expired grant");
318        assert!(
319            !allowed(&id, "anthropic"),
320            "an expired grant allows nothing"
321        );
322    }
323
324    #[test]
325    fn a_hand_written_grant_is_not_authentic() {
326        let _env = tests_support::isolated();
327        let id = tests_support::seed_block(&["destination.remote_restricted"]);
328        let path = grants_dir().expect("dir").join(format!("{id}.json"));
329        std::fs::create_dir_all(path.parent().expect("parent")).expect("dir");
330        let forged = serde_json::json!({
331            "id": id, "provider": "anthropic", "expires_unix": u64::MAX, "mac": "blake3k:00"
332        });
333        std::fs::write(&path, forged.to_string()).expect("forged");
334        assert!(!allowed(&id, "anthropic"));
335    }
336}