[eric] swarm: archive checksum (reject tampered) + transactional rollback on import

This commit is contained in:
ciregenz
2026-06-14 08:07:17 -07:00
parent fd10171ac1
commit 7eeb864a29
9 changed files with 178 additions and 18 deletions
+27 -9
View File
@@ -28,7 +28,7 @@ from .models import (
)
from .redact import scrub_payload
from .registry import IMPORT_ORDER, get_exportable
from .ziputil import MANIFEST_NAME, BundleError, has_member, is_zip, pack, read_manifest, unpack
from .ziputil import MANIFEST_NAME, BundleError, has_member, is_zip, pack, read_manifest, unpack, verify_checksum
def _now() -> str:
@@ -176,7 +176,9 @@ def stage_upload(raw: bytes, filename: str) -> tuple[str, Manifest, list[str]]:
if has_member(raw, MANIFEST_NAME):
sandbox = unpack(raw)
try:
manifest = Manifest(**read_manifest(sandbox))
raw_manifest = read_manifest(sandbox)
verify_checksum(sandbox, raw_manifest)
manifest = Manifest(**raw_manifest)
except BundleError:
shutil.rmtree(sandbox, ignore_errors=True)
raise
@@ -325,13 +327,29 @@ def _topo_order(manifest: Manifest) -> list[EntityRef]:
def commit(sandbox: str, manifest: Manifest, accept_requirements: list[str]):
remap = RemapTable()
created: dict[str, list[str]] = {}
for e in _topo_order(manifest):
cls = get_exportable(e.type)
if cls is None:
raise BundleError(f"can't import a {e.type.value} yet")
new_id = cls.import_(_read_payload(sandbox, e), _read_files(sandbox, e), remap)
remap.assign(e.bundle_id, new_id)
created.setdefault(e.type.value, []).append(new_id)
trail: list[tuple] = [] # (impl_cls, new_local_id) for rollback, newest last
try:
for e in _topo_order(manifest):
cls = get_exportable(e.type)
if cls is None:
raise BundleError(f"can't import a {e.type.value} yet")
new_id = cls.import_(_read_payload(sandbox, e), _read_files(sandbox, e), remap)
remap.assign(e.bundle_id, new_id)
created.setdefault(e.type.value, []).append(new_id)
trail.append((cls, new_id))
except Exception as ex:
# All-or-nothing: undo whatever already landed so a failed import never
# leaves half a dashboard behind.
for cls, nid in reversed(trail):
rb = getattr(cls, "rollback", None)
if rb:
try:
rb(nid)
except Exception:
pass
if isinstance(ex, BundleError):
raise
raise BundleError("import failed and was rolled back")
accepted = set(accept_requirements)
unresolved = [r for r in manifest.requirements if r.key not in accepted]
return manifest.root.type, remap.local(manifest.root.bundle_id), created, unresolved
+10 -1
View File
@@ -13,7 +13,7 @@ from uuid import uuid4
from backend.apps.outputs.models import Output
from backend.apps.outputs.workspace_io import _WALK_SKIP_DIRS, _save, load_output
from backend.config.paths import OUTPUTS_WORKSPACE_DIR
from backend.config.paths import OUTPUTS_DIR, OUTPUTS_WORKSPACE_DIR
from ..exportable import DepRef, ExportContext, RemapTable
from ..models import EntityType, Requirement
@@ -105,6 +105,15 @@ class AppExportable:
_save(o)
return o.id
@classmethod
def rollback(cls, local_id: str) -> None:
o = load_output(local_id)
if o and o.workspace_id:
shutil.rmtree(os.path.join(OUTPUTS_WORKSPACE_DIR, o.workspace_id), ignore_errors=True)
p = os.path.join(OUTPUTS_DIR, f"{local_id}.json")
if os.path.exists(p):
os.remove(p)
def _safe_join(folder: str, rel: str) -> str:
dest = os.path.realpath(os.path.join(folder, rel))
+17 -4
View File
@@ -105,6 +105,15 @@ class DashboardExportable:
_retag_sessions(cards.keys(), new_did)
return new_did
@classmethod
def rollback(cls, local_id: str) -> None:
import os
d = _dash_dir()
if d:
p = os.path.join(d, f"{local_id}.json")
if os.path.exists(p):
os.remove(p)
def _dash_dir() -> str | None:
try:
@@ -130,9 +139,13 @@ def _write(did: str, doc: dict) -> None:
def _retag_sessions(session_ids, dashboard_id: str) -> None:
# Best-effort: a hiccup here must not orphan the just-written dashboard.
from backend.apps.agents.manager.session.session_store import _load_session_data, _save_session
for sid in session_ids:
d = _load_session_data(sid)
if d is not None:
d["dashboard_id"] = dashboard_id
_save_session(sid, d)
try:
d = _load_session_data(sid)
if d is not None:
d["dashboard_id"] = dashboard_id
_save_session(sid, d)
except Exception:
pass
+5
View File
@@ -93,3 +93,8 @@ class SessionExportable:
}
_save_session(sid, doc)
return sid
@classmethod
def rollback(cls, local_id: str) -> None:
from backend.apps.agents.manager.session.session_store import _delete_session_file
_delete_session_file(local_id)
+11
View File
@@ -76,6 +76,17 @@ class SkillExportable:
return slug
@classmethod
def rollback(cls, local_id: str) -> None:
fpath = os.path.join(store.SKILLS_DIR, f"{local_id}.md")
if os.path.exists(fpath):
os.remove(fpath)
index = store._load_index()
if local_id in index:
index.pop(local_id, None)
store._save_index(index)
def _slug_taken(slug: str) -> bool:
return slug in store._load_index() or os.path.isfile(
os.path.join(store.SKILLS_DIR, f"{slug}.md")
+9
View File
@@ -103,6 +103,15 @@ class WorkflowExportable:
store.save_workflow(wf)
return wf.id
@classmethod
def rollback(cls, local_id: str) -> None:
store = _store()
if store is not None:
try:
store.delete_workflow(local_id)
except Exception:
pass
def _store():
try:
+3
View File
@@ -62,6 +62,9 @@ class Manifest(BaseModel):
created_with: str = "OpenSwarm"
created_at: str = ""
bundle_id: str
# sha256 over every entity payload + file (not the manifest itself); set at
# pack time, re-checked on import to reject a corrupted or edited archive.
checksum: Optional[str] = None
root: EntityRef
entities: list[EntityRef] = Field(default_factory=list)
edges: list[DependencyEdge] = Field(default_factory=list)
+46 -4
View File
@@ -4,6 +4,7 @@ headers, and only ever writes into a throwaway sandbox dir (never a real store).
pack re-checks that no secret slipped past redaction before writing a byte."""
from __future__ import annotations
import hashlib
import io
import json
import os
@@ -25,6 +26,17 @@ class BundleError(Exception):
"""Bundle is malformed or unsafe. Message is safe to show the user."""
def _content_digest(entries: dict[str, bytes]) -> str:
"""Order-independent sha256 over every non-manifest entry (path + bytes)."""
h = hashlib.sha256()
for path in sorted(entries):
h.update(path.encode("utf-8"))
h.update(b"\0")
h.update(entries[path])
h.update(b"\0")
return h.hexdigest()
def pack(manifest: dict, payloads: dict[str, dict], files: dict[str, bytes]) -> bytes:
"""payloads: bundle_id -> JSON payload (-> entities/<bid>/payload.json).
files: full zip path -> bytes (e.g. entities/<bid>/files/<rel>)."""
@@ -34,16 +46,46 @@ def pack(manifest: dict, payloads: dict[str, dict], files: dict[str, bytes]) ->
raise BundleError(
f"refusing to export: secret-shaped field(s) in {bid}: {leaked[:3]}"
)
entries: dict[str, bytes] = {}
for bid, payload in payloads.items():
entries[f"entities/{bid}/payload.json"] = json.dumps(payload, indent=2).encode("utf-8")
for path, data in files.items():
entries[path] = data
manifest = {**manifest, "checksum": _content_digest(entries)}
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr(MANIFEST_NAME, json.dumps(manifest, indent=2))
for bid, payload in sorted(payloads.items()):
zf.writestr(f"entities/{bid}/payload.json", json.dumps(payload, indent=2))
for path, data in sorted(files.items()):
zf.writestr(path, data)
for path in sorted(entries):
zf.writestr(path, entries[path])
return buf.getvalue()
def _sandbox_entries(sandbox: str) -> dict[str, bytes]:
"""Every file under the sandbox except the manifest, keyed by forward-slash
relpath so it matches the keys pack() hashed (cross-platform)."""
out: dict[str, bytes] = {}
root = os.path.realpath(sandbox)
for base, _dirs, fnames in os.walk(root):
for fn in fnames:
full = os.path.join(base, fn)
rel = os.path.relpath(full, root).replace(os.sep, "/")
if rel == MANIFEST_NAME:
continue
with open(full, "rb") as f:
out[rel] = f.read()
return out
def verify_checksum(sandbox: str, manifest: dict) -> None:
"""Reject an archive whose contents don't match the checksum the author
recorded (corruption or tampering). Older bundles without one are allowed."""
expected = manifest.get("checksum")
if not expected:
return
if _content_digest(_sandbox_entries(sandbox)) != expected:
raise BundleError("this .swarm looks corrupted or was modified")
def _safe_member_path(name: str, sandbox: str) -> str:
if name.startswith(("/", "\\")) or (len(name) > 1 and name[1] == ":"):
raise BundleError("bundle contains an absolute path")
+50
View File
@@ -217,6 +217,56 @@ def test_dashboard_import_remaps_to_fresh_local_ids(monkeypatch):
assert L["expanded_session_ids"] == ["newsess"] # the dangling ref is dropped
def test_checksum_rejects_tampering(skill_store):
_make_skill(skill_store, "tmp", "Tmp", "# original")
raw, _ = closure.build_bundle(EntityType.skill, "tmp")
# Rebuild the zip with the same manifest (old checksum) but an edited payload.
src = zipfile.ZipFile(io.BytesIO(raw))
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as out:
for n in src.namelist():
data = src.read(n)
if n.endswith("payload.json"):
d = json.loads(data)
d["content"] = "TAMPERED"
data = json.dumps(d, indent=2).encode("utf-8")
out.writestr(n, data)
with pytest.raises(BundleError):
closure.stage_upload(buf.getvalue(), "tmp.swarm")
def test_skill_rollback_removes_it(skill_store):
from backend.apps.swarm.entities.skills import SkillExportable
from backend.apps.swarm.exportable import RemapTable
sid = SkillExportable.import_({"slug": "rbk", "name": "Rbk", "content": "x"}, {}, RemapTable())
assert (skill_store / f"{sid}.md").exists()
SkillExportable.rollback(sid)
assert not (skill_store / f"{sid}.md").exists()
assert sid not in store._load_index()
def test_commit_rolls_back_created_on_failure(skill_store, tmp_path):
# A bundle of [skill, workflow]: skill imports first, then the workflow import
# fails (no workflow store on this branch), so the skill must be rolled back.
from backend.apps.swarm.models import BundlePreview, EntityRef, Manifest
sb = tmp_path / "sb"
skill_ref = EntityRef(type=EntityType.skill, bundle_id="s1", name="S", path="entities/s1")
wf_ref = EntityRef(type=EntityType.workflow, bundle_id="w1", name="W", path="entities/w1")
for ref, payload in ((skill_ref, {"slug": "rollme", "name": "Rollme", "content": "hi"}), (wf_ref, {"title": "W"})):
d = sb / "entities" / ref.bundle_id
d.mkdir(parents=True)
(d / "payload.json").write_text(json.dumps(payload), encoding="utf-8")
manifest = Manifest(
bundle_id="b", root=skill_ref, entities=[skill_ref, wf_ref],
preview=BundlePreview(root_type=EntityType.skill, root_name="S"),
)
with pytest.raises(BundleError):
closure.commit(str(sb), manifest, [])
assert "rollme" not in store._load_index()
assert not (skill_store / "rollme.md").exists()
def _zip_with(name, data=b"x"):
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf: