mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-06 17:57:43 +02:00
[eric] swarm: archive checksum (reject tampered) + transactional rollback on import
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user