bravos
Point it at a repo and it runs an autonomous whitebox pentest loop — annotating, probing, and verifying until every threat is confirmed or cleared.
bravosis the command, and the only one this package installs. It was calledbugbup to 0.1.0; everything that name wrote is still read, so upgrading needs no migration — see Upgrading frombugb. Free to use — see LICENSE.
bravos drives guardlink (threat-model annotation + SARIF export) and
cert-x-gen (cxg pentest, authenticated probe execution) in a closed loop. You describe what you
want in plain language; bravos derives a reviewable plan, stands up a lab, captures identities, then
annotates under rotating attack perspectives, synthesises pentest goals, runs real probes, writes
confirmed findings back into the source, and repeats — until several consecutive healthy rounds
surface nothing new and it can say why it stopped.
┌────────────────────────── round N (lens L) ──────────────────────────┐
brief │ annotate → export → synthesise → probe → write-back → report │
───▶ │ (agent) (SARIF) (goals) (cxg) (@confirmed) (advisory) │ ──▶ converged
└───────────────────────────────────┬────────────────────────────────────┘
▼
rotate lens · evaluate convergence
Every threat ends the run with a verdict and a reason: CONFIRMED, REFUTED, mitigation-held,
or an honest not-tested with the gap explained. Confirmed findings become @confirmed
annotations in the code and GitHub-style advisories with the exact probe that proved them.
Contents
- Quickstart
- Upgrading from
bugb - The web interface
- How a scan works
- Command reference
- Gate a build without a target
- Ask the code graph from a terminal
- Send a run to a server — and what a push uploads
- Point a coding agent at bravos (MCP)
- Why bravos isn't naive
- Architecture
- Testing
- Authorization
Quickstart
Install (free to use, Python ≥ 3.11).
pipx install bravos # or: pip install bravos
Requirements: guardlink on PATH for the model tier (npm install -g guardlink) — it is what
maintains the annotations. The verify tier additionally needs cxg and Docker (cxg pentest install), plus one coding-agent CLI — claude, codex, or gemini — or your editor's own model,
for the judgment points.
The graph tier needs no licence, no toolchain and no annotations in your code — but it does need
the graph binaries, and not every wheel carries them. They travel inside the wheel itself rather
than being downloaded on first use, so whether
bravos graph build and bravos graph work depends on which
wheel this install came from: a wheel is built for one platform and carries the binaries for it,
and the pure-Python py3-none-any wheel on the index carries none. bravos setup --check is the
answer for the install in front of you — it reports GRAPH TIER available or unavailable, and
when the binaries are there, which platform they were built for and which commit of the graph they
came from.
The analyzer is licensed, and having the binary is not having the licence. bravos-cli runs
the deterministic SAST analysis, and every bravos sast scan verifies a signed licence before it
starts one. Put yours at ~/.bravos/licence.jwt or in $BRAVOS_LICENCE — which takes the token
itself as well as a path, for a CI runner that holds secrets and not files. Verification is
offline: no network call, no account lookup, and an air-gapped machine verifies exactly as well
as a connected one for as long as its licence is unexpired. Without one bravos setup --check
reports the SAST channel unavailable, and bravos sast scan refuses with exit 3 — this
machine cannot do it — rather than exit 0 over an empty report, because a scan that could not run
and a scan that found nothing are different answers.
The analyzer brings six prerequisites of its own, and each one is a class of finding. They are ordinary open-source scanners, every one of them freely usable, and the analyzer drives them rather than reimplementing them:
| tool | what it covers | without it |
|---|---|---|
semgrep |
the primary engine, and the one required stage | the analyzer exits before it scans — there is no report to read |
gitleaks |
committed secrets, working tree and git history | the scan completes and reports itself degraded; the secret class is not in it |
osv-scanner |
dependency advisories, from your lockfiles | the scan completes and reports itself degraded; nothing else reads a lockfile |
kics |
Terraform, Kubernetes, Dockerfiles, CloudFormation, CI configs | thinner infrastructure coverage |
zizmor |
GitHub Actions workflows | the workflows in .github/ are not read |
checkov |
extra infrastructure breadth — opt-in, nothing runs it unless asked | nothing; it is off by default |
bravos setup --check lists them under the SAST channel with what each one is and how to get it,
and says beside the channel's verdict what an otherwise-available channel is not doing — so
SAST CHANNEL available (secret scanning is off — gitleaks is not installed) is a thing you can
read on one line rather than infer from a row further down.
bravos setup installs the two that a single command can install everywhere. semgrep and
zizmor publish to PyPI, so bravos setup --yes runs pipx install … for them with your consent,
exactly as it installs guardlink for a machine that has npm. pipx rather than pip because these
are command-line tools rather than libraries: pip install is refused outright by the
externally-managed interpreters that Homebrew, Debian, Ubuntu and Fedora ship, and on the
interpreters that accept it, it installs an engine into whichever environment happens to be active
— which is yours, not ours. The rest are single-file binaries with no one command that works
everywhere, so bravos names the route and leaves the choice to you. An absent prerequisite never
changes this command's exit code: the SAST channel is additive, and a machine that only wants the
free tiers is still a ready machine.
setup --check proves the SAST channel rather than probing it. Asking the analyzer for
--version and analyze --help establishes that a binary is reachable and has the verb — not that
a scan completes, which is a different question and the one a caller is actually asking. So when
the analyzer is present the check runs a real bravos analyze over a six-line fixture bravos owns,
through the same adapter bravos sast scan uses, and reports the analyzer's own verdict in three
states: available (the engine ran and its rules matched), degraded (the engine ran and
matched nothing — scans will complete and under-report), and unavailable (a required stage did
not run, so findings would be a floor rather than a result). Each of the last two carries the
reason, and the analyzer's own sentence naming the fix. It costs a few seconds and writes only
under $BRAVOS_HOME; --no-scan-proof skips it, and every surface then reports (unproved)
because the verdict rests on probes a broken scan can pass.
The fastest path is to describe the scan and let bravos derive a plan:
bravos intake "Thorough pentest of my app at /path/to/repo. It runs on
https://app.example.com, logins admin@example.com/Admin123! and user@example.com/User123!.
I've annotated with guardlink before but not deeply — go deep and find chained exploits.
I want all confirmed vulnerabilities."
bravos reads the brief and the repository, then prints a plan you can argue with:
PLAN 20260729-032359-portainer-2.39.2 derived from your brief
WHAT BRAVOS UNDERSTOOD
Go deep on portainer past the annotations it already carries and confirm
chained exploits against the running instance.
↳ your brief and this sentence are both carried into every annotation round
codebase portainer-2.39.2 ok
target http://localhost:9054 ok
auth credentials from your brief — refreshes itself ok
lenses unauth → mitigation-audit → stduser → crosstenant → chained
depth deep — up to 20 rounds, 4 dry to converge ok
agent codex ok
FOUND IN THE REPOSITORY
annotations 233 exposures 43 confirmed 2
suppressed 17 pair(s) hidden from the export
Approving the plan is the consent — for the actions it lists, and for the words behind them: your brief and that one-sentence reading are carried into every annotation round's prompt, so what you asked for steers what gets annotated. Anything that reads as a credential is starred out of both first. Then run the whole loop unattended:
bravos auto --plan 20260729-032359-portainer-2.39.2
Or skip intake and drive it directly against a running target:
bravos auto /path/to/repo --target http://localhost:9054 --auth alice,bob \
--attestation "Authorized self-test, <name>"
Interrupt any time and pick up where it stopped:
bravos resume # list resumable runs
bravos resume <run-id> # continue from the interrupted phase
Upgrading from bugb
This CLI was called bugb up to 0.1.0. Everything it wrote under that name is still read, so an
in-place upgrade needs no migration step — with one exception, the command itself:
| What you have | What happens |
|---|---|
the bugb command in scripts and CI |
replace it with bravos. This package installs one command, and bugb is not it |
~/.bugb full of runs |
used as the state root while ~/.bravos holds no runs of its own |
.bugb/ committed in a repository — policy.toml, sast-policy.toml, verifications.json, sessions/ |
read as it stands; the CI gate keeps the threshold you committed |
$BUGB_TOKEN, $BUGB_SERVER_URL, $BUGB_HOME in a pipeline |
still read |
git config bugb.server.url / bugb.projectId in a checkout |
still read, so a repository keeps pushing under the same name |
bugb/run-* branches, bugb/baseline/* and bugb/annotated/* tags |
still recognised, so a run resumed across the upgrade finds its baseline |
| auth profiles captured before the rename | still carry their bearer token |
Each of the state rows says so once on stderr — never stdout, so bravos ci --format json
stays a document a pipeline can parse. New state is always written under the new name, so migrating is
whenever you like:
mv ~/.bugb ~/.bravos # your runs, ledgers and promoted templates
git mv .bugb .bravos # a repository's committed policy and sessions
git config bravos.server.url "$(git config bugb.server.url)"
The old names are read, not supported forever; they stop being read in a future release.
The web interface
bravos dashboard # → http://127.0.0.1:8787
A single self-contained page (standard library only — no build step, no external assets, works offline, strict CSP). Auto light/dark, GitHub-style. Four views:
- New scan — the landing page. Write a brief in plain language, pick example templates, review the derived plan.
- Plans — every derived plan; approve one to get the run command.
- Repositories — findings organised per codebase. Each repo splits into a Guardlink findings table (every exposure, marked tested / not tested with the reason the loop recorded — the gap of what is still unprobed) and Advisories (cxg-verified findings, Verified / Closed). An advisory reads like a GitHub security advisory and embeds the exact cxg probe that produced the verdict, with save-and-run commands so you can reproduce it.
- Runs — a live master-detail view of every run, with stat tiles, a rounds timeline, and a streaming event log.
Deliberately not a control plane: it derives, approves, and displays — then hands you the CLI command. The CLI stays the only thing that executes a scan, so there's no second path that can drift from it. Loopback only; credentials are redacted before anything reaches the browser.
How a scan works
1 · Intake → a reviewable plan
An agent reads your brief; bravos measures the repository; a second agent call commits to specifics grounded in what's actually there — the login shape read from the auth handler, the lens rotation, and an explicit list of what it couldn't determine. Three auth modes fall out of what you wrote:
| You said | Mode | Behaviour |
|---|---|---|
| nothing about auth, no env | SEED |
a seed script creates ≥2 equal-privilege users, then self-heals |
| a credentials table | CREDENTIALS |
fully unattended; re-authenticates forever |
| "I need a browser to log in" | BROWSER |
flagged at plan time; the run pauses there |
Because every real brief says "I've annotated a few times," the plan seeds a round-0 baseline first, so round one measures only what it adds rather than counting the whole backlog as new.
2 · Environment & self-healing identities
A recipe is authored once per target (bravos env init) and replayed deterministically after that —
an environment that differs between rounds makes verdicts incomparable. bravos captures sessions with
its own headless login, because cert-x-gen's hardcodes POST /api/auth/login with
{email,password} and a csrf_token cookie, which throws on real targets (Portainer is
POST /api/auth with {Username,Password}, a JWT in the body, and an X-CSRF-Token response
header). LoginSpec makes all of that data. When sessions expire mid-scan, bravos re-authenticates
itself; it pauses only when a login genuinely needs a human.
3 · Lenses
Six perspectives, rotated deterministically: unauth, mitigation-audit, stduser,
crosstenant, admin, chained. Round 1 is always unauth (no identities, so it fails fast on a
broken env). mitigation-audit is scheduled second on purpose: a single @mitigates removes every
exposure sharing its pair from the export, so a control that's 90% correct is more dangerous than
none — it silently ends the investigation. Asking "does the declared control actually hold?" is
what produced a real bind-mount CVE.
4 · Goals
Each goal is assembled from eight slots, all but one fully deterministic:
| Slot | Source |
|---|---|
| scope | exposure + resolved route |
| actor | lens + seeded identities |
| chain | the declared @flows edge this exposure consumes — empty unless the run chains (see below) |
| preamble | environment recipe (goal_preamble) — e.g. a CSRF handshake |
| directives | the approved plan (the operator's emphasis, the OAST canary) |
| mechanism | the SARIF message, verbatim |
| baseline | lens (the control that must still hold) |
| success | per vulnerability class |
The preamble is the highest-leverage slot: without the target's CSRF handshake every mutating request 403s, the vulnerable code is never reached, and cxg reports the mitigation as holding — a false negative indistinguishable from a clean result. It lives in the recipe, written once per target. Only concrete exploit bodies and unresolved routes need an agent, batched into one call per round.
5 · Probe, classify, correlate
Goals run concurrently, each in its own session directory. Neither cxg's exit code nor its report
body is trusted alone: exit 0 covers a clean scan, a scan that skipped every probe for lack of
identities, and a scan aborted by a 5xx hard-kill, while a scan that stopped early writes a
report whose findings list is indistinguishable from a clean one's. bravos reads both, plus the
three places cxg says its own scan did not finish — exit 3, the scan_truncated_before_completion
report caveat, and the audit log's scan_end footer. Any one of them on its own is believed, and
the run is TRUNCATED: untested, never clean. A value bravos cannot read is refused rather than
coerced: caveats or scope_stats written in a shape this bravos does not understand is never read
as "nothing to report", and since those two are what would say the scan finished and reached the
target, refusing either blocks the clean path — the run is ERROR, not CLEAN. Only CONFIRMED
and CLEAN may move a verdict — every other outcome leaves the threat in the backlog, because a
scan that never reached the target is not evidence. Findings are traced back to their exposure by
a deterministic template join (verified against cxg's own cache-key function); anything ambiguous
is UNCORRELATED and never written back. Tracing a finding is not on its own a licence to sign for
it: when a finding's own threat_id names an exposure this run's goal never asked about, bravos
reports its best reading and still refuses to write a @confirmed for it, or to close the exposure
as a control that held — a refusal to sign beats a precise wrong claim in someone else's
repository (docs/prd/threat-id-design.md §5).
6 · Write-back & the ledger
Confirmed findings become @confirmed annotations next to the vulnerable line —
verified by re-parsing the model, not by trusting the agent — so the knowledge is durable and
guardlink stops re-surfacing them. The ledger (every exposure bravos has ever seen, with a
verdict and reason) is the dedup authority: a CONFIRMED exposure is terminal and never re-goaled,
so the loop never re-tests the same thing.
7 · Convergence
A round is dry only if it found nothing, was healthy, and left nothing untested — a threat cxg reported it could not reach, or a coverage statement bravos could not read, keeps the round out of the streak either way. A round degraded by timeouts or dead sessions is neutral — it neither advances nor resets the streak, because its emptiness proves nothing. The loop stops after N consecutive healthy dry rounds, when every lens is retired, or on a budget ceiling — each with a recorded reason.
Command reference
| Command | What it does |
|---|---|
bravos intake "<brief>" |
Turn a plain-language brief into a reviewable plan (repeat --scope-host <name> when part of the app answers on a second hostname, e.g. an API — probes otherwise reach the target's own host only; add --service-url <repo>=<url> to send that member repository's probes to its own address rather than to --target) |
bravos dashboard |
Local setup + results page (loopback) |
bravos auto <repo> / --plan <id> |
The whole loop, one command (drives interactive SSO/MFA logins inline; --no-capture pauses instead; add --chain-declared-flows to test declared cross-service @flows edges as chains — see below) |
bravos resume [<id>] |
List resumable runs, or continue one (keeps the run's --ai-provider and --chain-declared-flows) |
bravos run <repo> |
Drive rounds against a supplied --target |
bravos round <repo> --lens L |
Plan + execute a single round (the other verb that takes --chain-declared-flows) |
bravos plan <repo> --lens L |
Show the goals and cxg commands a round would run |
bravos inspect <repo> |
Threat-model state + where guardlink's view is lossy |
bravos env init|up|login|health|down |
Stand up / manage a disposable lab |
bravos env auth <slug> [--check] |
Login readiness per identity + the exact capture command (--check probes the target) |
bravos env rm <slug> [--auth] |
Forget a saved recipe so the repo is re-planned from the brief (--auth also clears its sessions) |
bravos ledger --run <id> |
Every verdict and its reason |
bravos ci [--run <id>] |
Judge a run's ledger against .bravos/policy.toml: PASS / WARN / FAIL (exit 1 = breach, 2 = could not evaluate; --policy-template prints a commented starter; --format json for pipelines — guide, design) |
bravos push [--run <id>] |
Upload a run to your team's Bravos server: the ledger, the run summary, the guardlink dashboard, the per-finding advisories, and the code graph's security surface (--no-graph sends everything but the last — exactly what is uploaded) |
bravos sast scan|gate |
The separate deterministic-SAST channel: its own report, its own .bravos/sast-policy.toml, its own exit code |
bravos graph build <repo> |
Index a repository into its code graph — ships in the wheel, needs no licence and no annotations (below) |
bravos graph [<question>] |
Ask the code graph from a terminal — bare, it lists what this repository's graph can answer; bravos graph callers 'src/ci.py::gate', bravos graph reachable-exposures <symbol>, --json for pipelines (below) |
bravos mcp |
Serve bravos to a coding agent over stdio — the threat model, the ledger and the code graph as MCP tools (below) |
bravos doctor |
What this machine can run and what would change that — the same report as bravos setup --check, under the name the analyzer uses (--json for pipelines) |
bravos version |
Which build this is — the verb form of --version |
Runs that edit code or send traffic require an explicit --attestation — recorded in cxg's audit
log as your assertion that the target is yours to test — unless it came from an approved plan.
<repo> also accepts a folder that holds several repositories, provided each repository directly
beneath it is initialised with guardlink init; the run then sees all of their weaknesses. Work
lands on a bravos/run-<id> branch, one commit per phase, so every round is reviewable and
revertible.
Gate a build without a target
A working CI gate needs no target, no agent and no credentials. bravos model
reads the repository's guardlink annotations into a ledger — guardlink, git and node, nothing
else, and no traffic leaves the machine. It is not read-only, though: left to itself it checks the
checkout out onto a bravos/run-<id> branch and publishes nine files into it, so the recipe below
passes --no-publish --no-checkpoint to suppress both. bravos ci judges that ledger against a policy file that
lives in the repository beside the code it gates. Measured end to end: 0.9 s + 0.1 s.
bravos model . --no-publish --no-checkpoint # produce the ledger, touch no files
bravos ci # judge it — this step's exit code is the job's
By default it warns rather than blocks: every live finding is named, in the terminal and in
--format json, and the build stays green. A team turns the dial when they are ready, and can set
a different floor per verdict class — a reproduced exploit is not the same news as one nobody has
tested yet:
# .bravos/policy.toml · bravos ci --policy-template > .bravos/policy.toml
fail_on = ["confirmed", "unverified"]
[fail_on_severity]
default = "none"
confirmed = "high" # reproduced: fail from high up
unverified = "critical" # declared but untested: fail only on criticals
The three outcomes are PASS (nothing found), WARN (found, below your floor — exit 0) and
FAIL (exit 1). Read them from the verdict field in JSON: passed is true for both of the
first two, which is exactly the distinction the warning tier exists to expose.
bravos model and bravos annotate also publish .guardlink/report.json — the file
bravos analyze's CodeGraph ingests — and refresh the eight agent instruction files, so the next
coding agent opened in the repository reads the model as it stands. bravos inspect reports when
those have gone stale without fixing them. Full guide: docs/ci/README.md.
Testing an attack that crosses between services
--chain-declared-flows (off by default) makes a run test a declared cross-service data flow as one
chained attack instead of two unrelated findings. When the threat model declares
// @flows #web-checkout -> #api-refund via refund_token -- "the checkout page mints the token the API trusts"
and both ends are exposures in the run, the goal that tests the consumer names the provider and its template budget is widened to cover it, so the probe engine can generate the pair together: one template that plants the value and one that spends it. Without the flag the provider is usually outranked and the connection is found and then discarded.
Four things to know before turning it on:
-
It costs money. Roughly 120s and one AI generation call per extra template, on partnered goals only. One declared edge costs one template however many exposures its source asset has.
cxg.chain_partner_cap(default 2) bounds how many declared edges one goal may be widened for; edges beyond the cap are reported as untested, never dropped silently. -
Only a generating run derives an edge, and a run that cannot chain says so at launch.
--fastgives a goal one template, and one template has no partner to pair with; a partnered goal steps around the reuse cache in both directions — it will not replay a probe generated before the goal named a partner, and it stores none of its own, because its two templates were written against two different files and an entry records one — so it pays for generation on every run; and a--from-registrypass synthesises no goals, so it never chains and never reports a dropped edge. Every replay path (bravos replay, carry-and-verify,--from-registry) does fire with chaining enabled, so a stored chained probe runs as written and one whose provider is absent is reported as skipped rather than counted as a probe that found nothing. -
Nothing discovers a flow, and the line has to be somewhere the probe engine reads. A human or an assistant has to write the
@flowsline. An unwritten connection is indistinguishable from an application that has none. cert-x-gen reads declarations only from the source files it walks itself —.ts/.tsx/.js/.jsx/.py/.rb/.go/.rs/.java/.php/.html— so a declaration in a.galsidecar (guardlink.annotation_mode = "external", which means every declaration) or in a language it does not walk is refused rather than paid for. External annotation mode is named at launch, since it disables the capability wholesale; a single declaration in a language cxg does not read is refused without a line of its own, so write the crossing in a file it walks. -
A crossing between two addresses needs each service's address. A generated template is built against the run's single
--target, so without a mapping both ends of a crossing are probed there — and if your provider and consumer answer at different addresses (another hostname, or the same host on another port) the provider probe is aimed at the wrong service, where it 404s, and the edge is reported dropped for a reason that is neither the ranker nor the annotation. Say where each repository answers:bravos intake --scope-host web.example.com \ --scope-host api.example.com \ --service-url web=https://web.example.com \ --service-url api=https://api.example.com \ "test shop/ at https://web.example.com"
The key is the first path segment of a finding's path as guardlink records it — the member repository's directory in a folder of initialised repositories, or the top-level package directory in a monorepo initialised at its own root, since guardlink records a finding under
api/src/refund.tseither way. It addresses everything beneath that segment, so a nested workspace cannot be addressed per service today: a root laid out asapps/webandapps/apihas one first segment,apps, and mapping it sends both services' probes to the one address. Bravos does not refuse that — it cannot tell a package directory from any other, and refusing would mean asserting they are separate services. Where you have declared a@flowscrossing between two of those child directories, the launch notice names them and the one address they share. That reads your threat model, so it can only speak once one exists: at approval time the rule above — the key is a first path segment — is what you reason with. What is refused before you approve it is a key that matches nothing — that is the disclosure failure, because you would believe traffic goes to one service while it goes to another. A key that matches everything is not refused: on a codebase whose sources all sit undersrc/,--service-url src=<url>is accepted and directs every probe there, because both halves are declared — you named the directory and you named the address, and nothing is inferred. Refusing it would be bravos overriding an explicit, unambiguous instruction because it looks redundant, which is not a judgement this tool is entitled to make. Nothing is derived: a repository you do not map keeps resolving against--target, exactly as before. Every host you send to must also be authorised with--scope-host— including the target's own if you map its repository, as above — and a mapping naming a host the plan does not authorise is refused when you type it, not when the run starts.--scope-hosttakes a HOST and not an address deliberately: it authorises where traffic may go, and that permission ignores ports, while where a probe is actually sent is the full URL you map here. The plan states the mapping as its own row on all three approval surfaces, and the run repeats it at launch, because a stale address turns every probe for that repository into a 404 that reads as the application defending itself.Put each service's mount point in its own URL. The
api_baseon your env recipe is the target's mount point, and it is applied only to services at the target's own origin — scheme, host and port — so mapapi=https://api.example.com/v2, notapi=https://api.example.com, if that service answers under/v2. A service on the target's own hostname but another port (--target http://localhost:3000with--service-url api=http://localhost:8080) is a different origin and carries its own mount point too: mapapi=http://localhost:8080/v1if it answers under/v1. Only the repository sitting at the target's own origin is the exception: it keeps the recipe'sapi_base, so mapping it does not cost you the mount point you already configured.
Write the annotation in the form the probe engine parses: flat ids on both ends (#api-refund, not
#api.refund) and a mandatory via <channel>. guardlink link-project currently teaches a
different cross-repo form that cert-x-gen's parser rejects; if your repository's agent instructions
show that form, the form above is the one that works.
Ask the code graph from a terminal
The code graph answers sixteen questions — who calls this, what it calls, what a change reaches,
what is in this file, what is declared exposed, what this repository says it protects, which
declared exposures no test reaches, who signed for a risk and when that lapses, and which declared
exposures control entering here touches. Until bravos graph the only way to ask any of them was
to wire up an MCP server.
The graph ships inside the wheel and needs no licence. The two binaries are
codegraph-build, which indexes a repository, and codegraph-mcp, which answers questions about
one. Neither needs a key, a server, or a single annotation in your code. They are carried by the
wheel rather than fetched, so an install has them only if the wheel it came from was built with
them — the py3-none-any wheel on the index was not; bravos setup --check reports under
GRAPH TIER whether this install can answer, separately from the licensed analyzer. With them
in place, index once, then ask:
$ bravos graph build . # index this repository — seconds to a couple of minutes
indexed 13,138 nodes · 50,278 edges · 445 files
annotations 424 GuardLink annotation(s) read from the source
address d11a187631d9bbae (git-remote) · main
A repository with no annotations still gets a graph and still answers every structural question —
the security columns are blank because the model is silent, which the output says out loud rather
than leaving you to read as "clean". bravos sast scan also builds a graph on its way to a scan,
but that is the licensed analyzer; bravos graph build is the free route to the same graph.
$ bravos graph # what this repository's graph can be asked
$ bravos graph search gate # find a symbol — results carry its exact address
$ bravos graph callers 'src/bravos/commands/ci.py::gate'
$ bravos graph reachable-exposures handle_request --max-depth 6 --json
bravos graph fronts whatever the graph advertises rather than a list kept here, so a question the
graph learns is answerable the day it lands — with its own --help, generated from the tool's own
schema (bravos graph <question> --help).
A symbol is addressed exactly. evaluate, Scanner.evaluate, or src/scan.ts::evaluate — the
last being the graph's own node id, which is what bravos graph search prints. A name several
symbols carry is refused with its candidates rather than answered about an arbitrary one of them:
$ bravos graph callers evaluate
bravos: Symbol 'evaluate' is ambiguous: 4 symbols carry that exact name
CANDIDATES
src/bravos/commands/ci.py::evaluate function, line 210
src/bravos/commands/sast.py::evaluate method, line 318
…
next bravos graph callers 'src/bravos/commands/ci.py::evaluate'
Exit codes follow the scheme in bravos --help: 2 when a different word would work (an
ambiguous symbol, a question this graph does not have, a repository nobody has indexed — the
refusal names bravos graph build <repo>), 3 when this installation carries no graph binary,
and the refusal says so.
Send a run to a server — and what a push uploads
bravos push uploads one run to your Bravos server — the one your team operates, under your
licence, behind your token. Everything else in bravos is local; this is the one command that sends
anything anywhere.
$ export BRAVOS_SERVER_URL=https://bravos.example.internal
$ export BRAVOS_TOKEN=…
$ bravos push # the latest run for this repository
pushed 2026-09-11T09-14-02Z → github.com/org/repo
sent 214
appended 214
duplicates 0
summary yes main
dashboard yes /repo/threat-dashboard.html
advisories yes 6 stored
graph yes 567 symbols, 111 declared exposures
templates yes 4 promoted @main
Every line names what went and what the server did with it, so a push that quietly skipped something is not a push that looks identical to one that did not.
What a push uploads
"What does your scanner send?" is the first question worth asking about any tool like this, so the answer is here rather than in a source comment. Five things cross the wire, each on its own endpoint:
| What | Where it comes from | |
|---|---|---|
| Ledger events | every verdict the run recorded: the exposure key, the verdict, the reason, the round, the lens, and the evidence attached to a confirmation | ledger.jsonl, which the run already wrote |
| Run summary | how the run was configured and how healthy it was: branch, commit, target URL, providers, status, termination reason, per-round counts, lens stats, persona labels, coverage flags | manifest.json, lenses.json, profiles.json |
| Guardlink dashboard | the generated threat-dashboard.html, as an opaque blob the server stores and serves back unparsed |
your codebase, when it has one |
| Finding advisories | per confirmed finding: the advisory write-up, the confirmation evidence, the probe's request trail, and the probe id | the run's own report/finding-artifacts.json |
| Graph security surface | see below | the code graph, read at push time |
The first four have been crossing since their channels shipped. The fifth is new, and it is the one that carries detail about your code rather than about a finding, so it gets the long answer.
The graph security surface, in full
What it is: the security-relevant subset of your code graph — not the graph. On this repository the graph holds 12,512 symbols and 37,533 edges; the surface carries 567 symbols and 532 edges, about 292 KiB. It scales with your threat model, not with your repository: a codebase 3.3× larger, measured, produced a smaller surface.
What is in it:
- Repository-relative file paths —
src/bravos/remote/client.py, never/home/you/work/…. - Symbol names — function, method and class names, and their
file::symboladdresses. - Line numbers — of declarations, and of the call sites that reach them.
- Your declared threat model, verbatim — asset names, threat ids, control ids, severities, CWE
references, and the rationale prose you wrote in your own
@exposes/@mitigatesannotations. It is sent as written. - Your risk-acceptance register — including the name of the person who signed for each accepted risk, and the date that signature lapses.
- One hop of call graph — for each declared exposure, the symbols that call into it, capped at 64 per exposure.
- Counts about the whole graph — files, symbols, call edges, exposures, so you can check the fraction above against your own repository.
What is not in it:
- No source code. Not a line, not a snippet, not a function body.
- No absolute paths — not your checkout, not
$BRAVOS_HOME, not a temp directory. - No machine, user or host identity. No hostname, no username, no environment.
- No credentials, tokens or target URLs.
- No file contents, content hashes or diffs.
- No symbol that neither carries a declaration nor calls one — on this repository, 11,507 of 12,074 symbols never leave the machine.
The honest framing. This is materially more than a summary, and it is sent because a team looking at a shared server should see what one developer sees locally. The reason that trade is acceptable is that the server is yours — you host it, you licence it, you hold the token. That is a mitigating fact, not a reason to be vague about the list above.
If you would rather not send it:
$ bravos push --no-graph # the ledger, summary, dashboard and advisories; no surface
The push reports it on the same line, so the choice is visible in the log rather than silent.
Sending it is re-runnable and cheap to store. The surface is identified by a hash of its own
content, computed without the run id or any timestamp, so an unchanged repository pushed every day
for a week costs your server one stored surface and seven associations — not seven copies. The
server answers stored: false and the push prints already on the server.
The full field-by-field contract, the measurement behind the subset, and what an older server does
with a newer payload are versioned alongside the server that implements them; bravos push --help
names what each channel carries, and --no-graph sends everything but this one.
Point a coding agent at bravos (MCP)
bravos mcp is an MCP server on stdio. It exposes what bravos
already knows about this machine's repositories — the threat model, the findings ledger, the run
history, and the code graph — as tools an agent can call while it works. Nothing it serves sends
traffic to a target. One tool writes: generate_model checks the repository out onto a
bravos/run-<id> branch and publishes the model into the working tree — nine files, CLAUDE.md,
AGENTS.md and .github/copilot-instructions.md among them. Every other tool reads.
Add it to a host that reads mcp.json (Claude Code, Cursor, Windsurf, VS Code):
{
"mcpServers": {
"bravos": { "command": "bravos", "args": ["mcp"] }
}
}
Or, in Claude Code: claude mcp add bravos -- bravos mcp.
It serves 13 tools:
| Tools | What the agent gets |
|---|---|
list_repositories, get_repository, get_advisory |
Which repositories have been scanned, one repository's guardlink exposure table, and the full write-up plus probe evidence for one finding |
list_runs, get_run, get_events, get_report, get_artifact |
Run history, one run's whole picture, its event stream, and its reports and artifacts |
generate_model |
Builds the threat-model report and dashboard from the annotations a repository already carries — the only tool that runs anything, and the only one that writes: it checks the repository out onto a bravos/run-<id> branch, leaves it there, and publishes nine files into the working tree |
request_verification |
Returns the intake → approve → auto flow filled in for a run. It fires no probe: approving the plan is a human decision, and the server will not make it |
codegraph_tools, codegraph_query |
The code graph — callers, callees, a change's blast radius, structural outlines, and which declared exposures are reachable from an entry point (the same answers are on the CLI as bravos graph) |
list_environments |
The disposable labs this machine has recipes for |
The graph tools are the reason to point an agent here rather than at plain text search: they know
which symbols touch a declared asset or threat. They proxy codegraph-mcp, which ships inside
this package — an installation that does not carry one (a wheel built for another platform, a
source checkout) answers codegraph_unavailable with the route to fix it rather than with an empty
result. The other eleven tools work regardless. bravos setup --check says what this installation
carries; the graph it reads is built by bravos graph build <path>.
Why bravos isn't naive
Several obvious designs are wrong. bravos exists because it handles the sharp edges the tools leave:
- Pair collisions. guardlink identifies an exposure by
asset::threatalone, soguardlink diffcan't see a new weakness in a pair it already knows — the round reads "nothing added." On Portainer 2.39.2 that's 21 findings collapsing to 16 identities, hiding both host-escape CVEs. bravos keys on(asset, threat, file, hash(message)). - Suppressed exposures. guardlink's SARIF drops any exposure whose pair carries a
@mitigatesor@acceptsanywhere — 17 pairs on the reference corpus, including a confirmed cross-tenant IDOR. bravos seeds them into the ledger and, for the mitigation-audit lens, restates them into the export so cxg can actually test the control. - Annotation gates. An agent that writes
@accepts(deleting a pair from the export) halts the run; a@mitigatesover an unverified pair is kept but recorded as suppressed and must be proven; an edit that is not an annotation the parser reads is reverted. The agent can't silently bury a finding, and it can't land a dead one either — an annotation written where guardlink does not read (a Python docstring, say) contributes nothing to the export, so it is reverted rather than left looking like work. That last gate then reports what it actually established, derived from the same read that judged the files rather than from a list of known-bad target shapes, and it claims only what it inspected — the lines the round ADDED to SOURCE files: enforced when every changed path was accounted for and the round did nothing outside that; enforced for what it inspected, naming each limit, when it did (lines the round removed, or what it wrote to a.guardlink/definition file or a.gal, neither of which is source the annotation oracle can judge); and not enforced, naming the files, when a changed path could not be read, put back, or confirmed put back. A target under no version control, a subdirectory of its repository rather than the root, and a repository whose files are staged but not yet committed all reach that last standing on their own, as does any other shape that leaves a changed path unaccounted for. What it still cannot see is anythinggit statusdoes not report even when asked about ignored paths — a nested repository, an--assume-unchangedfile — and, among the ignored paths it does see, a write into one git was already ignoring when the round began, since it compares those by name. A file it was not yet ignoring is caught either way: the round-start digest tells a rewrite from a file that was simply already there. A submodule's contents are a different case and not that one: git does report a submodule, as the gitlink entrywebin a whole-tree status, and it is the source pathspec that filters it out, because*.gomatches files and a gitlink is not one. So the gate reads the superproject and cannot judge what a round did inside a submodule — and when one has moved, gone dirty, or could not be asked about, it says so, naming it among the things it did not inspect, rather than reporting a clean bill over a repository it never opened. - cxg's exit code lies, and so can its report body. How bravos reads both — and how it catches a scan that stopped early before an empty findings list becomes a clean bill of health — is in Probe, classify, correlate. It also captures the generated probe templates into the run tree so every advisory can show the exact code that produced its verdict.
- The tools move; bravos would not notice. guardlink and cert-x-gen ship on their own schedules, and every reader here is a hand-written pick of named keys — a key that grew or a block written in a new shape is not an error, it is simply never seen, and the run reports the nothing it got back as a clean result. So bravos declares what it needs of each artifact and takes a census against the one that actually arrived: a field nothing consumes, a value it has no meaning for, a value it refused, or a question the artifact never answered is listed in the run's health banner in the web interface. It marks the numbers as a floor, not a failure — a tool that grew a field hasn't made the findings wrong, only bravos's reading of it incomplete.
- State lives outside the repo.
guardlink status/validaterewrite seven agent-instruction files on every call, so in-repo state would churn inside the very commits used as diff baselines. All run state is under$BRAVOS_HOME(default~/.bravos); the target repo only ever receives commits on abravos/run-<id>branch.
Architecture
A deterministic state machine owns the loop; AI coding-agent CLIs are invoked headlessly only at explicit judgment points — annotation, environment-recipe generation, goal enrichment, write-back.
models/ pure dataclasses, no I/O
adapters/ one external surface each (guardlink, cxg, agent), ProcRunner injected
env/ recipes, provisioning, the login adapter, auth recovery
phases/ p00 preflight · p10 env · p20 auth · p30 annotate · p40 export
p50 synthesise · p60 pentest · p70 write-back · p80 report
web/ self-contained page + read-only API
orchestrator sequencing only; convergence, lenses, ledger, journal
Dependency direction is models ← adapters ← phases ← orchestrator; phases never import each other.
Runs are journalled and resumable at phase granularity — interrupt at round 3, resume, and no
completed step re-executes.
Testing
.venv/bin/python -m pytest -m "not contract and not slow" # fast unit + web suite
.venv/bin/python -m pytest -m contract # real guardlink binary
Fixtures under tests/fixtures/portainer/ are verbatim artifacts from a completed manual run, so
the suite asserts against data the tools actually produced.
Authorization
bravos sends real exploit traffic and lets a coding agent edit the target repository. Use it only against systems you are authorised to test. Every run records an attestation, binds work to a dedicated branch, and keeps all other state outside your repository.
License
Free to use for any purpose, including commercial — see LICENSE. Not open source: redistributing, reselling, or shipping a modified version needs Bugb Technologies' written permission. Questions: bugb.io.
Release files for bravos 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Built distributions (wheels)
| File | Reset | |||
|---|---|---|---|---|
| bravos-0.2.0-py3-none-manylinux_2_28_x86_64.whl | Python 3 | none | Linux glibc 2.28+ x86-64 | Details |
| bravos-0.2.0-py3-none-manylinux_2_28_aarch64.whl | Python 3 | none | Linux glibc 2.28+ ARM64 | Details |
| bravos-0.2.0-py3-none-macosx_11_0_arm64.whl | Python 3 | none | macOS 11.0+ ARM64 | Details |
| bravos-0.2.0-py3-none-macosx_10_12_x86_64.whl | Python 3 | none | macOS 10.12+ x86-64 | Details |
| bravos-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 41.5 MB
Release files / bravos-0.2.0-py3-none-manylinux_2_28_x86_64.whl
| Download URL | bravos-0.2.0-py3-none-manylinux_2_28_x86_64.whl |
|---|---|
| Size | 10.3 MB |
| Tags | Linux glibc 2.28+ x86-64 Python 3 |
|
SHA-256 checksum How to use checksums |
b1ad7282168759bafb248646685897ecbd80324ed1c651586522d9212df9b84a
|
|
BLAKE2b-256 checksum How to use checksums |
454bbd25671c30d2cd6e0e20e88c4fedd571c6433eff8b9221602a403aeccec6
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.13
|
Release files / bravos-0.2.0-py3-none-manylinux_2_28_aarch64.whl
| Download URL | bravos-0.2.0-py3-none-manylinux_2_28_aarch64.whl |
|---|---|
| Size | 10.0 MB |
| Tags | Linux glibc 2.28+ ARM64 Python 3 |
|
SHA-256 checksum How to use checksums |
1e0faddf180fc21690b39327d0ab91cec40b58814c0b859197fcec58bf658b60
|
|
BLAKE2b-256 checksum How to use checksums |
9a49edf271538b92b813d427e144054fef14891be7a1ced5112791ee865169a0
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.13
|
Release files / bravos-0.2.0-py3-none-macosx_11_0_arm64.whl
| Download URL | bravos-0.2.0-py3-none-macosx_11_0_arm64.whl |
|---|---|
| Size | 9.6 MB |
| Tags | Python 3 macOS 11.0+ ARM64 |
|
SHA-256 checksum How to use checksums |
2b91a5960e02511323ce44c7f509366c9bf6ad0072adda6f03de8cda237be6eb
|
|
BLAKE2b-256 checksum How to use checksums |
6d4651c54fea5df78d23f6dc958159990be473000aff7ed6660143094c22f907
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.13
|
Release files / bravos-0.2.0-py3-none-macosx_10_12_x86_64.whl
| Download URL | bravos-0.2.0-py3-none-macosx_10_12_x86_64.whl |
|---|---|
| Size | 9.8 MB |
| Tags | Python 3 macOS 10.12+ x86-64 |
|
SHA-256 checksum How to use checksums |
def64ee7695488c554caf1b3e1b9d83badd403b2aef316cf30d1c313efb78154
|
|
BLAKE2b-256 checksum How to use checksums |
e26dc9e97139cbd38c1b4806fca6f4d4b6777e4ac4e53ca25981e929bb5ff5ca
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.13
|
Release files / bravos-0.2.0-py3-none-any.whl
| Download URL | bravos-0.2.0-py3-none-any.whl |
|---|---|
| Size | 1.7 MB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
1d757bb48aab83c1004b4cd2570943d303e1859e26cac42b7a79d7cb66bdea6e
|
|
BLAKE2b-256 checksum How to use checksums |
8946b97341f84b9112cab33fcab00036e149c26016997a26f415ad73271d888e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.13
|