feat(ai): harden local agent file edit guards
This commit is contained in:
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user