diff --git a/CHANGELOG.md b/CHANGELOG.md
index cf385e0..667a3e1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,43 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
## [Unreleased]
+### Fixed
+- Flea-market: derive next version_no from `max(version_history.n) + 1`
+ instead of `entity.version_no + 1` in PUT (edit) + restore. Under
+ deferred promotion (v37+) `entity.version_no` stays at the last
+ *approved* version while `version_history` accumulates blocked /
+ errored / pending entries — so the previous derivation would
+ overwrite an in-flight blocked v2 dir on the next PUT, and the
+ runner's hash-match promotion would then load bytes that don't
+ match the recorded submission. Surfaced by the adversarial review
+ while fixing the atomic-promote ordering.
+- Flea-market live-bundle swap + DB promote is now atomic-ish via a
+ new `promote_to_version` helper that swaps live FIRST and only
+ advances `entity.version_no` after the on-disk swap succeeds.
+ Pre-fix the runner / override / inline-promote paths called
+ `repo.promote_version` then `_swap_live_to_version`. A missing
+ source dir made the swap silently return False — leaving the DB
+ ahead of live. Helper now refuses on missing source and rolls back
+ live to the prior version if the DB promote fails. (Medium —
+ surfaced by adversarial review.)
+- Flea-market LLM prompt: file PATHS in the per-file
+ `--- FILE: {rel} ---` header now go through the same
+ `` / `` escape as file BODIES. Pre-fix only the
+ bodies were escaped — a ZIP whose relative path concatenated to
+ `` (a `<` directory + `bundle>` child) could forge the
+ trust-boundary close tag from inside the path slot and inject
+ apparent system instructions after the apparent boundary.
+ (Medium — surfaced by adversarial review.)
+- Flea-market admin forensic download
+ (`GET /api/admin/store/submissions/{id}/bundle.zip`) now returns
+ the STAGED bundle bytes the submission represents, not live.
+ Pre-fix downloading a blocked v2 submission streamed live's prior
+ approved v1 bytes — admins reviewing whether to override saw safe
+ bytes instead of the risky staged bytes they were deciding about.
+ Resolves staged `versions/v/plugin/` via
+ `_version_no_for_submission`; falls back to live for legacy rows.
+ (Low — surfaced by adversarial review.)
+
### Changed
- **BREAKING (operator-facing)**: flea-market guardrail pipeline now
fail-CLOSED on misconfig. `get_guardrails_enabled()` previously
diff --git a/app/api/admin.py b/app/api/admin.py
index e7bbf60..7a1a9e0 100644
--- a/app/api/admin.py
+++ b/app/api/admin.py
@@ -3773,19 +3773,20 @@ async def admin_override_store_submission(
# publish-gate model is designed to express.
if (target_version_no is not None
and target_version_no > int(entity_row.get("version_no") or 0)):
- if ents_repo.promote_version(entity_id, target_version_no):
- try:
- from app.api.store import _swap_live_to_version
- _swap_live_to_version(entity_id, target_version_no)
- promoted_to = target_version_no
- # Re-read after promotion so attribution picks up the
- # new version's name/type if a rename was bundled in.
- entity_row = ents_repo.get(entity_id) or entity_row
- except Exception:
- logger.exception(
- "override: live swap failed for entity %s v%d",
- entity_id, target_version_no,
- )
+ # Atomic helper: swap live bundle first, then update the DB.
+ # Eliminates the "DB promoted but live still on prior bytes"
+ # window. If the helper returns None (source missing / swap
+ # failed) the row's status + visibility are still flipped
+ # above — admin can re-trigger via /rescan once the bundle
+ # is recovered.
+ from app.api.store import promote_to_version
+ promoted_to = promote_to_version(
+ entity_id, target_version_no, ents_repo,
+ )
+ if promoted_to is not None:
+ # Re-read after promotion so attribution picks up the
+ # new version's name/type if a rename was bundled in.
+ entity_row = ents_repo.get(entity_id) or entity_row
# Update usage-attribution rows now that the entity is live.
update_flea_attribution(
@@ -4105,8 +4106,13 @@ async def admin_download_store_submission_bundle(
import io as _io
import zipfile as _zipfile
from pathlib import Path as _P
- from app.api.store import _plugin_dir as _sp_plugin_dir
+ from app.api.store import (
+ _plugin_dir as _sp_plugin_dir,
+ _submission_plugin_dir,
+ _version_no_for_submission,
+ )
+ from src.repositories.store_entities import StoreEntitiesRepository
from src.repositories.store_submissions import StoreSubmissionsRepository
sub = StoreSubmissionsRepository(conn).get(submission_id)
@@ -4116,7 +4122,20 @@ async def admin_download_store_submission_bundle(
if not entity_id:
raise HTTPException(status_code=410, detail="bundle_purged_or_missing")
- plugin_dir = _sp_plugin_dir(entity_id)
+ # Resolve the STAGED bundle this submission represents, not live.
+ # Under deferred promotion, live `plugin/` holds the prior approved
+ # version — so for a blocked v2 row, live shows v1's safe bytes
+ # while the staged v2 bytes (the actual risky upload the admin is
+ # reviewing) sit in `versions/v2/plugin/`. Falls back to live for
+ # legacy rows that never seeded a versions/ dir.
+ ent = StoreEntitiesRepository(conn).get(entity_id) or {}
+ target_n = _version_no_for_submission(ent, submission_id)
+ if target_n is not None:
+ plugin_dir = _submission_plugin_dir(entity_id, target_n)
+ if not plugin_dir.exists():
+ plugin_dir = _sp_plugin_dir(entity_id)
+ else:
+ plugin_dir = _sp_plugin_dir(entity_id)
if not plugin_dir.exists():
raise HTTPException(status_code=410, detail="bundle_missing")
diff --git a/app/api/store.py b/app/api/store.py
index 71ae0a6..8c0f819 100644
--- a/app/api/store.py
+++ b/app/api/store.py
@@ -826,6 +826,64 @@ def _write_synth_plugin_json(
+def promote_to_version(
+ entity_id: str,
+ target_version_no: int,
+ repo: StoreEntitiesRepository,
+) -> Optional[int]:
+ """Atomic-ish promotion: swap live bundle FIRST, then update DB.
+
+ Returns the promoted version number on success, ``None`` when the
+ source bundle is missing or the swap failed. The DB row only moves
+ forward after the live dir is in place — eliminating the
+ "DB promoted but live still on prior bytes" inconsistency
+ surfaced by the adversarial review.
+
+ Failure modes:
+ * Source ``versions/v/plugin/`` missing → return None,
+ no DB change, no live change.
+ * Swap raises mid-rename → live is restored from backup
+ (handled inside ``_swap_live_to_version``); DB untouched.
+ * DB ``promote_version`` reports no row updated (entity gone) →
+ best-effort swap back to prior version so live + DB stay
+ consistent.
+ """
+ source = _entity_dir(entity_id) / "versions" / f"v{int(target_version_no)}" / "plugin"
+ if not source.is_dir():
+ logger.error(
+ "promote_to_version: source missing for entity %s v%d at %s",
+ entity_id, target_version_no, source,
+ )
+ return None
+ prior_row = repo.get(entity_id) or {}
+ prior_n = int(prior_row.get("version_no") or 0)
+ try:
+ ok = _swap_live_to_version(entity_id, target_version_no)
+ except OSError:
+ logger.exception(
+ "promote_to_version: live swap raised for entity %s v%d",
+ entity_id, target_version_no,
+ )
+ return None
+ if not ok:
+ return None
+ if not repo.promote_version(entity_id, target_version_no):
+ # DB row vanished mid-flight (rare: hard-delete between our
+ # earlier `.get()` and the promote). Roll live back to the
+ # prior version to keep on-disk and (still-absent) DB
+ # consistent for the next caller.
+ if prior_n:
+ try:
+ _swap_live_to_version(entity_id, prior_n)
+ except Exception:
+ logger.exception(
+ "promote_to_version: rollback swap failed for entity %s",
+ entity_id,
+ )
+ return None
+ return int(target_version_no)
+
+
def _swap_live_to_version(entity_id: str, version_no: int) -> bool:
"""Replace the live ``plugin/`` dir with a copy of the named
version's contents. Used by the guardrails-disabled promote path
@@ -1626,7 +1684,19 @@ async def _update_entity_locked(
tmp.close()
scratch = Path(tempfile.mkdtemp(prefix="agnes_store_"))
existing_plugin = _plugin_dir(entity_id)
- new_version_no = int(entity.get("version_no") or 1) + 1
+ # New version number is max(version_history.n) + 1, NOT
+ # entity.version_no + 1. Under deferred promotion (v37+),
+ # entity.version_no stays at the last *approved* version while
+ # version_history accumulates blocked / errored / pending
+ # entries. Deriving from version_no would overwrite an
+ # in-flight (blocked or pending) version dir on the next PUT
+ # — and the runner's hash-match promotion would then load
+ # bytes that don't match the recorded submission. Bug surfaced
+ # by adversarial review (M2 / atomic promotion).
+ history_ns = [
+ int(e.get("n") or 0) for e in (entity.get("version_history") or [])
+ ]
+ new_version_no = (max(history_ns) if history_ns else int(entity.get("version_no") or 1)) + 1
version_root = _entity_dir(entity_id) / "versions" / f"v{new_version_no}"
staging_plugin = version_root / "plugin"
new_version_dir = version_root # exposed to outer scope
@@ -1867,10 +1937,10 @@ async def _update_entity_locked(
)
elif not hold_for_review:
# Guardrails explicitly disabled → implicit approval.
- # Promote inline: update entity columns + swap live to new
- # version.
- repo.promote_version(entity_id, appended_n)
- _swap_live_to_version(entity_id, appended_n)
+ # Promote inline via the atomic helper: swap-first then
+ # DB-promote so a missing source / mid-rename failure
+ # never leaves the DB ahead of the on-disk bundle.
+ promote_to_version(entity_id, appended_n, repo)
# Live bundle is now the new version; refresh attribution.
ent_after_swap = repo.get(entity_id) or {}
update_flea_attribution(
@@ -2037,7 +2107,13 @@ async def _restore_version_locked(
)
# Copy source → new version dir, run guardrails, swap live.
- new_version_no = int(entity.get("version_no") or 1) + 1
+ # Derive from max(version_history.n) so deferred-promotion blocked
+ # / errored entries don't get overwritten. Same fix as the PUT
+ # path above.
+ history_ns = [
+ int(e.get("n") or 0) for e in (entity.get("version_history") or [])
+ ]
+ new_version_no = (max(history_ns) if history_ns else int(entity.get("version_no") or 1)) + 1
target_root = _entity_dir(entity_id) / "versions" / f"v{new_version_no}"
target_plugin = target_root / "plugin"
if target_plugin.exists():
@@ -2107,9 +2183,8 @@ async def _restore_version_locked(
if schedule_async_llm:
_schedule_llm_review(background_tasks, sub_id, target_plugin)
elif not hold_for_review:
- # Guardrails explicitly disabled — inline-promote.
- repo.promote_version(entity_id, appended_n)
- _swap_live_to_version(entity_id, appended_n)
+ # Guardrails explicitly disabled — inline-promote atomically.
+ promote_to_version(entity_id, appended_n, repo)
# Else (enabled + not-ready): defer promotion, await admin retry.
_invalidate_etag()
diff --git a/src/store_guardrails/prompts.py b/src/store_guardrails/prompts.py
index 7d595b4..f555cb1 100644
--- a/src/store_guardrails/prompts.py
+++ b/src/store_guardrails/prompts.py
@@ -221,21 +221,21 @@ def build_review_prompt(
truncated = False
for rel, body in _ranked_text_files(plugin_dir):
- chunk_header = f"\n--- FILE: {rel} ---\n"
+ # Escape literal / tags in BOTH the file path
+ # AND the file body so a ZIP member named `` or a
+ # crafted README can't forge a close tag, escape the sentinel,
+ # and inject instructions the model would read as outside the
+ # trust boundary. The system prompt declares the tags as the
+ # boundary; we have to keep them unique. Pre-fix, only file
+ # bodies were escaped — a filename containing ``
+ # would bypass the boundary (adversarial-review finding).
+ safe_rel = _escape_sentinels(rel)
+ chunk_header = f"\n--- FILE: {safe_rel} ---\n"
# Per-file head clip.
chunk_body = body[:PER_FILE_HEAD_BYTES]
if len(body) > PER_FILE_HEAD_BYTES:
chunk_body += f"\n[... truncated {len(body) - PER_FILE_HEAD_BYTES} bytes ...]\n"
- # Escape any literal / tags inside user content so
- # an adversarial README can't forge a close tag, escape the
- # sentinel, and inject instructions that the model would read as
- # outside the trust boundary. The system prompt declares the
- # tags as the boundary; we have to keep them unique.
- chunk_body = (
- chunk_body
- .replace("", "")
- .replace("", "<_bundle_>")
- )
+ chunk_body = _escape_sentinels(chunk_body)
chunk = chunk_header + chunk_body
if used + len(chunk) > MAX_REVIEW_BYTES:
truncated = True
@@ -254,6 +254,25 @@ def build_review_prompt(
return "".join(parts)
+def _escape_sentinels(text: str) -> str:
+ """Neutralize literal ```` / ```` tags in any
+ untrusted bundle content (file bodies AND file paths).
+
+ The system prompt declares the ```` sentinels as the
+ trust boundary. If any content inside that boundary forges a
+ matching close tag, the model could be tricked into reading
+ subsequent text as outside the boundary — and following
+ instructions there. The substitution keeps each occurrence
+ visible to the reviewer (so it can be flagged) while preventing
+ the trust-boundary forgery.
+ """
+ return (
+ text
+ .replace("", "")
+ .replace("", "<_bundle_>")
+ )
+
+
# Files sorted by a "scan first" heuristic — manifests + docs + scripts
# come before random tail content so a truncated review still saw the
# parts most likely to contain a problem.
diff --git a/src/store_guardrails/runner.py b/src/store_guardrails/runner.py
index d5c4781..b59fd71 100644
--- a/src/store_guardrails/runner.py
+++ b/src/store_guardrails/runner.py
@@ -306,16 +306,14 @@ def run_llm_review(
# past a version that was approved more recently.
if (target_version_no is not None
and target_version_no > int(ent_row.get("version_no") or 0)):
- if ents_repo.promote_version(entity_id, target_version_no):
- try:
- from app.api.store import _swap_live_to_version
- _swap_live_to_version(entity_id, target_version_no)
- promoted_to = target_version_no
- except Exception:
- logger.exception(
- "promote_version live swap failed for entity %s v%d",
- entity_id, target_version_no,
- )
+ # Atomic helper: swap live bundle first, then
+ # update the DB. Eliminates the
+ # "DB promoted but live still on prior bytes"
+ # window flagged by adversarial review.
+ from app.api.store import promote_to_version
+ promoted_to = promote_to_version(
+ entity_id, target_version_no, ents_repo,
+ )
else:
# Entity left the serve-able states between BG
# task start + verdict-write. Record so admin
diff --git a/tests/test_admin_store_submissions.py b/tests/test_admin_store_submissions.py
index 29bf549..43cac45 100644
--- a/tests/test_admin_store_submissions.py
+++ b/tests/test_admin_store_submissions.py
@@ -1365,6 +1365,101 @@ class TestAdminBundleDownload:
)
assert r.status_code == 403
+ def test_download_v2_blocked_returns_staged_bundle_not_live(
+ self, web_client, monkeypatch,
+ ):
+ """Codex adversarial review [LOW]: pre-fix the download
+ streamed live `plugin/` bytes regardless of which submission
+ was being inspected. Under deferred promotion (v37+), live
+ holds the prior approved version's bytes — so downloading a
+ blocked v2 returned v1's safe bundle while the admin was
+ deciding whether to override the *staged* v2's risky bytes.
+ Fixed: resolve the staged `versions/v/plugin/` per
+ submission via `_version_no_for_submission`."""
+ from pathlib import Path
+ from app.utils import get_store_dir
+ from src.repositories.store_entities import StoreEntitiesRepository
+ from src.repositories.store_submissions import StoreSubmissionsRepository
+
+ monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-fake-for-dl-test")
+ user_id, user_cookies = _create_user(web_client, "dl-stage@x.com")
+ r = web_client.post(
+ "/api/store/entities",
+ files={"file": ("s.zip", _make_skill_zip("dlstage"), "application/zip")},
+ data={"type": "skill",
+ "description": (
+ "Use when verifying admin forensic download "
+ "serves staged version bytes for v2 blocked "
+ "submissions instead of the live prior version"
+ )},
+ cookies=user_cookies,
+ )
+ assert r.status_code == 201, r.text
+ eid = r.json()["id"]
+
+ # PUT v2 with mocked LLM block.
+ def mock_block(*a, **kw):
+ return {
+ "risk_level": "high", "summary": "mock block",
+ "findings": [{"severity": "high", "category": "test",
+ "file": "x", "explanation": "mock"}],
+ "template_placeholders_found": 0,
+ "reviewed_by_model": "mock", "error": None,
+ }
+ monkeypatch.setattr(
+ "src.store_guardrails.llm_review.review_bundle", mock_block,
+ )
+ monkeypatch.setattr(
+ "app.api.store.get_guardrails_enabled", lambda: True,
+ )
+ monkeypatch.setattr(
+ "app.api.store.get_guardrails_llm_provider_ready", lambda: True,
+ )
+ import io as _io
+ import zipfile as _zip
+ v2_marker = "V2_STAGED_PAYLOAD_UNIQUE_TOKEN"
+ buf = _io.BytesIO()
+ with _zip.ZipFile(buf, "w") as zf:
+ zf.writestr(
+ "dlstage/SKILL.md",
+ "---\nname: dlstage\ndescription: "
+ "Use when verifying admin download returns staged bytes for v2 blocked submissions\n---\n\n"
+ + (f"{v2_marker}. Body content long enough to clear the inline content-quality threshold for skill bodies. " * 3),
+ )
+ r = web_client.put(
+ f"/api/store/entities/{eid}",
+ files={"file": ("v2.zip", buf.getvalue(), "application/zip")},
+ cookies=user_cookies,
+ )
+ assert r.status_code == 200, r.text
+
+ # Confirm v2 sub is blocked + entity stayed at v1 (live = v1 bytes).
+ conn = get_system_db()
+ v2_sub_id = StoreSubmissionsRepository(conn).latest_for_entity(eid)["id"]
+ v2_sub = StoreSubmissionsRepository(conn).get(v2_sub_id)
+ ent = StoreEntitiesRepository(conn).get(eid)
+ conn.close()
+ assert v2_sub["status"] == "blocked_llm"
+ assert ent["version_no"] == 1
+
+ # Admin downloads the v2 submission's bundle. Must serve the
+ # STAGED v2 bytes (contain v2_marker), NOT live v1 (which
+ # doesn't).
+ _, admin_cookies = _create_admin(web_client)
+ r = web_client.get(
+ f"/api/admin/store/submissions/{v2_sub_id}/bundle.zip",
+ cookies=admin_cookies,
+ )
+ assert r.status_code == 200
+ with zipfile.ZipFile(io.BytesIO(r.content)) as zf:
+ blob = b""
+ for n in zf.namelist():
+ blob += zf.read(n)
+ assert v2_marker.encode() in blob, (
+ "admin download must return STAGED v2 bytes "
+ "(missing the v2-only marker — got live v1 bytes instead)"
+ )
+
class TestAdminSortBySize:
def test_sort_file_size_asc_desc(self, web_client):
diff --git a/tests/test_store_entity_versions.py b/tests/test_store_entity_versions.py
index cae19be..33123ec 100644
--- a/tests/test_store_entity_versions.py
+++ b/tests/test_store_entity_versions.py
@@ -1660,3 +1660,49 @@ class TestBgTaskIdempotency:
row = StoreSubmissionsRepository(conn).get(sid)
conn.close()
assert row["status"] == "pending_llm"
+
+
+class TestAtomicPromote:
+ """Codex adversarial review [MEDIUM]: pre-fix sequence was
+ ``repo.promote_version(...)`` → ``_swap_live_to_version(...)``.
+ If the source ``versions/v/plugin/`` was missing,
+ ``_swap_live_to_version`` returned False silently — leaving DB
+ at the new version but live still on the prior bytes.
+
+ Fix: a ``promote_to_version`` helper that swaps live FIRST, then
+ promotes the DB. Missing source → return None, no DB change."""
+
+ def test_missing_source_dir_does_not_advance_db(self, web_client):
+ """Promote with a missing version dir must leave both DB and
+ live untouched."""
+ from app.api.store import promote_to_version, _plugin_dir
+ from src.repositories.store_entities import StoreEntitiesRepository
+
+ user_id, _ = _create_user(web_client, "atomic@x.com")
+ conn = get_system_db()
+ repo = StoreEntitiesRepository(conn)
+ repo.create(
+ id="ent-atomic", owner_user_id=user_id, owner_username="atomic",
+ type="skill", name="atomic", description="x" * 40,
+ category=None, version="aaaaaaaaaaaaaaaa", file_size=10,
+ visibility_status="approved",
+ )
+ # Inject a v2 history entry without creating the on-disk dir
+ # — simulates the "DB has entry, bundle wiped" inconsistency.
+ repo.append_version_history(
+ "ent-atomic", version_hash="bbbbbbbbbbbbbbbb",
+ sha256=None, size=20, submission_id="fake-sub", created_by=user_id,
+ )
+ conn.close()
+
+ # Attempt to promote to v2 — version dir doesn't exist.
+ conn = get_system_db()
+ repo = StoreEntitiesRepository(conn)
+ result = promote_to_version("ent-atomic", 2, repo)
+ ent_after = repo.get("ent-atomic")
+ conn.close()
+ assert result is None, "must signal failure when source missing"
+ assert ent_after["version_no"] == 1, (
+ f"DB must NOT advance when live swap can't happen; got "
+ f"version_no={ent_after['version_no']}"
+ )
diff --git a/tests/test_store_guardrails_prompt_injection.py b/tests/test_store_guardrails_prompt_injection.py
index c522592..3e50bf5 100644
--- a/tests/test_store_guardrails_prompt_injection.py
+++ b/tests/test_store_guardrails_prompt_injection.py
@@ -173,3 +173,45 @@ class TestSystemPromptIgnoreRuleScope:
from src.store_guardrails.prompts import SYSTEM_PROMPT
assert "" in SYSTEM_PROMPT
assert "" in SYSTEM_PROMPT
+
+
+def test_filename_with_bundle_sentinel_is_escaped(plugin_dir):
+ """Adversarial-review finding: pre-fix, file BODIES escaped
+ ```` / ```` but the per-file ``--- FILE: {rel}
+ ---`` header inlined the untrusted relative path unescaped.
+
+ A ZIP member named e.g. ``foo/.md`` could forge the
+ closing sentinel from inside the path slot and inject
+ instructions after the apparent boundary. The fix escapes both
+ bodies AND paths via ``_escape_sentinels``."""
+ from src.store_guardrails.prompts import build_review_prompt
+
+ # POSIX filesystems can't have `/` literally inside a single
+ # filename, but the RELATIVE PATH string produced by
+ # `relative_to(plugin_dir).as_posix()` concatenates components
+ # with `/`. A two-component path `<` / `bundle>` renders as the
+ # exact string `` — forging the close sentinel from
+ # inside what's supposed to be a data-only path slot. Construct
+ # exactly that to prove the escape catches it.
+ bad_dir = plugin_dir / "evilskill"
+ bad_dir.mkdir()
+ (bad_dir / "SKILL.md").write_text(
+ "---\nname: evilskill\ndescription: probe\n---\nbody\n",
+ )
+ forged_dir = plugin_dir / "<"
+ forged_dir.mkdir()
+ (forged_dir / "bundle>").write_text("normal content")
+
+ prompt = build_review_prompt(
+ plugin_dir, type_="skill", name="evilskill",
+ version="1.0.0", description="x" * 60,
+ )
+
+ # The prompt must still contain exactly one open + one close
+ # sentinel — the filename injection must NOT have leaked
+ # additional sentinels through.
+ assert prompt.count("") == 1
+ assert prompt.count("") == 1
+ # The escaped form is present (proves the filename was processed
+ # through the escape).
+ assert "" in prompt