Cover the auto-fire decision matrix:
- _severity_rank ordering
- mode != auto-execute → never fires (auto-propose, manual)
- below-threshold action is skipped + audited
- federation case + no quorum → skipped + audited "no quorum"
- federation case + quorum met → fires
- local case + quorum required + local-only on → still fires
- local case + quorum required + local-only off → still fires
- quorum gating disabled → federation cases fire too
- kill switch armed → tick() skips everything
- pulse_audit records both auto-fire and skip rows
- audit_count_since returns the per-action counts the cockpit needs
- config round-trips through pulse_settings
Tests patch federation.is_quorum_met (raising=False so the sibling
agent can ship the real function later without breaking these), and
swap respond.execute_action for a counter so no SOAR sink call escapes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Cockpit:
- /admin/pulse now renders an "AUTO-RESPONSE STATE" panel above the
pipeline table — mode badge (traffic-light colored), threshold,
quorum on/off, local-only on/off, auto-fired-in-24h count, last 5
audit entries, and a one-form save for threshold + gates.
- POST /admin/pulse/respond-config writes the new gates.
CLI:
- pulse-respond-config [--threshold …] [--quorum/--no-quorum]
[--local-only/--no-local-only]
Args left unset are unchanged; echoes the post-state.
- pulse-respond-status prints mode, gates, and the last 10 audit
entries.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace the propose-only respond runner with a two-phase runner: phase 1
always proposes actions for fresh high-severity cases (unchanged); phase 2
fires when pipeline mode is auto-execute and the action clears all gates.
Gates:
- severity ≥ configured threshold
- if require_quorum is on, federation-sourced cases must hit
federation.is_quorum_met (wrapped in try/except so we tolerate the
sibling agent not having shipped that function yet — fallback is
"no quorum metric → don't auto-fire", the safe default)
- locally-generated cases (no row in federation_signals for their case_id)
bypass the quorum check
- when local-only is armed, federation-sourced cases never auto-fire
even with quorum
Every decision (auto-fire, skipped, error) records a pulse_audit row so
the cockpit and CLI can show history. Per-action try/except keeps one
bad action from aborting the whole batch.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Public JSON endpoints (no auth):
- GET /federation/vouches — our_vouches() so peers can pull our trust
- GET /federation/log — last 100 transparency-log entries
- GET /federation/log/verify — re-walks the chain, returns
{verified, head_hash} or 409 with {error, head_hash}
Admin pages (TOTP-gated):
- /admin/federation/vouches — issued list, issue form, revoke
buttons, per-peer quorum-met table
- /admin/federation/log — chain verification status + last 200 entries
- /admin/federation/quorum — config form + per-peer eligibility +
per-signal-hash distinct-eligible-peer counts
CLI: fed-vouch / fed-unvouch / fed-vouches / fed-quorum-set / fed-log /
fed-log-verify, plus existing fed-* commands untouched.
The base /admin/federation page now links to the three new sub-pages.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add a per-decision audit log so the cockpit + CLI can show what the
auto-response runner did each tick:
- pulse_audit table: id, pipeline, action ('auto-fire'|'skipped'|'error'),
action_id, case_id, detail, timestamp
- helpers: pulse_audit_record, pulse_audit_recent, pulse_audit_count_since
- indexes on (pipeline, timestamp desc) and on action_id
Also add db.signals_for_case(case_id) — checks the federation_signals
buffer to tell whether a case was peer-sourced. Used by the runner to
decide if a quorum check is required.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
import_signed_feed now refuses any feed whose declared fingerprint isn't
peer_is_listening_eligible (directly trusted OR vouched in), returning
Err("peer not trusted: …") before any signal lands.
For every case/IOC it does record, it also appends a "signal" entry to
the transparency log (best-effort — logger warns but doesn't abort
ingest if the append fails). This is the stage-trans-b hook: the
import path is the chokepoint, so attaching the chain there gives
us coverage of every peer-originated signal we've ever accepted.
build_signed_feed now includes our_vouches() in the feed body so vouches
propagate. On import we accept_vouch each one — but only if the embedded
voucher_fingerprint matches the peer we just authenticated, so a peer
can't forge vouches "from" someone else through us.
test_federation: the long-standing round-trip test now first registers
the synthetic peer as trusted so the gate lets it through.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add the web-of-trust primitives, all keyed off the existing node keypair:
- Vouch + QuorumConfig Pydantic models
- vouch_payload_bytes — canonical-JSON body that the voucher signs
- issue_vouch / accept_vouch / revoke_vouch / our_vouches / vouches_for
- is_vouched(target, min_vouchers) — counts DISTINCT trusted vouchers,
ignoring expired vouches and re-using QuorumConfig defaults
- peer_is_listening_eligible(fp) — direct-trust OR vouched-in
- is_quorum_met(signal_hash, k) — distinct listening-eligible peers
reporting the same hash
- quorum_evidence(signal_hash) — (peer_fp, received_at) tuples for UI
- quorum_config / set_quorum_config — persisted in pulse_settings
accept_vouch is paranoid: rejects expired vouches, vouchers that aren't
currently "trusted" in our peers table, mismatched pubkey-fingerprint
pairs, malformed base64, and Ed25519 verification failures — each with
a short Err reason.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Persisted-key/value helpers for the respond-pipeline auto-fire gates:
- respond_auto_threshold / set_… (Severity, default HIGH)
- respond_require_quorum / set_… (bool, default True)
- respond_local_only / set_… (bool, default False)
Plus a _severity_rank helper for threshold comparison.
Backing store is the existing pulse_settings table; this commit also adds
generic pulse_setting_get / pulse_setting_set helpers in db.py so future
pulse settings don't each need their own column-pair helper.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
translog.append computes
sha256(canonical({prev_hash, entry_type, entry_data, timestamp})) and
writes one row per call; the first entry uses prev_hash = "0"*64.
verify_chain walks rows in id order, re-hashes each, and returns
Err("broken at id=X expected=... got=...") on the first mismatch — so
tampering with either entry_data or prev_hash invalidates every
downstream row. recent / entries_after / head support peer sync and UI.
Tests cover: genesis prev_hash, chained prev_hash, full-chain verify,
tampered-data detection, tampered-prev_hash detection, slicing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add `vouches` (voucher_fp, target_fp, issued_at, expires_at, signature)
with unique (voucher, target) and target index, plus `translog` for the
append-only signed merkle chain (id, prev_hash, entry_type, entry_data,
timestamp, entry_hash). Also surface `setting_get` / `setting_set`
helpers on pulse_settings so the quorum config has a place to live.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Inserted the analytics.neuronetz.ai tracking script tag in the <head>
of base.html with the project's website-id. defer attribute means it
never blocks page render. Cookieless / privacy-respecting (Umami).
Applies to every page that extends base — the admin gate's standalone
template intentionally skips it (no point tracking the login screen).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1. Particles now blink in time with the scan column.
Each <circle>/<rect> in the case hero SVG carries class="hero-particle"
and a CSS animation-delay equal to -(arrival-time-of-sweep-at-its-x),
so as the sweep marches left-to-right the particles light up in
sequence — a real "the scanner just touched this point" effect. The
sweep is now linear / non-alternating on a 12s cycle so the phase
stays predictable. Drop-shadow + opacity flash at the peak.
2. Stat chips added under the featured title.
Up to five compact chips showing incident type, total IOC count with
per-type breakdown (U/D/I/H/C), victim country if mapped, confidence
level, malware family. Each chip is color-coded.
3. Radar emblem in the top-right of the hero.
Pure SVG: three concentric range rings, cross-hairs, a translucent
sweep arm that spins every 4.5s, a center dot. The whole emblem
inherits the cycling-hero hue cycle so it changes color with the
grid. Smaller + dimmer on mobile; honors prefers-reduced-motion.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The hero is now layered:
base — radial accent + dark gradient (truly full-width, min 240px /
up to 320px tall, no aspect-ratio cap)
grid — full-bleed CSS grid (38px cells), line color cycles through
cyan → red → gold → green → violet over 18s, plus a slow
grid-drift so the lattice subtly slides; a glowing column
sweeps left-to-right across the hero every 12s for depth
particles — the existing case-specific SVG sits on top at lower
opacity (mix-blend-mode: screen) so the per-case identity
is preserved as a constellation glittering over the grid
overlay — title + Open-case CTA pinned to the bottom
Severity just nudges the grid opacity (CRITICAL/HIGH get a bit denser);
the color cycle stays on for all severities. Honors prefers-reduced-motion.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The banner gradient was a horizontal accent → dark fade, so visually
only the left ~30% read as a banner; the right looked unstyled. Swapped
for a uniform severity-tinted background (subtle top-to-bottom gradient
for depth, but no horizontal falloff). Border + inset shadow on the
bottom edge give it a real divider between the banner and the hero
beneath. Per-severity tints (CRITICAL red, HIGH amber) get the same
treatment — full-width, uniform.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
When I refactored the featured card from <section>→<a> for full-card
clicking, I forgot the <a> defaults to display:inline. That collapsed
the hero+overlay layout — the title rendered word-per-line down the
left edge instead of spanning the hero. display:block (+ inherit
color, no text-decoration) restores the layout.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two enhancements from the screenshot annotations:
1. The featured card now has a proper banner header above the hero —
gradient strip with "⌖ Featured Threat" label, a pulsing severity
dot, severity/TLP badges, source + ingest time. The header is
sev-themed (red border + glow for CRITICAL, amber for HIGH), and an
"Open case →" CTA at the bottom of the hero with an arrow that
nudges right on hover.
2. News items are now full-card clickable (transparent overlay link)
with rich severity-tinted hover transitions:
- Lifts (translateY -1px), tinted background + border that matches
severity (red CRITICAL, amber HIGH, pale MEDIUM/LOW), per-kind
accents for enforced/submitted/rejected/failed items.
- A right-side arrow (→) slides in on hover signaling "click to open".
- Case glyph SVG gets a subtle drop-shadow + 1.04x scale on hover.
- 180ms ease transitions across background/border/transform/shadow.
- Respects prefers-reduced-motion (turns animations off).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Template had `{{ buckets|sum(attribute='items')|length }}` which tried
to sum lists starting at 0 → TypeError. Switched to a Python-side
`total_items = sum(len(b.items) for b in buckets)` passed through the
context and rendered directly.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The home page goes from a flat event stream to something that reads like
a news blog:
- Featured-case hero card at the top, picked as the highest-severity
case (CRITICAL > HIGH, tie-break recency) from the last 7 days. Wide,
with a procedurally generated SVG hero behind a gradient overlay that
carries title + severity + TLP + feed + ingest time.
- Recent activity is now grouped under Today / Yesterday / Earlier this
week / Older bucket headers.
- Each item gets a left-border severity accent (red CRITICAL, amber
HIGH, muted MEDIUM/LOW) so the page is scannable at a glance.
Images: new cockpit/case_visuals.py generates SVGs from case data —
zero external image gen, zero curated assets. Every visual is
deterministic from case_id (so a case keeps its identity across
sessions) and themed to its severity:
- case_hero_svg() — 880x220 hero with severity radial glow, a faint
scan grid, a particle constellation with auto-connecting lines, HUD
corner brackets, and the case id whispered in the bottom-right.
- case_glyph_svg() — small mirror-symmetric identicon (5-grid),
severity-colored, shown beside each case news item in place of an
emoji icon. Two case_ids → two distinct glyphs; same id → same glyph.
7 news tests pass; visual sanity print confirms hero is deterministic
and uses the right severity accent.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
User reported mobile nav still looked unresponsive. Verified the new
nav-toggle CSS and markup are deployed; the browser's service worker
was serving the prior cockpit.css cache-first from psyc-v1.
Two fixes so this can't recur:
- CACHE_VERSION bumped to psyc-v2 → the activate handler now wipes
the v1 cache on the user's next visit.
- Static-asset strategy changed from cache-first to
stale-while-revalidate: serve from cache for instant render, then
refresh in the background so the *next* load picks up the new CSS/JS
with no manual cache-busting. Falls back to network if cold.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaced the horizontal-scroll hack with a real mobile pattern:
- Hamburger button (☰) appears below 760px width, animates into an X when
open. Tap to slide the full nav into a drawer below the topbar, each
link a tap-target-sized row.
- Nav links auto-close the drawer on click so navigating dismisses it.
- aria-expanded / aria-controls wired up so screen readers track state.
- Below 420px the model status chip + family icon hide to keep the
topbar compact; brand-sub already hidden by the earlier mobile pass.
Discovered while debugging: prod had 377 unclassified cases (only
fetch-all was run, not classify-all/prove-all/reindex), so the journey
page's Classifier beat skipped the live model verdict entirely. Ran
the full pipeline on prod — the psyc-v5 model chip now renders on
case journeys. Worth noting for the deploy runbook: after psyc fetch-all
on a new env, always also run classify-all + prove-all + reindex to
prime the pipeline state.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The inference service declared 'image: psyc-trainer' but no build: stanza,
so 'docker compose build inference' was a silent no-op and 'compose up
inference' tried to pull from a registry that doesn't exist (denied).
Added build context pointing at Dockerfile.train so future production
deploys can rebuild it via the compose lifecycle instead of needing a
manual 'docker build' on the side.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two production-discovered fixes after first deploy:
- CISA's CDN was 403'ing the "psyc/0.1 (defensive CTI; hackathon
prototype)" User-Agent from the cloud.neuronetz.ai exit IP. Switched
to a Mozilla-compatible UA that identifies us honestly while passing
the CDN's UA filters. Overridable via PSYC_HTTP_USER_AGENT.
- fetch-all aborted on the first HTTPStatusError, so a CISA hiccup
killed the threatfox/malware-bazaar/otx legs that come after. The
outer loop now catches any exception per-source, logs a skip, and
moves on. Single-source failures no longer poison the rest of the
pull.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>