Skip to main content

zeph_subagent/
memory.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Persistent per-agent memory backed by `MEMORY.md` files on the local filesystem.
5//!
6//! Each sub-agent with a [`MemoryScope`] gets an isolated directory on first spawn.
7//! The first 200 lines of `MEMORY.md` are injected into the system prompt so the agent
8//! can recall information across sessions.
9//!
10//! Security guarantees:
11//! - Directory paths are validated against `AGENT_NAME_RE`
12//!   to prevent path traversal.
13//! - `MEMORY.md` is canonicalized and boundary-checked before reading (symlink escape guard).
14//! - Files larger than 256 KiB or containing null bytes are rejected.
15//! - `<agent-memory>` tags in file content are escaped to prevent prompt injection.
16
17use std::path::{Path, PathBuf};
18use std::sync::LazyLock;
19
20use regex::Regex;
21
22use super::def::{AGENT_NAME_RE, MemoryScope};
23use super::error::SubAgentError;
24
25/// Case-insensitive regex matching any variant of `<agent-memory>` or `</agent-memory>` tags.
26///
27/// Handles uppercase, mixed-case, and whitespace variants to prevent prompt injection bypass.
28static MEMORY_TAG_RE: LazyLock<Regex> =
29    LazyLock::new(|| Regex::new(r"(?i)</?(\s*)agent-memory(\s*)>").unwrap());
30
31/// Maximum allowed size for MEMORY.md (256 KiB — same cap as instruction files).
32const MAX_MEMORY_SIZE: u64 = 256 * 1024;
33
34/// Number of lines to inject from MEMORY.md into the system prompt.
35const MEMORY_INJECT_LINES: usize = 200;
36
37/// Resolve the memory directory path for a given scope and agent name.
38///
39/// Agent name is validated against the same regex enforced in `parse_with_path`.
40/// This prevents path traversal via crafted names (e.g., `../../../etc`).
41///
42/// | Scope | Directory |
43/// |-------|-----------|
44/// | `User` | `~/.zeph/agent-memory/<name>/` |
45/// | `Project` | `.zeph/agent-memory/<name>/` (relative to CWD) |
46/// | `Local` | `.zeph/agent-memory-local/<name>/` (relative to CWD) |
47///
48/// # Errors
49///
50/// Returns [`SubAgentError::Invalid`] if the agent name fails validation.
51/// Returns [`SubAgentError::Memory`] if the home directory is unavailable (`User` scope).
52///
53/// # Examples
54///
55/// ```rust,no_run
56/// use zeph_subagent::memory::resolve_memory_dir;
57/// use zeph_config::MemoryScope;
58///
59/// // Path traversal names are rejected.
60/// assert!(resolve_memory_dir(MemoryScope::Project, "../etc").is_err());
61/// // Valid names produce a usable path (relative to the current working directory).
62/// let path = resolve_memory_dir(MemoryScope::Project, "my-agent").unwrap();
63/// assert!(path.ends_with(".zeph/agent-memory/my-agent"));
64/// ```
65pub fn resolve_memory_dir(scope: MemoryScope, agent_name: &str) -> Result<PathBuf, SubAgentError> {
66    if !AGENT_NAME_RE.is_match(agent_name) {
67        return Err(SubAgentError::Invalid(format!(
68            "agent name '{agent_name}' is not valid for memory directory (must match \
69             ^[a-zA-Z0-9][a-zA-Z0-9_-]{{0,63}}$)"
70        )));
71    }
72
73    let dir = match scope {
74        MemoryScope::User => {
75            let home = dirs::home_dir().ok_or_else(|| SubAgentError::Memory {
76                name: agent_name.to_owned(),
77                reason: "home directory unavailable".to_owned(),
78            })?;
79            home.join(".zeph").join("agent-memory").join(agent_name)
80        }
81        MemoryScope::Project => {
82            let cwd = std::env::current_dir().map_err(|e| SubAgentError::Memory {
83                name: agent_name.to_owned(),
84                reason: format!("cannot determine working directory: {e}"),
85            })?;
86            cwd.join(".zeph").join("agent-memory").join(agent_name)
87        }
88        MemoryScope::Local => {
89            let cwd = std::env::current_dir().map_err(|e| SubAgentError::Memory {
90                name: agent_name.to_owned(),
91                reason: format!("cannot determine working directory: {e}"),
92            })?;
93            cwd.join(".zeph")
94                .join("agent-memory-local")
95                .join(agent_name)
96        }
97        _ => {
98            let home = dirs::home_dir().ok_or_else(|| SubAgentError::Memory {
99                name: agent_name.to_owned(),
100                reason: "home directory unavailable".to_owned(),
101            })?;
102            home.join(".zeph").join("agent-memory").join(agent_name)
103        }
104    };
105    Ok(dir)
106}
107
108/// Ensure the memory directory exists, creating it if necessary.
109///
110/// Returns the absolute path to the directory. Logs at `debug` level when the
111/// directory is newly created.
112///
113/// # Errors
114///
115/// Returns [`SubAgentError::Invalid`] if the agent name is invalid.
116/// Returns [`SubAgentError::Memory`] if the directory cannot be created.
117#[tracing::instrument(name = "subagent.memory.ensure_memory_dir", skip_all)]
118pub async fn ensure_memory_dir(
119    scope: MemoryScope,
120    agent_name: &str,
121) -> Result<PathBuf, SubAgentError> {
122    let dir = resolve_memory_dir(scope, agent_name)?;
123    // create_dir_all is idempotent — no need for a prior exists() check (REV-MED-02).
124    tokio::fs::create_dir_all(&dir)
125        .await
126        .map_err(|e| SubAgentError::Memory {
127            name: agent_name.to_owned(),
128            reason: format!("cannot create memory directory '{}': {e}", dir.display()),
129        })?;
130    tracing::debug!(
131        agent = agent_name,
132        scope = ?scope,
133        path = %dir.display(),
134        "ensured agent memory directory"
135    );
136
137    // Warn for Local scope if .gitignore likely does not cover the directory.
138    if scope == MemoryScope::Local {
139        check_gitignore_for_local(&dir).await;
140    }
141
142    Ok(dir)
143}
144
145/// Reads `MEMORY.md` from the given directory and returns the first 200 lines.
146///
147/// Returns `None` if the file does not exist or is empty.
148///
149/// Security:
150/// - Canonicalizes the path and verifies it stays within `dir` (symlink boundary).
151/// - Opens the canonical path after the boundary check (no TOCTOU window).
152/// - Rejects files larger than 256 KiB.
153/// - Rejects files containing null bytes.
154#[tracing::instrument(name = "subagent.memory.load_memory_content", skip_all)]
155pub async fn load_memory_content(dir: &Path) -> Option<String> {
156    let memory_path = dir.join("MEMORY.md");
157
158    // Canonicalize to resolve any symlinks before opening.
159    let canonical = tokio::fs::canonicalize(&memory_path).await.ok()?;
160
161    // Boundary check: MEMORY.md must be within the memory directory.
162    // REV-LOW-01: canonicalize dir separately (can't derive from canonical — symlink
163    // target's parent differs from the original dir when symlink escapes boundary).
164    let canonical_dir = tokio::fs::canonicalize(dir).await.ok()?;
165    if !canonical.starts_with(&canonical_dir) {
166        tracing::warn!(
167            path = %canonical.display(),
168            boundary = %canonical_dir.display(),
169            "MEMORY.md escapes memory directory boundary via symlink, skipping"
170        );
171        return None;
172    }
173
174    // Stat the canonical path before reading to check size and file type.
175    let meta = tokio::fs::metadata(&canonical).await.ok()?;
176
177    if !meta.is_file() {
178        return None;
179    }
180    if meta.len() > MAX_MEMORY_SIZE {
181        tracing::warn!(
182            path = %canonical.display(),
183            size = meta.len(),
184            limit = MAX_MEMORY_SIZE,
185            "MEMORY.md exceeds 256 KiB size limit, skipping"
186        );
187        return None;
188    }
189
190    let content = tokio::fs::read_to_string(&canonical).await.ok()?;
191
192    // Security: reject files with null bytes (potential binary or injection attack).
193    if content.contains('\0') {
194        tracing::warn!(
195            path = %canonical.display(),
196            "MEMORY.md contains null bytes, skipping"
197        );
198        return None;
199    }
200
201    if content.trim().is_empty() {
202        return None;
203    }
204
205    // Truncate to the first MEMORY_INJECT_LINES lines without full Vec allocation (REV-MED-01).
206    let mut line_count = 0usize;
207    let mut byte_offset = 0usize;
208    let mut truncated = false;
209    for line in content.lines() {
210        line_count += 1;
211        if line_count > MEMORY_INJECT_LINES {
212            truncated = true;
213            break;
214        }
215        byte_offset += line.len() + 1; // +1 for newline
216    }
217
218    let result = if truncated {
219        let head = content[..byte_offset.min(content.len())].trim_end_matches('\n');
220        format!(
221            "{head}\n\n[... truncated at {MEMORY_INJECT_LINES} lines. \
222             See full file at {}]",
223            dir.join("MEMORY.md").display()
224        )
225    } else {
226        content
227    };
228
229    Some(result)
230}
231
232/// Escape `<agent-memory>` and `</agent-memory>` tags from memory content.
233///
234/// Handles case variations (`</AGENT-MEMORY>`, `</Agent-Memory >`) via case-insensitive
235/// regex. Prevents prompt injection: an agent writing the closing tag to MEMORY.md would
236/// otherwise escape the `<agent-memory>` wrapper and inject arbitrary system prompt text.
237///
238/// Trust model note: MEMORY.md is written by the agent itself, unlike user-written
239/// instruction files. Agent-written content requires stricter escaping.
240#[must_use]
241pub fn escape_memory_content(content: &str) -> String {
242    MEMORY_TAG_RE
243        .replace_all(content, "<\\/$1agent-memory$2>")
244        .into_owned()
245}
246
247/// Check if `.zeph/agent-memory-local/` appears in `.gitignore` and warn if not.
248///
249/// This is best-effort — only checks the project-root `.gitignore`.
250async fn check_gitignore_for_local(memory_dir: &Path) {
251    // Collect candidate .gitignore paths (up to 5 levels up) before any I/O so
252    // we avoid holding path references across await points.
253    let mut candidates: Vec<std::path::PathBuf> = Vec::with_capacity(5);
254    let mut current = memory_dir;
255    for _ in 0..5 {
256        let Some(parent) = current.parent() else {
257            break;
258        };
259        current = parent;
260        candidates.push(current.join(".gitignore"));
261    }
262
263    for gitignore in candidates {
264        if !tokio::fs::try_exists(&gitignore).await.unwrap_or(false) {
265            continue;
266        }
267        if tokio::fs::read_to_string(&gitignore)
268            .await
269            .is_ok_and(|c| c.contains("agent-memory-local"))
270        {
271            return;
272        }
273        tracing::warn!(
274            "local agent memory directory is not in .gitignore — \
275             sensitive data may be committed. Add '.zeph/agent-memory-local/' to .gitignore"
276        );
277        return;
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    #![allow(clippy::format_collect)]
284    use std::assert_matches;
285
286    use serial_test::serial;
287
288    use super::*;
289
290    // ── resolve_memory_dir ────────────────────────────────────────────────────
291    //
292    // `Project`/`Local` scope reads the real process CWD (`std::env::current_dir`), which is
293    // process-wide, not per-thread — every test below that reaches that code path, whether or
294    // not it mutates the CWD itself, must share the crate's single (default, unnamed) `#[serial]`
295    // group with `ensure_*` below and `cwd_guard.rs`'s tests, or it can observe a directory a
296    // concurrent test has just deleted (issue #6686).
297
298    #[test]
299    #[serial]
300    fn resolve_project_scope_returns_correct_path() {
301        let dir = resolve_memory_dir(MemoryScope::Project, "my-agent").unwrap();
302        assert!(dir.ends_with(".zeph/agent-memory/my-agent"));
303    }
304
305    #[test]
306    #[serial]
307    fn resolve_local_scope_returns_correct_path() {
308        let dir = resolve_memory_dir(MemoryScope::Local, "my-agent").unwrap();
309        assert!(dir.ends_with(".zeph/agent-memory-local/my-agent"));
310    }
311
312    #[test]
313    fn resolve_user_scope_returns_home_path() {
314        if dirs::home_dir().is_none() {
315            return; // Skip in environments without home dir.
316        }
317        let dir = resolve_memory_dir(MemoryScope::User, "my-agent").unwrap();
318        assert!(dir.ends_with(".zeph/agent-memory/my-agent"));
319        assert!(dir.starts_with(dirs::home_dir().unwrap()));
320    }
321
322    #[test]
323    fn resolve_rejects_path_traversal_name() {
324        let err = resolve_memory_dir(MemoryScope::Project, "../etc/passwd").unwrap_err();
325        assert_matches!(err, SubAgentError::Invalid(_));
326    }
327
328    #[test]
329    fn resolve_rejects_slash_in_name() {
330        let err = resolve_memory_dir(MemoryScope::Project, "a/b").unwrap_err();
331        assert_matches!(err, SubAgentError::Invalid(_));
332    }
333
334    #[test]
335    fn resolve_rejects_empty_name() {
336        let err = resolve_memory_dir(MemoryScope::Project, "").unwrap_err();
337        assert_matches!(err, SubAgentError::Invalid(_));
338    }
339
340    #[test]
341    fn resolve_rejects_whitespace_only_name() {
342        let err = resolve_memory_dir(MemoryScope::Project, "   ").unwrap_err();
343        assert_matches!(err, SubAgentError::Invalid(_));
344    }
345
346    #[test]
347    #[serial]
348    fn resolve_accepts_single_char_name() {
349        resolve_memory_dir(MemoryScope::Project, "a").unwrap();
350    }
351
352    #[test]
353    #[serial]
354    fn resolve_accepts_64_char_name() {
355        let name = "a".repeat(64);
356        resolve_memory_dir(MemoryScope::Project, &name).unwrap();
357    }
358
359    #[test]
360    fn resolve_rejects_65_char_name() {
361        let name = "a".repeat(65);
362        let err = resolve_memory_dir(MemoryScope::Project, &name).unwrap_err();
363        assert_matches!(err, SubAgentError::Invalid(_));
364    }
365
366    #[test]
367    fn resolve_rejects_unicode_cyrillic() {
368        // Cyrillic 'а' (U+0430) looks like Latin 'a' but is not ASCII.
369        let err = resolve_memory_dir(MemoryScope::Project, "аgent").unwrap_err();
370        assert_matches!(err, SubAgentError::Invalid(_));
371    }
372
373    #[test]
374    fn resolve_rejects_fullwidth_slash() {
375        // Full-width solidus U+FF0F.
376        let err = resolve_memory_dir(MemoryScope::Project, "a\u{FF0F}b").unwrap_err();
377        assert_matches!(err, SubAgentError::Invalid(_));
378    }
379
380    // ── ensure_memory_dir ────────────────────────────────────────────────────
381
382    #[tokio::test]
383    #[serial]
384    async fn ensure_creates_directory_for_project_scope() {
385        let tmp = tempfile::tempdir().unwrap();
386        let _cwd = crate::cwd_guard::TestCwdGuard::enter(tmp.path());
387
388        let result = ensure_memory_dir(MemoryScope::Project, "test-agent")
389            .await
390            .unwrap();
391        assert!(result.exists());
392        assert!(result.ends_with(".zeph/agent-memory/test-agent"));
393    }
394
395    #[tokio::test]
396    #[serial]
397    async fn ensure_idempotent_when_directory_exists() {
398        let tmp = tempfile::tempdir().unwrap();
399        let _cwd = crate::cwd_guard::TestCwdGuard::enter(tmp.path());
400
401        let dir1 = ensure_memory_dir(MemoryScope::Project, "idempotent-agent")
402            .await
403            .unwrap();
404        let dir2 = ensure_memory_dir(MemoryScope::Project, "idempotent-agent")
405            .await
406            .unwrap();
407        assert_eq!(dir1, dir2);
408    }
409
410    // ── load_memory_content ───────────────────────────────────────────────────
411
412    #[tokio::test]
413    async fn load_returns_none_when_no_file() {
414        let tmp = tempfile::tempdir().unwrap();
415        assert!(load_memory_content(tmp.path()).await.is_none());
416    }
417
418    #[tokio::test]
419    async fn load_returns_content_when_file_exists() {
420        let tmp = tempfile::tempdir().unwrap();
421        std::fs::write(tmp.path().join("MEMORY.md"), "# Notes\nkey: value\n").unwrap();
422        let content = load_memory_content(tmp.path()).await.unwrap();
423        assert!(content.contains("key: value"));
424    }
425
426    #[tokio::test]
427    async fn load_truncates_at_200_lines() {
428        let tmp = tempfile::tempdir().unwrap();
429        let mut lines = String::new();
430        for i in 0..300 {
431            use std::fmt::Write as _;
432            writeln!(&mut lines, "line {i}").unwrap();
433        }
434        std::fs::write(tmp.path().join("MEMORY.md"), &lines).unwrap();
435        let content = load_memory_content(tmp.path()).await.unwrap();
436        let line_count = content.lines().count();
437        // Truncated content has 200 data lines + 1 truncation marker line.
438        assert!(line_count <= 202, "expected <= 202 lines, got {line_count}");
439        assert!(content.contains("truncated at 200 lines"));
440    }
441
442    #[tokio::test]
443    async fn load_rejects_null_bytes() {
444        let tmp = tempfile::tempdir().unwrap();
445        std::fs::write(tmp.path().join("MEMORY.md"), "valid\0content").unwrap();
446        assert!(load_memory_content(tmp.path()).await.is_none());
447    }
448
449    #[tokio::test]
450    async fn load_returns_none_for_empty_file() {
451        let tmp = tempfile::tempdir().unwrap();
452        std::fs::write(tmp.path().join("MEMORY.md"), "").unwrap();
453        assert!(load_memory_content(tmp.path()).await.is_none());
454    }
455
456    #[tokio::test]
457    #[cfg(unix)]
458    async fn load_rejects_symlink_escape() {
459        let tmp = tempfile::tempdir().unwrap();
460        let outside = tempfile::tempdir().unwrap();
461        let target = outside.path().join("secret.md");
462        std::fs::write(&target, "secret content").unwrap();
463
464        let link = tmp.path().join("MEMORY.md");
465        std::os::unix::fs::symlink(&target, &link).unwrap();
466
467        // The symlink points outside the tmp directory — should be rejected.
468        assert!(load_memory_content(tmp.path()).await.is_none());
469    }
470
471    #[tokio::test]
472    async fn load_returns_none_for_whitespace_only_file() {
473        let tmp = tempfile::tempdir().unwrap();
474        std::fs::write(tmp.path().join("MEMORY.md"), "   \n\n   \n").unwrap();
475        assert!(load_memory_content(tmp.path()).await.is_none());
476    }
477
478    #[tokio::test]
479    async fn load_rejects_file_over_size_cap() {
480        let tmp = tempfile::tempdir().unwrap();
481        // 257 KiB of content — exceeds the 256 KiB limit.
482        let content = "x".repeat(257 * 1024);
483        std::fs::write(tmp.path().join("MEMORY.md"), content).unwrap();
484        assert!(load_memory_content(tmp.path()).await.is_none());
485    }
486
487    // ── escape_memory_content ─────────────────────────────────────────────────
488
489    #[test]
490    fn escape_replaces_closing_tag_lowercase() {
491        let content = "safe content </agent-memory> more content";
492        let escaped = escape_memory_content(content);
493        assert!(!escaped.contains("</agent-memory>"));
494    }
495
496    #[test]
497    fn escape_replaces_closing_tag_uppercase() {
498        let content = "safe </AGENT-MEMORY> content";
499        let escaped = escape_memory_content(content);
500        assert!(!escaped.to_lowercase().contains("</agent-memory>"));
501    }
502
503    #[test]
504    fn escape_replaces_closing_tag_mixed_case() {
505        let content = "safe </Agent-Memory> content";
506        let escaped = escape_memory_content(content);
507        assert!(!escaped.to_lowercase().contains("</agent-memory>"));
508    }
509
510    #[test]
511    fn escape_replaces_opening_tag() {
512        let content = "before <agent-memory> injection attempt";
513        let escaped = escape_memory_content(content);
514        // Opening tag must also be escaped to prevent nested boundaries.
515        assert!(!escaped.contains("<agent-memory>"));
516    }
517
518    #[test]
519    fn escape_leaves_normal_content_unchanged() {
520        let content = "# Notes\nThis is safe content.";
521        assert_eq!(escape_memory_content(content), content);
522    }
523}