Harden auth/vault path sanitization and clean WeKnora docs

This commit is contained in:
Agent Board
2026-07-28 17:04:27 +08:00
parent 2deaf59f7b
commit 26ff1a9c9a
190 changed files with 13454 additions and 4987 deletions
+237 -27
View File
@@ -171,13 +171,39 @@ fn agent_profile_home() -> PathBuf {
.unwrap_or_else(|| PathBuf::from(".mnote-agent"))
}
fn agent_profile_config_path(profile: &str) -> PathBuf {
let home = agent_profile_home();
/// profile 段只允许单层安全名,拒绝 `/`、`\`、`..`,防止拼到 profiles/ 外。
fn sanitize_agent_profile_segment(profile: &str) -> Option<&str> {
let profile = profile.trim();
if profile.is_empty() || profile == "default" {
return home.join("config.yaml");
return None;
}
let candidate = home.join("profiles").join(profile);
if profile.contains('/')
|| profile.contains('\\')
|| profile.contains('\0')
|| profile == "."
|| profile == ".."
|| profile
.split(['/', '\\'])
.any(|seg| seg.is_empty() || seg == "." || seg == "..")
{
return None;
}
// 仅允许常见 profile 标识字符,避免奇怪路径段。
if !profile
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
{
return None;
}
Some(profile)
}
fn agent_profile_config_path(profile: &str) -> PathBuf {
let home = agent_profile_home();
let Some(safe) = sanitize_agent_profile_segment(profile) else {
return home.join("config.yaml");
};
let candidate = home.join("profiles").join(safe);
if candidate.exists() {
candidate.join("config.yaml")
} else {
@@ -388,6 +414,13 @@ pub async fn mnote_call(
Extension(context): Extension<RequestContext>,
Json(input): Json<ToolCallInput>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
// 7-76:外部 AI 经 tools 调用时按读写工具要求 notes scope。
let required = if is_read_tool(&input.tool_name) {
crate::routes::api_access_token::SCOPE_NOTES_READ
} else {
crate::routes::api_access_token::SCOPE_NOTES_WRITE
};
crate::routes::api_access_token::ensure_scope(&context, required)?;
let response_body = execute_mnote_tool_call(&state, &context, input).await?;
Ok((StatusCode::OK, stamp_tool_headers(), Json(response_body)))
}
@@ -588,15 +621,6 @@ pub(crate) async fn execute_mnote_tool_call(
.with_context(&context))
}
"mnote.knowledge_rag.status" => knowledge_rag::status(&state, &context, &input).await,
"mnote.weknora.search"
| "mnote.weknora.list_sources"
| "mnote.weknora.get_source_status"
| "mnote.weknora.open_reference" => Err(WebError::new(
StatusCode::GONE,
"mnote_weknora_tools_retired",
"WeKnora 专用工具已从默认 agent manifest 移除;请改用 provider-neutral mnote.knowledge_rag.*,或显式启动 legacy WeKnora provider 调试。",
)
.with_context(&context)),
"mnote.knowledge_rag.query" => knowledge_rag::query(&state, &context, &input).await,
"mnote.knowledge_rag.section_context" => {
knowledge_rag::section_context(&state, &context, &input).await
@@ -905,8 +929,11 @@ fn required_capability_scope(tool_name: &str) -> Vec<String> {
}
fn declared_capability_scope_covers(declared: Option<&Vec<String>>, required: &[String]) -> bool {
// required 为空时由 ensure_tool_capability_scope 短路。
let Some(declared) = declared else {
// 兼容旧调用方:缺省 capabilityScope 不改变既有执行路径。
// 兼容旧调用方:完全未声明 capabilityScope 不改变既有执行路径。
// 写工具仍由 shared_read / aiAccessScope / commandContext 等合同 fail-closed。
// 注意:显式声明 `[]` 与“未声明”语义不同——空数组表示调用方主动声明无能力,必须拒绝。
return true;
};
let declared = declared
@@ -914,6 +941,10 @@ fn declared_capability_scope_covers(declared: Option<&Vec<String>>, required: &[
.map(|value| normalize_capability_scope(value))
.filter(|value| !value.is_empty())
.collect::<Vec<_>>();
// 显式空 capabilityScope → fail-closed(禁止 None 与 [] 混同为“放行”)。
if declared.is_empty() {
return false;
}
required.iter().all(|scope| {
declared
.iter()
@@ -1311,6 +1342,7 @@ fn stamp_tool_headers() -> HeaderMap {
#[cfg(test)]
mod tests {
use super::{agent_profile_config_path, sanitize_agent_profile_segment};
use crate::app::{build_app, AppConfig, AppState};
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
@@ -1325,6 +1357,37 @@ mod tests {
crate::test_support::agent_env_lock()
}
#[test]
fn agent_profile_segment_rejects_path_traversal() {
assert!(sanitize_agent_profile_segment("ok-profile").is_some());
assert!(sanitize_agent_profile_segment("../etc").is_none());
assert!(sanitize_agent_profile_segment("a/b").is_none());
assert!(sanitize_agent_profile_segment("..").is_none());
assert!(sanitize_agent_profile_segment("default").is_none());
// 危险段回落到 default config.yaml,路径中不得含攻击串
let path = agent_profile_config_path("../../../etc/passwd");
let s = path.to_string_lossy();
assert!(!s.contains("etc/passwd"), "{s}");
assert!(s.ends_with("config.yaml"), "{s}");
}
#[test]
fn declared_capability_scope_empty_vec_is_fail_closed() {
use super::declared_capability_scope_covers;
let required = vec!["page.write".to_string()];
// 未声明:兼容旧路径
assert!(declared_capability_scope_covers(None, &required));
// 显式空:拒绝
let empty: Vec<String> = vec![];
assert!(!declared_capability_scope_covers(Some(&empty), &required));
// 显式覆盖:通过
let ok = vec!["page.write".to_string()];
assert!(declared_capability_scope_covers(Some(&ok), &required));
// 仅 read 不覆盖 write
let read_only = vec!["page.read".to_string()];
assert!(!declared_capability_scope_covers(Some(&read_only), &required));
}
fn app() -> axum::Router {
build_app(AppState::new(AppConfig {
service_name: "mnote-web".into(),
@@ -1389,6 +1452,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
}
@@ -1439,6 +1503,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
}
@@ -2333,6 +2398,10 @@ mod tests {
"dryRun": true,
"capabilityScope": ["office.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"address": "A1",
"value": "after"
}
@@ -2476,6 +2545,10 @@ mod tests {
"dryRun": true,
"capabilityScope": ["office.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"onlyofficeSessionId": bridge_session_id,
"address": "A1",
"value": "after"
@@ -4951,6 +5024,10 @@ mod tests {
"dryRun": true,
"capabilityScope": ["page.write", "block.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"command": "block_replace",
"blockId": "heading_1",
"content": "替换标题"
@@ -4990,6 +5067,10 @@ mod tests {
"dryRun": false,
"capabilityScope": ["block.write", "page.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"blockId": "heading_1",
"anchorBlockId": "heading_1",
"revision": 7,
@@ -5041,6 +5122,10 @@ mod tests {
"dryRun": false,
"capabilityScope": ["block.write", "page.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"blockId": "heading_1",
"content": "替换后的章节",
"revision": 7,
@@ -5089,6 +5174,10 @@ mod tests {
"dryRun": false,
"capabilityScope": ["block.write", "page.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"anchorBlockId": "heading_1",
"content": "新增段落",
"revision": 7,
@@ -5137,6 +5226,10 @@ mod tests {
"dryRun": false,
"capabilityScope": ["block.write", "page.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"blockId": "p_1",
"revision": 7,
"conflictDetectionKey": "doc_1:7",
@@ -5184,6 +5277,10 @@ mod tests {
"dryRun": false,
"capabilityScope": ["block.write", "page.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"blockId": "heading_1",
"anchorBlockId": "p_2",
"revision": 7,
@@ -5228,6 +5325,10 @@ mod tests {
"dryRun": false,
"capabilityScope": ["block.write", "page.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"blockId": "heading_1",
"content": "越界替换",
"revision": 7,
@@ -5248,6 +5349,10 @@ mod tests {
"dryRun": false,
"capabilityScope": ["block.write", "page.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"anchorBlockId": "heading_1",
"content": "越界插入",
"revision": 7,
@@ -5268,6 +5373,10 @@ mod tests {
"dryRun": false,
"capabilityScope": ["block.write", "page.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"blockId": "p_1",
"revision": 7,
"conflictDetectionKey": "doc_1:7",
@@ -5287,6 +5396,10 @@ mod tests {
"dryRun": false,
"capabilityScope": ["block.write", "page.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"blockId": "heading_1",
"anchorBlockId": "p_2",
"revision": 7,
@@ -5394,6 +5507,10 @@ mod tests {
"dryRun": false,
"capabilityScope": ["block.write", "page.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"blockId": block_id,
"anchorBlockId": "p_anchor",
"revision": 7,
@@ -5445,6 +5562,10 @@ mod tests {
"dryRun": false,
"capabilityScope": ["block.write", "page.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"anchorBlockId": "heading_1",
"blocks": ["新增第一段", {"type": "todo", "content": "新增待办"}],
"revision": 7,
@@ -5507,6 +5628,10 @@ mod tests {
"dryRun": false,
"capabilityScope": ["block.write", "page.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"anchorBlockId": "heading_1",
"blocks": blocks,
"revision": 7,
@@ -5552,6 +5677,10 @@ mod tests {
"dryRun": false,
"capabilityScope": ["block.write", "page.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"blockId": "heading_1",
"content": "不应写入"
}
@@ -5591,6 +5720,10 @@ mod tests {
"dryRun": false,
"capabilityScope": ["block.write", "page.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"blockId": "heading_1",
"content": "不应写入",
"revision": 7,
@@ -5636,6 +5769,10 @@ mod tests {
"dryRun": false,
"capabilityScope": ["block.write", "page.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"operations": [{
"op": "replace",
"blockId": "p_2",
@@ -5678,6 +5815,10 @@ mod tests {
"dryRun": false,
"capabilityScope": ["block.write", "page.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"revision": 7,
"conflictDetectionKey": "doc_1:7",
"operations": [{
@@ -6016,7 +6157,11 @@ mod tests {
"args": {
"content": [
{"type": "paragraph", "content": [{"type": "text", "text": "本地 page.save 写入"}]}
]
],
"aiAccessScope": {
"permissionLevel": "read_write",
"allowedResourceIds": ["local-md:README.md"]
}
}
})
.to_string(),
@@ -6097,6 +6242,10 @@ mod tests {
"wideLayout": true,
"showHeadingNumbers": true,
"hideTitleHeader": false
},
"aiAccessScope": {
"permissionLevel": "read_write",
"allowedResourceIds": ["local-md:README.md"]
}
}
})
@@ -6158,7 +6307,11 @@ mod tests {
"traceId": "trace_1",
"idempotencyKey": "idem_save_1",
"dryRun": true,
"args": {"content": [{"type":"paragraph","content":[{"type":"text","text":"AI 写入"}]}]}
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"content": [{"type":"paragraph","content":[{"type":"text","text":"AI 写入"}]}]}
})
.to_string(),
))
@@ -6238,8 +6391,12 @@ mod tests {
));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).expect("create temp root");
let path = root.join("page.md");
fs::write(&path, "第一段\n\n第二段\n").expect("write markdown");
fs::write(&root.join("page.md"), "第一段\n\n第二段\n").expect("write markdown");
let root_uri = format!("file://{}", root.display());
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
"user_1", &root_uri,
)
.expect("initialize workspace");
let response = app()
.oneshot(
@@ -6248,11 +6405,15 @@ mod tests {
.uri("/api/mnote/tools/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": "ws_demo",
"documentId": path.to_string_lossy(),
"workspaceId": "local-ws-dry-run",
"documentId": "local-md:page.md",
"sourceKind": "local_folder",
"rootUri": root_uri,
"actorId": "user_1",
"sessionId": "sess_1",
"runId": "run_1",
"toolCallId": "call_1",
@@ -6260,6 +6421,10 @@ mod tests {
"idempotencyKey": "idem_markdown_local_1",
"dryRun": true,
"args": {
"aiAccessScope": {
"permissionLevel": "read_write",
"allowedResourceIds": ["local-md:page.md"]
},
"operations": [{"search": "第二段", "replace": "测试123"}]
}
})
@@ -6277,10 +6442,9 @@ mod tests {
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["audit"]["effect"], "dry_run");
assert_eq!(payload["result"]["operationsApplied"], 1);
assert_eq!(payload["result"]["applyResult"]["written"], false);
assert_eq!(payload["result"]["applyResult"]["dryRun"], true);
// dry-run 不得改盘
assert_eq!(
fs::read_to_string(&path).expect("read markdown"),
fs::read_to_string(root.join("page.md")).expect("read markdown"),
"第一段\n\n第二段\n"
);
let _ = fs::remove_dir_all(&root);
@@ -6475,6 +6639,10 @@ mod tests {
"idempotencyKey": "idem_markdown_mapping_empty_2",
"dryRun": false,
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"operations": [{"search": "第二段 <!-- block:p_2 -->", "replace": "测试123"}]
}
})
@@ -6519,6 +6687,10 @@ mod tests {
"idempotencyKey": "idem_markdown_full_content_online_2",
"dryRun": false,
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"full_content": "章节一\n\n第一段已改\n\n第二段已改"
}
})
@@ -6562,6 +6734,10 @@ mod tests {
"idempotencyKey": "idem_markdown_normalized_1",
"dryRun": true,
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"operations": [{"search": "第二 段", "replace": "测试123"}]
}
})
@@ -6612,6 +6788,10 @@ mod tests {
"idempotencyKey": "idem_markdown_precondition",
"dryRun": false,
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"operations": [{"search": "第二段", "replace": "测试123"}]
}
})
@@ -6676,7 +6856,11 @@ mod tests {
"idempotencyKey": "idem_local_folder_md",
"dryRun": false,
"args": {
"operations": [{"search": "第一段", "replace": "第一段已由 AI 修改"}]
"operations": [{"search": "第一段", "replace": "第一段已由 AI 修改"}],
"aiAccessScope": {
"permissionLevel": "read_write",
"allowedResourceIds": ["local-md:README.md"]
}
}
})
.to_string(),
@@ -6730,6 +6914,10 @@ mod tests {
"idempotencyKey": "idem_markdown_noop",
"dryRun": false,
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"operations": [{"search": "不存在的段落", "replace": "测试123"}]
}
})
@@ -6771,6 +6959,10 @@ mod tests {
"idempotencyKey": "idem_markdown_scope",
"dryRun": true,
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"allowedTargetBlockIds": ["p_2"],
"operations": [{"search": "第一段", "replace": "不应越权修改"}]
}
@@ -6813,6 +7005,10 @@ mod tests {
"idempotencyKey": "idem_markdown_same_block_1",
"dryRun": true,
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"operations": [
{"search": "", "replace": "2"},
{"search": "2段", "replace": "2段落"}
@@ -6861,7 +7057,11 @@ mod tests {
"traceId": "trace_1",
"idempotencyKey": "idem_options_1",
"dryRun": true,
"args": {"options": {"wideLayout": true, "pageFont": "serif"}}
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"options": {"wideLayout": true, "pageFont": "serif"}}
})
.to_string(),
))
@@ -6904,7 +7104,11 @@ mod tests {
"traceId": "trace_1",
"idempotencyKey": "idem_summary_1",
"dryRun": true,
"args": {"summary": "摘要内容"}
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"summary": "摘要内容"}
})
.to_string(),
))
@@ -6958,7 +7162,13 @@ mod tests {
"traceId": "trace_artifact_local",
"idempotencyKey": "idem_artifact_local",
"dryRun": false,
"args": {"summary": "本地摘要"}
"args": {
"summary": "本地摘要",
"aiAccessScope": {
"permissionLevel": "read_write",
"allowedResourceIds": ["local-md:README.md"]
}
}
})
.to_string(),
))