feat(ai): harden local agent file edit guards

This commit is contained in:
lix-2026
2026-06-07 01:11:54 +08:00
parent 1f25364374
commit b65501bf86
8 changed files with 1445 additions and 60 deletions
+315 -46
View File
@@ -24,7 +24,7 @@ use std::io::Write;
use std::path::{Path as FsPath, PathBuf};
use std::process::Command;
use std::sync::{Arc, LazyLock, Mutex};
use std::time::Duration;
use std::time::{Duration, Instant};
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::sync::broadcast;
use tracing::{info, warn};
@@ -74,6 +74,9 @@ static ACP_LOCAL_AUDIT_SNAPSHOTS: LazyLock<Mutex<HashMap<String, LocalAgentAudit
const LOCAL_AGENT_AUDIT_DIR: &str = "/mnt/Data1T/Mnote_data/control-plane/agent-audit";
const LOCAL_AGENT_AUDIT_JSONL: &str = "agent-audit.jsonl";
const LOCAL_AGENT_AUDIT_MAX_FILES: usize = 512;
const LOCAL_AGENT_AUDIT_MAX_BYTES: u64 = 32 * 1024 * 1024;
const LOCAL_AGENT_AUDIT_MAX_ELAPSED_MS: u128 = 2_500;
#[derive(Debug, Clone)]
struct HermesRuntimeState {
@@ -133,6 +136,12 @@ struct LocalAgentAuditFileSnapshot {
struct LocalAgentAuditSnapshot {
root_uri: String,
files: BTreeMap<String, LocalAgentAuditFileSnapshot>,
scope: String,
truncated: bool,
truncated_reason: Option<String>,
file_count: usize,
total_bytes: u64,
elapsed_ms: u128,
}
#[derive(Debug, Clone)]
@@ -6287,6 +6296,7 @@ fn build_run_upstream_body(context: &RequestContext, payload: Value) -> Result<V
"并遵守 fileReference.rootUri、aiAccessScope.allowedRoots、MNOTE_AI_ALLOWED_ROOTS_JSON 与 MNOTE_AI_ACCESS_SCOPE_JSON 的授权边界。",
"不要调用已退役 cloud/Convex 文档读取工具读取本地 Markdown。",
"普通 Markdown 编辑优先使用 agent 原生 patch/diff 写入真实文件;写入后回读文件确认结果。",
"不要调用 mnote_doc_markdown_edit 或 mnote_page_save 处理 local-first 普通 Markdown 编辑。",
"只有复杂结构性块操作或 remote/cloud 兼容场景,才考虑 mnote block/doc 工具;",
"mnote_page_save 只允许在用户明确要求整页覆盖/整页追加且块级或文件 patch 无法表达时作为高风险兜底。",
"当 agentRunEnvelope.targetPackage.resourceKind 为 only_office 或 targets 中包含 only_office 时,",
@@ -6471,6 +6481,7 @@ fn build_run_upstream_body(context: &RequestContext, payload: Value) -> Result<V
json!([
"agent native file read/write within allowed roots",
"agent native patch/diff for plain Markdown edits",
"do not use mnote_doc_markdown_edit or mnote_page_save for ordinary local Markdown edits",
"mnote block/doc tools only for complex structural or remote/cloud fallback operations",
"read back the changed file after writing"
])
@@ -6508,7 +6519,8 @@ fn build_run_upstream_body(context: &RequestContext, payload: Value) -> Result<V
"blockEditingToolOrder": block_editing_tool_order,
"pageSavePolicy": {
"mnote_page_save": "fallback_only_for_explicit_whole_page_write",
"forBlockEditing": "forbidden_as_first_choice"
"forBlockEditing": "forbidden_as_first_choice",
"forOrdinaryLocalMarkdown": if is_local_source { "forbidden_use_agent_native_file_patch" } else { "not_applicable" }
},
"selectedBlockId": payload.get("selectedBlockId").cloned().unwrap_or(Value::Null),
"selectedText": payload.get("selectedText").cloned().unwrap_or(Value::Null),
@@ -7505,6 +7517,7 @@ fn build_agent_run_receipt(
status: &str,
permission: &str,
changed_files: Value,
audit_scope: Value,
write_attempt_rejected: bool,
) -> Value {
let touches_current_file = local_agent_audit_touches_current_file(payload, &changed_files);
@@ -7520,6 +7533,7 @@ fn build_agent_run_receipt(
"permission": permission,
"writeAttemptRejected": write_attempt_rejected,
"changedFiles": changed_files,
"auditScope": audit_scope,
"refresh": {
"touchesCurrentFile": touches_current_file,
"currentDocumentId": payload.get("documentId").cloned().unwrap_or(Value::Null),
@@ -8566,7 +8580,59 @@ fn local_agent_audit_snapshot_entry(
})
}
fn local_agent_audit_snapshot(
root_uri: &str,
files: BTreeMap<String, LocalAgentAuditFileSnapshot>,
scope: &str,
truncated_reason: Option<String>,
elapsed_ms: u128,
) -> LocalAgentAuditSnapshot {
let total_bytes = files.values().map(|entry| entry.size).sum();
let file_count = files.len();
LocalAgentAuditSnapshot {
root_uri: root_uri.to_string(),
files,
scope: scope.to_string(),
truncated: truncated_reason.is_some(),
truncated_reason,
file_count,
total_bytes,
elapsed_ms,
}
}
fn local_agent_audit_empty_snapshot(root_uri: &str, scope: &str) -> LocalAgentAuditSnapshot {
local_agent_audit_snapshot(root_uri, BTreeMap::new(), scope, None, 0)
}
fn local_agent_audit_scope_value(snapshot: Option<&LocalAgentAuditSnapshot>) -> Value {
let Some(snapshot) = snapshot else {
return Value::Null;
};
json!({
"scope": snapshot.scope.clone(),
"truncated": snapshot.truncated,
"truncatedReason": snapshot.truncated_reason.clone(),
"fileCount": snapshot.file_count,
"totalBytes": snapshot.total_bytes,
"elapsedMs": snapshot.elapsed_ms,
"limits": {
"maxFiles": LOCAL_AGENT_AUDIT_MAX_FILES,
"maxBytes": LOCAL_AGENT_AUDIT_MAX_BYTES,
"maxElapsedMs": LOCAL_AGENT_AUDIT_MAX_ELAPSED_MS
}
})
}
fn local_agent_audit_effective_scope_value(
source_snapshot: Option<&LocalAgentAuditSnapshot>,
target_snapshot: Option<&LocalAgentAuditSnapshot>,
) -> Value {
local_agent_audit_scope_value(target_snapshot.or(source_snapshot))
}
fn local_agent_audit_collect_snapshot(root_uri: &str) -> Result<LocalAgentAuditSnapshot, WebError> {
let started = Instant::now();
let root = local_ai_session_root_dir(root_uri)?;
let canonical_root = root.canonicalize().map_err(|error| {
WebError::bad_request_code(
@@ -8574,9 +8640,17 @@ fn local_agent_audit_collect_snapshot(root_uri: &str) -> Result<LocalAgentAuditS
format!("无法访问本地文件夹: {error}"),
)
})?;
let mut files = BTreeMap::new();
let mut files: BTreeMap<String, LocalAgentAuditFileSnapshot> = BTreeMap::new();
let mut truncated_reason = None;
let mut stack = vec![canonical_root.clone()];
while let Some(dir) = stack.pop() {
if truncated_reason.is_some() {
break;
}
if started.elapsed().as_millis() > LOCAL_AGENT_AUDIT_MAX_ELAPSED_MS {
truncated_reason = Some("max_elapsed_ms".to_string());
break;
}
for entry in fs::read_dir(&dir).map_err(|error| {
WebError::bad_request_code(
"local_ai_audit_snapshot_failed",
@@ -8606,6 +8680,10 @@ fn local_agent_audit_collect_snapshot(root_uri: &str) -> Result<LocalAgentAuditS
if !metadata.is_file() {
continue;
}
if files.len() >= LOCAL_AGENT_AUDIT_MAX_FILES {
truncated_reason = Some("max_files".to_string());
break;
}
let relative_path = path
.strip_prefix(&canonical_root)
.unwrap_or(&path)
@@ -8614,13 +8692,23 @@ fn local_agent_audit_collect_snapshot(root_uri: &str) -> Result<LocalAgentAuditS
if relative_path.is_empty() {
continue;
}
files.insert(relative_path, local_agent_audit_snapshot_entry(&path)?);
let snapshot_entry = local_agent_audit_snapshot_entry(&path)?;
let next_total =
files.values().map(|entry| entry.size).sum::<u64>() + snapshot_entry.size;
if next_total > LOCAL_AGENT_AUDIT_MAX_BYTES {
truncated_reason = Some("max_bytes".to_string());
break;
}
files.insert(relative_path, snapshot_entry);
}
}
Ok(LocalAgentAuditSnapshot {
root_uri: root_uri.to_string(),
Ok(local_agent_audit_snapshot(
root_uri,
files,
})
"full_root",
truncated_reason,
started.elapsed().as_millis(),
))
}
fn local_agent_audit_context_ref_requires_full_snapshot(ref_value: &Value) -> bool {
@@ -8642,8 +8730,41 @@ fn local_agent_audit_push_relative_path(paths: &mut Vec<String>, value: Option<&
paths.push(normalized);
}
fn local_agent_audit_push_allowed_files(paths: &mut Vec<String>, value: Option<&Value>) {
let Some(value) = value else {
return;
};
for relative_path in local_agent_target_allowed_files(value) {
local_agent_audit_push_relative_path(paths, Some(&Value::String(relative_path)));
}
}
fn local_agent_audit_relative_paths_from_payload(payload: &Value) -> Option<Vec<String>> {
let mut paths = Vec::new();
local_agent_audit_push_allowed_files(&mut paths, payload.get("targetPackage"));
local_agent_audit_push_allowed_files(
&mut paths,
payload
.get("pageContext")
.and_then(|page_context| page_context.get("aiContext"))
.and_then(|ai_context| ai_context.get("agentTargetPackage")),
);
local_agent_audit_push_allowed_files(
&mut paths,
payload
.get("agentRunEnvelope")
.and_then(|envelope| envelope.get("targetPackage")),
);
if let Some(items) = payload.get("allowedFiles").and_then(Value::as_array) {
for item in items {
local_agent_audit_push_relative_path(&mut paths, Some(item));
}
}
if !paths.is_empty() {
paths.sort();
paths.dedup();
return Some(paths);
}
if let Some(items) = payload.get("contextRefs").and_then(Value::as_array) {
if items
.iter()
@@ -8687,6 +8808,7 @@ fn local_agent_audit_collect_snapshot_for_paths(
root_uri: &str,
relative_paths: &[String],
) -> Result<LocalAgentAuditSnapshot, WebError> {
let started = Instant::now();
let root = local_ai_session_root_dir(root_uri)?;
let canonical_root = root.canonicalize().map_err(|error| {
WebError::bad_request_code(
@@ -8694,8 +8816,17 @@ fn local_agent_audit_collect_snapshot_for_paths(
format!("无法访问本地文件夹: {error}"),
)
})?;
let mut files = BTreeMap::new();
let mut files: BTreeMap<String, LocalAgentAuditFileSnapshot> = BTreeMap::new();
let mut truncated_reason = None;
for relative_path in relative_paths {
if started.elapsed().as_millis() > LOCAL_AGENT_AUDIT_MAX_ELAPSED_MS {
truncated_reason = Some("max_elapsed_ms".to_string());
break;
}
if files.len() >= LOCAL_AGENT_AUDIT_MAX_FILES {
truncated_reason = Some("max_files".to_string());
break;
}
let normalized = relative_path.replace('\\', "/");
if normalized.is_empty()
|| normalized.starts_with('/')
@@ -8716,15 +8847,21 @@ fn local_agent_audit_collect_snapshot_for_paths(
if !canonical_path.starts_with(&canonical_root) || !canonical_path.is_file() {
continue;
}
files.insert(
normalized,
local_agent_audit_snapshot_entry(&canonical_path)?,
);
let snapshot_entry = local_agent_audit_snapshot_entry(&canonical_path)?;
let next_total = files.values().map(|entry| entry.size).sum::<u64>() + snapshot_entry.size;
if next_total > LOCAL_AGENT_AUDIT_MAX_BYTES {
truncated_reason = Some("max_bytes".to_string());
break;
}
files.insert(normalized, snapshot_entry);
}
Ok(LocalAgentAuditSnapshot {
root_uri: root_uri.to_string(),
Ok(local_agent_audit_snapshot(
root_uri,
files,
})
"allowed_files",
truncated_reason,
started.elapsed().as_millis(),
))
}
fn local_agent_audit_collect_snapshot_for_payload(
@@ -8876,7 +9013,7 @@ fn local_agent_audit_event(
status: &str,
changed_files: Value,
source_snapshot: Option<&LocalAgentAuditSnapshot>,
_target_snapshot: Option<&LocalAgentAuditSnapshot>,
target_snapshot: Option<&LocalAgentAuditSnapshot>,
write_attempt_rejected: bool,
) -> Value {
let root_uri = payload
@@ -8902,6 +9039,7 @@ 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 audit_scope = local_agent_audit_effective_scope_value(source_snapshot, target_snapshot);
let agent_run_receipt = build_agent_run_receipt(
payload,
run_id,
@@ -8909,6 +9047,7 @@ fn local_agent_audit_event(
status,
permission,
changed_files.clone(),
audit_scope.clone(),
write_attempt_rejected,
);
json!({
@@ -8925,6 +9064,7 @@ fn local_agent_audit_event(
"status": if write_attempt_rejected { "read_only_write_rejected" } else { status },
"writeAttemptRejected": write_attempt_rejected,
"changedFiles": changed_files,
"auditScope": audit_scope,
"agentRunReceipt": agent_run_receipt,
"diffSummary": format!("{changed_file_count} changed file(s)"),
"createdAt": now_ms(),
@@ -8947,18 +9087,12 @@ fn local_agent_audit_finalize_run(
let changed_files = match (&before, &after) {
(Some(before), Some(after)) => local_agent_audit_change_files(before, after),
(None, Some(after)) => local_agent_audit_change_files(
&LocalAgentAuditSnapshot {
root_uri: after.root_uri.clone(),
files: BTreeMap::new(),
},
&local_agent_audit_empty_snapshot(&after.root_uri, "empty"),
after,
),
(Some(before), None) => local_agent_audit_change_files(
before,
&LocalAgentAuditSnapshot {
root_uri: before.root_uri.clone(),
files: BTreeMap::new(),
},
&local_agent_audit_empty_snapshot(&before.root_uri, "empty"),
),
(None, None) => Value::Array(vec![]),
};
@@ -10515,25 +10649,26 @@ mod tests {
.all(|capability| capability["id"] != "mnote-chat-only"),
"纯聊天是 agent 模式,不应作为 MNote 公共能力展示"
);
let local_index = payload["categories"]
assert!(!payload["categories"]
.as_array()
.expect("categories")
.iter()
.flat_map(|category| category["capabilities"].as_array().into_iter().flatten())
.find(|capability| capability["id"] == "mnote-local-index")
.expect("local index capability");
assert_eq!(local_index["enabled"], true);
assert_eq!(local_index["uiKind"], "ai_capability");
assert!(local_index["tools"]
.any(|capability| capability["id"] == "mnote-local-index"));
let knowledge_rag = payload["categories"]
.as_array()
.expect("local index tools")
.expect("categories")
.iter()
.any(|tool| tool["name"] == "mnote.index.status"));
assert!(local_index["tools"]
.flat_map(|category| category["capabilities"].as_array().into_iter().flatten())
.find(|capability| capability["id"] == "mnote-knowledge-rag")
.expect("knowledge rag capability");
assert_eq!(knowledge_rag["enabled"], true);
assert_eq!(knowledge_rag["uiKind"], "ai_capability");
assert!(knowledge_rag["tools"]
.as_array()
.expect("local index tools")
.expect("knowledge rag tools")
.iter()
.any(|tool| tool["name"] == "mnote.index.update_settings"));
.any(|tool| tool["name"] == "mnote.knowledge_rag.query"));
let toggle_response = app
.clone()
@@ -10548,7 +10683,7 @@ mod tests {
json!({
"runtime": "mnote",
"profile": "chemist",
"id": "mnote-local-index",
"id": "mnote-knowledge-rag",
"enabled": false
})
.to_string(),
@@ -10577,20 +10712,20 @@ mod tests {
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("capabilities json");
let local_index = payload["categories"]
let knowledge_rag = payload["categories"]
.as_array()
.expect("categories")
.iter()
.flat_map(|category| category["capabilities"].as_array().into_iter().flatten())
.find(|capability| capability["id"] == "mnote-local-index")
.expect("local index capability");
assert_eq!(local_index["enabled"], false);
assert_eq!(local_index["status"], "disabled");
assert!(local_index["tools"]
.find(|capability| capability["id"] == "mnote-knowledge-rag")
.expect("knowledge rag capability");
assert_eq!(knowledge_rag["enabled"], false);
assert_eq!(knowledge_rag["status"], "disabled");
assert!(knowledge_rag["tools"]
.as_array()
.expect("local index tools")
.expect("knowledge rag tools")
.iter()
.any(|tool| tool["name"] == "mnote.index.status" && tool["enabled"] == false));
.any(|tool| tool["name"] == "mnote.knowledge_rag.query" && tool["enabled"] == false));
let tools_response = app
.oneshot(
@@ -10619,8 +10754,7 @@ mod tests {
)
})
.collect::<HashMap<_, _>>();
assert_eq!(tools["mnote.index.status"]["enabled"], false);
assert_eq!(tools["mnote.index.update_settings"]["enabled"], false);
assert_eq!(tools["mnote.knowledge_rag.query"]["enabled"], false);
std::env::remove_var("HERMES_HOME");
let _ = fs::remove_dir_all(&hermes_home);
@@ -10963,6 +11097,124 @@ mod tests {
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_agent_audit_allowed_files_override_folder_context() {
let root = std::env::temp_dir().join(format!(
"mnote-local-agent-audit-allowed-files-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("create root");
std::fs::write(root.join("a.md"), "# A\nold\n").expect("write a");
std::fs::write(root.join("b.md"), "# B\nold\n").expect("write b");
let root_uri = format!("file://{}", root.display());
let payload = json!({
"documentId": "local-md:a.md",
"rootUri": root_uri,
"contextRefs": [{
"kind": "folder",
"rootUri": root_uri,
"relativePath": ""
}],
"targetPackage": {
"schema": "mnote.agent_target_package.v1",
"allowedFiles": ["a.md"],
"currentFile": {
"relativePath": "a.md"
}
}
});
let before = local_agent_audit_collect_snapshot_for_payload(&payload, None)
.expect("before scoped snapshot");
assert!(before.files.contains_key("a.md"));
assert!(!before.files.contains_key("b.md"));
std::fs::write(root.join("a.md"), "# A\nnew\n").expect("modify a");
std::fs::write(root.join("b.md"), "# B\nnew\n").expect("modify b");
let after = local_agent_audit_collect_snapshot_for_payload(&payload, Some(&before))
.expect("after scoped snapshot");
let changed = local_agent_audit_change_files(&before, &after);
let files = changed.as_array().expect("changed files");
assert_eq!(files.len(), 1);
assert_eq!(files[0]["path"], "a.md");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_agent_audit_reads_allowed_files_from_agent_run_envelope() {
let payload = json!({
"contextRefs": [{
"kind": "folder",
"relativePath": ""
}],
"agentRunEnvelope": {
"targetPackage": {
"allowedFiles": ["nested/a.md", "../blocked.md", "/abs.md", ""],
"currentFile": {
"relativePath": "nested/a.md"
}
}
}
});
let paths = local_agent_audit_relative_paths_from_payload(&payload)
.expect("allowed files should avoid full snapshot");
assert_eq!(paths, vec!["abs.md".to_string(), "nested/a.md".to_string()]);
}
#[test]
fn local_agent_audit_full_snapshot_truncates_and_receipt_exposes_scope() {
let root = std::env::temp_dir().join(format!(
"mnote-local-agent-audit-truncated-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("create root");
for index in 0..(LOCAL_AGENT_AUDIT_MAX_FILES + 1) {
std::fs::write(
root.join(format!("file-{index}.md")),
format!("# {index}\n"),
)
.expect("write file");
}
let root_uri = format!("file://{}", root.display());
let snapshot = local_agent_audit_collect_snapshot(&root_uri).expect("snapshot");
assert!(snapshot.truncated);
assert_eq!(snapshot.truncated_reason.as_deref(), Some("max_files"));
assert_eq!(snapshot.file_count, LOCAL_AGENT_AUDIT_MAX_FILES);
let context = RequestContext::from_http_parts(
&axum::http::Method::POST,
&"/api/hermes/client/runs".parse().expect("uri"),
&HeaderMap::new(),
);
let event = local_agent_audit_event(
&context,
&json!({
"workspaceId": "local-workspace-1",
"documentId": "local-md:file-0.md",
"sessionId": "sess_local_1",
"rootUri": root_uri,
}),
"run_truncated",
"reasonix",
"completed",
json!([]),
Some(&snapshot),
Some(&snapshot),
false,
);
assert_eq!(event["auditScope"]["truncated"], true);
assert_eq!(event["auditScope"]["truncatedReason"], "max_files");
assert_eq!(event["agentRunReceipt"]["auditScope"]["truncated"], true);
assert_eq!(
event["agentRunReceipt"]["auditScope"]["limits"]["maxFiles"],
LOCAL_AGENT_AUDIT_MAX_FILES
);
let _ = std::fs::remove_dir_all(&root);
}
fn app() -> axum::Router {
app_with_config(AppConfig {
service_name: "mnote-web".into(),
@@ -13995,6 +14247,13 @@ mod tests {
assert!(instructions.contains("\"relativePath\":\"README.md\""));
assert!(instructions.contains("本地文件夹上下文"));
assert!(instructions.contains("agent 自身文件读取/编辑能力"));
assert!(instructions
.contains("不要调用 mnote_doc_markdown_edit 或 mnote_page_save 处理 local-first 普通 Markdown 编辑"));
assert!(instructions.contains(
"do not use mnote_doc_markdown_edit or mnote_page_save for ordinary local Markdown edits"
));
assert!(instructions
.contains("\"forOrdinaryLocalMarkdown\":\"forbidden_use_agent_native_file_patch\""));
assert!(!instructions.contains("mnote_doc_fetch"));
assert!(instructions.contains("\"selectedText\":\"选中的句子\""));
assert!(!instructions.contains("完整页面正文不应进入本地 agent instructions"));
@@ -14932,7 +15191,17 @@ mod tests {
assert!(tools.contains_key("mnote.block.delete"));
assert!(tools.contains_key("mnote.block.move_after"));
assert!(tools.contains_key("mnote.doc.apply_block_ops"));
assert!(tools.contains_key("mnote.doc.markdown_edit"));
assert!(tools.contains_key("mnote.page.save"));
assert_eq!(tools["mnote.doc.fetch"]["enabled"], true);
assert!(tools["mnote.doc.markdown_edit"]["description"]
.as_str()
.unwrap_or_default()
.contains("local-first 本地 workspace 的普通 Markdown 编辑禁止使用该工具"));
assert!(tools["mnote.page.save"]["description"]
.as_str()
.unwrap_or_default()
.contains("local-first 本地 Markdown 普通编辑禁止使用该工具"));
assert_eq!(tools["mnote.block.replace"]["enabled"], false);
assert_eq!(tools["mnote.block.replace"]["status"], "disabled");
assert_eq!(