Skip to main content

lean_ctx/core/execution_ledger/host/
source_recheck.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Current receiver-owned file acquisition for authenticated transfer dependencies.
3//! A successful check is an observation, never a lease or narrative promotion.
4
5use 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/// Supplied only through the receiving operator's host configuration. Foreign
14/// package paths and caller-supplied source bodies never enter this mapping.
15#[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 &current.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(&current.root, &files, file_bindings)?;
81            }
82            if !providers.is_empty() {
83                check_providers(&current.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        // Reacquire immediately before returning, using the same policy view.
218        // Both passes charge original bytes before masking can shrink them.
219        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        // Project-local providers load only for a trusted workspace.
270        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}