diff --git a/backend/apps/swarm/closure.py b/backend/apps/swarm/closure.py index 9d13c8ce..b09ec962 100644 --- a/backend/apps/swarm/closure.py +++ b/backend/apps/swarm/closure.py @@ -126,9 +126,9 @@ def build_manifest(root_type: EntityType, root_id: str) -> Manifest: return p_assemble(root_type, root_id)[0] -def build_bundle(root_type: EntityType, root_id: str) -> tuple[bytes, str]: +def build_bundle(root_type: EntityType, root_id: str, allow_file_secrets: bool = False) -> tuple[bytes, str]: manifest, payloads, files = p_assemble(root_type, root_id) - raw = pack(manifest.model_dump(by_alias=True, mode="json"), payloads, files) + raw = pack(manifest.model_dump(by_alias=True, mode="json"), payloads, files, allow_file_secrets=allow_file_secrets) return raw, manifest.root.name diff --git a/backend/apps/swarm/models.py b/backend/apps/swarm/models.py index c5c02460..c47f7652 100644 --- a/backend/apps/swarm/models.py +++ b/backend/apps/swarm/models.py @@ -104,6 +104,8 @@ class ReviewSummary(BaseModel): class ExportRequest(BaseModel): type: EntityType id: str + # User-confirmed "export anyway": skips the file-content secret heuristic on direct download only; denied payload fields stay blocked. + allow_secrets: bool = False class ExportPreflightResponse(BaseModel): diff --git a/backend/apps/swarm/swarm.py b/backend/apps/swarm/swarm.py index 57c3a66f..ceca79ca 100644 --- a/backend/apps/swarm/swarm.py +++ b/backend/apps/swarm/swarm.py @@ -71,7 +71,7 @@ async def export_preflight(body: ExportRequest) -> ExportPreflightResponse: @swarm.router.post("/export") async def export_bundle(body: ExportRequest) -> Response: try: - raw, name = closure.build_bundle(body.type, body.id) + raw, name = closure.build_bundle(body.type, body.id, allow_file_secrets=body.allow_secrets) except BundleError as e: raise HTTPException(status_code=400, detail=str(e)) fname = closure.swarm_filename(name) diff --git a/backend/apps/swarm/ziputil.py b/backend/apps/swarm/ziputil.py index 4e1e3ceb..f47f240c 100644 --- a/backend/apps/swarm/ziputil.py +++ b/backend/apps/swarm/ziputil.py @@ -37,21 +37,25 @@ def p_content_digest(entries: dict[str, bytes]) -> str: return h.hexdigest() -def pack(manifest: dict, payloads: dict[str, dict], files: dict[str, bytes]) -> bytes: +def pack(manifest: dict, payloads: dict[str, dict], files: dict[str, bytes], allow_file_secrets: bool = False) -> bytes: """payloads: bundle_id -> JSON payload (-> entities//payload.json). - files: full zip path -> bytes (e.g. entities//files/).""" + files: full zip path -> bytes (e.g. entities//files/). + allow_file_secrets is a user-confirmed override for the FILE-content heuristic only + (workspace code trips it on look-alike strings); denied payload fields are our own + credential store and are never exportable, override or not.""" for bid, payload in payloads.items(): leaked = find_denied_keys(payload) if leaked: raise BundleError( f"refusing to export: secret-shaped field(s) in {bid}: {leaked[:3]}" ) - leaky_files = find_secrets_in_files(files) - if leaky_files: - raise BundleError( - f"refusing to export: a secret-shaped value is in {leaky_files[0]}; " - "remove it (use an environment variable) and try again" - ) + if not allow_file_secrets: + leaky_files = find_secrets_in_files(files) + if leaky_files: + raise BundleError( + f"refusing to export: a secret-shaped value is in {leaky_files[0]}; " + "remove it (use an environment variable) and try again" + ) entries: dict[str, bytes] = {} for bid, payload in payloads.items(): entries[f"entities/{bid}/payload.json"] = json.dumps(payload, indent=2).encode("utf-8") diff --git a/backend/tests/test_swarm_bundle.py b/backend/tests/test_swarm_bundle.py index 410991f8..a1b50e51 100644 --- a/backend/tests/test_swarm_bundle.py +++ b/backend/tests/test_swarm_bundle.py @@ -110,6 +110,15 @@ def test_pack_allows_clean_workspace_file(): assert zipfile.is_zipfile(io.BytesIO(raw)) +def test_pack_export_anyway_overrides_file_scan_but_never_denied_keys(): + # User-confirmed override ships a flagged workspace FILE (trusted recipient); our own credential fields stay unexportable no matter what. + leak = b"const KEY = 'sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAA';\n" + raw = pack({"format_version": 1}, {"bid1": {"name": "ok"}}, {"entities/bid1/files/config.js": leak}, allow_file_secrets=True) + assert zipfile.is_zipfile(io.BytesIO(raw)) + with pytest.raises(BundleError): + pack({"format_version": 1}, {"bid1": {"api_key": "leak"}}, {}, allow_file_secrets=True) + + def test_app_export_drops_machine_env(tmp_path, monkeypatch): # The live .env holds the source machine's absolute paths + pinned port; it must never ride along. .env.example (portable) does. from backend.apps.swarm.entities import apps as appmod diff --git a/frontend/src/app/components/share/ShareModal.tsx b/frontend/src/app/components/share/ShareModal.tsx index 0b571459..0b61fa25 100644 --- a/frontend/src/app/components/share/ShareModal.tsx +++ b/frontend/src/app/components/share/ShareModal.tsx @@ -55,11 +55,11 @@ const ShareModal: React.FC = ({ target, open, onClose }) => { return load(); }, [open, load]); - const handleDownload = async () => { + const handleDownload = async (allowSecrets = false) => { if (!preflight) return; setDownloading(true); try { - await downloadSwarm(target, preflight.filename); + await downloadSwarm(target, preflight.filename, allowSecrets); setToast(`Saved ${preflight.filename}`); onClose(); } catch (e: any) { @@ -68,6 +68,8 @@ const ShareModal: React.FC = ({ target, open, onClose }) => { setDownloading(false); } }; + // The file-content secret heuristic is overridable (download goes to people you trust); our own credential fields ("secret-shaped field(s)") are not. + const secretOverridable = error.includes('secret-shaped value'); const optionRow = ( selected: boolean, @@ -150,6 +152,16 @@ const ShareModal: React.FC = ({ target, open, onClose }) => { + {secretOverridable && ( + + )} ) : preflight ? ( @@ -179,7 +191,7 @@ const ShareModal: React.FC = ({ target, open, onClose }) => {