memory: add audit log events

This commit is contained in:
2026-05-25 03:24:04 +09:00
parent 08710d808d
commit 10d3556792
17 changed files with 1534 additions and 60 deletions
+152
View File
@@ -0,0 +1,152 @@
//! `MemoryDelete` tool for removing memory / knowledge records with audit logging.
use std::sync::Arc;
use async_trait::async_trait;
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use serde::Deserialize;
use crate::audit::{AuditStatus, RecordOperationAudit, append_record_operation, file_hash};
use crate::tool::MemoryToolKind;
use crate::workspace::WorkspaceLayout;
const DESCRIPTION: &str = "Delete an existing memory or knowledge record selected by `kind` + `slug`. \
For `summary` omit `slug`; for the others `slug` is required. The delete is audited and cannot target \
workflow or staging/log files.";
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct DeleteParams {
/// Kind of record to delete.
kind: MemoryToolKind,
/// Slug. Required for everything except `summary`; forbidden for `summary`.
#[serde(default)]
slug: Option<String>,
}
struct MemoryDeleteTool {
layout: WorkspaceLayout,
}
#[async_trait]
impl Tool for MemoryDeleteTool {
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
let params: DeleteParams = serde_json::from_str(input_json)
.map_err(|e| ToolError::InvalidArgument(format!("invalid MemoryDelete input: {e}")))?;
let path = params
.kind
.resolve_path(&self.layout, params.slug.as_deref())?;
let kind = params.kind.to_string();
let slug = audit_slug(&params.kind, params.slug.as_deref());
let before_hash = file_hash(&path).ok().flatten();
if before_hash.is_none() {
let reason = format!("record not found: {}", path.display());
let _ = append_record_operation(
&self.layout,
RecordOperationAudit {
op: "delete".to_string(),
status: AuditStatus::Failed,
kind,
slug,
path: path.display().to_string(),
before_hash,
after_hash: None,
reason: Some(reason.clone()),
},
);
return Err(ToolError::ExecutionFailed(reason));
}
if let Err(err) = std::fs::remove_file(&path) {
let reason = format!("failed to delete {}: {err}", path.display());
let _ = append_record_operation(
&self.layout,
RecordOperationAudit {
op: "delete".to_string(),
status: AuditStatus::Failed,
kind,
slug,
path: path.display().to_string(),
before_hash,
after_hash: None,
reason: Some(reason.clone()),
},
);
return Err(ToolError::ExecutionFailed(reason));
}
let _ = append_record_operation(
&self.layout,
RecordOperationAudit {
op: "delete".to_string(),
status: AuditStatus::Success,
kind,
slug,
path: path.display().to_string(),
before_hash,
after_hash: None,
reason: None,
},
);
Ok(ToolOutput {
summary: format!("Deleted {}", path.display()),
content: None,
})
}
}
pub fn delete_tool(layout: WorkspaceLayout) -> ToolDefinition {
Arc::new(move || {
let schema = schemars::schema_for!(DeleteParams);
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
let meta = ToolMeta::new("MemoryDelete")
.description(DESCRIPTION)
.input_schema(schema_value);
let tool: Arc<dyn Tool> = Arc::new(MemoryDeleteTool {
layout: layout.clone(),
});
(meta, tool)
})
}
fn audit_slug(kind: &MemoryToolKind, slug: Option<&str>) -> String {
match kind {
MemoryToolKind::Summary => "summary".to_string(),
_ => slug.unwrap_or("<missing>").to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use tempfile::TempDir;
#[tokio::test]
async fn delete_removes_file_and_audits() {
let dir = TempDir::new().unwrap();
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
std::fs::create_dir_all(layout.decisions_dir()).unwrap();
let path = layout.decisions_dir().join("obsolete.md");
let now = Utc::now().to_rfc3339();
std::fs::write(
&path,
format!(
"---\ncreated_at: {now}\nupdated_at: {now}\nsources: []\nstatus: open\n---\nold"
),
)
.unwrap();
let (_, tool) = delete_tool(layout.clone())();
let out = tool
.execute(r#"{"kind":"decision","slug":"obsolete"}"#)
.await
.unwrap();
assert!(out.summary.contains("Deleted"));
assert!(!path.exists());
let log = std::fs::read_to_string(layout.audit_current_log_path()).unwrap();
assert!(log.contains(r#""event":"record_operation""#));
assert!(log.contains(r#""op":"delete""#));
assert!(log.contains(r#""status":"success""#));
}
}
+140 -20
View File
@@ -12,6 +12,9 @@ use async_trait::async_trait;
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use serde::Deserialize;
use crate::audit::{
AuditStatus, RecordOperationAudit, append_record_operation, file_hash, hash_bytes,
};
use crate::linter::{LintReport, Linter, WriteMode};
use crate::tool::MemoryToolKind;
use crate::workspace::WorkspaceLayout;
@@ -62,30 +65,94 @@ impl Tool for EditTool {
let path = params
.kind
.resolve_path(&self.layout, params.slug.as_deref())?;
let kind = params.kind.to_string();
let slug = audit_slug(&params.kind, params.slug.as_deref());
let current_bytes = std::fs::read(&path).map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => ToolError::ExecutionFailed(format!(
"record not found (use MemoryWrite to create): {}",
path.display()
)),
_ => ToolError::ExecutionFailed(format!("read failed at {}: {e}", path.display())),
})?;
let current_text = std::str::from_utf8(&current_bytes).map_err(|_| {
ToolError::InvalidArgument(format!("file is not valid UTF-8: {}", path.display()))
})?;
let current_bytes = match std::fs::read(&path) {
Ok(bytes) => bytes,
Err(e) => {
let reason = match e.kind() {
std::io::ErrorKind::NotFound => format!(
"record not found (use MemoryWrite to create): {}",
path.display()
),
_ => format!("read failed at {}: {e}", path.display()),
};
let _ = append_record_operation(
&self.layout,
RecordOperationAudit {
op: "edit".to_string(),
status: AuditStatus::Failed,
kind,
slug,
path: path.display().to_string(),
before_hash: None,
after_hash: None,
reason: Some(reason.clone()),
},
);
return Err(ToolError::ExecutionFailed(reason));
}
};
let before_hash = Some(hash_bytes(&current_bytes));
let current_text = match std::str::from_utf8(&current_bytes) {
Ok(text) => text,
Err(_) => {
let reason = format!("file is not valid UTF-8: {}", path.display());
let _ = append_record_operation(
&self.layout,
RecordOperationAudit {
op: "edit".to_string(),
status: AuditStatus::Failed,
kind,
slug,
path: path.display().to_string(),
before_hash,
after_hash: None,
reason: Some(reason.clone()),
},
);
return Err(ToolError::InvalidArgument(reason));
}
};
let count = current_text.matches(&params.old_string).count();
if count == 0 {
return Err(ToolError::InvalidArgument(format!(
"old_string not found in {}",
path.display()
)));
let reason = format!("old_string not found in {}", path.display());
let _ = append_record_operation(
&self.layout,
RecordOperationAudit {
op: "edit".to_string(),
status: AuditStatus::Failed,
kind,
slug,
path: path.display().to_string(),
before_hash,
after_hash: None,
reason: Some(reason.clone()),
},
);
return Err(ToolError::InvalidArgument(reason));
}
if !params.replace_all && count > 1 {
return Err(ToolError::InvalidArgument(format!(
let reason = format!(
"old_string occurs {count} times in {}; pass replace_all: true or narrow the snippet",
path.display()
)));
);
let _ = append_record_operation(
&self.layout,
RecordOperationAudit {
op: "edit".to_string(),
status: AuditStatus::Failed,
kind,
slug,
path: path.display().to_string(),
before_hash,
after_hash: None,
reason: Some(reason.clone()),
},
);
return Err(ToolError::InvalidArgument(reason));
}
let new_text = if params.replace_all {
@@ -97,12 +164,58 @@ impl Tool for EditTool {
let report = self.linter.lint(&path, &new_text, WriteMode::Update);
if report.has_errors() {
return Err(ToolError::InvalidArgument(format_report(&report)));
let reason = format_report(&report);
let _ = append_record_operation(
&self.layout,
RecordOperationAudit {
op: "edit".to_string(),
status: AuditStatus::Failed,
kind,
slug,
path: path.display().to_string(),
before_hash,
after_hash: None,
reason: Some(reason.clone()),
},
);
return Err(ToolError::InvalidArgument(reason));
}
std::fs::write(&path, new_text.as_bytes()).map_err(|e| {
ToolError::ExecutionFailed(format!("failed to write {}: {e}", path.display()))
})?;
if let Err(e) = std::fs::write(&path, new_text.as_bytes()) {
let reason = format!("failed to write {}: {e}", path.display());
let _ = append_record_operation(
&self.layout,
RecordOperationAudit {
op: "edit".to_string(),
status: AuditStatus::Failed,
kind,
slug,
path: path.display().to_string(),
before_hash,
after_hash: None,
reason: Some(reason.clone()),
},
);
return Err(ToolError::ExecutionFailed(reason));
}
let after_hash = file_hash(&path).ok().flatten();
let _ = append_record_operation(
&self.layout,
RecordOperationAudit {
op: "edit".to_string(),
status: AuditStatus::Success,
kind,
slug,
path: path.display().to_string(),
before_hash,
after_hash,
reason: if report.warnings.is_empty() {
None
} else {
Some(format!("{} warning(s)", report.warnings.len()))
},
},
);
let summary = format!(
"Edited {} ({} replacement{}){}",
@@ -118,6 +231,13 @@ impl Tool for EditTool {
}
}
fn audit_slug(kind: &MemoryToolKind, slug: Option<&str>) -> String {
match kind {
MemoryToolKind::Summary => "summary".to_string(),
_ => slug.unwrap_or("<missing>").to_string(),
}
}
fn format_report(report: &LintReport) -> String {
use std::fmt::Write as _;
let mut buf = String::from("memory linter rejected the edit:");
+14
View File
@@ -5,11 +5,14 @@
//! to know the on-disk layout — Search returns `{slug, kind, ...}` and
//! that pair feeds straight into Read / Edit.
mod delete;
mod edit;
mod query;
mod read;
mod write;
pub use delete::delete_tool;
use std::path::PathBuf;
use llm_worker::tool::ToolError;
@@ -34,6 +37,17 @@ pub enum MemoryToolKind {
Knowledge,
}
impl std::fmt::Display for MemoryToolKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Summary => "summary",
Self::Decision => "decision",
Self::Request => "request",
Self::Knowledge => "knowledge",
})
}
}
impl MemoryToolKind {
pub fn as_str(self) -> &'static str {
match self {
+76 -4
View File
@@ -7,8 +7,9 @@
//! enumerate what records exist without knowing what's inside them.
//!
//! - `MemoryQuery` walks `.insomnia/memory/{summary.md,decisions/,
//! requests/}`. `.insomnia/workflow/` and `.insomnia/memory/_staging/`
//! are excluded by construction.
//! requests/}`. `.insomnia/workflow/`, `.insomnia/memory/_staging/`,
//! `.insomnia/memory/_usage/`, and `.insomnia/memory/_logs/` are excluded
//! by construction.
//! - `KnowledgeQuery` walks `.insomnia/knowledge/*.md` and supports a
//! `kind` filter against the Knowledge frontmatter's `kind` field.
//!
@@ -23,6 +24,7 @@ use async_trait::async_trait;
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use serde::{Deserialize, Serialize};
use crate::audit::{AuditStatus, RecordUsageAudit, append_record_usage};
use crate::schema::{KnowledgeFrontmatter, split_frontmatter};
use crate::workspace::WorkspaceLayout;
@@ -128,7 +130,25 @@ impl Tool for MemoryQueryTool {
let params: MemoryQueryParams = serde_json::from_str(input_json)
.map_err(|e| ToolError::InvalidArgument(format!("invalid MemoryQuery input: {e}")))?;
let needle = match params.query.as_deref() {
Some(q) => Some(validate_query(q)?),
Some(q) => match validate_query(q) {
Ok(q) => Some(q),
Err(err) => {
let _ = append_record_usage(
&self.layout,
RecordUsageAudit {
op: "query".to_string(),
status: AuditStatus::Failed,
kind: "memory".to_string(),
slug: None,
path: None,
query: params.query.clone(),
result_count: None,
reason: Some(err.to_string()),
},
);
return Err(err);
}
},
None => None,
};
@@ -194,6 +214,23 @@ impl Tool for MemoryQueryTool {
Some(q) => format!("{} hit(s) for {q:?}", records.len()),
None => format!("{} record(s)", records.len()),
};
let _ = append_record_usage(
&self.layout,
RecordUsageAudit {
op: "query".to_string(),
status: AuditStatus::Success,
kind: "memory".to_string(),
slug: None,
path: None,
query: params.query.clone(),
result_count: Some(records.len()),
reason: if records.len() >= limit {
Some("result_limit_reached".to_string())
} else {
None
},
},
);
Ok(ToolOutput {
summary,
content: Some(body),
@@ -208,7 +245,25 @@ impl Tool for KnowledgeQueryTool {
ToolError::InvalidArgument(format!("invalid KnowledgeQuery input: {e}"))
})?;
let needle = match params.query.as_deref() {
Some(q) => Some(validate_query(q)?),
Some(q) => match validate_query(q) {
Ok(q) => Some(q),
Err(err) => {
let _ = append_record_usage(
&self.layout,
RecordUsageAudit {
op: "query".to_string(),
status: AuditStatus::Failed,
kind: "knowledge".to_string(),
slug: None,
path: None,
query: params.query.clone(),
result_count: None,
reason: Some(err.to_string()),
},
);
return Err(err);
}
},
None => None,
};
let kind_filter = params.kind.as_deref();
@@ -272,6 +327,23 @@ impl Tool for KnowledgeQueryTool {
Some(q) => format!("{} hit(s) for {q:?}", records.len()),
None => format!("{} record(s)", records.len()),
};
let _ = append_record_usage(
&self.layout,
RecordUsageAudit {
op: "query".to_string(),
status: AuditStatus::Success,
kind: "knowledge".to_string(),
slug: None,
path: None,
query: params.query.clone(),
result_count: Some(records.len()),
reason: if records.len() >= limit {
Some("result_limit_reached".to_string())
} else {
None
},
},
);
Ok(ToolOutput {
summary,
content: Some(body),
+50 -5
View File
@@ -11,6 +11,7 @@ use async_trait::async_trait;
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use serde::Deserialize;
use crate::audit::{AuditStatus, RecordUsageAudit, append_record_usage};
use crate::tool::MemoryToolKind;
use crate::usage::{self, UsageSource};
use crate::workspace::WorkspaceLayout;
@@ -51,13 +52,32 @@ impl Tool for ReadTool {
let path = params
.kind
.resolve_path(&self.layout, params.slug.as_deref())?;
let kind = params.kind.to_string();
let slug = audit_slug(&params.kind, params.slug.as_deref());
let bytes = std::fs::read(&path).map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => {
ToolError::ExecutionFailed(format!("record not found: {}", path.display()))
let bytes = match std::fs::read(&path) {
Ok(bytes) => bytes,
Err(e) => {
let reason = match e.kind() {
std::io::ErrorKind::NotFound => format!("record not found: {}", path.display()),
_ => format!("read failed at {}: {e}", path.display()),
};
let _ = append_record_usage(
&self.layout,
RecordUsageAudit {
op: "read".to_string(),
status: AuditStatus::Failed,
kind,
slug: Some(slug),
path: Some(path.display().to_string()),
query: None,
result_count: None,
reason: Some(reason.clone()),
},
);
return Err(ToolError::ExecutionFailed(reason));
}
_ => ToolError::ExecutionFailed(format!("read failed at {}: {e}", path.display())),
})?;
};
let text = String::from_utf8_lossy(&bytes).into_owned();
if let Some(segment_id) = self.usage_session_id.as_deref() {
@@ -97,6 +117,24 @@ impl Tool for ReadTool {
)
};
let _ = append_record_usage(
&self.layout,
RecordUsageAudit {
op: "read".to_string(),
status: AuditStatus::Success,
kind,
slug: Some(slug),
path: Some(path.display().to_string()),
query: None,
result_count: Some(rendered.line_count),
reason: if rendered.truncated {
Some("truncated".to_string())
} else {
None
},
},
);
Ok(ToolOutput {
summary,
content: Some(rendered.body),
@@ -104,6 +142,13 @@ impl Tool for ReadTool {
}
}
fn audit_slug(kind: &MemoryToolKind, slug: Option<&str>) -> String {
match kind {
MemoryToolKind::Summary => "summary".to_string(),
_ => slug.unwrap_or("<missing>").to_string(),
}
}
struct Rendered {
body: String,
line_count: usize,
+81 -11
View File
@@ -12,6 +12,9 @@ use async_trait::async_trait;
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use serde::Deserialize;
use crate::audit::{
AuditStatus, RecordOperationAudit, append_record_operation, file_hash, hash_bytes,
};
use crate::linter::{LintReport, Linter, WriteMode};
use crate::tool::MemoryToolKind;
use crate::workspace::WorkspaceLayout;
@@ -46,8 +49,11 @@ impl Tool for WriteTool {
let path = params
.kind
.resolve_path(&self.layout, params.slug.as_deref())?;
let kind = params.kind.to_string();
let slug = audit_slug(&params.kind, params.slug.as_deref());
let already_exists = path.exists();
let before_hash = file_hash(&path).ok().flatten();
let already_exists = before_hash.is_some();
let mode = if already_exists {
WriteMode::Update
} else {
@@ -56,20 +62,77 @@ impl Tool for WriteTool {
let report = self.linter.lint(&path, &params.content, mode);
if report.has_errors() {
return Err(ToolError::InvalidArgument(format_report(&report)));
let reason = format_report(&report);
let _ = append_record_operation(
&self.layout,
RecordOperationAudit {
op: "write".to_string(),
status: AuditStatus::Failed,
kind,
slug,
path: path.display().to_string(),
before_hash,
after_hash: None,
reason: Some(reason.clone()),
},
);
return Err(ToolError::InvalidArgument(reason));
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
ToolError::ExecutionFailed(format!(
"failed to create directory {}: {e}",
parent.display()
))
})?;
if let Err(e) = std::fs::create_dir_all(parent) {
let reason = format!("failed to create directory {}: {e}", parent.display());
let _ = append_record_operation(
&self.layout,
RecordOperationAudit {
op: "write".to_string(),
status: AuditStatus::Failed,
kind,
slug,
path: path.display().to_string(),
before_hash,
after_hash: None,
reason: Some(reason.clone()),
},
);
return Err(ToolError::ExecutionFailed(reason));
}
}
std::fs::write(&path, params.content.as_bytes()).map_err(|e| {
ToolError::ExecutionFailed(format!("failed to write {}: {e}", path.display()))
})?;
if let Err(e) = std::fs::write(&path, params.content.as_bytes()) {
let reason = format!("failed to write {}: {e}", path.display());
let _ = append_record_operation(
&self.layout,
RecordOperationAudit {
op: "write".to_string(),
status: AuditStatus::Failed,
kind,
slug,
path: path.display().to_string(),
before_hash,
after_hash: None,
reason: Some(reason.clone()),
},
);
return Err(ToolError::ExecutionFailed(reason));
}
let after_hash = Some(hash_bytes(params.content.as_bytes()));
let _ = append_record_operation(
&self.layout,
RecordOperationAudit {
op: "write".to_string(),
status: AuditStatus::Success,
kind,
slug,
path: path.display().to_string(),
before_hash,
after_hash,
reason: if report.warnings.is_empty() {
None
} else {
Some(format!("{} warning(s)", report.warnings.len()))
},
},
);
let summary = format!(
"{} {}{}",
@@ -88,6 +151,13 @@ impl Tool for WriteTool {
}
}
fn audit_slug(kind: &MemoryToolKind, slug: Option<&str>) -> String {
match kind {
MemoryToolKind::Summary => "summary".to_string(),
_ => slug.unwrap_or("<missing>").to_string(),
}
}
fn format_report(report: &LintReport) -> String {
use std::fmt::Write as _;
let mut buf = String::from("memory linter rejected the write:");