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

+144 -2
View File
@@ -17,7 +17,10 @@ AUTOINCREMENT、无 SQLite 专有语法、参数化查询),换 Postgres =
- 库文件只活在宿主 `state_dir/<vm>/app-state.sqlite`,不在客机
磁盘镜像里,nspawn cell 物理上够不着(测试钉住)。
- 凭证绝不进库:所有写操作过无凭证守卫(键名 + 值模式双重
检查);守卫拒绝时只报键名/模式,不含值。
检查);守卫拒绝时只报键名/模式,不含值。唯一例外是
round-12 新增的 ``kv_put``(P8 登记文档含用户 prompt,
prompt 里合法出现密钥样文本是调用方的事,旧 JSON 后端
同样不过守卫;见 kv_put 的 docstring)。
- audit 表只追加:本模块不提供任何删除/修改审计的方法。
"""
@@ -81,6 +84,29 @@ CREATE TABLE IF NOT EXISTS audit (
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_audit_target ON audit(target, created_at);
-- P12 统一审计轨迹(round-12 迁移目标):与 JSONL 版同一记录
-- 形状(seq/hash/prev_hash 链、project_id 隔离),seq 由写入方
-- 显式给(链是全局的,不按项目分链),不靠自增。
CREATE TABLE IF NOT EXISTS audit_trail (
seq INTEGER PRIMARY KEY,
project_id TEXT NOT NULL,
actor TEXT NOT NULL,
category TEXT NOT NULL,
action TEXT NOT NULL,
object_type TEXT NOT NULL,
object_id TEXT NOT NULL,
result TEXT NOT NULL,
reason TEXT NOT NULL,
task_id TEXT NOT NULL,
approval_id TEXT NOT NULL,
detail_json TEXT NOT NULL,
ts REAL NOT NULL,
prev_hash TEXT NOT NULL,
hash TEXT NOT NULL,
legacy INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_audit_trail_project
ON audit_trail(project_id, ts, seq);
"""
_BACKUP_FILENAME = "app-state.sqlite"
@@ -113,6 +139,17 @@ def state_db_path(state_dir: str, vm_name: str) -> str:
return os.path.join(state_dir, vm_name, "app-state.sqlite")
def host_state_db_path(state_dir: str) -> str:
"""宿主级状态库路径(round-12):与 per-VM 库同库不同文件。
P8 的三处登记与 P12 的审计轨迹是宿主级状态(横跨多个 VM、
按 project_id 隔离),不属于任何一个 VM,所以不进
``state_db_path(state_dir, vm_name)`` 的 per-VM 库,另落在
state_dir 根下的 app-state.sqlite。同一个 AppStateStore 类、
同一套备份/恢复与 0600 权限口径,只是路径不同。"""
return os.path.join(state_dir, "app-state.sqlite")
def _utcnow() -> str:
"""ISO 时间戳(UTC,秒级)。"""
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
@@ -273,12 +310,116 @@ class AppStateStore:
("schema_version",)).fetchone()
return row[0] if row else ""
def kv_put(self, namespace: str, key: str, value: str) -> None:
"""写一条命名空间 KV(round-12:P8 三处登记的迁移后端)。
键存为 ``kv:<namespace>:<key>``,值原样存 TEXT(调用方
自己 JSON 序列化)。命名空间与键不得含 ``:``(分隔符),
不得为空。
注意:本方法**不过无凭证守卫**——P8 登记文档里含用户
写的子代理 prompt,prompt 里合法出现密钥样文本是调用
方的事(旧 JSON 文件后端同样不过守卫);守卫仍覆盖
put_task/put_goal/put_session/append_audit 这些写操作。
KV 的键是代码里写死的固定名,不含用户输入。"""
if not namespace or ":" in namespace \
or not isinstance(namespace, str):
raise ValueError("kv namespace must be a non-empty string"
" without ':'")
if not key or ":" in key or not isinstance(key, str):
raise ValueError("kv key must be a non-empty string"
" without ':'")
if not isinstance(value, str):
raise ValueError("kv value must be str")
with self._lock:
conn = self._require_conn()
conn.execute(
"INSERT OR REPLACE INTO meta(key, value) VALUES (?, ?)",
("kv:%s:%s" % (namespace, key), value))
conn.commit()
def kv_get(self, namespace: str, key: str) -> str | None:
"""读一条命名空间 KV;不存在回 None。"""
with self._lock:
conn = self._require_conn()
row = conn.execute(
"SELECT value FROM meta WHERE key = ?",
("kv:%s:%s" % (namespace, key),)).fetchone()
return row[0] if row else None
def kv_delete(self, namespace: str, key: str) -> None:
"""删一条命名空间 KV(不存在也静默成功)。"""
with self._lock:
conn = self._require_conn()
conn.execute("DELETE FROM meta WHERE key = ?",
("kv:%s:%s" % (namespace, key),))
conn.commit()
def insert_audit_trail_row(self, record: dict) -> None:
"""插一条 P12 审计轨迹行(round-12):原样存入,不重算
链——seq/hash/prev_hash 由调用方(StoreAuditTrail)算好,
迁移时也原样搬旧行。seq 重复即抛 sqlite3.IntegrityError。"""
detail = record.get("detail")
if not isinstance(detail, dict):
detail = {}
with self._lock:
conn = self._require_conn()
conn.execute(
"INSERT INTO audit_trail(seq, project_id, actor,"
" category, action, object_type, object_id, result,"
" reason, task_id, approval_id, detail_json, ts,"
" prev_hash, hash, legacy) VALUES (?, ?, ?, ?, ?, ?,"
" ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(int(record["seq"]), str(record.get("project_id", "")),
str(record.get("actor", "")),
str(record.get("category", "")),
str(record.get("action", "")),
str(record.get("object_type", "")),
str(record.get("object_id", "")),
str(record.get("result", "ok")),
str(record.get("reason", "")),
str(record.get("task_id", "")),
str(record.get("approval_id", "")),
json.dumps(detail, sort_keys=True,
ensure_ascii=False),
float(record.get("ts", 0.0)),
str(record.get("prev_hash", "")),
str(record.get("hash", "")),
1 if record.get("legacy") else 0))
conn.commit()
def list_audit_trail_rows(self) -> list[dict]:
"""全部审计轨迹行,按 seq 升序(链顺序),与 JSONL 版
同款记录形状;legacy 只在为真时出现。"""
with self._lock:
conn = self._require_conn()
rows = conn.execute(
"SELECT seq, project_id, actor, category, action,"
" object_type, object_id, result, reason, task_id,"
" approval_id, detail_json, ts, prev_hash, hash,"
" legacy FROM audit_trail ORDER BY seq ASC").fetchall()
out = []
for row in rows:
rec = {"seq": row[0], "ts": row[12],
"project_id": row[1], "actor": row[2],
"category": row[3], "action": row[4],
"object_type": row[5], "object_id": row[6],
"result": row[7], "reason": row[8],
"task_id": row[9], "approval_id": row[10],
"detail": json.loads(row[11]),
"prev_hash": row[13], "hash": row[14]}
if row[15]:
rec["legacy"] = True
out.append(rec)
return out
def counts(self) -> dict[str, int]:
"""各表行数(备份/恢复校验用)。"""
with self._lock:
conn = self._require_conn()
out: dict[str, int] = {}
for table in ("tasks", "goals", "sessions", "audit"):
for table in ("tasks", "goals", "sessions", "audit",
"audit_trail"):
row = conn.execute(
"SELECT COUNT(*) FROM %s" % table).fetchone()
out[table] = int(row[0])
@@ -469,6 +610,7 @@ class AppStateStore:
"goals": str(counts["goals"]),
"sessions": str(counts["sessions"]),
"audit": str(counts["audit"]),
"audit_trail": str(counts["audit_trail"]),
"created_at": _utcnow(),
}
atomic_write_text(