lean_ctx/core/context_admission/stored.rs
1// SPDX-License-Identifier: Apache-2.0
2//! Current rights for stored and derived copies (G03, G04).
3//!
4//! A persisted copy is never its own authorization, and neither is a digest
5//! of it. What decides a read is the **lineage**: the sources the copy was
6//! derived from. A derived summary can lose the marking of its original, and
7//! nothing in the copy changes when the original is deleted, restricted or
8//! reclassified — so re-detecting the stored text proves nothing about the
9//! reader's rights today. Every read therefore
10//!
11//! 1. resolves the project authority (bound root against the reader's root),
12//! 2. re-reads **each source** through the admitted rooted reader under the
13//! policy and role current *now* and compares it with what the copy was
14//! derived from — a source that is gone, changed, reclassified, outside the
15//! reader's project or no longer permitted withholds the copy,
16//! 3. checks the classification the original was admitted with, and
17//! 4. only then re-admits the stored text itself as an additional check.
18//!
19//! Two weaker cases are explicit, never implied:
20//!
21//! - `Lineage::CallerSupplied`: a value an agent or user typed (a bus
22//! message, a manually recorded note). No source exists to re-authorize; the
23//! copy is governed by content rules and by the project authority of its
24//! writer. That is *not* a source-bound guarantee.
25//! - no binding at all (a record written before bindings existed): withheld
26//! under an active project policy and in governed or sovereign mode with the
27//! tee store's wording; Community developer mode re-admits it, exactly as
28//! the originless tee rule (`core/ccr/storage.rs`) does.
29
30use lean_ctx_protocol::context_gateway::ClassificationV1;
31use serde::{Deserialize, Serialize};
32
33use super::carrier::{RESTRICTED_CODE, Withheld, admit_carrier_text_under, current_policy};
34use super::{AdmissionPolicy, fingerprint_digest};
35
36const BINDING_VERSION: u8 = 2;
37const BINDING_MISMATCH_CODE: &str = "store.binding_mismatch";
38const PROJECT_MISMATCH_CODE: &str = "store.project_mismatch";
39const PROJECT_UNRESOLVED_CODE: &str = "store.project_unresolved";
40const SOURCE_CHANGED_CODE: &str = "store.source_changed";
41const LINEAGE_UNBOUNDED_CODE: &str = "store.lineage_unbounded";
42/// Same bound as `archive::authority::MAX_SOURCES`.
43const MAX_SOURCES: usize = crate::core::archive::authority::MAX_SOURCES;
44
45/// One source a stored copy was derived from: the identity the rooted reader
46/// resolved, what it delivered, and how it was classified then.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct SourceRef {
49 /// Path as it was handed to the rooted reader (inside the bound project).
50 path: String,
51 /// Tool identity the read was authorized for (path jail, role rules).
52 tool: String,
53 /// BLAKE3 of the admitted text the reader returned.
54 admitted_digest: String,
55 /// Classification of the original at derivation time.
56 classification: ClassificationV1,
57}
58
59impl SourceRef {
60 /// Read `path` under `root` through the admitted rooted reader and bind
61 /// what it returned. This is the only way to obtain a source reference:
62 /// a path alone, or text the caller read some other way, is not one.
63 pub(crate) fn read(path: &str, root: &str, tool: &str) -> Result<(String, Self), Withheld> {
64 let mut remaining = crate::core::limits::max_read_bytes();
65 let read = Self::current(path, root, tool, &mut remaining)?;
66 let source = Self {
67 path: path.to_owned(),
68 tool: tool.to_owned(),
69 admitted_digest: content_digest(&read.content),
70 classification: read.classification,
71 };
72 Ok((read.content, source))
73 }
74
75 /// [`Self::read`] for a source the current call has already been given:
76 /// bound only if the reader returns now what it returned then
77 /// (`admitted_digest`, BLAKE3 hex of that text).
78 pub(crate) fn read_as_admitted(
79 path: &str,
80 root: &str,
81 tool: &str,
82 admitted_digest: &str,
83 ) -> Option<Self> {
84 Self::read(path, root, tool)
85 .ok()
86 .map(|(_, source)| source)
87 .filter(|source| source.admitted_digest == admitted_digest)
88 }
89
90 /// [`Self::read_as_admitted`] against a budget shared by several sources,
91 /// as [`serve_stored`] re-reads a whole lineage: a set of sources that
92 /// fits one by one but not together is not bound.
93 pub(crate) fn read_as_admitted_within(
94 path: &str,
95 root: &str,
96 tool: &str,
97 admitted_digest: &str,
98 remaining: &mut usize,
99 ) -> Option<Self> {
100 let read = Self::current(path, root, tool, remaining).ok()?;
101 let source = Self {
102 path: path.to_owned(),
103 tool: tool.to_owned(),
104 admitted_digest: content_digest(&read.content),
105 classification: read.classification,
106 };
107 (source.admitted_digest == admitted_digest).then_some(source)
108 }
109
110 /// The common rooted read, with the outcome of admitting the ORIGINAL
111 /// bytes. Classification and storability are inherited from that outcome,
112 /// never inferred from the delivered (possibly redacted) text: a source
113 /// that is restricted or only lossily decodable is not a storable source.
114 fn current(
115 path: &str,
116 root: &str,
117 tool: &str,
118 remaining: &mut usize,
119 ) -> Result<crate::core::rooted_read::AdmittedRootedRead, Withheld> {
120 let read = crate::core::rooted_read::read_file_for_tool_rooted_admitted(
121 path, root, tool, remaining,
122 )
123 .map_err(|_| Withheld::source_unavailable())?;
124 if !read.storable || !read.complete {
125 return Err(Withheld::new(&[RESTRICTED_CODE]));
126 }
127 Ok(read)
128 }
129
130 /// The reader's rights to this source *now*: still present, still inside
131 /// the project, still permitted for the current role and policy, still
132 /// delivering the same admitted bytes, original still storable and not
133 /// classified higher than when the copy was derived.
134 fn reauthorize(&self, root: &str, remaining: &mut usize) -> Result<(), Withheld> {
135 let read = Self::current(&self.path, root, &self.tool, remaining)?;
136 if content_digest(&read.content) != self.admitted_digest
137 || read.classification > self.classification
138 {
139 return Err(Withheld::new(&[SOURCE_CHANGED_CODE]));
140 }
141 Ok(())
142 }
143}
144
145/// Where a stored copy comes from.
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
147#[serde(tag = "kind", rename_all = "snake_case")]
148pub(crate) enum Lineage {
149 /// Typed by a caller; there is no source to re-authorize (weaker).
150 CallerSupplied,
151 /// Derived from these sources; all of them are re-authorized on a read.
152 Sources { sources: Vec<SourceRef> },
153}
154
155impl Lineage {
156 /// Lineage of a derivation: caller-supplied when it used no source.
157 /// Sources are deduplicated. More than [`MAX_SOURCES`] is refused — a
158 /// lineage is never truncated to the sources that happened to come first.
159 pub(crate) fn of(mut sources: Vec<SourceRef>) -> Result<Self, Withheld> {
160 let mut unique: Vec<SourceRef> = Vec::with_capacity(sources.len());
161 for source in sources.drain(..) {
162 if !unique.contains(&source) {
163 unique.push(source);
164 }
165 }
166 if unique.len() > MAX_SOURCES {
167 return Err(Withheld::new(&[LINEAGE_UNBOUNDED_CODE]));
168 }
169 Ok(if unique.is_empty() {
170 Self::CallerSupplied
171 } else {
172 Self::Sources { sources: unique }
173 })
174 }
175}
176
177/// What a derived store keeps next to an admitted copy.
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
179pub struct StoredBinding {
180 version: u8,
181 /// Who produced the copy (`agent:<id>`, `handoff`, `hook:<tool>`).
182 origin: String,
183 /// Canonical project root the copy belongs to, when one was bound.
184 #[serde(default, skip_serializing_if = "Option::is_none")]
185 project: Option<String>,
186 /// Role fingerprint of the writer (provenance; the reader's own rights
187 /// are established by re-reading the sources under the reader's role).
188 #[serde(default, skip_serializing_if = "Option::is_none")]
189 writer_role: Option<String>,
190 /// Hex digest of the gateway policy snapshot the copy was admitted under.
191 policy_digest: String,
192 /// BLAKE3 of the admitted text, so an edited record is not served.
193 content_digest: String,
194 /// Most sensitive classification over the copy and all its sources.
195 classification: ClassificationV1,
196 lineage: Lineage,
197}
198
199impl StoredBinding {
200 /// Whether this is a binding of exactly `text`. For a store that keeps
201 /// bindings apart from its items (`archive::bindings`): a binding of
202 /// another text is not this item's binding.
203 pub(crate) fn binds(&self, text: &str) -> bool {
204 self.version == BINDING_VERSION && self.content_digest == content_digest(text)
205 }
206
207 /// Who produced the copy, as its writer named it ([`admit_and_bind`]). A
208 /// store that keeps more than one kind of copy tells them apart by it
209 /// (`handoff_ledger`). It selects a rule of that store; it is never an
210 /// authorization and never widens what [`serve_stored`] decides.
211 pub(crate) fn origin(&self) -> &str {
212 &self.origin
213 }
214
215 /// The files this binding names and the project they are read in, as
216 /// `(project, [(path, tool, admitted digest)])`, for a store that derives
217 /// a new item from the bound one (`archive::bindings::carry`). `None` for
218 /// caller-supplied text and for a binding without a project. Read only:
219 /// what is returned is not a source until the admitted reader returns it
220 /// again ([`SourceRef::read_as_admitted`]).
221 pub(crate) fn file_sources(&self) -> Option<(&str, Vec<(&str, &str, &str)>)> {
222 let (Lineage::Sources { sources }, Some(project)) =
223 (&self.lineage, self.project.as_deref())
224 else {
225 return None;
226 };
227 let files = sources
228 .iter()
229 .map(|source| {
230 (
231 source.path.as_str(),
232 source.tool.as_str(),
233 source.admitted_digest.as_str(),
234 )
235 })
236 .collect();
237 Some((project, files))
238 }
239
240 /// Whether the copy is bound to sources (and not merely caller-supplied).
241 #[cfg(test)]
242 pub(crate) fn source_bound(&self) -> bool {
243 matches!(self.lineage, Lineage::Sources { .. })
244 }
245}
246
247/// Publish what a stored item that was just SERVED derives from into the
248/// current call's source capture (`archive::authority`), so that a tool
249/// result delivering the item has an ordinary file lineage
250/// (`archive::delivered`). The one function for every store.
251///
252/// Call it only after `serve_stored` returned `Ok` for this `binding`: that
253/// is the stricter check (project authority, every source re-read, no
254/// classification raise, content rules). An item it refuses is not served
255/// and publishes nothing. What is published is then re-checked like any
256/// file source — unchanged, complete, storable under the current policy and
257/// role; a later classification raise below `Restricted` is caught by
258/// `serve_stored` on the next read of the item, not by the file lineage.
259///
260/// - source-bound item with a project: every source is published;
261/// - no binding (originless), caller-supplied text, or no bound project:
262/// the capture is marked incomplete. Such an item contributes no lineage,
263/// and a result that contains it stays unknown. No source is ever inferred.
264///
265/// Without a capture, or without protection in any layer, nothing happens.
266pub fn publish_served(binding: Option<&StoredBinding>) {
267 use crate::core::archive::authority::{self, ArchiveAuthority};
268 if authority::current_capture().is_none() || !source_authority_required() {
269 return;
270 }
271 let bound = binding.and_then(|binding| match (&binding.lineage, &binding.project) {
272 (Lineage::Sources { sources }, Some(project)) => Some((project, sources)),
273 _ => None,
274 });
275 let Some((project, sources)) = bound else {
276 authority::mark_unbound();
277 return;
278 };
279 for source in sources {
280 // A source that cannot be bound marks the capture incomplete itself.
281 if let Some(source) = ArchiveAuthority::admitted_source(
282 std::path::Path::new(project),
283 &source.path,
284 &source.tool,
285 &source.admitted_digest,
286 ) {
287 source.publish();
288 }
289 }
290}
291
292fn canonical_project(root: &str) -> Option<String> {
293 crate::core::pathutil::canonicalize_secure(std::path::Path::new(root))
294 .ok()
295 .and_then(|path| path.to_str().map(str::to_owned))
296}
297
298fn resolve_project(project: Option<&str>) -> Option<String> {
299 project
300 .filter(|root| !root.is_empty())
301 .map(|root| canonical_project(root).unwrap_or_else(|| root.to_owned()))
302}
303
304fn content_digest(text: &str) -> String {
305 blake3::hash(text.as_bytes()).to_hex().to_string()
306}
307
308fn policy_digest(policy: &AdmissionPolicy) -> String {
309 fingerprint_digest(&policy.fingerprint()).hex().to_owned()
310}
311
312/// The one "source authority required" predicate: protection is active in
313/// some layer — a project policy pack, the global gateway in governed or
314/// sovereign mode, or a global configuration that could not be loaded (mode
315/// unknown). Then every stored or derived copy needs a current source
316/// authority to be written or served. Otherwise (Community, developer mode)
317/// originless copies keep the behaviour of the tee store.
318#[must_use]
319pub fn source_authority_required() -> bool {
320 protected(¤t_policy())
321}
322
323/// [`source_authority_required`] under a policy snapshot the caller holds,
324/// so that one operation decides protection and content under the same rules.
325#[must_use]
326pub(crate) fn source_authority_required_under(policy: &AdmissionPolicy) -> bool {
327 protected(policy)
328}
329
330fn protected(policy: &AdmissionPolicy) -> bool {
331 policy.config_unloadable()
332 || policy.detectors_mandatory()
333 || crate::core::policy::runtime::is_active()
334}
335
336/// Admit `text` for a derived store and bind the admitted copy to `lineage`.
337///
338/// Admission and binding use one policy snapshot: the digest in the binding
339/// is the policy the text was admitted under. Restricted content — in the
340/// text or in any source — is never stored. Under an active project policy
341/// and in governed or sovereign mode a copy without a project is refused: it
342/// could never be re-authorized.
343pub(crate) fn admit_and_bind(
344 text: &str,
345 origin: &str,
346 project: Option<&str>,
347 lineage: Lineage,
348) -> Result<(String, StoredBinding), Withheld> {
349 let policy = current_policy();
350 let project = resolve_project(project);
351 if project.is_none() && protected(&policy) {
352 return Err(Withheld::new(&[PROJECT_UNRESOLVED_CODE]));
353 }
354 let admission = super::semantic_deferred::for_stored_context(|| {
355 admit_carrier_text_under(text, Some(&policy))
356 })?;
357 let mut classification = admission.classification;
358 if let Lineage::Sources { sources } = &lineage {
359 for source in sources {
360 classification = classification.join(source.classification);
361 }
362 }
363 if !admission.storable || classification >= ClassificationV1::Restricted {
364 return Err(Withheld::new(&[RESTRICTED_CODE]));
365 }
366 let binding = StoredBinding {
367 version: BINDING_VERSION,
368 origin: origin.to_owned(),
369 project,
370 writer_role: crate::core::policy::runtime::role_fingerprint(
371 &crate::core::roles::active_role(),
372 )
373 .ok()
374 .map(|fingerprint| fingerprint.to_hex().to_string()),
375 policy_digest: policy_digest(&policy),
376 content_digest: content_digest(&admission.text),
377 classification,
378 lineage,
379 };
380 Ok((admission.text, binding))
381}
382
383/// Serve a stored copy as a new delivery to a reader working in `project`.
384/// See the module documentation for the order of checks.
385pub(crate) fn serve_stored(
386 text: &str,
387 binding: Option<&StoredBinding>,
388 project: Option<&str>,
389) -> Result<String, Withheld> {
390 let policy = current_policy();
391 let protected = protected(&policy);
392 let Some(binding) = binding else {
393 // Legacy record: the originless rule.
394 if protected {
395 return Err(Withheld::no_source_authority());
396 }
397 return admit_stored_text(text, &policy);
398 };
399 if binding.version != BINDING_VERSION || binding.content_digest != content_digest(text) {
400 return Err(Withheld::new(&[BINDING_MISMATCH_CODE]));
401 }
402 // Project authority. Protected deployments refuse anything unresolved;
403 // Community developer mode refuses only a proven mismatch.
404 let reader = resolve_project(project);
405 match (binding.project.as_deref(), reader.as_deref()) {
406 (Some(bound), Some(reader)) if bound == reader => {}
407 (Some(_), Some(_)) => return Err(Withheld::new(&[PROJECT_MISMATCH_CODE])),
408 _ if protected => return Err(Withheld::new(&[PROJECT_UNRESOLVED_CODE])),
409 _ => {}
410 }
411 // Source authority: the proof of current rights.
412 if let Lineage::Sources { sources } = &binding.lineage {
413 let Some(root) = binding.project.as_deref() else {
414 return Err(Withheld::no_source_authority());
415 };
416 let mut remaining = crate::core::limits::max_read_bytes();
417 // A check, not a delivery: the re-reads stay out of the call's source
418 // capture. A refused item publishes nothing — otherwise the sources
419 // that passed before the failing one would give the refusal itself a
420 // file lineage — and a served one is published by `publish_served`.
421 crate::core::archive::authority::without_capture(|| {
422 sources
423 .iter()
424 .try_for_each(|source| source.reauthorize(root, &mut remaining))
425 })?;
426 }
427 if binding.classification >= ClassificationV1::Restricted {
428 return Err(Withheld::new(&[RESTRICTED_CODE]));
429 }
430 // Additional check, never the proof: the copy under today's content rules.
431 admit_stored_text(text, &policy)
432}
433
434fn admit_stored_text(text: &str, policy: &AdmissionPolicy) -> Result<String, Withheld> {
435 let admission = super::semantic_deferred::for_stored_context(|| {
436 admit_carrier_text_under(text, Some(policy))
437 })?;
438 if admission.storable {
439 Ok(admission.text)
440 } else {
441 Err(Withheld::new(&[RESTRICTED_CODE]))
442 }
443}
444
445#[cfg(test)]
446mod tests;
447
448#[cfg(any(test, feature = "test-support"))]
449#[cfg_attr(not(test), allow(unused_imports))]
450pub mod lineage_support;