a207-policy 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,28 @@
1
+ Metadata-Version: 2.4
2
+ Name: a207-policy
3
+ Version: 0.2.0
4
+ Summary: A207 统一身份注入 / 权限矩阵 / 状态路径策略包(Plan A 单一事实源)
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+
8
+ # a207-policy
9
+
10
+ A207 统一身份注入 / 权限矩阵 / 状态路径策略包(Plan A 单一事实源)。
11
+
12
+ 13 个 MCP 统一依赖本包,取代原先散落在 M1/M2/M4/M11/M13 的多份权限副本。
13
+
14
+ ## 用法
15
+
16
+ ```python
17
+ from a207_policy import get_caller, enforce_read, enforce_write, LIS_READ_FULL
18
+
19
+ def get_labs(patient_id: str) -> dict:
20
+ caller = get_caller() # 来自 env A207_CALLER,模型碰不到
21
+ enforce_read("a207-lis-mcp") # 确定性拒绝越权(fail-closed)
22
+ ...
23
+ ```
24
+
25
+ - 身份注入:`A207_CALLER` 环境变量由部署配置写入,模型无法自证(修复 P0-1)。
26
+ - 测试:`from a207_policy import set_caller; set_caller("doctor_assistant")`。
27
+ - 状态路径:`from a207_policy import resolve_state_path; resolve_state_path("followup_store.json")`
28
+ 落到 `A207_DATA_DIR` 或系统临时目录,避免只读安装目录崩溃(修复 P1-3)。
@@ -0,0 +1,21 @@
1
+ # a207-policy
2
+
3
+ A207 统一身份注入 / 权限矩阵 / 状态路径策略包(Plan A 单一事实源)。
4
+
5
+ 13 个 MCP 统一依赖本包,取代原先散落在 M1/M2/M4/M11/M13 的多份权限副本。
6
+
7
+ ## 用法
8
+
9
+ ```python
10
+ from a207_policy import get_caller, enforce_read, enforce_write, LIS_READ_FULL
11
+
12
+ def get_labs(patient_id: str) -> dict:
13
+ caller = get_caller() # 来自 env A207_CALLER,模型碰不到
14
+ enforce_read("a207-lis-mcp") # 确定性拒绝越权(fail-closed)
15
+ ...
16
+ ```
17
+
18
+ - 身份注入:`A207_CALLER` 环境变量由部署配置写入,模型无法自证(修复 P0-1)。
19
+ - 测试:`from a207_policy import set_caller; set_caller("doctor_assistant")`。
20
+ - 状态路径:`from a207_policy import resolve_state_path; resolve_state_path("followup_store.json")`
21
+ 落到 `A207_DATA_DIR` 或系统临时目录,避免只读安装目录崩溃(修复 P1-3)。
@@ -0,0 +1,16 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "a207-policy"
7
+ version = "0.2.0"
8
+ description = "A207 统一身份注入 / 权限矩阵 / 状态路径策略包(Plan A 单一事实源)"
9
+ requires-python = ">=3.10"
10
+ readme = "README.md"
11
+
12
+ [tool.setuptools]
13
+ package-dir = {"" = "src"}
14
+
15
+ [tool.setuptools.packages.find]
16
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,89 @@
1
+ """a207-policy —— A207 统一身份注入 / 权限矩阵 / 状态路径策略包。
2
+
3
+ Plan A 单一事实源。13 个 MCP 统一依赖本包:
4
+ - caller 注入(env A207_CALLER,fail-closed)→ get_caller / set_caller
5
+ - 权限矩阵 + MX-3 写权 + 各包放行集合 → matrix 模块
6
+ - 确定性执行 → enforce_read / enforce_write / check_permission / knowledge_profile
7
+ - 可写状态路径 → resolve_state_path
8
+
9
+ 调用方(各 MCP 工具)应在函数入口:caller = get_caller(); access = enforce_read("a207-xxx-mcp")
10
+ 取代原先「caller 是 LLM 自己填的参数」的旧模式(P0-1 修复)。
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from .caller import CallerError, CallerUnknown, as_caller, get_caller, set_caller
16
+ from .gate import (
17
+ PermissionDenied,
18
+ _MATRIX_EXEMPT_WRITE_TOOLS,
19
+ check_permission,
20
+ detect_write_tool,
21
+ enforce_nutrition_tool,
22
+ enforce_read,
23
+ enforce_write,
24
+ is_write_action,
25
+ knowledge_profile,
26
+ )
27
+ from .matrix import (
28
+ ACCESS_LIMITED,
29
+ ACCESS_NONE,
30
+ ACCESS_RW,
31
+ ACCESS_READ,
32
+ CALLERS,
33
+ CHILD_FORBIDDEN_MCPS,
34
+ CLINICAL_CALC_MCP,
35
+ CLINICIAN_ONLY_FIELDS,
36
+ CLINICIAN_ONLY_HIDDEN_FROM,
37
+ GAMIFICATION_MCP,
38
+ FOLLOWUP_CLINICIAN,
39
+ FOLLOWUP_WRITE_ALLOWED,
40
+ GAMIFICATION_ALLOWED,
41
+ HIS_ALLOWED_FILTER_KEYS,
42
+ HIS_BLOCKED,
43
+ HIS_COHORT,
44
+ HIS_FULL_VIEW,
45
+ HIS_LIMITED,
46
+ HIS_READ,
47
+ KNOWLEDGE_PROFILE,
48
+ LIS_CRITICAL_CHANNEL,
49
+ LIS_READ_FULL,
50
+ LIS_READ_LIMITED,
51
+ LIS_WRITE_ALLOWED,
52
+ MCP_ALIASES,
53
+ MCP_REGISTRY,
54
+ MX1_BLOCKED_CALLERS,
55
+ NOTIFY_READ_ROLES,
56
+ NOTIFY_WRITE_ROLES,
57
+ NUTRITION_ASSESSMENT_CLINICAL_ROLES,
58
+ NUTRITION_ASSESSMENT_CLINICAL_TOOLS,
59
+ NUTRITION_ASSESSMENT_DATA_ROLES,
60
+ NUTRITION_ASSESSMENT_DATA_TOOLS,
61
+ NUTRITION_ASSESSMENT_WRITE_ALLOWED,
62
+ PERMISSION_MATRIX,
63
+ WRITE_TOOL_ALIASES,
64
+ WRITE_TOOL_POLICY,
65
+ normalize_mcp,
66
+ resolve_access,
67
+ )
68
+ from .state import resolve_state_path
69
+
70
+ __all__ = [
71
+ "CallerError", "CallerUnknown", "as_caller", "get_caller", "set_caller",
72
+ "PermissionDenied", "check_permission", "detect_write_tool",
73
+ "enforce_nutrition_tool", "enforce_read", "enforce_write", "is_write_action",
74
+ "knowledge_profile", "_MATRIX_EXEMPT_WRITE_TOOLS",
75
+ "ACCESS_NONE", "ACCESS_READ", "ACCESS_LIMITED", "ACCESS_RW",
76
+ "CALLERS", "CHILD_FORBIDDEN_MCPS", "CLINICAL_CALC_MCP", "CLINICIAN_ONLY_FIELDS",
77
+ "CLINICIAN_ONLY_HIDDEN_FROM",
78
+ "GAMIFICATION_MCP",
79
+ "FOLLOWUP_CLINICIAN", "FOLLOWUP_WRITE_ALLOWED", "GAMIFICATION_ALLOWED",
80
+ "HIS_ALLOWED_FILTER_KEYS", "HIS_BLOCKED", "HIS_COHORT", "HIS_FULL_VIEW",
81
+ "HIS_LIMITED", "HIS_READ", "KNOWLEDGE_PROFILE",
82
+ "LIS_CRITICAL_CHANNEL", "LIS_READ_FULL", "LIS_READ_LIMITED", "LIS_WRITE_ALLOWED",
83
+ "MCP_ALIASES", "MCP_REGISTRY", "MX1_BLOCKED_CALLERS",
84
+ "NOTIFY_READ_ROLES", "NOTIFY_WRITE_ROLES", "NUTRITION_ASSESSMENT_WRITE_ALLOWED",
85
+ "NUTRITION_ASSESSMENT_DATA_TOOLS", "NUTRITION_ASSESSMENT_DATA_ROLES",
86
+ "NUTRITION_ASSESSMENT_CLINICAL_TOOLS", "NUTRITION_ASSESSMENT_CLINICAL_ROLES",
87
+ "PERMISSION_MATRIX", "WRITE_TOOL_ALIASES", "WRITE_TOOL_POLICY",
88
+ "normalize_mcp", "resolve_access", "resolve_state_path",
89
+ ]
@@ -0,0 +1,95 @@
1
+ """身份注入(A1 核心)。
2
+
3
+ 设计要点(对照架构复盘 P0-1):
4
+ - 旧方案:caller 是 LLM 自己填的工具参数 → 模型可被提示词注入冒充医生。
5
+ - 新方案:caller 来自**部署时注入的环境变量 A207_CALLER**,模型碰不到。
6
+ 每个 Agent 用各自连接配置启动 MCP 服务端时写入该变量(如家长助手配置写死
7
+ A207_CALLER=parent_assistant),服务端进程内读取,fail-closed:缺失即拒绝。
8
+
9
+ stdio 部署:启动子进程时由连接配置 setenv。
10
+ HTTP/SSE 部署(将来):网关在会话建立时把身份写入请求上下文,服务端从上下文读取;
11
+ 本模块保留 get_caller() 单一入口,将来只需在 HTTP 分支扩展,调用方代码不变。
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ from contextlib import contextmanager
18
+ from typing import Iterator, Optional
19
+
20
+ ENV_KEY = "A207_CALLER"
21
+
22
+ # 测试覆盖位(仅测试用)。生产代码严禁调用 set_caller。
23
+ _test_override: Optional[str] = None
24
+
25
+
26
+ class CallerError(Exception):
27
+ """权限/身份相关错误的基类。"""
28
+
29
+
30
+ class CallerUnknown(CallerError):
31
+ """caller 未注入(环境变量缺失或为空)→ fail-closed 拒绝。"""
32
+
33
+
34
+ def get_caller() -> str:
35
+ """返回当前进程注入的调用方身份。缺失或空 → 抛 CallerUnknown(fail-closed)。
36
+
37
+ 生产环境:读取环境变量 A207_CALLER。
38
+ 测试环境:若已调用 set_caller(),返回覆盖值(便于单测,不依赖环境变量)。
39
+ """
40
+ if _test_override is not None:
41
+ return _test_override
42
+ value = (os.environ.get(ENV_KEY) or "").strip()
43
+ if not value:
44
+ raise CallerUnknown(
45
+ f"caller 未注入:环境变量 {ENV_KEY} 缺失或为空。"
46
+ f"服务端必须由部署配置注入身份,模型不可自证身份(P0-1 修复)。"
47
+ )
48
+ return value
49
+
50
+
51
+ def set_caller(value: Optional[str]) -> None:
52
+ """仅用于测试:覆盖 caller 来源。生产代码不得调用。"""
53
+ global _test_override
54
+ _test_override = value
55
+
56
+
57
+ @contextmanager
58
+ def as_caller(value: Optional[str]) -> Iterator[None]:
59
+ """仅用于测试:在代码块内临时切换身份,退出时**一定**还原(异常也还原)。
60
+
61
+ 为什么需要它
62
+ ------------
63
+ 真实部署里一个进程只有一个身份(部署配置写死),所以生产代码永远用不到这个。
64
+ 但端到端联调要模拟"医生录检验 → 风险引擎评估 → 通知发家长 → 孩子打卡"
65
+ 这样跨角色的完整链路,必须能逐步换身份,否则测不出真实的权限交接。
66
+
67
+ 手写 set_caller + try/finally 也能做,但漏写 finally 就会污染后续用例,
68
+ 而且那种污染很隐蔽——后面的用例莫名其妙有了不该有的权限,还看不出为什么。
69
+ 所以固化成上下文管理器,用法上不给人犯错的机会。
70
+
71
+ with as_caller("doctor_assistant"):
72
+ core.upsert_lab_result(...)
73
+ with as_caller("parent_assistant"):
74
+ core.get_labs(...) # 自动换回受限视图
75
+
76
+ 传 None 表示"模拟身份完全缺失",用来测 fail-closed。注意这里必须同时清掉
77
+ 环境变量——只把 _test_override 置 None 的话,get_caller 会回退去读 env,
78
+ 结果身份还在,fail-closed 测了个寂寞。这个坑很容易踩,所以封在里面。
79
+ """
80
+ global _test_override
81
+ prev_override = _test_override
82
+ prev_env = os.environ.get(ENV_KEY)
83
+ if value is None:
84
+ _test_override = None
85
+ os.environ.pop(ENV_KEY, None)
86
+ else:
87
+ _test_override = value
88
+ try:
89
+ yield
90
+ finally:
91
+ _test_override = prev_override
92
+ if prev_env is None:
93
+ os.environ.pop(ENV_KEY, None)
94
+ else:
95
+ os.environ[ENV_KEY] = prev_env
@@ -0,0 +1,229 @@
1
+ """权限执行点(确定性,非 LLM 可选)。
2
+
3
+ - check_permission:保留 M13 兼容的返回形态(dict),供路由层查表。
4
+ - enforce_read / enforce_write:供各 MCP 工具在入口处调用,放行返回 access 字符串,
5
+ 拒绝抛 PermissionDenied(确定性,模型无法绕过 —— 解决 P0-2 门禁虚设的根因:
6
+ 校验不再依赖 LLM「自觉」调用某个 gate 工具,而是每个工具内强制)。
7
+ - detect_write_tool / is_write_action:MX-3 写工具识别。
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+
14
+ from .caller import CallerError, get_caller
15
+ from .matrix import (
16
+ ACCESS_RW,
17
+ CALLERS,
18
+ KNOWLEDGE_PROFILE,
19
+ NUTRITION_ASSESSMENT_CLINICAL_ROLES,
20
+ NUTRITION_ASSESSMENT_CLINICAL_TOOLS,
21
+ NUTRITION_ASSESSMENT_DATA_ROLES,
22
+ NUTRITION_ASSESSMENT_DATA_TOOLS,
23
+ NUTRITION_ASSESSMENT_WRITE_ALLOWED,
24
+ PERMISSION_MATRIX,
25
+ WRITE_TOOL_POLICY,
26
+ WRITE_TOOL_ALIASES,
27
+ normalize_mcp,
28
+ resolve_access,
29
+ )
30
+
31
+ _ALIASES = WRITE_TOOL_ALIASES # 别名引用,保持 detect_write_tool 可读
32
+
33
+ _NOTIFY_RE = re.compile(r"notify_[a-z_]+")
34
+ _UNDERSCORE_PREFIXES = tuple(p for p in (
35
+ "write", "create", "update", "delete", "insert",
36
+ "upsert_", "notify_", "log_", "award_", "push_", "trigger_",
37
+ "close_", "schedule_", "save_",
38
+ ) if p.endswith("_"))
39
+
40
+ # OD-010 选项 A(已落地):M3 是「计算+数据」混合包,工具级 ACL(enforce_nutrition_tool)
41
+ # 在矩阵(MCP 粒度)之下做细分授权:
42
+ # - upsert_food_diary(写日记):仅家长/患儿,比矩阵 M3×家长=RL 更宽 → 豁免矩阵 R/W 校验
43
+ # - record_pew_risk(落 PEW 历史):仅临床角色,比矩阵 M3×临床=READ 更宽 → 豁免矩阵 R/W 校验
44
+ # 这两个写工具是「工具级白名单治理」的有意例外,红队 B7b 亦据此跳过(见 tests/red_team_probe.py)。
45
+ _MATRIX_EXEMPT_WRITE_TOOLS: frozenset[str] = frozenset(
46
+ {"upsert_food_diary", "record_pew_risk"})
47
+
48
+
49
+ class PermissionDenied(CallerError):
50
+ """确定性拒绝。携带足够信息供工具转成各包既有 forbidden 形态。"""
51
+
52
+ def __init__(self, caller: str, mcp: str, action: str, reason: str):
53
+ self.caller = caller
54
+ self.mcp = mcp
55
+ self.action = action
56
+ self.reason = reason
57
+ super().__init__(f"DENIED {caller} -> {mcp}:{action} : {reason}")
58
+
59
+
60
+ def detect_write_tool(text: str) -> str | None:
61
+ """从 intent/action 文本中识别已登记的写工具,识别不到返回 None。"""
62
+ lowered = (text or "").strip().lower()
63
+ if not lowered:
64
+ return None
65
+ for tool in sorted(WRITE_TOOL_POLICY, key=len, reverse=True):
66
+ if tool in lowered:
67
+ return tool
68
+ for alias, tool in sorted(_ALIASES.items(), key=lambda kv: len(kv[0]), reverse=True):
69
+ if alias in lowered:
70
+ return tool
71
+ if _NOTIFY_RE.search(lowered):
72
+ return "notify_physician"
73
+ return None
74
+
75
+
76
+ def is_write_action(action: str) -> bool:
77
+ lowered = (action or "").strip().lower()
78
+ if any(lowered.startswith(prefix) for prefix in (
79
+ "write", "create", "update", "delete", "insert")):
80
+ return True
81
+ if any(prefix in lowered for prefix in _UNDERSCORE_PREFIXES):
82
+ return True
83
+ return any(hint in lowered for hint in ("写入", "写回", "新增一条", "保存到", "提交写"))
84
+
85
+
86
+ def check_permission(caller: str, mcp: str, action: str = "read") -> dict:
87
+ """M13 兼容查表:返回 {allow, access, resolved_action, reason, ...}。"""
88
+ caller_id = (caller or "").strip()
89
+ mcp_id = normalize_mcp(mcp)
90
+ action_id = (action or "read").strip()
91
+
92
+ if caller_id not in CALLERS:
93
+ return _perm(False, "-", "read", f"caller={caller_id or '(空)'} 未在权限矩阵登记")
94
+ if mcp_id not in PERMISSION_MATRIX:
95
+ return _perm(False, "-", "read", f"mcp={mcp_id or '(空)'} 不在 13 个已登记 MCP 内")
96
+
97
+ access = PERMISSION_MATRIX[mcp_id][caller_id]
98
+ tool = detect_write_tool(action_id)
99
+ if tool:
100
+ return _check_write_tool(caller_id, mcp_id, access, tool)
101
+
102
+ is_write = is_write_action(action_id)
103
+ allow = resolve_access(access, is_write)
104
+ kind = "write" if is_write else "read"
105
+ verdict = "放行" if allow else "拒绝"
106
+ return _perm(allow, access, kind, f"矩阵 {mcp_id} x {caller_id} = {access},{kind} 请求{verdict}")
107
+
108
+
109
+ def _check_write_tool(caller_id: str, mcp_id: str, access: str, tool: str) -> dict:
110
+ policy = WRITE_TOOL_POLICY.get(tool)
111
+ if policy is None:
112
+ # 未登记的写工具名:回到读写二分
113
+ allow = resolve_access(access, True)
114
+ return _perm(allow, access, "write",
115
+ f"未登记写工具 {tool},按矩阵 {mcp_id} x {caller_id} = {access} 判定")
116
+ owner_mcp = str(policy["mcp"])
117
+ allowed: frozenset[str] = policy["allowed"]
118
+ if mcp_id != owner_mcp:
119
+ return _perm(False, access, "write",
120
+ f"写工具 {tool} 属于 {owner_mcp},与 mcp={mcp_id} 不一致")
121
+ if caller_id not in allowed:
122
+ return _perm(False, access, "write",
123
+ f"MX-3 写权收口:{tool} 仅允许 {'/'.join(sorted(allowed))},"
124
+ f"当前 caller={caller_id}")
125
+ return _perm(True, access, "write", f"MX-3 放行:{tool} 由 {caller_id} 执行",
126
+ tool=tool, requires_confirmation=bool(policy["requires_confirmation"]))
127
+
128
+
129
+ def _perm(allow: bool, access: str, resolved_action: str, reason: str, *,
130
+ tool: str | None = None, requires_confirmation: bool = False) -> dict:
131
+ return {
132
+ "allow": allow,
133
+ "access": access,
134
+ "resolved_action": resolved_action,
135
+ "reason": reason,
136
+ "write_tool": tool,
137
+ "requires_confirmation": requires_confirmation,
138
+ "limited_view": access == "RL",
139
+ }
140
+
141
+
142
+ def enforce_read(mcp_name: str) -> str:
143
+ """入口守卫:caller 是否可读 mcp_name。放行返回 access(R/RL/RW),拒绝抛 PermissionDenied。"""
144
+ return _enforce(mcp_name, "read")
145
+
146
+
147
+ def enforce_write(mcp_name: str, tool: str | None = None) -> str:
148
+ """入口守卫:caller 是否可写 mcp_name(tool 为写工具名时走 MX-3)。"""
149
+ return _enforce(mcp_name, tool or "write")
150
+
151
+
152
+ def enforce_nutrition_tool(caller: str, tool: str) -> str:
153
+ """M3 工具级授权(OD-010 选项 A):数据面写仅家长/患儿,数据面读 + 计算面仅临床角色。
154
+
155
+ M3 是「计算 + 数据」混合包。矩阵对 M3 仅做 MCP 粒度授权(家长/患儿=RL、临床=READ、
156
+ orchestrator=R/W),无法表达"同包内某些工具家长能写、某些只能临床角色算"。
157
+ 故在此做工具级细分(单一事实源在 matrix 模块):
158
+ - 数据面·写 upsert_food_diary:仅家长/患儿(与 MX-3 WRITE_TOOL_POLICY /
159
+ core._WRITE_ALLOWED_CALLERS 一致,单一事实源)
160
+ - 数据面·读 get_food_diary_summary:家长/患儿/临床角色(临床需读日记做评估)
161
+ - 计算面·临床判读/落库(calc_prnt_targets / assess_intake_vs_target / assess_pew_risk /
162
+ calc_growth_zscore / record_pew_risk / get_pew_history):仅 doctor/nutritionist/orchestrator
163
+ 未登记工具 fail-closed 拒绝。caller 为已校验身份字符串(由调用方经 get_caller() 取得)。
164
+ """
165
+ if caller not in CALLERS:
166
+ raise PermissionDenied(
167
+ caller, "a207-nutrition-assessment-mcp", tool, "caller 未登记")
168
+ # 数据面·写:饮食日记写入仅家长/患儿(与 MX-3 写权收口一致,单一事实源)
169
+ if tool == "upsert_food_diary":
170
+ if caller in NUTRITION_ASSESSMENT_WRITE_ALLOWED:
171
+ return "RL"
172
+ raise PermissionDenied(
173
+ caller, "a207-nutrition-assessment-mcp", tool,
174
+ "饮食日记写入仅限家长/患儿")
175
+ # 数据面·读:日记摘要家长/患儿/临床角色可读
176
+ if tool in NUTRITION_ASSESSMENT_DATA_TOOLS:
177
+ if caller in NUTRITION_ASSESSMENT_DATA_ROLES:
178
+ return "RL"
179
+ raise PermissionDenied(
180
+ caller, "a207-nutrition-assessment-mcp", tool,
181
+ "日记摘要仅限家长/患儿/临床角色")
182
+ # 计算面·临床判读/落库:仅临床角色
183
+ if tool in NUTRITION_ASSESSMENT_CLINICAL_TOOLS:
184
+ if caller in NUTRITION_ASSESSMENT_CLINICAL_ROLES:
185
+ return PERMISSION_MATRIX["a207-nutrition-assessment-mcp"][caller]
186
+ raise PermissionDenied(
187
+ caller, "a207-nutrition-assessment-mcp", tool,
188
+ "计算面工具仅限临床角色(doctor/nutritionist/orchestrator)")
189
+ raise PermissionDenied(
190
+ caller, "a207-nutrition-assessment-mcp", tool, f"未登记工具: {tool}")
191
+
192
+
193
+ def _enforce(mcp_name: str, action: str) -> str:
194
+ caller = get_caller()
195
+ if caller not in CALLERS:
196
+ raise PermissionDenied(caller, mcp_name, action, f"caller 未登记: {caller}")
197
+ mcp = normalize_mcp(mcp_name)
198
+ if mcp not in PERMISSION_MATRIX:
199
+ raise PermissionDenied(caller, mcp_name, action, "mcp 未登记")
200
+ access = PERMISSION_MATRIX[mcp][caller]
201
+
202
+ wt = detect_write_tool(action) if action != "read" else None
203
+ if wt:
204
+ policy = WRITE_TOOL_POLICY.get(wt)
205
+ if policy is None:
206
+ if not resolve_access(access, True):
207
+ raise PermissionDenied(caller, mcp, action, f"access={access} 不允许写")
208
+ elif mcp != policy["mcp"] or caller not in policy["allowed"]:
209
+ raise PermissionDenied(caller, mcp, action, f"MX-3 写权受限: {wt}")
210
+ else:
211
+ # OD-011:登记写工具额外回查矩阵 R/W(OD-010 待决豁免除外),确保矩阵为唯一事实源。
212
+ # 例:notify_* 仅 risk_warning=R/W 可写;push_to_emr 仅 doctor=R/W(即便 nutritionist 同有 R/W 仍按工具白名单收口)。
213
+ if wt not in _MATRIX_EXEMPT_WRITE_TOOLS:
214
+ owner = policy["mcp"]
215
+ if PERMISSION_MATRIX[owner][caller] != ACCESS_RW:
216
+ raise PermissionDenied(
217
+ caller, owner, action,
218
+ f"矩阵 {owner} x {caller} = {PERMISSION_MATRIX[owner][caller]},无 R/W,拒绝 {wt}")
219
+ else:
220
+ is_write = action != "read"
221
+ if not resolve_access(access, is_write):
222
+ raise PermissionDenied(caller, mcp, action,
223
+ f"access={access} 不允许 {action}")
224
+ return access
225
+
226
+
227
+ def knowledge_profile(caller: str) -> str:
228
+ """M12 按 caller 切语料 profile。"""
229
+ return KNOWLEDGE_PROFILE.get((caller or "").strip(), "none")
@@ -0,0 +1,347 @@
1
+ """权限矩阵 / 写权策略 / 各包放行集合 —— A207 唯一事实源(Plan A 收口位置)。
2
+
3
+ 本模块取代原先散落在 M1/M2/M4/M11/M13 的多份权限副本(架构复盘 P1-1 指出的漂移源)。
4
+ 所有 MCP 从这里 import,不再各自维护一份。
5
+
6
+ 注意:本模块**只固化数据**,不擅自改变「谁能做什么」。各包本地守卫逻辑保留,
7
+ 只是把 caller 来源改成 env 注入、把放行集合改成从本模块 import。
8
+ 矩阵值 = 需求文档 §7 + MX-3 写权收口;各包本地集合 = 当前实际生效范围(已核对代码)。
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ # ---------------------------------------------------------------- 角色与 MCP
14
+
15
+ CALLERS: tuple[str, ...] = (
16
+ "orchestrator",
17
+ "doctor_assistant",
18
+ "nutritionist",
19
+ "parent_assistant",
20
+ "child_companion",
21
+ "risk_warning",
22
+ )
23
+
24
+ MCP_REGISTRY: dict[str, str] = {
25
+ "a207-his-mcp": "M1",
26
+ "a207-lis-mcp": "M2",
27
+ "a207-nutrition-assessment-mcp": "M3",
28
+ "a207-followup-mcp": "M4",
29
+ "a207-nutrition-calc-mcp": "M5",
30
+ "a207-ckd-clinical-calc-mcp": "M6",
31
+ "a207-meal-plan-mcp": "M7",
32
+ "a207-risk-rules-mcp": "M8",
33
+ "a207-report-mcp": "M9",
34
+ "a207-notify-mcp": "M10",
35
+ "a207-gamification-mcp": "M11",
36
+ "a207-knowledge-mcp": "M12",
37
+ "a207-router-mcp": "M13",
38
+ }
39
+
40
+ GAMIFICATION_MCP = "a207-gamification-mcp"
41
+ CLINICAL_CALC_MCP = "a207-ckd-clinical-calc-mcp"
42
+
43
+ # 包名别名归一(OD-002):登记表沿用早期命名,实际交付目录/PyPI 包名已变更。
44
+ MCP_ALIASES: dict[str, str] = {
45
+ "a207-clinical-calc-mcp": "a207-ckd-clinical-calc-mcp",
46
+ "a207-notification-mcp": "a207-notify-mcp",
47
+ "a207-nutrition-assessment-mcp-nfyy": "a207-nutrition-assessment-mcp",
48
+ }
49
+
50
+
51
+ def normalize_mcp(name: str) -> str:
52
+ """把实际发行/目录包名归一为登记表内的键;未知名原样返回(由上层判未登记)。"""
53
+ key = (name or "").strip()
54
+ return MCP_ALIASES.get(key, key)
55
+
56
+
57
+ # MX-1:分期类问题不由营养师 / 家长助手判定,改读 M1 已确诊分期
58
+ MX1_BLOCKED_CALLERS: frozenset[str] = frozenset({"nutritionist", "parent_assistant"})
59
+
60
+ # MX-2:患儿伙伴的医疗数据禁区(构建规格 §2.1 逐条列出的 8 个包)
61
+ CHILD_FORBIDDEN_MCPS: frozenset[str] = frozenset(
62
+ {
63
+ "a207-his-mcp",
64
+ "a207-lis-mcp",
65
+ "a207-nutrition-calc-mcp",
66
+ "a207-ckd-clinical-calc-mcp",
67
+ "a207-meal-plan-mcp",
68
+ "a207-risk-rules-mcp",
69
+ "a207-report-mcp",
70
+ "a207-notify-mcp",
71
+ }
72
+ )
73
+
74
+ # ---------------------------------------------------------------- MX-1 字段可见性边界
75
+ CLINICIAN_ONLY_FIELDS: frozenset[str] = frozenset({
76
+ "note_to_clinician", "clinician_note", "doctor_note", "doctor_notes", "internal_note", "note_to_doctor",
77
+ "emr_status", "push_to_emr", "physician_confirmed",
78
+ "scr_umol_L", "egfr_ml_min", "k_mmol_L", "p_mmol_L", "ca_mmol_L", "na_mmol_L",
79
+ "cl_mmol_L", "bun_mmol_L", "albumin_g_L", "prealbumin_mg_L", "hb_g_L",
80
+ "ipth_pg_mL", "vitd25oh_nmol_L", "ua_umol_L", "urine_protein_g_24h", "upcr_mg_mmol",
81
+ "critical_value", "critical_flag",
82
+ "prior_level", "level_correction",
83
+ "nutrition_ceiling", "stage_confirmed_by", "z_score_height",
84
+ })
85
+
86
+ # 与 CLINICIAN_ONLY_FIELDS 配套:上述字段「绝不可见」的角色(家庭角色)。M9 报告层据此对
87
+ # sections / summary_markdown 做受限脱敏(OD-013)。单一事实源放在策略包,避免各 MCP 包
88
+ # 再硬编码角色集合(conformance C3 禁止包内硬编码角色集字面量)。
89
+ CLINICIAN_ONLY_HIDDEN_FROM: frozenset[str] = frozenset({"parent_assistant", "child_companion"})
90
+
91
+ # ---------------------------------------------------------------- 权限矩阵(§7)
92
+
93
+ ACCESS_NONE = "-"
94
+ ACCESS_READ = "R"
95
+ ACCESS_LIMITED = "RL"
96
+ ACCESS_RW = "R/W"
97
+
98
+ _READ_OK: frozenset[str] = frozenset({ACCESS_READ, ACCESS_LIMITED, ACCESS_RW})
99
+
100
+ PERMISSION_MATRIX: dict[str, dict[str, str]] = {
101
+ "a207-his-mcp": {
102
+ "orchestrator": ACCESS_READ, "doctor_assistant": ACCESS_READ,
103
+ "nutritionist": ACCESS_LIMITED, "parent_assistant": ACCESS_LIMITED,
104
+ "child_companion": ACCESS_NONE, "risk_warning": ACCESS_READ,
105
+ },
106
+ "a207-lis-mcp": {
107
+ "orchestrator": ACCESS_NONE, "doctor_assistant": ACCESS_RW,
108
+ "nutritionist": ACCESS_NONE, "parent_assistant": ACCESS_LIMITED,
109
+ "child_companion": ACCESS_NONE, "risk_warning": ACCESS_READ,
110
+ },
111
+ "a207-nutrition-assessment-mcp": {
112
+ "orchestrator": ACCESS_RW, "doctor_assistant": ACCESS_READ,
113
+ "nutritionist": ACCESS_READ, "parent_assistant": ACCESS_LIMITED,
114
+ "child_companion": ACCESS_LIMITED, "risk_warning": ACCESS_READ,
115
+ },
116
+ "a207-followup-mcp": {
117
+ "orchestrator": ACCESS_RW, "doctor_assistant": ACCESS_RW,
118
+ "nutritionist": ACCESS_RW, "parent_assistant": ACCESS_LIMITED,
119
+ "child_companion": ACCESS_LIMITED, "risk_warning": ACCESS_READ,
120
+ },
121
+ "a207-nutrition-calc-mcp": {
122
+ "orchestrator": ACCESS_NONE, "doctor_assistant": ACCESS_READ,
123
+ "nutritionist": ACCESS_READ, "parent_assistant": ACCESS_READ,
124
+ "child_companion": ACCESS_NONE, "risk_warning": ACCESS_NONE,
125
+ },
126
+ "a207-ckd-clinical-calc-mcp": {
127
+ "orchestrator": ACCESS_NONE, "doctor_assistant": ACCESS_READ,
128
+ "nutritionist": ACCESS_NONE, "parent_assistant": ACCESS_NONE,
129
+ "child_companion": ACCESS_NONE, "risk_warning": ACCESS_NONE,
130
+ },
131
+ "a207-meal-plan-mcp": {
132
+ "orchestrator": ACCESS_NONE, "doctor_assistant": ACCESS_READ,
133
+ "nutritionist": ACCESS_READ, "parent_assistant": ACCESS_NONE,
134
+ "child_companion": ACCESS_NONE, "risk_warning": ACCESS_NONE,
135
+ },
136
+ "a207-risk-rules-mcp": {
137
+ "orchestrator": ACCESS_NONE, "doctor_assistant": ACCESS_READ,
138
+ "nutritionist": ACCESS_NONE, "parent_assistant": ACCESS_NONE,
139
+ "child_companion": ACCESS_NONE, "risk_warning": ACCESS_READ,
140
+ },
141
+ "a207-report-mcp": {
142
+ "orchestrator": ACCESS_NONE, "doctor_assistant": ACCESS_RW,
143
+ "nutritionist": ACCESS_RW, "parent_assistant": ACCESS_LIMITED,
144
+ "child_companion": ACCESS_NONE, "risk_warning": ACCESS_READ,
145
+ },
146
+ "a207-notify-mcp": {
147
+ # OD-012 校正(OD-011 派生时误把编排层/家长挡死,导致 notify 单测回归 + 编排层写通知被拒):
148
+ # - orchestrator=R/W:ADR-010 明确"编排层在 M4/M8/M9 末端事件后调用 build_event_notification 写入"
149
+ # - doctor=R/W:临床角色可创建/确认通知
150
+ # - parent=READ:README 明确"家长读自己孩子的通知"
151
+ # - risk_warning=R/W:风险预警由 risk_warning 推送(WRITE_TOOL_POLICY notify_* 仍限 risk_warning)
152
+ # - nutritionist/child=NONE:营养师不在通知回路;患儿受 MX-2 禁区约束
153
+ "orchestrator": ACCESS_RW, "doctor_assistant": ACCESS_RW,
154
+ "nutritionist": ACCESS_NONE, "parent_assistant": ACCESS_READ,
155
+ "child_companion": ACCESS_NONE, "risk_warning": ACCESS_RW,
156
+ },
157
+ "a207-gamification-mcp": {
158
+ "orchestrator": ACCESS_NONE, "doctor_assistant": ACCESS_NONE,
159
+ "nutritionist": ACCESS_NONE, "parent_assistant": ACCESS_NONE,
160
+ "child_companion": ACCESS_RW, "risk_warning": ACCESS_NONE,
161
+ },
162
+ "a207-knowledge-mcp": {
163
+ "orchestrator": ACCESS_NONE, "doctor_assistant": ACCESS_READ,
164
+ "nutritionist": ACCESS_READ, "parent_assistant": ACCESS_LIMITED,
165
+ "child_companion": ACCESS_LIMITED, "risk_warning": ACCESS_READ,
166
+ },
167
+ "a207-router-mcp": {
168
+ "orchestrator": ACCESS_READ, "doctor_assistant": ACCESS_NONE,
169
+ "nutritionist": ACCESS_NONE, "parent_assistant": ACCESS_NONE,
170
+ "child_companion": ACCESS_NONE, "risk_warning": ACCESS_NONE,
171
+ },
172
+ }
173
+
174
+ # M12 按角色切语料 profile;注意 M12 旧接口用 role 命名空间(doctor/parent/child),
175
+ # 已由各包在转换层把 caller 映射到 role,本表保持 caller 维度。
176
+ KNOWLEDGE_PROFILE: dict[str, str] = {
177
+ "doctor_assistant": "full",
178
+ "nutritionist": "full",
179
+ "risk_warning": "full",
180
+ "parent_assistant": "plain_language",
181
+ "child_companion": "child_safe",
182
+ }
183
+
184
+ # ---------------------------------------------------------------- MX-3 写权
185
+ WRITE_TOOL_POLICY: dict[str, dict[str, object]] = {
186
+ "upsert_lab_result": {
187
+ "mcp": "a207-lis-mcp",
188
+ "allowed": frozenset({"doctor_assistant"}),
189
+ "requires_confirmation": False,
190
+ "note": "写入后强制触发 M8 重评;命中危急值经 get_critical_values 走 M10",
191
+ },
192
+ "upsert_food_diary": {
193
+ "mcp": "a207-nutrition-assessment-mcp",
194
+ "allowed": frozenset({"parent_assistant", "child_companion"}),
195
+ "requires_confirmation": False,
196
+ "note": "打卡回流的落点,供下次 sum_diet_intake 与食谱参考依从性",
197
+ },
198
+ "log_meal_checkin": {
199
+ "mcp": GAMIFICATION_MCP,
200
+ "allowed": frozenset({"child_companion"}),
201
+ "requires_confirmation": False,
202
+ "note": "只存吃了什么,全程零医疗判定",
203
+ },
204
+ "award_badge": {
205
+ "mcp": GAMIFICATION_MCP,
206
+ "allowed": frozenset({"child_companion"}),
207
+ "requires_confirmation": False,
208
+ "note": "激励侧写入,不得写入任何医疗结论",
209
+ },
210
+ "push_to_emr": {
211
+ "mcp": "a207-report-mcp",
212
+ "allowed": frozenset({"doctor_assistant"}),
213
+ "requires_confirmation": True,
214
+ "note": "需调用方另传 physician_confirmed=true,人在回路",
215
+ },
216
+ "notify_physician": {
217
+ "mcp": "a207-notify-mcp",
218
+ "allowed": frozenset({"risk_warning"}),
219
+ "requires_confirmation": False,
220
+ "note": "推送前须过 24h 同规则去重",
221
+ },
222
+ "notify_parent": {
223
+ "mcp": "a207-notify-mcp",
224
+ "allowed": frozenset({"risk_warning"}),
225
+ "requires_confirmation": False,
226
+ "note": "推送前须过 24h 同规则去重",
227
+ },
228
+ "trigger_warning_event": {
229
+ "mcp": "a207-notify-mcp",
230
+ "allowed": frozenset({"risk_warning"}),
231
+ "requires_confirmation": False,
232
+ "note": "等级须由本轮新数据重评得出,禁止沿用历史等级",
233
+ },
234
+ "close_warning": {
235
+ "mcp": "a207-notify-mcp",
236
+ "allowed": frozenset({"risk_warning"}),
237
+ "requires_confirmation": False,
238
+ "note": "关闭工单需留判定链路",
239
+ },
240
+ }
241
+
242
+ WRITE_TOOL_ALIASES: dict[str, str] = {
243
+ "写回病历": "push_to_emr", "写入病历": "push_to_emr", "推送病历": "push_to_emr",
244
+ "录入化验": "upsert_lab_result", "写入化验": "upsert_lab_result",
245
+ "新增生化": "upsert_lab_result", "新增检验": "upsert_lab_result",
246
+ "记录饮食日记": "upsert_food_diary", "录入饮食": "upsert_food_diary",
247
+ "写入饮食日记": "upsert_food_diary",
248
+ "打卡": "log_meal_checkin", "颁发勋章": "award_badge", "发勋章": "award_badge",
249
+ "推送医生": "notify_physician", "通知医生": "notify_physician",
250
+ "通知家长": "notify_parent", "发预警": "trigger_warning_event",
251
+ "触发预警": "trigger_warning_event", "关闭预警": "close_warning",
252
+ }
253
+
254
+ WRITE_ACTION_PREFIXES: tuple[str, ...] = (
255
+ "write", "create", "update", "delete", "insert",
256
+ "upsert_", "notify_", "log_", "award_", "push_", "trigger_",
257
+ "close_", "schedule_", "save_",
258
+ )
259
+
260
+ CN_WRITE_HINTS: tuple[str, ...] = ("写入", "写回", "新增一条", "保存到", "提交写")
261
+
262
+
263
+ def resolve_access(access: str, is_write: bool) -> bool:
264
+ """把矩阵格子翻译成布尔放行结果。"""
265
+ if is_write:
266
+ return access == ACCESS_RW
267
+ return access in _READ_OK
268
+
269
+
270
+ # ---------------------------------------------------------------- OD-011:从矩阵派生本地集合
271
+ # 本地「放行集合」不再独立声明,改由 PERMISSION_MATRIX 实时派生,杜绝第二副本漂移。
272
+ def _matrix_readers(mcp: str) -> frozenset[str]:
273
+ """从权限矩阵派生:对该 mcp 有读权限(R/RL/RW)的 caller 集合。"""
274
+ return frozenset(c for c, a in PERMISSION_MATRIX[mcp].items() if a in _READ_OK)
275
+
276
+
277
+ def _matrix_writers(mcp: str) -> frozenset[str]:
278
+ """从权限矩阵派生:对该 mcp 有写权限(R/W)的 caller 集合。
279
+
280
+ OD-011 收口点:各包本地写白名单的唯一事实源就是矩阵,不允许再手写一份更宽的集合。
281
+ """
282
+ return frozenset(c for c, a in PERMISSION_MATRIX[mcp].items() if a == ACCESS_RW)
283
+
284
+
285
+ # ================================================================
286
+ # 各包本地放行集合(原散落副本,集中到此处作为单一事实源)
287
+ # 命名带包前缀,避免与矩阵概念混淆。值 = 当前代码实际生效范围。
288
+ # ================================================================
289
+
290
+ # --- M1 HIS(core.py 原 READ_CALLERS / BLOCKED_CALLERS / COHORT_CALLERS 等)---
291
+ HIS_FULL_VIEW: frozenset[str] = frozenset({"orchestrator", "doctor_assistant", "risk_warning"})
292
+ HIS_LIMITED: frozenset[str] = frozenset({"nutritionist", "parent_assistant"})
293
+ HIS_READ: frozenset[str] = HIS_FULL_VIEW | HIS_LIMITED
294
+ HIS_BLOCKED: frozenset[str] = frozenset({"child_companion"})
295
+ HIS_COHORT: frozenset[str] = frozenset(
296
+ {"orchestrator", "doctor_assistant", "nutritionist", "risk_warning"})
297
+ HIS_ALLOWED_FILTER_KEYS: frozenset[str] = frozenset(
298
+ {"age_band", "ckd_stage", "dialysis", "sex", "primary_disease",
299
+ "has_allergies", "min_age_years", "max_age_years"})
300
+
301
+ # --- M2 LIS(core.py 原 READ_FULL / READ_LIMITED / CRITICAL_CHANNEL / WRITE_ALLOWED)---
302
+ # 读视图分层(full/limited)属视图 tier,与矩阵 RL 一致,保留原声明;写白名单改由矩阵派生(OD-011)。
303
+ LIS_READ_FULL: frozenset[str] = frozenset({"doctor_assistant", "risk_warning"})
304
+ LIS_READ_LIMITED: frozenset[str] = frozenset({"parent_assistant"})
305
+ LIS_CRITICAL_CHANNEL: frozenset[str] = frozenset({"risk_warning", "doctor_assistant"})
306
+ LIS_WRITE_ALLOWED: frozenset[str] = _matrix_writers("a207-lis-mcp") # OD-011: 派生自矩阵(M2 doctor=R/W)
307
+
308
+ # --- M4 随访(core.py 原 _WRITE_ALLOWED / _CLINICIAN)---
309
+ FOLLOWUP_WRITE_ALLOWED: frozenset[str] = _matrix_writers("a207-followup-mcp") # OD-011: doctor/nutritionist/orchestrator = R/W
310
+ FOLLOWUP_CLINICIAN: frozenset[str] = frozenset(
311
+ {"doctor_assistant", "nutritionist", "orchestrator", "risk_warning"})
312
+
313
+ # --- M3 营养评估 工具级 ACL(OD-010 选项 A)---
314
+ # M3 是「计算 + 数据」混合包:calc_prnt_targets / assess_pew_risk / calc_growth_zscore 是临床判读(计算面),
315
+ # upsert_food_diary / get_food_diary_summary 是家长/患儿日常记录与聚合(数据面)。
316
+ # 权限矩阵是 MCP 粒度,无法表达"同包内某些工具家长能写、某些只能临床角色算"。
317
+ # 故在此用工具级白名单作为矩阵之下的精细授权层(单一事实源,集中此处;enforce_nutrition_tool 据此执行):
318
+ # - 数据面·写 upsert_food_diary:仅家长/患儿(NUTRITION_ASSESSMENT_WRITE_ALLOWED)
319
+ # - 数据面·读 get_food_diary_summary:家长/患儿/临床角色(NUTRITION_ASSESSMENT_DATA_ROLES)
320
+ # - 计算面(6 个临床判读/落库工具):仅 doctor/nutritionist/orchestrator(NUTRITION_ASSESSMENT_CLINICAL_ROLES)
321
+ # 矩阵 M3 各行:家长/患儿=RL、临床=READ、orchestrator=R/W(编排层落 PEW 历史需写权)。
322
+ NUTRITION_ASSESSMENT_WRITE_ALLOWED: frozenset[str] = frozenset(
323
+ {"parent_assistant", "child_companion"})
324
+ NUTRITION_ASSESSMENT_DATA_TOOLS: frozenset[str] = frozenset({
325
+ "upsert_food_diary", # 数据面·写:家长/患儿记饮食日记(写权见 WRITE_ALLOWED)
326
+ "get_food_diary_summary", # 数据面·读:脱敏聚合摘要(家长/患儿/临床角色可读)
327
+ })
328
+ NUTRITION_ASSESSMENT_DATA_ROLES: frozenset[str] = frozenset({
329
+ "parent_assistant", "child_companion",
330
+ "doctor_assistant", "nutritionist", "orchestrator",
331
+ })
332
+ NUTRITION_ASSESSMENT_CLINICAL_TOOLS: frozenset[str] = frozenset({
333
+ "calc_prnt_targets", "assess_intake_vs_target", "assess_pew_risk",
334
+ "calc_growth_zscore", "record_pew_risk", "get_pew_history",
335
+ })
336
+ NUTRITION_ASSESSMENT_CLINICAL_ROLES: frozenset[str] = frozenset({
337
+ "doctor_assistant", "nutritionist", "orchestrator",
338
+ })
339
+
340
+ # --- M10 通知(core.py 原 _WRITE_ROLES / _READ_ROLES)---
341
+ # OD-011 收口:原先手写 {全部 6 角色} / {编排+临床 4 角色},比矩阵宽得多(矩阵 M10 仅 doctor=R、risk_warning=R/W,
342
+ # 其余 NONE)。现全部派生自矩阵,杜绝第二副本。
343
+ NOTIFY_WRITE_ROLES: frozenset[str] = _matrix_writers("a207-notify-mcp") # → {risk_warning}
344
+ NOTIFY_READ_ROLES: frozenset[str] = _matrix_readers("a207-notify-mcp") # → {doctor, risk_warning}
345
+
346
+ # --- M11 游戏化(server.py 原 _guard:仅患儿伙伴)---
347
+ GAMIFICATION_ALLOWED: frozenset[str] = _matrix_writers(GAMIFICATION_MCP) # OD-011: 派生自矩阵(M11 child=R/W)
@@ -0,0 +1,37 @@
1
+ """可写状态路径解析(A3 核心)。
2
+
3
+ 旧方案:状态文件写在 os.path.dirname(__file__)(即 site-packages 安装目录),
4
+ 容器/云沙箱里通常是只读的 → 写入抛异常(架构复盘 P1-3,与 M2 数据缺失败同源)。
5
+
6
+ 新方案:统一经本函数解析可写路径:
7
+ - 若设环境变量 A207_DATA_DIR,落到该目录;
8
+ - 否则落到系统临时目录下的 a207_state/(总是可写)。
9
+ 各包把原先写死的 STORE 常量改成 `resolve_state_path("xxx_store.json")` 即可。
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ from pathlib import Path
16
+
17
+ ENV_KEY = "A207_DATA_DIR"
18
+
19
+
20
+ def resolve_state_path(filename: str, *, base: str | None = None) -> Path:
21
+ """返回可写的 filename 绝对路径,并确保父目录存在。
22
+
23
+ :param filename: 状态文件名,如 "followup_store.json"
24
+ :param base: 测试可传入临时基目录覆盖环境变量
25
+ """
26
+ if base:
27
+ root = Path(base)
28
+ else:
29
+ override = os.environ.get(ENV_KEY)
30
+ if override:
31
+ root = Path(override)
32
+ else:
33
+ # 未设环境变量:落到系统临时目录,保证可写(避免只读安装目录崩溃)
34
+ import tempfile
35
+ root = Path(tempfile.gettempdir()) / "a207_state"
36
+ root.mkdir(parents=True, exist_ok=True)
37
+ return root / filename
@@ -0,0 +1,28 @@
1
+ Metadata-Version: 2.4
2
+ Name: a207-policy
3
+ Version: 0.2.0
4
+ Summary: A207 统一身份注入 / 权限矩阵 / 状态路径策略包(Plan A 单一事实源)
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+
8
+ # a207-policy
9
+
10
+ A207 统一身份注入 / 权限矩阵 / 状态路径策略包(Plan A 单一事实源)。
11
+
12
+ 13 个 MCP 统一依赖本包,取代原先散落在 M1/M2/M4/M11/M13 的多份权限副本。
13
+
14
+ ## 用法
15
+
16
+ ```python
17
+ from a207_policy import get_caller, enforce_read, enforce_write, LIS_READ_FULL
18
+
19
+ def get_labs(patient_id: str) -> dict:
20
+ caller = get_caller() # 来自 env A207_CALLER,模型碰不到
21
+ enforce_read("a207-lis-mcp") # 确定性拒绝越权(fail-closed)
22
+ ...
23
+ ```
24
+
25
+ - 身份注入:`A207_CALLER` 环境变量由部署配置写入,模型无法自证(修复 P0-1)。
26
+ - 测试:`from a207_policy import set_caller; set_caller("doctor_assistant")`。
27
+ - 状态路径:`from a207_policy import resolve_state_path; resolve_state_path("followup_store.json")`
28
+ 落到 `A207_DATA_DIR` 或系统临时目录,避免只读安装目录崩溃(修复 P1-3)。
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/a207_policy/__init__.py
4
+ src/a207_policy/caller.py
5
+ src/a207_policy/gate.py
6
+ src/a207_policy/matrix.py
7
+ src/a207_policy/state.py
8
+ src/a207_policy.egg-info/PKG-INFO
9
+ src/a207_policy.egg-info/SOURCES.txt
10
+ src/a207_policy.egg-info/dependency_links.txt
11
+ src/a207_policy.egg-info/top_level.txt
12
+ tests/test_policy.py
@@ -0,0 +1 @@
1
+ a207_policy
@@ -0,0 +1,384 @@
1
+ """a207-policy 自测:纯 python 可跑,不依赖 pytest / fastmcp。
2
+
3
+ 运行:python tests/test_policy.py
4
+
5
+ 为什么这个包必须单独有测试
6
+ --------------------------
7
+ a207-policy 是 13 个 MCP 包共同的信任根——身份从这里取,权限从这里判,
8
+ 状态路径从这里算。它错一行,13 个包一起错,而且**各包自己的测试照样全绿**
9
+ (因为它们都信任 policy 的返回值)。所以这里专打那些"下游测不出来"的盲区:
10
+ 矩阵有没有空洞、fail-closed 是否真的关死、写权是否唯一、患儿是否真隔离、
11
+ 未知角色会不会悄悄回退到全量专业语料。
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import sys
18
+ import tempfile
19
+ import traceback
20
+ from pathlib import Path
21
+
22
+ sys.stdout.reconfigure(encoding="utf-8")
23
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
24
+
25
+ from a207_policy import ( # noqa: E402
26
+ ACCESS_LIMITED,
27
+ ACCESS_NONE,
28
+ ACCESS_READ,
29
+ ACCESS_RW,
30
+ CALLERS,
31
+ CHILD_FORBIDDEN_MCPS,
32
+ KNOWLEDGE_PROFILE,
33
+ MCP_ALIASES,
34
+ PERMISSION_MATRIX,
35
+ WRITE_TOOL_POLICY,
36
+ CallerUnknown,
37
+ PermissionDenied,
38
+ as_caller,
39
+ check_permission,
40
+ enforce_read,
41
+ enforce_write,
42
+ get_caller,
43
+ knowledge_profile,
44
+ normalize_mcp,
45
+ resolve_state_path,
46
+ set_caller,
47
+ )
48
+
49
+ RESULTS: list[tuple[str, bool, str]] = []
50
+
51
+
52
+ def check(name: str):
53
+ def deco(fn):
54
+ try:
55
+ fn()
56
+ RESULTS.append((name, True, ""))
57
+ except AssertionError as exc:
58
+ RESULTS.append((name, False, str(exc) or "断言失败"))
59
+ except Exception: # noqa: BLE001
60
+ RESULTS.append((name, False, traceback.format_exc(limit=3)))
61
+ return fn
62
+
63
+ return deco
64
+
65
+
66
+ def _reset(caller: str = "doctor_assistant") -> None:
67
+ set_caller(None)
68
+ os.environ["A207_CALLER"] = caller
69
+
70
+
71
+ # --------------------------------------------------------- P0-1 身份 fail-closed
72
+
73
+ @check("get_caller:身份缺失时 fail-closed 抛 CallerUnknown")
74
+ def _caller_missing():
75
+ with as_caller(None):
76
+ try:
77
+ get_caller()
78
+ except CallerUnknown:
79
+ return
80
+ raise AssertionError("身份缺失时未抛 CallerUnknown")
81
+
82
+
83
+ @check("get_caller:空串/纯空格视同缺失(不许当成合法身份)")
84
+ def _caller_blank():
85
+ set_caller(None)
86
+ try:
87
+ for blank in ("", " ", "\t"):
88
+ os.environ["A207_CALLER"] = blank
89
+ try:
90
+ get_caller()
91
+ except CallerUnknown:
92
+ continue
93
+ raise AssertionError(f"空白身份 {blank!r} 被当成合法身份")
94
+ finally:
95
+ _reset()
96
+
97
+
98
+ @check("as_caller:块内切换、退出还原")
99
+ def _as_caller_restore():
100
+ _reset()
101
+ with as_caller("parent_assistant"):
102
+ assert get_caller() == "parent_assistant"
103
+ assert get_caller() == "doctor_assistant"
104
+
105
+
106
+ @check("as_caller:块内抛异常也必须还原(否则污染后续用例)")
107
+ def _as_caller_exception():
108
+ _reset()
109
+ try:
110
+ with as_caller("child_companion"):
111
+ raise RuntimeError("boom")
112
+ except RuntimeError:
113
+ pass
114
+ assert get_caller() == "doctor_assistant", "异常路径未还原身份"
115
+
116
+
117
+ @check("as_caller(None):必须同时清 env,否则 fail-closed 测了个寂寞")
118
+ def _as_caller_none_clears_env():
119
+ _reset()
120
+ with as_caller(None):
121
+ try:
122
+ get_caller()
123
+ except CallerUnknown:
124
+ pass
125
+ else:
126
+ raise AssertionError("as_caller(None) 内仍取到身份,env 未清")
127
+ assert get_caller() == "doctor_assistant", "退出后 env 未还原"
128
+
129
+
130
+ @check("as_caller:嵌套逐层还原")
131
+ def _as_caller_nested():
132
+ _reset()
133
+ with as_caller("nutritionist"):
134
+ with as_caller("risk_warning"):
135
+ assert get_caller() == "risk_warning"
136
+ assert get_caller() == "nutritionist"
137
+ assert get_caller() == "doctor_assistant"
138
+
139
+
140
+ # --------------------------------------------------------- 矩阵完整性(下游测不出)
141
+
142
+ @check("矩阵无空洞:每个已登记 MCP x 每个角色都有明确取值")
143
+ def _matrix_complete():
144
+ holes = []
145
+ for mcp_name, row in PERMISSION_MATRIX.items():
146
+ for caller in CALLERS:
147
+ if caller not in row:
148
+ holes.append(f"{mcp_name} x {caller}")
149
+ assert not holes, f"矩阵有空洞(缺失即行为未定义):{holes}"
150
+
151
+
152
+ @check("矩阵取值合法:只能是 - / R / RL / R/W 四种")
153
+ def _matrix_values():
154
+ legal = {ACCESS_NONE, ACCESS_READ, ACCESS_LIMITED, ACCESS_RW}
155
+ bad = [
156
+ f"{m} x {c} = {a!r}"
157
+ for m, row in PERMISSION_MATRIX.items()
158
+ for c, a in row.items()
159
+ if a not in legal
160
+ ]
161
+ assert not bad, f"非法权限值:{bad}"
162
+
163
+
164
+ @check("矩阵覆盖 13 个 MCP(不多不少)")
165
+ def _matrix_size():
166
+ assert len(PERMISSION_MATRIX) == 13, f"矩阵行数={len(PERMISSION_MATRIX)},期望 13"
167
+
168
+
169
+ @check("别名归一:旧包名解析到真实包名且落在矩阵内(OD-002 回归)")
170
+ def _alias_normalize():
171
+ for alias, canonical in MCP_ALIASES.items():
172
+ got = normalize_mcp(alias)
173
+ assert got == canonical, f"{alias} 归一到 {got},期望 {canonical}"
174
+ assert canonical in PERMISSION_MATRIX, f"{canonical} 不在矩阵中"
175
+
176
+
177
+ # --------------------------------------------------------- MX 硬规则
178
+
179
+ @check("MX-2 患儿隔离:CHILD_FORBIDDEN_MCPS 在矩阵里确实为 none")
180
+ def _child_isolation():
181
+ leaks = [
182
+ m for m in CHILD_FORBIDDEN_MCPS
183
+ if PERMISSION_MATRIX.get(normalize_mcp(m), {}).get("child_companion") != ACCESS_NONE
184
+ ]
185
+ assert not leaks, f"患儿禁入清单与矩阵不一致,存在泄漏:{leaks}"
186
+
187
+
188
+ @check("患儿绝不可触及检验原始数据(M2 LIS 必须为 none)")
189
+ def _child_no_lis():
190
+ assert PERMISSION_MATRIX["a207-lis-mcp"]["child_companion"] == ACCESS_NONE
191
+
192
+
193
+ @check("检验写权唯一:M2 LIS 只有 doctor_assistant 是 R/W")
194
+ def _lis_write_unique():
195
+ row = PERMISSION_MATRIX["a207-lis-mcp"]
196
+ writers = sorted(c for c, a in row.items() if a == ACCESS_RW)
197
+ assert writers == ["doctor_assistant"], f"检验写权不唯一:{writers}"
198
+
199
+
200
+ @check("家长看检验只能是受限视图 RL(不得是全量 R)")
201
+ def _parent_lis_limited():
202
+ assert PERMISSION_MATRIX["a207-lis-mcp"]["parent_assistant"] == ACCESS_LIMITED
203
+
204
+
205
+ # --------------------------------------------------------- 闸门确定性执行
206
+
207
+ @check("enforce_read:无权角色被拒(PermissionDenied)")
208
+ def _enforce_read_denied():
209
+ with as_caller("child_companion"):
210
+ try:
211
+ enforce_read("a207-lis-mcp")
212
+ except PermissionDenied:
213
+ return
214
+ raise AssertionError("child_companion 读 LIS 未被拒")
215
+
216
+
217
+ @check("enforce_read:有权角色放行")
218
+ def _enforce_read_allowed():
219
+ with as_caller("doctor_assistant"):
220
+ enforce_read("a207-lis-mcp")
221
+
222
+
223
+ @check("enforce_read:身份缺失时闸门也必须拒(fail-closed 贯穿)")
224
+ def _enforce_no_caller():
225
+ with as_caller(None):
226
+ try:
227
+ enforce_read("a207-his-mcp")
228
+ except (CallerUnknown, PermissionDenied):
229
+ return
230
+ raise AssertionError("身份缺失时闸门放行了")
231
+
232
+
233
+ @check("enforce_write:只读角色不得写")
234
+ def _enforce_write_denied():
235
+ with as_caller("nutritionist"):
236
+ try:
237
+ enforce_write("a207-lis-mcp", tool="upsert_lab_result")
238
+ except PermissionDenied:
239
+ return
240
+ raise AssertionError("nutritionist 竟能写 LIS")
241
+
242
+
243
+ @check("enforce_write:RW 角色放行")
244
+ def _enforce_write_allowed():
245
+ with as_caller("doctor_assistant"):
246
+ enforce_write("a207-lis-mcp", tool="upsert_lab_result")
247
+
248
+
249
+ @check("enforce:未登记的 MCP 一律拒绝(不许悄悄放行未知服务)")
250
+ def _enforce_unknown_mcp():
251
+ with as_caller("doctor_assistant"):
252
+ try:
253
+ enforce_read("a207-totally-unknown-mcp")
254
+ except (PermissionDenied, KeyError, ValueError):
255
+ return
256
+ raise AssertionError("未知 MCP 被放行")
257
+
258
+
259
+ @check("check_permission:未登记 caller 一律拒绝")
260
+ def _check_unknown_caller():
261
+ res = check_permission("hacker_agent", "a207-lis-mcp", "read")
262
+ assert res["allow"] is False, f"未登记 caller 被放行:{res}"
263
+
264
+
265
+ @check("MX-3 写权收口:每条写工具策略字段完整(mcp/allowed/note)")
266
+ def _write_policy_shape():
267
+ bad = []
268
+ for tool, policy in WRITE_TOOL_POLICY.items():
269
+ for field in ("mcp", "allowed"):
270
+ if field not in policy:
271
+ bad.append(f"{tool} 缺 {field}")
272
+ assert not bad, f"写权策略不完整:{bad}"
273
+
274
+
275
+ @check("MX-3:写工具的 owner mcp 必须在矩阵里登记")
276
+ def _write_policy_owner_registered():
277
+ bad = [
278
+ f"{tool} -> {policy['mcp']}"
279
+ for tool, policy in WRITE_TOOL_POLICY.items()
280
+ if normalize_mcp(str(policy["mcp"])) not in PERMISSION_MATRIX
281
+ ]
282
+ assert not bad, f"写工具指向未登记 MCP:{bad}"
283
+
284
+
285
+ @check("MX-3:写工具的 allowed 角色必须都是已登记 caller")
286
+ def _write_policy_allowed_registered():
287
+ bad = []
288
+ for tool, policy in WRITE_TOOL_POLICY.items():
289
+ for c in policy["allowed"]:
290
+ if c not in CALLERS:
291
+ bad.append(f"{tool} 允许了未登记角色 {c}")
292
+ assert not bad, bad
293
+
294
+
295
+ # --------------------------------------------------------- M12 语料分级
296
+
297
+ @check("语料分级与矩阵一致:能读 M12 的角色必有 profile,够不着的回退 none")
298
+ def _knowledge_matches_matrix():
299
+ # 不是"6 个角色都要有 profile"——orchestrator 在矩阵里对 M12 是 none(够不着),
300
+ # 不给它 profile 才是对的。真正的不变量是这两条:
301
+ row = PERMISSION_MATRIX["a207-knowledge-mcp"]
302
+ missing, leaked = [], []
303
+ for caller, access in row.items():
304
+ readable = access != ACCESS_NONE
305
+ if readable and caller not in KNOWLEDGE_PROFILE:
306
+ missing.append(caller) # 能读却没分级 → 语料给错风险
307
+ if not readable and knowledge_profile(caller) != "none":
308
+ leaked.append(caller) # 够不着却拿到 profile → 越权风险
309
+ assert not missing, f"能读 M12 却缺语料分级:{missing}"
310
+ assert not leaked, f"矩阵禁止访问 M12 却拿到语料 profile:{leaked}"
311
+
312
+
313
+ @check("患儿必须 child_safe,家长必须 plain_language")
314
+ def _knowledge_safety():
315
+ assert KNOWLEDGE_PROFILE["child_companion"] == "child_safe"
316
+ assert KNOWLEDGE_PROFILE["parent_assistant"] == "plain_language"
317
+ assert knowledge_profile("child_companion") == "child_safe"
318
+
319
+
320
+ @check("knowledge_profile:未知角色不得回退到 full(默认必须最保守)")
321
+ def _knowledge_unknown_not_full():
322
+ got = knowledge_profile("some_unknown_agent")
323
+ assert got != "full", f"未知角色拿到全量专业语料:{got!r}"
324
+
325
+
326
+ # --------------------------------------------------------- P1-3 状态外置
327
+
328
+ @check("resolve_state_path:base 参数优先")
329
+ def _state_base():
330
+ with tempfile.TemporaryDirectory() as tmp:
331
+ p = resolve_state_path("x.json", base=tmp)
332
+ assert p.parent == Path(tmp), p
333
+ assert p.name == "x.json"
334
+
335
+
336
+ @check("resolve_state_path:读 A207_DATA_DIR 环境变量")
337
+ def _state_env():
338
+ saved = os.environ.get("A207_DATA_DIR")
339
+ with tempfile.TemporaryDirectory() as tmp:
340
+ os.environ["A207_DATA_DIR"] = tmp
341
+ try:
342
+ p = resolve_state_path("y.json")
343
+ assert Path(tmp) == p.parent or Path(tmp) in p.parents, p
344
+ finally:
345
+ if saved is None:
346
+ os.environ.pop("A207_DATA_DIR", None)
347
+ else:
348
+ os.environ["A207_DATA_DIR"] = saved
349
+
350
+
351
+ @check("resolve_state_path:目录自动创建且可写")
352
+ def _state_writable():
353
+ with tempfile.TemporaryDirectory() as tmp:
354
+ p = resolve_state_path("z.json", base=str(Path(tmp) / "nested" / "deep"))
355
+ assert p.parent.is_dir(), "父目录未自动创建"
356
+ p.write_text("{}", encoding="utf-8")
357
+ assert p.read_text(encoding="utf-8") == "{}"
358
+
359
+
360
+ @check("resolve_state_path:绝不落在 policy 包安装目录内(P1-3 根本目的)")
361
+ def _state_not_in_pkg():
362
+ saved = os.environ.pop("A207_DATA_DIR", None)
363
+ try:
364
+ p = resolve_state_path("w.json")
365
+ pkg_root = Path(__file__).resolve().parents[1]
366
+ assert pkg_root not in p.parents, f"状态落到了安装目录:{p}"
367
+ finally:
368
+ if saved is not None:
369
+ os.environ["A207_DATA_DIR"] = saved
370
+
371
+
372
+ def main() -> int:
373
+ for name, ok, msg in RESULTS:
374
+ print(f"[{'PASS' if ok else 'FAIL'}] {name}")
375
+ if not ok:
376
+ print(" " + msg.strip().replace("\n", "\n "))
377
+ passed = sum(1 for _, ok, _ in RESULTS if ok)
378
+ failed = len(RESULTS) - passed
379
+ print(f"\n合计 {len(RESULTS)} 项:通过 {passed},失败 {failed}")
380
+ return 1 if failed else 0
381
+
382
+
383
+ if __name__ == "__main__":
384
+ raise SystemExit(main())