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
@@ -18,6 +18,110 @@ use tracing::{info, warn};
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_HERMES_TOOL_OWNER: &str = "x-mnote-hermes-tool-owner";
fn record_local_agent_write_rejection(
context: &RequestContext,
input: &ToolCallInput,
profile: &str,
code: &str,
message: &str,
) {
let Some(run_id) = input
.run_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return;
};
let payload = json!({
"toolName": input.tool_name.clone(),
"toolCallId": input.effective_tool_call_id(),
"sessionId": input.session_id.clone(),
"workspaceId": input.effective_workspace_id(),
"documentId": input.effective_document_id(),
"rootUri": input.effective_root_uri(),
"actorId": input.actor_id.clone(),
"actorType": input.arg_string("actorType").or_else(|| input.arg_string("actor_type")),
"permissionLevel": "read_only",
"rejection": {
"code": code,
"message": message,
"source": "local_ai_scope",
"toolName": input.tool_name.clone(),
},
});
if let Err(error) =
hermes_client::local_agent_audit_record_write_rejected(context, payload, run_id, profile)
{
warn!(
error = ?error,
run_id = %run_id,
tool_name = %input.tool_name,
"本地 agent 拒绝审计写入失败"
);
}
}
fn record_local_agent_tool_write(
context: &RequestContext,
input: &ToolCallInput,
profile: &str,
result: &Value,
) {
if input.effective_source_kind().as_deref() != Some("local_folder") {
return;
}
let Some(run_id) = input
.run_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return;
};
let document_id = input.effective_document_id().unwrap_or_default();
let command_name = result
.get("commandName")
.or_else(|| result.pointer("/applyResult/commandName"))
.and_then(Value::as_str)
.unwrap_or(input.tool_name.as_str());
let file_version = result
.get("fileVersion")
.or_else(|| result.pointer("/result/fileVersion"))
.or_else(|| result.pointer("/applyResult/result/fileVersion"))
.cloned()
.unwrap_or(Value::Null);
let changed_files = json!([{
"path": document_id,
"changeType": "modified",
"summary": format!("MNote tool 写入:{command_name}"),
"fileVersion": file_version
}]);
let payload = json!({
"toolName": input.tool_name.clone(),
"toolCallId": input.effective_tool_call_id(),
"sessionId": input.session_id.clone(),
"workspaceId": input.effective_workspace_id(),
"documentId": input.effective_document_id(),
"rootUri": input.effective_root_uri(),
"actorId": input.actor_id.clone(),
"actorType": input.arg_string("actorType").or_else(|| input.arg_string("actor_type")),
"permissionLevel": "read_write",
"commandName": command_name,
"changedFiles": changed_files,
});
if let Err(error) =
hermes_client::local_agent_audit_record_tool_write(context, payload, run_id, profile)
{
warn!(
error = ?error,
run_id = %run_id,
tool_name = %input.tool_name,
"本地 agent tool 写入审计失败"
);
}
}
pub async fn mnote_audit(
Extension(context): Extension<RequestContext>,
Query(query): Query<HashMap<String, String>>,
@@ -193,6 +297,13 @@ pub(crate) async fn execute_mnote_tool_call(
"message": error.message(),
"permissionLevel": "shared_read"
}));
record_local_agent_write_rejection(
&context,
&input,
&profile,
error.code(),
error.message(),
);
return Err(error);
}
if let Some(cached) = idempotency_key.as_deref().and_then(idempotency_cache_get) {
@@ -256,6 +367,18 @@ pub(crate) async fn execute_mnote_tool_call(
message = %error.message(),
"mnote Hermes tool call failed"
);
if matches!(
error.code(),
"mnote_tool_ai_scope_write_forbidden" | "mnote_tool_shared_read_write_forbidden"
) {
record_local_agent_write_rejection(
&context,
&input,
&profile,
error.code(),
error.message(),
);
}
audit_push(json!({
"phase": "failed",
"traceId": trace_id,
@@ -271,6 +394,9 @@ pub(crate) async fn execute_mnote_tool_call(
}));
}
let result = result?;
if !dry_run && !is_read_tool(&input.tool_name) {
record_local_agent_tool_write(&context, &input, &profile, &result);
}
let command_id = result.get("commandId").cloned().unwrap_or(Value::Null);
info!(
trace_id = %trace_id,
@@ -2291,11 +2417,18 @@ mod tests {
#[tokio::test]
async fn hermes_tools_page_save_local_folder_writes_markdown_file() {
let _env_guard = env_lock().lock().expect("env lock");
let root = std::env::temp_dir().join(format!(
"mnote-page-save-local-folder-{}",
std::process::id()
));
let audit_dir = std::env::temp_dir().join(format!(
"mnote-local-agent-audit-tool-write-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
let _ = fs::remove_dir_all(&audit_dir);
std::env::set_var("MNOTE_LOCAL_AGENT_AUDIT_DIR", &audit_dir);
fs::create_dir_all(root.join(".mnote")).expect("metadata");
fs::write(
root.join(".mnote").join("workspace.json"),
@@ -2350,7 +2483,22 @@ mod tests {
assert_eq!(payload["result"]["commandName"], "page.body.write");
let saved = fs::read_to_string(root.join("README.md")).expect("read");
assert!(saved.contains("本地 page.save 写入"), "{saved}");
let jsonl = fs::read_to_string(audit_dir.join("agent-audit.jsonl")).expect("audit jsonl");
let event = jsonl
.lines()
.filter(|line| !line.trim().is_empty())
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
.find(|event| event["runId"] == "run_page_save_local")
.expect("audit event");
assert_eq!(event["origin"], "mnote_tool");
assert_eq!(event["toolName"], "mnote.page.save");
assert_eq!(event["toolCallId"], "call_page_save_local");
assert_eq!(event["writeAttemptRejected"], false);
assert_eq!(event["changedFiles"][0]["path"], "local-md:README.md");
assert_eq!(event["changedFiles"][0]["changeType"], "modified");
std::env::remove_var("MNOTE_LOCAL_AGENT_AUDIT_DIR");
let _ = fs::remove_dir_all(&root);
let _ = fs::remove_dir_all(&audit_dir);
}
#[tokio::test]
@@ -2503,11 +2651,18 @@ mod tests {
#[tokio::test]
async fn hermes_tools_markdown_edit_shared_read_is_forbidden() {
let _env_guard = env_lock().lock().expect("env lock");
let root = std::env::temp_dir().join(format!(
"mnote-markdown-edit-shared-read-{}",
std::process::id()
));
let audit_dir = std::env::temp_dir().join(format!(
"mnote-local-agent-audit-shared-read-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
let _ = fs::remove_dir_all(&audit_dir);
std::env::set_var("MNOTE_LOCAL_AGENT_AUDIT_DIR", &audit_dir);
fs::create_dir_all(root.join(".mnote")).expect("metadata");
fs::write(
root.join(".mnote").join("workspace.json"),
@@ -2566,7 +2721,97 @@ mod tests {
fs::read_to_string(root.join("README.md")).expect("read"),
"原文\n"
);
std::env::remove_var("MNOTE_LOCAL_AGENT_AUDIT_DIR");
let _ = fs::remove_dir_all(&root);
let _ = fs::remove_dir_all(&audit_dir);
}
#[tokio::test]
async fn hermes_tools_markdown_edit_shared_read_rejection_writes_local_agent_audit() {
let _env_guard = env_lock().lock().expect("env lock");
let root = std::env::temp_dir().join(format!(
"mnote-markdown-edit-shared-read-audit-{}",
std::process::id()
));
let audit_dir = std::env::temp_dir().join(format!(
"mnote-local-agent-audit-rejected-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
let _ = fs::remove_dir_all(&audit_dir);
std::env::set_var("MNOTE_LOCAL_AGENT_AUDIT_DIR", &audit_dir);
fs::create_dir_all(root.join(".mnote")).expect("metadata");
fs::write(
root.join(".mnote").join("workspace.json"),
r#"{"workspaceId":"local-ws-user-1","ownerId":"user_1","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files","ai_sessions"]}"#,
)
.expect("manifest");
fs::write(root.join("README.md"), "原文\n").expect("write markdown");
let root_uri = format!("file://{}", root.display());
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/hermes/tools/mnote/call")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_1")
.header("x-mnote-actor-type", "user")
.body(Body::from(
json!({
"toolName": "mnote.doc.markdown_edit",
"workspaceId": "local-ws-user-1",
"documentId": "local-md:README.md",
"sourceKind": "local_folder",
"rootUri": root_uri,
"actorId": "user_1",
"sessionId": "sess_shared_read_md_audit",
"runId": "run_shared_read_md_audit",
"toolCallId": "call_shared_read_md_audit",
"traceId": "trace_shared_read_md_audit",
"idempotencyKey": "idem_shared_read_md_audit",
"dryRun": false,
"args": {
"aiAccessScope": {
"permissionLevel": "shared_read",
"shareContext": {"shareId": "share_read_1"}
},
"operations": [{"search": "原文", "replace": "不应写入"}]
}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
let jsonl = fs::read_to_string(audit_dir.join("agent-audit.jsonl")).expect("audit jsonl");
let event = jsonl
.lines()
.filter(|line| !line.trim().is_empty())
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
.find(|event| event["runId"] == "run_shared_read_md_audit")
.expect("audit event");
assert_eq!(event["runId"], "run_shared_read_md_audit");
assert_eq!(event["toolName"], "mnote.doc.markdown_edit");
assert_eq!(event["toolCallId"], "call_shared_read_md_audit");
assert_eq!(event["status"], "read_only_write_rejected");
assert_eq!(event["writeAttemptRejected"], true);
assert_eq!(
event["rejection"]["code"],
"mnote_tool_shared_read_write_forbidden"
);
assert!(event["changedFiles"].as_array().unwrap().is_empty());
assert_eq!(
fs::read_to_string(root.join("README.md")).expect("read"),
"原文\n"
);
std::env::remove_var("MNOTE_LOCAL_AGENT_AUDIT_DIR");
let _ = fs::remove_dir_all(&root);
let _ = fs::remove_dir_all(&audit_dir);
}
#[tokio::test]