Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,49 @@

All notable changes to Burnwall.

## [0.9.15] — 2026-06-10

A follow-up from live dogfooding: kill a false-positive class that could wedge a
whole session, make every block explain itself, give false positives a live
escape hatch, and stop surfaces from showing stale numbers when the proxy is
down.

### Added
- **`burnwall pause` / `resume` / `allow-once` — a live escape hatch.** After a
block you believe is a false positive, `burnwall allow-once` lets exactly the
next request through (then protection restores itself), and `burnwall pause
[5m]` relays everything unchecked for a bounded window — both take effect on
the running proxy with no daemon or AI-tool restart, so the agent's session
survives. Pauses auto-expire (default 5 minutes, capped at 24 hours), an
unused allow-once expires after 10 minutes, and every status surface shows a
loud `⏸ PAUSED` warning with a countdown for the whole window. Block messages
now point at these toggles; the previous advice (an environment variable plus
a tool restart) never reached a backgrounded daemon and has been removed.

### Fixed
- **A secret-shaped token in conversation history no longer blocks the session.**
Security data checks (credentials, cards, SSNs) now run only inside tool-call
arguments — the agent *action* — never on prose or resent conversation history.
Clients resend the full conversation every turn, so a key-shaped string merely
*quoted or discussed* (e.g. an example key in a summary) used to 403 every
request until the session was abandoned. The exfiltration vector that matters —
a credential leaving the machine inside a tool call — stays fully covered.
- **Subscribers no longer see a notional dollar figure where a plan reading
belongs.** When the latest plan reading is stale (idle, or the proxy was briefly
down), the status line keeps showing last-known plan headroom — marked stale —
instead of falling back to a session-cost figure that reads as real money. The
`status` command frames a subscriber's spend as notional, not a budget breach.

### Changed
- **Blocks now explain themselves.** A security block names the tool that tripped
it, shows a masked, recognisable preview of what matched (e.g. `AKIA…LKEY`) for
credential/PII hits — the raw value is never echoed or logged — and states why
that class is blocked, instead of a bare category label.
- **A down proxy now looks down.** When routing points at a dead proxy, status
surfaces drop the cost, plan, today, and block-count segments (all stale with no
capture happening) and show only the loud "proxy down" warning alongside the
tool-reported token and context gauges.

## [0.9.14] — 2026-06-10

A real-world robustness pass driven by dogfooding: a multi-agent review of
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "burnwall"
version = "0.9.14"
version = "0.9.15"
edition = "2024"
rust-version = "1.87"
description = "Local proxy for AI coding tools (Claude Code, Codex CLI, Aider): cache-aware cost tracking, path/command security checks, daily budget enforcement. Zero telemetry."
Expand Down Expand Up @@ -108,6 +108,12 @@ predicates = "3" # Assertion helpers
name = "proxy_test"
path = "tests/integration/proxy_test.rs"

# Own binary (= own process): proxy_test flips the process-global
# BURNWALL_BYPASS env var, which would race the pause/allow-once assertions.
[[test]]
name = "pause_test"
path = "tests/integration/pause_test.rs"

[[test]]
name = "parser_test"
path = "tests/unit/parser_test.rs"
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,23 @@ When a rule triggers:
Request returned 403 — file was never accessed.
```

### False positives

Every block explains what matched and why, and points at the escape hatches —
all of which take effect on the **running** proxy, with no restart of the proxy
or your AI tool (your agent session survives):

```bash
burnwall allow-once # let just the NEXT request through, then auto-restore
burnwall pause 5m # relay everything unchecked for a bounded window
burnwall resume # restore protection early
burnwall report-bug # write a sanitized local report (nothing is sent)
```

Pauses auto-expire (default 5 minutes, capped at 24 hours) and every status
surface shows a loud `⏸ PAUSED` warning for the whole window — the escape
hatch can't silently outlive the emergency.

## Cost Output

```
Expand Down
8 changes: 5 additions & 3 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,11 @@ A proxy that terminates or weakens TLS would be a liability. Burnwall does not:

## Kill switch

If anything ever misbehaves, `BURNWALL_BYPASS=1` turns the proxy into a pure
relay (no scanning, no budget checks, no storage) for the current session, and
`burnwall self-rollback <version>` reinstalls a prior release.
If anything ever misbehaves, `burnwall pause` flips the *running* proxy into a
pure relay (no scanning, no budget checks, no storage) and auto-restores after
5 minutes — `burnwall resume` restores it early, and `burnwall allow-once`
relays just the next request. `burnwall self-rollback <version>` reinstalls a
prior release.

## Scope

Expand Down
2 changes: 1 addition & 1 deletion editor/vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "burnwall",
"displayName": "Burnwall",
"description": "Cost + security for your AI coding agents, at a glance — reads your local Burnwall CLI.",
"version": "0.9.14",
"version": "0.9.15",
"publisher": "intbot",
"license": "FSL-1.1-MIT",
"repository": { "type": "git", "url": "https://github.com/intbot/burnwall" },
Expand Down
2 changes: 1 addition & 1 deletion packaging/mcp/server.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"url": "https://github.com/intbot/burnwall",
"source": "github"
},
"version": "0.9.14",
"version": "0.9.15",
"packages": [
{
"registryType": "oci",
Expand Down
2 changes: 1 addition & 1 deletion src/audit/aibom.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
//! each MCP server a `service`, and window-level totals ride in metadata
//! properties. Metadata only — no prompt content.

use serde_json::{json, Value};
use serde_json::{Value, json};

use crate::observe::digest::Digest;

Expand Down
6 changes: 4 additions & 2 deletions src/audit/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ use std::path::{Path, PathBuf};
use ed25519_dalek::{Signature, Signer, SigningKey};
use sha2::{Digest as _, Sha256};

use crate::storage::{data_dir, ReceiptRow, RequestRecord, SecurityEvent, Storage};
use crate::storage::{ReceiptRow, RequestRecord, SecurityEvent, Storage, data_dir};

/// 64 hex zeros — the `prev_hash` of the first receipt in a chain.
pub const GENESIS_HASH: &str = "0000000000000000000000000000000000000000000000000000000000000000";
Expand Down Expand Up @@ -190,7 +190,9 @@ impl AuditChain {
// Append-only archive of closed segments — the external record of
// where each key's coverage ends, so an auditor can still verify the
// old segment against the old public key.
let archive = self.chain_pub_path.with_file_name("audit_chain_segments.log");
let archive = self
.chain_pub_path
.with_file_name("audit_chain_segments.log");
let line = format!(
"{} closed-segment pubkey={} head={} receipts={}\n",
chrono::Utc::now().to_rfc3339(),
Expand Down
2 changes: 1 addition & 1 deletion src/audit/sarif.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
//! distinct `event_type` becomes a rule; each event a result at `error` level.
//! Metadata only — `details` may already be redacted by `log_redact_details`.

use serde_json::{json, Value};
use serde_json::{Value, json};

use crate::storage::SecurityEvent;

Expand Down
11 changes: 6 additions & 5 deletions src/budget/loop_detector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@
//! per-process which is fine — we only need same content -> same hash
//! within a single run.

use std::collections::hash_map::DefaultHasher;
use std::collections::VecDeque;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::sync::Mutex;

Expand Down Expand Up @@ -338,9 +338,7 @@ mod tests {
let prefix = r#"{"model":"claude-fable-5","messages":[{"role":"user","content":"please investigate why successful proxied requests are not recorded and fix the streaming usage parser so the cost tracking pipeline works again"}"#;
assert!(prefix.len() > 200, "prefix must exceed the old hash window");
for i in 0..10 {
let body = format!(
"{prefix},{{\"role\":\"assistant\",\"content\":\"turn {i}\"}}]}}"
);
let body = format!("{prefix},{{\"role\":\"assistant\",\"content\":\"turn {i}\"}}]}}");
let hash = h(&det, body.as_bytes());
let verdict = det.check_request(hash);
assert_eq!(verdict, LoopVerdict::Ok, "turn {i} wrongly flagged as loop");
Expand All @@ -351,7 +349,10 @@ mod tests {
#[test]
fn byte_identical_bodies_still_trip() {
let det = LoopDetector::with_defaults();
let hash = h(&det, br#"{"model":"m","messages":[{"role":"user","content":"same"}]}"#);
let hash = h(
&det,
br#"{"model":"m","messages":[{"role":"user","content":"same"}]}"#,
);
// Five identical *successful* requests are tolerated; the sixth peek
// sees a full window and blocks. Each Ok request records its arrival
// (as the tee does on a 2xx).
Expand Down
10 changes: 7 additions & 3 deletions src/budget/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ use chrono::Datelike;
pub mod limits;
pub mod loop_detector;

pub use limits::{check_daily, check_monthly, check_session, BudgetConfig, BudgetStatus};
pub use limits::{BudgetConfig, BudgetStatus, check_daily, check_monthly, check_session};
pub use loop_detector::{LoopConfig, LoopDetector, LoopVerdict};

use crate::storage::Storage;
Expand Down Expand Up @@ -158,7 +158,10 @@ impl BudgetTracker {
return;
}
let units = (cost_usd * MICROCENTS_PER_USD).round() as u64;
*self.session_microcents.entry(session.to_string()).or_insert(0) += units;
*self
.session_microcents
.entry(session.to_string())
.or_insert(0) += units;
}

/// Spend so far for a session (USD).
Expand Down Expand Up @@ -206,7 +209,8 @@ impl BudgetTracker {
let spent = storage.total_cost_for_month(month)?;
let units = (spent * MICROCENTS_PER_USD).round() as u64;
self.month_microcents.store(units, Ordering::Relaxed);
self.month_stamp.store(local_epoch_month(), Ordering::Relaxed);
self.month_stamp
.store(local_epoch_month(), Ordering::Relaxed);
Ok(())
}
}
Loading