agnes-the-ai-analyst/app/web/templates/admin_store_submissions.html
Vojtech d6ad08f107
Flea-market upload guardrails + soft delete + JOIN-based admin queue (#233)
* feat(store): flea-market upload guardrails + soft delete + JOIN-based admin queue

Adds an end-to-end guardrails pipeline for store uploads (manifest +
static-security + LLM review), persists blocked bundles for forensics,
introduces soft-delete (Archive) semantics, consolidates the legacy
/store/{id} surface into /marketplace/flea/{id}, and reworks the admin
queue so lifecycle filters read live entity visibility via LEFT JOIN
rather than a denormalized submission column.

Schema v29 → v35:
  * v29 store_submissions table + store_entities.visibility_status
  * v30 file_size, bundle_sha256, bundle_purged_at on submissions
  * v31 reshape store_submissions (drop legacy unique on entity_id)
  * v32 store_entities.archived_at/by + 'archived' visibility value
  * v33 drop store_submissions.retry_count (unused)
  * v34 ensure idx_store_submissions_entity exists post column-drop
  * v35 broaden visibility_status enum + JOIN architecture cutover

Pipeline (src/store_guardrails/):
  * Inline checks: manifest_check, static_scan, quality_check
  * LLM review configurable haiku|sonnet|opus (default haiku)
  * BackgroundTasks-driven async path with structured-output JSON
  * Per-submitter daily quota (default 50)
  * 30-day TTL purge job (POST /api/admin/run-blocked-purge)
  * Bundle SHA256 + size persisted; sha256 survives purge for forensics

Visibility model:
  * pending | approved | hidden | archived
  * _enforce_visibility returns 404 (no leak) for non-owner non-admin
  * Owner sees own non-approved entries via include_owner_id widening
  * Install refused with 409 entity_not_approved when not approved

Soft-delete (DELETE /api/store/entities/{id}):
  * Default = soft (visibility_status='archived'); existing installs
    keep getting served the bundle so users don't lose the plugin
  * ?hard=true admin-only: drops bundle + cascades user_store_installs
  * Hard-delete preserves entity_id on submission as tombstone so
    audit_log linkage survives for the activity timeline

Admin queue lifecycle (the JOIN refactor):
  * Verdict (store_submissions.status) is immutable forensic record
  * Lifecycle (store_entities.visibility_status) is live state
  * /admin/store/submissions Archived chip translates to
    `e.visibility_status='archived'` via LEFT JOIN — any path that
    flips visibility surfaces in the queue immediately
  * Detail page renders Status (verdict) and Entity lifecycle side by
    side so admins see "approved at review, now archived" at a glance

URL consolidation:
  * /store/{id} deleted (no redirect, stale bookmarks 404)
  * /marketplace/flea/{id} is the canonical detail surface
  * Three in-tree callers (upload-success, my-stack card, store
    listing card) updated to point at the new URL
  * Quarantine banner extracted to _quarantine_banner.html partial,
    self-guarded, included from both flea detail templates
  * Banner JS auto-refreshes when the verdict lands by polling
    /api/marketplace/flea/{id}/detail (visibility_status +
    submission_status — the latter is needed because blocked_llm
    keeps the entity at visibility_status='pending')

Audit log resource format:
  * runner.py emits prefixed `store_submission:{id}` (post-fix)
  * Detail-page timeline query handles three patterns: prefixed
    submission, helper-emitted `store_entity:{sub_id}`, and bare-id
    legacy rows — all surface in the activity timeline

UX fixes:
  * Owner sees Under review / Quarantined / Hidden banner with status
  * Install button gray-disabled (not blue) when non-approved
  * Owner cannot delete quarantined entries (403); admin can
  * Admin queue: filter chips, sortable columns, paging, page-size
  * Auto-refresh queue every 5s while pending rows are visible
  * Store upload page file picker no longer opens twice (label →
    input default action collided with explicit JS handler)

Tests: 168 passed across the guardrails suites (admin submissions,
store API, inline / LLM / purge guardrails, store repositories,
marketplace filter, schema version). New regression coverage
includes: archive surfaces via JOIN even when API path is bypassed;
deleted submission renders activity timeline (tombstone); flea
detail surfaces submission_status only for owner/admin; detail page
renders Entity lifecycle row; audit log resource format covers both
helper and runner paths.

* fix(store-guardrails): PR #233 follow-up — prompt injection, atomic PUT, BG race, schema, reaper, sort whitelist

Addresses 9 of the 23 findings from the PR #233 review (spec at
docs/superpowers/specs/2026-05-09-pr233-guardrails-fixes-spec.md).
Merge-gate items #1-#6 plus high-value mediums #7, #9-#12, #23.
Architectural items (#8 enum split, #14 factory) and pure
maintainability (#15-#22) deferred to follow-ups.

Security:
* #1 prompt injection — SYSTEM_PROMPT now passed via the SDK's
  dedicated system= parameter; bundle wrapped in <bundle>...</bundle>
  sentinels declared data-only by the system prompt; literal
  sentinel strings in user content are escaped so an adversarial
  README can't forge a close tag.
* #6 static scan honesty — module docstring + admin copy + docs
  declare static scan as signal not gate; .md/.txt/.rst/.html/.json/
  .yaml/.yml/.toml skipped to avoid false positives on prose.
  AST mode for Python deferred (separate flag, FP comparison work).

Correctness:
* #2 PUT atomicity — bundles bake into plugin.staging-<rand>/
  alongside live, atomic-rename on success; failed checks leave
  live tree byte-for-byte intact.
* #3 BG-task race — set_visibility_if_pending guards verdict flips
  to the (pending, hidden) review window; admin archives during
  review survive; skipped flips audit-logged.
* #4 v35 NOT NULL/DEFAULT — schema v35→v36 re-applies them on
  store_entities.visibility_status. CHECK constraint enforced
  application-side (DuckDB ADD CHECK on existing column unsupported).
* #7 stuck-review reaper — reap_stuck_llm_reviews flips pending_llm
  rows older than guardrails.stuck_review_grace_seconds (default
  1800) to review_error. Scheduler runs every 15 min via new
  /api/admin/run-reap-stuck-reviews. Set knob to 0 to disable.
* #9 quota counter — count_blocked_for_submitter_since now counts
  blocked_inline + blocked_llm + review_error so a submitter
  triggering only LLM-blocked verdicts is bounded.
* #10 missing risk_level — surfaces as review_error with
  error='missing_risk_level' instead of silently defaulting to
  'medium' (which looked like a model-decided block).
* #11 archived_at clear — set_visibility nulls archived_at +
  archived_by when transitioning out of 'archived' so a future
  read doesn't show stale archive forensics on an approved row.

Maintainability:
* #12 FSM doc comment — accurate insert/transition/lifecycle
  description in src/db.py near store_submissions schema.
* #23 sort-key whitelist — admin queue rejects unknown sort keys
  with 400 invalid_sort_key; substring-replace footgun removed.

Deferred (separate PRs):
* #5 quota race — proper fix requires asyncio.Lock spanning the
  full pipeline; threading.Lock blocks event loop, DuckDB MVCC
  doesn't help. API-level slowapi bounds worst case for now.
* #6 part 3 (AST static scan), #8 (enum split), #13 (import
  bundle docs), #14 (factory consolidation), #15-#22 (maint).

Tests:
* New: tests/test_store_guardrails_prompt_injection.py (corpus +
  trust-boundary invariants), tests/test_store_put_atomic.py,
  tests/test_store_guardrails_reaper.py.
* Extended: test_store_guardrails_llm.py (system param, missing
  risk_level, BG race), test_admin_store_submissions.py (quota
  counter widening, sort whitelist 400), test_store_repositories.py
  (un-archive metadata clear), test_db_schema_version.py (v36).
* Full suite: 3738 passed; 17 pre-existing baseline failures
  unchanged (db migration tests, cli binary rename, catalog export,
  user mgmt v5 backfill — confirmed by stash + rerun on clean tree).
2026-05-09 17:32:53 +04:00

337 lines
15 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "base.html" %}
{% block title %}Store submissions — {{ config.INSTANCE_NAME }}{% endblock %}
{% block content %}
<style>
/* Width + padding come from .page-shell (style-custom.css) — same
1280px container as /dashboard, /marketplace, /admin/* peers. */
.subs-title { margin: 0 0 8px 0; font-size: 22px; font-weight: 600; }
.subs-help { color: var(--text-secondary, #6b7280); font-size: 13px; margin-bottom: 20px; }
.subs-help code { background: var(--border-light, #f3f4f6); padding: 1px 6px; border-radius: 4px; font-size: 12px; }
.subs-form {
display: grid;
grid-template-columns: repeat(4, minmax(140px, 1fr)) auto auto;
gap: 8px;
margin-bottom: 12px;
align-items: end;
}
.subs-form label { font-size: 11px; color: var(--text-secondary, #6b7280); text-transform: uppercase; letter-spacing: 0.4px; display: block; margin-bottom: 4px; }
.subs-form input, .subs-form select {
width: 100%; padding: 6px 10px; font-size: 13px;
border: 1px solid var(--border, #e5e7eb); border-radius: 6px;
background: var(--surface, #fff); color: var(--text, #111827);
}
.subs-form .submit-btn {
padding: 7px 14px; border-radius: 6px;
background: var(--text, #111827); color: var(--surface, #fff);
border: 1px solid var(--text, #111827); font-size: 13px; cursor: pointer;
}
.subs-form .reset-btn {
padding: 7px 14px; border-radius: 6px;
background: var(--surface, #fff); color: var(--text, #111827);
border: 1px solid var(--border, #e5e7eb); font-size: 13px; text-decoration: none;
}
.subs-status-chips { margin-bottom: 12px; display: flex; gap: 8px; flex-wrap: wrap; }
.subs-status-chips a {
text-decoration: none; padding: 6px 12px; border-radius: 6px;
border: 1px solid var(--border, #e5e7eb); color: var(--text, #111827);
font-size: 12px; font-weight: 500;
}
.subs-status-chips a.active {
background: var(--text, #111827); color: var(--surface, #fff);
border-color: var(--text, #111827);
}
.subs-filter-chip {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 10px; margin-bottom: 12px;
background: #dbeafe; color: #1e40af; border-radius: 999px;
font-size: 12px; font-weight: 500;
}
.subs-filter-chip a { color: inherit; text-decoration: none; }
.subs-table-wrap {
background: var(--surface, #fff);
border: 1px solid var(--border, #e5e7eb);
border-radius: 12px;
overflow-x: auto;
}
.subs-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.subs-table thead th {
text-align: left; padding: 12px 16px;
background: var(--border-light, #f9fafb);
border-bottom: 1px solid var(--border, #e5e7eb);
font-weight: 600; color: var(--text-secondary, #6b7280);
font-size: 11px; text-transform: uppercase; letter-spacing: 0.4px;
white-space: nowrap;
}
.subs-table tbody td {
padding: 10px 16px;
border-bottom: 1px solid var(--border-light, #f3f4f6);
vertical-align: top;
}
.subs-table tbody tr { cursor: pointer; }
.subs-table tbody tr:last-child td { border-bottom: none; }
.subs-table tbody tr:hover { background: var(--border-light, #fafafa); }
.subs-table .ts { white-space: nowrap; color: var(--text-secondary, #6b7280); font-variant-numeric: tabular-nums; }
.subs-table .submitter a { color: var(--text-secondary, #4b5563); text-decoration: none; font-size: 12px; }
.subs-table .submitter a:hover { text-decoration: underline; color: var(--text, #111827); }
.badge {
display: inline-block; padding: 2px 8px; border-radius: 999px;
font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px;
}
.badge.pending_inline,
.badge.pending_llm {
background: #fef3c7; color: #92400e;
/* Subtle pulse so admins scanning the queue see in-flight reviews. */
animation: pulse 1.6s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.55; }
}
.badge.approved { background: #d1fae5; color: #065f46; }
.badge.overridden { background: #dbeafe; color: #1e40af; }
.badge.blocked_inline,
.badge.blocked_llm,
.badge.review_error { background: #fee2e2; color: #991b1b; }
.badge.archived { background: #e5e7eb; color: #374151; }
.badge.deleted { background: #4b5563; color: #f3f4f6; }
.findings {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px;
color: var(--text-secondary, #6b7280); max-width: 540px;
word-break: break-word; white-space: pre-wrap;
}
.subs-paging {
display: flex; justify-content: space-between; align-items: center;
padding: 12px 16px; font-size: 12px; color: var(--text-secondary, #6b7280);
border-top: 1px solid var(--border-light, #f3f4f6);
}
.subs-paging a, .subs-paging .disabled {
padding: 4px 10px; border-radius: 6px; text-decoration: none;
color: var(--text, #111827); border: 1px solid var(--border, #e5e7eb);
margin: 0 2px;
}
.subs-paging .disabled { opacity: 0.4; pointer-events: none; }
.subs-paging select {
padding: 4px 8px; border-radius: 6px; border: 1px solid var(--border, #e5e7eb);
background: var(--surface, #fff); font-size: 12px;
}
.empty {
padding: 40px 16px; text-align: center;
color: var(--text-secondary, #6b7280); font-size: 13px;
}
</style>
{% set base_qs %}
{%- if status_filter %}status={{ status_filter }}&{% endif -%}
{%- if submitter_filter %}submitter={{ submitter_filter }}&{% endif -%}
{%- if type_filter %}type={{ type_filter }}&{% endif -%}
{%- if name_filter %}name={{ name_filter }}&{% endif -%}
{%- if version_filter %}version={{ version_filter }}&{% endif -%}
{%- if sort_filter %}sort={{ sort_filter }}&{% endif -%}
{%- if order_filter %}order={{ order_filter }}&{% endif -%}
{%- endset %}
{% set base_qs_no_sort %}
{%- if status_filter %}status={{ status_filter }}&{% endif -%}
{%- if submitter_filter %}submitter={{ submitter_filter }}&{% endif -%}
{%- if type_filter %}type={{ type_filter }}&{% endif -%}
{%- if name_filter %}name={{ name_filter }}&{% endif -%}
{%- if version_filter %}version={{ version_filter }}&{% endif -%}
{%- endset %}
{# Sort-link helper: clicking a sortable header toggles asc/desc on
that column while preserving every active filter. Default order on
first click of any column except created_at is ascending; clicking
the active column flips it. #}
{% macro sort_link(col, label) -%}
{% set is_active = (sort_filter == col) %}
{% set new_order = "desc" if (is_active and order_filter == "asc") else ("asc" if is_active else ("desc" if col == "created_at" else "asc")) %}
<a href="?{{ base_qs_no_sort }}sort={{ col }}&order={{ new_order }}"
style="color:inherit; text-decoration:none;">
{{ label }}{% if is_active %}{{ " ↓" if order_filter == "desc" else " ↑" }}{% endif %}
</a>
{%- endmacro %}
{# Format bytes to KB for display. Returns "—" when None. #}
{% macro fmt_size(b) -%}
{%- if b is none -%}—{%- elif b < 1024 -%}{{ b }} B{%- elif b < 1048576 -%}{{ "%.1f"|format(b / 1024) }} KB{%- else -%}{{ "%.1f"|format(b / 1048576) }} MB{%- endif -%}
{%- endmacro %}
<div class="subs-page page-shell">
<h1 class="subs-title">Store submissions</h1>
<p class="subs-help">
Every upload to <code>/api/store/entities</code> writes one row here. Inline
checks (manifest, static security, quality+templating) run synchronously;
the LLM security review runs in the background and flips
<code>pending_llm → approved</code> or <code>blocked_llm</code>. Click a
row to see the full check breakdown. Override a blocked submission to
force-publish (audit trail captured), retry a <code>review_error</code>,
or delete spam outright. See <code>docs/STORE_GUARDRAILS.md</code>.
</p>
<form class="subs-form" method="get" action="/admin/store/submissions">
{# Preserve status filter when submitting other filters. #}
{% if status_filter %}<input type="hidden" name="status" value="{{ status_filter }}">{% endif %}
{% if submitter_filter %}<input type="hidden" name="submitter" value="{{ submitter_filter }}">{% endif %}
<div>
<label for="f-type">Type</label>
<select id="f-type" name="type">
<option value="" {% if not type_filter %}selected{% endif %}>All</option>
<option value="skill" {% if type_filter == 'skill' %}selected{% endif %}>Skill</option>
<option value="agent" {% if type_filter == 'agent' %}selected{% endif %}>Agent</option>
<option value="plugin" {% if type_filter == 'plugin' %}selected{% endif %}>Plugin</option>
</select>
</div>
<div>
<label for="f-name">Name contains</label>
<input id="f-name" name="name" type="text" value="{{ name_filter }}" placeholder="e.g. summarizer">
</div>
<div>
<label for="f-version">Version contains</label>
<input id="f-version" name="version" type="text" value="{{ version_filter }}" placeholder="e.g. 1.0">
</div>
<div>
<label for="f-limit">Per page</label>
<select id="f-limit" name="limit">
{% for n in [25, 50, 100, 200] %}
<option value="{{ n }}" {% if limit == n %}selected{% endif %}>{{ n }}</option>
{% endfor %}
</select>
</div>
<button class="submit-btn" type="submit">Apply</button>
<a class="reset-btn" href="/admin/store/submissions">Reset</a>
</form>
{% if submitter_filter %}
<div class="subs-filter-chip">
Submitter: <strong>{{ submitter_email or submitter_filter }}</strong>
<a href="/admin/store/submissions{% if status_filter or type_filter or name_filter or version_filter %}?{% if status_filter %}status={{ status_filter }}&{% endif %}{% if type_filter %}type={{ type_filter }}&{% endif %}{% if name_filter %}name={{ name_filter }}&{% endif %}{% if version_filter %}version={{ version_filter }}{% endif %}{% endif %}">✕ clear</a>
</div>
{% endif %}
<div class="subs-status-chips">
{% set _common %}{% if submitter_filter %}submitter={{ submitter_filter }}&{% endif %}{% if type_filter %}type={{ type_filter }}&{% endif %}{% if name_filter %}name={{ name_filter }}&{% endif %}{% if version_filter %}version={{ version_filter }}&{% endif %}{% endset %}
{# "All" excludes lifecycle-end states (archived, deleted) by default
— admin queue stays focused on actionable rows. The Archived /
Deleted chips opt back in. #}
<a href="/admin/store/submissions{% if _common %}?{{ _common[:-1] }}{% endif %}" class="{{ 'active' if not status_filter else '' }}">All</a>
{% set _pending = "pending_llm,pending_inline" %}
<a href="/admin/store/submissions?{{ _common }}status={{ _pending }}" class="{{ 'active' if status_filter == _pending else '' }}">Pending</a>
{% set _needs = "blocked_inline,blocked_llm,review_error" %}
<a href="/admin/store/submissions?{{ _common }}status={{ _needs }}" class="{{ 'active' if status_filter == _needs else '' }}">Needs review</a>
<a href="/admin/store/submissions?{{ _common }}status=approved" class="{{ 'active' if status_filter == 'approved' else '' }}">Approved</a>
<a href="/admin/store/submissions?{{ _common }}status=overridden" class="{{ 'active' if status_filter == 'overridden' else '' }}">Overridden</a>
<a href="/admin/store/submissions?{{ _common }}status=archived" class="{{ 'active' if status_filter == 'archived' else '' }}">Archived</a>
<a href="/admin/store/submissions?{{ _common }}status=deleted" class="{{ 'active' if status_filter == 'deleted' else '' }}">Deleted</a>
</div>
<div class="subs-table-wrap">
{% if items %}
<table class="subs-table">
<thead>
<tr>
<th>{{ sort_link("created_at", "When") }}</th>
<th>Submitter</th>
<th>Type / {{ sort_link("name", "name") }}</th>
<th>Version</th>
<th>{{ sort_link("status", "Status") }}</th>
<th>{{ sort_link("file_size", "Size") }}</th>
<th>Findings</th>
<th>Model</th>
</tr>
</thead>
<tbody>
{% for s in items %}
<tr data-href="/admin/store/submissions/{{ s.id }}">
<td class="ts">{{ s.created_at.strftime("%Y-%m-%d %H:%M:%S") if s.created_at else "" }}</td>
<td class="submitter">
{# Filter-by-submitter link. stopPropagation in JS so row click is suppressed when clicking the email. #}
<a href="?{{ base_qs }}submitter={{ s.submitter_id }}" data-noprop="1">{{ s.submitter_email or s.submitter_id }}</a>
</td>
<td>
<span style="color: var(--text-secondary, #6b7280); font-size: 11px;">{{ s.type }}</span>
<strong>{{ s.name | store_display_name }}</strong>
</td>
<td>{{ s.version or "" }}</td>
<td><span class="badge {{ s.status }}">{{ s.status }}</span></td>
<td style="white-space: nowrap; font-variant-numeric: tabular-nums; color: var(--text-secondary, #6b7280);">{{ fmt_size(s.file_size) }}</td>
<td class="findings">
{%- if s.inline_checks and s.inline_checks.static_security and s.inline_checks.static_security.findings -%}
{%- for f in s.inline_checks.static_security.findings %}
[{{ f.severity }}] {{ f.file }}:{{ f.line }} — {{ f.reason }}
{%- endfor %}
{%- endif -%}
{%- if s.inline_checks and s.inline_checks.manifest and s.inline_checks.manifest.issues %}
manifest: {{ s.inline_checks.manifest.issues | join(", ") }}
{%- endif -%}
{%- if s.llm_findings and s.llm_findings.summary %}
llm: {{ s.llm_findings.risk_level }} — {{ s.llm_findings.summary }}
{%- endif -%}
{%- if s.llm_findings and s.llm_findings.error %}
error: {{ s.llm_findings.error }}
{%- endif -%}
</td>
<td><span style="font-size: 11px; color: var(--text-secondary, #6b7280);">{{ s.reviewed_by_model or "" }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="subs-paging">
<div>
{% set first = skip + 1 if total else 0 %}
{% set last = skip + items|length %}
Showing {{ first }}{{ last }} of {{ total }}
</div>
<div>
{% set prev_skip = (skip - limit) if (skip - limit) > 0 else 0 %}
{% if skip > 0 %}
<a href="?{{ base_qs }}limit={{ limit }}&skip={{ prev_skip }}"> Prev</a>
{% else %}
<span class="disabled"> Prev</span>
{% endif %}
page {{ current_page }} of {{ pages }}
{% if current_page < pages %}
<a href="?{{ base_qs }}limit={{ limit }}&skip={{ skip + limit }}">Next </a>
{% else %}
<span class="disabled">Next </span>
{% endif %}
</div>
</div>
{% else %}
<div class="empty">No submissions match the current filter.</div>
{% endif %}
</div>
</div>
<script>
(function () {
// Row click → detail page; suppress when clicking the submitter-filter link.
document.querySelectorAll('.subs-table tbody tr[data-href]').forEach((tr) => {
tr.addEventListener('click', (ev) => {
if (ev.target.closest('a[data-noprop]')) return;
window.location.href = tr.dataset.href;
});
});
// If any visible row is pending_*, auto-refresh the list every 5s so
// admins watching the queue see verdicts land without a manual reload.
// Stops once no pending rows remain.
const hasPending = !!document.querySelector(
'.badge.pending_inline, .badge.pending_llm'
);
if (hasPending) {
setTimeout(() => window.location.reload(), 5000);
}
})();
</script>
{% endblock %}