WhatsApp-first verification & safety platform. Users message a WhatsApp number; Vishwas checks what they send — URLs, files (APK/PE/ELF/documents), images, audio, video, government documents — and replies in plain language with a verdict, a confidence band, and practical next steps. Built for constrained CPU-only hardware (designed around an i5-8250U laptop), zero-cloud, stdlib-first, zero-retention by contract.
| You send | It checks | Verdict vocabulary |
|---|---|---|
| A link | normalisation → SSRF guard → VirusTotal reputation → host-string + DOM phishing heuristics | trust / caution / do_not_use / unable_to_verify |
| An executable or archive (APK, PE, ELF/.so, .jar, zip…) | magic-byte validation → file entropy → ClamAV → YARA-x → Quark-Engine → PE static analysis → APK statics (MoBFS-lite) → (gated) dynamic sandbox (Cape/firejail+strace) | same |
| A document (PAN, DL, certificate, PDF/image scan) | extraction/OCR → document-type ID → field-level validation → GPG signature / PAdES verification / QR payload + native check / RAG template cache (retrieval cache, not source of truth) | same |
| Photo/video/audio | ffprobe probe → frame/audio heuristics → (gated) named deepfake detectors: EFFORT (ICML 2025 Oral spatial), aasist3 (Spectra-AASIST3), XLSR-Mamba, NYUAD (image, 2nd signal); cross-modal AV consistency (HAVIC); transform-battery robustness (no raw-score averaging) | same |
Deterministic routing end-to-end: input type → MIME/magic bytes → capability set. No LLM-gated decisions anywhere; the LLM (when provisioned) only narrates over already-computed evidence, behind a prompt-injection guard.
All figures below were re-measured on 2026-10-21 by re-scoring the raw
media with the real checkpoints (not by replaying stored feature vectors).
Raw records are in docs/research/.
Per-detector accuracy, on the corpora named:
| Channel | Detector | Corpus | n | AUC | EER |
|---|---|---|---|---|---|
| Audio | aasist3 (Spectra-AASIST3) | official ASVspoof2019-LA eval | 300 | 1.0000 | 0.00% |
| Audio | xlsr-mamba | official ASVspoof2019-LA eval | 300 | 1.0000 | 0.00% |
| Image | SPAI (spectral) | raw image corpus (30 AI / 30 real) | 60 | 0.7433 | 26.67% |
| Video | EFFORT (FF++-trained, face crop) | leak-free corpus (30 fake / 30 real) | 60 | 0.7789 | 50.00% |
Posterior medians: aasist3 bonafide 0.0000 / spoof 0.9831 (perfectly separated, 0/150 misclassified at 0.5) · SPAI real 0.0015 / AI 0.6790 · EFFORT real 0.4619 / fake 0.6881.
EFFORT was being fed the wrong pixels. It is a CLIP ViT-L/14, trained on
RGB, but the serving path handed it BGR (cv2.imread) with no conversion —
and then normalised it with ImageNet statistics instead of CLIP's. Every
sibling arch (spai.py, nyuad.py, effnet_image.py) does its own
BGR→RGB conversion; EFFORT was the only one relying on the shared
preprocess, which is why the omission was invisible. Fixing the input is
worth +0.070 AUC (0.6511 → 0.7189) and it also fixed the operator's
silent real video, which used to read MEDIUM and now reads LOW.
EFFORT is a face-forensics model and was being fed whole frames. The upstream paper applies it to face regions, and the cross-modal capability already loads a YuNet face detector for the HAVIC first-face crop. Cropping to the detected face (with a whole-frame fallback, because a clip may have no detectable face) is worth a further +0.060 AUC (0.7189 → 0.7789) and lifts the operator's AI videos from 0.32–0.37 to 0.54–0.65 on effort.
The fusion offset was re-derived twice to match (‑0.55 → ‑0.30 → ‑0.40),
because the old offset was fitted to the broken score distribution. See
docs/research/ and the measurement notes.
The cross-modal gates were originally weighted on the 87-clip calibration
corpus, which has no audio stream on 83 of its 87 rows — so AV-sync
(weight 3.5) and HAVIC (weight 0.8) were a known_gap on 95% of the set the
weights were tuned on, and their weights were never actually tested.
The first cross-modal corpus built to fill that gap (rows_crossmodal_v1)
muxed bonafide audio onto real clips and spoof audio onto fake clips —
so the audio label was a deterministic function of the video label. That
inflated the cross-modal gates by a large margin. A leak-free rebuild
(tools/build_crossmodal_corpus_v2.py, which distributes the same audio pool
across both classes and asserts the marginals match) measured the truth:
| Signal | leaked corpus AUC | leak-free AUC | verdict |
|---|---|---|---|
| effort (video-only) | 0.6328 | 0.6511 | the only video signal with real separation |
| frameheur (video-only) | 0.5233 | 0.5350 | noise |
| av_risk | 0.6422 | 0.4744 | below chance once the leak is gone |
| havic | 0.9461 | 0.6272 | the 0.9461 was label leakage |
Fixes applied (2026-10-21):
frameheur.prob's affine calibration was amplifying a noise signal 1.97x, so it contributed up to 0.76 while the strongest real signal (EFFORT) contributed at most 0.43 — the noise outweighed the evidence. Slope is now 1.0, with no invented amplification.av_risk_addition's weight dropped 3.5 → 1.5, and a corroboration gate now discounts an elevated cross-modal reading when no video-side signal agrees with it.⚠️ 2026-10-22: the gate used to treat the two elevated bands differently — the decorrelated band (0.45) was discounted but the anti-correlated band (0.5) was TRUSTED as "the genuine deepfake signature". That was wrong. The 0.5 readings were the correlation function's UNDEFINED sentinel (-2.0), i.e. clips with a silent audio track being reported as "the modalities were generated independently". Measured on the operator's real WhatsApp traffic, 12% of genuine video has the same all-zero audio track — so the band could not discriminate. Both bands are now treated identically: an elevated cross-modal reading may corroborate a video-side signal, never carry the verdict on its own.- The
disagreementmetric compared calibrated values across two differently-scaled detectors, so two detectors that agreed produced a spread of 0.39 and tripped the abort on genuine clips. With the calibrations now on comparable scales the calibrated spread is meaningful again.
Measured outcome on the leak-free corpus (60 rows, production code
paths): 0 genuine clips read DO_NOT_USE (was 2), 0 fakes read TRUST.
do_not_use
end-to-end" IS NO LONGER TRUE. It was true, but it was carried by the
cross-modal reading av=0.5 — which was the silence artefact described
above. With the honest reading the 3 AI videos read MEDIUM/caution, which
is the truthful verdict: measured on the operator's real traffic, no rule
over the current signals reaches do_not_use for them without also flagging
genuine media. The 4 genuine clips still read LOW/trust.
The honest limit: this corpus cannot yield both zero false positives and
full detection. real_006 and fake_006 differ by 0.003 in calibrated
EFFORT, so no threshold on the video-side signals separates them. The gate
therefore keeps 3 of 30 fakes flagged and misses the rest to CAUTION. Treat
the video channel as single-signal plus a cross-modal corroborator, not
as a detector with demonstrated separation.
Where each detector still falls short — stated because a security tool that only publishes its wins is not telling you the truth:
- SPAI misses 8 of 30 AI images outright (scores below 0.05): 3 flux, 2 midjourney v6.1, 2 stable-diffusion-3, 1 gigagan. It also false-highs 6 of 30 real photos. This is why a second, independent image signal (NYUAD, 3-class ViT) corroborates SPAI rather than trusting it alone. Measured: AI images reading clean drop from 11/30 to 3/30 with NYUAD, because the two detectors miss different images.
freqbandis not "dead noise" — it is INVERTED. The old comment claimed it was uninformative; measured on the raw 60-image corpus it is anti-correlated: real photos score higher than AI images (median 0.4625 vs 0.3990, AUC(fake=high) 0.1222). Its positive weight therefore pushes genuine photos toward more risk. It is also inert: flipping the sign, flipping it via calibration, and removing it outright all give identical verdicts on all 60 images (0 rows changed), because the image channel caps at CAUTION and the clean-side guard never fires (max freqband on any image is 0.5150, below its 0.60 threshold). It is left in place deliberately, but it is a landmine: a "tighter" 0.45 threshold would block 21 of 30 genuine photos and 1 of 30 AI images — the wrong way round.- EFFORT still barely separates (AUC 0.7789 after the input fix and the face crop). The 87-clip calibration corpus behind the older operating point has no audio stream on 83 of its 87 rows, so the two cross-modal gates (AV-sync, weight 3.5; HAVIC, weight 0.8) were never actually measured on 95% of the set the weights were tuned on. The older "42/42 reals not flagged" figure is true only because the clean-side floor is set to avoid false positives — it is not evidence that EFFORT separates. Treat the video channel as single-signal plus a cross-modal corroborator. EFFORT is a face-forensics model and is weak on full-scene AI video; the upstream paper applies it to face regions, which this pipeline does not do.
- With the corrected input, effort alone still cannot separate the
operator's own AI videos from some genuine clips — the AI videos score
0.54–0.65 on the face crop while some genuine clips score higher. The
cross-modal signal is what flagged them — via
av=0.5— and that reading was the silence artefact described above, so the AI videos now read MEDIUM/caution rather thando_not_use. On the leak-free corpus 11 of 30 fakes still read TRUST, because a muxed audio track decorrelates rather than anti-correlates and never reaches the anti-correlated band. A corpus with naturally synchronised audio is the missing instrument — and it is now the operator's own WhatsApp traffic, which is what exposed the silence artefact in the first place. The remaining gap for full-scene AI video is a missing checkpoint (a general-AIGC video detector; thegenimageEFFORT variant is image-domain and measured inverted on these frames), not a threshold. - The face crop also raises SOME genuine clips (a low-quality phone face can read as synthetic: ffc23 real 451 went 0.421 → 0.775 on the whole frame vs the crop). That is a real false-positive cost, and it is why the offset was re-derived a second time.
Test suite: 605 passed / 1 skipped on a developer machine (the service
venv: Python 3.11, torch 2.14.1+cu130, all learned detectors installed, and
the legacy pylibs tree present). In CI — a clean checkout with only the
declared dependencies — the same suite measures 506 passed / 31 skipped
(bare, no torch) and 582 passed / 21 skipped (with torch). The bare run
skips the six torch-gated architecture modules wholesale, so a bare run is
not evidence that the detectors work; CI asserts both counts separately
with environment-specific floors (495 bare / 575 full).
Run the suite with scripts/run_tests_split.sh on a memory-constrained
host. The full suite in ONE process is OOM-killed by the cgroup on a 25 GB
machine (each torch checkpoint costs ~4 GB RSS; the kill appears as exit -9
with an empty pytest output and dmesg reports "Memory cgroup out of
memory"). The script splits the run into two processes that between them
cover every test file (54 + 7 = 61, no gap) and prints the usual pytest
output for each.
Two CI gates run on every push, and a third checks the shipping invariants
(the aasist3 helper path exists, shell scripts are LF, the launcher keeps
pylibs off the PYTHONPATH, no secret file is tracked). master is
branch-protected: a direct push is rejected, and a PR must pass all three
checks to merge.
A learned fusion stack trained on out-of-fold predictions scored OOF AUC 0.512 against a 0.502 heuristic baseline, so it is not wired into the serving path. A gradient-boosted alternative was rejected at OOF AUC 0.499 at n=84 — chance.
The audio detector was vendored, loaded cleanly (1022/1022 checkpoint keys) and rejected on a 240-clip validation slice at AUC 0.54 against a bar of AUC ≥ 0.85 fixed before the run. That result was later superseded: the same checkpoint was re-measured on the official ASVspoof2019-LA evaluation set at AUC 0.9967 / EER 0.70% (vendor) and re-measured here at AUC 1.0000 / EER 0.00% on 300 official-eval samples. The detector is now wired in as the audio channel, with clean audio reading LOW and a spoof reading HIGH. The 0.54 figure was a bad validation slice, not a bad model — but it was a real measurement at the time, and the rejection was honest given the evidence available then.
Image verdicts deliberately cap at CAUTION: a single learned-model read never escalates a photo to DO_NOT_USE, because that detector false-highs 6/30 real images on our own check set.
cd /home/hermes/vishwas
export PYTHONPATH=$PWD/src
# Use the launcher, NOT a bare `python3` — it selects the interpreter that
# can actually load the learned detectors (see "Runtime" below). Calling
# `python3 -m vishwas.app` directly works for the cheap tiers, but the
# torch-gated detectors silently degrade to "unavailable" and the audio and
# image channels fail OPEN.
#
# CLI — simulate a WhatsApp user locally
scripts/run_vishwas.sh cli --text "https://bank-secure-login.example.net"
scripts/run_vishwas.sh cli --file ./path/to/file.apk
# Webhook server (pair with OpenWA below)
OPENWA_BASE_URL=http://localhost:2785 OPENWA_SESSION_ID=main \
OPENWA_API_KEY=<key> OPENWA_WEBHOOK_SECRET=<secret> \
scripts/run_vishwas.sh webhook --port 2790
# Full test suite (offline, no network needed): 471 tests, hermetic
python3 -m pytest tests/ -qThe learned detectors need a specific interpreter, and using the wrong one degrades the platform silently:
- The wav2vec2 frontend behind the audio gate is a 1.27 GB
pytorch_model.binpickle, andtransformers≥ 4.57 refusestorch.loadontorch< 2.6 (CVE-2025-32434). The systemtorchis 2.4.1, so the audio gates cannot load under it at all. pylibs(the legacy local dependency tree) must not be onPYTHONPATH: itscryptographyv50 no longer ships thelibsubmodule that pyOpenSSL imports, so Python resolveslibto a top-level namespace package and the wholeaccelerate/transformersimport chain dies.
scripts/run_vishwas.sh handles both: it execs the dedicated venv
(/home/hermes/.venv-ambient) and keeps PYTHONPATH to the repo's own
src/. If the venv is missing the launcher refuses to start, printing
the rebuild command, rather than starting a verifier whose detectors are all
dead.
Rebuild it with:
uv venv --python 3.11 /home/hermes/.venv-ambient
VIRTUAL_ENV=/home/hermes/.venv-ambient uv pip install --python $VIRTUAL_ENV/bin/python \
-e ".[learned,binary]"Runtime dependencies are pinned in pyproject.toml.
See docs/ARCHITECTURE.md for the full diagram.
- channels.py — OpenWA v0.21.0 client (REST
/api/sessions/{id}/..., port 2785, SQLite-backed) + inbound webhook withX-OpenWA-SignatureHMAC verification and idempotency dedupe. - router.py + file_validator.py — deterministic classification: extension hypothesis, then magic bytes as ground truth (
ext_mismatchflagged when they disagree). - capabilities/ — seven specialist modules (url_phishing, malware_file, gov_document, deepfake_video, deepfake_audio, cross_modal, image_facecheck). Each declares its dependency
requires=tuple and emitsCheckResult(name, cost, status, signals, notes)— evidence only, never verdicts. - fusion.py — per-target logistic regression stacking (LR stack if a trained checkpoint exists under
VISHWAS_FUSION_DIR; documented explicit weights otherwise), temperature calibration, disagreement metric, and a reliability gate that can forceunable_to_verifywhen evidence quality is insufficient. - report.py + i18n.py — plain-language replies with confidence bands; 7 languages (en hi ta te ml kn bn). The reply language is the user's saved choice (set once, persisted) — it is never auto-detected from message content (operator decision).
- quarantine.py — every job gets an isolated dir under
VISHWAS_QUARANTINE; original + every derived artifact are purged on completion and on failure, with a stale-sweep TTL (VISHWAS_STALE_TTL_S). Zero retention is enforced, not optional. - orchestrator.py — wall-clock budget (default 300s via
VISHWAS_BUDGET_S), 10s stage floor, conservative short-circuit after confirmed positives (P8), per-stage timing records, crash isolation per stage.
| Env var | Default | Purpose |
|---|---|---|
OPENWA_BASE_URL |
http://localhost:2785 |
OpenWA REST base |
OPENWA_SESSION_ID |
main |
Which session handles traffic |
OPENWA_API_KEY |
— | Bearer auth for OpenWA REST calls |
OPENWA_WEBHOOK_SECRET |
— | Expected HMAC for inbound webhooks; empty = unsigned accepted (documented operator choice) |
VISHWAS_WEBHOOK_HOST/PORT |
127.0.0.1 / 8899 | Inbound listener bind (the systemd unit uses 2790) |
VISHWAS_VT_API_KEY |
— | Enables VirusTotal (files + URLs) |
VISHWAS_LLM_BASE_URL / _API_KEY / _MODEL (or OPENAI_API_KEY) |
— | Optional LLM narration layer |
VISHWAS_FFMPEG_BIN/FFPROBE_BIN/THREADS |
ffmpeg / ffprobe / 2 | Media processing; thread cap is thermal-safe (see docs/PERFORMANCE.md) |
VISHWAS_CLAMSCAN_BIN / CLAMD_DB |
clamscan / /var/lib/clamav |
Local AV |
VISHWAS_YARA_RULES |
— | YARA rule pack path |
VISHWAS_TESSERACT_BIN / DOCLING / GPG_BIN |
tesseract / docling / gpg | OCR / document parsing / signature verification |
VISHWAS_QUARK_PY / MOBSF_CLI / CAPE_CMD |
— | Quark-Engine wrapper, MoBFS CLI, dynamic sandbox command |
VISHWAS_<MODEL>_WEIGHTS (EFFORT, AASIST, XLSRMAMBA, SSL_AUDIO, HAVIC, IMAGE_FACE, NYUAD) |
— | Path to a downloaded model weight set; enables that detector (canonical sources: docs/research/MODEL_WEIGHTS_MANIFEST.json) |
VISHWAS_FUSION_DIR |
— | Trained LR-stacking checkpoints (from fusion_train.py) |
VISHWAS_RAG_CACHE / _VERSION |
— | Document-template retrieval cache (NOT a source of truth) |
VISHWAS_QUARANTINE / STALE_TTL_S / WORKDIR / AUDIT_LOG / LOG_LEVEL / BUDGET_S |
see app.py |
storage paths, sweep TTL, audit trail, hard time budget |
Real-world wiring with OpenWA (docker compose, webhook registration, secret management, health checks) is in docs/DEPLOYMENT.md. Non-technical behaviour guide for users and operators is in docs/USER_GUIDE.md; adversary-facing design rationale is in docs/THREAT_MODEL.md; the honest capability inventory and how to enable every gated feature is in docs/GAPS_AND_ENABLEMENT.md.
More writing on this stack: https://dev.to/dawnofgenx
