Skip to main content

construct/config/
workspace.rs

1//! Workspace profile management for multi-client isolation.
2//!
3//! Each workspace represents an isolated client engagement with its own
4//! memory namespace, audit trail, secrets scope, and tool restrictions.
5//! Profiles are stored under `~/.construct/workspaces/<client_name>/`.
6
7use anyhow::{Context, Result, bail};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11
12/// A single client workspace profile.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct WorkspaceProfile {
15    /// Human-readable workspace name (also used as directory name).
16    pub name: String,
17    /// Allowed domains for network access within this workspace.
18    #[serde(default)]
19    pub allowed_domains: Vec<String>,
20    /// Credential profile name scoped to this workspace.
21    #[serde(default)]
22    pub credential_profile: Option<String>,
23    /// Memory namespace prefix for isolation.
24    #[serde(default)]
25    pub memory_namespace: Option<String>,
26    /// Audit namespace prefix for isolation.
27    #[serde(default)]
28    pub audit_namespace: Option<String>,
29    /// Tool names denied in this workspace (e.g. `["shell"]` to block shell access).
30    #[serde(default)]
31    pub tool_restrictions: Vec<String>,
32}
33
34impl WorkspaceProfile {
35    /// Effective memory namespace (falls back to workspace name).
36    pub fn effective_memory_namespace(&self) -> &str {
37        self.memory_namespace
38            .as_deref()
39            .unwrap_or(self.name.as_str())
40    }
41
42    /// Effective audit namespace (falls back to workspace name).
43    pub fn effective_audit_namespace(&self) -> &str {
44        self.audit_namespace
45            .as_deref()
46            .unwrap_or(self.name.as_str())
47    }
48
49    /// Returns true if the given tool name is restricted in this workspace.
50    pub fn is_tool_restricted(&self, tool_name: &str) -> bool {
51        self.tool_restrictions
52            .iter()
53            .any(|r| r.eq_ignore_ascii_case(tool_name))
54    }
55
56    /// Returns true if the given domain is allowed for this workspace.
57    /// An empty allowlist means all domains are allowed.
58    pub fn is_domain_allowed(&self, domain: &str) -> bool {
59        if self.allowed_domains.is_empty() {
60            return true;
61        }
62        let domain_lower = domain.to_ascii_lowercase();
63        self.allowed_domains
64            .iter()
65            .any(|d| domain_lower == d.to_ascii_lowercase())
66    }
67}
68
69/// Manages loading and switching between client workspace profiles.
70#[derive(Debug, Clone)]
71pub struct WorkspaceManager {
72    /// Base directory containing all workspace subdirectories.
73    workspaces_dir: PathBuf,
74    /// Loaded workspace profiles keyed by name.
75    profiles: HashMap<String, WorkspaceProfile>,
76    /// Currently active workspace name.
77    active: Option<String>,
78}
79
80impl WorkspaceManager {
81    /// Create a new workspace manager rooted at the given directory.
82    pub fn new(workspaces_dir: PathBuf) -> Self {
83        Self {
84            workspaces_dir,
85            profiles: HashMap::new(),
86            active: None,
87        }
88    }
89
90    /// Load all workspace profiles from disk.
91    ///
92    /// Each subdirectory of `workspaces_dir` that contains a `profile.toml`
93    /// is treated as a workspace.
94    pub async fn load_profiles(&mut self) -> Result<()> {
95        self.profiles.clear();
96
97        let dir = &self.workspaces_dir;
98        if !dir.exists() {
99            return Ok(());
100        }
101
102        let mut entries = tokio::fs::read_dir(dir)
103            .await
104            .with_context(|| format!("reading workspaces directory: {}", dir.display()))?;
105
106        while let Some(entry) = entries.next_entry().await? {
107            let path = entry.path();
108            if !path.is_dir() {
109                continue;
110            }
111            let profile_path = path.join("profile.toml");
112            if !profile_path.exists() {
113                continue;
114            }
115            match tokio::fs::read_to_string(&profile_path).await {
116                Ok(contents) => match toml::from_str::<WorkspaceProfile>(&contents) {
117                    Ok(profile) => {
118                        self.profiles.insert(profile.name.clone(), profile);
119                    }
120                    Err(e) => {
121                        tracing::warn!(
122                            "skipping malformed workspace profile {}: {e}",
123                            profile_path.display()
124                        );
125                    }
126                },
127                Err(e) => {
128                    tracing::warn!(
129                        "skipping unreadable workspace profile {}: {e}",
130                        profile_path.display()
131                    );
132                }
133            }
134        }
135
136        Ok(())
137    }
138
139    /// Switch to the named workspace. Returns an error if it does not exist.
140    pub fn switch(&mut self, name: &str) -> Result<&WorkspaceProfile> {
141        if !self.profiles.contains_key(name) {
142            bail!("workspace '{}' not found", name);
143        }
144        self.active = Some(name.to_string());
145        Ok(&self.profiles[name])
146    }
147
148    /// Get the currently active workspace profile, if any.
149    pub fn active_profile(&self) -> Option<&WorkspaceProfile> {
150        self.active
151            .as_deref()
152            .and_then(|name| self.profiles.get(name))
153    }
154
155    /// Get the active workspace name.
156    pub fn active_name(&self) -> Option<&str> {
157        self.active.as_deref()
158    }
159
160    /// List all loaded workspace names.
161    pub fn list(&self) -> Vec<&str> {
162        let mut names: Vec<&str> = self.profiles.keys().map(String::as_str).collect();
163        names.sort_unstable();
164        names
165    }
166
167    /// Get a workspace profile by name.
168    pub fn get(&self, name: &str) -> Option<&WorkspaceProfile> {
169        self.profiles.get(name)
170    }
171
172    /// Create a new workspace on disk and register it.
173    pub async fn create(&mut self, name: &str) -> Result<&WorkspaceProfile> {
174        if name.is_empty() {
175            bail!("workspace name must not be empty");
176        }
177        // Validate name: alphanumeric, hyphens, underscores only
178        if !name
179            .chars()
180            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
181        {
182            bail!(
183                "workspace name must contain only alphanumeric characters, hyphens, or underscores"
184            );
185        }
186        if self.profiles.contains_key(name) {
187            bail!("workspace '{}' already exists", name);
188        }
189
190        let ws_dir = self.workspaces_dir.join(name);
191        tokio::fs::create_dir_all(&ws_dir)
192            .await
193            .with_context(|| format!("creating workspace directory: {}", ws_dir.display()))?;
194
195        let profile = WorkspaceProfile {
196            name: name.to_string(),
197            allowed_domains: Vec::new(),
198            credential_profile: None,
199            memory_namespace: Some(name.to_string()),
200            audit_namespace: Some(name.to_string()),
201            tool_restrictions: Vec::new(),
202        };
203
204        let toml_str = toml::to_string_pretty(&profile).context("serializing workspace profile")?;
205        let profile_path = ws_dir.join("profile.toml");
206        tokio::fs::write(&profile_path, toml_str)
207            .await
208            .with_context(|| format!("writing workspace profile: {}", profile_path.display()))?;
209
210        self.profiles.insert(name.to_string(), profile);
211        Ok(&self.profiles[name])
212    }
213
214    /// Export a workspace profile as a sanitized TOML string (no secrets).
215    pub fn export(&self, name: &str) -> Result<String> {
216        let profile = self
217            .profiles
218            .get(name)
219            .with_context(|| format!("workspace '{}' not found", name))?;
220
221        // Create an export-safe copy with credential_profile redacted
222        let export = WorkspaceProfile {
223            credential_profile: profile
224                .credential_profile
225                .as_ref()
226                .map(|_| "***".to_string()),
227            ..profile.clone()
228        };
229
230        toml::to_string_pretty(&export).context("serializing workspace profile for export")
231    }
232
233    /// Directory for a specific workspace.
234    pub fn workspace_dir(&self, name: &str) -> PathBuf {
235        self.workspaces_dir.join(name)
236    }
237
238    /// Base workspaces directory.
239    pub fn workspaces_dir(&self) -> &Path {
240        &self.workspaces_dir
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use tempfile::TempDir;
248
249    fn sample_profile(name: &str) -> WorkspaceProfile {
250        WorkspaceProfile {
251            name: name.to_string(),
252            allowed_domains: vec!["example.com".to_string()],
253            credential_profile: Some("test-creds".to_string()),
254            memory_namespace: Some(format!("{name}_mem")),
255            audit_namespace: Some(format!("{name}_audit")),
256            tool_restrictions: vec!["shell".to_string()],
257        }
258    }
259
260    #[test]
261    fn workspace_profile_tool_restriction_check() {
262        let profile = sample_profile("client_a");
263        assert!(profile.is_tool_restricted("shell"));
264        assert!(profile.is_tool_restricted("Shell"));
265        assert!(!profile.is_tool_restricted("file_read"));
266    }
267
268    #[test]
269    fn workspace_profile_domain_allowlist_empty_allows_all() {
270        let mut profile = sample_profile("client_a");
271        profile.allowed_domains.clear();
272        assert!(profile.is_domain_allowed("anything.com"));
273    }
274
275    #[test]
276    fn workspace_profile_domain_allowlist_enforced() {
277        let profile = sample_profile("client_a");
278        assert!(profile.is_domain_allowed("example.com"));
279        assert!(!profile.is_domain_allowed("other.com"));
280    }
281
282    #[test]
283    fn workspace_profile_effective_namespaces() {
284        let profile = sample_profile("client_a");
285        assert_eq!(profile.effective_memory_namespace(), "client_a_mem");
286        assert_eq!(profile.effective_audit_namespace(), "client_a_audit");
287
288        let fallback = WorkspaceProfile {
289            name: "test_ws".to_string(),
290            memory_namespace: None,
291            audit_namespace: None,
292            ..sample_profile("test_ws")
293        };
294        assert_eq!(fallback.effective_memory_namespace(), "test_ws");
295        assert_eq!(fallback.effective_audit_namespace(), "test_ws");
296    }
297
298    #[tokio::test]
299    async fn workspace_manager_create_and_list() {
300        let tmp = TempDir::new().unwrap();
301        let mut mgr = WorkspaceManager::new(tmp.path().to_path_buf());
302
303        mgr.create("client_alpha").await.unwrap();
304        mgr.create("client_beta").await.unwrap();
305
306        let names = mgr.list();
307        assert_eq!(names, vec!["client_alpha", "client_beta"]);
308    }
309
310    #[tokio::test]
311    async fn workspace_manager_create_rejects_duplicate() {
312        let tmp = TempDir::new().unwrap();
313        let mut mgr = WorkspaceManager::new(tmp.path().to_path_buf());
314
315        mgr.create("client_a").await.unwrap();
316        let result = mgr.create("client_a").await;
317        assert!(result.is_err());
318    }
319
320    #[tokio::test]
321    async fn workspace_manager_create_rejects_invalid_name() {
322        let tmp = TempDir::new().unwrap();
323        let mut mgr = WorkspaceManager::new(tmp.path().to_path_buf());
324
325        assert!(mgr.create("").await.is_err());
326        assert!(mgr.create("bad name").await.is_err());
327        assert!(mgr.create("../escape").await.is_err());
328    }
329
330    #[tokio::test]
331    async fn workspace_manager_switch_and_active() {
332        let tmp = TempDir::new().unwrap();
333        let mut mgr = WorkspaceManager::new(tmp.path().to_path_buf());
334
335        mgr.create("ws_one").await.unwrap();
336        assert!(mgr.active_profile().is_none());
337
338        mgr.switch("ws_one").unwrap();
339        assert_eq!(mgr.active_name(), Some("ws_one"));
340        assert!(mgr.active_profile().is_some());
341    }
342
343    #[test]
344    fn workspace_manager_switch_nonexistent_fails() {
345        let mgr = WorkspaceManager::new(PathBuf::from("/tmp/nonexistent"));
346        let mut mgr = mgr;
347        assert!(mgr.switch("no_such_ws").is_err());
348    }
349
350    #[tokio::test]
351    async fn workspace_manager_load_profiles_from_disk() {
352        let tmp = TempDir::new().unwrap();
353        let mut mgr = WorkspaceManager::new(tmp.path().to_path_buf());
354
355        // Create a workspace via the manager
356        mgr.create("loaded_ws").await.unwrap();
357
358        // Create a fresh manager and load from disk
359        let mut mgr2 = WorkspaceManager::new(tmp.path().to_path_buf());
360        mgr2.load_profiles().await.unwrap();
361
362        assert_eq!(mgr2.list(), vec!["loaded_ws"]);
363        let profile = mgr2.get("loaded_ws").unwrap();
364        assert_eq!(profile.name, "loaded_ws");
365    }
366
367    #[tokio::test]
368    async fn workspace_manager_export_redacts_credentials() {
369        let tmp = TempDir::new().unwrap();
370        let mut mgr = WorkspaceManager::new(tmp.path().to_path_buf());
371        mgr.create("export_test").await.unwrap();
372
373        // Manually set a credential profile
374        if let Some(profile) = mgr.profiles.get_mut("export_test") {
375            profile.credential_profile = Some("secret-cred-id".to_string());
376        }
377
378        let exported = mgr.export("export_test").unwrap();
379        assert!(exported.contains("***"));
380        assert!(!exported.contains("secret-cred-id"));
381    }
382}