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
This commit is contained in:
host committed 2026-11-06 22:25:55 +00:00
1 parent ee456e6625
commit 25bcd355f8
11 files changed
+2436 -48

No files matched your search

+196
View File
@@ -0,0 +1,196 @@
"""对话外审批通道(feat-chv-muse-p5-C,叶子模块,纯标准库)。
接在 P3 的 ``ApprovalQueue`` 之上:队列只记 pending 待审批,
答复只走本通道,绝不走 agent 对话内容。对话里出现的“批准/
同意/allow”字样没有任何效力——没有合法 nonce 与 HMAC 签名,
校验一律失败。
通道契约(客户端 UI 呈现是另一侧的事,本模块只给协议):
- 请求载荷(``ApprovalRequest.payload()``,可外发给客户端)::
{"pending_id": str, "nonce": str, "target": {...},
"reasons": [str, ...], "expires_at": float}
``target`` 只有目标描述(连接器/方法/scheme/主机/端口/路径),
``reasons`` 只有原因名;载荷里绝无凭证值与用户数据内容。
``nonce`` 是随机 16 字节 hex(32 字符),每次请求唯一。
``expires_at`` 默认请求后 15 分钟(900 秒),可注入时钟。
- 答复(``Answer``,客户端答完直接回 Sentinel)::
{"pending_id": str, "nonce": str, "verdict": "allow"|"deny",
"expires_at": float, "signature": str}
``signature`` 是 HMAC-SHA256,key 为通道密钥,签名覆盖
``pending_id + nonce + verdict + expires_at``(见 ``_sign``)。
- 校验(``verify_answer``):pending 存在(本通道签发过且未
消费)、nonce 对上、未过期、签名对上、verdict 在 allow/deny
内——五项缺一即 False。答复只能用一次:``resolve_via_channel``
成功后该 pending 的通道记录即失效,重放同一答复被拒。
- 密钥由调用方保管:通道本身不保存密钥、不把密钥写进载荷、
绝不进日志;签发请求时传 ``secret`` 只是调用方证明其持有
通道密钥(本模块不存它),签名由持有密钥的答复方计算。
- 队列接线:校验通过才调 ``ApprovalQueue.resolve``;校验失败
返回 False 且队列原样不动(pending 仍在,须重新答复)。
"""
import hashlib
import hmac
import secrets
import time
from collections.abc import Iterable
from cloud.chv.sentinel import ApprovalQueue
# 默认审批有效期(秒):15 分钟。
DEFAULT_TTL_SECONDS = 900
_VERDICTS = ("allow", "deny")
# 本通道签发过、尚未消费的请求:pending_id -> {"nonce", "expires_at"}。
# 只记 nonce 与过期,不记任何凭证值与用户数据内容。
_REQUESTS: dict[str, dict] = {}
def _sign(pending_id: str, nonce: str, verdict: str,
expires_at: float, secret: str) -> str:
"""HMAC-SHA256 签名:覆盖 pending_id + nonce + verdict +
expires_at。expires_at 统一按 float 格式化,签发与校验
两侧口径一致。"""
message = "\n".join((pending_id, nonce, verdict,
repr(float(expires_at)))).encode("utf-8")
return hmac.new(secret.encode("utf-8"), message,
hashlib.sha256).hexdigest()
def _target_from_record(queue_record: dict) -> dict:
"""队列记录里只取目标描述字段,别的什么都不带出通道。"""
keys = ("connector", "method", "scheme", "host", "port", "path")
return {k: queue_record[k] for k in keys if k in queue_record}
class ApprovalRequest:
"""一次通道审批请求:pending_id + 目标描述 + 原因 + nonce
+ 过期时间。只含描述性字段,无凭证值、无用户数据内容。"""
def __init__(self, pending_id: str, target: dict,
reasons: Iterable[str] | None,
nonce: str | None = None,
now: float | None = None,
ttl_seconds: int = DEFAULT_TTL_SECONDS) -> None:
self.pending_id = pending_id
self.target = dict(target)
self.reasons = list(reasons or [])
self.nonce = nonce or secrets.token_hex(16)
at = now if now is not None else time.time()
self.expires_at = float(at) + ttl_seconds
def payload(self) -> dict:
"""可外发给客户端的请求载荷(只含描述字段 + nonce +
过期,无密钥、无凭证值、无用户数据内容)。"""
return {"pending_id": self.pending_id, "nonce": self.nonce,
"target": dict(self.target),
"reasons": list(self.reasons),
"expires_at": self.expires_at}
def request_approval(queue_record: dict, secret: str,
now: float | None = None,
ttl_seconds: int = DEFAULT_TTL_SECONDS
) -> ApprovalRequest:
"""为一条队列记录生成通道请求并登记(nonce + 过期)。
``secret`` 是调用方持有的通道密钥:本模块不保存它、不进
载荷、不进日志;答复方用同一密钥签名,校验时对上才算数。
返回的 ``ApprovalRequest.payload()`` 即发给客户端的东西。
"""
pending_id = queue_record["pending_id"]
req = ApprovalRequest(
pending_id, _target_from_record(queue_record),
queue_record.get("reasons", []),
now=now, ttl_seconds=ttl_seconds)
_REQUESTS[pending_id] = {"nonce": req.nonce,
"expires_at": req.expires_at}
return req
class Answer:
"""一次通道答复:pending_id + nonce + verdict + 过期 +
HMAC 签名。verdict 只认 allow/deny,其它值校验即拒。"""
def __init__(self, pending_id: str, nonce: str, verdict: str,
expires_at: float, signature: str) -> None:
self.pending_id = pending_id
self.nonce = nonce
self.verdict = verdict
self.expires_at = float(expires_at)
self.signature = signature
def as_dict(self) -> dict:
"""答复的线上传输格式(与请求载荷同款纯数据 dict)。"""
return {"pending_id": self.pending_id, "nonce": self.nonce,
"verdict": self.verdict,
"expires_at": self.expires_at,
"signature": self.signature}
def make_answer(request: ApprovalRequest, verdict: str,
secret: str) -> Answer:
"""答复方(持有通道密钥的客户端)构造已签名的答复。
verdict 只认 allow/deny,其它值直接抛错、不签发。"""
if verdict not in _VERDICTS:
raise ValueError("verdict must be allow or deny")
signature = _sign(request.pending_id, request.nonce, verdict,
request.expires_at, secret)
return Answer(request.pending_id, request.nonce, verdict,
request.expires_at, signature)
def _as_answer(answer) -> Answer | None:
"""Answer 对象或同款 dict 统一成 Answer;字段不全即 None
(缺字段的答复直接判无效,不抛错)。"""
if isinstance(answer, Answer):
return answer
if isinstance(answer, dict):
try:
return Answer(answer["pending_id"], answer["nonce"],
answer["verdict"], answer["expires_at"],
answer["signature"])
except (KeyError, TypeError, ValueError):
return None
return None
def verify_answer(answer: Answer | dict, secret: str,
now: float | None = None) -> bool:
"""校验一次答复:pending 存在(本通道签发过且未消费)、
nonce 对上、未过期、签名对上、verdict 合法——五项缺一
即 False。本函数是纯校验,不消费答复(单次性由
``resolve_via_channel`` 在成功时消费登记来保证)。"""
ans = _as_answer(answer)
if ans is None or ans.verdict not in _VERDICTS:
return False
issued = _REQUESTS.get(ans.pending_id)
if issued is None:
return False
if ans.nonce != issued["nonce"]:
return False
at = now if now is not None else time.time()
if at >= issued["expires_at"] or at >= ans.expires_at:
return False
expected = _sign(ans.pending_id, ans.nonce, ans.verdict,
ans.expires_at, secret)
return hmac.compare_digest(ans.signature, expected)
def resolve_via_channel(queue: ApprovalQueue,
answer: Answer | dict, secret: str,
now: float | None = None) -> bool:
"""校验通过才调队列的 resolve;校验失败返回 False 且
队列不动。成功时消费该 pending 的通道登记(答复只能用
一次),队列 resolve 失败(pending 已不在队列)则不消费、
返回 False。"""
if not verify_answer(answer, secret, now=now):
return False
ans = _as_answer(answer)
if ans is None:
return False
if not queue.resolve(ans.pending_id, ans.verdict):
return False
_REQUESTS.pop(ans.pending_id, None)
return True