Skip to main content

lean_ctx/core/execution_ledger/host/
task_scope.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Operator-owned logical identity for an exact, safe local project root.
3
4use 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    /// Recheck signer and exact workspace on every call, including cached replay.
22    /// Saved sessions and MCP arguments cannot grant or change this identity.
23    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}