feat(mcp): 将单次调用审阅绑定到已验证实例
This commit is contained in:
@@ -507,3 +507,15 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写
|
|||||||
- 单元测试覆盖分页、重复、循环、总量/页数限制,必填项/类型/额外参数、结构化结果、无效 base64、所有引用类型、未知方言/不支持正则/任务要求与过深输入。真实 AppContainer 测试新增两页目录、发现前/未知名/无效参数拒绝、错误 structuredContent、调用中及空闲时工具变更;全部经实际原生 Session/Pump 运行。
|
- 单元测试覆盖分页、重复、循环、总量/页数限制,必填项/类型/额外参数、结构化结果、无效 base64、所有引用类型、未知方言/不支持正则/任务要求与过深输入。真实 AppContainer 测试新增两页目录、发现前/未知名/无效参数拒绝、错误 structuredContent、调用中及空闲时工具变更;全部经实际原生 Session/Pump 运行。
|
||||||
- 62 项扩展回归通过,5 项 ignored 为三个父测试驱动 Job 辅助入口及两个需显式执行的长时验收,本轮未重跑长时项;上一轮实际 MCP 60 秒验收仍为其当时提交的证据。desktop 全目标 Clippy -D warnings 通过。日志 `.build/extension-mcp-catalog-tests.log`、`.build/extension-mcp-catalog-clippy.log`。协议依据 [MCP tools](https://modelcontextprotocol.io/specification/2025-11-25/server/tools)。
|
- 62 项扩展回归通过,5 项 ignored 为三个父测试驱动 Job 辅助入口及两个需显式执行的长时验收,本轮未重跑长时项;上一轮实际 MCP 60 秒验收仍为其当时提交的证据。desktop 全目标 Clippy -D warnings 通过。日志 `.build/extension-mcp-catalog-tests.log`、`.build/extension-mcp-catalog-clippy.log`。协议依据 [MCP tools](https://modelcontextprotocol.io/specification/2025-11-25/server/tools)。
|
||||||
- 工具发现和校验不等于用户批准执行。生产入口仍需完成实际安装实例注册、来源/活动版本/在线信任与工具目录摘要绑定、用户调用授权及 Core 路由;还需完成全部沙箱资源/并发攻击验证和其他生产化验收,第三方执行能力保持禁用。
|
- 工具发现和校验不等于用户批准执行。生产入口仍需完成实际安装实例注册、来源/活动版本/在线信任与工具目录摘要绑定、用户调用授权及 Core 路由;还需完成全部沙箱资源/并发攻击验证和其他生产化验收,第三方执行能力保持禁用。
|
||||||
|
|
||||||
|
|
||||||
|
## 增量:实例身份与一次性工具调用预览凭据
|
||||||
|
|
||||||
|
- Context.prepare 在现有许可、入口和上下文验证完成后生成独立实例 UUID,以及覆盖完整 Claims 的执行摘要;用于摘要的临时序列化字节由 Zeroizing 清理。公开身份预览只包含执行类型、来源/命名空间/包/版本、Vault、权限集和摘要,不包含环境秘密或原始启动参数。
|
||||||
|
- PreparedLaunch → LeasedSuspended → Running 持续携带该身份,带租约恢复方法收为 crate 内部。Session.new 要求运行实例具有这条准备链产生的身份,普通低层 resume 的受控测试进程不能直接进入该 MCP 会话入口。
|
||||||
|
- 新增内存 Gate,生成包含真实工具描述、冻结参数、实例身份和工具契约摘要的 Review;最多 64 条待确认预览、合计 2 MiB 待确认参数,120 秒单调时钟有效期。确认会消费预览 ID,不能重复确认;过期和目录失效清理待确认项,已确认对象也绑定原到期时间。
|
||||||
|
- 正式 Session.call_tool 现在只接受 ApprovedCall。该类型字段私有、不可 Clone、不可序列化/反序列化,工具名及参数不能在确认后另行替换;调用消费该对象并核对实例、目录代次和当前契约摘要,再进入参数/结果校验、运行租约及期限链路。按名称/参数直接调用的方法收为私有,只有 cfg(test) 辅助方法能模拟本测试用户确认。
|
||||||
|
- 目录刷新开始、已观察到工具变更及会话失败均作废旧预览和目录代次;即使工具名字和 schema 恢复成原值,旧已确认对象也不能重新使用。此处是 Host 观察和持有的目录版本绑定,不是对服务端执行语义永不变化或核验到发送间“零竞态”的证明。
|
||||||
|
- 单元测试覆盖预览对象被改写后实际参数仍冻结、重复确认、跨实例、目录代次、确认前/后过期、条数/字节上限及清理后复用。真实 AppContainer 测试核对预览来源/包/Vault,修改展示副本不会改变实际请求,旧目录凭据被拒绝;新增确认后锁定保险库导致 CREDENTIALS_LOCKED,重新解锁也不能恢复已失效会话。
|
||||||
|
- 64 项扩展回归通过,5 项 ignored 为三个父测试驱动 Job 辅助入口及两个显式长时验收,本轮未重跑长时项。desktop 全目标 Clippy -D warnings 通过。日志 `.build/extension-call-binding-tests.log`、`.build/extension-call-binding-clippy.log`。
|
||||||
|
- 当前 confirm_call 仍只是 Host 内部组件接口,未登记为 renderer/Core 命令,也没有自动批准策略或真实用户确认 UI。完整产品入口必须通过经过认证的 Host 路由确认用户批准,再与活动安装/在线信任、实例注册和 Core 路由组合;全部沙箱资源与其他生产化验收仍未完成,第三方执行能力保持禁用。
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
//! Host-memory call reviews. The UI/registry must establish actual user consent
|
||||||
|
//! before confirm; no renderer command or automatic-consent policy is added here.
|
||||||
|
use crate::{
|
||||||
|
extension_mcp_tools::{Description, Tool},
|
||||||
|
extension_permit::{Claims, ExecutionKind},
|
||||||
|
workspace::{HostError, Result},
|
||||||
|
};
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde_json::Value;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::{
|
||||||
|
collections::{BTreeMap, BTreeSet},
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
pub struct Identity {
|
||||||
|
instance_id: String,
|
||||||
|
kind: ExecutionKind,
|
||||||
|
source: String,
|
||||||
|
namespace: String,
|
||||||
|
package_id: String,
|
||||||
|
version: String,
|
||||||
|
vault_id: String,
|
||||||
|
permissions: BTreeSet<String>,
|
||||||
|
execution_digest: String,
|
||||||
|
}
|
||||||
|
impl Identity {
|
||||||
|
/// Only called after launch permit/entry/context validation.
|
||||||
|
pub(crate) fn from_claims(claims: &Claims) -> Result<Self> {
|
||||||
|
let bytes = Zeroizing::new(
|
||||||
|
serde_json::to_vec(claims)
|
||||||
|
.map_err(|_| HostError::new("EXTENSION_CALL_BINDING_INVALID"))?,
|
||||||
|
);
|
||||||
|
let mut hash = Sha256::new();
|
||||||
|
hash.update(b"OpenNexus execution identity v1\0");
|
||||||
|
hash.update(bytes.as_slice());
|
||||||
|
Ok(Self {
|
||||||
|
instance_id: uuid::Uuid::new_v4().to_string(),
|
||||||
|
kind: claims.kind,
|
||||||
|
source: claims.source.clone(),
|
||||||
|
namespace: claims.namespace.clone(),
|
||||||
|
package_id: claims.package_id.clone(),
|
||||||
|
version: claims.version.clone(),
|
||||||
|
vault_id: claims.vault_id.clone(),
|
||||||
|
permissions: claims.permissions.clone(),
|
||||||
|
execution_digest: format!("{:x}", hash.finalize()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct Review {
|
||||||
|
pub review_id: String,
|
||||||
|
pub identity: Identity,
|
||||||
|
pub tool: Description,
|
||||||
|
pub arguments: Value,
|
||||||
|
pub contract_digest: String,
|
||||||
|
pub valid_for_seconds: u64,
|
||||||
|
}
|
||||||
|
struct Pending {
|
||||||
|
name: String,
|
||||||
|
arguments: Value,
|
||||||
|
contract_digest: String,
|
||||||
|
expires: Instant,
|
||||||
|
bytes: usize,
|
||||||
|
}
|
||||||
|
/// An in-process, non-cloneable, non-serializable, single-consumption capability.
|
||||||
|
/// Tool name and arguments cannot be replaced after review confirmation.
|
||||||
|
pub struct ApprovedCall {
|
||||||
|
instance: String,
|
||||||
|
epoch: String,
|
||||||
|
call: Pending,
|
||||||
|
}
|
||||||
|
pub(crate) struct Invocation {
|
||||||
|
pub name: String,
|
||||||
|
pub arguments: Value,
|
||||||
|
pub contract_digest: String,
|
||||||
|
}
|
||||||
|
pub(crate) struct Gate {
|
||||||
|
identity: Identity,
|
||||||
|
epoch: String,
|
||||||
|
pending: BTreeMap<String, Pending>,
|
||||||
|
}
|
||||||
|
impl Gate {
|
||||||
|
pub(crate) fn new(identity: Identity) -> Self {
|
||||||
|
Self {
|
||||||
|
identity,
|
||||||
|
epoch: uuid::Uuid::new_v4().to_string(),
|
||||||
|
pending: BTreeMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) fn invalidate(&mut self) {
|
||||||
|
self.epoch = uuid::Uuid::new_v4().to_string();
|
||||||
|
self.pending.clear();
|
||||||
|
}
|
||||||
|
pub(crate) fn review(&mut self, tool: &Tool, arguments: Value) -> Result<Review> {
|
||||||
|
tool.validate_arguments(&arguments)?;
|
||||||
|
self.pending
|
||||||
|
.retain(|_, pending| pending.expires > Instant::now());
|
||||||
|
let bytes = serde_json::to_vec(&arguments)
|
||||||
|
.map_err(|_| HostError::new("EXTENSION_CALL_REVIEW_INVALID"))?
|
||||||
|
.len();
|
||||||
|
if self.pending.len() >= 64
|
||||||
|
|| self.pending.values().map(|p| p.bytes).sum::<usize>() + bytes > 2 * 1024 * 1024
|
||||||
|
{
|
||||||
|
return Err(HostError::new("EXTENSION_CALL_REVIEW_LIMIT"));
|
||||||
|
}
|
||||||
|
let tool = tool.description();
|
||||||
|
let contract_digest = contract_digest(&tool)?;
|
||||||
|
let review_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
self.pending.insert(
|
||||||
|
review_id.clone(),
|
||||||
|
Pending {
|
||||||
|
name: tool.name.clone(),
|
||||||
|
arguments: arguments.clone(),
|
||||||
|
contract_digest: contract_digest.clone(),
|
||||||
|
expires: Instant::now() + Duration::from_secs(120),
|
||||||
|
bytes,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Ok(Review {
|
||||||
|
review_id,
|
||||||
|
identity: self.identity.clone(),
|
||||||
|
tool,
|
||||||
|
arguments,
|
||||||
|
contract_digest,
|
||||||
|
valid_for_seconds: 120,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/// The authenticated Host approval route must verify user consent first.
|
||||||
|
pub(crate) fn confirm(&mut self, review_id: &str) -> Result<ApprovedCall> {
|
||||||
|
let call = self
|
||||||
|
.pending
|
||||||
|
.remove(review_id)
|
||||||
|
.ok_or_else(|| HostError::new("EXTENSION_CALL_REVIEW_UNKNOWN"))?;
|
||||||
|
if call.expires <= Instant::now() {
|
||||||
|
return Err(HostError::new("EXTENSION_CALL_REVIEW_EXPIRED"));
|
||||||
|
}
|
||||||
|
Ok(ApprovedCall {
|
||||||
|
instance: self.identity.instance_id.clone(),
|
||||||
|
epoch: self.epoch.clone(),
|
||||||
|
call,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pub(crate) fn consume(&self, approved: ApprovedCall) -> Result<Invocation> {
|
||||||
|
if approved.instance != self.identity.instance_id {
|
||||||
|
return Err(HostError::new("EXTENSION_CALL_INSTANCE_MISMATCH"));
|
||||||
|
}
|
||||||
|
if approved.epoch != self.epoch {
|
||||||
|
return Err(HostError::new("EXTENSION_CALL_CATALOG_CHANGED"));
|
||||||
|
}
|
||||||
|
if approved.call.expires <= Instant::now() {
|
||||||
|
return Err(HostError::new("EXTENSION_CALL_REVIEW_EXPIRED"));
|
||||||
|
}
|
||||||
|
Ok(Invocation {
|
||||||
|
name: approved.call.name,
|
||||||
|
arguments: approved.call.arguments,
|
||||||
|
contract_digest: approved.call.contract_digest,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) fn contract_digest(tool: &Description) -> Result<String> {
|
||||||
|
let mut hash = Sha256::new();
|
||||||
|
hash.update(b"OpenNexus MCP tool contract v1\0");
|
||||||
|
hash.update(
|
||||||
|
serde_json::to_vec(tool).map_err(|_| HostError::new("EXTENSION_CALL_REVIEW_INVALID"))?,
|
||||||
|
);
|
||||||
|
Ok(format!("{:x}", hash.finalize()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::extension_mcp_tools::Catalog;
|
||||||
|
use serde_json::json;
|
||||||
|
fn identity() -> Identity {
|
||||||
|
Identity {
|
||||||
|
instance_id: uuid::Uuid::new_v4().to_string(),
|
||||||
|
kind: ExecutionKind::Mcp,
|
||||||
|
source: "https://catalog.example/".into(),
|
||||||
|
namespace: "examples".into(),
|
||||||
|
package_id: "echo".into(),
|
||||||
|
version: "1.0.0".into(),
|
||||||
|
vault_id: uuid::Uuid::new_v4().to_string(),
|
||||||
|
permissions: BTreeSet::new(),
|
||||||
|
execution_digest: "a".repeat(64),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn tool() -> std::sync::Arc<Tool> {
|
||||||
|
Catalog::discover(|_| {
|
||||||
|
Ok(json!({"tools":[{"name":"echo","inputSchema":{"type":"object"}}]}))
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
.tool("echo")
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn review_freezes_arguments_and_is_single_use_instance_epoch_and_expiry_bound() {
|
||||||
|
let mut gate = Gate::new(identity());
|
||||||
|
let tool = tool();
|
||||||
|
let mut review = gate.review(&tool, json!({"value":"original"})).unwrap();
|
||||||
|
review.arguments = json!({"value":"tampered"});
|
||||||
|
review.tool.name = "tampered".into();
|
||||||
|
let approved = gate.confirm(&review.review_id).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
gate.confirm(&review.review_id).err().unwrap().code,
|
||||||
|
"EXTENSION_CALL_REVIEW_UNKNOWN"
|
||||||
|
);
|
||||||
|
let call = gate.consume(approved).unwrap();
|
||||||
|
assert_eq!(call.name, "echo");
|
||||||
|
assert_eq!(call.arguments, json!({"value":"original"}));
|
||||||
|
assert_eq!(
|
||||||
|
call.contract_digest,
|
||||||
|
contract_digest(&tool.description()).unwrap()
|
||||||
|
);
|
||||||
|
let review = gate.review(&tool, json!({})).unwrap();
|
||||||
|
let approved = gate.confirm(&review.review_id).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
Gate::new(identity()).consume(approved).err().unwrap().code,
|
||||||
|
"EXTENSION_CALL_INSTANCE_MISMATCH"
|
||||||
|
);
|
||||||
|
let review = gate.review(&tool, json!({})).unwrap();
|
||||||
|
let approved = gate.confirm(&review.review_id).unwrap();
|
||||||
|
let pending = gate.review(&tool, json!({})).unwrap();
|
||||||
|
gate.invalidate();
|
||||||
|
assert_eq!(
|
||||||
|
gate.consume(approved).err().unwrap().code,
|
||||||
|
"EXTENSION_CALL_CATALOG_CHANGED"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
gate.confirm(&pending.review_id).err().unwrap().code,
|
||||||
|
"EXTENSION_CALL_REVIEW_UNKNOWN"
|
||||||
|
);
|
||||||
|
let review = gate.review(&tool, json!({})).unwrap();
|
||||||
|
gate.pending.get_mut(&review.review_id).unwrap().expires = Instant::now();
|
||||||
|
assert_eq!(
|
||||||
|
gate.confirm(&review.review_id).err().unwrap().code,
|
||||||
|
"EXTENSION_CALL_REVIEW_EXPIRED"
|
||||||
|
);
|
||||||
|
let review = gate.review(&tool, json!({})).unwrap();
|
||||||
|
let mut approved = gate.confirm(&review.review_id).unwrap();
|
||||||
|
approved.call.expires = Instant::now();
|
||||||
|
assert_eq!(
|
||||||
|
gate.consume(approved).err().unwrap().code,
|
||||||
|
"EXTENSION_CALL_REVIEW_EXPIRED"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn pending_review_count_and_bytes_are_bounded_and_invalidated_slots_are_reusable() {
|
||||||
|
let tool = tool();
|
||||||
|
let mut gate = Gate::new(identity());
|
||||||
|
for _ in 0..64 {
|
||||||
|
gate.review(&tool, json!({})).unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
gate.review(&tool, json!({})).err().unwrap().code,
|
||||||
|
"EXTENSION_CALL_REVIEW_LIMIT"
|
||||||
|
);
|
||||||
|
gate.invalidate();
|
||||||
|
for _ in 0..10 {
|
||||||
|
gate.review(&tool, json!({"value":"x".repeat(200_000)}))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
gate.review(&tool, json!({"value":"x".repeat(200_000)}))
|
||||||
|
.err()
|
||||||
|
.unwrap()
|
||||||
|
.code,
|
||||||
|
"EXTENSION_CALL_REVIEW_LIMIT"
|
||||||
|
);
|
||||||
|
for value in gate.pending.values_mut() {
|
||||||
|
value.expires = Instant::now();
|
||||||
|
}
|
||||||
|
gate.review(&tool, json!({"value":"x".repeat(200_000)}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(gate.pending.len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -874,6 +874,7 @@ mod tests {
|
|||||||
"mcp_pages",
|
"mcp_pages",
|
||||||
"mcp_bad_result",
|
"mcp_bad_result",
|
||||||
"mcp_idle_change",
|
"mcp_idle_change",
|
||||||
|
"mcp_review_lock",
|
||||||
];
|
];
|
||||||
if _mcp_deadline {
|
if _mcp_deadline {
|
||||||
mcp_modes.push("mcp_deadline");
|
mcp_modes.push("mcp_deadline");
|
||||||
@@ -898,7 +899,7 @@ mod tests {
|
|||||||
let cancel = Arc::new(AtomicBool::new(false));
|
let cancel = Arc::new(AtomicBool::new(false));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
session
|
session
|
||||||
.call_tool("echo", serde_json::json!({}), &cancel)
|
.test_call_tool("echo", serde_json::json!({}), &cancel)
|
||||||
.unwrap_err()
|
.unwrap_err()
|
||||||
.code,
|
.code,
|
||||||
"EXTENSION_MCP_NOT_INITIALIZED"
|
"EXTENSION_MCP_NOT_INITIALIZED"
|
||||||
@@ -915,7 +916,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
session
|
session
|
||||||
.call_tool("echo", serde_json::json!({}), &cancel)
|
.test_call_tool("echo", serde_json::json!({}), &cancel)
|
||||||
.unwrap_err()
|
.unwrap_err()
|
||||||
.code,
|
.code,
|
||||||
"EXTENSION_MCP_CATALOG_REQUIRED"
|
"EXTENSION_MCP_CATALOG_REQUIRED"
|
||||||
@@ -923,14 +924,14 @@ mod tests {
|
|||||||
assert_eq!(session.refresh_tools(&cancel).unwrap()[0].name, "echo");
|
assert_eq!(session.refresh_tools(&cancel).unwrap()[0].name, "echo");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
session
|
session
|
||||||
.call_tool("missing", serde_json::json!({}), &cancel)
|
.test_call_tool("missing", serde_json::json!({}), &cancel)
|
||||||
.unwrap_err()
|
.unwrap_err()
|
||||||
.code,
|
.code,
|
||||||
"EXTENSION_MCP_TOOL_NOT_FOUND"
|
"EXTENSION_MCP_TOOL_NOT_FOUND"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
session
|
session
|
||||||
.call_tool("echo", serde_json::json!({"unexpected":1}), &cancel)
|
.test_call_tool("echo", serde_json::json!({"unexpected":1}), &cancel)
|
||||||
.unwrap_err()
|
.unwrap_err()
|
||||||
.code,
|
.code,
|
||||||
"EXTENSION_MCP_ARGUMENTS_INVALID"
|
"EXTENSION_MCP_ARGUMENTS_INVALID"
|
||||||
@@ -944,9 +945,42 @@ mod tests {
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
let mut earlier = None;
|
||||||
|
if mode == "mcp" {
|
||||||
|
let review = session.review_call("echo", serde_json::json!({})).unwrap();
|
||||||
|
let identity = serde_json::to_value(&review.identity).unwrap();
|
||||||
|
assert_eq!(identity["package_id"], mcp.package_id);
|
||||||
|
assert_eq!(identity["vault_id"], mcp.vault_id);
|
||||||
|
assert_eq!(identity["source"], mcp.source);
|
||||||
|
assert_eq!(identity["execution_digest"].as_str().unwrap().len(), 64);
|
||||||
|
earlier = Some(session.confirm_call(&review.review_id).unwrap());
|
||||||
|
}
|
||||||
let tool_started = std::time::Instant::now();
|
let tool_started = std::time::Instant::now();
|
||||||
let result = session.call_tool("echo", serde_json::json!({}), &cancel);
|
let result = if mode == "mcp" {
|
||||||
|
let mut review =
|
||||||
|
session.review_call("echo", serde_json::json!({})).unwrap();
|
||||||
|
review.arguments = serde_json::json!({"unexpected":1});
|
||||||
|
let approved = session.confirm_call(&review.review_id).unwrap();
|
||||||
|
session.call_tool(approved, &cancel)
|
||||||
|
} else if mode == "mcp_review_lock" {
|
||||||
|
let review = session.review_call("echo", serde_json::json!({})).unwrap();
|
||||||
|
let approved = session.confirm_call(&review.review_id).unwrap();
|
||||||
|
broker.lock();
|
||||||
|
session.call_tool(approved, &cancel)
|
||||||
|
} else {
|
||||||
|
session.test_call_tool("echo", serde_json::json!({}), &cancel)
|
||||||
|
};
|
||||||
match mode {
|
match mode {
|
||||||
|
"mcp_review_lock" => {
|
||||||
|
assert_eq!(result.unwrap_err().code, "CREDENTIALS_LOCKED");
|
||||||
|
broker
|
||||||
|
.unlock(Zeroizing::new(b"native fixture passphrase".to_vec()))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
session.refresh_tools(&cancel).err().unwrap().code,
|
||||||
|
"EXTENSION_MCP_SESSION_FAILED"
|
||||||
|
);
|
||||||
|
}
|
||||||
"mcp_bad_result" => assert_eq!(
|
"mcp_bad_result" => assert_eq!(
|
||||||
result.unwrap_err().code,
|
result.unwrap_err().code,
|
||||||
"EXTENSION_MCP_TOOL_RESULT_INVALID"
|
"EXTENSION_MCP_TOOL_RESULT_INVALID"
|
||||||
@@ -956,7 +990,7 @@ mod tests {
|
|||||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
session
|
session
|
||||||
.call_tool("echo", serde_json::json!({}), &cancel)
|
.test_call_tool("echo", serde_json::json!({}), &cancel)
|
||||||
.unwrap_err()
|
.unwrap_err()
|
||||||
.code,
|
.code,
|
||||||
"EXTENSION_MCP_CATALOG_REQUIRED"
|
"EXTENSION_MCP_CATALOG_REQUIRED"
|
||||||
@@ -1005,7 +1039,7 @@ mod tests {
|
|||||||
assert_eq!(result.unwrap_err().code, "EXTENSION_MCP_REMOTE_ERROR");
|
assert_eq!(result.unwrap_err().code, "EXTENSION_MCP_REMOTE_ERROR");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
session
|
session
|
||||||
.call_tool("echo", serde_json::json!({}), &cancel)
|
.test_call_tool("echo", serde_json::json!({}), &cancel)
|
||||||
.unwrap()["content"][0]["text"],
|
.unwrap()["content"][0]["text"],
|
||||||
"native MCP success"
|
"native MCP success"
|
||||||
);
|
);
|
||||||
@@ -1014,9 +1048,15 @@ mod tests {
|
|||||||
assert_eq!(result.unwrap()["content"][0]["text"], "native MCP success");
|
assert_eq!(result.unwrap()["content"][0]["text"], "native MCP success");
|
||||||
assert!(session.take_tools_changed());
|
assert!(session.take_tools_changed());
|
||||||
assert!(!session.take_tools_changed());
|
assert!(!session.take_tools_changed());
|
||||||
|
if let Some(approved) = earlier {
|
||||||
|
assert_eq!(
|
||||||
|
session.call_tool(approved, &cancel).unwrap_err().code,
|
||||||
|
"EXTENSION_CALL_CATALOG_CHANGED"
|
||||||
|
);
|
||||||
|
}
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
session
|
session
|
||||||
.call_tool("echo", serde_json::json!({}), &cancel)
|
.test_call_tool("echo", serde_json::json!({}), &cancel)
|
||||||
.unwrap_err()
|
.unwrap_err()
|
||||||
.code,
|
.code,
|
||||||
"EXTENSION_MCP_CATALOG_REQUIRED"
|
"EXTENSION_MCP_CATALOG_REQUIRED"
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ pub struct Context<'a> {
|
|||||||
pub struct PreparedLaunch {
|
pub struct PreparedLaunch {
|
||||||
data: LaunchData,
|
data: LaunchData,
|
||||||
lease: crate::extension_permit::Lease,
|
lease: crate::extension_permit::Lease,
|
||||||
|
identity: crate::extension_call_authorization::Identity,
|
||||||
path: std::path::PathBuf,
|
path: std::path::PathBuf,
|
||||||
entry: String,
|
entry: String,
|
||||||
tree: String,
|
tree: String,
|
||||||
@@ -29,6 +30,7 @@ pub struct PreparedLaunch {
|
|||||||
pub struct LeasedSuspended<'a> {
|
pub struct LeasedSuspended<'a> {
|
||||||
process: crate::extension_process::Suspended<'a>,
|
process: crate::extension_process::Suspended<'a>,
|
||||||
lease: crate::extension_permit::Lease,
|
lease: crate::extension_permit::Lease,
|
||||||
|
identity: crate::extension_call_authorization::Identity,
|
||||||
}
|
}
|
||||||
impl PreparedLaunch {
|
impl PreparedLaunch {
|
||||||
pub fn create_suspended<'a>(
|
pub fn create_suspended<'a>(
|
||||||
@@ -48,6 +50,7 @@ impl PreparedLaunch {
|
|||||||
Ok(LeasedSuspended {
|
Ok(LeasedSuspended {
|
||||||
process,
|
process,
|
||||||
lease: self.lease,
|
lease: self.lease,
|
||||||
|
identity: self.identity,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
pub fn create_suspended_with_stdio<'a>(
|
pub fn create_suspended_with_stdio<'a>(
|
||||||
@@ -70,6 +73,7 @@ impl PreparedLaunch {
|
|||||||
LeasedSuspended {
|
LeasedSuspended {
|
||||||
process,
|
process,
|
||||||
lease: self.lease,
|
lease: self.lease,
|
||||||
|
identity: self.identity,
|
||||||
},
|
},
|
||||||
io,
|
io,
|
||||||
))
|
))
|
||||||
@@ -80,7 +84,7 @@ impl<'a> LeasedSuspended<'a> {
|
|||||||
/// Live trust, active installation, broker and all sandbox resource policy
|
/// Live trust, active installation, broker and all sandbox resource policy
|
||||||
/// requirements must also hold. A lease does not establish those conditions.
|
/// requirements must also hold. A lease does not establish those conditions.
|
||||||
pub unsafe fn resume(self) -> Result<crate::extension_process::Running<'a>> {
|
pub unsafe fn resume(self) -> Result<crate::extension_process::Running<'a>> {
|
||||||
unsafe { self.process.resume_with_lease(self.lease) }
|
unsafe { self.process.resume_with_lease(self.lease, self.identity) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
struct EnvironmentValues(BTreeMap<String, String>);
|
struct EnvironmentValues(BTreeMap<String, String>);
|
||||||
@@ -153,6 +157,7 @@ impl Context<'_> {
|
|||||||
Ok(PreparedLaunch {
|
Ok(PreparedLaunch {
|
||||||
data,
|
data,
|
||||||
lease,
|
lease,
|
||||||
|
identity: crate::extension_call_authorization::Identity::from_claims(claims)?,
|
||||||
path: entry.path().to_owned(),
|
path: entry.path().to_owned(),
|
||||||
entry: entry.relative_name().to_owned(),
|
entry: entry.relative_name().to_owned(),
|
||||||
tree: entry.tree_sha256().to_owned(),
|
tree: entry.tree_sha256().to_owned(),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
//! Serial MCP session over an already-authorized native instance. Package trust,
|
//! Serial MCP session over an already-authorized native instance. The Host
|
||||||
//! tool consent/schema validation and registry routing remain Host responsibilities.
|
//! approval route still must establish user consent and current installation/trust.
|
||||||
use crate::{
|
use crate::{
|
||||||
extension_io::{Event, Pump},
|
extension_io::{Event, Pump},
|
||||||
extension_process::Running,
|
extension_process::Running,
|
||||||
@@ -93,6 +93,7 @@ pub struct Session<'a, 'p> {
|
|||||||
failed: bool,
|
failed: bool,
|
||||||
tools_changed: bool,
|
tools_changed: bool,
|
||||||
catalog: Option<crate::extension_mcp_tools::Catalog>,
|
catalog: Option<crate::extension_mcp_tools::Catalog>,
|
||||||
|
calls: crate::extension_call_authorization::Gate,
|
||||||
}
|
}
|
||||||
impl<'a, 'p> Session<'a, 'p> {
|
impl<'a, 'p> Session<'a, 'p> {
|
||||||
pub fn new(process: &'a Running<'p>, io: HostIo) -> Result<Self> {
|
pub fn new(process: &'a Running<'p>, io: HostIo) -> Result<Self> {
|
||||||
@@ -105,6 +106,7 @@ impl<'a, 'p> Session<'a, 'p> {
|
|||||||
failed: false,
|
failed: false,
|
||||||
tools_changed: false,
|
tools_changed: false,
|
||||||
catalog: None,
|
catalog: None,
|
||||||
|
calls: crate::extension_call_authorization::Gate::new(process.call_identity()?),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
pub fn initialize(&mut self, cancel: &AtomicBool) -> Result<String> {
|
pub fn initialize(&mut self, cancel: &AtomicBool) -> Result<String> {
|
||||||
@@ -149,6 +151,7 @@ impl<'a, 'p> Session<'a, 'p> {
|
|||||||
self.require_tools()?;
|
self.require_tools()?;
|
||||||
self.drain_pending()?;
|
self.drain_pending()?;
|
||||||
self.catalog = None;
|
self.catalog = None;
|
||||||
|
self.calls.invalidate();
|
||||||
self.tools_changed = false;
|
self.tools_changed = false;
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
let catalog = crate::extension_mcp_tools::Catalog::discover(|cursor| {
|
let catalog = crate::extension_mcp_tools::Catalog::discover(|cursor| {
|
||||||
@@ -201,12 +204,62 @@ impl<'a, 'p> Session<'a, 'p> {
|
|||||||
}
|
}
|
||||||
Ok(value)
|
Ok(value)
|
||||||
}
|
}
|
||||||
|
pub fn review_call(
|
||||||
|
&mut self,
|
||||||
|
name: &str,
|
||||||
|
arguments: Value,
|
||||||
|
) -> Result<crate::extension_call_authorization::Review> {
|
||||||
|
self.require_tools()?;
|
||||||
|
self.drain_pending()?;
|
||||||
|
let tool = self
|
||||||
|
.catalog
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| HostError::new("EXTENSION_MCP_CATALOG_REQUIRED"))?
|
||||||
|
.tool(name)?;
|
||||||
|
self.calls.review(&tool, arguments)
|
||||||
|
}
|
||||||
|
/// Only invoke from an authenticated Host route after the user approved this
|
||||||
|
/// exact review. This method is not registered as a renderer/Core command.
|
||||||
|
pub fn confirm_call(
|
||||||
|
&mut self,
|
||||||
|
review_id: &str,
|
||||||
|
) -> Result<crate::extension_call_authorization::ApprovedCall> {
|
||||||
|
self.require_tools()?;
|
||||||
|
self.drain_pending()?;
|
||||||
|
self.calls.confirm(review_id)
|
||||||
|
}
|
||||||
pub fn call_tool(
|
pub fn call_tool(
|
||||||
|
&mut self,
|
||||||
|
approved: crate::extension_call_authorization::ApprovedCall,
|
||||||
|
cancel: &AtomicBool,
|
||||||
|
) -> Result<Value> {
|
||||||
|
self.require_tools()?;
|
||||||
|
self.drain_pending()?;
|
||||||
|
let call = self.calls.consume(approved)?;
|
||||||
|
let tool = self
|
||||||
|
.catalog
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| HostError::new("EXTENSION_MCP_CATALOG_REQUIRED"))?
|
||||||
|
.tool(&call.name)?;
|
||||||
|
if crate::extension_call_authorization::contract_digest(&tool.description())?
|
||||||
|
!= call.contract_digest
|
||||||
|
{
|
||||||
|
return Err(HostError::new("EXTENSION_CALL_CATALOG_CHANGED"));
|
||||||
|
}
|
||||||
|
self.invoke_tool(&call.name, call.arguments, cancel)
|
||||||
|
}
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn test_call_tool(
|
||||||
&mut self,
|
&mut self,
|
||||||
name: &str,
|
name: &str,
|
||||||
arguments: Value,
|
arguments: Value,
|
||||||
cancel: &AtomicBool,
|
cancel: &AtomicBool,
|
||||||
) -> Result<Value> {
|
) -> Result<Value> {
|
||||||
|
let review = self.review_call(name, arguments)?;
|
||||||
|
let approved = self.confirm_call(&review.review_id)?;
|
||||||
|
self.call_tool(approved, cancel)
|
||||||
|
}
|
||||||
|
fn invoke_tool(&mut self, name: &str, arguments: Value, cancel: &AtomicBool) -> Result<Value> {
|
||||||
self.require_tools()?;
|
self.require_tools()?;
|
||||||
self.drain_pending()?;
|
self.drain_pending()?;
|
||||||
if name.is_empty()
|
if name.is_empty()
|
||||||
@@ -256,6 +309,7 @@ impl<'a, 'p> Session<'a, 'p> {
|
|||||||
} else if method == "notifications/tools/list_changed" {
|
} else if method == "notifications/tools/list_changed" {
|
||||||
self.tools_changed = true;
|
self.tools_changed = true;
|
||||||
self.catalog = None;
|
self.catalog = None;
|
||||||
|
self.calls.invalidate();
|
||||||
}
|
}
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
@@ -294,6 +348,7 @@ impl<'a, 'p> Session<'a, 'p> {
|
|||||||
}
|
}
|
||||||
fn abort(&mut self) {
|
fn abort(&mut self) {
|
||||||
self.failed = true;
|
self.failed = true;
|
||||||
|
self.calls.invalidate();
|
||||||
let _ = self.process.terminate();
|
let _ = self.process.terminate();
|
||||||
}
|
}
|
||||||
fn request(
|
fn request(
|
||||||
|
|||||||
@@ -77,6 +77,9 @@ pub struct Tool {
|
|||||||
output: Option<jsonschema::Validator>,
|
output: Option<jsonschema::Validator>,
|
||||||
}
|
}
|
||||||
impl Tool {
|
impl Tool {
|
||||||
|
pub(crate) fn description(&self) -> Description {
|
||||||
|
self.description.clone()
|
||||||
|
}
|
||||||
fn parse(value: &Value) -> Result<Self> {
|
fn parse(value: &Value) -> Result<Self> {
|
||||||
bounded(value, 192 * 1024, false)?;
|
bounded(value, 192 * 1024, false)?;
|
||||||
let name = value["name"]
|
let name = value["name"]
|
||||||
|
|||||||
@@ -94,6 +94,8 @@ pub struct Running<'a> {
|
|||||||
process: Process<'a>,
|
process: Process<'a>,
|
||||||
#[cfg(feature = "desktop")]
|
#[cfg(feature = "desktop")]
|
||||||
revocation: Option<crate::extension_revocation::Watch>,
|
revocation: Option<crate::extension_revocation::Watch>,
|
||||||
|
#[cfg(feature = "desktop")]
|
||||||
|
identity: Option<crate::extension_call_authorization::Identity>,
|
||||||
}
|
}
|
||||||
impl<'a> Suspended<'a> {
|
impl<'a> Suspended<'a> {
|
||||||
/// Creates hidden, with no inherited handles and an explicit environment and
|
/// Creates hidden, with no inherited handles and an explicit environment and
|
||||||
@@ -244,15 +246,18 @@ impl<'a> Suspended<'a> {
|
|||||||
process: self.0,
|
process: self.0,
|
||||||
#[cfg(feature = "desktop")]
|
#[cfg(feature = "desktop")]
|
||||||
revocation: None,
|
revocation: None,
|
||||||
|
#[cfg(feature = "desktop")]
|
||||||
|
identity: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/// # Safety
|
/// # Safety
|
||||||
/// The same complete resource/broker/trust preconditions as resume apply.
|
/// The same complete resource/broker/trust preconditions as resume apply.
|
||||||
/// This additionally arms revocation monitoring before any instruction resumes.
|
/// This additionally arms revocation monitoring before any instruction resumes.
|
||||||
#[cfg(feature = "desktop")]
|
#[cfg(feature = "desktop")]
|
||||||
pub unsafe fn resume_with_lease(
|
pub(crate) unsafe fn resume_with_lease(
|
||||||
self,
|
self,
|
||||||
lease: crate::extension_permit::Lease,
|
lease: crate::extension_permit::Lease,
|
||||||
|
identity: crate::extension_call_authorization::Identity,
|
||||||
) -> Result<Running<'a>> {
|
) -> Result<Running<'a>> {
|
||||||
let watch = crate::extension_revocation::Watch::arm(&self.0.job, lease)?;
|
let watch = crate::extension_revocation::Watch::arm(&self.0.job, lease)?;
|
||||||
watch.check()?;
|
watch.check()?;
|
||||||
@@ -262,12 +267,20 @@ impl<'a> Suspended<'a> {
|
|||||||
let running = Running {
|
let running = Running {
|
||||||
process: self.0,
|
process: self.0,
|
||||||
revocation: Some(watch),
|
revocation: Some(watch),
|
||||||
|
identity: Some(identity),
|
||||||
};
|
};
|
||||||
running.check_authorization()?;
|
running.check_authorization()?;
|
||||||
Ok(running)
|
Ok(running)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
impl Running<'_> {
|
impl Running<'_> {
|
||||||
|
#[cfg(feature = "desktop")]
|
||||||
|
pub(crate) fn call_identity(&self) -> Result<crate::extension_call_authorization::Identity> {
|
||||||
|
self.check_authorization()?;
|
||||||
|
self.identity
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| HostError::new("EXTENSION_CALL_BINDING_REQUIRED"))
|
||||||
|
}
|
||||||
#[cfg(feature = "desktop")]
|
#[cfg(feature = "desktop")]
|
||||||
pub fn start_io(
|
pub fn start_io(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -90,3 +90,6 @@ pub mod extension_mcp;
|
|||||||
|
|
||||||
#[cfg(all(windows, feature = "desktop"))]
|
#[cfg(all(windows, feature = "desktop"))]
|
||||||
pub mod extension_mcp_tools;
|
pub mod extension_mcp_tools;
|
||||||
|
|
||||||
|
#[cfg(all(windows, feature = "desktop"))]
|
||||||
|
pub mod extension_call_authorization;
|
||||||
|
|||||||
Reference in New Issue
Block a user