Skip to main content

zeph_core/
memory_tools.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::fmt::Write as _;
5use std::sync::Arc;
6
7use parking_lot::RwLock;
8use zeph_memory::embedding_store::SearchFilter;
9use zeph_memory::semantic::SemanticMemory;
10use zeph_memory::types::ConversationId;
11use zeph_tools::executor::{ToolCall, ToolError, ToolExecutor, ToolOutput, deserialize_params};
12use zeph_tools::registry::{InvocationHint, ToolDef};
13use zeph_tools::{CheckpointActionResult, CheckpointListResult};
14
15use zeph_sanitizer::ContentTrustLevel;
16use zeph_sanitizer::memory_validation::MemoryWriteValidator;
17
18/// Shared maximum content-trust-tier slot (issue #6490, `MemGhost`; TOCTOU-hardened by
19/// #6558/#6569).
20///
21/// Stores the `u8` discriminant of [`ContentTrustLevel`]. `MemoryToolExecutor` reads it to
22/// decide whether the interactive `memory_save` tool call needs user confirmation before
23/// content derived from untrusted tool output is persisted.
24///
25/// Three writers keep this correct despite `MemoryToolExecutor` having no access to `Agent`'s
26/// message history (the `ToolExecutor` trait is deliberately object-safe, with no `&Agent`
27/// parameter — see `execute_tool_call_erased`):
28/// - `Agent::ratchet_memory_consent_trust_for_dispatch` (`agent/tool_execution/sanitize.rs`) —
29///   the authoritative writer. Runs at the top of `handle_native_tool_calls`, BEFORE any tool
30///   call in the batch (including `memory_save` itself) starts executing, combining (a) the
31///   worst-case trust tier the batch's tool names could introduce and (b) whatever untrusted
32///   content is still tagged on a message in the live conversation context. Closes the
33///   same-tier/cross-tier parallel-dispatch race (#6569: previously the slot was only updated
34///   *after* `join_all` on the whole batch had already resolved) and the cross-turn deferral
35///   bypass (#6558: previously a hard reset every turn discarded trust for content that was
36///   still sitting in context, not yet compacted away).
37/// - `Agent::sanitize_tool_output` — still ratchets up as each tool's output is classified,
38///   as a defense-in-depth duplicate of (a) above (a no-op in practice since both derive the
39///   same trust tier from the same tool name).
40/// - `begin_turn`/`/clear` reset it to `0` as a floor; this is safe (not a re-introduction of
41///   #6558) only because `ratchet_memory_consent_trust_for_dispatch` unconditionally recomputes
42///   the correct value from live context before any subsequent tool dispatch.
43pub type MemoryConsentTrustSlot = Arc<RwLock<u8>>;
44
45/// Write-time consent-gate parameters attached via [`MemoryToolExecutor::with_consent_gate`].
46struct ConsentGate {
47    trust_slot: MemoryConsentTrustSlot,
48    confirm_threshold: ContentTrustLevel,
49}
50
51/// Parse a `[memory.consent_gate]` trust-tier config string (`confirm_threshold`/
52/// `disclose_threshold`) into a [`ContentTrustLevel`], falling back to
53/// [`ContentTrustLevel::ExternalUntrusted`] (the most conservative tier) and logging a warning
54/// on an unrecognized value.
55///
56/// # Examples
57///
58/// ```rust
59/// use zeph_core::memory_tools::parse_consent_trust_level;
60/// use zeph_sanitizer::ContentTrustLevel;
61///
62/// assert_eq!(
63///     parse_consent_trust_level("local_untrusted"),
64///     ContentTrustLevel::LocalUntrusted
65/// );
66/// assert_eq!(
67///     parse_consent_trust_level("not_a_real_tier"),
68///     ContentTrustLevel::ExternalUntrusted
69/// );
70/// ```
71#[must_use]
72pub fn parse_consent_trust_level(s: &str) -> ContentTrustLevel {
73    ContentTrustLevel::from_str_opt(s).unwrap_or_else(|| {
74        tracing::warn!(
75            value = s,
76            "invalid memory.consent_gate trust-tier value, falling back to external_untrusted"
77        );
78        ContentTrustLevel::ExternalUntrusted
79    })
80}
81
82#[derive(Debug, Clone, serde::Deserialize, schemars::JsonSchema)]
83struct MemorySearchParams {
84    /// Natural language query to search memory for relevant past messages and facts.
85    query: String,
86    /// Maximum number of results to return (default: 5, max: 20).
87    #[serde(default = "default_limit")]
88    limit: u32,
89}
90
91fn default_limit() -> u32 {
92    5
93}
94
95#[derive(Debug, Clone, serde::Deserialize, schemars::JsonSchema)]
96struct MemorySaveParams {
97    /// The content to save to long-term memory. Should be a concise, self-contained fact or note.
98    content: String,
99    /// Role label for the saved message (default: "assistant").
100    #[serde(default = "default_role")]
101    role: String,
102}
103
104fn default_role() -> String {
105    "assistant".into()
106}
107
108/// Executes `memory_search` and `memory_save` tool calls on behalf of the agent.
109pub struct MemoryToolExecutor {
110    memory: Arc<SemanticMemory>,
111    conversation_id: ConversationId,
112    validator: MemoryWriteValidator,
113    /// When `true` the backing store is in-memory (bare mode) and saves do not persist across sessions.
114    ephemeral: bool,
115    /// Write-time memory-consent gate (issue #6490). `None` when `memory.consent_gate.enabled
116    /// = false` — `memory_save` never requires confirmation in that case.
117    consent_gate: Option<ConsentGate>,
118    /// Audit sink for memory-write attribution (issue #6490). `None` when audit logging is
119    /// disabled — `memory_save` writes are not logged, matching other executors' behavior.
120    audit_logger: Option<Arc<zeph_tools::AuditLogger>>,
121    /// Mirrors `memory.consent_gate.audit_all` (issue #6559). Deliberately independent of
122    /// `consent_gate` (which is `None` whenever `enabled = false`) — `audit_all` gates the audit
123    /// log regardless of the master `enabled` switch, matching
124    /// `Agent::persist_message_inner`'s background-write-path gating. Defaults to `true` so
125    /// callers that have not been updated to call `with_audit_all` keep the pre-#6559 behavior
126    /// (audit fires whenever a logger is attached).
127    audit_all: bool,
128}
129
130impl MemoryToolExecutor {
131    /// Create with default validator and persistent (non-ephemeral) semantics.
132    #[must_use]
133    pub fn new(memory: Arc<SemanticMemory>, conversation_id: ConversationId) -> Self {
134        Self {
135            memory,
136            conversation_id,
137            validator: MemoryWriteValidator::new(
138                zeph_sanitizer::memory_validation::MemoryWriteValidationConfig::default(),
139            ),
140            ephemeral: false,
141            consent_gate: None,
142            audit_logger: None,
143            audit_all: true,
144        }
145    }
146
147    /// Create with a custom validator (used when security config is loaded).
148    #[must_use]
149    pub fn with_validator(
150        memory: Arc<SemanticMemory>,
151        conversation_id: ConversationId,
152        validator: MemoryWriteValidator,
153    ) -> Self {
154        Self {
155            memory,
156            conversation_id,
157            validator,
158            ephemeral: false,
159            consent_gate: None,
160            audit_logger: None,
161            audit_all: true,
162        }
163    }
164
165    /// Mark this executor as ephemeral (bare mode).
166    ///
167    /// When set, `memory_save` reports that the content is session-only and will not be
168    /// available after the session ends.
169    #[must_use]
170    pub fn ephemeral(mut self) -> Self {
171        self.ephemeral = true;
172        self
173    }
174
175    /// Attach the write-time memory-consent gate (issue #6490, `MemGhost`).
176    ///
177    /// `trust_slot` must be the same [`MemoryConsentTrustSlot`] the owning `Agent` ratchets up
178    /// in `sanitize_tool_output` — see `AgentBuilder::with_memory_consent_trust_slot`.
179    /// `confirm_threshold` is the minimum trust tier (inclusive) that requires
180    /// `Channel::confirm` before `memory_save` persists (`memory.consent_gate.confirm_threshold`
181    /// in config).
182    #[must_use]
183    pub fn with_consent_gate(
184        mut self,
185        trust_slot: MemoryConsentTrustSlot,
186        confirm_threshold: ContentTrustLevel,
187    ) -> Self {
188        self.consent_gate = Some(ConsentGate {
189            trust_slot,
190            confirm_threshold,
191        });
192        self
193    }
194
195    /// Attach an audit sink so every `memory_save` write is recorded with source attribution
196    /// (issue #6490, `MemGhost` part D).
197    #[must_use]
198    pub fn with_audit(mut self, logger: Arc<zeph_tools::AuditLogger>) -> Self {
199        self.audit_logger = Some(logger);
200        self
201    }
202
203    /// Set whether `memory_save` writes are audited, mirroring `memory.consent_gate.audit_all`
204    /// (issue #6559). Pass the config value here regardless of `consent_gate.enabled` — audit
205    /// attribution and the interactive/disclosure gate are independent switches, matching
206    /// `Agent::persist_message_inner`'s background-write-path gating (`store.rs`).
207    #[must_use]
208    pub fn with_audit_all(mut self, audit_all: bool) -> Self {
209        self.audit_all = audit_all;
210        self
211    }
212
213    /// Current maximum trust tier for the gate check, or [`ContentTrustLevel::Trusted`] when
214    /// no consent gate is attached (gate disabled).
215    ///
216    /// Since #6558/#6569's fix, this reads a slot that reflects both the current dispatch
217    /// batch AND any untrusted content still tagged in the live conversation context (see
218    /// `Agent::ratchet_memory_consent_trust_for_dispatch`, `agent/tool_execution/sanitize.rs`)
219    /// — not just "this turn's own tool output" as before.
220    ///
221    /// Intentional scope broadening (not an oversight): `memory_search` classifies as
222    /// `ExternalUntrusted` (`MemoryRetrieval` source) and its retrieval result is tagged and
223    /// persists in context like any other untrusted tool output. In a memory-enabled agent
224    /// that calls `memory_search` frequently, this means most `memory_save` calls will require
225    /// confirmation for as long as a `memory_search` result remains in the context window —
226    /// a broader gate footprint than the original per-turn design. This is the fail-safe
227    /// direction (more confirmations, never fewer) and is a deliberate tradeoff of closing
228    /// #6558/#6569's TOCTOU windows, not a bug. If this UX shift proves too aggressive in
229    /// practice, the fix is to exclude `memory_search` from `build_tool_output_source`'s
230    /// context-tagging path specifically (`agent/tool_execution/sanitize.rs`) — not to weaken
231    /// the gate check here.
232    fn current_trust_level(&self) -> ContentTrustLevel {
233        self.consent_gate
234            .as_ref()
235            .map_or(ContentTrustLevel::Trusted, |gate| {
236                ContentTrustLevel::from_ordinal(*gate.trust_slot.read())
237            })
238    }
239
240    /// Perform the actual `memory_save` write, bypassing the consent-gate confirmation check.
241    ///
242    /// Called both by `execute_tool_call` (when no confirmation is required) and
243    /// `execute_tool_call_confirmed` (after the user has approved).
244    async fn do_memory_save(
245        &self,
246        params: &MemorySaveParams,
247    ) -> Result<Option<ToolOutput>, ToolError> {
248        if params.content.is_empty() {
249            return Err(ToolError::InvalidParams {
250                message: "content must not be empty".to_owned(),
251            });
252        }
253        if params.content.len() > 4096 {
254            return Err(ToolError::InvalidParams {
255                message: "content exceeds maximum length of 4096 characters".to_owned(),
256            });
257        }
258
259        // Schema validation: check content before writing to memory.
260        if let Err(e) = self.validator.validate_memory_save(&params.content) {
261            return Err(ToolError::InvalidParams {
262                message: format!("memory write rejected: {e}"),
263            });
264        }
265
266        let role = params.role.as_str();
267        let trust_level = self.current_trust_level();
268
269        // Explicit user-directed saves bypass goal-conditioned scoring (goal_text = None).
270        // Provenance (issue #6490): the LLM-supplied `role` is never used as a trust signal —
271        // trust is derived from the turn's actual tool-output origin via the consent-gate slot.
272        let message_id_opt = self
273            .memory
274            .remember_with_provenance(
275                self.conversation_id,
276                role,
277                &params.content,
278                None,
279                None,
280                Some(trust_level.as_str()),
281            )
282            .await
283            .map_err(|e| ToolError::Execution(std::io::Error::other(e.to_string())))?;
284
285        if self.audit_all
286            && let Some(logger) = &self.audit_logger
287        {
288            let preview: String = params.content.chars().take(120).collect();
289            let entry = zeph_tools::AuditEntry::memory_write(
290                "memory_save",
291                format!("save: {preview}"),
292                None,
293                Some(trust_level.as_str()),
294            );
295            logger.log(&entry).await;
296        }
297
298        let summary = match message_id_opt {
299            Some(message_id) => {
300                if self.ephemeral {
301                    format!(
302                        "Saved to session memory (message_id: {message_id}, conversation: {}). Ephemeral — not available after session ends.",
303                        self.conversation_id
304                    )
305                } else {
306                    format!(
307                        "Saved to memory (message_id: {message_id}, conversation: {}). Content will be available for future recall.",
308                        self.conversation_id
309                    )
310                }
311            }
312            None => "Memory admission rejected: message did not meet quality threshold.".to_owned(),
313        };
314
315        Ok(Some(ToolOutput {
316            tool_name: zeph_common::ToolName::new("memory_save"),
317            summary,
318            blocks_executed: 1,
319            filter_stats: None,
320            diff: None,
321            streamed: false,
322            terminal_id: None,
323            locations: None,
324            raw_response: None,
325            claim_source: Some(zeph_tools::ClaimSource::Memory),
326            ..Default::default()
327        }))
328    }
329}
330
331impl ToolExecutor for MemoryToolExecutor {
332    fn tool_definitions(&self) -> Vec<ToolDef> {
333        vec![
334            ToolDef {
335                id: "memory_search".into(),
336                description: "Search long-term memory for relevant past messages, facts, and session summaries. Use to recall facts, preferences, or information the user provided during this or previous conversations.\n\nParameters: query (string, required) - natural language search query; limit (integer, optional) - max results 1-20 (default: 5)\nReturns: ranked list of memory entries with similarity scores and timestamps\nErrors: Execution on database failure\nExample: {\"query\": \"user preference for output format\", \"limit\": 5}".into(),
337                schema: schemars::schema_for!(MemorySearchParams),
338                invocation: InvocationHint::ToolCall,
339                output_schema: None,
340                server_id: None,
341            },
342            ToolDef {
343                id: "memory_save".into(),
344                description: "Save a fact or note to long-term memory for cross-session recall. Use sparingly for key decisions, user preferences, or critical context worth remembering across sessions.\n\nParameters: content (string, required) - concise, self-contained fact or note; role (string, optional) - message role label (default: \"assistant\")\nReturns: confirmation with saved entry ID\nErrors: Execution on database failure; InvalidParams if content is empty\nExample: {\"content\": \"User prefers JSON output over YAML\", \"role\": \"assistant\"}".into(),
345                schema: schemars::schema_for!(MemorySaveParams),
346                invocation: InvocationHint::ToolCall,
347                output_schema: None,
348                server_id: None,
349            },
350        ]
351    }
352
353    async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
354        Ok(None)
355    }
356
357    #[allow(clippy::too_many_lines)] // two tools with validation, search, and multi-source aggregation
358    async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
359        match call.tool_id.as_str() {
360            "memory_search" => {
361                let params: MemorySearchParams = deserialize_params(&call.params)?;
362                let limit = params.limit.clamp(1, 20) as usize;
363
364                let filter = Some(SearchFilter {
365                    conversation_id: Some(self.conversation_id),
366                    role: None,
367                    category: None,
368                });
369
370                let recalled = self
371                    .memory
372                    .recall(&params.query, limit, filter)
373                    .await
374                    .map_err(|e| ToolError::Execution(std::io::Error::other(e.to_string())))?;
375
376                let key_facts = self
377                    .memory
378                    .search_key_facts(&params.query, limit, Some(self.conversation_id))
379                    .await
380                    .map_err(|e| ToolError::Execution(std::io::Error::other(e.to_string())))?;
381
382                let summaries = self
383                    .memory
384                    .search_session_summaries(&params.query, limit, Some(self.conversation_id))
385                    .await
386                    .map_err(|e| ToolError::Execution(std::io::Error::other(e.to_string())))?;
387
388                let mut output = String::new();
389
390                let _ = writeln!(output, "## Recalled Messages ({} results)", recalled.len());
391                for r in &recalled {
392                    let role = match r.message.role {
393                        zeph_llm::provider::Role::Assistant => "assistant",
394                        zeph_llm::provider::Role::System => "system",
395                        zeph_llm::provider::Role::User | _ => "user",
396                    };
397                    let content = r.message.content.trim();
398                    let _ = writeln!(output, "[score: {:.2}] {role}: {content}", r.score);
399                }
400
401                let _ = writeln!(output);
402                let _ = writeln!(output, "## Key Facts ({} results)", key_facts.len());
403                for fact in &key_facts {
404                    let _ = writeln!(output, "- {fact}");
405                }
406
407                let _ = writeln!(output);
408                let _ = writeln!(output, "## Session Summaries ({} results)", summaries.len());
409                for s in &summaries {
410                    let _ = writeln!(
411                        output,
412                        "[conv #{}, score: {:.2}] {}",
413                        s.conversation_id, s.score, s.summary_text
414                    );
415                }
416
417                Ok(Some(ToolOutput {
418                    tool_name: zeph_common::ToolName::new("memory_search"),
419                    summary: output,
420                    blocks_executed: 1,
421                    filter_stats: None,
422                    diff: None,
423                    streamed: false,
424                    terminal_id: None,
425                    locations: None,
426                    raw_response: None,
427                    claim_source: Some(zeph_tools::ClaimSource::Memory),
428                    ..Default::default()
429                }))
430            }
431            "memory_save" => {
432                let params: MemorySaveParams = deserialize_params(&call.params)?;
433
434                // Write-time consent gate (issue #6490, MemGhost): require interactive
435                // confirmation when this turn already contains tool output at or above the
436                // configured trust threshold. Uses the same ConfirmationRequired ->
437                // Channel::confirm protocol as TrustGateExecutor::check_trust — the agent's
438                // `handle_confirmation_phase` catches this and re-dispatches via
439                // `execute_tool_call_confirmed` on approval.
440                if let Some(gate) = &self.consent_gate
441                    && self.current_trust_level() >= gate.confirm_threshold
442                {
443                    let preview: String = params.content.chars().take(80).collect();
444                    let ellipsis = if params.content.chars().count() > 80 {
445                        "…"
446                    } else {
447                        ""
448                    };
449                    let trust = self.current_trust_level();
450                    return Err(ToolError::ConfirmationRequired {
451                        command: format!(
452                            "Save to memory: {preview}{ellipsis} [source: {}]",
453                            trust.as_str()
454                        ),
455                    });
456                }
457
458                self.do_memory_save(&params).await
459            }
460            _ => Ok(None),
461        }
462    }
463
464    fn requires_confirmation(&self, call: &ToolCall) -> bool {
465        if call.tool_id.as_str() != "memory_save" {
466            return false;
467        }
468        let Some(gate) = &self.consent_gate else {
469            return false;
470        };
471        self.current_trust_level() >= gate.confirm_threshold
472    }
473
474    /// Execute bypassing the consent-gate confirmation check (called after the user approves).
475    ///
476    /// `memory_search` has no confirmation policy of its own, so it delegates to
477    /// [`ToolExecutor::execute_tool_call`] unchanged.
478    async fn execute_tool_call_confirmed(
479        &self,
480        call: &ToolCall,
481    ) -> Result<Option<ToolOutput>, ToolError> {
482        if call.tool_id.as_str() == "memory_save" {
483            let params: MemorySaveParams = deserialize_params(&call.params)?;
484            return self.do_memory_save(&params).await;
485        }
486        self.execute_tool_call(call).await
487    }
488
489    fn checkpoint_undo(&self, _n: usize) -> CheckpointActionResult {
490        CheckpointActionResult::unsupported()
491    }
492
493    fn checkpoint_redo(&self) -> CheckpointActionResult {
494        CheckpointActionResult::unsupported()
495    }
496
497    fn checkpoint_list(&self) -> CheckpointListResult {
498        CheckpointListResult::default()
499    }
500
501    fn is_tool_speculatable(&self, _tool_id: &str) -> bool {
502        false
503    }
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509    use zeph_llm::any::AnyProvider;
510    use zeph_llm::mock::MockProvider;
511    use zeph_memory::semantic::SemanticMemory;
512
513    async fn make_memory() -> SemanticMemory {
514        SemanticMemory::with_sqlite_backend(
515            ":memory:",
516            AnyProvider::Mock(MockProvider::default()),
517            "test-model",
518            0.7,
519            0.3,
520        )
521        .await
522        .unwrap()
523    }
524
525    fn make_executor(memory: SemanticMemory) -> MemoryToolExecutor {
526        MemoryToolExecutor::new(Arc::new(memory), ConversationId(1))
527    }
528
529    #[tokio::test]
530    async fn tool_definitions_returns_two_tools() {
531        let memory = make_memory().await;
532        let executor = make_executor(memory);
533        let defs = executor.tool_definitions();
534        assert_eq!(defs.len(), 2);
535        assert_eq!(defs[0].id.as_ref(), "memory_search");
536        assert_eq!(defs[1].id.as_ref(), "memory_save");
537    }
538
539    #[tokio::test]
540    async fn execute_always_returns_none() {
541        let memory = make_memory().await;
542        let executor = make_executor(memory);
543        let result = executor.execute("any response").await.unwrap();
544        assert!(result.is_none());
545    }
546
547    #[tokio::test]
548    async fn execute_tool_call_unknown_returns_none() {
549        let memory = make_memory().await;
550        let executor = make_executor(memory);
551        let call = ToolCall {
552            tool_id: zeph_common::ToolName::new("unknown_tool"),
553            params: serde_json::Map::new(),
554            caller_id: None,
555            context: None,
556
557            tool_call_id: String::new(),
558            skill_name: None,
559        };
560        let result = executor.execute_tool_call(&call).await.unwrap();
561        assert!(result.is_none());
562    }
563
564    #[tokio::test]
565    async fn memory_search_returns_output() {
566        let memory = make_memory().await;
567        let executor = make_executor(memory);
568        let mut params = serde_json::Map::new();
569        params.insert(
570            "query".into(),
571            serde_json::Value::String("test query".into()),
572        );
573        let call = ToolCall {
574            tool_id: zeph_common::ToolName::new("memory_search"),
575            params,
576            caller_id: None,
577            context: None,
578
579            tool_call_id: String::new(),
580            skill_name: None,
581        };
582        let result = executor.execute_tool_call(&call).await.unwrap();
583        assert!(result.is_some());
584        let output = result.unwrap();
585        assert_eq!(output.tool_name, "memory_search");
586        assert!(output.summary.contains("Recalled Messages"));
587        assert!(output.summary.contains("Key Facts"));
588        assert!(output.summary.contains("Session Summaries"));
589    }
590
591    #[tokio::test]
592    async fn memory_save_stores_and_returns_confirmation() {
593        let memory = make_memory().await;
594        let sqlite = memory.sqlite().clone();
595        // Create conversation first
596        let cid = sqlite.create_conversation().await.unwrap();
597        let executor = MemoryToolExecutor::new(Arc::new(memory), cid);
598
599        let mut params = serde_json::Map::new();
600        params.insert(
601            "content".into(),
602            serde_json::Value::String("User prefers dark mode".into()),
603        );
604        let call = ToolCall {
605            tool_id: zeph_common::ToolName::new("memory_save"),
606            params,
607            caller_id: None,
608            context: None,
609
610            tool_call_id: String::new(),
611            skill_name: None,
612        };
613        let result = executor.execute_tool_call(&call).await.unwrap();
614        assert!(result.is_some());
615        let output = result.unwrap();
616        assert!(output.summary.contains("Saved to memory"));
617        assert!(output.summary.contains("message_id:"));
618    }
619
620    #[tokio::test]
621    async fn memory_save_empty_content_returns_error() {
622        let memory = make_memory().await;
623        let executor = make_executor(memory);
624        let mut params = serde_json::Map::new();
625        params.insert("content".into(), serde_json::Value::String(String::new()));
626        let call = ToolCall {
627            tool_id: zeph_common::ToolName::new("memory_save"),
628            params,
629            caller_id: None,
630            context: None,
631
632            tool_call_id: String::new(),
633            skill_name: None,
634        };
635        let result = executor.execute_tool_call(&call).await;
636        assert!(result.is_err());
637    }
638
639    #[tokio::test]
640    async fn memory_save_oversized_content_returns_error() {
641        let memory = make_memory().await;
642        let executor = make_executor(memory);
643        let mut params = serde_json::Map::new();
644        params.insert(
645            "content".into(),
646            serde_json::Value::String("x".repeat(4097)),
647        );
648        let call = ToolCall {
649            tool_id: zeph_common::ToolName::new("memory_save"),
650            params,
651            caller_id: None,
652            context: None,
653
654            tool_call_id: String::new(),
655            skill_name: None,
656        };
657        let result = executor.execute_tool_call(&call).await;
658        assert!(result.is_err());
659    }
660
661    #[tokio::test]
662    async fn memory_save_ephemeral_returns_session_only_message() {
663        let memory = make_memory().await;
664        let sqlite = memory.sqlite().clone();
665        let cid = sqlite.create_conversation().await.unwrap();
666        let executor = MemoryToolExecutor::new(Arc::new(memory), cid).ephemeral();
667
668        let mut params = serde_json::Map::new();
669        params.insert(
670            "content".into(),
671            serde_json::Value::String("temp fact".into()),
672        );
673        let call = ToolCall {
674            tool_id: zeph_common::ToolName::new("memory_save"),
675            params,
676            caller_id: None,
677            context: None,
678            tool_call_id: String::new(),
679            skill_name: None,
680        };
681        let output = executor.execute_tool_call(&call).await.unwrap().unwrap();
682        assert!(
683            output.summary.contains("Ephemeral"),
684            "bare-mode save must mention ephemeral semantics; got: {}",
685            output.summary
686        );
687        assert!(
688            !output.summary.contains("available for future recall"),
689            "bare-mode save must not claim cross-session persistence; got: {}",
690            output.summary
691        );
692    }
693
694    fn memory_save_call(content: &str) -> ToolCall {
695        let mut params = serde_json::Map::new();
696        params.insert("content".into(), serde_json::Value::String(content.into()));
697        ToolCall {
698            tool_id: zeph_common::ToolName::new("memory_save"),
699            params,
700            caller_id: None,
701            context: None,
702            tool_call_id: String::new(),
703            skill_name: None,
704        }
705    }
706
707    // ── Write-time memory-consent gate (issue #6490, MemGhost) ─────────────────────
708
709    #[tokio::test]
710    async fn memory_save_without_consent_gate_never_requires_confirmation() {
711        let memory = make_memory().await;
712        let sqlite = memory.sqlite().clone();
713        let cid = sqlite.create_conversation().await.unwrap();
714        // No `.with_consent_gate(...)` attached — must behave exactly as before #6490.
715        let executor = MemoryToolExecutor::new(Arc::new(memory), cid);
716        let call = memory_save_call("a fact");
717        let result = executor.execute_tool_call(&call).await;
718        assert!(result.is_ok(), "expected no confirmation gate: {result:?}");
719    }
720
721    #[tokio::test]
722    async fn memory_save_requires_confirmation_when_turn_trust_at_or_above_threshold() {
723        let memory = make_memory().await;
724        let sqlite = memory.sqlite().clone();
725        let cid = sqlite.create_conversation().await.unwrap();
726        let trust_slot: MemoryConsentTrustSlot = Arc::new(RwLock::new(0u8));
727        let executor = MemoryToolExecutor::new(Arc::new(memory), cid).with_consent_gate(
728            Arc::clone(&trust_slot),
729            ContentTrustLevel::ExternalUntrusted,
730        );
731
732        // Simulate sanitize_tool_output having ratcheted the slot up this turn.
733        *trust_slot.write() = ContentTrustLevel::ExternalUntrusted as u8;
734
735        let call = memory_save_call("derived from untrusted web content");
736        let result = executor.execute_tool_call(&call).await;
737        assert!(
738            matches!(result, Err(ToolError::ConfirmationRequired { .. })),
739            "expected ConfirmationRequired, got: {result:?}"
740        );
741        assert!(executor.requires_confirmation(&call));
742    }
743
744    #[tokio::test]
745    async fn memory_save_below_confirm_threshold_does_not_require_confirmation() {
746        let memory = make_memory().await;
747        let sqlite = memory.sqlite().clone();
748        let cid = sqlite.create_conversation().await.unwrap();
749        let trust_slot: MemoryConsentTrustSlot = Arc::new(RwLock::new(0u8));
750        let executor = MemoryToolExecutor::new(Arc::new(memory), cid).with_consent_gate(
751            Arc::clone(&trust_slot),
752            ContentTrustLevel::ExternalUntrusted,
753        );
754
755        // Only LocalUntrusted this turn — below the ExternalUntrusted confirm threshold.
756        *trust_slot.write() = ContentTrustLevel::LocalUntrusted as u8;
757
758        let call = memory_save_call("derived from a local shell command");
759        let result = executor.execute_tool_call(&call).await;
760        assert!(result.is_ok(), "expected no confirmation gate: {result:?}");
761    }
762
763    #[tokio::test]
764    async fn execute_tool_call_confirmed_bypasses_consent_gate_and_saves() {
765        let memory = make_memory().await;
766        let sqlite = memory.sqlite().clone();
767        let cid = sqlite.create_conversation().await.unwrap();
768        let trust_slot: MemoryConsentTrustSlot = Arc::new(RwLock::new(0u8));
769        let executor = MemoryToolExecutor::new(Arc::new(memory), cid).with_consent_gate(
770            Arc::clone(&trust_slot),
771            ContentTrustLevel::ExternalUntrusted,
772        );
773        *trust_slot.write() = ContentTrustLevel::ExternalUntrusted as u8;
774
775        let call = memory_save_call("approved after confirmation");
776        // First attempt is gated.
777        assert!(matches!(
778            executor.execute_tool_call(&call).await,
779            Err(ToolError::ConfirmationRequired { .. })
780        ));
781        // Confirmed re-dispatch bypasses the gate and actually saves.
782        let result = executor.execute_tool_call_confirmed(&call).await;
783        assert!(result.is_ok(), "confirmed save should succeed: {result:?}");
784        let output = result.unwrap().unwrap();
785        assert!(output.summary.contains("Saved to memory"));
786    }
787
788    // ── audit_all gating on the interactive memory_save path (issue #6559) ─────────
789
790    async fn make_file_logger(log_path: &std::path::Path) -> Arc<zeph_tools::AuditLogger> {
791        let audit_config = zeph_tools::AuditConfig {
792            enabled: true,
793            destination: zeph_tools::AuditDestination::File(log_path.to_path_buf()),
794            tool_risk_summary: false,
795        };
796        Arc::new(
797            zeph_tools::AuditLogger::from_config(&audit_config, false)
798                .await
799                .unwrap(),
800        )
801    }
802
803    #[tokio::test]
804    async fn memory_save_audited_when_audit_all_true() {
805        let memory = make_memory().await;
806        let sqlite = memory.sqlite().clone();
807        let cid = sqlite.create_conversation().await.unwrap();
808        let dir = tempfile::tempdir().unwrap();
809        let log_path = dir.path().join("audit.jsonl");
810        let logger = make_file_logger(&log_path).await;
811
812        let executor = MemoryToolExecutor::new(Arc::new(memory), cid)
813            .with_audit(logger)
814            .with_audit_all(true);
815
816        let call = memory_save_call("audited fact");
817        executor.execute_tool_call(&call).await.unwrap();
818
819        let content = tokio::fs::read_to_string(&log_path).await.unwrap();
820        assert!(
821            content.contains("memory_save"),
822            "audit_all=true must record the interactive memory_save write, got: {content}"
823        );
824    }
825
826    #[tokio::test]
827    async fn memory_save_not_audited_when_audit_all_false() {
828        let memory = make_memory().await;
829        let sqlite = memory.sqlite().clone();
830        let cid = sqlite.create_conversation().await.unwrap();
831        let dir = tempfile::tempdir().unwrap();
832        let log_path = dir.path().join("audit.jsonl");
833        let logger = make_file_logger(&log_path).await;
834
835        let executor = MemoryToolExecutor::new(Arc::new(memory), cid)
836            .with_audit(logger)
837            .with_audit_all(false);
838
839        let call = memory_save_call("unaudited fact");
840        executor.execute_tool_call(&call).await.unwrap();
841
842        // The logger was never asked to write, so the destination file must stay empty —
843        // matches persist_message_inner's audit_all=false behavior on the background path.
844        let content = tokio::fs::read_to_string(&log_path)
845            .await
846            .unwrap_or_default();
847        assert!(
848            content.is_empty(),
849            "audit_all=false must suppress the interactive memory_save audit entry, got: {content}"
850        );
851    }
852
853    /// `memory_search` description must mention user-provided facts so the model
854    /// prefers it over `search_code` for recalling information from conversation (#2475).
855    #[tokio::test]
856    async fn memory_search_description_mentions_user_provided_facts() {
857        let memory = make_memory().await;
858        let executor = make_executor(memory);
859        let defs = executor.tool_definitions();
860        let memory_search = defs
861            .iter()
862            .find(|d| d.id.as_ref() == "memory_search")
863            .unwrap();
864        assert!(
865            memory_search
866                .description
867                .contains("user provided during this or previous conversations"),
868            "memory_search description must contain disambiguation phrase; got: {}",
869            memory_search.description
870        );
871    }
872}