aps-agent/tests/golden/test_fallback_p3_attack.py

425 lines
19 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.

# ============================================================
# P3 偏差⑦⑧ 攻击面专项(Agent-N 验证员补网,全部确定性):
# 偏差⑦ harness.refresh_confirmation_world_fingerprint 信封哈希同步重算
# —— 攻击面:第二张及以后确认卡的旧信封伪造/篡改/重放必须被拒。
# 偏差⑧ S6 兄弟卡世界指纹重锚 _reanchor_s6_sibling_cards
# —— 攻击面:非兄弟(跨 run / 审批窗口内业务)改动不得借重锚混过漂移检测。
# 手法与 test_fallback_highrisk.py 一致:fake runner / FakeStore / tmp 隔离。
# ============================================================
from __future__ import annotations
import copy
import json
from pathlib import Path
from typing import ClassVar
import pytest
from server.agent_core import fallback_lane, harness
from server.agent_core.providers import reset_provider
from server.aps_domain.workflow import execute_confirmed
from server.contracts import IntentResult
from server.state.checkpoints import CheckpointStore
from server.state.seed import seed_world
DEMO_PRODUCT = "CTRL-A"
@pytest.fixture(autouse=True)
def _isolate(tmp_path, monkeypatch):
monkeypatch.setenv("APS_FALLBACK_DIR", str(tmp_path / "fb"))
monkeypatch.setenv("APS_FEATURES_PATH", str(tmp_path / "features.json"))
monkeypatch.setenv("APS_FALLBACK_HIGHRISK_PATH", str(tmp_path / "fallback-highrisk.json"))
monkeypatch.delenv("LLM_API_KEY", raising=False)
monkeypatch.delenv("LLM_BASE_URL", raising=False)
monkeypatch.delenv("LLM_MODEL", raising=False)
monkeypatch.delenv("LLM_PROVIDER", raising=False)
reset_provider()
from server.state import checkpoints as _cp_mod
from server.state import store as _store_mod
kept_scoped = dict(_store_mod._stores)
kept_cps = dict(_cp_mod._checkpoints)
yield
_store_mod._stores.clear()
_store_mod._stores.update(kept_scoped)
_cp_mod._checkpoints.clear()
_cp_mod._checkpoints.update(kept_cps)
reset_provider()
class FakeStore:
_KIND_TABLE: ClassVar[dict[str, str]] = {
"salesOrder": "salesOrders", "material": "materials",
"audit": "auditEvents", "importBatch": "importBatches"}
def __init__(self, tmp_path: Path):
self.data = seed_world()
self._counters: dict[str, int] = {}
self.tenant_uuid = "platform"
self.world_key = "default"
self.path = str(tmp_path / "world.json")
self.checkpoints = CheckpointStore(str(tmp_path / "checkpoints.json"))
def next_id(self, kind: str) -> int:
if kind not in self._counters:
table = self._KIND_TABLE.get(kind)
self._counters[kind] = max(
(x.get("id", 0) for x in self.data.get(table, [])
if isinstance(x.get("id"), int)), default=0) if table else 0
self._counters[kind] += 1
return self._counters[kind]
def save(self) -> None:
pass
def restore(self, world: dict) -> None:
self.data = copy.deepcopy(world)
self._counters.clear()
def _cfg(tmp_path: Path) -> fallback_lane.FallbackConfig:
return fallback_lane.FallbackConfig(pi_home=str(tmp_path / "pi-home"))
def _write_features(tmp_path: Path) -> None:
(tmp_path / "features.json").write_text(
json.dumps({"version": 1, "features": {"fallback": True}}, ensure_ascii=False),
encoding="utf-8")
def _write_whitelist(tmp_path: Path) -> None:
doc = {"whitelistVersion": 1, "updatedAt": "2026-09-05T10:00:00",
"updatedBy": "attack-test",
"scenarios": {
"S4": {"enabled": True,
"intents": ["flex.simulate_due", "flex.compare",
"scenario.compare", "scenario.sensitivity"],
"roles": ["planner", "admin"]},
"S6": {"enabled": True, "intents": ["mes.report"],
"roles": ["planner", "admin"]},
"S7": {"enabled": True,
"intents": ["agent.fallback.ops.config.apply",
"agent.fallback.policy.update"],
"roles": ["ops", "admin"]}}}
(tmp_path / "fallback-highrisk.json").write_text(
json.dumps(doc, ensure_ascii=False), encoding="utf-8")
def _intent(query: str) -> IntentResult:
return IntentResult(intent="unknown", params={"query": query},
confidence=0.1, source="LLM")
def _fp(world: dict) -> str:
return harness.world_fingerprint(world)
def _pending(confirm_id: str) -> dict:
return harness._approval_store.pending[confirm_id]
def _make_plan_runner(plan_builder):
def runner(task: str, work_dir: Path):
run_dir = work_dir.parent
plan = plan_builder(run_dir)
(run_dir / "outbox").mkdir(parents=True, exist_ok=True)
(run_dir / "outbox" / "plan.json").write_text(
json.dumps(plan, ensure_ascii=False), encoding="utf-8")
yield {"type": "message_end", "message": {"role": "assistant",
"stopReason": "stop", "content": [{"type": "text", "text": "ok"}]}}
yield {"type": "agent_end", "messages": []}
return runner
async def _stage(store: FakeStore, tmp_path: Path, plan: dict, query: str):
reply = await fallback_lane.propose_reply(
store, "s1", _intent(query), runner=_make_plan_runner(lambda rd: plan),
config=_cfg(tmp_path))
confirm_ids = [b.props["confirmId"] for b in (reply.blocks or [])
if getattr(b, "type", "") == "confirm-card"] if reply else []
return reply, confirm_ids
def _approve(store: FakeStore, confirm_id: str, *, approve: bool = True) -> str:
return execute_confirmed(store, confirm_id, approve=approve, actor="attacker-test")
def _seed_s6_world(store: FakeStore, items: list[tuple]) -> None:
store.data.setdefault("workOrders", [])
store.data.setdefault("mesLinks", [])
for wo_id, ext_id, status, pct, qty in items:
store.data["workOrders"].append({
"id": wo_id, "mesExternalId": ext_id, "status": status,
"progressPct": pct, "qtyDone": qty, "productCode": DEMO_PRODUCT})
store.data["mesLinks"].append({
"kind": "dispatch", "woId": wo_id, "externalWoId": ext_id,
"idemKey": f"idem-{wo_id}"})
def _seed_mes_mirror(tmp_path: Path, monkeypatch, count: int):
from server.integrations import mes_stub
from server.integrations.mes_stub import MockMesClient
client = MockMesClient(tmp_path / "mes_mirror.json")
monkeypatch.setattr(mes_stub, "_client", client)
for i in range(count):
client.create_work_order({"productCode": DEMO_PRODUCT}, idem_key=f"idem-m-{i}")
return client
def _s6_plan(items: list[dict]) -> dict:
return {"planVersion": 1, "scenario": "S6", "goal": "MES 断连补录",
"steps": [{"seq": i + 1, "mode": "frozen", "intent": "mes.report",
"summary": f"补录工单 {p['woId']}", "params": p,
"constraints": {}, "expected": []}
for i, p in enumerate(items)]}
def _s3_plan() -> dict:
rows = [{"customerName": "锐扬精密", "productCode": DEMO_PRODUCT,
"quantity": 10, "deliveryDate": "2026-09-20", "orderNo": "RY-9001"}]
return {"planVersion": 1, "scenario": "S3", "goal": "导入订单",
"steps": [{"seq": 1, "mode": "frozen", "intent": "import.commit",
"summary": "导入订单批 1 行",
"params": {"batches": [{"kind": "orders", "rows": rows}]},
"constraints": {"kinds": ["orders"], "maxRows": 500},
"expected": [{"table": "salesOrders", "added": 1}]}]}
def _setup(tmp_path: Path, monkeypatch, wo_ids=(9001, 9002)):
_write_features(tmp_path)
_write_whitelist(tmp_path)
store = FakeStore(tmp_path)
_seed_mes_mirror(tmp_path, monkeypatch, len(wo_ids))
_seed_s6_world(store, [
(w, f"MES-WO-{i + 1:04d}", "RUNNING", 0, 0) for i, w in enumerate(wo_ids)])
return store
# ---------------------------------------------------------------------------
# 偏差⑦ A-1:第二张确认卡信封完整(核心回归——修复前第二张卡 100% 信封自破)
# ---------------------------------------------------------------------------
async def test_a71_second_card_envelope_intact_after_refresh(tmp_path, monkeypatch):
store = _setup(tmp_path, monkeypatch)
items = [{"woId": 9001, "progressPct": 80, "track": "fixed"},
{"woId": 9002, "progressPct": 60, "track": "fixed"}]
_reply, confirm_ids = await _stage(
store, tmp_path, _s6_plan(items), "MES 连不上了,今天的报工帮我补一下")
assert len(confirm_ids) == 2
# 两张卡的信封在出卡指纹推进后都必须通过完整性校验
# (修复前:第二张卡 stage 时捕获到非空 beforeFingerprint,refresh 推进后
# envelopeHash 冻结 → verify_confirmation_envelope 必抛 PermissionError)
for cid in confirm_ids:
harness.verify_confirmation_envelope(_pending(cid)) # 不抛即过
# 先批第二张(乱序审批——S6 逐笔独立语义的直接压力面)
msg2 = _approve(store, confirm_ids[1])
assert "执行完成" in msg2
wos = {w["id"]: w for w in store.data["workOrders"]}
assert wos[9002]["progressPct"] == 60
# 再批第一张(兄弟重锚后信封仍须完整)
harness.verify_confirmation_envelope(_pending(confirm_ids[0]))
msg1 = _approve(store, confirm_ids[0])
assert "执行完成" in msg1
assert wos[9001]["progressPct"] == 80
# ---------------------------------------------------------------------------
# 偏差⑦ A-2:伪造指纹推进(直改 beforeFingerprint 不重算信封)→ 信封拒绝
# ---------------------------------------------------------------------------
async def test_a72_forged_fingerprint_advance_rejected(tmp_path, monkeypatch):
store = _setup(tmp_path, monkeypatch, wo_ids=(9001,))
_reply, confirm_ids = await _stage(
store, tmp_path,
_s6_plan([{"woId": 9001, "progressPct": 80, "track": "fixed"}]),
"MES 连不上了帮我补录")
assert len(confirm_ids) == 1
cid = confirm_ids[0]
fp_before = _fp(store.data)
# 攻击:绕过 refresh_confirmation_world_fingerprint,直接改写冻结指纹并落盘
# (等价于直接篡改 approvals.json——decide 每次事务从磁盘刷新,纯内存篡改
# 会被刷新抹掉,本身就是一层防御;这里按真实攻击面改盘)
_pending(cid)["beforeFingerprint"] = "0" * 64
harness._approval_store.save()
with pytest.raises(PermissionError, match="完整性校验失败"):
harness.verify_confirmation_envelope(_pending(cid))
message = _approve(store, cid)
assert "确认信封证据校验未通过" in message
assert _fp(store.data) == fp_before # 零写入
wos = {w["id"]: w for w in store.data["workOrders"]}
assert wos[9001]["progressPct"] == 0
# ---------------------------------------------------------------------------
# 偏差⑦ A-3:篡改冻结参数(progressPct 80→100)→ 参数摘要拒绝
# ---------------------------------------------------------------------------
async def test_a73_tampered_params_rejected(tmp_path, monkeypatch):
store = _setup(tmp_path, monkeypatch, wo_ids=(9001,))
_reply, confirm_ids = await _stage(
store, tmp_path,
_s6_plan([{"woId": 9001, "progressPct": 80, "track": "fixed"}]),
"MES 连不上了帮我补录")
cid = confirm_ids[0]
fp_before = _fp(store.data)
_pending(cid)["params"]["plan"]["steps"][0]["params"]["progressPct"] = 100
harness._approval_store.save() # 篡改落盘(真实攻击面)
with pytest.raises(PermissionError, match="参数摘要不一致"):
harness.verify_confirmation_envelope(_pending(cid))
message = _approve(store, cid)
assert "确认信封证据校验未通过" in message
assert _fp(store.data) == fp_before
wos = {w["id"]: w for w in store.data["workOrders"]}
assert wos[9001]["progressPct"] == 0 # 未按篡改值落账
# ---------------------------------------------------------------------------
# 偏差⑦ A-4:重放已执行的确认卡 → 明确失效,不重复落账
# ---------------------------------------------------------------------------
async def test_a74_replay_executed_card_rejected(tmp_path, monkeypatch):
store = _setup(tmp_path, monkeypatch, wo_ids=(9001,))
_reply, confirm_ids = await _stage(
store, tmp_path,
_s6_plan([{"woId": 9001, "progressPct": 80, "track": "fixed"}]),
"MES 连不上了帮我补录")
cid = confirm_ids[0]
msg = _approve(store, cid)
assert "执行完成" in msg
wos = {w["id"]: w for w in store.data["workOrders"]}
assert wos[9001]["progressPct"] == 80
replay = _approve(store, cid) # 同 confirmId 重放
assert "已失效" in replay
assert wos[9001]["progressPct"] == 80 # 未重复落账
exec_audits = [e for e in store.data.get("auditEvents", [])
if e.get("action") == "agent.fallback.execute"
and e.get("category") == "WORLD_WRITE"
and e.get("result") == "SUCCESS"]
assert len(exec_audits) == 1 # 只有首次执行的成功总账
# ---------------------------------------------------------------------------
# 偏差⑧ B-1:重锚只认同 run 兄弟卡——跨 run 卡指纹不推进,漂移检测照常拦
# ---------------------------------------------------------------------------
async def test_b81_reanchor_only_touches_same_run_siblings(tmp_path, monkeypatch):
store = _setup(tmp_path, monkeypatch, wo_ids=(9001, 9002, 9003))
# run A:两笔补录
_reply_a, cards_a = await _stage(
store, tmp_path,
_s6_plan([{"woId": 9001, "progressPct": 80, "track": "fixed"},
{"woId": 9002, "progressPct": 60, "track": "fixed"}]),
"MES 连不上了,今天的报工帮我补一下")
# run B:另一轮补录(跨 run 非兄弟)
_reply_b, cards_b = await _stage(
store, tmp_path,
_s6_plan([{"woId": 9003, "progressPct": 50, "track": "fixed"}]),
"MES 连不上了,今天的报工帮我补一下")
assert len(cards_a) == 2 and len(cards_b) == 1
run_a = _pending(cards_a[0])["params"]["runId"]
run_b = _pending(cards_b[0])["params"]["runId"]
assert run_a != run_b
fp_b_frozen = _pending(cards_b[0])["beforeFingerprint"]
msg_a0 = _approve(store, cards_a[0]) # 执行 A 第一笔
assert "执行完成" in msg_a0
# 兄弟卡 A2 被重锚:指纹推进到当前世界且信封完整 → 可正常批准
assert _pending(cards_a[1])["beforeFingerprint"] == _fp(store.data)
harness.verify_confirmation_envelope(_pending(cards_a[1]))
msg_a1 = _approve(store, cards_a[1])
assert "执行完成" in msg_a1
# 跨 run 卡 B 未被重锚(指纹停留原值)→ 漂移检测拦截,fail-closed
assert _pending(cards_b[0])["beforeFingerprint"] == fp_b_frozen
assert _pending(cards_b[0])["beforeFingerprint"] != _fp(store.data)
msg_b = _approve(store, cards_b[0])
assert "世界指纹漂移" in msg_b
wos = {w["id"]: w for w in store.data["workOrders"]}
assert wos[9003]["progressPct"] == 0 # B 未落账
assert wos[9001]["progressPct"] == 80 # A 已落账不受影响
denied = [e for e in store.data.get("auditEvents", [])
if e.get("action") == "agent.fallback.execute"
and e.get("category") == "WORLD_WRITE"
and e.get("result") == "DENIED"]
assert denied # 漂移拒绝有 DENIED 审计
# ---------------------------------------------------------------------------
# 偏差⑧ B-2(核心攻击):重锚后审批窗口内的非兄弟业务改动仍被漂移检测拦下
# ---------------------------------------------------------------------------
async def test_b82_non_sibling_business_change_still_blocked(tmp_path, monkeypatch):
store = _setup(tmp_path, monkeypatch)
items = [{"woId": 9001, "progressPct": 80, "track": "fixed"},
{"woId": 9002, "progressPct": 60, "track": "fixed"}]
_reply, confirm_ids = await _stage(
store, tmp_path, _s6_plan(items), "MES 连不上了,今天的报工帮我补一下")
assert len(confirm_ids) == 2
msg1 = _approve(store, confirm_ids[0]) # 第一笔落账 → 兄弟重锚
assert "执行完成" in msg1
assert _pending(confirm_ids[1])["beforeFingerprint"] == _fp(store.data)
# 攻击/并发事实:重锚之后、第二笔批准之前,发生非兄弟业务改动
# (另一用户的正常业务写——若借重锚混过检测,第二笔将按过时口径落账)
store.data.setdefault("salesOrders", []).append(
{"id": 777001, "orderNo": "ATTACK-WINDOW", "productCode": DEMO_PRODUCT,
"quantity": 1, "deliveryDate": "2026-09-25"})
store.save()
msg2 = _approve(store, confirm_ids[1])
assert "世界指纹漂移" in msg2 # 被拦下:重锚没有放行非兄弟改动
wos = {w["id"]: w for w in store.data["workOrders"]}
assert wos[9002]["progressPct"] == 0 # 第二笔未落账
assert wos[9001]["progressPct"] == 80 # 第一笔已落账不动
# ---------------------------------------------------------------------------
# 偏差⑧ B-3:伪造兄弟(篡改 runId 蹭重锚)→ 参数摘要破损,信封层拒绝
# ---------------------------------------------------------------------------
async def test_b83_forged_sibling_runid_rejected(tmp_path, monkeypatch):
store = _setup(tmp_path, monkeypatch)
_reply_a, cards_a = await _stage(
store, tmp_path,
_s6_plan([{"woId": 9001, "progressPct": 80, "track": "fixed"},
{"woId": 9002, "progressPct": 60, "track": "fixed"}]),
"MES 连不上了,今天的报工帮我补一下")
_reply_b, cards_b = await _stage(
store, tmp_path, _s3_plan(), "把这份客户表格导进来")
assert len(cards_a) == 2 and len(cards_b) == 1
run_a = _pending(cards_a[0])["params"]["runId"]
orders_before = len(store.data.get("salesOrders") or [])
# 攻击:把非兄弟卡(S3 导入卡)的 runId 篡改为 S6 run 并落盘,试图让重锚
# 免费推进它的冻结指纹、混过漂移检测
_pending(cards_b[0])["params"]["runId"] = run_a
harness._approval_store.save()
msg_a0 = _approve(store, cards_a[0]) # 触发重锚
assert "执行完成" in msg_a0
# 重锚按匹配条件确实推进了被篡改卡(机制如实运作)……
assert _pending(cards_b[0])["beforeFingerprint"] == _fp(store.data)
# ……但 params 被篡改 → paramsHash 破损,信封层物理拒绝
msg_b = _approve(store, cards_b[0])
assert "确认信封证据校验未通过" in msg_b
assert len(store.data.get("salesOrders") or []) == orders_before # 导入未执行
# 真兄弟卡不受伪造事件影响,仍可正常批准落账
msg_a1 = _approve(store, cards_a[1])
assert "执行完成" in msg_a1
wos = {w["id"]: w for w in store.data["workOrders"]}
assert wos[9002]["progressPct"] == 60