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 super::*;
287
288    // ── resolve_memory_dir ────────────────────────────────────────────────────
289
290    #[test]
291    fn resolve_project_scope_returns_correct_path() {
292        let dir = resolve_memory_dir(MemoryScope::Project, "my-agent").unwrap();
293        assert!(dir.ends_with(".zeph/agent-memory/my-agent"));
294    }
295
296    #[test]
297    fn resolve_local_scope_returns_correct_path() {
298        let dir = resolve_memory_dir(MemoryScope::Local, "my-agent").unwrap();
299        assert!(dir.ends_with(".zeph/agent-memory-local/my-agent"));
300    }
301
302    #[test]
303    fn resolve_user_scope_returns_home_path() {
304        if dirs::home_dir().is_none() {
305            return; // Skip in environments without home dir.
306        }
307        let dir = resolve_memory_dir(MemoryScope::User, "my-agent").unwrap();
308        assert!(dir.ends_with(".zeph/agent-memory/my-agent"));
309        assert!(dir.starts_with(dirs::home_dir().unwrap()));
310    }
311
312    #[test]
313    fn resolve_rejects_path_traversal_name() {
314        let err = resolve_memory_dir(MemoryScope::Project, "../etc/passwd").unwrap_err();
315        assert_matches!(err, SubAgentError::Invalid(_));
316    }
317
318    #[test]
319    fn resolve_rejects_slash_in_name() {
320        let err = resolve_memory_dir(MemoryScope::Project, "a/b").unwrap_err();
321        assert_matches!(err, SubAgentError::Invalid(_));
322    }
323
324    #[test]
325    fn resolve_rejects_empty_name() {
326        let err = resolve_memory_dir(MemoryScope::Project, "").unwrap_err();
327        assert_matches!(err, SubAgentError::Invalid(_));
328    }
329
330    #[test]
331    fn resolve_rejects_whitespace_only_name() {
332        let err = resolve_memory_dir(MemoryScope::Project, "   ").unwrap_err();
333        assert_matches!(err, SubAgentError::Invalid(_));
334    }
335
336    #[test]
337    fn resolve_accepts_single_char_name() {
338        resolve_memory_dir(MemoryScope::Project, "a").unwrap();
339    }
340
341    #[test]
342    fn resolve_accepts_64_char_name() {
343        let name = "a".repeat(64);
344        resolve_memory_dir(MemoryScope::Project, &name).unwrap();
345    }
346
347    #[test]
348    fn resolve_rejects_65_char_name() {
349        let name = "a".repeat(65);
350        let err = resolve_memory_dir(MemoryScope::Project, &name).unwrap_err();
351        assert_matches!(err, SubAgentError::Invalid(_));
352    }
353
354    #[test]
355    fn resolve_rejects_unicode_cyrillic() {
356        // Cyrillic 'а' (U+0430) looks like Latin 'a' but is not ASCII.
357        let err = resolve_memory_dir(MemoryScope::Project, "аgent").unwrap_err();
358        assert_matches!(err, SubAgentError::Invalid(_));
359    }
360
361    #[test]
362    fn resolve_rejects_fullwidth_slash() {
363        // Full-width solidus U+FF0F.
364        let err = resolve_memory_dir(MemoryScope::Project, "a\u{FF0F}b").unwrap_err();
365        assert_matches!(err, SubAgentError::Invalid(_));
366    }
367
368    // ── ensure_memory_dir ────────────────────────────────────────────────────
369
370    #[tokio::test]
371    async fn ensure_creates_directory_for_project_scope() {
372        let tmp = tempfile::tempdir().unwrap();
373        let orig_dir = std::env::current_dir().unwrap();
374        std::env::set_current_dir(tmp.path()).unwrap();
375
376        let result = ensure_memory_dir(MemoryScope::Project, "test-agent")
377            .await
378            .unwrap();
379        assert!(result.exists());
380        assert!(result.ends_with(".zeph/agent-memory/test-agent"));
381
382        std::env::set_current_dir(orig_dir).unwrap();
383    }
384
385    #[tokio::test]
386    async fn ensure_idempotent_when_directory_exists() {
387        let tmp = tempfile::tempdir().unwrap();
388        let orig_dir = std::env::current_dir().unwrap();
389        std::env::set_current_dir(tmp.path()).unwrap();
390
391        let dir1 = ensure_memory_dir(MemoryScope::Project, "idempotent-agent")
392            .await
393            .unwrap();
394        let dir2 = ensure_memory_dir(MemoryScope::Project, "idempotent-agent")
395            .await
396            .unwrap();
397        assert_eq!(dir1, dir2);
398
399        std::env::set_current_dir(orig_dir).unwrap();
400    }
401
402    // ── load_memory_content ───────────────────────────────────────────────────
403
404    #[tokio::test]
405    async fn load_returns_none_when_no_file() {
406        let tmp = tempfile::tempdir().unwrap();
407        assert!(load_memory_content(tmp.path()).await.is_none());
408    }
409
410    #[tokio::test]
411    async fn load_returns_content_when_file_exists() {
412        let tmp = tempfile::tempdir().unwrap();
413        std::fs::write(tmp.path().join("MEMORY.md"), "# Notes\nkey: value\n").unwrap();
414        let content = load_memory_content(tmp.path()).await.unwrap();
415        assert!(content.contains("key: value"));
416    }
417
418    #[tokio::test]
419    async fn load_truncates_at_200_lines() {
420        let tmp = tempfile::tempdir().unwrap();
421        let mut lines = String::new();
422        for i in 0..300 {
423            use std::fmt::Write as _;
424            writeln!(&mut lines, "line {i}").unwrap();
425        }
426        std::fs::write(tmp.path().join("MEMORY.md"), &lines).unwrap();
427        let content = load_memory_content(tmp.path()).await.unwrap();
428        let line_count = content.lines().count();
429        // Truncated content has 200 data lines + 1 truncation marker line.
430        assert!(line_count <= 202, "expected <= 202 lines, got {line_count}");
431        assert!(content.contains("truncated at 200 lines"));
432    }
433
434    #[tokio::test]
435    async fn load_rejects_null_bytes() {
436        let tmp = tempfile::tempdir().unwrap();
437        std::fs::write(tmp.path().join("MEMORY.md"), "valid\0content").unwrap();
438        assert!(load_memory_content(tmp.path()).await.is_none());
439    }
440
441    #[tokio::test]
442    async fn load_returns_none_for_empty_file() {
443        let tmp = tempfile::tempdir().unwrap();
444        std::fs::write(tmp.path().join("MEMORY.md"), "").unwrap();
445        assert!(load_memory_content(tmp.path()).await.is_none());
446    }
447
448    #[tokio::test]
449    #[cfg(unix)]
450    async fn load_rejects_symlink_escape() {
451        let tmp = tempfile::tempdir().unwrap();
452        let outside = tempfile::tempdir().unwrap();
453        let target = outside.path().join("secret.md");
454        std::fs::write(&target, "secret content").unwrap();
455
456        let link = tmp.path().join("MEMORY.md");
457        std::os::unix::fs::symlink(&target, &link).unwrap();
458
459        // The symlink points outside the tmp directory — should be rejected.
460        assert!(load_memory_content(tmp.path()).await.is_none());
461    }
462
463    #[tokio::test]
464    async fn load_returns_none_for_whitespace_only_file() {
465        let tmp = tempfile::tempdir().unwrap();
466        std::fs::write(tmp.path().join("MEMORY.md"), "   \n\n   \n").unwrap();
467        assert!(load_memory_content(tmp.path()).await.is_none());
468    }
469
470    #[tokio::test]
471    async fn load_rejects_file_over_size_cap() {
472        let tmp = tempfile::tempdir().unwrap();
473        // 257 KiB of content — exceeds the 256 KiB limit.
474        let content = "x".repeat(257 * 1024);
475        std::fs::write(tmp.path().join("MEMORY.md"), content).unwrap();
476        assert!(load_memory_content(tmp.path()).await.is_none());
477    }
478
479    // ── escape_memory_content ─────────────────────────────────────────────────
480
481    #[test]
482    fn escape_replaces_closing_tag_lowercase() {
483        let content = "safe content </agent-memory> more content";
484        let escaped = escape_memory_content(content);
485        assert!(!escaped.contains("</agent-memory>"));
486    }
487
488    #[test]
489    fn escape_replaces_closing_tag_uppercase() {
490        let content = "safe </AGENT-MEMORY> content";
491        let escaped = escape_memory_content(content);
492        assert!(!escaped.to_lowercase().contains("</agent-memory>"));
493    }
494
495    #[test]
496    fn escape_replaces_closing_tag_mixed_case() {
497        let content = "safe </Agent-Memory> content";
498        let escaped = escape_memory_content(content);
499        assert!(!escaped.to_lowercase().contains("</agent-memory>"));
500    }
501
502    #[test]
503    fn escape_replaces_opening_tag() {
504        let content = "before <agent-memory> injection attempt";
505        let escaped = escape_memory_content(content);
506        // Opening tag must also be escaped to prevent nested boundaries.
507        assert!(!escaped.contains("<agent-memory>"));
508    }
509
510    #[test]
511    fn escape_leaves_normal_content_unchanged() {
512        let content = "# Notes\nThis is safe content.";
513        assert_eq!(escape_memory_content(content), content);
514    }
515}