Files
codex-app-server/cloud/tests/test_chv_muse_p5_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

361 lines
15 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.
"""对齐 Muse P5-B 测试:五档严格能力 + 默认策略三档。
只用标准库,绝不联网、不含真实 token(测试值全是假值)。覆盖:
- 五档签发与匹配:one-shot/session/task/timed/permanent;
- 会话/任务绑定(换会话/换任务即不匹配);
- timed 过期、one-shot 用完即废;
- 超范围调用被拒(换主机/换用例/换方法/换路径各一例);
- 撤销后被拒、permanent 默认拒绝签发 + 显式开启后签发成功;
- 签发校验失败(未知档位、空用例、缺绑定 id、timed 缺 ttl);
- 默认策略三档:default 放行只读已允许、cautious 收窄、
permissive 仍挡写操作与敏感路径;
- 规则变化有审计;审计记录只有事件名/能力 id/原因,无值。
"""
import json
import sys
import unittest
from pathlib import Path
from types import SimpleNamespace
try:
from cloud.chv.capabilities import (
TIERS,
CapabilityIssuer,
ScopedCapability,
)
from cloud.chv.policy import (
CAUTION_LEVELS,
READ_METHODS,
WRITE_METHODS,
DefaultPolicy,
)
from cloud.chv.sentinel import EgressRequest
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.chv.capabilities import (
TIERS,
CapabilityIssuer,
ScopedCapability,
)
from cloud.chv.policy import (
CAUTION_LEVELS,
READ_METHODS,
WRITE_METHODS,
DefaultPolicy,
)
from cloud.chv.sentinel import EgressRequest
HOST = "gitea.tose.top"
RULES = (("GET", "prefix", "/api/v1/"),
("POST", "prefix", "/api/v1/"))
NOW = 1000.0
def _req(host=HOST, method="GET", path="/api/v1/version"):
return EgressRequest("gitea", method, "https", host, 443, path)
def _scope(use_case="gitea-sync", session_id=None, task_id=None):
scope = {"use_case": use_case}
if session_id is not None:
scope["session_id"] = session_id
if task_id is not None:
scope["task_id"] = task_id
return scope
def _events(audit, event):
return [r for r in audit if r["event"] == event]
class CapabilitiesTest(unittest.TestCase):
def setUp(self):
self.audit = []
self.issuer = CapabilityIssuer(audit=self.audit)
def _issue(self, tier="one-shot", **kw):
kw.setdefault("connector", "gitea")
kw.setdefault("host", HOST)
kw.setdefault("methods", {"GET"})
kw.setdefault("path_rules", RULES)
kw.setdefault("use_case", "gitea-sync")
kw.setdefault("now", NOW)
return self.issuer.issue(tier, **kw)
def test_tiers_listed(self):
self.assertEqual(
TIERS, ("one-shot", "session", "task", "timed",
"permanent"))
def test_one_shot_defaults(self):
cap = self._issue()
self.assertIsInstance(cap, ScopedCapability)
self.assertEqual(cap.tier, "one-shot")
self.assertEqual(cap.max_uses, 1)
self.assertEqual(cap.uses_left, 1)
self.assertEqual(cap.expires_at, NOW + 300)
self.assertEqual(cap.use_case, "gitea-sync")
self.assertFalse(cap.revoked)
self.assertTrue(cap.matches(_req(), scope=_scope(), now=NOW))
issued = _events(self.audit, "sentinel_capability_issued")
self.assertEqual(len(issued), 1)
self.assertEqual(issued[0]["capability_id"], cap.id)
self.assertEqual(issued[0]["reason"], "issued")
def test_one_shot_use_once_then_exhausted(self):
cap = self._issue()
self.assertTrue(cap.use(_req(), scope=_scope(), now=NOW))
self.assertEqual(cap.uses_left, 0)
self.assertFalse(cap.matches(_req(), scope=_scope(), now=NOW))
self.assertFalse(cap.use(_req(), scope=_scope(), now=NOW))
denied = _events(self.audit, "sentinel_capability_denied")
reasons = [r["reason"] for r in denied]
self.assertIn("uses_exhausted", reasons)
self.assertEqual(
len(_events(self.audit, "sentinel_capability_used")), 1)
def test_session_bound_and_multi_use(self):
cap = self._issue("session", session_id="s1")
self.assertEqual(cap.max_uses, None)
self.assertEqual(cap.expires_at, NOW + 3600)
good = _scope(session_id="s1")
self.assertTrue(cap.matches(_req(), scope=good, now=NOW))
for _ in range(3):
self.assertTrue(cap.use(_req(), scope=good, now=NOW))
self.assertIsNone(cap.uses_left)
self.assertFalse(cap.matches(
_req(), scope=_scope(session_id="s2"), now=NOW))
self.assertFalse(cap.matches(_req(), scope=_scope(), now=NOW))
denied = _events(self.audit, "sentinel_capability_denied")
reasons = [r["reason"] for r in denied]
self.assertIn("session_mismatch", reasons)
# scope 也可是带属性的对象,不只 dict。
obj = SimpleNamespace(use_case="gitea-sync",
session_id="s1")
self.assertTrue(cap.matches(_req(), scope=obj, now=NOW))
def test_task_bound(self):
cap = self._issue("task", task_id="t1")
self.assertTrue(cap.matches(
_req(), scope=_scope(task_id="t1"), now=NOW))
self.assertTrue(cap.use(
_req(), scope=_scope(task_id="t1"), now=NOW))
self.assertTrue(cap.use(
_req(), scope=_scope(task_id="t1"), now=NOW))
self.assertFalse(cap.matches(
_req(), scope=_scope(task_id="t2"), now=NOW))
self.assertFalse(cap.matches(_req(), scope=_scope(), now=NOW))
def test_timed_expiry(self):
cap = self._issue("timed", ttl_seconds=60)
self.assertEqual(cap.expires_at, NOW + 60)
self.assertTrue(cap.matches(_req(), scope=_scope(), now=NOW + 59))
self.assertFalse(cap.matches(_req(), scope=_scope(), now=NOW + 60))
self.assertFalse(cap.use(_req(), scope=_scope(), now=NOW + 60))
expired = _events(self.audit, "sentinel_capability_expired")
self.assertTrue(expired)
self.assertTrue(all(r["reason"] == "expired"
for r in expired))
def test_out_of_scope_denied(self):
cap = self._issue()
cases = [
(_req(host="other.example"), _scope(),
"host_mismatch"),
(_req(), _scope(use_case="other-use"),
"use_case_mismatch"),
(_req(method="POST"), _scope(), "method_not_allowed"),
(_req(path="/login"), _scope(), "path_not_allowed"),
(_req(), None, "use_case_mismatch"),
]
for req, scope, reason in cases:
self.assertFalse(cap.matches(req, scope=scope, now=NOW), reason)
denied = _events(self.audit, "sentinel_capability_denied")
reasons = [r["reason"] for r in denied]
for _, _, reason in cases:
self.assertIn(reason, reasons)
def test_revoke(self):
cap = self._issue()
self.assertTrue(cap.revoke())
self.assertTrue(cap.revoked)
self.assertFalse(cap.matches(_req(), scope=_scope(), now=NOW))
self.assertFalse(cap.use(_req(), scope=_scope(), now=NOW))
self.assertFalse(cap.revoke())
revoked = _events(self.audit, "sentinel_capability_revoked")
self.assertEqual(len(revoked), 1)
denied = _events(self.audit, "sentinel_capability_denied")
self.assertIn("revoked",
[r["reason"] for r in denied])
def test_permanent_default_refused(self):
with self.assertRaises(ValueError) as ctx:
self._issue("permanent")
self.assertIn("permanent_requires_explicit_opt_in",
str(ctx.exception))
denied = _events(self.audit, "sentinel_capability_denied")
self.assertTrue(any(
r["reason"] == "permanent_requires_explicit_opt_in"
for r in denied))
def test_permanent_explicit_issue(self):
cap = self._issue("permanent", allow_permanent=True)
self.assertEqual(cap.tier, "permanent")
self.assertIsNone(cap.expires_at)
self.assertIsNone(cap.max_uses)
self.assertTrue(cap.matches(_req(), scope=_scope(),
now=NOW + 10 ** 9))
for _ in range(5):
self.assertTrue(cap.use(_req(), scope=_scope(),
now=NOW + 10 ** 9))
def test_issue_validation_errors(self):
with self.assertRaises(ValueError):
self._issue("nope")
with self.assertRaises(ValueError):
self._issue(use_case="")
with self.assertRaises(ValueError):
self._issue("session")
with self.assertRaises(ValueError):
self._issue("task")
with self.assertRaises(ValueError):
self._issue("timed")
def test_audit_records_shape_and_no_values(self):
cap = self._issue()
cap.use(_req(), scope=_scope(), now=NOW)
cap.revoke()
for record in self.audit:
self.assertIn("event", record)
self.assertIn("reason", record)
self.assertEqual(record["capability_id"], cap.id)
self.assertEqual(record["tier"], "one-shot")
self.assertEqual(record["connector"], "gitea")
self.assertEqual(record["host"], HOST)
self.assertEqual(record["use_case"], "gitea-sync")
text = json.dumps(self.audit)
self.assertNotIn("fake-secret", text)
self.assertEqual(
[r["event"] for r in self.audit],
["sentinel_capability_issued",
"sentinel_capability_used",
"sentinel_capability_revoked"])
class PolicyTest(unittest.TestCase):
def setUp(self):
self.audit = []
self.policy = DefaultPolicy(
allow_entries=[("gitea", HOST, "/api/v1/")],
audit=self.audit)
def test_caution_levels_and_method_sets(self):
self.assertEqual(CAUTION_LEVELS,
("default", "cautious", "permissive"))
self.assertEqual(READ_METHODS, {"GET", "HEAD"})
self.assertEqual(WRITE_METHODS,
{"POST", "PUT", "DELETE", "PATCH"})
self.assertEqual(self.policy.caution, "default")
def test_default_allows_read_allowlisted(self):
self.assertTrue(self.policy.auto_allow_eligible(
_req(), tainted=False))
self.assertTrue(self.policy.auto_allow_eligible(
_req(path="/api/v1/repos"), tainted=False))
self.assertTrue(self.policy.auto_allow_eligible(
_req(method="HEAD"), tainted=False))
def test_default_denies_unlisted_host_and_write(self):
self.assertFalse(self.policy.auto_allow_eligible(
_req(host="other.example"), tainted=False))
for method in ("POST", "PUT", "DELETE", "PATCH"):
self.assertFalse(self.policy.auto_allow_eligible(
_req(method=method), tainted=False), method)
def test_default_denies_sensitive_paths(self):
for path in ("/api/v1/admin/users", "/api/v1/settings",
"/api/v1/login", "/api/v1/tokens",
"/api/v1/user/token"):
self.assertFalse(self.policy.auto_allow_eligible(
_req(path=path), tainted=False), path)
def test_tainted_never_auto_allow(self):
self.assertFalse(self.policy.auto_allow_eligible(
_req(), tainted=True))
self.policy.set_caution("permissive")
self.assertFalse(self.policy.auto_allow_eligible(
_req(), tainted=True))
def test_cautious_narrows_to_exact_path(self):
req = _req()
self.assertTrue(self.policy.auto_allow_eligible(
req, tainted=False))
self.assertTrue(self.policy.set_caution("cautious"))
self.assertFalse(self.policy.auto_allow_eligible(
req, tainted=False))
self.assertTrue(self.policy.auto_allow_eligible(
_req(path="/api/v1/"), tainted=False))
self.assertTrue(self.policy.add_rule(
"gitea", HOST, "/api/v1/version"))
self.assertTrue(self.policy.auto_allow_eligible(
req, tainted=False))
def test_permissive_widens_but_blocks_write_and_sensitive(self):
self.assertTrue(self.policy.set_caution("permissive"))
self.assertTrue(self.policy.auto_allow_eligible(
_req(host="other.example"), tainted=False))
self.assertTrue(self.policy.auto_allow_eligible(
_req(host="other.example", path="/x"), tainted=False))
self.assertFalse(self.policy.auto_allow_eligible(
_req(host="other.example", method="POST"),
tainted=False))
for path in ("/admin/x", "/settings", "/login",
"/api/v1/tokens"):
self.assertFalse(self.policy.auto_allow_eligible(
_req(host="other.example", path=path),
tainted=False), path)
def test_set_caution_audits_and_validates(self):
self.assertTrue(self.policy.set_caution("cautious"))
self.assertEqual(self.policy.caution, "cautious")
changed = _events(self.audit, "sentinel_policy_changed")
self.assertEqual(len(changed), 1)
self.assertEqual(changed[0]["change"], "set_caution")
self.assertEqual(changed[0]["old_value"], "default")
self.assertEqual(changed[0]["new_value"], "cautious")
self.assertFalse(self.policy.set_caution("cautious"))
self.assertEqual(
len(_events(self.audit, "sentinel_policy_changed")), 1)
with self.assertRaises(ValueError):
self.policy.set_caution("reckless")
with self.assertRaises(ValueError):
DefaultPolicy(caution="reckless")
def test_add_remove_rule_audits(self):
self.assertTrue(self.policy.add_rule(
"gitea", "other.example", "/api/v1/"))
changed = _events(self.audit, "sentinel_policy_changed")
self.assertEqual(len(changed), 1)
self.assertEqual(changed[0]["change"], "add_rule")
self.assertIsNone(changed[0]["old_value"])
self.assertEqual(changed[0]["new_value"]["host"],
"other.example")
self.assertFalse(self.policy.add_rule(
"gitea", "other.example", "/api/v1/"))
self.assertEqual(
len(_events(self.audit, "sentinel_policy_changed")), 1)
self.assertTrue(self.policy.remove_rule(
"gitea", "other.example", "/api/v1/"))
changed = _events(self.audit, "sentinel_policy_changed")
self.assertEqual(changed[-1]["change"], "remove_rule")
self.assertEqual(changed[-1]["new_value"], None)
self.assertFalse(self.policy.remove_rule(
"gitea", "other.example", "/api/v1/"))
self.assertEqual(len(self.policy.list_rules()), 1)
self.assertEqual(self.policy.list_rules()[0]["host"], HOST)
if __name__ == "__main__":
unittest.main()