Skip to main content
Glama
README.md
# BlastConfirm Gate

An MCP confirm step for agent merges. When an agent calls `merge_pull_request`, BlastConfirm scores the diff's blast radius with [ripwire](https://github.com/redhat-et/ripwire). At or under the threshold, the merge goes through. Over the threshold, the call is blocked until a human signs an approval for that exact diff with a local CLI.

[![CI](https://github.com/SkilledDragonCorp/blastconfirm-gate/actions/workflows/ci.yml/badge.svg)](https://github.com/SkilledDragonCorp/blastconfirm-gate/actions/workflows/ci.yml)
[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](./LICENSE)

## The problem

Agents can open and merge pull requests at a pace no reviewer keeps up with, and a one-line leaf fix and a change to shared core code look the same in a PR title. Blast-radius tools can tell them apart, but a report does not stop a merge. BlastConfirm turns the score into a gate on the agent's merge tool and only lets a wide change through once a human has looked at it.

## How it works

```mermaid
flowchart LR
  agent[Agent] -->|merge_pull_request pr| gate[blastconfirm MCP]
  gate -->|--pr-context + --test-gate| rw[ripwire]
  rw --> score{score > threshold?}
  score -->|no| exec[executor: dry-run / gh / downstream MCP]
  score -->|yes| sig{valid signed approval<br/>for this exact diff?}
  sig -->|yes, once| exec
  sig -->|no| blocked[blocked + request id]
  human[Human] -->|blastconfirm approve id| file[(signed approval)]
  file --> sig
```

1. **Score.** ripwire runs on the configured checkout. `score = changed_files + impacted_files + impacted_symbols + 2 * untested`. Every term is a number ripwire prints, so you can check it by hand.
2. **Gate.** At or under `threshold`, the merge runs. Over it, the gate writes a pending request whose id is derived from a SHA-256 digest of repo, PR, base, ripwire `at=`, changed files and score.
3. **Confirm.** A human runs `blastconfirm approve <id>`. The CLI re-scores the diff, shows the impact, asks `y/N`, and writes an Ed25519-signed approval with an expiry.
4. **Check.** On the next `merge_pull_request`, the gate re-scores and verifies the record before merging. The key must be trusted, the signature valid, the digest the same and the record not expired. Each approval works once.

## Quickstart (keyless demo)

Needs Bun 1.4.3 (Node 24 in `.nvmrc`).

```bash
bun install --frozen-lockfile
bun run blastconfirm keygen                       # approver key in ~/.config/blastconfirm (0600)
C=examples/blastconfirm.demo.json                 # replays the captured "core" ripwire output
bun run blastconfirm check --pr 1 --config $C     # exit 3: blocked, prints bc-<id>
bun run blastconfirm approve bc-<id> --config $C  # shows impact, asks y/N, signs
bun run blastconfirm merge --pr 1 --config $C     # allow_approved, dry-run merge
bun run blastconfirm merge --pr 1 --config $C     # exit 3 again: approvals are single-use
```

To run the full local gate (install, skills, lint, typecheck, tests, report, audit, gitleaks):

```bash
bun run check
```

## MCP server

`bun run blastconfirm mcp --config blastconfirm.json` starts a stdio server (see `examples/mcp-servers.json`). The human sets the repo, base, threshold, score source, trusted keys and executor in the config. The agent only passes a PR number.

| Tool | Effect |
| --- | --- |
| `blast_score` | Read-only score, threshold, files and tests to run |
| `request_merge_approval` | `{pr, reason}` opens a pending request. It does not approve. |
| `approval_status` | `pending`, `approved`, `denied` or `invalid` |
| `merge_pull_request` | Gate, then merge through the configured executor |

Executors: `dry-run` (logs to `.blastconfirm/merges.jsonl`), `gh` (`gh pr merge <n> --repo <owner/name> --<method>`, argv only) and `mcp`. The `mcp` executor forwards to a downstream MCP tool using the `merge_pull_request` arguments that github/github-mcp-server v2.0.2 documents (`owner`, `repo`, `pullNumber`, `merge_method`). For a real repo, see `examples/blastconfirm.ripwire-gh.json` (needs a built ripwire in `RIPWIRE_BIN`).

## CLI

| Command | Exit codes |
| --- | --- |
| `keygen [--out DIR]` | 0 |
| `score` | 0 |
| `check --pr N` | 0 allowed, 3 blocked, 4 denied |
| `merge --pr N` | 0 merged, 3 blocked, 4 denied, 1 executor failed |
| `pending`, `show <id>`, `verify <id>` | 0 / 3 / 4 |
| `approve <id> [--ttl 30m]`, `deny <id>` | needs a TTY unless `--yes` |
| `mcp` | stdio server |

## Measured

From `bun run report` on 2026-10-21 (Bun 1.4.3, threshold 6, real Ed25519 keys, ripwire XML captured with ripwire 0.6.7 `a87470c7e`). Source: [reports/blastconfirm.md](./reports/blastconfirm.md). `bun run check`: 38 pass, 0 fail, 1 skip (the live ripwire test skips without `RIPWIRE_BIN`).

| Scenario | Score | Expected | Verdict | Reason | Merged |
| --- | ---: | --- | --- | --- | --- |
| leaf diff, no approval | 3 | allow | allow | under_threshold | yes |
| core diff, no approval | 12 | blocked | blocked | pending | no |
| core diff + trusted approval | 12 | allow_approved | allow_approved | signed_approval | yes |
| core diff, same approval replayed | 12 | blocked | blocked | already_used | no |
| core diff + approval from untrusted key | 12 | blocked | blocked | untrusted_key | no |
| core diff + approval with edited expires_at | 12 | blocked | blocked | bad_signature | no |
| core diff + expired approval | 12 | blocked | blocked | expired | no |
| core diff + signed deny | 12 | denied | denied | denied | no |
| core diff grows after approval | 13 | blocked | blocked | pending | no |

| Timing | n | p50 ms | p95 ms |
| --- | ---: | ---: | ---: |
| gate, leaf (allow) | 200 | 0.167 | 1.25 |
| gate, core (blocked) | 200 | 0.271 | 3.112 |
| gate, core + signed approval verify | 200 | 0.399 | 1.23 |
| MCP stdio merge_pull_request (blocked 20/20) | 20 | 1.956 | 52.129 |

Gate timings replay the captured XML, so they leave out ripwire's own run time (see the live run below).

MCP server spawn and initialize: 299.1 ms.

Live ripwire run (`RIPWIRE_BIN=... bun run report`, 2026-10-21, [reports/live-ripwire.json](./reports/live-ripwire.json)): leaf edit scored 3 (allow, 417.7 ms), core edit scored 12 (blocked, 446 ms). These match the captured fixtures.

## Keys and environment

No API keys. Everything runs keyless, and CI uses the captured ripwire fixtures.

| Variable | Required | Use |
| --- | --- | --- |
| `RIPWIRE_BIN` | no | Built ripwire binary for `source.kind = "ripwire"`, the live test and `bun run capture` |
| `BLASTCONFIRM_KEY_DIR` | no | Approver key directory (default `~/.config/blastconfirm`) |

## Limits

- **The approver key is the trust root.** If the agent can read it, the agent can approve its own merges. Keep it outside the agent's sandbox or user account (see [SECURITY.md](./SECURITY.md)).
- **Single-use is a local file.** `.blastconfirm/consumed.jsonl` records spent approvals. An agent that can write to that directory can reset it, so expiry is the backstop.
- **This does not replace branch protection.** It gates merges that go through this MCP server or CLI. An agent with another merge path (direct `gh`, a push token) is not stopped.
- **Approval is a terminal prompt, not a phone tap.** No push or mobile approval flow is included, and MCP elicitation is not used, since the client could answer an elicitation without a human.
- **ripwire is not bundled.** Building it needs CMake and a C++23 compiler. CI and the demo replay XML captured from ripwire 0.6.7 (commit `a87470c7e`) on `fixtures/sample-repo`. Scores for other languages depend on ripwire's call graph. The live test and `bun run capture` make a throwaway git repo in the OS temp dir.
- **The score formula and the default threshold (6) are a starting point, not a calibrated policy.** They separate the two fixture diffs (3 vs 12) and nothing more has been tested.
- **The `gh` and `mcp` executors** are tested against a stub `gh` and a fixture MCP server, not against GitHub.
- **Unverified framing.** The idea compares this to a shopping MCP that asks the buyer to confirm before a purchase. We did not verify that claim, and nothing here depends on it.
- **No Gemini impact note.** The optional plain-language summary from the idea is not built.

## Credits

- [redhat-et/ripwire](https://github.com/redhat-et/ripwire) (Apache-2.0): blast radius and test gate
- [Model Context Protocol TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk) (MIT)
- Building blocks from the SkilledDragon labs `minilab-ripwire-blast-radius` and `agent-flight-recorder` (see [NOTICE](./NOTICE))

Not affiliated with Red Hat, GitHub, Anthropic or the Model Context Protocol project.

## License

Apache-2.0. See [LICENSE](./LICENSE) and [NOTICE](./NOTICE). Contact: hello@skilleddragon.com.

Built by SkilledDragon LLC, [skilleddragon.com](https://skilleddragon.com)