round-12: migrate P8 registries + P12 audit to AppStateStore

- app_state.py: host_state_db_path() (host-level DB, distinct from
  per-VM state_db_path); kv_get/kv_put/kv_delete on meta (namespaced
  keys; kv_put skips the no-secrets guard by design — registry docs
  hold user prompts, documented); audit_trail table (P12 record
  shape + hash chain, seq explicit); counts()/backup() gain
  audit_trail.
- swarm.py / presentation.py / browser_tasks.py: JSON-file backends
  replaced by KV rows (p8-swarm / p8-ledger / p8-browser-tasks);
  legacy JSON imported once on first load (renamed .migrated);
  corrupt row / unreadable store returns empty (fail-safe, same
  semantics as before; next successful save self-heals).
- audit_trail.py: StoreAuditTrail (same record shape, hash chain,
  filters, export as JSONL version, backed by audit_trail table);
  migrate_jsonl_to_store() moves a chain verbatim (broken chain
  refused, divergent seq refused, re-run idempotent). JSONL class
  kept for reading old files.
- P12 dict contracts now run against real P3/P5 code
  (ApprovalQueue.record -> build_approval_card ->
  card_matches_channel; CapabilityIssuer.issue -> describe ->
  PermissionRegistry.record_grant).
- P6->P3 local protocol test: real PrivsepClient + real worker
  server + real git status; worker git argv carries the real P3
  helper + useHttpPath; no token in argv/env/audit. runuser + HTTP
  pull stay real-machine-only (see gap docs).
- Gap docs updated: P8 migration done, guest-side browser follow-up
  explicitly left for the real-machine track (no task_id in the
  browser protocol yet; lifecycle needs a real VM to verify);
  P6 wiring + P12 migration marked done; production-readiness.md
  gains §20 (round-12 integration).
- Tests: new cloud/tests/test_chv_muse_round12.py, 24/24 OK;
  test_chv_muse_p8.py ledger corrupt test rewritten for the KV
  backend (+ legacy-import test). P7+P8: 95+1 tests OK.
This commit is contained in:
host committed 2026-11-07 00:36:25 +00:00
1 parent 466cf7eae7
commit d4cef29cdb
11 files changed
+1147 -63

No files matched your search

+169 -3
View File
@@ -34,9 +34,13 @@ VM 内所有文件。
- project_id 强制:``list_records()``/``export_json()`` 必须传 project_id,
只返回该项目记录;跨项目读取返回空,不抛错(与现有
``list_audit`` 行为一致,不泄露“存在别的项目记录”)。
- 持久化:先落独立 JSONL 文件(0600),待 P7 状态库就绪后迁移;
``from_legacy_row()`` 把现有 ``audit_log`` 行转成统一记录
(标 ``legacy: true``,本身无哈希链,append 时重新入链)。
- 持久化:round-12 已迁移到 P7 状态库(``StoreAuditTrail``,
宿主级 AppStateStore 的 ``audit_trail`` 表,同一记录形状与
哈希链);``AuditTrail``(JSONL 0600)保留,用于读旧文件与
迁移源,``migrate_jsonl_to_store()`` 原样搬链(坏链拒绝、
分叉拒绝、重跑幂等);``from_legacy_row()`` 把现有
``audit_log`` 行转成统一记录(标 ``legacy: true``,本身无
哈希链,append 时重新入链)。
"""
import hashlib
@@ -310,6 +314,168 @@ class AuditTrail:
return [line.strip() for line in f if line.strip()]
class StoreAuditTrail:
"""AppStateStore 后端的统一审计轨迹(round-12 迁移)。
与 ``AuditTrail``(JSONL)同一记录形状、同一哈希链算法、
同一过滤/导出语义;持久化改走宿主级 AppStateStore 的
``audit_trail`` 表(``state_dir/app-state.sqlite``)。
JSONL 版保留,用于读旧文件与迁移源;新写一律走本类。
写失败不静默丢(与 JSONL 版同款:append 抛错由调用方
决定是否 best-effort);库打不开时 __init__ 即抛错,不
回空链——审计链丢了不能假装没事(与 P8 登记的 fail-safe
不同,见差距表)。
"""
def __init__(self, state_dir: str) -> None:
if not state_dir or not isinstance(state_dir, str):
raise ValueError("state_dir required")
self.state_dir = state_dir
self._last_hash = GENESIS_HASH
self._seq = 0
self._load_tail()
def _open_store(self):
"""打开宿主级状态库(每次调用新建连接,用完即关)。"""
from cloud.chv.app_state import AppStateStore, \
host_state_db_path
store = AppStateStore(host_state_db_path(self.state_dir))
store.open()
return store
def _load_tail(self) -> None:
"""启动时读库尾,恢复末尾 seq 与链哈希。"""
with self._open_store() as store:
rows = store.list_audit_trail_rows()
if not rows:
return
last = rows[-1]
self._seq = int(last["seq"])
self._last_hash = str(last["hash"])
def append(self, project_id: str, actor: str, category: str,
action: str, object_type: str = "",
object_id: str = "", result: str = "ok",
reason: str = "", task_id: str = "",
approval_id: str = "",
detail: dict | None = None,
ts: float | None = None) -> dict:
"""追加一条审计记录,返回该记录(含 seq 与链哈希)。"""
if not project_id:
raise ValueError("project_id 不能为空")
if category not in AUDIT_CATEGORIES:
raise ValueError("未知审计分类:%s" % category)
seq = self._seq + 1
record = {
"seq": seq,
"ts": ts if ts is not None else time.time(),
"project_id": project_id,
"actor": actor,
"category": category,
"action": action,
"object_type": object_type,
"object_id": object_id,
"result": result,
"reason": reason,
"task_id": task_id,
"approval_id": approval_id,
"detail": sanitize_detail(detail),
"prev_hash": self._last_hash,
}
chain_hash = compute_hash(self._last_hash, record)
record["hash"] = chain_hash
with self._open_store() as store:
store.insert_audit_trail_row(record)
self._seq = seq
self._last_hash = chain_hash
return dict(record)
def list_records(self, project_id: str, category: str = "",
action: str = "", task_id: str = "",
since: float = 0.0, until: float = 0.0,
limit: int = 100, offset: int = 0) -> list:
"""按时间倒序返回该项目的审计记录(副本,可分页过滤)。
project_id 为空或不匹配一律返回空列表。limit <= 0
表示不限制条数。"""
if not project_id:
return []
with self._open_store() as store:
rows = store.list_audit_trail_rows()
kept = [r for r in rows if r.get("project_id") == project_id]
kept = _filter_rows(kept, category, action, task_id,
since, until)
kept.sort(key=lambda r: (r.get("ts", 0.0), r.get("seq", 0)),
reverse=True)
if offset:
kept = kept[offset:]
if limit > 0:
kept = kept[:limit]
return [dict(r) for r in kept]
def verify(self) -> tuple[bool, int]:
"""校验整条哈希链:返回 (是否通过, 首个坏行下标)。
通过时下标为 -1。"""
with self._open_store() as store:
rows = store.list_audit_trail_rows()
expected_prev = GENESIS_HASH
expected_seq = 1
for index, record in enumerate(rows):
if not _chain_ok(record, expected_prev, expected_seq):
return False, index
expected_prev = record["hash"]
expected_seq += 1
return True, -1
def export_json(self, project_id: str, dest: str) -> int:
"""导出该项目全部审计记录为 JSON 数组,返回导出条数。"""
rows = self.list_records(project_id, limit=0)
rows.sort(key=lambda r: (r.get("ts", 0.0), r.get("seq", 0)))
with open(dest, "w", encoding="utf-8") as f:
json.dump(rows, f, ensure_ascii=False, indent=2)
os.chmod(dest, 0o600)
return len(rows)
def migrate_jsonl_to_store(path: str, state_dir: str) -> int:
"""把 JSONL 轨迹原样搬进 StoreAuditTrail,返回新插入条数。
先校验 JSONL 整条链(坏链即 ValueError,拒绝迁移,不静
默丢行);行按原文 seq/hash/prev_hash 插入,不重算链;
库里已有且内容相同的 seq 跳过(幂等,可重跑);已有但
内容不同的 seq 即 ValueError(分叉,fail-closed)。
文件不存在回 0。"""
if not os.path.exists(path):
return 0
trail = AuditTrail(path)
ok, bad = trail.verify()
if not ok:
raise ValueError("JSONL 审计链校验失败,拒绝迁移:行 %d"
% bad)
rows = trail._read_all()
from cloud.chv.app_state import AppStateStore, \
host_state_db_path
store = AppStateStore(host_state_db_path(state_dir))
store.open()
try:
existing = {int(r["seq"]): r
for r in store.list_audit_trail_rows()}
inserted = 0
for row in sorted(rows, key=lambda r: int(r.get("seq", 0))):
seq = int(row["seq"])
if seq in existing:
if existing[seq] != row:
raise ValueError(
"审计 seq %d 在库里已有不同内容,拒绝迁移"
% seq)
continue
store.insert_audit_trail_row(row)
inserted += 1
return inserted
finally:
store.close()
def _filter_rows(rows: list, category: str, action: str,
task_id: str, since: float, until: float) -> list:
"""list() 的过滤:分类/动作/任务/时间窗口,全空即不过滤。"""