This file provides guidance for AI assistants (Claude Code and similar tools) working in this repository.
Project Overview
Vigil is an open-source, AI-native Security Operations Center (SOC) platform. It orchestrates 13 specialized AI agents via Claude to perform triage, investigation, threat hunting, forensics, and automated response across 40 security integrations.
Core pillars:
- Agents — 13 agents defined in
AGENT_CONFIGS, which is the authoritative list: triage, investigator, threat_hunter, correlator, responder, reporter, mitre_analyst, forensics, threat_intel, compliance, malware_analyst, network_analyst, auto_responder. (The README says “12” — it omitsauto_responder.) - Workflows — Multi-agent orchestrated playbooks (Incident Response, Full
Investigation, Threat Hunt, Forensic Analysis, Cloud Incident). Four are
compose playbooks and walk their
phases:in order.threat-huntdeclaresrun_kind: huntand runs the hypothesis loop instead — a Hunt Lead picks each move from what the evidence did to each belief, so itsphases:block is a dispatch roster rather than an order. - Integrations — 40 MCP servers in
mcp-config.json(Splunk, CrowdStrike, VirusTotal, Shodan, Timesketch, Jira, Slack, etc.). Count only dict-valued keys: themcpServersobject also holds 7_comment_*string keys used as section separators.
Ports:
- Backend API:
http://localhost:6987 - Frontend:
http://localhost:6988 - PostgreSQL:
5432 - Redis:
6379
Repository Structure
vigil/
├── services/ # Deployables only — exactly api, daemon, worker
│ ├── api/ # API composition root: main.py (app entry), discovery.py, middleware/, routers/ (parked routers)
│ ├── daemon/ # Autonomous 24/7 SOC background process
│ │ ├── main.py # Daemon entry point (python services/daemon/main.py)
│ │ ├── orchestrator.py # Main autonomous agent orchestrator
│ │ ├── agent_runner.py # Executes agents with cost/resource guardrails
│ │ ├── poller.py # Fetches alerts from SIEM/EDR
│ │ ├── processor.py # Processes findings through AI pipeline
│ │ ├── responder.py # Executes containment actions
│ │ └── scheduler.py # Cron-style scheduled tasks
│ └── worker/ # ARQ llm-worker, drains the arq:llm queue, started directly by
│ # start.sh/compose/Helm (python -m services.worker), never supervised
├── clients/web/ # React + TypeScript + Vite SPA
│ └── src/
│ ├── redesign/ # The SOC console — screens/, shell/, shared/
│ ├── components/ # Cross-console components (auth, setup)
│ ├── services/ # Axios API client services
│ └── contexts/ # React Context (auth, theme)
├── tools/mcp/ # The MCP servers that talk to Vigil's own services
├── core/ # Shared library: capability domains + a storage/platform tier; API routers colocate at core/<domain>/*_router.py
│ ├── llm/ # The LLM layer: router/, harness/, providers/, cost/ — see core/llm/README.md
│ └── workflows/definitions/ # Workflow definitions as WORKFLOW.md files (incident-response, full-investigation, threat-hunt, forensic-analysis, cloud-incident)
├── data/ # Schemas, MITRE taxonomy, detection registry
├── tests/ # pytest + vitest test suites
├── docs/ # Detailed documentation
├── infra/ # Deploy machinery (was docker/ + helm/ + database/init/)
│ ├── docker/ # docker-compose.yml + Dockerfiles
│ ├── helm/ # Helm chart (vigil/)
│ └── database/init/ # PostgreSQL init SQL (docker-compose: lex order by filename; Helm: values.yaml dbInit.sqlFiles)
├── scripts/ # Init and utility shell scripts
├── mcp-config.json # 40 MCP server definitions (+ `_comment_*` separator keys)
└── env.example # Template for all 220+ environment variables
Layering:
core/is a library and must never importservices/, and the shared-infrastructure tier (core/storage,core/platform) must never import a capability domain..importlinterenforces both on every PR —lint-importsgates, alongsideflake8,blackandisort. Onlymypyand the comment-style check are advisory. Run it locally withlint-imports.The only sanctioned
sys.pathentry is the repo root, added byservices/api/main.pyandservices/daemon/main.pysocore.*andservices.*resolve however the process was launched. Never addservices/itself: that makesdaemon/api/workerimportable as bare top-level names, giving the same file two module identities, and import-linter cannot see those edges.
Development Setup
Quick Start
git clone --recurse-submodules https://github.com/Vigil-SOC/vigil.git
cd vigil
./start.sh # Starts PostgreSQL (Docker), backend, and frontend
Manual Start
# 1. Start infrastructure
cd infra/docker && docker compose up -d postgres redis
# 2. Backend (from repo root)
source venv/bin/activate
uvicorn services.api.main:app --host 0.0.0.0 --port 6987 --reload
# 3. Frontend
cd clients/web && npm run dev
# 4. (Optional) Daemon
./start.sh --daemon
Desktop (Electron)
cd desktop && npm run dev # builds TS, launches Electron
cd desktop && npm run dist # packages a .dmg via electron-builder
The desktop app drives the stack through scripts/app_up.sh / app_down.sh
rather than start.sh: no --reload, no Vite (the built SPA is served by the
backend at :6987), and it forces DEV_MODE=false because its login and
first-run bootstrap are the point.
Fresh Environment
./setup_dev.sh # Creates venv, installs all Python + npm deps
The Python toolchain
Vigil provisions its interpreter rather than discovering one. .python-version
pins the version (3.12) and scripts/lib.sh uses uv to download a standalone
CPython matching it and the host’s architecture — so conda, pyenv, or a system
Python on PATH cannot influence what the venv is built from. There is no
find_python; do not reintroduce PATH scanning.
Two details are load-bearing and easy to undo by accident:
--python-preference only-managedis passed as a flag touv venv. Without it uv reuses any same-version interpreter onPATH; as an env var it loses to a user who hasUV_PYTHON_PREFERENCEset.- A
uvfound onPATHis used only when its own target triple matchesuname -m. An x86_64 uv provisions x86_64 interpreters even underonly-managed.
The venv is disposable: one that doesn’t match the pin is deleted and rebuilt
(~2s warm) rather than repaired. install_python_deps is fatal on failure and
ends by importing what the services need, so breakage reports at its cause.
Dependencies install from requirements.lock (fully pinned, --universal
so one file covers macOS/Linux and arm64/x86_64). requirements.txt remains the
list of direct dependencies with acceptable ranges. After changing it, run
./scripts/update_lock.sh and commit both. CI and both Docker images install
from the lock too.
Prerequisites
- Python 3.12 — provisioned automatically by
uv(see.python-version); no host Python needed - Node.js 18+
- Docker Desktop (must be running — used for PostgreSQL and Redis)
- Git with submodule support
Environment Variables
Copy env.example to .env and populate as needed. .env is for
bootstrap-only settings (DB URL, ports, dev flags). LLM provider keys,
integration credentials, and other secrets are configured in the web UI
(Settings → AI / LLM Providers, Settings → Integrations) and stored
encrypted at ~/.vigil/secrets.enc — see docs/STATE.md.
| Variable | Purpose | Default |
|---|---|---|
DEV_MODE |
Bypass all authentication | false when unset; env.example ships true |
DATABASE_URL |
PostgreSQL connection | auto-set by docker-compose |
REDIS_URL |
ARQ job queue | redis://localhost:6379/0 |
BIFROST_URL |
LLM gateway address | http://bifrost:8080 |
SECRETS_BACKEND |
Where new secrets are written | encrypted |
⚠️ Do not put
ANTHROPIC_API_KEY,OPENAI_API_KEY, Splunk credentials, etc. in.env. Use the UI. Placeholder values in.envare ignored when the encrypted store has a value.
Default dev login: admin / admin123 (when DEV_MODE=false)
Reading configuration
Config flows through exactly three channels. Do not add os.getenv calls —
tests/unit/_ratchets/test_no_ambient_state.py fails CI on them.
| Need | Use | Owner |
|---|---|---|
| Non-secret setting | core.config.get_settings().field |
env / .env |
| Credential | core.secrets.get_secret("NAME") |
~/.vigil/secrets.enc, then env |
| UI-editable at runtime | core.platform.runtime_config / core.storage.config_service |
system_config table |
core/config.py is the single definition site: every setting is a typed field
with its default, and tests/unit/_ratchets/test_settings_env_example.py fails if a field
is missing from env.example or vice versa. get_settings() is lru_cached, so
a test that changes env mid-test must call get_settings.cache_clear().
Config file paths go through core.config.vigil_path(), which reads from
~/.vigil with a fallback to the legacy ~/.deeptempo copy and always writes to
~/.vigil. Pass write=True on save paths.
The two legitimate exceptions, both marked # noqa: ENV001: exporting env into
spawned MCP child processes (env is their config protocol, so third-party servers
need no adaptation), and genuinely dynamic variable names.
Running Tests
Python (pytest)
The pytest config lives at tests/pytest.ini — there is no root-level
pytest.ini and no [tool:pytest] in setup.cfg. A bare pytest from the repo
root therefore finds no ini file: markers go unregistered, --asyncio-mode=auto
and the coverage flags are skipped, and collection starts from the CWD instead of
testpaths = tests. Always scope to tests/ (or pass -c tests/pytest.ini).
# All tests, with the intended config
pytest tests/
# By marker
pytest tests/ -m unit
pytest tests/ -m integration # requires running PostgreSQL
pytest tests/ -m "not external_service" # what CI's main unit job runs
# Specific file
pytest tests/unit/test_backend_tools.py -v
Available markers: unit, integration, slow, auth, siem, claude,
database, api, daemon, performance, external_service.
--strict-markers is on, so an unregistered marker is an error — add new ones to
tests/pytest.ini.
Frontend (vitest)
cd clients/web
npm run test # watch mode
npm run test:run # single run with verbose output
npm run test:coverage # with coverage report
npm run test:ci # CI mode (JSON output)
Linting
flake8, black and isort gate CI over services/ and core/. Pass no
arguments: setup.cfg is the only config, and it is the same file pre-commit
reads, so formatting locally cannot produce a tree CI rejects. mypy stays
advisory.
./setup_dev.sh installs the pinned toolchain and the pre-commit hook. To add it
to an existing venv:
pip install -r requirements-dev.txt && pre-commit install
# Python — the three that gate, scoped as CI scopes them
black services/ core/
isort services/ core/
flake8 services/ core/
mypy services/ core/ --ignore-missing-imports # advisory
# TypeScript
cd clients/web && npm run lint
# All three at once, on what you changed
pre-commit run --all-files
tests/, tools/ and scripts/ are not gated and have not been cleaned;
pre-commit is scoped to match, so a one-line change there does not arrive
carrying hundreds. Bumping a pin means committing the reformatting it causes,
in requirements-dev.txt and .pre-commit-config.yaml together.
Key Architecture Patterns
Async-First Backend
All FastAPI endpoints and service methods use async/await. Long-running LLM operations go through the ARQ Redis queue (worker pattern). Never add blocking I/O to endpoint handlers.
The DB layer is synchronous SQLAlchemy — there is no AsyncSession in this
repo. database/connection.py exposes a sessionmaker and a get_db()
dependency yielding a plain Session. So don’t type a dependency as
AsyncSession or await a session call. Handlers that are fully synchronous can
be plain def (FastAPI runs those in a threadpool); handlers that must stay
async should push sync DB calls through asyncio.to_thread rather than
blocking the loop.
LLM Traffic Routes Through Bifrost
All LLM calls — including Anthropic — go through the Bifrost gateway
(BIFROST_URL, default http://bifrost:8080), which layers on caching,
centralized cost tracking, and budget enforcement.
- Never instantiate
Anthropic()directly. Importcreate_anthropic_client/create_async_anthropic_clientfromservices/llm_clients.py— the single source of truth for client construction. The one exception is key-validation endpoints that must hit the real upstream to verify a user-supplied credential. services/llm_router.pydispatches and translates Bifrost’s budget/rate-limit responses (HTTP 402/429) intoservices.budget_service.BudgetExceeded.services/model_registry.pyresolves component→provider+model assignments and owns the pricing/capability catalog.- Provider API keys are not in
.env— they live in the encrypted secrets store and are configured via the UI.
Service Layer
Business logic lives in services/, not in API route handlers. A router lives with its domain as core/<domain>/<name>_router.py (or, until that domain is in core/, parked in services/api/routers/) and delegates to service classes. When adding a feature:
- Add logic to an existing service or create
services/your_feature_service.py - Add the router module (a
routerand aROUTER_META) undercore/<domain>/orservices/api/routers/ - Nothing to register —
services/api/discovery.pyscans both locations and mounts it at startup (issues #478, #488)
MCP Tool Access
Agents access external tools through the MCP protocol. Tool definitions live in mcp-config.json, which spawns each in-repo server as its own python3 subprocess. A vendor’s server lives in that vendor’s slice as core/integrations/<vendor>/tool.py (see core/integrations/README.md for the inventory and the outbound-HTTP conventions); tools/mcp/ holds the servers that talk to Vigil’s own services; the rest of the 40 entries are external servers. services/mcp_service.py coordinates tool access.
Database
- PostgreSQL 16 via SQLAlchemy ORM — models in
core/storage/models.py, sessions and theget_dbdependency incore/storage/connection.py - Schema initialized by
infra/database/init/SQL files. Execution order differs by deploy path: docker-compose mounts the directory at/docker-entrypoint-initdb.d, where Postgres runs files in lexicographic filename order (the01_/04_/…/16_prefixes are authoritative there). The Helm chart, by contrast, iteratesinfra/helm/vigil/values.yaml’sdbInit.sqlFileslist in the order written there — prefixes are decorative for the chart path - pgvector extension for embeddings
- Use
core/storage/database_data_service.pyfor data access — do not query the DB directly from API handlers infra/database/init/is not the whole schema.cases,findings,case_evidence,case_tasks,case_watchersandinvestigationshave noCREATE TABLEthere —create_allat startup is the only thing that builds them, so anALTER TABLEin a numbered init file for one of them hard-fails (the dbInit Job runs before the app ever starts).users/rolesandskills/custom_agents/workflow_runs/custom_workflowsare covered. Grepping for a table name is misleading —casesandfindingsappear in 9–10 init files, but every hit is a string literal, not DDL- Adding a column to one of those ORM-only tables does not reach existing
databases.
create_allischeckfirst=True— it creates missing tables and never alters existing ones.check_schema_drift()incore/storage/connection.pyreports the gap at ERROR and publishes the state underschemain/api/health;DB_STRICT_SCHEMA=truemakes it fatal. Reporting is not fixing: see #562 before relying onscripts/migrate_schema.py, which only covers columns registered there by hand (and #568 — one of its seed migrations still cannot succeed). The check compares column names, so a changed type or a newNOT NULLis drift it cannot see
When adding or modifying an init SQL file under infra/database/init/: the
chart bundles a copy under infra/helm/vigil/files/database-init/ (Helm can
only read files inside the chart directory). You must (1) copy the file
into the chart bundle, (2) add it to infra/helm/vigil/values.yaml
dbInit.sqlFiles in the correct execution order, and (3) verify with
helm template ... | grep -E '^[[:space:]]*apply "NEWFILE\.sql"' that
the dbInit Job script applies it (a bare grep NEWFILE.sql false-matches
the ConfigMap key and the SQL header comment). CI catches step 1
(Helm Chart / Lint and Template runs diff -r between the two
directories). Skipping step 2 is silent (the file is in the ConfigMap
but never applied); skipping step 1 while keeping the file in
dbInit.sqlFiles makes the Job hard-fail at runtime — unless the
filename already has a row in _vigil_schema_versions, in which case
it SKIPs as already-applied (the marker-table check runs before the
file-existence check, so legacy 003_* ghost rows on v0.1.x upgrades
don’t break helm upgrade --reuse-values). See
infra/database/init/README.md, which also lists
two filenames that are reserved and must never be reused.
Authentication
DEV_MODE=truebypasses all auth — use for local development- Unset means
false. The default lives in one place since #520 —dev_mode: bool = Falseincore/config.py— so anywhere without a.env(CI, containers, production) enforces auth.env.exampleshipsDEV_MODE=trueandsetup_dev.shcopies it, which is why a local checkout behaves as thoughtruewere the default. Tests that lean on a developer’s.env— e.g. forJWT_SECRET_KEY, which is required onceDEV_MODEis off — will fail in CI; set what you need explicitly - Production uses JWT tokens via
backend/api/auth.py+backend/middleware/ - RBAC is implemented in
database/init/06_auth_tables.sql
Daemon / Autonomous Mode
The daemon (services/daemon/) runs as a separate process with its own orchestration loop (python services/daemon/main.py). It polls for new alerts, processes them through the AI pipeline, and can execute automated responses. Cost and resource guardrails are enforced by services/daemon/agent_runner.py.
Key config variables: DAEMON_AUTO_TRIAGE, DAEMON_CONFIDENCE_THRESHOLD, ORCHESTRATOR_MAX_COST, ORCHESTRATOR_MAX_HOURLY_COST
Code Conventions
Python
- Formatter: Black, line length 88 (its default; pinned in
requirements-dev.txt) - Linter: Flake8 — gates CI.
E501is off because black owns wrapping, so the only long lines left are strings black cannot split - Imports: isort
- Type hints: mypy (ignore-missing-imports mode)
- Async: prefer
async deffor all service methods and route handlers - File naming:
snake_case.py - Classes:
PascalCase - Constants:
UPPER_SNAKE_CASE
TypeScript / React
- Framework: React 18 + Vite 5 (not CRA)
- UI: Tailwind utility classes + the CSS custom properties in
clients/web/src/redesign/styles.css. Reuse the primitives inredesign/shared/(ui.tsx,formKit.tsx,icons.tsx) — there is no component library, so do not add one - State/data: plain hooks (
useState/useEffect) over the axios services; React Context for auth/theme/toasts - HTTP: axios via
clients/web/src/services/ - Linter: ESLint with
@typescript-eslint/recommended+react-hooks/recommended - Component files:
PascalCase.tsx - Service files:
camelCase.ts - Test files collocated:
Component.test.tsx
Commit Messages
- Required: DCO sign-off (
git commit -s) - Format: short summary line (≤50 chars) + optional body (wrap at 72)
- One logical change per commit
- Example:
git commit -s -m "Add SentinelOne MCP integration"
API Routes
Follow the existing pattern:
# core/<domain>/your_feature_router.py (or services/api/routers/your_feature.py)
from sqlalchemy.orm import Session
from core.storage.connection import get_db
from core.routing import Auth, RouterMeta
router = APIRouter() # prefix/tags live in ROUTER_META, not here
ROUTER_META = RouterMeta(prefix="/api/your-feature", tags=["your-feature"], auth=Auth.REQUIRED)
@router.get("/")
async def list_items(db: Session = Depends(get_db)):
return your_feature_service.list(db) # sync Session — do not await it
No registration step — discovery mounts every module that exports a router and a ROUTER_META.
Adding New Features
New MCP Integration
- Implement the MCP server as
core/integrations/<vendor>/tool.py(ortools/mcp/for one that talks to Vigil’s own services) —core/integrations/README.mdhas the HTTP conventions - Add the server definition to
mcp-config.json - Expose via
services/mcp_service.pyif needed - Document in
docs/INTEGRATIONS.md
New Agent
- Add the agent record in
core/agents/builtins.py(prompt text lives incore/agents/prompts.py) - Wire agent invocation in
core/llm/harness/claude.py - Expose via
services/api/routers/agents.py - Document in
docs/AGENTS.md
New Workflow
- Create
core/workflows/definitions/your-workflow/WORKFLOW.mdfollowing existing format - Define agent sequence, tools, and phase instructions
- Register in workflow service if needed
For a hypothesis-driven hunt instead of a phase chain, declare run_kind: hunt
and state hypotheses (required — a hunt with nothing to test is refused),
attack_techniques and data_domains. playbook_resolver.resolve_hunt() binds
the capabilities services/agent/arch/threathunt.yaml declares to whatever tools
the deployment carries, dropping any it has none for.
New API Endpoint
- Add the router module (with
router+ROUTER_META) undercore/<domain>/orservices/api/routers/ - Add service logic in
services/ - Add Pydantic schema alongside the domain (e.g.
core/skills/schemas.py) if needed - No registration — discovery mounts it automatically
- Add corresponding frontend API call in
clients/web/src/services/
CI/CD
GitHub Actions workflows in .github/workflows/:
| Workflow | Trigger | Jobs |
|---|---|---|
ci-cd.yml |
Push/PR to main, develop | Lint → Unit Tests → Integration Tests → Security Scan → Docker Build |
release-please.yml |
Push to main, manual |
Read Conventional Commits since last tag → open/update a release PR with bumped VERSION / Chart.yaml (appVersion + version, lockstep) / clients/web/package.json / clients/web/package-lock.json + CHANGELOG.md. On merge, push vX.Y.Z tag and create the GitHub Release. See RELEASING.md. |
release.yml |
Version tags (v*.*.*) |
Build & push vigil-backend + vigil-daemon images to GHCR → Trivy scan → smoke-test that they start → annotate the GitHub Release with image digests. Publishes images only — it does not deploy. Does not create the GitHub Release object either (release-please owns that). |
helm-chart.yml |
Push/PR touching helm/ |
Verify database/init/ ↔ chart-bundle copies are in sync (diff -r) → helm lint/template across default, dev, and Bitnami-subchart values → kubeconform → ct lint |
nightly.yml |
Daily 2 AM UTC | Comprehensive security & performance audits |
CI runs:
black --check+flake8+isort --check+mypy(Python)eslint(TypeScript)hadolint(Dockerfiles)pytest --cov(backend unit + integration)vitest run(frontend)bandit+npm audit(security)
All CI checks must pass before merging.
Important Files
| File | Purpose |
|---|---|
services/api/main.py |
FastAPI app entry, middleware wiring, startup/shutdown |
services/api/discovery.py |
Router auto-discovery — scans core/**/*_router.py + services/api/routers/ |
core/routing.py |
Auth + RouterMeta — the declarative mount metadata every router exports |
core/llm/harness/claude.py |
Central AI/agent orchestration (~124KB) |
services/worker/jobs.py |
ARQ llm-worker jobs — the arq:llm queue consumer (python -m services.worker) |
core/agents/ |
Agent records (builtins.py), prompt assembly (prompts.py), runtime manager (manager.py) |
services/mcp_service.py |
MCP protocol coordination |
infra/database/init/ |
Schema SQL — see Database section for the add/modify checklist |
mcp-config.json |
All MCP server definitions |
.python-version |
The pinned interpreter — venv, both Docker images, and CI |
requirements.lock |
Fully pinned dependency tree; what actually gets installed |
requirements-dev.txt |
Pinned lint/type toolchain; the three that gate CI |
setup.cfg |
The only flake8 + isort config — CI and pre-commit both read it |
env.example |
Every supported environment variable |
infra/docker/docker-compose.yml |
Full local stack definition |
docs/AGENTS.md |
Agent reference |
docs/INTEGRATIONS.md |
Integration/MCP reference |
DEV_MODE.md |
Development auth bypass details |
Submodules
This repo uses one Git submodule:
# Initialize after cloning
git submodule update --init --recursive
# Update submodules
git submodule update --remote
| Submodule | Path | Purpose |
|---|---|---|
mempalace |
./mempalace |
Agent memory / knowledge palace |
Installed as an editable package (-e ./mempalace) in requirements.txt. If it
is not initialized, start.sh skips the install gracefully.
deeptempo-core and mcp-servers were submodules until they were dropped: the
former had no production importer in this repo, and the latter’s four servers
are vendored at tools/mcp/, reading Vigil’s own approval service, data service,
DatabaseService and get_integration_config rather than deeptempo_core’s.
mempalace ships its own tests/benchmarks/, which a bare pytest from the
repo root tries to collect and fails on. Scope your runs the way CI does
(pytest tests/unit/, pytest tests/integration/).
Security Notes
- Never commit secrets or API keys — use
.env(gitignored) DEV_MODE=truedisables all auth — never enable in production- Default PostgreSQL password in
docker-compose.ymlmust be changed for production - Bandit runs in CI to catch common Python security issues
- MCP tool calls that perform actions (host isolation, firewall rules) require approval workflow by default — see
core/response/approval_service.py