lean_ctx/core/execution_ledger/host/
source_recheck.rs1use std::{collections::BTreeMap, path::Path};
6
7use anyhow::{Result, ensure};
8use lean_ctx_protocol::Sha256Digest;
9use serde::Deserialize;
10
11use crate::core::policy::runtime;
12
13#[derive(Deserialize)]
16#[serde(deny_unknown_fields)]
17pub(super) struct ReceivingFileBinding {
18 pub(super) source_ref: String,
19 pub(super) path: String,
20}
21
22#[derive(Deserialize)]
23#[serde(deny_unknown_fields)]
24pub(super) struct ReceivingProviderBinding {
25 pub(super) source_ref: String,
26 pub(super) origin: crate::core::providers::provenance::ProviderOrigin,
27}
28
29const MAX_SOURCES: usize = 128;
30const MAX_TOTAL_BYTES: usize = 8 * 1024 * 1024;
31
32impl super::HostReceiptAuthority {
33 pub fn recheck_checkpoint_sources(
34 &self,
35 request: &super::checkpoint_transfer::HostCheckpointImportRequest,
36 ) -> Result<serde_json::Value> {
37 use super::checkpoint_transfer::SourceKind;
38 ensure!(
39 self.checkpoint_source_files.is_some() || self.checkpoint_source_providers.is_some(),
40 "receiving source check not authorized"
41 );
42 let verified = self.inspect_checkpoint_sources(request)?;
43 let root = verified
44 .root
45 .to_str()
46 .ok_or_else(|| anyhow::anyhow!("unsafe receiving root"))?;
47 ensure!(
48 runtime::request_project_is(&verified.root),
49 "receiving source scope mismatch"
50 );
51 runtime::with_project_source_view(root, || -> Result<serde_json::Value> {
52 let current = self.inspect_checkpoint_sources(request)?;
53 ensure!(
54 current.root == verified.root && current.dependencies == verified.dependencies,
55 "receiving source evidence changed"
56 );
57 let mut files = BTreeMap::new();
58 let mut providers = BTreeMap::new();
59 for (reference, dependency) in ¤t.dependencies {
60 match dependency.kind {
61 SourceKind::File => {
62 files.insert(reference.clone(), dependency.digest.clone());
63 }
64 SourceKind::Provider => {
65 providers.insert(reference.clone(), dependency.digest.clone());
66 }
67 SourceKind::Unsupported => anyhow::bail!("receiving source type unsupported"),
68 }
69 }
70 let file_bindings = self.checkpoint_source_files.as_deref().unwrap_or_default();
71 let provider_bindings = self
72 .checkpoint_source_providers
73 .as_deref()
74 .unwrap_or_default();
75 ensure!(
76 files.len() == file_bindings.len() && providers.len() == provider_bindings.len(),
77 "receiving source mapping coverage mismatch"
78 );
79 if !files.is_empty() {
80 check_files(¤t.root, &files, file_bindings)?;
81 }
82 if !providers.is_empty() {
83 check_providers(¤t.root, &providers, provider_bindings)?;
84 }
85 let latest = self.inspect_checkpoint_sources(request)?;
86 ensure!(
87 latest.root == current.root && latest.dependencies == current.dependencies,
88 "receiving source evidence changed"
89 );
90 self.validate_current().map_err(anyhow::Error::msg)?;
91 Ok(serde_json::json!({
92 "schema_version":"leanctx.checkpoint-source-check/v1",
93 "package_digest":current.package_digest,
94 "source_count":current.dependencies.len(),
95 "current_source_reads_verified":true,
96 "coverage":"recorded_invocations_only",
97 "narrative_source_coverage":"unknown",
98 "automatic_continuation_admitted":false,
99 "authorization_lease":false,
100 }))
101 })
102 .map_err(|_| anyhow::anyhow!("receiving source policy changed or unavailable"))?
103 }
104}
105
106pub(super) fn check_providers(
107 root: &Path,
108 dependencies: &BTreeMap<String, Sha256Digest>,
109 bindings: &[ReceivingProviderBinding],
110) -> Result<()> {
111 ensure!(
112 !dependencies.is_empty()
113 && dependencies.len() <= 16
114 && bindings.len() == dependencies.len(),
115 "receiving provider coverage unavailable"
116 );
117 let mut mapped = BTreeMap::new();
118 for binding in bindings {
119 ensure!(
120 dependencies.contains_key(&binding.source_ref)
121 && mapped
122 .insert(binding.source_ref.as_str(), &binding.origin)
123 .is_none(),
124 "receiving provider mapping mismatch"
125 );
126 }
127 let root_text = root
128 .to_str()
129 .ok_or_else(|| anyhow::anyhow!("unsafe receiving root"))?;
130 ensure!(
131 runtime::request_project_is(root),
132 "receiving source scope mismatch"
133 );
134 runtime::with_project_source_view(root_text, || -> Result<()> {
135 ensure!(
136 runtime::REQUEST_PROJECT
137 .try_with(|slot| slot.borrow().as_deref() == Some(root))
138 .unwrap_or(false),
139 "receiving source scope mismatch"
140 );
141 crate::core::providers::provenance::with_reuse_deadline(|| -> Result<()> {
142 for _ in 0..2 {
143 let mut remaining = MAX_TOTAL_BYTES;
144 for (reference, expected) in dependencies {
145 let origin = mapped
146 .get(reference.as_str())
147 .ok_or_else(|| anyhow::anyhow!("receiving provider mapping missing"))?;
148 let view = origin
149 .current_chunks(root_text)
150 .map_err(|_| anyhow::anyhow!("receiving provider unavailable"))?;
151 ensure!(
152 view.chunks.len() == 1,
153 "receiving provider result ambiguous"
154 );
155 let content = &view.chunks[0].content;
156 remaining = remaining.checked_sub(view.original_bytes).ok_or_else(|| {
157 anyhow::anyhow!("receiving provider content exceeds bound")
158 })?;
159 ensure!(
160 super::digest(content.as_bytes()).map_err(anyhow::Error::msg)? == *expected,
161 "receiving provider changed or requires transformation"
162 );
163 }
164 }
165 Ok(())
166 })
167 })
168 .map_err(|_| anyhow::anyhow!("receiving provider policy changed or unavailable"))?
169}
170
171pub(super) fn check_files(
172 root: &Path,
173 dependencies: &BTreeMap<String, Sha256Digest>,
174 bindings: &[ReceivingFileBinding],
175) -> Result<()> {
176 ensure!(
177 !dependencies.is_empty()
178 && dependencies.len() <= MAX_SOURCES
179 && bindings.len() == dependencies.len(),
180 "receiving source coverage unavailable"
181 );
182 let mut mapped = BTreeMap::new();
183 for binding in bindings {
184 ensure!(
185 dependencies.contains_key(&binding.source_ref)
186 && mapped
187 .insert(binding.source_ref.as_str(), binding)
188 .is_none(),
189 "receiving source mapping mismatch"
190 );
191 let path = Path::new(&binding.path);
192 ensure!(
193 !binding.path.is_empty()
194 && binding.path.len() <= 4096
195 && !binding.path.chars().any(char::is_control)
196 && !path.is_absolute()
197 && path
198 .components()
199 .all(|part| matches!(part, std::path::Component::Normal(_))),
200 "receiving source mapping invalid"
201 );
202 }
203 let root_text = root
204 .to_str()
205 .ok_or_else(|| anyhow::anyhow!("unsafe receiving root"))?;
206 ensure!(
207 runtime::request_project_is(root),
208 "receiving source scope mismatch"
209 );
210 runtime::with_project_source_view(root_text, || -> Result<()> {
211 ensure!(
212 runtime::REQUEST_PROJECT
213 .try_with(|slot| slot.borrow().as_deref() == Some(root))
214 .unwrap_or(false),
215 "receiving source scope mismatch"
216 );
217 ensure!(
220 crate::core::roles::active_role().is_tool_allowed("ctx_read")
221 && runtime::active().is_none_or(|policy| policy.tool_allowed("ctx_read")),
222 "receiving file access denied"
223 );
224 for _ in 0..2 {
225 let mut remaining = MAX_TOTAL_BYTES;
226 for (reference, expected) in dependencies {
227 let binding = mapped
228 .get(reference.as_str())
229 .ok_or_else(|| anyhow::anyhow!("receiving source mapping missing"))?;
230 let path = root.join(&binding.path);
231 let path = path
232 .to_str()
233 .ok_or_else(|| anyhow::anyhow!("receiving source path unavailable"))?;
234 let read = crate::core::rooted_read::read_file_for_tool_rooted_with_path(
235 path,
236 root_text,
237 "ctx_read",
238 &mut remaining,
239 )
240 .map_err(|_| anyhow::anyhow!("receiving source unavailable"))?;
241 ensure!(
242 super::digest(read.content.as_bytes()).map_err(anyhow::Error::msg)?
243 == *expected,
244 "receiving source changed or requires transformation"
245 );
246 }
247 }
248 Ok(())
249 })
250 .map_err(|_| anyhow::anyhow!("receiving source policy changed or unavailable"))?
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256
257 #[test]
258 fn receiving_provider_rechecks_actual_access_revision_and_masking() {
259 use crate::core::providers::{
260 config_provider::{ConfigProvider, schema::ProviderConfig},
261 hardened_http::redirect_tests::Server,
262 provenance::BoundResult,
263 provider_trait::ProviderParams,
264 };
265 let _data = crate::core::data_dir::isolated_data_dir();
266 let _policy = runtime::lock_test_override();
267 let directory = tempfile::tempdir().unwrap();
268 let root = crate::core::pathutil::canonicalize_secure(directory.path()).unwrap();
269 crate::core::workspace_trust::trust(&root).unwrap();
271 let configs = root.join(".lean-ctx/providers");
272 std::fs::create_dir_all(&configs).unwrap();
273 let body = r#"[{"id":"1","title":"K-731842","body":"Investigate login"}]"#;
274 let server = Server::new(200, None, body);
275 let config_text = format!(
276 "id='receiving-provider-fixture'\nname='Fixture'\nbase_url='{}'\n[auth]\ntype='none'\n[resources.issues]\nmethod='GET'\npath='/items'\n[resources.issues.response.mapping]\nid='id'\ntitle='title'\nbody='body'\n",
277 server.url
278 );
279 let config_path = configs.join("fixture.toml");
280 std::fs::write(&config_path, &config_text).unwrap();
281 let config: ProviderConfig = toml::from_str(&config_text).unwrap();
282 let provider = ConfigProvider::from_config(config).unwrap();
283 let acquired =
284 BoundResult::execute(&provider, "issues", &ProviderParams::default()).unwrap();
285 let chunks = acquired.chunks(&acquired.result);
286 assert_eq!(chunks.len(), 1);
287 let origin = chunks[0].origin.clone().unwrap();
288 let dependencies = BTreeMap::from([(
289 "object:issue-1".into(),
290 super::super::digest(chunks[0].content.as_bytes()).unwrap(),
291 )]);
292 let bindings = vec![ReceivingProviderBinding {
293 source_ref: "object:issue-1".into(),
294 origin,
295 }];
296 let before = server.count();
297 assert!(check_providers(&root, &dependencies, &bindings).is_ok());
298 assert_eq!(
299 server.count() - before,
300 2,
301 "both checks must reacquire the original source"
302 );
303 server.respond(403, "denied");
304 assert!(check_providers(&root, &dependencies, &bindings).is_err());
305 server.respond(
306 200,
307 r#"[{"id":"1","title":"changed","body":"Investigate login"}]"#,
308 );
309 assert!(check_providers(&root, &dependencies, &bindings).is_err());
310 server.respond(200, body);
311 let policy_path = root.join(".lean-ctx/policy.toml");
312 std::fs::write(&policy_path, "name='receiving'\nversion='1.0.0'\ndescription='test'\n[redaction]\ncustomer='K-731842'\n").unwrap();
313 assert!(check_providers(&root, &dependencies, &bindings).is_err());
314 std::fs::write(
315 &policy_path,
316 "name='receiving'\nversion='1.0.0'\ndescription='test'\n[redaction]\nall='(?s).+'\n",
317 )
318 .unwrap();
319 let masked = runtime::with_project_source_view(root.to_str().unwrap(), || {
320 crate::core::providers::provenance::with_reuse_deadline(|| {
321 bindings[0].origin.current_chunks(root.to_str().unwrap())
322 })
323 })
324 .unwrap()
325 .unwrap();
326 assert!(
327 masked.original_bytes > masked.chunks[0].content.len(),
328 "budget retains original item bytes even when policy shrinks all content"
329 );
330 std::fs::remove_file(policy_path).unwrap();
331 assert!(check_providers(&root, &dependencies, &bindings).is_ok());
332 std::fs::remove_file(config_path).unwrap();
333 assert!(check_providers(&root, &dependencies, &bindings).is_err());
334 }
335
336 #[test]
337 fn actual_receiving_file_must_match_and_remain_authorized() {
338 let _policy = runtime::lock_test_override();
339 let directory = tempfile::tempdir().unwrap();
340 let root = crate::core::pathutil::canonicalize_secure(directory.path()).unwrap();
341 std::fs::write(root.join("source.txt"), "K-482193").unwrap();
342 let dependencies = BTreeMap::from([(
343 "source:fixture".into(),
344 super::super::digest(b"K-482193").unwrap(),
345 )]);
346 let bindings = vec![ReceivingFileBinding {
347 source_ref: "source:fixture".into(),
348 path: "source.txt".into(),
349 }];
350 assert!(check_files(&root, &dependencies, &bindings).is_ok());
351 std::fs::create_dir(root.join(".lean-ctx")).unwrap();
352 let policy = root.join(".lean-ctx/policy.toml");
353 let base = "name = 'receiving'\nversion = '1.0.0'\ndescription = 'test'\n";
354 for rules in [
355 "[context]\ndeny_tools = ['ctx_read']\n",
356 "[redaction]\ncustomer = 'K-[0-9]{6}'\n",
357 "[redaction]\ninvalid = '['\n",
358 ] {
359 std::fs::write(&policy, format!("{base}{rules}")).unwrap();
360 assert!(check_files(&root, &dependencies, &bindings).is_err());
361 }
362 std::fs::remove_file(policy).unwrap();
363 std::fs::write(root.join("source.txt"), "changed").unwrap();
364 assert!(check_files(&root, &dependencies, &bindings).is_err());
365 std::fs::remove_file(root.join("source.txt")).unwrap();
366 assert!(check_files(&root, &dependencies, &bindings).is_err());
367 }
368
369 #[test]
370 fn receiving_mappings_cannot_omit_duplicate_or_escape_sources() {
371 let _policy = runtime::lock_test_override();
372 let directory = tempfile::tempdir().unwrap();
373 let root = crate::core::pathutil::canonicalize_secure(directory.path()).unwrap();
374 let dependencies = BTreeMap::from([(
375 "source:fixture".into(),
376 super::super::digest(b"safe").unwrap(),
377 )]);
378 assert!(check_files(&root, &dependencies, &[]).is_err());
379 assert!(check_files(&root, &BTreeMap::new(), &[]).is_err());
380 for path in ["../outside", "/etc/passwd", "", "./source.txt"] {
381 let bindings = vec![ReceivingFileBinding {
382 source_ref: "source:fixture".into(),
383 path: path.into(),
384 }];
385 assert!(check_files(&root, &dependencies, &bindings).is_err());
386 }
387 let bindings = vec![ReceivingFileBinding {
388 source_ref: "source:unknown".into(),
389 path: "source.txt".into(),
390 }];
391 assert!(check_files(&root, &dependencies, &bindings).is_err());
392 #[cfg(unix)]
393 {
394 let outside = tempfile::NamedTempFile::new().unwrap();
395 std::fs::write(outside.path(), "safe").unwrap();
396 std::os::unix::fs::symlink(outside.path(), root.join("escape")).unwrap();
397 let bindings = vec![ReceivingFileBinding {
398 source_ref: "source:fixture".into(),
399 path: "escape".into(),
400 }];
401 assert!(check_files(&root, &dependencies, &bindings).is_err());
402 }
403 }
404}