agnes-the-ai-analyst/app/web/templates/admin_store_submissions.html
Vojtech 513711ed37
feat(store): hard-reject inline guardrail failures, trace security only (#290)
* feat(store): hard-reject inline guardrail failures, trace security only

Inline failures (manifest + content validation, static-security
deny-list hits) now hard-reject upstream of any DB write or bundle
persistence. The v30 contract that landed every inline failure as a
hidden+blocked_inline entity + admin-rescannable bundle is replaced
with two response shapes:

  - 422 code=validation_failed — manifest/content issues. Banner-only,
    no submission row, no audit_log entry. Submitter fixes and retries.
  - 422 code=security_blocked — static_scan finding. Banner-only on
    the wire, plus one audit_log row (store.upload.security_blocked)
    carrying findings + sha256 + size for admin forensics.

Quarantine + admin rescan/override apply only to the async LLM path
(blocked_llm / review_error) — the cases that genuinely benefit from
admin judgment.

Spam-quota counter narrows to blocked_llm + review_error. Admin queue
filter chip drops blocked_inline. Bundle TTL purge stops sweeping
blocked_inline. Legacy blocked_inline rows from instances that ran
the v30 contract remain reachable via the "All" tab.

New _reject_inline_or_continue helper in app/api/store.py centralises
the two-tier rejection across create_entity, update_entity, and
restore_version. Frontend templates render the new payloads as inline
banners (no redirect on failure) and keep submission_blocked as a
one-release back-compat branch.

Tests: new _seed_quarantined_entity helper replaces the older
_make_eval_skill_zip-driven setup wherever a test needs a
hidden+blocked_llm entity. 199 store tests pass under -n auto.

* release: 0.54.8 — store inline hard-reject (BREAKING)

Last commit on the PR per CLAUDE.md hard rule. Patch bump (0.54.7 →
0.54.8) wrapping Vojta's hard-reject refactor.

**BREAKING for store-upload clients**: validation failures now return
422 with `code='validation_failed'` (no entity row, no submission row,
no audit_log entry) instead of the v30 `submission_blocked` 200
response that landed a hidden `blocked_inline` row. Frontend wizard +
edit + restore still understand the legacy code for one release as a
fallback for stale clients hitting an older deploy. Operators with
custom integrations against `POST /api/store/entities` should update
to handle the new `code='validation_failed'` / `code='security_blocked'`
422 responses.

No DB migration required (legacy `blocked_inline` rows from instances
that ran the v30 contract remain reachable via the admin queue's
"All" tab; bundle-purge job no longer covers them but they linger
harmlessly).

---------

Co-authored-by: ZdenekSrotyr <zdenek.srotyr@keboola.com>
2026-05-13 19:59:12 +00:00

352 lines
16 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>
{# 'blocked_inline' deliberately omitted — inline failures are now
hard-rejected upstream (no submission row created). Legacy
blocked_inline rows from instances that ran the v30 contract
remain reachable via the 'All' tab. #}
{% set _needs = "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 title="Human version index (v1, v2, …) derived from the entity's version_history. Hash shown below.">v#</th>
<th>Hash</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 style="white-space: nowrap; font-variant-numeric: tabular-nums;">
{%- if s.version_no -%}
<strong>v{{ s.version_no }}</strong>
{%- if s.entity_version_no and s.entity_version_no == s.version_no %}
<span style="display:inline-block;padding:1px 5px;margin-left:4px;border-radius:999px;background:#d1fae5;color:#065f46;font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:0.3px;">current</span>
{%- endif %}
{%- else -%}
<span style="color: var(--text-secondary, #9ca3af);"></span>
{%- endif -%}
</td>
<td><code style="font-size:11px;">{{ s.version[:12] if s.version else "" }}</code></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 %}