Skip to content
Draft
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
18 changes: 18 additions & 0 deletions crates/buzz-cli/src/commands/sandbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ mod tests;
/// Owner approvals and interactive sandbox terminals.
#[derive(Subcommand)]
pub enum SandboxCmd {
/// Validate a local captured bundle before any Git import (internal worker seam)
#[command(hide = true)]
InspectBundle {
/// Captured bundle file
file: std::path::PathBuf,
},
/// Attach a local TTY; omitted targets are selected from BUZZ_PASEO_AGENTS
Attach {
/// Channel UUID containing the sandbox thread
Expand Down Expand Up @@ -56,6 +62,17 @@ pub enum SandboxCmd {
},
}

/// Print bounded local preflight metadata, without reading credentials or connecting.
pub(crate) fn inspect_bundle_cmd(file: &std::path::Path) -> Result<(), CliError> {
let summary = crate::sandbox_archive::inspect_bundle_file(file).map_err(failure)?;
let encoded = serde_json::to_string(&summary).map_err(failure)?;
if encoded.len() > 16 * 1024 * 1024 {
return Err(failure("bundle metadata exceeded budget"));
}
println!("{encoded}");
Ok(())
}

fn failure(message: impl ToString) -> CliError {
CliError::Other(message.to_string())
}
Expand Down Expand Up @@ -168,6 +185,7 @@ pub async fn dispatch(command: SandboxCmd, client: &BuzzClient) -> Result<(), Cl
));
}
match command {
SandboxCmd::InspectBundle { file } => inspect_bundle_cmd(&file),
SandboxCmd::Authorize { request, minutes } => {
let event = one(client, json!({"ids":[request],"kinds":[30624],"limit":1})).await?;
if event.id != request {
Expand Down
6 changes: 6 additions & 0 deletions crates/buzz-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ mod commands;
mod error;
mod help_tree;
mod links;
/// Safe local snapshot archive inspection and restoration.
pub mod sandbox_archive;
mod validate;

use clap::{CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum};
Expand Down Expand Up @@ -2303,6 +2305,10 @@ async fn run(cli: Cli) -> Result<(), CliError> {
};
}

if let Cmd::Sandbox(commands::sandbox::SandboxCmd::InspectBundle { ref file }) = cli.command {
return commands::sandbox::inspect_bundle_cmd(file);
}

// Auth: private key is required for all relay operations.
// The keypair IS the identity — no tokens, no other auth.
let private_key_str = cli.private_key.ok_or_else(|| {
Expand Down
99 changes: 99 additions & 0 deletions crates/buzz-cli/src/sandbox_archive.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
//! Local-only bounded archive preflight. No identity, relay or Git process is used.
use std::fs::OpenOptions;
use std::io::Read;
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use std::path::Path;

use buzz_sdk::sandbox_attach::snapshot::{
archive::inspect_bundle, MAX_BLOB_BYTES, MAX_SOURCE_BYTES,
};
use serde::{Deserialize, Serialize};

/// Local archive refusal; file contents and scanner diagnostics are never included.
#[derive(Debug, thiserror::Error)]
pub enum ArchiveError {
/// Filesystem access failed.
#[error("archive file access failed: {0}")]
Io(#[from] std::io::Error),
/// A hard file/type or metadata bound was exceeded.
#[error("{0}")]
Invalid(&'static str),
/// The SDK rejected malformed or oversized bundle contents.
#[error("{0}")]
Bundle(#[from] buzz_sdk::SdkError),
}

/// Bounded admission metadata for the exact captured bundle.
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct BundleSummary {
/// Local helper format version.
pub version: u8,
/// Number of packed objects, checked before import.
pub objects: u32,
/// Sum of reconstructed object bytes, including delta targets.
pub expanded_bytes: u64,
/// Object-ID length in bytes (20 or 32).
pub hash_bytes: usize,
/// Object ID/ref name pairs; callers account for ref/index/block costs.
pub refs: Vec<(String, String)>,
}

/// Inspect a regular file without allocating from unchecked file metadata.
/// The bounded descriptor read also catches growth after the initial metadata check.
pub fn inspect_bundle_file(path: &Path) -> Result<BundleSummary, ArchiveError> {
if !std::fs::symlink_metadata(path)?.is_file() {
return Err(ArchiveError::Invalid("bundle must be a regular file"));
}
let mut options = OpenOptions::new();
options.read(true);
#[cfg(unix)]
options.custom_flags(nix::libc::O_NONBLOCK | nix::libc::O_NOFOLLOW);
let file = options.open(path)?;
let metadata = file.metadata()?;
if !metadata.is_file() || metadata.len() > MAX_BLOB_BYTES as u64 {
return Err(ArchiveError::Invalid(
"bundle exceeds regular-file byte budget",
));
}
let mut bytes = Vec::new();
file.take(MAX_BLOB_BYTES as u64 + 1)
.read_to_end(&mut bytes)?;
if bytes.len() > MAX_BLOB_BYTES {
return Err(ArchiveError::Invalid("bundle grew beyond byte budget"));
}
let inventory = inspect_bundle(&bytes, MAX_SOURCE_BYTES)?;
Ok(BundleSummary {
version: 1,
objects: inventory.objects,
expanded_bytes: inventory.expanded_bytes,
hash_bytes: inventory.hash_bytes,
refs: inventory.refs,
})
}

#[cfg(test)]
mod tests {
use super::*;
#[test]
fn inspector_refuses_oversized_files_before_reading_and_nonregular_inputs() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("sparse.bundle");
std::fs::File::create(&path)
.unwrap()
.set_len(MAX_BLOB_BYTES as u64 + 1)
.unwrap();
assert!(matches!(
inspect_bundle_file(&path),
Err(ArchiveError::Invalid(_))
));
assert!(inspect_bundle_file(temp.path()).is_err());
#[cfg(unix)]
{
let link = temp.path().join("link");
std::os::unix::fs::symlink(&path, &link).unwrap();
assert!(inspect_bundle_file(&link).is_err());
}
}
}
61 changes: 61 additions & 0 deletions docs/sandbox-snapshot-producer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Snapshot archive producer

`buzz-agent-snapshot` builds a fully scanned archive inside an agent sandbox.
This layer packages a local artifact only. The ACP request handler, encrypted Blossom transfer, Rust extraction, and user-facing CLI follow separately.
The caller must hold a live owner-approved snapshot grant and run the helper under the isolated worker's containment and cancellation rules.
No terminal approval or global gateway delegation alone permits export.

```sh
buzz-agent-snapshot --workspace /workspace --output /tmp/private-job/archive.tar.gz --work-bytes 536870912
```

The output must be new. It is mode 0600 and is removed if creation fails.
Stdout contains only version, scan status, repository count, and byte count.
Failures report only `scan`, `budget`, or `archive`; scanner output and file names are suppressed.
The helper never uploads, fetches from a forge, signs relay events, or falls back to quarantine.

Every repository gets a self-contained `git bundle --all HEAD` plus its tracked and non-ignored untracked working files.
Nested repositories have separate records and bundles; Git's directory markers are not copied as files.
The manifest records `version: 1`, `snapshot: true`, `scanned: true`, and repository path, head, branch, explicit shallow boundaries, file list, exclusions, and deletions.
Every record uses `base: bundle`; there is no origin URL or clone prerequisite.
A workspace that is itself a repository uses the output path `workspace`.

The tar layout reuses the checkpoint structure:

```text
snapshot/manifest.json
snapshot/repositories/0/history.bundle
snapshot/repositories/0/files/<relative paths>
```

The user chose full locally available scanned history.
Historical versioned content, including a historical `.env.example`, remains in the bundle.
The working copy excludes checkpoint private/runtime paths and `.env*` files.
The next extractor must mark intentionally omitted tracked paths skip-worktree so they do not appear as accidental deletions; genuine source deletions remain absent.
Shallow clones retain their explicit local boundaries, without unshallow or network fetch.
Unborn HEADs are refused.

Before packing, the helper inventories source objects and byte/count budgets.
After capture, the required local `buzz sandbox inspect-bundle` helper validates the actual bundle with the SDK before any Git import, independently of credentials or relay configuration.
The producer reserves the captured expansion, object/ref/index/block costs and aggregate counts, then verifies the neutral inventory matches before writing raw review bodies.
A source commit between preflight and capture therefore cannot inherit stale scratch admission; missing or malformed inspector output fails closed.
It imports the bundle into a neutral Git repository, verifies its object graph, and scans that exact history with immutable gitleaks rules.
It also scans the copied working files, manifest, ref names, and every archived raw object, including merge-only blobs and annotated-tag messages.
Global/system configuration, replacement refs, grafts, lazy fetch, hooks, fsmonitor, repository ignore files, and `GITLEAKS_*` overrides cannot weaken the scan.
Repository and file reads use open directory descriptors and refuse source path redirection through symlinks.
Internal relative symlinks are preserved; external links and special files are refused.

Limits are 256 MiB source/expanded object bytes, 200,000 objects, 50,000 files/references, 64 repositories, and 180 seconds total creation time.
Subprocess output and time are bounded, and Git object bodies use one streamed batch.
Bundle and gzip output are admitted before writes; the final archive leaves space for the encrypted blob prefix/tag within 100 MiB.
The local work budget defaults to 512 MiB and may be set from 1 MiB through 2 GiB; filesystem free space can lower it.
The budget counts object review files, bundles/imports, copied files, block/index overhead, compressed output, and an operational reserve.
`statvfs` cannot reveal Kubernetes emptyDir sizeLimit: configure the work budget below the actual scratch quota after accounting for other agent data.
The separate attaching-machine extractor also needs its own store/work budget and capacity checks.

Tar ownership and timestamps are normalized. UTF-8 path/link metadata is capped at 16 KiB per header and 1 MiB total; the next extractor must apply bounds before extension allocation.
The archive is validated and scanned after copying; source work can continue during capture, so it is a bounded working view rather than a filesystem transaction.

Run `python3 voltage/images/buzz-agent/test-snapshot.py <agent-image>` to exercise packaged code and the actual scanner with no network, a read-only root, UID 10001, dropped capabilities, and a 1 GiB memory cap.
The tests cover SHA-1/SHA-256, local branches, nested repositories, dirty/deleted files, executable modes, relative links, long PAX names, shallow boundaries, source/capacity refusal, and current/deleted/merge/tag secret refusal with ignore bypass attempts.
The shared policy fixture is checked against checkpoint constants and will also bind the Rust extractor.
1 change: 1 addition & 0 deletions voltage/images/buzz-agent/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@
# Build from the repository root so the image packages the checked-out Buzz
# source; Dockerfile.dockerignore next to this file selects the context.
FROM rust:1.95-alpine3.22@sha256:064dfc925d68d1a63f4fd2871bd7dc6e6ea56692989a487185855d62885d90aa AS source
RUN apk add --no-cache bash build-base cmake git musl-dev openssl-dev \

Check warning on line 5 in voltage/images/buzz-agent/Dockerfile

View workflow job for this annotation

GitHub Actions / Lint Dockerfiles

Pin versions in apk add. Instead of `apk add <package>` use `apk add <package>=<version>`
openssl-libs-static perl pkgconf protoc python3 procps
WORKDIR /build
ENV CARGO_INCREMENTAL=0 CARGO_BUILD_JOBS=2
COPY . .
FROM source AS package-checks
RUN apk add --no-cache tini \

Check warning on line 11 in voltage/images/buzz-agent/Dockerfile

View workflow job for this annotation

GitHub Actions / Lint Dockerfiles

Pin versions in apk add. Instead of `apk add <package>` use `apk add <package>=<version>`
&& tini -s -- cargo test --locked -p sprig -p buzz-acp -p buzz-agent -p buzz-dev-mcp -- --test-threads=1

FROM source AS builder
Expand All @@ -19,7 +19,7 @@

FROM node:22-trixie-slim@sha256:154ba2f4d6fec323d28e4f4bb86bba4677f1223391a1979cf521304e03a98dfa
LABEL maintainer="Voltage <hello@voltage.cloud>"
RUN apt-get update \

Check warning on line 22 in voltage/images/buzz-agent/Dockerfile

View workflow job for this annotation

GitHub Actions / Lint Dockerfiles

Pin versions in apt get install. Instead of `apt-get install <package>` use `apt-get install <package>=<version>`
&& apt-get install -y --no-install-recommends bash ca-certificates curl git gh gnupg openssh-client socat tini \
build-essential clang cmake pkg-config libssl-dev protobuf-compiler \
python3 python3-venv python3-pip golang-go postgresql-client sqlite3 \
Expand All @@ -33,7 +33,7 @@
# Release assets are public and pinned by checksum per architecture;
# credentials enter only at runtime.
ARG TARGETARCH
RUN case "$TARGETARCH" in \

Check warning on line 36 in voltage/images/buzz-agent/Dockerfile

View workflow job for this annotation

GitHub Actions / Lint Dockerfiles

Set the SHELL option -o pipefail before RUN with a pipe in it. If you are using /bin/sh in an alpine image or if your shell is symlinked to busybox then consider explicitly setting your SHELL to /bin/ash, or disable this check
amd64) via=x86_64 gitleaks=x64 sccache=x86_64 \
kubectl_sha=874d5e72dbb819f43cff16bcd1e4f8bac5b7f2361fe1e55049b0a6c676fb0cbf \
via_sha=9e5a7f9384cacda29061d814e9397e7b6d90a72ee4663775362978ac9b65d8b7 \
Expand Down Expand Up @@ -83,6 +83,7 @@
COPY --chmod=0755 voltage/images/buzz-agent/profile.py /usr/local/bin/buzz-agent-profile
COPY --chmod=0755 voltage/images/buzz-agent/cache.py /usr/local/bin/buzz-agent-cache
COPY --chmod=0755 voltage/images/buzz-agent/checkpoint.py /usr/local/bin/buzz-agent-checkpoint
COPY --chmod=0755 voltage/images/buzz-agent/snapshot.py /usr/local/bin/buzz-agent-snapshot
COPY --chmod=0755 voltage/images/buzz-agent/review.py /usr/local/bin/buzz-agent-review
COPY --chmod=0755 voltage/images/buzz-agent/heavy.py /usr/local/bin/buzz-heavy
COPY --chmod=0755 voltage/images/buzz-agent/worker.py /usr/local/bin/buzz-agent-worker
Expand Down
1 change: 1 addition & 0 deletions voltage/images/buzz-agent/Dockerfile.dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ voltage/
!voltage/images/buzz-agent/profile.py
!voltage/images/buzz-agent/cache.py
!voltage/images/buzz-agent/checkpoint.py
!voltage/images/buzz-agent/snapshot.py
!voltage/images/buzz-agent/review.py
!voltage/images/buzz-agent/checkpoint-gitleaks.toml
!voltage/images/buzz-agent/gh.sh
Expand Down
Loading
Loading