Skip to main content

clawft_core/workspace/
agent.rs

1//! Per-agent workspace isolation.
2//!
3//! Provides methods on [`WorkspaceManager`] for creating, deleting, and
4//! listing per-agent workspaces under `~/.clawft/agents/<agent_id>/`.
5//!
6//! Each agent workspace contains:
7//!
8//! ```text
9//! ~/.clawft/agents/<agent_id>/
10//!   SOUL.md          # Agent personality
11//!   AGENTS.md        # Agent capabilities
12//!   USER.md          # User preferences for this agent
13//!   config.toml      # Per-agent config overrides
14//!   sessions/        # Per-agent session store
15//!   memory/          # Per-agent memory namespace
16//!   skills/          # Per-agent skill overrides
17//!   tool_state/      # Per-plugin state
18//! ```
19//!
20//! Directories are created with `0700` permissions on Unix for security.
21//! Cross-agent sharing is done via symlinks.
22
23use std::path::{Path, PathBuf};
24
25use clawft_types::{ClawftError, Result};
26
27use super::WorkspaceManager;
28
29/// Subdirectories created inside each per-agent workspace.
30const AGENT_WORKSPACE_SUBDIRS: &[&str] = &[
31    "sessions",
32    "memory",
33    "skills",
34    "tool_state",
35];
36
37impl WorkspaceManager {
38    /// Resolve the agents directory root.
39    ///
40    /// For the default manager this is `~/.clawft/agents/`.
41    /// For a test manager the parent of the registry is the root.
42    pub(crate) fn agents_root(&self) -> PathBuf {
43        self.registry_path
44            .parent()
45            .unwrap_or(Path::new("."))
46            .join("agents")
47    }
48
49    /// Create or ensure a per-agent workspace exists.
50    ///
51    /// Idempotent: if the workspace already exists, returns its path
52    /// without modifying it. Creates the directory structure described
53    /// in the [module docs](self).
54    ///
55    /// Directories are created with `0700` permissions on Unix for security.
56    pub fn ensure_agent_workspace(&self, agent_id: &str) -> Result<PathBuf> {
57        validate_agent_id(agent_id)?;
58
59        let agent_dir = self.agents_root().join(agent_id);
60
61        // Create subdirectories.
62        for subdir in AGENT_WORKSPACE_SUBDIRS {
63            let dir = agent_dir.join(subdir);
64            std::fs::create_dir_all(&dir)?;
65            set_dir_permissions_0700(&dir);
66        }
67
68        // Set permissions on the agent root directory itself.
69        set_dir_permissions_0700(&agent_dir);
70
71        // Create template files (only if they don't already exist).
72        let files = [
73            ("SOUL.md", format!("# Agent: {agent_id}\n\nPersonality and directives for this agent.\n")),
74            ("AGENTS.md", format!("# Agent: {agent_id}\n\nCapabilities and tool access.\n")),
75            ("USER.md", format!("# Agent: {agent_id}\n\nUser preferences for this agent.\n")),
76            ("config.toml", format!("# Per-agent configuration overrides for {agent_id}\n")),
77        ];
78
79        for (filename, content) in &files {
80            let path = agent_dir.join(filename);
81            if !path.exists() {
82                std::fs::write(&path, content)?;
83            }
84        }
85
86        Ok(agent_dir)
87    }
88
89    /// Create a per-agent workspace from a template.
90    ///
91    /// If `template` is `None`, falls back to `~/.clawft/agents/default/` if
92    /// it exists, otherwise creates a bare workspace via
93    /// [`ensure_agent_workspace`](Self::ensure_agent_workspace).
94    pub fn create_agent_workspace(
95        &self,
96        agent_id: &str,
97        template: Option<&Path>,
98    ) -> Result<PathBuf> {
99        validate_agent_id(agent_id)?;
100
101        let agent_dir = self.agents_root().join(agent_id);
102        if agent_dir.exists() {
103            return Err(ClawftError::ConfigInvalid {
104                reason: format!("agent workspace already exists: {agent_id}"),
105            });
106        }
107
108        // Determine template source.
109        let template_dir = match template {
110            Some(p) => {
111                if p.is_dir() {
112                    Some(p.to_path_buf())
113                } else {
114                    return Err(ClawftError::ConfigInvalid {
115                        reason: format!(
116                            "template path is not a directory: {}",
117                            p.display()
118                        ),
119                    });
120                }
121            }
122            None => {
123                let default_template = self.agents_root().join("default");
124                if default_template.is_dir() {
125                    Some(default_template)
126                } else {
127                    None
128                }
129            }
130        };
131
132        if let Some(ref src) = template_dir {
133            // Copy template directory contents.
134            copy_dir_recursive(src, &agent_dir)?;
135            set_dir_permissions_0700(&agent_dir);
136        } else {
137            // No template: create bare workspace.
138            self.ensure_agent_workspace(agent_id)?;
139        }
140
141        Ok(agent_dir)
142    }
143
144    /// Delete a per-agent workspace by agent ID.
145    ///
146    /// Removes the directory and all its contents from disk.
147    pub fn delete_agent_workspace(&self, agent_id: &str) -> Result<()> {
148        validate_agent_id(agent_id)?;
149
150        let agent_dir = self.agents_root().join(agent_id);
151        if !agent_dir.exists() {
152            return Err(ClawftError::ConfigInvalid {
153                reason: format!("agent workspace not found: {agent_id}"),
154            });
155        }
156
157        std::fs::remove_dir_all(&agent_dir)?;
158        Ok(())
159    }
160
161    /// List all per-agent workspaces.
162    ///
163    /// Returns a sorted list of agent IDs found under the agents directory.
164    pub fn list_agent_workspaces(&self) -> Result<Vec<String>> {
165        let agents_dir = self.agents_root();
166        if !agents_dir.is_dir() {
167            return Ok(Vec::new());
168        }
169
170        let mut agent_ids: Vec<String> = std::fs::read_dir(&agents_dir)?
171            .filter_map(|entry| {
172                let entry = entry.ok()?;
173                if entry.file_type().ok()?.is_dir() {
174                    Some(entry.file_name().to_string_lossy().into_owned())
175                } else {
176                    None
177                }
178            })
179            .collect();
180
181        agent_ids.sort();
182        Ok(agent_ids)
183    }
184
185    /// Set up a cross-agent symlink for shared namespace access.
186    ///
187    /// Creates a symlink from `importer_id`'s memory directory to
188    /// `exporter_id`'s namespace directory. The symlink provides
189    /// read-only access by default.
190    ///
191    /// # Security
192    ///
193    /// - Validates both agent IDs to prevent path traversal.
194    /// - The exporter workspace must exist.
195    /// - The namespace directory must exist under the exporter.
196    pub fn link_shared_namespace(
197        &self,
198        exporter_id: &str,
199        importer_id: &str,
200        namespace: &str,
201    ) -> Result<PathBuf> {
202        validate_agent_id(exporter_id)?;
203        validate_agent_id(importer_id)?;
204        validate_agent_id(namespace)?; // reuse same validation for namespace
205
206        let exporter_ns = self
207            .agents_root()
208            .join(exporter_id)
209            .join("memory")
210            .join(namespace);
211
212        if !exporter_ns.is_dir() {
213            // Create the namespace directory if it doesn't exist yet.
214            std::fs::create_dir_all(&exporter_ns)?;
215        }
216
217        let importer_link = self
218            .agents_root()
219            .join(importer_id)
220            .join("memory")
221            .join(format!("{exporter_id}--{namespace}"));
222
223        // Ensure the importer's memory directory exists.
224        let importer_memory = self.agents_root().join(importer_id).join("memory");
225        std::fs::create_dir_all(&importer_memory)?;
226
227        // Validate the symlink target is within the agents directory.
228        let agents_root = self.agents_root();
229        let canonical_target = exporter_ns
230            .canonicalize()
231            .unwrap_or_else(|_| exporter_ns.clone());
232        let canonical_root = agents_root
233            .canonicalize()
234            .unwrap_or_else(|_| agents_root.clone());
235        if !canonical_target.starts_with(&canonical_root) {
236            return Err(ClawftError::ConfigInvalid {
237                reason: "symlink target escapes agents directory".into(),
238            });
239        }
240
241        // Remove existing symlink if present (re-link).
242        if importer_link.exists() || importer_link.is_symlink() {
243            std::fs::remove_file(&importer_link)?;
244        }
245
246        #[cfg(unix)]
247        std::os::unix::fs::symlink(&exporter_ns, &importer_link)?;
248
249        #[cfg(not(unix))]
250        {
251            // On non-Unix, fall back to directory junction or just error.
252            return Err(ClawftError::ConfigInvalid {
253                reason: "symlink-based sharing requires Unix".into(),
254            });
255        }
256
257        Ok(importer_link)
258    }
259}
260
261// ── Helpers ────────────────────────────────────────────────────────────
262
263/// Validate an agent ID to prevent path traversal attacks.
264///
265/// Agent IDs must be non-empty, contain only alphanumeric characters,
266/// hyphens, underscores, and dots, and must not start with a dot.
267pub(crate) fn validate_agent_id(agent_id: &str) -> Result<()> {
268    if agent_id.is_empty() {
269        return Err(ClawftError::ConfigInvalid {
270            reason: "agent ID cannot be empty".into(),
271        });
272    }
273    if agent_id.starts_with('.') {
274        return Err(ClawftError::ConfigInvalid {
275            reason: format!("agent ID must not start with dot: {agent_id}"),
276        });
277    }
278    if agent_id.contains('/')
279        || agent_id.contains('\\')
280        || agent_id.contains('\0')
281    {
282        return Err(ClawftError::ConfigInvalid {
283            reason: format!("agent ID contains invalid characters: {agent_id}"),
284        });
285    }
286    if agent_id == ".." {
287        return Err(ClawftError::ConfigInvalid {
288            reason: "agent ID cannot be '..'".into(),
289        });
290    }
291    Ok(())
292}
293
294/// Set directory permissions to 0700 on Unix (no-op on other platforms).
295pub(crate) fn set_dir_permissions_0700(path: &Path) {
296    #[cfg(unix)]
297    {
298        use std::os::unix::fs::PermissionsExt;
299        let _ = std::fs::set_permissions(
300            path,
301            std::fs::Permissions::from_mode(0o700),
302        );
303    }
304    #[cfg(not(unix))]
305    {
306        let _ = path; // suppress unused warning
307    }
308}
309
310/// Recursively copy a directory tree from `src` to `dst`.
311pub(crate) fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
312    std::fs::create_dir_all(dst)?;
313    for entry in std::fs::read_dir(src)? {
314        let entry = entry?;
315        let file_type = entry.file_type()?;
316        let src_path = entry.path();
317        let dst_path = dst.join(entry.file_name());
318
319        if file_type.is_dir() {
320            copy_dir_recursive(&src_path, &dst_path)?;
321        } else {
322            std::fs::copy(&src_path, &dst_path)?;
323        }
324    }
325    Ok(())
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use crate::workspace::tests::temp_registry;
332
333    #[test]
334    fn ensure_agent_workspace_creates_structure() {
335        let (dir, registry_path) = temp_registry("agent-ensure");
336        let wm = WorkspaceManager::with_registry_path(registry_path).unwrap();
337
338        let agent_dir = wm.ensure_agent_workspace("test-agent").unwrap();
339        assert!(agent_dir.is_dir());
340
341        for subdir in &["sessions", "memory", "skills", "tool_state"] {
342            assert!(
343                agent_dir.join(subdir).is_dir(),
344                "missing subdir: {subdir}"
345            );
346        }
347
348        for file in &["SOUL.md", "AGENTS.md", "USER.md", "config.toml"] {
349            assert!(agent_dir.join(file).exists(), "missing file: {file}");
350        }
351
352        let _ = std::fs::remove_dir_all(&dir);
353    }
354
355    #[test]
356    fn ensure_agent_workspace_is_idempotent() {
357        let (dir, registry_path) = temp_registry("agent-idempotent");
358        let wm = WorkspaceManager::with_registry_path(registry_path).unwrap();
359
360        let path1 = wm.ensure_agent_workspace("idempotent-agent").unwrap();
361        std::fs::write(path1.join("SOUL.md"), "custom content").unwrap();
362
363        let path2 = wm.ensure_agent_workspace("idempotent-agent").unwrap();
364        assert_eq!(path1, path2);
365
366        let content = std::fs::read_to_string(path2.join("SOUL.md")).unwrap();
367        assert_eq!(content, "custom content");
368
369        let _ = std::fs::remove_dir_all(&dir);
370    }
371
372    #[test]
373    fn delete_agent_workspace_removes_dir() {
374        let (dir, registry_path) = temp_registry("agent-delete");
375        let wm = WorkspaceManager::with_registry_path(registry_path).unwrap();
376
377        let agent_dir = wm.ensure_agent_workspace("del-agent").unwrap();
378        assert!(agent_dir.is_dir());
379
380        wm.delete_agent_workspace("del-agent").unwrap();
381        assert!(!agent_dir.exists());
382
383        let _ = std::fs::remove_dir_all(&dir);
384    }
385
386    #[test]
387    fn delete_nonexistent_agent_workspace_errors() {
388        let (dir, registry_path) = temp_registry("agent-delete-missing");
389        let wm = WorkspaceManager::with_registry_path(registry_path).unwrap();
390        assert!(wm.delete_agent_workspace("nonexistent").is_err());
391        let _ = std::fs::remove_dir_all(&dir);
392    }
393
394    #[test]
395    fn list_agent_workspaces_returns_sorted() {
396        let (dir, registry_path) = temp_registry("agent-list");
397        let wm = WorkspaceManager::with_registry_path(registry_path).unwrap();
398
399        assert!(wm.list_agent_workspaces().unwrap().is_empty());
400
401        wm.ensure_agent_workspace("charlie").unwrap();
402        wm.ensure_agent_workspace("alpha").unwrap();
403        wm.ensure_agent_workspace("bravo").unwrap();
404
405        let agents = wm.list_agent_workspaces().unwrap();
406        assert_eq!(agents, vec!["alpha", "bravo", "charlie"]);
407
408        let _ = std::fs::remove_dir_all(&dir);
409    }
410
411    #[test]
412    fn validate_agent_id_rejects_traversal() {
413        assert!(validate_agent_id("").is_err());
414        assert!(validate_agent_id("..").is_err());
415        assert!(validate_agent_id(".hidden").is_err());
416        assert!(validate_agent_id("../escape").is_err());
417        assert!(validate_agent_id("path/traversal").is_err());
418        assert!(validate_agent_id("back\\slash").is_err());
419        // Valid IDs
420        assert!(validate_agent_id("agent-1").is_ok());
421        assert!(validate_agent_id("my_agent_2").is_ok());
422        assert!(validate_agent_id("Agent.v3").is_ok());
423    }
424
425    #[test]
426    fn create_agent_workspace_from_template() {
427        let (dir, registry_path) = temp_registry("agent-template");
428        let wm = WorkspaceManager::with_registry_path(registry_path).unwrap();
429
430        let template = dir.join("my-template");
431        std::fs::create_dir_all(template.join("skills")).unwrap();
432        std::fs::write(template.join("SOUL.md"), "template soul").unwrap();
433        std::fs::write(
434            template.join("skills/custom.md"),
435            "skill data",
436        )
437        .unwrap();
438
439        let agent_dir = wm
440            .create_agent_workspace("from-template", Some(&template))
441            .unwrap();
442
443        assert_eq!(
444            std::fs::read_to_string(agent_dir.join("SOUL.md")).unwrap(),
445            "template soul"
446        );
447        assert_eq!(
448            std::fs::read_to_string(agent_dir.join("skills/custom.md")).unwrap(),
449            "skill data"
450        );
451
452        let _ = std::fs::remove_dir_all(&dir);
453    }
454
455    #[test]
456    fn create_agent_workspace_rejects_duplicate() {
457        let (dir, registry_path) = temp_registry("agent-dup");
458        let wm = WorkspaceManager::with_registry_path(registry_path).unwrap();
459
460        wm.ensure_agent_workspace("dup-agent").unwrap();
461        assert!(wm.create_agent_workspace("dup-agent", None).is_err());
462
463        let _ = std::fs::remove_dir_all(&dir);
464    }
465
466    #[cfg(unix)]
467    #[test]
468    fn agent_workspace_has_0700_permissions() {
469        use std::os::unix::fs::PermissionsExt;
470
471        let (dir, registry_path) = temp_registry("agent-perms");
472        let wm = WorkspaceManager::with_registry_path(registry_path).unwrap();
473
474        let agent_dir = wm.ensure_agent_workspace("perms-agent").unwrap();
475        let mode = std::fs::metadata(&agent_dir)
476            .unwrap()
477            .permissions()
478            .mode()
479            & 0o777;
480        assert_eq!(mode, 0o700, "agent dir should have 0700 permissions");
481
482        let _ = std::fs::remove_dir_all(&dir);
483    }
484
485    #[cfg(unix)]
486    #[test]
487    fn cross_agent_symlink_sharing() {
488        let (dir, registry_path) = temp_registry("agent-symlink");
489        let wm = WorkspaceManager::with_registry_path(registry_path).unwrap();
490
491        wm.ensure_agent_workspace("exporter").unwrap();
492        wm.ensure_agent_workspace("importer").unwrap();
493
494        let exporter_ns =
495            wm.agents_root().join("exporter/memory/project-ctx");
496        std::fs::create_dir_all(&exporter_ns).unwrap();
497        std::fs::write(exporter_ns.join("data.txt"), "shared data").unwrap();
498
499        let link = wm
500            .link_shared_namespace("exporter", "importer", "project-ctx")
501            .unwrap();
502
503        assert!(link.is_symlink());
504
505        let content =
506            std::fs::read_to_string(link.join("data.txt")).unwrap();
507        assert_eq!(content, "shared data");
508
509        let _ = std::fs::remove_dir_all(&dir);
510    }
511}