From 7eeb864a29115cdb3c8a6b1720821115ec42ffcf Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 14 Jun 2026 08:07:17 -0700 Subject: [PATCH] [eric] swarm: archive checksum (reject tampered) + transactional rollback on import --- backend/apps/swarm/closure.py | 36 ++++++++++++---- backend/apps/swarm/entities/apps.py | 11 ++++- backend/apps/swarm/entities/dashboards.py | 21 ++++++++-- backend/apps/swarm/entities/sessions.py | 5 +++ backend/apps/swarm/entities/skills.py | 11 +++++ backend/apps/swarm/entities/workflows.py | 9 ++++ backend/apps/swarm/models.py | 3 ++ backend/apps/swarm/ziputil.py | 50 +++++++++++++++++++++-- backend/tests/test_swarm_bundle.py | 50 +++++++++++++++++++++++ 9 files changed, 178 insertions(+), 18 deletions(-) diff --git a/backend/apps/swarm/closure.py b/backend/apps/swarm/closure.py index dba4e398..c7e7054d 100644 --- a/backend/apps/swarm/closure.py +++ b/backend/apps/swarm/closure.py @@ -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 diff --git a/backend/apps/swarm/entities/apps.py b/backend/apps/swarm/entities/apps.py index a9a34544..ba3f2321 100644 --- a/backend/apps/swarm/entities/apps.py +++ b/backend/apps/swarm/entities/apps.py @@ -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)) diff --git a/backend/apps/swarm/entities/dashboards.py b/backend/apps/swarm/entities/dashboards.py index eebb2507..71ee3d3f 100644 --- a/backend/apps/swarm/entities/dashboards.py +++ b/backend/apps/swarm/entities/dashboards.py @@ -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 diff --git a/backend/apps/swarm/entities/sessions.py b/backend/apps/swarm/entities/sessions.py index a028b77d..847601e6 100644 --- a/backend/apps/swarm/entities/sessions.py +++ b/backend/apps/swarm/entities/sessions.py @@ -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) diff --git a/backend/apps/swarm/entities/skills.py b/backend/apps/swarm/entities/skills.py index cb8fbf06..e02b41b2 100644 --- a/backend/apps/swarm/entities/skills.py +++ b/backend/apps/swarm/entities/skills.py @@ -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") diff --git a/backend/apps/swarm/entities/workflows.py b/backend/apps/swarm/entities/workflows.py index d7b2fb8f..a8b1143c 100644 --- a/backend/apps/swarm/entities/workflows.py +++ b/backend/apps/swarm/entities/workflows.py @@ -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: diff --git a/backend/apps/swarm/models.py b/backend/apps/swarm/models.py index 65ff68ef..b6c7779e 100644 --- a/backend/apps/swarm/models.py +++ b/backend/apps/swarm/models.py @@ -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) diff --git a/backend/apps/swarm/ziputil.py b/backend/apps/swarm/ziputil.py index d53033cd..b4682695 100644 --- a/backend/apps/swarm/ziputil.py +++ b/backend/apps/swarm/ziputil.py @@ -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//payload.json). files: full zip path -> bytes (e.g. entities//files/).""" @@ -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") diff --git a/backend/tests/test_swarm_bundle.py b/backend/tests/test_swarm_bundle.py index fc6bb7a7..dcb5b733 100644 --- a/backend/tests/test_swarm_bundle.py +++ b/backend/tests/test_swarm_bundle.py @@ -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: