lean_ctx/core/context_admission/
withheld.rs1use std::path::PathBuf;
20
21use lean_ctx_protocol::Sha256Digest;
22use serde::{Deserialize, Serialize};
23
24pub(crate) const ALLOWED_ONCE: &str = "destination.remote_allowed_once";
26const MAX_RECORDS: usize = 1024;
28const ID_HEX: usize = 12;
30
31pub(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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49pub struct WithheldBlock {
50 pub id: String,
51 pub reason_codes: Vec<String>,
52 pub position: String,
54 pub provider: String,
56 pub locality: String,
57 pub mode: String,
58}
59
60#[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: 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
108pub(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#[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#[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
191pub 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
214pub 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#[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
243pub(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 pub fn isolated() -> crate::core::data_dir::IsolatedDataDir {
254 crate::core::data_dir::isolated_data_dir()
255 }
256
257 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}