New:Introducing Socket Scanning for VS Code Marketplace Extensions.Learn more →
Get Started

bravos

Package Overview
Dependencies
Maintainers
1
Versions
8
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

bravos

Continuous SAST and whitebox pentest orchestrator

pipPyPI
Version
0.7.0
Weekly downloads
290
9.43%
Maintainers
1
Weekly downloads
 
Created

bravos

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.

The brand mark above is for the README and docs. In a terminal — bravos --version, the landing, the terminal UI — bravos prints the same wordmark in ASCII.

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, credential-free and authenticated probe execution) in a closed loop. You describe what you want; bravos derives a reviewable plan and captures identities only when selected, 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

Open it. Run bravos with no arguments to open the terminal UI — threats, live runs, and what they need from you, for one repository (it is exactly bravos tui). bravos <command> runs that command instead, and bravos --help lists them all. Piped or in CI, where a full-screen UI would garble the stream, a bare bravos prints a short get-started intro rather than opening the UI; bravos welcome prints that intro anywhere.

Update with bravos update. It compares this install with PyPI and upgrades it through the installer that made it — pipx upgrade bravos, uv tool upgrade bravos, or the running interpreter's pip — then has the new bravos bring its companions to the versions it pins: the analyzer (on a licensed machine), GuardLink (npm install -g guardlink@<pin>), cxg (Homebrew, cxg update, or cargo) and cxg's pentest pipeline. It asks before each install (--yes approves all of them; in CI or off a terminal nothing is installed without it), never downgrades a companion that is ahead of its pin, stops at the first failure with the command to finish it by hand, and ends with what moved and what is left to you — rebuilding code graphs when the graph format moved, and updating the VS Code extension through the editor. For cxg and GuardLink it names the exact file that runs and every other copy on PATH with its version, warns when two copies disagree or an update landed in a copy PATH does not reach first, and gives the command that removes the unwanted one (brew uninstall cxg, cargo uninstall cert-x-gen, npm uninstall -g guardlink); --json carries the same as path, duplicates and warnings. bravos update --check reports all of it and changes nothing. A system or externally-managed Python is never modified: you get the command instead, as you do offline. Once a day, a command run at a terminal mentions a newer release on stderr; it is silent in CI, for --json/--format output, and with BRAVOS_NO_UPDATE_NOTICE=1.

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. The file the portal downloads, bravos-license.jwt, works as it is in ~/.bravos/. 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.

Try local SAST for 30 days, without a card. Run bravos account activate, create an account or sign in, and approve activation in the browser. Bravos verifies and privately saves the licence for you. The trial starts when you approve, covers unlimited local repositories and scans, and does not renew automatically. Running activation again keeps the original dates. Each scan prints the time remaining and an upgrade link to stderr; paid licences show no trial banner. When the trial ends, new scans need an upgrade or an operator-approved extension; saved reports and the free graph and report gates remain available.

Use bravos account activate --no-browser to open the approval link on another device. For an offline machine, download the licence from your account and run bravos account import /path/to/bravos-license.jwt. Existing $BRAVOS_LICENCE / $BRAVOS_LICENSE overrides still take precedence. Installing the package and running setup --check start no trial. Activation, recovery and rollout details.

On a licensed machine, bravos setup installs the analyzer. With your consent, as for every other install: it checks the licence first and fetches nothing without one, verifies the analyzer's signature before anything is written, and stages it under ~/.bravos/analyzer/ together with its rules, gitleaks, osv-scanner, kics and zizmor — the set the CI runner image carries — plus the runner image's pinned Semgrep in an environment of its own. bravos sast scan and bravos sast gate then run on your laptop the way they run in CI. How it works, and every way it refuses: docs/design/analyzer-laptop-install.md.

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:

toolwhat it coverswithout it
semgrepthe primary engine, and the one required stagethe analyzer exits before it scans — there is no report to read
gitleakscommitted secrets, working tree and git historythe scan completes and reports itself degraded; the secret class is not in it
osv-scannerdependency advisories, from your lockfilesthe scan completes and reports itself degraded; nothing else reads a lockfile
kicsTerraform, Kubernetes, Dockerfiles, CloudFormation, CI configsthinner infrastructure coverage
zizmorGitHub Actions workflowsthe workflows in .github/ are not read
checkovextra infrastructure breadth — opt-in, nothing runs it unless askednothing; 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.

Without the analyzer install, bravos setup installs the three that a single command can install everywhere. semgrep, zizmor and the opt-in checkov publish to PyPI, so bravos setup --yes runs pipx install … for them with your consent — or uv tool install … on a machine that has uv and no pipx — 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. A machine with neither pipx nor uv is told where to get one. checkov is the one scanner the analyzer's own bundle does not carry, so with an analyzer bravos setup staged it goes into an environment of bravos's own beside the analyzer instead, and needs neither; the analyzer runs it only when asked (--iac-tools checkov), so its absence narrows nothing. 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.

Set a repository up with one command. bravos init is the first command to run in a repository, after bravos setup has said what the machine can do:

cd /path/to/repo
bravos init            # or: bravos init -y   to also install the analyzer on a licensed machine

It runs guardlink init --mode external when the repository has no .guardlink/ (annotations then live in .guardlink/annotations/*.gal sidecars, never in your source files), widens GuardLink's include to every language guardlink actually scans (it writes one language — a Tauri app's Rust half was left out), publishes .guardlink/report.json and the agent instruction files, builds the code graph with type checkers — --scip no-exec by default, so nothing the repository wrote is executed; --trust-repo lets them run its build scripts — and checks the SAST analyzer is the version this bravos runs, installing or upgrading it through bravos setup's verified install with your consent. Every language no type checker resolved is named with what it costs (its callers and impact come from syntax alone) and the fix. It ends with a table of what is ready and the commands to run next. Run it again any time: on a set-up repository it writes nothing. It commits nothing: review what it wrote with git status and commit it yourself — the graph is keyed on the committed tree. --dry-run says what it would do; --json is the same answer for an agent. Exit 0 ready, 3 when a required step (GuardLink, the published model, the graph) is not; the analyzer and the agent skills are reported and never make it fail.

Its last step, agent-config, says what the repository's coding-agent configuration lets each agent do. That configuration is the instruction files, .claude/ settings and permissions, .mcp.json and the other harnesses' MCP files, hooks and skills, and Bravos treats it as part of the attack surface. The step reads the threat model, so it can say how many capabilities each agent has and which file granted them, how many a human entitled, which hooks run on their own, and which MCP servers are built from this repository. A guardlink that does not read agent configuration yet is named as such, and the step never reports that as a repository that configures nothing. bravos inspect shows what a default claude -p would load and run in the repository. Bravos's own agents load none of it: claude runs with --setting-sources user, codex with project_doc_max_bytes=0, and a harness with no such flag is never a failover target for annotate, write-back or retract (docs/design/hermetic-agent-argv.md).

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

One dashboard threat, without a repository-wide annotation pass:

bravos auto /path/to/repo --threat-id gl-0219496516a1 \
  --target http://localhost:9054 --auth alice,bob \
  --attestation "Authorized self-test, <name>"

The id must exist in that repository's current GuardLink export. An unknown id refuses before environment work; a coarse id shared by several claims lists their locations and refuses rather than guessing. Exact selection requires cxg to advertise exact-threat-id through cxg pentest capabilities; an older installation refuses, never falls back to a broad scan. This mode supports JS generation for web/Electron, not legacy Python templates or template replay.

Bravos reads the claim, source, graph routes/access facts, declared controls and selected identity metadata, then prints its actor, victim, route, mechanism and positive/negative controls with reasons. For an RLS claim, intended service-role access is not an exploit: a cross-tenant test needs ordinary identities and a known victim-owned row. Missing identities, a missing route or an unauthorized host are prerequisites, not negative findings; there is no silent anonymous fallback. The id is not consent: --attestation, --target and explicit --scope-host authorization still apply.

Only the selected workload enters export → synthesis → pentest. No annotation rotation, unrelated carried/registry replay, route discovery or chain expansion runs. The run stops after that bounded workload, not whole-repository convergence; its report names the excluded scope. The fine claim, snapshot fingerprint, strategy and authorization are saved in manifest.json. Resume restores that decision and refuses a changed snapshot; resolving a new prerequisite or choosing a changed claim requires a new invocation. --retest remains additive and is not combined with this selector.

Each attempt also saves CXG's returned threat_selection beside that frozen request: all selected hypotheses, exclusions, dropped templates and any no-match snapshot diagnostics. The report and dashboard/editor details show this account; excluded threats do not count as attempts or refutations. An exact run with a missing, unreadable or contradictory response ends with an execution error and supplies no negative evidence. Broad runs and older broad reports need no selection response. CXG's reported auth_mode (anonymous, authenticated or mixed) is shown alongside Bravos's selected identities; it does not establish credential liveness or replace coverage checks.

After fixing the code, replay the saved local probes for that run (when matching templates and a local session index were stored). Replay uses that session's target, identities and scope, and updates its original ledger. A settled finding is not guaranteed a fresh probe by another auto --threat-id invocation: current source and verification memory still govern selection.

bravos replay <run-id> /path/to/repo --threat gl-0219496516a1 \
  --attestation "Authorized verification of my fix, <name>"

No login is required for public probing. Without selected credentials the run performs real anonymous recon and probing. To explicitly exclude saved recipe credentials, use --anonymous:

bravos auto /path/to/public-app --target http://localhost:9054 --anonymous \
  --attestation "Authorized self-test, <name>"

run, round and plan accept the same flag. It cannot be combined with --auth or credentials. Intake preserves requests for public-only testing as anonymous plans. No account, login route, seed-user requirement or dummy session is invented; an empty selection says nothing about whether the app itself has authentication. Resume and replay keep that selection.

Public probing and public-control audits remain runnable when a requested login is unavailable. Authenticated and cross-user perspectives require their actual usable accounts and are reported unavailable, not refuted, when those accounts are absent. App readiness is independent of login: a supplied URL answering 401/403/404 is reachable; explicit recipe health expectations and service failures remain enforced. Zero requests or skipped probes never produce a clean dynamic verdict.

This requires a cxg runtime supporting zero-auth templates. Bravos sends no --auth argument for an empty set, no credentials file, no auth directory and no session-health bypass. Older runtimes that skip all probes produce a non-evidence error naming the runtime requirement, not a request to create an account.

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 haveWhat happens
the bugb command in scripts and CIreplace it with bravos. This package installs one command, and bugb is not it
~/.bugb full of runsused 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 pipelinestill read
git config bugb.server.url / bugb.projectId in a checkoutstill read, so a repository keeps pushing under the same name
bugb/run-* branches, bugb/baseline/* and bugb/annotated/* tagsstill recognised, so a run resumed across the upgrade finds its baseline
auth profiles captured before the renamestill 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), with automatic light/dark appearance:

  • New scan — 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 — the landing page. Findings organised per codebase, including a Guardlink findings table with tested/not-tested state, provenance and the recorded reason. Click a gl- ID to open the full finding: Overview, Evidence, Reproduce when a probe survives, and Command handoff. Links preserve the viewed run and fine claim across reload and browser history. An ID shared by several claims opens a group; choosing a row is inspection, not an exact CLI selector. Test this threat previews a safely quoted targeted-auto template for an open, unambiguous historical claim, or explains why it is unavailable. Current source eligibility, environment and login readiness remain unknown: opening the page runs no export, agent, login check or scan. Supply the target, profile names and actual authorization yourself; an existing recipe's target takes precedence over --target. Saved-probe replay is offered only after the local session index and matching stored probes are inspected, with its stored target/auth/scope shown separately. It may replay several variants for one ID, not one fine row. Commands remain labelled Copy template, never “Run now”, and are commented out so pasting an authorization placeholder cannot execute a probe. Fill every missing value and review readiness and authorization before removing the leading # locally. Copy ID, link, source location and command are separate actions, with selectable manual-copy text if the clipboard is unavailable.
  • 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 saidModeBehaviour
nothing about auth, no envSEEDa seed script creates ≥2 equal-privilege users, then self-heals
a credentials tableCREDENTIALSfully unattended; re-authenticates forever
"I need a browser to log in"BROWSERflagged 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.

Header-gated targets can give each recipe identity an extra_headers map, for example {"apikey": "<project key>", "Authorization": "Bearer <identity token>"}. bravos writes this to the cxg profile's existing AuthProfile.extra_headers field in <profile>.meta.json; it does not invent a second header field in Playwright storage state. With no password it creates the static profile without attempting a guessed login or writing an empty Playwright state file. With a password it carries those headers on the login requests too. A captured header is not proof of authentication: liveness still comes from the target's configured probe. A header with illegal characters (such as a newline) is refused with a credential-free error.

Saving a recipe redacts header values from manifest.yaml and keeps them in owner-only auth-headers.json, keyed by profile. Every captured profile file is owner-only too. bravos's liveness probe uses static credentials only at the profile's recorded target origin; login and liveness redirects cannot forward them to another origin.

3 · Lenses

Six perspectives, rotated deterministically: unauth, mitigation-audit, stduser, crosstenant, admin, chained. Round 1 defaults to unauth unless an approved plan specifies another order. unauth and public mitigation-audit need no account; named anonymous actors do not count as signed-in users. mitigation-audit is scheduled early 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:

SlotSource
scopeexposure + resolved route
actorlens + seeded identities
chainthe declared @flows edge this exposure consumes — empty unless the run chains (see below)
preambleenvironment recipe (goal_preamble) — e.g. a CSRF handshake
directivesthe approved plan (the operator's emphasis, the OAST canary)
mechanismthe SARIF message, verbatim
baselinelens (the control that must still hold)
successper 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.

Callback-capable goals (ssrf, xxe, rce, command_injection) select cxg's readable registered interactsh session by default when the flag is supported and the canary server is reachable. An explicitly configured external canary is preserved, but it is inject-only: cxg cannot poll it. CSRF, CORS and clickjacking goals select the attacker-origin primitive without broadening the target host scope or enabling destructive requests. Request-smuggling/desync raw sockets remain opt-in through cxg.allow_raw_socket, because byte-level probes can disrupt a target.

Availability is not coverage. goals.json and each attempt's correlation.json carry proof_unavailable reasons; the finding's ledger note and goal outcome say why a stronger proof route was unavailable (unsupported flag, unreachable canary, inject-only channel, or raw sockets off). Arming a channel alone is not a callback, a browser action, or an exploit proof. In-band SQL/command-injection observations remain distinct from an unavailable callback experiment.

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

CommandWhat it does
bravos (no command)Open the terminal UI — threats, live runs and what they need from you, for one repository (the same as bravos tui). bravos <command> runs that command instead. Into a pipe or CI it prints the get-started intro rather than opening the UI
bravos welcomePrint the get-started intro — the banner, the tools bravos drives, and the first commands to try (what a bare bravos shows into a pipe)
bravos init [<repo>]Set a repository up for Bravos in one idempotent command — GuardLink in external mode, its scope, the published model and agent instructions, a type-checked code graph (--scip no-exec by default, --trust-repo to run the repository's build code) and the SAST analyzer (-y installs or upgrades it on a licensed machine) — then a ready/not-ready table and what to run next; commits nothing, --dry-run, --json (above)
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 dashboardLocal 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 LPlan + execute a single round (the other verb that takes --chain-declared-flows)
bravos plan <repo> --lens LShow the goals and cxg commands a round would run
bravos inspect <repo>Threat-model state + where guardlink's view is lossy, and what a default claude -p would load and run in the repository (Bravos's own agents load none of it)
bravos env init|up|login|health|downStand 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 diff [<ref>]What a change moved in security terms, and whether it is safe to merge: tiers from reaches-confirmed down, including auth-guard-changed — a changed function the graph's authentication boundary reads as a login, permission or role guard for some route — and route-reaches-sink — a changed function an HTTP route reaches that touches a database, shell, template, network or filesystem sink, both straight from the code graph with no annotation needed. Advisory by default (only reaches-confirmed fails; --fail-on <tier> opts in), and a graph too old, too sparse or out of date is named as could not tell, never as clean. --format markdown prints the pull-request comment body; --reporter github annotates the changed lines (design)
bravos pre-pr [<repo>]The checks to run before raising a pull request, deterministic by default: the threat model against the PR's base (parse errors, new exposures, flows and acceptances, guardlink's lint of what changed), the change's new entry points, routes and sink calls that carry no annotation, SAST over the changed files with each issue marked NEW when the change wrote its line, and a list of what to fix first. Every stage this machine cannot run — no coding agent, analyzer, graph, network or server — is reported as skipped with its reason; --annotate writes missing annotations with a coding agent, --push offers bravos push and pushes only with a yes, --require sast makes a pipeline exit 3 without a scan (design)
bravos hooks list|show|install <name>Opt-in templates that make bravos pre-pr automatic, written for you to edit: a Claude Code PreToolUse hook that runs it before gh pr create and blocks the PR on a finding the change introduced, and a git pre-push hook. Installed only after a yes at the prompt or --yes; bravos init never installs one, and tells your coding agent to run and suggest bravos pre-pr instead (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, the code graph's security surface (--no-graph sends everything but that), and the agent reach export from the run's threat model (exactly what is uploaded)
bravos sast scan|gateThe separate deterministic-SAST channel: its own report, its own .bravos/sast-policy.toml, its own exit code. scan writes a missing or stale threat model first (annotate-then-scan; --no-annotate skips) and ends by recommending — never running — a cxg verification of its top corroborated, reachable findings; gate holds IaC/CI findings, test-path secrets, unreached or dev-only advisories and review-labelled noise to warnings by default (design). The agent_config stage judges the repository's coding-agent configuration by default: bypass modes, committed secrets, endpoint redirection (no analyzer check reports it yet) and known-vulnerable MCP packages fail, every other agent-config finding warns, and [classes] agent_config = "off" turns the stage off
bravos graph build <repo>Index a repository into its code graph — ships in the wheel, needs no licence and no annotations; --scip auto / --trust-repo also merges the installed type checkers' indexes and runs the repository's build scripts, --isolate confines them (below)
bravos graph [<question>]Ask the code graph from a terminal — bare, it lists what this repository's graph can answer; bravos graph entry-points (IPC commands, MCP and LLM tools, deep links, listeners, CLI commands and routes, each with who can drive it), bravos graph routes, bravos graph attack-surface, bravos graph reachable-sinks <symbol>, bravos graph callers 'src/ci.py::gate', --json for pipelines (below)
bravos coverage [<repo>]Which functions a route handler reaches carry no GuardLink annotation, ranked by how many handlers reach them, and which annotations sit on code no handler reaches (--depth hops, default 3; --json). Advisory: exit 0 when it measured, 3 when it did not — when the graph cannot support the answer (no route support, a sparse resolver by the graph's own gate, the one attack-surface and untested read) it says not measured and why rather than printing an empty list. The annotate phase runs the same check after every pass
bravos attack-surface [<repo>]A report to read and share: every entry point — routes, desktop IPC commands, MCP and LLM tools, deep links, message listeners, CLI commands — with its kind, who can drive it and its handler, the sink classes each reaches with an example path, ranked; SSRF and exec reach called out; unbound registrations listed; the graph's confidence verdict first. --entry-model <file> / --repo-models add entry models. `--format text
bravos model <repo> --draftA threat model proposed from the code graph for a person or agent to confirm: route and entry-point flows, exposure candidates per reachable sink class, flows to sink-backed assets, and agent-reach candidates (@agents/@effects) for MCP and LLM tools, written as guardlink annotation text into .bravos/draft.gal.txt, which guardlink, cxg and the gates do not read. --promote <id>… moves confirmed entries into guardlink's sidecar at their anchor. Exit 0 only when the graph ranked; below its confidence gate only route flows are drafted and the command exits 3 (below)
bravos untested [<repo>]Reachable security-relevant code no test reaches: every route-reachable declared @exposes and every route-reachable function that makes a sink call, with the graph's test verdict, the route that reaches it and the nearest test, ranked by reach. Rows the graph cannot decide are listed apart, never as untested. `--format text
bravos mcpServe bravos to a coding agent over stdio — the threat model, the ledger and the code graph as MCP tools (below)
bravos skills list|show|installAgent skills for using Bravos well: show <name> prints one and writes nothing; install copies them into your agent skill directories (or, with --local, the repository's) after asking (below)
bravos doctorWhat 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 versionWhich build this is — the verb form of --version
bravos updateUpgrade bravos from PyPI with the installer that made it (pipx, uv tool, pip in a virtualenv or --user), then bring the installed analyzer, GuardLink, cxg and cxg's pentest pipeline to the versions the new bravos pins (bravos setup --update, run in a fresh process), asking before each — --yes approves all, --check only reports the whole toolchain, --json for pipelines. Stops at the first failure with the command to run by hand (exit 3); a system or externally-managed Python, an editable checkout, or an offline machine gets the command instead (above)

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.

Pentesting a pull request

Two ways, both leaving your checkout where it is — the run works in its own worktree.

By PR number (needs gh, signed in; github.repo is honoured for -R):

bravos auto . --target http://localhost:3000 --attestation "Authorized self-test, <name>" --pr 42

bravos reads the PR's base and head with gh pr view 42, fetches its head with git fetch origin pull/42/head:refs/bravos/pr/42 (a fork's PR works the same way: GitHub serves every PR head on the base repository), starts the run from that commit, and narrows it to the change against the base — --delta origin/<base> unless you pass your own --delta. The run is filed under pr/42, and its manifest records the PR (pull_request: number, base, head, the commit tested). Without gh, name the base yourself: --pr 42 --pr-base main.

Afterwards bravos writes the PR comment (confirmed / refuted / untested, what was confirmed, the delta scope) to <run dir>/pr-comment.md and prints the command that posts it. Nothing is posted unless you ask: add --post to auto, or run bravos pr comment --pr 42 --from-summary <run dir>/pr-comment.md --post.

In the TUI, the PRs view (g u) does the same with t on an open PR: it asks first, then uses the session's target and written authorization; the PENTEST column and the PR card show the latest run's confirmed / refuted / untested counts.

By branch (the PR's branch is already local or on origin):

bravos auto . --target http://localhost:3000 --attestation "…" --branch feature-y --delta origin/main

--branch starts the run from that branch's commit; --delta <base> narrows it to what the branch changed. In CI, --delta auto reads the base from $GITHUB_BASE_REF (PR-delta).

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 annotate commits that refresh on its run branch, as bravos run does. bravos inspect reports when those have gone stale without fixing them. Both bravos ci and bravos sast gate print an agent config changed section listing the instruction files (AGENTS.md, CLAUDE.md, .cursorrules, …) and agent configuration the branch edited since it left the integration branch. This is a line for the reviewer and never moves the verdict. 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 (external annotation mode — declared in the repository's .guardlink/config.json, set by guardlink.annotation_mode = "external" for a repository that declares none, or forced with bravos annotate --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.

Ask the code graph from a terminal

The code graph answers over twenty questions — which HTTP routes this repository registers and the handler each one binds, which of those routes reach a database, a shell, a template, the network, the file system or a secret, ranked, what one handler reaches and by which path, 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 of 471 GuardLink annotations ingested from .guardlink/report.json
               47 not ingested, verbs the graph does not hold: @comment 41 · @validates 6
  address      d11a187631d9bbae (git-remote) · main

The second number is GuardLink's own count, the one guardlink status prints. The graph does not hold @comment, @assumes, @validates and a few other descriptive verbs, and it says which. If GuardLink parsed records the indexer neither ingested nor named, that gap gets a line of its own. An older graph binary reports its count alone and says it is not comparable.

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 entry-points          # every entry point, by kind and who can drive it
$ bravos graph entry-points --kinds mcp_tool --boundaries agent
$ bravos graph routes                # every route registration and the handler it binds
$ bravos graph attack-surface        # entry points ranked by the sink classes they reach
$ bravos graph attack-surface --rank-by network   # one sink class: SSRF lands on `network`
$ bravos graph reachable-sinks 'controllers/api/user.go::User'   # one handler, with a path per class
$ 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 query --query '{"security": {"declares_exposure": true, "untested": true}}'   # compose: filter, walk and threat model in one

Routes are entry points — and so is everything else untrusted input enters by. A handler the framework calls has no caller in the repository, so the graph reads the registrations themselves — Express, Koa, Fastify, Hono, Restify, Flask, FastAPI, connexion, Django, net/http, gorilla/mux, httprouter and Spring — and never reports a bound handler as dead code. Since graph format 14 the same holds for the rest of a desktop or agent application: Tauri IPC commands (a #[tauri::command] registered in generate_handler!), Electron IPC, MCP tools (the SDKs and hand-rolled impl Tool registries), LLM tools, deep links, window and extension message listeners, CLI commands and serverless handlers, each recognised by an entry model — a JSON file the graph ships, one per framework — and each recorded with who can drive it: webview (script in the app's own window), agent (whatever prompt reaches the agent calling a tool; an MCP tool served over HTTP is network-reachable too), os_link, web_content, local, network. bravos graph entry-points lists them with the models that found them. attack-surface walks forward from each one. Read its verdict first: insufficient_resolution means the graph resolved too few of this repository's calls to rank anything, and the empty list is on purpose — never a small attack surface.

An in-house framework is a JSON file, not a release. --entry-model my-framework.json (repeatable) adds an operator model on graph build, attack-surface, untested, model --draft and sast scan; --repo-models also applies the repository's own .bravos/entrypoints/*.json, which is off by default because the repository wrote them, and --trust-repo turns it on with the rest of trusting the repository. Both are additive: a model can add entry points and can never hide one a shipped model finds. Put them under [bravos] as entry_models = ["…"] / repo_models = true to keep them for every graph a verb builds — an annotate round re-checks its graph with no flags, and the graph re-recognises entry points whenever the settings it is asked under change.

The annotator starts where the attack surface is. Before each full annotation round the annotate phase asks the same question and puts a short, bounded worklist in the agent's prompt — the highest-reach entry points (routes, IPC commands, MCP tools…) named by kind and by who can drive them, with the sink classes each reaches and one example path, handlers no annotation mentions yet first, plus any handler lower down that reaches the network or a shell and the heaviest of each entry kind the top rows leave out — so a desktop app's MCP tools are not buried under its IPC commands. It is advice, not scope: nothing the agent may annotate changes. When the graph cannot rank — a sparse resolver, no recognised routes, graph binaries that predate attack-surface — the prompt is the one it was before, and the round's annotate/worklist.json, the progress line and the run summary say why. annotate_worklist = false under [bravos] turns it off.

Each lens gets its own slice of the graph. The worklist is the same ranked surface every round; a lens is an attacker perspective, so beside it the annotate phase hands each lens the graph scoped to its own question — unauth the routes reachable before login, stduser the after-login surface, crosstenant the routes that take an object id (the IDOR surface), admin the elevated routes and the sink classes each reaches, and chained the call paths that join one exposure to the next. Entry points that are not routes join the slice by who can drive them: unauth also gets the IPC commands any webview script can invoke, deep links, message listeners and tools served over an HTTP transport (none of which has a login in front of it), stduser the MCP and LLM tools an agent calls and the CLI and local IPC a local user drives, and chained, until a finding is confirmed, the graph's shortest entry → sink paths — so a desktop or agent application no longer needs a hand-written --focus to point the agent at its real surface. mitigation-audit keeps its prompt. The slice rests on the authentication boundary the graph now carries (bravos graph auth-boundary), and it obeys that boundary's confidence gate: below the resolver-density gate the public surface is withheld entirely (a graph that cannot see a guard would call a protected route public), while authenticated and elevated verdicts — which rest on a guard found in source — are kept with the gate stated plainly. Like the worklist it only steers, never scopes; every decline leaves the prompt as it was and records why on annotate/slice.json, the progress line and the run summary. annotate_slice = false under [bravos] turns it off.

A run hands cxg the graph's routes. Each round's export places every exposure on the function it annotates, walks the call graph inward to the route handlers that reach it, and writes the nearest route, reachable and enclosing_function into the SARIF cxg reads (codegraph_reachability), with route_source: graph — or flows where the graph found no route and the @flows annotation's route is kept. An exposure reached only from an IPC command, an MCP tool or another entry point is reachable too, with the entry named in graph_entry_points; it has no route for cxg to probe, so its @flows route, if any, is kept. A route the graph replaced is kept as declared_route. When the graph cannot vouch for its answer — its gate reports insufficient_resolution, the installed binaries predate the route table, or it is out of step with the tree — the export keeps today's @flows routes and says why, in the run's progress output and in the SARIF run's bravos_graph_routes. Under insufficient_resolution one thing is still kept: an exposure on a function that is itself a route handler gets that handler's route, because it needs no call edge (code: direct-only). [bravos] graph_routes = false turns it off. The default terminal progress shows the routed/total location counts from that export. Malformed route metadata leaves the base exposure count visible without interrupting the run.

Declared boundaries are claims the graph verifies. Each round's export reads every @boundary, @assumes and @handles in the threat model as a claim keyed by guardlink's claim key, from guardlink's SARIF where it carries them (run.graphs, guardlink/boundary-claim) and from guardlink parse otherwise. A directed boundary, @boundary from <outer> to <inner>, has the sides its author declared. An undirected one's untrusted side is inferred as the operand the model never declared as an asset; with two declared operands, or none, its direction is unknown and it is untestable. Every verdict records which basis it used: direction (declared, inferred or unknown) on each claim, the basis in its reason, and boundary_directions in the summary. Where the declaration names an access requirement ("everything past this point has been authenticated"), the routes whose handlers sit in the inner asset's files are checked against the graph's measured access levels: supported when all of them are at least that protected, contradicted when one is public (public by absence counts only above the resolver-density gate), unverified with the reason otherwise, and untestable when no measurement here could decide. The answer is rounds/<n>/claims.json and a boundary_claims event. A claim never closes, demotes or suppresses a finding; a contradicted boundary is reported as a finding of its own. [bravos] boundary_check = false turns the graph check off.

--scip auto resolves what tree-sitter cannot. bravos graph build . --scip auto also runs each installed type checker that applies to a project root in the checkout — rust-analyzer, scip-go, scip-typescript (JavaScript too), scip-python, scip-java, scip-dotnet, scip-clang — and merges its index, so calls into your dependencies and across files become edges. Measured on gophish, the Go feed placed 2,180 call sites tree-sitter had left unplaced. It is off unless you pass it, and the mode you pass is what decides whether the repository's own code runs:

FlagWhat runs
--scip off (the default)no type checker — the parser-only graph
--scip auto, --scip trusted, --trust-repoeach type checker natively, executing the repository's code: rust-analyzer runs its build scripts and procedural macros, scip-java and scip-dotnet run the project's Maven/Gradle/MSBuild build. Only for a repository you trust
--scip no-execeach type checker with nothing the repository wrote executed; Java, Kotlin and C# then get none
--isolate (--scip isolate)the type checkers inside a container or the platform sandbox, and none where there is neither

bravos attack-surface, bravos untested, bravos model --draft and bravos sast scan take the same three flags (sast scan defaults to auto, the analyzer's own default; its graph is the analyzer's, so the indexer budget flags below are not on it). Every build that ran the type checkers leads with a ⚠ semantic resolution line when a parsed language was left without them — naming the language and the one command that fixes it — and --json carries the same as semantic with degraded: true; the exit code stays 0, because the graph is correct, only thinner. Nothing is installed by a build: bravos setup --check proves each type checker by running it the way a build would — proven, indexer_missing, or indexer_broken with the exact fix (a rustup proxy whose pinned toolchain has no rust-analyzer component is broken, not found) — once for the machine and once in the repository you run it from, because the toolchain a repository pins decides what runs there. Install dependencies first (cargo fetch, go mod download, npm ci, pip install -r requirements.txt); without them an indexer resolves little or, for Go, fails and says so. bravos setup --indexers fetches the signed type-checker bundle into $BRAVOS_HOME/indexers once a release publishes it.

A large repository needs a bigger budget, and gets one root per line. The builder runs one type checker per project root, one after another, and stops each after 600 s (scaled by machine load to at most 1,800 s; Rust defaults to 1,200–3,600 s), and caps each one’s memory at 8 GiB (Rust can default to 16 GiB). One big package can need more: a single large TypeScript project took 17 minutes on its own. --scip-timeout <seconds> raises that budget, --scip-memory <size> (16G, 12GiB, 6144M; a bare number is GiB) raises the memory cap, and --indexer-dir <dir> names a directory to look for type checkers in first; all three work on graph build, init, attack-surface, untested and model --draft, and [bravos] scip_timeout_s / scip_memory / indexer_dir keep them for every build. --rust-toolchain pinned|local and [bravos] rust_toolchain choose the project pin or rustup’s default without downloading; a missing pin is toolchain_missing, not an install attempt. When a go.mod asks for a newer Go than the one installed, scip-go runs on a toolchain already in the module cache; --go-toolchain auto lets the build download it instead, and is never read from the configuration, because it is a network fetch. Every TypeScript project is its own type-checker run, and a Python root may take up to four passes, each with the full budget. The report prints one line per project root — its type checker, how long it ran, and merged, partial (part of its index was kept before it stopped), skipped (discovery caps or omitted .NET jobs, never run) or why not, with the fix, and pass N of at most 4 on a Python root that needed more than one — and --json forwards the builder's own rows, where partial is a failed row that carries an ingest_id. A second build of the same graph waits for the first to finish and answers from the graph it built, instead of failing; the wait shows as waited and, in --json, waited_for_writer_s. For Rust cache control, JVM/clang partial shards, .NET solution batches, PHP autoload-only coverage, Ruby warnings and non-UTF-8 source-skip receipts, read large-repository preparation before building; the same language guidance ships in bravos-codegraph’s reference.md.

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.

An attack-surface report you can share

bravos attack-surface turns the graph's entry-points, routes and attack-surface answers into one report: every entry point — routes and, on a desktop or agent application, IPC commands, MCP tools, deep links, message listeners and CLI commands — with its kind, who can drive it and its handler, the sink classes each handler reaches with one example path per class, ranked by the graph's weighted reach; entry points that reach an outbound network client (where SSRF lands) or process execution called out by name; registrations the graph could not bind to a handler listed with the reason; and the graph's own confidence verdict stated before any of it. It builds the graph when there is none for this commit (--no-build reuses only, --scip auto indexes with the installed type checkers first).

$ bravos attack-surface .
  status       RANKED
  confidence   sufficient — 1,191 resolved call edges over 992 functions = 1.20 per function (needs 0.50); …
  routes       48 registration(s) · 46 handler(s) · 0 unbound · gorilla_mux

  OUTBOUND NETWORK — WHERE SSRF LANDS (4)
    ANY /api/import/site  →  controllers/api/import.go::ImportSite
      ImportSite → client.Get (controllers/api/import.go:125)
  …
$ bravos attack-surface ~/src/desktop-app       # a Tauri + MCP desktop application
  entries      236 entry point(s) · 1 http_route · 177 ipc_command · 2 deep_link · 10 message_listener · 45 mcp_tool · 1 cli_command · 1 unbound
  boundaries   177 webview · 45 agent · 10 web_content · 2 os_link · 1 local · 1 network

  PROCESS EXECUTION AND CODE EVALUATION — WHERE COMMAND AND CODE INJECTION LAND (11)
    ipc_command agent_spawn [webview]  →  src-tauri/src/commands/agent/supervisor.rs::agent_spawn
      agent_spawn → launch → Command::new (src-tauri/src/commands/agent/supervisor.rs:1146)
  …
$ bravos attack-surface . --format markdown -o attack-surface.md    # the document to share
$ bravos attack-surface . --format sarif -o attack-surface.sarif    # one result per route × sink class

It never presents a thin result as clean. When the graph's gate says it resolved too few of the repository's calls to rank (CommonJS require() code is the usual case), or the installed graph binaries predate the question, the status line says NOT RANKED or UNAVAILABLE with the reason, the routes the graph could list are listed without a ranking, the SARIF run is marked unsuccessful rather than empty, and the command exits 3. Only a ranked report exits 0.

Each run is recorded under $BRAVOS_HOME/attack-surface/<graph identity>/, so the next run says which routes and other entry points are new or gone and which gained or lost reach to a sink class since then (reach is compared only when both runs ranked). --no-record leaves the history alone.

Untested reachable code

bravos untested asks the code graph which security-relevant code a route reaches and no test does: every function that makes a sink call (data layer, process execution, templates, outbound network, filesystem, crypto) and every declared @exposes, each with the route that reaches it, how far in, and the test that comes nearest. Rows are ranked by how close a route is to them. It builds the graph when there is none for this commit (--no-build reuses only, --scip auto indexes with the installed type checkers first).

$ bravos untested .
  status       RANKED
  confidence   sufficient — 1,191 resolved call edges over 992 functions = 1.20 per function (needs 0.50); …
  tests        28 test file(s) of 101 (go_suffix 28) · 1,620 calls from tests, 1,234 of them unresolved
  subjects     123 security-relevant symbol(s) (123 calling a sink, 0 declaring an exposure) · 87 reached by a route · 36 by none · 0 unknown
  found        15 of 87 sink-touching symbol(s) no test reaches · 20 the graph cannot tell · 52 reached by a test

  NO TEST REACHES THESE (15)
    #   route                                         symbol                what              nearest test
    25  ANY /api/webhooks/{id:[0-9]+}/validate +1 (…  GetWebhook            calls data_layer  none within reach
    40  ANY /api/campaigns/{id:[0-9]+}/results (1 h…  GetCampaignResults    calls data_layer  none within reach
  …
  A TEST MAY REACH THESE — THE GRAPH CANNOT TELL (5)
    1   ANY /api/import/site (0 hops)          ImportSite  calls network, data_layer  controllers/api/api_test.go names ImportSite
  …
$ bravos untested . --format markdown -o untested.md     # the document to share
$ bravos untested . --format sarif -o untested.sarif     # warning per finding, note per undecided row

Only no test reaches it is a finding. The graph walks backwards from each symbol over the calls it resolved. When a call it could not resolve names something on the way, when the symbol is a type or property reached by member access, or when it is a route handler whose only caller is the router, the row is listed under the graph cannot tell and never counted as untested. A test that drives code over HTTP draws no call edge, and the report's What this cannot see section says so.

It never presents a thin result as clean. When the graph resolved too few of the repository's calls, recognises no route, or holds no file it classifies as a test, the status says NOT ASSESSED, nothing is listed as untested, the SARIF run is marked unsuccessful, and the command exits 3. When the installed graph binaries predate the question, the status says PARTIAL: only declared exposures are assessed, with the graph's older test-reach answer, and nothing about sink-touching code or routes is claimed. It also exits 3. Only a ranked report exits 0.

A threat model drafted from the graph

bravos model <repo> --draft writes what the code graph already knows as guardlink annotation text, for a person or a coding agent to confirm. The draft has three parts. Each route becomes @flows External.Client -> <handler's asset> via METHOD./path. Each sink class a handler reaches becomes an @exposes candidate, with the graph's example path in its description. Each sink a handler reaches also becomes a @flows to the sink-backed asset (Infrastructure.Database, External.Network, …). Any @threat the model doesn't already declare is proposed beside them. Every other entry point the graph recognises becomes a flow from who drives it, named by kind: @flows External.Webview -> <asset> via ipc_command:agent_spawn, External.Agent for an MCP or LLM tool, External.OS for a deep link, External.WebContent for a message listener. An MCP or LLM tool whose handler reaches a sink that can change something (process execution, the file system, the data layer) is also drafted as agent reach (guardlink's @agents, SPEC §3.2.1): @agents #mcp-client to <tool> on <asset>, with the @actor it needs, plus an @effects where the graph's example call proves the effect class — Command::new executes, INSERT writes; a read is never drafted as a write. Promoted, guardlink reports each such reach as unentitled until a person writes the @entitles, which is the review an agent's blast radius is for. Entries are grouped under one @source header per handler, and anything the model already declares is left out.

$ bravos model . --draft
  status       RANKED
  confidence   sufficient — 1,191 resolved call edges over 992 functions = 1.20 per function (needs 0.50); …
  drafted      48 route flow(s) · 55 exposure candidate(s) · 35 sink flow(s) · 4 threat definition(s)
  conformance  guardlink parsed all 142 drafted line(s)
  wrote        ./.bravos/draft.gal.txt
$ bravos model . --promote d29bd7e1d ddb806e6f     # the entries you confirmed, after any edit

Nothing reads the draft as a claim. It lives at .bravos/draft.gal.txt. guardlink skips .bravos/, and neither guardlink nor cxg opens a file with that suffix. bravos ci, bravos sast gate and the code graph read annotations only through guardlink, and bravos diff leaves the draft out of the change it judges. Each run also checks the text: the installed guardlink parses a scratch copy, and every drafted line has to come back as an annotation.

Below the graph's confidence gate, only routes and entry points are drafted. They come from the graph's registration tables and need no call edge, so they are drafted whenever the graph can list them. Exposure candidates, sink flows and agent reach need reach. When the graph's gate says it resolved too few calls, or the installed graph binaries predate the question, the draft's status is ROUTES-ONLY with the graph's reason. It carries no candidate at all, and the command exits 3. When the graph cannot list routes either, no file is written.

Promotion writes where guardlink's own structured write does: guardlink's sidecar .guardlink/annotations/<file>.gal, under the handler's @source file:… line:… symbol:…. A @threat or @actor the entry needs is added to the existing .guardlink/definitions.*, using that file's own comment marker. Promoted entries leave the draft. guardlink then re-parses the repository, and the command fails unless it reads exactly as many new annotations as were written. A draft you edited is never overwritten by the next --draft unless you pass --force. A route the framework registers for every method is drafted as via HTTP <path>, which declares no route channel; name its methods before promoting it.

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
    agents      yes   7 reaches — review items: 5 unentitled, 5 ungated
    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. Six things cross the wire, each on its own endpoint:

WhatWhere it comes from
Ledger eventsevery verdict the run recorded: the exposure key, the verdict, the reason, the round, the lens, and the evidence attached to a confirmationledger.jsonl, which the run already wrote
Run summaryhow 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 flagsmanifest.json, lenses.json, profiles.json
Guardlink dashboardthe generated threat-dashboard.html, as an opaque blob the server stores and serves back unparsedyour codebase, when it has one
Finding advisoriesper confirmed finding: the advisory write-up, the confirmation evidence, the probe's request trail, and the probe idthe run's own report/finding-artifacts.json
Graph security surfacesee belowthe code graph, read at push time
Agent reachwhat each AI agent and other principal embedded in your code can reach, and guardlink's answer to which of those reaches nobody entitled and which writes nobody gates — see belowthe run's own stored threat model

The first four have been crossing since their channels shipped. The last two carry detail about your code rather than about a finding, so each 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::symbol addresses.
  • 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 / @mitigates annotations. 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, advisories and agent reach; 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.

The agent reach export, in full

What it is: guardlink's reach export for the threat model the run stored — the @agents/@reaches, @effects and @gates annotations, and guardlink's own reach_analysis over them. It is sent only when your guardlink exported that analysis and the model declares at least one of those annotations; otherwise the push line says why nothing went. Every item in it is a review item, never a finding: a capability nobody entitled, or a write nobody gates, is something to look at or test, and the server is told not to count it as a finding.

What is in it:

  • Repository-relative file paths and line numbers — of each @agents, @reaches, @effects and @gates annotation, and of the @entitles and @gates that nearly covered one.
  • The annotations' own words, verbatim — actor names, capability names, asset names, the identity an effect runs as, the approver a gate names, and the description prose written in each annotation.
  • Which reaches are unentitled and which mutating effects are ungated, with guardlink's reason for each near miss (uncited, no-asset, other-asset, capability-unknown, other-capability) and which effects and reaches are bound to the same code.
  • guardlink's claim keys — its digest of each annotation's own words and file, the key its SARIF results carry, so the server can join the two. A digest of the annotation, not of your code.
  • guardlink's counts — reaches, agent reaches, unentitled reaches, effects, mutating effects, ungated effects, gates — and the guardlink version that produced them.

What is not in it:

  • No source code, and no hash of code: guardlink records a hash of the code beneath each annotation, and the push drops it.
  • No absolute paths, no machine, user or host identity, no credentials, tokens or target URLs.
  • No key guardlink adds later: the export is copied through a fixed list of fields, so a newer guardlink cannot widen what is sent without this section changing first.

Coding-agent reach, when your threat model has it. guardlink can read the repository's own coding-agent configuration (.claude/, .mcp.json, .cursor/, skills, hooks) into the model as what each agent can do. A model that carries it is sent as version 2 of this export. Each such row carries its harness, scope, and the configuration file and line that granted it. A count summary is added: files per harness and kind, MCP transports, hook events. Never sent from agent configuration: the setting's text, commands, URLs or hosts, environment variables, or instruction-file text. A model without it is sent exactly as before.

Sending it is re-runnable and cheap to store, for the surface's reason: it is identified by a hash of its own content, computed without the run id or any timestamp, so an unchanged threat model pushed every day costs your server one stored export. A server that predates this channel answers 404; the push reports it on the agents line and still succeeds.

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:

ToolsWhat the agent gets
list_repositories, get_repository, get_advisoryWhich 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_artifactRun history, one run's whole picture, its event stream, and its reports and artifacts
generate_modelBuilds 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_verificationReturns 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_queryThe code graph — routes and the ranked attack surface, 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_environmentsThe 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>.

Skills: how an agent uses Bravos well

The MCP tools say what bravos can answer; the skills say how to use it well — the order of the steps, which artifact each tool hands the next, the human gates, and what done looks like. They ship inside this package as ordinary SKILL.md files, so they are available the moment bravos is installed, to any agent host and to a person with a terminal:

SkillFor
bravos-security-reviewStart here. A security review end to end: graph, threat model, SAST, pentest, report
bravos-annotateThreat-modelling a repository (whole codebase, or its delta once covered), or annotating code you are changing
bravos-sastThe SAST channel: annotate-then-scan, read the stage line, gate, hand the cxg candidates to the pentest
bravos-pentestIntake → the human's approval → auto --plan; Electron targets through cxg pentest under the human's attestation
bravos-codegraphOne bounded graph question, reported with its confidence
bravos-reading-findingsWhat a verdict, a ledger or a SAST report actually claims, before you report it
bravos skills list                 # what ships, and where a current copy is installed
bravos skills show <name>          # print one to stdout — nothing is written anywhere
bravos skills install [<name>...]  # copy them into your agent skill directories, after asking

install lists every path it will write and asks with the answer defaulting to No (--yes for unattended use, --dry-run to print the plan only). By default it writes into your user-level directories, so your agent knows Bravos in every repository; --local (or --repo <path>) writes into a repository instead, for a team that wants to pin a version there. Under either, it writes one directory per agent host it finds evidence of:

HostDirectoryEvidence
Claude Code.claude/skills/<name>/.claude/ or CLAUDE.md
Codex, Gemini CLI, and other hosts that read the shared directory.agents/skills/<name>/.agents/, .codex/, .gemini/, AGENTS.md or GEMINI.md

With no evidence it uses .agents/skills; --host claude|agents chooses explicitly.

Other ways an agent finds them. bravos setup reports whether they are installed and offers the install (its own y/N; --yes installs them with everything else). bravos mcp serves every skill as an MCP prompt and as a resource at bravos://skills/<name>, which is how Cursor, Copilot, Windsurf and Claude Desktop reach them. Bravos never writes a pointer into CLAUDE.md or AGENTS.md: guardlink owns those files.

Each skills directory gets a .bravos-skills.lock recording the sha256 of every file bravos wrote. On a later install a file that still matches is replaced quietly when the shipped copy changed; a file somebody edited — or one bravos has no record of writing — is shown as a diff and refused (exit 2, nothing written) until you re-run with --force. A renamed skill (bravos-running-a-scan → bravos-pentest, bravos-writing-annotations → bravos-annotate) is retired by the same rule when its successor is installed, and the old name still works in show. Commit the lock with the skills when you install into a repository, so a teammate's install recognises them. After pip install -U bravos, show serves the new text at once; bravos skills list reports an installed copy as stale until you install again.

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; 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 anything git status does not report even when asked about ignored paths — a nested repository, an --assume-unchanged file — 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 entry web in a whole-tree status, and it is the source pathspec that filters it out, because *.go matches 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/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.

Keywords

appsec

FAQs

Related posts