From 80fef6f9af812fa1ec017c7f301396f762b37031 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Thu, 8 Oct 2026 17:05:05 -0400 Subject: [PATCH 01/18] feat(host): list every expected artifact as not installed when no placement is known The host doctor module takes an observation, judges it into findings and reports lines and a code, so the doctor can print a host section before any placement is read. --- crates/baley/src/host_doctor/mod.rs | 108 ++++++++++++++++++++ crates/baley/src/host_doctor/report.rs | 136 +++++++++++++++++++++++++ crates/baley/src/lib.rs | 2 + 3 files changed, 246 insertions(+) create mode 100644 crates/baley/src/host_doctor/mod.rs create mode 100644 crates/baley/src/host_doctor/report.rs diff --git a/crates/baley/src/host_doctor/mod.rs b/crates/baley/src/host_doctor/mod.rs new file mode 100644 index 00000000..68693324 --- /dev/null +++ b/crates/baley/src/host_doctor/mod.rs @@ -0,0 +1,108 @@ +//! The runtime doctor's host checks: what Claude Code's artifacts look like +//! on this machine, gathered as observations, judged as plain values and +//! reported as owner lines with an exit code. +//! +//! The work is three steps, each public and tested apart: +//! - an [`Observation`], what was gathered; +//! - [`judge`], which turns an observation into [`Findings`]; +//! - [`Report::new`], which turns findings into lines and a code. +//! +//! The held delivery doctor (Build 3 T17) takes the same three steps over a +//! placement map `baley install` supplies. Nothing here writes a file, the +//! ledger or Claude Code's settings, and nothing guesses where an artifact +//! belongs: an artifact whose placement is unknown is reported as not +//! installed, and that alone never raises the exit status. + +pub mod report; + +use std::path::PathBuf; + +pub use report::Report; + +use crate::host_artifacts::executable::{Executable, MissingPrerequisite}; +use crate::host_artifacts::placement::{Artifact, Placement, PlacementMap}; +use crate::host_artifacts::stubs; + +/// Why no placement map could be built for the running binary. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MapFault { + /// The running binary's path could not be read; the system's cause. + PathUnreadable(String), + /// The running binary's path is not usable as the executable the hook + /// and the registration run. + Executable { + /// The path as read. + path: PathBuf, + /// What is wrong with it. + refusal: MissingPrerequisite, + }, + /// The map was refused when it was built; the reason. + Refused(String), +} + +/// The placement map the command line uses: every stub of the compiled +/// manifest, the registration, the hook and the settings all with an +/// unknown place, and the running binary as the executable. `running` is +/// what `std::env::current_exe()` returned. +pub fn all_unknown(running: std::io::Result) -> Result { + let path = running.map_err(|error| MapFault::PathUnreadable(error.to_string()))?; + let executable = Executable::new(&path).map_err(|refusal| MapFault::Executable { + path: path.clone(), + refusal, + })?; + let manifest = stubs::manifest(&stubs::front_doors()) + .map_err(|duplicate| MapFault::Refused(duplicate.to_string()))?; + let stubs = manifest + .into_iter() + .map(|entry| (entry, Placement::Unknown)) + .collect(); + PlacementMap::new( + executable, + stubs, + Placement::Unknown, + Placement::Unknown, + Placement::Unknown, + ) + .map_err(|refusal| MapFault::Refused(refusal.to_string())) +} + +/// What was gathered about the host. +#[derive(Debug, Clone)] +pub struct Observation { + /// The placement map, or why none could be built. + pub placement: Result, +} + +/// What the judgement found out about one artifact. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ArtifactState { + /// No place is known for it, so nothing was looked for. + NotInstalled, +} + +/// The judged observation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Findings { + /// Why no map exists, when none could be built. + pub no_map: Option, + /// Each artifact of the map and what was found, in artifact order. + pub artifacts: Vec<(Artifact, ArtifactState)>, +} + +/// Judges an observation into findings. +pub fn judge(observation: &Observation) -> Findings { + match &observation.placement { + Err(fault) => Findings { + no_map: Some(fault.clone()), + artifacts: Vec::new(), + }, + Ok(map) => Findings { + no_map: None, + artifacts: map + .not_installed() + .into_iter() + .map(|artifact| (artifact, ArtifactState::NotInstalled)) + .collect(), + }, + } +} diff --git a/crates/baley/src/host_doctor/report.rs b/crates/baley/src/host_doctor/report.rs new file mode 100644 index 00000000..0a24cd8e --- /dev/null +++ b/crates/baley/src/host_doctor/report.rs @@ -0,0 +1,136 @@ +//! The report: findings as owner lines and an exit code. + +use super::{ArtifactState, Findings, MapFault}; + +/// The heading that opens the host section. +const HEADING: &str = "Claude Code host checks"; + +/// The host section: its lines, in print order, and the exit status they +/// call for. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Report { + /// The lines to print. + pub lines: Vec, + /// 0, or 1 when a host gap was found. A host finding never gives 2 or 3. + pub code: u8, +} + +impl Report { + /// Words the findings as lines and sets the code. + pub fn new(findings: &Findings) -> Self { + let mut lines = vec![HEADING.to_owned()]; + let mut gap = false; + if let Some(fault) = &findings.no_map { + gap = true; + lines.push(match fault { + MapFault::PathUnreadable(cause) => format!( + "running binary: its path could not be read: {cause}, so no hook or registration can be rendered for it" + ), + MapFault::Executable { path, refusal } => format!( + "running binary {}: {refusal}, so no hook or registration can be rendered for it", + path.display() + ), + MapFault::Refused(reason) => format!( + "running binary: the placement map was refused: {reason}" + ), + }); + } + for (artifact, state) in &findings.artifacts { + match state { + ArtifactState::NotInstalled => { + lines.push(format!("{artifact}: not installed, no placement is known")); + } + } + } + Report { + lines, + code: u8::from(gap), + } + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::super::{MapFault, Observation, all_unknown, judge}; + use super::*; + use crate::host_artifacts::executable::{MissingPrerequisite, PathFault}; + + fn unknown_observation() -> Observation { + Observation { + placement: all_unknown(Ok(PathBuf::from("/usr/local/bin/baley"))), + } + } + + fn report_of(observation: &Observation) -> Report { + Report::new(&judge(observation)) + } + + /// Whether a line says the artifact is installed, once the words + /// "not installed" are set aside. + fn says_installed(line: &str) -> bool { + let rest = line.replace("not installed", ""); + ["installed", "present", "matches"] + .iter() + .any(|word| rest.contains(word)) + } + + #[test] + fn an_unknown_placement_shown_as_installed_or_left_unlisted_is_caught() { + let report = report_of(&unknown_observation()); + let names = [ + "stub `bal-capture`", + "stub `bal-help`", + "registration", + "hook", + "settings", + ]; + for name in names { + let lines: Vec<&String> = report + .lines + .iter() + .filter(|line| line.starts_with(name)) + .collect(); + assert_eq!(lines.len(), 1, "{name}: {:?}", report.lines); + assert!(lines[0].contains("not installed"), "{}", lines[0]); + assert!(!says_installed(lines[0]), "{}", lines[0]); + } + assert_eq!(report.code, 0); + } + + #[test] + fn an_unusable_binary_path_dropped_from_the_report_is_caught() { + let relative = Observation { + placement: Err(MapFault::Executable { + path: PathBuf::from("baley"), + refusal: MissingPrerequisite { + fault: PathFault::Relative, + }, + }), + }; + let report = report_of(&relative); + let named: Vec<&String> = report + .lines + .iter() + .filter(|line| line.contains("baley") && line.contains("not absolute")) + .collect(); + assert_eq!(named.len(), 1, "{:?}", report.lines); + assert_eq!(report.code, 1); + assert!(!report.lines.iter().any(|line| says_installed(line))); + + let cause = "No such file or directory (os error 2)"; + let unreadable = Observation { + placement: Err(MapFault::PathUnreadable(cause.into())), + }; + let report = report_of(&unreadable); + let named: Vec<&String> = report + .lines + .iter() + .filter(|line| line.contains("could not be read") && line.contains(cause)) + .collect(); + assert_eq!(named.len(), 1, "{:?}", report.lines); + assert_eq!(report.code, 1); + assert!(!report.lines.iter().any(|line| says_installed(line))); + } +} diff --git a/crates/baley/src/lib.rs b/crates/baley/src/lib.rs index 3b3a9879..b55d6844 100644 --- a/crates/baley/src/lib.rs +++ b/crates/baley/src/lib.rs @@ -65,6 +65,8 @@ pub mod guard_hook; pub mod hook_input; /// The content Claude Code loads for Baley, rendered from compiled tables and supplied values, with no file read or written. pub mod host_artifacts; +/// The runtime doctor's host checks: gathered observations of Claude Code's artifacts, judged as plain values and reported as owner lines with an exit code. +pub mod host_doctor; /// `baley init`: ties a repository to a ledger project. pub mod init; /// The compiled instruction registry: every instruction served to a model, by identity, with no disk loader and no override. From 8329e12f1d542e8ef5798c0b022cd31fe747c695 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Thu, 8 Oct 2026 17:10:06 -0400 Subject: [PATCH 02/18] feat(ledger): print the host section after every line the doctor already printed The doctor builds an all-unknown placement map for the running binary and appends the host report, taking the larger of the two exit codes so a host gap never turns a damaged store into a 2. --- crates/baley/src/ledger/commands.rs | 18 ++++-- crates/baley/src/ledger/display.rs | 8 +++ crates/baley/src/ledger/tests.rs | 95 +++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 4 deletions(-) diff --git a/crates/baley/src/ledger/commands.rs b/crates/baley/src/ledger/commands.rs index cc0d4456..58a848eb 100644 --- a/crates/baley/src/ledger/commands.rs +++ b/crates/baley/src/ledger/commands.rs @@ -12,7 +12,7 @@ use super::{ }; use crate::checkout::{Site, gather_and_admit}; use crate::discovery::{self, Discovery, PROJECT_FILE}; -use crate::{init, policy_step, settings}; +use crate::{host_doctor, init, policy_step, settings}; use baley_core::policy::recorded::{PurgePolicy, RecordedPolicy, purge_policy, recorded_policy}; use baley_core::policy::{CONFIG_UNAVAILABLE, EffectivePolicy, ProjectIdentity, Unavailable}; use baley_core::*; @@ -140,10 +140,20 @@ fn doctor( }; checks.insert(project, check); } - store + let health = store .doctor(&SystemClock::now(), &checks) - .map(|h| display::doctor(&h, &projects, &reasons, &judged)) - .map_err(|e| display::store_error(&e, None)) + .map_err(|e| display::store_error(&e, None))?; + let rendered = display::doctor(&health, &projects, &reasons, &judged); + Ok(display::with_host(rendered, &host_report())) +} + +/// The host section: the running binary against a placement map with every +/// artifact unknown, so each one is reported as not installed. +fn host_report() -> host_doctor::Report { + let observation = host_doctor::Observation { + placement: host_doctor::all_unknown(std::env::current_exe()), + }; + host_doctor::Report::new(&host_doctor::judge(&observation)) } fn export(store: &SqliteStore, project: &ProjectId, to: &Path) -> Result { store diff --git a/crates/baley/src/ledger/display.rs b/crates/baley/src/ledger/display.rs index 4439b7ea..5c83b56b 100644 --- a/crates/baley/src/ledger/display.rs +++ b/crates/baley/src/ledger/display.rs @@ -480,6 +480,14 @@ pub(super) fn doctor( } result } +/// Appends the host section's lines after every line `render` holds and +/// raises the exit status to the host report's when that is higher. The two +/// codes are never added, and the host lines print whatever either code is. +pub(super) fn with_host(mut render: Render, host: &crate::host_doctor::Report) -> Render { + render.lines.extend(host.lines.iter().cloned()); + render.code = render.code.max(host.code); + render +} /// Reports body removals, shared references and recovery. pub(super) fn purge(report: &PurgeReport) -> Render { let mut result = Render { diff --git a/crates/baley/src/ledger/tests.rs b/crates/baley/src/ledger/tests.rs index 6e3746aa..2841dd5b 100644 --- a/crates/baley/src/ledger/tests.rs +++ b/crates/baley/src/ledger/tests.rs @@ -512,6 +512,101 @@ fn doctor_prints_the_purge_warning_once_for_the_project_that_lists_a_restore() { assert_eq!(r.code, 0); assert_eq!(warnings(&r), 1); } +fn host_report(lines: &[&str], code: u8) -> crate::host_doctor::Report { + crate::host_doctor::Report { + lines: lines.iter().map(|line| (*line).to_owned()).collect(), + code, + } +} +#[test] +fn doctor_lines_reordered_or_reworded_by_the_host_section_are_caught() { + let mut h = health(); + h.scrub_pending = Some("T0".into()); + h.integrity = vec!["damaged page".into()]; + let q = { + let mut other = h.projects[0].clone(); + other.project = ProjectId("Q".into()); + other + }; + h.projects[0].verify = Ok(with_restores(&[1])); + h.projects.push(q); + let mut faults = no_faults(); + faults.faults = vec![ + "config-unavailable: cannot read /c/config.toml: Permission denied (os error 13)".into(), + ]; + let head = format!("chain head 50 {}", "05".repeat(32)); + let chain_tail = [ + "bodies checked 4, tombstones 0", + "local anchor row: not compared with a remote anchor", + "unanchored age: none", + "view set 2, binary 2", + "views checked at sequence 50", + "no differences", + "claims: 0 active, 0 interrupted, 0 awaiting owner", + ]; + let mut expected: Vec = [ + "epoch 1", + "scrub pending since T0", + "integrity: damaged page", + "database 8192 bytes, log 0 bytes", + "settings finding: config-unavailable: cannot read /c/config.toml: Permission denied (os error 13)", + "project P (One): local only", + ] + .map(String::from) + .into(); + expected.push(head.clone()); + expected.push("matches the remote anchor".into()); + expected.push(format!( + "acknowledged restore at 1: empty behind 60 {}", + "06".repeat(32) + )); + expected.extend(display::PURGE_WARNING.map(String::from)); + expected.extend(chain_tail.map(String::from)); + expected.push("project Q (Two): local only".into()); + expected.push(head); + expected.push("matches the remote anchor".into()); + expected.extend(chain_tail.map(String::from)); + + let host = crate::host_doctor::Report::new(&crate::host_doctor::judge( + &crate::host_doctor::Observation { + placement: crate::host_doctor::all_unknown(Ok("/usr/local/bin/baley".into())), + }, + )); + assert!(!host.lines.is_empty()); + let r = display::with_host( + display::doctor(&h, &projects(), &no_reasons(), &faults), + &host, + ); + assert_eq!(r.lines[..expected.len()], expected[..]); + assert_eq!(r.lines[expected.len()..], host.lines[..]); + assert_eq!(warnings(&r), 1); + assert_eq!(r.code, 1); +} +#[test] +fn a_clean_host_section_lowering_the_store_code_is_caught() { + let mut h = health(); + h.integrity = vec!["damaged page".into()]; + for host_code in [0, 1] { + let store = display::doctor(&h, &projects(), &no_reasons(), &no_faults()); + assert_eq!(store.code, 1); + let r = display::with_host(store, &host_report(&["host line"], host_code)); + assert_eq!(r.code, 1, "host code {host_code}"); + let lines = &r.lines; + let damaged = lines.iter().position(|l| l == "integrity: damaged page"); + let host = lines.iter().position(|l| l == "host line"); + assert!(damaged.unwrap() < host.unwrap(), "{lines:?}"); + } +} +#[test] +fn a_host_gap_left_out_of_a_clean_store_code_is_caught() { + let store = display::doctor(&health(), &projects(), &no_reasons(), &no_faults()); + assert_eq!(store.code, 0); + let store_lines = store.lines.clone(); + let r = display::with_host(store, &host_report(&["host gap"], 1)); + assert_eq!(r.code, 1); + assert_eq!(r.lines[..store_lines.len()], store_lines[..]); + assert_eq!(r.lines[store_lines.len()..], ["host gap".to_owned()]); +} #[test] fn a_failed_export_prints_the_purge_warning_and_exits_one() { let r = display::store_error( From a7a700dd2c0b9909af3609f3014ac02afa4e3ab5 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Thu, 8 Oct 2026 17:16:33 -0400 Subject: [PATCH 03/18] feat(host): read the placed files and the executable and name what is wrong with each A placed file that is missing, not a regular file, unreadable or not a JSON object, and an executable that is missing or not a regular file, are host gaps. An unreadable document is never judged as an empty one, which would invent coverage gaps. --- crates/baley/src/host_doctor/mod.rs | 293 +++++++++++++++++++++++-- crates/baley/src/host_doctor/placed.rs | 218 ++++++++++++++++++ crates/baley/src/host_doctor/report.rs | 98 +++++++-- crates/baley/src/ledger/commands.rs | 4 +- crates/baley/src/ledger/tests.rs | 5 +- 5 files changed, 576 insertions(+), 42 deletions(-) create mode 100644 crates/baley/src/host_doctor/placed.rs diff --git a/crates/baley/src/host_doctor/mod.rs b/crates/baley/src/host_doctor/mod.rs index 68693324..afa4cd4c 100644 --- a/crates/baley/src/host_doctor/mod.rs +++ b/crates/baley/src/host_doctor/mod.rs @@ -7,18 +7,25 @@ //! - [`judge`], which turns an observation into [`Findings`]; //! - [`Report::new`], which turns findings into lines and a code. //! +//! [`gather`] is the one place that touches the filesystem: it reads each +//! placed file once and asks about the executable, and owns no rule. The +//! readers and their judgements are in [`placed`]. +//! //! The held delivery doctor (Build 3 T17) takes the same three steps over a //! placement map `baley install` supplies. Nothing here writes a file, the //! ledger or Claude Code's settings, and nothing guesses where an artifact //! belongs: an artifact whose placement is unknown is reported as not //! installed, and that alone never raises the exit status. +pub mod placed; pub mod report; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; pub use report::Report; +use placed::{ExecutableGap, FileState}; + use crate::host_artifacts::executable::{Executable, MissingPrerequisite}; use crate::host_artifacts::placement::{Artifact, Placement, PlacementMap}; use crate::host_artifacts::stubs; @@ -66,11 +73,56 @@ pub fn all_unknown(running: std::io::Result) -> Result, + /// Every distinct path of the map's expected files, read once. + pub reads: Vec, +} + /// What was gathered about the host. #[derive(Debug, Clone)] pub struct Observation { - /// The placement map, or why none could be built. - pub placement: Result, + /// What was found for the placement map, or why none could be built. + pub host: Result, +} + +/// Gathers the observation for a placement map: reads each expected file +/// once and asks the filesystem about the executable. Nothing is written, +/// opened for writing or run. It owns no rule and has no unit test. +pub fn gather(placement: Result) -> Observation { + let host = placement.map(|map| { + let executable = placed::observe_executable(Path::new(map.executable().as_str())); + let mut reads: Vec = Vec::new(); + for file in map.expected_files() { + if !reads.iter().any(|read| read.path == file.path) { + reads.push(PlacedRead { + path: file.path.to_path_buf(), + state: placed::read(file.path), + }); + } + } + Mapped { + map, + executable, + reads, + } + }); + Observation { host } } /// What the judgement found out about one artifact. @@ -78,31 +130,238 @@ pub struct Observation { pub enum ArtifactState { /// No place is known for it, so nothing was looked for. NotInstalled, + /// A place is known but the observation holds no read of it. + NotRead { + /// The place. + path: PathBuf, + }, + /// A place is known and nothing is there. + Missing { + /// The place. + path: PathBuf, + }, + /// A place is known and what is there cannot be used. + Fault { + /// The place. + path: PathBuf, + /// What is wrong with it. + fault: placed::Fault, + }, + /// A place is known and what is there was read. + Read { + /// The place. + path: PathBuf, + }, +} + +impl ArtifactState { + /// Whether the state is a host gap, which raises the exit status. An + /// artifact that is not installed is not one. + pub fn is_gap(&self) -> bool { + !matches!( + self, + ArtifactState::NotInstalled | ArtifactState::Read { .. } + ) + } +} + +/// What a settings, hook or registration file held. +#[derive(Debug, Clone, PartialEq)] +pub enum Content { + /// The observation holds no read of it. + NotRead, + /// Nothing is there. + Missing, + /// It could not be used. + Fault(placed::Fault), + /// A JSON object. + Object(serde_json::Value), +} + +/// One registration, hook or settings file and what it held, carried for the +/// judgements that read documents. +#[derive(Debug, Clone, PartialEq)] +pub struct Document { + /// The place. + pub path: PathBuf, + /// The artifacts placed in it, in artifact order. + pub artifacts: Vec, + /// What it held. + pub content: Content, } /// The judged observation. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub struct Findings { /// Why no map exists, when none could be built. pub no_map: Option, /// Each artifact of the map and what was found, in artifact order. pub artifacts: Vec<(Artifact, ArtifactState)>, + /// What is wrong with the executable, with its path. + pub executable: Option<(String, ExecutableGap)>, + /// Each document the map places artifacts in. + pub documents: Vec, +} + +/// Where an artifact sits in the order the report lists them: the stubs, +/// then the registration, the hook and the settings. +fn rank(artifact: &Artifact) -> u8 { + match artifact { + Artifact::Stub(_) => 0, + Artifact::Registration => 1, + Artifact::Hook => 2, + Artifact::Settings => 3, + } } /// Judges an observation into findings. pub fn judge(observation: &Observation) -> Findings { - match &observation.placement { - Err(fault) => Findings { - no_map: Some(fault.clone()), - artifacts: Vec::new(), - }, - Ok(map) => Findings { - no_map: None, - artifacts: map - .not_installed() - .into_iter() - .map(|artifact| (artifact, ArtifactState::NotInstalled)) - .collect(), - }, + let mapped = match &observation.host { + Ok(mapped) => mapped, + Err(fault) => { + return Findings { + no_map: Some(fault.clone()), + artifacts: Vec::new(), + executable: None, + documents: Vec::new(), + }; + } + }; + let read = |path: &Path| { + mapped + .reads + .iter() + .find(|read| read.path == path) + .map(|read| &read.state) + }; + + let mut documents = Vec::new(); + for document in mapped.map.documents() { + let content = match read(document.path) { + None => Content::NotRead, + Some(FileState::Absent) => Content::Missing, + Some(FileState::Fault(fault)) => Content::Fault(fault.clone()), + Some(FileState::Bytes(bytes)) => match placed::document(bytes) { + Ok(value) => Content::Object(value), + Err(fault) => Content::Fault(fault), + }, + }; + documents.push(Document { + path: document.path.to_path_buf(), + artifacts: document.artifacts, + content, + }); + } + + let mut artifacts: Vec<(Artifact, ArtifactState)> = Vec::new(); + for file in mapped.map.expected_files() { + let path = file.path.to_path_buf(); + let state = match documents + .iter() + .find(|document| document.artifacts.contains(&file.artifact)) + { + Some(document) => match &document.content { + Content::NotRead => ArtifactState::NotRead { path }, + Content::Missing => ArtifactState::Missing { path }, + Content::Fault(fault) => ArtifactState::Fault { + path, + fault: fault.clone(), + }, + Content::Object(_) => ArtifactState::Read { path }, + }, + None => match read(file.path) { + None => ArtifactState::NotRead { path }, + Some(FileState::Absent) => ArtifactState::Missing { path }, + Some(FileState::Fault(fault)) => ArtifactState::Fault { + path, + fault: fault.clone(), + }, + Some(FileState::Bytes(_)) => ArtifactState::Read { path }, + }, + }; + artifacts.push((file.artifact, state)); + } + artifacts.extend( + mapped + .map + .not_installed() + .into_iter() + .map(|artifact| (artifact, ArtifactState::NotInstalled)), + ); + artifacts.sort_by_key(|(artifact, _)| rank(artifact)); + + Findings { + no_map: None, + artifacts, + executable: mapped + .executable + .clone() + .map(|gap| (mapped.map.executable().as_str().to_owned(), gap)), + documents, + } +} + +/// Plain-value builders the tests of this module's files share. +#[cfg(test)] +pub(crate) mod fixtures { + use super::*; + + pub(crate) const EXECUTABLE: &str = "/usr/local/bin/baley"; + pub(crate) const HELP: &str = "/home/o/.claude/skills/bal-help/SKILL.md"; + pub(crate) const REGISTRATION: &str = "/home/o/.claude.json"; + pub(crate) const SETTINGS: &str = "/home/o/.claude/settings.json"; + + pub(crate) fn executable() -> Executable { + Executable::new(EXECUTABLE).unwrap() + } + + /// A map with the given places and every other placement unknown. + pub(crate) fn map( + capture: Option<&str>, + help: Option<&str>, + registration: Option<&str>, + hook: Option<&str>, + settings: Option<&str>, + ) -> PlacementMap { + let place = + |path: Option<&str>| path.map_or(Placement::Unknown, |p| Placement::At(p.into())); + let stubs = stubs::manifest(&stubs::front_doors()) + .unwrap() + .into_iter() + .map(|entry| { + let placement = match entry.identity.as_str() { + "bal-capture" => place(capture), + "bal-help" => place(help), + other => panic!("an unexpected front door {other}"), + }; + (entry, placement) + }) + .collect(); + PlacementMap::new( + executable(), + stubs, + place(registration), + place(hook), + place(settings), + ) + .unwrap() + } + + /// The observation of a map whose executable is a regular file and + /// whose files were read as given. + pub(crate) fn observed(map: PlacementMap, reads: Vec<(&str, FileState)>) -> Observation { + Observation { + host: Ok(Mapped { + map, + executable: None, + reads: reads + .into_iter() + .map(|(path, state)| PlacedRead { + path: path.into(), + state, + }) + .collect(), + }), + } } } diff --git a/crates/baley/src/host_doctor/placed.rs b/crates/baley/src/host_doctor/placed.rs new file mode 100644 index 00000000..06dd4041 --- /dev/null +++ b/crates/baley/src/host_doctor/placed.rs @@ -0,0 +1,218 @@ +//! Placed files and the executable, read and judged. +//! +//! Gathering and judging are apart, as `settings.rs` has them. Gathering +//! reads a path once through `settings::gather`, or asks the filesystem +//! about the executable, and owns no rule. Judging turns what was seen into +//! plain values: a file that is absent, faulty or holds bytes, a document +//! that is a JSON object or a fault, an executable that is present or has a +//! gap. A file that is not readable is never taken for an empty one, because +//! the coverage judge would turn an empty document into invented gaps. + +use std::fmt; +use std::path::Path; + +use serde_json::Value; + +use crate::settings::{self, Seen}; + +/// Why a placed file cannot be used as found. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Fault { + /// A link whose target does not exist. + DanglingLink, + /// Something other than a regular file is there. + NotRegular, + /// The file is there but could not be read; the system's cause. + Unreadable(String), + /// The bytes are not JSON; the parser's message. + NotJson(String), + /// The bytes are JSON but not an object. + NotObject, +} + +impl fmt::Display for Fault { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Fault::DanglingLink => f.write_str("is a link to a missing file"), + Fault::NotRegular => f.write_str("is not a regular file"), + Fault::Unreadable(cause) => write!(f, "cannot be read: {cause}"), + Fault::NotJson(message) => write!(f, "is not JSON: {message}"), + Fault::NotObject => f.write_str("is JSON but not an object"), + } + } +} + +/// What one read of a placed path found. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FileState { + /// Nothing is there. + Absent, + /// Something is there that cannot be used. + Fault(Fault), + /// The bytes, exactly as read. They are never decoded as text, since a + /// stub is compared and hashed byte for byte. + Bytes(Vec), +} + +/// Reads one placed path. +pub(crate) fn read(path: &Path) -> FileState { + file_state(settings::gather(path)) +} + +/// Judges what a read found, by the rules `settings::judge` follows but +/// with this module's words. +pub(crate) fn file_state(seen: Seen) -> FileState { + match seen { + Seen::NotOpened { cause, link } => { + let missing = cause.kind() == std::io::ErrorKind::NotFound; + match link { + Err(error) if missing && error.kind() == std::io::ErrorKind::NotFound => { + FileState::Absent + } + Ok(true) if missing => FileState::Fault(Fault::DanglingLink), + _ => FileState::Fault(Fault::Unreadable(cause.to_string())), + } + } + Seen::NotRegular => FileState::Fault(Fault::NotRegular), + Seen::Unreadable { cause } => FileState::Fault(Fault::Unreadable(cause)), + Seen::Opened(bytes) => FileState::Bytes(bytes), + } +} + +/// Reads bytes as a JSON object. Any other JSON value, and anything that +/// does not parse, is a fault and never an empty document. +pub fn document(bytes: &[u8]) -> Result { + match serde_json::from_slice::(bytes) { + Err(error) => Err(Fault::NotJson(error.to_string())), + Ok(value @ Value::Object(_)) => Ok(value), + Ok(_) => Err(Fault::NotObject), + } +} + +/// Why the executable the hook and the registration run cannot be used. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ExecutableGap { + /// Nothing is at the path. + Missing, + /// A link whose target does not exist. + DanglingLink, + /// Something other than a regular file is there, a directory for one. + NotRegular, + /// It could not be examined; the system's cause. + Unreadable(String), +} + +impl fmt::Display for ExecutableGap { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ExecutableGap::Missing => f.write_str("does not exist"), + ExecutableGap::DanglingLink => f.write_str("is a link to a missing file"), + ExecutableGap::NotRegular => f.write_str("is not a regular file"), + ExecutableGap::Unreadable(cause) => write!(f, "cannot be examined: {cause}"), + } + } +} + +/// What the filesystem said about the executable's path. +pub(crate) struct ExecutableSeen { + /// Whether the path itself is a link, from `symlink_metadata`. + pub(crate) link: std::io::Result, + /// Whether the path, with links followed, is a regular file. + pub(crate) target: std::io::Result, +} + +/// Asks the filesystem about the executable. Nothing is opened or run. +pub(crate) fn observe_executable(path: &Path) -> Option { + executable_gap(ExecutableSeen { + link: std::fs::symlink_metadata(path).map(|meta| meta.file_type().is_symlink()), + target: std::fs::metadata(path).map(|meta| meta.is_file()), + }) +} + +/// The executable's gap, or none when it is a regular file, reached through +/// a link or not. +pub(crate) fn executable_gap(seen: ExecutableSeen) -> Option { + match seen.target { + Ok(true) => None, + Ok(false) => Some(ExecutableGap::NotRegular), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => match seen.link { + Ok(true) => Some(ExecutableGap::DanglingLink), + _ => Some(ExecutableGap::Missing), + }, + Err(error) => Some(ExecutableGap::Unreadable(error.to_string())), + } +} + +#[cfg(test)] +mod tests { + use std::io::ErrorKind; + + use super::*; + + #[test] + fn a_placed_directory_or_unreadable_file_reported_as_missing_or_present_is_caught() { + let not_found = || std::io::Error::from(ErrorKind::NotFound); + assert_eq!( + file_state(Seen::NotOpened { + cause: not_found(), + link: Err(not_found()), + }), + FileState::Absent + ); + assert_eq!( + file_state(Seen::NotOpened { + cause: not_found(), + link: Ok(true), + }), + FileState::Fault(Fault::DanglingLink) + ); + assert_eq!( + file_state(Seen::NotRegular), + FileState::Fault(Fault::NotRegular) + ); + let cause = "Permission denied (os error 13)"; + assert_eq!( + file_state(Seen::Unreadable { + cause: cause.into() + }), + FileState::Fault(Fault::Unreadable(cause.into())) + ); + assert_eq!( + file_state(Seen::Opened(vec![0xff, 0x00, 0x78])), + FileState::Bytes(vec![0xff, 0x00, 0x78]) + ); + } + + #[test] + fn a_document_that_is_not_a_json_object_judged_as_empty_settings_is_caught() { + let message = serde_json::from_str::("{").unwrap_err().to_string(); + assert_eq!(document(b"{"), Err(Fault::NotJson(message))); + for scalar in ["[]", "null", "true", "3", "\"s\""] { + assert_eq!( + document(scalar.as_bytes()), + Err(Fault::NotObject), + "{scalar}" + ); + } + assert_eq!(document(b"{}"), Ok(Value::Object(Default::default()))); + } + + #[test] + fn a_directory_or_dangling_link_counted_as_the_executable_is_caught() { + let not_found = || std::io::Error::from(ErrorKind::NotFound); + let seen = |link: std::io::Result, target: std::io::Result| { + executable_gap(ExecutableSeen { link, target }) + }; + assert_eq!( + seen(Err(not_found()), Err(not_found())), + Some(ExecutableGap::Missing) + ); + assert_eq!( + seen(Ok(true), Err(not_found())), + Some(ExecutableGap::DanglingLink) + ); + assert_eq!(seen(Ok(false), Ok(false)), Some(ExecutableGap::NotRegular)); + assert_eq!(seen(Ok(true), Ok(true)), None); + assert_eq!(seen(Ok(false), Ok(true)), None); + } +} diff --git a/crates/baley/src/host_doctor/report.rs b/crates/baley/src/host_doctor/report.rs index 0a24cd8e..a988967b 100644 --- a/crates/baley/src/host_doctor/report.rs +++ b/crates/baley/src/host_doctor/report.rs @@ -30,17 +30,34 @@ impl Report { "running binary {}: {refusal}, so no hook or registration can be rendered for it", path.display() ), - MapFault::Refused(reason) => format!( - "running binary: the placement map was refused: {reason}" - ), + MapFault::Refused(reason) => { + format!("running binary: the placement map was refused: {reason}") + } }); } for (artifact, state) in &findings.artifacts { - match state { + gap |= state.is_gap(); + lines.push(match state { ArtifactState::NotInstalled => { - lines.push(format!("{artifact}: not installed, no placement is known")); + format!("{artifact}: not installed, no placement is known") + } + ArtifactState::NotRead { path } => { + format!("{artifact}: {} was not read", path.display()) + } + ArtifactState::Missing { path } => { + format!("{artifact}: missing, nothing is at {}", path.display()) } - } + ArtifactState::Fault { path, fault } => { + format!("{artifact}: {} {fault}", path.display()) + } + ArtifactState::Read { path } => format!("{artifact}: read {}", path.display()), + }); + } + if let Some((path, gap_found)) = &findings.executable { + gap = true; + lines.push(format!( + "executable {path} {gap_found}, and the hook and the registration run it" + )); } Report { lines, @@ -53,14 +70,15 @@ impl Report { mod tests { use std::path::PathBuf; + use super::super::fixtures::*; + use super::super::placed::{Fault, FileState}; use super::super::{MapFault, Observation, all_unknown, judge}; use super::*; use crate::host_artifacts::executable::{MissingPrerequisite, PathFault}; fn unknown_observation() -> Observation { - Observation { - placement: all_unknown(Ok(PathBuf::from("/usr/local/bin/baley"))), - } + let map = all_unknown(Ok(PathBuf::from(EXECUTABLE))).unwrap(); + observed(map, vec![]) } fn report_of(observation: &Observation) -> Report { @@ -76,6 +94,17 @@ mod tests { .any(|word| rest.contains(word)) } + /// The one line that starts with `name`. + fn line_for<'r>(report: &'r Report, name: &str) -> &'r String { + let lines: Vec<&String> = report + .lines + .iter() + .filter(|line| line.starts_with(name)) + .collect(); + assert_eq!(lines.len(), 1, "{name}: {:?}", report.lines); + lines[0] + } + #[test] fn an_unknown_placement_shown_as_installed_or_left_unlisted_is_caught() { let report = report_of(&unknown_observation()); @@ -87,14 +116,9 @@ mod tests { "settings", ]; for name in names { - let lines: Vec<&String> = report - .lines - .iter() - .filter(|line| line.starts_with(name)) - .collect(); - assert_eq!(lines.len(), 1, "{name}: {:?}", report.lines); - assert!(lines[0].contains("not installed"), "{}", lines[0]); - assert!(!says_installed(lines[0]), "{}", lines[0]); + let line = line_for(&report, name); + assert!(line.contains("not installed"), "{line}"); + assert!(!says_installed(line), "{line}"); } assert_eq!(report.code, 0); } @@ -102,7 +126,7 @@ mod tests { #[test] fn an_unusable_binary_path_dropped_from_the_report_is_caught() { let relative = Observation { - placement: Err(MapFault::Executable { + host: Err(MapFault::Executable { path: PathBuf::from("baley"), refusal: MissingPrerequisite { fault: PathFault::Relative, @@ -121,7 +145,7 @@ mod tests { let cause = "No such file or directory (os error 2)"; let unreadable = Observation { - placement: Err(MapFault::PathUnreadable(cause.into())), + host: Err(MapFault::PathUnreadable(cause.into())), }; let report = report_of(&unreadable); let named: Vec<&String> = report @@ -133,4 +157,40 @@ mod tests { assert_eq!(report.code, 1); assert!(!report.lines.iter().any(|line| says_installed(line))); } + + #[test] + fn a_placed_artifact_found_missing_reported_as_not_installed_is_caught() { + let map = map(None, Some(HELP), Some(REGISTRATION), None, None); + let report = report_of(&observed( + map, + vec![(HELP, FileState::Absent), (REGISTRATION, FileState::Absent)], + )); + for (name, path) in [("stub `bal-help`", HELP), ("registration", REGISTRATION)] { + let line = line_for(&report, name); + assert!(line.contains(path) && line.contains("missing"), "{line}"); + assert!(!line.contains("not installed"), "{line}"); + } + for name in ["stub `bal-capture`", "hook", "settings"] { + assert!(line_for(&report, name).contains("not installed")); + } + assert_eq!(report.code, 1); + } + + #[test] + fn a_placed_document_that_cannot_be_read_left_out_of_the_code_is_caught() { + let cases = [ + (FileState::Bytes(b"{".to_vec()), "is not JSON"), + ( + FileState::Fault(Fault::Unreadable("Permission denied (os error 13)".into())), + "Permission denied (os error 13)", + ), + ]; + for (state, words) in cases { + let map = map(None, None, None, None, Some(SETTINGS)); + let report = report_of(&observed(map, vec![(SETTINGS, state)])); + let line = line_for(&report, "settings"); + assert!(line.contains(SETTINGS) && line.contains(words), "{line}"); + assert_eq!(report.code, 1, "{line}"); + } + } } diff --git a/crates/baley/src/ledger/commands.rs b/crates/baley/src/ledger/commands.rs index 58a848eb..4c6ba901 100644 --- a/crates/baley/src/ledger/commands.rs +++ b/crates/baley/src/ledger/commands.rs @@ -150,9 +150,7 @@ fn doctor( /// The host section: the running binary against a placement map with every /// artifact unknown, so each one is reported as not installed. fn host_report() -> host_doctor::Report { - let observation = host_doctor::Observation { - placement: host_doctor::all_unknown(std::env::current_exe()), - }; + let observation = host_doctor::gather(host_doctor::all_unknown(std::env::current_exe())); host_doctor::Report::new(&host_doctor::judge(&observation)) } fn export(store: &SqliteStore, project: &ProjectId, to: &Path) -> Result { diff --git a/crates/baley/src/ledger/tests.rs b/crates/baley/src/ledger/tests.rs index 2841dd5b..2f0cefbc 100644 --- a/crates/baley/src/ledger/tests.rs +++ b/crates/baley/src/ledger/tests.rs @@ -567,10 +567,9 @@ fn doctor_lines_reordered_or_reworded_by_the_host_section_are_caught() { expected.push("matches the remote anchor".into()); expected.extend(chain_tail.map(String::from)); + let map = crate::host_doctor::all_unknown(Ok("/usr/local/bin/baley".into())).unwrap(); let host = crate::host_doctor::Report::new(&crate::host_doctor::judge( - &crate::host_doctor::Observation { - placement: crate::host_doctor::all_unknown(Ok("/usr/local/bin/baley".into())), - }, + &crate::host_doctor::fixtures::observed(map, vec![]), )); assert!(!host.lines.is_empty()); let r = display::with_host( From 5dfe87b30b8a732ebb00443055391b5d4caf181d Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Thu, 8 Oct 2026 17:21:03 -0400 Subject: [PATCH 04/18] feat(host): compare each placed stub byte for byte and the registration by composition's rule A stub that differs is reported with both digests so the owner can see which side changed. The registration is judged by the same command and arguments rule composition uses, so alwaysLoad never counts as a difference. --- crates/baley/src/host_artifacts/compose.rs | 5 +- crates/baley/src/host_doctor/mod.rs | 70 +++++++++++- crates/baley/src/host_doctor/placed.rs | 123 +++++++++++++++++++++ crates/baley/src/host_doctor/report.rs | 65 +++++++++++ 4 files changed, 258 insertions(+), 5 deletions(-) diff --git a/crates/baley/src/host_artifacts/compose.rs b/crates/baley/src/host_artifacts/compose.rs index a06c7130..b9429c70 100644 --- a/crates/baley/src/host_artifacts/compose.rs +++ b/crates/baley/src/host_artifacts/compose.rs @@ -174,7 +174,10 @@ fn merge(target: &mut Value, ours: &Value, key: &str, conflicts: &mut Vec bool { +/// Whether an existing `mcpServers` entry runs the same command with the +/// same arguments as ours. `alwaysLoad` and every other key are ignored, so +/// the doctor judges a registration by composition's own rule. +pub(crate) fn same_server(existing: &Value, ours: &Value) -> bool { existing.get("command") == ours.get("command") && existing.get("args") == ours.get("args") } diff --git a/crates/baley/src/host_doctor/mod.rs b/crates/baley/src/host_doctor/mod.rs index afa4cd4c..12913bd8 100644 --- a/crates/baley/src/host_doctor/mod.rs +++ b/crates/baley/src/host_doctor/mod.rs @@ -24,7 +24,7 @@ use std::path::{Path, PathBuf}; pub use report::Report; -use placed::{ExecutableGap, FileState}; +use placed::{ExecutableGap, FileState, RegistrationJudgement, StubJudgement}; use crate::host_artifacts::executable::{Executable, MissingPrerequisite}; use crate::host_artifacts::placement::{Artifact, Placement, PlacementMap}; @@ -126,7 +126,7 @@ pub fn gather(placement: Result) -> Observation { } /// What the judgement found out about one artifact. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub enum ArtifactState { /// No place is known for it, so nothing was looked for. NotInstalled, @@ -152,6 +152,39 @@ pub enum ArtifactState { /// The place. path: PathBuf, }, + /// The stub is the manifest entry's, byte for byte. + StubMatches { + /// The place. + path: PathBuf, + /// The manifest entry's SHA-256, which the bytes found share. + digest: String, + }, + /// The stub differs from the manifest entry. + StubDiffers { + /// The place. + path: PathBuf, + /// The manifest entry's SHA-256. + expected: String, + /// The SHA-256 of the bytes found. + found: String, + }, + /// The registration document holds the entry Baley renders. + RegistrationMatches { + /// The place. + path: PathBuf, + }, + /// The registration document has no entry under Baley's key. + RegistrationMissing { + /// The place. + path: PathBuf, + }, + /// The registration document's entry runs another command or arguments. + RegistrationDiffers { + /// The place. + path: PathBuf, + /// The entry found. + found: serde_json::Value, + }, } impl ArtifactState { @@ -160,7 +193,10 @@ impl ArtifactState { pub fn is_gap(&self) -> bool { !matches!( self, - ArtifactState::NotInstalled | ArtifactState::Read { .. } + ArtifactState::NotInstalled + | ArtifactState::Read { .. } + | ArtifactState::StubMatches { .. } + | ArtifactState::RegistrationMatches { .. } ) } } @@ -267,6 +303,19 @@ pub fn judge(observation: &Observation) -> Findings { path, fault: fault.clone(), }, + Content::Object(value) if file.artifact == Artifact::Registration => { + match placed::registration(value, mapped.map.executable()) { + RegistrationJudgement::Matches => { + ArtifactState::RegistrationMatches { path } + } + RegistrationJudgement::Missing => { + ArtifactState::RegistrationMissing { path } + } + RegistrationJudgement::Differs(found) => { + ArtifactState::RegistrationDiffers { path, found } + } + } + } Content::Object(_) => ArtifactState::Read { path }, }, None => match read(file.path) { @@ -276,7 +325,20 @@ pub fn judge(observation: &Observation) -> Findings { path, fault: fault.clone(), }, - Some(FileState::Bytes(_)) => ArtifactState::Read { path }, + Some(FileState::Bytes(bytes)) => match file.stub { + Some(entry) => match placed::stub(entry, bytes) { + StubJudgement::Matches => ArtifactState::StubMatches { + path, + digest: entry.digest.clone(), + }, + StubJudgement::Differs { found_digest } => ArtifactState::StubDiffers { + path, + expected: entry.digest.clone(), + found: found_digest, + }, + }, + None => ArtifactState::Read { path }, + }, }, }; artifacts.push((file.artifact, state)); diff --git a/crates/baley/src/host_doctor/placed.rs b/crates/baley/src/host_doctor/placed.rs index 06dd4041..ffec66f9 100644 --- a/crates/baley/src/host_doctor/placed.rs +++ b/crates/baley/src/host_doctor/placed.rs @@ -13,7 +13,12 @@ use std::path::Path; use serde_json::Value; +use crate::host_artifacts::compose::same_server; +use crate::host_artifacts::executable::Executable; +use crate::host_artifacts::registration; +use crate::host_artifacts::stubs::Entry; use crate::settings::{self, Seen}; +use crate::store::model::digest; /// Why a placed file cannot be used as found. #[derive(Debug, Clone, PartialEq, Eq)] @@ -143,6 +148,59 @@ pub(crate) fn executable_gap(seen: ExecutableSeen) -> Option { } } +/// How a placed stub compares with the manifest's. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StubJudgement { + /// The bytes are the manifest entry's, byte for byte. + Matches, + /// The bytes differ. + Differs { + /// Lowercase hex SHA-256 of the bytes found, to set beside the + /// entry's own digest. + found_digest: String, + }, +} + +/// Compares the bytes found at a stub's place with the manifest entry's +/// bytes. Only equality counts: a stub that is longer, shorter or changed +/// by one byte is a different stub. +pub fn stub(entry: &Entry, found: &[u8]) -> StubJudgement { + if found == entry.bytes.as_slice() { + StubJudgement::Matches + } else { + StubJudgement::Differs { + found_digest: digest(found), + } + } +} + +/// How a registration document compares with the entry Baley renders. +#[derive(Debug, Clone, PartialEq)] +pub enum RegistrationJudgement { + /// The `mcpServers` entry under Baley's key runs the same command with + /// the same arguments. + Matches, + /// The document has no entry under Baley's key. + Missing, + /// The entry runs another command or other arguments; the entry found. + Differs(Value), +} + +/// Judges the entry under `mcpServers.baley` by composition's rule: equal +/// `command` and `args`, with `alwaysLoad` and any other key ignored. +pub fn registration(document: &Value, executable: &Executable) -> RegistrationJudgement { + let key = registration::KEY; + let Some(found) = document.pointer(&format!("/mcpServers/{key}")) else { + return RegistrationJudgement::Missing; + }; + let ours = registration::render(executable, false); + if same_server(found, &ours["mcpServers"][key]) { + RegistrationJudgement::Matches + } else { + RegistrationJudgement::Differs(found.clone()) + } +} + #[cfg(test)] mod tests { use std::io::ErrorKind; @@ -215,4 +273,69 @@ mod tests { assert_eq!(seen(Ok(true), Ok(true)), None); assert_eq!(seen(Ok(false), Ok(true)), None); } + fn entry(identity: &str) -> Entry { + let manifest = + crate::host_artifacts::stubs::manifest(&crate::host_artifacts::stubs::front_doors()) + .unwrap(); + manifest + .into_iter() + .find(|entry| entry.identity == identity) + .unwrap() + } + + #[test] + fn a_stub_judged_by_length_or_prefix_is_caught() { + let help = entry("bal-help"); + let mut changed = help.bytes.clone(); + changed[0] ^= 1; + let mut appended = help.bytes.clone(); + appended.push(b'\n'); + let mut cut = help.bytes.clone(); + cut.pop(); + assert_eq!(stub(&help, &help.bytes), StubJudgement::Matches); + for found in [changed, appended, cut] { + assert_eq!( + stub(&help, &found), + StubJudgement::Differs { + found_digest: digest(&found) + } + ); + } + let capture = entry("bal-capture"); + assert_eq!(stub(&capture, &capture.bytes), StubJudgement::Matches); + } + + #[test] + fn a_registration_with_always_load_called_different_or_other_arguments_accepted_is_caught() { + let exe = Executable::new("/usr/local/bin/baley").unwrap(); + let entry_of = |document: &Value| document["mcpServers"][registration::KEY].clone(); + let with = |edit: &dyn Fn(&mut Value)| { + let mut document = registration::render(&exe, false); + edit(&mut document["mcpServers"][registration::KEY]); + document + }; + let described = with(&|entry| entry["description"] = "x".into()); + let other_args = with(&|entry| entry["args"] = serde_json::json!(["serve", "--x"])); + let other_command = with(&|entry| entry["command"] = "/other/baley".into()); + assert_eq!( + registration(®istration::render(&exe, true), &exe), + RegistrationJudgement::Matches + ); + assert_eq!( + registration(&described, &exe), + RegistrationJudgement::Matches + ); + assert_eq!( + registration(&other_args, &exe), + RegistrationJudgement::Differs(entry_of(&other_args)) + ); + assert_eq!( + registration(&other_command, &exe), + RegistrationJudgement::Differs(entry_of(&other_command)) + ); + assert_eq!( + registration(&serde_json::json!({}), &exe), + RegistrationJudgement::Missing + ); + } } diff --git a/crates/baley/src/host_doctor/report.rs b/crates/baley/src/host_doctor/report.rs index a988967b..87caf62d 100644 --- a/crates/baley/src/host_doctor/report.rs +++ b/crates/baley/src/host_doctor/report.rs @@ -51,6 +51,31 @@ impl Report { format!("{artifact}: {} {fault}", path.display()) } ArtifactState::Read { path } => format!("{artifact}: read {}", path.display()), + ArtifactState::StubMatches { path, digest } => format!( + "{artifact}: {} matches the stub Baley renders, sha256 {digest}", + path.display() + ), + ArtifactState::StubDiffers { + path, + expected, + found, + } => format!( + "{artifact}: {} differs from the stub Baley renders: expected sha256 {expected}, found sha256 {found}", + path.display() + ), + ArtifactState::RegistrationMatches { path } => format!( + "{artifact}: {} holds the entry Baley renders for this binary", + path.display() + ), + ArtifactState::RegistrationMissing { path } => format!( + "{artifact}: {} has no mcpServers entry under the key {}", + path.display(), + crate::host_artifacts::registration::KEY + ), + ArtifactState::RegistrationDiffers { path, found } => format!( + "{artifact}: {} runs another command or arguments than Baley renders: {found}", + path.display() + ), }); } if let Some((path, gap_found)) = &findings.executable { @@ -193,4 +218,44 @@ mod tests { assert_eq!(report.code, 1, "{line}"); } } + #[test] + fn a_differing_stub_reported_without_both_digests_is_caught() { + use sha2::{Digest, Sha256}; + + let manifest = + crate::host_artifacts::stubs::manifest(&crate::host_artifacts::stubs::front_doors()) + .unwrap(); + let help = manifest + .iter() + .find(|entry| entry.identity == "bal-help") + .unwrap(); + let mut changed = help.bytes.clone(); + changed[0] ^= 1; + let found: String = Sha256::digest(&changed) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + + let differing = report_of(&observed( + map(None, Some(HELP), None, None, None), + vec![(HELP, FileState::Bytes(changed))], + )); + let line = line_for(&differing, "stub `bal-help`"); + for part in [HELP, help.digest.as_str(), found.as_str()] { + assert!(line.contains(part), "{part} missing from {line}"); + } + assert_eq!(differing.code, 1); + + let equal = report_of(&observed( + map(None, Some(HELP), None, None, None), + vec![(HELP, FileState::Bytes(help.bytes.clone()))], + )); + let line = line_for(&equal, "stub `bal-help`"); + assert!( + line.contains("matches") && !line.contains("differs"), + "{line}" + ); + assert!(!equal.lines.iter().any(|line| line.contains("differs"))); + assert_eq!(equal.code, 0); + } } From f8d3e7748514e296571087e6dea108979e4b3673 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Thu, 8 Oct 2026 17:26:51 -0400 Subject: [PATCH 05/18] feat(host): report each tool's coverage as configuration of the document it was read from The coverage judge runs once over the settings and hook documents, and each verdict names the document its mechanism lives in, since the sandbox and the guard hook can sit in two files. Grep and Glob stay best-effort, and the report says outright that the verdicts are not proof of enforcement. --- crates/baley/src/host_doctor/mod.rs | 54 +++- crates/baley/src/host_doctor/protection.rs | 168 ++++++++++ crates/baley/src/host_doctor/report.rs | 342 ++++++++++++++++++++- crates/baley/src/ledger/commands.rs | 30 +- crates/baley/src/ledger/mod.rs | 6 +- 5 files changed, 573 insertions(+), 27 deletions(-) create mode 100644 crates/baley/src/host_doctor/protection.rs diff --git a/crates/baley/src/host_doctor/mod.rs b/crates/baley/src/host_doctor/mod.rs index 12913bd8..bc8b1c1c 100644 --- a/crates/baley/src/host_doctor/mod.rs +++ b/crates/baley/src/host_doctor/mod.rs @@ -9,7 +9,9 @@ //! //! [`gather`] is the one place that touches the filesystem: it reads each //! placed file once and asks about the executable, and owns no rule. The -//! readers and their judgements are in [`placed`]. +//! readers and their judgements are in [`placed`]. [`protection`] runs the +//! coverage judge once over the settings and hook documents the map +//! places, with the map's protected paths as the write-only list. //! //! The held delivery doctor (Build 3 T17) takes the same three steps over a //! placement map `baley install` supplies. Nothing here writes a file, the @@ -18,6 +20,7 @@ //! installed, and that alone never raises the exit status. pub mod placed; +pub mod protection; pub mod report; use std::path::{Path, PathBuf}; @@ -26,6 +29,7 @@ pub use report::Report; use placed::{ExecutableGap, FileState, RegistrationJudgement, StubJudgement}; +use crate::folders::Folders; use crate::host_artifacts::executable::{Executable, MissingPrerequisite}; use crate::host_artifacts::placement::{Artifact, Placement, PlacementMap}; use crate::host_artifacts::stubs; @@ -99,12 +103,20 @@ pub struct Mapped { pub struct Observation { /// What was found for the placement map, or why none could be built. pub host: Result, + /// Baley's resolved folders, which the coverage judge protects. + pub folders: Folders, + /// The discovered checkout's `baley.toml`, which is write-only too. + pub checkout_file: Option, } /// Gathers the observation for a placement map: reads each expected file /// once and asks the filesystem about the executable. Nothing is written, /// opened for writing or run. It owns no rule and has no unit test. -pub fn gather(placement: Result) -> Observation { +pub fn gather( + placement: Result, + folders: Folders, + checkout_file: Option, +) -> Observation { let host = placement.map(|map| { let executable = placed::observe_executable(Path::new(map.executable().as_str())); let mut reads: Vec = Vec::new(); @@ -122,7 +134,11 @@ pub fn gather(placement: Result) -> Observation { reads, } }); - Observation { host } + Observation { + host, + folders, + checkout_file, + } } /// What the judgement found out about one artifact. @@ -237,6 +253,9 @@ pub struct Findings { pub executable: Option<(String, ExecutableGap)>, /// Each document the map places artifacts in. pub documents: Vec, + /// What the settings and hook documents configure, or why that was not + /// judged. None when there is no map. + pub coverage: Option>, } /// Where an artifact sits in the order the report lists them: the stubs, @@ -260,6 +279,7 @@ pub fn judge(observation: &Observation) -> Findings { artifacts: Vec::new(), executable: None, documents: Vec::new(), + coverage: None, }; } }; @@ -359,6 +379,13 @@ pub fn judge(observation: &Observation) -> Findings { .executable .clone() .map(|gap| (mapped.map.executable().as_str().to_owned(), gap)), + coverage: Some(protection::judge( + &mapped.map, + &documents, + &observation.folders, + observation.checkout_file.as_deref(), + &[], + )), documents, } } @@ -372,11 +399,21 @@ pub(crate) mod fixtures { pub(crate) const HELP: &str = "/home/o/.claude/skills/bal-help/SKILL.md"; pub(crate) const REGISTRATION: &str = "/home/o/.claude.json"; pub(crate) const SETTINGS: &str = "/home/o/.claude/settings.json"; + pub(crate) const HOOKS: &str = "/home/o/.claude/hooks.json"; + pub(crate) const HOME: &str = "/home/o/.local/share/crenshawdev/baley"; + pub(crate) const CONFIG: &str = "/home/o/.config/crenshawdev/baley"; pub(crate) fn executable() -> Executable { Executable::new(EXECUTABLE).unwrap() } + pub(crate) fn folders() -> Folders { + Folders { + home: HOME.into(), + config: CONFIG.into(), + } + } + /// A map with the given places and every other placement unknown. pub(crate) fn map( capture: Option<&str>, @@ -409,6 +446,15 @@ pub(crate) mod fixtures { .unwrap() } + /// The observation of a host for which no map could be built. + pub(crate) fn unmapped(fault: MapFault) -> Observation { + Observation { + host: Err(fault), + folders: folders(), + checkout_file: None, + } + } + /// The observation of a map whose executable is a regular file and /// whose files were read as given. pub(crate) fn observed(map: PlacementMap, reads: Vec<(&str, FileState)>) -> Observation { @@ -424,6 +470,8 @@ pub(crate) mod fixtures { }) .collect(), }), + folders: folders(), + checkout_file: None, } } } diff --git a/crates/baley/src/host_doctor/protection.rs b/crates/baley/src/host_doctor/protection.rs new file mode 100644 index 00000000..50c16d25 --- /dev/null +++ b/crates/baley/src/host_doctor/protection.rs @@ -0,0 +1,168 @@ +//! What the supplied settings and hook documents configure: the coverage +//! judge run over each pair, with the placement map's own protected list. +//! +//! The verdicts describe configuration in the documents given. They are not +//! proof that Claude Code enforces them, and other settings files can change +//! what applies. + +use std::path::{Path, PathBuf}; + +use super::{Content, Document}; +use crate::folders::Folders; +use crate::host_artifacts::coverage::{self, Coverage, Inputs, Mechanism}; +use crate::host_artifacts::placement::{Artifact, PlacementMap}; + +/// Coverage judged over a settings document and a hook document. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Judged { + /// The document the sandbox and the deny rules were read from. + pub settings: PathBuf, + /// The document the hooks were read from; the settings document's path + /// when one file holds both. + pub hook: PathBuf, + /// The coverage judge's verdicts. + pub coverage: Coverage, +} + +/// Why coverage was not judged. Neither reason is a gap in itself. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum NotJudged { + /// The settings placement, the hook placement or both are unknown, so + /// there is no document to judge. Nothing is judged against an empty or + /// invented one. + Unknown(Vec), + /// A document could not be used. Its fault is reported with the + /// artifact. + Unusable, +} + +/// Runs the coverage judge once over the settings and hook documents of the +/// map. The write-only list is the map's protected paths, taken whole, then +/// the discovered checkout's `baley.toml` when there is one. `unsupported` +/// names the mechanisms this machine cannot carry. +pub fn judge( + map: &PlacementMap, + documents: &[Document], + folders: &Folders, + checkout_file: Option<&Path>, + unsupported: &[Mechanism], +) -> Result { + let holding = |artifact: &Artifact| { + documents + .iter() + .find(|document| document.artifacts.contains(artifact)) + }; + let (settings, hook) = (holding(&Artifact::Settings), holding(&Artifact::Hook)); + let unknown: Vec = [ + (Artifact::Settings, settings.is_none()), + (Artifact::Hook, hook.is_none()), + ] + .into_iter() + .filter_map(|(artifact, missing)| missing.then_some(artifact)) + .collect(); + let (Some(settings), Some(hook)) = (settings, hook) else { + return Err(NotJudged::Unknown(unknown)); + }; + let (Content::Object(settings_value), Content::Object(hook_value)) = + (&settings.content, &hook.content) + else { + return Err(NotJudged::Unusable); + }; + + let mut write_only = map.protected_paths(); + write_only.extend(checkout_file.map(Path::to_path_buf)); + let coverage = coverage::judge(&Inputs { + settings: settings_value, + hook: hook_value, + folders, + executable: map.executable(), + write_only: &write_only, + unsupported, + }); + Ok(Judged { + settings: settings.path.clone(), + hook: hook.path.clone(), + coverage, + }) +} + +#[cfg(test)] +mod tests { + use serde_json::Value; + + use super::super::fixtures::*; + use super::*; + use crate::host_artifacts::compose::compose; + use crate::host_artifacts::coverage::Verdict; + use crate::host_artifacts::{hook, security}; + + fn document(path: &str, artifacts: &[Artifact], content: Value) -> Document { + Document { + path: path.into(), + artifacts: artifacts.to_vec(), + content: Content::Object(content), + } + } + + #[test] + fn a_coverage_judged_without_the_settings_or_hook_document_is_caught() { + let exe = executable(); + let proposal = security::propose(&folders(), &exe, &[SETTINGS.into()]).settings; + let hook_only = map(None, None, None, Some(HOOKS), None); + let hook_doc = document(HOOKS, &[Artifact::Hook], hook::render(&exe)); + assert_eq!( + judge(&hook_only, &[hook_doc], &folders(), None, &[]), + Err(NotJudged::Unknown(vec![Artifact::Settings])) + ); + let settings_only = map(None, None, None, None, Some(SETTINGS)); + let settings_doc = document(SETTINGS, &[Artifact::Settings], proposal); + assert_eq!( + judge(&settings_only, &[settings_doc], &folders(), None, &[]), + Err(NotJudged::Unknown(vec![Artifact::Hook])) + ); + } + + #[test] + fn the_discovered_checkouts_baley_toml_left_out_of_the_write_only_files_is_caught() { + let exe = executable(); + let toml = "/work/a/baley.toml"; + let map = map( + None, + Some(HELP), + Some(REGISTRATION), + Some(SETTINGS), + Some(SETTINGS), + ); + let write_only: Vec = [HELP, REGISTRATION, SETTINGS, toml] + .into_iter() + .map(PathBuf::from) + .collect(); + let proposal = security::propose(&folders(), &exe, &write_only).settings; + let composed = compose(None, &[proposal, hook::render(&exe)], &folders(), &exe).document; + let documents = [document( + SETTINGS, + &[Artifact::Hook, Artifact::Settings], + composed, + )]; + + let judged = judge(&map, &documents, &folders(), Some(Path::new(toml)), &[]).unwrap(); + let files: Vec<&Path> = judged + .coverage + .files + .iter() + .map(|file| file.path.as_path()) + .collect(); + assert_eq!( + files, + [HELP, REGISTRATION, SETTINGS, EXECUTABLE, toml].map(Path::new) + ); + for file in &judged.coverage.files { + assert!( + matches!(file.write, Verdict::Covered { .. }), + "{:?}", + file.path + ); + } + assert_eq!(judged.settings, PathBuf::from(SETTINGS)); + } +} diff --git a/crates/baley/src/host_doctor/report.rs b/crates/baley/src/host_doctor/report.rs index 87caf62d..81182963 100644 --- a/crates/baley/src/host_doctor/report.rs +++ b/crates/baley/src/host_doctor/report.rs @@ -1,6 +1,10 @@ //! The report: findings as owner lines and an exit code. +use std::path::Path; + +use super::protection::{Judged, NotJudged}; use super::{ArtifactState, Findings, MapFault}; +use crate::host_artifacts::coverage::{Access, Cause, Mechanism, Tool, Verdict}; /// The heading that opens the host section. const HEADING: &str = "Claude Code host checks"; @@ -84,6 +88,26 @@ impl Report { "executable {path} {gap_found}, and the hook and the registration run it" )); } + match &findings.coverage { + None => {} + Some(Err(NotJudged::Unknown(missing))) => { + let names: Vec = missing.iter().map(ToString::to_string).collect(); + lines.push(if names.len() == 1 { + format!( + "coverage: not judged because the {} placement is unknown", + names[0] + ) + } else { + "coverage: not judged because the settings and hook placements are unknown" + .to_owned() + }); + } + Some(Err(NotJudged::Unusable)) => lines.push( + "coverage: not judged because a settings or hook document above could not be used" + .to_owned(), + ), + Some(Ok(judged)) => gap |= coverage_lines(judged, &mut lines), + } Report { lines, code: u8::from(gap), @@ -91,6 +115,136 @@ impl Report { } } +/// The coverage section: one line per tool access and per write-only file, +/// worded as configuration of the named documents, then two lines on what +/// the verdicts do not say. Returns whether any verdict is a gap. +fn coverage_lines(judged: &Judged, lines: &mut Vec) -> bool { + let (settings, hook) = (judged.settings.as_path(), judged.hook.as_path()); + let mut gap = false; + lines.push(if settings == hook { + format!( + "coverage judged over the settings and hook in {}", + settings.display() + ) + } else { + format!( + "coverage judged over the settings in {} and the hook in {}", + settings.display(), + hook.display() + ) + }); + for judged_tool in &judged.coverage.tools { + let tool = judged_tool.tool; + let rules = match tool { + Tool::Read | Tool::Grep | Tool::Glob => "Read", + _ => "Edit", + }; + let access = match judged_tool.access { + Access::Read => "read", + Access::Write => "write", + }; + let best_effort = if matches!(tool, Tool::Grep | Tool::Glob) { + " (best-effort)" + } else { + "" + }; + gap |= matches!(judged_tool.verdict, Verdict::Gap(_)); + lines.push(format!( + "tool {}, {access} access: {}{best_effort}", + tool.name(), + verdict_text(&judged_tool.verdict, rules, settings, hook) + )); + } + for file in &judged.coverage.files { + gap |= matches!(file.write, Verdict::Gap(_)); + lines.push(format!( + "write-only file {}: write access {}", + file.path.display(), + verdict_text(&file.write, "Edit", settings, hook) + )); + } + let documents = if settings == hook { + settings.display().to_string() + } else { + format!("{} and {}", settings.display(), hook.display()) + }; + lines.push(format!( + "These verdicts describe configuration in {documents} and are not proof that Claude Code enforces it." + )); + lines.push( + "Other Claude Code settings files (managed, command line, local, project and user) and the sandbox.enabledPlatforms policy setting can change what applies: lists combine across files and a single value comes from the highest file.".to_owned(), + ); + gap +} + +/// One verdict as configuration: the mechanisms that carry it, or each gap +/// with the setting to change. +fn verdict_text(verdict: &Verdict, rules: &str, settings: &Path, hook: &Path) -> String { + match verdict { + Verdict::Covered { by, .. } => { + let by: Vec = by + .iter() + .map(|mechanism| match mechanism { + Mechanism::Sandbox => format!("the sandbox in {}", settings.display()), + Mechanism::PermissionRules => { + format!("the {rules} deny rules in {}", settings.display()) + } + Mechanism::Hook => format!("the guard hook in {}", hook.display()), + }) + .collect(); + format!("configured as covered by {}", by.join(" and ")) + } + Verdict::Gap(causes) => { + let causes: Vec = causes + .iter() + .map(|cause| cause_text(cause, settings, hook)) + .collect(); + format!("configured with a gap: {}", causes.join("; ")) + } + } +} + +/// What one cause of a gap asks the owner to change, naming the document +/// the coverage judge read it from. +fn cause_text(cause: &Cause, settings: &Path, hook: &Path) -> String { + let (s, h) = (settings.display(), hook.display()); + match cause { + Cause::Unsupported(mechanism) => format!( + "{} cannot run on this machine", + match mechanism { + Mechanism::Sandbox => "the sandbox", + Mechanism::PermissionRules => "the permission rules", + Mechanism::Hook => "the guard hook", + } + ), + Cause::SandboxSetting(key) => { + let secure = if *key == "allowUnsandboxedCommands" || *key == "filesystem.disabled" { + "false" + } else { + "true" + }; + format!("set sandbox.{key} to {secure} in {s}") + } + Cause::Excluded(entry) => format!( + "remove \"{entry}\" from sandbox.excludedCommands in {s}, since a listed command runs outside the sandbox" + ), + Cause::NotDenied { list, path } => { + format!("add {path} to sandbox.filesystem.{list} in {s}") + } + Cause::Reopened { list, entry } => format!( + "remove {entry} from sandbox.filesystem.{list} in {s}, since it re-opens a protected path" + ), + Cause::Unjudged { list, entry } => format!( + "{entry} in sandbox.filesystem.{list} in {s} does not start with / and cannot be judged" + ), + Cause::NoRule(rule) => format!("add {rule} to permissions.deny in {s}"), + Cause::NoGuard => format!("no PreToolUse item in {h} runs the guard for it"), + Cause::HooksDisabled if settings == hook => format!("disableAllHooks is true in {h}"), + Cause::HooksDisabled => format!("disableAllHooks is true in {h} or {s}"), + Cause::Unrendered(report) => report.to_string(), + } +} + #[cfg(test)] mod tests { use std::path::PathBuf; @@ -99,7 +253,9 @@ mod tests { use super::super::placed::{Fault, FileState}; use super::super::{MapFault, Observation, all_unknown, judge}; use super::*; + use crate::host_artifacts::compose::compose; use crate::host_artifacts::executable::{MissingPrerequisite, PathFault}; + use crate::host_artifacts::{hook, security}; fn unknown_observation() -> Observation { let map = all_unknown(Ok(PathBuf::from(EXECUTABLE))).unwrap(); @@ -150,14 +306,12 @@ mod tests { #[test] fn an_unusable_binary_path_dropped_from_the_report_is_caught() { - let relative = Observation { - host: Err(MapFault::Executable { - path: PathBuf::from("baley"), - refusal: MissingPrerequisite { - fault: PathFault::Relative, - }, - }), - }; + let relative = unmapped(MapFault::Executable { + path: PathBuf::from("baley"), + refusal: MissingPrerequisite { + fault: PathFault::Relative, + }, + }); let report = report_of(&relative); let named: Vec<&String> = report .lines @@ -169,9 +323,7 @@ mod tests { assert!(!report.lines.iter().any(|line| says_installed(line))); let cause = "No such file or directory (os error 2)"; - let unreadable = Observation { - host: Err(MapFault::PathUnreadable(cause.into())), - }; + let unreadable = unmapped(MapFault::PathUnreadable(cause.into())); let report = report_of(&unreadable); let named: Vec<&String> = report .lines @@ -258,4 +410,172 @@ mod tests { assert!(!equal.lines.iter().any(|line| line.contains("differs"))); assert_eq!(equal.code, 0); } + /// Baley's proposal and hook composed into one document, as a read. + fn composed(write_only: &[&str]) -> Vec { + let exe = executable(); + let write_only: Vec = write_only.iter().map(PathBuf::from).collect(); + let proposal = security::propose(&folders(), &exe, &write_only).settings; + let document = compose(None, &[proposal, hook::render(&exe)], &folders(), &exe).document; + serde_json::to_vec(&document).unwrap() + } + + /// Settings and hook placed in one document, the document composed from + /// Baley's own values. + fn one_document() -> Report { + report_of(&observed( + map(None, None, None, Some(SETTINGS), Some(SETTINGS)), + vec![(SETTINGS, FileState::Bytes(composed(&[SETTINGS])))], + )) + } + + /// The tool and write-only file verdict lines. + fn verdict_lines(report: &Report) -> Vec<&String> { + report + .lines + .iter() + .filter(|line| line.starts_with("tool ") || line.starts_with("write-only file ")) + .collect() + } + + /// What a verdict line claims, after the tool or file it is about. + fn claim(line: &str) -> &str { + line.split_once(": ").unwrap().1 + } + + fn tool_lines<'r>(report: &'r Report, tool: &str) -> Vec<&'r String> { + let prefix = format!("tool {tool}, "); + let lines: Vec<&String> = report + .lines + .iter() + .filter(|line| line.starts_with(&prefix)) + .collect(); + assert!(!lines.is_empty(), "{tool}: {:?}", report.lines); + lines + } + + #[test] + fn a_coverage_verdict_shown_as_proof_or_grep_and_glob_unlabelled_is_caught() { + let report = one_document(); + let verdicts = verdict_lines(&report); + assert_eq!(verdicts.len(), 14, "{:?}", report.lines); + for line in &verdicts { + assert!( + line.contains(SETTINGS) && line.contains("configured"), + "{line}" + ); + assert!( + !line.contains("enforced") && !line.contains("proven"), + "{line}" + ); + let labelled = line.starts_with("tool Grep,") || line.starts_with("tool Glob,"); + assert_eq!(line.contains("best-effort"), labelled, "{line}"); + } + let notes: Vec<&String> = report + .lines + .iter() + .filter(|line| line.contains("configuration") && line.contains("not proof")) + .collect(); + assert_eq!(notes.len(), 1, "{:?}", report.lines); + assert_eq!(report.code, 0, "{:?}", report.lines); + + let split = report_of(&observed( + map(None, None, None, Some(HOOKS), Some(SETTINGS)), + vec![ + ( + SETTINGS, + FileState::Bytes( + serde_json::to_vec( + &security::propose( + &folders(), + &executable(), + &[SETTINGS.into(), HOOKS.into()], + ) + .settings, + ) + .unwrap(), + ), + ), + ( + HOOKS, + FileState::Bytes(serde_json::to_vec(&hook::render(&executable())).unwrap()), + ), + ], + )); + for tool in ["Read", "Grep", "Glob", "Write", "Edit", "NotebookEdit"] { + for line in tool_lines(&split, tool) { + assert!(line.contains(SETTINGS) && line.contains(HOOKS), "{line}"); + } + } + let shell = ["Bash", "Monitor", "PowerShell"] + .iter() + .flat_map(|tool| tool_lines(&split, tool)) + .collect::>(); + let files: Vec<&String> = split + .lines + .iter() + .filter(|line| line.starts_with("write-only file ")) + .collect(); + assert_eq!(files.len(), 3, "{:?}", split.lines); + for line in shell.into_iter().chain(files) { + let claim = claim(line); + assert!(claim.contains(SETTINGS) && !claim.contains(HOOKS), "{line}"); + } + } + + #[test] + fn shell_sandboxing_and_file_tool_rules_reported_as_one_mechanism_is_caught() { + let report = one_document(); + for tool in ["Bash", "Monitor", "PowerShell"] { + for line in tool_lines(&report, tool) { + assert!(line.contains("the sandbox"), "{line}"); + assert!(!line.contains("deny rules"), "{line}"); + } + } + for tool in ["Read", "Grep", "Glob"] { + for line in tool_lines(&report, tool) { + assert!( + line.contains("Read deny rules") && line.contains("guard hook"), + "{line}" + ); + assert!( + !line.contains("Edit deny rules") && !line.contains("sandbox"), + "{line}" + ); + } + } + for tool in ["Write", "Edit", "NotebookEdit"] { + for line in tool_lines(&report, tool) { + assert!( + line.contains("Edit deny rules") && line.contains("guard hook"), + "{line}" + ); + assert!( + !line.contains("Read deny rules") && !line.contains("sandbox"), + "{line}" + ); + } + } + } + + #[test] + fn other_settings_scopes_left_unmentioned_beside_coverage_is_caught() { + let mentions = |report: &Report| -> Vec { + report + .lines + .iter() + .filter(|line| line.contains("sandbox.enabledPlatforms")) + .cloned() + .collect() + }; + let judged = mentions(&one_document()); + assert_eq!(judged.len(), 1, "{judged:?}"); + assert!(judged[0].contains("settings files"), "{}", judged[0]); + assert!( + judged[0].contains("can change what applies"), + "{}", + judged[0] + ); + assert!(!judged[0].contains("cannot"), "{}", judged[0]); + assert!(mentions(&report_of(&unknown_observation())).is_empty()); + } } diff --git a/crates/baley/src/ledger/commands.rs b/crates/baley/src/ledger/commands.rs index 4c6ba901..f123ec66 100644 --- a/crates/baley/src/ledger/commands.rs +++ b/crates/baley/src/ledger/commands.rs @@ -12,6 +12,7 @@ use super::{ }; use crate::checkout::{Site, gather_and_admit}; use crate::discovery::{self, Discovery, PROJECT_FILE}; +use crate::folders::Folders; use crate::{host_doctor, init, policy_step, settings}; use baley_core::policy::recorded::{PurgePolicy, RecordedPolicy, purge_policy, recorded_policy}; use baley_core::policy::{CONFIG_UNAVAILABLE, EffectivePolicy, ProjectIdentity, Unavailable}; @@ -33,15 +34,17 @@ fn require_remote(forge: &mut Forge, name: &str) -> Result<(), Render> { forge.require_remote(name).map_err(|e| Render::refusal(e.0)) } -/// Wires one owner command to the core and storage port. `config` is the -/// config folder, where `purge`'s policy step finds the global file. +/// Wires one owner command to the core and storage port. The config folder +/// is where `purge`'s policy step finds the global file; `doctor` also takes +/// the ledger home. pub(super) fn dispatch( command: LedgerCommand, store: Arc, cwd: PathBuf, - config: &Path, + folders: &Folders, started_at: String, ) -> Render { + let config = folders.config.as_path(); let mut forge = GitForge::new(crate::process::System, cwd.clone(), SystemClock::seconds); let result = match command { LedgerCommand::Verify { @@ -51,7 +54,7 @@ pub(super) fn dispatch( } => command_plan::verify_form(project.as_deref(), local_only, views) .map_err(Render::refusal) .and_then(|form| verify(&store, &mut forge, config, &cwd, form)), - LedgerCommand::Doctor => doctor(&store, &mut forge, config, &cwd), + LedgerCommand::Doctor => doctor(&store, &mut forge, folders, &cwd), LedgerCommand::Export { project, to } => export(&store, &ProjectId(project), &to), LedgerCommand::Purge { project, @@ -116,9 +119,10 @@ fn verify( fn doctor( store: &SqliteStore, forge: &mut Forge, - config: &Path, + folders: &Folders, cwd: &Path, ) -> Result { + let config = folders.config.as_path(); let projects = store .projects() .map_err(|e| display::store_error(&e, None))?; @@ -144,13 +148,23 @@ fn doctor( .doctor(&SystemClock::now(), &checks) .map_err(|e| display::store_error(&e, None))?; let rendered = display::doctor(&health, &projects, &reasons, &judged); - Ok(display::with_host(rendered, &host_report())) + let checkout_file = gathered + .settings + .project_file + .as_ref() + .map(|file| file.path.clone()); + let host = host_report(folders.clone(), checkout_file); + Ok(display::with_host(rendered, &host)) } /// The host section: the running binary against a placement map with every /// artifact unknown, so each one is reported as not installed. -fn host_report() -> host_doctor::Report { - let observation = host_doctor::gather(host_doctor::all_unknown(std::env::current_exe())); +fn host_report(folders: Folders, checkout_file: Option) -> host_doctor::Report { + let observation = host_doctor::gather( + host_doctor::all_unknown(std::env::current_exe()), + folders, + checkout_file, + ); host_doctor::Report::new(&host_doctor::judge(&observation)) } fn export(store: &SqliteStore, project: &ProjectId, to: &Path) -> Result { diff --git a/crates/baley/src/ledger/mod.rs b/crates/baley/src/ledger/mod.rs index 32eaeea6..3acbe5aa 100644 --- a/crates/baley/src/ledger/mod.rs +++ b/crates/baley/src/ledger/mod.rs @@ -39,11 +39,7 @@ pub fn run(command: LedgerCommand) -> ExitCode { .map_err(|e| display::store_error(&e, None))?, ); Ok(commands::dispatch( - command, - store, - cwd, - &folders.config, - started_at, + command, store, cwd, &folders, started_at, )) })() .unwrap_or_else(|e| e); From 16f32152584a9887c8e6d28e6f09ac47b2979efb Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Thu, 8 Oct 2026 17:32:57 -0400 Subject: [PATCH 06/18] feat(host): name every tool in the hook matcher that no item runs the guard for The coverage judge credits Bash, Monitor and PowerShell to the sandbox and never asks whether the guard runs before them, so the nine tools get their own check. A matcher in one item never borrows the guard of another, and a look-alike command does not count. --- crates/baley/src/host_artifacts/coverage.rs | 4 +- crates/baley/src/host_doctor/mod.rs | 5 + crates/baley/src/host_doctor/protection.rs | 127 +++++++++++++++++++- crates/baley/src/host_doctor/report.rs | 15 +++ 4 files changed, 147 insertions(+), 4 deletions(-) diff --git a/crates/baley/src/host_artifacts/coverage.rs b/crates/baley/src/host_artifacts/coverage.rs index ba89b898..0b1374cd 100644 --- a/crates/baley/src/host_artifacts/coverage.rs +++ b/crates/baley/src/host_artifacts/coverage.rs @@ -504,7 +504,7 @@ fn guard(inputs: &Inputs<'_>, tool: Tool) -> Vec { /// absent, empty or `*` matcher, and reads one holding only letters, digits, /// `_`, `-`, spaces, `,` and `|` as a list of exact names. Anything else is /// a regular expression, which this judge does not evaluate. -fn matcher_names(matcher: Option<&Value>, tool: Tool) -> bool { +pub(crate) fn matcher_names(matcher: Option<&Value>, tool: Tool) -> bool { let text = match matcher { None => return true, Some(Value::String(text)) => text.as_str(), @@ -526,7 +526,7 @@ fn matcher_names(matcher: Option<&Value>, tool: Tool) -> bool { /// through bash, with no field that narrows it (`if`), detaches it /// (`async`), reshapes it (`args`) or is unknown, and no timeout below the /// guard's budget, since a cut-off hook lets the call run. -fn is_guard(handler: &Value, command: &str) -> bool { +pub(crate) fn is_guard(handler: &Value, command: &str) -> bool { let Some(fields) = handler.as_object() else { return false; }; diff --git a/crates/baley/src/host_doctor/mod.rs b/crates/baley/src/host_doctor/mod.rs index bc8b1c1c..1072bec1 100644 --- a/crates/baley/src/host_doctor/mod.rs +++ b/crates/baley/src/host_doctor/mod.rs @@ -256,6 +256,9 @@ pub struct Findings { /// What the settings and hook documents configure, or why that was not /// judged. None when there is no map. pub coverage: Option>, + /// Whether the guard runs before all nine tools of the hook's matcher. + /// None when the hook placement is unknown or its document unusable. + pub nine_tools: Option, } /// Where an artifact sits in the order the report lists them: the stubs, @@ -280,6 +283,7 @@ pub fn judge(observation: &Observation) -> Findings { executable: None, documents: Vec::new(), coverage: None, + nine_tools: None, }; } }; @@ -386,6 +390,7 @@ pub fn judge(observation: &Observation) -> Findings { observation.checkout_file.as_deref(), &[], )), + nine_tools: protection::nine_tools(&mapped.map, &documents), documents, } } diff --git a/crates/baley/src/host_doctor/protection.rs b/crates/baley/src/host_doctor/protection.rs index 50c16d25..35e69090 100644 --- a/crates/baley/src/host_doctor/protection.rs +++ b/crates/baley/src/host_doctor/protection.rs @@ -7,9 +7,14 @@ use std::path::{Path, PathBuf}; +use serde_json::Value; + use super::{Content, Document}; use crate::folders::Folders; -use crate::host_artifacts::coverage::{self, Coverage, Inputs, Mechanism}; +use crate::host_artifacts::coverage::{ + self, Coverage, Inputs, Mechanism, Tool, is_guard, matcher_names, +}; +use crate::host_artifacts::hook; use crate::host_artifacts::placement::{Artifact, PlacementMap}; /// Coverage judged over a settings document and a hook document. @@ -86,9 +91,58 @@ pub fn judge( }) } +/// The nine-tool check over the hook document: which tools of the hook's +/// matcher have no `PreToolUse` item that runs the guard for them. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NineTools { + /// The document the hooks were read from. + pub hook: PathBuf, + /// The tools no item runs the guard for, in matcher order. Empty when + /// all nine are guarded. + pub missing: Vec, +} + +/// Checks that the guard runs before every tool of `hook::MATCHER`. The +/// coverage judge credits Bash, Monitor and PowerShell to the sandbox and +/// never asks whether the guard also runs for them, so this is its own +/// judgement. A matcher in one item never borrows another item's guard, and +/// a handler counts only when the coverage judge accepts it whole. Nothing +/// is judged when the hook placement is unknown or its document unusable. +pub fn nine_tools(map: &PlacementMap, documents: &[Document]) -> Option { + let document = documents + .iter() + .find(|document| document.artifacts.contains(&Artifact::Hook))?; + let Content::Object(value) = &document.content else { + return None; + }; + let command = hook::command(map.executable()); + let items: &[Value] = value + .pointer(&format!("/hooks/{}", hook::EVENT)) + .and_then(Value::as_array) + .map_or(&[], Vec::as_slice); + let guarded = |tool: Tool| { + items.iter().any(|item| { + matcher_names(item.get("matcher"), tool) + && item + .get("hooks") + .and_then(Value::as_array) + .is_some_and(|handlers| { + handlers.iter().any(|handler| is_guard(handler, &command)) + }) + }) + }; + Some(NineTools { + hook: document.path.clone(), + missing: Tool::ALL + .into_iter() + .filter(|tool| !guarded(*tool)) + .collect(), + }) +} + #[cfg(test)] mod tests { - use serde_json::Value; + use serde_json::json; use super::super::fixtures::*; use super::*; @@ -165,4 +219,73 @@ mod tests { } assert_eq!(judged.settings, PathBuf::from(SETTINGS)); } + /// A hook document holding the given `PreToolUse` items, each as a + /// matcher and the command it runs. + fn hook_items(items: &[(&str, &str)]) -> Document { + let handler_for = |command: &str| { + let mut handler = + hook::render(&executable())["hooks"][hook::EVENT][0]["hooks"][0].clone(); + handler["command"] = command.into(); + handler + }; + let items: Vec = items + .iter() + .map(|(matcher, command)| json!({"matcher": matcher, "hooks": [handler_for(command)]})) + .collect(); + document( + HOOKS, + &[Artifact::Hook], + json!({"hooks": {hook::EVENT: items}}), + ) + } + + fn missing_tools(document: Document) -> Vec<&'static str> { + let map = map(None, None, None, Some(HOOKS), None); + nine_tools(&map, &[document]) + .expect("the hook document is usable") + .missing + .into_iter() + .map(Tool::name) + .collect() + } + + #[test] + fn a_guard_split_across_two_hook_items_reported_as_missing_tools_is_caught() { + let guard = hook::command(&executable()); + let split = hook_items(&[ + ("Bash|Monitor|PowerShell", &guard), + ("Read|Grep|Glob|Write|Edit|NotebookEdit", &guard), + ]); + assert_eq!(missing_tools(split), Vec::<&str>::new()); + + let borrowed = hook_items(&[ + ("Read|Grep|Glob|Write|Edit|NotebookEdit", &guard), + ("Bash|Monitor|PowerShell", "/bin/echo"), + ]); + assert_eq!(missing_tools(borrowed), ["Bash", "Monitor", "PowerShell"]); + } + + #[test] + fn a_tool_named_by_a_pattern_or_a_look_alike_guard_counted_as_guarded_is_caught() { + let guard = hook::command(&executable()); + let all_nine = [ + "Bash", + "Monitor", + "PowerShell", + "Read", + "Grep", + "Glob", + "Write", + "Edit", + "NotebookEdit", + ]; + let echo_guard = format!("/bin/echo {guard}"); + for item in [ + ("Ba.*", guard.as_str()), + (hook::MATCHER, "/bin/echo guard"), + (hook::MATCHER, echo_guard.as_str()), + ] { + assert_eq!(missing_tools(hook_items(&[item])), all_nine, "{item:?}"); + } + } } diff --git a/crates/baley/src/host_doctor/report.rs b/crates/baley/src/host_doctor/report.rs index 81182963..ddf45ecf 100644 --- a/crates/baley/src/host_doctor/report.rs +++ b/crates/baley/src/host_doctor/report.rs @@ -108,6 +108,21 @@ impl Report { ), Some(Ok(judged)) => gap |= coverage_lines(judged, &mut lines), } + if let Some(nine) = &findings.nine_tools { + let hook = nine.hook.display(); + if nine.missing.is_empty() { + lines.push(format!( + "hook: a PreToolUse item in {hook} runs the guard for all nine tools in the matcher" + )); + } else { + gap = true; + let names: Vec<&str> = nine.missing.iter().map(|tool| tool.name()).collect(); + lines.push(format!( + "hook: no PreToolUse item in {hook} runs the guard for {}", + names.join(", ") + )); + } + } Report { lines, code: u8::from(gap), From c8c36f9ece69da17f52deafb8cc0a408ae5d50f8 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Thu, 8 Oct 2026 17:39:07 -0400 Subject: [PATCH 07/18] feat(host): find the sandbox programs on PATH by platform and fail the sandbox verdicts without them On Linux a missing bwrap or socat is named with its package and marks the sandbox unsupported for the coverage judge. macOS needs nothing, and any other platform is reported as one where the sandbox does not run. --- crates/baley/src/host_doctor/mod.rs | 100 ++++++- crates/baley/src/host_doctor/prerequisites.rs | 282 ++++++++++++++++++ crates/baley/src/host_doctor/report.rs | 194 +++++++++++- crates/baley/src/ledger/commands.rs | 2 + 4 files changed, 570 insertions(+), 8 deletions(-) create mode 100644 crates/baley/src/host_doctor/prerequisites.rs diff --git a/crates/baley/src/host_doctor/mod.rs b/crates/baley/src/host_doctor/mod.rs index 1072bec1..ddd286c4 100644 --- a/crates/baley/src/host_doctor/mod.rs +++ b/crates/baley/src/host_doctor/mod.rs @@ -11,7 +11,11 @@ //! placed file once and asks about the executable, and owns no rule. The //! readers and their judgements are in [`placed`]. [`protection`] runs the //! coverage judge once over the settings and hook documents the map -//! places, with the map's protected paths as the write-only list. +//! places, with the map's protected paths as the write-only list, and checks +//! that the guard runs before all nine tools of the hook's matcher. +//! [`prerequisites`] finds the programs the sandbox needs on `PATH` for the +//! target platform, and a missing one marks the sandbox unsupported for the +//! coverage judge. //! //! The held delivery doctor (Build 3 T17) takes the same three steps over a //! placement map `baley install` supplies. Nothing here writes a file, the @@ -20,9 +24,11 @@ //! installed, and that alone never raises the exit status. pub mod placed; +pub mod prerequisites; pub mod protection; pub mod report; +use std::ffi::OsStr; use std::path::{Path, PathBuf}; pub use report::Report; @@ -107,6 +113,8 @@ pub struct Observation { pub folders: Folders, /// The discovered checkout's `baley.toml`, which is write-only too. pub checkout_file: Option, + /// The platform and where the sandbox's programs were found. + pub prerequisites: prerequisites::Observed, } /// Gathers the observation for a placement map: reads each expected file @@ -116,6 +124,8 @@ pub fn gather( placement: Result, folders: Folders, checkout_file: Option, + path: Option<&OsStr>, + os: &str, ) -> Observation { let host = placement.map(|map| { let executable = placed::observe_executable(Path::new(map.executable().as_str())); @@ -138,6 +148,7 @@ pub fn gather( host, folders, checkout_file, + prerequisites: prerequisites::gather(path, os), } } @@ -259,6 +270,8 @@ pub struct Findings { /// Whether the guard runs before all nine tools of the hook's matcher. /// None when the hook placement is unknown or its document unusable. pub nine_tools: Option, + /// The platform and the sandbox programs it needs, found on `PATH`. + pub prerequisites: prerequisites::Judged, } /// Where an artifact sits in the order the report lists them: the stubs, @@ -284,6 +297,7 @@ pub fn judge(observation: &Observation) -> Findings { documents: Vec::new(), coverage: None, nine_tools: None, + prerequisites: prerequisites::judge(&observation.prerequisites), }; } }; @@ -295,6 +309,8 @@ pub fn judge(observation: &Observation) -> Findings { .map(|read| &read.state) }; + let prerequisites = prerequisites::judge(&observation.prerequisites); + let mut documents = Vec::new(); for document in mapped.map.documents() { let content = match read(document.path) { @@ -388,9 +404,10 @@ pub fn judge(observation: &Observation) -> Findings { &documents, &observation.folders, observation.checkout_file.as_deref(), - &[], + &prerequisites.unsupported(), )), nine_tools: protection::nine_tools(&mapped.map, &documents), + prerequisites, documents, } } @@ -451,12 +468,38 @@ pub(crate) mod fixtures { .unwrap() } + /// A Linux search that finds both sandbox programs. + pub(crate) fn linux_with_both() -> prerequisites::Observed { + found_on("linux", &["bwrap", "socat"]) + } + + /// A search on `os` that finds each named program in `/usr/bin`, and + /// gives the others a `PATH` entry with nothing in it. + pub(crate) fn found_on(os: &str, programs: &[&str]) -> prerequisites::Observed { + let entry = |present: bool| prerequisites::Candidate { + folder: "/usr/bin".into(), + regular_file: present, + mode: if present { 0o755 } else { 0 }, + }; + prerequisites::Observed { + os: os.into(), + searches: ["bwrap", "socat"] + .into_iter() + .map(|name| prerequisites::Search { + program: name.into(), + candidates: vec![entry(programs.contains(&name))], + }) + .collect(), + } + } + /// The observation of a host for which no map could be built. pub(crate) fn unmapped(fault: MapFault) -> Observation { Observation { host: Err(fault), folders: folders(), checkout_file: None, + prerequisites: linux_with_both(), } } @@ -477,6 +520,59 @@ pub(crate) mod fixtures { }), folders: folders(), checkout_file: None, + prerequisites: linux_with_both(), + } + } +} + +#[cfg(test)] +mod tests { + use super::fixtures::*; + use super::*; + use crate::host_artifacts::compose::compose; + use crate::host_artifacts::coverage::{Access, Cause, Mechanism, Tool, Verdict}; + use crate::host_artifacts::{hook, security}; + + #[test] + fn a_missing_program_left_out_of_the_coverage_verdicts_is_caught() { + let exe = executable(); + let proposal = security::propose(&folders(), &exe, &[SETTINGS.into()]).settings; + let document = compose(None, &[proposal, hook::render(&exe)], &folders(), &exe).document; + let bytes = serde_json::to_vec(&document).unwrap(); + for present in [["bwrap"], ["socat"]] { + let mut observation = observed( + map(None, None, None, Some(SETTINGS), Some(SETTINGS)), + vec![(SETTINGS, FileState::Bytes(bytes.clone()))], + ); + observation.prerequisites = found_on("linux", &present); + let findings = judge(&observation); + let coverage = findings.coverage.unwrap().unwrap().coverage; + for tool in [Tool::Bash, Tool::Monitor, Tool::PowerShell] { + for access in [Access::Read, Access::Write] { + assert_eq!( + coverage.verdict(tool, access), + Some(&Verdict::Gap(vec![Cause::Unsupported(Mechanism::Sandbox)])), + "{tool:?} {access:?} with only {present:?} found" + ); + } + } + for tool in [ + Tool::Read, + Tool::Grep, + Tool::Glob, + Tool::Write, + Tool::Edit, + Tool::NotebookEdit, + ] { + let access = tool.accesses()[0]; + assert!( + matches!( + coverage.verdict(tool, access), + Some(Verdict::Covered { .. }) + ), + "{tool:?}" + ); + } } } } diff --git a/crates/baley/src/host_doctor/prerequisites.rs b/crates/baley/src/host_doctor/prerequisites.rs new file mode 100644 index 00000000..6359e2ae --- /dev/null +++ b/crates/baley/src/host_doctor/prerequisites.rs @@ -0,0 +1,282 @@ +//! The programs Claude Code's sandbox needs, found on `PATH` by platform. +//! +//! Gathering takes the `PATH` value as supplied and the target operating +//! system's name, looks at each folder's entry for each program and starts +//! nothing. Judging is pure: it switches on the operating system's name with +//! `linux` and `macos` as the only named platforms. +//! +//! Host facts, read on 2026-10-08 from code.claude.com's sandboxing page and +//! Claude Code 2.1.294: on Linux and WSL2 the sandbox needs `bubblewrap` +//! (the program `bwrap`) and `socat`, installed with `apt-get install +//! bubblewrap socat` or `dnf install bubblewrap socat`. macOS uses its +//! built-in Seatbelt and needs nothing. Native Windows and WSL1 cannot +//! sandbox commands. +//! +//! Not detected: WSL1, a container that blocks bubblewrap, Ubuntu's +//! AppArmor rule on user namespaces, ripgrep (bundled with Claude Code's +//! native binary) and the optional seccomp filter. A found program shows +//! that a file is there, not that the sandbox starts. + +use std::ffi::OsStr; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; + +use crate::host_artifacts::coverage::Mechanism; + +/// A program the Linux sandbox needs and the package that provides it. +struct Required { + name: &'static str, + package: &'static str, +} + +const LINUX: [Required; 2] = [ + Required { + name: "bwrap", + package: "bubblewrap", + }, + Required { + name: "socat", + package: "socat", + }, +]; + +/// One `PATH` entry's file for a program. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Candidate { + /// The `PATH` entry, as written. + pub folder: PathBuf, + /// Whether the file there is a regular file, with links followed. + pub regular_file: bool, + /// The file's mode bits; 0 when nothing is there. + pub mode: u32, +} + +/// What a program search found: one candidate per `PATH` entry, in order. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Search { + /// The program's name. + pub program: String, + /// The entries, in `PATH` order. + pub candidates: Vec, +} + +/// What was gathered about the platform and its sandbox programs. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Observed { + /// The target operating system, `std::env::consts::OS`. + pub os: String, + /// A search for each program the Linux sandbox needs. + pub searches: Vec, +} + +/// Looks for each sandbox program in each entry of `path`. A program is +/// searched whatever the platform, since the judgement decides which ones +/// the platform needs. Nothing is opened or run. +pub fn gather(path: Option<&OsStr>, os: &str) -> Observed { + let folders: Vec = path + .map(|path| std::env::split_paths(path).collect()) + .unwrap_or_default(); + let searches = LINUX + .iter() + .map(|required| Search { + program: required.name.to_owned(), + candidates: folders + .iter() + .map(|folder| { + let meta = std::fs::metadata(folder.join(required.name)); + Candidate { + folder: folder.clone(), + regular_file: meta.as_ref().is_ok_and(|meta| meta.is_file()), + mode: meta.map_or(0, |meta| meta.permissions().mode()), + } + }) + .collect(), + }) + .collect(); + Observed { + os: os.to_owned(), + searches, + } +} + +/// A program the platform needs and where it was found. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Program { + /// The program's name. + pub name: &'static str, + /// The package that provides it. + pub package: &'static str, + /// The file found, or none when no `PATH` entry holds it. + pub found: Option, +} + +/// What the platform is to Claude Code's sandbox. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Kind { + /// Linux: bubblewrap and socat. + Linux, + /// macOS: the built-in Seatbelt. + MacOs, + /// Any other platform: the sandbox does not run. + Other, +} + +/// The judged prerequisites. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Judged { + /// The operating system's name as observed. + pub os: String, + /// What the platform is to the sandbox. + pub kind: Kind, + /// The programs the platform requires; empty off Linux. + pub required: Vec, +} + +impl Judged { + /// The required programs no `PATH` entry holds. + pub fn missing(&self) -> Vec<&Program> { + self.required + .iter() + .filter(|program| program.found.is_none()) + .collect() + } + + /// The mechanisms this machine cannot carry: the sandbox, when the + /// platform cannot run it or a required program is missing. + pub fn unsupported(&self) -> Vec { + if self.kind == Kind::Other || !self.missing().is_empty() { + vec![Mechanism::Sandbox] + } else { + Vec::new() + } + } +} + +/// Judges what was gathered. A program counts at the first entry that is an +/// absolute folder holding a regular file with any execute bit; a relative +/// entry is skipped because what it names depends on the working directory. +pub fn judge(observed: &Observed) -> Judged { + let (kind, required): (Kind, &[Required]) = match observed.os.as_str() { + "linux" => (Kind::Linux, &LINUX), + "macos" => (Kind::MacOs, &[]), + _ => (Kind::Other, &[]), + }; + let required = required + .iter() + .map(|required| Program { + name: required.name, + package: required.package, + found: observed + .searches + .iter() + .find(|search| search.program == required.name) + .and_then(|search| first_usable(&search.candidates)) + .map(|folder| folder.join(required.name)), + }) + .collect(); + Judged { + os: observed.os.clone(), + kind, + required, + } +} + +/// The folder of the first candidate that is usable. +fn first_usable(candidates: &[Candidate]) -> Option<&Path> { + candidates + .iter() + .find(|candidate| { + candidate.folder.is_absolute() && candidate.regular_file && candidate.mode & 0o111 != 0 + }) + .map(|candidate| candidate.folder.as_path()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn candidate(folder: &str, regular_file: bool, mode: u32) -> Candidate { + Candidate { + folder: folder.into(), + regular_file, + mode, + } + } + + fn observed(os: &str, bwrap: Vec, socat: Vec) -> Observed { + Observed { + os: os.into(), + searches: vec![ + Search { + program: "bwrap".into(), + candidates: bwrap, + }, + Search { + program: "socat".into(), + candidates: socat, + }, + ], + } + } + + #[test] + fn bwrap_or_socat_required_on_macos_is_caught() { + for observed in [ + observed("macos", vec![], vec![]), + observed("macos", vec![candidate("/usr/bin", true, 0o755)], vec![]), + ] { + let judged = judge(&observed); + assert!(judged.missing().is_empty(), "{judged:?}"); + assert!(judged.unsupported().is_empty(), "{judged:?}"); + } + } + + #[test] + fn an_unsupported_platform_reported_as_sandbox_ready_is_caught() { + let both = vec![candidate("/usr/bin", true, 0o755)]; + for observed in [ + observed("freebsd", vec![], vec![]), + observed("windows", both.clone(), both), + ] { + let judged = judge(&observed); + assert_eq!( + judged.unsupported(), + [Mechanism::Sandbox], + "{}", + observed.os + ); + assert!(judged.required.is_empty(), "{}", observed.os); + } + } + + #[test] + fn a_directory_or_non_executable_file_on_path_counted_as_the_program_is_caught() { + let relative = candidate("bin", true, 0o755); + let directory = candidate("/a", false, 0o755); + let not_executable = candidate("/b", true, 0o644); + let usable = candidate("/c", true, 0o755); + let found = |bwrap: Vec| { + let judged = judge(&observed("linux", bwrap, vec![])); + judged + .required + .into_iter() + .find(|program| program.name == "bwrap") + .unwrap() + .found + }; + let before = [relative, directory, not_executable]; + assert_eq!( + found([&before[..], &[usable]].concat()), + Some(PathBuf::from("/c/bwrap")) + ); + assert_eq!(found(before.to_vec()), None); + assert_eq!( + found(vec![candidate("/e", true, 0o610)]), + Some(PathBuf::from("/e/bwrap")) + ); + assert_eq!( + found(vec![candidate("/f", true, 0o601)]), + Some(PathBuf::from("/f/bwrap")) + ); + } +} diff --git a/crates/baley/src/host_doctor/report.rs b/crates/baley/src/host_doctor/report.rs index ddf45ecf..cd9cb356 100644 --- a/crates/baley/src/host_doctor/report.rs +++ b/crates/baley/src/host_doctor/report.rs @@ -2,6 +2,7 @@ use std::path::Path; +use super::prerequisites::{Judged as Prerequisites, Kind}; use super::protection::{Judged, NotJudged}; use super::{ArtifactState, Findings, MapFault}; use crate::host_artifacts::coverage::{Access, Cause, Mechanism, Tool, Verdict}; @@ -88,6 +89,7 @@ impl Report { "executable {path} {gap_found}, and the hook and the registration run it" )); } + gap |= prerequisite_lines(&findings.prerequisites, &mut lines); match &findings.coverage { None => {} Some(Err(NotJudged::Unknown(missing))) => { @@ -112,13 +114,13 @@ impl Report { let hook = nine.hook.display(); if nine.missing.is_empty() { lines.push(format!( - "hook: a PreToolUse item in {hook} runs the guard for all nine tools in the matcher" + "guard hook: a PreToolUse item in {hook} runs the guard for all nine tools in the matcher" )); } else { gap = true; let names: Vec<&str> = nine.missing.iter().map(|tool| tool.name()).collect(); lines.push(format!( - "hook: no PreToolUse item in {hook} runs the guard for {}", + "guard hook: no PreToolUse item in {hook} runs the guard for {}", names.join(", ") )); } @@ -130,6 +132,33 @@ impl Report { } } +/// The sandbox prerequisite lines for the platform. Returns whether the +/// sandbox cannot run here: a platform it does not run on, or a program it +/// needs that no `PATH` entry holds. +fn prerequisite_lines(judged: &Prerequisites, lines: &mut Vec) -> bool { + match judged.kind { + Kind::MacOs => { + lines.push("sandbox: macOS uses the built-in Seatbelt and needs no program".to_owned()) + } + Kind::Other => lines.push(format!( + "sandbox: {} is a platform where Claude Code's sandbox does not run", + judged.os + )), + Kind::Linux => { + for program in &judged.required { + lines.push(match &program.found { + Some(path) => format!("sandbox: {} found at {}", program.name, path.display()), + None => format!( + "sandbox: {} is missing from PATH, so the sandbox cannot run; install {} with `apt-get install bubblewrap socat` or `dnf install bubblewrap socat` (this lookup used this command's PATH, which may differ from the one Claude Code runs with)", + program.name, program.package + ), + }); + } + } + } + !judged.unsupported().is_empty() +} + /// The coverage section: one line per tool access and per write-only file, /// worded as configuration of the named documents, then two lines on what /// the verdicts do not say. Returns whether any verdict is a gap. @@ -317,6 +346,18 @@ mod tests { assert!(!says_installed(line), "{line}"); } assert_eq!(report.code, 0); + + for (os, code) in [("macos", 0), ("freebsd", 1)] { + let mut other = unknown_observation(); + other.prerequisites = found_on(os, &[]); + let report = report_of(&other); + for name in names { + let line = line_for(&report, name); + assert!(line.contains("not installed"), "{os}: {line}"); + assert!(!says_installed(line), "{os}: {line}"); + } + assert_eq!(report.code, code, "{os}: {:?}", report.lines); + } } #[test] @@ -425,13 +466,17 @@ mod tests { assert!(!equal.lines.iter().any(|line| line.contains("differs"))); assert_eq!(equal.code, 0); } - /// Baley's proposal and hook composed into one document, as a read. - fn composed(write_only: &[&str]) -> Vec { + /// Baley's proposal and hook composed into one document. + fn composed_value(write_only: &[&str]) -> serde_json::Value { let exe = executable(); let write_only: Vec = write_only.iter().map(PathBuf::from).collect(); let proposal = security::propose(&folders(), &exe, &write_only).settings; - let document = compose(None, &[proposal, hook::render(&exe)], &folders(), &exe).document; - serde_json::to_vec(&document).unwrap() + compose(None, &[proposal, hook::render(&exe)], &folders(), &exe).document + } + + /// The same document, as the bytes a read finds. + fn composed(write_only: &[&str]) -> Vec { + serde_json::to_vec(&composed_value(write_only)).unwrap() } /// Settings and hook placed in one document, the document composed from @@ -593,4 +638,141 @@ mod tests { assert!(!judged[0].contains("cannot"), "{}", judged[0]); assert!(mentions(&report_of(&unknown_observation())).is_empty()); } + /// The report for settings and hook in one document, edited by `edit`. + fn edited(edit: impl FnOnce(&mut serde_json::Value)) -> Report { + let mut document = composed_value(&[SETTINGS]); + edit(&mut document); + report_of(&observed( + map(None, None, None, Some(SETTINGS), Some(SETTINGS)), + vec![( + SETTINGS, + FileState::Bytes(serde_json::to_vec(&document).unwrap()), + )], + )) + } + + /// Removes the entries of the arrays at `pointers` that `drop` picks. + fn without(document: &mut serde_json::Value, pointers: &[&str], drop: impl Fn(&str) -> bool) { + for pointer in pointers { + document + .pointer_mut(pointer) + .and_then(serde_json::Value::as_array_mut) + .unwrap() + .retain(|item| !drop(item.as_str().unwrap())); + } + } + + fn names(report: &Report, words: &[&str]) -> bool { + report + .lines + .iter() + .any(|line| words.iter().all(|word| line.contains(word))) + } + + #[test] + fn a_host_gap_left_unnamed_in_the_report_is_caught() { + use serde_json::json; + + let clean = edited(|_| {}); + assert_eq!(clean.code, 0, "{:?}", clean.lines); + + let off = edited(|document| document["sandbox"]["enabled"] = json!(false)); + assert!(names(&off, &["sandbox.enabled"]), "{:?}", off.lines); + assert_eq!(off.code, 1); + + let mut no_bwrap = observed( + map(None, None, None, Some(SETTINGS), Some(SETTINGS)), + vec![(SETTINGS, FileState::Bytes(composed(&[SETTINGS])))], + ); + no_bwrap.prerequisites = found_on("linux", &["socat"]); + let no_bwrap = report_of(&no_bwrap); + assert!( + names(&no_bwrap, &["bwrap", "bubblewrap"]), + "{:?}", + no_bwrap.lines + ); + assert_eq!(no_bwrap.code, 1); + + let no_config = edited(|document| { + without( + document, + &[ + "/sandbox/filesystem/denyRead", + "/sandbox/filesystem/denyWrite", + "/permissions/deny", + ], + |item| item.contains("/.config/"), + ); + }); + assert!(names(&no_config, &[CONFIG]), "{:?}", no_config.lines); + assert_eq!(no_config.code, 1); + + let no_deny_read = + edited(|document| document["sandbox"]["filesystem"]["denyRead"] = json!([])); + assert!( + names(&no_deny_read, &["denyRead"]), + "{:?}", + no_deny_read.lines + ); + assert_eq!(no_deny_read.code, 1); + + let no_edit_rules = edited(|document| { + without(document, &["/permissions/deny"], |rule| { + rule.starts_with("Edit(") + }); + }); + for tool in ["Write", "Edit", "NotebookEdit"] { + for line in tool_lines(&no_edit_rules, tool) { + assert!( + line.contains("Edit(//home/o/.local/share/crenshawdev/baley/**)"), + "{line}" + ); + } + } + assert_eq!(no_edit_rules.code, 1); + + let six_tools = edited(|document| { + document["hooks"]["PreToolUse"][0]["matcher"] = + json!("Read|Grep|Glob|Write|Edit|NotebookEdit"); + }); + let line = six_tools + .lines + .iter() + .find(|line| line.starts_with("guard hook:")) + .unwrap_or_else(|| panic!("{:?}", six_tools.lines)); + for tool in ["Bash", "Monitor", "PowerShell"] { + assert!(line.contains(tool), "{line}"); + } + assert!(!line.contains("Read"), "{line}"); + assert_eq!(six_tools.code, 1); + + let mut changed = + crate::host_artifacts::stubs::manifest(&crate::host_artifacts::stubs::front_doors()) + .unwrap() + .into_iter() + .find(|entry| entry.identity == "bal-help") + .unwrap() + .bytes; + changed[0] ^= 1; + let stub = report_of(&observed( + map(None, Some(HELP), None, None, None), + vec![(HELP, FileState::Bytes(changed))], + )); + assert!( + names(&stub, &["bal-help", HELP, "differs"]), + "{:?}", + stub.lines + ); + assert_eq!(stub.code, 1); + + let mut freebsd = unknown_observation(); + freebsd.prerequisites = found_on("freebsd", &[]); + let freebsd = report_of(&freebsd); + assert!( + names(&freebsd, &["freebsd", "sandbox does not run"]), + "{:?}", + freebsd.lines + ); + assert_eq!(freebsd.code, 1); + } } diff --git a/crates/baley/src/ledger/commands.rs b/crates/baley/src/ledger/commands.rs index f123ec66..47fb0ef9 100644 --- a/crates/baley/src/ledger/commands.rs +++ b/crates/baley/src/ledger/commands.rs @@ -164,6 +164,8 @@ fn host_report(folders: Folders, checkout_file: Option) -> host_doctor: host_doctor::all_unknown(std::env::current_exe()), folders, checkout_file, + std::env::var_os("PATH").as_deref(), + std::env::consts::OS, ); host_doctor::Report::new(&host_doctor::judge(&observation)) } From 4211cde0637a61f9fd6b75d7d244043a072a1b61 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Thu, 8 Oct 2026 18:32:40 -0400 Subject: [PATCH 08/18] fix(host): stop crediting the guard for nine tools while disableAllHooks is true The nine-tool check read only the hook items, so a hook document with the disabling setting still reported the guard running for every tool, and with the settings placement unknown the coverage judge never ran to say otherwise. The check now names the document that sets it and the report raises the code. --- crates/baley/src/host_doctor/protection.rs | 57 +++++++++++++++++++++- crates/baley/src/host_doctor/report.rs | 37 +++++++++++++- 2 files changed, 90 insertions(+), 4 deletions(-) diff --git a/crates/baley/src/host_doctor/protection.rs b/crates/baley/src/host_doctor/protection.rs index 35e69090..5ecde7ef 100644 --- a/crates/baley/src/host_doctor/protection.rs +++ b/crates/baley/src/host_doctor/protection.rs @@ -100,14 +100,22 @@ pub struct NineTools { /// The tools no item runs the guard for, in matcher order. Empty when /// all nine are guarded. pub missing: Vec, + /// The document that sets `disableAllHooks` true, the hook document + /// first, then the settings document when its placement is known. Claude + /// Code then runs no hook, so no item guards any tool whatever the + /// matchers say. + pub disabled_in: Option, } /// Checks that the guard runs before every tool of `hook::MATCHER`. The /// coverage judge credits Bash, Monitor and PowerShell to the sandbox and /// never asks whether the guard also runs for them, so this is its own /// judgement. A matcher in one item never borrows another item's guard, and -/// a handler counts only when the coverage judge accepts it whole. Nothing -/// is judged when the hook placement is unknown or its document unusable. +/// a handler counts only when the coverage judge accepts it whole. A +/// `disableAllHooks` of true in the hook document, or in the settings +/// document when one is placed, is reported with the document and does not +/// change which tools the items name. Nothing is judged when the hook +/// placement is unknown or its document unusable. pub fn nine_tools(map: &PlacementMap, documents: &[Document]) -> Option { let document = documents .iter() @@ -131,8 +139,20 @@ pub fn nine_tools(map: &PlacementMap, documents: &[Document]) -> Option = nine.missing.iter().map(|tool| tool.name()).collect(); @@ -775,4 +784,28 @@ mod tests { ); assert_eq!(freebsd.code, 1); } + + #[test] + fn a_guard_credited_for_all_nine_tools_while_disable_all_hooks_is_true_is_caught() { + let mut document = hook::render(&executable()); + document["disableAllHooks"] = serde_json::json!(true); + let report = report_of(&observed( + map(None, None, None, Some(HOOKS), None), + vec![( + HOOKS, + FileState::Bytes(serde_json::to_vec(&document).unwrap()), + )], + )); + assert!( + names(&report, &["guard hook", "disableAllHooks", HOOKS]), + "{:?}", + report.lines + ); + assert!( + !names(&report, &["runs the guard for all nine tools"]), + "{:?}", + report.lines + ); + assert_eq!(report.code, 1, "{:?}", report.lines); + } } From 4f7d958ca764ab5667546e21970f2ed25048a0f1 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Thu, 8 Oct 2026 18:36:11 -0400 Subject: [PATCH 09/18] fix(host): name an executable with no execute bit as a gap The executable check looked only at the file type, so a regular file at mode 0644 passed while the hook and the registration fail to start it. It now keeps the mode and uses the rule the sandbox programs already use: any execute bit counts. --- crates/baley/src/host_doctor/mod.rs | 4 +- crates/baley/src/host_doctor/placed.rs | 77 ++++++++++++++++++++++---- crates/baley/src/host_doctor/report.rs | 29 ++++++++++ 3 files changed, 97 insertions(+), 13 deletions(-) diff --git a/crates/baley/src/host_doctor/mod.rs b/crates/baley/src/host_doctor/mod.rs index ddd286c4..55aa08c7 100644 --- a/crates/baley/src/host_doctor/mod.rs +++ b/crates/baley/src/host_doctor/mod.rs @@ -98,7 +98,7 @@ pub struct Mapped { /// The placement map the host is judged against. pub map: PlacementMap, /// What is wrong with the map's executable, or none when it is a - /// regular file. + /// regular file with an execute bit. pub executable: Option, /// Every distinct path of the map's expected files, read once. pub reads: Vec, @@ -503,7 +503,7 @@ pub(crate) mod fixtures { } } - /// The observation of a map whose executable is a regular file and + /// The observation of a map whose executable is a runnable file and /// whose files were read as given. pub(crate) fn observed(map: PlacementMap, reads: Vec<(&str, FileState)>) -> Observation { Observation { diff --git a/crates/baley/src/host_doctor/placed.rs b/crates/baley/src/host_doctor/placed.rs index ffec66f9..32204833 100644 --- a/crates/baley/src/host_doctor/placed.rs +++ b/crates/baley/src/host_doctor/placed.rs @@ -9,6 +9,7 @@ //! the coverage judge would turn an empty document into invented gaps. use std::fmt; +use std::os::unix::fs::PermissionsExt; use std::path::Path; use serde_json::Value; @@ -103,6 +104,9 @@ pub enum ExecutableGap { DanglingLink, /// Something other than a regular file is there, a directory for one. NotRegular, + /// A regular file is there but no execute bit is set for anyone, so the + /// hook and the registration fail to start it. + NotExecutable, /// It could not be examined; the system's cause. Unreadable(String), } @@ -113,6 +117,7 @@ impl fmt::Display for ExecutableGap { ExecutableGap::Missing => f.write_str("does not exist"), ExecutableGap::DanglingLink => f.write_str("is a link to a missing file"), ExecutableGap::NotRegular => f.write_str("is not a regular file"), + ExecutableGap::NotExecutable => f.write_str("has no execute permission"), ExecutableGap::Unreadable(cause) => write!(f, "cannot be examined: {cause}"), } } @@ -122,24 +127,37 @@ impl fmt::Display for ExecutableGap { pub(crate) struct ExecutableSeen { /// Whether the path itself is a link, from `symlink_metadata`. pub(crate) link: std::io::Result, - /// Whether the path, with links followed, is a regular file. - pub(crate) target: std::io::Result, + /// The file at the path, with links followed. + pub(crate) target: std::io::Result, +} + +/// The file an executable's path leads to. +pub(crate) struct Target { + /// Whether it is a regular file. + pub(crate) regular_file: bool, + /// Its mode bits. + pub(crate) mode: u32, } /// Asks the filesystem about the executable. Nothing is opened or run. pub(crate) fn observe_executable(path: &Path) -> Option { executable_gap(ExecutableSeen { link: std::fs::symlink_metadata(path).map(|meta| meta.file_type().is_symlink()), - target: std::fs::metadata(path).map(|meta| meta.is_file()), + target: std::fs::metadata(path).map(|meta| Target { + regular_file: meta.is_file(), + mode: meta.permissions().mode(), + }), }) } -/// The executable's gap, or none when it is a regular file, reached through -/// a link or not. +/// The executable's gap, or none when it is a regular file with any execute +/// bit set, reached through a link or not. Any bit counts, as it does for +/// the sandbox programs in `prerequisites.rs`. pub(crate) fn executable_gap(seen: ExecutableSeen) -> Option { match seen.target { - Ok(true) => None, - Ok(false) => Some(ExecutableGap::NotRegular), + Ok(target) if !target.regular_file => Some(ExecutableGap::NotRegular), + Ok(target) if target.mode & 0o111 == 0 => Some(ExecutableGap::NotExecutable), + Ok(_) => None, Err(error) if error.kind() == std::io::ErrorKind::NotFound => match seen.link { Ok(true) => Some(ExecutableGap::DanglingLink), _ => Some(ExecutableGap::Missing), @@ -258,9 +276,15 @@ mod tests { #[test] fn a_directory_or_dangling_link_counted_as_the_executable_is_caught() { let not_found = || std::io::Error::from(ErrorKind::NotFound); - let seen = |link: std::io::Result, target: std::io::Result| { + let seen = |link: std::io::Result, target: std::io::Result| { executable_gap(ExecutableSeen { link, target }) }; + let file = |regular_file: bool| { + Ok(Target { + regular_file, + mode: 0o755, + }) + }; assert_eq!( seen(Err(not_found()), Err(not_found())), Some(ExecutableGap::Missing) @@ -269,9 +293,40 @@ mod tests { seen(Ok(true), Err(not_found())), Some(ExecutableGap::DanglingLink) ); - assert_eq!(seen(Ok(false), Ok(false)), Some(ExecutableGap::NotRegular)); - assert_eq!(seen(Ok(true), Ok(true)), None); - assert_eq!(seen(Ok(false), Ok(true)), None); + assert_eq!( + seen(Ok(false), file(false)), + Some(ExecutableGap::NotRegular) + ); + assert_eq!(seen(Ok(true), file(true)), None); + assert_eq!(seen(Ok(false), file(true)), None); + } + + #[test] + fn a_regular_file_with_no_execute_bit_counted_as_the_executable_is_caught() { + let seen = |mode: u32| { + executable_gap(ExecutableSeen { + link: Ok(false), + target: Ok(Target { + regular_file: true, + mode, + }), + }) + }; + for mode in [0o644, 0o600, 0o000] { + assert_eq!(seen(mode), Some(ExecutableGap::NotExecutable), "{mode:o}"); + } + // Any one bit lets someone start it, the rule the sandbox programs use. + for mode in [0o755, 0o100, 0o010, 0o001] { + assert_eq!(seen(mode), None, "{mode:o}"); + } + let directory = executable_gap(ExecutableSeen { + link: Ok(false), + target: Ok(Target { + regular_file: false, + mode: 0o000, + }), + }); + assert_eq!(directory, Some(ExecutableGap::NotRegular)); } fn entry(identity: &str) -> Entry { let manifest = diff --git a/crates/baley/src/host_doctor/report.rs b/crates/baley/src/host_doctor/report.rs index 2ec345bb..759c4436 100644 --- a/crates/baley/src/host_doctor/report.rs +++ b/crates/baley/src/host_doctor/report.rs @@ -785,6 +785,35 @@ mod tests { assert_eq!(freebsd.code, 1); } + #[test] + fn an_executable_with_no_execute_bit_left_out_of_the_report_and_its_code_is_caught() { + use super::super::placed::{ExecutableSeen, Target, executable_gap}; + + let mut observation = unknown_observation(); + let healthy = report_of(&observation); + assert_eq!(healthy.code, 0, "{:?}", healthy.lines); + assert!(!healthy.lines.iter().any(|line| line.contains("executable"))); + + let gap = executable_gap(ExecutableSeen { + link: Ok(false), + target: Ok(Target { + regular_file: true, + mode: 0o644, + }), + }); + observation.host.as_mut().unwrap().executable = gap; + let report = report_of(&observation); + assert!( + names( + &report, + &["executable", EXECUTABLE, "no execute permission"] + ), + "{:?}", + report.lines + ); + assert_eq!(report.code, 1, "{:?}", report.lines); + } + #[test] fn a_guard_credited_for_all_nine_tools_while_disable_all_hooks_is_true_is_caught() { let mut document = hook::render(&executable()); From f4eab164188d3db98af2c059d97a4649bf249f4f Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Thu, 8 Oct 2026 18:43:49 -0400 Subject: [PATCH 10/18] feat(host): report the server's project and working directory from the last recorded server call The ledger already records both on every server write. Judging them with the server's own rule keeps the doctor and the server from disagreeing, and reading no environment avoids a false missing-project finding from a plain terminal. --- crates/baley/src/host_doctor/mod.rs | 174 ++++++++++- crates/baley/src/host_doctor/report.rs | 150 +++++++++- .../baley/src/host_doctor/server_context.rs | 277 ++++++++++++++++++ crates/baley/src/ledger/commands.rs | 14 +- 4 files changed, 608 insertions(+), 7 deletions(-) create mode 100644 crates/baley/src/host_doctor/server_context.rs diff --git a/crates/baley/src/host_doctor/mod.rs b/crates/baley/src/host_doctor/mod.rs index 55aa08c7..b66ff1e2 100644 --- a/crates/baley/src/host_doctor/mod.rs +++ b/crates/baley/src/host_doctor/mod.rs @@ -27,10 +27,13 @@ pub mod placed; pub mod prerequisites; pub mod protection; pub mod report; +pub mod server_context; use std::ffi::OsStr; use std::path::{Path, PathBuf}; +use baley_store::{Ledger, ProjectId}; + pub use report::Report; use placed::{ExecutableGap, FileState, RegistrationJudgement, StubJudgement}; @@ -115,17 +118,32 @@ pub struct Observation { pub checkout_file: Option, /// The platform and where the sandbox's programs were found. pub prerequisites: prerequisites::Observed, + /// The last server call the ledger records. + pub server: server_context::Observed, +} + +/// The ledger side of the gather: the store to read history from and the +/// projects the doctor already listed. +pub struct Stored<'a, S> { + /// The store, read through the storage port's history only. + pub store: &'a S, + /// Every project in the ledger, in id order. + pub projects: &'a [ProjectId], } /// Gathers the observation for a placement map: reads each expected file -/// once and asks the filesystem about the executable. Nothing is written, -/// opened for writing or run. It owns no rule and has no unit test. -pub fn gather( +/// once, asks the filesystem about the executable and pages through the +/// ledger's history for the last server call. Nothing is written, opened for +/// writing or run. It owns no rule and has no unit test of its own; one +/// integration check runs it whole and asserts only that nothing was +/// written. +pub fn gather( placement: Result, folders: Folders, checkout_file: Option, path: Option<&OsStr>, os: &str, + stored: Stored<'_, S>, ) -> Observation { let host = placement.map(|map| { let executable = placed::observe_executable(Path::new(map.executable().as_str())); @@ -149,6 +167,7 @@ pub fn gather( folders, checkout_file, prerequisites: prerequisites::gather(path, os), + server: server_context::gather(stored.store, stored.projects), } } @@ -272,6 +291,8 @@ pub struct Findings { pub nine_tools: Option, /// The platform and the sandbox programs it needs, found on `PATH`. pub prerequisites: prerequisites::Judged, + /// What the last recorded server call shows of the server's context. + pub server: server_context::Judged, } /// Where an artifact sits in the order the report lists them: the stubs, @@ -298,6 +319,7 @@ pub fn judge(observation: &Observation) -> Findings { coverage: None, nine_tools: None, prerequisites: prerequisites::judge(&observation.prerequisites), + server: server_context::judge(&observation.server), }; } }; @@ -408,6 +430,7 @@ pub fn judge(observation: &Observation) -> Findings { )), nine_tools: protection::nine_tools(&mapped.map, &documents), prerequisites, + server: server_context::judge(&observation.server), documents, } } @@ -500,6 +523,7 @@ pub(crate) mod fixtures { folders: folders(), checkout_file: None, prerequisites: linux_with_both(), + server: server_context::Observed::NoCall, } } @@ -521,6 +545,7 @@ pub(crate) mod fixtures { folders: folders(), checkout_file: None, prerequisites: linux_with_both(), + server: server_context::Observed::NoCall, } } } @@ -576,3 +601,146 @@ mod tests { } } } + +#[cfg(test)] +mod integration { + use std::collections::BTreeMap; + use std::ffi::OsString; + + use baley_core::capture::CaptureKind; + use baley_store::{Admin, Hash, Ledger, ProjectId, RequestId, ServerCaller}; + use serde_json::json; + + use super::fixtures::{executable, folders, map}; + use super::*; + use crate::host_artifacts::compose::compose; + use crate::host_artifacts::{hook, security}; + use crate::mcp::capture::{CAPTURE_COMMAND, record}; + use crate::mcp::prepare::{WriteRequest, prepared_command}; + + const T0: &str = "2026-10-08T09:00:00Z"; + const A: &str = "6f1c2a4e-8b1d-4c3a-9e2f-0a5b7c9d1e3f"; + const B: &str = "7a2d3b5f-9c0e-4f1a-8b2c-3d4e5f6a7b8c"; + const SESSION: &str = "0b7e4a52-3c1d-4f6a-8e9b-1a2b3c4d5e6f"; + const REQUEST: &str = "9d0c1b7e-2f4a-4b6c-8d1e-3a5b7c9d0e2f"; + + /// Every file under `folder` with its bytes, sorted by path. + fn listing(folder: &Path) -> BTreeMap> { + let mut found = BTreeMap::new(); + let mut pending = vec![folder.to_path_buf()]; + while let Some(next) = pending.pop() { + for entry in std::fs::read_dir(&next).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + pending.push(path); + } else { + let bytes = std::fs::read(&path).unwrap(); + found.insert(path, bytes); + } + } + } + found + } + + /// Each listed project with the sequence of its last event. + fn heads(store: &S) -> Vec<(ProjectId, Option)> { + let ids: Vec = Admin::projects(store) + .unwrap() + .into_iter() + .map(|(id, _)| id) + .collect(); + ids.into_iter() + .map(|id| { + let head = store.head(&id).unwrap().map(|head| head.seq); + (id, head) + }) + .collect() + } + + #[test] + fn a_host_check_that_writes_the_ledger_or_a_placed_file_is_caught() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let (project, sub) = (root.join("project"), root.join("project/sub")); + std::fs::create_dir_all(&sub).unwrap(); + + let store = + crate::ledger::open::store(&root.join("home"), T0, crate::ledger::open::options()) + .unwrap(); + for (id, name) in [(A, "a"), (B, "b")] { + store + .create_project(&ProjectId(id.into()), name, T0) + .unwrap(); + } + let request = WriteRequest { + kind: baley_store::CommandKind(CAPTURE_COMMAND.into()), + request_id: RequestId(REQUEST.into()), + digest: Hash([1; 32]), + }; + let caller = ServerCaller::new( + project.to_str().unwrap(), + sub.to_str().unwrap(), + "claude-code", + SESSION, + &json!(7), + ) + .unwrap(); + let command = prepared_command(&ProjectId(A.into()), &request, 0, T0, &caller); + record(&store, &command, CaptureKind::Note, "a note", None).unwrap(); + + let place = root.join("place"); + let help = place.join("skills/bal-help/SKILL.md"); + let settings = place.join("settings.json"); + let registration = place.join("claude.json"); + std::fs::create_dir_all(help.parent().unwrap()).unwrap(); + let exe = executable(); + let proposal = security::propose(&folders(), &exe, &[]).settings; + let document = compose(None, &[proposal, hook::render(&exe)], &folders(), &exe).document; + std::fs::write(&settings, serde_json::to_vec(&document).unwrap()).unwrap(); + std::fs::write(®istration, b"{}").unwrap(); + let stub = stubs::manifest(&stubs::front_doors()) + .unwrap() + .into_iter() + .find(|entry| entry.identity == "bal-help") + .unwrap(); + std::fs::write(&help, &stub.bytes).unwrap(); + let empty_path = root.join("bin"); + std::fs::create_dir_all(&empty_path).unwrap(); + + let (settings_text, help_text, registration_text) = ( + settings.to_str().unwrap(), + help.to_str().unwrap(), + registration.to_str().unwrap(), + ); + let placement = map( + None, + Some(help_text), + Some(registration_text), + Some(settings_text), + Some(settings_text), + ); + + let files_before = listing(&place); + let projects_before = Admin::projects(&store).unwrap(); + let heads_before = heads(&store); + let ids: Vec = projects_before.iter().map(|(id, _)| id.clone()).collect(); + let search: OsString = empty_path.into(); + let observation = gather( + Ok(placement), + folders(), + None, + Some(search.as_os_str()), + "linux", + Stored { + store: &store, + projects: &ids, + }, + ); + let _ = Report::new(&judge(&observation)); + + assert_eq!(listing(&place), files_before); + assert_eq!(Admin::projects(&store).unwrap(), projects_before); + assert_eq!(heads(&store), heads_before); + assert_eq!(heads_before.len(), 2); + } +} diff --git a/crates/baley/src/host_doctor/report.rs b/crates/baley/src/host_doctor/report.rs index 759c4436..398c06ca 100644 --- a/crates/baley/src/host_doctor/report.rs +++ b/crates/baley/src/host_doctor/report.rs @@ -4,8 +4,9 @@ use std::path::Path; use super::prerequisites::{Judged as Prerequisites, Kind}; use super::protection::{Judged, NotJudged}; -use super::{ArtifactState, Findings, MapFault}; +use super::{ArtifactState, Findings, MapFault, server_context}; use crate::host_artifacts::coverage::{Access, Cause, Mechanism, Tool, Verdict}; +use crate::mcp::context::ProjectContext; /// The heading that opens the host section. const HEADING: &str = "Claude Code host checks"; @@ -134,6 +135,7 @@ impl Report { )); } } + server_lines(&findings.server, &mut lines); Report { lines, code: u8::from(gap), @@ -141,6 +143,39 @@ impl Report { } } +/// The server's context as the last recorded server call shows it. None of +/// these lines is a finding that raises the code: a working directory that +/// differs from the project is valid, and the lines only tell the owner what +/// to set when the project variable is not usable. +fn server_lines(judged: &server_context::Judged, lines: &mut Vec) { + match judged { + server_context::Judged::NoCall => { + lines.push("server context: no server call is recorded in the ledger yet".to_owned()); + } + server_context::Judged::ReadError(text) => lines.push(format!( + "server context: the last server call could not be read: {text}" + )), + server_context::Judged::Call { + project, + recorded, + working_directory, + recorded_at, + } => { + lines.push(match project { + ProjectContext::Valid(text) => format!("server CLAUDE_PROJECT_DIR: {text}"), + ProjectContext::Missing => "server CLAUDE_PROJECT_DIR is not set (project-context-missing): set it to the project's absolute folder".to_owned(), + ProjectContext::Invalid(fault) => format!( + "server CLAUDE_PROJECT_DIR {} cannot be the project (project-context-invalid): {fault}; restore the folder or start the session in an existing project so Claude Code sets it", + recorded.as_deref().unwrap_or_default() + ), + }); + lines.push(format!( + "server working directory: {working_directory} (from the last server call, recorded at {recorded_at})" + )); + } + } +} + /// The sandbox prerequisite lines for the platform. Returns whether the /// sandbox cannot run here: a platform it does not run on, or a program it /// needs that no `PATH` entry holds. @@ -837,4 +872,117 @@ mod tests { ); assert_eq!(report.code, 1, "{:?}", report.lines); } + + use super::super::server_context::{Observed as Server, ServerCall}; + + fn server_call(project: Option<&str>, is_directory: bool, working: &str) -> Server { + Server::Call(ServerCall { + project: project.map(str::to_owned), + project_is_directory: is_directory, + working_directory: working.to_owned(), + recorded_at: "2026-10-08T10:00:05Z".to_owned(), + }) + } + + fn with_server(server: Server) -> Report { + let mut observation = unknown_observation(); + observation.server = server; + report_of(&observation) + } + + #[test] + fn a_missing_or_invalid_project_variable_without_its_fix_is_caught() { + let missing = with_server(server_call(None, false, "/w/other")); + assert!( + names( + &missing, + &[ + "CLAUDE_PROJECT_DIR", + "project-context-missing", + "not set", + "set it to the project's absolute folder" + ] + ), + "{:?}", + missing.lines + ); + + let invalid = with_server(server_call(Some("/w/gone"), false, "/w/other")); + assert!( + names( + &invalid, + &[ + "CLAUDE_PROJECT_DIR", + "project-context-invalid", + "/w/gone", + "it is not an existing directory", + "restore the folder or start the session in an existing project so Claude Code sets it" + ] + ), + "{:?}", + invalid.lines + ); + for report in [&missing, &invalid] { + let working = line_for(report, "server working directory"); + assert!(working.contains("/w/other"), "{working}"); + assert!(!working.contains("project-context-"), "{working}"); + assert!( + !report + .lines + .iter() + .any(|line| line.contains("CLAUDE_PROJECT_DIR") && line.contains("/w/other")), + "{:?}", + report.lines + ); + } + } + + #[test] + fn a_project_and_working_directory_difference_called_a_finding_is_caught() { + for working in ["/w/p/sub", "/tmp"] { + let report = with_server(server_call(Some("/w/p"), true, working)); + let project = line_for(&report, "server CLAUDE_PROJECT_DIR"); + assert!(project.contains("/w/p"), "{project}"); + assert!(!project.contains(working), "{project}"); + let directory = line_for(&report, "server working directory"); + assert!(directory.contains(working), "{directory}"); + assert!( + !report + .lines + .iter() + .any(|line| line.contains("project-context-")), + "{:?}", + report.lines + ); + assert_eq!(report.code, 0, "{:?}", report.lines); + } + } + + #[test] + fn no_recorded_server_call_reported_as_a_missing_project_is_caught() { + let report = with_server(Server::NoCall); + assert_eq!( + report + .lines + .iter() + .filter(|line| line.contains("no server call is recorded")) + .count(), + 1, + "{:?}", + report.lines + ); + for word in [ + "project-context-missing", + "project-context-invalid", + "CLAUDE_PROJECT_DIR", + "working directory", + ] { + assert!( + !report.lines.iter().any(|line| line.contains(word)), + "{word}: {:?}", + report.lines + ); + } + assert_eq!(report.code, 0); + } } diff --git a/crates/baley/src/host_doctor/server_context.rs b/crates/baley/src/host_doctor/server_context.rs new file mode 100644 index 00000000..e9d1c1ac --- /dev/null +++ b/crates/baley/src/host_doctor/server_context.rs @@ -0,0 +1,277 @@ +//! The server's project and working directory, as the last recorded server +//! call shows them. +//! +//! The ledger keeps, on every event a server call appended, the +//! `CLAUDE_PROJECT_DIR` and the working directory the server started with. +//! Gathering pages through each project's history with the storage port's +//! existing read, keeps the latest server call and asks whether its project +//! folder is a directory now. Judging runs the server's own rule, +//! [`project_context`], over that text, so the doctor and the server cannot +//! disagree. The doctor process's own environment is never read: a plain +//! terminal has no `CLAUDE_PROJECT_DIR`, which would be a false finding. +//! +//! A server without the variable answers `project-context-missing` and +//! records nothing, so a missing variable never comes from the ledger. The +//! observation allows it anyway, because that is the server's rule, and +//! the tests prove the wording over a supplied observation. + +use std::ffi::OsStr; + +use baley_store::{ + Caller, EVENT_PAGE_BOUND, Event, HistoryFilter, Ledger, PageRequest, ProjectId, StoreError, + UtcInstant, +}; + +use crate::mcp::context::{ProjectContext, project_context}; + +/// One server call read from a project's history. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Recorded { + /// The event's sequence in its project. + pub seq: u64, + /// The `CLAUDE_PROJECT_DIR` the server recorded. + pub project_directory: String, + /// The working directory the server recorded. + pub working_directory: String, + /// When the event was recorded. + pub recorded_at: String, +} + +/// The last recorded server call and what was observed about its project +/// folder. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ServerCall { + /// The recorded `CLAUDE_PROJECT_DIR`, or none when the server had none. + pub project: Option, + /// Whether that path is an existing directory now, a link followed. + pub project_is_directory: bool, + /// The recorded working directory. + pub working_directory: String, + /// When the call was recorded. + pub recorded_at: String, +} + +/// What the ledger showed about the server's context. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Observed { + /// No event in any project carries a server caller. + NoCall, + /// The last recorded server call. + Call(ServerCall), + /// A history read failed; the store's text. + ReadError(String), +} + +/// The server call kept after one more page: the page's last event with a +/// server caller, or `kept` when the page holds none. Events are compared by +/// sequence, so the order of the page does not matter. +pub fn last_server_call(kept: Option, page: &[Event]) -> Option { + page.iter() + .filter_map(|event| match &event.caller { + Some(Caller::Server(caller)) => Some(Recorded { + seq: event.seq, + project_directory: caller.project_directory().to_owned(), + working_directory: caller.working_directory().to_owned(), + recorded_at: event.recorded_at.clone(), + }), + _ => None, + }) + .chain(kept) + .max_by_key(|call| call.seq) +} + +/// The call recorded latest across projects. `calls` holds one call per +/// project in project id order; instants are compared whole, fraction of a +/// second included, never as text, and a tie keeps the earlier project. A +/// time that does not parse never wins over one that does. +pub fn latest(calls: Vec) -> Option { + let mut best: Option<(Option, Recorded)> = None; + for call in calls { + let at = UtcInstant::parse(&call.recorded_at).ok(); + if !matches!(&best, Some((held, _)) if at <= *held) { + best = Some((at, call)); + } + } + best.map(|(_, call)| call) +} + +/// Reads every project's history and observes the latest server call. It +/// reads only; it owns no rule and has no unit test of its own. +pub fn gather(store: &S, projects: &[ProjectId]) -> Observed { + let mut calls = Vec::new(); + for project in projects { + match last_in(store, project) { + Ok(Some(call)) => calls.push(call), + Ok(None) => {} + Err(error) => return Observed::ReadError(format!("project {}: {error}", project.0)), + } + } + match latest(calls) { + None => Observed::NoCall, + Some(call) => Observed::Call(ServerCall { + project_is_directory: std::fs::metadata(&call.project_directory) + .is_ok_and(|meta| meta.is_dir()), + project: Some(call.project_directory), + working_directory: call.working_directory, + recorded_at: call.recorded_at, + }), + } +} + +/// One project's last server call, over its whole chain. +fn last_in(store: &S, project: &ProjectId) -> Result, StoreError> { + let filter = HistoryFilter::default(); + let mut kept = None; + let mut after = None; + loop { + let page = PageRequest { + limit: EVENT_PAGE_BOUND, + after, + }; + let page = store.history(project, 1..=u64::MAX, &filter, page)?; + kept = last_server_call(kept, &page.items); + match page.next { + Some(cursor) => after = Some(cursor), + None => return Ok(kept), + } + } +} + +/// The server's context, judged. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Judged { + /// No server call is recorded. + NoCall, + /// The read failed; the store's text. + ReadError(String), + /// The last server call. + Call { + /// The server's own judgement of the project variable. + project: ProjectContext, + /// The recorded project text, when the server had a variable. + recorded: Option, + /// The recorded working directory. It may differ from the project. + working_directory: String, + /// When the call was recorded. + recorded_at: String, + }, +} + +/// Judges the observation. The project variable goes through the server's +/// own rule, unchanged. +pub fn judge(observed: &Observed) -> Judged { + match observed { + Observed::NoCall => Judged::NoCall, + Observed::ReadError(text) => Judged::ReadError(text.clone()), + Observed::Call(call) => Judged::Call { + project: project_context( + call.project.as_deref().map(OsStr::new), + call.project_is_directory, + ), + recorded: call.project.clone(), + working_directory: call.working_directory.clone(), + recorded_at: call.recorded_at.clone(), + }, + } +} + +#[cfg(test)] +mod tests { + use baley_store::{Actor, Hash, HookCaller, RequestId, ServerCaller}; + use serde_json::json; + + use super::*; + + fn server(project: &str, working: &str) -> Caller { + Caller::Server( + ServerCaller::new( + project, + working, + "claude-code", + "0b7e4a52-3c1d-4f6a-8e9b-1a2b3c4d5e6f", + &json!(1), + ) + .unwrap(), + ) + } + + fn hook() -> Caller { + Caller::Hook(HookCaller::new("claude-code", "/hook/dir", "toolu_1").unwrap()) + } + + fn event(seq: u64, caller: Option) -> Event { + Event { + project_id: ProjectId("p".into()), + seq, + stream: "s".into(), + stream_version: seq, + type_name: "t".into(), + type_version: 1, + actor: Actor::Baley, + caller, + recorded_at: "2026-10-08T10:00:00Z".into(), + request_id: RequestId(format!("r{seq}")), + git: None, + policy_version: 0, + payload: json!({}), + prev_hash: None, + hash: Hash([0; 32]), + } + } + + fn recorded(at: &str, project: &str) -> Recorded { + Recorded { + seq: 1, + project_directory: project.into(), + working_directory: project.into(), + recorded_at: at.into(), + } + } + + #[test] + fn a_hook_caller_or_an_earlier_server_call_chosen_as_the_last_server_call_is_caught() { + let first = [ + event(1, Some(server("/p1", "/p1"))), + event(2, Some(hook())), + event(3, Some(server("/p3", "/p3/sub"))), + event(4, None), + ]; + let kept = last_server_call(None, &first).expect("a server call"); + assert_eq!( + ( + kept.seq, + kept.project_directory.as_str(), + kept.working_directory.as_str() + ), + (3, "/p3", "/p3/sub") + ); + + let second = [event(5, Some(hook())), event(6, None)]; + let kept = last_server_call(Some(kept), &second).expect("the earlier page's call"); + assert_eq!( + ( + kept.seq, + kept.project_directory.as_str(), + kept.working_directory.as_str() + ), + (3, "/p3", "/p3/sub") + ); + } + + #[test] + fn the_last_server_call_ordered_by_text_or_by_project_instead_of_time_is_caught() { + let chosen = |a: &str, b: &str| { + latest(vec![recorded(a, "/a"), recorded(b, "/b")]) + .expect("a call") + .project_directory + }; + assert_eq!( + chosen("2026-10-08T10:00:05.5Z", "2026-10-08T10:00:05Z"), + "/a" + ); + assert_eq!( + chosen("2026-10-08T10:00:05Z", "2026-10-08T10:00:05.5Z"), + "/b" + ); + } +} diff --git a/crates/baley/src/ledger/commands.rs b/crates/baley/src/ledger/commands.rs index 47fb0ef9..e6cb8068 100644 --- a/crates/baley/src/ledger/commands.rs +++ b/crates/baley/src/ledger/commands.rs @@ -153,19 +153,27 @@ fn doctor( .project_file .as_ref() .map(|file| file.path.clone()); - let host = host_report(folders.clone(), checkout_file); + let ids: Vec = projects.iter().map(|(id, _)| id.clone()).collect(); + let host = host_report(folders.clone(), checkout_file, store, &ids); Ok(display::with_host(rendered, &host)) } /// The host section: the running binary against a placement map with every -/// artifact unknown, so each one is reported as not installed. -fn host_report(folders: Folders, checkout_file: Option) -> host_doctor::Report { +/// artifact unknown, so each one is reported as not installed, and the +/// server's context from the last server call the ledger holds. +fn host_report( + folders: Folders, + checkout_file: Option, + store: &SqliteStore, + projects: &[ProjectId], +) -> host_doctor::Report { let observation = host_doctor::gather( host_doctor::all_unknown(std::env::current_exe()), folders, checkout_file, std::env::var_os("PATH").as_deref(), std::env::consts::OS, + host_doctor::Stored { store, projects }, ); host_doctor::Report::new(&host_doctor::judge(&observation)) } From 7a02132eb6048fc745153aa608f528de9d9a9038 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Thu, 8 Oct 2026 18:54:15 -0400 Subject: [PATCH 11/18] feat(host): tell the owner when a newer Baley wrote the ledger and which folders it lives in A ledger at a newer epoch opens read-only, so the owner needs to hear that this binary can only read it. The server's startup quick_check does not run from the command line, so the line says the integrity rows stand in for it. --- crates/baley/src/host_doctor/mod.rs | 34 +++++- crates/baley/src/host_doctor/report.rs | 93 ++++++++++++++++ crates/baley/src/host_doctor/store_health.rs | 107 +++++++++++++++++++ crates/baley/src/ledger/commands.rs | 9 +- 4 files changed, 240 insertions(+), 3 deletions(-) create mode 100644 crates/baley/src/host_doctor/store_health.rs diff --git a/crates/baley/src/host_doctor/mod.rs b/crates/baley/src/host_doctor/mod.rs index b66ff1e2..0b2899df 100644 --- a/crates/baley/src/host_doctor/mod.rs +++ b/crates/baley/src/host_doctor/mod.rs @@ -28,11 +28,12 @@ pub mod prerequisites; pub mod protection; pub mod report; pub mod server_context; +pub mod store_health; use std::ffi::OsStr; use std::path::{Path, PathBuf}; -use baley_store::{Ledger, ProjectId}; +use baley_store::{Health, Ledger, ProjectId}; pub use report::Report; @@ -120,6 +121,9 @@ pub struct Observation { pub prerequisites: prerequisites::Observed, /// The last server call the ledger records. pub server: server_context::Observed, + /// What the store's doctor returned, which the compatibility and + /// folder lines are judged from. + pub health: Health, } /// The ledger side of the gather: the store to read history from and the @@ -129,6 +133,8 @@ pub struct Stored<'a, S> { pub store: &'a S, /// Every project in the ledger, in id order. pub projects: &'a [ProjectId], + /// What the store's doctor already returned. + pub health: &'a Health, } /// Gathers the observation for a placement map: reads each expected file @@ -168,6 +174,7 @@ pub fn gather( checkout_file, prerequisites: prerequisites::gather(path, os), server: server_context::gather(stored.store, stored.projects), + health: stored.health.clone(), } } @@ -293,6 +300,8 @@ pub struct Findings { pub prerequisites: prerequisites::Judged, /// What the last recorded server call shows of the server's context. pub server: server_context::Judged, + /// The stored epoch against this binary's, and the folders. + pub store: store_health::Judged, } /// Where an artifact sits in the order the report lists them: the stubs, @@ -320,6 +329,7 @@ pub fn judge(observation: &Observation) -> Findings { nine_tools: None, prerequisites: prerequisites::judge(&observation.prerequisites), server: server_context::judge(&observation.server), + store: store_health::judge(&observation.health, &observation.folders), }; } }; @@ -431,6 +441,7 @@ pub fn judge(observation: &Observation) -> Findings { nine_tools: protection::nine_tools(&mapped.map, &documents), prerequisites, server: server_context::judge(&observation.server), + store: store_health::judge(&observation.health, &observation.folders), documents, } } @@ -491,6 +502,19 @@ pub(crate) mod fixtures { .unwrap() } + /// The store's health with nothing to find: this binary's epoch, a clean + /// integrity check and no projects. + pub(crate) fn clean_health() -> Health { + Health { + epoch: baley_store_sqlite::EPOCH, + scrub_pending: None, + integrity: vec!["ok".into()], + database_bytes: 8192, + log_bytes: 0, + projects: vec![], + } + } + /// A Linux search that finds both sandbox programs. pub(crate) fn linux_with_both() -> prerequisites::Observed { found_on("linux", &["bwrap", "socat"]) @@ -524,6 +548,7 @@ pub(crate) mod fixtures { checkout_file: None, prerequisites: linux_with_both(), server: server_context::Observed::NoCall, + health: clean_health(), } } @@ -546,6 +571,7 @@ pub(crate) mod fixtures { checkout_file: None, prerequisites: linux_with_both(), server: server_context::Observed::NoCall, + health: clean_health(), } } } @@ -722,6 +748,11 @@ mod integration { let files_before = listing(&place); let projects_before = Admin::projects(&store).unwrap(); + let checks: BTreeMap = projects_before + .iter() + .map(|(id, _)| (id.clone(), baley_store::AnchorCheck::LocalOnly)) + .collect(); + let health = Admin::doctor(&store, T0, &checks).unwrap(); let heads_before = heads(&store); let ids: Vec = projects_before.iter().map(|(id, _)| id.clone()).collect(); let search: OsString = empty_path.into(); @@ -734,6 +765,7 @@ mod integration { Stored { store: &store, projects: &ids, + health: &health, }, ); let _ = Report::new(&judge(&observation)); diff --git a/crates/baley/src/host_doctor/report.rs b/crates/baley/src/host_doctor/report.rs index 398c06ca..65ce4d1f 100644 --- a/crates/baley/src/host_doctor/report.rs +++ b/crates/baley/src/host_doctor/report.rs @@ -4,6 +4,7 @@ use std::path::Path; use super::prerequisites::{Judged as Prerequisites, Kind}; use super::protection::{Judged, NotJudged}; +use super::store_health::{Compatibility, Judged as Store}; use super::{ArtifactState, Findings, MapFault, server_context}; use crate::host_artifacts::coverage::{Access, Cause, Mechanism, Tool, Verdict}; use crate::mcp::context::ProjectContext; @@ -136,6 +137,7 @@ impl Report { } } server_lines(&findings.server, &mut lines); + gap |= store_lines(&findings.store, &mut lines); Report { lines, code: u8::from(gap), @@ -176,6 +178,41 @@ fn server_lines(judged: &server_context::Judged, lines: &mut Vec) { } } +/// The compatibility, write fence, home and config lines. Returns whether the +/// ledger is newer than this binary can write, the only finding among them. +fn store_lines(judged: &Store, lines: &mut Vec) -> bool { + let newer = match judged.compatibility { + Compatibility::Matches => { + lines.push(format!( + "compatibility: the ledger is at epoch {0}, the epoch of this binary", + baley_store_sqlite::EPOCH + )); + false + } + Compatibility::Newer { epoch } => { + lines.push(format!( + "compatibility: the ledger is at epoch {epoch}, newer than this binary's epoch {}: it is read-only for this binary, which can only read it, and a build at epoch {epoch} is needed to write it", + baley_store_sqlite::EPOCH + )); + true + } + }; + lines.push("write fence: the server's startup quick_check is not observed from the command line, and the integrity_check rows above stand in for it".to_owned()); + lines.push(format!( + "home: {} opened with its ownership, mode and link checks passed", + judged.home.display() + )); + lines.push(if judged.one_folder { + format!( + "config folder: {}, the same folder as the home", + judged.config.display() + ) + } else { + format!("config folder: {}", judged.config.display()) + }); + newer +} + /// The sandbox prerequisite lines for the platform. Returns whether the /// sandbox cannot run here: a platform it does not run on, or a program it /// needs that no `PATH` entry holds. @@ -985,4 +1022,60 @@ mod tests { } assert_eq!(report.code, 0); } + + fn with_health(health: baley_store::Health) -> Report { + let mut observation = unknown_observation(); + observation.health = health; + report_of(&observation) + } + + #[test] + fn a_newer_ledger_epoch_not_reported_as_read_only_is_caught() { + use baley_store_sqlite::EPOCH; + + for newer in [EPOCH + 1, EPOCH + 2] { + let mut health = clean_health(); + health.epoch = newer; + let report = with_health(health); + let epoch = newer.to_string(); + let own = EPOCH.to_string(); + assert!( + names( + &report, + &[ + epoch.as_str(), + own.as_str(), + "read-only for this binary", + "can only read it", + "a build at epoch" + ] + ), + "{:?}", + report.lines + ); + assert_eq!(report.code, 1, "{:?}", report.lines); + } + } + + #[test] + fn the_write_fence_limit_left_unstated_is_caught() { + for integrity in [vec!["ok".to_owned()], vec!["damaged page".to_owned()]] { + let mut health = clean_health(); + health.integrity = integrity.clone(); + let report = with_health(health); + let fences: Vec<&String> = report + .lines + .iter() + .filter(|line| line.contains("quick_check")) + .collect(); + assert_eq!(fences.len(), 1, "{integrity:?}: {:?}", report.lines); + assert!( + fences[0].contains("not observed from the command line") + && fences[0].contains("integrity_check"), + "{}", + fences[0] + ); + assert_eq!(report.code, 0, "{integrity:?}: {:?}", report.lines); + } + } } diff --git a/crates/baley/src/host_doctor/store_health.rs b/crates/baley/src/host_doctor/store_health.rs new file mode 100644 index 00000000..956f0719 --- /dev/null +++ b/crates/baley/src/host_doctor/store_health.rs @@ -0,0 +1,107 @@ +//! What the store's own health tells the host section: whether this binary +//! can write the ledger, and which folders the ledger and the settings live +//! in. +//! +//! Everything here is judged from values the doctor already holds: the +//! [`Health`] that the store's doctor returned, the resolved [`Folders`] and +//! this binary's compatibility epoch. Nothing is read or written. The home +//! folder was opened with its ownership, mode and link checks before the +//! doctor ran, since the open refuses an unsafe home. The config folder gets +//! no mode, owner or link judgement, and its `keys` file is never opened. + +use std::path::PathBuf; + +use baley_store::Health; +use baley_store_sqlite::EPOCH; + +use crate::folders::Folders; + +/// What the stored epoch means for this binary. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Compatibility { + /// The ledger is at this binary's epoch. + Matches, + /// A newer Baley wrote the ledger: this binary can only read it. + Newer { + /// The epoch the ledger holds, which a writing build must have. + epoch: u32, + }, +} + +/// The store's health as the host section needs it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Judged { + /// The stored epoch against this binary's. + pub compatibility: Compatibility, + /// The ledger home, which opened with its checks passed. + pub home: PathBuf, + /// The configuration folder. + pub config: PathBuf, + /// Whether the two are one folder, as `BALEY_HOME` and macOS make them. + pub one_folder: bool, +} + +/// Judges the stored epoch and the folders. An epoch below this binary's +/// never reaches the doctor, because opening the store refuses it, so a +/// stored epoch that is not newer is this binary's own. The folders are one +/// folder only when the two paths are the same path; a config folder inside +/// the home is another folder. +pub fn judge(health: &Health, folders: &Folders) -> Judged { + let compatibility = if health.epoch > EPOCH { + Compatibility::Newer { + epoch: health.epoch, + } + } else { + Compatibility::Matches + }; + Judged { + compatibility, + home: folders.home.clone(), + config: folders.config.clone(), + one_folder: folders.home == folders.config, + } +} + +#[cfg(test)] +mod tests { + use super::super::fixtures::clean_health; + use super::*; + + fn folders(home: &str, config: &str) -> Folders { + Folders { + home: home.into(), + config: config.into(), + } + } + + #[test] + fn the_binarys_own_epoch_reported_as_read_only_is_caught() { + let mut health = clean_health(); + health.epoch = EPOCH; + assert_eq!( + judge(&health, &folders("/h", "/c")).compatibility, + Compatibility::Matches + ); + health.epoch = EPOCH + 1; + assert_eq!( + judge(&health, &folders("/h", "/c")).compatibility, + Compatibility::Newer { epoch: EPOCH + 1 } + ); + } + + #[test] + fn a_config_folder_shared_with_the_home_left_unremarked_is_caught() { + let health = clean_health(); + for (home, config, shared) in [ + ("/h", "/h", true), + ("/h", "/c", false), + ("/h", "/h/config", false), + ] { + assert_eq!( + judge(&health, &folders(home, config)).one_folder, + shared, + "home {home}, config {config}" + ); + } + } +} diff --git a/crates/baley/src/ledger/commands.rs b/crates/baley/src/ledger/commands.rs index e6cb8068..82db05c2 100644 --- a/crates/baley/src/ledger/commands.rs +++ b/crates/baley/src/ledger/commands.rs @@ -154,7 +154,7 @@ fn doctor( .as_ref() .map(|file| file.path.clone()); let ids: Vec = projects.iter().map(|(id, _)| id.clone()).collect(); - let host = host_report(folders.clone(), checkout_file, store, &ids); + let host = host_report(folders.clone(), checkout_file, store, &ids, &health); Ok(display::with_host(rendered, &host)) } @@ -166,6 +166,7 @@ fn host_report( checkout_file: Option, store: &SqliteStore, projects: &[ProjectId], + health: &Health, ) -> host_doctor::Report { let observation = host_doctor::gather( host_doctor::all_unknown(std::env::current_exe()), @@ -173,7 +174,11 @@ fn host_report( checkout_file, std::env::var_os("PATH").as_deref(), std::env::consts::OS, - host_doctor::Stored { store, projects }, + host_doctor::Stored { + store, + projects, + health, + }, ); host_doctor::Report::new(&host_doctor::judge(&observation)) } From bd12b616a7fef3ea18d9e0fa52ed706b6a1dd1bd Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Thu, 8 Oct 2026 19:01:40 -0400 Subject: [PATCH 12/18] feat(host): tell the owner to rebuild the guard's records that were behind at the doctor's start The store's doctor reads the user project's view stamps before it verifies the views, and verifying brings an old project current. Judging the raw stamps shows the state the guard saw, so the line can name baley rebuild user while it still matters. --- crates/baley/src/host_doctor/guard_records.rs | 137 ++++++++++++++ crates/baley/src/host_doctor/mod.rs | 92 ++++++++++ crates/baley/src/host_doctor/report.rs | 168 +++++++++++++++++- 3 files changed, 396 insertions(+), 1 deletion(-) create mode 100644 crates/baley/src/host_doctor/guard_records.rs diff --git a/crates/baley/src/host_doctor/guard_records.rs b/crates/baley/src/host_doctor/guard_records.rs new file mode 100644 index 00000000..2d10739a --- /dev/null +++ b/crates/baley/src/host_doctor/guard_records.rs @@ -0,0 +1,137 @@ +//! Whether the guard's per-user records were behind this binary when the +//! doctor started. +//! +//! The guard keeps its decisions in the per-user project `user`. When that +//! project's views are behind this binary's, the guard answers needs-rebuild +//! and records nothing until `baley rebuild user` runs. The store's doctor +//! reads each project's raw view stamps before it verifies the views, and +//! verifying brings an old project current, so the stamps in the [`Health`] +//! it returned show the state the doctor started with. +//! +//! The judgement mirrors the store adapter's rule for a project's live views +//! as far as the raw stamps show it. A stored view this binary no longer +//! declares is not in the stamps, so a project that holds only such a view +//! is not seen here. + +use baley_core::catalog::USER_PROJECT; +use baley_store::Health; + +/// What the `user` project's views are to this binary. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Judged { + /// The ledger holds no `user` project yet. + NoUserProject, + /// The views are this binary's, or the project is empty and unstamped. + Current, + /// The views were built by an older binary: the guard needs a rebuild. + Behind, + /// A newer binary built the views. A rebuild would take them backward, so + /// there is no rebuild finding; the doctor's other lines report it. + Newer, + /// The stamps or the chain could not be read, which the doctor's other + /// lines report. + NotJudged, +} + +/// Judges the `user` project's raw views from the store's health, and no +/// other project's. +pub fn judge(health: &Health) -> Judged { + let Some(user) = health + .projects + .iter() + .find(|project| project.project.0 == USER_PROJECT) + else { + return Judged::NoUserProject; + }; + let Ok(raw) = &user.raw_views else { + return Judged::NotJudged; + }; + let (live_set, binary_set) = raw.view_set; + if live_set.is_some_and(|set| set > binary_set) + || raw.views.iter().any(|view| { + view.live_version + .is_some_and(|live| live > view.binary_version) + }) + { + return Judged::Newer; + } + let stamped = live_set.is_some() || raw.views.iter().any(|view| view.live_version.is_some()); + let behind = if stamped { + live_set.is_some_and(|set| set != binary_set) + || raw + .views + .iter() + .any(|view| view.live_version != Some(view.binary_version)) + } else { + // Nothing is stamped. An empty project needs no stamps from a rebuild, + // the guard stamps it itself, and one with events needs a rebuild. + match &user.verify { + Ok(report) => report.chain.head.is_some(), + Err(_) => return Judged::NotJudged, + } + }; + if behind { + Judged::Behind + } else { + Judged::Current + } +} + +#[cfg(test)] +mod tests { + use baley_store::StoreError; + + use super::super::fixtures::{health_with, project_health, raw_views}; + use super::*; + + fn behind_views() -> baley_store::RawViewHealth { + raw_views(Some(6), 7, &[(Some(1), 1), (Some(3), 3)]) + } + + fn current_views() -> baley_store::RawViewHealth { + raw_views(Some(7), 7, &[(Some(1), 1), (Some(3), 3)]) + } + + #[test] + fn a_project_other_than_user_judged_for_the_guard_is_caught() { + let other_behind = health_with(vec![ + project_health("P", Ok(behind_views()), Some(5)), + project_health(USER_PROJECT, Ok(current_views()), Some(5)), + ]); + assert_eq!(judge(&other_behind), Judged::Current); + + let user_behind = health_with(vec![ + project_health("P", Ok(current_views()), Some(5)), + project_health(USER_PROJECT, Ok(behind_views()), Some(5)), + ]); + assert_eq!(judge(&user_behind), Judged::Behind); + + let no_user = health_with(vec![project_health("P", Ok(behind_views()), Some(5))]); + assert_eq!(judge(&no_user), Judged::NoUserProject); + } + + #[test] + fn an_empty_user_project_called_behind_or_a_used_one_without_stamps_called_current_is_caught() { + let unstamped = || raw_views(None, 7, &[(None, 1), (None, 3)]); + + let empty = health_with(vec![project_health(USER_PROJECT, Ok(unstamped()), None)]); + assert_eq!(judge(&empty), Judged::Current); + + let used = health_with(vec![project_health(USER_PROJECT, Ok(unstamped()), Some(3))]); + assert_eq!(judge(&used), Judged::Behind); + + let mut failed = project_health(USER_PROJECT, Ok(unstamped()), Some(3)); + failed.verify = Err(StoreError::Busy); + assert_eq!(judge(&health_with(vec![failed])), Judged::NotJudged); + } + + #[test] + fn newer_user_views_called_needs_rebuild_is_caught() { + let newer_view = raw_views(Some(7), 7, &[(Some(2), 1), (Some(3), 3)]); + let newer_set = raw_views(Some(8), 7, &[(Some(1), 1), (Some(3), 3)]); + for raw in [newer_view, newer_set] { + let health = health_with(vec![project_health(USER_PROJECT, Ok(raw), Some(5))]); + assert_eq!(judge(&health), Judged::Newer); + } + } +} diff --git a/crates/baley/src/host_doctor/mod.rs b/crates/baley/src/host_doctor/mod.rs index 0b2899df..e48eec30 100644 --- a/crates/baley/src/host_doctor/mod.rs +++ b/crates/baley/src/host_doctor/mod.rs @@ -23,6 +23,7 @@ //! belongs: an artifact whose placement is unknown is reported as not //! installed, and that alone never raises the exit status. +pub mod guard_records; pub mod placed; pub mod prerequisites; pub mod protection; @@ -302,6 +303,8 @@ pub struct Findings { pub server: server_context::Judged, /// The stored epoch against this binary's, and the folders. pub store: store_health::Judged, + /// Whether the guard's per-user records were behind at the start. + pub guard_records: guard_records::Judged, } /// Where an artifact sits in the order the report lists them: the stubs, @@ -330,6 +333,7 @@ pub fn judge(observation: &Observation) -> Findings { prerequisites: prerequisites::judge(&observation.prerequisites), server: server_context::judge(&observation.server), store: store_health::judge(&observation.health, &observation.folders), + guard_records: guard_records::judge(&observation.health), }; } }; @@ -442,6 +446,7 @@ pub fn judge(observation: &Observation) -> Findings { prerequisites, server: server_context::judge(&observation.server), store: store_health::judge(&observation.health, &observation.folders), + guard_records: guard_records::judge(&observation.health), documents, } } @@ -515,6 +520,93 @@ pub(crate) mod fixtures { } } + /// The raw view state of a project: the live view set against the + /// binary's, and each view's live version against its binary version. + pub(crate) fn raw_views( + live_set: Option, + binary_set: u32, + views: &[(Option, u32)], + ) -> baley_store::RawViewHealth { + baley_store::RawViewHealth { + views: views + .iter() + .enumerate() + .map(|(index, (live, binary))| baley_store::ViewHealth { + view: format!("view{index}"), + live_version: *live, + binary_version: *binary, + }) + .collect(), + view_set: (live_set, binary_set), + building: None, + } + } + + /// A project whose chain verified, with `head` as its last sequence. + pub(crate) fn project_health( + id: &str, + raw_views: Result, + head: Option, + ) -> baley_store::ProjectHealth { + use baley_store::{ + AnchorCheck, AnchorVerdict, ChainReport, ClaimCounts, Hash, Head, + StoredAnchorComparison, UnanchoredAge, VerifyReport, ViewsReport, + }; + baley_store::ProjectHealth { + project: ProjectId(id.into()), + check: AnchorCheck::LocalOnly, + verify: Ok(VerifyReport { + chain: ChainReport { + head: head.map(|seq| Head { + seq, + hash: Hash([5; 32]), + }), + first_break: None, + anchor: AnchorVerdict::NoAnchor, + unanchored: None, + acknowledged_restores: vec![], + age_unanchored_since: None, + }, + payloads: vec![], + bodies_checked: 0, + tombstones_checked: 0, + stored_anchor: None, + stored_anchor_comparison: StoredAnchorComparison::NotCompared, + }), + remote_absent_local_row: None, + unanchored: UnanchoredAge::None, + raw_views, + views_check: Ok(ViewsReport { + checked_seq: head.unwrap_or(0), + differing: vec![], + }), + claims: Ok(ClaimCounts { + active: 0, + interrupted: 0, + awaiting_owner: 0, + }), + } + } + + /// The clean health holding exactly these projects. + pub(crate) fn health_with(projects: Vec) -> Health { + Health { + projects, + ..clean_health() + } + } + + /// A health whose `user` project has views behind this binary's, or at + /// them. + pub(crate) fn user_health(behind: bool) -> Health { + let set = if behind { 6 } else { 7 }; + health_with(vec![project_health( + baley_core::catalog::USER_PROJECT, + Ok(raw_views(Some(set), 7, &[(Some(1), 1), (Some(3), 3)])), + Some(4), + )]) + } + /// A Linux search that finds both sandbox programs. pub(crate) fn linux_with_both() -> prerequisites::Observed { found_on("linux", &["bwrap", "socat"]) diff --git a/crates/baley/src/host_doctor/report.rs b/crates/baley/src/host_doctor/report.rs index 65ce4d1f..5f6e2cc8 100644 --- a/crates/baley/src/host_doctor/report.rs +++ b/crates/baley/src/host_doctor/report.rs @@ -2,6 +2,7 @@ use std::path::Path; +use super::guard_records::Judged as GuardRecords; use super::prerequisites::{Judged as Prerequisites, Kind}; use super::protection::{Judged, NotJudged}; use super::store_health::{Compatibility, Judged as Store}; @@ -138,6 +139,7 @@ impl Report { } server_lines(&findings.server, &mut lines); gap |= store_lines(&findings.store, &mut lines); + gap |= guard_record_lines(&findings.guard_records, &mut lines); Report { lines, code: u8::from(gap), @@ -213,6 +215,23 @@ fn store_lines(judged: &Store, lines: &mut Vec) -> bool { newer } +/// The line on the guard's per-user records. Returns whether they were +/// behind this binary when the doctor started, which needs a rebuild. +fn guard_record_lines(judged: &GuardRecords, lines: &mut Vec) -> bool { + lines.push(match judged { + GuardRecords::NoUserProject => { + "guard records: there are no per-user guard records yet".to_owned() + } + GuardRecords::Current => { + "guard records: the views of the user project are current for this binary".to_owned() + } + GuardRecords::Behind => "guard records: the views of the user project were behind this binary's when the doctor started, so the guard answers needs-rebuild and leaves its decisions unrecorded until `baley rebuild user` runs; this doctor's own view check may already have brought them current".to_owned(), + GuardRecords::Newer => "guard records: a newer Baley built the views of the user project, which the lines above report, so a rebuild does not apply".to_owned(), + GuardRecords::NotJudged => "guard records: not judged because the views or the chain of the user project could not be read, which the lines above report".to_owned(), + }); + *judged == GuardRecords::Behind +} + /// The sandbox prerequisite lines for the platform. Returns whether the /// sandbox cannot run here: a platform it does not run on, or a program it /// needs that no `PATH` entry holds. @@ -376,10 +395,11 @@ mod tests { use super::super::fixtures::*; use super::super::placed::{Fault, FileState}; - use super::super::{MapFault, Observation, all_unknown, judge}; + use super::super::{MapFault, Observation, PlacedRead, all_unknown, judge}; use super::*; use crate::host_artifacts::compose::compose; use crate::host_artifacts::executable::{MissingPrerequisite, PathFault}; + use crate::host_artifacts::stubs; use crate::host_artifacts::{hook, security}; fn unknown_observation() -> Observation { @@ -1078,4 +1098,150 @@ mod tests { assert_eq!(report.code, 0, "{integrity:?}: {:?}", report.lines); } } + + #[test] + fn guard_records_behind_at_doctors_start_without_the_rebuild_step_are_caught() { + use baley_core::catalog::USER_PROJECT; + + let behind_set = with_health(user_health(true)); + let current = with_health(user_health(false)); + assert!( + names( + &behind_set, + &[ + "user", + "behind this binary's", + "when the doctor started", + "baley rebuild user" + ] + ), + "{:?}", + behind_set.lines + ); + assert_eq!(behind_set.code, 1, "{:?}", behind_set.lines); + assert!( + !current + .lines + .iter() + .any(|line| line.contains("baley rebuild user")), + "{:?}", + current.lines + ); + assert_eq!(current.code, 0, "{:?}", current.lines); + + let one_view_behind = health_with(vec![project_health( + USER_PROJECT, + Ok(raw_views(Some(7), 7, &[(Some(1), 1), (Some(2), 3)])), + Some(4), + )]); + let report = with_health(one_view_behind); + assert!( + names( + &report, + &["user", "behind this binary's", "baley rebuild user"] + ), + "{:?}", + report.lines + ); + assert_eq!(report.code, 1, "{:?}", report.lines); + } + + /// A host with nothing to find, health aside: every placement unknown, + /// both programs found on Linux and no server call. + fn quiet() -> Observation { + let mut observation = unknown_observation(); + observation.health = user_health(false); + observation + } + + /// The `bal-help` stub's bytes with one byte changed. + fn changed_stub_bytes() -> Vec { + let mut bytes = stubs::manifest(&stubs::front_doors()) + .unwrap() + .into_iter() + .find(|entry| entry.identity == "bal-help") + .unwrap() + .bytes; + bytes[0] ^= 1; + bytes + } + + /// Settings and hook in one document edited by `edit`, otherwise quiet. + fn one_document_of(edit: impl FnOnce(&mut serde_json::Value)) -> Observation { + let mut document = composed_value(&[SETTINGS]); + edit(&mut document); + let mut observation = observed( + map(None, None, None, Some(SETTINGS), Some(SETTINGS)), + vec![( + SETTINGS, + FileState::Bytes(serde_json::to_vec(&document).unwrap()), + )], + ); + observation.health = user_health(false); + observation + } + + #[test] + fn an_unknown_placement_raising_the_code_or_a_host_gap_left_at_zero_is_caught() { + use serde_json::json; + + let code = |observation: &Observation| report_of(observation).code; + let quiet_report = report_of(&quiet()); + assert_eq!(quiet_report.code, 0, "{:?}", quiet_report.lines); + let mut invalid = quiet(); + invalid.server = server_call(Some("/w/gone"), false, "/w/other"); + assert_eq!(code(&invalid), 0); + + let matcher_without_bash = "Monitor|PowerShell|Read|Grep|Glob|Write|Edit|NotebookEdit"; + let mut stub = observed( + map(None, Some(HELP), None, None, None), + vec![(HELP, FileState::Bytes(changed_stub_bytes()))], + ); + stub.health = user_health(false); + let mut no_bwrap = quiet(); + no_bwrap.prerequisites = found_on("linux", &["socat"]); + let mut freebsd = quiet(); + freebsd.prerequisites = found_on("freebsd", &[]); + let mut newer = quiet(); + newer.health.epoch = baley_store_sqlite::EPOCH + 1; + let mut behind = quiet(); + behind.health = user_health(true); + let singles = [ + ("a changed stub", stub), + ( + "the sandbox off", + one_document_of(|d| d["sandbox"]["enabled"] = json!(false)), + ), + ( + "Bash dropped from the matcher", + one_document_of(|d| { + d["hooks"]["PreToolUse"][0]["matcher"] = json!(matcher_without_bash) + }), + ), + ("bwrap not found", no_bwrap), + ("freebsd", freebsd), + ("a newer epoch", newer), + ("user behind", behind), + ]; + for (name, observation) in &singles { + let report = report_of(observation); + assert_eq!(report.code, 1, "{name}: {:?}", report.lines); + } + + let mut all = one_document_of(|d| { + d["sandbox"]["enabled"] = json!(false); + d["hooks"]["PreToolUse"][0]["matcher"] = json!(matcher_without_bash); + }); + let mapped = all.host.as_mut().unwrap(); + mapped.map = map(None, Some(HELP), None, Some(SETTINGS), Some(SETTINGS)); + mapped.reads.push(PlacedRead { + path: HELP.into(), + state: FileState::Bytes(changed_stub_bytes()), + }); + all.prerequisites = found_on("linux", &["socat"]); + all.health = user_health(true); + all.health.epoch = baley_store_sqlite::EPOCH + 1; + let report = report_of(&all); + assert_eq!(report.code, 1, "{:?}", report.lines); + } } From 39233126da938559a327a25291a661e18ebf560a Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Thu, 8 Oct 2026 19:11:26 -0400 Subject: [PATCH 13/18] docs(design): describe the runtime doctor in the guard and host designs The guard and host designs still gave the doctor's host checks to T13. They now list what the doctor reports, what it cannot see, and keep the checks of the installed result as T17's planned work. --- docs/design/0010-guard.md | 21 ++++++++++++++------- docs/design/0012-host-interface.md | 22 +++++++++++++++++----- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/docs/design/0010-guard.md b/docs/design/0010-guard.md index 56ef30c2..710f49ed 100644 --- a/docs/design/0010-guard.md +++ b/docs/design/0010-guard.md @@ -129,8 +129,15 @@ No model is dispatched by this area. ### baley doctor (the guard's part) -- **Inputs:** the host name, and the placement map's expected files and protected paths (`crates/baley/src/host_artifacts/placement.rs:231-296`), which T13 observes on disk. -- **Outputs:** whether the hook is installed and points at this binary; whether the sandbox keeps an agent from writing Baley's home and config folder, and from reading them, judged per tool and per read or write from the installed settings by the coverage judge (`crates/baley/src/host_artifacts/coverage.rs:281-356`); the answer forms the host honours. +- **Inputs:** the host name, the placement map's expected files and protected paths (`crates/baley/src/host_artifacts/placement.rs:231-296`), the settings and hook documents the map places, and the `user` project's view stamps from the store's own doctor. Until T15 supplies a placement, the command line gives a map whose every artifact has an unknown place (`crates/baley/src/host_doctor/mod.rs:69-89`), so the settings, hook, registration and stubs all read as not installed and nothing is judged against an invented document. +- **Outputs:** the host section the doctor prints after its ledger lines (`crates/baley/src/host_doctor/report.rs:26-148`). For the settings and hook documents it is given, it reports: + - whether a `PreToolUse` item runs the guard for all nine tools of the hook's matcher, and whether `disableAllHooks` is true. The coverage judge credits `Bash`, `Monitor` and `PowerShell` to the sandbox and never asks about the guard for them, so this is a separate check (`crates/baley/src/host_doctor/protection.rs:97-161`). + - which mechanism covers Baley's home and config folder for each tool and for each read or write, from the coverage judge (`crates/baley/src/host_doctor/protection.rs:48-92`, `crates/baley/src/host_doctor/report.rs:265-322`). Each verdict is worded as configuration of the named document, not as proof that Claude Code enforces it, and `Grep` and `Glob` are marked best-effort. + - the programs the sandbox needs, looked up on the command's own `PATH`: `bwrap` and `socat` on Linux, none on macOS, and the sandbox unsupported on any other platform. A missing program marks the sandbox unsupported for the coverage judge (`crates/baley/src/host_doctor/prerequisites.rs:75-182`). + - whether the `user` project's views were behind this binary's when the doctor started, naming `baley rebuild user` (`crates/baley/src/host_doctor/guard_records.rs:38-78`, `crates/baley/src/host_doctor/report.rs:220-233`). The store's doctor reads the stamps before it verifies the views, and verifying brings them current, so the line says what the guard would have found. +- **Exit status:** a gap in a given document, a missing sandbox program, an unsupported platform, a ledger at a newer epoch and views behind this binary's raise it to 1. An artifact that is not installed does not. No host finding gives 2 or 3. +- **Limits:** only the documents given are judged. Other Claude Code settings files and `sandbox.enabledPlatforms` can change what applies, and the report says so. The doctor carries no live evidence: how Claude Code honours each answer is the dated host-matrix record cited in section 11, not a doctor output. A stored view this binary no longer declares is not in the stamps, so it is not seen. WSL1, containers that block bubblewrap, Ubuntu's AppArmor rule on user namespaces, ripgrep and the seccomp filter are not checked. +- **Planned (T17):** the delivery checks over what `baley install` wrote, among them that the hook points at the stable path and that the installed settings hold the rendered content. They run through the same observe, judge and report steps over a placement map the installer supplies ([ADR 0038](../adr/0038-installer-and-opt-in-updates.md)). - **Refusals:** none; findings are reported (GRD-R13). ## 6. Records @@ -315,7 +322,7 @@ Not applicable. The guard gives no instructions; an agent sees only the host's o ## 11. Build status -`baley guard` runs the library hook (`crates/baley/src/main.rs:127`, `crates/baley/src/guard_hook/mod.rs:36-150`). It reads one call from standard input, binds the project from `CLAUDE_PROJECT_DIR`, judges the call with `baley-core`'s guard module (`crates/baley-core/src/guard/`) and the library's `hook_input` and `protected_paths` modules, records what it must in the per-user project `user`, and answers in Claude Code's hook form. The inherited guard is gone, and with it its `.planning` walk, its JSON settings, its `.planning`, `config.v4.json` and rendered-file protections, and its audit in the old store. What stays open is owned elsewhere: the placement projection that adds the placed stubs, settings and registration files and the executable to the protected list is built (`crates/baley/src/host_artifacts/placement.rs:271-296`) and T15 passes it to `judge`, the lease is Build 5's, and the doctor is T13's. The 2026-10-08 runs against Claude Code 2.1.294 showed how Claude Code handles the answers they reached (GRD-R12, first sheet `claude-live-2026-10-08-run1.md#ctl.commit-main-bash`) and saw no live redelivery (GRD-R10, `claude-live-2026-10-08-run1.md#ctl.redelivery`). +`baley guard` runs the library hook (`crates/baley/src/main.rs:127`, `crates/baley/src/guard_hook/mod.rs:36-150`). It reads one call from standard input, binds the project from `CLAUDE_PROJECT_DIR`, judges the call with `baley-core`'s guard module (`crates/baley-core/src/guard/`) and the library's `hook_input` and `protected_paths` modules, records what it must in the per-user project `user`, and answers in Claude Code's hook form. The inherited guard is gone, and with it its `.planning` walk, its JSON settings, its `.planning`, `config.v4.json` and rendered-file protections, and its audit in the old store. What stays open is owned elsewhere: the placement projection that adds the placed stubs, settings and registration files and the executable to the protected list is built (`crates/baley/src/host_artifacts/placement.rs:271-296`) and T15 passes it to `judge`, the lease is Build 5's, and the doctor's runtime part is built (`crates/baley/src/host_doctor/`) while T17 plans its delivery part. The 2026-10-08 runs against Claude Code 2.1.294 showed how Claude Code handles the answers they reached (GRD-R12, first sheet `claude-live-2026-10-08-run1.md#ctl.commit-main-bash`) and saw no live redelivery (GRD-R10, `claude-live-2026-10-08-run1.md#ctl.redelivery`). | Requirement | Status | Where | |---|---|---| @@ -324,14 +331,14 @@ Not applicable. The guard gives no instructions; an agent sees only the host's o | GRD-R3 | Built | The core scanner returns `GitCommand::Commit(CommitTarget)` or `Push`, with ordered `-C` operands and explicit `Cwd`, `Directory` and `Unestablished` targets (`crates/baley-core/src/guard/scan.rs:13-220`), the PowerShell ask is a core decision (`crates/baley-core/src/guard/answer.rs:133-144`), and the library classifies `Bash`, `Monitor` command and watch forms, and `PowerShell` (`crates/baley/src/hook_input/mod.rs:101-195`). The hook scans a `Bash` or `Monitor` command and passes a declined command or a watch with nothing recorded (`crates/baley/src/guard_hook/decide.rs:81-114`). Claude Code sent `command`, `description` and `timeout_ms` for the `Monitor` command form and `ws`, `description` and `timeout_ms` with no `command` for its watch form, and the names for `PowerShell` are unverified (section 12, `claude-live-2026-10-08-run1.md#fld.monitor`, `#fld.monitor-watch`, `#fld.powershell`). A commit with shell structure the scanner declines passed with nothing recorded and landed on `main` (`claude-live-2026-10-08-run1.md#ctl.declined-syntax`) | | GRD-R4 | Built | The core answer (`crates/baley-core/src/guard/answer.rs:61-73`). The hook asks on every push in a project before it reads a branch or a settings file (`crates/baley/src/guard_hook/decide.rs:115-118`) | | GRD-R5 | Built | The core answer, with the settings read from a complete policy (`crates/baley-core/src/guard/answer.rs:82-102`, `crates/baley-core/src/guard/settings.rs:12-54`) and the three settings in the schema (`crates/baley-core/src/policy/schema.rs:296-316`). The hook reads the session project's policy with Claude Code's host sections (`crates/baley/src/guard_hook/policy.rs:31-107`) and judges a commit on the branch git read at its target directory. An unestablished target selects a recorded ask with a fixed reason before any branch or policy step (`crates/baley/src/guard_hook/decide.rs:115-149`, `crates/baley-core/src/guard/reason.rs:23-26`) | -| GRD-R6 | Built | The core answer (`crates/baley-core/src/guard/answer.rs:90-100`). The hook asks git for the branch at the resolved commit directory on the guard's budget, reads `.git/HEAD` without following links beside it, and judges the two (`crates/baley/src/guard_hook/branch.rs:23-141`, `crates/baley/src/guard_hook/mod.rs:106-110`). A pass on failure is recorded (`crates/baley/src/guard_hook/decide.rs:242-254`) and printed as one stderr line (`crates/baley/src/guard_hook/render.rs:56-62`). Claude Code's hooks reference sends a hook's stderr on exit 0 to its debug log only, so where the owner sees that line stays unobserved: no loud line was produced in the 2026-10-08 runs (`claude-live-2026-10-08-run1.md#ctl.stderr-line`). The `.git/HEAD` fallback was tried by taking git off `PATH`. The guard then could not read HEAD's copy of `baley.toml`, so it asked on torn settings (GRD-R7) and the pass-on-failure path was not reached, which leaves the fallback name's rule unverified live (`claude-live-2026-10-08-run1.md#ctl.fallback-head`). T13's doctor reports the hook's observations | +| GRD-R6 | Built | The core answer (`crates/baley-core/src/guard/answer.rs:90-100`). The hook asks git for the branch at the resolved commit directory on the guard's budget, reads `.git/HEAD` without following links beside it, and judges the two (`crates/baley/src/guard_hook/branch.rs:23-141`, `crates/baley/src/guard_hook/mod.rs:106-110`). A pass on failure is recorded (`crates/baley/src/guard_hook/decide.rs:242-254`) and printed as one stderr line (`crates/baley/src/guard_hook/render.rs:56-62`). Claude Code's hooks reference sends a hook's stderr on exit 0 to its debug log only, so where the owner sees that line stays unobserved: no loud line was produced in the 2026-10-08 runs (`claude-live-2026-10-08-run1.md#ctl.stderr-line`). The `.git/HEAD` fallback was tried by taking git off `PATH`. The guard then could not read HEAD's copy of `baley.toml`, so it asked on torn settings (GRD-R7) and the pass-on-failure path was not reached, which leaves the fallback name's rule unverified live (`claude-live-2026-10-08-run1.md#ctl.fallback-head`). The doctor carries none of the hook's live observations: they stay in the dated host-matrix record cited here | | GRD-R7 | Built | The shared settings reader refuses a dangling link as a link to a missing file with `config-unavailable`, including a global `config.toml` link whose target is missing (`crates/baley/src/settings.rs:111-135`). The hook treats that global file and an unreadable or non-regular nearest `baley.toml` as torn settings and asks, subject to the remembered denials (`crates/baley/src/guard_hook/policy.rs:61-107`). The core answer for torn settings and the two remembered denials (`crates/baley-core/src/guard/answer.rs:104-131`), and the denials kept, their change judge and the `guard_policy` view (`crates/baley-core/src/guard/remembered.rs:28-226`). Under torn settings the hook reads the denials remembered for the session project root, the target checkout root and Claude Code inside the audit transaction and judges the commit again with them, and a complete policy appends `guard.policy_recorded` beside its answer only when its denials changed (`crates/baley/src/guard_hook/record.rs:102-151, 286-354`) | | GRD-R8 | Built | The two events, their payloads and the input digest (`crates/baley-core/src/guard/event.rs:19-42, 63-91, 104-181`). The hook selects every ask, deny and pass on failure of a call with an envelope, with its input digest, a path tool's target or a redirected commit's resolved directory in the existing nullable `target`, and a commit's facts (`crates/baley/src/guard_hook/decide.rs:62-78, 115-149, 242-254`), and records it as one `guard.record` command in `user` at policy version 0, by `baley`, with the hook caller, creating `user` when missing (`crates/baley/src/guard_hook/record.rs:165-284, 356-371`). A torn HEAD copy's cause is kept with `[redacted]` in place of git's excerpt, which the bounded reader gives apart (`crates/baley/src/committed.rs:84-137`). Both events stay at version 1, since their field shapes and readers are unchanged. Both views stay registered at view set 7 (`crates/baley/src/ledger/open.rs:15-44`) | -| GRD-R9 | Built | The core mapping from an answer and the audit precondition (`crates/baley-core/src/guard/recording.rs:27-34`). The hook builds the call's identity from its envelope and `CLAUDE_PROJECT_DIR`, or the line saying why it cannot, and invents none (`crates/baley/src/guard_hook/unrecordable.rs:12-41`). Every store failure is unrecordable with its cause named, and needs-rebuild names `baley rebuild user` (`crates/baley/src/guard_hook/unrecordable.rs:43-58`). A call id answered for other input is unrecordable (`crates/baley/src/guard_hook/record.rs:215-236`, `crates/baley/src/guard_hook/unrecordable.rs:60-64`), and a new answer counts as recorded only once its transaction commits (`crates/baley/src/guard_hook/record.rs:275-283`). When that transaction returns successfully without staging or replaying an answer, the entry prints the call-id clash line (`crates/baley/src/guard_hook/record.rs:281-282`, `crates/baley/src/guard_hook/mod.rs:138-141`). Missing recording facts or a policy key that cannot be represented as UTF-8 are unrecordable, as is a redirected target that could not be walked (`crates/baley/src/guard_hook/record.rs:102-151`). Without Baley's home folder nothing is recorded, and the precondition decides the final answer (`crates/baley/src/guard_hook/mod.rs:73-81, 123-150`). No unrecordable answer was produced in the 2026-10-08 runs, so where the owner sees the loud line stays unobserved (`claude-live-2026-10-08-run1.md#ctl.stderr-line`). T13's doctor reports views that need a rebuild | +| GRD-R9 | Built | The core mapping from an answer and the audit precondition (`crates/baley-core/src/guard/recording.rs:27-34`). The hook builds the call's identity from its envelope and `CLAUDE_PROJECT_DIR`, or the line saying why it cannot, and invents none (`crates/baley/src/guard_hook/unrecordable.rs:12-41`). Every store failure is unrecordable with its cause named, and needs-rebuild names `baley rebuild user` (`crates/baley/src/guard_hook/unrecordable.rs:43-58`). A call id answered for other input is unrecordable (`crates/baley/src/guard_hook/record.rs:215-236`, `crates/baley/src/guard_hook/unrecordable.rs:60-64`), and a new answer counts as recorded only once its transaction commits (`crates/baley/src/guard_hook/record.rs:275-283`). When that transaction returns successfully without staging or replaying an answer, the entry prints the call-id clash line (`crates/baley/src/guard_hook/record.rs:281-282`, `crates/baley/src/guard_hook/mod.rs:138-141`). Missing recording facts or a policy key that cannot be represented as UTF-8 are unrecordable, as is a redirected target that could not be walked (`crates/baley/src/guard_hook/record.rs:102-151`). Without Baley's home folder nothing is recorded, and the precondition decides the final answer (`crates/baley/src/guard_hook/mod.rs:73-81, 123-150`). No unrecordable answer was produced in the 2026-10-08 runs, so where the owner sees the loud line stays unobserved (`claude-live-2026-10-08-run1.md#ctl.stderr-line`). The doctor reports the `user` project's views that were behind this binary's when it started and names `baley rebuild user` (`crates/baley/src/host_doctor/guard_records.rs:38-78`, `crates/baley/src/host_doctor/report.rs:220-233`) | | GRD-R10 | Built | The `guard` view, its projector and the redelivery judge (`crates/baley-core/src/guard/redelivery.rs:13-205`). A command with a commit or push verb, or a `PowerShell` call, in a project is looked up on a short store open of its own before any git or policy read (`crates/baley/src/guard_hook/decide.rs:87-93, 108-114`, `crates/baley/src/guard_hook/mod.rs:88-105`, `crates/baley/src/guard_hook/record.rs:378-398`), and the audit transaction reads the view again before it appends (`crates/baley/src/guard_hook/record.rs:215-236`). No `tool_use_id` arrived twice in either 2026-10-08 run, so a live redelivery was not observed and stays unverified (`claude-live-2026-10-08-run1.md#ctl.redelivery`, and the timing read in `claude-live-2026-10-08-run2.md#post-run-reads`) | | GRD-R11 | Partly built | The library write decision and its lease input (`crates/baley/src/protected_paths/write.rs:18-101`), with path resolution and containment (`crates/baley/src/protected_paths/resolve.rs:151-238`, `crates/baley/src/protected_paths/contain.rs:14-129`). A protected file that does not exist yet is matched through its deepest existing ancestor, so a case-variant spelling cannot create it (`crates/baley/src/protected_paths/write.rs:111-133`, `crates/baley/src/protected_paths/contain.rs:44-75`). The hook judges `Write`, `Edit` and `NotebookEdit` at the working directory with or without a project, and denies them when Baley's folders cannot be resolved (`crates/baley/src/guard_hook/decide.rs:154-204`). It protects the home and config folders, the session project's `baley.toml` and the one that binds the working directory's checkout (`crates/baley/src/guard_hook/context.rs:77-132`); production `judge` lists only those two `baley.toml` files (`crates/baley/src/guard_hook/context.rs:106-114, 126-130`). The placement projection is built and holds the placed stubs, the placed settings and registration files and the installed executable (`crates/baley/src/host_artifacts/placement.rs:271-296`), and T15 passes it in at `judge`. T14's staged-version folder is planned, not built. Build 5 owns the lease | -| GRD-R12 | Built | The renderer (`crates/baley/src/guard_hook/render.rs:6-99`) and the hook's write of its answer, with the blocking exit when stdout fails (`crates/baley/src/guard_hook/mod.rs:174-197`). In the 2026-10-08 runs Claude Code obeyed `deny` for a commit on `main` through `Bash` and `Monitor`: the command did not run and `git log` was unchanged (first sheet `claude-live-2026-10-08-run1.md#ctl.commit-main-bash`, `#ctl.commit-main-monitor`, and `claude-live-2026-10-08-run2.md#ses.a.smoke-commit`). It obeyed `ask` for a push through both tools: a yes ran the push and a no rejected the call and left the remote's refs as they were (`claude-live-2026-10-08-run1.md#ctl.push-bash-yes`, `#ctl.push-bash-no`, `#ctl.push-monitor-yes`, `#ctl.push-monitor-no`). A pass with no output let the call run (`claude-live-2026-10-08-run1.md#ctl.declined-syntax`). In the third run the guard denied `Grep` and `Glob` calls over a parent of Baley's folders, and Claude Code refused each call and showed the hook's message as the result (`claude-live-2026-10-08-run3.md#ctl.grep-parent-guard`, `#ctl.glob-parent-guard`). The answers for `PowerShell`, and for `Write`, `Edit` and `NotebookEdit` over a protected file, were not reached and are unverified (`claude-live-2026-10-08-run1.md#ctl.powershell-ask`, `#ctl.write-baleytoml-denied`). The doctor's report of the answer forms is T13's | -| GRD-R13 | Partly built | The library read decision (`crates/baley/src/protected_paths/read.rs:22-105`), which the hook calls for `Read`, `Grep` and `Glob` at the working directory with or without a project (`crates/baley/src/guard_hook/decide.rs:193-195`), and which the matcher puts in front of those tools (`hooks/hooks.json:5`). The sandbox and `Read` and `Edit` deny-rule content is rendered (`crates/baley/src/host_artifacts/security.rs:142-199`) and composed into an existing settings document (`crates/baley/src/host_artifacts/compose.rs:98-126, 189-270`). Where the content is composed, Baley's secure values replace a disabled sandbox, `failIfUnavailable` false and `allowUnsandboxedCommands` true, each replacement reported. Composition reports, and keeps, `sandbox.filesystem.disabled` or `disableAllHooks` true, those three switches left insecure in a document the content is not composed into, every `sandbox.excludedCommands` entry, a folder re-opened by an absolute `allowRead` or `allowWrite` entry, and a `~/` or relative entry it cannot judge. The coverage judge reports from a settings document, per tool and per read or write, which mechanism covers each folder, with Grep and Glob best-effort (`crates/baley/src/host_artifacts/coverage.rs:281-356`). Any excluded command leaves the shell tools and the write-only files a gap (`crates/baley/src/host_artifacts/coverage.rs:375-398`), and a `~/` or relative allow entry is a gap reported as not judged (`crates/baley/src/host_artifacts/coverage.rs:429-442`). Both read only the settings they name, in the one document they are given, so a setting such as `sandbox.enabledPlatforms` leaving out this platform, or one in another settings file, is not seen. T15 applies the content, and T13's doctor observes the result with the judge. The sandbox probe is a spike (`spikes/host-matrix`). The 2026-10-08 runs extended it to separate home and config folders and the built-in file tools: Bash, its children and a Monitor child could neither read nor write either folder, and `Read`, `Write` and `Edit` naming a file there were refused by the deny rules before the hook ran, so the guard's own decision for those calls was not reached (first sheet `claude-live-2026-10-08-run1.md#bar.home.bash-read`, `#bar.config.bash-write`, `#bar.home.monitor-child-read`, `#bar.config.read-tool`, `#bar.config.write-tool`). `Grep` and `Glob` are not in that Claude Code version's default tool list, so the first two runs could not test them. In the third run, in a session launched with `--allowedTools Grep,Glob`, the guard denied both over each folder itself and over its parent, for the home and for the config folder, and the session showed the refusal with nothing of the folder's content (third sheet `claude-live-2026-10-08-run3.md#bar.home.grep-folder`, `#bar.home.grep-parent`, `#bar.home.glob-folder`, `#bar.home.glob-parent`, `#bar.config.grep-folder`, `#bar.config.grep-parent`, `#bar.config.glob-folder`, `#bar.config.glob-parent`, `#ctl.grep-parent-guard`, `#ctl.glob-parent-guard`). Over the folder itself the guard answered before the best-effort `Read` rule, so that rule's own reach over `Grep` and `Glob` was not measured and stays unverified | +| GRD-R12 | Built | The renderer (`crates/baley/src/guard_hook/render.rs:6-99`) and the hook's write of its answer, with the blocking exit when stdout fails (`crates/baley/src/guard_hook/mod.rs:174-197`). In the 2026-10-08 runs Claude Code obeyed `deny` for a commit on `main` through `Bash` and `Monitor`: the command did not run and `git log` was unchanged (first sheet `claude-live-2026-10-08-run1.md#ctl.commit-main-bash`, `#ctl.commit-main-monitor`, and `claude-live-2026-10-08-run2.md#ses.a.smoke-commit`). It obeyed `ask` for a push through both tools: a yes ran the push and a no rejected the call and left the remote's refs as they were (`claude-live-2026-10-08-run1.md#ctl.push-bash-yes`, `#ctl.push-bash-no`, `#ctl.push-monitor-yes`, `#ctl.push-monitor-no`). A pass with no output let the call run (`claude-live-2026-10-08-run1.md#ctl.declined-syntax`). In the third run the guard denied `Grep` and `Glob` calls over a parent of Baley's folders, and Claude Code refused each call and showed the hook's message as the result (`claude-live-2026-10-08-run3.md#ctl.grep-parent-guard`, `#ctl.glob-parent-guard`). The answers for `PowerShell`, and for `Write`, `Edit` and `NotebookEdit` over a protected file, were not reached and are unverified (`claude-live-2026-10-08-run1.md#ctl.powershell-ask`, `#ctl.write-baleytoml-denied`). The doctor carries no live evidence: how Claude Code honours each answer is the dated record cited here | +| GRD-R13 | Partly built | The library read decision (`crates/baley/src/protected_paths/read.rs:22-105`), which the hook calls for `Read`, `Grep` and `Glob` at the working directory with or without a project (`crates/baley/src/guard_hook/decide.rs:193-195`), and which the matcher puts in front of those tools (`hooks/hooks.json:5`). The sandbox and `Read` and `Edit` deny-rule content is rendered (`crates/baley/src/host_artifacts/security.rs:142-199`) and composed into an existing settings document (`crates/baley/src/host_artifacts/compose.rs:98-126, 189-270`). Where the content is composed, Baley's secure values replace a disabled sandbox, `failIfUnavailable` false and `allowUnsandboxedCommands` true, each replacement reported. Composition reports, and keeps, `sandbox.filesystem.disabled` or `disableAllHooks` true, those three switches left insecure in a document the content is not composed into, every `sandbox.excludedCommands` entry, a folder re-opened by an absolute `allowRead` or `allowWrite` entry, and a `~/` or relative entry it cannot judge. The coverage judge reports from a settings document, per tool and per read or write, which mechanism covers each folder, with Grep and Glob best-effort (`crates/baley/src/host_artifacts/coverage.rs:281-356`). Any excluded command leaves the shell tools and the write-only files a gap (`crates/baley/src/host_artifacts/coverage.rs:375-398`), and a `~/` or relative allow entry is a gap reported as not judged (`crates/baley/src/host_artifacts/coverage.rs:429-442`). Both read only the settings they name, in the one document they are given, so a setting such as `sandbox.enabledPlatforms` leaving out this platform, or one in another settings file, is not seen. T15 applies the content. The doctor runs the same judge over the settings and hook documents it is given and words each verdict as configuration of the named document, not as proof (`crates/baley/src/host_doctor/protection.rs:48-92`, `crates/baley/src/host_doctor/report.rs:265-322`), and it checks that the guard runs for all nine tools of the hook's matcher (`crates/baley/src/host_doctor/protection.rs:97-161`). Until T15 supplies a placement it reports every artifact as not installed, and T17 plans the delivery checks over the installed result. The sandbox probe is a spike (`spikes/host-matrix`). The 2026-10-08 runs extended it to separate home and config folders and the built-in file tools: Bash, its children and a Monitor child could neither read nor write either folder, and `Read`, `Write` and `Edit` naming a file there were refused by the deny rules before the hook ran, so the guard's own decision for those calls was not reached (first sheet `claude-live-2026-10-08-run1.md#bar.home.bash-read`, `#bar.config.bash-write`, `#bar.home.monitor-child-read`, `#bar.config.read-tool`, `#bar.config.write-tool`). `Grep` and `Glob` are not in that Claude Code version's default tool list, so the first two runs could not test them. In the third run, in a session launched with `--allowedTools Grep,Glob`, the guard denied both over each folder itself and over its parent, for the home and for the config folder, and the session showed the refusal with nothing of the folder's content (third sheet `claude-live-2026-10-08-run3.md#bar.home.grep-folder`, `#bar.home.grep-parent`, `#bar.home.glob-folder`, `#bar.home.glob-parent`, `#bar.config.grep-folder`, `#bar.config.grep-parent`, `#bar.config.glob-folder`, `#bar.config.glob-parent`, `#ctl.grep-parent-guard`, `#ctl.glob-parent-guard`). Over the folder itself the guard answered before the best-effort `Read` rule, so that rule's own reach over `Grep` and `Glob` was not measured and stays unverified | | GRD-R14 | Partly built | The hook starts the budget before it reads standard input, and reads one byte past the bound (`crates/baley/src/guard_hook/mod.rs:50-61`). The classifier applies the bound to every tool and denies unreadable input for the six path tools (`crates/baley/src/hook_input/mod.rs:19-21, 133-149, 225-233`). The budget's limits and grants are pure decisions over supplied times (`crates/baley/src/guard_budget.rs:20-115`). Git for the branch launches only on a grant from it and is charged the time it took, and `.git/HEAD` is opened without following links or blocking, read only when it is a regular file, and capped at 4,096 bytes (`crates/baley/src/guard_hook/branch.rs:23-141`). The launch validator accepts a guard caller's git only at the timeout its grant gave it, above zero and at most 5 seconds, in its own process group, and every other caller only at its exact registered deadline (`crates/baley/src/process.rs:128-138, 233-274`, `crates/baley/src/git_process.rs:49-99`). The bounded reader of HEAD's copy of `baley.toml` launches under its own guard caller on the same git allowance, keeps at most 1 MiB of each stream and keeps at most 256 bytes of git's first stderr line for its error (`crates/baley/src/committed.rs:75-137, 200-208`), and the global and working-tree files are read capped at 1 MiB (`crates/baley/src/settings.rs:42-57, 137-148`). Each of the hook's two store opens takes the storage time the budget has left and is charged the time it took (`crates/baley/src/guard_hook/mod.rs:152-167`). The guard's storage options (`crates/baley/src/ledger/open.rs:56-69`) open a store that takes its writer queue and connections by nonblocking tries until its storage time is spent and sets each statement's busy timeout to the time left (`crates/baley-store-sqlite/src/queue.rs:73-90, 175-242`, `crates/baley-store-sqlite/src/store.rs:31-65, 423-454, 687-700`). Such a store takes its maintenance lock the same bounded way (`crates/baley-store-sqlite/src/store.rs:416-421`), but the guard path never asks for it: it answers views behind this binary's as needs-rebuild instead of rebuilding them (`crates/baley-store-sqlite/src/rebuild.rs:83-103`). The hook starts no async runtime. Each guard process's write to `user` checks that every event type in `user`'s chain is readable, because a store keeps the head it last checked only in memory (`crates/baley-store-sqlite/src/transact.rs:370-411`). The cost of that check grows with `user`'s chain and nothing bounds it yet, so the 10-second answer is not guaranteed. The 2026-10-08 runs measured the hook's times while other sessions exited: guard calls took 46 to 47 ms in the first run and 45 to 47 ms in the second, with a highest of 48 and 49 ms, none near the timeout (`claude-live-2026-10-08-run1.md#ctl.contention-exit`, `#ctl.latency`, `claude-live-2026-10-08-run2.md#ctl.contention-exit`, `claude-live-2026-10-08-run2.md#post-run-reads`). The `user` chain held only 48 events in the first run (`claude-live-2026-10-08-run1.md#post.verify-user`), so this is the cost on a short chain, and Build 9 bounds the check on a long one | ## 12. Open questions diff --git a/docs/design/0012-host-interface.md b/docs/design/0012-host-interface.md index 9ecf55be..20b8b160 100644 --- a/docs/design/0012-host-interface.md +++ b/docs/design/0012-host-interface.md @@ -203,9 +203,21 @@ The updater uses ADR 0028's `reqwest` client to fetch releases without project c ### baley doctor -- **Inputs:** the host, default `claude-code`, and resolved runtime and installed folders. -- **Outputs:** reports the stable binary path and version, MCP entry, hook matcher and timeout, stubs and their hashes, resolved folders, sandbox and file-tool denials, chosen provider hosts, current warning acknowledgement and update setting. Each failed or unavailable check names a fix. It reads no key and tests no credential. It distinguishes a running session's version from the version staged for new sessions. -- **Refusals:** `unknown-host`. An unwritable location is a failed check naming the location and its fix. Every failed or unavailable diagnostic check is reported with its cause and fix, never certified as healthy. Doctor only reads the ledger and appends nothing. +- **Inputs:** the resolved home and config folders. The command takes no flags, so the host is Claude Code. Until T15 supplies a placement, the host checks run over a placement map in which every artifact's place is unknown (`crates/baley/src/host_doctor/mod.rs:69-89`). +- **Outputs, built:** the ledger lines first, then a host section headed `Claude Code host checks` (`crates/baley/src/host_doctor/report.rs:26-148`): + - each artifact (the stubs, the registration, the hook and the settings) as not installed when no place is known, as missing or unreadable when a place is given and the file is not usable, and otherwise as read (`crates/baley/src/host_doctor/mod.rs:322-452`); + - each placed stub byte for byte against the manifest, with both SHA-256 digests when it differs (`crates/baley/src/host_doctor/placed.rs:185-193`); + - the registration against the entry Baley renders for this binary, by the rule composition uses (`crates/baley/src/host_doctor/placed.rs:209-220`); + - the executable's kind: missing, a dangling link, not a regular file, or without an execute bit (`crates/baley/src/host_doctor/placed.rs:143-167`); + - the nine-tool hook check and the coverage of Baley's folders per tool and per read or write, worded as configuration of the named documents and not as proof, with `Grep` and `Glob` best-effort ([0010](0010-guard.md) section 5); + - `bwrap` and `socat` on Linux, none on macOS and the sandbox unsupported on any other platform, looked up on the command's own `PATH` (`crates/baley/src/host_doctor/prerequisites.rs:75-182`); + - the last recorded server call's `CLAUDE_PROJECT_DIR` and working directory on separate lines, read from the ledger and judged with the server's own rule (`crates/baley/src/host_doctor/server_context.rs:68-176`). An unusable variable is reported with `project-context-invalid`, the cause and what to set, and a missing one with `project-context-missing`. A working directory that differs from the project is valid and is not a finding. With no server call recorded, one line says so; + - compatibility, where a ledger at a newer epoch is read-only for this binary, the write fence, where the server's startup `quick_check` is not observed from the command line and the `integrity_check` rows stand in for it, the home folder, and the config folder with a note when it is the home folder (`crates/baley/src/host_doctor/store_health.rs:49-63`, `crates/baley/src/host_doctor/report.rs:185-216`); + - whether the guard's per-user records were behind this binary's when the doctor started, naming `baley rebuild user` (`crates/baley/src/host_doctor/guard_records.rs:38-78`). +- **Outputs, planned (T17):** the stable binary path and version, the installed MCP entry, hook and settings, the chosen provider hosts, the current warning acknowledgement and update setting, and a running session's version against the version staged for new sessions. T17 owns these checks under [ADR 0038](../adr/0038-installer-and-opt-in-updates.md). They run through the same observe, judge and report steps over a placement map `baley install` supplies. The doctor reads no key and tests no credential. +- **Exit status:** a gap in a given document, a placed file missing, unreadable or differing, a registration that differs, an executable that is missing or not a regular file, a tool the hook leaves unguarded, a missing sandbox program, an unsupported platform, a ledger at a newer epoch and guard records behind raise it to 1. An artifact that is not installed, the server's context and the folder lines do not. No host finding gives 2 or 3. +- **Limits:** only the documents given are judged. Other Claude Code settings files and `sandbox.enabledPlatforms` can change what applies. WSL1, containers that block bubblewrap, Ubuntu's AppArmor rule on user namespaces, ripgrep and the seccomp filter are not checked. A missing project variable is never seen from the ledger, because a server without it records nothing, so the doctor sees only a recorded project folder that is no longer a directory. There is no plugin, so there is no check of plugin defaults ([ADR 0038](../adr/0038-installer-and-opt-in-updates.md)). +- **Refusals:** none today. `unknown-host` comes with T17's host argument. A gap in a document names the setting to change, and a missing sandbox program names the package to install. An unwritable installed location is a failed check naming the location and its fix (T17). The doctor reads the ledger and the placed files, appends nothing and writes no file. ## 6. Records @@ -522,12 +534,12 @@ The library holds the per-session server (`crates/baley/src/mcp/`), and `baley s | HST-R8 | Not built | Only the parked engine runs a suite inside one call (`crates/baley/src/execution_service.rs:275-287`, `crates/baley/src/execution/runner.rs:972-1005`). The session server answers the execution operations as unavailable (`crates/baley/src/mcp/operations.rs:131-198`) until Build 5, so no call it serves runs a suite | | HST-R9, HST-R10 | Not built | The parked engine holds owner questions as gate records answered by `execution-authorize` with any non-blank owner and time (`crates/baley/src/execution_service.rs:364-500`). The session server answers that operation as unavailable (`crates/baley/src/mcp/operations.rs:131-198`) until Build 5 | | HST-R11 | Partly built | The compiled registry gives every instruction an identity, a version and a SHA-256 hash pinned beside its text (`crates/baley/src/instruction/mod.rs:65-210`, `crates/baley/src/help/front_door.rs:6-13`, `crates/baley/src/instruction/read_contract.rs:7-14`, `crates/baley/src/instruction/capture.rs:4-12`). A test fails when a text changes without its version and hash (`crates/baley/src/instruction/tests.rs:86-142`). The lookup reads no file, environment variable or setting (`crates/baley/src/instruction/mod.rs:212-227`). `bal-help`, `bal-read-contract` and `bal-capture` are served, and every other front door answers unavailable naming its build (`crates/baley/src/instruction/mod.rs:65-210`). `bal-read-contract` is served at version 2, which names the `document` read of a capture (`crates/baley/src/instruction/read_contract.rs:10, 23`). The caller has a place for each instruction's identity, version and hash (`crates/baley-store/src/caller.rs:273-298`), and the registry turns a session's identity into instruction evidence carrying its own version and hash (`crates/baley/src/instruction/mod.rs:229-246`). `capture` attaches that evidence before handing its caller to preparation, so every event of the call carries it (`crates/baley/src/mcp/capture.rs:132-145, 169-210, 292-304`). The skill stub manifest renders from the registry and the help table, with each stub's SHA-256, and reads no file (`crates/baley/src/host_artifacts/stubs.rs:20-88`). The other front doors' writes and the work-order calls are their builds' | -| HST-R12 | Partly built | The skill stub content and manifest are built from the help table and the registry (`crates/baley/src/host_artifacts/stubs.rs:20-88`), and the placement map projects the stub paths into the files the doctor expects and the protected list (`crates/baley/src/host_artifacts/placement.rs:231-296`). `baley artifact stub ` prints a stub's manifest bytes (`crates/baley/src/host_artifacts/command.rs:126-136`). The inherited render commands still print full skills (`crates/baley/src/main.rs:142-179`). The guard's protected list holds no stub until T15 passes the projection in at `guard_hook::context::judge` (`crates/baley/src/guard_hook/context.rs:106-114, 126-130`). The agent definition renderer, the effort table and the route records are Build 4's. Writing and activating the stubs is T15's, and the byte-for-byte doctor check is T13's. | +| HST-R12 | Partly built | The skill stub content and manifest are built from the help table and the registry (`crates/baley/src/host_artifacts/stubs.rs:20-88`), and the placement map projects the stub paths into the files the doctor expects and the protected list (`crates/baley/src/host_artifacts/placement.rs:231-296`). `baley artifact stub ` prints a stub's manifest bytes (`crates/baley/src/host_artifacts/command.rs:126-136`). The inherited render commands still print full skills (`crates/baley/src/main.rs:142-179`). The guard's protected list holds no stub until T15 passes the projection in at `guard_hook::context::judge` (`crates/baley/src/guard_hook/context.rs:106-114, 126-130`). The agent definition renderer, the effort table and the route records are Build 4's. Writing and activating the stubs is T15's. The doctor compares a placed stub byte for byte with the manifest and reports both digests when it differs (`crates/baley/src/host_doctor/placed.rs:185-193`, `crates/baley/src/host_doctor/mod.rs:322-452`), over a placement map T15 supplies. Until then it reports each stub as not installed (`crates/baley/src/host_doctor/mod.rs:69-89`). | | HST-R13 | Built | The initialize instructions are one line naming `help` (`crates/baley/src/mcp/tools.rs:52-63`, asserted at `crates/baley/src/mcp/tools.rs:488-494`) | | HST-R14 | Not built | The parked engine builds a dispatch answer of an id and a route, never a prompt (`crates/baley/src/execution/boundary.rs:93-147`). The session server answers `execute-next` as unavailable (`crates/baley/src/mcp/operations.rs:114`) until Build 5 | | HST-R15 | Not built | `baley exec --key` still reads and injects a key and redacts output (`crates/baley/src/exec.rs`). The binary's HTTPS model lister still reads credentials (`crates/baley/src/detection/lister.rs`). Their removal belongs to Build 4 (#25). The parked review engine also holds its own provider calls (`crates/baley/src/review/provider/credentials.rs`). Build 4 owns the session review work order and raw-response parser. | | HST-R16 | Partly built | The CLI has `serve`, which Claude Code starts and the owner does not, `guard`, which runs the library hook for Claude Code's pre-tool call (`crates/baley/src/main.rs:127`, `crates/baley/src/guard_hook/mod.rs:36-150`), `skill-description`, the render commands, the ledger commands (`verify`, `doctor`, `export`, `purge`, `scrub`, `rebuild`, `anchor`, `acknowledge-restore`), `exec` (today only the `--key` credential wrapper ADR 0039 removes in Build 4), `artifact`, which prints Claude Code's artifacts and writes no file, `init`, `config` (`show`, `set` and `interview`) and `models` (`list`, `add`, `remove` and `update`) (`crates/baley/src/main.rs:23-96`). The `exec` execution group is Build 5's. It has no `service` command, and the other commands are later builds. | -| HST-R17 | Not built | `hooks/hooks.json:3-14` holds a hand-written nine-tool `PreToolUse` entry with a 10-second timeout. No installer, `baley install`, updater or install record is built. Delivery is decided by ADR 0038; T14 to T17 implement and qualify it with unsigned development artifacts only. T14 adds the detached updater, daily claims and update outcome events; #14 supplies and qualifies both verifiers at `verify_download` before any release. T16 owns the unbuilt provider choice, typed acknowledgement, installation-writer API-host allowances and separate global reviewer-list write before the settings `config set`. The registration, the hook, the sandbox and deny-rule proposal, its composition into an existing settings document and the coverage judge are rendered in the library (`crates/baley/src/host_artifacts/registration.rs:12-26`, `crates/baley/src/host_artifacts/hook.rs:21-37`, `crates/baley/src/host_artifacts/security.rs:142-199`, `crates/baley/src/host_artifacts/compose.rs:98-126`, `crates/baley/src/host_artifacts/coverage.rs:281-356`), and the placement projection carries the installed executable path (`crates/baley/src/host_artifacts/placement.rs:271-296`). T15 writes them and passes the projection to the guard. T14 defines and adds the staged-version folder, which is planned, not built. `hooks/hooks.json` stays hand-written, and a test holds its matcher and timeout to the renderer's (`crates/baley/src/host_artifacts/hook.rs:104-118`) until T15 writes the hook from the renderer. Removal of the built key paths belongs to Build 4 (#25). | +| HST-R17 | Not built | `hooks/hooks.json:3-14` holds a hand-written nine-tool `PreToolUse` entry with a 10-second timeout. No installer, `baley install`, updater or install record is built. Delivery is decided by ADR 0038; T14 to T17 implement and qualify it with unsigned development artifacts only. T14 adds the detached updater, daily claims and update outcome events; #14 supplies and qualifies both verifiers at `verify_download` before any release. T16 owns the unbuilt provider choice, typed acknowledgement, installation-writer API-host allowances and separate global reviewer-list write before the settings `config set`. The registration, the hook, the sandbox and deny-rule proposal, its composition into an existing settings document and the coverage judge are rendered in the library (`crates/baley/src/host_artifacts/registration.rs:12-26`, `crates/baley/src/host_artifacts/hook.rs:21-37`, `crates/baley/src/host_artifacts/security.rs:142-199`, `crates/baley/src/host_artifacts/compose.rs:98-126`, `crates/baley/src/host_artifacts/coverage.rs:281-356`), and the placement projection carries the installed executable path (`crates/baley/src/host_artifacts/placement.rs:271-296`). T15 writes them and passes the projection to the guard. T14 defines and adds the staged-version folder, which is planned, not built. `hooks/hooks.json` stays hand-written, and a test holds its matcher and timeout to the renderer's (`crates/baley/src/host_artifacts/hook.rs:104-118`) until T15 writes the hook from the renderer. The runtime part of the doctor is built (`crates/baley/src/host_doctor/mod.rs:147-180`, `crates/baley/src/host_doctor/report.rs:26-148`): it judges the documents it is given and reads the ledger, and over the all-unknown placement it reports every artifact as not installed. T17 plans the checks of the installed result: the stable path and version, the installed registration, hook and settings, the provider hosts, the warning acknowledgement, the update setting, running against staged versions and the `unknown-host` refusal. Removal of the built key paths belongs to Build 4 (#25). | | HST-R18 | Built | The `failed` answer is built: a code, a place, `recorded: false` and `retryable`, returned as a successful tool result (`crates/baley/src/envelope.rs:82-153`, `crates/baley/src/mcp/gate.rs:66-69`). The server uses it for `unknown-host` (`crates/baley/src/mcp/gate.rs:162-178`), `server-overloaded` (`crates/baley/src/mcp/admission.rs:50-59`) and the project and caller faults (`crates/baley/src/mcp/gate.rs:112-151`). Only `server-overloaded` and `ledger-busy` are retryable (`crates/baley/src/envelope.rs:105-114`). Preparation answers each fault as `failed`: its codes and places (`crates/baley/src/mcp/prepare.rs:34-56`, `crates/baley-core/src/policy/parse.rs:16`, `crates/baley-core/src/checkout/judge.rs:12`), the answers a store failure, a git fault and a fork get (`crates/baley/src/mcp/prepare.rs:217-261`, `crates/baley/src/checkout/mod.rs:22-63`) and where the plan raises each (`crates/baley/src/mcp/prepare.rs:263-415`). A ledger that could not be opened at start answers `ledger-unavailable` (`crates/baley/src/mcp/serve.rs:70-97`). Checkout admission and the policy step are separate transactions, so a checkout admission recorded before a later step failed stays recorded (`crates/baley/src/mcp/prepare.rs:1-13`). `capture` answers a store fault `failed`, `ledger-busy` as retryable and any other as `ledger-unavailable`, answers bytes that were purged before `failed` `text-purged`, not retryable, and answers `request-id-reuse` as a refusal that records nothing (`crates/baley/src/mcp/capture.rs:324-378`). `document` maps its read faults the same way (`crates/baley/src/mcp/document.rs:215-323`). A capture's argument refusals are answered before preparation and record nothing (`crates/baley/src/mcp/capture.rs:87-167, 283-287`), and a named phase is refused `no-such-phase` inside the capture's transaction and recorded as a refused `command.completed` (`crates/baley/src/mcp/capture.rs:230-245`) | | HST-R19 | Partly built | The decoder bounds a whole frame at 4 MiB and its depth at 128 (`crates/baley/src/mcp/frame.rs:14-18, 153, 246`). A frame over a bound or not JSON is discarded to its newline and answered with a JSON-RPC error while reading goes on (`crates/baley/src/mcp/frame.rs:470-493`, `crates/baley/src/mcp/transport.rs:52-73, 218-247`). Hook input is bounded (`crates/baley/src/hook_input/mod.rs:19-21`, `crates/baley/src/guard_hook/mod.rs:50-61`). Every git child runs with a deadline that `validate_launch` enforces (`crates/baley/src/process.rs:233-274`, `crates/baley/src/git_process.rs:49-99`): a caller outside the guard only at its exact registered deadline, and the guard's two callers, for the branch and for HEAD's copy of `baley.toml`, only at the timeout a budget grant gave the launch, above zero and at most 5 seconds, in their own process group. Only a grant sets that timeout, so one set by hand is refused (`crates/baley/src/process.rs:128-138`, `crates/baley/src/guard_budget.rs:33-115`). The suite runner's `sh -c` (`crates/baley/src/execution/runner.rs`) runs with none, owned by Build 5, and the still-built `baley exec --key` has no deadline. Its removal belongs to Build 4 (#25) under ADR 0039. | | HST-R20 | Built | Each descriptor carries the marker (`crates/baley/src/mcp/tools.rs:83-120`, asserted at `crates/baley/src/mcp/tools.rs:272-282`). The registration renders the server-level `alwaysLoad: true` only when asked (`crates/baley/src/host_artifacts/registration.rs:20-26`), and the 2026-10-08 runs tried the plain form: with a registration that has no `alwaysLoad`, all three tools were callable without a tool search in the main session and in a subagent, in a session that does defer other tools, so the tool-level marker works alone (`claude-live-2026-10-08-run1.md#hand.tools.explicit-main`, `#hand.tools.explicit-subagent`, and again `claude-live-2026-10-08-run2.md#hand.tools.explicit-main`, `#hand.tools.explicit-subagent`). The first two runs had no login into the isolated configuration, so they could not start a session with the `alwaysLoad` form. The third run did, after the owner's login: `claude mcp add-json --scope user` stored a registration with `alwaysLoad: true`, a running session listed `baley` with 3 tools from the user scope, and in that session, which defers other servers' tools, all three tools were callable without a tool search in the main session and in a subagent, whose capture carries the session's `baley_session`. Claude Code accepted the `alwaysLoad` form (`claude-live-2026-10-08-run3.md#ses.b.registered`, `#hand.tools.user-main`, `#hand.tools.user-subagent`) | From 2650317cd4cbcd077492023b6d506cb59196a2d9 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Thu, 8 Oct 2026 19:12:40 -0400 Subject: [PATCH 14/18] docs(design): show T13 in progress and the doctor's host checks in the ledger and config designs The ledger design now lists what the doctor's host section adds and which findings raise the exit status, and says the startup quick_check is not observed from the command line. The roadmap marks T13 in progress. --- docs/design/0001-evidence-ledger.md | 16 ++++++++-------- docs/design/0003-configuration-and-routing.md | 4 ++-- docs/roadmap.md | 10 ++++++---- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/docs/design/0001-evidence-ledger.md b/docs/design/0001-evidence-ledger.md index 35cbe830..6eaf5fc1 100644 --- a/docs/design/0001-evidence-ledger.md +++ b/docs/design/0001-evidence-ledger.md @@ -1259,7 +1259,7 @@ The domain rules are owned, specified and tested by the area design documents ([ See [Threat model](#threat-model) for who is defended against. -- **Agent isolation (EVD-R24).** Agents run as the owner's user, so file modes cannot keep them out. The barrier is Claude Code's, and three mechanisms carry it: the sandbox, which denies shell commands and their child processes reads and writes of Baley's home and its config folder; the `Read` and `Edit` deny rules, which cover its built-in file tools; and the guard's refusal of Read, Grep and Glob calls into either folder. Claude Code applies `Read` rules to Grep and Glob only on a best-effort basis, which is why the guard refusal is needed. An unavailable sandbox is reported, never passed over. The owner's own Claude Code settings can loosen the barrier, and `baley doctor` reports what an agent can reach. The settings content that carries the sandbox and the deny rules, and the per-tool coverage judgement over a settings document, are rendered in the library (`crates/baley/src/host_artifacts/security.rs:142-199`, `crates/baley/src/host_artifacts/coverage.rs:281-356`); Build 3 T15 applies the content, and the 2026-10-08 runs measured what Claude Code 2.1.294 does with it (see the host matrix below, first sheet `claude-live-2026-10-08-run1.md#bar.home.bash-read`). With these in place, the ledger and global settings are out of an agent's reach. API keys stay in the owner's environment, outside Baley's folders and outside this protection ([0003](0003-configuration-and-routing.md), CFG-R24, [ADR 0039](../adr/0039-session-owned-provider-credentials.md)). Baley does not claim to hide the ledger from a process running as the owner outside the host. What Baley guarantees whether or not reads are denied: writes are refused, tampering is detected by the chain and the anchors, Baley records no credential values by design, sensitive returned content is kept in payloads, and payloads can be purged. The 2026-09-25 host matrix showed this for a stand-in home through Bash: the agent was blocked from reading and writing while Baley's server and hook still wrote. The 2026-10-08 runs of the built binary added the config folder and the built-in file tools. Both folders behaved alike through Bash and its children, Read, Write and Edit naming a file there were refused by the deny rules before the hook ran, and Grep and Glob are not in Claude Code 2.1.294's default tool list, so those two runs could not test them (`claude-live-2026-10-08-run1.md#bar.config.bash-read`, `#bar.config.read-tool`, `#bar.config.write-tool`). The third run launched a session with `--allowedTools Grep,Glob`, which offers them. The guard refused both tools over each folder itself and over its parent, for the home and for the config folder, and nothing of the folder came back (`claude-live-2026-10-08-run3.md#bar.home.grep-folder`, `#bar.home.grep-parent`, `#bar.home.glob-folder`, `#bar.home.glob-parent`, `#bar.config.grep-folder`, `#bar.config.grep-parent`, `#bar.config.glob-folder`, `#bar.config.glob-parent`). Over the folder itself the guard's refusal came before the best-effort `Read` rule answered, so that rule's own reach over Grep and Glob was not measured and stays unverified. The guard also refuses file-tool writes into the home and the config folder, as a best-effort layer. In the runs the deny rules refused those writes first, so the guard's refusal was not reached. +- **Agent isolation (EVD-R24).** Agents run as the owner's user, so file modes cannot keep them out. The barrier is Claude Code's, and three mechanisms carry it: the sandbox, which denies shell commands and their child processes reads and writes of Baley's home and its config folder; the `Read` and `Edit` deny rules, which cover its built-in file tools; and the guard's refusal of Read, Grep and Glob calls into either folder. Claude Code applies `Read` rules to Grep and Glob only on a best-effort basis, which is why the guard refusal is needed. An unavailable sandbox is reported, never passed over. The owner's own Claude Code settings can loosen the barrier, and `baley doctor` judges the settings and hook documents it is given and reports what they configure for an agent to reach, as configuration and not as proof of enforcement (`crates/baley/src/host_doctor/protection.rs:48-92`). Until Build 3 T15 supplies a placement, it reports every artifact as not installed. The settings content that carries the sandbox and the deny rules, and the per-tool coverage judgement over a settings document, are rendered in the library (`crates/baley/src/host_artifacts/security.rs:142-199`, `crates/baley/src/host_artifacts/coverage.rs:281-356`); Build 3 T15 applies the content, and the 2026-10-08 runs measured what Claude Code 2.1.294 does with it (see the host matrix below, first sheet `claude-live-2026-10-08-run1.md#bar.home.bash-read`). With these in place, the ledger and global settings are out of an agent's reach. API keys stay in the owner's environment, outside Baley's folders and outside this protection ([0003](0003-configuration-and-routing.md), CFG-R24, [ADR 0039](../adr/0039-session-owned-provider-credentials.md)). Baley does not claim to hide the ledger from a process running as the owner outside the host. What Baley guarantees whether or not reads are denied: writes are refused, tampering is detected by the chain and the anchors, Baley records no credential values by design, sensitive returned content is kept in payloads, and payloads can be purged. The 2026-09-25 host matrix showed this for a stand-in home through Bash: the agent was blocked from reading and writing while Baley's server and hook still wrote. The 2026-10-08 runs of the built binary added the config folder and the built-in file tools. Both folders behaved alike through Bash and its children, Read, Write and Edit naming a file there were refused by the deny rules before the hook ran, and Grep and Glob are not in Claude Code 2.1.294's default tool list, so those two runs could not test them (`claude-live-2026-10-08-run1.md#bar.config.bash-read`, `#bar.config.read-tool`, `#bar.config.write-tool`). The third run launched a session with `--allowedTools Grep,Glob`, which offers them. The guard refused both tools over each folder itself and over its parent, for the home and for the config folder, and nothing of the folder came back (`claude-live-2026-10-08-run3.md#bar.home.grep-folder`, `#bar.home.grep-parent`, `#bar.home.glob-folder`, `#bar.home.glob-parent`, `#bar.config.grep-folder`, `#bar.config.grep-parent`, `#bar.config.glob-folder`, `#bar.config.glob-parent`). Over the folder itself the guard's refusal came before the best-effort `Read` rule answered, so that rule's own reach over Grep and Glob was not measured and stays unverified. The guard also refuses file-tool writes into the home and the config folder, as a best-effort layer. In the runs the deny rules refused those writes first, so the guard's refusal was not reached. - **Detection.** Anchors on the forge make a rewrite, truncation or rollback detectable up to the latest anchor, even by an agent that can run `sqlite3` and sign with the owner's key. - **Secrets.** Test output and review material can carry secrets. They are always payloads, so they can always be purged, with the limits stated in [Payloads, retention and purge](#payloads-retention-and-purge-evd-r11-evd-r14). Baley reads no API key and cannot promise to recognize or scrub one a command prints into returned content. Every program Claude Code starts can see keys in the environment, agents and subagents included. Code sent for review goes to the chosen provider under its terms (CFG-R28). - **Network.** The store makes no network calls. Anchors are pushed with git to the project's own remote, and the latest anchor is fetched from it, through the core's forge seam; the binary implements that seam, and the store only receives the fetched anchor. A remote is named in the ledger by its configured name, never by URL or credentials. Git runs without prompts. The remote is the project's `git.remote`, from the committed `baley.toml`, and must be configured in the repository at the current directory. @@ -1276,9 +1276,9 @@ See [Threat model](#threat-model) for who is defended against. | Power loss after acknowledgement | None needed | Nothing is lost (`synchronous=FULL`) | None | | Store busy longer than 5 s | `SQLITE_BUSY` after the timeout | "store busy"; the CLI says the ledger is busy and to run the command again | Retry; `doctor` reports claim states and log size | | A guard store busy past its storage time | `Busy` from the guard's lookup or audit transaction, once its nonblocking tries have used the storage time its budget left | The decision is unrecordable and nothing is recorded: an ask is denied, saying the guard could not record it, a deny stands and a pass on failure stays a loud pass, and a stderr line says the ledger stayed busy ([0010](0010-guard.md), GRD-R9) | The agent's retry, which runs the hook again | -| A guard store whose `user` views are behind this binary's | `NeedsRebuild` from the guard's lookup or audit transaction, since the guard never rebuilds views or takes the maintenance lock | The same answers, with the hook's stderr line naming `baley rebuild user` | Run `baley rebuild user`. Any normal use of `user` also brings its views current: the server's policy step on a project write, `baley models`, `config set` and detection. The runtime doctor reports it (Build 3 T13) | +| A guard store whose `user` views are behind this binary's | `NeedsRebuild` from the guard's lookup or audit transaction, since the guard never rebuilds views or takes the maintenance lock | The same answers, with the hook's stderr line naming `baley rebuild user` | Run `baley rebuild user`. Any normal use of `user` also brings its views current: the server's policy step on a project write, `baley models`, `config set` and detection. The doctor reports the views as found at its start (`crates/baley/src/host_doctor/guard_records.rs:38-78`), and its own view check may already have brought them current | | Disk full | `SQLITE_FULL` | The current transaction fails; an earlier claim or external effect may remain | Free space and retry; for an anchor, follow the printed reconciliation step | -| Database corruption | `quick_check` when a session's server opens the store, `integrity_check` in doctor | The server refuses every write as unavailable with the check's report, and reads continue | Restore an earlier copy of the store and run `verify`; the restore returns to the copy's moment, so the copy lacks the purges recorded after it and the owner re-runs the purges they know of | +| Database corruption | `quick_check` when a session's server opens the store, `integrity_check` in doctor, which stands in for the startup check that the command line does not observe | The server refuses every write as unavailable with the check's report, and reads continue | Restore an earlier copy of the store and run `verify`; the restore returns to the copy's moment, so the copy lacks the purges recorded after it and the owner re-runs the purges they know of | | Chain differs from its anchor | `baley verify` | The first bad sequence, truncation or rollback, and the unanchored range | Restore an earlier copy; the difference is itself evidence, and the copy lacks the purges recorded after it, so the owner re-runs the purges they know of | | A view differs from its rebuild | `baley verify --views` | The documents that differ, and the head compared at | Run `baley rebuild `; authority was never granted from it | | Process killed during a rebuild or view verification | Its generation's marker remains in `project_gen.building_gen` | Nothing live changes; `verify --views` refuses with `UnfinishedGeneration` until a rebuild | Run `baley rebuild ` to remove the unfinished generation before replay | @@ -1286,7 +1286,7 @@ See [Threat model](#threat-model) for who is defended against. | A building marker names the live generation | Every removal turn checks `live_gen` before deleting a row, and verification compares the marker with `live_gen` before it starts | `rebuild`, `verify --views`, or a read or command that must first rebuild the project, returns `LiveGenerationProtected`; nothing is removed and the live views stand | Restore an earlier copy of the store and run `verify`; the restore returns to the copy's moment, so the copy lacks the purges recorded after it and the owner re-runs the purges they know of | | Deleting the old generation fails after a flip | The rebuild's cleanup error | `rebuild` returns `CleanupFailed` with the generation now live and the cause, and the old one is left; a rebuild started by a project's first use goes on with the current views | The next rebuild removes the old generation | | Older binary after a newer one rebuilt a project's views | The live view stamps, checked in each view read and new command | That project's view reads and new commands are refused as read-only; history, payloads and other projects still work | Use the newer binary | -| Older process after an upgrade | Epoch check in its next write | Read-only, naming the needed binary | Start a new session, which starts the new binary | +| Older process after an upgrade | Epoch check in its next write | Read-only, naming the needed binary. The doctor reports a ledger at a newer epoch as read-only for the binary that runs it (`crates/baley/src/host_doctor/store_health.rs:49-63`) | Start a new session, which starts the new binary | | Migration fails | Transaction error | Refused; database unchanged | Report the bug; the old binary still works | | Unsafe home | The ownership, kind, mode and link checks at every open | Refused with `unsafe-home`, naming each path, what is wrong and the fix; nothing is opened or created | Run the fix named: `chmod 700`, `chmod 600`, `chown`, replace the link with the real folder or file, or remove or rename a wrong-kind path | | No usable home location | `BALEY_HOME` empty, relative or without a final folder name, or `HOME` missing, empty or relative when needed | Refused before anything is created, naming the variable | Set `BALEY_HOME` to an absolute path ending in the home folder's name, or set `HOME` | @@ -1364,8 +1364,8 @@ SQLite's limits sit far beyond these numbers: 281 TB per database and about 1 GB ### Observability -- `baley doctor` reports the compatibility epoch, any pending scrub time, every row of `integrity_check`, the database and log file sizes, and each project's chain and body verification, view verification, raw live versions and building lag, and active, interrupted and awaiting-owner claims. Each project carries its supplied `AnchorCheck`: a remote anchor, confirmed absence, unreachable, malformed or local only, and the report names the reason for a local check: the discovered project's `git.remote` is not set, its settings could not be read, or the project is not checked against a remote from this directory. The report also prints the settings lines: each diagnostic of the global file, then of HEAD's copy of the discovered `baley.toml`, with its file and position, then the note that the working-tree file's changes apply once committed. A settings fault, an invalid or unreadable settings file or a `baley.toml` that yields no id, is printed with its file, and every other check still runs. An unreachable or malformed remote, a failed verify, or a stored work time that cannot be read gives `Unchecked` age. A confirmed-absent remote with a local anchor row is reported separately. The chain report lists every acknowledged restore even after a later anchor matches, prints the purge warning once while any is listed, and its first unanchored work time drives the one-day warning. -- The CLI prints this report and exits 1 for any integrity or verification finding, pending scrub, unchecked or old unanchored work, a local row beside confirmed remote absence, newer live views or view set, a building marker, a view difference, an error reading a project's verification, view check, view versions or claims, interrupted or awaiting-owner claims, log size above 8,192,000 bytes, or a settings fault. Active claims alone are not a finding, and neither are settings diagnostics and the pending note. +- `baley doctor` reports the compatibility epoch, any pending scrub time, every row of `integrity_check`, the database and log file sizes, and each project's chain and body verification, view verification, raw live versions and building lag, and active, interrupted and awaiting-owner claims. Each project carries its supplied `AnchorCheck`: a remote anchor, confirmed absence, unreachable, malformed or local only, and the report names the reason for a local check: the discovered project's `git.remote` is not set, its settings could not be read, or the project is not checked against a remote from this directory. The report also prints the settings lines: each diagnostic of the global file, then of HEAD's copy of the discovered `baley.toml`, with its file and position, then the note that the working-tree file's changes apply once committed. After those lines the report prints a host section, `Claude Code host checks`: each Claude Code artifact's state (not installed when no place is known), stubs byte for byte, the registration, the executable's kind, the hook's coverage of all nine tools, the coverage of Baley's two folders judged from the documents given as configuration and not as proof, the sandbox programs found on `PATH`, the last recorded server call's project folder and working directory, the stored epoch against this binary's, the write fence, the home and config folders, and the guard's per-user records ([0010](0010-guard.md) section 5, [0012](0012-host-interface.md) section 5). A settings fault, an invalid or unreadable settings file or a `baley.toml` that yields no id, is printed with its file, and every other check still runs. An unreachable or malformed remote, a failed verify, or a stored work time that cannot be read gives `Unchecked` age. A confirmed-absent remote with a local anchor row is reported separately. The chain report lists every acknowledged restore even after a later anchor matches, prints the purge warning once while any is listed, and its first unanchored work time drives the one-day warning. +- The CLI prints this report and exits 1 for any integrity or verification finding, pending scrub, unchecked or old unanchored work, a local row beside confirmed remote absence, newer live views or view set, a building marker, a view difference, an error reading a project's verification, view check, view versions or claims, interrupted or awaiting-owner claims, log size above 8,192,000 bytes, a settings fault, a placed artifact that is missing, unreadable or differing, a registration that differs, an executable that is missing or not a regular file, a coverage gap, a tool the hook leaves unguarded, a missing sandbox program or an unsupported platform, a ledger at a newer epoch, or `user` views behind this binary's when the doctor started. Active claims alone are not a finding, and neither are settings diagnostics, the pending note, an artifact that is not installed, the server's context and the folder lines. - The `trace` table records diagnostics (timings, retries, busy waits) outside the chain, with its own size cap and rotation. - Every refusal carries a stable code and the facts that caused it, as refusals do today. @@ -1378,7 +1378,7 @@ Claude Code is the only supported host. A host is added when the matrix shows th | Project discovery from the working directory | Hook and MCP server start in the session's directory, which may be a subdirectory, and Claude Code gives both `CLAUDE_PROJECT_DIR` | Shown on 2026-09-25, and again on 2026-10-02 for a stdio server: Claude Code starts the MCP server in the session's directory. Its documentation does not promise that directory, so the server takes its project from `CLAUDE_PROJECT_DIR` and records its working directory beside it, rather than binding the directory it starts in. The hook and the server both walk up from `CLAUDE_PROJECT_DIR`, and the hook keeps its working directory for the paths a tool names and the branch it reads (Build 3). Measured on 2026-10-08 against the built binary through an explicit `--mcp-config` launch: `CLAUDE_PROJECT_DIR` and the server's recorded working directory were both the session's project folder, and for a session started in the project's `sub` folder both were that `sub` folder, read from `project_directory` and `working_directory` on the server callers in the ledger (first sheet `claude-live-2026-10-08-run1.md#id.explicit.startup`, `#id.two-sessions`, and the second run's reads in `claude-live-2026-10-08-run2.md#post-run-reads`). The server started in `sub` bound the project found through the nearer `baley.toml` (`claude-live-2026-10-08-run1.md#hand.nearer-file`). The first two runs made no login into an isolated configuration, so the user scope was not measured in them. The third run did, with the owner's login: a session started in the project's `sub` folder with no `--mcp-config`, whose `/mcp` panel listed `baley` from the user scope, had `CLAUDE_PROJECT_DIR` and the server's recorded working directory both equal to that `sub` folder, read from the server caller in the ledger (`claude-live-2026-10-08-run3.md#id.user-scope.startup`, `#ses.b.registered`, `#ses.b.login`). Its explicit launch again gave the project folder for both (`claude-live-2026-10-08-run3.md#id.explicit.startup`). | | Hook contract (tool names, event names, answer format) | `PreToolUse` with the matcher `Bash\|Monitor\|PowerShell\|Read\|Grep\|Glob\|Write\|Edit\|NotebookEdit`, answering `ask` and `deny` in `hookSpecificOutput`, and a pass with no output | Shown for Bash on 2026-09-25: the hook's input needs no adapter. The answer fields follow Claude Code's hooks reference. The library renders the hook with this matcher (`crates/baley/src/host_artifacts/hook.rs:13-37`). Measured on 2026-10-08 with the built hook on Claude Code 2.1.294 (first sheet `claude-live-2026-10-08-run1.md#ctl.hook.bash`, `#ctl.hook.monitor`, `#ctl.hook.read`): it fired for Bash, Monitor and Read. Claude Code obeyed `deny` for a commit on `main` through Bash and through Monitor, and again through Bash in the second run (`claude-live-2026-10-08-run1.md#ctl.commit-main-bash`, `#ctl.commit-main-monitor`, `claude-live-2026-10-08-run2.md#ses.a.smoke-commit`). It obeyed `ask` for a push through both: a yes ran the push and a no left the remote's refs unchanged (`claude-live-2026-10-08-run1.md#ctl.push-bash-yes`, `#ctl.push-bash-no`, `#ctl.push-monitor-yes`, `#ctl.push-monitor-no`). A pass with no output let a commit with shell structure the scanner declines run (`claude-live-2026-10-08-run1.md#ctl.declined-syntax`). In the first run, Write, Edit and NotebookEdit calls into the two folders never reached the hook, because Claude Code's deny rules and its read-first check refuse them first (`claude-live-2026-10-08-run1.md#ctl.hook.write`, `#ctl.hook.edit`, `#ctl.hook.notebookedit`). In the third run the same three tools were called on unprotected files in the project, and the hook fired once for each, the guard answered none, and the file followed each call (`claude-live-2026-10-08-run3.md#ctl.hook.write`, `#ctl.hook.edit`, `#ctl.hook.notebookedit`). Grep and Glob are not in Claude Code 2.1.294's default tool list, so the first two runs had no hook line for them. In the third run, in a session launched with `--allowedTools Grep,Glob`, the hook fired four times for each and the guard denied all eight calls, and the guard's highest time in that run, for any tool, was 49 ms (`claude-live-2026-10-08-run3.md#ctl.hook.grep`, `#ctl.hook.glob`, `#ctl.latency`). `pwsh` was absent in every run, so the hook on PowerShell is unverified (`claude-live-2026-10-08-run1.md#ctl.hook.powershell`, `claude-live-2026-10-08-run3.md#ctl.hook.powershell`). The `tool_input` field names were read from the stand-in probe's hook input (`claude-live-2026-10-08-run1.md#fld.bash`, `#fld.monitor`, `#fld.monitor-watch`, `#fld.read`, `#fld.write`, `#fld.edit`, `#fld.notebookedit`), and for Grep (`pattern`, `path`) and Glob (`pattern`, `path`) from a stand-in session launched with `--allowedTools Grep,Glob` (`claude-live-2026-10-08-run3.md#fld.grep`, `#fld.glob`). The `/hooks` panel does not list a hook given through `--settings`, though the hook fires (`claude-live-2026-10-08-run1.md#ses.a.panels`). | | Re-delivered tool calls | Retries after timeout | The hook answers a call whose host, session and `tool_use_id` it already answered from the `guard` view in `user`, when the input digest, project directory and working directory match, with no git or policy read. A reused call id with other input is answered on its own and is unrecordable, so its ask is denied ([0010](0010-guard.md), GRD-R9, GRD-R10). No `tool_use_id` arrived twice in either 2026-10-08 run, so a live redelivery was not observed and stays unverified (`claude-live-2026-10-08-run1.md#ctl.redelivery`, and the timing read in `claude-live-2026-10-08-run2.md#post-run-reads`). Proven for MCP calls in Build 1, where the `request` view lands. | -| Access to Baley's home and config folder | Server and hook write. Agents are denied reads and writes of both | The home was shown through Bash on 2026-09-25: a denied read looks like a missing file, a denied write exits 0 and nothing lands. Measured on 2026-10-08 against the built binary for the config folder as well as the home, and measured again before each release. Through Bash, a `sh -c` child and a script child, a denied read looked like a missing file and a denied write exited 0 with nothing landing (first sheet `claude-live-2026-10-08-run1.md#bar.home.bash-read`, `#bar.home.bash-write`, `#bar.config.bash-read`, `#bar.config.bash-write`, and the child and script rows beside them). Claude Code's own permission check refused a Monitor command that named a file in either folder, and a child script that Monitor ran was sandboxed (`claude-live-2026-10-08-run1.md#bar.home.monitor-read`, `#bar.home.monitor-child-read`). Read, Write and Edit naming a file there were refused by the deny rules before the hook (`claude-live-2026-10-08-run1.md#bar.home.read-tool`, `#bar.home.write-tool`, `#bar.home.edit-tool`, and the same rows for the config folder). NotebookEdit was refused by the read-first check, so the Edit rule's reach over it is not shown (`claude-live-2026-10-08-run1.md#bar.home.notebook-edit`). Grep and Glob over the folder itself are labelled best-effort. They are not in Claude Code 2.1.294's default tool list, so the first two runs could not test them. In the third run, in a session launched with `--allowedTools Grep,Glob`, the guard refused both tools over each folder itself and over its parent, for the home and for the config folder, and nothing of the folder came back (`claude-live-2026-10-08-run3.md#bar.home.grep-folder`, `#bar.home.grep-parent`, `#bar.home.glob-folder`, `#bar.home.glob-parent`, `#bar.config.grep-folder`, `#bar.config.grep-parent`, `#bar.config.glob-folder`, `#bar.config.glob-parent`). Over the folder itself the guard answered before the `Read` rule, so that rule's own reach over Grep and Glob was not measured. The PowerShell rows are unverified because `pwsh` was absent (`claude-live-2026-10-08-run1.md#bar.home.ps-read`, `claude-live-2026-10-08-run3.md#bar.home.ps-read`). Without `bwrap` and `socat` Claude Code refused to start the session (`claude-live-2026-10-08-run1.md#prot.absent-sandbox`), and the server and the hook wrote the denied home in all three runs (`claude-live-2026-10-08-run1.md#prot.server-writes-home`, `#prot.hook-writes-home`, `claude-live-2026-10-08-run2.md#prot.server-writes-home`, `claude-live-2026-10-08-run3.md#prot.server-writes-home`, `#prot.hook-writes-home`). The sandbox and deny-rule content for both folders is rendered in the library, and the coverage judge reports from a settings document which of the three mechanisms covers each tool's reads and writes (`crates/baley/src/host_artifacts/security.rs:142-199`, `crates/baley/src/host_artifacts/coverage.rs:281-356`). | +| Access to Baley's home and config folder | Server and hook write. Agents are denied reads and writes of both | The home was shown through Bash on 2026-09-25: a denied read looks like a missing file, a denied write exits 0 and nothing lands. Measured on 2026-10-08 against the built binary for the config folder as well as the home, and measured again before each release. Through Bash, a `sh -c` child and a script child, a denied read looked like a missing file and a denied write exited 0 with nothing landing (first sheet `claude-live-2026-10-08-run1.md#bar.home.bash-read`, `#bar.home.bash-write`, `#bar.config.bash-read`, `#bar.config.bash-write`, and the child and script rows beside them). Claude Code's own permission check refused a Monitor command that named a file in either folder, and a child script that Monitor ran was sandboxed (`claude-live-2026-10-08-run1.md#bar.home.monitor-read`, `#bar.home.monitor-child-read`). Read, Write and Edit naming a file there were refused by the deny rules before the hook (`claude-live-2026-10-08-run1.md#bar.home.read-tool`, `#bar.home.write-tool`, `#bar.home.edit-tool`, and the same rows for the config folder). NotebookEdit was refused by the read-first check, so the Edit rule's reach over it is not shown (`claude-live-2026-10-08-run1.md#bar.home.notebook-edit`). Grep and Glob over the folder itself are labelled best-effort. They are not in Claude Code 2.1.294's default tool list, so the first two runs could not test them. In the third run, in a session launched with `--allowedTools Grep,Glob`, the guard refused both tools over each folder itself and over its parent, for the home and for the config folder, and nothing of the folder came back (`claude-live-2026-10-08-run3.md#bar.home.grep-folder`, `#bar.home.grep-parent`, `#bar.home.glob-folder`, `#bar.home.glob-parent`, `#bar.config.grep-folder`, `#bar.config.grep-parent`, `#bar.config.glob-folder`, `#bar.config.glob-parent`). Over the folder itself the guard answered before the `Read` rule, so that rule's own reach over Grep and Glob was not measured. The PowerShell rows are unverified because `pwsh` was absent (`claude-live-2026-10-08-run1.md#bar.home.ps-read`, `claude-live-2026-10-08-run3.md#bar.home.ps-read`). Without `bwrap` and `socat` Claude Code refused to start the session (`claude-live-2026-10-08-run1.md#prot.absent-sandbox`), and the server and the hook wrote the denied home in all three runs (`claude-live-2026-10-08-run1.md#prot.server-writes-home`, `#prot.hook-writes-home`, `claude-live-2026-10-08-run2.md#prot.server-writes-home`, `claude-live-2026-10-08-run3.md#prot.server-writes-home`, `#prot.hook-writes-home`). The sandbox and deny-rule content for both folders is rendered in the library, and the coverage judge reports from a settings document which of the three mechanisms covers each tool's reads and writes (`crates/baley/src/host_artifacts/security.rs:142-199`, `crates/baley/src/host_artifacts/coverage.rs:281-356`). The doctor runs that judge over the documents it is given and words each verdict as configuration, not as enforcement (`crates/baley/src/host_doctor/protection.rs:48-92`, `crates/baley/src/host_doctor/report.rs:265-322`). | | Store unreachable | The decision is unrecordable (GRD-R9): an ask is denied, a deny stands and a pass on failure stays a loud pass. A plain pass needs no store | Judged in tests from the store's busy and needs-rebuild answers and from a transaction that fails after its answer was staged. The hook's times were measured on 2026-10-08 while other sessions exited: guard calls took 46 to 47 ms in the first run and 45 to 47 ms in the second (54 to 58 ms with the wrapper that timed them), and the highest guard time of each run was 48 ms and 49 ms, far below the host's 10-second timeout (`claude-live-2026-10-08-run1.md#ctl.contention-exit`, `#ctl.latency`, `claude-live-2026-10-08-run2.md#ctl.contention-exit`, and the timing read in `claude-live-2026-10-08-run2.md#post-run-reads`). No call reached the timeout, so what Claude Code does with a hook that times out is unverified (`claude-live-2026-10-08-run1.md#ctl.timeout-not-denial`). | | Skill loaded from an uncommitted folder (ADR 0009) | A rendered stub placed as `SKILL.md` in an uncommitted skills folder is expected to be listed and to run `baley_query` with per-call approval | Shown on 2026-10-08 in the third run, in both placements. The stub was placed as `claude-config/skills/bal-help/SKILL.md` in the isolated configuration, and the first run's write rows read it back unchanged (`claude-live-2026-10-08-run1.md#prot.stub.write-tool`, `#prot.stub.bash-write`). Session B, after the owner's login, listed `bal-help` as a user skill, and running it called `baley_query` for the instruction and then for help, each call with its own permission prompt (`claude-live-2026-10-08-run3.md#hand.skill-listed`, `#hand.skill-run`). The same stub placed uncommitted at the disposable project's `.claude/skills/bal-help/SKILL.md` was listed as a project skill and ran the same way, and the folder was removed afterwards (`claude-live-2026-10-08-run3.md#hand.skill-project-listed`, `#hand.skill-project-run`). The question ADR 0009 left open, whether a skill loads from a rendered, uncommitted folder, is answered yes for both placements on Claude Code 2.1.294. | @@ -1463,7 +1463,7 @@ Builds: | EVD-R20 | Relies on SQLite's documented durability with `synchronous=FULL`. Power loss is not reproducible in a portable test and is not re-tested. | | EVD-R21 | `crates/baley-bench` measures real-adapter open, commits, keyed reads, guard store work, writer waits, rebuild total and batches, flip, size, purge, scrub and verification. Search, server start and memory are measured in the builds named in Performance. The open figure includes the checks. Timings are measurements, never test assertions. | | EVD-R23 | Withdrawn. | -| EVD-R24 | The host matrix, run by hand on Claude Code before acceptance and again before each release. The 2026-09-25 run, on both hosts at the time (`spikes/host-matrix`), covered the home through Bash only. The 2026-10-08 runs of the built binary against Claude Code 2.1.294 measured the extended bar for the config folder and the built-in file tools as far as the host allowed: Bash and its children, Monitor, Read, Write and Edit held for both folders, and the guard's answers (a commit on `main` denied, a push asked) were obeyed through Bash and Monitor (`claude-live-2026-10-08-run1.md#bar.config.bash-read`, `#bar.config.read-tool`, `#ctl.commit-main-bash`, `claude-live-2026-10-08-run2.md#ses.a.smoke-commit`). The third run measured Grep and Glob in a session launched with `--allowedTools Grep,Glob`, where the guard refused them over each folder and over its parent (`claude-live-2026-10-08-run3.md#bar.config.grep-parent`, `#bar.config.glob-parent`, `#ctl.grep-parent-guard`, `#ctl.glob-parent-guard`), the hook on Write, Edit and NotebookEdit (`claude-live-2026-10-08-run3.md#ctl.hook.write`, `#ctl.hook.edit`, `#ctl.hook.notebookedit`), and the user-scope registration (`claude-live-2026-10-08-run3.md#id.user-scope.startup`). Still open and unverified: PowerShell (`pwsh` absent), the Edit rule over NotebookEdit, a live redelivery and a hook timeout. The coverage judge reports, from a settings document, per tool and per read or write which mechanism covers each folder and which cause leaves a gap, and its tests judge documents that drop the config folder, give write-only protection, guard Bash only, turn the sandbox off, exclude a command from the sandbox, or re-open a folder through an allow entry, whether it names the folder only once `..` is resolved or is a `~/` or relative entry it cannot judge (`crates/baley/src/host_artifacts/coverage.rs:281-356`). It judges configuration, not enforcement: the 2026-10-08 runs measured the host. | +| EVD-R24 | The host matrix, run by hand on Claude Code before acceptance and again before each release. The 2026-09-25 run, on both hosts at the time (`spikes/host-matrix`), covered the home through Bash only. The 2026-10-08 runs of the built binary against Claude Code 2.1.294 measured the extended bar for the config folder and the built-in file tools as far as the host allowed: Bash and its children, Monitor, Read, Write and Edit held for both folders, and the guard's answers (a commit on `main` denied, a push asked) were obeyed through Bash and Monitor (`claude-live-2026-10-08-run1.md#bar.config.bash-read`, `#bar.config.read-tool`, `#ctl.commit-main-bash`, `claude-live-2026-10-08-run2.md#ses.a.smoke-commit`). The third run measured Grep and Glob in a session launched with `--allowedTools Grep,Glob`, where the guard refused them over each folder and over its parent (`claude-live-2026-10-08-run3.md#bar.config.grep-parent`, `#bar.config.glob-parent`, `#ctl.grep-parent-guard`, `#ctl.glob-parent-guard`), the hook on Write, Edit and NotebookEdit (`claude-live-2026-10-08-run3.md#ctl.hook.write`, `#ctl.hook.edit`, `#ctl.hook.notebookedit`), and the user-scope registration (`claude-live-2026-10-08-run3.md#id.user-scope.startup`). Still open and unverified: PowerShell (`pwsh` absent), the Edit rule over NotebookEdit, a live redelivery and a hook timeout. The coverage judge reports, from a settings document, per tool and per read or write which mechanism covers each folder and which cause leaves a gap, and its tests judge documents that drop the config folder, give write-only protection, guard Bash only, turn the sandbox off, exclude a command from the sandbox, or re-open a folder through an allow entry, whether it names the folder only once `..` is resolved or is a `~/` or relative entry it cannot judge (`crates/baley/src/host_artifacts/coverage.rs:281-356`). It judges configuration, not enforcement: the 2026-10-08 runs measured the host. The doctor runs the judge over supplied documents and, until T15 supplies a placement, reports every artifact as not installed (`crates/baley/src/host_doctor/mod.rs:69-89`, `crates/baley/src/host_doctor/protection.rs:48-92`). | | EVD-R25 | From Build 9, `show` and the record export render every record family from views. | | EVD-R26 | The conformance suite proves the store half with supplied times and findings: commands outside an active claim's scope proceed, commands inside return Blocked before deciding, a retry returns InProgress, a clean failure completes the claim, an interrupted claim reconciles from a supplied finding, and an awaiting-owner claim refuses automatic reconciliation and accepts owner resolution. It also checks that a claim and its completion by two callers are each attributed to their own caller, that reconciliation refuses a caller, and that a caller-free reconciliation records none and copies none from the claim. The core's tests over the adapter prove the anchor call path in separate units: the claim step acts only after its claim event exists and names the pre-claim head; missing remote, in-progress, replayed and blocked requests do not act; refused and unreachable pushes complete the claim with `anchor.failed`; a successful record writes `anchor.pushed`, the completion and the row together, and a conflicting row leaves all three unwritten; matching, conflicting and absent holder tags reconcile and retry once under the original identity; an unreachable holder fetch writes one trace row and leaves the claim open; the heartbeat renews at once, ticks through the work and stops before the record step, and a failed renewal cancels nothing. The binary's git forge is tested over literal output and the process fake, and its ticker over a scripted pace and a ticker-local worker join seam. No test runs git. Build 6 proves a real revert held for the owner. | | EVD-R27 | An edited view document that says "approved" or "complete" does not grant admission or completion, because the event is missing. | diff --git a/docs/design/0003-configuration-and-routing.md b/docs/design/0003-configuration-and-routing.md index 6858818b..db5e660d 100644 --- a/docs/design/0003-configuration-and-routing.md +++ b/docs/design/0003-configuration-and-routing.md @@ -120,7 +120,7 @@ graph LR | CFG-R8 | Whenever the merged result changes, for any layer, Baley records `policy.effective` with the full merged policy and the layer and file each value came from; every command records the policy version it ran under, 0 when no recorded policy applies, as the table below says. A write the server prepares records the version its policy step returned, under the host's key, on its own command. | The record says what Baley acted under, not what the files say now. | EVD-R17 | Active | | CFG-R9 | The effective policy is re-read and re-validated before every command that appends to a project's chain from a checkout. An invalid file (unparseable, wrong type, value outside its grammar) makes the policy unavailable, and every command that needs it is refused with `config-unavailable` naming the file and the fault and, for a parse, type or grammar fault, its line and column. A genuinely missing file is an empty layer; a dangling symbolic link is unavailable and is named as a link to a missing file. This includes the global `config.toml`: every command that reads it gets `config-unavailable`, and the guard treats it as torn settings and asks, subject to its remembered denials (GRD-R7). A file that exists but is not a regular file or cannot be read makes the policy unavailable in the same way, naming the file and the cause. HEAD's copy of the project file, which holds the project's settings, makes the policy unavailable in the same way when git cannot read it. A checkout, or a settings file the policy reads, whose path is not UTF-8 makes the policy unavailable in the same way, naming the path, since the record holds paths as text. The server re-reads and re-validates both files before every write it prepares, after the lookup for a request the ledger already holds, so a retry of a recorded request still gets its original receipt when a file has since broken. A server read validates neither file. The server's refusal is `failed` with `config-unavailable`, and it records nothing. | Never act on a torn or half-edited policy. | CFG-R8 | Active | | CFG-R10 | A dispatch whose routing inputs changed between admission and its run is refused as `routing-inputs-changed`. | A worker must run under the route the owner's policy produced when it was admitted. | CFG-R8, SYS-R6 | Active | -| CFG-R11 | Only Baley's command line and its interview write the settings files. The host sandbox and the guard refuse any agent write to Baley's config folder (the global file) and to the project file. Instructions served to models never mention the files. The server never writes a settings file and never initializes a project: it answers an unknown project `failed` and names `baley init` as the owner's step, and the owner's steps stay `baley init` and `baley config`. | The owner sets policy; the model never does. | SYS-P11, SYS-R13 | Active | +| CFG-R11 | Only Baley's command line and its interview write the settings files. The host sandbox and the guard refuse any agent write to Baley's config folder (the global file) and to the project file, and `baley doctor` reports the config folder, notes when it is the home folder, and judges from the documents it is given whether those writes are refused. Instructions served to models never mention the files. The server never writes a settings file and never initializes a project: it answers an unknown project `failed` and names `baley init` as the owner's step, and the owner's steps stay `baley init` and `baley config`. | The owner sets policy; the model never does. | SYS-P11, SYS-R13 | Active | | CFG-R12 | Six roles are routed: `planner`, `analyzer`, `checker`, `executor`, `verifier`, `reviewer`. Each has `roles..model` (a model name, default absent) and `roles..effort` (a rung; defaults: planner, analyzer, executor and verifier `high`, reviewer `medium`, checker `low`). | Every worker Baley dispatches has an owner-set cost. | SYS-P1 | Active | | CFG-R13 | An absent model means the host session's own model; Baley then passes no model to the host. | The owner's session choice is the default everywhere. | CFG-R12 | Active | | CFG-R14 | A model name is checked against the model catalog for the host or provider it is written for, at write time; an unknown name is refused by the command line and interview with `unknown-model`, naming the catalog entries that exist. A name is never silently dropped at dispatch. | A misspelled model must fail where the owner can see it. | CFG-R12, CFG-R19 | Active | @@ -735,7 +735,7 @@ The binary parks the inherited engine for Build 9 to delete, and nothing in prod | CFG-R8 | Partly built | The `policy.effective` event, registered at version 1, and its payload built from the merged policy (`crates/baley-core/src/policy/recorded/event.rs:14-25`, `crates/baley-core/src/policy/recorded/event.rs:74-172`); the `policy` view and `PolicyProjector`, which keep the latest record per checkout and host (`crates/baley-core/src/policy/recorded/view.rs:13-124`); and the judge of whether to record (`crates/baley-core/src/policy/recorded/judge.rs:8-49`). The binary's policy step holds the recorder, which appends `policy.effective` as Baley's own `policy.record` command only when the judge finds a change (`crates/baley/src/policy_step/record.rs:18-160`), and the entry point, which reads the catalog version and returns the version in force (`crates/baley/src/policy_step/mod.rs:16-58`). The command line's store registers `policy.effective`, `checkout.seen`, `capture.recorded`, `guard.answered` and `guard.policy_recorded`, and the `policy`, `checkout`, `capture`, `guard` and `guard_policy` views, at view set version 7 (`crates/baley/src/ledger/open.rs:15-44`). `baley init` runs the step after `project.initialized` and checkout admission, in the order its plan gives (`crates/baley/src/init.rs:417-465`, `crates/baley/src/init.rs:698-710`). `purge` runs it only from a checkout of the project it names and records the version in force, otherwise 0 (`crates/baley-core/src/policy/recorded/purge.rs:12-25`, `crates/baley/src/ledger/commands.rs:154-211`, `crates/baley/src/ledger/commands.rs:233-264`). `config set` runs it after its write, in a project in this machine's ledger, and reports the version it returns (`crates/baley/src/config_command/set.rs:172-202`, `crates/baley/src/config_command/set.rs:334-352`). A set outside a project or in a project the ledger does not hold runs no step and reports 0, and one that changes nothing reports the version in force (`crates/baley-core/src/policy/config_command/outcome.rs:57-69`). `anchor` and `acknowledge-restore` run checkout admission and then the step after the settings read and the exact-name check of `git.remote`, and before their own command, which carries the version the step returns (`crates/baley/src/ledger/command_plan.rs:265-309`, `crates/baley/src/ledger/commands.rs:378-426`, `crates/baley/src/ledger/commands.rs:449-512`, `crates/baley/src/ledger/commands.rs:529-559`). Anchored `verify` and `doctor` run no step and append nothing. Every caller runs checkout admission between the settings read and the step. The core judges the checkout against the project's other checkouts, refusing a different remote URL as `project-id-conflict`, and plans `checkout.seen` before the step and the step before the command (`crates/baley-core/src/checkout/judge.rs:79-121`, `crates/baley-core/src/checkout/plan.rs:42-82`). The store step records `checkout.seen` as Baley's own `checkout.admit` command, or refuses a fork and records nothing, in one transaction (`crates/baley/src/checkout/admit.rs:52-183`), after the facts are gathered through git (`crates/baley/src/checkout/gather.rs:26-36`, `crates/baley/src/checkout/gather.rs:84-137`) and stripped of user information (`crates/baley/src/checkout/strip.rs:4-29`). `purge`, `config set`, `anchor` and `acknowledge-restore` call it through one entry (`crates/baley/src/checkout/mod.rs:77-101`), as does the server's write preparation, each right before its step: `purge` (`crates/baley/src/ledger/commands.rs:204-210`), `config set` (`crates/baley/src/config_command/set.rs:178-195`), `anchor` (`crates/baley/src/ledger/commands.rs:462-469`) and `acknowledge-restore` (`crates/baley/src/ledger/commands.rs:546-553`). `baley init` gathers before it opens the ledger and admits as a step of its plan (`crates/baley/src/init.rs:595-604`, `crates/baley/src/init.rs:676-687`). The server's write preparation runs checkout admission and then the step for the call's host, as Baley's own commands carrying the call's caller and the server's time, and the prepared command carries the version the step returned (`crates/baley/src/mcp/prepare.rs:338-415, 469-548`, `crates/baley/src/policy_step/mod.rs:16-58`). The step records under the host's key, while checkout admission is keyed by the policy that names no host and so carries the version stored for the checkout and no host (`crates/baley/src/mcp/prepare.rs:114-137`). A read and a request the ledger already holds run neither (`crates/baley/src/mcp/prepare.rs:263-336, 338-415`). `capture` reaches this for a request the ledger does not yet hold (`crates/baley/src/mcp/capture.rs:292-304`), `document` never does, and the guard never runs it: it builds a commit's policy with the step's `build` and records no `policy.effective` (`crates/baley/src/guard_hook/policy.rs:61-107`). The parked engine's routes persist its config inputs only (`crates/baley/src/config/reload.rs:168-181`) until Build 9. | | CFG-R9 | Partly built | Validation and `config-unavailable` naming file, line, column and fault (`crates/baley-core/src/policy/parse.rs:254-358`, `crates/baley-core/src/policy/parse.rs:418-508`), and a file that is not a regular file or cannot be read, including a dangling link named as a link to a missing file (`crates/baley/src/settings.rs:111-135`). The same reader serves the global `config.toml`: a link there to a missing file gives `config-unavailable` to every command that reads it. The guard treats it as torn settings and asks, subject to its remembered denials (`crates/baley/src/guard_hook/policy.rs:61-107`, `crates/baley-core/src/guard/answer.rs:104-131`). The policy step's read gathers the global file and HEAD's copy of the project file and builds the policy from them, refusing with the first fault (`crates/baley/src/policy_step/read.rs:24-63`). The guard's bounded reader of HEAD's copy refuses as the policy step's reader does, with `config-unavailable` naming the working-tree file, and also when the guard's git time is spent before a launch, which then never starts (`crates/baley/src/committed.rs:106-137`). A parse, type or grammar fault in HEAD's copy is named as HEAD's copy of the file, since that copy carries the working-tree path (`crates/baley-core/src/policy/parse.rs:239-251`, `crates/baley-core/src/policy/config_command/show.rs:122-127`). A checkout or settings path that is not UTF-8 is refused with `config-unavailable` naming the path (`crates/baley-core/src/policy/recorded/event.rs:27-53`, `crates/baley-core/src/policy/recorded/event.rs:74-103`). `baley init` runs the read and build before it writes a file or opens the ledger (`crates/baley/src/init.rs:490-509`, `crates/baley/src/init.rs:580-610`), and `purge` runs them from a checkout of the project it names, refusing a managed checkout whose `baley.toml` yields no id (`crates/baley/src/ledger/commands.rs:175-231`). `config set` refuses `config-unavailable` before it writes or opens the ledger (`crates/baley/src/config_command/set.rs:390-405`, `crates/baley/src/config_command/set.rs:407-450`), and `config show` before it prints anything, opening no ledger at all (`crates/baley-core/src/policy/config_command/show.rs:86-129`). `config interview` refuses it before its first question, running `config set`'s check of the working-tree file's project id first and then the judge `config show` uses (`crates/baley/src/config_command/interview.rs:135-166`). `config interview` binds the selected layer, the confirmed target path and its own read digest, or absence, and `set::apply` checks the path and digest after preparation (`crates/baley/src/config_command/interview.rs:70-99`, `crates/baley/src/config_command/interview.rs:167-186`, `crates/baley/src/config_command/set.rs:110-119`). Its pure judges refuse a project that disappeared before pair validation and a changed path or digest after preparation with `config-conflict`. A changed path names both files, and a changed digest says the shown file changed since it was shown. When discovery finds no project, the message says no project covers this folder any more and names the shown file. All three say to run the interview again (`crates/baley/src/config_command/set.rs:81-87, 217-256`). `config-conflict` also comes from the replace's decision, which refuses a file that is not the one read (`crates/baley/src/replace.rs:58-66`), and from its whole-file replace, which writes through a temporary file and refuses a link (`crates/baley/src/replace.rs:115-165`, `crates/baley/src/replace.rs:181-214`). `config set` also refuses a link before it reads the catalog, so a set that changes nothing refuses on one (`crates/baley/src/config_command/set.rs:452-462`). `anchor`, `acknowledge-restore` and anchored `verify` refuse `config-unavailable` before any record, and refuse a managed checkout whose `baley.toml` yields no id the same way (`crates/baley/src/ledger/command_plan.rs:95-105`, `crates/baley/src/ledger/command_plan.rs:265-309`, `crates/baley/src/ledger/commands.rs:301-341`). `verify --local-only` and `verify --views` read no settings (`crates/baley/src/ledger/command_plan.rs:265-309`). `doctor` reports such a fault as a finding, leaves the discovered project checked locally and keeps checking every other project (`crates/baley/src/ledger/command_plan.rs:140-170`, `crates/baley/src/ledger/anchor_plan.rs:97-125`, `crates/baley/src/ledger/display.rs:360-363`). The server's write preparation reads and validates both files after its replay lookup and before checkout admission, and answers `failed` with `config-unavailable` and nothing recorded when either fails (`crates/baley/src/mcp/prepare.rs:127-137, 338-415`). A read validates neither file (`crates/baley/src/mcp/prepare.rs:263-336`), and a request the ledger already holds is answered before the settings are read. `capture` reaches this for a request the ledger does not yet hold (`crates/baley/src/mcp/capture.rs:292-304`), `document` never does. The guard hook reads the global and working-tree files capped at 1 MiB (`crates/baley/src/settings.rs:42-57, 137-148`), and takes a file it cannot read, one over the cap or an invalid one as torn settings, which it answers rather than refusing ([0010](0010-guard.md), GRD-R7), with no checkout admitted and nothing recorded in the project (`crates/baley/src/guard_hook/policy.rs:61-107`). The parked engine re-reads and validates its JSON layers before each write (`crates/baley/src/config/reload.rs:300-312`, `crates/baley/src/session/mod.rs:493-502`) until Build 9. | | CFG-R10 | Not built | Only the parked admission compares the routing inputs (`crates/baley/src/config/reload.rs:210-221`, `crates/baley/src/execution/boundary.rs:172`). The session server answers `execution-admit` as unavailable (`crates/baley/src/mcp/operations.rs:153`) until Build 5 | -| CFG-R11 | Partly built | `baley config interview` binds its write to the shown target's path and own digest, or absence, through `set::apply` (`crates/baley/src/config_command/set.rs:36-59, 110-119`). `baley config set` writes either settings file whole (`crates/baley/src/config_command/set.rs:478-494`), `baley config interview` writes only through `set::apply`, once, after the owner types `yes` (`crates/baley/src/config_command/interview.rs:83-99`, `crates/baley/src/config_command/interview.rs:374-391`, `crates/baley/src/config_command/interview.rs:408-444`), and `baley init` writes the project file when none exists (`crates/baley/src/init.rs:227-239`) and rewrites it whole under `--new-id` (`crates/baley/src/init.rs:633-645`); the guard hook denies an agent's `Write`, `Edit` or `NotebookEdit` into the config folder, which holds `config.toml` and `keys.env`, into the home folder, and to the session project's `baley.toml` and the one of the checkout the hook's working directory is in, from any working directory and with or without a project (`crates/baley/src/protected_paths/write.rs:18-66`, `crates/baley/src/guard_hook/context.rs:77-132`, `crates/baley/src/guard_hook/decide.rs:154-204`). The placement projection that adds the placed stubs, the placed Claude Code settings and registration files and the installed executable to that list is built (`crates/baley/src/host_artifacts/placement.rs:271-296`), and in production the list holds none of them until T15 passes the projection in. The sandbox and `Edit` deny-rule content that keeps agents out of the config folder and off the supplied project files is rendered by the proposal (`crates/baley/src/host_artifacts/security.rs:142-199`), and T15 applies it. The session server answers `config-apply` and `config-interview-apply` as unavailable and names Build 2, whose `baley config` commands replace them (`crates/baley/src/mcp/operations.rs:163-164`). The server's preparation writes no settings file and creates no project: it answers an unknown project `failed` and names `baley init` as the owner's step (`crates/baley/src/mcp/prepare.rs:263-336`), and it calls no `init`, project creation or catalog seeding (`crates/baley/src/mcp/prepare.rs:426-548`). `capture` and `document` both reach it (`crates/baley/src/mcp/capture.rs:292-304`, `crates/baley/src/mcp/document.rs:187-198`) | +| CFG-R11 | Partly built | `baley config interview` binds its write to the shown target's path and own digest, or absence, through `set::apply` (`crates/baley/src/config_command/set.rs:36-59, 110-119`). `baley config set` writes either settings file whole (`crates/baley/src/config_command/set.rs:478-494`), `baley config interview` writes only through `set::apply`, once, after the owner types `yes` (`crates/baley/src/config_command/interview.rs:83-99`, `crates/baley/src/config_command/interview.rs:374-391`, `crates/baley/src/config_command/interview.rs:408-444`), and `baley init` writes the project file when none exists (`crates/baley/src/init.rs:227-239`) and rewrites it whole under `--new-id` (`crates/baley/src/init.rs:633-645`); the guard hook denies an agent's `Write`, `Edit` or `NotebookEdit` into the config folder, which holds `config.toml` and `keys.env`, into the home folder, and to the session project's `baley.toml` and the one of the checkout the hook's working directory is in, from any working directory and with or without a project (`crates/baley/src/protected_paths/write.rs:18-66`, `crates/baley/src/guard_hook/context.rs:77-132`, `crates/baley/src/guard_hook/decide.rs:154-204`). The placement projection that adds the placed stubs, the placed Claude Code settings and registration files and the installed executable to that list is built (`crates/baley/src/host_artifacts/placement.rs:271-296`), and in production the list holds none of them until T15 passes the projection in. The sandbox and `Edit` deny-rule content that keeps agents out of the config folder and off the supplied project files is rendered by the proposal (`crates/baley/src/host_artifacts/security.rs:142-199`), and T15 applies it. The doctor reports the config folder and notes when it is the home folder, as `BALEY_HOME` and macOS make it (`crates/baley/src/host_doctor/store_health.rs:49-63`, `crates/baley/src/host_doctor/report.rs:185-216`). It judges the project file and the placed settings files as write-only files of the coverage judge over the settings and hook documents it is given, worded as configuration and not as proof (`crates/baley/src/host_doctor/protection.rs:48-92`). It gives the config folder no mode, owner or link judgement and never opens `keys.env`. The session server answers `config-apply` and `config-interview-apply` as unavailable and names Build 2, whose `baley config` commands replace them (`crates/baley/src/mcp/operations.rs:163-164`). The server's preparation writes no settings file and creates no project: it answers an unknown project `failed` and names `baley init` as the owner's step (`crates/baley/src/mcp/prepare.rs:263-336`), and it calls no `init`, project creation or catalog seeding (`crates/baley/src/mcp/prepare.rs:426-548`). `capture` and `document` both reach it (`crates/baley/src/mcp/capture.rs:292-304`, `crates/baley/src/mcp/document.rs:187-198`) | | CFG-R12, CFG-R13 | Built | In the policy module: the six roles and their defaults (`crates/baley-core/src/policy/schema.rs:255-288`), and an absent model passed as none (`crates/baley-core/src/policy/route.rs:176-279`). The parked engine's `crates/baley/src/config/roles.rs:7-14`, which its `route` served (`crates/baley/src/mcp/operations.rs:109`), keeps its `bal-*` roles until Build 9. | | CFG-R14 | Partly built | Resolution refuses `unknown-model` against the supplied accepted names (`crates/baley-core/src/policy/route.rs:200-211`). The catalog lookup gives each host's and provider's accepted names with the catalog version (`crates/baley-core/src/catalog/lookup.rs:24-41`); resolution's production caller, which passes them, is Build 4. The write-time check is built for `config set`: `judge_models` checks each model name against the accepted names of the host `--host` names, or of at least one host with none, and never against a provider's catalog (`crates/baley-core/src/policy/config_command/set.rs:262-307`). The names are read from the `model_catalog` view after the seeding step, which runs only when a pair sets a model. Inside a project the store opens for every set, to learn whether this machine's ledger holds the project, and outside one only when a pair sets a model (`crates/baley/src/config_command/set.rs:121-148`, `crates/baley/src/config_command/set.rs:277-300`). `config interview` checks no name while it asks: a model answer is any non-empty text, taken as typed and never asked again (`crates/baley/src/config_command/interview.rs:218-227`, `crates/baley/src/config_command/interview.rs:330-352`). The answer reaches `judge_models` through the interview's call to `set::apply`, which refuses `unknown-model` there (`crates/baley/src/config_command/interview.rs:83-99`), and the catalog is seeded only after the owner accepts with a model answer. The parked engine's role resolution (`crates/baley/src/config/roles.rs:132-142`) still drops unsupported names. | | CFG-R15 | Partly built | The five rungs and the rung map argument of `resolve_route`, whose mapped value the route carries as `host_effort` (`crates/baley-core/src/policy/schema.rs:52-95`, `crates/baley-core/src/policy/route.rs:25-40`, `crates/baley-core/src/policy/route.rs:219`); Claude Code's identity map, the per-model support table and the separate requested and effective records are Build 4's first dispatch task. The parked engine's resolution maps a rung to one of 30 agent names (`crates/baley/src/config/roles.rs:15-60`) until Build 9. | diff --git a/docs/roadmap.md b/docs/roadmap.md index 62b574f4..111ca085 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap -This file is the order of work from now to Baley's first public release: nine builds, then the release design, a live acceptance run on Claude Code, and publishing. Status is Build 2 complete, Build 3 T1 to T12 merged, as of main `f68cfbed`, on 2026-10-08, taken from the GitHub issues, milestones and pull requests of crenshawdev/baley and from the [design documents](design/) and [decision records](adr/). No issue, milestone or design document gives a date, so this file shows order only. The plan re-check design [#134](https://github.com/crenshawdev/baley/issues/134) is recorded as landed on 2026-10-06 in [ADR 0037](adr/0037-plan-recheck-scope.md); its implementation belongs to Builds 4 and 5. The integration check design [#229](https://github.com/crenshawdev/baley/issues/229) is recorded as landed on 2026-10-08 in [ADR 0042](adr/0042-mocked-boundary-integration-checks.md); its implementation belongs to Builds 4 and 5. The repository upkeep design [#47](https://github.com/crenshawdev/baley/issues/47) is recorded as landed on 2026-10-06 in [0015](design/0015-repository-upkeep-and-build-gate.md); its remaining implementation belongs to #54, while UPK-R2 is implemented by [Build 3 delivery tasks T14 to T17](#build-3-hosts) ([#24](https://github.com/crenshawdev/baley/issues/24)). The delivery and credential designs are recorded as landed on 2026-10-06 in [ADR 0038](adr/0038-installer-and-opt-in-updates.md) and [ADR 0039](adr/0039-session-owned-provider-credentials.md). Build 3 T14 to T17 are planned, no longer held for a decision. Removal of the built key reader, `baley exec --key` and HTTPS model lister belongs to Build 4 ([#25](https://github.com/crenshawdev/baley/issues/25)). Each build pull request updates this roadmap. +This file is the order of work from now to Baley's first public release: nine builds, then the release design, a live acceptance run on Claude Code, and publishing. Status is Build 2 complete, Build 3 T1 to T12 merged and T13 in progress, as of main `95178c23`, on 2026-10-08, taken from the GitHub issues, milestones and pull requests of crenshawdev/baley and from the [design documents](design/) and [decision records](adr/). No issue, milestone or design document gives a date, so this file shows order only. The plan re-check design [#134](https://github.com/crenshawdev/baley/issues/134) is recorded as landed on 2026-10-06 in [ADR 0037](adr/0037-plan-recheck-scope.md); its implementation belongs to Builds 4 and 5. The integration check design [#229](https://github.com/crenshawdev/baley/issues/229) is recorded as landed on 2026-10-08 in [ADR 0042](adr/0042-mocked-boundary-integration-checks.md); its implementation belongs to Builds 4 and 5. The repository upkeep design [#47](https://github.com/crenshawdev/baley/issues/47) is recorded as landed on 2026-10-06 in [0015](design/0015-repository-upkeep-and-build-gate.md); its remaining implementation belongs to #54, while UPK-R2 is implemented by [Build 3 delivery tasks T14 to T17](#build-3-hosts) ([#24](https://github.com/crenshawdev/baley/issues/24)). The delivery and credential designs are recorded as landed on 2026-10-06 in [ADR 0038](adr/0038-installer-and-opt-in-updates.md) and [ADR 0039](adr/0039-session-owned-provider-credentials.md). Build 3 T14 to T17 are planned, no longer held for a decision. Removal of the built key reader, `baley exec --key` and HTTPS model lister belongs to Build 4 ([#25](https://github.com/crenshawdev/baley/issues/25)). Each build pull request updates this roadmap. ## The path to the first release @@ -217,13 +217,15 @@ flowchart LR Release14 --> Verified classDef done fill:#1a7f37,stroke:#116329,color:#ffffff + classDef progress fill:#9a6700,stroke:#7d4e00,color:#ffffff classDef planned fill:#eaeef2,stroke:#6e7781,color:#24292f class T1,T2,T3,T4,T5,T6,T7,T8,T9,T10,T11,T12 done class Delivery done - class T13,T14,T15,T16,T17,Release14,Verified planned + class T13 progress + class T14,T15,T16,T17,Release14,Verified planned ``` -Figure 4. Build 3's tasks. Arrows point from a task to what waits on it. The dotted arrow is an evidence link. T14 to T17 are planned, not built, and T14 and T17 use unsigned development artifacts only. #14 fills and qualifies T14's `verify_download` seam before delivery can release verified artifacts; its other build dependencies are listed under R1. One pull request per Build 3 task. +Figure 4. Build 3's tasks. Arrows point from a task to what waits on it. The dotted arrow is an evidence link. T13 is in progress. T14 to T17 are planned, not built, and T14 and T17 use unsigned development artifacts only. #14 fills and qualifies T14's `verify_download` seam before delivery can release verified artifacts; its other build dependencies are listed under R1. One pull request per Build 3 task. | Task | What | Pull requests | Status | |---|---|---|---| @@ -239,7 +241,7 @@ Figure 4. Build 3's tasks. Arrows point from a task to what waits on it. The dot | T10 | Per-user guard records and Claude hook answers | [#207](https://github.com/crenshawdev/baley/pull/207) | Merged | | T11 | Claude artifact content, logical stubs and supplied executable-path protection | [#228](https://github.com/crenshawdev/baley/pull/228) | Merged | | T12 | Live Claude qualification and concurrent sessions. Run on 2026-10-08 against Claude Code 2.1.294 in three runs kept in `spikes/host-matrix`: the first sheet is `claude-live-2026-10-08-run1.md`, the rerun after the fixes for issues #231 and #232 is `claude-live-2026-10-08-run2.md`, and the final run, against commit `7c8ca4c3` after changes to the procedure, is `claude-live-2026-10-08-run3.md`. The two defects the first run showed are `claude-live-2026-10-08-run1.md#exit.session-a` and `claude-live-2026-10-08-run1.md#hand.parts.document-main`, and the rerun passes both (`claude-live-2026-10-08-run2.md#exit.session-a`, `claude-live-2026-10-08-run2.md#hand.parts.document-main`). No barrier failed: the Bash, Read, Write and Edit rows for the home and the config folder pass (`claude-live-2026-10-08-run1.md#bar.home.bash-read`, `claude-live-2026-10-08-run1.md#bar.home.bash-write`, `claude-live-2026-10-08-run1.md#bar.home.read-tool`, `claude-live-2026-10-08-run1.md#bar.home.write-tool`, `claude-live-2026-10-08-run1.md#bar.home.edit-tool`, `claude-live-2026-10-08-run1.md#bar.config.bash-read`, `claude-live-2026-10-08-run1.md#bar.config.bash-write`, `claude-live-2026-10-08-run1.md#bar.config.read-tool`, `claude-live-2026-10-08-run1.md#bar.config.write-tool`, `claude-live-2026-10-08-run1.md#bar.config.edit-tool`). The final run found no defect. It passes Grep and Glob over each folder and over its parent, in a session launched with `--allowedTools Grep,Glob` because Claude Code 2.1.294 leaves both tools out of its default tool list, and the guard refused every call (`claude-live-2026-10-08-run3.md#bar.home.grep-folder`, `claude-live-2026-10-08-run3.md#bar.home.grep-parent`, `claude-live-2026-10-08-run3.md#bar.home.glob-folder`, `claude-live-2026-10-08-run3.md#bar.home.glob-parent`, `claude-live-2026-10-08-run3.md#bar.config.grep-folder`, `claude-live-2026-10-08-run3.md#bar.config.grep-parent`, `claude-live-2026-10-08-run3.md#bar.config.glob-folder`, `claude-live-2026-10-08-run3.md#bar.config.glob-parent`, `claude-live-2026-10-08-run3.md#ctl.grep-parent-guard`, `claude-live-2026-10-08-run3.md#ctl.glob-parent-guard`). It also passes the hook on Grep, Glob, Write, Edit and NotebookEdit (`claude-live-2026-10-08-run3.md#ctl.hook.grep`, `claude-live-2026-10-08-run3.md#ctl.hook.glob`, `claude-live-2026-10-08-run3.md#ctl.hook.write`, `claude-live-2026-10-08-run3.md#ctl.hook.edit`, `claude-live-2026-10-08-run3.md#ctl.hook.notebookedit`), the user-scope registration in an isolated configuration, whose three tools were callable without a tool search in the main session and a subagent (`claude-live-2026-10-08-run3.md#id.user-scope.startup`, `claude-live-2026-10-08-run3.md#hand.tools.user-main`, `claude-live-2026-10-08-run3.md#hand.tools.user-subagent`), and the skill from both placements (`claude-live-2026-10-08-run3.md#hand.skill-listed`, `claude-live-2026-10-08-run3.md#hand.skill-run`, `claude-live-2026-10-08-run3.md#hand.skill-project-listed`, `claude-live-2026-10-08-run3.md#hand.skill-project-run`). Acceptance left open or unverified: keys and detection, because Build 4 (#25) removes the key reader, the credential wrapper and the model lister the row would exercise (`claude-live-2026-10-08-run3.md#hand.keys-detection`); the restore report, which failed on one step, since a repeated `acknowledge-restore` is a new request that exits 1 with "already acknowledged" and prints no purge warning, so whether a replayed acknowledgement carrying the warning is reachable from the command line is open (`claude-live-2026-10-08-run3.md#hand.restore-report`); the PowerShell reads, writes, hook and recorded ask, because `pwsh` was absent in every run (`claude-live-2026-10-08-run3.md#bar.home.ps-read`, `claude-live-2026-10-08-run3.md#ctl.hook.powershell`, `claude-live-2026-10-08-run3.md#ctl.powershell-ask`); the `.git/HEAD` fallback, since with git off `PATH` the guard asked about unreadable settings and never reached the fallback path (`claude-live-2026-10-08-run1.md#ctl.fallback-head`); a hook timeout, since no guard call reached 10,000 ms and the highest took 49 ms (`claude-live-2026-10-08-run3.md#ctl.timeout-not-denial`, `claude-live-2026-10-08-run3.md#ctl.latency`); a live redelivery, since no `tool_use_id` arrived twice (`claude-live-2026-10-08-run3.md#ctl.redelivery`); the burst of calls in flight at exit, since Claude Code held `/exit` until the turn ended and no call was inside the server (`claude-live-2026-10-08-run2.md#exit.burst`); the guard's own deny of a Write to a protected file, since the Edit deny rule refused that Write before the hook ran (`claude-live-2026-10-08-run1.md#ctl.write-baleytoml-denied`); the Edit rule's reach over NotebookEdit, which the read-first check refused first (`claude-live-2026-10-08-run1.md#bar.home.notebook-edit`); host-specific settings, since neither config file had a host section to list (`claude-live-2026-10-08-run1.md#hand.config-show`); and the arrival of served-answer parts after part 1, since only part 1 was read (`claude-live-2026-10-08-run2.md#hand.parts.document-main`) | [#236](https://github.com/crenshawdev/baley/pull/236) | Merged | -| T13 | Runtime doctor host observations | | Planned | +| T13 | Runtime doctor host observations | | In progress | | T14 | Binary delivery, ledger update claims and events, version-folder protection and update compatibility using unsigned development artifacts only; leave `verify_download` between download and staging for #14's verifiers | | Planned; released delivery depends on #14 | | T15 | Claude artifact ownership and application | | Planned | | T16 | Owner setup: final summary lists every file, then `yes` records provider acknowledgement, writes API-host allowances through the installation writer, writes global `review.reviewers`, and runs settings `config set` in order. Stop on failure, report completed writes and permit safe rerun | | Planned | From 3074c3c69f8d34a327edf2321f9abe7f61848b34 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Thu, 8 Oct 2026 19:26:35 -0400 Subject: [PATCH 15/18] test(host): catch a host check that changes an existing ledger row The no-write check compared project names and head sequences, so a lease renewal or any other in-place row change slipped past it. It now compares the ledger home's bytes before and after, with an open claim in the fixture. --- crates/baley/src/host_doctor/mod.rs | 40 +++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/crates/baley/src/host_doctor/mod.rs b/crates/baley/src/host_doctor/mod.rs index e48eec30..a54743dc 100644 --- a/crates/baley/src/host_doctor/mod.rs +++ b/crates/baley/src/host_doctor/mod.rs @@ -726,7 +726,10 @@ mod integration { use std::ffi::OsString; use baley_core::capture::CaptureKind; - use baley_store::{Admin, Hash, Ledger, ProjectId, RequestId, ServerCaller}; + use baley_store::{ + Admin, ClaimDecision, ClaimOwner, Claimed, Hash, Ledger, Observed, ProjectId, RequestId, + ServerCaller, + }; use serde_json::json; use super::fixtures::{executable, folders, map}; @@ -741,8 +744,11 @@ mod integration { const B: &str = "7a2d3b5f-9c0e-4f1a-8b2c-3d4e5f6a7b8c"; const SESSION: &str = "0b7e4a52-3c1d-4f6a-8e9b-1a2b3c4d5e6f"; const REQUEST: &str = "9d0c1b7e-2f4a-4b6c-8d1e-3a5b7c9d0e2f"; + const CLAIM_REQUEST: &str = "1e2f3a4b-5c6d-4e7f-8a9b-0c1d2e3f4a5b"; - /// Every file under `folder` with its bytes, sorted by path. + /// Every file under `folder` with its bytes, sorted by path. Over the ledger + /// home it covers the database and its write-ahead log, which hold every + /// durable row, so a changed row, new or old, shows as changed bytes. fn listing(folder: &Path) -> BTreeMap> { let mut found = BTreeMap::new(); let mut pending = vec![folder.to_path_buf()]; @@ -782,9 +788,8 @@ mod integration { let (project, sub) = (root.join("project"), root.join("project/sub")); std::fs::create_dir_all(&sub).unwrap(); - let store = - crate::ledger::open::store(&root.join("home"), T0, crate::ledger::open::options()) - .unwrap(); + let home = root.join("home"); + let store = crate::ledger::open::store(&home, T0, crate::ledger::open::options()).unwrap(); for (id, name) in [(A, "a"), (B, "b")] { store .create_project(&ProjectId(id.into()), name, T0) @@ -805,6 +810,29 @@ mod integration { .unwrap(); let command = prepared_command(&ProjectId(A.into()), &request, 0, T0, &caller); record(&store, &command, CaptureKind::Note, "a note", None).unwrap(); + // An open claim gives the ledger a row a host check could change in place, + // with no event and no new project. + let claim_request = WriteRequest { + kind: baley_store::CommandKind("anchor.push".into()), + request_id: RequestId(CLAIM_REQUEST.into()), + digest: Hash([2; 32]), + }; + let claim_command = prepared_command(&ProjectId(B.into()), &claim_request, 0, T0, &caller); + let claimed = store + .claim(&claim_command, &mut |_| { + Ok(ClaimDecision::Claim { + intent: json!({"action": "push"}), + owner: ClaimOwner { + process: "test".into(), + host_session: SESSION.into(), + started_at: T0.into(), + }, + git: None, + observed: Observed::default(), + }) + }) + .unwrap(); + assert!(matches!(claimed, Claimed::New { .. })); let place = root.join("place"); let help = place.join("skills/bal-help/SKILL.md"); @@ -846,6 +874,7 @@ mod integration { .collect(); let health = Admin::doctor(&store, T0, &checks).unwrap(); let heads_before = heads(&store); + let ledger_before = listing(&home); let ids: Vec = projects_before.iter().map(|(id, _)| id.clone()).collect(); let search: OsString = empty_path.into(); let observation = gather( @@ -865,6 +894,7 @@ mod integration { assert_eq!(listing(&place), files_before); assert_eq!(Admin::projects(&store).unwrap(), projects_before); assert_eq!(heads(&store), heads_before); + assert_eq!(listing(&home), ledger_before); assert_eq!(heads_before.len(), 2); } } From 7c60abe8fa3be377af8bf4c7844e34391d828972 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Thu, 8 Oct 2026 19:28:18 -0400 Subject: [PATCH 16/18] test(host): catch a kept server call that a later page never replaces The test only fed later pages that held no server call, so an implementation that kept the first answer passed it. A third page with a newer server call now has to replace the kept one. --- crates/baley/src/host_doctor/server_context.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/baley/src/host_doctor/server_context.rs b/crates/baley/src/host_doctor/server_context.rs index e9d1c1ac..029fe2f9 100644 --- a/crates/baley/src/host_doctor/server_context.rs +++ b/crates/baley/src/host_doctor/server_context.rs @@ -256,6 +256,17 @@ mod tests { ), (3, "/p3", "/p3/sub") ); + + let third = [event(7, None), event(8, Some(server("/p8", "/p8/sub")))]; + let kept = last_server_call(Some(kept), &third).expect("the newer call"); + assert_eq!( + ( + kept.seq, + kept.project_directory.as_str(), + kept.working_directory.as_str() + ), + (8, "/p8", "/p8/sub") + ); } #[test] From be561509be79e0294fc87830191061f016bd767e Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Thu, 8 Oct 2026 19:28:57 -0400 Subject: [PATCH 17/18] docs(design): scope the doctor's no-write claim to its host checks The store's doctor runs first and can bring stale views current, which writes the database. The exit-status lists also named only a missing or non-regular executable, but one with no execute bit and a true disableAllHooks raise the code too. --- docs/design/0001-evidence-ledger.md | 2 +- docs/design/0012-host-interface.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/design/0001-evidence-ledger.md b/docs/design/0001-evidence-ledger.md index 6eaf5fc1..85c90797 100644 --- a/docs/design/0001-evidence-ledger.md +++ b/docs/design/0001-evidence-ledger.md @@ -1365,7 +1365,7 @@ SQLite's limits sit far beyond these numbers: 281 TB per database and about 1 GB ### Observability - `baley doctor` reports the compatibility epoch, any pending scrub time, every row of `integrity_check`, the database and log file sizes, and each project's chain and body verification, view verification, raw live versions and building lag, and active, interrupted and awaiting-owner claims. Each project carries its supplied `AnchorCheck`: a remote anchor, confirmed absence, unreachable, malformed or local only, and the report names the reason for a local check: the discovered project's `git.remote` is not set, its settings could not be read, or the project is not checked against a remote from this directory. The report also prints the settings lines: each diagnostic of the global file, then of HEAD's copy of the discovered `baley.toml`, with its file and position, then the note that the working-tree file's changes apply once committed. After those lines the report prints a host section, `Claude Code host checks`: each Claude Code artifact's state (not installed when no place is known), stubs byte for byte, the registration, the executable's kind, the hook's coverage of all nine tools, the coverage of Baley's two folders judged from the documents given as configuration and not as proof, the sandbox programs found on `PATH`, the last recorded server call's project folder and working directory, the stored epoch against this binary's, the write fence, the home and config folders, and the guard's per-user records ([0010](0010-guard.md) section 5, [0012](0012-host-interface.md) section 5). A settings fault, an invalid or unreadable settings file or a `baley.toml` that yields no id, is printed with its file, and every other check still runs. An unreachable or malformed remote, a failed verify, or a stored work time that cannot be read gives `Unchecked` age. A confirmed-absent remote with a local anchor row is reported separately. The chain report lists every acknowledged restore even after a later anchor matches, prints the purge warning once while any is listed, and its first unanchored work time drives the one-day warning. -- The CLI prints this report and exits 1 for any integrity or verification finding, pending scrub, unchecked or old unanchored work, a local row beside confirmed remote absence, newer live views or view set, a building marker, a view difference, an error reading a project's verification, view check, view versions or claims, interrupted or awaiting-owner claims, log size above 8,192,000 bytes, a settings fault, a placed artifact that is missing, unreadable or differing, a registration that differs, an executable that is missing or not a regular file, a coverage gap, a tool the hook leaves unguarded, a missing sandbox program or an unsupported platform, a ledger at a newer epoch, or `user` views behind this binary's when the doctor started. Active claims alone are not a finding, and neither are settings diagnostics, the pending note, an artifact that is not installed, the server's context and the folder lines. +- The CLI prints this report and exits 1 for any integrity or verification finding, pending scrub, unchecked or old unanchored work, a local row beside confirmed remote absence, newer live views or view set, a building marker, a view difference, an error reading a project's verification, view check, view versions or claims, interrupted or awaiting-owner claims, log size above 8,192,000 bytes, a settings fault, a placed artifact that is missing, unreadable or differing, a registration that differs, an executable that is missing, not a regular file or without an execute bit, a coverage gap, a tool the hook leaves unguarded, `disableAllHooks` true in a hook or settings document, a missing sandbox program or an unsupported platform, a ledger at a newer epoch, or `user` views behind this binary's when the doctor started. Active claims alone are not a finding, and neither are settings diagnostics, the pending note, an artifact that is not installed, the server's context and the folder lines. - The `trace` table records diagnostics (timings, retries, busy waits) outside the chain, with its own size cap and rotation. - Every refusal carries a stable code and the facts that caused it, as refusals do today. diff --git a/docs/design/0012-host-interface.md b/docs/design/0012-host-interface.md index 20b8b160..4a94aade 100644 --- a/docs/design/0012-host-interface.md +++ b/docs/design/0012-host-interface.md @@ -215,9 +215,9 @@ The updater uses ADR 0028's `reqwest` client to fetch releases without project c - compatibility, where a ledger at a newer epoch is read-only for this binary, the write fence, where the server's startup `quick_check` is not observed from the command line and the `integrity_check` rows stand in for it, the home folder, and the config folder with a note when it is the home folder (`crates/baley/src/host_doctor/store_health.rs:49-63`, `crates/baley/src/host_doctor/report.rs:185-216`); - whether the guard's per-user records were behind this binary's when the doctor started, naming `baley rebuild user` (`crates/baley/src/host_doctor/guard_records.rs:38-78`). - **Outputs, planned (T17):** the stable binary path and version, the installed MCP entry, hook and settings, the chosen provider hosts, the current warning acknowledgement and update setting, and a running session's version against the version staged for new sessions. T17 owns these checks under [ADR 0038](../adr/0038-installer-and-opt-in-updates.md). They run through the same observe, judge and report steps over a placement map `baley install` supplies. The doctor reads no key and tests no credential. -- **Exit status:** a gap in a given document, a placed file missing, unreadable or differing, a registration that differs, an executable that is missing or not a regular file, a tool the hook leaves unguarded, a missing sandbox program, an unsupported platform, a ledger at a newer epoch and guard records behind raise it to 1. An artifact that is not installed, the server's context and the folder lines do not. No host finding gives 2 or 3. +- **Exit status:** a gap in a given document, a placed file missing, unreadable or differing, a registration that differs, an executable that is missing, not a regular file or without an execute bit, a tool the hook leaves unguarded, `disableAllHooks` true in a hook or settings document, a missing sandbox program, an unsupported platform, a ledger at a newer epoch and guard records behind raise it to 1. An artifact that is not installed, the server's context and the folder lines do not. No host finding gives 2 or 3. - **Limits:** only the documents given are judged. Other Claude Code settings files and `sandbox.enabledPlatforms` can change what applies. WSL1, containers that block bubblewrap, Ubuntu's AppArmor rule on user namespaces, ripgrep and the seccomp filter are not checked. A missing project variable is never seen from the ledger, because a server without it records nothing, so the doctor sees only a recorded project folder that is no longer a directory. There is no plugin, so there is no check of plugin defaults ([ADR 0038](../adr/0038-installer-and-opt-in-updates.md)). -- **Refusals:** none today. `unknown-host` comes with T17's host argument. A gap in a document names the setting to change, and a missing sandbox program names the package to install. An unwritable installed location is a failed check naming the location and its fix (T17). The doctor reads the ledger and the placed files, appends nothing and writes no file. +- **Refusals:** none today. `unknown-host` comes with T17's host argument. A gap in a document names the setting to change, and a missing sandbox program names the package to install. An unwritable installed location is a failed check naming the location and its fix (T17). The host checks read the ledger and the placed files, append nothing and write no file. The store's own doctor runs before them and verifies each project's views, which first brings views behind this binary's current, so it can write the database. It appends no event. ## 6. Records From 682c12c7df17d241d419bbc058f1f2201a6574ad Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Thu, 8 Oct 2026 19:34:26 -0400 Subject: [PATCH 18/18] test(host): leave the WAL index out of the ledger-home byte snapshot A plain read may update SQLite's -shm file, which holds no durable row. The database and -wal stay in, so a changed row still shows. --- crates/baley/src/host_doctor/mod.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/baley/src/host_doctor/mod.rs b/crates/baley/src/host_doctor/mod.rs index a54743dc..7a0bcc30 100644 --- a/crates/baley/src/host_doctor/mod.rs +++ b/crates/baley/src/host_doctor/mod.rs @@ -748,7 +748,9 @@ mod integration { /// Every file under `folder` with its bytes, sorted by path. Over the ledger /// home it covers the database and its write-ahead log, which hold every - /// durable row, so a changed row, new or old, shows as changed bytes. + /// durable row, so a changed row, new or old, shows as changed bytes. The + /// `-shm` file is left out: it is SQLite's WAL index, which a plain read may + /// update and which holds no durable row. fn listing(folder: &Path) -> BTreeMap> { let mut found = BTreeMap::new(); let mut pending = vec![folder.to_path_buf()]; @@ -757,7 +759,7 @@ mod integration { let path = entry.unwrap().path(); if path.is_dir() { pending.push(path); - } else { + } else if !path.to_string_lossy().ends_with("-shm") { let bytes = std::fs::read(&path).unwrap(); found.insert(path, bytes); }