Skip to main content

lean_ctx_protocol/
engine_context_materialization_v2.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Opt-in v2 materialization: the unchanged v1 exchange plus a presenter
4//! capability.
5//!
6//! A presenter capability is a bearer secret the Engine hands to the caller
7//! that presented the sources. Whoever later shows it is treated as THE
8//! HOLDER OF THAT CAPABILITY for those presentations. It is not an agent
9//! name, not an attested identity and not an authorization: current source
10//! rights are checked again at every use, with or without it.
11//!
12//! The capability is host-internal. It must never be placed in model context,
13//! tool arguments, tool results, logs or receipts, and never sent to a
14//! provider. Records name a presenter only by [`EnginePresenterGrantV1::presenter_ref`].
15
16use serde::{Deserialize, Serialize};
17
18use crate::{
19    EngineContextSourceMaterializationRequestV1, EngineContextSourceMaterializationResponseV1,
20    ProtocolReference, ValidationError,
21};
22
23/// Schema id of the v2 materialization exchange.
24pub const ENGINE_CONTEXT_MATERIALIZATION_V2_SCHEMA_ID: &str =
25    "leanctx.engine-context-materialization/v2";
26/// Schema id of the presenter revocation exchange.
27pub const ENGINE_PRESENTER_REVOCATION_V1_SCHEMA_ID: &str = "leanctx.engine-presenter-revocation/v1";
28/// First Engine release that serves `context-materialize-sources-v2`.
29pub const ENGINE_PRESENTER_CAPABILITY_MIN_ENGINE_VERSION: &str = "3.11.0";
30/// Longest lifetime a caller may ask for: one day.
31pub const MAX_ENGINE_PRESENTER_TTL_SECONDS: u32 = 24 * 60 * 60;
32/// Wire prefix of a presenter capability.
33pub const ENGINE_PRESENTER_CAPABILITY_PREFIX: &str = "lcp1_";
34/// Wire prefix of a content-free presenter reference.
35pub const ENGINE_PRESENTER_REF_PREFIX: &str = "presenter:";
36
37const CAPABILITY_HEX_CHARS: usize = 64;
38const PRESENTER_REF_HEX_CHARS: usize = 32;
39
40fn lower_hex(text: &str, length: usize) -> bool {
41    text.len() == length
42        && text
43            .bytes()
44            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
45}
46
47/// Whether `text` has the wire form of a presenter capability. Form only:
48/// says nothing about whether any Engine issued it.
49#[must_use]
50pub fn is_engine_presenter_capability_form(text: &str) -> bool {
51    text.strip_prefix(ENGINE_PRESENTER_CAPABILITY_PREFIX)
52        .is_some_and(|hex| lower_hex(hex, CAPABILITY_HEX_CHARS))
53}
54
55/// How the caller wants the sources of this request to be presented.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(deny_unknown_fields, rename_all = "snake_case", tag = "mode")]
58pub enum EnginePresenterRequestV1 {
59    /// Mint a new capability with this lifetime.
60    Issue { ttl_seconds: u32 },
61    /// Present under a capability the caller already holds. The capability
62    /// is never part of this JSON: it reaches the Engine through
63    /// `--presenter-capability-file`.
64    Renew,
65}
66
67/// The v1 materialization request, with the presenter mode.
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(deny_unknown_fields)]
70pub struct EngineContextSourceMaterializationRequestV2 {
71    pub schema_version: u32,
72    pub materialization: EngineContextSourceMaterializationRequestV1,
73    pub presenter: EnginePresenterRequestV1,
74}
75
76impl EngineContextSourceMaterializationRequestV2 {
77    /// Validate the envelope and the unchanged v1 payload.
78    pub fn validate_payload(&self) -> Result<(), ValidationError> {
79        if self.schema_version != 2 {
80            return Err(invalid());
81        }
82        match self.presenter {
83            EnginePresenterRequestV1::Issue { ttl_seconds }
84                if !(1..=MAX_ENGINE_PRESENTER_TTL_SECONDS).contains(&ttl_seconds) =>
85            {
86                Err(invalid())
87            }
88            _ => self.materialization.validate_payload(),
89        }
90    }
91}
92
93/// What the Engine tells the caller about its presenter entry.
94#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(deny_unknown_fields)]
96pub struct EnginePresenterGrantV1 {
97    /// Content-free reference of the presenter entry: `presenter:` and 32
98    /// lowercase hex characters. Derived from a stored verifier, never from
99    /// the capability. Safe in records.
100    pub presenter_ref: ProtocolReference,
101    /// End of the capability's lifetime, Unix seconds.
102    pub expires_at_unix: u64,
103    /// The capability, for `issue` only. Host-internal secret.
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub capability: Option<String>,
106}
107
108impl std::fmt::Debug for EnginePresenterGrantV1 {
109    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110        formatter
111            .debug_struct("EnginePresenterGrantV1")
112            .field("presenter_ref", &self.presenter_ref)
113            .field("expires_at_unix", &self.expires_at_unix)
114            .field(
115                "capability",
116                &self.capability.as_ref().map(|_| "<redacted>"),
117            )
118            .finish()
119    }
120}
121
122impl EnginePresenterGrantV1 {
123    fn validate(&self) -> Result<(), ValidationError> {
124        let reference_ok = self
125            .presenter_ref
126            .as_str()
127            .strip_prefix(ENGINE_PRESENTER_REF_PREFIX)
128            .is_some_and(|hex| lower_hex(hex, PRESENTER_REF_HEX_CHARS));
129        let capability_ok = self
130            .capability
131            .as_deref()
132            .is_none_or(is_engine_presenter_capability_form);
133        if reference_ok && capability_ok {
134            Ok(())
135        } else {
136            Err(invalid())
137        }
138    }
139}
140
141/// The unchanged v1 materialization response, with the presenter grant.
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
143#[serde(deny_unknown_fields)]
144pub struct EngineContextSourceMaterializationResponseV2 {
145    pub schema_version: u32,
146    pub materialization: EngineContextSourceMaterializationResponseV1,
147    pub presenter: EnginePresenterGrantV1,
148}
149
150impl EngineContextSourceMaterializationResponseV2 {
151    /// Validate the envelope, the grant's form and the v1 payload. The
152    /// capability never appears inside the materialized content.
153    pub fn validate(&self) -> Result<(), ValidationError> {
154        if self.schema_version != 2 {
155            return Err(invalid());
156        }
157        self.presenter.validate()?;
158        let leaked = self
159            .presenter
160            .capability
161            .as_deref()
162            .is_some_and(|capability| self.materialization.content.contains(capability));
163        if leaked {
164            return Err(invalid());
165        }
166        self.materialization.validate()
167    }
168}
169
170/// Request of `context-presenter-revoke`. The capability to revoke reaches
171/// the Engine through `--presenter-capability-file`, never through this JSON.
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
173#[serde(deny_unknown_fields)]
174pub struct EnginePresenterRevocationRequestV1 {
175    pub schema_version: u32,
176}
177
178/// Answer of `context-presenter-revoke`.
179#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
180#[serde(deny_unknown_fields)]
181pub struct EnginePresenterRevocationResponseV1 {
182    pub schema_version: u32,
183    pub presenter_ref: ProtocolReference,
184    pub revoked: bool,
185}
186
187fn invalid() -> ValidationError {
188    ValidationError::new("invalid Engine materialization v2 envelope")
189}