lean_ctx/core/execution_ledger/host/
task_scope.rs1use std::path::{Path, PathBuf};
5
6use lean_ctx_protocol::{ProjectId, TenantId};
7use serde::Deserialize;
8
9use super::HostReceiptAuthority;
10use crate::core::task_spine::AdmittedTaskIdentity;
11
12#[derive(Deserialize)]
13#[serde(deny_unknown_fields)]
14pub(super) struct HostTaskScope {
15 project_root: PathBuf,
16 project_id: ProjectId,
17 tenant_id: TenantId,
18}
19
20impl HostReceiptAuthority {
21 pub fn admit_task_identity(
24 &self,
25 project_root: Option<&str>,
26 ) -> Result<Option<AdmittedTaskIdentity>, &'static str> {
27 let Some(scope) = &self.task_scope else {
28 return Ok(None);
29 };
30 self.validate_current()?;
31 let receiving = project_root
32 .map(Path::new)
33 .ok_or("host_task_scope_root_missing")?;
34 if !scope.project_root.is_absolute() || !receiving.is_absolute() {
35 return Err("host_task_scope_root_invalid");
36 }
37 let expected = std::fs::canonicalize(&scope.project_root)
38 .map_err(|_| "host_task_scope_root_invalid")?;
39 let actual =
40 std::fs::canonicalize(receiving).map_err(|_| "host_task_scope_root_invalid")?;
41 if !expected.is_dir()
42 || crate::core::pathutil::is_broad_or_unsafe_root(&expected)
43 || expected != actual
44 {
45 return Err("host_task_scope_root_mismatch");
46 }
47 Ok(Some(AdmittedTaskIdentity {
48 project_id: scope.project_id.clone(),
49 tenant_id: scope.tenant_id.clone(),
50 }))
51 }
52}
53
54#[cfg(test)]
55mod tests {
56 use super::*;
57 use ed25519_dalek::SigningKey;
58 use serde_json::{Value, json};
59
60 fn settings(root: &Path) -> Value {
61 let key = SigningKey::from_bytes(&[61; 32]);
62 json!({"schema_version":1, "signing_key_hex":"3d".repeat(32),
63 "ledger_path":root.join("ledger.jsonl"),
64 "signer":{"key_id":"task-scope-test",
65 "public_key_digest":super::super::digest(key.verifying_key().as_bytes()).unwrap(),
66 "admitted_at":"2020-01-01T00:00:00Z", "expires_at":"9999-01-01T00:00:00Z", "revoked_at":null},
67 "task_scope":{"project_root":root,"project_id":"portable-project","tenant_id":"local-account"}})
68 }
69
70 fn authority(settings: &Value) -> HostReceiptAuthority {
71 HostReceiptAuthority::from_reader(&mut serde_json::to_vec(settings).unwrap().as_slice())
72 .unwrap()
73 }
74
75 #[test]
76 fn host_task_scope_requires_exact_safe_root_and_explicit_operator_binding() {
77 let project = tempfile::tempdir().unwrap();
78 let root = project.path().canonicalize().unwrap();
79 let config = settings(&root);
80 let host = authority(&config);
81 let identity = host.admit_task_identity(root.to_str()).unwrap().unwrap();
82 assert_eq!(identity.project_id.as_str(), "portable-project");
83 assert_eq!(identity.tenant_id.as_str(), "local-account");
84 let foreign = tempfile::tempdir().unwrap();
85 for receiving in [None, Some("relative"), foreign.path().to_str()] {
86 assert!(host.admit_task_identity(receiving).is_err());
87 }
88 for configured in [Path::new("relative"), Path::new("/")] {
89 let mut invalid = config.clone();
90 invalid["task_scope"]["project_root"] = json!(configured);
91 assert!(
92 authority(&invalid)
93 .admit_task_identity(root.to_str())
94 .is_err()
95 );
96 }
97 let mut absent = config.clone();
98 absent.as_object_mut().unwrap().remove("task_scope");
99 assert!(
100 authority(&absent)
101 .admit_task_identity(root.to_str())
102 .unwrap()
103 .is_none()
104 );
105 let mut malformed = config;
106 malformed["task_scope"]["caller_granted"] = json!(true);
107 assert!(
108 HostReceiptAuthority::from_reader(
109 &mut serde_json::to_vec(&malformed).unwrap().as_slice()
110 )
111 .is_err()
112 );
113 assert!(!root.join("ledger.jsonl").exists());
114 }
115}