# Project export: Blast Radius

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: OpenAI Build Week
- Tagline: Your AI coding agent proposes dozens of actions a day and asks you to approve them. Approve the wrong one and you are in trouble. Blast Radius teaches users about risks and how to learn from mistakes.
- Devpost: https://devpost.com/software/blast-radius-86wstf
- GitHub: https://github.com/Lockelamoree/Blast_Radius
- Demo: https://blastradius.max-gutowski.de/
- Video: https://www.youtube.com/embed/ybRj2Z5t8oU?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 1 GitHub contributor(s) — Max_gtski (89 commits)

## Devpost submission (written by the team)

### Inspiration

Since I'm both an IT-security enthusiast and an AI enthusiast, I have a lot of concerns about the new threats that new and old users of AI tools like Codex will run into. Honestly, just auto-approving every action your agent takes is really tempting — and most users aren't aware of the risk. I wanted to teach and educate about those risks, and to ship real tools and helpers for a new generation of developers and users, in a fun way with some flavour from my security background. AI coding agents propose dozens of actions a day — shell commands, new dependencies, tool manifests, diffs — and ask you to approve them. Prompt injection is OWASP's #1 LLM risk, with real CVEs and stolen credentials to prove it, yet nobody trains the reflex that actually stops it: knowing which of your agent's requests to trust. Blast Radius makes that reflex playable. You sit in the operator's seat: each round a realistic proposed agent action shows up — a slopsquatted pip install, a curl | bash, a skill manifest quietly reaching for ~/.ssh — and you choose approve, sandbox, or reject, and name the evidence tell in your own words. Then GPT-5.6 grades your reasoning live and shows the receipts: concrete, checkable facts, with links. The twist that makes it trustworthy — and the part I cared about most: nothing reaches your screen without passing a correctness gate against verified ground truth, so the game can't teach you something false. That mattered a lot to me; I never wanted to teach anyone a hallucinated lesson. Every lesson and every detection is grounded in a gated receipt. Codex brought the magic of building into everyone's hands; Blast Radius is about learning to handle that power. Because — with great power comes great responsibility. :)

### What it does

Important for judges: https://blastradius.max-gutowski.de/ is currently locked behind an access token, provided to you in the judging project details. If there are any issues accessing the site, please reach out via email and I'll address it ASAP. (maximilian.gutowski@gmail.com) Blast Radius gives you several ways to learn, and either way every question and answer is grounded in verified facts — anything that can't pass the correctness gate is rejected. In verified mode you play curated, receipt-backed scenarios and GPT-5.6 Sol grades your reasoning live. In live-variation mode, GPT-5.6 Luna reskins a verified scenario for freshness while your call is still graded deterministically against the immutable tells. You choose approve / sandbox / reject, name the evidence tell in your own words, and — where it applies — write the exact sandbox policy you'd allow. The verdict grades all three, shows receipts (real, link-checked sources), and explains what the attack would have done. A five-competency pre-test and a distinct post-test bracket every session, and the deck reorders toward your weakest measured competency — so the game adapts to your blind spots instead of replaying its greatest hits. I didn't want the detection trapped inside the game, so the same deterministic screen ships as a CLI pre-commit tool, a GitHub Action, a Codex skill, and a Codex plugin — and the plugin is published to a Codex plugin marketplace, so you can add it and install "Blast Radius" in one click. To show it off, I fed blastradius check a friendly-looking "doc" that buried "ignore all previous instructions" and a hidden curl | bash behind a thank-you — and it flagged all three: the injection, the remote-code pipe, and the network egress, deterministically, with no model running (and it says out loud what it can't do: it can't prove something is safe). You can see it on the showcase: https://lockelamoree.github.io/Blast_Radius/#screen I've also added both persistent and non-persistent users, with session tracking via session tokens, a public leaderboard, and a fun little pet editor inspired by my very own Codex pet. Sorry — I had some fun with that one. ;) How I built it Built with Codex in one primary thread, starting July 14. The repo's AGENTS.md (root and nested) encodes the invariant everything else hangs on: never show a scenario that hasn't passed the correctness gate, and the browser only ever receives presentation data — never answer keys. Codex helped me a lot with the brainstorming and planning. I started with a planning session — brainstorming the idea, making Codex ask me questions, then planning everything in .md docs so Codex could structure and implement it for me later — and I used review agents to challenge my own features and implementations. Especially for evaluating ideas and brainstorming, Codex is a gamechanger for me. I packaged that gate as a custom Codex Skill (verify-scenario) that bulk-verifies the scenario bank, and CI runs it on every push alongside ruff and pytest on Python 3.11 and 3.13. The GitHub Action and the marketplace-published plugin come straight out of that same engine, so developers can wire the gate into their own projects right now. Codex also wrote adversarial regression tests against its own engine: truth drift, prompt injection, unsafe sandbox scope, duplicate-session mutation, model failure. GPT-5.6 runs in two named roles via the Responses API with strict Structured Outputs. gpt-5.6-sol is the reasoning critic: it grades your free-form answer live on the hosted demo, but by design it can only widen coverage of allowlisted, immutable tells and write the follow-up critique — it can never author truth or evidence. gpt-5.6-luna reskins the presentation of a verified scenario anchor for variety, and it's enabled in production now that its proof artifact is captured, because on this project no claim ships before its receipt. Every model failure — timeout, malformed output, provider error, exhausted budget — falls back to a deterministic grader, so the app can't fail in front of you.

### Challenges we ran into

Getting a VPS set up with my own domain and then launching the app on it was more of a challenge than I expected. I had some initial issues with my DNS entry, ofc. The nastiest bug never threw an error: my strict-output schema was subtly invalid, which meant every future keyed call would have silently 400'd and fallen back to the deterministic grader. The headline feature would have been quietly off — GPT-5.6 Sol would never actually have graded anything — and everything still would have looked green. Finding that turned into the project's philosophy: I rebuilt the schemas (extra="forbid", schema round-trip tests), gave /healthz a tri-state reasoning_grading: live | key_present_unverified | off backed by a real startup probe, made failed calls refund the token budget, and made the deploy script refuse to deploy unless the critic is verifiably live. The other hard problem — grading free-form human reasoning with an LLM without letting the LLM author the truth — is solved by that allowlist trust boundary. A fun thought experiment, and Codex helped me quite a lot with it.

### Accomplishments we're proud of

I launched the app to a VPS on my own domain (https://blastradius.max-gutowski.de/) and integrated the OpenAI API so GPT-5.6 Sol can grade live in the app, plus a gate-catch endpoint that shows the correctness gate rejecting a planted hallucination on demand. Most of all, I really like that I was able to deliver a fully functional application that teaches and educates in a gamified way, while also directly shipping the tools and precautions users need.

### What we learned

The most valuable thing an AI product can do is check its own work before you see it. That generate-then-verify loop that keeps this game honest is the exact loop developers now need in their heads every time an agent asks "approve?" — which is, of course, what the game teaches. Building the tool with the tool's own lesson was the point. :D

### What's next

Probably more scenarios, more learning materials, and more useful tools to teach and enable the next generation of developers to protect themselves from the new threats that keep emerging. I'll also keep developing the plugin, CLI, and GitHub Action to provide more value to users. And I'll definitely be pushing the developers at my own company to use this. ;)

## README (from the GitHub repository)

# Blast Radius

**Blast Radius is my browser game for practicing safe approval decisions around AI coding
agents — backed by 20 receipt-linked scenarios and 436 automated tests.** You inspect a proposed
command, dependency, tool manifest, diff, retrieved instruction, or marketplace skill; choose
**approve**, **sandbox**, or **reject**; then name the evidence tell. The verdict scores your
action, your tell coverage, and—when it applies—the exact sandbox policy, with direct evidence for
every scenario.

**What makes it different:** it's an AI-security trainer whose *own* AI-generated content can never
become a source of security misinformation — nothing reaches your screen unless it passes a
deterministic correctness gate against verified ground truth. That's the golden thread, and the
whole build hangs off it.

I built it for the OpenAI Build Week 2026 Developer Tools track. Every scenario command is an
inert string and is never executed.

> **🔎 See it work → [Live walkthrough &amp; proof](https://lockelamoree.github.io/Blast_Radius/)** —
> annotated real-UI screenshots, the live GPT-5.6 grade with its response id, and the correctness
> gate rejecting a planted hallucination on demand. Mirrored in the repo under [`docs/`](docs/).
>
> **▶ Watch the 3-minute demo:** <https://www.youtube.com/watch?v=ybRj2Z5t8oU>

![The decision screen: a proposed agent action, three color-coded calls, and the tell prompt](assets/thumb_decision.png)

## Why I built this

I'm both an IT-security and an AI enthusiast, so I keep thinking about the new threats coming at
people who use AI tools like Codex. Honestly, just auto-approving every action your agent proposes
is really tempting — and most users aren't aware of the risk. Prompt injection is OWASP's #1 LLM
risk right now, with real CVEs and stolen credentials to prove it, and yet nobody trains the one
reflex that actually stops it: knowing which of your agent's requests to trust. Every security game
out there trains the attacker. Nobody was training the operator. So I built the operator's seat —
and I tried to make it genuinely fun, with a bit of my security background mixed in.

The thing I cared about most: **nothing reaches your screen unless it passes a correctness gate
against verified ground truth**, so the game can't teach you a hallucinated lesson. That rule
actually came out of a pretty scary bug — my strict-output schema was subtly invalid, which would
have silently switched GPT-5.6 grading off while everything still looked totally fine. Nothing on
the surface would've told me. After I caught that, "no claim ships before its receipt" basically
became the whole philosophy. Codex was a genuine gamechanger for me here, especially for the
thinking part: I started with a planning session, had it ask me questions about the idea, wrote
everything down in design docs it could implement later, and used review agents to challenge my own
features before I shipped them.

With great power comes great responsibility. :) What's next? Probably more scenarios, more learning
material, and more tools to help the next generation of developers protect themselves. I'll keep
building out the CLI, GitHub Action and Codex plugin — and yes, I'll definitely be pushing the
developers at my own company to use this. ;)

## What is shipped

Here's what actually ships today:

- 20 curated, receipt-backed scenarios across six threat families — including retrieval
  (web-fetch) prompt injection and MCP tool-description poisoning.
- A mandatory deterministic pre-display gate and a visible planted-defect self-catch.
- **Fresh on every replay** — a browser-local recently-seen list rotates selection so repeat
  playthroughs cycle the bank: the drill serves a new incident each play, demo varies the
  within-family scenario, and live avoids recently-seen anchors. Nothing is persisted server-side.
- A six-round judge mode that never depends on generation and reorders only the unplayed
  verified deck toward the learner's weakest measured competency.
- Distinct five-question pre- and post-assessments, one question per competency, with stable
  per-session option shuffling and measured category deltas.
- Deterministic phrase matching as the grading floor. When the configured critic is verified,
  GPT-5.6 Sol may match only immutable allowlisted tells and write a follow-up question.
- A deterministic fallback for every model timeout, malformed response, provider failure, or
  exhausted application budget.
- **Daily-tool surfaces** that point the same deterministic engine at your own work: a one-round
  no-signup `drill` mode, a coached retry that re-grades revised reasoning, browser-local
  progress with spaced-repetition callbacks, an offline `blastradius` CLI, `POST /api/check` /
  `POST /api/gate/verify`, an MCP server, and a GitHub Action. See
  [Use it as a daily tool](#use-it-as-a-daily-tool).
- **Developer-role views**: a scores-only team board at `/team` and an incident-authoring page at
  `/author` that validates drafts against the production gate before a PR.
- **A bring-your-own-artifact screen** at `/screen`, plus an offline fuzz/evaluation harness,
  lets developers verify commands, diffs, and sandbox policies with the same model-free engine.
  Frozen benign/caution/critical examples, copyable fixes, learning links, and downloadable JSON
  receipts make it useful beyond the game.
- **Persistent, pseudonymous learner profiles** use a signed browser cookie, optional nickname,
  recoverable token, custom Blastling companion, score/level progression, and a public
  scores-only leaderboard. The Blastling can be dragged or moved with the keyboard; its normalized
  position stays only on that device and never enters the profile token. No email address or
  password is collected.
- A judge-friendly **60-second verified incident** is the primary entry path. The full six-round
  pre/post measurement remains available as “Measure my approval reflex,” while the landing page
  is organized around **Learn / Screen / Integrate**.
- Every grade now carries a public-presentation fingerprint and the actual deterministic scenario
  gate result. Grade, screening, and learning receipts can be downloaded as JSON without exposing
  hidden keyword mappings or scenario ground truth.

`BLAST_RADIUS_LIVE_GENERATION=false` is the safe deployment default I ship with. When a live
session is explicitly enabled, it selects a verified anchor first, then Luna may reskin only its
presentation fields. The anchor's family, template, action, tells, evidence, explanation,
sandbox policy, and receipts remain deep-copied and immutable. The deterministic gate and a
separate Sol consistency gate must both pass or the unchanged verified anchor is returned.
Generated presentations use deterministic tell-coverage grading; only verified and fallback
rounds may use the Sol reasoning critic.

```text
curated anchor -> optional Luna presentation reskin -> deterministic gate -> Sol gate
               -> player decision -> immutable truth -> tell coverage -> cited receipts
```

## Try it

Hosted demo: **<https://blastradius.max-gutowski.de/>**

The hosted demo runs in private preview behind an access code — judges enter the code from the
Devpost testing-access field. If you'd rather run it yourself, the local setup below needs no code
and reproduces the full verified experience offline.

Requirements:

- Python 3.11 or newer
- A current Chrome, Edge, Firefox, or Safari browser
- Optional: a server-side OpenAI API key with GPT-5.6 access

Windows PowerShell:

```powershell
python -m venv .venv
.\.venv\Scripts\python -m pip install -e ".[dev]"
Copy-Item .env.example .env
.\.venv\Scripts\python -m uvicorn blast_radius.main:app --reload
```

macOS or Linux:

```bash
python3 -m venv .venv
. .venv/bin/activate
python -m pip install -e ".[dev]"
cp .env.example .env
python -m uvicorn blast_radius.main:app --reload
```

Open <http://127.0.0.1:8000>. The verified run works without a key. `OPENAI_A

[README truncated for size]

## Detected evidence (automated analysis)

Indexed codebase: 98 recognized source files, 998 KB.
- CSS (language) — detected in the code
- FastAPI (technology) — detected in the code
- HTML (language) — detected in the code
- JavaScript (language) — detected in the code
- OpenAI (technology) — detected in the code
- Python (language) — detected in the code
- AI coding agent: Codex — evidence: config files committed to the repository

## Codebase structure (from repository index)

### Files (120 of 120)

```
.agents/plugins/marketplace.json
.agents/skills/verify-scenario/scripts/verify_scenarios.py
.agents/skills/verify-scenario/SKILL.md
.blastradius.toml.example
.env.example
.github/workflows/ci.yml
.gitignore
16_DEVPOST_FINAL_COPY.md
ACTION_RELEASE.md
action.yml
AGENTS.md
assets/SCREENSHOT_CAPTURE.md
blast_radius/__init__.py
blast_radius/api.py
blast_radius/audit.py
blast_radius/auth.py
blast_radius/cli.py
blast_radius/config.py
blast_radius/data/detection_corpus.jsonl
blast_radius/data/detection_eval_baseline.json
blast_radius/data/learn.json
blast_radius/data/model_eval_baseline.json
blast_radius/data/questions.json
blast_radius/data/scenarios.json
blast_radius/data/templates.json
blast_radius/data/toolkit.json
blast_radius/engine/__init__.py
blast_radius/engine/AGENTS.md
blast_radius/engine/bank.py
blast_radius/engine/custom_rules.py
blast_radius/engine/gate.py
blast_radius/engine/grader.py
blast_radius/engine/inspector.py
blast_radius/engine/openai_adapter.py
blast_radius/engine/service.py
blast_radius/eval/__init__.py
blast_radius/eval/detection_eval.py
blast_radius/eval/inspector_fuzz.py
blast_radius/eval/model_eval.py
blast_radius/integrations/__init__.py
blast_radius/integrations/codex_hook.py
blast_radius/main.py
blast_radius/mcp_server.py
blast_radius/models.py
blast_radius/static/access.css
blast_radius/static/AGENTS.md
blast_radius/static/app.js
blast_radius/static/author.css
blast_radius/static/author.js
blast_radius/static/guardrails.js
blast_radius/static/history.js
blast_radius/static/improvements.css
blast_radius/static/integrity-check.js
blast_radius/static/pet.css
blast_radius/static/pet.js
blast_radius/static/resources.js
blast_radius/static/screen.css
blast_radius/static/screen.js
blast_radius/static/styles.css
blast_radius/static/team.css
blast_radius/static/team.js
blast_radius/storage.py
blast_radius/templates/access.html
blast_radius/templates/author.html
blast_radius/templates/index.html
blast_radius/templates/screen.html
blast_radius/templates/team.html
deploy/blast-radius.service
deploy/Caddyfile
deploy/deploy.sh
deploy/README.md
deploy/update_env.py
docs/.nojekyll
docs/index.html
docs/README.md
evidence/.gitkeep
evidence/live_grade_resp_024208198fc0ff3c016a5cbcdbd3708192887a3ae615e727a1.json
integrations/codex/hooks.json
integrations/codex/README.md
LICENSE
plugins/blast-radius/.codex-plugin/plugin.json
plugins/blast-radius/.mcp.json
plugins/blast-radius/README.md
plugins/blast-radius/scripts/preflight.py
plugins/blast-radius/skills/screen-agent-artifacts/SKILL.md
pyproject.toml
README.md
scripts/action_summary.py
scripts/action_verify.sh
scripts/capture_live_grade.py
scripts/check_evidence_links.py
scripts/submission_preflight.py
tests/conftest.py
tests/test_action.py
tests/test_api.py
tests/test_audit.py
tests/test_auth.py
tests/test_bank.py
tests/test_capture_live_grade.py
tests/test_cli.py
tests/test_codex_hook.py
tests/test_custom_rules.py
tests/test_deploy.py
tests/test_detection_eval.py
tests/test_drill.py
tests/test_frontend.py
tests/test_gate_adversarial_corpus.py
tests/test_gate.py
tests/test_grader.py
tests/test_inspector_fuzz.py
tests/test_inspector.py
tests/test_mcp_server.py
tests/test_model_eval.py
tests/test_models.py
tests/test_openai_schema.py
tests/test_plugin.py
tests/test_service.py
tests/test_storage.py
tests/test_submission_preflight.py
tests/test_tools_api.py
```

### Dependencies

- pyproject.toml: build@>=1.2,<2, fastapi@>=0.116,<1, httpx@>=0.28,<1, jinja2@>=3.1,<4, mcp@>=1.2,<2, mcp@>=1.2,<2, openai@>=1.99,<3, pydantic@>=2.11,<3, pytest@>=8.4,<9, pytest-asyncio@>=1.1,<2, python-dotenv@>=1.1,<2, ruff@>=0.12,<1, uvicorn[standard]@>=0.35,<1

### Recent commits (newest first)

- docs: lead with the correctness-gate differentiator; clarify no-signup wording
- docs(readme): show the CLI catching a live prompt injection
- docs(showcase): show the CLI catching a live prompt injection
- docs(devpost): drop stale revision pin, fix gallery-asset wording
- docs: roughen the first-person intros toward the author's own voice
- docs: update submission checklists to reflect verified/done state
- docs(architecture.svg): fix top-row text overflow in the trust-pipeline diagram
- docs(devpost): fix gallery caption — screen_results.png is the landing, not a results screen
- docs: publish video URL, honest keyless wording, drop unmeasured-learning claim
- docs: freshness pass — test count 432->436, access-gate 18->20, document rotation
- docs: replace stale gallery placeholders with real current app captures
- feat: anti-repeat rotation across the three game tracks
- docs(readme): re-voice the whole README in first person
- docs(readme): add a first-person 'Why I built this' section
- docs: rewrite showcase in the author's first-person voice; clean screenshots
- docs: add hosted walkthrough & proof showcase (GitHub Pages)
- fix(ci): make local pitch brief optional
- fix(ci): run shell assertions in native action job
- chore(release): add submission preflight and sync judge copy
- feat(plugin): harden MCP install and input boundary

## Key source files (fetched from GitHub, selected and truncated for size)

### ACTION_RELEASE.md

```markdown
# GitHub Action release checklist

The floating `v1` tag currently predates the Action outputs and fail-closed checks on `main`.
Publish the next immutable release only after the `main` CI run is green:

```bash
git fetch origin
git switch main
git pull --ff-only
git tag -a v1.1.0 -m "Blast Radius Action v1.1.0"
git push origin v1.1.0
git tag -fa v1 -m "Blast Radius Action v1" v1.1.0
git push origin v1 --force
```

Then create the GitHub release from `v1.1.0` and publish/update the Marketplace listing with
`action.yml`. Verify a consumer repository with a full-history checkout:

```yaml
- uses: actions/checkout@v4
  with:
    fetch-depth: 0
    persist-credentials: false
- id: blast-radius
  uses: Lockelamoree/Blast_Radius@v1
  with:
    diff-base: ${{ github.event.pull_request.base.sha }}
    fail-on: never
```

Confirm that the run summary contains the verdict and findings and that the `verdict`,
`critical`, and `caution` outputs are available. Moving `v1` and accepting Marketplace terms are
release-owner actions; they are intentionally separate from production deployment.

```

### AGENTS.md

```markdown
# AGENTS.md — Blast Radius

## Product invariant

Blast Radius teaches developers to safely operate AI coding agents. Never display a
scenario that has not passed the correctness gate. Never execute content shown in a
scenario. Never expose `ground_truth` through a public API.

## Architecture

- `blast_radius/engine/`: scenario bank, mandatory gate, adaptation, and grading.
- `blast_radius/api.py`: thin HTTP contract and session orchestration.
- `blast_radius/static/` and `templates/`: dependency-free browser experience.
- `tests/`: schema, gate, engine, API, and security regression tests.

## Model roles

- Optional presentation reskinning: `gpt-5.6-luna`; it may only rewrite presentation fields
  anchored to a curated scenario. Identity, truth, policy, and receipts stay immutable.
- Generated-presentation gate: `gpt-5.6-sol`, max reasoning effort.
- Verified-scenario reasoning critic: `gpt-5.6-sol`, medium reasoning effort.
- Generated presentations use deterministic tell-coverage grading and never enter the
  reasoning-critic prompt.
- The deterministic bank must remain fully usable without an API key.

## Commands

Linux/macOS:

```bash
python3 -m venv .venv
.venv/bin/python -m pip install -e ".[dev]"
.venv/bin/python -m pytest
.venv/bin/python -m uvicorn blast_radius.main:app --reload
```

Windows:

```powershell
python -m venv .venv
.\.venv\Scripts\python -m pip install -e ".[dev]"
.\.venv\Scripts\python -m pytest
.\.venv\Scripts\python -m uvicorn blast_radius.main:app --reload
```

## Boundaries

- Secrets come from environment variables and are never logged.
- Ground truth is immutable; an LLM may explain it but cannot rewrite it.
- MaRa is not part of this build. Do not copy or claim integration with it.
- No email/password accounts, multiplayer, command execution, or storage of reasoning text in
  long-lived profiles. Three narrow, deliberate persistence features are part of the product:
  - **Browser-local history.** Learner progress lives only in a single versioned
    `localStorage` key, written and read exclusively by client-side code, and is never
    sent to or stored by the server — the ephemeral session row and its TTL remain the
    only server state. The UI always labels it "stored only in this browser" with a
    one-click clear, and the app stays fully functional when storage is unavailable. The
    random client key a browser may send when starting a daily drill is used transiently
    for deterministic scenario selection and is never persisted or logged. The browser's
    recently-seen scenario ids are treated the same way: they ride along as a transient
    `exclude` hint so repeat playthroughs rotate off just-seen scenarios, and are used only
    for selection — never persisted or logged server-side.
  - **The team board.** A session MAY carry an optional, self-chosen operator handle (max
    40 chars, no emails, no auth, no password, never required to play). Finished sessions
    write a scores-only summary row (handle
[truncated — 1861 more characters]
```

### pyproject.toml

```
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "blast-radius"
version = "0.1.0"
description = "A verification game for safely operating AI coding agents"
readme = "README.md"
requires-python = ">=3.11"
license = { text = "MIT" }
authors = [{ name = "Lockelamoree" }]
keywords = ["agent-security", "guardrail", "mcp", "sandbox"]
classifiers = [
  "Development Status :: 4 - Beta",
  "License :: OSI Approved :: MIT License",
  "Programming Language :: Python :: 3",
  "Programming Language :: Python :: 3 :: Only",
  "Topic :: Software Development :: Quality Assurance",
]
dependencies = [
  "fastapi>=0.116,<1",
  "jinja2>=3.1,<4",
  "openai>=1.99,<3",
  "pydantic>=2.11,<3",
  "python-dotenv>=1.1,<2",
  "uvicorn[standard]>=0.35,<1",
]

[project.scripts]
blastradius = "blast_radius.cli:main"
blastradius-mcp = "blast_radius.mcp_server:main"
blastradius-supervise = "blast_radius.integrations.codex_hook:main"

[project.optional-dependencies]
mcp = ["mcp>=1.2,<2"]
dev = [
  "build>=1.2,<2",
  "httpx>=0.28,<1",
  "mcp>=1.2,<2",
  "pytest>=8.4,<9",
  "pytest-asyncio>=1.1,<2",
  "ruff>=0.12,<1",
]

[project.urls]
Homepage = "https://github.com/Lockelamoree/Blast_Radius"
Repository = "https://github.com/Lockelamoree/Blast_Radius"
Issues = "https://github.com/Lockelamoree/Blast_Radius/issues"

[tool.hatch.build.targets.wheel]
packages = ["blast_radius"]

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q"

[tool.ruff]
line-length = 100
target-version = "py311"

```

### blast_radius/main.py

```python
from __future__ import annotations

import asyncio
import ipaddress
import logging
from contextlib import asynccontextmanager, suppress
from urllib.parse import parse_qs, quote

from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates

from blast_radius.api import build_router
from blast_radius.auth import ACCESS_COOKIE, AttemptLimiter, issue_token, verify_token
from blast_radius.config import Settings, settings
from blast_radius.engine import TrustEngine
from blast_radius.storage import SessionStore


def create_app(config: Settings = settings) -> FastAPI:
    store = SessionStore(config.database_path, config.session_ttl_minutes)
    engine = TrustEngine(
        config,
        reserve_llm_call=lambda: store.reserve_llm_call(config.daily_llm_budget),
        refund_llm_call=store.refund_llm_call,
    )

    @asynccontextmanager
    async def lifespan(_: FastAPI):
        probe_task: asyncio.Task[None] | None = None
        if engine.openai.grading_enabled:
            probe_task = asyncio.create_task(engine.openai.probe_reasoning_grading())
        yield
        if probe_task is not None and not probe_task.done():
            probe_task.cancel()
            with suppress(asyncio.CancelledError):
                await probe_task

    application = FastAPI(
        title="Blast Radius",
        version="0.1.0",
        description="A verification game for safely operating AI coding agents.",
        docs_url="/api/docs" if config.enable_docs else None,
        openapi_url="/api/openapi.json" if config.enable_docs else None,
        redoc_url=None,
        lifespan=lifespan,
    )
    application.include_router(build_router(config, engine, store))
    application.mount(
        "/static", StaticFiles(directory=config.base_dir / "static"), name="static"
    )
    templates = Jinja2Templates(directory=config.base_dir / "templates")

    # methods includes HEAD so uptime monitors that probe HEAD / see 200, not 405.
    @application.api_route(
        "/",
        methods=["GET", "HEAD"],
        response_class=HTMLResponse,
        include_in_schema=False,
    )
    def index(request: Request) -> HTMLResponse:
        return templates.TemplateResponse(request=request, name="index.html")

    @application.get("/healthz")
    def health() -> dict:
        generation_available, generation_reason = engine.live_generation_availability(
            store.budget_remaining(config.daily_llm_budget)
        )
        return {
            "status": "ok",
            "bank_scenarios": len(engine.bank.scenarios),
            "live_generation": generation_available,
            "live_generation_reason": generation_reason,
            "reasoning_grading": engine.openai.reasoning_grading_state,
            "critic_model": config.critic_model,
            "revision": config.revision,
            "auth_enabled": config.auth_enabled,
        }

    # Access gate. When configured, everything except health, the access page,
    # logout and static assets is held behind a signed cookie so only holders of
    # a judge/developer code get in. /healthz stays open so the deploy health
    # gate and uptime probes keep working.
    access_codes = config.access_code_map
    auth_enabled = config.auth_enabled
    auth_secret = config.auth_secret
    cookie_max_age = config.auth_cookie_ttl_days * 86400
    attempt_limiter = AttemptLimiter()
    exempt_paths = {"/healthz", "/access", "/logout", "/favicon.ico"}

    # A half-configured gate fails open (serves the app ungated); make that loud
    # rather than silent so a missing secret/code in prod is noticed.
    if bool(config.auth_secret) != bool(config.access_code_map):
        logging.getLogger("blast_radius").warning(
            "Access gate DISABLED: set BOTH BLAST_RADIUS_AUTH_SECRET and "
            "BLAST_RADIUS_ACCESS_CODES to enable it (only one is currently set)."
        )

    def client_key(request: Request) -> str:
        # Exactly one trusted hop (Caddy) appends the real peer to the END of
        # X-Forwarded-For, so the LAST entry is the client IP the edge observed;
        # the first hop is client-supplied and spoofable. Validate it parses as an
        # IP (which also bounds its length); otherwise fall back to the direct
        # peer. In local/no-proxy runs there is no XFF and we use the peer.
        forwarded = request.headers.get("x-forwarded-for", "")
        candidate = forwarded.split(",")[-1].strip() if forwarded else ""
        if candidate:
            try:
                return str(ipaddress.ip_address(candidate))
            except ValueError:
                pass
        return request.client.host if request.client else "unknown"

    def safe_next(target: str | None) -> str:
        # Same-site absolute paths only. Reject protocol-relative ("//"), backslash
        # tricks ("/\\..." which some browsers coerce to "//"), and CR/LF smuggling.
        if (
            not target
            or not target.startswith("/")
            or target.startswith("//")
            or "\\" in target
            or "\n" in target
            or "\r" in target
        ):
            return "/"
        return target

    def current_role(request: Request) -> str | None:
        token = request.cookies.get(ACCESS_COOKIE)
        if not token:
            return None
        return verify_token(auth_secret, token, max_age_seconds=cookie_max_age)

    def render_access(
        request: Request, next_target: str, error: str | None, status: int = 200
    ) -> HTMLResponse:
        return templates.TemplateResponse(
            request=request,
            name="access.html",
            context={"next": next_target, "error": error},
            status_code=status,
        )

    @application.middleware("http")
    async def access_gate(request: Request, call_next):
        if not auth_enabled:
            ret
[truncated — 3994 more characters]
```

### blast_radius/cli.py

```python
"""``blastradius`` — a deterministic, offline command-line screen for real
agent artifacts, plus a bank/scenario gate verifier.

Runs entirely in-process against the installed package (no server, no auth, no
network, no model). Imports only the engine, never ``blast_radius.main``/``api``,
so it stays light and safe to invoke from a pre-commit hook or CI step.
"""

from __future__ import annotations

import argparse
import json
import sys
from glob import glob, has_magic
from pathlib import Path

from pydantic import ValidationError

from blast_radius import __version__
from blast_radius.engine import inspector
from blast_radius.engine.bank import ScenarioBank
from blast_radius.engine.gate import CorrectnessGate
from blast_radius.models import BlastRadiusConfig, InspectionReport, Scenario

_DATA_DIR = Path(__file__).resolve().parent / "data"
_VERDICT_RANK = {"looks-scoped": 0, "sandbox-recommended": 1, "reject-recommended": 2}
_FAIL_ON_THRESHOLD = {"never": 3, "reject": 2, "sandbox": 1}
_MODEL_EVAL_DEFAULT = "model_eval_baseline.json"
_DETECTION_EVAL_DEFAULT = "detection_eval_baseline.json"


def _read_source(positional: str | None, file_option: str | None) -> str:
    if file_option is not None:
        return Path(file_option).read_text(encoding="utf-8")
    if positional is None or positional == "-":
        return sys.stdin.read()
    return positional


def _infer_kind(args: argparse.Namespace, content: str) -> str:
    if args.kind:
        return args.kind
    if args.config:
        return "config"
    if args.diff or content.lstrip().startswith(("diff --git", "--- ", "+++ ")):
        return "diff"
    return "command"


def _render_human(report: InspectionReport, *, explain: bool = False) -> None:
    print(f"verdict: {report.verdict}  ({report.method}, {report.graded_by})")
    if report.parsed_as:
        print(f"parsed as: {report.parsed_as}")
    if report.score is not None:
        print(f"blast-radius score: {report.score}/100  (baseline: {report.baseline})")
    if not report.findings:
        print("no known red-flag pattern matched.")
    for finding in report.findings:
        tier = f", {finding.confidence} confidence" if explain and finding.confidence else ""
        print(f"  [{finding.severity}] {finding.label} ({finding.category}{tier})")
        for match in finding.matches:
            print(f"      - {match.matched}: {match.excerpt}")
        if explain:
            if finding.why:
                print(f"      why: {finding.why}")
            if finding.fix:
                print(f"      fix: {finding.fix}")
    if report.policy_deltas:
        for delta in report.policy_deltas:
            if delta.status != "ok":
                print(f"  policy {delta.status}: {delta.dimension} — yours={delta.yours} safe={delta.safe}")
    if report.learn:
        print(f"learn: {report.learn['title']}")
    if report.toolkit:
        print(f"toolkit: {report.toolkit['title']}")
    print(f"\n{report.disclaimer}")


def _cmd_check(args: argparse.Namespace) -> int:
    custom = None
    if not args.no_rules:
        from blast_radius.engine import custom_rules

        rules_path = Path(args.rules) if args.rules else custom_rules.discover()
        custom, rules_error = custom_rules.load_safe(rules_path)
        if rules_error:
            print(f"blast-radius: ignoring custom rules — {rules_error}", file=sys.stderr)

    if args.config:
        config = BlastRadiusConfig.model_validate_json(
            Path(args.config).read_text(encoding="utf-8")
        )
        expected = None
        if args.expected:
            expected = BlastRadiusConfig.model_validate_json(
                Path(args.expected).read_text(encoding="utf-8")
            )
        report = inspector.inspect_config(config, expected, custom=custom)
        kind = "config"
    else:
        content = _read_source(args.artifact, args.diff)
        kind = _infer_kind(args, content)
        if kind == "config":
            print("error: use --config FILE for config checks", file=sys.stderr)
            return 2
        report = inspector.inspect_text(content, kind=kind, custom=custom)

    if not args.no_audit:
        from blast_radius import audit

        audit.record(report, kind=kind, source="cli")

    if args.json:
        print(report.model_dump_json(indent=2))
    else:
        _render_human(report, explain=args.explain)

    threshold = _FAIL_ON_THRESHOLD[args.fail_on]
    return 1 if _VERDICT_RANK[report.verdict] >= threshold else 0


def _cmd_verify(args: argparse.Namespace) -> int:
    bank = ScenarioBank(_DATA_DIR)
    gate = CorrectnessGate(bank)
    if args.bank:
        scenarios = list(bank.scenarios.values())
        failures = 0
        for scenario in scenarios:
            result = gate.verify(scenario)
            if not result.passed:
                failures += 1
                print(f"FAIL {scenario.id}: {'; '.join(result.reasons)}")
        if failures:
            return 1
        print(f"PASS {len(scenarios)} scenario(s) through CorrectnessGate")
        return 0

    paths: list[str] = []
    for candidate in args.scenarios:
        matches = sorted(glob(candidate)) if has_magic(candidate) else [candidate]
        if not matches:
            raise FileNotFoundError(f"no scenario files matched: {candidate}")
        paths.extend(matches)

    failures = 0
    for path in dict.fromkeys(paths):
        scenario = Scenario.model_validate_json(Path(path).read_text(encoding="utf-8"))
        result = gate.verify(scenario)
        if result.passed:
            print(f"PASS {scenario.id}")
        else:
            failures += 1
            print(f"FAIL {scenario.id}: {'; '.join(result.reasons)}")
    return 1 if failures else 0


def _cmd_audit(args: argparse.Namespace) -> int:
    """Review the local, fingerprint-only record of what the screen has flagged.
    Contains no raw commands, diffs, excerpts, or secrets — only hashes, verdicts,
    and category ids."""
    from blast_radius
[truncated — 15042 more characters]
```

### action.yml

```yaml
name: "Blast Radius verify"
description: "Deterministic, offline CI check: verify Blast Radius scenario drafts and screen PR diffs for agent-security red flags."
branding:
  icon: shield
  color: green

inputs:
  scenarios:
    description: "Glob of scenario JSON files to gate-verify (e.g. 'scenarios/*.json'). Empty to skip."
    required: false
    default: ""
  diff-base:
    description: "Git ref to diff against (e.g. the PR base sha). Empty to skip the diff screen."
    required: false
    default: ""
  fail-on:
    description: "Verdict level that fails the diff screen: reject | sandbox | never."
    required: false
    default: "reject"

outputs:
  verdict:
    description: "Diff screen verdict: reject-recommended | sandbox-recommended | looks-scoped."
    value: ${{ steps.screen.outputs.verdict }}
  critical:
    description: "Count of critical red-flag findings in the screened diff."
    value: ${{ steps.screen.outputs.critical }}
  caution:
    description: "Count of caution findings in the screened diff."
    value: ${{ steps.screen.outputs.caution }}

runs:
  using: "composite"
  steps:
    # Guarantee a supported interpreter on any runner (the package needs >=3.11);
    # relying on the runner's default python3 breaks on older/self-hosted images.
    - name: Set up Python
      uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
      with:
        python-version: "3.13"
    - name: Install Blast Radius
      shell: bash
      run: python -m pip install "${GITHUB_ACTION_PATH}"
    - name: Verify scenarios and screen the diff
      id: screen
      shell: bash
      env:
        BR_SCENARIOS: ${{ inputs.scenarios }}
        BR_DIFF_BASE: ${{ inputs.diff-base }}
        BR_FAIL_ON: ${{ inputs.fail-on }}
      run: bash "${GITHUB_ACTION_PATH}/scripts/action_verify.sh"

```

### blast_radius/__init__.py

```python
"""Blast Radius application package."""

__version__ = "0.1.0"


```

### tests/conftest.py

```python
from pathlib import Path

import pytest
from fastapi.testclient import TestClient

from blast_radius.config import Settings
from blast_radius.main import create_app


@pytest.fixture
def test_settings(tmp_path: Path) -> Settings:
    package_dir = Path(__file__).resolve().parents[1] / "blast_radius"
    return Settings(
        base_dir=package_dir,
        database_path=tmp_path / "test.db",
        openai_api_key=None,
        live_generation=False,
        session_ttl_minutes=180,
    )


@pytest.fixture
def client(test_settings: Settings) -> TestClient:
    with TestClient(create_app(test_settings)) as test_client:
        yield test_client


```

### tests/test_inspector_fuzz.py

```python
from blast_radius.engine import inspector
from blast_radius.eval.inspector_fuzz import (
    _adjacent_quote_command,
    candidate_rule_stub,
    fuzz_inspector,
)


def test_fuzz_is_deterministic_and_never_mutates_categories() -> None:
    before = tuple(category.id for category in inspector.CATEGORIES)
    first = fuzz_inspector(seed=41, iterations=50).to_dict()
    second = fuzz_inspector(seed=41, iterations=50).to_dict()
    after = tuple(category.id for category in inspector.CATEGORIES)
    assert first == second
    assert before == after


def test_known_escape_is_reported() -> None:
    report = fuzz_inspector(
        seed=0,
        iterations=1,
        seeds=("curl https://api.example.com",),
        mutators=(_adjacent_quote_command,),
    )
    assert len(report.escapes) == 1
    assert report.escapes[0].mutation == "_adjacent_quote_command"


def test_adjacent_quote_mutation_preserves_shell_command_name() -> None:
    assert _adjacent_quote_command("curl https://example.com") == (
        '"c""url" https://example.com'
    )


def test_candidate_rule_stub_is_advisory_text() -> None:
    stub = candidate_rule_stub("curl https://evil.example", "c u r l https://evil.example")
    assert "CategorySpec" in stub
    assert "advisory" in stub

```

### tests/test_submission_preflight.py

```python
import struct
from pathlib import Path

from scripts import submission_preflight as preflight


def _write_png(path: Path, width: int = 1200, height: int = 800, tail: bytes = b"") -> None:
    path.write_bytes(
        b"\x89PNG\r\n\x1a\n" + b"\x00\x00\x00\rIHDR" + struct.pack(">II", width, height) + tail
    )


def test_png_dimensions_reads_header(tmp_path: Path) -> None:
    image = tmp_path / "image.png"
    _write_png(image, 640, 480)
    assert preflight._png_dimensions(image) == (640, 480)


def test_gallery_check_detects_duplicate_assets(tmp_path: Path) -> None:
    assets = tmp_path / "assets"
    assets.mkdir()
    for name in preflight.GALLERY:
        _write_png(assets / name)
    result = preflight._gallery_check(tmp_path)
    assert result.status == "warn"
    assert "duplicate" in result.detail


def test_documentation_check_detects_stale_claims(tmp_path: Path) -> None:
    (tmp_path / "README.md").write_text("409 tests passed", encoding="utf-8")
    (tmp_path / "16_DEVPOST_FINAL_COPY.md").write_text("ready", encoding="utf-8")
    result = preflight._documentation_check(tmp_path)
    assert result.status == "fail"
    assert "409 tests passed" in result.detail


def test_current_submission_has_no_structural_failures() -> None:
    failures = [check for check in preflight.run_checks() if check.status == "fail"]
    assert failures == []

```

[80 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]