Skip to main content

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.

bravos is the command, and the only one this package installs. It was called bugb up to 0.1.0; everything that name wrote is still read, so upgrading needs no migration — see Upgrading from bugb. 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

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 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

  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. 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 web        # → 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.

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 web 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 sast scan|gate The separate deterministic-SAST channel: its own report, its own .bravos/sast-policy.toml, its own exit code

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, no credentials and no consent. bravos model reads the repository's guardlink annotations into a ledger — guardlink, git and node, nothing else, and no traffic leaves the machine. 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-checkpoint      # produce the ledger
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. --fast gives 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-registry pass 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 @flows line. 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 .gal sidecar (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.ts either way. It addresses everything beneath that segment, so a nested workspace cannot be addressed per service today: a root laid out as apps/web and apps/api has 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 @flows crossing 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 under src/, --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-host takes 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_base on 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 map api=https://api.example.com/v2, not api=https://api.example.com, if that service answers under /v2. A service on the target's own hostname but another port (--target http://localhost:3000 with --service-url api=http://localhost:8080) is a different origin and carries its own mount point too: map api=http://localhost:8080/v1 if it answers under /v1. Only the repository sitting at the target's own origin is the exception: it keeps the recipe's api_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.


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::threat alone, so guardlink diff can'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 @mitigates or @accepts anywhere — 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 @mitigates over an unverified pair is kept but recorded as suppressed and must be proven; non-comment edits are reverted. The agent can't silently bury a finding. That last gate needs git to undo with, so where it has none — a target under no version control, a subdirectory of its repository rather than the root, or a repository with no commits yet — the round reports the code-edit gate as not enforced instead of looking like a clean one.
  • 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/validate rewrite 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 a bravos/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.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Built distribution (wheel)

Table of built distributions (wheels) for bravos 0.1.0
File Interpreter ABI Platform
bravos-0.1.0-py3-none-any.whl Python 3 none any Details

Release files / bravos-0.1.0-py3-none-any.whl

Download URL bravos-0.1.0-py3-none-any.whl
Size 1.4 MB
Tags Python 3
SHA-256 checksum
How to use checksums
eb7441de25ee7f28c237f703e622c4adb30e32679c1d850dbda828e91d0ac81b
BLAKE2b-256 checksum
How to use checksums
9424d8a724a10d7f4ca6d77d723ba8d5e486f245d2fd12fb508f90e54b0afc71
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.13

Release history Release notifications | RSS feed

0.3.1

5 release files

0.3.0

5 release files

0.2.0

5 release files

This release

0.1.0 This release

1 release file

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page