agnes-the-ai-analyst/app/web/templates/_flea_versions.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

172 lines
6.4 KiB
HTML

{# Versions card — owner + admin only.
Renders entity.version_history (oldest-first) reversed so newest
appears at top. Each row gets:
* version label (vN, "current" badge for the active one)
* short hash + size + created_at
* Restore button (owner + admin) for non-current versions
* Download button (admin only) — links to admin submission bundle
Required scope:
entity — store_entities row carrying version_no + version_history
is_owner — bool
is_admin — bool
Self-guards on visibility (only renders for owner/admin) and on
history length (>= 1). Plain entities created post-v37 always have
at least a v1 entry.
#}
{% if (is_owner or is_admin) and entity and entity.version_history and entity.version_history|length >= 1 %}
<style>
.versions-card {
margin: 16px 0; padding: 14px 18px; background: var(--surface, #fff);
border: 1px solid var(--border, #e5e7eb); border-radius: 10px;
font-size: 13px;
}
.versions-card h3 {
margin: 0 0 10px 0; font-size: 14px; font-weight: 600;
color: var(--text, #111827);
}
.versions-card table { width: 100%; border-collapse: collapse; }
.versions-card th, .versions-card td {
text-align: left; padding: 6px 10px; border-bottom: 1px solid var(--border-light, #f3f4f6);
vertical-align: middle;
}
.versions-card th {
font-size: 11px; text-transform: uppercase; letter-spacing: 0.4px;
color: var(--text-secondary, #6b7280); font-weight: 500;
}
.versions-card .vn { font-weight: 600; }
.versions-card .current-badge {
display: inline-block; padding: 1px 6px; border-radius: 999px;
background: #d1fae5; color: #065f46; font-size: 10px;
font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px;
margin-left: 6px;
}
.versions-card code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px; background: rgba(0,0,0,0.04); padding: 1px 4px; border-radius: 3px;
}
.versions-card button.restore {
padding: 4px 10px; border-radius: 5px;
border: 1px solid var(--border, #d1d5db); background: var(--surface, #fff);
font-size: 12px; cursor: pointer;
}
.versions-card button.restore:hover { background: var(--surface-muted, #f9fafb); }
.versions-card button.restore:disabled { opacity: 0.5; cursor: not-allowed; }
</style>
<div class="versions-card">
<h3>Versions ({{ entity.version_history|length }})</h3>
<table>
<thead>
<tr>
<th>Version</th>
<th>Hash</th>
<th>Size</th>
<th>Created</th>
<th></th>
</tr>
</thead>
<tbody>
{# Render newest-first. Build a reversed list in Jinja. #}
{% set ordered = entity.version_history | sort(attribute='n', reverse=true) %}
{% for v in ordered %}
<tr>
<td class="vn">
v{{ v.n }}
{% if v.n == entity.version_no %}<span class="current-badge">current</span>{% endif %}
</td>
<td><code>{{ v.hash[:12] if v.hash else '—' }}</code></td>
<td>
{%- if v.size is not none -%}
{%- if v.size < 1024 -%}{{ v.size }} B
{%- elif v.size < 1048576 -%}{{ "%.1f"|format(v.size / 1024) }} KB
{%- else -%}{{ "%.1f"|format(v.size / 1048576) }} MB
{%- endif -%}
{%- else -%}{%- endif -%}
</td>
<td style="white-space: nowrap; color: var(--text-secondary, #6b7280);">
{{ v.created_at[:10] if v.created_at else '' }}
</td>
<td>
{% if v.n != entity.version_no %}
<button class="restore" type="button"
data-version-no="{{ v.n }}"
onclick="restoreVersion({{ v.n }})">
Restore
</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<script>
async function restoreVersion(versionNo) {
const currentVersionNo = {{ entity.version_no | tojson }};
const ok = confirm(
`Restore version ${versionNo}?\n\n` +
`This will create a new version (v${currentVersionNo + 1}) with v${versionNo}'s ` +
`bundle and re-run security checks. The current version stays in history.`
);
if (!ok) return;
const r = await fetch(
`/api/store/entities/{{ entity.id }}/versions/${versionNo}/restore`,
{method: 'POST', credentials: 'same-origin'}
);
if (r.ok) {
window.location.reload();
return;
}
let msg = 'Restore failed.';
try {
const j = await r.json();
if (j.detail) {
const code = j.detail.code || '';
const checks = j.detail.checks || {};
if (code === 'validation_failed') {
// Stay on the detail page; surface manifest/content issues.
// The restored version's source bundle predates today's
// rules — admin or owner can either fix the source version
// or restore a different one.
const issues = (checks.manifest?.issues || [])
.concat((checks.content?.issues || []).map(i => i.code || 'issue'));
msg = 'Restore blocked: today\'s validation rules reject the v'
+ versionNo + ' bundle.';
if (issues.length) msg += '\n• ' + issues.slice(0, 5).join('\n• ');
} else if (code === 'security_blocked') {
const findings = (checks.static_security?.findings) || [];
msg = 'Restore blocked: security review found risky patterns in the v'
+ versionNo + ' bundle.';
if (findings.length) {
msg += '\n' + findings.slice(0, 5).map(f =>
'• ' + (f.file || '?') + ':' + (f.line || '?') + ' — ' + (f.reason || f.category || '')
).join('\n');
}
} else if (code === 'submission_blocked') {
// Legacy server response (pre-cutover). Land on the detail
// page so the existing quarantine banner UX still works.
const eid = j.detail.entity_id || '{{ entity.id }}';
window.location = `/marketplace/flea/${eid}`;
return;
} else if (code === 'prior_version_pending') {
msg = 'A previous edit is still under review. Wait for the verdict before restoring.';
} else if (code === 'version_not_found') {
msg = 'That version is no longer on disk.';
} else if (code === 'already_current') {
msg = 'Already on that version.';
} else if (typeof j.detail === 'object') {
msg = `Restore failed: ${code || JSON.stringify(j.detail)}`;
} else {
msg = `Restore failed: ${j.detail}`;
}
}
} catch (_) {}
alert(msg);
}
</script>
{% endif %}