Files
codex-app-server/cloud/chv/taint.py
T
host 25bcd355f8 feat(chv): 对齐 Muse P5 纵深——eBPF 污点传播 + 五档严格能力 + 对话外审批 + 默认策略
- taint.py:污点追踪器(登记/污点不可逆/子进程继承/审计)
- ebpf_taint.py:eBPF 程序源码 + 加载器(能力探测 + 降级);
  本机实测加载被 seccomp 挡(bpf() EPERM),报 blocked 不冒充
- capabilities.py:五档严格能力(一次/会话/任务/时限/永久,
  三元组绑定 + 精确匹配 + 撤销;permanent 默认不签发)
- policy.py:默认策略规则表 + 谨慎度三档(写操作与敏感路径
  永远不自动放行,规则变化审计)
- approval_channel.py:对话外审批通道(nonce + 15 分钟过期 +
  HMAC,答复一次有效,对话伪造无效)
- sentinel.py 接线:evaluate() 可选接 taint/policy/tier/scope,
  不传时行为与 P3 一致;自动放行步骤 + 档位签发回落单次;
  Capability 基类属性放宽为 Optional(None = 不限)
- 新测试 61 项;全量 cloud 套件 1958 项 OK(skipped=8)

真机待验证(未做,排队在 round-11 之后):见
docs/chv-muse-p5-gap.md §4
2026-11-06 22:25:55 +00:00

172 lines
6.9 KiB
Python

"""污点追踪器(对齐 Muse P5-A,叶子模块,纯标准库)。
Muse 口径(见 docs/chv-muse-p5-gap.md §1 污点出口行):每个工具
执行进程初始干净,读取用户数据后变“污点”;干净且符合窄范围
自动放行策略并通过出站 URL 其他检查的请求可免打扰放行,污点
或不可验证进程失去自动放行、回到正常审批流。
本模块只是用户态追踪器,不是强制层:
- “读用户数据”事件靠调用方上报(工具执行层在读用户数据前
调 `mark_tainted`,或 eBPF 通了之后由 LSM hook 回调上报)。
没接 LSM hook 之前漏报是可能的,本模块不宣称“所有读用户
数据的行为都被捕获”。
- 污点是单向的:没有清除入口,只能随进程退出消失。
- `source` 只记“哪类用户数据”(见 `_SOURCE_CATEGORIES`),
绝不记内容;不在分类表里的上报值一律归一化为 `unknown`,
原始字符串不会落进任何记录。审计记录里只有事件名、pid、
原因与分类,绝无用户数据内容、无凭证值。
- 未知 pid(未注册)一律不可自动放行:`is_tainted` 对它报
False(没证据说它污),`auto_allow_eligible` 对它也报
False(没证据说它干净可信)。
"""
import time
from collections.abc import Callable
# 允许的 source 分类:只记“哪类用户数据”,不记内容。
_SOURCE_CATEGORIES = frozenset({
"gitea_repo",
"user_file",
"user_memory",
"chat_history",
"credential_vault",
"tool_output",
"unknown",
})
# 审计事件名:沿用 P3 的 `sentinel_*` 契约(见 gap §1 审计行)。
_EVENT_MARKED = "sentinel_taint_marked"
_EVENT_INHERITED = "sentinel_taint_inherited"
_EVENT_REGISTERED = "sentinel_taint_registered"
_EVENT_RELEASED = "sentinel_taint_released"
def _normalize_source(source: str) -> str:
"""source 归一化为分类表里的值,不在表里的记 `unknown`。
调用方误把内容当 source 传进来时,原始字符串在这里就被
丢掉,绝不会进入进程记录或审计记录。"""
value = (source or "").strip().lower()
if value in _SOURCE_CATEGORIES:
return value
return "unknown"
class TaintTracker:
"""进程污点两态追踪器(内存态,进程退出即清)。
每个进程一条记录:pid、cgroup、连接器、用例、是否污点。
污点只能由 `mark_tainted` 置位、由 `fork_child` 继承、由
`process_exit` 随进程消失;没有反向清除入口。
"""
def __init__(self, clock: Callable[[], float] | None = None
) -> None:
self._records: dict[int, dict] = {}
self._audit: list[dict] = []
self._clock = clock or time.time
def _emit(self, event: str, pid: int, reason: str,
source: str = "") -> None:
"""记一条审计(事件名 + pid + 原因 + 分类,无内容)"""
record = {"event": event, "pid": pid, "reason": reason,
"ts": self._clock()}
if source:
record["source"] = source
self._audit.append(record)
def register(self, pid: int, cgroup: str = "",
connector: str = "", use_case: str = "") -> bool:
"""登记一个干净进程。pid 非法或已登记返回 False。"""
if pid <= 0 or pid in self._records:
return False
self._records[pid] = {
"pid": pid, "cgroup": cgroup,
"connector": connector, "use_case": use_case,
"tainted": False,
}
self._emit(_EVENT_REGISTERED, pid, "process_registered")
return True
def mark_tainted(self, pid: int, source: str) -> bool:
"""上报“读用户数据”事件:该进程变污点,不可逆。
未登记的 pid 也接受上报(fail-closed:读了用户数据的
进程先记为污点,不等它补登记);source 只存分类。"""
category = _normalize_source(source)
record = self._records.get(pid)
if record is None:
self._records[pid] = {
"pid": pid, "cgroup": "", "connector": "",
"use_case": "", "tainted": True,
}
else:
record["tainted"] = True
reason = "read_user_data:" + category
self._emit(_EVENT_MARKED, pid, reason, source=category)
return True
def fork_child(self, parent_pid: int, child_pid: int) -> bool:
"""子进程继承污点:父污则子污,父干净则子干净。
父未登记或子 pid 非法返回 False,不建记录;子已登记
时只改它的污点位,不覆盖它自己的 cgroup/连接器/用例。"""
if child_pid <= 0 or child_pid == parent_pid:
return False
parent = self._records.get(parent_pid)
if parent is None:
return False
child = self._records.get(child_pid)
if child is None:
self._records[child_pid] = {
"pid": child_pid, "cgroup": parent["cgroup"],
"connector": parent["connector"],
"use_case": parent["use_case"],
"tainted": parent["tainted"],
}
else:
child["tainted"] = parent["tainted"]
if parent["tainted"]:
self._emit(_EVENT_INHERITED, child_pid,
"inherited_from_parent:%d" % parent_pid)
return True
def process_exit(self, pid: int) -> bool:
"""进程退出:记录连同污点一起消失。未知 pid 返回 False。"""
if pid not in self._records:
return False
del self._records[pid]
self._emit(_EVENT_RELEASED, pid, "process_exit")
return True
def is_tainted(self, pid: int) -> bool:
"""该进程是否污点。未知 pid 报 False(无证据说它污)。"""
record = self._records.get(pid)
return bool(record and record["tainted"])
def is_registered(self, pid: int) -> bool:
"""该 pid 是否已登记。"""
return pid in self._records
def state(self, pid: int) -> dict | None:
"""该进程记录的副本(无记录返回 None)。"""
record = self._records.get(pid)
if record is None:
return None
return dict(record)
def auto_allow_eligible(self, pid: int) -> bool:
"""出口自动放行资格:已登记且干净才 True。
污点进程一律 False;未知 pid(不可验证)也一律 False。
这只是决策输入,不放行任何请求——放行仍须走 Sentinel
的出站 URL 其他检查(见 gap §1)。"""
record = self._records.get(pid)
if record is None:
return False
return not record["tainted"]
def audit(self) -> list:
"""全部审计记录的副本(事件名 + pid + 原因 + 分类)。"""
return [dict(record) for record in self._audit]