advance 1-8 post-mvp execution batches
This commit is contained in:
@@ -276,19 +276,5 @@ fn sanitize_local_artifact_file_name(value: &str) -> String {
|
||||
}
|
||||
|
||||
fn ensure_write_contract(context: &RequestContext, input: &ToolCallInput) -> Result<(), WebError> {
|
||||
if !input.has_idempotency_key() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_tool_idempotency_required",
|
||||
"写入型 mnote Hermes tool 必须携带 idempotencyKey",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
if input.dry_run.is_none() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_tool_dry_run_required",
|
||||
"写入型 mnote Hermes tool 必须显式携带 dryRun",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
Ok(())
|
||||
crate::hermes_tools::ensure_write_authorized(context, input)
|
||||
}
|
||||
|
||||
@@ -693,29 +693,7 @@ pub(crate) fn ensure_write_contract(
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<(), WebError> {
|
||||
if !input.has_idempotency_key() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_tool_idempotency_required",
|
||||
"写入型 mnote Hermes tool 必须携带 idempotencyKey",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
if input.dry_run.is_none() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_tool_dry_run_required",
|
||||
"写入型 mnote Hermes tool 必须显式携带 dryRun",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
if input.ai_access_scope_is_read_only() {
|
||||
return Err(WebError::new(
|
||||
axum::http::StatusCode::FORBIDDEN,
|
||||
"mnote_tool_ai_scope_write_forbidden",
|
||||
"当前 AI scope 是只读权限,禁止执行写入型 mnote tool",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
Ok(())
|
||||
crate::hermes_tools::ensure_write_authorized(context, input)
|
||||
}
|
||||
|
||||
fn ensure_leaf_block(
|
||||
|
||||
@@ -375,6 +375,8 @@ fn page_get_tool() -> Value {
|
||||
"description": "读取当前页面 Page Aggregate 摘要",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["page.read"],
|
||||
"status": "available",
|
||||
"annotations": tool_annotations(true, false, true, false),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["workspaceId", "documentId", "sessionId", "runId", "toolCallId", "traceId", "actorId"],
|
||||
|
||||
@@ -5,6 +5,8 @@ pub mod manifest;
|
||||
pub mod page;
|
||||
pub mod resource;
|
||||
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -145,4 +147,163 @@ impl ToolCallInput {
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn command_context_bridge(&self) -> Option<CommandContextBridge> {
|
||||
let ctx = self.args.as_ref().and_then(|args| {
|
||||
args.get("commandContext")
|
||||
.or_else(|| args.get("command_context"))
|
||||
})?;
|
||||
let workspace_readonly = ctx
|
||||
.get("workspace.readonly")
|
||||
.or_else(|| ctx.get("workspaceReadonly"))
|
||||
.or_else(|| ctx.get("workspace_readonly"))
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let ai_can_write = ctx
|
||||
.get("ai.canWrite")
|
||||
.or_else(|| ctx.get("aiCanWrite"))
|
||||
.or_else(|| ctx.get("ai_can_write"))
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(true);
|
||||
Some(CommandContextBridge {
|
||||
workspace_readonly,
|
||||
ai_can_write,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// CommandContext 桥接信息,用于将 `core-protocol` 的 command context 引入 hermes_tools 写入守卫。
|
||||
///
|
||||
/// 当此桥接可用时,`ensure_write_authorized` 除检查 `ToolCallInput` 自带的
|
||||
/// `aiAccessScope.permissionLevel` 外,额外检查 `ai_can_write` 和 `workspace_readonly`。
|
||||
///
|
||||
/// 设计意图:`CommandContext.ai.canWrite` / `workspace.readonly` 是从 tree/workspace/editor
|
||||
/// 状态推导的写权限口径,与 `args.aiAccessScope.permissionLevel`(AI 客户端声明的权限)是
|
||||
/// 两个独立的信息源。桥接合并两者,任一拒绝则阻止写入。
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct CommandContextBridge {
|
||||
pub workspace_readonly: bool,
|
||||
pub ai_can_write: bool,
|
||||
}
|
||||
|
||||
/// 统一的 hermes_tools 写入守卫。检查:
|
||||
///
|
||||
/// - `idempotencyKey` 必须存在
|
||||
/// - `dryRun` 必须显式携带
|
||||
/// - `aiAccessScope.permissionLevel` 不是只读(来自 ToolCallInput)
|
||||
/// - 如果提供了 `bridge` 且 `ai_can_write == false`,拒绝
|
||||
/// - 如果提供了 `bridge` 且 `workspace_readonly == true`,拒绝
|
||||
///
|
||||
/// 拒绝响应可解释(包含具体原因),不静默成功,不 panic。
|
||||
pub fn ensure_write_authorized(context: &RequestContext, input: &ToolCallInput) -> Result<(), WebError> {
|
||||
if !input.has_idempotency_key() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_tool_idempotency_required",
|
||||
"写入型 mnote Hermes tool 必须携带 idempotencyKey",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
if input.dry_run.is_none() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_tool_dry_run_required",
|
||||
"写入型 mnote Hermes tool 必须显式携带 dryRun",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
if input.ai_access_scope_is_read_only() {
|
||||
return Err(WebError::new(
|
||||
axum::http::StatusCode::FORBIDDEN,
|
||||
"mnote_tool_ai_scope_write_forbidden",
|
||||
"当前 AI scope 是只读权限,禁止执行写入型 mnote tool",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
if let Some(bridge) = input.command_context_bridge() {
|
||||
if bridge.workspace_readonly {
|
||||
return Err(WebError::new(
|
||||
axum::http::StatusCode::FORBIDDEN,
|
||||
"mnote_tool_workspace_readonly",
|
||||
"当前 workspace 是只读权限,禁止执行写入型 mnote tool",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
if !bridge.ai_can_write {
|
||||
return Err(WebError::new(
|
||||
axum::http::StatusCode::FORBIDDEN,
|
||||
"mnote_tool_ai_write_forbidden",
|
||||
"CommandContext 标记 AI 写入未授权,禁止执行写入型 mnote tool",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::http::{HeaderMap, Method};
|
||||
use serde_json::json;
|
||||
|
||||
fn context() -> RequestContext {
|
||||
RequestContext::from_http_parts(
|
||||
&Method::POST,
|
||||
&"/api/hermes/tools".parse().expect("uri"),
|
||||
&HeaderMap::new(),
|
||||
)
|
||||
}
|
||||
|
||||
fn write_input(args: Value) -> ToolCallInput {
|
||||
ToolCallInput {
|
||||
tool_name: "mnote.page.save".into(),
|
||||
workspace_id: Some("ws_1".into()),
|
||||
document_id: Some("doc_1".into()),
|
||||
source_kind: Some("local_folder".into()),
|
||||
root_uri: Some("file:///tmp/mnote".into()),
|
||||
actor_id: Some("user_1".into()),
|
||||
profile: None,
|
||||
session_id: Some("sess_1".into()),
|
||||
run_id: Some("run_1".into()),
|
||||
tool_call_id: Some("tool_1".into()),
|
||||
trace_id: Some("trace_1".into()),
|
||||
idempotency_key: Some("idem_1".into()),
|
||||
dry_run: Some(false),
|
||||
capability_scope: None,
|
||||
args: Some(args),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_write_authorized_rejects_command_context_ai_cannot_write() {
|
||||
let error = ensure_write_authorized(
|
||||
&context(),
|
||||
&write_input(json!({
|
||||
"commandContext": {
|
||||
"ai.canWrite": false,
|
||||
"workspace.readonly": false
|
||||
}
|
||||
})),
|
||||
)
|
||||
.expect_err("ai.canWrite=false should reject write tools");
|
||||
|
||||
assert_eq!(error.status(), axum::http::StatusCode::FORBIDDEN);
|
||||
assert_eq!(error.code(), "mnote_tool_ai_write_forbidden");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_write_authorized_rejects_command_context_readonly_workspace() {
|
||||
let error = ensure_write_authorized(
|
||||
&context(),
|
||||
&write_input(json!({
|
||||
"commandContext": {
|
||||
"ai.canWrite": true,
|
||||
"workspace.readonly": true
|
||||
}
|
||||
})),
|
||||
)
|
||||
.expect_err("workspace.readonly=true should reject write tools");
|
||||
|
||||
assert_eq!(error.status(), axum::http::StatusCode::FORBIDDEN);
|
||||
assert_eq!(error.code(), "mnote_tool_workspace_readonly");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,29 +72,7 @@ pub async fn page_get(
|
||||
}
|
||||
|
||||
fn ensure_write_contract(context: &RequestContext, input: &ToolCallInput) -> Result<(), WebError> {
|
||||
if !input.has_idempotency_key() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_tool_idempotency_required",
|
||||
"写入型 mnote Hermes tool 必须携带 idempotencyKey",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
if input.dry_run.is_none() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_tool_dry_run_required",
|
||||
"写入型 mnote Hermes tool 必须显式携带 dryRun",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
if input.ai_access_scope_is_read_only() {
|
||||
return Err(WebError::new(
|
||||
axum::http::StatusCode::FORBIDDEN,
|
||||
"mnote_tool_ai_scope_write_forbidden",
|
||||
"当前 AI scope 是只读权限,禁止执行写入型 mnote tool",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
Ok(())
|
||||
crate::hermes_tools::ensure_write_authorized(context, input)
|
||||
}
|
||||
|
||||
pub async fn page_save(
|
||||
|
||||
@@ -285,29 +285,7 @@ fn ensure_resource_write_contract(
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<(), WebError> {
|
||||
if !input.has_idempotency_key() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_tool_idempotency_required",
|
||||
"写入型 mnote resource tool 必须携带 idempotencyKey",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
if input.dry_run.is_none() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_tool_dry_run_required",
|
||||
"写入型 mnote resource tool 必须显式携带 dryRun",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
if input.ai_access_scope_is_read_only() {
|
||||
return Err(WebError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"mnote_tool_ai_scope_write_forbidden",
|
||||
"当前 AI scope 是只读权限,禁止执行写入型 mnote resource tool",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
Ok(())
|
||||
crate::hermes_tools::ensure_write_authorized(context, input)
|
||||
}
|
||||
|
||||
fn local_root_uri_for_resource(input: &ToolCallInput) -> Option<String> {
|
||||
|
||||
Reference in New Issue
Block a user