This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
ProjectAchilles is a purple team platform for continuous security validation. It deploys a custom Go agent to endpoints, executes security tests, and measures detection coverage via Elasticsearch analytics.
Modules:
- Browser: Git-synced test library with MITRE ATT&CK mapping, build/sign/download (Clerk auth)
- Analytics: 30+ Elasticsearch query endpoints — defense scores, heatmaps, treemaps, trends (Clerk auth)
- Agent: Custom Go agent enrollment, heartbeat monitoring, task execution, scheduling (Clerk auth + SQLite)
# Full stack (auto port detection, installs deps)
./scripts/start.sh -k --daemon # Kill existing processes and start fresh
./scripts/start.sh --stop # Stop daemon processes
# Individual services
cd frontend && npm run dev # Vite dev server (port 5173)
cd backend && npm run dev # tsx watch with hot reload (port 3000)
# TypeScript validation
cd frontend && npm run build # tsc -b + vite build
cd backend && npm run build # tsc → dist/# All tests
cd backend && npm test # 912 tests across 40 files (~12s)
cd frontend && npm test # 127 tests across 8 files (~2s)
cd backend-serverless && npm test # 626 tests across 25 files (~11s)
# Single file
cd backend && npx vitest src/services/agent/__tests__/enrollment.service.test.ts
cd frontend && npx vitest src/hooks/__tests__/useAnalyticsFilters.test.ts
# Filter by test name
cd backend && npx vitest -t "creates a token"
# Watch mode / coverage
cd backend && npm run test:watch
cd backend && npm run test:coverageTest file pattern: src/**/__tests__/**/*.test.{ts,tsx}
cd agent && make build-all # Cross-compile Windows/Linux/macOS (amd64 + arm64)
cd agent && make sign-windows # Build + Authenticode sign (osslsigncode)
cd agent && make sign-darwin # Build + ad-hoc sign (rcodesign)
cd agent && go test ./... # Run Go tests
cd agent && go build ./... # Validate compilation/release # Interactive release flow (platform or agent)
/changelog # Generate changelog entries from commits
/pr # Create PR with filled template and pre-checks- React 19 + TypeScript + Vite 7 + Tailwind CSS v4
- Clerk for authentication, Redux Toolkit for state, React Router v7 for routing
- Path alias:
@/→src/
Key directories:
pages/- Module pages (browser/, analytics/, endpoints/, auth/)components/shared/ui/- Base UI primitives (Button, Card, Input)services/api/- API client moduleshooks/- Custom hooks (useAuthenticatedApiinjects JWT automatically)store/- Redux slices; use typed hooksuseAppDispatch/useAppSelector(not rawuseDispatch/useSelector)
- Express + TypeScript (ES modules)
- Clerk for auth (
@clerk/express)
Key directories:
api/- Route handlers (*.routes.ts)services/- Business logic organized by module:agent/- Enrollment, heartbeat, tasks, schedules, update, databaseanalytics/- Elasticsearch queries, client factory, encrypted settingsbrowser/- Git sync, test indexing, metadata extractiontests/- Go cross-compilation (build service), multi-cert management
middleware/- Auth, error handling, rate limiting
- Go 1.24 — lightweight binary with enrollment, heartbeat, task execution, self-update
- Platforms: Windows (amd64), Linux (amd64), macOS (amd64 + arm64)
- Internal packages:
config,enrollment,executor,httpclient,poller,reporter,service,store,sysinfo,updater - Platform-specific files use build tags (
//go:build darwin, etc.) for service management, sysinfo, and binary updates - CGO disabled for static cross-platform binaries
- Version set via LDFLAGS:
-X main.version=$(VERSION) - Service integration: Windows (SCM via
sc.exe), Linux (systemd), macOS (launchd plist at/Library/LaunchDaemons/) - Code signing: Windows (Authenticode via
osslsigncode), macOS (ad-hoc viarcodesign), Linux (none)
- Location:
~/.projectachilles/agents.db(better-sqlite3, WAL mode) - Schema: Created via
CREATE TABLE IF NOT EXISTSinbackend/src/services/agent/database.tswith incremental migrations (column additions, CHECK constraint updates) - Tables:
agents,enrollment_tokens,tasks,agent_versions,schedules - Settings storage:
~/.projectachilles/—analytics.json(AES-256-GCM encrypted),tests.json,certs/
SQLite has no ALTER COLUMN, so changing CHECK constraints requires recreating the table. Follow this pattern to avoid pitfalls:
- Drop leftover temp tables first —
DROP TABLE IF EXISTS <temp>preventsSQLITE_ERRORif a previous migration crashed partway through - Disable FK checks —
database.pragma('foreign_keys = OFF')before the swap. Tables liketasksreferenceagentsvia FK; SQLite refusesDROP TABLEwith FKs on (SQLITE_CONSTRAINT_FOREIGNKEY) - Use
pragma()not string SQL —PRAGMA foreign_keysonly works outside transactions; usedatabase.pragma(...)notdatabase.exec('PRAGMA ...') - Full pattern: FK OFF, DROP IF EXISTS temp, CREATE temp, INSERT SELECT, DROP old, RENAME, recreate indexes, FK ON
| Route | Auth | Purpose |
|---|---|---|
/api/browser/* |
Clerk | Security test browser |
/api/analytics/* |
Clerk | Elasticsearch analytics |
/api/analytics/defender/* |
Clerk | Defender Secure Score, alerts, controls, cross-correlation |
/api/integrations/defender/* |
Clerk | Defender credentials, sync trigger |
/api/agent/admin/* |
Clerk | Agent management (tokens, tasks, schedules) |
/api/agent/* |
Agent key | Device endpoints (enroll, heartbeat, tasks) |
/api/tests/* |
Clerk | Build system, certificates |
/api/integrations/alerts/* |
Clerk | Alert thresholds, Slack/email config |
Backend requires .js extensions in imports (TypeScript compiles to .js):
// Correct
import browserRoutes from './api/browser.routes.js';
// Incorrect - fails at runtime
import browserRoutes from './api/browser.routes';- Strict mode enabled; avoid
any - Use
import typefor type-only imports - Satisfy
noUnusedLocals/noUnusedParameters
Wrap async route handlers with asyncHandler; throw AppError for HTTP errors:
import { asyncHandler, AppError } from '../middleware/error.middleware.js';
router.get('/resource/:id', asyncHandler(async (req, res) => {
const item = await findItem(req.params.id);
if (!item) throw new AppError('Resource not found', 404);
res.json({ success: true, data: item });
}));Error response format: { success: false, error: "message" }
Both cyber-hygiene bundle tests and multi-stage intel-driven tests produce per-control/per-stage results that are fanned out into individual Elasticsearch documents for granular tracking. The same bundle_results.json protocol and backend ingestion pipeline handles both.
Data flow:
- Agent reads
c:\F0\bundle_results.jsonafter test execution, validatesbundle_idmatches task UUID, and includes it in the result payload (agent/internal/executor/executor.go) - Backend detects
bundle_results.controlsin the task result and routes toingestBundleControls()instead of the standard single-document path (backend/src/services/agent/results.service.ts) - Bulk fan-out — each control becomes an independent ES document with its own
exit_code,severity,techniques, andtacticsviaclient.bulk()operations
Additional ES fields for bundle controls:
| Field | Type | Description |
|---|---|---|
f0rtika.bundle_id |
keyword | Bundle test UUID |
f0rtika.bundle_name |
keyword | Bundle human-readable name |
f0rtika.control_id |
keyword | Individual control ID (e.g., CH-DEF-001) |
f0rtika.control_validator |
keyword | Parent validator name |
f0rtika.is_bundle_control |
boolean | true for fan-out bundle control documents |
Each control uses its own exit_code/severity/techniques, so the Defense Score counts each control independently.
Composite test_uuid: Bundle control documents use <bundle-uuid>::<control-id> as the test_uuid (e.g., 7659eeba-f315-440e-9882-4aa015d68b27::CH-IEP-003). The :: separator is unambiguous — UUIDs and control IDs contain only hyphens. Use split('::') to decompose.
Executions table grouping: The frontend Executions table groups bundle controls under collapsible parent rows. The parent row shows the bundle name, a X/Y Protected summary badge, and an item count badge. The badge shows "X controls" for cyber-hygiene bundles and "X stages" for other categories (e.g., intel-driven). Expanding reveals individual sub-rows with per-control/per-stage results. Skipped stages (non-cyber-hygiene bundles with exit code 0) render with a "Skipped" label and are excluded from the Protected/Unprotected count. Standalone (non-bundle) tests render as flat rows unchanged.
Key files:
agent/internal/executor/executor.go— bundle file read and validationagent/internal/executor/types.go—BundleResultsandBundleControlResultGo structsbackend/src/types/agent.ts—BundleResultsandBundleControlResultTS interfacesbackend/src/services/agent/results.service.ts—ingestBundleControls()fan-out logicbackend/src/services/analytics/index-management.service.ts— ES mapping with bundle fields
The bundle results protocol is defined in the f0_library (CLAUDE.md → "Bundle Results Protocol" section).
Multi-binary bundle support:
Some bundles (baseline, identity-endpoint) use a multi-binary architecture where each validator is a separate embedded binary. The orchestrator runs build_all.sh which needs the active signing certificate to sign validator binaries before embedding. The build service passes the cert via environment variables:
F0_SIGN_CERT_PATH— absolute path to the active PFX certificateF0_SIGN_CERT_PASS_FILE— path to a temporary file containing the cert password (cleaned up after build)
These env vars are set automatically in buildService.ts when a build_all.sh is detected and the target platform is Windows. The inner cert password file uses mode 0o600 and is deleted in a finally block.
Three-tier model:
- Clerk (global): All routes use
<RequireAuth>wrapper; JWT injected viauseAuthenticatedApihook - Analytics:
AnalyticsAuthProvidercontext → redirects to/analytics/setupif unconfigured - Agent admin: Clerk JWT required; device endpoints use agent API key (hashed in DB)
Tests use in-memory SQLite via createTestDatabase() from backend/src/__tests__/helpers/db.ts. The vi.mock + dynamic import ordering is critical:
let testDb: Database.Database;
vi.mock('../database.js', () => ({ getDatabase: () => testDb }));
// Import the module AFTER mock setup
const { functionToTest } = await import('../service.js');Frontend tests mock all Clerk hooks globally via frontend/src/__tests__/setup.ts.
- Use
@/alias forfrontend/srcpaths - Group imports: external → internal
Vite proxies /api → http://localhost:$VITE_BACKEND_PORT (default 3000)
Never commit live secrets to docs. Real API keys, tokens, passwords, private keys, OAuth client secrets, webhook URLs, signed URLs, or any other production credential must never appear in any tracked file under docs/, README.md, AGENTS.md, CLAUDE.md, or any other markdown / .txt / config sample. Once a value is committed to git history it must be considered compromised — redacting it after the fact does not retroactively protect it; the credential must be rotated.
Applies equally to: Elasticsearch API keys + Cloud IDs, Clerk sk_* / pk_live_* keys, Defender client_secret, Slack webhook URLs, GitHub PATs, AWS access keys, signing certificate passwords, Turso tokens, Vercel Blob tokens, Render/Fly secrets.
Use placeholders instead — <elasticsearch-api-key>, pk_live_XXX, ${ENV_VAR_NAME}, or [REDACTED]. For runbooks that need to record a credential's value during a transient operation (e.g. rollback values during a migration soak window), store the actual value in a password manager or ~/.projectachilles/ settings file and reference it from the doc by name only — never inline. If a credential leaks despite this, treat it as an incident: rotate immediately, then sanitize the doc.
<type>(<scope>): <description>
Types: feat, fix, docs, style, refactor, perf, test, chore
Scopes: frontend, backend, backend-serverless, agent, analytics, browser, docker, render, vercel, fly, settings, certs, deps, ci, release, wiki
GitHub Actions (.github/workflows/ci.yml) runs on push/PR to main:
test-backend: npm ci → build → testtest-frontend: npm ci → build → test- Node 22
Five deployment targets are supported. The original backend/ and frontend/ are used for all targets except Vercel, which uses a purpose-built serverless fork.
| Target | Backend | DB | File Storage | Builds (Go) | Guide |
|---|---|---|---|---|---|
| Docker Compose | backend/ |
SQLite (volume) | Filesystem (volume) | Yes | Below |
| Railway | backend/ |
SQLite (volume) | Filesystem (volume) | Partial | docs/deployment/RAILWAY.md |
| Render | backend/ |
SQLite (persistent disk) | Filesystem (disk) | Partial | docs/deployment/RENDER.md |
| Fly.io | backend/ |
SQLite (volume) | Filesystem (volume) | Yes | docs/deployment/FLY.md |
| Vercel | backend-serverless/ |
Turso (@libsql) | Vercel Blob | No | docs/deployment/VERCEL.md |
docker compose up -d # Backend + frontend
docker compose --profile elasticsearch up -d # Include ES + synthetic seed dataThe elasticsearch profile starts ES 8.17 (single-node, security disabled) and seeds 1000 synthetic test results.
Uses the existing Dockerfiles with Render's persistent disk for SQLite and settings. Deploy via Blueprint (render.yaml) or manual setup. See docs/deployment/RENDER.md for full walkthrough.
# Blueprint deploy: push render.yaml then connect repo at render.com/deploy
# Key env vars: CLERK_*, ENCRYPTION_SECRET, CORS_ORIGIN, AGENT_SERVER_URL, ELASTICSEARCH_*
# Persistent disk: /root/.projectachilles (1 GB)
# Port: 10000 (Render default for Docker services)Uses the existing Dockerfiles with Fly.io Machines and a persistent volume for SQLite and settings. Deploy via flyctl CLI. See docs/deployment/FLY.md for full walkthrough.
# Create apps + volume, set secrets, deploy
# Key env vars: CLERK_*, ENCRYPTION_SECRET, CORS_ORIGIN, AGENT_SERVER_URL, ELASTICSEARCH_*
# Persistent volume: /root/.projectachilles (1 GB)
# Backend: shared-2x 512 MB, Frontend: shared-1x 256 MB
# Cost: ~$8/mo (cheapest always-on option)Uses backend-serverless/ — a fork of the backend adapted for serverless. Replaces SQLite with Turso, filesystem with Vercel Blob, and signing keys with env vars. Features not available on serverless (Go builds, cert generation, git sync) return 503. See docs/deployment/VERCEL.md for full walkthrough.
# Two Vercel projects: backend (backend-serverless/), frontend (frontend/)
# Key env vars: CLERK_*, TURSO_*, BLOB_READ_WRITE_TOKEN, ENCRYPTION_SECRET
# Cron routes: /api/cron/schedules, /api/cron/auto-rotation
# Capabilities endpoint: GET /api/capabilities (feature flags for frontend)
cd backend-serverless && npm test # 552 tests across 23 filesA separate directory — not a build target of backend/. Key differences from the original backend:
| Component | backend/ |
backend-serverless/ |
|---|---|---|
| Database | better-sqlite3 (sync) |
@libsql/client (async, Turso) |
| DB helper | getDatabase() returns sync Database |
getDb() returns async DbHelper |
| Storage | fs (filesystem) |
@vercel/blob (via storage.ts) |
| Signing | Filesystem keypair | SIGNING_PRIVATE_KEY_B64 / SIGNING_PUBLIC_KEY_B64 env vars |
| Entry point | server.ts (Express listen) |
app.ts (Express export) + api/index.ts |
| Scheduling | setInterval in process |
Vercel Crons → cron.routes.ts |
| Test library | Runtime git sync | Build-time clone (vercel-build script) |
| Build system | Go cross-compilation | Stubbed (returns 503) |
| Cert generation | OpenSSL CLI | node-forge (pure JS, no native deps) |
When modifying backend/, changes do not propagate to backend-serverless/ — they are independent codebases. If a change affects shared logic (types, API contracts, ES mappings), update both.
Two browser tools are available for visual verification. Prefer the Claude Code Chrome Extension (uses real browser with auth sessions); fall back to Playwright (mcp__plugin_playwright_playwright__*) for headless screenshots, drag-and-drop, file uploads, browser_wait_for, or programmatic JS execution.
- Start dev server:
./scripts/start.sh -k --daemon - Navigate to
http://localhost:5173 - When encountering Clerk login, ask the user for credentials — never guess
- For Analytics setup, read Elasticsearch credentials from
backend/.env
When writing custom content renderers for Recharts components (Treemap, etc.), always set stroke="none" on <text> elements. Recharts sets stroke="var(--background)" on the parent SVG container for cell borders, and SVG stroke is an inherited property — it cascades to all children including text. In dark mode --background is near-black, so text renders with a visible dark outline around every glyph. In light mode the stroke is white-on-white (invisible), making the bug theme-specific and easy to miss.
- Multi-cert storage:
~/.projectachilles/certs/cert-<timestamp>/(max 5) - Active cert tracked in
active-cert.txt - Legacy flat files auto-migrate to subdirectory on first
listCertificates()call - Build service reads active cert dynamically via
settingsService.getActiveCertPfxPath() - Windows signing:
osslsigncodewith PFX certificate (password via temp file, not CLI arg) - macOS signing:
rcodesign sign --code-signature-flags adhoc(in-place, no certificate needed) - Linux: No code signing
- Both agent builds (
agentBuild.service.ts) and test builds (buildService.ts) follow the same signing logic - Signing failures are non-fatal — builds continue unsigned
EmbedDependency has a sourceBuilt: boolean flag distinguishing binaries compiled from Go source by build_all.sh from external pre-compiled binaries. Detection uses four heuristics in isSourceBuiltBinary() (buildService.ts):
- Direct match —
foo.exe→foo.goexists - Hyphen-to-underscore —
validator-defender.exe→validator_defender.goexists - UUID-prefix stage —
<uuid>-T1486.exe→ strip UUID, checkstage-T1486.goor prefix match (stage1-defense-evasion.go) - Fallback — parse
build_all.shfor literalgo build -o <filename>
Only external (non-source-built) missing deps block the Build button and show Upload. Source-built deps show a wrench icon + "Auto-built" label. saveUploadedFile() rejects uploads for source-built deps.
Pulls Secure Score, alerts (v2), and control profiles from Microsoft Graph API. Conditionally shown in Analytics dashboard when configured.
- Configuration: Settings → Integrations → Microsoft Defender card. Requires Azure AD App Registration with
SecurityEvents.Read.All(Application type, admin consent) - Credentials:
DEFENDER_TENANT_ID,DEFENDER_CLIENT_ID,DEFENDER_CLIENT_SECRETenv vars or UI (AES-256-GCM encrypted in~/.projectachilles/integrations.json) - Graph client: Custom
fetch-based (services/defender/graph-client.ts) — OAuth2 client_credentials, token caching, OData pagination, 429 retry - ES storage: Single index
achilles-defenderwithdoc_typediscriminator (secure_score,control_profile,alert). Sparse fields across doc types - Background sync: Scores/controls every 6h, alerts every 5min (Docker:
setInterval, Vercel: Cron at/api/cron/defender-sync) - Analytics routes: 9 endpoints under
/api/analytics/defender/(secure-score, alerts, controls, cross-correlation) - Cross-correlation: Defense Score vs Secure Score over time, MITRE technique overlap between test results and Defender alerts
- Conditional UI: All Defender dashboard elements hidden when not configured (
useDefenderConfighook) - Serverless parity: Full implementation in
backend-serverless/with async blob storage and Vercel Cron
Programmatically resolves Achilles-correlated alerts in Microsoft Defender so continuous-validation activity doesn't flood the SOC queue. Architecturally integral to the Defender integration, operationally opt-in.
- Extra permission required:
SecurityAlert.ReadWrite.Allon the Azure AD app (in addition to the existing read-only scopes). 403 failures surface the exact scope name in the error message. - Modes (persisted in
auto_resolve_modefield of Defender settings):disabled(default) |dry_run(compute + log + receipt, no PATCH) |enabled(PATCH viaMicrosoftGraphClient.updateAlert) - Correlation flags on alert docs (written by the enrichment pass when an alert matches an Achilles test):
f0rtika.achilles_correlated,f0rtika.achilles_test_uuid,f0rtika.achilles_matched_at - Receipt flags on alert docs (written by the auto-resolve pass):
f0rtika.auto_resolved,f0rtika.auto_resolved_at,f0rtika.auto_resolve_mode,f0rtika.auto_resolve_error - Resolver semantics: PATCH sets
status=resolved,classification=informationalExpectedActivity,determination=securityTesting, with an audit-trail comment naming the bundle UUID. 403 halts the pass cleanly; 404 writes a skip-forever receipt; transient errors skip the receipt so the next pass retries. - Cadence: runs after every enrichment pass (5 min on Docker/Render/Fly, Vercel Cron tick on Vercel). Capped at 30 PATCHes per pass to protect the tenant from rate-limiting.
- Defense Score invariant: auto-resolve NEVER writes to test docs. Test docs and Defense Score stay byte-identical whether auto-resolve is disabled or enabled.
- Routes:
GET /api/integrations/defender/auto-resolve/status,PUT /.../mode,GET /.../receipts?limit=&offset= - UI: Collapsed
DefenderAutoResolveSectioninside the existing Defender settings card, shown only once Defender is configured - Key files:
services/defender/auto-resolve.service.ts,graph-client.ts(updateAlert+GraphPatchError),enrichment.service.ts(alert-side writes),sync.service.ts(wiring) - Customer setup guide:
docs/defender-auto-resolve.md(permission grant, dry-run audit, enable flow, troubleshooting)
Threshold-based alerting dispatched when test results cross configured score thresholds. Hooked into the result ingestion pipeline.
- Channels: Slack (Block Kit via webhook URL), Email (Nodemailer with SMTP)
- Thresholds: Score drop % (relative) and absolute score floor, configurable per metric
- Settings: Stored in
~/.projectachilles/integrations.json(AES-256-GCM encrypted) - Backend service:
services/alerting/—alerting.service.ts(threshold evaluation),slack.service.ts,email.service.ts - Frontend:
AlertsConfigsettings component,NotificationBellin TopBar - Dispatch trigger: Called from
results.service.tsafter successful ES ingestion
Three selectable themes: Default (light/dark), Neobrutalism (hot pink accent, bold borders), Hacker Terminal (phosphor green/amber scanlines). Theme selector in settings. CSS variables drive all theme-specific styling via Tailwind CSS v4 @theme blocks.
This project is indexed by GitNexus as ProjectAchilles (13953 symbols, 32240 relationships, 300 execution flows).
- Read
gitnexus://repo/{name}/context— codebase overview + check index freshness - Match your task to a skill below and read that skill file
- Follow the skill's workflow and checklist
If step 1 warns the index is stale, run
npx gitnexus analyzein the terminal first.
| Task | Read this skill file |
|---|---|
| Understand architecture / "How does X work?" | .claude/skills/gitnexus/gitnexus-exploring/SKILL.md |
| Blast radius / "What breaks if I change X?" | .claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md |
| Trace bugs / "Why is X failing?" | .claude/skills/gitnexus/gitnexus-debugging/SKILL.md |
| Rename / extract / split / refactor | .claude/skills/gitnexus/gitnexus-refactoring/SKILL.md |
| Tools, resources, schema reference | .claude/skills/gitnexus/gitnexus-guide/SKILL.md |
| Index, status, clean, wiki CLI commands | .claude/skills/gitnexus/gitnexus-cli/SKILL.md |