Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
0bfe9cc
feat(hush): add broker daemon and metadata catalog
AojdevStudio Oct 13, 2026
3c38647
fix(hush): harden broker token, socket, bws, and fallback paths
AojdevStudio Oct 13, 2026
9b09338
fix(hush): read token metadata from the open file so the check builds…
AojdevStudio Oct 13, 2026
e651b46
fix(hush): harden trusted path, dev flag, bws timeout, socket dir
AojdevStudio Oct 13, 2026
c107963
test(hush): pin broker test temp dirs to 0700 so a loose umask does n…
AojdevStudio Oct 13, 2026
c1cc927
fix(hush): let debug same-user daemons trust root-owned ancestors of bws
AojdevStudio Oct 13, 2026
36dfe94
fix(hush): apply review round fixes to the broker core
AojdevStudio Oct 14, 2026
f9b79c5
ci(gitguardian): ignore the ADR CLI example placeholder finding
AojdevStudio Oct 14, 2026
41efb02
fix(hush): bound the socket liveness probe and wake the test listener
AojdevStudio Oct 14, 2026
5dca726
fix(hush): keep saturated macOS listeners alive during the liveness p…
AojdevStudio Oct 14, 2026
a1e8b0b
fix(hush): make the saturation test deterministic and umask-proof
AojdevStudio Oct 14, 2026
beb0504
fix(hush): reach queue saturation deterministically in the probe test
AojdevStudio Oct 14, 2026
e5dd00e
docs(gitguardian): use the documented ggshield config filename
AojdevStudio Oct 14, 2026
ba3057d
chore(hush): trigger a fresh GitGuardian scan after the incident disp…
AojdevStudio Oct 14, 2026
1559939
fix(hush): a failed poll in the liveness probe leaves the socket in p…
AojdevStudio Oct 14, 2026
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
2 changes: 2 additions & 0 deletions bws-tui/Cargo.lock

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

4 changes: 4 additions & 0 deletions bws-tui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,7 @@ humantime = "2"
clap = { version = "4", features = ["derive"] }
arboard = "3"
base64 = "0.22"
libc = "0.2"

[dev-dependencies]
static_assertions = "1"
4 changes: 4 additions & 0 deletions bws-tui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,10 @@ That narrow boundary is the point. `hush` is an interface, not another secrets p
- **Scoped reads:** `get` and `exec` fetch single secrets by id; only `list`/`sync` refreshes see the full metadata set, and nothing persists values.
- **Names-only audit log:** every operation is recorded with timestamp, verb, and key names — never values.

### Broker daemon (in progress)

A hidden `hush daemon` runs the broker from [ADR 0002](../docs/adr/0002-hush-broker.md). When it is reachable on the socket, `hush list` and `hush list --json` answer from the daemon metadata catalog. When no daemon is reachable, they keep today's direct `bws` behavior. The daemon refuses a token file that is not mode 0600 owned by the daemon user.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

### Known upstream limit: argv exposure

`bws` 2.1.0 accepts create/edit values only as positional argv. That makes a value briefly visible to local process inspection (`ps`) while the subprocess runs. `hush` cannot remove that exposure until upstream supports stdin or file descriptors.
Expand Down
69 changes: 69 additions & 0 deletions bws-tui/src/broker/audit.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
//! Daemon-owned audit log. Names only, never values or tokens.

use super::peercred::PeerCredentials;
use anyhow::{Context, Result};
use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::SystemTime;

/// The daemon records every catalog request with kernel peer identity.
pub(crate) trait AuditLog: Send + Sync {
fn record_catalog(&self, peer: PeerCredentials, project: Option<&str>) -> Result<()>;
}

/// Append-only file log. The daemon owns this file, not the client audit log.
pub(crate) struct FileAuditLog {
file: Mutex<std::fs::File>,
}

impl FileAuditLog {
pub(crate) fn open(path: &Path) -> Result<Self> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let file = OpenOptions::new()
.create(true)
.append(true)
.open(path)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
.with_context(|| format!("failed to open daemon audit log at {}", path.display()))?;
Ok(Self {
file: Mutex::new(file),
})
}
}

impl AuditLog for FileAuditLog {
fn record_catalog(&self, peer: PeerCredentials, project: Option<&str>) -> Result<()> {
let line = format!(
"{} catalog uid={} gid={} pid={} project={}\n",
humantime::format_rfc3339_seconds(SystemTime::now()),
peer.uid,
peer.gid,
peer.pid,
project.unwrap_or("-"),
Comment thread
AojdevStudio marked this conversation as resolved.
Outdated
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
let mut file = self
.file
.lock()
.map_err(|_| anyhow::anyhow!("audit log lock poisoned"))?;
file.write_all(line.as_bytes())?;
Ok(())
}
}

/// Default daemon audit path. The `daemon` subcommand can override it.
pub(crate) fn default_audit_path() -> PathBuf {
if let Some(path) = std::env::var_os("HUSH_AUDIT_LOG") {
return PathBuf::from(path);
}
#[cfg(target_os = "macos")]
{
PathBuf::from("/var/db/hush/audit.log")
}
#[cfg(not(target_os = "macos"))]
{
PathBuf::from("/var/log/hush/audit.log")
}
}
269 changes: 269 additions & 0 deletions bws-tui/src/broker/backend.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
//! Vault access. The `bws` wire structs stay private to this module.

use super::material::SecretMaterial;
use super::meta::{SecretMeta, SecretRef};
use anyhow::{anyhow, Context, Result};
use secrecy::{ExposeSecret, SecretString};
use serde::Deserialize;
#[cfg(test)]
use std::collections::HashMap;
use std::io::Read;
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};

/// Hard cap on bws stdout. `bws secret list` returns every value at once.
pub(crate) const MAX_BWS_OUTPUT: usize = 16 * 1024 * 1024;

/// Hard deadline for one bws call.
pub(crate) const BWS_TIMEOUT: Duration = Duration::from_secs(60);

/// The daemon's only path to stored values. `catalog` never returns values.
#[allow(dead_code)]
pub(crate) trait VaultBackend: Send + Sync {
fn catalog(&self) -> Result<Vec<SecretMeta>>;
fn fetch(&self, secret: &SecretRef) -> Result<SecretMaterial>;
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct BwsProject {
id: String,
name: String,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct BwsSecret {
id: String,
key: String,
value: String,
#[serde(default)]
note: String,
project_id: Option<String>,
}

fn drop_value(secret: BwsSecret, projects: &[BwsProject]) -> SecretMeta {
let BwsSecret {
id,
key,
note,
project_id,
value,
} = secret;
drop(value);
SecretMeta {
id,
key,
project: project_id.as_ref().and_then(|wanted| {
projects
.iter()
.find(|p| &p.id == wanted)
.map(|p| p.name.clone())
}),
project_id,
note,
}
}

/// Real backend. Shells out to `bws` by absolute path with a cleared environment.
pub(crate) struct BwsBackend {
bws_path: PathBuf,
token: SecretString,
}

impl BwsBackend {
pub(crate) fn new(bws_path: PathBuf, token: SecretString) -> Self {
Self { bws_path, token }
}

fn run_bws(&self, args: &[&str]) -> Result<String> {
self.run_bws_capped(args, MAX_BWS_OUTPUT)
}

/// Run bws with a byte cap on stdout. Stderr is discarded.
pub(crate) fn run_bws_capped(&self, args: &[&str], cap: usize) -> Result<String> {
self.run_bws_with_deadline(args, cap, BWS_TIMEOUT)
}

/// Run bws with a byte cap on stdout and a hard deadline. Stderr is discarded.
pub(crate) fn run_bws_with_deadline(
&self,
args: &[&str],
cap: usize,
timeout: Duration,
) -> Result<String> {
let deadline = Instant::now()
.checked_add(timeout)
.context("failed to run bws")?;
let child = Command::new(&self.bws_path)
.env_clear()
.env("PATH", "/usr/local/bin:/usr/bin:/bin")
.env("BWS_ACCESS_TOKEN", self.token.expose_secret())
.args(args)
.args(["-o", "json"])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.context("failed to run bws")?;
let mut guard = ChildGuard::new(child);
let stdout = guard.take_stdout().context("failed to run bws")?;
let (mut bytes, overflow) = read_capped_until(stdout, cap, deadline)?;
if overflow {
return Err(anyhow!("bws output exceeded the size limit"));
}
let status = guard.wait_until(deadline)?;
if !status.success() {
let name = args.iter().take(2).copied().collect::<Vec<_>>().join(" ");
return Err(anyhow!("bws {name} failed ({status})"));
}
Ok(String::from_utf8(std::mem::take(&mut bytes))?)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
}

/// Read at most `cap` bytes. The flag is true when more data remains.
fn read_capped(reader: &mut impl Read, cap: usize) -> Result<(Vec<u8>, bool)> {
let mut out = Vec::new();
let mut chunk = [0u8; 8192];
loop {
let read = reader.read(&mut chunk)?;
if read == 0 {
return Ok((out, false));
}
if out.len() + read > cap {
let keep = cap.saturating_sub(out.len());
out.extend_from_slice(&chunk[..keep]);
return Ok((out, true));
}
out.extend_from_slice(&chunk[..read]);
}
}

/// Read at most `cap` bytes before the deadline. Stdout waits on a helper thread.
fn read_capped_until(
mut stdout: std::process::ChildStdout,
cap: usize,
deadline: Instant,
) -> Result<(Vec<u8>, bool)> {
let (sender, receiver) = std::sync::mpsc::channel();
let _reader = std::thread::spawn(move || {
let _ = sender.send(read_capped(&mut stdout, cap));
});
let remaining = deadline.saturating_duration_since(Instant::now());
match receiver.recv_timeout(remaining) {
Ok(result) => result.context("failed to run bws"),
Err(_) => Err(anyhow!("bws timed out")),
}
}

/// Kills and reaps the child on drop unless it already exited through `wait_until`.
struct ChildGuard {
child: Option<Child>,
}

impl ChildGuard {
/// Wrap a spawned child.
fn new(child: Child) -> Self {
Self { child: Some(child) }
}

/// Take the piped stdout handle.
fn take_stdout(&mut self) -> Option<std::process::ChildStdout> {
self.child.as_mut()?.stdout.take()
}

/// Wait for exit before the deadline.
fn wait_until(&mut self, deadline: Instant) -> Result<std::process::ExitStatus> {
loop {
let child = match self.child.as_mut() {
Some(child) => child,
None => return Err(anyhow!("failed to run bws")),
};
if let Some(status) = child.try_wait().context("failed to run bws")? {
self.child = None;
return Ok(status);
}
if Instant::now() >= deadline {
return Err(anyhow!("bws timed out"));
}
std::thread::sleep(
Duration::from_millis(20).min(deadline.saturating_duration_since(Instant::now())),
);
}
}
}

impl Drop for ChildGuard {
fn drop(&mut self) {
if let Some(mut child) = self.child.take() {
let _ = child.kill();
let _ = child.wait();
}
}
}

impl VaultBackend for BwsBackend {
fn catalog(&self) -> Result<Vec<SecretMeta>> {
let projects: Vec<BwsProject> = serde_json::from_str(&self.run_bws(&["project", "list"])?)?;
let secrets: Vec<BwsSecret> = serde_json::from_str(&self.run_bws(&["secret", "list"])?)?;
Ok(secrets
.into_iter()
.map(|secret| drop_value(secret, &projects))
.collect())
}

fn fetch(&self, secret: &SecretRef) -> Result<SecretMaterial> {
let SecretRef::Id(id) = secret;
let wire: BwsSecret = serde_json::from_str(&self.run_bws(&["secret", "get", id])?)?;
Ok(SecretMaterial::new(wire.value.into_bytes()))
}
}

/// In-memory backend for tests. Values live only in this struct and leave
/// exclusively through [`VaultBackend::fetch`].
#[cfg(test)]
pub(crate) struct FakeBackend {
secrets: HashMap<String, (SecretMeta, String)>,
}

#[cfg(test)]
impl FakeBackend {
pub(crate) fn new(secrets: HashMap<String, (SecretMeta, String)>) -> Self {
Self { secrets }
}
}

/// Catalog always fails. Tests use it to prove connected errors propagate.
#[cfg(test)]
pub(crate) struct FailingBackend;

#[cfg(test)]
impl VaultBackend for FailingBackend {
fn catalog(&self) -> Result<Vec<SecretMeta>> {
Err(anyhow!("backend exploded"))
}

fn fetch(&self, _secret: &SecretRef) -> Result<SecretMaterial> {
Err(anyhow!("backend exploded"))
}
}

#[cfg(test)]
impl VaultBackend for FakeBackend {
fn catalog(&self) -> Result<Vec<SecretMeta>> {
let mut metas: Vec<SecretMeta> = self
.secrets
.values()
.map(|(meta, _)| meta.clone())
.collect();
metas.sort_by(|left, right| left.key.cmp(&right.key));
Ok(metas)
}

fn fetch(&self, secret: &SecretRef) -> Result<SecretMaterial> {
let SecretRef::Id(id) = secret;
let (_, value) = self.secrets.get(id).context("unknown secret id")?;
Ok(SecretMaterial::new(value.as_bytes().to_vec()))
}
}
Loading
Loading