[eric] share: user-confirmed Export anyway when the file secret heuristic trips (download only; own credential fields stay blocked)

This commit is contained in:
ciregenz
2026-07-15 15:44:10 -07:00
parent e1dd537a94
commit 0190e232ae
7 changed files with 43 additions and 16 deletions
+2 -2
View File
@@ -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
+2
View File
@@ -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):
+1 -1
View File
@@ -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)
+12 -8
View File
@@ -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/<bid>/payload.json).
files: full zip path -> bytes (e.g. entities/<bid>/files/<rel>)."""
files: full zip path -> bytes (e.g. entities/<bid>/files/<rel>).
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")
+9
View File
@@ -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
@@ -55,11 +55,11 @@ const ShareModal: React.FC<Props> = ({ 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<Props> = ({ 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<Props> = ({ target, open, onClose }) => {
<Button size="small" onClick={load} sx={{ textTransform: 'none', color: c.accent.primary }}>
Try again
</Button>
{secretOverridable && (
<Button
size="small"
onClick={() => { setError(''); handleDownload(true); }}
disabled={downloading}
sx={{ textTransform: 'none', color: c.status.error, ml: 1 }}
>
Export anyway (includes the flagged value; only send to people you trust)
</Button>
)}
</Box>
) : preflight ? (
<IncludesList summary={preflight.summary} />
@@ -179,7 +191,7 @@ const ShareModal: React.FC<Props> = ({ target, open, onClose }) => {
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 1 }}>
<Button
variant="contained"
onClick={handleDownload}
onClick={() => handleDownload()}
disabled={!preflight || downloading}
startIcon={
downloading ? (
@@ -28,11 +28,11 @@ export async function exportPreflight(target: ShareTarget): Promise<ExportPrefli
return res.json();
}
export async function downloadSwarm(target: ShareTarget, filename: string): Promise<void> {
export async function downloadSwarm(target: ShareTarget, filename: string, allowSecrets = false): Promise<void> {
const res = await fetch(`${API_BASE}/swarm/export`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: target.kind, id: target.id }),
body: JSON.stringify({ type: target.kind, id: target.id, allow_secrets: allowSecrets }),
});
if (!res.ok) throw new Error(await _detail(res, "We couldn't build the file."));
const blob = await res.blob();