为 InterSystems AI Hub 代理和外部 MCP 客户端构建一套受管控的 FHIR 工具集——以及那个让每次拒绝都拥有完美借口的日志记录漏洞。
大家好,社区的朋友们! 我终于下定决心写下我的第一篇帖子——并决定直接挑战两个当下最热门的话题:AI 和安全。
先做个简短的免责声明:这是试图将两个在很长一段时间内都不会真正交汇的世界结合起来:AI 和老派的安全人员。让我来解释一下。
几年前,我曾参与一个非常老旧的航空医疗认证系统项目——该系统负责判定人员是否具备在航空领域工作和学习的体能及心理健康条件。项目本身以及我在其中的角色并不怎么有趣:主要是将系统从Cache迁移到IRIS,并根据新法规对客户端进行现代化改造。 但我至今仍记得那支由审计员和安全官员组成的团队:每次新的集成、每个新的封装层、每一步数据提取操作,都会引发数十个问题,产生数页的文档和数据流图。其中有三个问题让我印象深刻:
- 什么能阻止这个函数读取它无权访问的记录?
- 这些规则记录在哪里?我能否在不阅读你们代码的情况下查阅它们?
- 谁发起了这个调用,记录在哪里?
前两个问题我们轻松通过了。但第三个问题却让审计搁浅了:审计员要求提供针对医疗数据的特定访问记录,却未能找到。这并非因为没有发生访问:这些访问是合法的,检查机制也有效——我们只是在错误的层级上进行了日志记录。 我们花了一周时间证明,漏洞出在日志记录上,而不是访问控制上。“一种不留痕迹的控制措施,在审计时,与根本不存在的控制措施毫无二致。”这话确实有道理。 当时这感觉像是偏执。多年后,目睹了他人遭遇的失败(其中一些险些让我自己也陷入险境),我才理解并接受了那种“偏执”。我也完全意识到,为什么医疗数据的隐私性,尤其与其他数据截然不同。
回到当下。最近,我一直在尝试全新的 AI Hub,寻找超越简单问答范畴的应用场景。 而探索得越深,情况就越清晰:那三个问题从未消失——只是现在提出它们的不再是每年一次的审计员,而是一个语言模型,它会自主地在运行时,每次都提出这些问题。 此外,还有一个问题,即便在审计室里,连开玩笑都不敢提:这里真正的访问主体究竟是谁?是提出请求的模型?是执行请求的工具?还是那个承诺会“老实”的提示词?
这个演示仓库就是我对这四个问题的回答。而在这个过程中,它的审计日志成功实现了标题中所说的那个“绝招”。
盒子里有什么?
iris-governed-fhir-agent 旨在为 InterSystems AI Hub 上的代理以及外部 MCP 客户端构建受监管的 FHIR 工具。其架构很简单:一个代理、一项技能、三个工具、一台 MCP 服务器以及一个 docker compose up。

每款工具的首要原则是自我约束:
FindPatient——搜索患者并返回最多 10 个匹配结果。此后,每次仅通过GetPatientSummary/FhirSearch获取一名患者GetPatientSummary- 不会返回大量原始FHIR数据,而是返回紧凑的投影数据:患者、病症、生命体征、实验室检查、用药、过敏史——其中病症和用药仅限于当前有效的项目FhirSearch- 基于资源和参数的白名单进行搜索,严格限定在单个患者的范畴内。一旦超出该范畴,即被拒绝
数据为18名Synthea患者的NDJSON格式数据,已提交至代码库。
该镜像仍处于 EAP 阶段(我在 irishealth-community 2026.3.0AI.126.0 上构建并测试过),因此您需要从EAP 门户下载它,并手动将其导入 docker load。之后操作很简单:
git clone https://github.com/antonyartsev/iris-governed-fhir-agent.git
cd iris-governed-fhir-agent
cp .env.example .env # any OpenAI-compatible API key
docker compose up -d --build
其余所有内容均在构建时已集成到镜像中:容器启动时即可立即使用,无需外部卷;重新创建后,您将获得一个审计表为空的干净状态。
最后是关键点——这也是该项目存在的根本原因:这里的限制*是由工具和策略来执行的,而不是由提示词决定的。*提示词向模型说明如何行为;而策略和工具则决定它被允许做什么。这就是我对关于访问主体的第四个问题的回答。
四种场景,其中两种值得您花时间研究
在 README 中,我整理了四个客户端试图越界的情景。这些情景在文档中已有编号;下面我将其中两个按不同顺序呈现,因为第二个情景才是故事真正开始的地方。 一个重要细节:这四种场景都是通过 MCP 客户端进行的,且未使用我们的系统提示,因此拒绝操作来自 ToolSet 本身,而非被礼貌要求遵守规则的模型。
**根本没有患者:一个空的 patientId 。**客户端在未先解析患者信息的情况下就调用了 FhirSearch:该字段只是空的。 工具模式对此未加任何质疑就放行了:类型正确,字符串就是字符串。大多数客户端会直接在此处停止,但我们的客户端却固执地还是发出了调用。AI.Policy.Compartment在工具甚至尚未被调用之前就将其拦截了:
ToolAccessDenied: Tool 'FhirSearch' requires a valid patientId.
Resolve the patient first; patient scope is not optional.
其中的教训很简单,但我认为很重要:模式验证的是结构,而非权限。
**合法的 FHIR,非法的意图:_include 。**客户端使用 _include=Observation:patient 请求“观察”数据。从语法上讲,这完全符合 FHIR 规范,但本质上这是试图将结果集扩展到允许范围之外。 守护程序予以拒绝。这里有趣的不是“拒绝什么”,而是“如何拒绝”:拒绝响应并非以错误形式返回,而是作为带有有效载荷的正常工具结果返回:
{"error":"denied","message":"search parameter '_include' is not allowed for Observation, Allowed: category,code,date,status,_sort,_count"}
另外两个案例处理得更快。其中一个使用 _count=10000 请求 resourceType=Practitioner:由于该资源类型不在允许列表中,因此检查甚至未涉及所请求的 _count。 最后一个示例展示了从相反角度进行的检查:在隔离区内一切仍正常运行,经过三次拒绝后,客户端并未因此受阻——而是切换到了该患者的另一个就诊记录。
最终,空的 patientId 在工具主体之前被 AI.Policy.Compartment 拦截,因此 FHIR 服务器甚至从未看到过该调用。 _include 被 Core.Guard 从主体内部拦截:即使通过终端直接调用也无法绕过它,而任何 ToolSet 策略都无法检测到这种调用。 这并非重复,而是角色分离:策略层是快速路径,而守护程序才是无法绕过的部分。
现在进入最有趣的部分——它始于 _include 的拒绝:守护程序通过返回一个值来拒绝请求,而不是通过失败来拒绝
每次拒绝都会获得一个“不在场证明”
**拒绝以值的形式呈现,而非以 %Status 的形式 。**Guard 本可以像 ObjectScript 通常那样,返回一个带有错误的 %Status,但这里我刻意采用了不同的处理方式。 对于 LLM 客户端而言,拒绝是一种正常的响应:模型应读取该响应并调整其策略。 传输错误则不会给它这样的机会。客户端封装器会在模型察觉之前就吞噬该错误,最多只会留下“工具调用失败”的提示。而“作为值的拒绝”,连同其理由以及内部允许的内容列表,则会继续作为对话的一部分存在。
这正是最后一个场景所展示的。在连续遭遇三次拒绝后,智能体并未陷入僵局,也没有反复发送相同的请求——而是切换到了该隔离区内被允许的“Encounter”。它读取了拒绝信息——并做出了调整。
/// Every denial looks the same to the model
ClassMethod Deny(reason As %String) As %String
{
Return {"error":"denied","message":(reason)}.%ToJSON()
}
// inside the tool body the denial is returned as a regular result:
If query = "" Return ##class(GovernedFHIR.Core.Guard).Deny("a search query is required")
**这一后果导致了审计失败。**该决策(拒绝 = 值)产生了一个我起初未察觉的后果:AuditLog 通过其 %Status 确定了调用的结果。 请记住,Guard的拒绝其实是载荷中包含拒绝信息的成功%Status。结果是:每次Guard的拒绝都被记录在日志中为成功。防护机制运行得天衣无缝,而日志却显示它们从未触发。
// before: outcome = the %Status of the call, but a Guard denial is a successful %Status
Set allowed = $$$ISOK(status)
// after: status is OK - the verdict is still read from the payload
If allowed {
Set denialReason = ..DeniedReason(result) // "denied" / "result_too_large"
If denialReason '= "" Set allowed = 0
}
无人察觉的拒绝。被策略拒绝的调用甚至无法到达审计层:ToolManager 会在 %CanExecute 失败时停止,审计还来不及查看任何内容,链条就已经中断了。 这就是为什么 AI.Policy.Compartment 在返回拒绝响应之前,会自行写入一条日志记录。 否则,没有人会注意到这次被拦截的尝试。为了额外的安全起见,拒绝操作还会作为 GovernedFHIR / ToolCall / Denied 事件写入 IRIS 自身的审计数据库——这是安全团队每天都会查看的地方。

这并非 ObjectScript 特有的问题。如果您的守护程序通过返回一个值来表示拒绝,而您的审计层根据调用状态来判定成功与否,那么您的合规日志将显示 100% 的调用成功,尽管拒绝机制实际上运行得非常完美。在 HAPI FHIR、Python MCP 服务器以及任何其他将“拒绝”和“错误”视为不同概念的工具层中,都存在同样的陷阱。
细则说明
再次强调:这只是对受管工具机制的教育性演示,并非生产环境的访问控制模型,且该实例未启用端到端身份验证。在我实现的 MCP 应用中,该应用特意接受无身份验证的调用,以便在客户端与数据之间,唯一存在的障碍就是受管工具本身。
两层架构带来一个不错的副作用:Core 完全不依赖 AI Hub,因此单元测试无需大语言模型(LLM)、无需网络访问,甚至无需任何 API 密钥——纯粹的 %UnitTest。
在此过程中,我还发现了一个与连接 OpenRouter 提供商相关的错误——ai-hub-eap#30,但这又是另一个故事了。
作为结语,有两个问题
- 你会将哪条规则添加到允许列表中——又认为哪条现有的规则已经过于严格了?
- 目前在你的系统架构中,哪里存在“拒绝”却伪装成“成功”的情况?