Skip to main content

lean_ctx/core/dashboard_pro/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Engine side of the cockpit analysis bridge.
3//!
4//! The cockpit asks for an op's inputs; the engine validates the op and
5//! collects bounded inputs from the bound project only. The cockpit computes
6//! the answer and hands it back; the engine executes any write it plans with
7//! its own code paths, reading every effect back. The computation never
8//! touches files, clock or env.
9//!
10//! Analysis state the cockpit used to keep (alerts, profiles, adaptive context,
11//! knowledge snapshots) lives under `<data>/dashboard-pro/<partition>/`,
12//! private (0600) and written atomically.
13
14pub mod bridge;
15pub mod collect;
16pub mod effects;
17pub(crate) mod export;
18pub(crate) mod guard;
19pub mod host_usage;
20pub mod ledger;
21pub mod request;
22pub(crate) mod security_fields;
23pub(crate) mod snapshots;
24pub(crate) mod state;
25pub(crate) mod watch;
26pub mod wire;
27
28#[cfg(test)]
29mod tests;
30
31use std::io::{Read, Write};
32use std::path::{Path, PathBuf};
33
34use serde_json::{Value, json};
35
36/// Guard kinds the audit trail records, in the cockpit's order.
37pub(crate) const GUARD_KINDS: [&str; 7] = [
38    "secret_redacted",
39    "shell_blocked",
40    "path_blocked",
41    "injection_flagged",
42    "pii_redacted",
43    "content_withheld",
44    "coverage_incomplete",
45];
46/// Upper bound of any JSON state file this module reads.
47const STATE_FILE_LIMIT: u64 = 4 << 20;
48
49/// An answer the cockpit shows: HTTP status and a plain message.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct ProError {
52    pub status: u16,
53    pub(crate) message: String,
54}
55
56impl ProError {
57    pub(crate) fn new(status: u16, message: impl Into<String>) -> Self {
58        Self {
59            status,
60            message: message.into(),
61        }
62    }
63    pub(crate) fn bad(message: impl Into<String>) -> Self {
64        Self::new(400, message)
65    }
66    pub(crate) fn not_found(message: impl Into<String>) -> Self {
67        Self::new(404, message)
68    }
69    pub(crate) fn conflict(message: impl Into<String>) -> Self {
70        Self::new(409, message)
71    }
72    pub(crate) fn internal(message: impl Into<String>) -> Self {
73        Self::new(500, message)
74    }
75}
76
77impl std::fmt::Display for ProError {
78    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        write!(f, "{} ({})", self.message, self.status)
80    }
81}
82
83pub(crate) type ProResult<T> = Result<T, ProError>;
84
85/// The bound project and its private Pro partition.
86#[derive(Debug, Clone)]
87pub struct ProjectScope {
88    /// The root as the dashboard binds it; the knowledge store is keyed by it.
89    pub(crate) root: String,
90    /// The canonical root, for jail checks.
91    pub(crate) canonical: PathBuf,
92    /// The engine's project hash; names the Pro partition.
93    pub(crate) partition: String,
94    /// The project's display name.
95    pub(crate) label: String,
96    /// Ledger folder hashes (`savings_ledger::repo_hash`) of the project.
97    /// Empty for the device scope, which the runtime reads as not narrowed.
98    pub(crate) repo_hashes: Vec<String>,
99    /// Device scope only: the roots of the projects on this device, which
100    /// name their ledger folders and host sessions.
101    pub(crate) projects: Vec<String>,
102    /// The agent sessions worked in the project's folders, which place its
103    /// audit-trail entries. Empty for the device scope.
104    pub(crate) sessions: std::collections::HashSet<String>,
105    pub(crate) data_dir: PathBuf,
106    pub(crate) state_dir: PathBuf,
107    pub(crate) home: Option<PathBuf>,
108}
109
110impl ProjectScope {
111    /// Every project on this device, for an "All projects" request of an op
112    /// that reads device-wide stores ([`request::Op::device_wide`]).
113    pub fn device(projects: &[String]) -> ProResult<Self> {
114        let data = crate::core::data_dir::lean_ctx_data_dir().map_err(ProError::internal)?;
115        let state = crate::core::paths::state_dir().map_err(ProError::internal)?;
116        Ok(Self::device_with_dirs(
117            projects,
118            data,
119            state,
120            dirs::home_dir(),
121        ))
122    }
123
124    pub(crate) fn device_with_dirs(
125        projects: &[String],
126        data_dir: PathBuf,
127        state_dir: PathBuf,
128        home: Option<PathBuf>,
129    ) -> Self {
130        let mut roots: Vec<String> = Vec::new();
131        for root in projects.iter().map(|r| r.trim()).filter(|r| !r.is_empty()) {
132            let canonical = Path::new(root)
133                .canonicalize()
134                .map(|p| p.to_string_lossy().into_owned());
135            for candidate in std::iter::once(root.to_string()).chain(canonical) {
136                if !roots.contains(&candidate) {
137                    roots.push(candidate);
138                }
139            }
140        }
141        Self {
142            root: String::new(),
143            canonical: PathBuf::new(),
144            partition: repo_hash("lean-ctx:device"),
145            label: "All projects".to_string(),
146            repo_hashes: Vec::new(),
147            projects: roots,
148            sessions: Default::default(),
149            data_dir,
150            state_dir,
151            home,
152        }
153    }
154
155    /// Whether this is the device scope (no bound project).
156    pub(crate) fn is_device(&self) -> bool {
157        self.root.is_empty()
158    }
159
160    /// The dashboard's bound project with the engine's own directories.
161    pub fn bind(root: &str) -> ProResult<Self> {
162        let data = crate::core::data_dir::lean_ctx_data_dir().map_err(ProError::internal)?;
163        let state = crate::core::paths::state_dir().map_err(ProError::internal)?;
164        let mut scope = Self::with_dirs(root, data, state, dirs::home_dir())?;
165        scope.adopt_folders(&crate::core::savings_ledger::ProjectScope::for_project(
166            &scope.canonical,
167        ));
168        Ok(scope)
169    }
170
171    /// Counts the project's subfolders and agent worktrees towards it, as the
172    /// project switch groups them: their ledger folders and their sessions.
173    pub(crate) fn adopt_folders(&mut self, folders: &crate::core::savings_ledger::ProjectScope) {
174        for hash in folders.repo_hashes() {
175            if !self.repo_hashes.contains(&hash) {
176                self.repo_hashes.push(hash);
177            }
178        }
179        self.sessions.extend(folders.sessions().iter().cloned());
180    }
181
182    pub(crate) fn with_dirs(
183        root: &str,
184        data_dir: PathBuf,
185        state_dir: PathBuf,
186        home: Option<PathBuf>,
187    ) -> ProResult<Self> {
188        let root = root.trim();
189        if root.is_empty() {
190            return Err(ProError::bad(
191                "Choose a project: this view reads one project's data",
192            ));
193        }
194        let path = Path::new(root);
195        if !path.is_absolute() || !path.is_dir() {
196            return Err(ProError::bad("The bound project is not a directory"));
197        }
198        let canonical = path
199            .canonicalize()
200            .map_err(|_| ProError::bad("The bound project is not readable"))?;
201        let partition = crate::core::project_hash::hash_project_root(root);
202        if !is_partition(&partition) {
203            return Err(ProError::internal("Unexpected project hash"));
204        }
205        let mut repo_hashes = vec![repo_hash(root)];
206        let canonical_text = canonical.to_string_lossy().into_owned();
207        if canonical_text != root {
208            repo_hashes.push(repo_hash(&canonical_text));
209        }
210        let label = canonical.file_name().map_or_else(
211            || "Project".to_string(),
212            |n| n.to_string_lossy().into_owned(),
213        );
214        Ok(Self {
215            root: root.to_string(),
216            canonical,
217            partition,
218            label,
219            repo_hashes,
220            projects: Vec::new(),
221            sessions: Default::default(),
222            data_dir,
223            state_dir,
224            home,
225        })
226    }
227
228    /// `<data>/dashboard-pro/<partition>`.
229    pub(crate) fn partition_dir(&self) -> PathBuf {
230        self.data_dir.join("dashboard-pro").join(&self.partition)
231    }
232
233    /// The wire `project` of a request.
234    pub(crate) fn project(&self) -> wire::Project {
235        wire::Project {
236            root_hash: self.repo_hashes.first().cloned().unwrap_or_default(),
237            labels: self.labels(),
238            repo_hashes: self.repo_hashes.clone(),
239            name: Some(self.label.clone()),
240        }
241    }
242
243    pub(crate) fn labels(&self) -> Vec<wire::ProjectLabel> {
244        if self.is_device() {
245            return self
246                .projects
247                .iter()
248                .map(|root| wire::ProjectLabel {
249                    hash: repo_hash(root),
250                    name: Path::new(root).file_name().map_or_else(
251                        || "Project".to_string(),
252                        |n| n.to_string_lossy().into_owned(),
253                    ),
254                })
255                .collect();
256        }
257        self.repo_hashes
258            .iter()
259            .map(|hash| wire::ProjectLabel {
260                hash: hash.clone(),
261                name: self.label.clone(),
262            })
263            .collect()
264    }
265
266    /// A project-relative path inside the jail: no absolute part, no `..`,
267    /// and resolving under the canonical root.
268    pub(crate) fn jailed(&self, relative: &str) -> ProResult<PathBuf> {
269        let relative = relative.trim();
270        let candidate = Path::new(relative);
271        if self.is_device()
272            || relative.is_empty()
273            || relative.len() > 4096
274            || relative.chars().any(char::is_control)
275            || candidate.is_absolute()
276            || crate::core::pathutil::is_windows_absolute_path(relative)
277            || candidate
278                .components()
279                .any(|c| !matches!(c, std::path::Component::Normal(_)))
280        {
281            return Err(ProError::conflict("The path is outside this project"));
282        }
283        let joined = self.canonical.join(candidate);
284        if let Ok(resolved) = joined.canonicalize()
285            && !resolved.starts_with(&self.canonical)
286        {
287            return Err(ProError::conflict("The path is outside this project"));
288        }
289        Ok(joined)
290    }
291}
292
293/// `savings_ledger::repo_hash` of a folder: the first 16 hex digits of
294/// SHA-256 over its path.
295pub(crate) fn repo_hash(path: &str) -> String {
296    use sha2::{Digest, Sha256};
297    let digest = Sha256::digest(path.as_bytes());
298    hex::encode(digest)[..16].to_string()
299}
300
301fn is_partition(value: &str) -> bool {
302    (8..=64).contains(&value.len()) && value.bytes().all(|b| b.is_ascii_hexdigit())
303}
304
305/// Writes `bytes` to `path` atomically, private to the user.
306pub(crate) fn write_private(path: &Path, bytes: &[u8]) -> ProResult<()> {
307    let parent = path
308        .parent()
309        .ok_or_else(|| ProError::internal("state path has no parent"))?;
310    create_private_dir(parent)?;
311    let mut temporary = tempfile::NamedTempFile::new_in(parent)
312        .map_err(|_| ProError::internal("Cockpit state could not be written"))?;
313    #[cfg(unix)]
314    {
315        use std::os::unix::fs::PermissionsExt;
316        std::fs::set_permissions(temporary.path(), std::fs::Permissions::from_mode(0o600))
317            .map_err(|_| ProError::internal("Cockpit state could not be written"))?;
318    }
319    temporary
320        .write_all(bytes)
321        .and_then(|()| temporary.as_file().sync_all())
322        .map_err(|_| ProError::internal("Cockpit state could not be written"))?;
323    temporary
324        .persist(path)
325        .map_err(|_| ProError::internal("Cockpit state could not be written"))?;
326    Ok(())
327}
328
329pub(crate) fn write_json(path: &Path, value: &impl serde::Serialize) -> ProResult<()> {
330    let bytes =
331        serde_json::to_vec(value).map_err(|_| ProError::internal("Cockpit state is not JSON"))?;
332    write_private(path, &bytes)
333}
334
335pub(crate) fn create_private_dir(dir: &Path) -> ProResult<()> {
336    if std::fs::symlink_metadata(dir).is_ok_and(|m| m.file_type().is_symlink()) {
337        return Err(ProError::internal("Cockpit state directory is a symlink"));
338    }
339    #[cfg(unix)]
340    {
341        use std::os::unix::fs::DirBuilderExt;
342        std::fs::DirBuilder::new()
343            .recursive(true)
344            .mode(0o700)
345            .create(dir)
346            .map_err(|_| ProError::internal("Cockpit state directory could not be created"))
347    }
348    #[cfg(not(unix))]
349    {
350        std::fs::create_dir_all(dir)
351            .map_err(|_| ProError::internal("Cockpit state directory could not be created"))
352    }
353}
354
355/// A regular, non-symlink file read up to `limit` bytes; `None` when it is
356/// missing, not regular or larger than `limit`.
357pub(crate) fn read_bounded(path: &Path, limit: u64) -> Option<Vec<u8>> {
358    let meta = std::fs::symlink_metadata(path).ok()?;
359    if !meta.file_type().is_file() || meta.len() > limit {
360        return None;
361    }
362    let mut bytes = Vec::with_capacity(usize::try_from(meta.len()).unwrap_or(0));
363    std::fs::File::open(path)
364        .ok()?
365        .take(limit + 1)
366        .read_to_end(&mut bytes)
367        .ok()?;
368    (bytes.len() as u64 <= limit).then_some(bytes)
369}
370
371pub(crate) fn read_json<T: serde::de::DeserializeOwned>(path: &Path) -> Option<T> {
372    serde_json::from_slice(&read_bounded(path, STATE_FILE_LIMIT)?).ok()
373}
374
375/// Text without control characters, at most `limit` characters.
376pub(crate) fn plain_text(value: &str, limit: usize) -> bool {
377    value.chars().count() <= limit && !value.chars().any(char::is_control)
378}
379
380pub fn error_body(error: &ProError) -> Value {
381    json!({ "error": error.message })
382}