Skip to content

Commit fe84ecc

Browse files
committed
v0.9.8: savings, signed share card, sidecar, destructive/exfil detection, swarm budgets, attestations
Iter 1 (trust + ROI): cargo-dist attestations + SECURITY.md + README verify; burnwall savings (self-measured cache savings + opportunity); status protection heartbeat; TLS-integrity guard test. Iter 2 (security depth): catastrophic-command detection by shape; exfil technique detection (opt-in); whitespace-normalized command matching; per-session/swarm budget ceiling + session attribution. Iter 3 (frontier): burnwall sidecar (co-located egress for off-laptop sandboxes/CI); burnwall share (opt-in signed value card).
1 parent 8231c65 commit fe84ecc

11 files changed

Lines changed: 252 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,44 @@
22

33
All notable changes to Burnwall.
44

5+
## [0.9.8] — 2026-06-07
6+
7+
### Added
8+
9+
- **`burnwall savings`** — your own *measured* cache-savings report: dollars
10+
recovered through caching over a window (from real token buckets at published
11+
cache-read vs base-input rates), plus models that are underusing caching. No
12+
marketing percentages — your numbers.
13+
- **`burnwall watch` / `status` self-test heartbeat**`status` now states
14+
plainly whether protection is live ("proxy running (pid …); every request is
15+
scanned"), so a passive proxy never leaves you wondering if it's working.
16+
- **`burnwall share`** — an opt-in, screenshot-friendly, **signed** value card
17+
(spend / cache savings / blocks), verifiable against the local audit key so the
18+
numbers can't be faked. Nothing leaves your machine.
19+
- **`burnwall sidecar`** — run the proxy as a co-located egress point for an
20+
agent that executes off your laptop (self-hosted sandbox / container / CI
21+
runner), with the in-sandbox env-var recipe. Same scanning + budgets; not a
22+
TLS-terminating proxy (no CA injection — see `SECURITY.md`).
23+
- **Catastrophic-command detection by shape** — recursive-force deletes, disk
24+
destruction (`dd of=/dev/…`, `mkfs`), and destructive SQL (`DROP`/`TRUNCATE`)
25+
are blocked regardless of flag order, spacing, or target expansion — the forms
26+
that slipped past literal/approval checks in real incidents.
27+
- **Data-exfiltration technique detection** (opt-in under `security.dlp`): DNS
28+
exfiltration, secret-file-piped-to-network, command-substituted uploads.
29+
- **Per-session / swarm budget ceiling** (`budget.per_session`, opt-in via an
30+
`x-burnwall-session` request header) — agents in a fan-out that share a session
31+
id share one blast-radius cap; `status` shows a per-session breakdown.
32+
- **Build provenance** — releases now carry GitHub Artifact Attestations (SLSA
33+
Build L2); verify with `gh attestation verify … --repo intbot/burnwall`. New
34+
`SECURITY.md` documents integrity + TLS handling (rustls, no CA injection, no
35+
plaintext at rest), backed by a guard test.
36+
37+
### Changed
38+
39+
- `command_matches` is whitespace-normalized, so padding (`rm -rf /`) can't
40+
evade a literal deny rule.
41+
- README: "Verify your download" + the trust/defense-in-depth sections.
42+
543
## [0.9.7] — 2026-06-07
644

745
### Added

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "burnwall"
3-
version = "0.9.7"
3+
version = "0.9.8"
44
edition = "2024"
55
rust-version = "1.87"
66
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."

editor/vscode/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "burnwall",
33
"displayName": "Burnwall",
44
"description": "Cost + security for your AI coding agents, at a glance — reads your local Burnwall CLI.",
5-
"version": "0.9.7",
5+
"version": "0.9.8",
66
"publisher": "intbot",
77
"license": "FSL-1.1-MIT",
88
"repository": { "type": "git", "url": "https://github.com/intbot/burnwall" },

packaging/mcp/server.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"url": "https://github.com/intbot/burnwall",
77
"source": "github"
88
},
9-
"version": "0.9.7",
9+
"version": "0.9.8",
1010
"packages": [
1111
{
1212
"registryType": "oci",

src/audit/mod.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,13 @@ impl AuditChain {
8686
hex(self.key.verifying_key().as_bytes())
8787
}
8888

89+
/// Sign arbitrary bytes with the local audit key, returning a hex
90+
/// signature. Lets `burnwall share` emit a *verifiable* value card whose
91+
/// numbers can't be faked (verify against [`AuditChain::public_key_hex`]).
92+
pub fn sign_hex(&self, bytes: &[u8]) -> String {
93+
hex(&self.key.sign(bytes).to_bytes())
94+
}
95+
8996
/// Seal every not-yet-sealed request + security event into the chain, in
9097
/// chronological order. Idempotent: rows already sealed are skipped (the
9198
/// `audit_receipts.UNIQUE(source, source_id)` constraint backs this).

src/cli/mod.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ pub mod report;
2929
pub mod routing;
3030
pub mod rules;
3131
pub mod savings;
32+
#[cfg(feature = "audit")]
33+
pub mod share;
34+
pub mod sidecar;
3235
pub mod security;
3336
pub mod self_rollback;
3437
pub mod service;
@@ -112,6 +115,11 @@ pub enum Command {
112115
Watch(watch::WatchArgs),
113116
/// Your own measured cache savings + where caching is underused.
114117
Savings(savings::SavingsArgs),
118+
/// Run the proxy as a co-located egress sidecar (for off-laptop sandboxes/CI).
119+
Sidecar(sidecar::SidecarArgs),
120+
/// Emit an opt-in, signed, screenshot-friendly value card.
121+
#[cfg(feature = "audit")]
122+
Share(share::ShareArgs),
115123
}
116124

117125
impl Cli {
@@ -153,6 +161,9 @@ impl Cli {
153161
Command::Statusline(args) => statusline::run_cmd(args),
154162
Command::Watch(args) => watch::run_cmd(args),
155163
Command::Savings(args) => savings::run_cmd(args),
164+
Command::Sidecar(args) => sidecar::run_cmd(args).await,
165+
#[cfg(feature = "audit")]
166+
Command::Share(args) => share::run_cmd(args),
156167
}
157168
}
158169
}

src/cli/security.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ fn friendly_type(event_type: &str) -> &str {
125125
"secret_detected" => "secret/credential in payload",
126126
"dlp_blocked" => "PII/data exfiltration",
127127
"exfil_blocked" => "data-exfiltration technique",
128+
"destructive_blocked" => "catastrophic command",
128129
other => other,
129130
}
130131
}
@@ -154,6 +155,7 @@ fn print_summary<W: Write>(
154155
*counts.entry(e.event_type.as_str()).or_default() += 1;
155156
}
156157
let order = [
158+
"destructive_blocked",
157159
"exfil_blocked",
158160
"secret_detected",
159161
"dlp_blocked",

src/cli/share.rs

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
//! `burnwall share` — an opt-in, screenshot-friendly, *signed* value card.
2+
//!
3+
//! A zero-telemetry tool produces nothing to share automatically — so virality,
4+
//! if any, has to be earned: the user chooses to post a card. To keep it honest
5+
//! (no faked numbers), the card's figures are signed with the local audit key
6+
//! and can be verified against the printed public key. Nothing leaves the
7+
//! machine; this just renders text the user may copy.
8+
9+
use std::io::Write;
10+
11+
use anyhow::Context;
12+
use clap::Args;
13+
14+
use crate::audit::AuditChain;
15+
use crate::pricing;
16+
use crate::providers::TokenUsage;
17+
use crate::storage::{ModelBreakdown, Storage};
18+
19+
#[derive(Args, Debug)]
20+
pub struct ShareArgs {
21+
/// How many days the card summarizes (default 30).
22+
#[arg(long, default_value_t = 30)]
23+
pub days: i64,
24+
/// Skip signing (no audit key needed) — emits an unsigned card.
25+
#[arg(long)]
26+
pub no_sign: bool,
27+
}
28+
29+
pub fn run_cmd(args: ShareArgs) -> anyhow::Result<()> {
30+
let storage = Storage::open_default().context("opening storage")?;
31+
let rows = storage.breakdown_since_days(args.days)?;
32+
let (spent, saved) = spend_and_savings(&rows);
33+
let blocked = storage
34+
.security_events_since_days(args.days)?
35+
.len();
36+
37+
// Canonical, signable payload — the exact numbers shown, so a verifier can
38+
// confirm the card wasn't doctored.
39+
let payload = format!(
40+
"burnwall-card|days={}|spent={:.2}|saved={:.2}|blocked={}",
41+
args.days, spent, saved, blocked
42+
);
43+
44+
let signature = if args.no_sign {
45+
None
46+
} else {
47+
match AuditChain::open_default() {
48+
Ok(chain) => Some((chain.sign_hex(payload.as_bytes()), chain.public_key_hex())),
49+
Err(_) => None,
50+
}
51+
};
52+
53+
let mut out = std::io::stdout().lock();
54+
let line1 = format!("🔥 Burnwall · last {} days", args.days);
55+
let line2 = format!("💰 ${:.2} spent · ${:.2} saved by caching", spent, saved);
56+
let line3 = format!("🛡 {blocked} risky action{} blocked", if blocked == 1 { "" } else { "s" });
57+
let width = [line1.len(), line2.len(), line3.len()].into_iter().max().unwrap_or(40) + 2;
58+
let rule = "─".repeat(width);
59+
60+
writeln!(out, "┌{rule}┐")?;
61+
writeln!(out, " {line1}")?;
62+
writeln!(out, " {line2}")?;
63+
writeln!(out, " {line3}")?;
64+
match &signature {
65+
Some((sig, pubkey)) => {
66+
let sig_short = &sig[..sig.len().min(16)];
67+
let key_short = &pubkey[..pubkey.len().min(16)];
68+
writeln!(out, " 🔐 signed {sig_short}… · key {key_short}…")?;
69+
}
70+
None => writeln!(out, " (unsigned — run `burnwall audit seal` once to enable signing)")?,
71+
}
72+
writeln!(out, "└{rule}┘")?;
73+
if let Some((sig, pubkey)) = &signature {
74+
writeln!(out)?;
75+
writeln!(out, "verify: payload \"{payload}\"")?;
76+
writeln!(out, " sig {sig}")?;
77+
writeln!(out, " key {pubkey}")?;
78+
}
79+
Ok(())
80+
}
81+
82+
/// Total real spend and cache-captured savings over the rows (USD), using the
83+
/// same cache-aware math as `burnwall savings`.
84+
fn spend_and_savings(rows: &[ModelBreakdown]) -> (f64, f64) {
85+
let mut real = 0.0;
86+
let mut without = 0.0;
87+
for r in rows {
88+
if let Some(p) = pricing::get_pricing(&r.model) {
89+
let usage = TokenUsage {
90+
input_tokens: r.input_tokens,
91+
output_tokens: r.output_tokens,
92+
cache_creation_tokens: r.cache_creation_tokens,
93+
cache_read_tokens: r.cache_read_tokens,
94+
};
95+
real += pricing::cost(&usage, p);
96+
without += pricing::cost_without_cache(&usage, p);
97+
}
98+
}
99+
(real, (without - real).max(0.0))
100+
}

src/cli/sidecar.rs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
//! `burnwall sidecar` — run the proxy as a co-located egress point for an
2+
//! agent that executes off your laptop (a self-hosted sandbox, a container, a
3+
//! CI runner).
4+
//!
5+
//! As agentic dev shifts to background/cloud sandboxes, a proxy bound only to
6+
//! `127.0.0.1` can't see the agent's traffic. This subcommand is the same
7+
//! reverse proxy, bound by default to `0.0.0.0` so an agent in a co-located
8+
//! sandbox can reach it, plus the exact env-vars to set inside that sandbox.
9+
//!
10+
//! It is NOT a TLS-terminating forward proxy — Burnwall never injects a CA (see
11+
//! SECURITY.md). It's the existing path-prefix proxy, deployed beside the agent
12+
//! on infrastructure you control.
13+
14+
use clap::Args;
15+
16+
use super::start::{self, StartArgs};
17+
18+
#[derive(Args, Debug)]
19+
pub struct SidecarArgs {
20+
/// TCP port to listen on (default 4100).
21+
#[arg(long)]
22+
pub port: Option<u16>,
23+
/// Bind address. Defaults to `0.0.0.0` so an agent in a co-located
24+
/// sandbox/container can reach it. Set a specific bridge IP to limit
25+
/// exposure.
26+
#[arg(long)]
27+
pub host: Option<String>,
28+
/// Run in the background (PID file under the data dir).
29+
#[arg(long)]
30+
pub daemon: bool,
31+
}
32+
33+
pub async fn run_cmd(args: SidecarArgs) -> anyhow::Result<()> {
34+
let host = args.host.unwrap_or_else(|| "0.0.0.0".to_string());
35+
let port = args.port.unwrap_or(4100);
36+
37+
println!("🛰 Burnwall sidecar — co-locate this proxy with your agent's sandbox / CI runner.");
38+
println!(" Binding {host}:{port}. Inside the sandbox, point the agent at it:");
39+
println!(" ANTHROPIC_BASE_URL=http://<sidecar-host>:{port}/anthropic");
40+
println!(" OPENAI_BASE_URL=http://<sidecar-host>:{port}/openai");
41+
println!(" GOOGLE_GEMINI_BASE_URL=http://<sidecar-host>:{port}/google");
42+
if host == "0.0.0.0" {
43+
println!(
44+
" ⚠ 0.0.0.0 binds all interfaces — run it on an isolated/trusted network \
45+
(the sandbox bridge), never a public host."
46+
);
47+
}
48+
println!(" (Same scanning + budgets + cost tracking as `burnwall start`, just deployed beside the agent.)");
49+
println!();
50+
51+
// Delegate to the normal start path with the sidecar bind defaults.
52+
start::run_cmd(StartArgs {
53+
port: Some(port),
54+
host: Some(host),
55+
daemon: args.daemon,
56+
upstream_anthropic: "https://api.anthropic.com".to_string(),
57+
upstream_openai: "https://api.openai.com".to_string(),
58+
upstream_google: "https://generativelanguage.googleapis.com".to_string(),
59+
rewrite_anthropic_cache: false,
60+
})
61+
.await
62+
}

0 commit comments

Comments
 (0)