# Bastion user guide (full) > The complete user guide, concatenated in reading order. Canonical pages live under https://bastion.attune.inc/guide. # Bastion user guide > Agentic code review for a world where agents write all of the code. This guide teaches you how to use Bastion on your own project: what it is, how to run it, how to write reviewers, and how to wire it into CI and governance. It is written for two audiences at once (the human curating the review policy and the agent looping against it), because Bastion runs the repository's reviewers and merge gate for both through whatever surface is natural to each (both surfaces feed a detected PR's discussion to the reviewers, and a purely local run can add an author's personal user-level reviewers, which CI never sees). This guide is self-contained: everything you need to run Bastion, write reviewers, and wire it into CI is here, with nothing essential living elsewhere. If you want to work on Bastion itself rather than use it, the contributor and design docs live in the [Bastion repository](https://github.com/attunehq/bastion). > **Reading this as an agent?** The whole guide is also served as a single plain-text > file at [`bastion.attune.inc/llms-full.txt`](https://bastion.attune.inc/llms-full.txt), > so you can ingest every chapter in one fetch instead of crawling pages. ## Read in order The chapters build on each other. If you read them top to bottom you will go from "what is this" to "running it in CI with a governed policy" without backtracking. 1. **[Introduction](./introduction.md)**: the problem Bastion solves, the core idea (reviewers as fitness functions), and the mental model. Start here. 2. **[Getting started](./getting-started.md)**: install the CLI, write your first reviewer, and run your first review in about five minutes. 3. **[Concepts](./concepts.md)**: reviewers, triggers, modes, the verdict, and the merge gate. The vocabulary the rest of the guide assumes. 4. **[Authoring reviewers](./authoring-reviewers.md)**: the registry schema in full, from the four required fields to timeouts, backends, environment, and prompt inputs. How to write a reviewer that stays at high recall. 5. **[The local workflow](./local-workflow.md)**: the `bastion review` loop in depth: human output vs. the JSONL agent stream, exit codes, and inspecting saved runs (`runs`, `show`, `transcript`, `clean`). 6. **[Continuous integration](./continuous-integration.md)**: promoting your repository's reviewers into GitHub Actions: checks, the aggregate gate, and per-author billing. 7. **[Governance](./governance.md)**: keeping humans at the policy layer with CODEOWNERS and branch protection, the escape-to-improvement loop, and what Bastion deliberately does not guarantee. ## In a hurry: set up Bastion in CI If your goal is "get Bastion reviewing pull requests on GitHub," here is the whole path; each step links to its details: 1. **Install the CLI and pick a backend.** [Getting started](./getting-started.md) (a subscription works; no API key required). 2. **Write `.bastion.yaml`** at your repo root with one or two reviewers, and check it with `bastion validate`. [Authoring reviewers](./authoring-reviewers.md). To pin a model like `gpt-5.5:high`, set `model:` and `effort:` separately under a pinned `backend:`. 3. **Add the workflow** and the per-author auth step. [Continuous integration](./continuous-integration.md#the-workflow). The complete, copy-pasteable auth recipe (the `_AUTH_` secret convention, the `case`-arm mapping, Dependabot, and fork safety) is in [Authentication & billing](./continuous-integration.md#authentication--billing). 4. **Protect the policy and require the check.** [Governance](./governance.md): CODEOWNERS over `.bastion.yaml` and the workflow, and branch protection requiring the aggregate `bastion` check. ## The one-paragraph version You declare **reviewers** (focused agent prompts, one concern each) in `.bastion.yaml`. Each reviewer has a **trigger** (path globs or an agent prompt) and a **mode** (`gate` blocks the merge, `advisor` only comments). `bastion review` finds the reviewer candidates for your working-tree changes. Each candidate executes in parallel, records an agent-trigger skip, replays from a verified attestation (CI), or carries its unchanged pass from the newest prior run on the branch that resolved that reviewer (local or CI). Their terminal outcomes aggregate into one decision: every applicable gate must pass, while a semantic skip counts separately from a pass. A local run uses personal reviewers from a user-level `.bastion.yaml` when a repo has not adopted Bastion, or merges them with `--with-user-reviewers`. An authoring agent loops `bastion review` to a green gate (the bundled skill stops after three full reviews), then opens a PR where CI executes, skips, replays, or carries the repository's reviewers (the user-level ones are local-only). CI usually confirms the result. They still differ when a local run cannot see the pull request, and when personal user-level reviewers are merged in. Humans stay in the loop by owning the reviewer registry, not by reading every diff. ## Status Bastion is experimental and still partial. The routing, runner, verdict aggregation, and on-disk run store are implemented and tested, and the Claude Code, Codex, Pi, Grok Build, and Muse Code backends execute reviewers for real, natively or inside a container when a reviewer declares a `runner` and opts into `capabilities.network: true`. The remaining capability fields (`mcp` and `skills`) are accepted but not provisioned, so a reviewer that opts into one fails closed rather than running without it. `network: true` grants a containerized reviewer general (unscoped) egress; a container with the default `network: false` is rejected before it runs, so a gate blocks and an advisor is skipped (provider-only scoping is unbuilt). These are called out where they appear in [Authoring reviewers](./authoring-reviewers.md). --- # Introduction > Why Bastion exists, and the one idea you need to hold in your head. ## The problem Agents write most of the code on a growing number of teams. When they are fully unlocked, output volume looks more like *engineers x 100* than *x 1*. Two things stop teams from unlocking that: - **Human diff review does not scale.** Asking a 5-person team to review their agents' output is like asking 5 people in a 500-person org to review the other 495. You cannot fix that by trying harder. - **Without review, codebases rot.** Things go fine until they do not, and then you have a ball of mud nobody can work in. The usual shape of agentic review hands the whole diff to one reviewer that checks everything and writes comments designed for a person to act on. As you ask one generic reviewer to check more things, its recall on any single one degrades. A one-item checklist agent works; at ten items it is weaker; at a hundred it fails. ## The core idea In Bastion, a reviewer is a **focused fitness function** (an automated check that continuously asserts one property holds as the system evolves), and review is the **author agent's loop taken to its conclusion**. An authoring agent already loops against the compiler, the linter, and the tests. Bastion adds loops whose oracle is *another agent*, one that encodes judgment a compiler or a test cannot. The whole system follows from five principles: 1. **One concern per reviewer.** Single-responsibility reviewers stay at high recall and confidence. The unit of the system is *the reviewer*, not *the review*. You cover more ground by adding narrow reviewers, never by broadening one. A cross-cutting property like tenant isolation or migration safety is not special; it is just another reviewer whose single concern is that property. 2. **Reviewers run in the author's own loop, not only in CI.** The repository's reviewers run locally (fast, pre-PR) and in CI (authoritative), so CI usually confirms a green local loop. The two can differ when a local run cannot see the pull request (no PR, or `gh` is missing or failed), so reviewers miss that discussion, and when a purely local run includes your personal user-level reviewers with `--with-user-reviewers`, which CI never runs (see [Authoring reviewers](./authoring-reviewers.md#user-level-reviewers)). 3. **Humans sit at the policy layer.** The goal is not human-out-of-the-loop. It is to move the human from reviewing diffs to *authoring, curating, and governing reviewers*, plus triaging escapes (bugs that slipped through a review that should have caught them). Your interface becomes the reviewer registry, not the diff. 4. **Aligned agents can still inadvertently game the system.** Bastion tolerates this and makes it *visible* and *easy to correct* by adjusting reviewers, rather than trying to make gaming impossible (which would give up the benefits of agentic development entirely). 5. **Reviewers converge through use.** Ship a reviewer that is good enough, then improve it from the escapes you actually hit, rather than trying to design a perfect one up front. The escape-to-improvement loop is where that happens. ## The mental model Picture the way a good team did code review before agents: > An author opens a PR. A reviewer reads it, leaves feedback (some blocking, some > optional) and withholds approval until satisfied. The author addresses the > blocking items (by changing the code, or by convincing the reviewer the code is > already right) and requests re-review. Repeat until approved. Bastion brings *that* process to the agent era. The reviewers play the colleague's role, their verdicts are the feedback, and the author agent resolves the blocking items and re-runs. The human is still in charge, but of the reviewers, not of every line. ## What Bastion is not Two non-guarantees are deliberate. Keep them in mind before you adopt it: - **No guarantee of correctness.** Bastion does not prove your code is free of bugs or vulnerabilities. It is code review without the human in the small loop; a reviewer is only as good as its model and its prompt. - **No guarantee the right thing is being built.** Catching "this is the wrong thing to build" was never review's job. By PR time that ship has sailed; it is a design-time question. Keep humans in the design loop. Bastion is also **not an adversarial security boundary**. It is the agent-era equivalent of team code review for aligned contributors: a speed bump and a set of good defaults that keep earnest actors on the rails, not a defense against a determined malicious one. The practical consequences for you, and how to govern within these limits, show up in [Governance](./governance.md). --- Next: [Getting started](./getting-started.md) -> install the CLI and run your first review. --- # Getting started > Install Bastion, write one reviewer, and run your first review. This chapter gets you from nothing to a working review loop. It assumes you have a git repository and one of the supported agent backends installed (the Claude Code, Codex, Pi, Grok Build, or Muse Code CLI). A little vocabulary shows up here in passing: *reviewer*, *gate*, *advisor*, *verdict*, *findings*. The inline definitions are enough to follow along; the next chapter, [Concepts](./concepts.md), defines each precisely. ## 1. Install the CLI The quickest path is the install script. It detects your platform, downloads the matching archive from the latest [GitHub release](https://github.com/attunehq/bastion/releases), verifies its SHA-256 checksum, and puts `bastion` on your `PATH`. On Linux, macOS, or Windows under Git Bash: ```sh curl -sSfL https://raw.githubusercontent.com/attunehq/bastion/main/scripts/install.sh | bash bastion --version ``` On Windows, from PowerShell: ```powershell irm https://raw.githubusercontent.com/attunehq/bastion/main/scripts/install.ps1 | iex bastion --version ``` On Windows both installers install `bastion.exe` to the same default location (`$env:LOCALAPPDATA\Programs\bastion`); use whichever matches your shell. The shell installer takes `-v/--version`, `-b/--bin-dir`, `-t/--tmp-dir`, and `-l/--libc` (pass them after `bash -s --`); the PowerShell installer reads the `Version` and `BinDir` environment variables. Pass `--help` (or set `$env:Help="true"`) to see them all. On Linux the installer autodetects the C runtime: it picks the statically linked musl build on musl systems and on any host whose glibc is older than 2.35 (or undetectable), and the glibc build only when the host glibc is 2.35 or newer (Ubuntu 22.04, Debian 12, RHEL 9, and later). Force the choice with `--libc gnu|musl` (or `BASTION_LIBC=...`) when you want to override it, for example to take the portable musl build everywhere: ```sh curl -sSfL https://raw.githubusercontent.com/attunehq/bastion/main/scripts/install.sh | bash -s -- --libc musl # ...or, without the `-s --` dance, via the environment: curl -sSfL https://raw.githubusercontent.com/attunehq/bastion/main/scripts/install.sh | BASTION_LIBC=musl bash ``` Prefer to grab the archive yourself? Prebuilt binaries are attached to every release for Linux (x86_64 and aarch64, glibc and musl), macOS (Intel and Apple silicon), and Windows (x86_64). Download the one for your platform, extract it, and put `bastion` on your `PATH`: ```sh # Example: Linux x86_64 curl -sSL https://github.com/attunehq/bastion/releases/latest/download/bastion-x86_64-unknown-linux-gnu.tar.gz | tar -xz sudo install bastion-x86_64-unknown-linux-gnu/bastion /usr/local/bin/ bastion --version ``` On a system with glibc older than 2.35, swap `gnu` for `musl` in those URLs to get the static build. Prefer to build from source? You need a Rust 2024 toolchain: ```sh cargo build --release ./target/release/bastion --version ``` `bastion --version` reports a release tag when one is reachable, otherwise the short commit SHA, with a `-dirty` suffix when the tree has uncommitted changes. Once installed, `bastion update` upgrades in place: it resolves the latest release, downloads the archive built for your platform, verifies its SHA-256 against the release checksums, and swaps it over the running binary, no shell or `curl` needed. It installs the same bits as the install scripts, so a self-update and a fresh install converge. `bastion update --check` reports whether a newer release exists without installing it: it exits 0 whenever the release lookup succeeds (including when an update is available, or when the running binary is a development build), and non-zero only when the check itself fails, such as when the network is unreachable. So `--check` is a status report, not a pass/fail gate; script against its printed output rather than its exit code. `bastion update --force` reinstalls the latest release even when the running version is already current. Bastion also prints a notice on stderr when a release build detects that a newer version is available: a line naming the available version, followed by the `bastion update` command to run. It shows only on an interactive terminal, never in CI or a pipe; set `BASTION_NO_UPDATE_CHECK=1` to silence it entirely. Two environment variables retarget where updates come from, for a fork or a private mirror of the releases: `BASTION_REPO` overrides the `owner/name` repository (default `attunehq/bastion`), and `BASTION_BASE_URL` overrides the base URL the release archive and `checksums.txt` are fetched from. Leave both unset for the normal case. ## 2. Make sure the backend is ready Bastion does not run its own agent loop. It shells out to an existing coding-agent CLI and reuses whatever you already have configured locally, so your billing and auth come along for free. Install and sign in to one of: - **[Claude Code](https://docs.claude.com/en/docs/claude-code)** (`claude`): the default when a reviewer does not pin a backend. - **[Codex](https://github.com/openai/codex)** (`codex`): pin it with `backend: codex` on a reviewer. - **[Pi](https://github.com/earendil-works/pi)** (`pi`): pin it with `backend: pi`. Pi runs against whatever provider you have configured it with locally, unless a reviewer pins a `model` (Pi's `provider/id` form, which selects the provider too). - **[Grok Build](https://x.ai/cli)** (`grok`): pin it with `backend: grok`. - **Muse Code** (`muse`): pin it with `backend: muse`. A **subscription** is fine; you do not need an API key. Because Bastion just runs the CLI, whatever you signed in with works: a ChatGPT subscription through `codex`, a Claude subscription through `claude`, and so on. The CLI reads its own auth file (`~/.codex/auth.json`, `~/.claude`, `~/.grok/auth.json`, `~/.config/muse/auth.json`) and refreshes its token itself. Getting that same subscription to bill the right person in CI is its own step, covered in [Continuous integration](./continuous-integration.md#authentication--billing). Bastion invokes the backend as a plain executable on your `PATH` (`claude`, `codex`, `pi`, `grok`, or `muse`), so confirm the one you intend to use is installed and authenticated before running a review: ```sh claude --version # for the Claude Code backend codex --version # for the Codex backend pi --version # for the Pi backend grok --version # for the Grok Build backend muse --version # for the Muse Code backend ``` If the binary lives elsewhere or you want to point at a wrapper, set `BASTION_CLAUDE_BIN`, `BASTION_CODEX_BIN`, `BASTION_PI_BIN`, `BASTION_GROK_BIN`, or `BASTION_MUSE_BIN` to its path. That covers the default, **native** path. If you author a reviewer with a [`runner`](./authoring-reviewers.md#runner-and-capabilities), that reviewer runs its backend inside a container instead (and must opt into `capabilities.network: true`; without it the reviewer is rejected before it runs, so a gate blocks and an advisor is skipped), so it needs a container engine on the host rather than the backend CLI: Bastion shells out to `docker` by default (set `BASTION_CONTAINER_ENGINE` to use another, for example `podman`), and the backend CLI (`claude` / `codex` / `pi` / `grok` / `muse`) must be present inside the image. A fixed set of provider credential variables (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, and the like) is forwarded from your environment into the container by name so the in-container agent can authenticate; host CLI auth that lives in a file (`~/.claude`, `~/.codex/auth.json`) is not, so an image that relies on that should bake it in. You only need this once you start using `runner` reviewers; the quickstart below stays native. ## 3. Write your first reviewer Reviewers live in a declarative file at your repository root: `.bastion.yaml` (the `.bastion.yml` spelling is also honored). Bastion discovers it by walking up from your current directory, so you can run `bastion` from anywhere inside the repo. You can also keep personal reviewers in a user-level `.bastion.yaml` in your platform config directory. Bastion uses them when a repo has not adopted Bastion; pass `--with-user-reviewers` to merge them with a repository's reviewers (see [Authoring reviewers](./authoring-reviewers.md#user-level-reviewers)). Create the repository file: ```yaml # .bastion.yaml reviewers: - name: single-responsibility trigger: [src/**/*.rs] # which changed files wake this reviewer mode: gate # gate = blocks the merge; advisor = comments only prompt: | Review the changeset to determine whether any one file concentrates too many unrelated responsibilities. If a file has clearly taken on multiple distinct concerns that should be separate modules, block the PR and name the file(s) and the concerns; otherwise approve it. A single large but cohesive module is not a violation. ``` That is a complete reviewer. Four fields carry the meaning: a unique `name`, the `trigger` globs over your changed files, the `mode`, and the `prompt`. Everything else has a sensible default. The next chapter, [Concepts](./concepts.md), explains each of these; [Authoring reviewers](./authoring-reviewers.md) covers the full schema. > Adapt the trigger to your language: `src/**/*.ts`, `app/**/*.py`, and so on. The > glob matches against the paths git reports as changed. ## 4. Run a review Make a change in your working tree (you do not need to commit it; Bastion reviews the working tree, including uncommitted and untracked files), then: ```sh bastion review ``` Bastion asks `gh` for the current PR and uses its direct base, or `main` when the branch has no PR or `gh` cannot run. It computes the files changed from that merge base, selects the reviewer candidates, resolves them in parallel, and renders progress plus each terminal verdict or agent-trigger skip. Automatic PR base selection uses an installed and authenticated GitHub CLI. A branch without a PR, or a missing `gh`, uses `main` without a warning. If `gh` runs and fails, Bastion warns and uses the same fallback. Pass `--base ` to select a base without `gh`. A re-run of the same branch may carry an already-passed reviewer's verdict forward instead of executing it again; the exact conditions are in [the local workflow](./local-workflow.md#re-runs-are-incremental). A reviewer that does execute continues its compatible prior agent conversation when the backend session is available, and otherwise starts fresh. A blocked review exits non-zero; a clean one exits zero. That exit code is what lets an agent (or a shell loop) know whether to keep working: ```sh while ! bastion review; do # ... fix what blocked, then loop ... done ``` ## 5. Read it as a machine stream An agent driving the loop wants structured events, not rendered text. Ask for JSONL: one JSON object per line, emitted as each thing happens: ```sh bastion review --format jsonl ``` You will get one typed event per line as the run progresses, ending in a `run.completed` that carries the aggregate verdict. The [local workflow](./local-workflow.md) chapter documents every event type and the exact contract an agent should follow when consuming them. ## 6. Look at what was saved Every run is persisted. Inspect history without re-running anything: ```sh bastion runs # list recent runs and their verdicts bastion show # re-print the latest run's findings bastion transcript # the full agent session for one reviewer ``` These are the on-demand detail; the common loop never needs them, but they are one command away when a verdict surprises you. (`show` and `transcript` default to the latest run; pass a run id for an older one, and the full forms are in [the local workflow](./local-workflow.md).) ## 7. Teach your agents to use Bastion You just drove the loop by hand. The point, though, is for your *coding agents* to drive it themselves: run the review, read the findings, fix what blocks, and reach a green gate before they ever open a PR. Bastion ships that instruction as a skill you install into the repo and commit, so every agent picks it up on checkout: ```sh bastion skills install ``` This writes a `using-bastion` skill into both `.claude/skills/` (Claude Code's native skill path) and `.agents/skills/` (the agent-neutral convention). Commit the result: ```sh git add .claude/skills .agents/skills git commit -m "Install the bastion onboarding skill" ``` The skill is generated from the binary, so re-running install after you upgrade Bastion keeps the checked-in copy current. To confirm it has not drifted from the binary (handy as a CI guard), run: ```sh bastion skills check # exits non-zero if a skill is missing or has drifted ``` The rendered file is deterministic (no version stamp or timestamp), so `check` stays green across upgrades that do not change the skill text and only flags real drift: a hand edit, or a forgotten re-install after the skill itself changed. When you do upgrade, re-run `bastion skills install` to refresh, or `bastion skills install --force` if you have local edits to overwrite. See what is bundled with `bastion skills list`, and install into a different directory with `--dir ` (repeatable). ## Keeping scratch runs out of your history While you are experimenting, point Bastion at a throwaway data directory so trial runs do not pile up in your real run history. A new data directory has no prior runs, so nothing carries and every reviewer executes: ```sh bastion --data-dir /tmp/bastion-scratch review ``` The same override is available as the `BASTION_DATA_DIR` environment variable. Note that `bastion review` never fabricates a verdict: a reviewer that executes runs on a real backend and costs a model call. What a re-run *can* do is skip execution entirely for a reviewer that already passed and whose inputs are unchanged, carrying the prior verdict forward at zero token cost (see [the local workflow](./local-workflow.md#re-runs-are-incremental)), so the loop's cost concentrates on first runs and on the reviewers your fixes touch. To keep cost down while iterating, start with one cheap, fast reviewer and a tight `timeout`. ## When something goes wrong The most common first-run snags and what they mean: - **"no reviewer registry found ..."**: there is no `.bastion.yaml` (or `.bastion.yml`) in this repo or any ancestor, and no user-level one in your config directory either. The command errors only when both are absent and no `--include ` supplied a registry file, so create a repository registry (step 3), a personal one, or pass a file explicitly. - **A reviewer registry error (malformed YAML, duplicate name, missing field).** The registry is validated before any agent runs, so these fail fast with a clear message. Run `bastion validate` (no model call) to check the set a local review would run, add `--with-user-reviewers` to check the merged set, or run `bastion validate path/to/.bastion.yaml` to check one registry (and everything it includes) without the user-level layer; fix it and re-run. See [Authoring reviewers](./authoring-reviewers.md). - **The review blocks immediately with "did not produce a verdict".** A gate failed closed, usually because the backend binary is missing or unauthenticated. Re-check `claude --version` / `codex --version` / `pi --version` / `grok --version` / `muse --version` and that you are signed in (step 2). - **No reviewers ran (a trivial pass).** Nothing in your changeset matched any reviewer's `trigger`. Confirm you actually changed a file the globs cover, and that `--base` points at the right branch. - **Everything looks unchanged.** Bastion uses an explicit `--base`, the current PR's direct base from `gh`, or `main` when the branch has no PR or `gh` cannot run. Confirm that this selected base is where the branch started. --- You now have a working reviewer and a review loop. Next: [Concepts](./concepts.md). The vocabulary (triggers, modes, verdicts, the gate) the rest of the guide builds on. --- # Concepts > The vocabulary Bastion runs on: reviewers, triggers, modes, verdicts, and the > merge gate. This chapter defines the terms the rest of the guide uses. It is short on purpose; each idea has a deeper home later, linked as it comes up. ## The reviewer A **reviewer** is the unit of the system: a focused agent prompt responsible for exactly one property of a changeset. It is a bundle of *prompt + trigger + mode*, plus an optional execution profile (backend, timeout, environment, inputs, a container `runner`, and `capabilities`, among others). All of it is declared statically in `.bastion.yaml`; [Authoring reviewers](./authoring-reviewers.md) is the full field reference. The repository's `.bastion.yaml` is the shared, governed set; locally you can also keep fallback reviewers in a user-level `.bastion.yaml`, or merge them explicitly with `--with-user-reviewers` (see [Authoring reviewers](./authoring-reviewers.md#user-level-reviewers)). Two properties matter most: - **Single concern.** A reviewer checks one thing and checks it well. You scale coverage by adding reviewers, never by widening one. This is what keeps recall high (see [Introduction](./introduction.md#the-core-idea)). - **Declarative and static.** Reviewers are data, not code. Bastion never generates them on the fly. That keeps the trigger set stable and makes every reviewer reviewable, which is the foundation of [governance](./governance.md). ## The trigger and the changeset A reviewer's **trigger** decides whether the reviewer applies to a changeset. The usual form is an ordered list of path globs; the reviewer runs when at least one changed file is included. A leading `!` excludes a path, and the last matching pattern wins. A docs-only change then wakes the docs reviewers and nothing else. ```yaml trigger: [src/server/**, src/client/**] # runs when server or client code changed trigger: ["docs/**", "!docs/audit-reports/**"] # skips audit snapshots ``` For a concern that paths cannot identify narrowly, `kind: agent` asks a cheaper model whether the full reviewer applies. Optional `paths` run first as a cheap prefilter. The agent sees the actual changeset, and any failure or uncertainty runs the full reviewer. A confident skip is recorded separately from a pass verdict. See [Agent triggers](./authoring-reviewers.md#trigger) for the schema and cost model. The **changeset** is everything in your working tree that differs from the point where your branch forked from the base branch (the merge base), *including uncommitted edits and new untracked files*, not just committed history. This is deliberate on both ends. Including uncommitted work lets an author loop against reviewers before committing anything. Diffing at the merge base rather than the base branch's tip means the changeset is only ever *your* work: changes that landed on the base after you forked are never routed on, never shown to a reviewer, and never flagged as yours. (Locally, this means a reviewer sees your work in progress; in CI the head is already committed, so the same definition gives the same result.) ## The mode: gate vs. advisor Every reviewer has a **mode** that decides whether it can block a merge: | Mode | Blocks the merge? | On crash/timeout/bad output | | --- | --- | --- | | `gate` | Yes, when it returns `block` | **Fails closed**: resolves to `block` | | `advisor` | No, ever | **Fails open**: ignored in the aggregate | A **gate** is a hard requirement when its trigger says the reviewer applies: the full reviewer must produce a clean `pass` for the merge to proceed. An agent trigger may instead record a semantic skip, which is counted separately from a pass. If an applicable gate crashes, times out, or cannot produce a valid verdict, it resolves to a block, never a silent pass. An **advisor** comments but never holds up the merge; even a clean `block` verdict from an advisor is treated as a pass for aggregation, and its findings are recorded as `optional` (an advisor's findings are advice, never a merge blocker) so they still surface as suggestions. A failed advisor is dropped. Use a gate for properties that must hold (tenant isolation, fail-closed error handling). Use an advisor for guidance you want surfaced but not enforced (test coverage, doc gaps, style preferences). ## The verdict Every full reviewer execution returns a structured **verdict**, captured through the backend's structured-output mechanism (a JSON schema for Claude Code, a requested verdict block for Codex) so Bastion can parse and aggregate it. An agent trigger that skips the full reviewer records `reviewer.skipped` instead, with no verdict or findings. A full reviewer's verdict has this shape: ```yaml verdict: pass | block # the authoritative gate decision (ignored for advisors) summary: "..." # a human-friendly one-paragraph explanation findings: # specific, located comments - kind: blocking # blocking | optional path: src/server/db.rs line_start: 88 line_end: 91 detail: "scope this query by tenant_id" ``` The top-level `verdict` is the decision; `findings` explain it. A `block` should carry at least one `blocking` finding (the reason), and a `pass` may still carry `optional` findings as non-blocking suggestions. A finding's `kind` changes how it is *surfaced*, not whether the merge proceeds; only `verdict` decides that. A `pass` never carries a `blocking` finding: the two would contradict each other. Because an advisor is always resolved to a `pass`, its findings are recorded as `optional` regardless of what the reviewer emitted. **Findings are the actionable surface.** An agent fixing a PR gets everything it needs from the findings: a file, a line range, and what to change. It should never have to open a transcript to learn what to do. A reviewer reports the complete actionable set in one pass, one finding per distinct instance, not just one representative reason. The author can then fix everything from a single run instead of meeting the next issue on the following review cycle. Bastion requests this from every reviewer automatically, so a prompt does not need to ask for it. ## The merge gate Bastion resolves the reviewer candidates in parallel (they have wildly different latencies, one might take 90 seconds, another 15 minutes) and **aggregates** their terminal outcomes into a single decision. A candidate may execute, record an agent-trigger skip, replay from a verified attestation in CI, or carry an unchanged prior pass on a re-run: - **Every applicable gate must pass.** An agent trigger may decide its reviewer does not apply; that gate increments `gates.skipped` without producing a pass verdict. The aggregate is `pass` when every gate that did apply passed. - **Any blocked, errored, or timed-out gate blocks the aggregate.** "All gates pass" never includes a gate that failed to produce a verdict. - **Advisors never affect the aggregate.** They contribute findings, not gate decisions. Locally, that aggregate is the exit code of `bastion review`. In CI it is the result of the Bastion review job, and `bastion github report` also posts it as a single always-present check named `bastion`. Either way the aggregation rule is the same, and CI runs the repository's reviewers. The decision matches when both runs see the same reviewers and context. They still differ when a local run cannot see the pull request (no PR, or `gh` is missing or failed), so reviewers miss that discussion, and when a purely local run includes your personal user-level reviewers with `--with-user-reviewers`, which CI never runs (see [Authoring reviewers](./authoring-reviewers.md#user-level-reviewers)). ## The backend A **backend** is the agent harness a reviewer runs on. Bastion does not implement its own agent loop; it translates the reviewer into the backend's native config and shells out to its CLI, reusing your local auth and billing. - `any` (the default): Bastion chooses; that resolves to Claude Code. - `claude-code`: Anthropic's Claude Code CLI. - `codex`: OpenAI's Codex CLI. - `pi`: the Pi CLI; uses whatever provider you have configured it with locally, unless a reviewer pins a `model` (Pi's `provider/id` form selects the provider too). - `grok`: xAI's Grok Build CLI. - `muse`: Meta's Muse Code CLI. You pin a backend when a subscription's terms require a specific harness, or when one model is better at a given concern. See [Authoring reviewers](./authoring-reviewers.md#backend) and, for CI billing, [Continuous integration](./continuous-integration.md#authentication--billing). By default the backend CLI runs **natively** on the host, using the `claude`, `codex`, `pi`, `grok`, or `muse` already on your `PATH` and the auth and billing that CLI is configured with. A reviewer that declares a [`runner`](./authoring-reviewers.md#runner-and-capabilities) instead runs that same backend **inside a container** (which requires `capabilities.network: true`; without it the reviewer is rejected before it runs, so a gate blocks and an advisor is skipped): Bastion invokes the container engine on the host, and the backend CLI resolves inside the image. A fixed set of model-provider credential variables (`ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_BASE_URL`, `ANTHROPIC_MODEL`, `CLAUDE_CODE_OAUTH_TOKEN`, `OPENAI_API_KEY`, `OPENAI_BASE_URL`, `CODEX_API_KEY`, `XAI_API_KEY`, `META_API_KEY`) is forwarded from Bastion's environment into the container by name, so the in-container agent can still reach its provider; an image can also bake in its own auth. If the reviewer's own `env` sets one of those names, that value wins and the host's is not also forwarded, so the reviewer can pin a specific credential. Nothing else from your host environment crosses that boundary. To give the in-container agent another value, set it as a literal in the reviewer's `env`, which is forwarded in alongside the credentials. The fixed set covers the Anthropic, OpenAI, xAI, and Meta variables only, so a containerized Pi reviewer on another provider authenticates from auth baked into its image or from a credential written into its `env`. ## How it all fits ```text .bastion.yaml you author this | v bastion review ---> compute changeset (working tree vs merge base) | v route: apply path prefilters, then resolve agent triggers | v run matched reviewers in parallel; a reviewer may instead replay from a verified attestation (CI), or carry an unchanged prior pass from the newest prior run on the branch that resolved that reviewer (local or CI), both with no backend dispatch (each executed reviewer is timeout-bounded) | v each returns a verdict, or records an agent-trigger skip | v aggregate: every applicable gate must pass ---> one decision (exit code locally; the review gate in CI) ``` --- Next: [Authoring reviewers](./authoring-reviewers.md). The full registry schema, from the four required fields out to timeouts, environment, and prompt inputs. --- # Authoring reviewers > The registry schema in full, and how to write a reviewer that stays sharp. Reviewers are the whole policy. This chapter is the reference for writing them: the file, the required fields, the optional execution profile, and the craft of a prompt that keeps recall high. It progresses from the minimum you need to the fields you will reach for only occasionally. ## The registry file The repository's reviewers live in a file at its root: `.bastion.yaml` (the `.bastion.yml` spelling is also honored). Bastion finds it by walking up from the current directory, so the command works anywhere inside the repo. The file is a top-level mapping with a `reviewers:` list plus four optional keys, `defaults:`, `attestations:`, `limits:`, and `include:`: ```yaml attestations: true # optional, default off; see below defaults: # optional; see "Registry-wide defaults" model: gpt-5 effort: high limits: # optional; see "Bounding a run's spend" max_concurrent: 8 include: # optional; see "Splitting the registry across files" - reviewers/security.yaml reviewers: - name: single-responsibility trigger: [src/**/*.rs] mode: gate prompt: | ... - name: test-coverage trigger: [src/**/*.rs] mode: advisor prompt: | ... ``` `attestations: true` opts the repository into letting CI verify and replay a signed local run instead of re-executing every reviewer; see [Attesting a run so CI can replay it](./continuous-integration.md#attesting-a-run-so-ci-can-replay-it). Only the root registry file may set it; an included file that tries is a load error. `limits:` bounds how much one `bastion review` may spend on its agent fan-out, so a broken or looping run fails fast instead of multiplying cost; see [Bounding a run's spend](#bounding-a-runs-spend). Conservative defaults apply even with no `limits:` block, and like `attestations:`, only the root registry file may set it. Reviewer **names must be unique** across the merged registry (the root file plus everything it includes); a duplicate name is a load error that names both files. A name also has to work as a directory name in the run store, so a name that reduces to an empty, `.`, or `..` component is rejected, as are two names that collapse to the same component once non-portable characters are normalized (for example `repo:test` and `repo-test`); plain names are unaffected. Every reviewer also needs a non-empty `prompt`; an empty one (a blank inline value, or a prompt file with no content) is a load error rather than a reviewer that silently checks nothing. Because these files *are* the review policy, changes to them should require human review; see [Governance](./governance.md) and `bastion github codeowners`. > **Migrating from `bastion/reviewers.yaml`.** Bastion still loads the legacy > `bastion/reviewers.yaml` location but prints a deprecation warning; the supported > location is `.bastion.yaml` at your repository root. Move the file (the contents > are unchanged) and regenerate your CODEOWNERS block with `bastion github > codeowners`. ## Splitting the registry across files A registry can spread across files without changing what it means. The top-level `include:` array names further registry files whose reviewers merge into the including file's, in order: the including file's own reviewers first, then each include's. An included file has the same schema as the root one (its own `reviewers:`, `defaults:`, and even `include:`, so includes nest), with two exceptions: only the root file may set `attestations:` or `limits:`. ```yaml # .bastion.yaml include: - reviewers/security.yaml - reviewers/docs.yaml reviewers: - name: single-responsibility ... ``` Paths resolve relative to the file that lists them, so `reviewers/security.yaml` above is relative to the repository root, and an `include:` inside it would be relative to `reviewers/`. A file reached twice (two files including the same third one, or a cycle) merges once. The result behaves exactly like one big file: names must be unique across all of it, and each file's `defaults:` apply to the reviewers declared in that same file only, so an included file means the same thing no matter who includes it. You can also merge extra registry files for a single invocation with the repeatable `--include ` flag, on `review`, `validate`, `attest`, and `github codeowners`. The files merge into the repository layer like `include:` entries, with one difference: a relative `--include` path resolves against the directory you run the command from, not against the registry file. The extra reviewers become part of the effective repository configuration for that run, which has one consequence for [attestation](./continuous-integration.md#attesting-a-run-so-ci-can-replay-it): `bastion attest` and CI re-derive the configuration themselves, so a run reviewed with `--include` only attests and replays if they are given the same files. ## Prompt files A `prompt:` can name a file instead of carrying the text inline, with the `{file: }` form. The file's whole content becomes the prompt, so a long review instruction can live in markdown with real editor support instead of a YAML block scalar: ```yaml reviewers: - name: tenant-isolation trigger: [src/**] mode: gate prompt: file: reviewers/prompts/tenant-isolation.md ``` The path resolves relative to the registry file that declares the reviewer (an included file reads its prompt files relative to itself). A missing or empty prompt file is a load error. Everything downstream sees the resolved text: the run record, the attestation hash, and carry all bind the prompt's *content*, so editing the markdown file is a policy change exactly like editing an inline prompt, and prompt files belong under the same human review as the registry. `bastion github codeowners` lists prompt files that live inside the repository automatically; one outside the repository still works as a prompt, but CODEOWNERS cannot protect a path outside the tree, so it is left out of the generated block. ## User-level reviewers You can also keep personal reviewers in a user-level `.bastion.yaml` (or `.bastion.yml`) in your platform config directory. Bastion uses them as the fallback in repositories that have not configured reviewers: - Linux: `$XDG_CONFIG_HOME/bastion`, defaulting to `~/.config/bastion`. - macOS: `~/Library/Application Support/bastion`. - Windows: `%APPDATA%\bastion`. By default, Bastion uses this file only when the repository has no reviewer registry. This keeps a local run aligned with the repository's configured policy and avoids extra model calls. Pass `--with-user-reviewers` to `bastion review` or `bastion validate` to merge both files. The merge combines reviewers by name: - A reviewer only one file defines is included as-is. - The same reviewer in both files is deduplicated to one. Sameness is compared by the *effective* configuration after each file's registry `defaults` are applied, so a reviewer that inherits a default `model` or `effort` and one that spells out the same value count as identical. - A name in both files with a *different* effective configuration is a collision; both are kept, your copy under its plain name and the repository's scoped to `repo:`, so neither silently wins. The two files are governed separately, so the collision is surfaced rather than resolved by precedence. This layer is local-only. A review carrying a GitHub source (with `--repo`/`--pr`, as CI runs) skips the user-level registry, so a pull request is gated by the repository's reviewers alone, the `repo:` scope never appears there, and a personal reviewer can never gate someone else's change. `--config-dir ` (or `$BASTION_CONFIG_DIR`) overrides where the user-level file is read from. It does not enable merging by itself. `--include` adds files to the repository layer but does not suppress the personal fallback when no repository registry exists. The user-level file supports the full schema, though `attestations:` only has meaning in the repository's registry (a personal reviewer never gates a PR, so there is nothing to attest). Its `include:` entries and prompt files resolve relative to the config directory and merge into the user layer, never the repository's, so splitting your personal registry across files cannot leak a reviewer into a repository's attested configuration. ## Registry-wide defaults An optional top-level `defaults:` block sets a house `model` and `effort` that every reviewer inherits unless it sets its own. A reviewer's explicit field always wins; the default just fills the gap, so you set the model and effort once instead of repeating them on every reviewer: ```yaml defaults: model: gpt-5 effort: high reviewers: - name: single-responsibility trigger: [src/**/*.rs] mode: gate backend: codex # required: an inherited model needs a pinned backend prompt: | ... ``` A default `model` is still backend-specific, so a reviewer that inherits it must pin a `backend`; an inherited model under `backend: any` is rejected the same way an explicit one is. `defaults` sits *above* each backend's own built-in default (Opus 4.8 at `high` effort on Claude Code), so the resolution order is: the reviewer's own field, then `defaults`, then the backend default. ## Bounding a run's spend Every reviewer shells out to an agent, and an agent run costs tokens. Nothing in the routing bounds the *total* cost of a fan-out on its own: a reviewer whose agent fails to start is retried, a transient spawn or authentication failure can turn into a respawn loop, and a run that is quietly broken keeps launching agents until something outside Bastion notices. An optional top-level `limits:` block is the spend cap that stops that. Every field is optional and takes a conservative default, so a registry with no `limits:` block still runs fully capped: ```yaml limits: max_concurrent: 8 # most agents running at once (default 8) max_total_spawns: 60 # most agent launches in one review run (default 60) max_consecutive_failures: 4 # dead launches in a row before aborting (default 4) ``` - **`max_concurrent`** bounds how many agents run at the same time, and so the peak concurrent spend. A run with more matched reviewers than this still runs them all; the extras queue until a slot frees. - **`max_total_spawns`** caps how many agent launches one review run may make in total. Every launch counts, including a reprompt for a malformed verdict and a launch that dies immediately, so a respawn storm trips this even though its spawns did no real work. A healthy full run spends a small fraction of the default. - **`max_consecutive_failures`** trips a breaker when this many agent launches in a row produce no output at all: the signature of a broken or unauthenticated agent CLI (an exit-127, a failed login) that launches, dies at zero tokens, and would otherwise be retried forever. A single productive launch resets the count, so an occasional transient failure never trips it. Reaching any cap **aborts the whole run** with a clear error rather than continuing to spend: the run is recorded (every reviewer that had launched fails closed) but never sealed, and `bastion review` exits non-zero. The message names what was capped, how many agents launched, and why it stopped. The defaults leave a healthy run untouched while turning a runaway one into a loud, fast failure; set the block only if your fan-out is genuinely larger than the defaults allow, or if you want tighter caps than the defaults. Like `attestations:`, only the root registry file may set `limits:`. These caps reset on every `bastion review` invocation. The bundled `using-bastion` skill is what stops an unattended agent from accumulating that spend across invocations (three full reviews, then hand remaining findings to the human). ## The required fields Four fields are mandatory. A reviewer with just these is complete and runnable. ### `name` A unique identifier. It is also the reviewer's check-run name in CI (`bastion / single-responsibility`), so keep it short and descriptive. ### `trigger` A path trigger is an ordered list of globs matched against the changed files. The reviewer runs when at least one changed file is included. A leading `!` excludes a match, and the last matching pattern wins, so a later positive pattern can include a path again. Globs use the usual `**` (any depth) and `*` (one segment) syntax: ```yaml trigger: [src/**/*.rs] # all Rust under src, any depth trigger: [src/server/**, src/client/**] # either subtree trigger: [src/**/*.rs, docs/**/*.md, ".bastion.yaml"] # multiple kinds trigger: ["docs/**/*.md", "!docs/audit-reports/**"] # docs except snapshots trigger: ["docs/**", "!docs/generated/**", docs/generated/index.md] # include one again ``` Quote a glob if YAML would otherwise mis-parse it (a bare leading `*`, for instance). Quote every exclusion because YAML treats a bare leading `!` as a tag. A path trigger must contain at least one positive pattern. Empty and exclusion-only lists are invalid. When paths cannot express the concern without waking an expensive reviewer on most changes, use an agent trigger: ```yaml reviewers: - name: single-responsibility trigger: kind: agent prompt: > Run when the changeset creates or materially expands a responsibility boundary in production code. Skip tests, docs, and mechanical edits. backend: codex model: gpt-5.6-luna effort: high timeout: 45s paths: [src/**/*.rs] mode: gate prompt: | Review each changed production file for single responsibility. ... ``` `paths` is an optional cheap prefilter and uses the same ordered inclusion and exclusion rules. With it, both conditions must hold: a changed path is included and the trigger agent decides `run`. Without it, Bastion asks the trigger agent on every non-empty changeset. The trigger agent receives the actual changeset but not the author's description or PR discussion, so a claim about relevance cannot suppress review. It runs with no capabilities, environment, or container even when the full reviewer has them. The trigger's `prompt` is inline text. The trigger agent returns `run` or `skip` with a reason. A timeout, backend error, malformed response, or uncertain answer becomes `run`, then the full reviewer applies its normal gate or advisor policy. An explicit `--reviewer ` also bypasses the agent trigger because naming a reviewer is a request to run it. A skip is recorded as `reviewer.skipped`; it is not a pass verdict. Its tokens count toward the run, and a full run seals and attests the skip like any other terminal reviewer outcome. `timeout` bounds only the routing call and defaults to 2 minutes. A trigger that reaches the limit resolves to `run`; the full reviewer then uses its own top-level `timeout` (15 minutes by default). Agent triggers inherit `model` and `effort` from registry-wide defaults when they omit those fields. `trigger.backend` defaults to `any`, which currently resolves to Claude Code, and is independent of the full reviewer's backend. A trigger model requires its own pinned `backend`; the full reviewer's backend does not implicitly select the trigger backend. Keep the routing prompt narrower and cheaper than the reviewer prompt, since every candidate pays for the routing call before Bastion can save the full review. ### `mode` `gate` (blocks the merge when it returns `block`; fails closed) or `advisor` (never blocks; fails open). See [Concepts](./concepts.md#the-mode-gate-vs-advisor) for the full semantics. ### `prompt` The instruction handed to the reviewing agent, written inline or as a `{file: }` reference to a markdown file (see [Prompt files](#prompt-files)). Either way it must be non-empty. This is where the craft lives; see [Writing a good prompt](#writing-a-good-prompt) below. ## The optional execution profile The remaining fields tune *how* a reviewer runs. All have defaults; omit them until you need them. ### `backend` Which agent harness runs the reviewer. Default `any` (resolves to Claude Code). Pin `claude-code`, `codex`, `pi`, `grok`, or `muse` to force a specific harness, usually because a subscription's terms require it, or because one model is better at a given concern. ```yaml backend: codex ``` > `pi` is multi-provider. Pin its provider and model together in the [`model`](#model) > field using Pi's `provider/id` form (e.g. `openai-codex/gpt-5.5`); omit `model` to > run against whatever provider and model your local Pi CLI defaults to. ### `model` The specific model the backend should use, for example `claude-opus-4-8` on Claude Code, `gpt-5` on Codex, `grok-4.6` on Grok Build, or `muse-spark-1.2` on Muse Code. A model id is **backend-specific**, so pinning one requires a pinned `backend`: a `model` under `backend: any` is rejected when the registry loads, since Bastion cannot know which backend the id is meant for. ```yaml backend: codex model: gpt-5 ``` Under `backend: pi` the model also names its **provider**, written in Pi's `provider/id` form, because Pi is multi-provider and its bare default provider is `google`. So a Pi reviewer that wants an OpenAI Codex model writes the provider into the id rather than a separate field: ```yaml backend: pi model: openai-codex/gpt-5.5 ``` Omit it to take the backend's default. On Claude Code that default is **Opus 4.8**; on Codex, Pi, Grok Build, and Muse Code it is whatever the harness itself resolves (for Pi, its configured default provider and model; for Grok Build, `grok models` shows it; for Muse Code, the model its settings select, `muse-spark-1.2-contributor` out of the box). To set a model once for the whole registry rather than per reviewer, use the [`defaults`](#registry-wide-defaults) block. ### `effort` The reasoning-effort level, forwarded verbatim to the active backend's effort control (Claude Code's `--effort`, Codex's `model_reasoning_effort`, Pi's `--thinking`, Grok Build's and Muse Code's `--reasoning-effort`). Like `model`, the value is opaque: use whatever vocabulary your backend accepts. Claude Code takes `low`, `medium`, `high`, `xhigh`, or `max`; Codex takes `minimal`, `low`, `medium`, or `high`; Pi takes `off`, `minimal`, `low`, `medium`, `high`, or `xhigh`; Grok Build takes whatever levels its current models expose (`low`, `medium`, `high`, and `xhigh` on `grok-4.6`; its docs also list `none`, `minimal`, `max`, and per-model menu ids); Muse Code takes `minimal`, `low`, `medium`, `high`, `xhigh`, or `ultra`. The shared `low`/`medium`/`high` levels work on any backend; the backend-specific ones do not, so a value that does not match the reviewer's backend is the backend's problem (Claude Code, for instance, warns and falls back to its own default; Grok Build and Muse Code exit with an error, which fails a gate closed). ```yaml effort: high ``` The default is **`high`** (accepted by every backend). Lower it on cheap, mechanical reviewers to save tokens; raise it on the ones that need to reason hard. > **The `model:effort` shorthand.** People often write a model and effort together > as `gpt-5.5:high` or `claude-opus-4-8:max`. Bastion has no combined field: that is > just `model:` plus `effort:`. Split it across the two fields, with a `backend` > pinned so the model id is unambiguous: > > ```yaml > backend: codex > model: gpt-5.5 # the part before the colon > effort: high # the part after it > ``` ### `timeout` A per-reviewer wall-clock limit, written in human form (`90s`, `15m`). When a reviewer exceeds it, a gate fails closed (block) and an advisor is skipped. The default is **15 minutes**. Set a short timeout on cheap reviewers and a long one on heavy end-to-end checks: ```yaml timeout: 15m ``` ### `attestation` Set to `never` to ask for fresh execution every time, on both surfaces: the reviewer is never replayed from a signed local run in CI (even when the repository sets `attestations: true`), and a re-run never carries its prior pass forward, locally or in CI (see [the local workflow](./local-workflow.md#re-runs-are-incremental)). Absent means both apply: CI may replay this reviewer's terminal verdict or skip outcome from an attested run, and a re-run may carry its unchanged pass. Use `never` for a gate your team wants executed unconditionally regardless of any prior run. See [Attesting a run so CI can replay it](./continuous-integration.md#attesting-a-run-so-ci-can-replay-it). ```yaml attestation: never ``` ### `env` Environment variables injected into the reviewer's process, so the agent and any tool it runs can see them. Use this to hand a reviewer a value your environment already provides, say a preview URL: ```yaml env: PREVIEW_URL: http://localhost:3000 ``` Values are **literal**: Bastion does not perform shell `$VAR` expansion, so write the actual value, not `${SOMETHING}`. Bastion consumes environments, it does not provision them: locally the value must already exist (a precommit script might boot the service and export it), and in CI the workflow stands it up. See [Continuous integration](./continuous-integration.md#environments--inputs). How the value reaches the agent depends on where the reviewer runs: - **Native reviewers** (no `runner`) also inherit Bastion's own environment, so a variable your shell or CI has already exported is visible to the agent even without listing it here; the `env` block sets additional values explicitly. - **Containerized reviewers** (with a `runner` and `capabilities.network: true`) do *not* inherit Bastion's arbitrary environment. Into the container go exactly the `env` pairs written here (as literal values, the same as everywhere else) plus a fixed set of model-provider credential variables (see [Backends](./concepts.md#the-backend)). Nothing else crosses, so a value an outer shell or CI job exported reaches a containerized reviewer only if its literal value is written into this `env` block (template the registry if the value is dynamic, for example a per-PR preview URL). For a containerized reviewer the `env` pairs are written to a temporary file handed to the engine as `--env-file`, so their values never appear on the `docker run` command line (a secret in `env` stays out of a process listing) and their names never touch the engine *client* process; the provider credentials are the only variables forwarded by name from Bastion's own environment. If you set one of those provider credential names in this `env` block, your value wins: Bastion does not also forward the host's value for that name, so the reviewer's `env` overrides it (matching how a native reviewer's `env` overrides the inherited environment). One container-only constraint follows from that env-file format (one `KEY=VALUE` per line, no escaping): a containerized reviewer's `env` cannot carry a key containing a newline or `=`, or a value containing a newline. Such a pair is rejected and the reviewer fails closed rather than receive a corrupted value; a multiline value (a PEM key, say) has to reach a containerized reviewer some other way (a file in the image, or one its Dockerfile copies in). Native reviewers have no such limit. ### `inputs` Values interpolated into the prompt *before* it reaches the agent. Reference an input as `${name}` in the prompt; Bastion substitutes the value. Unknown placeholders are left untouched. ```yaml inputs: preview_url: http://localhost:3000 prompt: | Run the checkout flow against the preview environment at `${preview_url}`. If it fails, block the PR and explain; otherwise approve it. ``` `env` puts a value in the *process*; `inputs` puts a value in the *prompt text*. They are independent: use `env` for tools the agent invokes, `inputs` for values the agent should read in its instructions. Input values are literal as well: a `${name}` in the prompt is substituted only from this `inputs` map, never from your shell environment. ### `runner` and `capabilities` The schema also accepts a `runner` block (`dockerfile` / `image`) and a `capabilities` block (`network`, `mcp`, `skills`) to opt into an execution environment beyond the least-privilege default. Where these stand: - **`runner` is provisioned (paired with `network: true`).** A reviewer with a `runner` block and `capabilities.network: true` runs its backend inside a container: a `dockerfile` is built (tagged by a content hash of the Dockerfile, so an unchanged file reuses the engine's layer cache), an `image` is used as-is (the engine pulls it on demand at run time). If both are set, `dockerfile` wins; a `runner` with neither fails closed. The `dockerfile` path is relative to the repository root and must resolve inside it: an absolute path, any path with a `..` component (rejected outright, even one that would resolve back inside), or one that canonicalizes outside the repo through a symlink all fail closed. The build runs with the repository root as its build context, so the Dockerfile's `COPY` and `ADD` can reference files anywhere in the repo. An `image` reference beginning with `-` fails closed, since the engine would read it as a command-line option rather than an image name. The selected backend's executable must exist inside the image on `PATH` (`claude` for `claude-code`, `codex` for `codex`, `pi` for `pi`, `grok` for `grok`, `muse` for `muse`). This lets a reviewer carry tools or a pinned toolchain the host does not have. - **`capabilities.network: true` is required to run a container; the default `network: false` fails closed.** `network: true` gives a containerized reviewer general (unscoped) outbound network. A container's egress cannot be scoped to the model provider yet (the allowlisting proxy is unbuilt), so the default `network: false` reads as restricted but cannot be enforced: rather than silently attach general egress, `ExecutionPlan::resolve` rejects a container with `network: false` before it runs. As with `mcp`/`skills`, that rejection **fails closed**: a gate blocks and an advisor is skipped, with a message naming the field. A containerized reviewer must opt into `network: true` to run, accepting general egress for now. A *native* `network: true` (no `runner`) also fails closed, since with no container there is nothing to scope. - **`capabilities.mcp` and `capabilities.skills` are not provisioned.** A reviewer that declares either **fails closed**: a gate blocks and an advisor is skipped, with a message naming the unprovisioned field, rather than running degraded (a gate that quietly ran without a privilege it asked for would be a silent fail-open). Leave them out. The least-privilege default (no `runner`, `network: false`, no `mcp` or `skills`) runs natively on the host. ## A fully-loaded example Putting the optional fields together. As written, this reviewer runs in the container built from its Dockerfile. It must declare `network: true` to run (a containerized reviewer needs general egress, since provider-only scoping is unbuilt), and Bastion forwards its `env` into that container. ```yaml reviewers: - name: e2e-checkout-flow trigger: [src/**] mode: gate backend: claude-code timeout: 15m env: PREVIEW_URL: http://localhost:3000 # literal value, no shell expansion inputs: preview_url: http://localhost:3000 # substituted into the prompt as ${preview_url} runner: # provisioned: runs the backend in this image dockerfile: ./.bastion/e2e.Dockerfile capabilities: network: true # required to run a container; grants general (unscoped) egress prompt: | Run the e2e checkout flow against the preview environment at `${preview_url}` using Playwright. If it fails, block the PR and explain; otherwise approve it. ``` Adding an unprovisioned capability flips the whole reviewer to fail closed. For example, adding `mcp: [playwright]` under `capabilities` would block this gate before it ever reaches the container, since `mcp` is checked first. Leave `mcp` and `skills` out until those tiers land. ## Writing a good prompt The prompt is the reviewer. A few habits keep recall high: - **Say what to block on, explicitly.** End with a clear instruction: "block the PR if X; otherwise approve it." The reviewer's job is a decision, not an essay. - **Name the one concern and stay on it.** If you find yourself writing "also check...", that "also" is a second reviewer. Split it. - **Carve out the false positives you can predict.** "A single large but cohesive module is not a violation." "Panics in `#[cfg(test)]` code are acceptable." Pre-empting the obvious wrong flags keeps false positives down. - **Match the mode to the language.** A gate's prompt should be decisive; an advisor's should say "report as optional findings... do not block," so its output stays advisory even if the model is tempted to be firm. - **Let the agent explore.** Every reviewer gets a full checkout and is told how to see the changeset (the diff against the merge base, plus untracked files). You do not need to paste the diff into the prompt; point the reviewer at the property. - **You do not need to ask for completeness.** Bastion appends an instruction to every reviewer prompt telling the agent to report every distinct finding in one pass, not just the first. Write the prompt for the concern and phrase findings per instance (one per file and line range), and the agent enumerates them all so the author fixes the whole set from one run. Some worked examples, taken from Bastion's own registry ([`.bastion.yaml`](https://github.com/attunehq/bastion/blob/main/.bastion.yaml)): ```yaml - name: error-handling trigger: [src/**/*.rs] mode: gate backend: codex prompt: | Review the changeset for error-handling discipline: no `.unwrap()` or `.expect()` on recoverable errors in non-test code, errors propagated with `?` and given context, and gates that fail closed. Block the PR if you find a recoverable error that can panic in production; otherwise approve it. Panics in `#[cfg(test)]` code and in genuinely-unreachable invariants that are documented as such are acceptable. - name: test-coverage trigger: [src/**/*.rs] mode: advisor backend: codex prompt: | Check whether new or changed behavior in this changeset is covered by tests. This is advisory: report uncovered behavior as optional findings so the author can decide, but do not block. ``` ## Validating your registry Run `bastion validate` to parse the registry and report any problem without running a single reviewer or spending a model call: ```sh bastion validate # validate the default set review would run bastion validate --with-user-reviewers # validate the explicit merged set bastion validate path/to/.bastion.yaml # check one registry and its include tree ``` With no file argument it validates the same set a local `bastion review` would run: the repository registry when one exists, otherwise your user-level registry. Pass `--with-user-reviewers` to validate the merged set. The output names each source it loaded and lists every included registry file and prompt file it pulled in. An explicit `FILE` rejects `--with-user-reviewers` and still resolves the file fully: its `include:` entries, its prompt files, and any `--include` flags all load, as they would on a real review. It loads through the same path `bastion review` uses, so it catches exactly the errors a real review would hit at load time: malformed YAML, an unknown field, a duplicate name (including one that survives the user/repo merge or arrives via an include), a reviewer missing a required field, an empty or unreadable prompt file, a missing include, or a model pinned under `backend: any`. A valid registry prints a one-line summary and the reviewers it parsed, and exits zero; an invalid one prints the error and exits non-zero, so the command works as a pre-commit or CI lint as well as a quick local check. The registry is also validated whenever it loads for a real `bastion review`, so a malformed file fails fast there too. `bastion validate` just lets you check it on its own, for free, before you run anything. --- Next: [The local workflow](./local-workflow.md). Running `bastion review` in depth, the JSONL agent stream, and inspecting saved runs. --- # The local workflow > Running `bastion review` for real: the loop, the two output formats, exit codes, > and inspecting what was saved. The local CLI applies the same reviewers and decisions CI enforces: CI executes them fresh, records an agent-trigger skip, replays an attested local outcome, or carries an unchanged reviewer from the newest prior CI run on the branch that resolved that reviewer. So a green local loop usually means a PR that CI confirms. They still differ when a local run cannot see the pull request (no PR, or `gh` is missing or failed), so reviewers miss that discussion, and when a local run merges in personal reviewers with `--with-user-reviewers`, which CI never sees (see [Authoring reviewers](./authoring-reviewers.md#user-level-reviewers)). This chapter covers the loop in depth. ## The loop The intended use is a tight loop: run the review, read what blocks, fix it, run again. A person watching the CLI can keep going until green. An agent following the bundled `using-bastion` skill stops after three full `bastion review` invocations if the gate is still blocked, because each invocation's launch cap resets and an unbounded loop can spend without a stopping point. ```sh bastion review ``` Without an explicit `--base`, `bastion review` asks `gh pr view` for the current branch's PR. It uses that PR's direct base commit, or `main` when the branch has no PR or `gh` cannot run. If `gh` runs and fails, it warns and uses the same fallback. If the local parent branch has unpushed commits already included in the child, Bastion uses that local parent tip instead of GitHub's older commit. The changeset includes uncommitted and untracked files but excludes the base branch's own changes. Bastion then selects reviewer candidates and renders progress plus each terminal verdict or agent-trigger skip. Candidates resolve in parallel with per-reviewer timeouts. A re-run is incremental (next section): a reviewer that already passed may carry its verdict forward instead of executing again, locally and in CI. To send native reviewer sessions to a local [Akari](https://github.com/attunehq/akari) install, write `akari.yaml` with `enabled: true` in your user config directory (the same place as a personal `.bastion.yaml`). This is off by default and is not repository configuration, so a checkout cannot turn it on. `BASTION_AKARI=1` enables it without a file. A failed ingest is logged and does not change the verdict. For a native GitHub stack, automatic base selection reviews one PR layer at a time: C against B, B against A, and A against trunk. The checkout may contain uncommitted or untracked work. If the repository sets `attestations: true`, CI then checks for a verified attestation covering the run. A covered reviewer replays its recorded terminal outcome, while every other reviewer carries an eligible prior pass or executes fresh (see [Attestation](../developer-guide/attestation.md)). - `--base `: the branch you are merging into. The changeset is diffed at the merge base with it, not at its tip, so the base moving on does not change what is under review. An explicit value always wins. Without one, Bastion asks `gh` for the current PR's direct base and falls back to `main` when no PR exists. A missing `gh` is the same silent fallback; a `gh` that ran and failed prints a warning before using it. The review fails if no merge base resolves (an unrelated branch, or a shallow clone). When the base is a local branch whose remote-tracking ref would give HEAD a different merge base (a local `main` that lags `origin/main`, say), the review warns on stderr that the changeset may include upstream commits and suggests reviewing against `--base` with the tracking ref, or fetching and bringing the local branch up to date with it; the check reads only refs already on disk, never fetches, and the review proceeds unchanged. - `--format `: output format. Defaults to `human`. - `--repo `: the GitHub repository containing the pull request. Defaults to `$GITHUB_REPOSITORY`. - `--pr `: select the pull request used for automatic base detection and reviewer context. Requires a repository, from `--repo` or `$GITHUB_REPOSITORY`; passing `--pr` with no repository is an error. - `--config-dir `: the user-level config directory to search for personal reviewers (env `BASTION_CONFIG_DIR`). Defaults to your platform config directory (`~/.config/bastion` on Linux, `~/Library/Application Support/bastion` on macOS, `%APPDATA%\bastion` on Windows). Personal reviewers are the fallback when the repository has no reviewer configuration. - `--with-user-reviewers`: merge personal reviewers into a repository's configured reviewer set. This applies only to a purely local review; it is rejected when either `--repo` or `--pr` is supplied on the command line, and with `bastion validate FILE`. An ambient `$GITHUB_REPOSITORY` without `--pr` does not conflict or turn a local review into a GitHub-source review. - `--include ` (repeatable): merge an extra reviewer registry file into the repository registry, like an `include:` entry in the root file except that a relative path resolves against the current directory (see [Splitting the registry across files](./authoring-reviewers.md#splitting-the-registry-across-files)). The extra reviewers become part of the effective repository configuration for the run, so `bastion attest` needs the same `--include` flags to re-derive the same configuration hash. - `--reviewer ` (repeatable; alias `--only`): run only these triggered reviewers. An unknown or untriggered name is an error. Excluding a triggered reviewer makes the run *partial* (see below). - `--fresh`: disable incremental reuse below. No reviewer carries a prior pass or continues a prior agent conversation. It does not affect attestation replay: a `--repo`/`--pr` run still replays reviewers a verified attestation covers. ### Re-runs are incremental The loop's dominant cost would otherwise be re-executing reviewers that already passed. So on a re-run of the same branch, a reviewer whose newest prior verdict on that branch was a pass, and whose *scope digest* is unchanged since that run, is *carried*: its prior verdict counts in the gate tally, the stream marks it `"carried": true`, and no agent runs and no tokens are spent on it. The digest covers everything the verdict was keyed to: the reviewer's own definition, the path-matched diff for a path trigger or the entire changeset for an agent trigger, the commit messages that touched the same files, and the content of untracked files in that scope. So an edit to scoped content, a reworded commit that touched it, or an edited reviewer re-runs the reviewer; the ones that blocked always re-run, since your fix touched the files they flagged. Blocks are never carried. What deliberately does *not* re-run a reviewer: the base branch moving, or a rebase over it, when your scoped diff comes out identical. The digest binds the changeset a verdict judged, not the commit it happened to be diffed at, so a rebase over unrelated upstream changes carries every pass straight through, while one that changes the diff (a conflict resolution, upstream edits close enough to shift a hunk's context) re-runs the affected reviewers. What the base changed was reviewed by its own changesets when it merged; your reviewers judge only what your branch changes. A path trigger bounds the concern and its carry digest to the matched files. Agent-trigger `paths` only prefilter whether routing starts, so an admitted agent-trigger reviewer keys carry to the full changeset. A reviewer with `attestation: never` in the registry is never carried, and `--fresh` re-runs everything. When a reviewer does re-run, Bastion continues the newest compatible agent conversation recorded for that reviewer on this repository and branch. It sends the complete current review prompt as the next turn. Changing the reviewer prompt, backend, model, effort, trigger, or other effective configuration starts a new conversation. `--fresh` also starts a new conversation. Conversation state is a cache. If the backend session or thread is unavailable, Bastion immediately performs the review in a fresh conversation. The verdict does not depend on resume succeeding. One extra condition applies to the repository's own reviewers (not personal user-level ones): they carry only from a prior run the binary sealed and can still verify, with no test seam recorded. A prior run that was never sealed, or whose seal no longer checks out, executes those reviewers fresh; nothing warns about it, since carry is an optimization and fresh execution is always correct. CI carries too. A workflow that persists and restores the run store across pushes (the example workflow in [Continuous integration](./continuous-integration.md#the-workflow) includes the restore and upload steps that do this) lets a push carry an unchanged reviewer from the newest prior CI run on the branch that resolved that reviewer, the same way your local loop does and on the same verified-seal condition. This is separate from [attestation replay](#attesting-a-run-for-ci): replay reuses your signed local run so CI need not re-execute it at all, while carry walks CI's own prior runs newest first when a later push leaves a reviewer's scoped content untouched. The packaged GitHub Action restores `/runs`, including conversation ids, but it does not restore the backend's native session store. A resume on a standard ephemeral runner therefore falls back to a fresh conversation. A custom workflow can preserve the backend session store too. With Akari isolation enabled for Claude Code, Codex, or Pi, persist `/native` alongside `runs`; otherwise persist the session location used by the selected backend. ### Running a subset by hand `--reviewer ` narrows the run to reviewers you name, for iterating on one stubborn gate without waiting on the rest. The named reviewers never carry a prior pass, local or CI (asking for a reviewer by name means asking for it to run); on a `--repo`/`--pr` run, a verified attestation can still replay a selected reviewer. When the selection excludes at least one triggered reviewer, the run is marked **partial** everywhere it is recorded: the `run.started`/`run.completed` events carry `"partial": true`, the human output and `bastion runs` say so, and the run cannot be attested. (Naming every triggered reviewer is a full run: the selection reduced nothing, so nothing is marked.) A partial green speaks only for the reviewers that ran. Finish with a plain `bastion review`: only a full run seals a real green. Carry walks the branch's prior runs newest first and, for each reviewer, uses the newest run that resolved it. A later partial run does not hide earlier sealed passes for reviewers it did not run, so those unchanged passes still carry on the finishing full run. The named reviewer itself executes fresh there: a partial run is never sealed, so a repository reviewer's pass from the partial cannot carry. The CI workflow passes `--repo`/`--pr` to select the PR and give reviewers its stated intent and discussion. Locally, `gh pr view` detects the current branch's PR without those flags and uses your existing `gh` authentication; `gh api` then reads the same first 100 conversation comments and first 100 review comments. If you pass `--repo`/`--pr`, Bastion also accepts the Actions REST client as a compatibility fallback. If `gh` cannot run and no REST token is available, the review continues without PR context. If `gh` runs and fails, Bastion warns and continues the same way. Those discussion requests are best effort and do not paginate. Intent is the PR body when it is non-empty, otherwise the title, otherwise your branch's commit messages (`base..HEAD`). Each reviewer's prior findings come from the run store. ### Exit codes The exit code *is* the gate, so a loop can branch on it: | Aggregate verdict | Exit code | | --- | --- | | `pass` (every applicable gate passed; other gates may be semantically skipped) | `0` | | `block` (a gate blocked, errored, or timed out) | non-zero | ```sh # Keep working until every gate is green. until bastion review; do echo "still blocked; fixing..." # ... make changes ... done ``` A blocked review is an *expected* outcome, not a crash: Bastion still exits cleanly with structured output, and only the code signals the gate. ## Two audiences, two formats By default `bastion review` renders human-readable progress for a person watching. An agent passes `--format jsonl` and gets a machine stream instead. Both describe the same run; only the presentation differs. ### The JSONL stream With `--format jsonl`, Bastion emits one JSON object per line, as each thing happens. A run is a typed sequence of events: ```jsonl {"type":"run.started","run":"r-0f3a","branch":"feat/cart","base":"main","changed":12,"reviewers":[{"name":"tenant-isolation","mode":"gate"},{"name":"single-responsibility","mode":"gate"}]} {"type":"reviewer.started","run":"r-0f3a","reviewer":"tenant-isolation","mode":"gate","backend":"claude-code"} {"type":"reviewer.started","run":"r-0f3a","reviewer":"single-responsibility","mode":"gate","backend":"codex"} {"type":"reviewer.finished","run":"r-0f3a","reviewer":"single-responsibility","duration_ms":842,"completed":1,"total":2} {"type":"reviewer.finished","run":"r-0f3a","reviewer":"tenant-isolation","duration_ms":38120,"completed":2,"total":2} {"type":"reviewer.resolved","run":"r-0f3a","reviewer":"tenant-isolation","verdict":"block","summary":"A new query path reads rows without scoping by tenant id.","findings":[{"kind":"blocking","path":"src/server/db.rs","line_start":88,"line_end":91,"detail":"scope this query by tenant_id"}],"usage":{"tokens_in":18204,"tokens_out":1560,"cache_read":12000,"cost_usd":0.21},"duration_ms":38120,"has_transcript":true} {"type":"reviewer.skipped","run":"r-0f3a","reviewer":"single-responsibility","mode":"gate","trigger":{"backend":"codex","decision":"skip","reason":"No responsibility boundary changed.","duration_ms":842},"has_transcript":true} {"type":"run.completed","run":"r-0f3a","verdict":"block","gates":{"total":2,"passed":0,"blocked":1,"skipped":1},"duration_ms":41030,"tokens_in":20480,"tokens_out":1875,"cache_read":13100,"cost_usd":0.37} ``` The event types: | Event | Meaning | | --- | --- | | `run.started` | The run began; lists the reviewer candidates in the plan. Each executes, semantically skips, replays from a verified attestation, or carries from the newest prior run on the branch that resolved that reviewer. Under `--reviewer` the list holds only the selected reviewers, and the event carries `partial: true` when that selection excludes a candidate. | | `reviewer.started` | One reviewer candidate began resolving: dispatched to its trigger or reviewer backend, reconstructed from a verified attestation bundle, or carried from the newest prior run on the branch that resolved that reviewer. | | `reviewer.finished` | One fresh reviewer task stopped executing. `completed` and `total` count only the fresh tasks because replayed and carried reviewers dispatch no backend. This event is progress only. The final outcome follows after post-run scope-digest checks. | | `reviewer.resolved` | One reviewer was finalized; carries its `verdict`, `summary`, `findings`, `usage` (present only when the backend reported it; a Muse Code reviewer never does), and a `has_transcript` flag. An agent-triggered reviewer that ran also carries its preceding `trigger` decision and, when its backend reported it, usage. Carries `replayed: true` when the terminal outcome came from a verified attestation, and `carried: true` when the verdict was carried from the newest prior run on the branch that resolved that reviewer instead of a fresh execution. A reviewer that produced a real verdict this run is also stamped with `scope_digest`, a hash of everything the verdict was keyed to; a later run carries a prior pass only when its own digest is identical. | | `reviewer.skipped` | An agent trigger decided that its full reviewer did not apply. Carries the trigger backend, decision, reason, usage (when the backend reported it), duration, and transcript availability without recording a pass verdict. It can also carry `replayed: true` when CI restored the terminal outcome from an attestation. | | `run.completed` | The aggregate decision and gate tally, including `gates.skipped`, plus the run's wall-clock `duration_ms` and usage totals summed across trigger and full-reviewer calls. Carries `partial: true` (as does `run.started`) when `--reviewer` narrowed the run. | | `run.attested` | A signed local run was replayed; carries the replayed `reviewers`, the attesting `public_key`, and `attested_at`. | | `run.attestation-fallback` | An attestation was *offered but refused*; carries the `reason` (an unreadable or unverifiable note, an unregistered key, a stale binding, and so on). A dirty CI checkout is the one refusal that needs no note: it is checked before note lookup, so a dirty tree emits this event even when HEAD carries no note. Otherwise a commit that offered no note is not a refusal and emits no such event: it resolves through the ordinary carry-or-execute path silently. | How an agent should consume it: - **Only need the outcome?** Ignore everything until `run.completed` and read its `verdict`. - **Want live progress?** Read each `reviewer.finished` event as it lands. Act on `reviewer.resolved` findings after finalization; record `reviewer.skipped` as an intentional omission, not a pass that needs fixing. ### For agents: the consumption contract If you are an agent driving the loop, this is the whole contract: 1. Run `bastion review --format jsonl`. 2. Parse stdout one line at a time as JSON; each line has a `type`. 3. Act on every `reviewer.resolved` with `verdict: "block"` using its `findings` (`path` + `line_start`/`line_end` + `detail`). Do not open transcripts; the findings already say what to change. 4. Treat `reviewer.skipped` as a recorded routing outcome. It has no verdict or findings, so never count it as a pass. 5. The aggregate decision is `run.completed.verdict`. The process also exits non-zero on `block`, so you can branch on the exit code alone if you only need pass/fail. 6. Fix what blocked and re-run. Stop when `run.completed.verdict` is `pass` (exit zero), or after three full invocations, whichever comes first. Then open your PR. If you stopped blocked, do not keep paying for another local run. Without `--base`, Bastion uses `gh` to detect the current PR and selects its direct base. If there is no PR, or `gh` cannot run, it uses `main` without a warning. If `gh` runs and fails, Bastion warns and uses the same fallback. Pass `--base ` when you need an explicit comparison point. This contract is exactly what `bastion skills install` checks into your repo as the `using-bastion` agent skill, so your agents follow it without being told each time. See [Teach your agents to use Bastion](./getting-started.md#7-teach-your-agents-to-use-bastion). ### The skills-freshness notice on stderr Before it runs, `bastion review` compares the `using-bastion` skill checked into your repo (under `.claude/skills` and `.agents/skills`) against the copy bundled in the running binary, the same comparison `bastion skills check` makes. When the checked-in copy is missing or has drifted, it prints a one-line notice to **stderr** naming the affected files and pointing at `bastion skills install`. This is the case where your agents may be following stale guidance, so the driving agent sees the notice inline with the run. It goes to stderr on purpose, keeping stdout as pure JSONL for a parser; the notice is advisory, so it never adds an event to the stream and never changes the exit status. A `block` still comes only from a reviewer. Run `bastion skills install` (add `--force` to overwrite a file you edited) and commit the result to clear it. The notice appears only when this repository has adopted Bastion, meaning a repo-level reviewer registry is present: a `.bastion.yaml`, its `.bastion.yml` spelling, or the deprecated `bastion/reviewers.yaml`, the same registry discovery a review already does. If your review is running solely on your own [user-level reviewers](./authoring-reviewers.md#user-level-reviewers) in a repo that has not configured Bastion, the notice stays silent: installing skills into a project that has not adopted Bastion would be beside the point. ### Money is dollars Cost fields (`cost_usd`) serialize as dollars (`0.21`) even though Bastion tracks exact cents internally, so you never see floating-point cent drift in the stream. Token fields (`tokens_in`, `tokens_out`, `cache_read`) are plain integer counts; on `run.completed` they are the totals summed across every agent call that reported usage, including trigger calls that skipped the full reviewer. A resolved reviewer's usage is top-level on `reviewer.resolved`; trigger usage is nested under `trigger` on either terminal reviewer event. `cache_read` is the input tokens served from the provider's prompt cache (cache hits); each backend names it differently natively (Claude's and Grok Build's `cache_read_input_tokens`, Codex's `cached_input_tokens`, Pi's `cacheRead`) and Bastion normalizes them to one field. It is 0 when a backend reports no cache usage. Muse Code's stream carries no usage at all, so a Muse reviewer reports no tokens and no cost and adds nothing to the run totals. ## What is streamed vs. what is saved The stream deliberately leaves out the verbose detail. A transcript is mostly noise to an agent that just wants to know what to fix; streaming thousands of lines on every run would bury the findings and burn the agent's own context. - **Streamed:** the decisions and the things you act on immediately: the reviewer set, start and terminal events, verdicts or skip reasons, summaries, findings, and per-reviewer usage. - **Saved, not streamed:** the verbose detail: full session transcripts, raw verdict payloads when a review ran, and per-reviewer metadata. Written to disk, read on demand. That is why both `reviewer.resolved` and `reviewer.skipped` carry the boolean `has_transcript` rather than the transcript itself. When it is `true` and a decision surprises you, the transcript is one command away (next section); a replayed outcome may have no local transcript and carry `false`. ## Inspecting saved runs Every run is persisted, so you can inspect history without re-running anything. These commands are the local equivalent of clicking "Details" on a CI check. The run-targeted ones (`show`, `transcript`) default to the latest run when you omit a run id; `runs` and `clean` operate over all saved runs. ```sh bastion runs # list recent runs: id, verdict, branch, reviewer count bastion show [] # re-print terminal verdicts, skips, and findings bastion transcript [] # the full agent session for one reviewer bastion clean [--keep N | --older-than ] # prune saved runs ``` - **`runs`** is the index: what ran recently and how each landed. - **`show`** re-emits a past run's terminal outcomes and aggregate: verdicts with findings, or semantic skip reasons with no findings. It accepts `--format human|jsonl`. - **`transcript`** prints the saved session for one reviewer. This is the explicit, opt-in way to see what was kept off the stream; reach for it when a verdict is surprising and you want to know why. It is raw text (a transcript is already a document). Pass either `` (latest run) or ` `. - **`clean`** prunes old runs. `--keep N` retains the N most recent; `--older-than ` (e.g. `7d`, `12h`) removes runs older than a duration. The two are mutually exclusive. ## Where runs live Bastion persists every run under a per-user data directory, by platform convention: - Linux: `$XDG_DATA_HOME/bastion`, default `~/.local/share/bastion` - macOS: `~/Library/Application Support/bastion` - Windows: `%APPDATA%\bastion` Override it with `--data-dir ` or the `BASTION_DATA_DIR` environment variable, handy for scratch runs you do not want in your real history. The layout: ```text / runs/ r-0f3a/ run.jsonl # the full event stream (always JSONL, regardless of display format) identity.json # opaque repository identity used for history lookup seal.json # the run seal, when the run was sealed (what `bastion attest` reads) reviewers/ tenant-isolation/ transcript.jsonl # the full agent session verdict.json # the raw structured verdict; absent on a semantic skip meta.json # backend, timing, usage, trigger, resumable conversation latest # a plain file holding the most recent run id native/ # isolated backend session state when Akari is enabled ``` Full runs at one commit reuse `r-` and overwrite the previous full run. A `--reviewer` partial is stored as `r--partial` so it cannot overwrite that full record. Prior-run lookup is scoped to the repository and branch. Runs created before this identity metadata existed remain available to `show` and `transcript`, but Bastion does not use them for findings, carry, or conversation continuation. `run.jsonl` is the same event stream whether a human or an agent triggered the run, so any run can be replayed or inspected after the fact. Runs accumulate: `bastion review` does not prune, so history grows until you run `bastion clean`, which keeps the most recent 20 when given no arguments (or use `--keep N` / `--older-than `). ## Providing environments locally For a **native** reviewer, the reviewer process inherits Bastion's own environment, so anything your shell or a `precommit` script has exported (a service on `http://localhost:3000`, say) is visible to the agent; a reviewer's `env` and `inputs` values are literal text set in the YAML, not shell-expanded. Bastion only reads values your shell or CI already exported; it does not stand them up. This is the same boundary CI honors, which keeps the local and CI surfaces in agreement. A **containerized** reviewer (one with a [`runner`](./authoring-reviewers.md#runner-and-capabilities), which today must also set `capabilities.network: true` to run) does not inherit your shell environment, since it runs in a container. Into it go the reviewer's literal `env` pairs plus a fixed provider-credential set, and nothing else. (If the reviewer's `env` sets one of those credential names, its value wins and the host's is not also forwarded.) So an exported `PREVIEW_URL` that a native reviewer would see for free reaches a containerized one only if you write its literal value into that reviewer's `env`, and a containerized reviewer typically reaches a host service over the container network rather than `localhost`. ## Attesting a run for CI Every reviewer is an agent invocation, so a project running Bastion both locally and in CI can pay for each review roughly twice: once in your loop, once again when CI confirms it. Incremental carry recovers some of that across CI pushes (an unchanged reviewer carries from the newest prior CI run on the branch that resolved that reviewer), but the first CI run of a changeset has nothing to carry from. If your repository has set `attestations: true` in its registry (see [Continuous integration](./continuous-integration.md#attesting-a-run-so-ci-can-replay-it)), you can sign your last green local run so CI reuses it instead of re-running every reviewer: ```sh git commit -am "final change" # attest needs a review over committed content git fetch origin base="$(gh pr view --json baseRefName --jq .baseRefName)" git rebase "origin/$base" # or merge; get up to date with the direct base bastion review --base "origin/$base" # ends green bastion attest # signs the run that just finished git push origin refs/notes/bastion ``` The sequence syncs with the base branch before the review because CI does not take the note's word for what was reviewed: it re-derives the merge base against the PR's base branch, the diff's patch id, and HEAD's tree from its own checkout, and replays only when all of them match what the run sealed. A review against a stale view of the base seals a merge base CI will not derive, so CI refuses the note and runs every reviewer fresh, the duplicate spend attestation exists to avoid. Diff against the fetched direct-base ref rather than a local branch, which can lag its remote. And sync before the review, not after: a rebase or merge moves HEAD, and the note binds to the reviewed HEAD. If the base moves again before CI runs and the PR reports an attestation fallback, repeat the sequence, and expect it to be cheap: a rebase moves the merge base but not your changeset, so every reviewer whose scoped diff comes out identical carries instead of re-running, and `bastion attest` signs the carried run like any other. `bastion attest [RUN]` takes an optional run id positional; omit it and it signs the latest recorded run, which is what you want right after `bastion review`. Pass one explicitly (`bastion attest r-0f3a`) to attest an older run instead. The review has to run over committed content for this to work. To use attestation, commit your final change, then run `bastion review`, then `bastion attest`. A review over a dirty working tree (uncommitted tracked changes or untracked files) still runs and seals, but the seal records that the tree was dirty, and `bastion attest` refuses that run outright and tells you to commit the final content, re-run the review, and attest that run instead. `bastion attest` also refuses a run recorded while any backend or container override was set (`BASTION_CLAUDE_BIN`, `BASTION_CODEX_BIN`, `BASTION_PI_BIN`, `BASTION_GROK_BIN`, `BASTION_MUSE_BIN`, `BASTION_CONTAINER_ENGINE`): such a run exercised a stubbed reviewer, not a real review, so it cannot be attested either. Re-run `bastion review` without those variables set, then attest that run. A partial run (`bastion review --reviewer`) is refused too: its verdict speaks only for the reviewers you selected, so run a full `bastion review` and attest that. `bastion attest` also re-checks that your repository has not moved on since a clean review (the same tree, the same diff, the same effective reviewer config) and refuses to sign if it has, so the note can never claim the reviewers saw something they did not. It signs with your SSH key (`git config user.signingkey`, or `--key ` to name one explicitly), prompting for a hardware token or keychain if your key requires it, and prints the exact push command. The signed bundle carries each repository reviewer's terminal outcome: either its verdict and findings or its agent-trigger skip reason and usage. A repository reviewer that blocked locally still blocks in CI when its verdict replays, and a skip replays as a skip rather than a pass. Your personal user-level reviewers are excluded from the bundle (they never gate anyone else's PR), so a run blocked only by a personal reviewer still attests, and CI sees only the repository reviewers' results. Push the printed command (or fold it into your normal `git push`) before opening the PR, so CI has the note when it runs. CI verifies the signature against the SSH signing keys you have registered with GitHub, so this only works for a key you have added there; a key freshly generated on the machine you are pushing from, with nothing registered, never verifies. Whether to use a plain key file or a presence-gated one (a hardware token or an OS keychain entry that prompts you per signature) is your call to make. A plain file key means an agent running on your machine could sign an attestation without you noticing, the same trust you already extend to that machine through your commit access. See [Attestation](https://github.com/attunehq/bastion/blob/main/docs/developer-guide/attestation.md) for the full trust model. ## The same surface in CI For the repository's reviewers, these local events are not a separate system from CI; their terminal outcomes have GitHub twins (check runs, comments, and annotations), laid out side by side in the [Continuous integration](./continuous-integration.md#how-a-run-maps-to-github) chapter. The local `run.started`, `reviewer.started`, and `reviewer.finished` progress events have no separate GitHub surface. A green local loop predicts a green PR when both runs see the same reviewers and context. The two surfaces run the repository's reviewers and aggregation. A local run that cannot see the pull request (no PR, or `gh` missing or failed) omits that discussion, so a reviewer that weighs it can decide differently. A purely local run can also include your personal user-level reviewers with `--with-user-reviewers`; their `run.started` and terminal `reviewer.resolved` or `reviewer.skipped` events are local-only and never become checks or comments (see [Authoring reviewers](./authoring-reviewers.md#user-level-reviewers)). --- Next: [Continuous integration](./continuous-integration.md). Promoting these same reviewers into GitHub Actions as a required merge check. --- # Continuous integration > Promoting your reviewers into GitHub Actions: one required check and per-author > billing. The local loop gets you to green before you open a PR. CI is the authoritative confirmation: it executes, skips, replays, or carries the reviewers from the repository's `.bastion.yaml` (replay draws from a verified attestation, when the registry sets `attestations: true`; carry reuses an unchanged reviewer's pass from the newest prior CI run on the branch that resolved that reviewer) and reports one merge gate. Because routing and aggregation are shared, CI rarely surprises an author who looped locally. It can differ when a local run cannot see the pull request (no PR, or `gh` is missing or failed), so reviewers miss that discussion, and because CI runs the repository's reviewers only, while a local run can also include your personal user-level reviewers with `--with-user-reviewers` (see [Authoring reviewers](./authoring-reviewers.md#user-level-reviewers)). The user-level layer is local-only by design, so it can never gate someone else's pull request. This chapter covers the GitHub adapter, the one forge Bastion targets. > Bastion does not own CI; it plugs into yours. The workflow, the secrets, the > preview environments, and the branch-protection rules are GitHub's. Bastion > reads and writes them through a thin adapter and otherwise stays out of the way. ## How a run maps to GitHub On each pull-request event (`opened`, `synchronize`, `reopened`) the workflow runs `bastion review`, which computes the changed files, routes reviewer candidates, and resolves them in parallel. A candidate may execute with a timeout, record an agent-trigger skip, replay from a verified attestation with no backend dispatch, or carry an unchanged pass from the newest prior CI run on the branch that resolved that reviewer. The GitHub Action persists the run store across runs by default. A second step, `bastion github report`, reads the persisted run and posts each terminal outcome to two GitHub surfaces: - **Findings are posted to the PR.** `bastion github report` renders every finding (blocking and optional) into a single sticky PR comment, and attaches each located finding to its reviewer's check run as an annotation on the finding's `path` and line range. The sticky comment is the surface an implementing agent reads; it carries everything it needs to act. - **Each terminal outcome becomes a check run** named after the reviewer (`bastion / tenant-isolation`). A blocking gate reports `failure`; a passing gate reports `success`; an advisor reports `success` with its findings attached. An agent-trigger skip reports `success` with a `Skipped` title and its routing reason, without claiming that the reviewer passed. `bastion github report` also folds a skills-freshness advisory into the sticky comment when the checked-out repo's bundled skills (`.claude/skills` and `.agents/skills`) are missing or have drifted from the reporting binary, the same comparison `bastion skills check` makes. It renders as a `> [!WARNING]` callout just under the headline, naming each affected file and pointing at `bastion skills install`. It is advisory only, so it never changes a check-run conclusion or the `bastion` gate; it tells you to refresh stale skills without failing the build. The local `bastion review` prints the same notice to stderr when the repository has adopted Bastion (a repo-level reviewer registry is present); a review running on user-level reviewers alone stays silent. In CI the repository always has a registry, so this advisory is unaffected. The local-to-GitHub mapping is one-to-one for the repository's reviewers: the JSONL events a CI or `bastion review --repo/--pr` run produces are the same decisions GitHub renders as checks and a comment. (A purely local run can also include your personal user-level reviewers with `--with-user-reviewers`; their events are local-only and have no GitHub twin.) Each GitHub surface has a local twin: | GitHub | Local | | -------------------------------------------------------------- | ----------------------------------- | | A per-reviewer check run reaching its conclusion | `reviewer.resolved` or `reviewer.skipped` event | | Findings in the sticky PR comment and as check-run annotations | `findings` in `reviewer.resolved` | | Tokens and cost in the check output | `usage` in `reviewer.resolved`; `trigger.usage` for trigger calls and skips | | The aggregate `bastion` check and the sticky PR comment | `run.completed` event | | Transcript in the uploaded run artifact | saved on disk, `bastion transcript` | The local stream additionally carries `run.started`, `reviewer.started`, and `reviewer.finished` for an agent reacting as the run goes; those have no separate GitHub surface, because `bastion github report` runs after the review finishes and renders the result in one pass. This mapping is deliberate, so an agent's local loop and the CI gate stay aligned on what a review means. ## The one required check Branch protection needs you to name the checks that must pass, but Bastion's set of reviewers *varies per PR*: a docs-only PR and a server PR trigger different reviewers, so there is no fixed list of names to require. The fix is a single always-present check, **`bastion`**, and it is the only one branch protection requires. It runs even when zero reviewers match (a trivial pass) so it is always there to require. Internally it reflects the aggregate: `success` when every applicable gate passed, including runs where an agent trigger recorded a semantic skip; `failure` if any gate blocked, errored, or timed out (fail-closed). The per-reviewer checks stay informational; `bastion` is the gate. ## The workflow The packaged adapter is the **Bastion GitHub Action**: the `action.yml` at the root of the Bastion repository, pinned as `attunehq/bastion@v0`. Its contract mirrors the local loop. You own the checkout and the backend credential, exactly as a contributor owns their clone and their `codex login`; the action owns the engine and the review. A complete workflow: ```yaml name: bastion on: pull_request: types: [opened, synchronize, reopened] # The action posts the PR comment and the check runs, so the job needs more than # read access. `actions: read` lets it restore the branch's prior run artifact. permissions: contents: read pull-requests: write checks: write actions: read jobs: review: runs-on: ubuntu-latest # Agentic backends run over the PR's code with live credentials, so restrict to # same-repo PRs; a maintainer re-runs a fork PR from a trusted branch. if: github.event.pull_request.head.repo.full_name == github.repository steps: - uses: actions/checkout@v4 with: fetch-depth: 0 # full history; the review fails without a resolvable merge base # The PR head, not the default merge commit: attestation replay binds # to the head tree the author attested, which a merge commit never matches. ref: ${{ github.event.pull_request.head.sha }} # Your half of the contract: install and authenticate the backend CLI the # repository's reviewers pin (claude, codex, pi, grok, or muse), billed to the PR # author. The concrete per-author auth step is in "Authentication & # billing" below; drop it in here. Then stand up anything your reviewers # consume (a preview env, a database). - uses: attunehq/bastion@v0 ``` The action then, in order: 1. **Installs a published `bastion` release** (checksum-verified, via the same installer as a local install). The action ref picks the engine: `@v0.3.0` installs exactly that release, a floating `@v0` installs the newest stable release in that major, and any other ref installs the latest stable release. The `version` input overrides both, which is also how a SHA-pinned action pins its engine. 2. **Fetches the attestation notes ref**, so a signed local run can replay instead of re-executing (see below). Absence is the ordinary case and is skipped quietly. 3. **Restores the branch's most recent prior run** into the data directory. A fresh runner starts with an empty store, so without this two things reset on every push: a reviewer's recall of the findings it raised last push, and incremental carry (an unchanged reviewer reusing its prior pass instead of re-executing). The restored metadata also names prior backend conversations. Best effort: a first push restores nothing and every reviewer runs fresh. 4. **Runs `bastion review`**, diffing at the merge base with that PR's direct base and feeding reviewers its description and discussion via `--repo`/`--pr`. Each PR in a native GitHub stack is reviewed as one independent layer. 5. **Uploads the run as an artifact**, so the next push can restore it and so the full transcripts are kept. 6. **Runs `bastion github report`**, posting the sticky comment and the per-reviewer and aggregate check runs. 7. **Fails on a blocked review**, deliberately last, so the comment and checks land even when the gate blocks. The step's failure is your merge gate. The action uploads only `/runs`. It does not persist the agent CLI's native session store, so a reviewer that must execute usually cannot resume its prior conversation on a fresh hosted runner. Bastion treats that as a cache miss and starts a fresh conversation. To preserve conversations in a custom workflow, also restore the backend's session state. When `BASTION_AKARI=1` isolates Claude Code, Codex, or Pi sessions, include `/native` in the artifact. Its inputs, all optional: | Input | Default | What it does | | --------------- | -------------------- | --------------------------------------------------------------------------------------------------------- | | `version` | the action's own ref | The engine release to install: an exact tag or `latest`. | | `github-token` | the job token | Restoring history, authenticating `gh`, and reading REST context (`actions: read`, `pull-requests: read`). | | `report-token` | `github-token` | Posting the report; set a dedicated app's minted token here (see below). | | `base` | the PR's base branch | Passed explicitly to review. An override wins over automatic PR base detection. | | `report` | `true` | Whether to run `bastion github report` after the review. | | `run-history` | `true` | Whether to restore and upload the run store across pushes. | | `artifact-name` | `bastion-run` | The run artifact's name. | It outputs the resolved engine `version` and the review's `exit-code`, for workflows that set `continue-on-error` and branch on the outcome themselves. What stays yours: - **The checkout.** Full history (`fetch-depth: 0`) so the base resolves, and the PR's head SHA rather than the default merge commit, for attestation. - **The backend CLI and its credential.** The engine runs whatever backend CLI it finds on `PATH` with whatever auth that CLI reads, exactly as it does locally; install and authenticate it before the action runs (see [Authentication & billing](#authentication--billing)). The action never touches credentials. That host CLI and its auth cover **native** reviewers (the default). A reviewer with a [`runner`](./authoring-reviewers.md#runner-and-capabilities) runs its backend *inside a container* instead (and must declare `capabilities.network: true`; without it the reviewer is rejected before it runs, so a gate blocks and an advisor is skipped), so for those the runner needs a container engine (`docker` by default, or whatever `BASTION_CONTAINER_ENGINE` names) and the backend executable plus its auth inside the image, not on the host. The fixed provider credential variables are forwarded from the job into the container by name, so host auth still reaches a containerized reviewer's provider even though the CLI itself lives in the image. - **Environments.** Anything your reviewers consume (a preview URL, a database) is stood up before the action runs; see [Environments & inputs](#environments--inputs). - **The fork guard.** The `if:` above keeps live credentials away from untrusted code; see [Fork-PR safety](#fork-pr-safety). - **The dedicated app, optionally.** Mint its token in a prior step and hand it to `report-token`; see [Grouping the checks under their own app](#grouping-the-checks-under-their-own-app). The action supports `pull_request` events only, and deliberately rejects `pull_request_target`: that trigger hands repository secrets to a workflow run against fork code, which is exactly what the fork guard exists to prevent. ### Rolling your own workflow Use the raw shape below when you cannot consume actions from github.com (say, a GitHub Enterprise instance without action sync) or when you need to rearrange the steps: ```yaml name: bastion on: pull_request: types: [opened, synchronize, reopened] # The report step writes the PR comment and the check runs, so the job needs more # than read access. `actions: read` lets the run-store restore step below list and # download this branch's prior run artifact. permissions: contents: read pull-requests: write checks: write actions: read jobs: review: runs-on: ubuntu-latest # True only when both dedicated-app secrets are set (the id and key are one # credential), so a half-configured repo falls back instead of failing the mint # step. Computed here because the `if:` below can read `env` but not `secrets`. env: HAS_BASTION_APP: ${{ secrets.BASTION_APP_ID != '' && secrets.BASTION_APP_PRIVATE_KEY != '' }} # Agentic backends run over the PR's code with live credentials, so restrict to # same-repo PRs; a maintainer re-runs a fork PR from a trusted branch. if: github.event.pull_request.head.repo.full_name == github.repository steps: - uses: actions/checkout@v4 with: fetch-depth: 0 # full history; the review fails without a resolvable merge base # The PR head, not the default merge commit: attestation replay binds # to the head tree the author attested, which a merge commit never matches. ref: ${{ github.event.pull_request.head.sha }} # actions/checkout does not fetch notes by default. Tolerant of the ref # being absent: attestation is optional, so most PRs will not carry a note. - name: Fetch the attestation notes ref run: git fetch origin +refs/notes/bastion:refs/notes/bastion || true # 1. Install a published bastion release (not built from the PR). # 2. For native reviewers: install your backend CLI (claude, codex, pi, grok, # or muse) on the runner and authenticate it as the PR author. The # concrete per-author auth step is in "Authentication & billing" below; # drop it in here. For # reviewers with a `runner`: ensure a container engine is on the runner # (docker by default, or set BASTION_CONTAINER_ENGINE) and that the backend # CLI and its auth live inside the image; the provider credential variables # are forwarded in by name. # 3. Stand up anything your reviewers consume (a preview env, a database). # Bring this branch's most recent prior run into the data directory before # reviewing. A fresh runner starts with an empty store, so without this two # things reset on every push: a reviewer's recall of the findings it raised # last push, and incremental carry (an unchanged reviewer reusing its prior # pass instead of re-executing). Best effort: a first push, or an expired # artifact, restores nothing and the review runs every reviewer fresh. - name: Restore prior run history env: GH_TOKEN: ${{ github.token }} # Pass the branch name through the environment, never spliced into the # script text, so an attacker-chosen branch name cannot inject shell. HEAD_REF: ${{ github.head_ref }} RUN_ID: ${{ github.run_id }} WORKSPACE: ${{ github.workspace }} run: | set -euo pipefail mkdir -p "$WORKSPACE/.bastion/runs" # --workflow takes the `name:` at the top of this file. The newest run of # this branch other than the current one is the prior run to restore. prior="$(gh run list --workflow bastion --branch "$HEAD_REF" \ --json databaseId \ --jq "map(select(.databaseId != $RUN_ID)) | .[0].databaseId // empty")" \ || prior= if [ -n "$prior" ]; then gh run download "$prior" -n bastion-run -D "$WORKSPACE/.bastion/runs" \ || echo "no prior bastion-run artifact to restore (first run or expired)" fi - name: Review env: BASTION_DATA_DIR: ${{ github.workspace }}/.bastion # Authenticates `gh pr view` and the optional REST discussion requests. # The two comments requests are best effort and read 100 each. GITHUB_TOKEN: ${{ github.token }} # Non-zero exit on a blocked gate fails the job; that is the merge gate. # --repo/--pr select the PR used for automatic base detection and feed its # intent and discussion to reviewers. The restore and upload steps persist the run # store between runs, which buys two things a fresh runner would lose: cross-run # prior-findings memory, and incremental carry, where an unchanged reviewer # reuses its prior pass instead of re-executing. Keep the backend on PATH with no # BASTION_*_BIN override, so the run seals clean and stays carry-eligible. run: | bastion review --repo "${{ github.repository }}" \ --pr "${{ github.event.pull_request.number }}" # Optional: mint a token for a dedicated Bastion app so the check runs get # their own check suite and render under the app's name. Skipped (and the # report falls back to the default GITHUB_TOKEN) when the app is not set up. # See "Grouping the checks under their own app" below. - id: app-token if: ${{ always() && env.HAS_BASTION_APP == 'true' }} uses: actions/create-github-app-token@v2 with: app-id: ${{ secrets.BASTION_APP_ID }} private-key: ${{ secrets.BASTION_APP_PRIVATE_KEY }} - name: Report to the PR # Runs even when the review blocked and failed the job, so the comment and # checks always land. Creating check runs needs a GitHub App installation # token (a classic PAT cannot); both the dedicated-app token and the default # GITHUB_TOKEN qualify, so use the dedicated one when present and fall back. if: always() env: GITHUB_TOKEN: ${{ steps.app-token.outputs.token || github.token }} BASTION_DATA_DIR: ${{ github.workspace }}/.bastion run: | set -euo pipefail bastion github report \ --repo "${{ github.repository }}" \ --pr "${{ github.event.pull_request.number }}" \ --sha "${{ github.event.pull_request.head.sha }}" # Persist this run so the next push can restore it (see the restore step # above). The data dir is dot-prefixed, so hidden files must be included or the # upload is empty and the next restore finds nothing to carry from. - name: Upload the run if: always() uses: actions/upload-artifact@v4 with: name: bastion-run path: ${{ github.workspace }}/.bastion/runs/** include-hidden-files: true if-no-files-found: warn ``` ### `bastion github report` The report step reads the run that `bastion review` just persisted (under `BASTION_DATA_DIR`) and posts it to the pull request. Its full surface: ``` bastion github report --repo --pr --sha [RUN] ``` - `--repo `: the repository to post to. Defaults to the `GITHUB_REPOSITORY` environment variable that Actions sets, so you can usually omit it. - `--pr `: the pull request number (required). - `--sha `: the head commit the check runs attach to (required); pass the PR's `head.sha`, not the merge commit. - `RUN`: an optional positional run id to report; defaults to the latest recorded run, which is what you want right after `bastion review`. It needs a token with `pull-requests: write` and `checks: write` in `GITHUB_TOKEN`, and reads `GITHUB_API_URL` (Actions sets it; also the hook for GitHub Enterprise). Creating check runs requires a GitHub App installation token; both the default Actions `GITHUB_TOKEN` and a dedicated-app token (see below) are installation tokens and qualify, while a classic personal access token does not. If the run cannot be found (an earlier failure persisted nothing), it prints a notice and exits 0 rather than failing the step a second time. The command is CI-facing and has no local mirror: locally you read findings straight from `bastion review --format jsonl`. ### Grouping the checks under their own app In the PR checks list, the name before the `/` is not the workflow that created a check; it is the **check suite** the check belongs to, and a check suite is keyed by `(GitHub App, commit)`. Every GitHub Actions workflow runs under the one shared `github-actions` app, so a commit that triggers several workflows has several `github-actions` suites. The check runs `bastion github report` creates through the REST API carry no suite id (the API does not accept one), so GitHub attaches them to one of those suites of its own choosing, often a sibling workflow's. The result is check runs that read like `Security / fail-closed-gates` instead of grouping on their own. A check run lands in its own named suite only when a **distinct GitHub App** creates it. So the fix is to post the report under a small app of your own rather than the shared Actions identity: 1. Create the app. Go to [bastion.attune.inc/github-app](https://bastion.attune.inc/github-app) and follow the walkthrough; it shows how to create a GitHub App by hand in GitHub's UI with exactly the permissions the report step needs (`checks: write`, `pull_requests: write`, `contents: read`, no webhook). The app's **name** is what the checks group under, for example `YourOrg's Bastion`. 2. Generate the app's private key, note its numeric App ID, and install the app on the repositories that run Bastion. 3. Store `BASTION_APP_ID` (the App ID) and `BASTION_APP_PRIVATE_KEY` (the `.pem` contents) as Actions secrets. For Dependabot-triggered runs, set them in the Dependabot secret store too. The workflow mints a token from those secrets with [`actions/create-github-app-token`](https://github.com/actions/create-github-app-token) and hands it to the action's `report-token` input (or, rolling your own, to the report step's `GITHUB_TOKEN`); the per-reviewer and aggregate checks then render under the app's name. The step is fully optional: with the secrets unset it is skipped and reporting falls back to the default `GITHUB_TOKEN`, which still posts the comment and checks, only grouped under whichever suite GitHub picks. When that happens, `bastion github report` notices (it reads back the app that GitHub stamped on the check runs it just created) and closes the PR comment with a short note linking here; once a dedicated app is configured the note disappears. Because the report reads GitHub's response, the workflow does not pass a flag. For a complete, working example (the action plus per-author backend credentials, the dedicated-app mint, and fork-PR safety), see Bastion's own [`.github/workflows/bastion.yml`](https://github.com/attunehq/bastion/blob/main/.github/workflows/bastion.yml). It wires up the per-author auth recipe in [Authentication & billing](#authentication--billing) below, on the Codex backend. Configure branch protection on your default branch to require this job (and to require review of the reviewer-policy paths; see [Governance](./governance.md)). Merging stays GitHub-native: an author enables auto-merge, and once the required job is green GitHub merges. A push re-triggers the workflow and it resolves again. ## Attesting a run so CI can replay it Every reviewer is an agent invocation, so a PR that ran clean locally pays for each reviewer the first time CI confirms it. (Once the run store is persisted across runs, a later push carries any reviewer whose scoped content did not change since the newest prior CI run on the branch that resolved that reviewer, so the recurring cost falls on subsequent pushes; the very first CI run of a changeset still has nothing to carry from.) Attestation cuts the cost of that first run too: if you would rather CI trust a signed local run than re-execute every reviewer, opt in with one registry field: ```yaml attestations: true reviewers: # ... ``` This works only for a review over committed content, on a branch that is up to date with the base: CI re-derives the merge base from the PR's base branch and refuses a note sealed against a stale one. To use attestation, commit the final change, fetch and sync with the base branch, run `bastion review` against the fetched base, then run `bastion attest` (see [The local workflow](./local-workflow.md#attesting-a-run-for-ci)). A review over a dirty working tree still runs and still seals, but the seal records that the tree was dirty, and `bastion attest` refuses to sign it; attest the clean, committed run instead. Once an author pushes the resulting note, CI can replay the covered reviewers instead of re-running them. `bastion review` in CI verifies the note's signature against the PR author's GitHub-registered SSH signing keys, checks that the attested run reviewed the exact same content CI is looking at (the same trees, the same diff, the same effective reviewer config), and only then replays. A replayed block still blocks the merge, exactly as a fresh one would; attestation skips duplicate execution, not the gate. Two workflow requirements make this work. Checking out the PR's head commit (`ref: ${{ github.event.pull_request.head.sha }}`) is yours, since attestation binds to that exact tree and the default merge-commit checkout will never match it; the workflow at the top of this chapter does it. Fetching the notes ref is the action's: `actions/checkout` does not fetch notes by default, so the action fetches `refs/notes/bastion` before reviewing (rolling your own, add `git fetch origin +refs/notes/bastion:refs/notes/bastion`, tolerant of the ref being absent). When attestation replaces execution, the sticky comment opens with a callout naming which reviewers replayed, the key that attested, and when; each replayed reviewer's check-run summary says so too. When an attestation is offered but *refused* (an unreadable or unverifiable note, an unregistered key, a stale base, or any other mismatch), CI falls back to resolving each reviewer the ordinary way and the comment carries a `> [!WARNING]` block naming the reason: a reviewer whose content is unchanged since the newest prior CI run on the branch that resolved that reviewer is still carried, and the rest execute fresh. A dirty CI checkout (uncommitted or untracked files) is treated as a refusal too, and is checked before the note is even looked up: it warns even when HEAD carries no note, since the reviewers see content no attestation could bind. On a clean checkout that simply carries no note, nothing was offered to refuse: CI resolves reviewers the ordinary way (an unchanged prior pass still carries, the rest execute) and says nothing about attestation, so an un-attested PR is never nagged. Attestation short-circuits the note lookup, not carry. A reviewer can opt out of ever being replayed with `attestation: never` on that reviewer, for a gate your team wants CI to execute unconditionally regardless of what was attested locally. Whether the SSH key an author attests with is a plain file or a presence-gated one (a hardware token or an OS keychain entry that prompts per signature) is worth deciding deliberately. A coding agent running on the author's machine can use a plain file key without their involvement, so enrolling one means accepting that an agent on that machine could sign an attestation on its own, the same trust already extended to that machine through commit access. Bastion cannot tell the two kinds of key apart from the signature alone, so this is a call for the author (or your team's policy) to make, not something the tool enforces. See the [attestation design](https://github.com/attunehq/bastion/blob/main/docs/developer-guide/attestation.md#trust-posture) for the full reasoning. ## Authentication & billing Coding-agent subscriptions tie usage to an individual, not a team, so Bastion bills a PR's reviews to the *PR author*. Reviewing Alice's PR is billed to Alice's subscription, which is the ToS-compliant reading: each contributor's plan powers the review of their own changes. Bastion never stores credentials. The team stores each author's credential as an Actions secret, and the workflow maps the PR author's GitHub login to the matching secret at run time. Bastion just runs your backend CLI, and the backend reads whatever auth it finds on the runner. Your job in CI is to place the right author's credential where that CLI looks before `bastion review` runs. The pattern is the same for every backend: 1. **Capture the credential once, locally.** Each contributor signs in to the backend on their own machine. The CLI writes a credential file: | Backend | Sign-in | Credential file the CLI reads | | ------------- | ------------------ | ---------------------------------------------- | | `codex` | `codex login` | `~/.codex/auth.json` (relocatable: `CODEX_HOME`) | | `pi` | `pi` auth flow | `~/.pi/agent/auth.json` | | `claude-code` | `claude` sign-in | `~/.claude` (OAuth token) | | `grok` | `grok login` | `~/.grok/auth.json` (or `XAI_API_KEY` for API billing) | | `muse` | `muse login` | `~/.config/muse/auth.json` (or `META_API_KEY` for API billing) | For a ChatGPT or Claude **subscription**, this file holds an OAuth credential (an access token plus a refresh token); the CLI refreshes the short-lived access token from the stored refresh token on each run, so the secret does not need rotating every time the access token expires. One sharp edge: the provider rotates the refresh token when it is used, so two jobs refreshing the same stored credential at once (two PRs from one author, say) can collide, and the loser fails closed with a `refresh_token_reused` error. Re-run the failed job; if the error persists across re-runs, the stored copy has been superseded, so sign in again locally and update the secret. A Codex `auth.json` from a ChatGPT sign-in carries `"auth_mode": "chatgpt"`, and the native `backend: codex` reads it directly: you do **not** need Pi to spend a ChatGPT subscription (see [Spending a subscription in CI](#spending-a-subscription-in-ci) below). 2. **Store it as a per-author secret.** Copy the file's contents into a repository secret named `_AUTH_`: the backend, then the GitHub login uppercased. For the `codex` backend and the login `jssblck`, that is `CODEX_AUTH_JSSBLCK`; for `pi`, `PI_AUTH_JSSBLCK`. The name is a convention you pick and reference in the workflow, not something Bastion parses. 3. **Map the login to the secret in the workflow.** Resolve `github.event.pull_request.user.login` to the matching secret through a `case` arm, then write it back to the path the CLI reads: ```yaml - name: Authenticate Codex as the PR author env: AUTHOR: ${{ github.event.pull_request.user.login }} CODEX_AUTH_JSSBLCK: ${{ secrets.CODEX_AUTH_JSSBLCK }} run: | set -euo pipefail author="$(printf '%s' "$AUTHOR" | tr '[:upper:]' '[:lower:]')" case "$author" in jssblck) cred="$CODEX_AUTH_JSSBLCK" ;; *) echo "::error::No Codex credential mapped for PR author '$AUTHOR'. Add a CODEX_AUTH_ secret and a case arm." >&2 exit 1 ;; esac if [ -z "$cred" ]; then echo "::error::Codex credential for '$AUTHOR' is mapped but its secret is empty." >&2 exit 1 fi mkdir -p "$HOME/.codex" printf '%s' "$cred" > "$HOME/.codex/auth.json" chmod 600 "$HOME/.codex/auth.json" ``` Onboarding a contributor is then two reviewed lines: their secret and a `case` arm. Because the mapping lives in the workflow, which is a CODEOWNERS-protected path (see [Governance](./governance.md)), changing who may spend a subscription is itself a human-reviewed change. An author with no mapped secret **fails closed**: the step errors and the gate blocks, rather than silently billing someone else's subscription. If you would rather a new contributor never be blocked, point the `*)` arm at a shared metered **API key** instead of erroring: store the provider's API key as a secret and export it (for example `CODEX_API_KEY` / `ANTHROPIC_API_KEY`) into the review step rather than writing an `auth.json`. The same login-to-secret shape applies. Under heavy volume a subscription's rate limits can throttle reviewers, and because gates fail closed a throttled reviewer reads as a blocked merge, so some teams use API billing in CI and keep subscriptions for the local loop. ### Spending a subscription in CI A ChatGPT or Claude subscription works in CI the same way it does locally: the backend CLI reads its OAuth `auth.json` and refreshes the token itself. Use the backend that matches the subscription you have: - **`backend: codex` with a ChatGPT subscription.** Sign in with `codex login` (ChatGPT), store `~/.codex/auth.json` as `CODEX_AUTH_`, and rehydrate it to `$HOME/.codex/auth.json` as shown above. This is the direct path; no Pi involved. - **`backend: claude-code` with a Claude subscription.** Same shape against the `claude` CLI's auth. - **`backend: pi` with the `openai-codex` provider.** Pi can also spend a ChatGPT subscription, through its `openai-codex` provider (`model: openai-codex/gpt-5.5`). Reach for this only when you specifically want Pi's multi-provider routing; for plain Codex-on-ChatGPT, the native `codex` backend is simpler. > **The two `auth.json` files are different.** `~/.codex/auth.json` (Codex CLI) and > `~/.pi/agent/auth.json` (Pi CLI) are distinct file formats backed by the same > ChatGPT account. The secret you store must match the backend you pin: a Codex > `auth.json` rehydrated where Pi looks (or the reverse) will not authenticate. Pick > the backend first, then capture that CLI's file. ### Dependabot and bot authors Dependabot opens **same-repo** PRs, so they clear the fork guard and Bastion reviews them like any other PR. With the `permissions:` block the example workflow declares, the default `GITHUB_TOKEN` posts the `bastion` check on a Dependabot PR, so you can require it for those PRs too. There is no read-only-token deadlock to work around. Dependabot has one required difference for everyone and one extra step that applies only to per-author billing: - **Secrets come from a separate store (applies to everyone).** GitHub serves secrets to Dependabot-triggered runs from a *Dependabot* secret store, not the Actions store. Whatever credential your review step reads, an `ANTHROPIC_API_KEY` or a per-author `_AUTH_`, must be set in that store as well (`gh secret set --app dependabot`), or it arrives empty on a Dependabot PR and the gate fails closed. - **A bot has no subscription of its own (per-author billing only).** If you map per-author credentials, the bot author needs a `case` arm pointing at a maintainer who sponsors its reviews, and the bracketed login must be quoted, since `[bot]` is a glob character class in a shell `case` pattern: `'dependabot[bot]') cred="$CODEX_AUTH_JSSBLCK" ;;`. An arm that maps to an empty secret fails closed with a "mapped but empty" error, usually the sign the Dependabot-store copy is missing. Billing with a shared API key instead of per-author secrets avoids this entirely: there is no per-author arm to maintain. ### Fork-PR safety GitHub does not expose secrets to workflows triggered by **fork** pull requests, and an agentic backend should never run over untrusted code with a live credential anyway. The example workflow guards on `github.event.pull_request.head.repo.full_name == github.repository`, so it runs for same-repo PRs only. A fork contribution is reviewed by a maintainer re-running it from a trusted branch in the repo. ## Environments & inputs Bastion consumes environments; it does not provision them. A reviewer that needs a preview URL, a database, or any running dependency expects the workflow to have stood it up and exposed it. Typically an earlier job deploys a preview environment for the PR and passes its URL into the Bastion job as an environment variable. How that variable reaches the agent depends on where the reviewer runs. A **native** reviewer inherits the job environment, so the agent can see it directly. A **containerized** reviewer (one with a [`runner`](./authoring-reviewers.md#runner-and-capabilities) and `capabilities.network: true`) runs in a container and does *not* inherit the arbitrary job environment. Only the reviewer's literal `env` pairs cross that boundary (plus a fixed provider-credential set, except that a credential name set in the reviewer's own `env` wins and is not also forwarded from the job environment), so a per-PR value reaches a containerized reviewer only if you write its value into the registry, typically by templating `.bastion.yaml` before the Bastion job runs. A reviewer's `env` and `inputs` values are literal (Bastion does not shell-expand them), so to put a dynamic value into the prompt itself you template the registry or have the prompt read the variable. Standing up the environment is a deploy concern; Bastion's job starts once it exists. (See [Authoring reviewers](./authoring-reviewers.md#env) for the reviewer side.) ## Self-hosting note Bastion dogfoods the adapter through [`.github/workflows/bastion.yml`](https://github.com/attunehq/bastion/blob/main/.github/workflows/bastion.yml), which consumes the GitHub Action from the PR's own checkout (`uses: ./`) so action changes take effect in the same PR. The engine stays out of the PR's reach: with no release ref to pin, the action installs the latest published `bastion` release rather than a binary built from the PR's own sources, so a change can never edit the engine that judges it. That workflow is a concrete instance of everything this chapter describes. --- Next: [Governance](./governance.md). Keeping humans at the policy layer with CODEOWNERS and branch protection, and the escape-to-improvement loop. --- # Governance > Keeping humans at the policy layer: protecting the registry, the > escape-to-improvement loop, and what Bastion deliberately does not guarantee. Bastion relocates the human from reviewing diffs to *governing the reviewers*. That only works if the reviewer policy itself is protected and continuously improved. This chapter is the human's operating manual. ## The policy layer The reviewers, their prompts, and their triggers *are* the review policy. The whole safety story rests on a simple rule: **any change to that policy is reviewed by a human before it merges.** Otherwise an aligned-but-mistaken agent could quietly loosen a trigger or soften a prompt, and the gate would erode without anyone noticing. Two native GitHub mechanisms enforce this; neither is exotic. ### CODEOWNERS protects the registry Bastion can generate a CODEOWNERS block covering the reviewer-policy paths: the registry, the reviewer definitions, the Bastion workflow, and the CODEOWNERS file itself: ```sh bastion github codeowners --owner @your-org/platform ``` Pass `--owner` once per owner (it is repeatable). When run inside the repository, the command loads the registry and adds an entry for every file it pulls in (each `include:`d registry file and each `prompt: {file: ...}` prompt file), since those carry policy exactly like the root file. A pulled-in file that resolves outside the repository is left out: CODEOWNERS cannot protect a path outside the tree, so keep policy files in the repository if you want them governed. If the registry, an included file, or a prompt file fails to load, the command prints the error and exits non-zero rather than emitting a block that silently omits policy paths. With no repository, or with no registry and no `--include ` files (which merge here the same way they do on a review), it prints the static paths alone. Add the generated block to your `CODEOWNERS`, and regenerate it when you add an include or a prompt file so the new path is covered. With that block in place, any PR that adds, removes, or edits a reviewer; loosens a trigger; or changes a prompt touches an owned path, so GitHub requires a human review before merge. You can also write your own CODEOWNERS instead; the generated block is a correct starting suggestion. > Why generate it statically rather than have Bastion manage it live? CODEOWNERS > changes only take effect *after* a PR merges, so the file must be written to > protect every path Bastion will ever write into, ahead of time, which is what > the generated, reviewed block provides. ### Branch protection requires the check Require Bastion's review on your default branch. That is the review job from [Continuous integration](./continuous-integration.md#the-workflow), which also posts the always-present aggregate check named `bastion` (with a check run per reviewer alongside it), so you can require either the job or that `bastion` check. A PR then cannot merge with the gate switched off, and because the workflow file and the registry are themselves owned paths, switching it off is itself a policy change a human sees. That is the entire enforcement story, and it is intentionally modest. The contributor Bastion is designed for is an aligned agent that would never quietly disable CI; the CODEOWNERS trip wire and the required check exist so that *if* policy changes, a human is in the loop, not so that a determined adversary is stopped. ## The escape-to-improvement loop An **escape** is a PR that merged but should have been blocked: a reviewer missed something. Escapes are inevitable, especially early while reviewers are still being tuned, and they are the single most valuable signal for improving the system. Bastion cannot detect escapes itself: if it could, it would have blocked them. This is a human governance loop: 1. **Notice** an escape (monitoring, a bug report, a production incident). 2. **Triage** it: which reviewer(s) should have caught it, and why did they not? 3. **Improve** the policy: sharpen a prompt, add a new single-concern reviewer for the missed property, or fix the reviewer's environment. 4. **Merge** the policy change (through the CODEOWNERS-gated human review above). This is why Bastion expects reviewers to improve over time. Start with a reviewer that is good enough and sharpen it from real escapes instead of perfecting it on paper. Treat escapes as expected feedback rather than failures, and triage them regularly so the policy keeps improving. ## What Bastion does not guarantee Govern with these limits in mind; they are deliberate, not gaps to be closed: - **It is not a correctness proof.** Bastion does not guarantee code is free of bugs or vulnerabilities. A reviewer is only as good as its model and prompt; it is code review without the human in the small loop, not a verifier. - **It does not judge whether the right thing is being built.** That is a design-time question; by PR time the ship has sailed. Keep humans in the design loop. - **It is not an adversarial security boundary.** Bastion assumes PR authors are aligned contributors and treats reviewed code as trusted input; it does not defend reviewer agents against prompt injection or exfiltration from the code they review. The bar is *reasonable reduction proportionate to effort*: a speed bump and good defaults, like lint and CI and human review before it. [Attestation](../developer-guide/attestation.md) (`bastion attest`) fits the same bar. `bastion attest` signs a local run's terminal reviewer events with the author's SSH key, including both verdicts and agent-trigger skips. CI verifies that signature against the author's GitHub-registered signing keys before replaying anything, so trust is rooted in the forge account, the same account repository permissions already trust with merge access. That distinguishes an author's enrolled key from one a coding agent minted on the spot. It does not defend against a malicious author who deliberately forges a seal or signs off on a run they know is wrong: the threat model assumes aligned authors throughout, so using signing as an adversarial boundary against a malicious author is out of scope, along with an enumerated trusted-computing-base and rule storage independent of the forge's own trust. These limits follow from one assumption: the threat being managed is an aligned-but-fallible agent, not a determined adversary. Govern accordingly. Bastion is a control on honest mistakes and drift, layered with the rest of your CI, not a boundary that holds against someone actively trying to defeat it. ## A governance checklist For a healthy deployment: - [ ] `.bastion.yaml` and the Bastion workflow are CODEOWNERS-protected. - [ ] Bastion's review is required by branch protection on the default branch (the review job, or the aggregate `bastion` check that `bastion github report` posts). - [ ] Reviewer-policy PRs get a real human review, not a rubber stamp. - [ ] Someone owns escape triage, and escapes feed back into reviewer changes. - [ ] Billing is configured (per-author secrets or an API-key fallback) so reviews are not silently blocked by missing credentials. See [Continuous integration](./continuous-integration.md#authentication--billing). --- That is the guide. If you want to work on Bastion itself rather than use it, the design notes and contributor docs live in the [Bastion repository](https://github.com/attunehq/bastion).