advance 1-8 post-mvp execution batches
This commit is contained in:
@@ -306,6 +306,23 @@ pub(crate) async fn execute_mnote_tool_call(
|
||||
);
|
||||
return Err(error);
|
||||
}
|
||||
if let Err(error) = ensure_tool_capability_scope(&context, &input) {
|
||||
audit_push(json!({
|
||||
"phase": "failed",
|
||||
"traceId": trace_id,
|
||||
"sessionId": input.session_id,
|
||||
"runId": input.run_id,
|
||||
"toolCallId": tool_call_id,
|
||||
"toolName": input.tool_name,
|
||||
"workspaceId": workspace_id,
|
||||
"documentId": document_id,
|
||||
"actorId": input.actor_id,
|
||||
"status": error.status().as_u16(),
|
||||
"message": error.message(),
|
||||
"capabilityScope": input.capability_scope
|
||||
}));
|
||||
return Err(error);
|
||||
}
|
||||
if let Some(cached) = idempotency_key.as_deref().and_then(idempotency_cache_get) {
|
||||
info!(
|
||||
trace_id = %trace_id,
|
||||
@@ -449,6 +466,74 @@ pub(crate) async fn execute_mnote_tool_call(
|
||||
Ok(response_body)
|
||||
}
|
||||
|
||||
fn ensure_tool_capability_scope(
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<(), WebError> {
|
||||
let required = required_capability_scope(&input.tool_name);
|
||||
if required.is_empty()
|
||||
|| declared_capability_scope_covers(input.capability_scope.as_ref(), &required)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
Err(WebError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"mnote_tool_capability_scope_forbidden",
|
||||
"调用方声明的 capabilityScope 未覆盖目标 mnote tool 所需能力",
|
||||
)
|
||||
.with_context(context)
|
||||
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools"))
|
||||
}
|
||||
|
||||
fn required_capability_scope(tool_name: &str) -> Vec<String> {
|
||||
manifest::manifest()
|
||||
.get("tools")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.find(|tool| tool.get("name").and_then(Value::as_str) == Some(tool_name))
|
||||
.and_then(|tool| tool.get("capabilityScope").and_then(Value::as_array))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_str)
|
||||
.map(normalize_capability_scope)
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn declared_capability_scope_covers(declared: Option<&Vec<String>>, required: &[String]) -> bool {
|
||||
let Some(declared) = declared else {
|
||||
// 兼容旧调用方:缺省 capabilityScope 不改变既有执行路径。
|
||||
return true;
|
||||
};
|
||||
let declared = declared
|
||||
.iter()
|
||||
.map(|value| normalize_capability_scope(value))
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
required.iter().all(|scope| {
|
||||
declared
|
||||
.iter()
|
||||
.any(|candidate| capability_scope_satisfies(candidate, scope))
|
||||
})
|
||||
}
|
||||
|
||||
fn capability_scope_satisfies(candidate: &str, required: &str) -> bool {
|
||||
if candidate == required {
|
||||
return true;
|
||||
}
|
||||
required
|
||||
.strip_suffix(".read")
|
||||
.map(|prefix| format!("{prefix}.write"))
|
||||
.as_deref()
|
||||
== Some(candidate)
|
||||
}
|
||||
|
||||
fn normalize_capability_scope(value: &str) -> String {
|
||||
value.trim().to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn is_read_tool(tool_name: &str) -> bool {
|
||||
matches!(
|
||||
tool_name,
|
||||
@@ -964,6 +1049,46 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_call_rejects_declared_scope_that_does_not_cover_tool() {
|
||||
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")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"toolName": "mnote.page.save",
|
||||
"workspaceId": "ws_demo",
|
||||
"documentId": "doc_1",
|
||||
"sessionId": "sess_scope",
|
||||
"runId": "run_scope",
|
||||
"toolCallId": "call_scope",
|
||||
"traceId": "trace_scope",
|
||||
"actorId": "user_1",
|
||||
"capabilityScope": ["page.read"],
|
||||
"dryRun": true,
|
||||
"idempotencyKey": "scope-mismatch",
|
||||
"args": {"content": "不会写入"}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-error-code")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("mnote_tool_capability_scope_forbidden")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_manifest_returns_first_batch_tools() {
|
||||
let response = app()
|
||||
|
||||
Reference in New Issue
Block a user