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:
1 parent
466cf7eae7
commit
d4cef29cdb
11 files changed
+1147
-63
No files matched your search
+144
-2
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user