feat: audit local agent file writes

- 为本地 agent 写入补充审计事件,区分原生修改与 mnote tool 写入
- 只读 grant 写入尝试会记录拒绝事件,便于会话面板追踪 changed files
- 页面 AI smoke 脚本补充 changed files 展示链路验证
- 更新当前优先级 checklist 的完成状态与验证记录
This commit is contained in:
lix-2026
2026-05-19 08:49:02 +08:00
parent cdff672aa5
commit 8ed594f1c2
5 changed files with 421 additions and 41 deletions
@@ -13,6 +13,7 @@ use futures_util::TryStreamExt;
use serde::Deserialize;
use serde_json::{json, Value};
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
use std::env;
use std::fs;
use std::hash::{Hash, Hasher};
use std::io::Write;
@@ -3752,7 +3753,12 @@ fn local_ai_session_root_dir(root_uri: &str) -> Result<PathBuf, WebError> {
}
fn local_agent_audit_root_dir() -> PathBuf {
PathBuf::from(LOCAL_AGENT_AUDIT_DIR)
env::var("MNOTE_LOCAL_AGENT_AUDIT_DIR")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(LOCAL_AGENT_AUDIT_DIR))
}
fn local_agent_audit_path() -> PathBuf {
@@ -3990,6 +3996,7 @@ fn local_agent_audit_event(
changed_files: Value,
source_snapshot: Option<&LocalAgentAuditSnapshot>,
_target_snapshot: Option<&LocalAgentAuditSnapshot>,
write_attempt_rejected: bool,
) -> Value {
let root_uri = payload
.get("rootUri")
@@ -4014,7 +4021,6 @@ fn local_agent_audit_event(
.filter(|value| !value.trim().is_empty())
.unwrap_or(&context.auth.actor_type);
let changed_file_count = changed_files.as_array().map(Vec::len).unwrap_or_default();
let write_attempt_rejected = permission == "read" && changed_file_count > 0;
json!({
"eventId": format!("local_audit:{}:{}", sanitize_id_part(run_id), now_ms()),
"actorId": actor_id,
@@ -4065,6 +4071,8 @@ fn local_agent_audit_finalize_run(
),
(None, None) => Value::Array(vec![]),
};
let write_attempt_rejected = local_ai_payload_is_read_only(payload)
&& changed_files.as_array().map(Vec::len).unwrap_or_default() > 0;
let event = local_agent_audit_event(
context,
payload,
@@ -4074,11 +4082,87 @@ fn local_agent_audit_finalize_run(
changed_files,
before.as_ref(),
after.as_ref(),
write_attempt_rejected,
);
local_agent_audit_write_event(&event)?;
Ok(event)
}
pub(crate) fn local_agent_audit_record_write_rejected(
context: &RequestContext,
payload: Value,
run_id: &str,
acp_runtime: &str,
) -> Result<Value, WebError> {
let mut event = local_agent_audit_event(
context,
&payload,
run_id,
acp_runtime,
"read_only_write_rejected",
Value::Array(Vec::new()),
None,
None,
true,
);
if let Some(object) = event.as_object_mut() {
object.insert(
"toolName".into(),
payload.get("toolName").cloned().unwrap_or(Value::Null),
);
object.insert(
"toolCallId".into(),
payload.get("toolCallId").cloned().unwrap_or(Value::Null),
);
object.insert(
"rejection".into(),
payload.get("rejection").cloned().unwrap_or(Value::Null),
);
}
local_agent_audit_write_event(&event)?;
Ok(event)
}
pub(crate) fn local_agent_audit_record_tool_write(
context: &RequestContext,
payload: Value,
run_id: &str,
acp_runtime: &str,
) -> Result<Value, WebError> {
let changed_files = payload
.get("changedFiles")
.cloned()
.unwrap_or_else(|| Value::Array(Vec::new()));
let mut event = local_agent_audit_event(
context,
&payload,
run_id,
acp_runtime,
"completed",
changed_files,
None,
None,
false,
);
if let Some(object) = event.as_object_mut() {
object.insert(
"toolName".into(),
payload.get("toolName").cloned().unwrap_or(Value::Null),
);
object.insert(
"toolCallId".into(),
payload.get("toolCallId").cloned().unwrap_or(Value::Null),
);
object.insert(
"commandName".into(),
payload.get("commandName").cloned().unwrap_or(Value::Null),
);
object.insert("origin".into(), Value::String("mnote_tool".into()));
}
local_agent_audit_write_event(&event)?;
Ok(event)
}
fn local_ai_session_dir(root_uri: &str, share_id: Option<&str>) -> Result<PathBuf, WebError> {
let root = local_ai_session_root_dir(root_uri)?;
let mut dir = root.join("ai-sessions");