Files
codex-app-server/cloud/chv/capabilities.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

277 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""五档严格能力(feat-chv-muse-p5-B,叶子模块,纯标准库)。
建在 P3 的 ``Capability`` 之上(继承,不重写绑定语义):P3 只给
单次 + 300 秒一档;本模块加五档与用例维度,签发/使用/过期/
超次/撤销全审计(事件名 + 能力 id + 原因,绝无凭证值与用户
数据内容)。
档位(``TIERS``)与签发默认值(``CapabilityIssuer.issue``):
- one-shot:单次、300 秒(P3 默认);
- session:会话内多次、无固定次数,会话 id 绑定,默认 3600 秒;
- task:任务内多次、无固定次数,任务 id 绑定,默认 3600 秒;
- timed:时限内多次、无固定次数,须显式 ttl;
- permanent:永久、无过期无次数上限——默认不签发,须显式
``allow_permanent=True`` + 审计,否则 ``ValueError``。
能力绑定 = {连接器,主机,用例} 三元组 + 方法/路径规则 +
次数/过期。``matches(request, now, scope)`` 精确匹配(``scope``
须按关键字传,``now`` 仍占第二位置参数,与 P3 旧口径一致):
用例、方法、路径、会话/任务绑定、有效期、剩余次数任一项越界
即 False(并记审计)。``scope`` 是 dict 或带属性的对象,含
``use_case`` / ``session_id`` / ``task_id``;没给 scope(None)
按空 scope 计,即用例/会话/任务绑定一律不匹配(fail-closed)。
"""
import time
from collections.abc import Callable, Iterable
from cloud.chv.sentinel import (
DEFAULT_CAPABILITY_TTL_SECONDS,
Capability,
EgressRequest,
_path_allowed,
)
TIERS = ("one-shot", "session", "task", "timed", "permanent")
DEFAULT_SESSION_TTL_SECONDS = 3600
DEFAULT_TASK_TTL_SECONDS = 3600
_EVENT_ISSUED = "sentinel_capability_issued"
_EVENT_USED = "sentinel_capability_used"
_EVENT_DENIED = "sentinel_capability_denied"
_EVENT_EXPIRED = "sentinel_capability_expired"
_EVENT_REVOKED = "sentinel_capability_revoked"
def _scope_get(scope: object | None, key: str) -> object | None:
"""从 scope(dict 或带属性的对象)取一键,缺失即 None。"""
if scope is None:
return None
if isinstance(scope, dict):
return scope.get(key)
return getattr(scope, key, None)
def _audit_record(cap: "ScopedCapability", event: str,
reason: str) -> dict:
"""审计记录:事件名 + 能力 id + 原因,无值。"""
return {"event": event, "capability_id": cap.id,
"tier": cap.tier, "reason": reason,
"connector": cap.connector, "host": cap.host,
"use_case": cap.use_case}
class ScopedCapability(Capability):
"""五档严格能力:P3 绑定 + 用例三元组 + 会话/任务绑定。
``max_uses=None`` 表示无固定次数(session/task/timed/
permanent 的默认);``expires_at=None`` 表示永不过期
(仅 permanent)。``revoked`` 后 ``matches`` 恒 False。
``audit_log`` 与签发方共享:签发/使用/过期/超次/撤销
全记进同一份审计(事件名 + 能力 id + 原因,无值)。
"""
def __init__(self, connector: str, host: str,
methods: Iterable[str],
path_rules: Iterable[tuple[str, str, str]],
tier: str, use_case: str,
session_id: str | None = None,
task_id: str | None = None,
ttl_seconds: int | None = None,
max_uses: int | None = None,
now: float | None = None,
audit: list | None = None) -> None:
super().__init__(
connector, host, methods, path_rules,
ttl_seconds=(ttl_seconds if ttl_seconds is not None
else 0),
max_uses=(max_uses if max_uses is not None else 1),
now=now)
self.tier = tier
self.use_case = use_case
self.session_id = session_id
self.task_id = task_id
self.revoked = False
self.max_uses = max_uses
self.uses_left = max_uses
if ttl_seconds is None:
self.expires_at = None
self.audit_log = audit if audit is not None else []
def _mismatch_reason(self, request: EgressRequest,
scope: object | None,
now: float | None) -> str | None:
"""越界原因(None 表示在绑定内)。检查顺序固定:
撤销 → 用例 → 会话/任务 → 连接器/主机/方法/路径 →
过期 → 次数。"""
if self.revoked:
return "revoked"
if _scope_get(scope, "use_case") != self.use_case:
return "use_case_mismatch"
if self.tier == "session" and (
_scope_get(scope, "session_id") != self.session_id):
return "session_mismatch"
if self.tier == "task" and (
_scope_get(scope, "task_id") != self.task_id):
return "task_mismatch"
if request.connector != self.connector:
return "connector_mismatch"
if request.host != self.host:
return "host_mismatch"
if request.method not in self.methods:
return "method_not_allowed"
if not _path_allowed(request, self.path_rules):
return "path_not_allowed"
at = now if now is not None else time.time()
if self.expires_at is not None and at >= self.expires_at:
return "expired"
if self.uses_left is not None and self.uses_left <= 0:
return "uses_exhausted"
return None
def _audit(self, event: str, reason: str) -> None:
"""记一条审计(事件名 + 能力 id + 原因,无值)。"""
self.audit_log.append(_audit_record(self, event, reason))
def matches(self, request: EgressRequest,
now: float | None = None,
scope: object | None = None) -> bool:
"""该请求是否在能力绑定内(不消耗次数,只判定)。
``scope`` 须按关键字传(``now`` 仍占第二位置参数,
与 P3 旧口径一致)。越界即 False 并记审计:过期记
expired,其余记 denied。
"""
reason = self._mismatch_reason(request, scope, now)
if reason is None:
return True
event = (_EVENT_EXPIRED if reason == "expired"
else _EVENT_DENIED)
self._audit(event, reason)
return False
def use(self, request: EgressRequest,
now: float | None = None,
scope: object | None = None) -> bool:
"""消耗一次能力:绑定内才扣次数并返回 True,否则 False
(失败原因已由 ``matches`` 记审计,不再重复记)。"""
if not self.matches(request, now=now, scope=scope):
return False
if self.uses_left is not None:
self.uses_left -= 1
self._audit(_EVENT_USED, "used")
return True
def revoke(self) -> bool:
"""撤销:首次返回 True 并记审计;已撤销再调返回 False。
撤销后 ``matches`` 恒 False(原因 revoked)。"""
if self.revoked:
self._audit(_EVENT_DENIED, "already_revoked")
return False
self.revoked = True
self._audit(_EVENT_REVOKED, "revoked")
return True
def describe(self) -> dict:
"""审计/展示用绑定描述(只含绑定,不含值)。"""
return {"capability_id": self.id, "tier": self.tier,
"connector": self.connector, "host": self.host,
"use_case": self.use_case,
"session_id": self.session_id,
"task_id": self.task_id,
"methods": sorted(self.methods),
"max_uses": self.max_uses,
"uses_left": self.uses_left,
"expires_at": self.expires_at,
"revoked": self.revoked}
class CapabilityIssuer:
"""五档能力签发入口:按档位设默认值,审计与签出的能力共享。
permanent 默认拒绝:须调用时显式 ``allow_permanent=True``,
拒绝本身也记审计(denied,原因
``permanent_requires_explicit_opt_in``)。
"""
def __init__(self, audit: list | None = None,
clock: Callable[[], float] | None = None) -> None:
self.audit_log = audit if audit is not None else []
self._clock = clock or time.time
def issue(self, tier: str, connector: str, host: str,
methods: Iterable[str],
path_rules: Iterable[tuple[str, str, str]],
use_case: str, session_id: str | None = None,
task_id: str | None = None,
ttl_seconds: int | None = None,
max_uses: int | None = None,
now: float | None = None,
allow_permanent: bool = False) -> ScopedCapability:
"""签发一档能力(按档位设默认值,见模块 docstring)。
未知档位、空用例、session 缺会话 id、task 缺任务 id、
timed 缺 ttl、permanent 未显式开启一律 ValueError;
校验失败先记审计(无能力 id)再抛错。
"""
at = now if now is not None else self._clock()
if tier not in TIERS:
raise ValueError("unknown_tier:%s" % tier)
if not use_case:
raise ValueError("use_case_required")
try:
ttl, uses = self._defaults(
tier, session_id, task_id, ttl_seconds, max_uses,
allow_permanent)
except ValueError as exc:
self.audit_log.append(
{"event": _EVENT_DENIED, "capability_id": None,
"tier": tier, "reason": str(exc),
"connector": connector, "host": host,
"use_case": use_case})
raise
cap = ScopedCapability(
connector, host, methods, path_rules, tier, use_case,
session_id=session_id, task_id=task_id,
ttl_seconds=ttl, max_uses=uses, now=at,
audit=self.audit_log)
cap._audit(_EVENT_ISSUED, "issued")
return cap
def _defaults(self, tier: str, session_id: str | None,
task_id: str | None, ttl_seconds: int | None,
max_uses: int | None,
allow_permanent: bool
) -> tuple[int | None, int | None]:
"""各档默认值(非法组合抛 ValueError,原因即消息)。
one-shot:1 次/300 秒;session/task:无固定次数,默认
3600 秒;timed:须显式 ttl;permanent:无过期无次数,
忽略传入的 ttl/max_uses。"""
if tier == "one-shot":
return (ttl_seconds if ttl_seconds is not None
else DEFAULT_CAPABILITY_TTL_SECONDS,
max_uses if max_uses is not None else 1)
if tier == "session":
if not session_id:
raise ValueError("session_id_required")
return (ttl_seconds if ttl_seconds is not None
else DEFAULT_SESSION_TTL_SECONDS, max_uses)
if tier == "task":
if not task_id:
raise ValueError("task_id_required")
return (ttl_seconds if ttl_seconds is not None
else DEFAULT_TASK_TTL_SECONDS, max_uses)
if tier == "timed":
if ttl_seconds is None:
raise ValueError("ttl_required")
return (ttl_seconds, max_uses)
if not allow_permanent:
raise ValueError("permanent_requires_explicit_opt_in")
return (None, None)
__all__ = ["TIERS", "DEFAULT_SESSION_TTL_SECONDS",
"DEFAULT_TASK_TTL_SECONDS", "ScopedCapability",
"CapabilityIssuer"]