From a01aa6f8761b81a156ff204502bd12ece8ea7af3 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 08:38:12 -0400 Subject: [PATCH 01/35] feat(host): derive every installed Claude path from one reading of HOME and CLAUDE_CONFIG_DIR Install, the guard and the doctor each need the same answer for where the stubs, the settings file, the registration and the executable live, so the paths come from one pure derivation instead of three. --- crates/baley/src/host_artifacts/installed.rs | 339 +++++++++++++++++++ crates/baley/src/host_artifacts/mod.rs | 13 +- crates/baley/src/update/installation.rs | 13 +- 3 files changed, 358 insertions(+), 7 deletions(-) create mode 100644 crates/baley/src/host_artifacts/installed.rs diff --git a/crates/baley/src/host_artifacts/installed.rs b/crates/baley/src/host_artifacts/installed.rs new file mode 100644 index 00000000..4f2a2405 --- /dev/null +++ b/crates/baley/src/host_artifacts/installed.rs @@ -0,0 +1,339 @@ +//! Installed Claude Code paths derived from supplied HOME, CLAUDE_CONFIG_DIR +//! and a stub manifest. The guard, install and the doctor share this +//! derivation so they agree on every path. Nothing is read or created. + +use std::ffi::OsString; +use std::fmt; +use std::path::PathBuf; + +use crate::folders::Environment; +use crate::update::installation::{HomeRefusal, Layout}; + +use super::executable::{Executable, PathFault, judge}; +use super::placement::{Placement, PlacementMap, PlacementRefusal}; +use super::stubs::Entry; + +/// The host files and installation paths, whether or not install has run. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Installed { + /// Claude Code's configuration folder, holding settings and skills. + pub claude_folder: PathBuf, + /// The one document holding the hook and security settings. + pub settings_file: PathBuf, + /// The user-scope MCP registration file. + pub registration_file: PathBuf, + /// The stable executable and versions folder from the same HOME. + pub layout: Layout, + /// Every stub and JSON artifact, with the executable and versions folder. + pub placements: PlacementMap, +} + +/// Why the supplied values cannot place the installed artifacts. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Refusal { + /// HOME cannot place the stable executable. + Home(HomeRefusal), + /// CLAUDE_CONFIG_DIR is set to an unusable path. + ConfigDirectory(PathFault), + /// The derived artifact paths cannot form a placement map. + Placement(PlacementRefusal), +} + +impl fmt::Display for Refusal { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Home(refusal) => refusal.fmt(f), + Self::ConfigDirectory(fault) => write!( + f, + "CLAUDE_CONFIG_DIR {fault}; set it to a non-empty absolute UTF-8 path for Claude Code's configuration folder, or unset it to use HOME/.claude" + ), + Self::Placement(refusal) => refusal.fmt(f), + } + } +} + +/// Derives installed paths without reading the environment or filesystem. +/// Only HOME is used from `env`. A set configuration override must be +/// non-empty, absolute and UTF-8, and its trailing slashes are removed. +/// HOME must be usable even with an override, since it places the binary. +pub fn resolve( + env: &Environment, + claude_config_dir: Option, + manifest: &[Entry], +) -> Result { + let layout = Layout::resolve(env).map_err(Refusal::Home)?; + let (claude_folder, registration_file) = match claude_config_dir { + Some(value) => { + let text = judge(&value).map_err(Refusal::ConfigDirectory)?; + let folder = text.trim_end_matches('/'); + let folder = PathBuf::from(if folder.is_empty() { "/" } else { folder }); + let registration = folder.join(".claude.json"); + (folder, registration) + } + None => ( + layout.home_folder().join(".claude"), + layout.home_folder().join(".claude.json"), + ), + }; + let settings_file = claude_folder.join("settings.json"); + let stubs = manifest + .iter() + .map(|entry| { + let path = claude_folder + .join("skills") + .join(&entry.identity) + .join("SKILL.md"); + (entry.clone(), Placement::At(path)) + }) + .collect(); + let executable = + Executable::new(layout.stable_path()).expect("the layout's stable path is absolute UTF-8"); + let placements = PlacementMap::new( + executable, + stubs, + Placement::At(registration_file.clone()), + Placement::At(settings_file.clone()), + Placement::At(settings_file.clone()), + Placement::At(layout.versions_folder().to_path_buf()), + ) + .map_err(Refusal::Placement)?; + Ok(Installed { + claude_folder, + settings_file, + registration_file, + layout, + placements, + }) +} + +#[cfg(test)] +mod tests { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + use std::path::{Path, PathBuf}; + + use super::{Refusal, resolve}; + use crate::folders::Environment; + use crate::host_artifacts::executable::PathFault; + use crate::host_artifacts::placement::{Artifact, Document}; + use crate::host_artifacts::stubs::{front_doors, manifest}; + use crate::update::installation::HomeRefusal; + + #[test] + fn a_claude_target_or_protected_file_outside_claude_config_dir_is_caught() { + let env = Environment { + home: Some("/home/o".into()), + ..Environment::default() + }; + let entries = manifest(&front_doors()).unwrap(); + for (value, folder) in [ + ("/c/claude", "/c/claude"), + ("/c/claude/", "/c/claude"), + ("/home/o/my claude", "/home/o/my claude"), + ] { + let installed = resolve(&env, Some(value.into()), &entries).unwrap(); + let settings = format!("{folder}/settings.json"); + let registration = format!("{folder}/.claude.json"); + assert_eq!(installed.claude_folder.as_os_str(), folder); + assert_eq!(installed.settings_file.as_os_str(), settings.as_str()); + assert_eq!( + installed.registration_file.as_os_str(), + registration.as_str() + ); + let files: Vec<_> = installed + .placements + .expected_files() + .into_iter() + .map(|file| (file.artifact, file.path.to_path_buf())) + .collect(); + assert_eq!( + files, + [ + ( + Artifact::Stub("bal-capture".into()), + PathBuf::from(format!("{folder}/skills/bal-capture/SKILL.md")), + ), + ( + Artifact::Stub("bal-help".into()), + PathBuf::from(format!("{folder}/skills/bal-help/SKILL.md")), + ), + (Artifact::Registration, PathBuf::from(registration)), + (Artifact::Hook, PathBuf::from(&settings)), + (Artifact::Settings, PathBuf::from(settings)), + ] + ); + assert_eq!( + installed.placements.executable().as_str(), + "/home/o/.local/bin/baley" + ); + for path in installed.placements.protected_paths() { + if path != Path::new("/home/o/.local/bin/baley") { + assert!(path.to_str().unwrap().starts_with(&format!("{folder}/"))); + } + } + } + } + + #[test] + fn a_claude_config_dir_that_is_empty_relative_or_not_utf8_used_as_a_folder_is_caught() { + let env = Environment { + home: Some("/home/o".into()), + ..Environment::default() + }; + let entries = manifest(&front_doors()).unwrap(); + for (value, fault, cause) in [ + (OsString::new(), PathFault::Empty, "is empty"), + ("claude".into(), PathFault::Relative, "is not absolute"), + ( + OsString::from_vec(b"/c/\xff".to_vec()), + PathFault::NotUtf8, + "is not UTF-8", + ), + ] { + let refusal = resolve(&env, Some(value), &entries).unwrap_err(); + assert_eq!(refusal, Refusal::ConfigDirectory(fault)); + let text = refusal.to_string(); + assert!( + text.starts_with(&format!("CLAUDE_CONFIG_DIR {cause};")), + "{text}" + ); + assert!( + text.contains("set it to a non-empty absolute UTF-8 path"), + "{text}" + ); + } + } + + #[test] + fn a_default_claude_folder_moved_by_baley_home_or_xdg_is_caught() { + let entries = manifest(&front_doors()).unwrap(); + for env in [ + Environment { + home: Some("/home/o".into()), + baley_home: Some("/b".into()), + xdg_config_home: Some("/x/c".into()), + xdg_data_home: Some("/x/d".into()), + }, + Environment { + home: Some("/home/o/".into()), + ..Environment::default() + }, + ] { + let installed = resolve(&env, None, &entries).unwrap(); + assert_eq!(installed.claude_folder.as_os_str(), "/home/o/.claude"); + assert_eq!( + installed.settings_file.as_os_str(), + "/home/o/.claude/settings.json" + ); + assert_eq!( + installed.registration_file.as_os_str(), + "/home/o/.claude.json" + ); + let stubs: Vec<_> = installed + .placements + .expected_files() + .into_iter() + .filter(|file| file.stub.is_some()) + .map(|file| file.path.as_os_str()) + .collect(); + assert_eq!( + stubs, + [ + "/home/o/.claude/skills/bal-capture/SKILL.md", + "/home/o/.claude/skills/bal-help/SKILL.md", + ] + ); + assert_eq!(installed.layout.home_folder().as_os_str(), "/home/o"); + assert_eq!( + installed.layout.stable_path().as_os_str(), + "/home/o/.local/bin/baley" + ); + assert_eq!( + installed.placements.executable().as_str(), + "/home/o/.local/bin/baley" + ); + assert_eq!( + installed.layout.versions_folder().as_os_str(), + "/home/o/.local/lib/crenshawdev/baley/versions" + ); + } + } + + #[test] + fn an_unusable_home_given_claude_placements_is_caught() { + let entries = manifest(&front_doors()).unwrap(); + for (home, expected, text) in [ + ( + None, + HomeRefusal::Unset, + "HOME is not set; set it to your home folder", + ), + ( + Some("home/o".into()), + HomeRefusal::Relative("home/o".into()), + "HOME is \"home/o\", which is not an absolute path; set it to the absolute path of your home folder", + ), + ] { + let env = Environment { + home, + ..Environment::default() + }; + let refusal = resolve(&env, Some("/c/claude".into()), &entries).unwrap_err(); + assert_eq!(refusal, Refusal::Home(expected)); + assert_eq!(refusal.to_string(), text); + } + } + + #[test] + fn a_placement_map_with_an_unknown_artifact_or_no_versions_folder_is_caught() { + let env = Environment { + home: Some("/home/o".into()), + ..Environment::default() + }; + let entries = manifest(&front_doors()).unwrap(); + let installed = resolve(&env, None, &entries).unwrap(); + let map = &installed.placements; + assert!(map.not_installed().is_empty()); + assert_eq!( + map.write_only_folders(), + [PathBuf::from( + "/home/o/.local/lib/crenshawdev/baley/versions" + )] + ); + assert_eq!( + map.documents(), + [ + Document { + path: Path::new("/home/o/.claude.json"), + artifacts: vec![Artifact::Registration], + }, + Document { + path: Path::new("/home/o/.claude/settings.json"), + artifacts: vec![Artifact::Hook, Artifact::Settings], + }, + ] + ); + assert_eq!( + map.protected_paths(), + [ + PathBuf::from("/home/o/.claude/skills/bal-capture/SKILL.md"), + PathBuf::from("/home/o/.claude/skills/bal-help/SKILL.md"), + PathBuf::from("/home/o/.claude.json"), + PathBuf::from("/home/o/.claude/settings.json"), + PathBuf::from("/home/o/.local/bin/baley"), + ] + ); + + let extra = manifest(&[("bal-extra", "Extra")]).unwrap(); + let installed = resolve(&env, None, &extra).unwrap(); + assert_eq!( + installed.placements.protected_paths(), + [ + PathBuf::from("/home/o/.claude/skills/bal-extra/SKILL.md"), + PathBuf::from("/home/o/.claude.json"), + PathBuf::from("/home/o/.claude/settings.json"), + PathBuf::from("/home/o/.local/bin/baley"), + ] + ); + } +} diff --git a/crates/baley/src/host_artifacts/mod.rs b/crates/baley/src/host_artifacts/mod.rs index ead8d8c4..9cd7fd00 100644 --- a/crates/baley/src/host_artifacts/mod.rs +++ b/crates/baley/src/host_artifacts/mod.rs @@ -3,7 +3,7 @@ //! the environment, reads or writes a file, or asks a clock, so the same //! binary with the same inputs always renders the same bytes. //! -//! After Build 3 T11 the module holds: +//! The module holds: //! - `stubs`: one skill stub per served front door and the manifest that //! lists them with host, identity, bytes and digest; //! - `executable`: the supplied absolute executable the hook and the @@ -11,6 +11,9 @@ //! - `registration`: the `mcpServers` entry for `baley serve`; //! - `hook`: the `PreToolUse` hook for `baley guard`, with GRD-R1's //! nine-tool matcher and the guard's host timeout; +//! - `installed`: the installed paths derived from supplied HOME and +//! CLAUDE_CONFIG_DIR values and the stub manifest, shared by install, +//! the guard and the doctor; //! - `placement`: an explicit placement map that turns supplied paths into //! the expected files and the protected-path list the guard and the doctor //! take; @@ -26,9 +29,9 @@ //! registration, the hook and the settings to standard output and writes //! no file. //! -//! Delivery is `baley install`'s (Build 3 T15): where each artifact goes, who -//! owns the file and how it is written or removed are decided there, with -//! this module's content as the input. +//! Delivery is `baley install`'s (Build 3 T15): who owns each file and how it +//! is written or removed are decided there, with this module's content and +//! installed paths as the input. //! //! Build 4 adds the agent and rung definition renderer here. Its definitions //! reference the session's `baley` MCP entry by the key `registration` @@ -55,6 +58,8 @@ pub mod compose; pub mod coverage; pub mod executable; pub mod hook; +/// Shared paths for installed Claude Code artifacts. +pub mod installed; pub mod placement; pub mod registration; pub mod security; diff --git a/crates/baley/src/update/installation.rs b/crates/baley/src/update/installation.rs index 747a37c1..41ba7fec 100644 --- a/crates/baley/src/update/installation.rs +++ b/crates/baley/src/update/installation.rs @@ -24,6 +24,7 @@ use super::version::Version; /// The paths of one installation, all derived from `HOME`. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Layout { + home: String, stable: String, versions: String, } @@ -97,13 +98,19 @@ impl Layout { .collect::>() .join("/") ); - let home = home.trim_end_matches('/'); + let prefix = home.trim_end_matches('/'); Ok(Layout { - stable: format!("{home}/.local/bin/baley"), - versions: format!("{home}/.local/lib/crenshawdev/baley/versions"), + stable: format!("{prefix}/.local/bin/baley"), + versions: format!("{prefix}/.local/lib/crenshawdev/baley/versions"), + home, }) } + /// The normalized `HOME` used for every installation path. + pub fn home_folder(&self) -> &Path { + Path::new(&self.home) + } + /// The stable path, `~/.local/bin/baley`. pub fn stable_path(&self) -> &Path { Path::new(&self.stable) From b3c1a1010a102765b9f5bfda5fc046d750876411 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 08:52:01 -0400 Subject: [PATCH 02/35] feat(install): add baley install with host selection and a receipt that says partial until every artifact is written Install starts as a command the owner can run and read: it refuses a host Baley does not support, seeds the catalog, and reports each artifact as not written until the later steps give it a write decision. --- crates/baley/src/install/command.rs | 96 +++++++++++++ crates/baley/src/install/mod.rs | 8 ++ crates/baley/src/install/receipt.rs | 205 ++++++++++++++++++++++++++++ crates/baley/src/lib.rs | 2 + crates/baley/src/main.rs | 29 ++++ 5 files changed, 340 insertions(+) create mode 100644 crates/baley/src/install/command.rs create mode 100644 crates/baley/src/install/mod.rs create mode 100644 crates/baley/src/install/receipt.rs diff --git a/crates/baley/src/install/command.rs b/crates/baley/src/install/command.rs new file mode 100644 index 00000000..745378a0 --- /dev/null +++ b/crates/baley/src/install/command.rs @@ -0,0 +1,96 @@ +//! Host selection and gathering for `baley install`. + +use std::io; +use std::process::ExitCode; + +use baley_core::policy::Host; +use clap::Args; + +use crate::folders::{Environment, Folders, Platform}; +use crate::host_artifacts::{installed, stubs}; +use crate::ledger::clock::SystemClock; +use crate::ledger::commands::new_request_id; +use crate::ledger::display::{self, Render}; +use crate::ledger::open; +use crate::models; + +use super::receipt::{self, ArtifactOutcome}; + +/// The host whose user-level wiring install places. +#[derive(Debug, Args)] +pub struct InstallArgs { + /// The host name, defaulting to Claude Code. + #[arg(long, value_name = "name", default_value = "claude-code")] + pub host: String, +} + +/// Runs installation gathering and emits its receipt or refusal. +pub fn run(args: InstallArgs) -> ExitCode { + let render = install(args).unwrap_or_else(|error| error); + ExitCode::from(display::emit( + &render, + &mut io::stdout().lock(), + &mut io::stderr().lock(), + )) +} + +fn select_host(name: &str) -> Result { + Host::parse(name).ok_or_else(|| { + let supported = Host::ALL.map(Host::name).join(", "); + Render::refusal(format!( + "unknown-host: {name} is not a host Baley supports; supported: {supported}" + )) + }) +} + +fn install(args: InstallArgs) -> Result { + let Host::ClaudeCode = select_host(&args.host)?; + let env = Environment::read(); + let claude_config_dir = std::env::var_os("CLAUDE_CONFIG_DIR"); + let folders = Folders::resolve(Platform::current(), &env) + .map_err(|error| Render::refusal(error.to_string()))?; + let manifest = stubs::manifest(&stubs::front_doors()).expect("unique compiled front doors"); + let installed = installed::resolve(&env, claude_config_dir, &manifest) + .map_err(|error| Render::refusal(error.to_string()))?; + let at = SystemClock::now(); + let store = open::store(&folders.home, &at, open::options()) + .map_err(|error| display::store_error(&error, None))?; + let seed = models::seed(&store, new_request_id(), &SystemClock::now()); + // Phase 15 plan 1 adds stub writes in tasks 6 and 7. Plan 2 adds JSON writes. + let outcomes: Vec<_> = installed + .placements + .expected_files() + .into_iter() + .map(|file| (file.artifact, ArtifactOutcome::NotWritten)) + .collect(); + Ok(receipt::render( + env!("CARGO_PKG_VERSION"), + installed.layout.stable_path(), + &installed.placements, + &outcomes, + &seed, + )) +} + +#[cfg(test)] +mod tests { + use baley_core::policy::Host; + + use super::select_host; + + #[test] + fn an_install_for_another_host_not_refused_unknown_host_is_caught() { + for name in ["codex", "Claude-Code"] { + let refused = select_host(name).expect_err("unsupported host"); + assert_eq!( + refused.lines, + [format!( + "unknown-host: {name} is not a host Baley supports; supported: claude-code" + )] + ); + assert_eq!(refused.code, 2); + assert!(refused.error); + } + assert_eq!(select_host("claude-code").unwrap(), Host::ClaudeCode); + } +} diff --git a/crates/baley/src/install/mod.rs b/crates/baley/src/install/mod.rs new file mode 100644 index 00000000..83d87b5c --- /dev/null +++ b/crates/baley/src/install/mod.rs @@ -0,0 +1,8 @@ +//! `baley install`: Claude Code's wiring at the stable path, from the content +//! `host_artifacts` renders, with ownership evidence in the ledger before +//! any replacement. Gathering is kept apart from judging. + +/// Command arguments, host selection and installation gathering. +pub mod command; +/// The install outcome and the owner's next steps, rendered from supplied facts. +pub mod receipt; diff --git a/crates/baley/src/install/receipt.rs b/crates/baley/src/install/receipt.rs new file mode 100644 index 00000000..ef3cd168 --- /dev/null +++ b/crates/baley/src/install/receipt.rs @@ -0,0 +1,205 @@ +//! The install receipt from supplied placements, artifact outcomes and seed result. + +use std::path::Path; + +use baley_core::catalog::{HINT_VERSION, USER_PROJECT}; +use baley_store::StoreError; + +use crate::host_artifacts::placement::{Artifact, PlacementMap}; +use crate::ledger::display::{self, Render}; + +/// What the run left at an artifact's placed path. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ArtifactOutcome { + /// The artifact has not been placed or confirmed as Baley's. + NotWritten, + /// The artifact is already Baley's and needed no write. + Unchanged, +} + +/// Renders every placed artifact in map order. A missing outcome counts as +/// not written. Completion requires every placement to be known, every +/// artifact to be Baley's and the catalog seed to be recorded. +pub(crate) fn render( + version: &str, + stable_path: &Path, + placements: &PlacementMap, + outcomes: &[(Artifact, ArtifactOutcome)], + seed: &Result, +) -> Render { + let mut complete = placements.not_installed().is_empty() && seed.is_ok(); + let mut artifacts = Vec::new(); + for file in placements.expected_files() { + let outcome = outcomes + .iter() + .find(|(artifact, _)| *artifact == file.artifact) + .map_or(ArtifactOutcome::NotWritten, |(_, outcome)| *outcome); + let text = match outcome { + ArtifactOutcome::NotWritten => { + complete = false; + "not written" + } + ArtifactOutcome::Unchanged => "unchanged", + }; + artifacts.push(format!( + "{} at {}: {text}", + file.artifact, + file.path.display() + )); + } + let mut lines = vec![ + format!( + "install outcome: {}", + if complete { "complete" } else { "partial" } + ), + format!("baley {version} at {}", stable_path.display()), + ]; + lines.extend(artifacts); + lines.push(match seed { + Ok(true) => format!("seeded the model catalog with hint table version {HINT_VERSION}"), + Ok(false) => format!( + "the model catalog seed with hint table version {HINT_VERSION} is already recorded" + ), + Err(error) => format!( + "the model catalog seed was not recorded: {}", + display::store_error(error, Some(USER_PROJECT)) + .lines + .join("; ") + ), + }); + lines.extend([ + "start a new Claude Code session to load the wiring".into(), + "run baley init in each checkout Baley should manage".into(), + "automatic update checks follow the updates.auto setting; baley config set --global updates.auto=true turns them on".into(), + "baley update checks for a newer version by hand".into(), + "baley config interview chooses settings later".into(), + "baley doctor checks the installed result".into(), + ]); + Render { + lines, + code: u8::from(!complete), + error: false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::folders::Environment; + use crate::host_artifacts::{installed, stubs}; + + fn installed() -> installed::Installed { + let env = Environment { + home: Some("/home/o".into()), + ..Environment::default() + }; + let manifest = stubs::manifest(&stubs::front_doors()).unwrap(); + installed::resolve(&env, None, &manifest).unwrap() + } + + fn outcomes(outcome: ArtifactOutcome) -> Vec<(Artifact, ArtifactOutcome)> { + vec![ + (Artifact::Stub("bal-capture".into()), outcome), + (Artifact::Stub("bal-help".into()), outcome), + (Artifact::Registration, outcome), + (Artifact::Hook, outcome), + (Artifact::Settings, outcome), + ] + } + + #[test] + fn an_install_with_an_unwritten_artifact_reported_complete_is_caught() { + let installed = installed(); + let receipt = render( + "0.2.0", + installed.layout.stable_path(), + &installed.placements, + &outcomes(ArtifactOutcome::NotWritten), + &Ok(true), + ); + assert_eq!(receipt.lines[0], "install outcome: partial"); + assert!( + !receipt + .lines + .iter() + .any(|line| line == "install outcome: complete") + ); + assert_eq!( + receipt.lines[2..7], + [ + "stub `bal-capture` at /home/o/.claude/skills/bal-capture/SKILL.md: not written", + "stub `bal-help` at /home/o/.claude/skills/bal-help/SKILL.md: not written", + "registration at /home/o/.claude.json: not written", + "hook at /home/o/.claude/settings.json: not written", + "settings at /home/o/.claude/settings.json: not written", + ] + ); + assert_eq!(receipt.code, 1); + assert!(!receipt.error); + } + + #[test] + fn a_receipt_missing_a_next_step_is_caught() { + let installed = installed(); + let receipt = render( + "0.2.0", + installed.layout.stable_path(), + &installed.placements, + &outcomes(ArtifactOutcome::NotWritten), + &Ok(true), + ); + assert_eq!(receipt.lines[1], "baley 0.2.0 at /home/o/.local/bin/baley"); + assert_eq!( + receipt.lines[8..], + [ + "start a new Claude Code session to load the wiring", + "run baley init in each checkout Baley should manage", + "automatic update checks follow the updates.auto setting; baley config set --global updates.auto=true turns them on", + "baley update checks for a newer version by hand", + "baley config interview chooses settings later", + "baley doctor checks the installed result", + ] + ); + } + + #[test] + fn a_catalog_seed_failure_hidden_by_the_receipt_is_caught() { + let installed = installed(); + let outcomes = outcomes(ArtifactOutcome::Unchanged); + let receipt = render( + "0.2.0", + installed.layout.stable_path(), + &installed.placements, + &outcomes, + &Err(StoreError::Busy), + ); + assert_eq!( + receipt.lines[7], + "the model catalog seed was not recorded: the ledger is busy; run the command again" + ); + assert_eq!(receipt.lines[0], "install outcome: partial"); + assert_eq!(receipt.code, 1); + assert!(!receipt.error); + + for recorded in [true, false] { + let receipt = render( + "0.2.0", + installed.layout.stable_path(), + &installed.placements, + &outcomes, + &Ok(recorded), + ); + assert_eq!(receipt.lines[0], "install outcome: complete"); + assert_eq!(receipt.code, 0); + assert!(!receipt.error); + let expected = if recorded { + format!("seeded the model catalog with hint table version {HINT_VERSION}") + } else { + format!( + "the model catalog seed with hint table version {HINT_VERSION} is already recorded" + ) + }; + assert_eq!(receipt.lines[7], expected); + } + } +} diff --git a/crates/baley/src/lib.rs b/crates/baley/src/lib.rs index caeb8380..3e0485b8 100644 --- a/crates/baley/src/lib.rs +++ b/crates/baley/src/lib.rs @@ -69,6 +69,8 @@ pub mod host_artifacts; pub mod host_doctor; /// `baley init`: ties a repository to a ledger project. pub mod init; +/// `baley install`: Claude Code's wiring and the catalog seed at the stable path. +pub mod install; /// The compiled instruction registry: every instruction served to a model, by identity, with no disk loader and no override. pub mod instruction; /// The owner's provider keys, read from `keys.env`. diff --git a/crates/baley/src/main.rs b/crates/baley/src/main.rs index c16e7f9a..308a792c 100644 --- a/crates/baley/src/main.rs +++ b/crates/baley/src/main.rs @@ -34,6 +34,8 @@ enum Command { Models(baley::models::ModelsArgs), /// Check the update source and stage a newer version behind the stable path. Update(baley::update::command::UpdateArgs), + /// Install Claude Code's user-level wiring and seed the model catalog. + Install(baley::install::command::InstallArgs), /// Owner operations on the evidence ledger. #[command(flatten)] Ledger(baley::ledger::LedgerCommand), @@ -115,6 +117,7 @@ fn run_command(command: Command) -> std::process::ExitCode { Command::Config(args) => return baley::config_command::run(args), Command::Models(args) => return baley::models::run(args), Command::Update(args) => return baley::update::command::run(args), + Command::Install(args) => return baley::install::command::run(args), other => other, }; let arguments: Vec<&str> = match &command { @@ -124,6 +127,7 @@ fn run_command(command: Command) -> std::process::ExitCode { | Command::Init(_) | Command::Config(_) | Command::Models(_) + | Command::Install(_) | Command::Update(_) => { unreachable!("dispatched above") } @@ -239,6 +243,31 @@ mod serve_argument_tests { } } +#[cfg(test)] +mod install_argument_tests { + use super::*; + + #[test] + fn an_install_host_argument_lost_or_defaulted_wrongly_is_caught() { + for (input, expected) in [ + (vec!["baley", "install"], "claude-code"), + (vec!["baley", "install", "--host", "codex"], "codex"), + ] { + let Command::Install(args) = Cli::try_parse_from(input).unwrap().command else { + panic!("install command lost"); + }; + assert_eq!(args.host, expected); + } + for input in [ + vec!["baley", "install", "extra"], + vec!["baley", "install", "--host"], + ] { + let error = Cli::try_parse_from(input).err().expect("usage refusal"); + assert_eq!(error.exit_code(), 2); + } + } +} + #[cfg(test)] mod update_argument_tests { use super::*; From 5ca6fe4c5d87a014b78d679c3426145b77b5ebea Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 09:08:09 -0400 Subject: [PATCH 03/35] feat(guard): make the guard protect the installed stubs, settings, registration and executable The guard derives those paths from HOME and CLAUDE_CONFIG_DIR, so they are protected whether or not install has run, and the versions folder is write-only. When the paths cannot be placed the guard fails closed, as it does when Baley's own folders cannot be found, and the denial names the cause it met. --- crates/baley/src/guard_hook/context.rs | 134 +++++++++++++++-- crates/baley/src/guard_hook/decide.rs | 200 ++++++++++++++++++++++++- crates/baley/src/guard_hook/mod.rs | 10 +- crates/baley/src/guard_hook/policy.rs | 18 +-- crates/baley/src/guard_hook/record.rs | 4 +- 5 files changed, 331 insertions(+), 35 deletions(-) diff --git a/crates/baley/src/guard_hook/context.rs b/crates/baley/src/guard_hook/context.rs index df605bdf..622ebdbe 100644 --- a/crates/baley/src/guard_hook/context.rs +++ b/crates/baley/src/guard_hook/context.rs @@ -1,13 +1,16 @@ -//! Where the hook runs (design 0010, GRD-R2): the session project from +//! Where the hook runs: the session project from //! `CLAUDE_PROJECT_DIR`, the checkout the hook's cwd is in, Baley's folders -//! and the paths the guard protects. The cwd never stands in for the project, -//! so a `/cd` never changes whose policy applies. +//! and the installed paths derived from HOME and CLAUDE_CONFIG_DIR. The cwd +//! never stands in for the project, so a `/cd` never changes whose policy +//! applies. use crate::discovery::{self, Discovery, PROJECT_FILE}; use crate::folders::{Environment, FolderRefusal, Folders, Platform}; +use crate::host_artifacts::{installed, stubs}; use crate::mcp::context::{DirectoryFault, ProjectContext, project_context}; use crate::protected_paths::ProtectedPaths; use std::ffi::OsStr; +use std::fmt; use std::path::{Path, PathBuf}; /// The session project, bound because its directory is in a managed @@ -32,9 +35,27 @@ pub(super) struct HookContext { /// The root of the cwd's checkout, used for remembered denials when a /// commit has no redirect. A redirected commit supplies its own walk. pub checkout: Option, - /// The paths path tools are judged against, or why Baley's folders could - /// not be resolved. - pub protected: Result, + /// The paths path tools are judged against, or why Baley's folders or + /// installed artifact paths could not be resolved. + pub protected: Result, +} + +/// Why the guard cannot establish the complete set of protected paths. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum Refusal { + /// Baley's ledger home or configuration folder could not be resolved. + Folders(FolderRefusal), + /// The installed Claude files or executable could not be placed. + Placements(installed::Refusal), +} + +impl fmt::Display for Refusal { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Folders(refusal) => refusal.fmt(f), + Self::Placements(refusal) => refusal.fmt(f), + } + } } /// A directory's discovery, or why its walk failed. A failed walk is not @@ -53,13 +74,18 @@ pub(super) fn gather(cwd: &str) -> HookContext { ProjectContext::Valid(directory) => Some(walk(Path::new(directory))), ProjectContext::Missing | ProjectContext::Invalid(_) => None, }; - let folders = Folders::resolve(Platform::current(), &Environment::read()); + let env = Environment::read(); + let folders = Folders::resolve(Platform::current(), &env); + let claude_config_dir = std::env::var_os("CLAUDE_CONFIG_DIR"); + let manifest = stubs::manifest(&stubs::front_doors()).expect("unique compiled front doors"); + let installed = installed::resolve(&env, claude_config_dir, &manifest); judge( project_dir.as_deref(), &project, project_walk, walk(Path::new(cwd)), folders, + installed.map(Some), ) } @@ -81,13 +107,16 @@ pub(super) fn walk(directory: &Path) -> Walk { /// The protected files are the bound project's `baley.toml` and the one that /// binds the cwd's checkout: its project file when managed, or the root's /// missing `baley.toml` when unmanaged, so an agent cannot create the file -/// that would bind it. Stubs are not listed yet. +/// that would bind it. Supplied installed placements follow, each file once +/// in first-seen order, with the versions folder protected against writes. +/// A folder or placement refusal leaves no protected paths to judge against. pub(super) fn judge( project_dir: Option<&OsStr>, project: &ProjectContext, project_walk: Option, cwd_walk: Walk, folders: Result, + installed: Result, installed::Refusal>, ) -> HookContext { let project = match (project, project_walk) { (ProjectContext::Valid(_), Some(Ok(Discovery::Managed { folder, root }))) => { @@ -123,12 +152,23 @@ pub(super) fn judge( project_directory, project, checkout, - protected: folders.map(|folders| ProtectedPaths { - home: folders.home, - config: folders.config, - files, - // T15 passes the placement map's write-only folders here. - write_only_folders: Vec::new(), + protected: folders.map_err(Refusal::Folders).and_then(|folders| { + let installed = installed.map_err(Refusal::Placements)?; + let mut write_only_folders = Vec::new(); + if let Some(installed) = installed { + for file in installed.placements.protected_paths() { + if !files.contains(&file) { + files.push(file); + } + } + write_only_folders = installed.placements.write_only_folders(); + } + Ok(ProtectedPaths { + home: folders.home, + config: folders.config, + files, + write_only_folders, + }) }), } } @@ -155,7 +195,7 @@ mod tests { Ok(Discovery::Unmanaged { root: path.into() }) } - fn protecting(files: &[&str]) -> Result { + fn protecting(files: &[&str]) -> Result { Ok(ProtectedPaths { home: "/u/.local/share/crenshawdev/baley".into(), config: "/u/.config/crenshawdev/baley".into(), @@ -171,6 +211,62 @@ mod tests { }) } + #[test] + fn the_installed_placements_left_off_or_repeated_in_the_protected_list_is_caught() { + use crate::host_artifacts::{installed, stubs}; + + let env = Environment { + home: Some("/u".into()), + ..Environment::default() + }; + let installed = + installed::resolve(&env, None, &stubs::manifest(&stubs::front_doors()).unwrap()) + .unwrap(); + let placed = [ + "/u/.claude/skills/bal-capture/SKILL.md", + "/u/.claude/skills/bal-help/SKILL.md", + "/u/.claude.json", + "/u/.claude/settings.json", + "/u/.local/bin/baley", + ]; + for (given, project, project_walk, cwd_walk, project_files) in [ + ( + Some(OsStr::new("/p")), + ProjectContext::Valid("/p".into()), + Some(managed("/p")), + managed("/q"), + vec!["/p/baley.toml", "/q/baley.toml"], + ), + ( + None, + ProjectContext::Missing, + None, + Ok(Discovery::Outside), + vec![], + ), + ] { + let context = judge( + given, + &project, + project_walk, + cwd_walk, + folders(), + Ok(Some(installed.clone())), + ); + let protected = context.protected.unwrap(); + let expected: Vec = project_files + .into_iter() + .chain(placed) + .map(PathBuf::from) + .collect(); + assert_eq!(protected.files, expected); + assert_eq!( + protected.write_only_folders, + [PathBuf::from("/u/.local/lib/crenshawdev/baley/versions")] + ); + } + } + #[test] fn the_cwd_taking_over_the_session_project_is_caught() { let context = judge( @@ -179,6 +275,7 @@ mod tests { Some(managed("/p")), managed("/q"), folders(), + Ok(None), ); assert_eq!( context, @@ -207,6 +304,7 @@ mod tests { None, managed("/q"), folders(), + Ok(None), ); assert_eq!( context, @@ -231,6 +329,7 @@ mod tests { None, managed("/q"), folders(), + Ok(None), ); assert_eq!(context.project_directory, Err(DirectoryFault::NotUtf8)); assert_eq!(context.project, None); @@ -244,6 +343,7 @@ mod tests { Some(unmanaged("/p")), managed("/q"), folders(), + Ok(None), ); assert_eq!(context.project, None); assert_eq!(context.checkout, Some("/q".into())); @@ -257,6 +357,7 @@ mod tests { Some(managed("/p")), unmanaged("/q"), folders(), + Ok(None), ); assert_eq!(context.project, bound("/p")); assert_eq!(context.checkout, Some("/q".into())); @@ -274,6 +375,7 @@ mod tests { Some(managed("/p")), managed("/q"), Err(FolderRefusal::UserHomeUnset), + Ok(None), ); assert_eq!( context, @@ -281,7 +383,7 @@ mod tests { project_directory: Ok(Some("/p".into())), project: bound("/p"), checkout: Some("/q".into()), - protected: Err(FolderRefusal::UserHomeUnset), + protected: Err(Refusal::Folders(FolderRefusal::UserHomeUnset)), } ); } diff --git a/crates/baley/src/guard_hook/decide.rs b/crates/baley/src/guard_hook/decide.rs index 8151a5f8..138727ff 100644 --- a/crates/baley/src/guard_hook/decide.rs +++ b/crates/baley/src/guard_hook/decide.rs @@ -151,8 +151,8 @@ pub(super) fn next(input: &HookInput, context: &HookContext, seen: &Seen, fs: &d } } -/// A path call, judged against the hook's cwd. Without Baley's folders -/// nothing can be cleared, so the call is refused. +/// A path call, judged against the hook's cwd. Without the complete protected +/// paths nothing can be cleared, so the call is refused. fn path(envelope: &Envelope, target: &PathTarget, context: &HookContext, fs: &dyn Lookup) -> Next { let spelled = target.path.as_deref(); let pattern = target.pattern.as_deref(); @@ -180,7 +180,7 @@ fn path(envelope: &Envelope, target: &PathTarget, context: &HookContext, fs: &dy let cwd = envelope.cwd.as_str(); let answer = match (&context.protected, target.tool) { (Err(refusal), tool) => Answer::Deny(format!( - "Baley cannot find its home and config folders ({refusal}), so this {} call is refused", + "Baley cannot resolve its protected paths ({refusal}), so this {} call is refused", tool.name() )), (Ok(protected), PathTool::Write | PathTool::Edit | PathTool::NotebookEdit) => write_answer( @@ -629,7 +629,9 @@ mod tests { #[test] fn a_path_call_judged_without_baleys_folders_is_caught() { let mut unresolved = context(Some("/p")); - unresolved.protected = Err(FolderRefusal::UserHomeUnset); + unresolved.protected = Err(super::super::context::Refusal::Folders( + FolderRefusal::UserHomeUnset, + )); let decided = decided(next( &write("/p", "/p/src/lib.rs"), &unresolved, @@ -656,6 +658,196 @@ mod tests { ); } + #[test] + fn a_write_to_an_installed_placement_passed_by_the_guard_is_caught() { + use crate::discovery::Discovery; + use crate::folders::{Environment, Folders}; + use crate::guard_hook::context; + use crate::host_artifacts::{installed, stubs}; + use crate::mcp::context::ProjectContext; + use crate::protected_paths::Entry; + use std::ffi::OsStr; + use std::io; + use std::path::Path; + + const STABLE: &str = "/u/.local/bin/baley"; + const STAGED: &str = "/u/.local/lib/crenshawdev/baley/versions/0.2.0/baley"; + + // Supply the stable link's observations without resolving paths in the fake. + struct WithStableLink(Tree); + + impl Lookup for WithStableLink { + fn metadata(&self, path: &Path) -> io::Result { + self.0.metadata(if path == Path::new(STABLE) { + Path::new(STAGED) + } else { + path + }) + } + + fn present(&self, path: &Path) -> io::Result<()> { + self.0.present(path) + } + + fn canonicalize(&self, path: &Path) -> io::Result { + if path == Path::new(STABLE) { + Ok(STAGED.into()) + } else { + self.0.canonicalize(path) + } + } + + fn absolute(&self, path: &Path) -> io::Result { + self.0.absolute(path) + } + } + + let fs = [ + "/", + "/p", + "/q", + "/u", + "/u/.config", + "/u/.config/crenshawdev", + CONFIG, + "/u/.local", + "/u/.local/share", + "/u/.local/share/crenshawdev", + HOME, + "/u/.claude", + "/u/.claude/skills", + "/u/.claude/skills/bal-capture", + "/u/.claude/skills/bal-help", + "/u/.local/bin", + "/u/.local/lib", + "/u/.local/lib/crenshawdev", + "/u/.local/lib/crenshawdev/baley", + "/u/.local/lib/crenshawdev/baley/versions", + "/u/.local/lib/crenshawdev/baley/versions/0.2.0", + ] + .into_iter() + .fold(tree(), |fs, path| fs.dir(path)); + let fs = WithStableLink( + fs.file("/p/baley.toml") + .file("/q/baley.toml") + .file("/u/.claude/skills/bal-capture/SKILL.md") + .file("/u/.claude/skills/bal-help/SKILL.md") + .file("/u/.claude/settings.json") + .file("/u/.claude.json") + .file(STAGED) + .file(STABLE), + ); + let env = Environment { + home: Some("/u".into()), + ..Environment::default() + }; + let installed = + installed::resolve(&env, None, &stubs::manifest(&stubs::front_doors()).unwrap()); + let managed = |path: &str| { + Ok(Discovery::Managed { + folder: path.into(), + root: path.into(), + }) + }; + let context = context::judge( + Some(OsStr::new("/p")), + &ProjectContext::Valid("/p".into()), + Some(managed("/p")), + managed("/q"), + Ok(Folders { + home: HOME.into(), + config: CONFIG.into(), + }), + installed.map(Some), + ); + + for (tool, path) in [ + (PathTool::Write, "/u/.claude/skills/bal-help/SKILL.md"), + (PathTool::Edit, "/u/.claude/settings.json"), + (PathTool::Write, "/u/.claude.json"), + (PathTool::Write, STABLE), + ( + PathTool::NotebookEdit, + "/u/.local/lib/crenshawdev/baley/versions/0.2.0/notes.ipynb", + ), + ] { + let call = HookInput::Path { + envelope: envelope("/q"), + target: PathTarget { + tool, + path: Some(path.into()), + pattern: None, + }, + }; + let answer = decided(next(&call, &context, &Seen::default(), &fs)).answer; + assert!( + matches!(answer, Answer::Deny(_)), + "{} {path}: {answer:?}", + tool.name() + ); + } + assert_eq!( + decided(next( + &write("/q", "/q/notes.txt"), + &context, + &Seen::default(), + &fs + )) + .answer, + Answer::Pass, + ); + } + + #[test] + fn a_placement_refusal_letting_a_path_tool_through_is_caught() { + use crate::discovery::Discovery; + use crate::folders::{Environment, Folders}; + use crate::guard_hook::context; + use crate::host_artifacts::{installed, stubs}; + use crate::mcp::context::ProjectContext; + + let env = Environment { + home: Some("/u".into()), + ..Environment::default() + }; + let installed = installed::resolve( + &env, + Some("claude".into()), + &stubs::manifest(&stubs::front_doors()).unwrap(), + ); + let context = context::judge( + None, + &ProjectContext::Missing, + None, + Ok(Discovery::Outside), + Ok(Folders { + home: HOME.into(), + config: CONFIG.into(), + }), + installed.map(Some), + ); + let fs = tree().dir("/q"); + for tool in [PathTool::Write, PathTool::Read] { + let call = HookInput::Path { + envelope: envelope("/q"), + target: PathTarget { + tool, + path: Some("/q/notes.txt".into()), + pattern: None, + }, + }; + let answer = decided(next(&call, &context, &Seen::default(), &fs)).answer; + let Answer::Deny(reason) = answer else { + panic!("{} was not denied: {answer:?}", tool.name()) + }; + assert!(reason.contains("CLAUDE_CONFIG_DIR"), "{reason}"); + assert!( + !reason.contains("home folder") && !reason.contains("home and config folders"), + "{reason}" + ); + } + } + #[test] fn a_record_digest_over_the_wrong_fields_or_carrying_the_command_is_caught() { let grep = HookInput::Path { diff --git a/crates/baley/src/guard_hook/mod.rs b/crates/baley/src/guard_hook/mod.rs index a4ae5b19..96eaf759 100644 --- a/crates/baley/src/guard_hook/mod.rs +++ b/crates/baley/src/guard_hook/mod.rs @@ -42,10 +42,10 @@ use unrecordable::{not_recorded, store_failure}; /// answer recorded under its call id, on a short store open of its own, /// and a replay is answered with no git or policy read. Only a bound commit /// then reads a branch and a policy, in that order. Any answer selected for -/// recording takes a second short open for the audit transaction. Without -/// Baley's home folder or the call's identity, after a call id clash, or on -/// any store failure the decision is unrecordable, which a loud line says: -/// an ask becomes a deny. +/// recording takes a second short open for the audit transaction. When +/// Baley's folders or installed paths cannot be resolved, without the call's +/// identity, after a call id clash, or on any store failure the decision is +/// unrecordable, which a loud line says: an ask becomes a deny. pub fn run() -> ExitCode { let mut budget = Budget::start(); let mut bytes = Vec::new(); @@ -73,7 +73,7 @@ pub fn run() -> ExitCode { // Who records, and where, or why nothing can be. let recorder = match (envelope, &context.protected) { (_, Err(refusal)) => Err(not_recorded(&format!( - "Baley's home folder cannot be found ({refusal})" + "the protected paths cannot be resolved ({refusal})" ))), (None, Ok(_)) => Err(not_recorded("the call carries no envelope")), (Some(envelope), Ok(paths)) => unrecordable::caller(envelope, &context.project_directory) diff --git a/crates/baley/src/guard_hook/policy.rs b/crates/baley/src/guard_hook/policy.rs index 20bb8952..5c6cdca7 100644 --- a/crates/baley/src/guard_hook/policy.rs +++ b/crates/baley/src/guard_hook/policy.rs @@ -3,10 +3,9 @@ //! each bounded, merged with Claude Code's host sections. Nothing here admits //! a checkout or records a policy. -use super::context::Bound; +use super::context::{Bound, Refusal}; use crate::committed::{self, Committed, GuardRefusal}; use crate::discovery::PROJECT_FILE; -use crate::folders::FolderRefusal; use crate::guard_budget::Budget; use crate::policy_step::{self, Reads}; use crate::process::Process; @@ -18,8 +17,8 @@ use std::path::{Path, PathBuf}; /// What the policy reads found, before any rule is applied. #[derive(Debug, Clone, PartialEq, Eq)] pub(super) struct Seen { - /// The global file's bounded read, or why the config folder is unknown. - pub global: Result, Unavailable>, FolderRefusal>, + /// The global file's bounded read, or why the protected paths are unresolved. + pub global: Result, Unavailable>, Refusal>, /// The working-tree `baley.toml` the walk found. pub working_path: PathBuf, /// The working-tree `baley.toml`'s bounded read. @@ -31,7 +30,7 @@ pub(super) struct Seen { /// Reads the global file from `config` and the project's files, HEAD's copy /// on the guard's budget. It owns no policy, so it has no unit test. pub(super) fn gather( - config: Result<&Path, &FolderRefusal>, + config: Result<&Path, &Refusal>, project: &Bound, process: &mut dyn Process, budget: &mut Budget, @@ -60,9 +59,9 @@ pub(super) fn gather( /// The settings a commit is judged under, and the excerpt of git's stderr /// in them when HEAD's copy is the torn file, which a record leaves out. A -/// refused working-tree read is torn, and so is the global file when Baley's -/// folders are unknown, since a policy without the owner's global file is -/// not the owner's policy. The merge applies Claude Code's +/// refused working-tree read is torn, and so is the global file when the +/// protected paths are unresolved, since a policy without the owner's global +/// file is not the owner's policy. The merge applies Claude Code's /// `[host.claude-code]` sections. pub(super) fn settings(seen: Seen) -> (SettingsInput, Option) { let global = seen.global.unwrap_or_else(|refusal| { @@ -109,6 +108,7 @@ pub(super) fn settings(seen: Seen) -> (SettingsInput, Option) { #[cfg(test)] mod tests { use super::*; + use crate::folders::FolderRefusal; use baley_core::policy::OnProtected; const PROJECT: &str = "/p/baley.toml"; @@ -187,7 +187,7 @@ mod tests { assert_eq!(settings(seen), (SettingsInput::Torn(refused), None)); let seen = Seen { - global: Err(FolderRefusal::UserHomeUnset), + global: Err(Refusal::Folders(FolderRefusal::UserHomeUnset)), ..at_head("[git]\non_protected = \"refuse\"\n") }; let (SettingsInput::Torn(torn), None) = settings(seen) else { diff --git a/crates/baley/src/guard_hook/record.rs b/crates/baley/src/guard_hook/record.rs index bacc62a7..8004f188 100644 --- a/crates/baley/src/guard_hook/record.rs +++ b/crates/baley/src/guard_hook/record.rs @@ -530,7 +530,9 @@ mod tests { root: "/p".into(), }), checkout: Some("/q".into()), - protected: Err(crate::folders::FolderRefusal::UserHomeUnset), + protected: Err(super::super::context::Refusal::Folders( + crate::folders::FolderRefusal::UserHomeUnset, + )), } } From 4743243f07d61f1d72f42c236423769d3c01f577 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 09:22:17 -0400 Subject: [PATCH 04/35] feat(ledger): record installs as install.recorded and read the latest back through an install view Ownership evidence has to survive between runs, so the record holds Baley's entries whole and the view keeps only the newest record per host. The view set rises to 8, which makes an older binary read-only on the user project. --- crates/baley/src/install/event.rs | 234 ++++++++++++++++++++++++++++++ crates/baley/src/install/mod.rs | 4 + crates/baley/src/install/view.rs | 177 ++++++++++++++++++++++ crates/baley/src/ledger/open.rs | 66 ++++++--- 4 files changed, 458 insertions(+), 23 deletions(-) create mode 100644 crates/baley/src/install/event.rs create mode 100644 crates/baley/src/install/view.rs diff --git a/crates/baley/src/install/event.rs b/crates/baley/src/install/event.rs new file mode 100644 index 00000000..de12cac2 --- /dev/null +++ b/crates/baley/src/install/event.rs @@ -0,0 +1,234 @@ +//! Ownership facts for `install.recorded`, version 1, on `user`'s `install` +//! stream. Builders use supplied values only. The caller records the event +//! after judging which artifacts remain Baley's and whether the run completed. + +use baley_core::policy::Host; +use baley_core::{Registry, RegistryError}; +use baley_store::{NewEvent, StreamName}; +use serde::Serialize; +use serde_json::{Value, json}; + +use crate::update::events::INSTALL_STREAM; + +/// An installation's ownership evidence after its writes finish. +pub const INSTALL_RECORDED: &str = "install.recorded"; +/// The current `install.recorded` payload version. +pub const INSTALL_RECORDED_VERSION: u32 = 1; + +/// Registers `install.recorded` at version 1, with no upcasters. +pub fn register_install_events(registry: &mut Registry) -> Result<(), RegistryError> { + registry.register(INSTALL_RECORDED, INSTALL_RECORDED_VERSION, []) +} + +/// The whole MCP entry Baley owns, even when this run left it unchanged. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Registration { + /// The user-scope registration file's absolute path. + pub path: String, + /// The complete `mcpServers.baley` entry after the run. + pub entry: Value, +} + +/// The whole pre-tool hook item Baley owns after the run. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Hook { + /// The settings file's absolute path. + pub path: String, + /// Baley's complete `PreToolUse` item, including its matcher and hooks. + pub item: Value, +} + +/// A stub whose bytes are Baley's after the run. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Stub { + /// The served front door's identity. + pub identity: String, + /// The stub's absolute path. + pub path: String, + /// The lowercase hex SHA-256 of the bytes left at that path. + pub sha256: String, +} + +/// The protected paths and whole security entries Baley owns after the run. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Sandbox { + /// The settings file's absolute path. + pub settings_path: String, + /// The lowercase hex SHA-256 of the settings file's bytes after the run. + pub sha256: String, + /// Baley's resolved data folder. + pub home: String, + /// Baley's resolved configuration folder. + pub config: String, + /// Files protected from writes. + pub write_only_files: Vec, + /// Folders protected from writes. + pub write_only_folders: Vec, + /// Every `permissions.deny` entry Baley owns after the run. + pub permissions_deny: Vec, + /// Every `sandbox.filesystem.denyRead` entry Baley owns after the run. + pub deny_read: Vec, + /// Every `sandbox.filesystem.denyWrite` entry Baley owns after the run. + pub deny_write: Vec, + /// Why the sandbox block was not written, or none when it was. + pub held_back: Option, +} + +/// The effective update setting and the version the stable path runs. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Updates { + /// Effective `updates.auto`, or none when global settings could not be read. + pub auto: Option, + /// The version the stable path runs, or none when it names none. + pub staged_version: Option, +} + +/// Supplied facts about all artifacts Baley owns after the run, including +/// unchanged entries and older artifacts whose replacement did not finish. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Facts { + /// The host whose wiring was installed. + pub host: Host, + /// The installing binary's version as text. + pub binary_version: String, + /// The stable executable path as absolute UTF-8 text. + pub binary_path: String, + /// True only with every artifact owned, no gap and the catalog seed recorded. + /// The caller judges this from the same outcomes used by the receipt. + pub complete: bool, + /// The registration Baley owns, or none when none is present. + pub registration: Option, + /// The hook Baley owns, or none when none is present. + pub hook: Option, + /// Every stub Baley owns after the run, in manifest order. + pub stubs: Vec, + /// Baley's security entries, or none when the settings file holds none. + pub sandbox: Option, + /// The effective update setting and active staged version. + pub updates: Updates, +} + +/// Builds the payload without applying any setting or acknowledgement. +/// Whole ownership entries are preserved so a later run can replace them. +pub fn payload(facts: &Facts) -> Value { + json!({ + "host": facts.host.name(), + "binary_version": facts.binary_version, + "binary_path": facts.binary_path, + "complete": facts.complete, + "registered": { + "registration": facts.registration, + "hook": facts.hook, + }, + "stubs": facts.stubs, + "sandbox": facts.sandbox, + "defaults": {}, + "updates": facts.updates, + }) +} + +/// Builds an attachment-free event for the caller to append in project `user`. +pub fn recorded_event(facts: &Facts) -> NewEvent { + NewEvent { + stream: StreamName(INSTALL_STREAM.into()), + type_name: INSTALL_RECORDED.into(), + type_version: INSTALL_RECORDED_VERSION, + git: None, + payload: payload(facts), + attachments: vec![], + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_install_payload_missing_a_design_field_or_an_acknowledgement_slipped_in_is_caught() { + let facts = Facts { + host: Host::ClaudeCode, + binary_version: "0.2.0".into(), + binary_path: "/home/o/.local/bin/baley".into(), + complete: false, + registration: None, + hook: Some(Hook { + path: "/home/o/.claude/settings.json".into(), + item: json!({ + "matcher": "Write|Edit", + "hooks": [{"type": "command", "command": "/home/o/.local/bin/baley guard", "timeout": 5}], + }), + }), + stubs: vec![ + Stub { + identity: "bal-capture".into(), + path: "/home/o/.claude/skills/bal-capture/SKILL.md".into(), + sha256: "a".repeat(64), + }, + Stub { + identity: "bal-help".into(), + path: "/home/o/.claude/skills/bal-help/SKILL.md".into(), + sha256: "b".repeat(64), + }, + ], + sandbox: Some(Sandbox { + settings_path: "/home/o/.claude/settings.json".into(), + sha256: "c".repeat(64), + home: "/home/o/.local/share/crenshawdev/baley".into(), + config: "/home/o/.config/crenshawdev/baley".into(), + write_only_files: vec!["/home/o/.local/bin/baley".into()], + write_only_folders: vec!["/home/o/.local/lib/crenshawdev/baley/versions".into()], + permissions_deny: vec!["Edit(//home/o/.local/bin/baley)".into()], + deny_read: vec![], + deny_write: vec![], + held_back: Some("socat is missing; install socat".into()), + }), + updates: Updates { + auto: None, + staged_version: Some("0.2.0".into()), + }, + }; + let event = recorded_event(&facts); + assert_eq!(event.type_name, "install.recorded"); + assert_eq!(event.type_version, 1); + assert_eq!(event.stream, StreamName("install".into())); + assert!(event.attachments.is_empty()); + assert_eq!(event.git, None); + assert_eq!( + event.payload, + json!({ + "host": "claude-code", + "binary_version": "0.2.0", + "binary_path": "/home/o/.local/bin/baley", + "complete": false, + "registered": { + "registration": null, + "hook": { + "path": "/home/o/.claude/settings.json", + "item": { + "matcher": "Write|Edit", + "hooks": [{"type": "command", "command": "/home/o/.local/bin/baley guard", "timeout": 5}], + }, + }, + }, + "stubs": [ + {"identity": "bal-capture", "path": "/home/o/.claude/skills/bal-capture/SKILL.md", "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + {"identity": "bal-help", "path": "/home/o/.claude/skills/bal-help/SKILL.md", "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}, + ], + "sandbox": { + "settings_path": "/home/o/.claude/settings.json", + "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "home": "/home/o/.local/share/crenshawdev/baley", + "config": "/home/o/.config/crenshawdev/baley", + "write_only_files": ["/home/o/.local/bin/baley"], + "write_only_folders": ["/home/o/.local/lib/crenshawdev/baley/versions"], + "permissions_deny": ["Edit(//home/o/.local/bin/baley)"], + "deny_read": [], + "deny_write": [], + "held_back": "socat is missing; install socat", + }, + "defaults": {}, + "updates": {"auto": null, "staged_version": "0.2.0"}, + }) + ); + } +} diff --git a/crates/baley/src/install/mod.rs b/crates/baley/src/install/mod.rs index 83d87b5c..a3cc629a 100644 --- a/crates/baley/src/install/mod.rs +++ b/crates/baley/src/install/mod.rs @@ -4,5 +4,9 @@ /// Command arguments, host selection and installation gathering. pub mod command; +/// Installation ownership facts and the `install.recorded` event. +pub mod event; /// The install outcome and the owner's next steps, rendered from supplied facts. pub mod receipt; +/// The latest installation ownership record for each host. +pub mod view; diff --git a/crates/baley/src/install/view.rs b/crates/baley/src/install/view.rs new file mode 100644 index 00000000..1f4f3c5b --- /dev/null +++ b/crates/baley/src/install/view.rs @@ -0,0 +1,177 @@ +//! The `install` view keeps the latest `install.recorded` payload and its +//! sequence for each host. Update events stay on their stream without a view. + +use baley_store::{ + Change, DocKey, Event, FieldKind, FieldSpec, KeyValue, Projector, ProjectorError, ViewSpec, +}; +use serde_json::{Value, json}; + +use super::event::INSTALL_RECORDED; + +/// The view's name. +pub const INSTALL_VIEW: &str = "install"; + +/// Declares a text host key with no index. +pub fn install_spec() -> ViewSpec { + ViewSpec { + name: INSTALL_VIEW.into(), + version: 1, + key: vec![FieldSpec { + name: "host".into(), + kind: FieldKind::Text, + }], + indexes: vec![], + page_bound: 100, + } +} + +/// The key of a host's latest installation record. +pub fn install_key(host: &str) -> DocKey { + DocKey(vec![KeyValue::Text(host.into())]) +} + +/// Replaces each host's document with `{host, seq, payload}` from its latest +/// `install.recorded`, preserving the whole payload as ownership evidence. +pub struct InstallProjector { + spec: ViewSpec, +} + +impl InstallProjector { + /// Makes the projector. + pub fn new() -> Self { + Self { + spec: install_spec(), + } + } +} + +impl Default for InstallProjector { + fn default() -> Self { + Self::new() + } +} + +impl Projector for InstallProjector { + fn spec(&self) -> &ViewSpec { + &self.spec + } + + fn handles(&self) -> &[&str] { + &[INSTALL_RECORDED] + } + + // Each event replaces the whole document, so no previous value is needed. + fn keys(&self, _event: &Event) -> Vec { + vec![] + } + + fn apply( + &self, + event: &Event, + _documents: &[(DocKey, Value)], + ) -> Result, ProjectorError> { + let refuse = |message: &str| { + ProjectorError(format!( + "{} at seq {}: {message}", + event.type_name, event.seq + )) + }; + let Value::Object(payload) = &event.payload else { + return Err(refuse("the payload is not an object")); + }; + let Some(Value::String(host)) = payload.get("host") else { + return Err(refuse("host is missing or not text")); + }; + Ok(vec![Change::Put { + key: install_key(host), + body: json!({"host": host, "seq": event.seq, "payload": event.payload}), + }]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use baley_store::{Actor, Hash, ProjectId, RequestId}; + + fn event(seq: u64, payload: Value) -> Event { + Event { + project_id: ProjectId("user".into()), + seq, + stream: "install".into(), + stream_version: 1, + type_name: "install.recorded".into(), + type_version: 1, + actor: Actor::Owner, + caller: None, + recorded_at: "2026-10-10T09:00:00Z".into(), + request_id: RequestId("00000000-0000-4000-8000-000000000001".into()), + git: None, + policy_version: 0, + payload, + prev_hash: Some(Hash([0; 32])), + hash: Hash([1; 32]), + } + } + + #[test] + fn an_install_view_keeping_an_older_record_is_caught() { + let projector = InstallProjector::new(); + assert_eq!(projector.handles(), ["install.recorded"]); + assert_eq!(projector.spec().name, "install"); + assert_eq!( + projector.spec().key, + [FieldSpec { + name: "host".into(), + kind: FieldKind::Text, + }] + ); + assert!(projector.spec().indexes.is_empty()); + let first = event( + 3, + json!({"host": "claude-code", "binary_version": "0.1.0", "stubs": ["old"]}), + ); + let changes = projector.apply(&first, &[]).unwrap(); + let [Change::Put { key, body }] = changes.as_slice() else { + panic!("one host document must be written"); + }; + assert_eq!(key, &DocKey(vec![KeyValue::Text("claude-code".into())])); + assert_eq!( + body, + &json!({ + "host": "claude-code", "seq": 3, + "payload": {"host": "claude-code", "binary_version": "0.1.0", "stubs": ["old"]}, + }) + ); + + let second = event( + 7, + json!({"host": "claude-code", "binary_version": "0.2.0", "stubs": []}), + ); + assert_eq!( + projector + .apply(&second, &[(key.clone(), body.clone())]) + .unwrap(), + [Change::Put { + key: DocKey(vec![KeyValue::Text("claude-code".into())]), + body: json!({ + "host": "claude-code", "seq": 7, + "payload": {"host": "claude-code", "binary_version": "0.2.0", "stubs": []}, + }), + }] + ); + } + + #[test] + fn an_install_record_without_a_host_projected_is_caught() { + let projector = InstallProjector::new(); + for (payload, reason) in [ + (json!({}), "host is missing or not text"), + (json!({"host": 3}), "host is missing or not text"), + (json!([]), "the payload is not an object"), + ] { + let refused = projector.apply(&event(4, payload), &[]).unwrap_err(); + assert_eq!(refused.0, format!("install.recorded at seq 4: {reason}")); + } + } +} diff --git a/crates/baley/src/ledger/open.rs b/crates/baley/src/ledger/open.rs index fe70f540..53a0c5d2 100644 --- a/crates/baley/src/ledger/open.rs +++ b/crates/baley/src/ledger/open.rs @@ -12,17 +12,19 @@ use baley_core::policy::recorded::{PolicyProjector, register_policy_events}; use baley_core::{Registry, register_anchor_events, register_project_events}; use baley_store_sqlite::Options; +use crate::install::event::register_install_events; +use crate::install::view::InstallProjector; use crate::update::events::register_update_events; /// Registers exactly the anchor types, `project.initialized`, the four /// `models.*` types, `policy.effective`, `checkout.seen`, `capture.recorded`, -/// `guard.answered`, `guard.policy_recorded`, `update.checked` and -/// `update.failed` understood by the CLI, and declares view set version 7, -/// `capture checkout claim_scope guard guard_policy model_catalog policy request`. -/// The update events have no view: a view would raise the set version and -/// fence every older running server. -/// The startup `quick_check` is off, so every command-line caller opens as -/// before. +/// `guard.answered`, `guard.policy_recorded`, `install.recorded`, +/// `update.checked` and `update.failed` understood by the CLI, and declares +/// view set version 8: `capture checkout claim_scope guard guard_policy +/// install model_catalog policy request`. The install view holds only install +/// records. Update outcomes are read from their stream and have no projector. +/// A project brought to view set 8 is read-only for older binaries. +/// The startup `quick_check` is off for command-line callers. pub(crate) fn options() -> Options { let mut registry = Registry::new(); register_anchor_events(&mut registry).expect("unique anchor types"); @@ -32,6 +34,7 @@ pub(crate) fn options() -> Options { register_checkout_events(&mut registry).expect("unique checkout types"); register_capture_events(&mut registry).expect("unique capture types"); register_guard_events(&mut registry).expect("unique guard types"); + register_install_events(&mut registry).expect("unique install types"); register_update_events(&mut registry).expect("unique update types"); Options { schema: Box::new(registry), @@ -42,8 +45,9 @@ pub(crate) fn options() -> Options { Box::new(CaptureProjector::new()), Box::new(GuardProjector::new()), Box::new(GuardPolicyProjector::new()), + Box::new(InstallProjector::new()), ], - view_set_version: NonZeroU32::new(7).expect("nonzero"), + view_set_version: NonZeroU32::new(8).expect("nonzero"), ..Options::default() } } @@ -169,8 +173,8 @@ pub(crate) mod tests { } } - /// The options as they stood at view set 7, before the update events - /// were registered. The newer binary must stay compatible with them. + /// The options at view set 7 before update events were registered. + /// A project stays writable here until a newer binary brings it forward. fn view_set_7() -> Options { let mut registry = Registry::new(); register_anchor_events(&mut registry).unwrap(); @@ -455,8 +459,19 @@ pub(crate) mod tests { assert!(reopened.is_ok(), "{:?}", reopened.err()); } - // Catches update events a binary cannot read back, which would fence - // `user` for itself, or an `install` view that raises the view set. + #[test] + fn an_install_record_unreadable_by_the_server_or_the_guard_is_caught() { + for opened in [ + options(), + server_options(), + guard_options(Duration::from_millis(1_500)), + ] { + assert!(opened.schema.reads("install.recorded", 1)); + assert!(!opened.schema.reads("install.recorded", 2)); + } + } + + // Update outcomes stay on their stream, outside the install view. #[test] fn the_update_events_left_unregistered_or_given_a_view_is_caught() { use crate::update::events::{UPDATE_CHECKED, UPDATE_EVENT_VERSION, UPDATE_FAILED}; @@ -473,7 +488,7 @@ pub(crate) mod tests { "{name}" ); } - assert_eq!(opened.view_set_version, NonZeroU32::new(7).unwrap()); + assert_eq!(opened.view_set_version, NonZeroU32::new(8).unwrap()); let mut names = view_names(&opened); names.sort(); assert_eq!( @@ -483,6 +498,7 @@ pub(crate) mod tests { "checkout", "guard", "guard_policy", + "install", "model_catalog", "policy" ] @@ -580,24 +596,21 @@ pub(crate) mod tests { .unwrap(); } - // Catches a newer binary that fences the session's project for the older - // one: an `install` view that raises the view set so the newer write - // rebuilds and the older read is refused, or an update event appended to - // the session's project instead of `user`. + // A newer view set fences only the project brought forward to it. #[test] fn an_older_baley_fenced_out_of_the_session_project_by_the_newer_binary_is_caught() { use crate::models; use crate::update::events::{CheckOutcome, ClaimRef, Facts, checked_event}; use crate::update::version::Version; use baley_core::capture::{CAPTURE_ID_INDEX, CAPTURE_VIEW}; - use baley_core::catalog::HINT_VERSION; + use baley_core::catalog::USER_PROJECT; use baley_core::guard::{ Answer, AnsweredFacts, GUARD_ANSWERED, GUARD_ANSWERED_VERSION, GUARD_STREAM, SettingsFact, answered_payload, }; use baley_store::{ - Admin, IndexQuery, KeyValue, NewEvent, PageRequest, ProjectId, Recorded, RequestId, - StreamName, Views, + Admin, IndexQuery, KeyValue, NewEvent, PageRequest, ProjectId, Recorded, Refusal, + RequestId, StoreError, StreamName, Views, }; const T2: &str = "2026-10-01T10:00:02Z"; @@ -689,10 +702,17 @@ pub(crate) mod tests { ); drop(guarded); - // The older binary reopens: `user`'s catalog reads, and the project - // still takes and finds a capture. + // Only `user` was brought forward, so the session project stays writable. let older = store(&home, T2, view_set_7()).unwrap(); - assert_eq!(models::observe_hint_version(&older), Ok(Some(HINT_VERSION))); + let catalog = models::observe_hint_version(&older); + assert!( + matches!( + &catalog, + Err(StoreError::Refused(Refusal::ProjectReadOnly { project, .. })) + if *project == ProjectId(USER_PROJECT.into()) + ), + "{catalog:?}" + ); let second = record_capture( &older, &project, From b0c8f4a4dd8da065c75e9354b5e9ae4e623f5b4a Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 09:33:59 -0400 Subject: [PATCH 05/35] feat(install): append an install record only when it differs from the latest one A re-run that changed nothing must change nothing in the ledger either, so the record step compares the whole payload against the install view before it appends. A fresh machine has no user project yet, and reading the latest record there answers none instead of failing. --- crates/baley/src/install/mod.rs | 2 + crates/baley/src/install/record.rs | 317 +++++++++++++++++++++++++++++ 2 files changed, 319 insertions(+) create mode 100644 crates/baley/src/install/record.rs diff --git a/crates/baley/src/install/mod.rs b/crates/baley/src/install/mod.rs index a3cc629a..a8c0190e 100644 --- a/crates/baley/src/install/mod.rs +++ b/crates/baley/src/install/mod.rs @@ -8,5 +8,7 @@ pub mod command; pub mod event; /// The install outcome and the owner's next steps, rendered from supplied facts. pub mod receipt; +/// Recording and reading installation ownership evidence in the ledger. +pub mod record; /// The latest installation ownership record for each host. pub mod view; diff --git a/crates/baley/src/install/record.rs b/crates/baley/src/install/record.rs new file mode 100644 index 00000000..d4dd355a --- /dev/null +++ b/crates/baley/src/install/record.rs @@ -0,0 +1,317 @@ +//! Records changed installation ownership facts in `user` and reads the latest +//! payload for a host from the `install` view. Times and request ids are supplied. + +use baley_core::catalog::USER_PROJECT; +use baley_store::{ + Actor, Admin, Command, CommandKind, Decision, Ledger, Observed, OutcomeKind, ProjectId, + Refusal, RequestId, StoreError, Views, request_digest, +}; +use serde_json::{Value, json}; + +use crate::models; + +use super::event::{self, Facts}; +use super::view::{INSTALL_VIEW, install_key}; + +/// The command kind used to record installation ownership facts. +pub const RECORD_COMMAND: &str = "install.record"; + +/// Reads the latest payload for `host`, or none when `user` or its record is absent. +pub fn read(store: &(impl Admin + Views), host: &str) -> Result, StoreError> { + let project = ProjectId(USER_PROJECT.into()); + if !store.projects()?.iter().any(|(known, _)| *known == project) { + return Ok(None); + } + let stored = store.get(&project, INSTALL_VIEW, &install_key(host))?; + Ok(stored.map(|document| document.body["payload"].clone())) +} + +/// Records supplied facts as the owner, creating `user` when absent. The request +/// id must be fresh. Returns true only when this call appended `install.recorded`. +/// An unchanged record skips the command. The comparison runs again inside the +/// transaction, so a racing match records only `command.completed`. +pub fn write( + store: &(impl Admin + Views + Ledger), + facts: &Facts, + request_id: RequestId, + at: &str, +) -> Result { + let event = event::recorded_event(facts); + // Avoid even a command completion when the installation already matches. + if read(store, facts.host.name())?.as_ref() == Some(&event.payload) { + return Ok(false); + } + let actor = Actor::Owner; + let digest = request_digest(&json!({ + "kind": RECORD_COMMAND, + "project": USER_PROJECT, + "actor": actor.as_str(), + "policy_version": 0, + "payload": event.payload, + "scope": [], + })) + .map_err(|error| StoreError::Refused(Refusal::InvalidEvent(error.to_string())))?; + models::create_user(store, at)?; + let command = Command { + project: ProjectId(USER_PROJECT.into()), + kind: CommandKind(RECORD_COMMAND.into()), + request_id, + digest, + scope: vec![], + policy_version: 0, + recorded_at: at.into(), + actor, + caller: None, + }; + let mut appended = false; + store.transact(&command, &mut |tx| { + let stored = tx.get(INSTALL_VIEW, &install_key(facts.host.name()))?; + appended = + stored.as_ref().map(|document| &document.body["payload"]) != Some(&event.payload); + if appended { + tx.append(event.clone())?; + } + Ok(Decision { + kind: OutcomeKind::Done, + answer: json!({"recorded": appended}), + sensitive: false, + observed: Observed::default(), + git: None, + }) + })?; + Ok(appended) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::host_artifacts::stubs; + use crate::install::event::{Facts, Hook, Registration, Sandbox, Stub, Updates}; + use crate::ledger::open::{self, tests::Fixed}; + use baley_core::policy::Host; + use baley_store::{Actor, Event, Ledger, PageRequest, ProjectId, RequestId, StreamName}; + use baley_store_sqlite::{Options, SqliteStore}; + use serde_json::{Value, json}; + use std::sync::Arc; + + const AT: &str = "2026-10-10T09:00:00Z"; + + fn store(dir: &tempfile::TempDir) -> SqliteStore { + open::store( + &dir.path().join("home"), + AT, + Options { + timing: Arc::new(Fixed), + ..open::options() + }, + ) + .unwrap() + } + + fn request(n: u8) -> RequestId { + RequestId(format!("00000000-0000-4000-8000-{n:012x}")) + } + + fn facts() -> Facts { + let manifest = stubs::manifest(&stubs::front_doors()).unwrap(); + Facts { + host: Host::ClaudeCode, + binary_version: env!("CARGO_PKG_VERSION").into(), + binary_path: "/home/o/.local/bin/baley".into(), + complete: true, + registration: Some(Registration { + path: "/home/o/.claude.json".into(), + entry: json!({"command": "/home/o/.local/bin/baley", "args": ["serve"]}), + }), + hook: Some(Hook { + path: "/home/o/.claude/settings.json".into(), + item: json!({ + "matcher": "Write|Edit", + "hooks": [{"type": "command", "command": "/home/o/.local/bin/baley guard", "timeout": 5}], + }), + }), + stubs: manifest + .into_iter() + .map(|entry| Stub { + path: format!("/home/o/.claude/skills/{}/SKILL.md", entry.identity), + identity: entry.identity, + sha256: entry.digest, + }) + .collect(), + sandbox: Some(Sandbox { + settings_path: "/home/o/.claude/settings.json".into(), + sha256: "c".repeat(64), + home: "/home/o/.local/share/crenshawdev/baley".into(), + config: "/home/o/.config/crenshawdev/baley".into(), + write_only_files: vec!["/home/o/.local/bin/baley".into()], + write_only_folders: vec!["/home/o/.local/lib/crenshawdev/baley/versions".into()], + permissions_deny: vec!["Edit(//home/o/.local/bin/baley)".into()], + deny_read: vec!["/home/o/.local/share/crenshawdev/baley".into()], + deny_write: vec!["/home/o/.local/bin/baley".into()], + held_back: None, + }), + updates: Updates { + auto: Some(false), + staged_version: Some("0.2.0".into()), + }, + } + } + + fn expected(capture_hash: &str, help_hash: &str) -> Value { + json!({ + "host": "claude-code", + "binary_version": env!("CARGO_PKG_VERSION"), + "binary_path": "/home/o/.local/bin/baley", + "complete": true, + "registered": { + "registration": { + "path": "/home/o/.claude.json", + "entry": {"command": "/home/o/.local/bin/baley", "args": ["serve"]}, + }, + "hook": { + "path": "/home/o/.claude/settings.json", + "item": { + "matcher": "Write|Edit", + "hooks": [{"type": "command", "command": "/home/o/.local/bin/baley guard", "timeout": 5}], + }, + }, + }, + "stubs": [ + {"identity": "bal-capture", "path": "/home/o/.claude/skills/bal-capture/SKILL.md", "sha256": capture_hash}, + {"identity": "bal-help", "path": "/home/o/.claude/skills/bal-help/SKILL.md", "sha256": help_hash}, + ], + "sandbox": { + "settings_path": "/home/o/.claude/settings.json", + "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "home": "/home/o/.local/share/crenshawdev/baley", + "config": "/home/o/.config/crenshawdev/baley", + "write_only_files": ["/home/o/.local/bin/baley"], + "write_only_folders": ["/home/o/.local/lib/crenshawdev/baley/versions"], + "permissions_deny": ["Edit(//home/o/.local/bin/baley)"], + "deny_read": ["/home/o/.local/share/crenshawdev/baley"], + "deny_write": ["/home/o/.local/bin/baley"], + "held_back": null, + }, + "defaults": {}, + "updates": {"auto": false, "staged_version": "0.2.0"}, + }) + } + + fn events(store: &impl Ledger) -> Vec { + store + .stream( + &ProjectId("user".into()), + &StreamName("install".into()), + 0, + PageRequest { + limit: 100, + after: None, + }, + ) + .unwrap() + .items + } + + #[test] + fn an_install_record_missing_an_artifact_or_off_the_install_stream_is_caught() { + let dir = tempfile::tempdir().unwrap(); + let store = store(&dir); + let facts = facts(); + let expected = expected(&facts.stubs[0].sha256, &facts.stubs[1].sha256); + + assert_eq!(write(&store, &facts, request(1), AT), Ok(true)); + + let events = events(&store); + assert_eq!(events.len(), 1); + let event = &events[0]; + assert_eq!(event.project_id, ProjectId("user".into())); + assert_eq!(event.stream, "install"); + assert_eq!(event.type_name, "install.recorded"); + assert_eq!(event.type_version, 1); + assert_eq!(event.actor, Actor::Owner); + assert_eq!(event.caller, None); + assert_eq!(event.policy_version, 0); + assert_eq!(event.recorded_at, AT); + assert_eq!(event.request_id, request(1)); + assert_eq!(event.payload, expected); + assert_eq!(read(&store, "claude-code"), Ok(Some(expected))); + } + + #[test] + fn a_repeated_install_recorded_twice_is_caught() { + let dir = tempfile::tempdir().unwrap(); + let store = store(&dir); + let mut facts = facts(); + let mut expected = expected(&facts.stubs[0].sha256, &facts.stubs[1].sha256); + assert_eq!(write(&store, &facts, request(1), AT), Ok(true)); + let head = store.head(&ProjectId("user".into())).unwrap(); + + assert_eq!( + write(&store, &facts, request(2), "2026-10-10T09:05:00Z"), + Ok(false) + ); + assert_eq!(events(&store).len(), 1); + assert_eq!(store.head(&ProjectId("user".into())).unwrap(), head); + + facts.stubs[1].sha256 = "d".repeat(64); + expected["stubs"][1]["sha256"] = json!("d".repeat(64)); + assert_eq!( + write(&store, &facts, request(3), "2026-10-10T09:10:00Z"), + Ok(true) + ); + assert_eq!(events(&store).len(), 2); + + facts.hook.as_mut().unwrap().item["hooks"][0]["timeout"] = json!(10); + expected["registered"]["hook"]["item"]["hooks"][0]["timeout"] = json!(10); + assert_eq!( + write(&store, &facts, request(4), "2026-10-10T09:15:00Z"), + Ok(true) + ); + assert_eq!(events(&store).len(), 3); + + facts.complete = false; + expected["complete"] = json!(false); + assert_eq!( + write(&store, &facts, request(5), "2026-10-10T09:20:00Z"), + Ok(true) + ); + let events = events(&store); + assert_eq!(events.len(), 4); + assert_eq!(events[3].payload, expected); + assert_eq!(read(&store, "claude-code"), Ok(Some(expected))); + } + + #[test] + fn a_partial_install_recorded_as_complete_is_caught() { + let dir = tempfile::tempdir().unwrap(); + let store = store(&dir); + let mut facts = facts(); + let mut expected = expected(&facts.stubs[0].sha256, &facts.stubs[1].sha256); + facts.complete = false; + facts.registration = None; + facts.hook = None; + facts.sandbox = None; + expected["complete"] = json!(false); + expected["registered"] = json!({"registration": null, "hook": null}); + expected["sandbox"] = Value::Null; + + assert_eq!(write(&store, &facts, request(1), AT), Ok(true)); + + let events = events(&store); + assert_eq!(events.len(), 1); + assert_eq!(events[0].payload, expected); + assert_eq!(read(&store, "claude-code"), Ok(Some(expected))); + } + + #[test] + fn a_first_install_refused_for_a_missing_user_project_is_caught() { + let dir = tempfile::tempdir().unwrap(); + let store = store(&dir); + let facts = facts(); + let expected = expected(&facts.stubs[0].sha256, &facts.stubs[1].sha256); + + assert_eq!(read(&store, "claude-code"), Ok(None)); + assert_eq!(write(&store, &facts, request(1), AT), Ok(true)); + assert_eq!(read(&store, "claude-code"), Ok(Some(expected))); + } +} From 74f42a133218857ee2b8718a30d18c951f317608 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 09:43:31 -0400 Subject: [PATCH 06/35] feat(install): refuse every write when a stub is not provably Baley's A name or a folder proves nothing about who wrote a file. A stub is Baley's only when its bytes equal this binary's render or a hash the install record holds for that identity, and one conflict plans no write at all, so the stubs Baley could place are not written beside one it cannot. --- crates/baley/src/install/mod.rs | 4 + crates/baley/src/install/plan.rs | 151 +++++++++++++++++++++++ crates/baley/src/install/stubs.rs | 198 ++++++++++++++++++++++++++++++ 3 files changed, 353 insertions(+) create mode 100644 crates/baley/src/install/plan.rs create mode 100644 crates/baley/src/install/stubs.rs diff --git a/crates/baley/src/install/mod.rs b/crates/baley/src/install/mod.rs index a8c0190e..ced213a8 100644 --- a/crates/baley/src/install/mod.rs +++ b/crates/baley/src/install/mod.rs @@ -6,9 +6,13 @@ pub mod command; /// Installation ownership facts and the `install.recorded` event. pub mod event; +/// Ordered artifact writes or a refusal from supplied file observations. +pub mod plan; /// The install outcome and the owner's next steps, rendered from supplied facts. pub mod receipt; /// Recording and reading installation ownership evidence in the ledger. pub mod record; +/// Stub ownership judged from observed bytes and the latest install record. +pub mod stubs; /// The latest installation ownership record for each host. pub mod view; diff --git a/crates/baley/src/install/plan.rs b/crates/baley/src/install/plan.rs new file mode 100644 index 00000000..d77187bd --- /dev/null +++ b/crates/baley/src/install/plan.rs @@ -0,0 +1,151 @@ +//! Installation writes decided from supplied placements, file observations +//! and the latest ownership record. Any stub conflict refuses the whole plan. +//! Registration and settings decisions join this judge in phase 15 plan 2. + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use serde_json::Value; + +use crate::host_artifacts::placement::{Artifact, PlacementMap}; +use crate::host_doctor::placed::{Fault, FileState}; +use crate::ledger::display::Render; + +use super::receipt::ArtifactOutcome; +use super::stubs::{self, Decision}; + +/// One read of a placed file and the path's own link status. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Observation { + /// The bytes or fault found by the read, following links. + pub state: FileState, + /// Whether the placed path itself is a symbolic link. + pub symbolic_link: bool, +} + +/// A file write permitted by the ownership decision. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Write { + /// The artifact whose bytes will be placed. + pub artifact: Artifact, + /// The absolute target path from the placement map. + pub path: PathBuf, + /// The manifest bytes to write. + pub bytes: Vec, + /// SHA-256 of the observed bytes, or none when the path was absent. + pub read_digest: Option, +} + +/// Ordered writes and each artifact's state before those writes are applied. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Plan { + /// Writes in placement order, excluding unchanged artifacts. + pub writes: Vec, + /// Placed artifacts in map order. Pending writes remain `NotWritten` until + /// the applier confirms them. Undecided JSON artifacts are `NotWritten` too. + pub outcomes: Vec<(Artifact, ArtifactOutcome)>, +} + +/// Plans stub writes, or refuses every write with one line per conflict. +/// Observations are keyed by path so artifacts sharing a document use one read. +/// A missing stub observation refuses rather than inventing an absent file. +#[cfg_attr( + not(test), + expect( + dead_code, + reason = "The install command consumes this in phase 15 plan 1 task 7." + ) +)] +pub(crate) fn judge( + placements: &PlacementMap, + latest: Option<&Value>, + observations: &BTreeMap, +) -> Result { + let mut plan = Plan { + writes: Vec::new(), + outcomes: Vec::new(), + }; + let mut conflicts = Vec::new(); + for file in placements.expected_files() { + let mut outcome = ArtifactOutcome::NotWritten; + if let Some(entry) = file.stub { + let missing = Observation { + state: FileState::Fault(Fault::Unreadable( + "no file observation was supplied".into(), + )), + symbolic_link: false, + }; + let seen = observations.get(file.path).unwrap_or(&missing); + match stubs::judge(entry, file.path, &seen.state, seen.symbolic_link, latest) { + Ok(Decision::Unchanged) => outcome = ArtifactOutcome::Unchanged, + Ok(decision) => { + let read_digest = match decision { + Decision::Replace { read_digest } => Some(read_digest), + _ => None, + }; + plan.writes.push(Write { + artifact: file.artifact.clone(), + path: file.path.into(), + bytes: entry.bytes.clone(), + read_digest, + }); + } + Err(conflict) => conflicts.push(conflict), + } + } + plan.outcomes.push((file.artifact, outcome)); + } + if conflicts.is_empty() { + Ok(plan) + } else { + let mut refusal = Render::refusal(""); + refusal.lines = conflicts; + Err(refusal) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::folders::Environment; + use crate::host_artifacts::{installed, stubs}; + + #[test] + fn stubs_written_around_an_ownership_conflict_is_caught() { + let manifest = stubs::manifest(&stubs::front_doors()).unwrap(); + let installed = installed::resolve( + &Environment { + home: Some("/home/o".into()), + ..Environment::default() + }, + None, + &manifest, + ) + .unwrap(); + let capture = PathBuf::from("/home/o/.claude/skills/bal-capture/SKILL.md"); + let help = PathBuf::from("/home/o/.claude/skills/bal-help/SKILL.md"); + let seen = |state| Observation { + state, + symbolic_link: false, + }; + let mut observations = BTreeMap::from([ + (capture.clone(), seen(FileState::Absent)), + (help.clone(), seen(FileState::Bytes(b"mine".to_vec()))), + ]); + let help_line = "install-ownership-conflict: stub `bal-help` at /home/o/.claude/skills/bal-help/SKILL.md holds bytes that match neither this binary's stub nor a recorded hash for this identity; move it aside and run `baley install` again"; + let capture_line = "install-ownership-conflict: stub `bal-capture` at /home/o/.claude/skills/bal-capture/SKILL.md holds bytes that match neither this binary's stub nor a recorded hash for this identity; move it aside and run `baley install` again"; + + let refusal = judge(&installed.placements, None, &observations) + .expect_err("a conflict must return no write plan, including the absent stub"); + assert_eq!(refusal.lines, [help_line]); + assert_eq!(refusal.code, 2); + assert!(refusal.error); + + observations.insert(capture, seen(FileState::Bytes(b"mine".to_vec()))); + let refusal = judge(&installed.placements, None, &observations) + .expect_err("both conflicts must return no write plan"); + assert_eq!(refusal.lines, [capture_line, help_line]); + assert_eq!(refusal.code, 2); + assert!(refusal.error); + } +} diff --git a/crates/baley/src/install/stubs.rs b/crates/baley/src/install/stubs.rs new file mode 100644 index 00000000..b92e890a --- /dev/null +++ b/crates/baley/src/install/stubs.rs @@ -0,0 +1,198 @@ +//! Stub ownership from manifest bytes, a placed path, a supplied file read +//! and the latest install record. No file or environment value is read here. + +use std::path::Path; + +use serde_json::Value; + +use crate::host_artifacts::stubs::Entry; +use crate::host_doctor::placed::FileState; +use crate::store::model::digest; + +/// What installation may do with one placed stub. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Decision { + /// The path is absent, so the manifest bytes may be written. + Write, + /// The manifest bytes are already there, so no write is needed. + Unchanged, + /// The record proves the observed bytes belong to this stub. + Replace { + /// SHA-256 of the observed bytes, required by the replacement check. + read_digest: String, + }, +} + +/// Judges one stub without reading its path. Matching bytes stay unchanged, +/// including through a symbolic link. Other bytes need a recorded hash for +/// this identity, and a link may never be replaced. Conflicts name the artifact, +/// path, observed cause and how to retry. +pub fn judge( + entry: &Entry, + path: &Path, + state: &FileState, + symbolic_link: bool, + latest: Option<&Value>, +) -> Result { + let conflict = |cause: &str| { + format!( + "install-ownership-conflict: stub `{}` at {} {cause}; move it aside and run `baley install` again", + entry.identity, + path.display() + ) + }; + let link = "is a symbolic link, which Baley does not write through"; + match state { + FileState::Absent if symbolic_link => Err(conflict(link)), + FileState::Absent => Ok(Decision::Write), + FileState::Fault(fault) => Err(conflict(&fault.to_string())), + FileState::Bytes(bytes) if *bytes == entry.bytes => Ok(Decision::Unchanged), + FileState::Bytes(_) if symbolic_link => Err(conflict(link)), + FileState::Bytes(bytes) => { + let read_digest = digest(bytes); + let owned = latest + .and_then(|record| record.get("stubs")) + .and_then(Value::as_array) + .is_some_and(|stubs| { + stubs.iter().any(|stub| { + stub.get("identity").and_then(Value::as_str) == Some(&entry.identity) + && stub.get("sha256").and_then(Value::as_str) == Some(&read_digest) + }) + }); + if owned { + Ok(Decision::Replace { read_digest }) + } else { + Err(conflict( + "holds bytes that match neither this binary's stub nor a recorded hash for this identity", + )) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::host_artifacts::stubs::{front_doors, manifest}; + use crate::host_doctor::placed::Fault; + use serde_json::json; + + const PATH: &str = "/home/o/.claude/skills/bal-help/SKILL.md"; + const OLD: &str = "cba06b5736faf67e54b07b561eae94395e774c517a7d910a54369e1263ccfbd4"; + + fn help() -> Entry { + manifest(&front_doors()) + .unwrap() + .into_iter() + .find(|entry| entry.identity == "bal-help") + .unwrap() + } + + #[test] + fn a_stub_replaced_on_its_name_alone_is_caught() { + let entry = help(); + let latest = json!({"stubs": [{ + "identity": "bal-help", "path": PATH, "sha256": OLD, + }]}); + for (state, link, record, expected) in [ + (FileState::Absent, false, None, Decision::Write), + ( + FileState::Bytes(entry.bytes.clone()), + false, + None, + Decision::Unchanged, + ), + ( + FileState::Bytes(b"old".to_vec()), + false, + Some(&latest), + Decision::Replace { + read_digest: OLD.into(), + }, + ), + ( + FileState::Bytes(entry.bytes.clone()), + true, + None, + Decision::Unchanged, + ), + ] { + assert_eq!( + judge(&entry, Path::new(PATH), &state, link, record), + Ok(expected), + "{state:?}, link {link}" + ); + } + for (state, link, record, cause) in [ + ( + FileState::Bytes(b"mine".to_vec()), + false, + Some(&latest), + "match neither", + ), + ( + FileState::Bytes(b"mine".to_vec()), + false, + None, + "match neither", + ), + ( + FileState::Fault(Fault::NotRegular), + false, + None, + "not a regular file", + ), + ( + FileState::Bytes(b"old".to_vec()), + true, + Some(&latest), + "symbolic link", + ), + ( + FileState::Fault(Fault::DanglingLink), + true, + Some(&latest), + "link to a missing file", + ), + ( + FileState::Fault(Fault::Unreadable("Permission denied".into())), + false, + Some(&latest), + "Permission denied", + ), + ] { + let refusal = judge(&entry, Path::new(PATH), &state, link, record) + .expect_err("ownership conflict"); + assert!( + refusal.starts_with(&format!( + "install-ownership-conflict: stub `bal-help` at {PATH} " + )), + "{refusal}" + ); + assert!(refusal.contains(cause), "{refusal}"); + assert!( + refusal.ends_with("move it aside and run `baley install` again"), + "{refusal}" + ); + } + } + + #[test] + fn a_recorded_hash_of_another_stub_taken_as_ownership_is_caught() { + let latest = json!({"stubs": [{ + "identity": "bal-capture", + "path": "/home/o/.claude/skills/bal-capture/SKILL.md", + "sha256": "2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881", + }]}); + let refusal = judge( + &help(), + Path::new(PATH), + &FileState::Bytes(b"x".to_vec()), + false, + Some(&latest), + ) + .expect_err("another identity's hash is not ownership"); + assert!(refusal.starts_with("install-ownership-conflict:")); + assert!(refusal.contains(PATH)); + } +} From 8c8832c5a83c51fcfde72c4f9797f7957aa67332 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 09:53:01 -0400 Subject: [PATCH 07/35] feat(install): write the stubs through replace and record the install after the writes The applier writes each planned stub with the digest of the bytes the decision read, so a file changed in between is refused, not overwritten. A stub whose replacement failed keeps the older entry in the record, because the file still holds those bytes and the next run must not take them for foreign. The receipt says which stubs were written, replaced or left alone, and a record that could not be written makes the outcome partial. --- crates/baley/src/install/apply.rs | 33 +++++ crates/baley/src/install/command.rs | 74 ++++++++-- crates/baley/src/install/mod.rs | 4 + crates/baley/src/install/plan.rs | 208 +++++++++++++++++++++++++++- crates/baley/src/install/receipt.rs | 129 +++++++++++++++-- 5 files changed, 415 insertions(+), 33 deletions(-) create mode 100644 crates/baley/src/install/apply.rs diff --git a/crates/baley/src/install/apply.rs b/crates/baley/src/install/apply.rs new file mode 100644 index 00000000..a13877ea --- /dev/null +++ b/crates/baley/src/install/apply.rs @@ -0,0 +1,33 @@ +//! Planned stub writes through digest-checked replacement. The plan owns +//! the permission to write and interprets the returned observations. + +use std::fs; + +use crate::replace::{self, Failure}; + +use super::plan::Plan; + +/// Applies writes in order with default folder modes, stopping at the first +/// failure. A failed sync is returned too, since the rename already happened. +pub(crate) fn run(plan: &Plan) -> Vec> { + let mut results = Vec::new(); + for write in &plan.writes { + let result = fs::create_dir_all( + write + .path + .parent() + .expect("an absolute stub path has a parent"), + ) + .map_err(|cause| Failure::Unchanged { + path: write.path.clone(), + cause, + }) + .and_then(|()| replace::replace(&write.path, &write.bytes, write.read_digest.as_deref())); + let stopped = result.is_err(); + results.push(result); + if stopped { + break; + } + } + results +} diff --git a/crates/baley/src/install/command.rs b/crates/baley/src/install/command.rs index 745378a0..ca9122f7 100644 --- a/crates/baley/src/install/command.rs +++ b/crates/baley/src/install/command.rs @@ -1,20 +1,24 @@ //! Host selection and gathering for `baley install`. -use std::io; +use std::collections::BTreeMap; use std::process::ExitCode; +use std::{fs, io}; -use baley_core::policy::Host; +use baley_core::catalog::USER_PROJECT; +use baley_core::policy::{FileLayer, Host, Schema, Value, merge, parse_layer}; use clap::Args; use crate::folders::{Environment, Folders, Platform}; use crate::host_artifacts::{installed, stubs}; +use crate::host_doctor::placed; use crate::ledger::clock::SystemClock; use crate::ledger::commands::new_request_id; use crate::ledger::display::{self, Render}; use crate::ledger::open; -use crate::models; +use crate::update::installation::{self, Active}; +use crate::{models, settings}; -use super::receipt::{self, ArtifactOutcome}; +use super::{apply, event, plan, receipt, record}; /// The host whose user-level wiring install places. #[derive(Debug, Args)] @@ -55,20 +59,64 @@ fn install(args: InstallArgs) -> Result { let at = SystemClock::now(); let store = open::store(&folders.home, &at, open::options()) .map_err(|error| display::store_error(&error, None))?; + let latest = record::read(&store, args.host.as_str()) + .map_err(|error| display::store_error(&error, Some(USER_PROJECT)))?; + let mut observations = BTreeMap::new(); + for file in installed.placements.expected_files() { + if file.stub.is_some() { + observations + .entry(file.path.to_path_buf()) + .or_insert_with(|| plan::Observation { + state: placed::read(file.path), + symbolic_link: fs::symlink_metadata(file.path) + .is_ok_and(|meta| meta.is_symlink()), + }); + } + } + let plan = plan::judge(&installed.placements, latest.as_ref(), &observations)?; + let applied = plan::applied(&plan, &apply::run(&plan)); let seed = models::seed(&store, new_request_id(), &SystemClock::now()); - // Phase 15 plan 1 adds stub writes in tasks 6 and 7. Plan 2 adds JSON writes. - let outcomes: Vec<_> = installed - .placements - .expected_files() - .into_iter() - .map(|file| (file.artifact, ArtifactOutcome::NotWritten)) - .collect(); - Ok(receipt::render( + + let schema = Schema::standard(); + let global = settings::read(&settings::global_path(&folders)).and_then(|file| { + file.as_ref() + .map(|file| parse_layer(file, FileLayer::Global, schema)) + .transpose() + }); + let auto = global.ok().and_then(|global| { + let policy = merge(schema, None, global.as_ref(), None); + match policy.settings["updates.auto"].value { + Some(Value::Bool(value)) => Some(value), + _ => None, + } + }); + let staged_version = installation::gather_stable(&installed.layout) + .ok() + .and_then( + |seen| match installation::judge_active(&installed.layout, &seen) { + Active::Version(version) => Some(version.to_string()), + Active::NotManaged(_) => None, + }, + ); + let facts = plan::facts( + env!("CARGO_PKG_VERSION"), + &installed.placements, + latest.as_ref(), + &applied, + &seed, + event::Updates { + auto, + staged_version, + }, + ); + let recorded = record::write(&store, &facts, new_request_id(), &SystemClock::now()); + Ok(receipt::render_applied( env!("CARGO_PKG_VERSION"), installed.layout.stable_path(), &installed.placements, - &outcomes, + &applied, &seed, + &recorded, )) } diff --git a/crates/baley/src/install/mod.rs b/crates/baley/src/install/mod.rs index ced213a8..2db5c403 100644 --- a/crates/baley/src/install/mod.rs +++ b/crates/baley/src/install/mod.rs @@ -1,7 +1,11 @@ //! `baley install`: Claude Code's wiring at the stable path, from the content //! `host_artifacts` renders, with ownership evidence in the ledger before //! any replacement. Gathering is kept apart from judging. +//! Stubs are applied and recorded here. Phase 15 plan 2 adds the registration, +//! hook and settings writes through the same plan and ownership facts. +/// Planned stub writes and their filesystem observations. +pub mod apply; /// Command arguments, host selection and installation gathering. pub mod command; /// Installation ownership facts and the `install.recorded` event. diff --git a/crates/baley/src/install/plan.rs b/crates/baley/src/install/plan.rs index d77187bd..302e43e7 100644 --- a/crates/baley/src/install/plan.rs +++ b/crates/baley/src/install/plan.rs @@ -1,16 +1,21 @@ //! Installation writes decided from supplied placements, file observations //! and the latest ownership record. Any stub conflict refuses the whole plan. +//! Applied outcomes retain older ownership evidence when a write cannot finish. //! Registration and settings decisions join this judge in phase 15 plan 2. use std::collections::BTreeMap; use std::path::PathBuf; +use baley_core::policy::Host; +use baley_store::StoreError; use serde_json::Value; use crate::host_artifacts::placement::{Artifact, PlacementMap}; use crate::host_doctor::placed::{Fault, FileState}; use crate::ledger::display::Render; +use crate::replace::Failure; +use super::event; use super::receipt::ArtifactOutcome; use super::stubs::{self, Decision}; @@ -49,13 +54,6 @@ pub struct Plan { /// Plans stub writes, or refuses every write with one line per conflict. /// Observations are keyed by path so artifacts sharing a document use one read. /// A missing stub observation refuses rather than inventing an absent file. -#[cfg_attr( - not(test), - expect( - dead_code, - reason = "The install command consumes this in phase 15 plan 1 task 7." - ) -)] pub(crate) fn judge( placements: &PlacementMap, latest: Option<&Value>, @@ -104,12 +102,208 @@ pub(crate) fn judge( } } +/// Each artifact's outcome after applying a plan, with the first write failure. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Applied { + /// Placed artifacts in map order, including writes that were not reached. + pub outcomes: Vec<(Artifact, ArtifactOutcome)>, + /// The first write failure's text, including a failed sync after a rename. + pub failure: Option, +} + +/// Interprets write observations in plan order. A failed folder sync still +/// placed the bytes, but every failure stops subsequent writes. +pub(crate) fn applied(plan: &Plan, results: &[Result<(), Failure>]) -> Applied { + let mut applied = Applied { + outcomes: plan.outcomes.clone(), + failure: None, + }; + for (index, write) in plan.writes.iter().enumerate() { + let result = results.get(index); + let written = matches!(result, Some(Ok(()) | Err(Failure::Unsynced { .. }))); + if let Some(Err(failure)) = result { + applied.failure = Some(match failure { + Failure::Refused(_) | Failure::Unsynced { .. } => failure.to_string(), + Failure::Unchanged { path, cause } => { + format!("not-writable: {}: {cause}", path.display()) + } + }); + } + let outcome = if written { + if write.read_digest.is_some() { + ArtifactOutcome::Replaced + } else { + ArtifactOutcome::Written + } + } else { + let cause = applied + .failure + .as_deref() + .unwrap_or("the write was not reached"); + ArtifactOutcome::Failed(if result.is_none() { + format!("an earlier write failed: {cause}") + } else { + cause.into() + }) + }; + if let Some((_, state)) = applied + .outcomes + .iter_mut() + .find(|(artifact, _)| *artifact == write.artifact) + { + *state = outcome; + } + } + applied +} + +/// Completion requires every artifact's current bytes and a recorded catalog seed. +pub(crate) fn complete( + placements: &PlacementMap, + outcomes: &[(Artifact, ArtifactOutcome)], + seed: &Result, +) -> bool { + placements.not_installed().is_empty() + && seed.is_ok() + && placements.expected_files().iter().all(|file| { + outcomes + .iter() + .any(|(artifact, outcome)| *artifact == file.artifact && outcome.is_owned()) + }) +} + +/// Builds ownership facts from applied outcomes without reading any path. +/// Failed or unreached writes keep that identity's previous entry, so the +/// next install can still recognize an older stub. JSON artifacts join in +/// phase 15 plan 2 at this same seam. +pub(crate) fn facts( + version: &str, + placements: &PlacementMap, + latest: Option<&Value>, + applied: &Applied, + seed: &Result, + updates: event::Updates, +) -> event::Facts { + let stubs = placements + .expected_files() + .into_iter() + .filter_map(|file| { + let entry = file.stub?; + let owned = applied + .outcomes + .iter() + .any(|(artifact, outcome)| *artifact == file.artifact && outcome.is_owned()); + if owned { + Some(event::Stub { + identity: entry.identity.clone(), + path: file.path.to_str().expect("placements are UTF-8").into(), + sha256: entry.digest.clone(), + }) + } else { + let previous = latest?.get("stubs")?.as_array()?.iter().find(|stub| { + stub.get("identity").and_then(Value::as_str) == Some(entry.identity.as_str()) + })?; + Some(event::Stub { + identity: entry.identity.clone(), + path: previous.get("path")?.as_str()?.into(), + sha256: previous.get("sha256")?.as_str()?.into(), + }) + } + }) + .collect(); + event::Facts { + host: Host::ClaudeCode, + binary_version: version.into(), + binary_path: placements.executable().as_str().into(), + complete: applied.failure.is_none() && complete(placements, &applied.outcomes, seed), + registration: None, + hook: None, + stubs, + sandbox: None, + updates, + } +} + #[cfg(test)] mod tests { use super::*; use crate::folders::Environment; use crate::host_artifacts::{installed, stubs}; + #[test] + fn an_unwritten_stub_recorded_as_baleys_is_caught() { + let manifest = stubs::manifest(&stubs::front_doors()).unwrap(); + let installed = installed::resolve( + &Environment { + home: Some("/home/o".into()), + ..Environment::default() + }, + None, + &manifest, + ) + .unwrap(); + let capture = event::Stub { + identity: "bal-capture".into(), + path: "/home/o/.claude/skills/bal-capture/SKILL.md".into(), + sha256: manifest[0].digest.clone(), + }; + let help = event::Stub { + identity: "bal-help".into(), + path: "/home/o/.claude/skills/bal-help/SKILL.md".into(), + sha256: manifest[1].digest.clone(), + }; + let old_help = event::Stub { + sha256: "cba06b5736faf67e54b07b561eae94395e774c517a7d910a54369e1263ccfbd4".into(), + ..help.clone() + }; + let latest = serde_json::json!({"stubs": [old_help]}); + let failure = "not-writable: /home/o/.claude/skills/bal-help/SKILL.md: Permission denied"; + for (latest, capture_outcome, help_outcome, expected) in [ + ( + None, + ArtifactOutcome::Written, + ArtifactOutcome::Failed(failure.into()), + vec![capture.clone()], + ), + ( + None, + ArtifactOutcome::Replaced, + ArtifactOutcome::Unchanged, + vec![capture.clone(), help], + ), + ( + Some(&latest), + ArtifactOutcome::Replaced, + ArtifactOutcome::Failed(failure.into()), + vec![capture, old_help], + ), + ] { + let applied = Applied { + outcomes: vec![ + (Artifact::Stub("bal-capture".into()), capture_outcome), + (Artifact::Stub("bal-help".into()), help_outcome), + (Artifact::Registration, ArtifactOutcome::NotWritten), + (Artifact::Hook, ArtifactOutcome::NotWritten), + (Artifact::Settings, ArtifactOutcome::NotWritten), + ], + failure: None, + }; + let facts = facts( + "0.2.0", + &installed.placements, + latest, + &applied, + &Ok(true), + event::Updates { + auto: Some(false), + staged_version: None, + }, + ); + assert_eq!(facts.stubs, expected); + assert!(!facts.complete); + } + } + #[test] fn stubs_written_around_an_ownership_conflict_is_caught() { let manifest = stubs::manifest(&stubs::front_doors()).unwrap(); diff --git a/crates/baley/src/install/receipt.rs b/crates/baley/src/install/receipt.rs index ef3cd168..01b7152c 100644 --- a/crates/baley/src/install/receipt.rs +++ b/crates/baley/src/install/receipt.rs @@ -1,4 +1,5 @@ -//! The install receipt from supplied placements, artifact outcomes and seed result. +//! The install receipt from supplied placements, artifact outcomes, catalog +//! seed and record results. Write and recording failures remain visible. use std::path::Path; @@ -9,12 +10,25 @@ use crate::host_artifacts::placement::{Artifact, PlacementMap}; use crate::ledger::display::{self, Render}; /// What the run left at an artifact's placed path. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum ArtifactOutcome { /// The artifact has not been placed or confirmed as Baley's. NotWritten, + /// The manifest bytes were placed at an absent path. + Written, + /// An older owned artifact was replaced with the manifest bytes. + Replaced, /// The artifact is already Baley's and needed no write. Unchanged, + /// The write failed or was not reached, with the cause. + Failed(String), +} + +impl ArtifactOutcome { + /// Whether this run placed or confirmed the current artifact's bytes. + pub fn is_owned(&self) -> bool { + matches!(self, Self::Written | Self::Replaced | Self::Unchanged) + } } /// Renders every placed artifact in map order. A missing outcome counts as @@ -27,19 +41,19 @@ pub(crate) fn render( outcomes: &[(Artifact, ArtifactOutcome)], seed: &Result, ) -> Render { - let mut complete = placements.not_installed().is_empty() && seed.is_ok(); + let complete = super::plan::complete(placements, outcomes, seed); let mut artifacts = Vec::new(); for file in placements.expected_files() { let outcome = outcomes .iter() .find(|(artifact, _)| *artifact == file.artifact) - .map_or(ArtifactOutcome::NotWritten, |(_, outcome)| *outcome); + .map_or(&ArtifactOutcome::NotWritten, |(_, outcome)| outcome); let text = match outcome { - ArtifactOutcome::NotWritten => { - complete = false; - "not written" - } - ArtifactOutcome::Unchanged => "unchanged", + ArtifactOutcome::NotWritten => "not written".into(), + ArtifactOutcome::Written => "written".into(), + ArtifactOutcome::Replaced => "replaced".into(), + ArtifactOutcome::Unchanged => "unchanged".into(), + ArtifactOutcome::Failed(cause) => format!("not written: {cause}"), }; artifacts.push(format!( "{} at {}: {text}", @@ -82,6 +96,38 @@ pub(crate) fn render( } } +/// Adds write and recording failures to the artifact receipt. Either failure +/// leaves a partial outcome, including a rename whose folder was not synced. +pub(crate) fn render_applied( + version: &str, + stable_path: &Path, + placements: &PlacementMap, + applied: &super::plan::Applied, + seed: &Result, + recorded: &Result, +) -> Render { + let mut receipt = render(version, stable_path, placements, &applied.outcomes, seed); + let mut failures = Vec::new(); + if let Some(failure) = &applied.failure { + failures.push(failure.clone()); + } + if let Err(error) = recorded { + failures.push(format!( + "the install record was not written: {}", + display::store_error(error, Some(USER_PROJECT)) + .lines + .join("; ") + )); + } + if !failures.is_empty() { + receipt.lines[0] = "install outcome: partial".into(); + receipt.code = 1; + let after_seed = 3 + placements.expected_files().len(); + receipt.lines.splice(after_seed..after_seed, failures); + } + receipt +} + #[cfg(test)] mod tests { use super::*; @@ -99,14 +145,71 @@ mod tests { fn outcomes(outcome: ArtifactOutcome) -> Vec<(Artifact, ArtifactOutcome)> { vec![ - (Artifact::Stub("bal-capture".into()), outcome), - (Artifact::Stub("bal-help".into()), outcome), - (Artifact::Registration, outcome), - (Artifact::Hook, outcome), + (Artifact::Stub("bal-capture".into()), outcome.clone()), + (Artifact::Stub("bal-help".into()), outcome.clone()), + (Artifact::Registration, outcome.clone()), + (Artifact::Hook, outcome.clone()), (Artifact::Settings, outcome), ] } + #[test] + fn a_replaced_or_unchanged_stub_reported_as_written_is_caught() { + let installed = installed(); + let mut applied = super::super::plan::Applied { + outcomes: vec![ + ( + Artifact::Stub("bal-capture".into()), + ArtifactOutcome::Replaced, + ), + ( + Artifact::Stub("bal-help".into()), + ArtifactOutcome::Unchanged, + ), + ], + failure: None, + }; + let receipt = render_applied( + "0.2.0", + installed.layout.stable_path(), + &installed.placements, + &applied, + &Ok(true), + &Ok(true), + ); + assert_eq!( + receipt.lines[2], + "stub `bal-capture` at /home/o/.claude/skills/bal-capture/SKILL.md: replaced" + ); + assert_eq!( + receipt.lines[3], + "stub `bal-help` at /home/o/.claude/skills/bal-help/SKILL.md: unchanged" + ); + + applied.outcomes[1].1 = ArtifactOutcome::Failed("Permission denied".into()); + applied.failure = Some( + "not-writable: /home/o/.claude/skills/bal-help/SKILL.md: Permission denied".into(), + ); + let receipt = render_applied( + "0.2.0", + installed.layout.stable_path(), + &installed.placements, + &applied, + &Ok(true), + &Err(StoreError::Busy), + ); + assert_eq!( + receipt.lines[3], + "stub `bal-help` at /home/o/.claude/skills/bal-help/SKILL.md: not written: Permission denied" + ); + assert!(receipt.lines.contains(applied.failure.as_ref().unwrap())); + assert!(receipt.lines.iter().any(|line| line + == "the install record was not written: the ledger is busy; run the command again")); + assert_eq!(receipt.lines[0], "install outcome: partial"); + assert_eq!(receipt.code, 1); + assert!(!receipt.error); + } + #[test] fn an_install_with_an_unwritten_artifact_reported_complete_is_caught() { let installed = installed(); From 176af59975906b1f021a414043860dc88d793a0a Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 11:14:57 -0400 Subject: [PATCH 08/35] fix(install): refuse to record install facts planned from an older record A run keeps the ownership hashes of the record it read, so if another run recorded in between, appending its facts would roll those hashes back and a retry would refuse files Baley did write. The record step now takes the sequence of the record the run read and, inside the transaction, returns the store's stale-input error when the stored record has moved and the payload differs. A racing identical record still only completes the command, and a sequential run is unchanged. I kept the check in the step that appends, using the sequence the view already stores, instead of a lock around the whole install, which would need a new seam. --- crates/baley/src/install/command.rs | 15 +++- crates/baley/src/install/record.rs | 118 +++++++++++++++++++++++++--- 2 files changed, 116 insertions(+), 17 deletions(-) diff --git a/crates/baley/src/install/command.rs b/crates/baley/src/install/command.rs index ca9122f7..f4f99528 100644 --- a/crates/baley/src/install/command.rs +++ b/crates/baley/src/install/command.rs @@ -59,8 +59,9 @@ fn install(args: InstallArgs) -> Result { let at = SystemClock::now(); let store = open::store(&folders.home, &at, open::options()) .map_err(|error| display::store_error(&error, None))?; - let latest = record::read(&store, args.host.as_str()) + let latest = record::latest(&store, args.host.as_str()) .map_err(|error| display::store_error(&error, Some(USER_PROJECT)))?; + let latest_payload = latest.as_ref().map(|latest| &latest.payload); let mut observations = BTreeMap::new(); for file in installed.placements.expected_files() { if file.stub.is_some() { @@ -73,7 +74,7 @@ fn install(args: InstallArgs) -> Result { }); } } - let plan = plan::judge(&installed.placements, latest.as_ref(), &observations)?; + let plan = plan::judge(&installed.placements, latest_payload, &observations)?; let applied = plan::applied(&plan, &apply::run(&plan)); let seed = models::seed(&store, new_request_id(), &SystemClock::now()); @@ -101,7 +102,7 @@ fn install(args: InstallArgs) -> Result { let facts = plan::facts( env!("CARGO_PKG_VERSION"), &installed.placements, - latest.as_ref(), + latest_payload, &applied, &seed, event::Updates { @@ -109,7 +110,13 @@ fn install(args: InstallArgs) -> Result { staged_version, }, ); - let recorded = record::write(&store, &facts, new_request_id(), &SystemClock::now()); + let recorded = record::write( + &store, + &facts, + latest.as_ref().map(|latest| latest.seq), + new_request_id(), + &SystemClock::now(), + ); Ok(receipt::render_applied( env!("CARGO_PKG_VERSION"), installed.layout.stable_path(), diff --git a/crates/baley/src/install/record.rs b/crates/baley/src/install/record.rs index d4dd355a..f4661f87 100644 --- a/crates/baley/src/install/record.rs +++ b/crates/baley/src/install/record.rs @@ -4,7 +4,7 @@ use baley_core::catalog::USER_PROJECT; use baley_store::{ Actor, Admin, Command, CommandKind, Decision, Ledger, Observed, OutcomeKind, ProjectId, - Refusal, RequestId, StoreError, Views, request_digest, + Refusal, RequestId, StaleInput, StoreError, Views, request_digest, }; use serde_json::{Value, json}; @@ -16,23 +16,45 @@ use super::view::{INSTALL_VIEW, install_key}; /// The command kind used to record installation ownership facts. pub const RECORD_COMMAND: &str = "install.record"; -/// Reads the latest payload for `host`, or none when `user` or its record is absent. -pub fn read(store: &(impl Admin + Views), host: &str) -> Result, StoreError> { +/// The latest record of a host: the sequence of the event that wrote it and its +/// payload. A later run names the sequence to show which record it planned from. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Latest { + /// The sequence of the `install.recorded` event behind the payload. + pub seq: u64, + /// The recorded payload. + pub payload: Value, +} + +/// Reads the latest record for `host`, or none when `user` or its record is absent. +pub fn latest(store: &(impl Admin + Views), host: &str) -> Result, StoreError> { let project = ProjectId(USER_PROJECT.into()); if !store.projects()?.iter().any(|(known, _)| *known == project) { return Ok(None); } let stored = store.get(&project, INSTALL_VIEW, &install_key(host))?; - Ok(stored.map(|document| document.body["payload"].clone())) + Ok(stored.map(|document| Latest { + seq: document.produced_seq, + payload: document.body["payload"].clone(), + })) +} + +/// Reads the latest payload for `host`, or none when `user` or its record is absent. +pub fn read(store: &(impl Admin + Views), host: &str) -> Result, StoreError> { + Ok(latest(store, host)?.map(|latest| latest.payload)) } /// Records supplied facts as the owner, creating `user` when absent. The request /// id must be fresh. Returns true only when this call appended `install.recorded`. /// An unchanged record skips the command. The comparison runs again inside the /// transaction, so a racing match records only `command.completed`. +/// `seen` is the sequence of the record the facts were planned from, or none when +/// the run saw no record. The facts keep ownership hashes from that record, so a +/// newer record in the store means they may be stale, and nothing is recorded. pub fn write( store: &(impl Admin + Views + Ledger), facts: &Facts, + seen: Option, request_id: RequestId, at: &str, ) -> Result { @@ -65,9 +87,21 @@ pub fn write( }; let mut appended = false; store.transact(&command, &mut |tx| { - let stored = tx.get(INSTALL_VIEW, &install_key(facts.host.name()))?; + let key = install_key(facts.host.name()); + let stored = tx.get(INSTALL_VIEW, &key)?; appended = stored.as_ref().map(|document| &document.body["payload"]) != Some(&event.payload); + // The facts carry hashes from the record this run read. If another run + // recorded since, appending them would roll its hashes back. + let now = stored.as_ref().map(|document| document.produced_seq); + if appended && now != seen { + return Err(StoreError::Stale(StaleInput::Document { + view: INSTALL_VIEW.into(), + key, + seen, + now, + })); + } if appended { tx.append(event.clone())?; } @@ -197,6 +231,17 @@ mod tests { }) } + /// Plans from the stored record the way a sequential run does, then records. + fn write_from_latest( + store: &SqliteStore, + facts: &Facts, + request_id: RequestId, + at: &str, + ) -> Result { + let seen = latest(store, facts.host.name())?.map(|latest| latest.seq); + write(store, facts, seen, request_id, at) + } + fn events(store: &impl Ledger) -> Vec { store .stream( @@ -219,7 +264,7 @@ mod tests { let facts = facts(); let expected = expected(&facts.stubs[0].sha256, &facts.stubs[1].sha256); - assert_eq!(write(&store, &facts, request(1), AT), Ok(true)); + assert_eq!(write_from_latest(&store, &facts, request(1), AT), Ok(true)); let events = events(&store); assert_eq!(events.len(), 1); @@ -243,11 +288,11 @@ mod tests { let store = store(&dir); let mut facts = facts(); let mut expected = expected(&facts.stubs[0].sha256, &facts.stubs[1].sha256); - assert_eq!(write(&store, &facts, request(1), AT), Ok(true)); + assert_eq!(write_from_latest(&store, &facts, request(1), AT), Ok(true)); let head = store.head(&ProjectId("user".into())).unwrap(); assert_eq!( - write(&store, &facts, request(2), "2026-10-10T09:05:00Z"), + write_from_latest(&store, &facts, request(2), "2026-10-10T09:05:00Z"), Ok(false) ); assert_eq!(events(&store).len(), 1); @@ -256,7 +301,7 @@ mod tests { facts.stubs[1].sha256 = "d".repeat(64); expected["stubs"][1]["sha256"] = json!("d".repeat(64)); assert_eq!( - write(&store, &facts, request(3), "2026-10-10T09:10:00Z"), + write_from_latest(&store, &facts, request(3), "2026-10-10T09:10:00Z"), Ok(true) ); assert_eq!(events(&store).len(), 2); @@ -264,7 +309,7 @@ mod tests { facts.hook.as_mut().unwrap().item["hooks"][0]["timeout"] = json!(10); expected["registered"]["hook"]["item"]["hooks"][0]["timeout"] = json!(10); assert_eq!( - write(&store, &facts, request(4), "2026-10-10T09:15:00Z"), + write_from_latest(&store, &facts, request(4), "2026-10-10T09:15:00Z"), Ok(true) ); assert_eq!(events(&store).len(), 3); @@ -272,7 +317,7 @@ mod tests { facts.complete = false; expected["complete"] = json!(false); assert_eq!( - write(&store, &facts, request(5), "2026-10-10T09:20:00Z"), + write_from_latest(&store, &facts, request(5), "2026-10-10T09:20:00Z"), Ok(true) ); let events = events(&store); @@ -281,6 +326,53 @@ mod tests { assert_eq!(read(&store, "claude-code"), Ok(Some(expected))); } + #[test] + fn a_stale_install_run_overwriting_a_newer_record_is_caught() { + let dir = tempfile::tempdir().unwrap(); + let store = store(&dir); + let mut facts = facts(); + assert_eq!(write_from_latest(&store, &facts, request(1), AT), Ok(true)); + let planned_from = latest(&store, "claude-code").unwrap().unwrap(); + + // A newer run records different stub hashes after both read the record above. + let mut newer = facts.clone(); + newer.stubs[0].sha256 = "e".repeat(64); + assert_eq!( + write( + &store, + &newer, + Some(planned_from.seq), + request(2), + "2026-10-10T09:05:00Z" + ), + Ok(true) + ); + let newest = latest(&store, "claude-code").unwrap().unwrap(); + let head = store.head(&ProjectId("user".into())).unwrap(); + + facts.stubs[1].sha256 = "d".repeat(64); + let stale = write( + &store, + &facts, + Some(planned_from.seq), + request(3), + "2026-10-10T09:10:00Z", + ); + + assert_eq!( + stale, + Err(StoreError::Stale(StaleInput::Document { + view: "install".into(), + key: install_key("claude-code"), + seen: Some(planned_from.seq), + now: Some(newest.seq), + })) + ); + assert_eq!(latest(&store, "claude-code"), Ok(Some(newest))); + assert_eq!(events(&store).len(), 2); + assert_eq!(store.head(&ProjectId("user".into())).unwrap(), head); + } + #[test] fn a_partial_install_recorded_as_complete_is_caught() { let dir = tempfile::tempdir().unwrap(); @@ -295,7 +387,7 @@ mod tests { expected["registered"] = json!({"registration": null, "hook": null}); expected["sandbox"] = Value::Null; - assert_eq!(write(&store, &facts, request(1), AT), Ok(true)); + assert_eq!(write_from_latest(&store, &facts, request(1), AT), Ok(true)); let events = events(&store); assert_eq!(events.len(), 1); @@ -311,7 +403,7 @@ mod tests { let expected = expected(&facts.stubs[0].sha256, &facts.stubs[1].sha256); assert_eq!(read(&store, "claude-code"), Ok(None)); - assert_eq!(write(&store, &facts, request(1), AT), Ok(true)); + assert_eq!(write_from_latest(&store, &facts, request(1), AT), Ok(true)); assert_eq!(read(&store, "claude-code"), Ok(Some(expected))); } } From 7a3fcab77f9da32c7c30544675d205c1b4ab18d0 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 11:24:26 -0400 Subject: [PATCH 09/35] fix(install): hold one install lock so two runs cannot overlap Two installs planned from the same record could finish in either order, and the one that wrote the stubs could be refused while the record kept the old hashes. The install now takes an exclusive lock in Baley's own home before it reads anything and holds it through the record, so a second install is refused with a try-again line. The stale-record check stays as a second guard. --- crates/baley/src/install/command.rs | 114 +++++++++++++++++++++++++++- crates/baley/src/install/mod.rs | 2 + crates/baley/src/install/record.rs | 5 +- 3 files changed, 116 insertions(+), 5 deletions(-) diff --git a/crates/baley/src/install/command.rs b/crates/baley/src/install/command.rs index f4f99528..9c296e75 100644 --- a/crates/baley/src/install/command.rs +++ b/crates/baley/src/install/command.rs @@ -1,14 +1,17 @@ //! Host selection and gathering for `baley install`. use std::collections::BTreeMap; +use std::fs::{self, File, OpenOptions, TryLockError}; +use std::io; +use std::os::unix::fs::OpenOptionsExt; +use std::path::Path; use std::process::ExitCode; -use std::{fs, io}; use baley_core::catalog::USER_PROJECT; use baley_core::policy::{FileLayer, Host, Schema, Value, merge, parse_layer}; use clap::Args; -use crate::folders::{Environment, Folders, Platform}; +use crate::folders::{self, Environment, Folders, Platform}; use crate::host_artifacts::{installed, stubs}; use crate::host_doctor::placed; use crate::ledger::clock::SystemClock; @@ -47,6 +50,68 @@ fn select_host(name: &str) -> Result { }) } +/// The file in Baley's home that the one running install holds. +const LOCK_FILE: &str = "install.lock"; + +/// What trying to take the install lock found, when it was not taken. +#[derive(Debug)] +enum Observed { + /// Another install holds the lock. + Held, + /// The lock file could not be created, opened or locked. + Unusable(String), +} + +/// The install lock, held for as long as this value lives. Closing the file +/// releases it, so an install that crashes never leaves the lock behind. +#[derive(Debug)] +struct InstallLock { + _file: File, +} + +/// Judges one attempt to lock the file: a held lock is another install +/// running, anything else is a lock that cannot be used. +fn observe(attempt: Result<(), TryLockError>) -> Result<(), Observed> { + match attempt { + Ok(()) => Ok(()), + Err(TryLockError::WouldBlock) => Err(Observed::Held), + Err(TryLockError::Error(error)) => Err(Observed::Unusable(error.to_string())), + } +} + +/// The refusal for a lock that was not taken. +fn refusal(observed: &Observed, path: &Path) -> Render { + match observed { + Observed::Held => Render::refusal( + "install-running: another baley install is running; try again when it has finished", + ), + Observed::Unusable(cause) => Render::refusal(format!( + "install-lock-unavailable: cannot take the install lock at {}: {cause}", + path.display() + )), + } +} + +/// Takes the install lock without waiting. Two installs overlapping would +/// each plan from the same record and could leave the record naming hashes +/// the files no longer hold, so the second one is refused before it reads +/// anything. The lock sits in Baley's own home, never in Claude's folder. +fn acquire(home: &Path) -> Result { + let path = home.join(LOCK_FILE); + let unusable = |cause: io::Error| refusal(&Observed::Unusable(cause.to_string()), &path); + folders::create_private(home).map_err(unusable)?; + let file = OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW) + .open(&path) + .map_err(unusable)?; + observe(file.try_lock()).map_err(|observed| refusal(&observed, &path))?; + Ok(InstallLock { _file: file }) +} + fn install(args: InstallArgs) -> Result { let Host::ClaudeCode = select_host(&args.host)?; let env = Environment::read(); @@ -56,6 +121,8 @@ fn install(args: InstallArgs) -> Result { let manifest = stubs::manifest(&stubs::front_doors()).expect("unique compiled front doors"); let installed = installed::resolve(&env, claude_config_dir, &manifest) .map_err(|error| Render::refusal(error.to_string()))?; + // Held to the end of the run, record included. + let _lock = acquire(&folders.home)?; let at = SystemClock::now(); let store = open::store(&folders.home, &at, open::options()) .map_err(|error| display::store_error(&error, None))?; @@ -129,9 +196,13 @@ fn install(args: InstallArgs) -> Result { #[cfg(test)] mod tests { + use std::fs::TryLockError; + use std::io; + use std::path::Path; + use baley_core::policy::Host; - use super::select_host; + use super::{Observed, acquire, observe, refusal, select_host}; #[test] fn an_install_for_another_host_not_refused_unknown_host_is_caught() { @@ -148,4 +219,41 @@ mod tests { } assert_eq!(select_host("claude-code").unwrap(), Host::ClaudeCode); } + + #[test] + fn a_second_install_started_while_one_holds_the_lock_not_refused_is_caught() { + let home = tempfile::tempdir().unwrap(); + let first = acquire(home.path()).expect("the first install takes the lock"); + + let refused = acquire(home.path()).expect_err("a second install while the first runs"); + assert_eq!( + refused.lines, + ["install-running: another baley install is running; try again when it has finished"] + ); + assert_eq!(refused.code, 2); + assert!(refused.error); + + drop(first); + acquire(home.path()).expect("the lock is free once the first install ends"); + } + + #[test] + fn a_lock_that_cannot_be_taken_reported_as_a_running_install_is_caught() { + assert!(observe(Ok(())).is_ok()); + assert!(matches!( + observe(Err(TryLockError::WouldBlock)), + Err(Observed::Held) + )); + let failed = io::Error::from(io::ErrorKind::PermissionDenied); + let observed = observe(Err(TryLockError::Error(failed))).expect_err("an io failure"); + + let refused = refusal(&observed, Path::new("/h/install.lock")); + assert_eq!(refused.lines.len(), 1); + let line = &refused.lines[0]; + assert!(line.contains("/h/install.lock"), "{line}"); + assert!(line.contains("permission denied"), "{line}"); + assert!(!line.contains("another baley install is running"), "{line}"); + assert_eq!(refused.code, 2); + assert!(refused.error); + } } diff --git a/crates/baley/src/install/mod.rs b/crates/baley/src/install/mod.rs index 2db5c403..2c4a3b79 100644 --- a/crates/baley/src/install/mod.rs +++ b/crates/baley/src/install/mod.rs @@ -1,6 +1,8 @@ //! `baley install`: Claude Code's wiring at the stable path, from the content //! `host_artifacts` renders, with ownership evidence in the ledger before //! any replacement. Gathering is kept apart from judging. +//! One install runs at a time: the command holds an exclusive lock in Baley's +//! own home from reading the latest record to recording the result. //! Stubs are applied and recorded here. Phase 15 plan 2 adds the registration, //! hook and settings writes through the same plan and ownership facts. diff --git a/crates/baley/src/install/record.rs b/crates/baley/src/install/record.rs index f4661f87..2cfa6954 100644 --- a/crates/baley/src/install/record.rs +++ b/crates/baley/src/install/record.rs @@ -91,8 +91,9 @@ pub fn write( let stored = tx.get(INSTALL_VIEW, &key)?; appended = stored.as_ref().map(|document| &document.body["payload"]) != Some(&event.payload); - // The facts carry hashes from the record this run read. If another run - // recorded since, appending them would roll its hashes back. + // The install lock keeps two runs from overlapping, and this stays as a + // second guard. The facts carry hashes from the record this run read, so + // if another run recorded since, appending them would roll its hashes back. let now = stored.as_ref().map(|document| document.produced_seq); if appended && now != seen { return Err(StoreError::Stale(StaleInput::Document { From 223a7e51693b0791f433c4ef4c322627537dcef3 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 11:32:23 -0400 Subject: [PATCH 10/35] feat(install): refuse a settings file that cannot be composed into An owner's settings file that is not a JSON object, or cannot be read, is refused with install-settings-conflict. Reading it as an empty document would let install write over what the owner had. --- crates/baley/src/install/mod.rs | 2 + crates/baley/src/install/settings.rs | 125 +++++++++++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 crates/baley/src/install/settings.rs diff --git a/crates/baley/src/install/mod.rs b/crates/baley/src/install/mod.rs index 2c4a3b79..b255ed91 100644 --- a/crates/baley/src/install/mod.rs +++ b/crates/baley/src/install/mod.rs @@ -18,6 +18,8 @@ pub mod plan; pub mod receipt; /// Recording and reading installation ownership evidence in the ledger. pub mod record; +/// Claude Code's user settings file read, composed into and judged. +pub mod settings; /// Stub ownership judged from observed bytes and the latest install record. pub mod stubs; /// The latest installation ownership record for each host. diff --git a/crates/baley/src/install/settings.rs b/crates/baley/src/install/settings.rs new file mode 100644 index 00000000..149394e1 --- /dev/null +++ b/crates/baley/src/install/settings.rs @@ -0,0 +1,125 @@ +//! Claude Code's user settings file for `baley install`: what a read of it +//! found, which older Baley entries leave it on the install record's +//! evidence, and what Baley's entries compose into it. Nothing here reads or +//! writes a file. + +use std::path::Path; + +use serde_json::{Map, Value}; + +use crate::host_doctor::placed::{self, FileState}; +use crate::store::model::digest; + +use super::plan::Observation; + +/// The settings file as a read found it, ready to compose into. +#[derive(Debug, Clone, PartialEq)] +pub struct Current { + /// The JSON object the file holds, or an empty one when it is absent. + pub document: Value, + /// Whether the settings path is itself a symbolic link. A link whose + /// target reads as an object is accepted here and refused only when a + /// write is needed. + pub symbolic_link: bool, + /// SHA-256 of the bytes read, or none when the file is absent. + pub read_digest: Option, +} + +/// The refusal for a settings file that cannot be composed into. +fn conflict(path: &Path, cause: &str) -> String { + format!( + "install-settings-conflict: {} {cause}; fix it by hand, then run baley install again", + path.display() + ) +} + +/// Judges what the read found. An absent file is an empty document, and a +/// file that is not a JSON object, or is not a readable file, is refused: +/// it is never read as an empty one, since composing into it would write +/// over the owner's file. +pub fn current(path: &Path, seen: &Observation) -> Result { + match &seen.state { + FileState::Absent => Ok(Current { + document: Value::Object(Map::new()), + symbolic_link: seen.symbolic_link, + read_digest: None, + }), + FileState::Fault(fault) => Err(conflict(path, &fault.to_string())), + FileState::Bytes(bytes) => { + let document = + placed::document(bytes).map_err(|fault| conflict(path, &fault.to_string()))?; + Ok(Current { + document, + symbolic_link: seen.symbolic_link, + read_digest: Some(digest(bytes)), + }) + } + } +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use super::*; + use crate::host_doctor::placed::{Fault, FileState}; + use crate::install::plan::Observation; + + const PATH: &str = "/home/o/.claude/settings.json"; + + fn seen(state: FileState) -> Observation { + Observation { + state, + symbolic_link: false, + } + } + + #[test] + fn an_unparseable_settings_file_composed_into_is_caught() { + let bytes = br#"{"model": "opus","#.to_vec(); + let refusal = current(Path::new(PATH), &seen(FileState::Bytes(bytes))) + .expect_err("bytes that are not JSON give no document to compose into"); + assert!( + refusal.starts_with("install-settings-conflict: "), + "{refusal}" + ); + assert!(refusal.contains(PATH), "{refusal}"); + assert!(refusal.contains("is not JSON"), "{refusal}"); + assert_eq!(refusal.lines().count(), 1); + } + + #[test] + fn a_settings_file_that_is_not_an_object_or_not_a_file_composed_into_is_caught() { + for (state, cause) in [ + ( + FileState::Bytes(b"[1]".to_vec()), + "is JSON but not an object", + ), + ( + FileState::Bytes(br#""x""#.to_vec()), + "is JSON but not an object", + ), + (FileState::Fault(Fault::NotRegular), "is not a regular file"), + ( + FileState::Fault(Fault::DanglingLink), + "is a link to a missing file", + ), + ( + FileState::Fault(Fault::Unreadable("Permission denied".into())), + "Permission denied", + ), + ] { + let refusal = current(Path::new(PATH), &seen(state.clone())) + .expect_err("a file that is not a settings document is refused"); + assert!( + refusal.starts_with("install-settings-conflict: "), + "{refusal}" + ); + assert!(refusal.contains(PATH), "{refusal}"); + assert!(refusal.contains(cause), "{refusal}"); + } + let empty = current(Path::new(PATH), &seen(FileState::Absent)).expect("absent is empty"); + assert_eq!(empty.document, serde_json::json!({})); + assert_eq!(empty.read_digest, None); + } +} From ed9b4dc058aaaafcf19435925ffe3afa8e8cfcd3 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 11:37:02 -0400 Subject: [PATCH 11/35] feat(install): remove an older baley hook or deny entry only on the record's evidence The record says what Baley wrote last time, so only those whole values leave the settings document. Matching by pattern would delete an owner's own entries that happen to look like Baley's. --- crates/baley/src/install/mod.rs | 27 ++++ crates/baley/src/install/settings.rs | 192 +++++++++++++++++++++++++++ 2 files changed, 219 insertions(+) diff --git a/crates/baley/src/install/mod.rs b/crates/baley/src/install/mod.rs index b255ed91..daf904c8 100644 --- a/crates/baley/src/install/mod.rs +++ b/crates/baley/src/install/mod.rs @@ -24,3 +24,30 @@ pub mod settings; pub mod stubs; /// The latest installation ownership record for each host. pub mod view; + +/// Shared inputs for the install tests: the placements derived from +/// `HOME` `/home/o`, Baley's folders and the sandbox programs found or not. +#[cfg(test)] +pub(crate) mod fixtures { + use crate::folders::{Environment, Folders}; + use crate::host_artifacts::installed::{self, Installed}; + use crate::host_artifacts::stubs; + + /// The placements for `HOME` `/home/o` with `CLAUDE_CONFIG_DIR` unset. + pub fn installed() -> Installed { + let manifest = stubs::manifest(&stubs::front_doors()).unwrap(); + let env = Environment { + home: Some("/home/o".into()), + ..Environment::default() + }; + installed::resolve(&env, None, &manifest).unwrap() + } + + /// Baley's data and configuration folders under `/home/o`. + pub fn folders() -> Folders { + Folders { + home: "/home/o/.local/share/crenshawdev/baley".into(), + config: "/home/o/.config/crenshawdev/baley".into(), + } + } +} diff --git a/crates/baley/src/install/settings.rs b/crates/baley/src/install/settings.rs index 149394e1..44e04a29 100644 --- a/crates/baley/src/install/settings.rs +++ b/crates/baley/src/install/settings.rs @@ -7,6 +7,10 @@ use std::path::Path; use serde_json::{Map, Value}; +use crate::folders::Folders; +use crate::host_artifacts::hook; +use crate::host_artifacts::placement::PlacementMap; +use crate::host_artifacts::security::{Proposal, propose}; use crate::host_doctor::placed::{self, FileState}; use crate::store::model::digest; @@ -57,6 +61,110 @@ pub fn current(path: &Path, seen: &Observation) -> Result { } } +/// This binary's entries in the settings document: the hook item and the +/// three lists the sandbox and permission proposal renders, with the paths +/// the proposal could not render. +#[derive(Debug, Clone, PartialEq)] +pub struct Ours { + /// The `PreToolUse` item that runs the guard at the stable path. + pub hook_item: Value, + /// The proposal composition merges in. + pub proposal: Proposal, + /// The `permissions.deny` rules. + pub permissions_deny: Vec, + /// The `sandbox.filesystem.denyRead` entries. + pub deny_read: Vec, + /// The `sandbox.filesystem.denyWrite` entries. + pub deny_write: Vec, +} + +fn strings(value: Option<&Value>) -> Vec { + value + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect() + }) + .unwrap_or_default() +} + +/// Renders this binary's entries from the placement map and Baley's +/// folders: the stable path is the executable, the map's protected paths are +/// write-only files and its write-only folders are the versions folder. +pub fn ours(placements: &PlacementMap, folders: &Folders) -> Ours { + let executable = placements.executable(); + let proposal = propose( + folders, + executable, + &placements.protected_paths(), + &placements.write_only_folders(), + ); + let hook_item = hook::render(executable)["hooks"][hook::EVENT][0].clone(); + let settings = &proposal.settings; + Ours { + permissions_deny: strings(settings.pointer("/permissions/deny")), + deny_read: strings(settings.pointer("/sandbox/filesystem/denyRead")), + deny_write: strings(settings.pointer("/sandbox/filesystem/denyWrite")), + hook_item, + proposal, + } +} + +/// Removes an older Baley entry on the install record's evidence only: the +/// hook item the record holds when it differs from this binary's, and each +/// string the record lists under `permissions_deny`, `deny_read` or +/// `deny_write` that this binary's lists do not hold. Whole values are +/// compared, never a pattern. No key goes and no other entry, so an owner's +/// own items, rules and switches stay. With `keep_sandbox` the sandbox block +/// is left exactly as it was. +pub fn remove_recorded( + document: &mut Value, + latest: Option<&Value>, + ours: &Ours, + keep_sandbox: bool, +) { + let Some(latest) = latest else { return }; + if let Some(item) = latest.pointer("/registered/hook/item") + && *item != ours.hook_item + && let Some(items) = document + .pointer_mut(&format!("/hooks/{}", hook::EVENT)) + .and_then(Value::as_array_mut) + { + items.retain(|existing| existing != item); + } + let mut lists = vec![( + "/permissions/deny", + "/sandbox/permissions_deny", + &ours.permissions_deny, + )]; + if !keep_sandbox { + lists.push(( + "/sandbox/filesystem/denyRead", + "/sandbox/deny_read", + &ours.deny_read, + )); + lists.push(( + "/sandbox/filesystem/denyWrite", + "/sandbox/deny_write", + &ours.deny_write, + )); + } + for (pointer, recorded, ours) in lists { + let recorded = strings(latest.pointer(recorded)); + if let Some(list) = document.pointer_mut(pointer).and_then(Value::as_array_mut) { + list.retain(|entry| { + entry.as_str().is_none_or(|entry| { + !recorded.iter().any(|old| old == entry) + || ours.iter().any(|ours| ours == entry) + }) + }); + } + } +} + #[cfg(test)] mod tests { use std::path::Path; @@ -123,3 +231,87 @@ mod tests { assert_eq!(empty.read_digest, None); } } + +#[cfg(test)] +mod removal_tests { + use serde_json::json; + + use super::*; + use crate::install::fixtures::{folders, installed}; + + const OLD_HOOK_COMMAND: &str = "'/home/o/.local/bin/baley' guard"; + + fn old_hook() -> Value { + json!({ + "matcher": "Bash|Write|Edit", + "hooks": [{"type": "command", "command": OLD_HOOK_COMMAND, "timeout": 5}], + }) + } + + fn owner_hook() -> Value { + json!({"matcher": "Bash", "hooks": [{"type": "command", "command": "echo hi"}]}) + } + + #[test] + fn an_older_baley_hook_or_deny_entry_left_beside_the_new_one_is_caught() { + let record = json!({ + "registered": {"hook": {"path": "/home/o/.claude/settings.json", "item": old_hook()}}, + "sandbox": { + "permissions_deny": ["Edit(//home/o/old/**)", "Edit(//home/o/.local/bin/baley)"], + "deny_read": ["/home/o/old"], + "deny_write": ["/home/o/old"], + }, + }); + let mut document = json!({ + "hooks": {"PreToolUse": [old_hook(), owner_hook()]}, + "permissions": {"deny": [ + "Edit(//home/o/old/**)", + "Read(./secrets/**)", + "Edit(//home/o/.local/bin/baley)", + ]}, + "sandbox": {"filesystem": { + "denyRead": ["/home/o/old"], + "denyWrite": ["/home/o/old"], + }}, + }); + let installed = installed(); + let ours = ours(&installed.placements, &folders()); + + remove_recorded(&mut document, Some(&record), &ours, false); + + assert_eq!(document["hooks"]["PreToolUse"], json!([owner_hook()])); + assert_eq!( + document["permissions"]["deny"], + json!(["Read(./secrets/**)", "Edit(//home/o/.local/bin/baley)"]) + ); + assert_eq!(document["sandbox"]["filesystem"]["denyRead"], json!([])); + assert_eq!(document["sandbox"]["filesystem"]["denyWrite"], json!([])); + } + + #[test] + fn an_entry_removed_because_it_looks_like_baleys_is_caught() { + let document = json!({ + "hooks": {"PreToolUse": [{ + "matcher": "Bash", + "hooks": [{"type": "command", "command": OLD_HOOK_COMMAND}], + }]}, + "permissions": {"deny": ["Edit(//home/o/.config/crenshawdev/baley-old/**)"]}, + }); + let record = json!({ + "registered": {"hook": {"path": "/home/o/.claude/settings.json", "item": old_hook()}}, + "sandbox": { + "permissions_deny": ["Edit(//home/o/old/**)"], + "deny_read": [], + "deny_write": ["/home/o/old"], + }, + }); + let installed = installed(); + let ours = ours(&installed.placements, &folders()); + + for latest in [None, Some(&record)] { + let mut seen = document.clone(); + remove_recorded(&mut seen, latest, &ours, false); + assert_eq!(seen, document); + } + } +} From 53689e26a988234370e51ee2437e95869556a256 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 11:49:31 -0400 Subject: [PATCH 12/35] feat(install): compose baley's hook and sandbox into the settings file and name every gap One composition over the owner's document adds the hook, the deny rules and the sandbox block. A document that cannot be merged, or holds a guard hook or registration Baley did not write, is refused. A setting that weakens protection stays in the file and becomes a gap with its fix, because removing it would be the owner's call. --- crates/baley/src/install/mod.rs | 71 ++++ crates/baley/src/install/settings.rs | 499 ++++++++++++++++++++++++++- 2 files changed, 558 insertions(+), 12 deletions(-) diff --git a/crates/baley/src/install/mod.rs b/crates/baley/src/install/mod.rs index daf904c8..6650dcf7 100644 --- a/crates/baley/src/install/mod.rs +++ b/crates/baley/src/install/mod.rs @@ -29,6 +29,8 @@ pub mod view; /// `HOME` `/home/o`, Baley's folders and the sandbox programs found or not. #[cfg(test)] pub(crate) mod fixtures { + use serde_json::{Value, json}; + use crate::folders::{Environment, Folders}; use crate::host_artifacts::installed::{self, Installed}; use crate::host_artifacts::stubs; @@ -50,4 +52,73 @@ pub(crate) mod fixtures { config: "/home/o/.config/crenshawdev/baley".into(), } } + /// The matcher design 0012 gives the guard hook. + pub const MATCHER: &str = "Bash|Monitor|PowerShell|Read|Grep|Glob|Write|Edit|NotebookEdit"; + + /// Baley's `PreToolUse` item for the stable path, written out by hand. + pub fn hook_item() -> Value { + json!({ + "matcher": MATCHER, + "hooks": [{ + "type": "command", + "command": "'/home/o/.local/bin/baley' guard", + "timeout": 10, + }], + }) + } + + /// A settings document of a completed install, written by hand from + /// design 0012: the owner's `model`, every Baley entry, and the deny + /// rules in an order the renderer does not use. + pub fn complete_settings() -> Value { + json!({ + "model": "opus", + "permissions": {"deny": [ + "Edit(//home/o/.local/bin/baley)", + "Edit(//home/o/.claude/settings.json)", + "Edit(//home/o/.claude.json)", + "Edit(//home/o/.claude/skills/bal-help/SKILL.md)", + "Edit(//home/o/.claude/skills/bal-capture/SKILL.md)", + "Edit(//home/o/.local/lib/crenshawdev/baley/versions/**)", + "Edit(//home/o/.config/crenshawdev/baley/**)", + "Read(//home/o/.config/crenshawdev/baley/**)", + "Edit(//home/o/.local/share/crenshawdev/baley/**)", + "Read(//home/o/.local/share/crenshawdev/baley/**)", + ]}, + "sandbox": { + "enabled": true, + "failIfUnavailable": true, + "allowUnsandboxedCommands": false, + "filesystem": { + "denyRead": [ + "/home/o/.config/crenshawdev/baley", + "/home/o/.local/share/crenshawdev/baley", + ], + "denyWrite": [ + "/home/o/.local/bin/baley", + "/home/o/.claude/settings.json", + "/home/o/.claude.json", + "/home/o/.claude/skills/bal-help/SKILL.md", + "/home/o/.claude/skills/bal-capture/SKILL.md", + "/home/o/.local/lib/crenshawdev/baley/versions", + "/home/o/.config/crenshawdev/baley", + "/home/o/.local/share/crenshawdev/baley", + ], + }, + }, + "hooks": {"PreToolUse": [hook_item()]}, + }) + } + + /// The document pretty-printed with four-space indentation, the way an + /// owner's editor might have left it. + pub fn four_spaces(document: &Value) -> Vec { + use serde::Serialize; + let mut bytes = Vec::new(); + let format = serde_json::ser::PrettyFormatter::with_indent(b" "); + let mut serializer = serde_json::Serializer::with_formatter(&mut bytes, format); + document.serialize(&mut serializer).unwrap(); + bytes.push(b'\n'); + bytes + } } diff --git a/crates/baley/src/install/settings.rs b/crates/baley/src/install/settings.rs index 44e04a29..aef7203a 100644 --- a/crates/baley/src/install/settings.rs +++ b/crates/baley/src/install/settings.rs @@ -8,10 +8,12 @@ use std::path::Path; use serde_json::{Map, Value}; use crate::folders::Folders; +use crate::host_artifacts::compose::{Conflict, compose}; use crate::host_artifacts::hook; use crate::host_artifacts::placement::PlacementMap; -use crate::host_artifacts::security::{Proposal, propose}; +use crate::host_artifacts::security::{Proposal, Unrendered, propose}; use crate::host_doctor::placed::{self, FileState}; +use crate::replace; use crate::store::model::digest; use super::plan::Observation; @@ -165,13 +167,191 @@ pub fn remove_recorded( } } +/// An owner value that Baley's own secure value replaced. +#[derive(Debug, Clone, PartialEq)] +pub struct Replaced { + /// The setting's dotted key. + pub key: String, + /// The value the owner's file held. + pub was: Value, +} + +/// What the settings decision reads. +#[derive(Debug, Clone, Copy)] +pub struct Input<'a> { + /// The settings file's path, for the words of a refusal or a gap. + pub path: &'a Path, + /// The file as read. + pub current: &'a Current, + /// The latest install record, when there is one. + pub latest: Option<&'a Value>, + /// Where every artifact goes, and the stable executable. + pub placements: &'a PlacementMap, + /// Baley's data and configuration folders. + pub folders: &'a Folders, +} + +/// What install does with the settings file. +#[derive(Debug, Clone, PartialEq)] +pub struct Decision { + /// The bytes to write, or none when the composed document equals the one + /// read, whatever its formatting. + pub bytes: Option>, + /// SHA-256 of the bytes read, or none when the file was absent. + pub read_digest: Option, + /// The document as read. + pub read: Value, + /// The document after removal and composition. + pub document: Value, + /// Owner values Baley's secure values replaced, to report. + pub replaced: Vec, + /// Each setting that leaves protection off, with its fix. + pub gaps: Vec, + /// This binary's entries, for the install record. + pub ours: Ours, +} + +/// The refusal text for a replacement that was refused, under install's +/// own code instead of the replacement's. +pub fn replace_refusal(conflict: &replace::Conflict) -> String { + let text = conflict.to_string(); + let text = text + .strip_prefix(&format!("{}: ", conflict.code())) + .unwrap_or(&text); + format!("install-settings-conflict: {text}") +} + +fn secure(setting: &str) -> bool { + setting != "sandbox.allowUnsandboxedCommands" +} + +fn gap(file: &Path, conflict: &Conflict) -> Option { + let file = file.display(); + Some(match conflict { + Conflict::Disabled("disableAllHooks") => format!( + "disableAllHooks is true in {file}, so no hook runs the guard; remove it or set it to false" + ), + Conflict::Disabled("sandbox.filesystem.disabled") => format!( + "sandbox.filesystem.disabled is true in {file}, so the sandbox's filesystem rules do not apply; remove it or set it to false" + ), + Conflict::Disabled(setting) => format!( + "{setting} in {file} is not its secure value; set it to {}", + secure(setting) + ), + Conflict::Reopened { list, entry } => format!( + "`{entry}` is listed in sandbox.filesystem.{list} in {file}, which re-opens a Baley folder; remove it" + ), + Conflict::Unjudged { list, entry } => format!( + "`{entry}` is listed in sandbox.filesystem.{list} in {file} and is not an absolute path, so whether it re-opens a Baley folder cannot be judged; write it as an absolute path outside Baley's folders or remove it" + ), + Conflict::Excluded(entry) => format!( + "`{entry}` is listed in sandbox.excludedCommands in {file}, so it runs outside the sandbox; remove it" + ), + _ => return None, + }) +} + +fn unrendered_gap(file: &Path, unrendered: &Unrendered) -> String { + let fix = match unrendered { + Unrendered::MissingPrerequisite { .. } => "use an absolute UTF-8 path", + Unrendered::UnsupportedCharacter { .. } => "use a path without that character", + }; + format!( + "{unrendered}, so the deny rules in {} leave it unprotected; {fix}", + file.display() + ) +} + +fn refusal(file: &Path, conflict: &Conflict) -> Option { + let file = file.display(); + Some(match conflict { + Conflict::NotComposable(key) => format!( + "install-settings-conflict: {file} holds `{key}` as a kind of value Baley cannot add its entries to; fix it by hand, then run baley install again" + ), + Conflict::OtherGuardHook(item) => format!( + "install-ownership-conflict: the guard hook at {file} holds a PreToolUse item that runs another guard command than the one Baley wrote: {item}; remove it, then run baley install again" + ), + Conflict::OtherRegistration(entry) => format!( + "install-ownership-conflict: mcpServers.baley at {file} holds an entry that is not the one Baley wrote: {entry}; remove it, then run baley install again" + ), + _ => return None, + }) +} + +/// Decides what happens to the settings file: removes older Baley entries +/// the record shows, composes this binary's hook and proposal in once, and +/// sorts what composition found. A document that cannot be merged, or holds +/// a guard hook or registration the record does not show Baley wrote, is +/// refused with every line. A setting that leaves protection off is kept and +/// becomes a gap, and Baley's own secure value replacing an owner's is +/// written and reported. A settings path that is a link is refused only when +/// a write is needed. +pub fn judge(input: &Input<'_>) -> Result> { + let ours = ours(input.placements, input.folders); + let mut document = input.current.document.clone(); + remove_recorded(&mut document, input.latest, &ours, false); + let executable = input.placements.executable(); + let hook = hook::render(executable); + let composed = compose( + Some(&document), + &[ours.proposal.settings.clone(), hook], + input.folders, + executable, + ); + let (mut refusals, mut replaced, mut gaps) = (Vec::new(), Vec::new(), Vec::new()); + for conflict in &composed.conflicts { + if let Some(line) = refusal(input.path, conflict) { + refusals.push(line); + } else if let Some(line) = gap(input.path, conflict) { + gaps.push(line); + } else if let Conflict::Replaced { key, was } = conflict { + replaced.push(Replaced { + key: key.clone(), + was: was.clone(), + }); + } + } + gaps.extend( + ours.proposal + .unrendered + .iter() + .map(|unrendered| unrendered_gap(input.path, unrendered)), + ); + if !refusals.is_empty() { + return Err(refusals); + } + let bytes = (composed.document != input.current.document).then(|| { + let mut bytes = + serde_json::to_vec_pretty(&composed.document).expect("a JSON document serializes"); + bytes.push(b'\n'); + bytes + }); + if bytes.is_some() && input.current.symbolic_link { + let link = replace::Conflict::Link { + path: input.path.to_path_buf(), + }; + return Err(vec![replace_refusal(&link)]); + } + Ok(Decision { + bytes, + read_digest: input.current.read_digest.clone(), + read: input.current.document.clone(), + document: composed.document, + replaced, + gaps, + ours, + }) +} + #[cfg(test)] mod tests { - use std::path::Path; + use std::path::PathBuf; + + use serde_json::json; use super::*; - use crate::host_doctor::placed::{Fault, FileState}; - use crate::install::plan::Observation; + use crate::host_doctor::placed::Fault; + use crate::install::fixtures::{complete_settings, folders, four_spaces, hook_item, installed}; const PATH: &str = "/home/o/.claude/settings.json"; @@ -230,14 +410,6 @@ mod tests { assert_eq!(empty.document, serde_json::json!({})); assert_eq!(empty.read_digest, None); } -} - -#[cfg(test)] -mod removal_tests { - use serde_json::json; - - use super::*; - use crate::install::fixtures::{folders, installed}; const OLD_HOOK_COMMAND: &str = "'/home/o/.local/bin/baley' guard"; @@ -314,4 +486,307 @@ mod removal_tests { assert_eq!(seen, document); } } + + fn read(document: Value) -> Current { + Current { + document, + symbolic_link: false, + read_digest: Some("a".repeat(64)), + } + } + + fn decide_in( + current: &Current, + latest: Option<&Value>, + folders: &Folders, + ) -> Result> { + let installed = installed(); + judge(&Input { + path: Path::new(PATH), + current, + latest, + placements: &installed.placements, + folders, + }) + } + + fn decide(current: &Current, latest: Option<&Value>) -> Result> { + decide_in(current, latest, &folders()) + } + + fn gap_with<'d>(decision: &'d Decision, parts: &[&str]) -> &'d str { + decision + .gaps + .iter() + .find(|gap| parts.iter().all(|part| gap.contains(part))) + .unwrap_or_else(|| panic!("no gap holds {parts:?} in {:?}", decision.gaps)) + } + + #[test] + fn an_owner_setting_lost_or_a_baley_entry_missing_from_the_composed_settings_is_caught() { + let owner = json!({ + "model": "opus", + "env": {"FOO": "1"}, + "permissions": {"allow": ["Bash(ls:*)"]}, + }); + + let decision = decide(&read(owner), None).expect("an unrelated document composes"); + + let document = &decision.document; + let keys: Vec<&str> = document + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + assert_eq!(keys[..3], ["model", "env", "permissions"]); + assert_eq!(document["model"], "opus"); + assert_eq!(document["env"], json!({"FOO": "1"})); + assert_eq!(document["permissions"]["allow"], json!(["Bash(ls:*)"])); + assert_eq!( + document["hooks"]["PreToolUse"], + json!([{ + "matcher": "Bash|Monitor|PowerShell|Read|Grep|Glob|Write|Edit|NotebookEdit", + "hooks": [{ + "type": "command", + "command": "'/home/o/.local/bin/baley' guard", + "timeout": 10, + }], + }]) + ); + let sandbox = &document["sandbox"]; + assert_eq!(sandbox["enabled"], true); + assert_eq!(sandbox["failIfUnavailable"], true); + assert_eq!(sandbox["allowUnsandboxedCommands"], false); + assert_eq!( + sandbox["filesystem"]["denyRead"], + json!([ + "/home/o/.local/share/crenshawdev/baley", + "/home/o/.config/crenshawdev/baley" + ]) + ); + assert_eq!( + sandbox["filesystem"]["denyWrite"], + json!([ + "/home/o/.local/share/crenshawdev/baley", + "/home/o/.config/crenshawdev/baley", + "/home/o/.local/lib/crenshawdev/baley/versions", + "/home/o/.claude/skills/bal-capture/SKILL.md", + "/home/o/.claude/skills/bal-help/SKILL.md", + "/home/o/.claude.json", + "/home/o/.claude/settings.json", + "/home/o/.local/bin/baley", + ]) + ); + assert_eq!( + document["permissions"]["deny"], + json!([ + "Read(//home/o/.local/share/crenshawdev/baley/**)", + "Edit(//home/o/.local/share/crenshawdev/baley/**)", + "Read(//home/o/.config/crenshawdev/baley/**)", + "Edit(//home/o/.config/crenshawdev/baley/**)", + "Edit(//home/o/.local/lib/crenshawdev/baley/versions/**)", + "Edit(//home/o/.claude/skills/bal-capture/SKILL.md)", + "Edit(//home/o/.claude/skills/bal-help/SKILL.md)", + "Edit(//home/o/.claude.json)", + "Edit(//home/o/.claude/settings.json)", + "Edit(//home/o/.local/bin/baley)", + ]) + ); + assert!(decision.gaps.is_empty(), "{:?}", decision.gaps); + assert!(decision.replaced.is_empty()); + let bytes = decision.bytes.as_ref().expect("a write is needed"); + assert!(bytes.ends_with(b"\n")); + assert_eq!(serde_json::from_slice::(bytes).unwrap(), *document); + } + + #[test] + fn an_owner_setting_that_weakens_protection_written_as_complete_is_caught() { + let owner = json!({ + "disableAllHooks": true, + "sandbox": { + "excludedCommands": ["docker"], + "filesystem": {"allowWrite": [ + "/home/o/.config/crenshawdev/baley/x", + "~/scratch", + ]}, + }, + }); + + let decision = decide(&read(owner), None).expect("written around, never refused"); + + let document = &decision.document; + assert_eq!(document["disableAllHooks"], true); + assert_eq!(document["sandbox"]["excludedCommands"], json!(["docker"])); + assert_eq!( + document["sandbox"]["filesystem"]["allowWrite"], + json!(["/home/o/.config/crenshawdev/baley/x", "~/scratch"]) + ); + assert_eq!(decision.gaps.len(), 4, "{:?}", decision.gaps); + let hooks = gap_with(&decision, &["disableAllHooks", PATH]); + assert!(hooks.contains("remove it or set it to false"), "{hooks}"); + let docker = gap_with(&decision, &["docker", "sandbox.excludedCommands", PATH]); + assert!(docker.contains("remove it"), "{docker}"); + let reopened = gap_with( + &decision, + &[ + "/home/o/.config/crenshawdev/baley/x", + "sandbox.filesystem.allowWrite", + PATH, + ], + ); + assert!(reopened.contains("remove it"), "{reopened}"); + let relative = gap_with( + &decision, + &["~/scratch", "sandbox.filesystem.allowWrite", PATH], + ); + assert!( + relative.contains("write it as an absolute path outside Baley's folders or remove it"), + "{relative}" + ); + } + + #[test] + fn a_replaced_sandbox_switch_reported_as_a_gap_or_left_unreported_is_caught() { + let owner = json!({"sandbox": {"enabled": false, "failIfUnavailable": false}}); + + let decision = decide(&read(owner), None).expect("Baley's secure values are written"); + + assert_eq!(decision.document["sandbox"]["enabled"], true); + assert_eq!(decision.document["sandbox"]["failIfUnavailable"], true); + let replaced: Vec<(&str, &Value)> = decision + .replaced + .iter() + .map(|replaced| (replaced.key.as_str(), &replaced.was)) + .collect(); + assert_eq!( + replaced, + [ + ("sandbox.enabled", &json!(false)), + ("sandbox.failIfUnavailable", &json!(false)), + ] + ); + assert!(decision.gaps.is_empty(), "{:?}", decision.gaps); + } + + #[test] + fn a_settings_document_that_cannot_be_merged_written_anyway_is_caught() { + for (owner, code, names) in [ + (json!({"hooks": []}), "install-settings-conflict", "hooks"), + ( + json!({"permissions": "all"}), + "install-settings-conflict", + "permissions", + ), + ( + json!({"mcpServers": {"baley": {"command": "/opt/x/baley", "args": ["serve"]}}}), + "install-ownership-conflict", + "mcpServers.baley", + ), + ( + json!({"permissions": {"deny": "all"}}), + "install-settings-conflict", + "permissions.deny", + ), + ] { + let refusal = decide(&read(owner.clone()), None) + .expect_err("a document Baley's entries cannot be added to is refused"); + assert_eq!(refusal.len(), 1, "{refusal:?}"); + assert!(refusal[0].starts_with(&format!("{code}: ")), "{refusal:?}"); + assert!(refusal[0].contains(PATH), "{refusal:?}"); + assert!(refusal[0].contains(names), "{refusal:?}"); + } + } + + #[test] + fn an_unchanged_settings_file_rewritten_is_caught() { + let complete = complete_settings(); + let seen = |document: &Value| Observation { + state: FileState::Bytes(four_spaces(document)), + symbolic_link: false, + }; + let decide_seen = |document: &Value| { + let current = current(Path::new(PATH), &seen(document)).unwrap(); + decide(¤t, None).expect("composes") + }; + + let unchanged = decide_seen(&complete); + assert_eq!(unchanged.bytes, None); + + let mut lost_rule = complete.clone(); + lost_rule["permissions"]["deny"] + .as_array_mut() + .unwrap() + .retain(|rule| rule != "Edit(//home/o/.claude.json)"); + let restored = decide_seen(&lost_rule); + assert!(restored.bytes.is_some()); + assert!( + restored.document["permissions"]["deny"] + .as_array() + .unwrap() + .contains(&json!("Edit(//home/o/.claude.json)")) + ); + + let mut lost_hook = complete; + lost_hook["hooks"]["PreToolUse"] = json!([]); + let restored = decide_seen(&lost_hook); + assert!(restored.bytes.is_some()); + assert_eq!( + restored.document["hooks"]["PreToolUse"], + json!([hook_item()]) + ); + } + + #[test] + fn a_settings_link_written_through_is_caught() { + let linked = |document: Value| Current { + symbolic_link: true, + ..read(document) + }; + + let refusal = decide(&linked(json!({"model": "opus"})), None) + .expect_err("a write through a link is refused"); + assert_eq!(refusal.len(), 1, "{refusal:?}"); + assert!( + refusal[0].starts_with("install-settings-conflict: "), + "{refusal:?}" + ); + assert!(refusal[0].contains(PATH), "{refusal:?}"); + assert!(refusal[0].contains("symbolic link"), "{refusal:?}"); + + let unchanged = decide(&linked(complete_settings()), None) + .expect("a link whose target holds everything needs no write"); + assert_eq!(unchanged.bytes, None); + } + + #[test] + fn a_folder_baley_cannot_write_as_a_rule_left_out_of_the_gaps_is_caught() { + let owner = read(json!({})); + let broken_home = Folders { + home: PathBuf::from("/home/o/b[1]/data"), + ..folders() + }; + + let decision = decide_in(&owner, None, &broken_home).expect("composes"); + + assert_eq!(decision.gaps.len(), 1, "{:?}", decision.gaps); + gap_with(&decision, &["/home/o/b[1]/data", "["]); + let written = decision.document.to_string(); + assert!(!written.contains("b[1]"), "{written}"); + assert!(written.contains("/home/o/.config/crenshawdev/baley")); + + let broken_both = Folders { + config: PathBuf::from("/home/o/c?/config"), + ..broken_home + }; + let decision = decide_in(&owner, None, &broken_both).expect("composes"); + assert_eq!(decision.gaps.len(), 2, "{:?}", decision.gaps); + gap_with(&decision, &["/home/o/b[1]/data", "["]); + gap_with(&decision, &["/home/o/c?/config", "?"]); + let written = decision.document.to_string(); + assert!( + !written.contains("b[1]") && !written.contains("c?"), + "{written}" + ); + } } From 8e95306f50d697e03fd25d947adc6934a6ee2ebb Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 11:55:14 -0400 Subject: [PATCH 13/35] feat(install): hold the sandbox block back when its linux programs are missing failIfUnavailable true would stop sandboxed Bash in every session until bwrap and socat exist, so the hook and deny rules go in and the sandbox waits. The doctor and install now word the missing-program fix from one function. --- crates/baley/src/host_doctor/prerequisites.rs | 11 + crates/baley/src/host_doctor/report.rs | 7 +- crates/baley/src/install/mod.rs | 25 +++ crates/baley/src/install/settings.rs | 211 +++++++++++++++++- 4 files changed, 242 insertions(+), 12 deletions(-) diff --git a/crates/baley/src/host_doctor/prerequisites.rs b/crates/baley/src/host_doctor/prerequisites.rs index 6359e2ae..a345c881 100644 --- a/crates/baley/src/host_doctor/prerequisites.rs +++ b/crates/baley/src/host_doctor/prerequisites.rs @@ -110,6 +110,17 @@ pub struct Program { pub found: Option, } +/// The sentence for a required program that no `PATH` entry holds: the +/// program, its package, the two install commands and that the lookup used +/// this command's own `PATH`. The doctor and `baley install` both use it, so +/// neither words the fix differently. +pub fn missing_sentence(program: &Program) -> String { + format!( + "{} 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 + ) +} + /// What the platform is to Claude Code's sandbox. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Kind { diff --git a/crates/baley/src/host_doctor/report.rs b/crates/baley/src/host_doctor/report.rs index 191a5a24..4a300023 100644 --- a/crates/baley/src/host_doctor/report.rs +++ b/crates/baley/src/host_doctor/report.rs @@ -3,7 +3,7 @@ use std::path::Path; use super::guard_records::Judged as GuardRecords; -use super::prerequisites::{Judged as Prerequisites, Kind}; +use super::prerequisites::{Judged as Prerequisites, Kind, missing_sentence}; use super::protection::{Judged, NotJudged}; use super::store_health::{Compatibility, Judged as Store}; use super::{ArtifactState, Findings, MapFault, server_context}; @@ -248,10 +248,7 @@ fn prerequisite_lines(judged: &Prerequisites, lines: &mut Vec) -> bool { 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 - ), + None => format!("sandbox: {}", missing_sentence(program)), }); } } diff --git a/crates/baley/src/install/mod.rs b/crates/baley/src/install/mod.rs index 6650dcf7..9168bbe1 100644 --- a/crates/baley/src/install/mod.rs +++ b/crates/baley/src/install/mod.rs @@ -29,11 +29,14 @@ pub mod view; /// `HOME` `/home/o`, Baley's folders and the sandbox programs found or not. #[cfg(test)] pub(crate) mod fixtures { + use std::path::PathBuf; + use serde_json::{Value, json}; use crate::folders::{Environment, Folders}; use crate::host_artifacts::installed::{self, Installed}; use crate::host_artifacts::stubs; + use crate::host_doctor::prerequisites::{self, Candidate, Judged, Observed, Search}; /// The placements for `HOME` `/home/o` with `CLAUDE_CONFIG_DIR` unset. pub fn installed() -> Installed { @@ -45,6 +48,28 @@ pub(crate) mod fixtures { installed::resolve(&env, None, &manifest).unwrap() } + /// Linux with `bwrap` and `socat` both found. + pub fn linux() -> Judged { + prerequisites("linux", &["bwrap", "socat"]) + } + + /// The prerequisites judged from an observation for `os` in which each + /// named program is a regular file with mode `0o755` in `/usr/bin`. + pub fn prerequisites(os: &str, found: &[&str]) -> Judged { + let search = |program: &str| Search { + program: program.into(), + candidates: vec![Candidate { + folder: PathBuf::from("/usr/bin"), + regular_file: found.contains(&program), + mode: if found.contains(&program) { 0o755 } else { 0 }, + }], + }; + prerequisites::judge(&Observed { + os: os.into(), + searches: vec![search("bwrap"), search("socat")], + }) + } + /// Baley's data and configuration folders under `/home/o`. pub fn folders() -> Folders { Folders { diff --git a/crates/baley/src/install/settings.rs b/crates/baley/src/install/settings.rs index aef7203a..bf42ed60 100644 --- a/crates/baley/src/install/settings.rs +++ b/crates/baley/src/install/settings.rs @@ -13,6 +13,7 @@ use crate::host_artifacts::hook; use crate::host_artifacts::placement::PlacementMap; use crate::host_artifacts::security::{Proposal, Unrendered, propose}; use crate::host_doctor::placed::{self, FileState}; +use crate::host_doctor::prerequisites::{Judged, missing_sentence}; use crate::replace; use crate::store::model::digest; @@ -189,6 +190,24 @@ pub struct Input<'a> { pub placements: &'a PlacementMap, /// Baley's data and configuration folders. pub folders: &'a Folders, + /// The sandbox programs the platform needs, judged by the doctor's check. + pub prerequisites: &'a Judged, +} + +/// Baley's entries in the composed document, for the install record. +#[derive(Debug, Clone, PartialEq)] +pub struct Entries { + /// Baley's `PreToolUse` item, when the composed document holds it. + pub hook: Option, + /// The `permissions.deny` rules Baley owns. + pub permissions_deny: Vec, + /// The `sandbox.filesystem.denyRead` entries Baley owns. While the + /// sandbox is held back, the latest record's entries the kept block + /// still holds. + pub deny_read: Vec, + /// The `sandbox.filesystem.denyWrite` entries Baley owns, as for + /// `deny_read`. + pub deny_write: Vec, } /// What install does with the settings file. @@ -207,8 +226,10 @@ pub struct Decision { pub replaced: Vec, /// Each setting that leaves protection off, with its fix. pub gaps: Vec, - /// This binary's entries, for the install record. - pub ours: Ours, + /// Baley's entries in the composed document, for the install record. + pub entries: Entries, + /// Why the sandbox block was left out, or none when it is composed in. + pub held_back: Option, } /// The refusal text for a replacement that was refused, under install's @@ -278,6 +299,45 @@ fn refusal(file: &Path, conflict: &Conflict) -> Option { }) } +/// Why the sandbox block is left out, with a gap for each cause, when the +/// platform cannot run the sandbox or a program it needs is missing. +fn held_back(prerequisites: &Judged, file: &Path) -> Option<(String, Vec)> { + if prerequisites.unsupported().is_empty() { + return None; + } + let file = file.display(); + let missing = prerequisites.missing(); + if missing.is_empty() { + let why = format!( + "{} is a platform where Claude Code's sandbox does not run", + prerequisites.os + ); + let gap = format!("{why}, so the sandbox block was left out of {file}"); + return Some((why, vec![gap])); + } + let names: Vec<&str> = missing.iter().map(|program| program.name).collect(); + let gaps = missing + .iter() + .map(|program| { + format!( + "{}; the sandbox block was left out of {file}, so run baley install again once it is installed", + missing_sentence(program) + ) + }) + .collect(); + Some((format!("{} missing from PATH", names.join(" and ")), gaps)) +} + +/// The entries the latest record lists under `key` that the document's list +/// at `pointer` still holds. +fn still_held(document: &Value, latest: Option<&Value>, pointer: &str, key: &str) -> Vec { + let held = strings(document.pointer(pointer)); + strings(latest.and_then(|latest| latest.pointer(&format!("/sandbox/{key}")))) + .into_iter() + .filter(|entry| held.contains(entry)) + .collect() +} + /// Decides what happens to the settings file: removes older Baley entries /// the record shows, composes this binary's hook and proposal in once, and /// sorts what composition found. A document that cannot be merged, or holds @@ -288,13 +348,20 @@ fn refusal(file: &Path, conflict: &Conflict) -> Option { /// a write is needed. pub fn judge(input: &Input<'_>) -> Result> { let ours = ours(input.placements, input.folders); + let held = held_back(input.prerequisites, input.path); let mut document = input.current.document.clone(); - remove_recorded(&mut document, input.latest, &ours, false); + remove_recorded(&mut document, input.latest, &ours, held.is_some()); let executable = input.placements.executable(); let hook = hook::render(executable); + let mut proposal = ours.proposal.settings.clone(); + if held.is_some() + && let Some(settings) = proposal.as_object_mut() + { + settings.shift_remove("sandbox"); + } let composed = compose( Some(&document), - &[ours.proposal.settings.clone(), hook], + &[proposal, hook], input.folders, executable, ); @@ -317,6 +384,9 @@ pub fn judge(input: &Input<'_>) -> Result> { .iter() .map(|unrendered| unrendered_gap(input.path, unrendered)), ); + if let Some((_, lines)) = &held { + gaps.extend(lines.iter().cloned()); + } if !refusals.is_empty() { return Err(refusals); } @@ -332,6 +402,28 @@ pub fn judge(input: &Input<'_>) -> Result> { }; return Err(vec![replace_refusal(&link)]); } + let document = &composed.document; + let hook_held = document + .pointer(&format!("/hooks/{}", hook::EVENT)) + .and_then(Value::as_array) + .is_some_and(|items| items.contains(&ours.hook_item)); + let sandbox = |pointer: &str, key: &str, ours: &[String]| { + if held.is_some() { + still_held(document, input.latest, pointer, key) + } else { + ours.to_vec() + } + }; + let entries = Entries { + hook: hook_held.then(|| ours.hook_item.clone()), + permissions_deny: ours.permissions_deny.clone(), + deny_read: sandbox("/sandbox/filesystem/denyRead", "deny_read", &ours.deny_read), + deny_write: sandbox( + "/sandbox/filesystem/denyWrite", + "deny_write", + &ours.deny_write, + ), + }; Ok(Decision { bytes, read_digest: input.current.read_digest.clone(), @@ -339,7 +431,8 @@ pub fn judge(input: &Input<'_>) -> Result> { document: composed.document, replaced, gaps, - ours, + entries, + held_back: held.map(|(why, _)| why), }) } @@ -351,7 +444,10 @@ mod tests { use super::*; use crate::host_doctor::placed::Fault; - use crate::install::fixtures::{complete_settings, folders, four_spaces, hook_item, installed}; + use crate::host_doctor::prerequisites::Judged; + use crate::install::fixtures::{ + complete_settings, folders, four_spaces, hook_item, installed, linux, prerequisites, + }; const PATH: &str = "/home/o/.claude/settings.json"; @@ -495,10 +591,11 @@ mod tests { } } - fn decide_in( + fn decide_with( current: &Current, latest: Option<&Value>, folders: &Folders, + prerequisites: &Judged, ) -> Result> { let installed = installed(); judge(&Input { @@ -507,9 +604,18 @@ mod tests { latest, placements: &installed.placements, folders, + prerequisites, }) } + fn decide_in( + current: &Current, + latest: Option<&Value>, + folders: &Folders, + ) -> Result> { + decide_with(current, latest, folders, &linux()) + } + fn decide(current: &Current, latest: Option<&Value>) -> Result> { decide_in(current, latest, &folders()) } @@ -789,4 +895,95 @@ mod tests { "{written}" ); } + fn deny_rules() -> Value { + json!([ + "Read(//home/o/.local/share/crenshawdev/baley/**)", + "Edit(//home/o/.local/share/crenshawdev/baley/**)", + "Read(//home/o/.config/crenshawdev/baley/**)", + "Edit(//home/o/.config/crenshawdev/baley/**)", + "Edit(//home/o/.local/lib/crenshawdev/baley/versions/**)", + "Edit(//home/o/.claude/skills/bal-capture/SKILL.md)", + "Edit(//home/o/.claude/skills/bal-help/SKILL.md)", + "Edit(//home/o/.claude.json)", + "Edit(//home/o/.claude/settings.json)", + "Edit(//home/o/.local/bin/baley)", + ]) + } + + #[test] + fn a_sandbox_written_without_its_linux_programs_is_caught() { + let owner = read(json!({"model": "opus"})); + let no_socat = prerequisites("linux", &["bwrap"]); + + let decision = + decide_with(&owner, None, &folders(), &no_socat).expect("the rest still composes"); + + let document = &decision.document; + assert_eq!(document["model"], "opus"); + assert_eq!(document["hooks"]["PreToolUse"], json!([hook_item()])); + assert_eq!(document["permissions"]["deny"], deny_rules()); + assert!(document.get("sandbox").is_none(), "{document}"); + assert_eq!(decision.gaps.len(), 1, "{:?}", decision.gaps); + let gap = &decision.gaps[0]; + assert!(gap.contains("socat"), "{gap}"); + assert!( + gap.contains("install socat with `apt-get install bubblewrap socat`"), + "{gap}" + ); + } + + #[test] + fn a_held_back_sandbox_that_rewrites_the_owners_sandbox_is_caught() { + let sandbox = json!({ + "enabled": true, + "network": {"allowedDomains": ["example.com"]}, + "filesystem": {"denyWrite": ["/home/o/old"]}, + }); + let record = json!({"sandbox": { + "permissions_deny": [], + "deny_read": [], + "deny_write": ["/home/o/old"], + }}); + let no_socat = prerequisites("linux", &["bwrap"]); + + let decision = decide_with( + &read(json!({"sandbox": sandbox.clone()})), + Some(&record), + &folders(), + &no_socat, + ) + .expect("composes"); + + assert_eq!(decision.document["sandbox"], sandbox); + assert_eq!( + decision.document["hooks"]["PreToolUse"], + json!([hook_item()]) + ); + assert_eq!(decision.document["permissions"]["deny"], deny_rules()); + assert_eq!(decision.gaps.len(), 1, "{:?}", decision.gaps); + assert!(decision.gaps[0].contains("socat")); + assert_eq!(decision.entries.deny_write, ["/home/o/old"]); + assert!(decision.entries.deny_read.is_empty()); + } + + #[test] + fn a_platform_without_a_sandbox_given_one_is_caught() { + let freebsd = prerequisites("freebsd", &[]); + + let decision = decide_with(&read(json!({})), None, &folders(), &freebsd).expect("composes"); + + assert!(decision.document.get("sandbox").is_none()); + assert_eq!( + decision.document["hooks"]["PreToolUse"], + json!([hook_item()]) + ); + assert_eq!(decision.document["permissions"]["deny"], deny_rules()); + assert_eq!(decision.gaps.len(), 1, "{:?}", decision.gaps); + let gap = &decision.gaps[0]; + assert!(gap.contains("freebsd"), "{gap}"); + assert!( + gap.contains("a platform where Claude Code's sandbox does not run"), + "{gap}" + ); + } } From 5a23412c66d10e7522ac0d17f4c68d30351b4052 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 12:05:11 -0400 Subject: [PATCH 14/35] feat(install): write the settings file first and report its gaps in the receipt The install decision now judges the settings file beside the stubs, and a refusal from either writes nothing. The settings write runs first, so a link or a changed file stops the install before any stub lands. A gap, a replaced owner value and the sandbox's reach are named in the receipt, and any gap makes the install partial. --- crates/baley/src/install/command.rs | 18 +- crates/baley/src/install/plan.rs | 382 +++++++++++++++++++++------ crates/baley/src/install/receipt.rs | 255 +++++++++++++++++- crates/baley/src/install/settings.rs | 103 +++++++- 4 files changed, 672 insertions(+), 86 deletions(-) diff --git a/crates/baley/src/install/command.rs b/crates/baley/src/install/command.rs index 9c296e75..04365420 100644 --- a/crates/baley/src/install/command.rs +++ b/crates/baley/src/install/command.rs @@ -12,8 +12,9 @@ use baley_core::policy::{FileLayer, Host, Schema, Value, merge, parse_layer}; use clap::Args; use crate::folders::{self, Environment, Folders, Platform}; +use crate::host_artifacts::placement::Artifact; use crate::host_artifacts::{installed, stubs}; -use crate::host_doctor::placed; +use crate::host_doctor::{placed, prerequisites}; use crate::ledger::clock::SystemClock; use crate::ledger::commands::new_request_id; use crate::ledger::display::{self, Render}; @@ -131,7 +132,7 @@ fn install(args: InstallArgs) -> Result { let latest_payload = latest.as_ref().map(|latest| &latest.payload); let mut observations = BTreeMap::new(); for file in installed.placements.expected_files() { - if file.stub.is_some() { + if file.stub.is_some() || file.artifact == Artifact::Settings { observations .entry(file.path.to_path_buf()) .or_insert_with(|| plan::Observation { @@ -141,7 +142,17 @@ fn install(args: InstallArgs) -> Result { }); } } - let plan = plan::judge(&installed.placements, latest_payload, &observations)?; + let prerequisites = prerequisites::judge(&prerequisites::gather( + std::env::var_os("PATH").as_deref(), + std::env::consts::OS, + )); + let plan = plan::judge(&plan::Gathered { + placements: &installed.placements, + folders: &folders, + latest: latest_payload, + observations: &observations, + prerequisites: &prerequisites, + })?; let applied = plan::applied(&plan, &apply::run(&plan)); let seed = models::seed(&store, new_request_id(), &SystemClock::now()); @@ -170,6 +181,7 @@ fn install(args: InstallArgs) -> Result { env!("CARGO_PKG_VERSION"), &installed.placements, latest_payload, + &plan.settings, &applied, &seed, event::Updates { diff --git a/crates/baley/src/install/plan.rs b/crates/baley/src/install/plan.rs index 302e43e7..25d62e2e 100644 --- a/crates/baley/src/install/plan.rs +++ b/crates/baley/src/install/plan.rs @@ -1,22 +1,26 @@ //! Installation writes decided from supplied placements, file observations -//! and the latest ownership record. Any stub conflict refuses the whole plan. -//! Applied outcomes retain older ownership evidence when a write cannot finish. -//! Registration and settings decisions join this judge in phase 15 plan 2. +//! and the latest ownership record. Any stub or settings conflict refuses the +//! whole plan. Applied outcomes retain older ownership evidence when a write +//! cannot finish. +//! The registration decision joins this judge in phase 15 plan 2. use std::collections::BTreeMap; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use baley_core::policy::Host; use baley_store::StoreError; use serde_json::Value; +use crate::folders::Folders; use crate::host_artifacts::placement::{Artifact, PlacementMap}; use crate::host_doctor::placed::{Fault, FileState}; +use crate::host_doctor::prerequisites::Judged; use crate::ledger::display::Render; use crate::replace::Failure; use super::event; -use super::receipt::ArtifactOutcome; +use super::receipt::{ArtifactOutcome, Notes}; +use super::settings::{self, Input}; use super::stubs::{self, Decision}; /// One read of a placed file and the path's own link status. @@ -28,61 +32,88 @@ pub struct Observation { pub symbolic_link: bool, } +/// Everything the install decision reads, gathered before any write. +#[derive(Debug, Clone, Copy)] +pub struct Gathered<'a> { + /// Where every artifact goes, and the stable executable. + pub placements: &'a PlacementMap, + /// Baley's data and configuration folders. + pub folders: &'a Folders, + /// The latest install record's payload, when there is one. + pub latest: Option<&'a Value>, + /// One observation per placed file the decision judges, keyed by path. + pub observations: &'a BTreeMap, + /// The sandbox programs the platform needs, judged by the doctor's check. + pub prerequisites: &'a Judged, +} + /// A file write permitted by the ownership decision. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Write { - /// The artifact whose bytes will be placed. - pub artifact: Artifact, + /// The artifacts whose bytes will be placed: one stub, or the hook and + /// the settings together, since they share one document. + pub artifacts: Vec, /// The absolute target path from the placement map. pub path: PathBuf, - /// The manifest bytes to write. + /// The bytes to write. pub bytes: Vec, /// SHA-256 of the observed bytes, or none when the path was absent. pub read_digest: Option, } /// Ordered writes and each artifact's state before those writes are applied. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub struct Plan { - /// Writes in placement order, excluding unchanged artifacts. + /// Writes in order, the settings file first, excluding unchanged files. pub writes: Vec, /// Placed artifacts in map order. Pending writes remain `NotWritten` until - /// the applier confirms them. Undecided JSON artifacts are `NotWritten` too. + /// the applier confirms them. An undecided registration is `NotWritten` too. pub outcomes: Vec<(Artifact, ArtifactOutcome)>, + /// The settings decision, kept for the receipt and the record. + pub settings: settings::Decision, +} + +fn unobserved() -> Observation { + Observation { + state: FileState::Fault(Fault::Unreadable("no file observation was supplied".into())), + symbolic_link: false, + } } -/// Plans stub writes, or refuses every write with one line per conflict. +/// Plans the writes, or refuses every write with one line per conflict. /// Observations are keyed by path so artifacts sharing a document use one read. -/// A missing stub observation refuses rather than inventing an absent file. -pub(crate) fn judge( - placements: &PlacementMap, - latest: Option<&Value>, - observations: &BTreeMap, -) -> Result { - let mut plan = Plan { - writes: Vec::new(), - outcomes: Vec::new(), +/// A missing observation refuses rather than inventing an absent file. +pub(crate) fn judge(gathered: &Gathered<'_>) -> Result { + let placements = gathered.placements; + let observed = |path: &Path| { + gathered + .observations + .get(path) + .cloned() + .unwrap_or_else(unobserved) }; + let mut stub_writes = Vec::new(); + let mut outcomes = Vec::new(); let mut conflicts = Vec::new(); for file in placements.expected_files() { let mut outcome = ArtifactOutcome::NotWritten; if let Some(entry) = file.stub { - let missing = Observation { - state: FileState::Fault(Fault::Unreadable( - "no file observation was supplied".into(), - )), - symbolic_link: false, - }; - let seen = observations.get(file.path).unwrap_or(&missing); - match stubs::judge(entry, file.path, &seen.state, seen.symbolic_link, latest) { + let seen = observed(file.path); + match stubs::judge( + entry, + file.path, + &seen.state, + seen.symbolic_link, + gathered.latest, + ) { Ok(Decision::Unchanged) => outcome = ArtifactOutcome::Unchanged, Ok(decision) => { let read_digest = match decision { Decision::Replace { read_digest } => Some(read_digest), _ => None, }; - plan.writes.push(Write { - artifact: file.artifact.clone(), + stub_writes.push(Write { + artifacts: vec![file.artifact.clone()], path: file.path.into(), bytes: entry.bytes.clone(), read_digest, @@ -91,15 +122,63 @@ pub(crate) fn judge( Err(conflict) => conflicts.push(conflict), } } - plan.outcomes.push((file.artifact, outcome)); + outcomes.push((file.artifact, outcome)); } - if conflicts.is_empty() { - Ok(plan) - } else { + + let settings_path = placements + .expected_files() + .into_iter() + .find(|file| file.artifact == Artifact::Settings) + .map(|file| file.path.to_path_buf()) + .expect("install places the settings file"); + let decision = match settings::current(&settings_path, &observed(&settings_path)) { + Err(line) => { + conflicts.push(line); + None + } + Ok(current) => match settings::judge(&Input { + path: &settings_path, + current: ¤t, + latest: gathered.latest, + placements, + folders: gathered.folders, + prerequisites: gathered.prerequisites, + }) { + Err(lines) => { + conflicts.extend(lines); + None + } + Ok(decision) => Some(decision), + }, + }; + let Some(decision) = decision.filter(|_| conflicts.is_empty()) else { let mut refusal = Render::refusal(""); refusal.lines = conflicts; - Err(refusal) + return Err(refusal); + }; + + let mut writes = Vec::new(); + match &decision.bytes { + Some(bytes) => writes.push(Write { + artifacts: vec![Artifact::Hook, Artifact::Settings], + path: settings_path, + bytes: bytes.clone(), + read_digest: decision.read_digest.clone(), + }), + None => { + for (artifact, outcome) in &mut outcomes { + if matches!(artifact, Artifact::Hook | Artifact::Settings) { + *outcome = ArtifactOutcome::Unchanged; + } + } + } } + writes.extend(stub_writes); + Ok(Plan { + writes, + outcomes, + settings: decision, + }) } /// Each artifact's outcome after applying a plan, with the first write failure. @@ -109,6 +188,8 @@ pub struct Applied { pub outcomes: Vec<(Artifact, ArtifactOutcome)>, /// The first write failure's text, including a failed sync after a rename. pub failure: Option, + /// The gaps and replaced values the receipt reports. + pub notes: Notes, } /// Interprets write observations in plan order. A failed folder sync still @@ -117,12 +198,15 @@ pub(crate) fn applied(plan: &Plan, results: &[Result<(), Failure>]) -> Applied { let mut applied = Applied { outcomes: plan.outcomes.clone(), failure: None, + notes: Notes::from_settings(&plan.settings), }; for (index, write) in plan.writes.iter().enumerate() { let result = results.get(index); let written = matches!(result, Some(Ok(()) | Err(Failure::Unsynced { .. }))); + let settings_file = write.artifacts.contains(&Artifact::Settings); if let Some(Err(failure)) = result { applied.failure = Some(match failure { + Failure::Refused(conflict) if settings_file => settings::replace_refusal(conflict), Failure::Refused(_) | Failure::Unsynced { .. } => failure.to_string(), Failure::Unchanged { path, cause } => { format!("not-writable: {}: {cause}", path.display()) @@ -130,7 +214,7 @@ pub(crate) fn applied(plan: &Plan, results: &[Result<(), Failure>]) -> Applied { }); } let outcome = if written { - if write.read_digest.is_some() { + if write.read_digest.is_some() && !settings_file { ArtifactOutcome::Replaced } else { ArtifactOutcome::Written @@ -146,24 +230,29 @@ pub(crate) fn applied(plan: &Plan, results: &[Result<(), Failure>]) -> Applied { cause.into() }) }; - if let Some((_, state)) = applied - .outcomes - .iter_mut() - .find(|(artifact, _)| *artifact == write.artifact) - { - *state = outcome; + for artifact in &write.artifacts { + if let Some((_, state)) = applied + .outcomes + .iter_mut() + .find(|(placed, _)| placed == artifact) + { + *state = outcome.clone(); + } } } applied } -/// Completion requires every artifact's current bytes and a recorded catalog seed. +/// Completion requires every artifact's current bytes, no gap and a recorded +/// catalog seed. pub(crate) fn complete( placements: &PlacementMap, outcomes: &[(Artifact, ArtifactOutcome)], + notes: &Notes, seed: &Result, ) -> bool { placements.not_installed().is_empty() + && notes.gaps.is_empty() && seed.is_ok() && placements.expected_files().iter().all(|file| { outcomes @@ -174,12 +263,13 @@ pub(crate) fn complete( /// Builds ownership facts from applied outcomes without reading any path. /// Failed or unreached writes keep that identity's previous entry, so the -/// next install can still recognize an older stub. JSON artifacts join in -/// phase 15 plan 2 at this same seam. +/// next install can still recognize an older stub, hook or deny entry. +/// The registration joins in phase 15 plan 2 at this same seam. pub(crate) fn facts( version: &str, placements: &PlacementMap, latest: Option<&Value>, + settings: &settings::Decision, applied: &Applied, seed: &Result, updates: event::Updates, @@ -211,37 +301,68 @@ pub(crate) fn facts( } }) .collect(); + let settings_owned = applied + .outcomes + .iter() + .any(|(artifact, outcome)| *artifact == Artifact::Settings && outcome.is_owned()); + let (hook, sandbox) = settings::recorded(settings, settings_owned, latest); event::Facts { host: Host::ClaudeCode, binary_version: version.into(), binary_path: placements.executable().as_str().into(), - complete: applied.failure.is_none() && complete(placements, &applied.outcomes, seed), + complete: applied.failure.is_none() + && complete(placements, &applied.outcomes, &applied.notes, seed), registration: None, - hook: None, + hook, stubs, - sandbox: None, + sandbox, updates, } } #[cfg(test)] mod tests { + use serde_json::json; + use super::*; - use crate::folders::Environment; - use crate::host_artifacts::{installed, stubs}; + use crate::host_artifacts::stubs; + use crate::install::fixtures::{ + complete_settings, folders, four_spaces, hook_item, installed, linux, + }; + use crate::store::model::digest; + + const SETTINGS: &str = "/home/o/.claude/settings.json"; + + fn updates() -> event::Updates { + event::Updates { + auto: Some(false), + staged_version: None, + } + } + + /// The settings decision over a document the owner's file holds. + fn decision(document: &Value, latest: Option<&Value>) -> settings::Decision { + let installed = installed(); + let seen = Observation { + state: FileState::Bytes(four_spaces(document)), + symbolic_link: false, + }; + let current = settings::current(Path::new(SETTINGS), &seen).unwrap(); + settings::judge(&Input { + path: Path::new(SETTINGS), + current: ¤t, + latest, + placements: &installed.placements, + folders: &folders(), + prerequisites: &linux(), + }) + .expect("composes") + } #[test] fn an_unwritten_stub_recorded_as_baleys_is_caught() { let manifest = stubs::manifest(&stubs::front_doors()).unwrap(); - let installed = installed::resolve( - &Environment { - home: Some("/home/o".into()), - ..Environment::default() - }, - None, - &manifest, - ) - .unwrap(); + let installed = installed(); let capture = event::Stub { identity: "bal-capture".into(), path: "/home/o/.claude/skills/bal-capture/SKILL.md".into(), @@ -258,6 +379,7 @@ mod tests { }; let latest = serde_json::json!({"stubs": [old_help]}); let failure = "not-writable: /home/o/.claude/skills/bal-help/SKILL.md: Permission denied"; + let settings = decision(&json!({}), None); for (latest, capture_outcome, help_outcome, expected) in [ ( None, @@ -287,17 +409,16 @@ mod tests { (Artifact::Settings, ArtifactOutcome::NotWritten), ], failure: None, + notes: Notes::default(), }; let facts = facts( "0.2.0", &installed.placements, latest, + &settings, &applied, &Ok(true), - event::Updates { - auto: Some(false), - staged_version: None, - }, + updates(), ); assert_eq!(facts.stubs, expected); assert!(!facts.complete); @@ -306,16 +427,7 @@ mod tests { #[test] fn stubs_written_around_an_ownership_conflict_is_caught() { - let manifest = stubs::manifest(&stubs::front_doors()).unwrap(); - let installed = installed::resolve( - &Environment { - home: Some("/home/o".into()), - ..Environment::default() - }, - None, - &manifest, - ) - .unwrap(); + let installed = installed(); let capture = PathBuf::from("/home/o/.claude/skills/bal-capture/SKILL.md"); let help = PathBuf::from("/home/o/.claude/skills/bal-help/SKILL.md"); let seen = |state| Observation { @@ -323,23 +435,139 @@ mod tests { symbolic_link: false, }; let mut observations = BTreeMap::from([ + (PathBuf::from(SETTINGS), seen(FileState::Absent)), (capture.clone(), seen(FileState::Absent)), (help.clone(), seen(FileState::Bytes(b"mine".to_vec()))), ]); let help_line = "install-ownership-conflict: stub `bal-help` at /home/o/.claude/skills/bal-help/SKILL.md holds bytes that match neither this binary's stub nor a recorded hash for this identity; move it aside and run `baley install` again"; let capture_line = "install-ownership-conflict: stub `bal-capture` at /home/o/.claude/skills/bal-capture/SKILL.md holds bytes that match neither this binary's stub nor a recorded hash for this identity; move it aside and run `baley install` again"; + let judged = |observations: &BTreeMap| { + judge(&Gathered { + placements: &installed.placements, + folders: &folders(), + latest: None, + observations, + prerequisites: &linux(), + }) + }; - let refusal = judge(&installed.placements, None, &observations) + let refusal = judged(&observations) .expect_err("a conflict must return no write plan, including the absent stub"); assert_eq!(refusal.lines, [help_line]); assert_eq!(refusal.code, 2); assert!(refusal.error); observations.insert(capture, seen(FileState::Bytes(b"mine".to_vec()))); - let refusal = judge(&installed.placements, None, &observations) - .expect_err("both conflicts must return no write plan"); + let refusal = judged(&observations).expect_err("both conflicts must return no write plan"); assert_eq!(refusal.lines, [capture_line, help_line]); assert_eq!(refusal.code, 2); assert!(refusal.error); } + + #[test] + fn a_settings_entry_recorded_that_the_file_does_not_hold_or_dropped_while_it_does_is_caught() { + let installed = installed(); + let outcomes = |stubs: ArtifactOutcome, settings: ArtifactOutcome| { + vec![ + (Artifact::Stub("bal-capture".into()), stubs.clone()), + (Artifact::Stub("bal-help".into()), stubs), + (Artifact::Registration, ArtifactOutcome::NotWritten), + (Artifact::Hook, settings.clone()), + (Artifact::Settings, settings), + ] + }; + let refused = ArtifactOutcome::Failed( + "not-writable: /home/o/.claude/settings.json: Permission denied".into(), + ); + let build = |latest: Option<&Value>, + decision: &settings::Decision, + outcomes: Vec<(Artifact, ArtifactOutcome)>, + failure: Option<&str>| { + let applied = Applied { + outcomes, + failure: failure.map(str::to_owned), + notes: Notes::default(), + }; + facts( + "0.2.0", + &installed.placements, + latest, + decision, + &applied, + &Ok(true), + updates(), + ) + }; + let failure = Some("not-writable: /home/o/.claude/settings.json: Permission denied"); + + // The write failed and no record says Baley ever wrote anything. + let owner = json!({"model": "opus"}); + let facts = build( + None, + &decision(&owner, None), + outcomes(ArtifactOutcome::Written, refused.clone()), + failure, + ); + assert_eq!(facts.hook, None); + assert_eq!(facts.sandbox, None); + assert!(!facts.complete); + assert_eq!(facts.stubs.len(), 2); + + // A completed installation left unchanged stays in the record. + let complete = complete_settings(); + let facts = build( + None, + &decision(&complete, None), + outcomes(ArtifactOutcome::Unchanged, ArtifactOutcome::Unchanged), + None, + ); + let hook = facts.hook.expect("the unchanged hook stays recorded"); + assert_eq!(hook.path, SETTINGS); + assert_eq!(hook.item, hook_item()); + let sandbox = facts.sandbox.expect("the unchanged sandbox stays recorded"); + assert_eq!(sandbox.settings_path, SETTINGS); + assert_eq!(sandbox.sha256, digest(&four_spaces(&complete))); + assert_eq!( + sandbox.deny_read, + [ + "/home/o/.local/share/crenshawdev/baley", + "/home/o/.config/crenshawdev/baley" + ] + ); + assert_eq!(sandbox.deny_write.len(), 8); + assert_eq!(sandbox.permissions_deny.len(), 10); + + // The write failed over Baley's own older entries. + let old_hook = json!({ + "matcher": "Bash|Write|Edit", + "hooks": [{"type": "command", "command": "'/home/o/.local/bin/baley' guard", "timeout": 5}], + }); + let record = json!({ + "registered": {"hook": {"path": SETTINGS, "item": old_hook}}, + "sandbox": { + "permissions_deny": ["Edit(//home/o/old/**)", "Edit(//home/o/gone/**)"], + "deny_read": [], + "deny_write": ["/home/o/old"], + }, + }); + let older = json!({ + "hooks": {"PreToolUse": [old_hook]}, + "permissions": {"deny": ["Edit(//home/o/old/**)"]}, + "sandbox": {"filesystem": {"denyWrite": ["/home/o/old"]}}, + }); + let facts = build( + Some(&record), + &decision(&older, Some(&record)), + outcomes(ArtifactOutcome::Unchanged, refused), + failure, + ); + assert_eq!(facts.hook.expect("the older hook is kept").item, old_hook); + let sandbox = facts.sandbox.expect("the older entries are kept"); + assert_eq!(sandbox.settings_path, SETTINGS); + assert_eq!(sandbox.sha256, digest(&four_spaces(&older))); + assert_eq!(sandbox.permissions_deny, ["Edit(//home/o/old/**)"]); + assert_eq!(sandbox.deny_write, ["/home/o/old"]); + assert!(sandbox.deny_read.is_empty()); + assert!(!facts.complete); + } } diff --git a/crates/baley/src/install/receipt.rs b/crates/baley/src/install/receipt.rs index 01b7152c..ac3a61c2 100644 --- a/crates/baley/src/install/receipt.rs +++ b/crates/baley/src/install/receipt.rs @@ -9,6 +9,8 @@ use baley_store::StoreError; use crate::host_artifacts::placement::{Artifact, PlacementMap}; use crate::ledger::display::{self, Render}; +use super::settings::{self, Replaced}; + /// What the run left at an artifact's placed path. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ArtifactOutcome { @@ -31,25 +33,57 @@ impl ArtifactOutcome { } } +/// What the settings step adds to the receipt beyond the artifact lines. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Notes { + /// Each gap with its fix. Any gap makes the install partial. + pub gaps: Vec, + /// Owner values Baley's secure values replaced. + pub replaced: Vec, + /// Why the sandbox block was left out of the settings file, if it was. + pub sandbox_held_back: Option, +} + +impl Notes { + /// The notes a settings decision gives the receipt. + pub fn from_settings(decision: &settings::Decision) -> Self { + Self { + gaps: decision.gaps.clone(), + replaced: decision.replaced.clone(), + sandbox_held_back: decision.held_back.clone(), + } + } +} + /// Renders every placed artifact in map order. A missing outcome counts as /// not written. Completion requires every placement to be known, every -/// artifact to be Baley's and the catalog seed to be recorded. +/// artifact to be Baley's, no gap and the catalog seed to be recorded. pub(crate) fn render( version: &str, stable_path: &Path, placements: &PlacementMap, outcomes: &[(Artifact, ArtifactOutcome)], + notes: &Notes, seed: &Result, ) -> Render { - let complete = super::plan::complete(placements, outcomes, seed); + let complete = super::plan::complete(placements, outcomes, notes, seed); let mut artifacts = Vec::new(); + let mut settings_path = None; for file in placements.expected_files() { + if file.artifact == Artifact::Settings { + settings_path = Some(file.path.display().to_string()); + } let outcome = outcomes .iter() .find(|(artifact, _)| *artifact == file.artifact) .map_or(&ArtifactOutcome::NotWritten, |(_, outcome)| outcome); let text = match outcome { ArtifactOutcome::NotWritten => "not written".into(), + ArtifactOutcome::Written + if file.artifact == Artifact::Settings && notes.sandbox_held_back.is_some() => + { + "written without the sandbox block".into() + } ArtifactOutcome::Written => "written".into(), ArtifactOutcome::Replaced => "replaced".into(), ArtifactOutcome::Unchanged => "unchanged".into(), @@ -81,6 +115,28 @@ pub(crate) fn render( .join("; ") ), }); + let settings_path = settings_path.unwrap_or_default(); + for replaced in ¬es.replaced { + lines.push(format!( + "replaced {} in {settings_path}: it held {}, and Baley's secure value {} is now there", + replaced.key, + replaced.was, + settings::secure_value(&replaced.key) + )); + } + lines.extend(notes.gaps.iter().map(|gap| format!("gap: {gap}"))); + let sandbox_written = outcomes + .iter() + .any(|(artifact, outcome)| *artifact == Artifact::Settings && outcome.is_owned()) + && notes.sandbox_held_back.is_none(); + if sandbox_written { + lines.push(format!( + "the sandbox settings in {settings_path} apply to every Claude Code session on this machine, including projects Baley does not manage" + )); + lines.push(format!( + "managed settings, command-line settings and a project's own .claude/settings.json and .claude/settings.local.json take precedence over {settings_path} for single values such as sandbox.enabled" + )); + } lines.extend([ "start a new Claude Code session to load the wiring".into(), "run baley init in each checkout Baley should manage".into(), @@ -106,7 +162,14 @@ pub(crate) fn render_applied( seed: &Result, recorded: &Result, ) -> Render { - let mut receipt = render(version, stable_path, placements, &applied.outcomes, seed); + let mut receipt = render( + version, + stable_path, + placements, + &applied.outcomes, + &applied.notes, + seed, + ); let mut failures = Vec::new(); if let Some(failure) = &applied.failure { failures.push(failure.clone()); @@ -168,6 +231,7 @@ mod tests { ), ], failure: None, + notes: Notes::default(), }; let receipt = render_applied( "0.2.0", @@ -218,6 +282,7 @@ mod tests { installed.layout.stable_path(), &installed.placements, &outcomes(ArtifactOutcome::NotWritten), + &Notes::default(), &Ok(true), ); assert_eq!(receipt.lines[0], "install outcome: partial"); @@ -249,6 +314,7 @@ mod tests { installed.layout.stable_path(), &installed.placements, &outcomes(ArtifactOutcome::NotWritten), + &Notes::default(), &Ok(true), ); assert_eq!(receipt.lines[1], "baley 0.2.0 at /home/o/.local/bin/baley"); @@ -274,6 +340,7 @@ mod tests { installed.layout.stable_path(), &installed.placements, &outcomes, + &Notes::default(), &Err(StoreError::Busy), ); assert_eq!( @@ -290,6 +357,7 @@ mod tests { installed.layout.stable_path(), &installed.placements, &outcomes, + &Notes::default(), &Ok(recorded), ); assert_eq!(receipt.lines[0], "install outcome: complete"); @@ -305,4 +373,185 @@ mod tests { assert_eq!(receipt.lines[7], expected); } } + #[test] + fn a_gap_left_unnamed_or_an_install_with_a_gap_called_complete_is_caught() { + use std::collections::BTreeMap; + + use serde_json::json; + + use crate::host_doctor::placed::FileState; + use crate::install::fixtures::{folders, prerequisites}; + use crate::install::plan::{self, Gathered, Observation}; + + let installed = installed(); + let owner = json!({"sandbox": { + "excludedCommands": ["docker"], + "filesystem": { + "disabled": true, + "allowRead": ["/home/o/.local/share/crenshawdev/baley"], + "allowWrite": ["~/scratch"], + }, + }}); + let seen = |state| Observation { + state, + symbolic_link: false, + }; + let observations = BTreeMap::from([ + ( + installed.settings_file.clone(), + seen(FileState::Bytes(serde_json::to_vec(&owner).unwrap())), + ), + ( + "/home/o/.claude/skills/bal-capture/SKILL.md".into(), + seen(FileState::Absent), + ), + ( + "/home/o/.claude/skills/bal-help/SKILL.md".into(), + seen(FileState::Absent), + ), + ]); + let no_socat = prerequisites("linux", &["bwrap"]); + let plan = plan::judge(&Gathered { + placements: &installed.placements, + folders: &folders(), + latest: None, + observations: &observations, + prerequisites: &no_socat, + }) + .expect("a gap is not a refusal"); + let written: Vec> = + plan.writes.iter().map(|_| Ok(())).collect(); + let applied = plan::applied(&plan, &written); + + let receipt = render_applied( + "0.2.0", + installed.layout.stable_path(), + &installed.placements, + &applied, + &Ok(true), + &Ok(true), + ); + + assert_eq!(receipt.lines[0], "install outcome: partial"); + assert!( + !receipt + .lines + .iter() + .any(|line| line == "install outcome: complete") + ); + let gap = |parts: &[&str]| { + receipt + .lines + .iter() + .find(|line| line.starts_with("gap: ") && parts.iter().all(|p| line.contains(p))) + .unwrap_or_else(|| panic!("no gap line holds {parts:?}: {:#?}", receipt.lines)) + }; + gap(&["docker", "sandbox.excludedCommands", "remove it"]); + gap(&[ + "sandbox.filesystem.disabled", + "remove it or set it to false", + ]); + gap(&[ + "/home/o/.local/share/crenshawdev/baley", + "sandbox.filesystem.allowRead", + "remove it", + ]); + gap(&[ + "~/scratch", + "sandbox.filesystem.allowWrite", + "write it as an absolute path outside Baley's folders or remove it", + ]); + gap(&["socat", "apt-get install bubblewrap socat"]); + assert_eq!( + receipt + .lines + .iter() + .filter(|line| line.starts_with("gap: ")) + .count(), + 5 + ); + assert_eq!(receipt.code, 1); + assert!(!receipt.error); + } + #[test] + fn a_settings_change_or_its_reach_hidden_from_the_receipt_is_caught() { + let installed = installed(); + let render_with = |outcome: ArtifactOutcome, notes: Notes| { + render( + "0.2.0", + installed.layout.stable_path(), + &installed.placements, + &outcomes(outcome), + ¬es, + &Ok(true), + ) + }; + let line_with = + |receipt: &Render, text: &str| receipt.lines.iter().any(|line| line.contains(text)); + let every_session = "apply to every Claude Code session on this machine, including projects Baley does not manage"; + let precedence = [ + "managed settings", + "command-line settings", + ".claude/settings.json", + ".claude/settings.local.json", + "take precedence", + ]; + let settings_line = |receipt: &Render| { + receipt + .lines + .iter() + .find(|line| line.starts_with("settings at ")) + .cloned() + }; + + let replaced = Notes { + replaced: vec![Replaced { + key: "sandbox.enabled".into(), + was: serde_json::json!(false), + }], + ..Notes::default() + }; + let written = render_with(ArtifactOutcome::Written, replaced); + assert_eq!( + settings_line(&written).unwrap(), + "settings at /home/o/.claude/settings.json: written" + ); + assert!(written.lines.iter().any(|line| { + line.contains("sandbox.enabled") + && line.contains("false") + && line.contains("/home/o/.claude/settings.json") + })); + assert!(line_with(&written, every_session)); + assert!( + written + .lines + .iter() + .any(|line| precedence.iter().all(|part| line.contains(part))) + ); + + let held_back = Notes { + sandbox_held_back: Some("socat missing from PATH".into()), + ..Notes::default() + }; + let held = render_with(ArtifactOutcome::Written, held_back); + assert_eq!( + settings_line(&held).unwrap(), + "settings at /home/o/.claude/settings.json: written without the sandbox block" + ); + assert!(!line_with(&held, "every Claude Code session")); + assert!(!line_with(&held, "take precedence")); + + let unchanged = render_with(ArtifactOutcome::Unchanged, Notes::default()); + assert_eq!( + settings_line(&unchanged).unwrap(), + "settings at /home/o/.claude/settings.json: unchanged" + ); + assert!(line_with(&unchanged, every_session)); + assert!( + unchanged + .lines + .iter() + .any(|line| precedence.iter().all(|part| line.contains(part))) + ); + } } diff --git a/crates/baley/src/install/settings.rs b/crates/baley/src/install/settings.rs index bf42ed60..09248920 100644 --- a/crates/baley/src/install/settings.rs +++ b/crates/baley/src/install/settings.rs @@ -17,6 +17,7 @@ use crate::host_doctor::prerequisites::{Judged, missing_sentence}; use crate::replace; use crate::store::model::digest; +use super::event; use super::plan::Observation; /// The settings file as a read found it, ready to compose into. @@ -169,7 +170,7 @@ pub fn remove_recorded( } /// An owner value that Baley's own secure value replaced. -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct Replaced { /// The setting's dotted key. pub key: String, @@ -230,6 +231,12 @@ pub struct Decision { pub entries: Entries, /// Why the sandbox block was left out, or none when it is composed in. pub held_back: Option, + /// This binary's hook item, whether or not the document holds it. + pub hook_item: Value, + /// The record's sandbox facts that do not depend on the run: the file, + /// Baley's folders and the write-only paths. Its digest and lists are + /// empty until [`recorded`] fills them. + pub base: event::Sandbox, } /// The refusal text for a replacement that was refused, under install's @@ -242,7 +249,8 @@ pub fn replace_refusal(conflict: &replace::Conflict) -> String { format!("install-settings-conflict: {text}") } -fn secure(setting: &str) -> bool { +/// The secure value of one of the three sandbox switches Baley sets. +pub fn secure_value(setting: &str) -> bool { setting != "sandbox.allowUnsandboxedCommands" } @@ -257,7 +265,7 @@ fn gap(file: &Path, conflict: &Conflict) -> Option { ), Conflict::Disabled(setting) => format!( "{setting} in {file} is not its secure value; set it to {}", - secure(setting) + secure_value(setting) ), Conflict::Reopened { list, entry } => format!( "`{entry}` is listed in sandbox.filesystem.{list} in {file}, which re-opens a Baley folder; remove it" @@ -424,6 +432,29 @@ pub fn judge(input: &Input<'_>) -> Result> { &ours.deny_write, ), }; + let text = |path: &Path| path.to_str().expect("placements are UTF-8").to_owned(); + let base = event::Sandbox { + settings_path: text(input.path), + sha256: String::new(), + home: text(&input.folders.home), + config: text(&input.folders.config), + write_only_files: input + .placements + .protected_paths() + .iter() + .map(|path| text(path)) + .collect(), + write_only_folders: input + .placements + .write_only_folders() + .iter() + .map(|path| text(path)) + .collect(), + permissions_deny: Vec::new(), + deny_read: Vec::new(), + deny_write: Vec::new(), + held_back: None, + }; Ok(Decision { bytes, read_digest: input.current.read_digest.clone(), @@ -433,9 +464,75 @@ pub fn judge(input: &Input<'_>) -> Result> { gaps, entries, held_back: held.map(|(why, _)| why), + hook_item: ours.hook_item, + base, }) } +/// The hook and the sandbox facts to record after the run. With the settings +/// file Baley's after the run (`owned`), they are what the composed document +/// holds. Otherwise the file still holds the bytes read, so the hook is this +/// binary's item or the latest record's while that document holds it, and the +/// sandbox facts are the latest record's lists narrowed to what that document +/// still holds, or none when it holds none. Dropping them would make the next +/// install refuse Baley's own older entries as foreign. +pub fn recorded( + decision: &Decision, + owned: bool, + latest: Option<&Value>, +) -> (Option, Option) { + let path = decision.base.settings_path.clone(); + if owned { + let hook = decision.entries.hook.clone().map(|item| event::Hook { + path: path.clone(), + item, + }); + let sha256 = decision + .bytes + .as_deref() + .map(digest) + .or_else(|| decision.read_digest.clone()); + let sandbox = sha256.map(|sha256| event::Sandbox { + sha256, + permissions_deny: decision.entries.permissions_deny.clone(), + deny_read: decision.entries.deny_read.clone(), + deny_write: decision.entries.deny_write.clone(), + held_back: decision.held_back.clone(), + ..decision.base.clone() + }); + return (hook, sandbox); + } + let read = &decision.read; + let holds = |item: &Value| { + read.pointer(&format!("/hooks/{}", hook::EVENT)) + .and_then(Value::as_array) + .is_some_and(|items| items.contains(item)) + }; + let older = latest.and_then(|latest| latest.pointer("/registered/hook/item")); + let item = [Some(&decision.hook_item), older] + .into_iter() + .flatten() + .find(|item| holds(item)) + .cloned(); + let narrowed = |pointer: &str, key: &str| still_held(read, latest, pointer, key); + let permissions_deny = narrowed("/permissions/deny", "permissions_deny"); + let deny_read = narrowed("/sandbox/filesystem/denyRead", "deny_read"); + let deny_write = narrowed("/sandbox/filesystem/denyWrite", "deny_write"); + let none = permissions_deny.is_empty() && deny_read.is_empty() && deny_write.is_empty(); + let sandbox = decision + .read_digest + .clone() + .filter(|_| !none) + .map(|sha256| event::Sandbox { + sha256, + permissions_deny, + deny_read, + deny_write, + ..decision.base.clone() + }); + (item.map(|item| event::Hook { path, item }), sandbox) +} + #[cfg(test)] mod tests { use std::path::PathBuf; From c1da8b965950b3820f2a51d717169fcf71034512 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 12:16:55 -0400 Subject: [PATCH 15/35] feat(install): decide the registration from Claude Code's own file Claude Code is the only writer of .claude.json, so install reads it and asks for the claude mcp commands that place this binary's entry. Only an entry equal to this binary's or to the one the record holds is Baley's. An entry that sets environment entries is not Baley's by its command alone, for the install and the doctor alike, since it can point Baley at another ledger. --- crates/baley/src/host_artifacts/compose.rs | 21 +- crates/baley/src/host_doctor/mod.rs | 3 +- crates/baley/src/host_doctor/placed.rs | 32 ++- crates/baley/src/host_doctor/report.rs | 2 +- crates/baley/src/install/command.rs | 4 +- crates/baley/src/install/mod.rs | 2 + crates/baley/src/install/plan.rs | 106 +++++++- crates/baley/src/install/receipt.rs | 1 + crates/baley/src/install/registration.rs | 289 +++++++++++++++++++++ 9 files changed, 445 insertions(+), 15 deletions(-) create mode 100644 crates/baley/src/install/registration.rs diff --git a/crates/baley/src/host_artifacts/compose.rs b/crates/baley/src/host_artifacts/compose.rs index e393f54c..f3d55f47 100644 --- a/crates/baley/src/host_artifacts/compose.rs +++ b/crates/baley/src/host_artifacts/compose.rs @@ -49,8 +49,8 @@ pub enum Conflict { /// not Baley's hook item: another matcher, another executable or another /// field. It stays, and Baley's item is added beside it. OtherGuardHook(Value), - /// `mcpServers.baley` runs another command or other arguments. It stays - /// as it was. + /// `mcpServers.baley` runs another command or other arguments, or sets + /// environment entries. It stays as it was. OtherRegistration(Value), /// An `allowRead` or `allowWrite` entry equals or lies inside the home or /// the config folder. @@ -103,8 +103,8 @@ impl Composed { /// Baley adds goes last. Arrays gain each Baley entry not already present, /// so composing the result again changes nothing. Baley's own values win, /// and each one that replaced an owner value is reported. An existing -/// `mcpServers.baley` running something else is left as it was and -/// reported. +/// `mcpServers.baley` running something else, or setting environment +/// entries, is left as it was and reported. pub fn compose( existing: Option<&Value>, ours: &[Value], @@ -175,10 +175,17 @@ fn merge(target: &mut Value, ours: &Value, key: &str, conflicts: &mut Vec bool { - existing.get("command") == ours.get("command") && existing.get("args") == ours.get("args") + existing.get("command") == ours.get("command") + && existing.get("args") == ours.get("args") + && existing + .get("env") + .is_none_or(|env| env.as_object().is_some_and(Map::is_empty)) } fn strings<'v>(document: &'v Value, pointer: &str) -> Vec<&'v str> { diff --git a/crates/baley/src/host_doctor/mod.rs b/crates/baley/src/host_doctor/mod.rs index f2732ab8..f2be149c 100644 --- a/crates/baley/src/host_doctor/mod.rs +++ b/crates/baley/src/host_doctor/mod.rs @@ -233,7 +233,8 @@ pub enum ArtifactState { /// The place. path: PathBuf, }, - /// The registration document's entry runs another command or arguments. + /// The registration document's entry runs another command or arguments, + /// or sets environment entries. RegistrationDiffers { /// The place. path: PathBuf, diff --git a/crates/baley/src/host_doctor/placed.rs b/crates/baley/src/host_doctor/placed.rs index 32204833..40d05f37 100644 --- a/crates/baley/src/host_doctor/placed.rs +++ b/crates/baley/src/host_doctor/placed.rs @@ -196,16 +196,18 @@ pub fn stub(entry: &Entry, found: &[u8]) -> StubJudgement { #[derive(Debug, Clone, PartialEq)] pub enum RegistrationJudgement { /// The `mcpServers` entry under Baley's key runs the same command with - /// the same arguments. + /// the same arguments and sets no environment entry. Matches, /// The document has no entry under Baley's key. Missing, - /// The entry runs another command or other arguments; the entry found. + /// The entry runs another command or other arguments, or sets + /// environment entries; 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. +/// Judges the entry under `mcpServers.baley` by the rule `baley install` and +/// composition use: equal `command` and `args` and no environment entry, 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 { @@ -393,4 +395,26 @@ mod tests { RegistrationJudgement::Missing ); } + #[test] + fn a_registration_install_would_refuse_called_matching_by_the_doctor_is_caught() { + let executable = Executable::new("/home/o/.local/bin/baley").unwrap(); + let stored = |env: Value| { + serde_json::json!({"mcpServers": {"baley": { + "type": "stdio", + "command": "/home/o/.local/bin/baley", + "args": ["serve"], + "env": env, + }}}) + }; + + let elsewhere = stored(serde_json::json!({"BALEY_HOME": "/elsewhere"})); + assert_eq!( + registration(&elsewhere, &executable), + RegistrationJudgement::Differs(elsewhere["mcpServers"]["baley"].clone()) + ); + assert_eq!( + registration(&stored(serde_json::json!({})), &executable), + RegistrationJudgement::Matches + ); + } } diff --git a/crates/baley/src/host_doctor/report.rs b/crates/baley/src/host_doctor/report.rs index 4a300023..baf5321f 100644 --- a/crates/baley/src/host_doctor/report.rs +++ b/crates/baley/src/host_doctor/report.rs @@ -81,7 +81,7 @@ impl Report { crate::host_artifacts::registration::KEY ), ArtifactState::RegistrationDiffers { path, found } => format!( - "{artifact}: {} runs another command or arguments than Baley renders: {found}", + "{artifact}: {} differs from the entry Baley renders (another command, other arguments or environment entries): {found}", path.display() ), }); diff --git a/crates/baley/src/install/command.rs b/crates/baley/src/install/command.rs index 04365420..97410678 100644 --- a/crates/baley/src/install/command.rs +++ b/crates/baley/src/install/command.rs @@ -132,7 +132,9 @@ fn install(args: InstallArgs) -> Result { let latest_payload = latest.as_ref().map(|latest| &latest.payload); let mut observations = BTreeMap::new(); for file in installed.placements.expected_files() { - if file.stub.is_some() || file.artifact == Artifact::Settings { + if file.stub.is_some() + || matches!(file.artifact, Artifact::Settings | Artifact::Registration) + { observations .entry(file.path.to_path_buf()) .or_insert_with(|| plan::Observation { diff --git a/crates/baley/src/install/mod.rs b/crates/baley/src/install/mod.rs index 9168bbe1..a4f77e93 100644 --- a/crates/baley/src/install/mod.rs +++ b/crates/baley/src/install/mod.rs @@ -18,6 +18,8 @@ pub mod plan; pub mod receipt; /// Recording and reading installation ownership evidence in the ledger. pub mod record; +/// The registration install requests from Claude Code, decided from the file. +pub mod registration; /// Claude Code's user settings file read, composed into and judged. pub mod settings; /// Stub ownership judged from observed bytes and the latest install record. diff --git a/crates/baley/src/install/plan.rs b/crates/baley/src/install/plan.rs index 25d62e2e..213c9912 100644 --- a/crates/baley/src/install/plan.rs +++ b/crates/baley/src/install/plan.rs @@ -20,6 +20,7 @@ use crate::replace::Failure; use super::event; use super::receipt::{ArtifactOutcome, Notes}; +use super::registration::{self, Step}; use super::settings::{self, Input}; use super::stubs::{self, Decision}; @@ -71,6 +72,9 @@ pub struct Plan { pub outcomes: Vec<(Artifact, ArtifactOutcome)>, /// The settings decision, kept for the receipt and the record. pub settings: settings::Decision, + /// The registration decision: the `claude` commands to run after every + /// file is written, or that the entry is Baley's already. + pub registration: Step, } fn unobserved() -> Observation { @@ -151,7 +155,26 @@ pub(crate) fn judge(gathered: &Gathered<'_>) -> Result { Ok(decision) => Some(decision), }, }; - let Some(decision) = decision.filter(|_| conflicts.is_empty()) else { + let registration_path = placements + .expected_files() + .into_iter() + .find(|file| file.artifact == Artifact::Registration) + .map(|file| file.path.to_path_buf()) + .expect("install places the registration file"); + let registration = match registration::judge( + ®istration_path, + &observed(®istration_path).state, + gathered.latest, + placements.executable(), + ) { + Ok(step) => Some(step), + Err(line) => { + conflicts.push(line); + None + } + }; + let (Some(decision), Some(registration), true) = (decision, registration, conflicts.is_empty()) + else { let mut refusal = Render::refusal(""); refusal.lines = conflicts; return Err(refusal); @@ -174,10 +197,18 @@ pub(crate) fn judge(gathered: &Gathered<'_>) -> Result { } } writes.extend(stub_writes); + if registration == Step::Unchanged { + for (artifact, outcome) in &mut outcomes { + if *artifact == Artifact::Registration { + *outcome = ArtifactOutcome::Unchanged; + } + } + } Ok(Plan { writes, outcomes, settings: decision, + registration, }) } @@ -325,6 +356,7 @@ mod tests { use serde_json::json; use super::*; + use crate::host_artifacts::installed::Installed; use crate::host_artifacts::stubs; use crate::install::fixtures::{ complete_settings, folders, four_spaces, hook_item, installed, linux, @@ -436,6 +468,10 @@ mod tests { }; let mut observations = BTreeMap::from([ (PathBuf::from(SETTINGS), seen(FileState::Absent)), + ( + PathBuf::from("/home/o/.claude.json"), + seen(FileState::Absent), + ), (capture.clone(), seen(FileState::Absent)), (help.clone(), seen(FileState::Bytes(b"mine".to_vec()))), ]); @@ -570,4 +606,72 @@ mod tests { assert!(sandbox.deny_read.is_empty()); assert!(!facts.complete); } + /// Every judged path absent, as on a machine install never ran on. + fn all_absent(installed: &Installed) -> BTreeMap { + let absent = Observation { + state: FileState::Absent, + symbolic_link: false, + }; + let stubs = installed + .placements + .expected_files() + .into_iter() + .filter(|file| file.stub.is_some()) + .map(|file| file.path.to_path_buf()); + stubs + .chain([ + installed.settings_file.clone(), + installed.registration_file.clone(), + ]) + .map(|path| (path, absent.clone())) + .collect() + } + + #[test] + fn an_artifact_under_baleys_name_replaced_without_record_evidence_is_caught() { + let installed = installed(); + for (path, bytes, artifact) in [ + ( + "/home/o/.claude/skills/bal-help/SKILL.md", + &b"my own notes"[..], + "stub `bal-help`", + ), + ( + "/home/o/.claude.json", + &br#"{"mcpServers":{"baley":{"command":"/opt/other/baley","args":["serve"]}}}"#[..], + "mcpServers.baley", + ), + ( + SETTINGS, + &br#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"/opt/other/baley guard"}]}]}}"#[..], + "guard hook", + ), + ] { + let mut observations = all_absent(&installed); + observations.insert( + PathBuf::from(path), + Observation { + state: FileState::Bytes(bytes.to_vec()), + symbolic_link: false, + }, + ); + + let refusal = judge(&Gathered { + placements: &installed.placements, + folders: &folders(), + latest: None, + observations: &observations, + prerequisites: &linux(), + }) + .expect_err("content under Baley's name without evidence is refused"); + + assert_eq!(refusal.lines.len(), 1, "{:?}", refusal.lines); + let line = &refusal.lines[0]; + assert!(line.starts_with("install-ownership-conflict: "), "{line}"); + assert!(line.contains(artifact), "{line}"); + assert!(line.contains(path), "{line}"); + assert_eq!(refusal.code, 2); + assert!(refusal.error); + } + } } diff --git a/crates/baley/src/install/receipt.rs b/crates/baley/src/install/receipt.rs index ac3a61c2..f313978f 100644 --- a/crates/baley/src/install/receipt.rs +++ b/crates/baley/src/install/receipt.rs @@ -401,6 +401,7 @@ mod tests { installed.settings_file.clone(), seen(FileState::Bytes(serde_json::to_vec(&owner).unwrap())), ), + (installed.registration_file.clone(), seen(FileState::Absent)), ( "/home/o/.claude/skills/bal-capture/SKILL.md".into(), seen(FileState::Absent), diff --git a/crates/baley/src/install/registration.rs b/crates/baley/src/install/registration.rs new file mode 100644 index 00000000..6a548908 --- /dev/null +++ b/crates/baley/src/install/registration.rs @@ -0,0 +1,289 @@ +//! The MCP registration install requests from Claude Code. Claude Code is +//! the only writer of its own configuration file, so install reads the file, +//! judges whether the `mcpServers.baley` entry is Baley's, and requests the +//! `claude mcp` commands that place this binary's entry. The decision is pure: +//! it returns launches and never runs one. + +use std::path::Path; +use std::time::Duration; + +use serde_json::Value; + +use crate::host_artifacts::compose::same_server; +use crate::host_artifacts::executable::Executable; +use crate::host_artifacts::registration::{self, KEY}; +use crate::host_doctor::placed::{self, FileState}; +use crate::process::Launch; + +/// How long one `claude` command may run. +const TIMEOUT: Duration = Duration::from_secs(30); +/// How much of each output stream is kept. +const LIMIT: usize = 64 * 1024; + +/// What install does about the registration. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Step { + /// The entry is this binary's already, so nothing runs. + Unchanged, + /// The `claude` commands to run in order: the add alone, or the remove of + /// Baley's older entry and then the add, since `claude mcp add-json` + /// refuses a name that already exists in the scope. + Run(Vec), +} + +/// This binary's entry: the stable path and `serve`. +pub fn entry(executable: &Executable) -> Value { + registration::render(executable, false)["mcpServers"][KEY].clone() +} + +fn claude(args: [&str; 5]) -> Launch { + Launch::new("claude") + .args(args) + .own_group() + .timeout(TIMEOUT) + .limit(LIMIT) +} + +/// The command that registers `entry` at user scope. +pub fn add_launch(entry: &Value) -> Launch { + let compact = serde_json::to_string(entry).expect("a JSON entry serializes"); + claude(["mcp", "add-json", "--scope", "user", KEY]).arg(compact) +} + +/// The command that removes the user-scope entry. +pub fn remove_launch() -> Launch { + claude(["mcp", "remove", "--scope", "user", KEY]) +} + +fn unusable(path: &Path, cause: &str) -> String { + format!( + "install-settings-conflict: {} {cause}; fix it by hand, then run baley install again", + path.display() + ) +} + +/// Decides from what a read of Claude Code's configuration file found, the +/// latest install record and this binary's entry. A file that is not a JSON +/// object, or whose `mcpServers` is present but not an object, is refused as +/// a settings conflict. With no `mcpServers.baley` entry the add is +/// requested. An entry equal to this binary's by [`same_server`] is +/// unchanged, since Claude Code stores keys of its own such as `type`. An +/// entry equal by that rule to the one the record holds is Baley's older +/// registration and is removed and added again. Any other entry is not +/// Baley's and is refused as an ownership conflict. +pub fn judge( + path: &Path, + state: &FileState, + latest: Option<&Value>, + executable: &Executable, +) -> Result { + let ours = entry(executable); + let document = match state { + FileState::Absent => return Ok(Step::Run(vec![add_launch(&ours)])), + FileState::Fault(fault) => return Err(unusable(path, &fault.to_string())), + FileState::Bytes(bytes) => { + placed::document(bytes).map_err(|fault| unusable(path, &fault.to_string()))? + } + }; + let servers = match document.get("mcpServers") { + None => return Ok(Step::Run(vec![add_launch(&ours)])), + Some(Value::Object(servers)) => servers, + Some(_) => { + return Err(unusable( + path, + "holds mcpServers that is not a JSON object, so no entry can be judged in it", + )); + } + }; + let Some(found) = servers.get(KEY) else { + return Ok(Step::Run(vec![add_launch(&ours)])); + }; + if same_server(found, &ours) { + return Ok(Step::Unchanged); + } + let recorded = latest.and_then(|latest| latest.pointer("/registered/registration/entry")); + if recorded.is_some_and(|recorded| same_server(found, recorded)) { + return Ok(Step::Run(vec![remove_launch(), add_launch(&ours)])); + } + Err(format!( + "install-ownership-conflict: mcpServers.baley in {} is not an entry Baley wrote: {found}; remove it with `claude mcp remove --scope user baley` if it is yours to remove, then run `baley install` again", + path.display() + )) +} + +#[cfg(test)] +mod tests { + use std::path::Path; + use std::time::Duration; + + use serde_json::{Value, json}; + + use super::*; + use crate::host_artifacts::executable::Executable; + use crate::host_doctor::placed::{Fault, FileState}; + use crate::process::{Launch, StdioPlan, Stream, stdio_plan}; + + const PATH: &str = "/home/o/.claude.json"; + const EXECUTABLE: &str = "/home/o/.local/bin/baley"; + + fn executable() -> Executable { + Executable::new(EXECUTABLE).unwrap() + } + + fn file(document: Value) -> FileState { + FileState::Bytes(serde_json::to_vec(&document).unwrap()) + } + + fn older() -> Value { + json!({"command": EXECUTABLE, "args": ["serve", "--old"]}) + } + + fn record(entry: &Value) -> Value { + json!({"registered": {"registration": {"path": PATH, "entry": entry}}}) + } + + fn decide(state: &FileState, latest: Option<&Value>) -> Result { + judge(Path::new(PATH), state, latest, &executable()) + } + + fn launches_of(step: Result) -> Vec { + match step.expect("no refusal") { + Step::Run(launches) => launches, + Step::Unchanged => panic!("a launch was expected"), + } + } + + fn args(launch: &Launch) -> Vec<&str> { + launch + .args + .iter() + .map(|arg| arg.to_str().unwrap()) + .collect() + } + + fn add_args() -> [&'static str; 5] { + ["mcp", "add-json", "--scope", "user", "baley"] + } + + #[test] + fn a_registration_off_user_scope_or_without_the_stable_path_is_caught() { + let launches = launches_of(decide(&FileState::Absent, None)); + + assert_eq!(launches.len(), 1); + let launch = &launches[0]; + assert_eq!(launch.program, "claude"); + let arguments = args(launch); + assert_eq!(arguments.len(), 6, "{arguments:?}"); + assert_eq!(arguments[..5], add_args()); + let entry: Value = serde_json::from_str(arguments[5]).expect("the sixth is JSON"); + assert_eq!(entry, json!({"command": EXECUTABLE, "args": ["serve"]})); + } + + #[test] + fn a_registration_replaced_without_record_evidence_is_caught() { + let remove = ["mcp", "remove", "--scope", "user", "baley"]; + let recorded = record(&older()); + + let held = file(json!({"mcpServers": {"baley": older()}})); + let launches = launches_of(decide(&held, Some(&recorded))); + assert_eq!(launches.len(), 2); + assert_eq!(args(&launches[0]), remove); + assert_eq!(args(&launches[1])[..5], add_args()); + + let refusal = decide(&held, None).expect_err("no record, no replacement"); + assert!( + refusal.starts_with("install-ownership-conflict: "), + "{refusal}" + ); + assert!(refusal.contains("mcpServers.baley"), "{refusal}"); + assert!(refusal.contains(PATH), "{refusal}"); + + let stored = file(json!({"mcpServers": {"baley": { + "type": "stdio", + "command": EXECUTABLE, + "args": ["serve", "--old"], + "env": {}, + }}})); + let launches = launches_of(decide(&stored, Some(&recorded))); + assert_eq!(launches.len(), 2); + assert_eq!(args(&launches[0]), remove); + assert_eq!(args(&launches[1])[..5], add_args()); + } + + #[test] + fn an_equal_registration_registered_again_is_caught() { + let stored = |env: Value| { + file(json!({"mcpServers": {"baley": { + "type": "stdio", + "command": EXECUTABLE, + "args": ["serve"], + "env": env, + }}})) + }; + + assert_eq!(decide(&stored(json!({})), None), Ok(Step::Unchanged)); + + let refusal = decide(&stored(json!({"BALEY_HOME": "/elsewhere"})), None) + .expect_err("an entry that sets environment entries is not Baley's"); + assert!( + refusal.starts_with("install-ownership-conflict: "), + "{refusal}" + ); + assert!(refusal.contains("mcpServers.baley"), "{refusal}"); + assert!(refusal.contains(PATH), "{refusal}"); + } + + #[test] + fn a_claude_json_that_cannot_be_read_taken_as_no_registration_is_caught() { + for state in [ + FileState::Bytes(br#"{"mcpServers":"#.to_vec()), + FileState::Fault(Fault::NotRegular), + ] { + let refusal = decide(&state, None).expect_err("an unreadable file is refused"); + assert!( + refusal.starts_with("install-settings-conflict: "), + "{refusal}" + ); + assert!(refusal.contains(PATH), "{refusal}"); + } + + let launches = launches_of(decide(&file(json!({"projects": {}})), None)); + assert_eq!(args(&launches[0])[..5], add_args()); + + for servers in [json!([]), json!("x")] { + let refusal = decide(&file(json!({"mcpServers": servers})), None) + .expect_err("a malformed mcpServers is refused"); + assert!( + refusal.starts_with("install-settings-conflict: "), + "{refusal}" + ); + assert!(refusal.contains(PATH), "{refusal}"); + assert!(refusal.contains("mcpServers"), "{refusal}"); + } + } + + #[test] + fn a_registration_launch_that_could_hang_or_flood_is_caught() { + let held = file(json!({"mcpServers": {"baley": older()}})); + let recorded = record(&older()); + for launches in [ + launches_of(decide(&FileState::Absent, None)), + launches_of(decide(&held, Some(&recorded))), + ] { + for launch in &launches { + assert!(launch.own_group); + assert_eq!(launch.timeout, Some(Duration::from_secs(30))); + assert_eq!(launch.limit, 64 * 1024); + assert!(launch.env.is_empty()); + assert_eq!( + stdio_plan(launch), + StdioPlan { + stdin: Stream::Null, + stdout: Stream::Piped, + stderr: Stream::Piped, + } + ); + } + } + } +} From 059f7901a31c9f55fb373f7d2019450c92db747b Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 12:31:14 -0400 Subject: [PATCH 16/35] feat(install): run the registration after every file is written claude mcp add-json refuses a name that exists, so replacing Baley's older entry is a remove then an add, and a remove deletes what is there. The file is read and judged again just before it, so an entry the owner put there in the meantime is never removed. A failed claude command leaves the install partial with the command to run by hand, and the record keeps the older entry while the file still holds it. --- crates/baley/src/install/apply.rs | 96 ++++++- crates/baley/src/install/command.rs | 8 +- crates/baley/src/install/mod.rs | 7 +- crates/baley/src/install/plan.rs | 338 ++++++++++++++++++++++- crates/baley/src/install/receipt.rs | 89 +++++- crates/baley/src/install/registration.rs | 205 +++++++++++++- 6 files changed, 719 insertions(+), 24 deletions(-) diff --git a/crates/baley/src/install/apply.rs b/crates/baley/src/install/apply.rs index a13877ea..e29757d7 100644 --- a/crates/baley/src/install/apply.rs +++ b/crates/baley/src/install/apply.rs @@ -1,15 +1,49 @@ -//! Planned stub writes through digest-checked replacement. The plan owns -//! the permission to write and interprets the returned observations. +//! Planned file writes through digest-checked replacement, then the +//! registration through Claude Code's own command. The plan owns the +//! permission to act and interprets the returned observations. use std::fs; +use std::path::Path; +use serde_json::Value; + +use crate::host_artifacts::executable::Executable; +use crate::host_doctor::placed; +use crate::process::{Process, System}; use crate::replace::{self, Failure}; use super::plan::Plan; +use super::registration::{self, Attempt, Next, Registered, Step}; + +/// What applying a plan did: each file write, in plan order, and the +/// registration step. +pub(crate) struct Applying { + /// One result per write reached, in plan order. + pub writes: Vec>, + /// What became of the registration. + pub attempt: Attempt, +} -/// Applies writes in order with default folder modes, stopping at the first -/// failure. A failed sync is returned too, since the rename already happened. -pub(crate) fn run(plan: &Plan) -> Vec> { +/// Applies the file writes in order with default folder modes, stopping at the +/// first failure. A failed sync is returned too, since the rename already +/// happened. The registration launches run after every file is written, and +/// only when every write was reached. +pub(crate) fn run( + plan: &Plan, + latest: Option<&Value>, + registration_file: &Path, + executable: &Executable, +) -> Applying { + let writes = write_files(plan); + let reached = writes.len() == plan.writes.len() + && writes + .iter() + .all(|result| matches!(result, Ok(()) | Err(Failure::Unsynced { .. }))); + let attempt = register(plan, reached, latest, registration_file, executable); + Applying { writes, attempt } +} + +fn write_files(plan: &Plan) -> Vec> { let mut results = Vec::new(); for write in &plan.writes { let result = fs::create_dir_all( @@ -31,3 +65,55 @@ pub(crate) fn run(plan: &Plan) -> Vec> { } results } + +fn register( + plan: &Plan, + reached: bool, + latest: Option<&Value>, + file: &Path, + executable: &Executable, +) -> Attempt { + let mut attempt = Attempt { + seen: plan.registration_seen.clone(), + removed: false, + result: Registered::Unchanged, + }; + let Step::Run(launches) = &plan.registration else { + return attempt; + }; + if !reached { + attempt.result = Registered::NotReached("an earlier write failed".into()); + return attempt; + } + let mut launches = launches.clone(); + if launches.len() == 2 { + // A remove deletes an entry, so it never runs on evidence read before + // the file writes: the file is read again and judged again first. + let again = registration::recheck(file, &placed::read(file), latest, executable); + attempt.seen = again.seen; + match again.next { + Next::Run(next) => launches = next, + Next::Unchanged => return attempt, + Next::Refused(cause) => { + attempt.result = Registered::Refused(cause); + return attempt; + } + } + } + let replacing = launches.len() == 2; + for (index, launch) in launches.iter().enumerate() { + match registration::interpret(file, System.run(launch)) { + Ok(()) => attempt.removed |= replacing && index == 0, + Err(cause) => { + attempt.result = Registered::Failed(cause); + return attempt; + } + } + } + attempt.result = if replacing { + Registered::Replaced + } else { + Registered::Registered + }; + attempt +} diff --git a/crates/baley/src/install/command.rs b/crates/baley/src/install/command.rs index 97410678..35e0ea7a 100644 --- a/crates/baley/src/install/command.rs +++ b/crates/baley/src/install/command.rs @@ -155,7 +155,13 @@ fn install(args: InstallArgs) -> Result { observations: &observations, prerequisites: &prerequisites, })?; - let applied = plan::applied(&plan, &apply::run(&plan)); + let applying = apply::run( + &plan, + latest_payload, + &installed.registration_file, + installed.placements.executable(), + ); + let applied = plan::applied(&plan, &applying.writes, &applying.attempt); let seed = models::seed(&store, new_request_id(), &SystemClock::now()); let schema = Schema::standard(); diff --git a/crates/baley/src/install/mod.rs b/crates/baley/src/install/mod.rs index a4f77e93..fd6891b4 100644 --- a/crates/baley/src/install/mod.rs +++ b/crates/baley/src/install/mod.rs @@ -3,10 +3,11 @@ //! any replacement. Gathering is kept apart from judging. //! One install runs at a time: the command holds an exclusive lock in Baley's //! own home from reading the latest record to recording the result. -//! Stubs are applied and recorded here. Phase 15 plan 2 adds the registration, -//! hook and settings writes through the same plan and ownership facts. +//! The stubs and the settings file (the hook, the deny rules and the sandbox +//! block) are written and the registration is requested from Claude Code, all +//! from one plan and recorded with the same ownership facts. -/// Planned stub writes and their filesystem observations. +/// Planned file writes, then the registration launches, applied in order. pub mod apply; /// Command arguments, host selection and installation gathering. pub mod command; diff --git a/crates/baley/src/install/plan.rs b/crates/baley/src/install/plan.rs index 213c9912..00e65fff 100644 --- a/crates/baley/src/install/plan.rs +++ b/crates/baley/src/install/plan.rs @@ -1,8 +1,7 @@ //! Installation writes decided from supplied placements, file observations -//! and the latest ownership record. Any stub or settings conflict refuses the -//! whole plan. Applied outcomes retain older ownership evidence when a write -//! cannot finish. -//! The registration decision joins this judge in phase 15 plan 2. +//! and the latest ownership record. Any stub, settings or registration +//! conflict refuses the whole plan. Applied outcomes retain older ownership +//! evidence when a write or launch cannot finish. use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -12,6 +11,7 @@ use baley_store::StoreError; use serde_json::Value; use crate::folders::Folders; +use crate::host_artifacts::compose::same_server; use crate::host_artifacts::placement::{Artifact, PlacementMap}; use crate::host_doctor::placed::{Fault, FileState}; use crate::host_doctor::prerequisites::Judged; @@ -20,7 +20,7 @@ use crate::replace::Failure; use super::event; use super::receipt::{ArtifactOutcome, Notes}; -use super::registration::{self, Step}; +use super::registration::{self, Attempt, Registered, Step}; use super::settings::{self, Input}; use super::stubs::{self, Decision}; @@ -75,6 +75,9 @@ pub struct Plan { /// The registration decision: the `claude` commands to run after every /// file is written, or that the entry is Baley's already. pub registration: Step, + /// The `mcpServers.baley` entry the first read of the registration file + /// held, which the record keeps if no launch changes it. + pub registration_seen: Option, } fn unobserved() -> Observation { @@ -161,9 +164,10 @@ pub(crate) fn judge(gathered: &Gathered<'_>) -> Result { .find(|file| file.artifact == Artifact::Registration) .map(|file| file.path.to_path_buf()) .expect("install places the registration file"); + let registration_read = observed(®istration_path).state; let registration = match registration::judge( ®istration_path, - &observed(®istration_path).state, + ®istration_read, gathered.latest, placements.executable(), ) { @@ -209,6 +213,7 @@ pub(crate) fn judge(gathered: &Gathered<'_>) -> Result { outcomes, settings: decision, registration, + registration_seen: registration::held(®istration_read), }) } @@ -221,15 +226,23 @@ pub struct Applied { pub failure: Option, /// The gaps and replaced values the receipt reports. pub notes: Notes, + /// The `mcpServers.baley` entry the latest read of the registration file + /// held. + pub registration_seen: Option, + /// Whether a remove launch succeeded after that read. + pub registration_removed: bool, } -/// Interprets write observations in plan order. A failed folder sync still -/// placed the bytes, but every failure stops subsequent writes. -pub(crate) fn applied(plan: &Plan, results: &[Result<(), Failure>]) -> Applied { +/// Interprets write observations in plan order, then the registration step. +/// A failed folder sync still placed the bytes, but every failure stops +/// subsequent writes. +pub(crate) fn applied(plan: &Plan, results: &[Result<(), Failure>], attempt: &Attempt) -> Applied { let mut applied = Applied { outcomes: plan.outcomes.clone(), failure: None, notes: Notes::from_settings(&plan.settings), + registration_seen: attempt.seen.clone(), + registration_removed: attempt.removed, }; for (index, write) in plan.writes.iter().enumerate() { let result = results.get(index); @@ -271,6 +284,27 @@ pub(crate) fn applied(plan: &Plan, results: &[Result<(), Failure>]) -> Applied { } } } + let registration = match &attempt.result { + Registered::Unchanged => ArtifactOutcome::Unchanged, + Registered::Registered => ArtifactOutcome::Written, + Registered::Replaced => ArtifactOutcome::Replaced, + Registered::Refused(cause) => ArtifactOutcome::Failed(cause.clone()), + Registered::Failed(cause) => { + applied.notes.registration_by_hand = true; + ArtifactOutcome::Failed(cause.clone()) + } + Registered::NotReached(cause) => ArtifactOutcome::Failed(match &applied.failure { + Some(failure) => format!("{cause}: {failure}"), + None => cause.clone(), + }), + }; + if let Some((_, state)) = applied + .outcomes + .iter_mut() + .find(|(placed, _)| *placed == Artifact::Registration) + { + *state = registration; + } applied } @@ -294,8 +328,8 @@ pub(crate) fn complete( /// Builds ownership facts from applied outcomes without reading any path. /// Failed or unreached writes keep that identity's previous entry, so the -/// next install can still recognize an older stub, hook or deny entry. -/// The registration joins in phase 15 plan 2 at this same seam. +/// next install can still recognize an older stub, hook, deny entry or +/// registration while the file still holds it. pub(crate) fn facts( version: &str, placements: &PlacementMap, @@ -337,13 +371,14 @@ pub(crate) fn facts( .iter() .any(|(artifact, outcome)| *artifact == Artifact::Settings && outcome.is_owned()); let (hook, sandbox) = settings::recorded(settings, settings_owned, latest); + let registration = recorded_registration(placements, latest, applied); event::Facts { host: Host::ClaudeCode, binary_version: version.into(), binary_path: placements.executable().as_str().into(), complete: applied.failure.is_none() && complete(placements, &applied.outcomes, &applied.notes, seed), - registration: None, + registration, hook, stubs, sandbox, @@ -351,6 +386,49 @@ pub(crate) fn facts( } } +/// The registration to record. With the entry Baley's after the run, it is +/// this binary's. Otherwise the file still holds what the latest read saw, so +/// it is this binary's entry when that read held it, or the latest record's +/// entry while that read holds it by the registration rule and no remove +/// launch succeeded since. Dropping it would make the next install refuse +/// Baley's own older registration as foreign. +fn recorded_registration( + placements: &PlacementMap, + latest: Option<&Value>, + applied: &Applied, +) -> Option { + let path = placements + .expected_files() + .into_iter() + .find(|file| file.artifact == Artifact::Registration)? + .path + .to_str() + .expect("placements are UTF-8") + .to_owned(); + let ours = registration::entry(placements.executable()); + let owned = applied + .outcomes + .iter() + .any(|(artifact, outcome)| *artifact == Artifact::Registration && outcome.is_owned()); + if owned { + return Some(event::Registration { path, entry: ours }); + } + let seen = applied.registration_seen.as_ref()?; + if same_server(seen, &ours) { + return Some(event::Registration { path, entry: ours }); + } + let older = latest?.pointer("/registered/registration")?; + let entry = older.get("entry")?; + (!applied.registration_removed && same_server(seen, entry)).then(|| event::Registration { + path: older + .get("path") + .and_then(Value::as_str) + .unwrap_or(&path) + .to_owned(), + entry: entry.clone(), + }) +} + #[cfg(test)] mod tests { use serde_json::json; @@ -358,6 +436,7 @@ mod tests { use super::*; use crate::host_artifacts::installed::Installed; use crate::host_artifacts::stubs; + use crate::host_doctor::prerequisites::Judged; use crate::install::fixtures::{ complete_settings, folders, four_spaces, hook_item, installed, linux, }; @@ -442,6 +521,8 @@ mod tests { ], failure: None, notes: Notes::default(), + registration_seen: None, + registration_removed: false, }; let facts = facts( "0.2.0", @@ -523,6 +604,8 @@ mod tests { outcomes, failure: failure.map(str::to_owned), notes: Notes::default(), + registration_seen: None, + registration_removed: false, }; facts( "0.2.0", @@ -674,4 +757,235 @@ mod tests { assert!(refusal.error); } } + fn gathered_for<'a>( + installed: &'a Installed, + latest: Option<&'a Value>, + observations: &'a BTreeMap, + prerequisites: &'a Judged, + folders: &'a Folders, + ) -> Gathered<'a> { + Gathered { + placements: &installed.placements, + folders, + latest, + observations, + prerequisites, + } + } + + #[test] + fn an_executable_reference_to_anything_but_the_stable_path_is_caught() { + let installed = installed(); + let observations = all_absent(&installed); + let (folders, prerequisites) = (folders(), linux()); + + let plan = judge(&gathered_for( + &installed, + None, + &observations, + &prerequisites, + &folders, + )) + .expect("an empty machine plans"); + + let document = &plan.settings.document; + assert_eq!( + document["hooks"]["PreToolUse"][0]["hooks"][0]["command"], + "'/home/o/.local/bin/baley' guard" + ); + let Step::Run(launches) = &plan.registration else { + panic!("the registration is requested"); + }; + assert_eq!(launches.len(), 1); + let arguments: Vec<&str> = launches[0] + .args + .iter() + .map(|arg| arg.to_str().unwrap()) + .collect(); + let entry: Value = serde_json::from_str(arguments[5]).unwrap(); + assert_eq!(entry["command"], "/home/o/.local/bin/baley"); + assert!( + document["permissions"]["deny"] + .as_array() + .unwrap() + .contains(&json!("Edit(//home/o/.local/bin/baley)")) + ); + assert!( + document["sandbox"]["filesystem"]["denyWrite"] + .as_array() + .unwrap() + .contains(&json!("/home/o/.local/bin/baley")) + ); + let mut named = Vec::new(); + collect_versions(document, &mut named); + named.extend( + arguments + .iter() + .filter(|argument| argument.contains("/versions")) + .map(|argument| (*argument).to_owned()), + ); + named.sort(); + assert_eq!( + named, + [ + "/home/o/.local/lib/crenshawdev/baley/versions", + "Edit(//home/o/.local/lib/crenshawdev/baley/versions/**)", + ] + ); + } + + /// Every string in the document that names the versions folder. + fn collect_versions(value: &Value, found: &mut Vec) { + match value { + Value::String(text) if text.contains("/crenshawdev/baley/versions") => { + found.push(text.clone()); + } + Value::Array(items) => items.iter().for_each(|item| collect_versions(item, found)), + Value::Object(map) => map.values().for_each(|item| collect_versions(item, found)), + _ => {} + } + } + + #[test] + fn a_completed_installation_rewritten_by_a_second_install_is_caught() { + let installed = installed(); + let manifest = stubs::manifest(&stubs::front_doors()).unwrap(); + let mut observations = all_absent(&installed); + let seen = |bytes: Vec| Observation { + state: FileState::Bytes(bytes), + symbolic_link: false, + }; + observations.insert( + PathBuf::from(SETTINGS), + seen(four_spaces(&complete_settings())), + ); + for entry in &manifest { + observations.insert( + PathBuf::from(format!( + "/home/o/.claude/skills/{}/SKILL.md", + entry.identity + )), + seen(entry.bytes.clone()), + ); + } + let stored = json!({"mcpServers": {"baley": { + "type": "stdio", + "command": "/home/o/.local/bin/baley", + "args": ["serve"], + "env": {}, + }}}); + observations.insert( + PathBuf::from("/home/o/.claude.json"), + seen(serde_json::to_vec(&stored).unwrap()), + ); + let record = json!({ + "registered": { + "registration": { + "path": "/home/o/.claude.json", + "entry": {"command": "/home/o/.local/bin/baley", "args": ["serve"]}, + }, + "hook": {"path": SETTINGS, "item": hook_item()}, + }, + "stubs": manifest.iter().map(|entry| json!({ + "identity": entry.identity, + "path": format!("/home/o/.claude/skills/{}/SKILL.md", entry.identity), + "sha256": entry.digest, + })).collect::>(), + }); + let (folders, prerequisites) = (folders(), linux()); + + let plan = judge(&gathered_for( + &installed, + Some(&record), + &observations, + &prerequisites, + &folders, + )) + .expect("a completed installation plans"); + + assert!(plan.writes.is_empty(), "{:?}", plan.writes); + assert_eq!(plan.registration, Step::Unchanged); + assert_eq!(plan.outcomes.len(), 5); + for (artifact, outcome) in &plan.outcomes { + assert_eq!(*outcome, ArtifactOutcome::Unchanged, "{artifact}"); + } + } + + #[test] + fn a_failed_seed_recorded_complete_or_the_registration_misrecorded_after_a_failed_launch_is_caught() + { + let installed = installed(); + let settings = decision(&complete_settings(), None); + let outcomes = |registration: ArtifactOutcome| { + vec![ + ( + Artifact::Stub("bal-capture".into()), + ArtifactOutcome::Written, + ), + (Artifact::Stub("bal-help".into()), ArtifactOutcome::Written), + (Artifact::Registration, registration), + (Artifact::Hook, ArtifactOutcome::Written), + (Artifact::Settings, ArtifactOutcome::Written), + ] + }; + let build = |latest: Option<&Value>, applied: Applied, seed: &Result| { + facts( + "0.2.0", + &installed.placements, + latest, + &settings, + &applied, + seed, + updates(), + ) + }; + let applied = |outcomes, seen: Option, removed| Applied { + outcomes, + failure: None, + notes: Notes::default(), + registration_seen: seen, + registration_removed: removed, + }; + + // Everything is Baley's: complete follows the catalog seed. + let done = || applied(outcomes(ArtifactOutcome::Written), None, false); + let busy: Result = Err(StoreError::Busy); + assert!(!build(None, done(), &busy).complete); + assert!(build(None, done(), &Ok(true)).complete); + + // A failed remove leaves Baley's older entry in the file. + let older = json!({"command": "/home/o/.local/bin/baley", "args": ["serve", "--old"]}); + let record = json!({"registered": {"registration": { + "path": "/home/o/.claude.json", + "entry": older, + }}}); + let stored = json!({ + "type": "stdio", + "command": "/home/o/.local/bin/baley", + "args": ["serve", "--old"], + "env": {}, + }); + let failed = + ArtifactOutcome::Failed("not-writable: /home/o/.claude.json: exit status 1".into()); + let facts = build( + Some(&record), + applied(outcomes(failed.clone()), Some(stored.clone()), false), + &Ok(true), + ); + let kept = facts + .registration + .expect("the older entry is still in the file"); + assert_eq!(kept.path, "/home/o/.claude.json"); + assert_eq!(kept.entry, older); + assert!(!facts.complete); + + // A failed add after a removed entry leaves nothing to record. + let facts = build( + Some(&record), + applied(outcomes(failed), Some(stored), true), + &Ok(true), + ); + assert_eq!(facts.registration, None); + assert!(!facts.complete); + } } diff --git a/crates/baley/src/install/receipt.rs b/crates/baley/src/install/receipt.rs index f313978f..f584eaab 100644 --- a/crates/baley/src/install/receipt.rs +++ b/crates/baley/src/install/receipt.rs @@ -9,6 +9,7 @@ use baley_store::StoreError; use crate::host_artifacts::placement::{Artifact, PlacementMap}; use crate::ledger::display::{self, Render}; +use super::registration; use super::settings::{self, Replaced}; /// What the run left at an artifact's placed path. @@ -42,6 +43,9 @@ pub struct Notes { pub replaced: Vec, /// Why the sandbox block was left out of the settings file, if it was. pub sandbox_held_back: Option, + /// Whether a `claude` command failed, so the owner is given the command + /// to run by hand. + pub registration_by_hand: bool, } impl Notes { @@ -51,6 +55,7 @@ impl Notes { gaps: decision.gaps.clone(), replaced: decision.replaced.clone(), sandbox_held_back: decision.held_back.clone(), + registration_by_hand: false, } } } @@ -84,8 +89,19 @@ pub(crate) fn render( { "written without the sandbox block".into() } + ArtifactOutcome::Written if file.artifact == Artifact::Registration => { + "registered".into() + } ArtifactOutcome::Written => "written".into(), ArtifactOutcome::Replaced => "replaced".into(), + ArtifactOutcome::Failed(cause) + if file.artifact == Artifact::Registration && notes.registration_by_hand => + { + format!( + "not written: {cause}; run `{}` by hand to finish it", + registration::hand_command(®istration::entry(placements.executable())) + ) + } ArtifactOutcome::Unchanged => "unchanged".into(), ArtifactOutcome::Failed(cause) => format!("not written: {cause}"), }; @@ -232,6 +248,8 @@ mod tests { ], failure: None, notes: Notes::default(), + registration_seen: None, + registration_removed: false, }; let receipt = render_applied( "0.2.0", @@ -382,6 +400,7 @@ mod tests { use crate::host_doctor::placed::FileState; use crate::install::fixtures::{folders, prerequisites}; use crate::install::plan::{self, Gathered, Observation}; + use crate::install::registration::{Attempt, Registered}; let installed = installed(); let owner = json!({"sandbox": { @@ -422,7 +441,12 @@ mod tests { .expect("a gap is not a refusal"); let written: Vec> = plan.writes.iter().map(|_| Ok(())).collect(); - let applied = plan::applied(&plan, &written); + let registered = Attempt { + seen: None, + removed: false, + result: Registered::Registered, + }; + let applied = plan::applied(&plan, &written, ®istered); let receipt = render_applied( "0.2.0", @@ -555,4 +579,67 @@ mod tests { .any(|line| precedence.iter().all(|part| line.contains(part))) ); } + #[test] + fn a_registration_failure_without_the_command_to_run_is_caught() { + let cause = + "not-writable: /home/o/.claude.json: could not start claude: No such file or directory"; + let failed = |home: &str| { + let manifest = stubs::manifest(&stubs::front_doors()).unwrap(); + let env = Environment { + home: Some(home.into()), + ..Environment::default() + }; + let installed = installed::resolve(&env, None, &manifest).unwrap(); + let mut outcomes = outcomes(ArtifactOutcome::Written); + outcomes[2].1 = ArtifactOutcome::Failed(cause.into()); + let applied = super::super::plan::Applied { + outcomes, + failure: None, + notes: Notes { + registration_by_hand: true, + ..Notes::default() + }, + registration_seen: None, + registration_removed: false, + }; + render_applied( + "0.2.0", + installed.layout.stable_path(), + &installed.placements, + &applied, + &Ok(true), + &Ok(true), + ) + }; + + let receipt = failed("/home/o"); + assert_eq!(receipt.lines[0], "install outcome: partial"); + assert_eq!(receipt.code, 1); + let line = receipt + .lines + .iter() + .find(|line| line.starts_with("registration at ")) + .expect("a registration line"); + assert!(line.contains(": not written: "), "{line}"); + assert!(line.contains("could not start claude"), "{line}"); + assert!( + line.contains( + r#"claude mcp add-json --scope user baley '{"command":"/home/o/.local/bin/baley","args":["serve"]}'"# + ), + "{line}" + ); + + let receipt = failed("/home/o'connor"); + let line = receipt + .lines + .iter() + .find(|line| line.starts_with("registration at ")) + .expect("a registration line"); + assert!( + line.contains( + r#"claude mcp add-json --scope user baley '{"command":"/home/o'\''connor/.local/bin/baley","args":["serve"]}'"# + ), + "{line}" + ); + } } diff --git a/crates/baley/src/install/registration.rs b/crates/baley/src/install/registration.rs index 6a548908..850b7e3c 100644 --- a/crates/baley/src/install/registration.rs +++ b/crates/baley/src/install/registration.rs @@ -4,6 +4,7 @@ //! `claude mcp` commands that place this binary's entry. The decision is pure: //! it returns launches and never runs one. +use std::io; use std::path::Path; use std::time::Duration; @@ -13,7 +14,7 @@ use crate::host_artifacts::compose::same_server; use crate::host_artifacts::executable::Executable; use crate::host_artifacts::registration::{self, KEY}; use crate::host_doctor::placed::{self, FileState}; -use crate::process::Launch; +use crate::process::{Launch, Output}; /// How long one `claude` command may run. const TIMEOUT: Duration = Duration::from_secs(30); @@ -111,8 +112,128 @@ pub fn judge( )) } +/// The entry a read found under `mcpServers.baley`, when the file reads as a +/// JSON object that holds one. +pub fn held(state: &FileState) -> Option { + let FileState::Bytes(bytes) = state else { + return None; + }; + placed::document(bytes) + .ok()? + .pointer(&format!("/mcpServers/{KEY}")) + .cloned() +} + +/// What the registration step did during a run. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Registered { + /// The entry was Baley's already, so nothing ran. + Unchanged, + /// `claude` added this binary's entry. + Registered, + /// `claude` removed Baley's older entry and added this binary's. + Replaced, + /// The file read just before a remove no longer showed an entry Baley + /// may replace, so nothing ran. The refusal's own text. + Refused(String), + /// A `claude` command failed, as a `not-writable` line naming the file. + Failed(String), + /// The step was not reached, for the cause. + NotReached(String), +} + +/// The registration step's result with the evidence the record needs. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Attempt { + /// The `mcpServers.baley` entry the latest read of the file held: the + /// read before the file writes, or the read just before the remove. + pub seen: Option, + /// Whether a remove launch succeeded after that read. + pub removed: bool, + /// What the step did. + pub result: Registered, +} + +/// Reads one launch's result. Exit status 0 is done. Another exit status +/// gives its first line of standard error, or the status when that is empty. +/// A signal, a timeout and a start error each give their cause. A failure is +/// a `not-writable` line naming the registration file. +pub fn interpret(path: &Path, result: io::Result) -> Result<(), String> { + let cause = match result { + Ok(output) if output.success() => return Ok(()), + Ok(output) => match (output.code(), output.signal()) { + (Some(code), _) => { + let stderr = String::from_utf8_lossy(&output.stderr); + match stderr.lines().next() { + Some(first) if !first.trim().is_empty() => first.trim().to_owned(), + _ => format!("exit status {code}"), + } + } + (None, Some(signal)) => format!("signal {signal}"), + (None, None) => "process ended without an exit status".to_owned(), + }, + Err(error) if error.kind() == io::ErrorKind::TimedOut => format!("timed out: {error}"), + Err(error) => format!("could not start claude: {error}"), + }; + Err(format!("not-writable: {}: {cause}", path.display())) +} + +/// What to do after the file is read again just before a remove launch. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Next { + /// Run these launches. + Run(Vec), + /// The entry is this binary's now, so nothing runs. + Unchanged, + /// The entry is not one Baley may replace, so nothing runs. + Refused(String), +} + +/// The second look and the entry it saw. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Recheck { + /// The entry the second read held. + pub seen: Option, + /// The new answer, from the same decision the plan used. + pub next: Next, +} + +/// Judges the second read. A remove deletes an entry, so it never runs on +/// evidence read before the file writes: the entry may have changed since. +pub fn recheck( + path: &Path, + reread: &FileState, + latest: Option<&Value>, + executable: &Executable, +) -> Recheck { + let next = match judge(path, reread, latest, executable) { + Ok(Step::Run(launches)) => Next::Run(launches), + Ok(Step::Unchanged) => Next::Unchanged, + Err(refusal) => Next::Refused(refusal), + }; + Recheck { + seen: held(reread), + next, + } +} + +fn shell_quote(text: &str) -> String { + format!("'{}'", text.replace('\'', r"'\''")) +} + +/// The command an owner can run by hand to place the entry, with the entry +/// single-quoted for a POSIX shell. +pub fn hand_command(entry: &Value) -> String { + let compact = serde_json::to_string(entry).expect("a JSON entry serializes"); + format!( + "claude mcp add-json --scope user {KEY} {}", + shell_quote(&compact) + ) +} + #[cfg(test)] mod tests { + use std::io; use std::path::Path; use std::time::Duration; @@ -121,7 +242,7 @@ mod tests { use super::*; use crate::host_artifacts::executable::Executable; use crate::host_doctor::placed::{Fault, FileState}; - use crate::process::{Launch, StdioPlan, Stream, stdio_plan}; + use crate::process::{Launch, Output, StdioPlan, Stream, stdio_plan}; const PATH: &str = "/home/o/.claude.json"; const EXECUTABLE: &str = "/home/o/.local/bin/baley"; @@ -286,4 +407,84 @@ mod tests { } } } + #[test] + fn a_failed_claude_command_reported_as_registered_is_caught() { + let path = Path::new(PATH); + assert_eq!(interpret(path, Ok(Output::exited(0, "", ""))), Ok(())); + + for (result, cause) in [ + ( + Ok(Output::exited( + 1, + "", + "MCP server baley already exists in user config\nsecond line\n", + )), + "MCP server baley already exists in user config", + ), + (Ok(Output::exited(2, "", "")), "exit status 2"), + (Ok(Output::signaled(9)), "signal 9"), + ( + Err(io::Error::new(io::ErrorKind::TimedOut, "deadline reached")), + "timed out", + ), + ( + Err(io::Error::new( + io::ErrorKind::NotFound, + "No such file or directory", + )), + "could not start claude: No such file or directory", + ), + ] { + let line = interpret(path, result).expect_err("a failed command is a failure"); + assert!(line.starts_with("not-writable: "), "{line}"); + assert!(line.contains(PATH), "{line}"); + assert!(line.contains(cause), "{line}"); + assert!(!line.contains("second line"), "{line}"); + } + } + + #[test] + fn a_registration_removed_on_stale_ownership_evidence_is_caught() { + let latest = record(&older()); + let look = |document: Value| { + recheck( + Path::new(PATH), + &file(document), + Some(&latest), + &executable(), + ) + }; + + let foreign = json!({"command": "/opt/other/baley", "args": ["serve"]}); + let first = look(json!({"mcpServers": {"baley": foreign}})); + assert_eq!(first.seen, Some(foreign)); + let Next::Refused(line) = first.next else { + panic!("a foreign entry must run nothing"); + }; + assert!(line.starts_with("install-ownership-conflict: "), "{line}"); + assert!(line.contains("mcpServers.baley"), "{line}"); + assert!(line.contains(PATH), "{line}"); + + let Next::Run(launches) = look(json!({"mcpServers": {"baley": older()}})).next else { + panic!("the recorded older entry is replaced"); + }; + assert_eq!(launches.len(), 2); + assert_eq!( + args(&launches[0]), + ["mcp", "remove", "--scope", "user", "baley"] + ); + assert_eq!(args(&launches[1])[..5], add_args()); + + let ours = json!({"command": EXECUTABLE, "args": ["serve"]}); + assert_eq!( + look(json!({"mcpServers": {"baley": ours}})).next, + Next::Unchanged + ); + + let Next::Run(launches) = look(json!({"mcpServers": {}})).next else { + panic!("an absent entry is added"); + }; + assert_eq!(launches.len(), 1); + assert_eq!(args(&launches[0])[..5], add_args()); + } } From 33410c92773404018b4f6811149ac9e08f53b944 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 12:36:36 -0400 Subject: [PATCH 17/35] feat(install): hold the hook and registration back from an empty stable path A development build has no binary at ~/.local/bin/baley, and wiring Claude Code to run that path would break every session. The doctor's own look at the executable decides, so a binary placed by hand or a link to a build counts. The stubs, deny rules and sandbox block run nothing and are written anyway. --- crates/baley/src/install/apply.rs | 4 + crates/baley/src/install/command.rs | 3 + crates/baley/src/install/mod.rs | 3 +- crates/baley/src/install/plan.rs | 127 ++++++++++++++++++++++- crates/baley/src/install/receipt.rs | 10 +- crates/baley/src/install/registration.rs | 2 + crates/baley/src/install/settings.rs | 13 +-- 7 files changed, 149 insertions(+), 13 deletions(-) diff --git a/crates/baley/src/install/apply.rs b/crates/baley/src/install/apply.rs index e29757d7..b6e55c33 100644 --- a/crates/baley/src/install/apply.rs +++ b/crates/baley/src/install/apply.rs @@ -78,6 +78,10 @@ fn register( removed: false, result: Registered::Unchanged, }; + if let Some(wiring) = &plan.wiring { + attempt.result = Registered::Withheld(wiring.cause.clone()); + return attempt; + } let Step::Run(launches) = &plan.registration else { return attempt; }; diff --git a/crates/baley/src/install/command.rs b/crates/baley/src/install/command.rs index 35e0ea7a..0938d454 100644 --- a/crates/baley/src/install/command.rs +++ b/crates/baley/src/install/command.rs @@ -148,12 +148,15 @@ fn install(args: InstallArgs) -> Result { std::env::var_os("PATH").as_deref(), std::env::consts::OS, )); + let stable_gap = + placed::observe_executable(Path::new(installed.placements.executable().as_str())); let plan = plan::judge(&plan::Gathered { placements: &installed.placements, folders: &folders, latest: latest_payload, observations: &observations, prerequisites: &prerequisites, + executable: stable_gap.as_ref(), })?; let applying = apply::run( &plan, diff --git a/crates/baley/src/install/mod.rs b/crates/baley/src/install/mod.rs index fd6891b4..f92e3ad1 100644 --- a/crates/baley/src/install/mod.rs +++ b/crates/baley/src/install/mod.rs @@ -5,7 +5,8 @@ //! own home from reading the latest record to recording the result. //! The stubs and the settings file (the hook, the deny rules and the sandbox //! block) are written and the registration is requested from Claude Code, all -//! from one plan and recorded with the same ownership facts. +//! from one plan and recorded with the same ownership facts. Nothing that runs +//! the stable path is written unless something runnable sits there. /// Planned file writes, then the registration launches, applied in order. pub mod apply; diff --git a/crates/baley/src/install/plan.rs b/crates/baley/src/install/plan.rs index 00e65fff..ada2de3d 100644 --- a/crates/baley/src/install/plan.rs +++ b/crates/baley/src/install/plan.rs @@ -13,7 +13,7 @@ use serde_json::Value; use crate::folders::Folders; use crate::host_artifacts::compose::same_server; use crate::host_artifacts::placement::{Artifact, PlacementMap}; -use crate::host_doctor::placed::{Fault, FileState}; +use crate::host_doctor::placed::{ExecutableGap, Fault, FileState}; use crate::host_doctor::prerequisites::Judged; use crate::ledger::display::Render; use crate::replace::Failure; @@ -46,6 +46,10 @@ pub struct Gathered<'a> { pub observations: &'a BTreeMap, /// The sandbox programs the platform needs, judged by the doctor's check. pub prerequisites: &'a Judged, + /// What is wrong at the stable path, or none when a runnable file sits + /// there. While something is wrong, the hook and the registration that + /// run it are not written. + pub executable: Option<&'a ExecutableGap>, } /// A file write permitted by the ownership decision. @@ -78,6 +82,18 @@ pub struct Plan { /// The `mcpServers.baley` entry the first read of the registration file /// held, which the record keeps if no launch changes it. pub registration_seen: Option, + /// Why the hook and the registration were held back, when nothing + /// runnable sits at the stable path. + pub wiring: Option, +} + +/// The hook and the registration held back from an empty stable path. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Wiring { + /// The stable path and what is wrong there, as the artifact lines say it. + pub cause: String, + /// The gap with its fix. + pub gap: String, } fn unobserved() -> Observation { @@ -150,6 +166,7 @@ pub(crate) fn judge(gathered: &Gathered<'_>) -> Result { placements, folders: gathered.folders, prerequisites: gathered.prerequisites, + wire: gathered.executable.is_none(), }) { Err(lines) => { conflicts.extend(lines); @@ -184,10 +201,23 @@ pub(crate) fn judge(gathered: &Gathered<'_>) -> Result { return Err(refusal); }; + let wiring = gathered.executable.map(|gap| { + let path = placements.executable().as_str(); + Wiring { + cause: format!("the stable path {path} {gap}"), + gap: format!( + "nothing runnable is at {path}: it {gap}, so the hook and the registration that run it were not written; run install.sh, or place the baley binary or a link to it at that path, then run baley install again" + ), + } + }); let mut writes = Vec::new(); match &decision.bytes { Some(bytes) => writes.push(Write { - artifacts: vec![Artifact::Hook, Artifact::Settings], + artifacts: if wiring.is_some() { + vec![Artifact::Settings] + } else { + vec![Artifact::Hook, Artifact::Settings] + }, path: settings_path, bytes: bytes.clone(), read_digest: decision.read_digest.clone(), @@ -208,12 +238,20 @@ pub(crate) fn judge(gathered: &Gathered<'_>) -> Result { } } } + if let Some(wiring) = &wiring { + for (artifact, outcome) in &mut outcomes { + if matches!(artifact, Artifact::Hook | Artifact::Registration) { + *outcome = ArtifactOutcome::Failed(wiring.cause.clone()); + } + } + } Ok(Plan { writes, outcomes, settings: decision, registration, registration_seen: registration::held(®istration_read), + wiring, }) } @@ -240,7 +278,10 @@ pub(crate) fn applied(plan: &Plan, results: &[Result<(), Failure>], attempt: &At let mut applied = Applied { outcomes: plan.outcomes.clone(), failure: None, - notes: Notes::from_settings(&plan.settings), + notes: Notes::from_settings( + &plan.settings, + plan.wiring.as_ref().map(|wiring| wiring.gap.as_str()), + ), registration_seen: attempt.seen.clone(), registration_removed: attempt.removed, }; @@ -293,6 +334,7 @@ pub(crate) fn applied(plan: &Plan, results: &[Result<(), Failure>], attempt: &At applied.notes.registration_by_hand = true; ArtifactOutcome::Failed(cause.clone()) } + Registered::Withheld(cause) => ArtifactOutcome::Failed(cause.clone()), Registered::NotReached(cause) => ArtifactOutcome::Failed(match &applied.failure { Some(failure) => format!("{cause}: {failure}"), None => cause.clone(), @@ -436,7 +478,6 @@ mod tests { use super::*; use crate::host_artifacts::installed::Installed; use crate::host_artifacts::stubs; - use crate::host_doctor::prerequisites::Judged; use crate::install::fixtures::{ complete_settings, folders, four_spaces, hook_item, installed, linux, }; @@ -466,6 +507,7 @@ mod tests { placements: &installed.placements, folders: &folders(), prerequisites: &linux(), + wire: true, }) .expect("composes") } @@ -565,6 +607,7 @@ mod tests { latest: None, observations, prerequisites: &linux(), + executable: None, }) }; @@ -745,6 +788,7 @@ mod tests { latest: None, observations: &observations, prerequisites: &linux(), + executable: None, }) .expect_err("content under Baley's name without evidence is refused"); @@ -770,6 +814,7 @@ mod tests { latest, observations, prerequisites, + executable: None, } } @@ -988,4 +1033,78 @@ mod tests { assert_eq!(facts.registration, None); assert!(!facts.complete); } + #[test] + fn a_hook_or_registration_wired_to_an_empty_stable_path_is_caught() { + let installed = installed(); + let observations = all_absent(&installed); + let (folders, prerequisites) = (folders(), linux()); + let plan_for = |gap: Option<&ExecutableGap>| { + judge(&Gathered { + placements: &installed.placements, + folders: &folders, + latest: None, + observations: &observations, + prerequisites: &prerequisites, + executable: gap, + }) + .expect("an empty machine plans") + }; + let stable = "/home/o/.local/bin/baley"; + + for (gap, text) in [ + (ExecutableGap::Missing, "does not exist"), + (ExecutableGap::DanglingLink, "is a link to a missing file"), + ] { + let plan = plan_for(Some(&gap)); + + assert!(matches!(plan.registration, Step::Run(_))); + let document = &plan.settings.document; + assert!(document.get("hooks").is_none(), "{document}"); + assert_eq!(document["sandbox"]["enabled"], true); + assert_eq!( + document["permissions"]["deny"].as_array().unwrap().len(), + 10 + ); + let stubs = plan + .writes + .iter() + .filter(|write| matches!(write.artifacts[0], Artifact::Stub(_))) + .count(); + assert_eq!(stubs, 2); + assert_eq!(plan.writes[0].artifacts, [Artifact::Settings]); + let wiring = plan.wiring.as_ref().expect("the wiring is held back"); + assert!(wiring.gap.contains(stable), "{}", wiring.gap); + assert!(wiring.gap.contains(text), "{}", wiring.gap); + assert!(wiring.gap.contains("install.sh"), "{}", wiring.gap); + assert_eq!(plan.settings.entries.hook, None); + for artifact in [Artifact::Hook, Artifact::Registration] { + let outcome = plan + .outcomes + .iter() + .find(|(placed, _)| *placed == artifact) + .map(|(_, outcome)| outcome) + .unwrap(); + let ArtifactOutcome::Failed(cause) = outcome else { + panic!("{artifact} must be held back, not {outcome:?}"); + }; + assert!(cause.contains(stable) && cause.contains(text), "{cause}"); + } + } + + let plan = plan_for(None); + let Step::Run(launches) = &plan.registration else { + panic!("the registration is requested"); + }; + assert_eq!(launches.len(), 1); + assert_eq!( + plan.settings.document["hooks"]["PreToolUse"], + json!([hook_item()]) + ); + assert!(plan.wiring.is_none()); + assert!( + plan.settings.gaps.iter().all(|gap| !gap.contains(stable)), + "{:?}", + plan.settings.gaps + ); + } } diff --git a/crates/baley/src/install/receipt.rs b/crates/baley/src/install/receipt.rs index f584eaab..96751c14 100644 --- a/crates/baley/src/install/receipt.rs +++ b/crates/baley/src/install/receipt.rs @@ -50,9 +50,14 @@ pub struct Notes { impl Notes { /// The notes a settings decision gives the receipt. - pub fn from_settings(decision: &settings::Decision) -> Self { + pub fn from_settings(decision: &settings::Decision, wiring_gap: Option<&str>) -> Self { Self { - gaps: decision.gaps.clone(), + gaps: decision + .gaps + .iter() + .cloned() + .chain(wiring_gap.map(str::to_owned)) + .collect(), replaced: decision.replaced.clone(), sandbox_held_back: decision.held_back.clone(), registration_by_hand: false, @@ -437,6 +442,7 @@ mod tests { latest: None, observations: &observations, prerequisites: &no_socat, + executable: None, }) .expect("a gap is not a refusal"); let written: Vec> = diff --git a/crates/baley/src/install/registration.rs b/crates/baley/src/install/registration.rs index 850b7e3c..99088f57 100644 --- a/crates/baley/src/install/registration.rs +++ b/crates/baley/src/install/registration.rs @@ -138,6 +138,8 @@ pub enum Registered { Refused(String), /// A `claude` command failed, as a `not-writable` line naming the file. Failed(String), + /// Nothing runnable sits at the stable path, so the step was held back. + Withheld(String), /// The step was not reached, for the cause. NotReached(String), } diff --git a/crates/baley/src/install/settings.rs b/crates/baley/src/install/settings.rs index 09248920..257dae41 100644 --- a/crates/baley/src/install/settings.rs +++ b/crates/baley/src/install/settings.rs @@ -193,6 +193,9 @@ pub struct Input<'a> { pub folders: &'a Folders, /// The sandbox programs the platform needs, judged by the doctor's check. pub prerequisites: &'a Judged, + /// Whether this binary's hook item is composed in. It is not while + /// nothing runnable sits at the stable path the hook would run. + pub wire: bool, } /// Baley's entries in the composed document, for the install record. @@ -367,12 +370,9 @@ pub fn judge(input: &Input<'_>) -> Result> { { settings.shift_remove("sandbox"); } - let composed = compose( - Some(&document), - &[proposal, hook], - input.folders, - executable, - ); + let mut ours_to_compose = vec![proposal]; + ours_to_compose.extend(input.wire.then_some(hook)); + let composed = compose(Some(&document), &ours_to_compose, input.folders, executable); let (mut refusals, mut replaced, mut gaps) = (Vec::new(), Vec::new(), Vec::new()); for conflict in &composed.conflicts { if let Some(line) = refusal(input.path, conflict) { @@ -702,6 +702,7 @@ mod tests { placements: &installed.placements, folders, prerequisites, + wire: true, }) } From ded925278b6e7958de5b24d3eddd76b6c99021c0 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 13:04:05 -0400 Subject: [PATCH 18/35] fix(install): refuse a non-stdio server as Baley's registration A leftover http or sse entry that still held Baley's command and args was judged unchanged, or removable as an older Baley entry, though Claude Code does not launch Baley through it. same_server now requires a stdio type, or none, so install, the doctor and composition agree. --- crates/baley/src/host_artifacts/compose.rs | 21 +++++++++----- crates/baley/src/install/registration.rs | 33 +++++++++++++++++++++- 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/crates/baley/src/host_artifacts/compose.rs b/crates/baley/src/host_artifacts/compose.rs index f3d55f47..ce1b2be5 100644 --- a/crates/baley/src/host_artifacts/compose.rs +++ b/crates/baley/src/host_artifacts/compose.rs @@ -174,14 +174,21 @@ fn merge(target: &mut Value, ours: &Value, key: &str, conflicts: &mut Vec bool { - existing.get("command") == ours.get("command") + existing + .get("type") + .is_none_or(|transport| transport == "stdio") + && existing.get("command") == ours.get("command") && existing.get("args") == ours.get("args") && existing .get("env") diff --git a/crates/baley/src/install/registration.rs b/crates/baley/src/install/registration.rs index 99088f57..e5f1fcc6 100644 --- a/crates/baley/src/install/registration.rs +++ b/crates/baley/src/install/registration.rs @@ -71,7 +71,9 @@ fn unusable(path: &Path, cause: &str) -> String { /// unchanged, since Claude Code stores keys of its own such as `type`. An /// entry equal by that rule to the one the record holds is Baley's older /// registration and is removed and added again. Any other entry is not -/// Baley's and is refused as an ownership conflict. +/// Baley's and is refused as an ownership conflict, including a server of +/// another transport (`http`, `sse`) that still holds Baley's command and +/// arguments. pub fn judge( path: &Path, state: &FileState, @@ -356,6 +358,35 @@ mod tests { assert!(refusal.contains(PATH), "{refusal}"); } + #[test] + fn a_non_stdio_server_with_baleys_command_taken_as_baleys_registration_is_caught() { + let recorded = record(&older()); + for transport in ["http", "sse", "ws"] { + let leftover = |args: Value| { + file(json!({"mcpServers": {"baley": { + "type": transport, + "url": "https://example.test/mcp", + "command": EXECUTABLE, + "args": args, + }}})) + }; + + let current = decide(&leftover(json!(["serve"])), None) + .expect_err("a remote server is not this binary's registration"); + assert!( + current.starts_with("install-ownership-conflict: "), + "{transport}: {current}" + ); + assert!(current.contains(PATH), "{transport}: {current}"); + + let older = decide(&leftover(json!(["serve", "--old"])), Some(&recorded)) + .expect_err("a remote server is not an older entry Baley may remove"); + assert!( + older.starts_with("install-ownership-conflict: "), + "{transport}: {older}" + ); + } + } #[test] fn a_claude_json_that_cannot_be_read_taken_as_no_registration_is_caught() { for state in [ From 26323eb0dcdd45e8ee61ee0add05e263015894ab Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 13:06:00 -0400 Subject: [PATCH 19/35] fix(install): report a replaced security value only when the settings write placed it The receipt said Baley's secure value was now in the settings file from the plan alone, so a refused or failed write still claimed the file had changed. --- crates/baley/src/install/receipt.rs | 53 +++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/crates/baley/src/install/receipt.rs b/crates/baley/src/install/receipt.rs index 96751c14..c099baa1 100644 --- a/crates/baley/src/install/receipt.rs +++ b/crates/baley/src/install/receipt.rs @@ -39,7 +39,8 @@ impl ArtifactOutcome { pub struct Notes { /// Each gap with its fix. Any gap makes the install partial. pub gaps: Vec, - /// Owner values Baley's secure values replaced. + /// Owner values Baley's secure values replace when the settings write + /// places them. The receipt reports them only then. pub replaced: Vec, /// Why the sandbox block was left out of the settings file, if it was. pub sandbox_held_back: Option, @@ -137,7 +138,11 @@ pub(crate) fn render( ), }); let settings_path = settings_path.unwrap_or_default(); - for replaced in ¬es.replaced { + // A replacement is only planned until the settings write places it. + let settings_placed = outcomes + .iter() + .any(|(artifact, outcome)| *artifact == Artifact::Settings && outcome.is_owned()); + for replaced in notes.replaced.iter().filter(|_| settings_placed) { lines.push(format!( "replaced {} in {settings_path}: it held {}, and Baley's secure value {} is now there", replaced.key, @@ -585,6 +590,50 @@ mod tests { .any(|line| precedence.iter().all(|part| line.contains(part))) ); } + + #[test] + fn a_secure_value_reported_as_placed_after_a_failed_settings_write_is_caught() { + let installed = installed(); + let notes = || Notes { + replaced: vec![Replaced { + key: "sandbox.enabled".into(), + was: serde_json::json!(false), + }], + ..Notes::default() + }; + let says_placed = |receipt: &Render| { + receipt + .lines + .iter() + .any(|line| line.starts_with("replaced ") || line.contains("is now there")) + }; + + for outcome in [ + ArtifactOutcome::Failed("Permission denied".into()), + ArtifactOutcome::NotWritten, + ] { + let receipt = render( + "0.2.0", + installed.layout.stable_path(), + &installed.placements, + &outcomes(outcome), + ¬es(), + &Ok(true), + ); + assert!(!says_placed(&receipt), "{:#?}", receipt.lines); + assert_eq!(receipt.lines[0], "install outcome: partial"); + } + + let receipt = render( + "0.2.0", + installed.layout.stable_path(), + &installed.placements, + &outcomes(ArtifactOutcome::Written), + ¬es(), + &Ok(true), + ); + assert!(says_placed(&receipt), "{:#?}", receipt.lines); + } #[test] fn a_registration_failure_without_the_command_to_run_is_caught() { let cause = From 65047b73d716ee062d64bfeab6615b353b61a663 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 13:08:01 -0400 Subject: [PATCH 20/35] fix(install): refuse a Baley folder that is not UTF-8 before any write The record and the deny rules hold folders as text, so a path with bytes that are not UTF-8 reached an expect and panicked mid-install. The settings decision now refuses it first, naming the path. --- crates/baley/src/install/settings.rs | 55 +++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/crates/baley/src/install/settings.rs b/crates/baley/src/install/settings.rs index 257dae41..1723d1e2 100644 --- a/crates/baley/src/install/settings.rs +++ b/crates/baley/src/install/settings.rs @@ -356,8 +356,23 @@ fn still_held(document: &Value, latest: Option<&Value>, pointer: &str, key: &str /// refused with every line. A setting that leaves protection off is kept and /// becomes a gap, and Baley's own secure value replacing an owner's is /// written and reported. A settings path that is a link is refused only when -/// a write is needed. +/// a write is needed. A Baley folder that is not valid UTF-8 is refused first, +/// since the record and the deny rules hold paths as text. pub fn judge(input: &Input<'_>) -> Result> { + let not_utf8: Vec = [&input.folders.home, &input.folders.config] + .into_iter() + .filter(|folder| folder.to_str().is_none()) + .map(|folder| { + format!( + "install-settings-conflict: Baley's folder {} is not valid UTF-8, so its deny rules cannot be written to {}; move it to a UTF-8 path, then run baley install again", + folder.display(), + input.path.display() + ) + }) + .collect(); + if !not_utf8.is_empty() { + return Err(not_utf8); + } let ours = ours(input.placements, input.folders); let held = held_back(input.prerequisites, input.path); let mut document = input.current.document.clone(); @@ -432,7 +447,11 @@ pub fn judge(input: &Input<'_>) -> Result> { &ours.deny_write, ), }; - let text = |path: &Path| path.to_str().expect("placements are UTF-8").to_owned(); + let text = |path: &Path| { + path.to_str() + .expect("placements and Baley's folders are UTF-8") + .to_owned() + }; let base = event::Sandbox { settings_path: text(input.path), sha256: String::new(), @@ -993,6 +1012,38 @@ mod tests { "{written}" ); } + + #[test] + fn a_baley_folder_that_is_not_utf8_panicking_instead_of_refusing_is_caught() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + + let raw = |bytes: &[u8]| PathBuf::from(OsString::from_vec(bytes.to_vec())); + let owner = read(json!({"model": "opus"})); + + let bad_home = Folders { + home: raw(b"/home/o/\xffdata"), + ..folders() + }; + let refusal = decide_in(&owner, None, &bad_home) + .expect_err("a folder that is not UTF-8 cannot be written as a rule"); + assert_eq!(refusal.len(), 1, "{refusal:?}"); + assert!( + refusal[0].starts_with("install-settings-conflict: "), + "{refusal:?}" + ); + assert!(refusal[0].contains("/home/o/"), "{refusal:?}"); + assert!(refusal[0].contains("UTF-8"), "{refusal:?}"); + + let bad_both = Folders { + config: raw(b"/home/o/\xfeconfig"), + ..bad_home + }; + let refusal = decide_in(&owner, None, &bad_both).expect_err("both folders are refused"); + assert_eq!(refusal.len(), 2, "{refusal:?}"); + assert!(refusal[0].contains("data"), "{refusal:?}"); + assert!(refusal[1].contains("config"), "{refusal:?}"); + } fn deny_rules() -> Value { json!([ "Read(//home/o/.local/share/crenshawdev/baley/**)", From 432a86a0cbde403bdbcdb6da4e9e87bfc1699c02 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 13:26:43 -0400 Subject: [PATCH 21/35] feat(host): judge the doctor's artifacts against what install recorded The doctor built its map from the running binary with every artifact unknown, so it could never see an installed one. It now takes the paths the guard and install share and the latest install record: an artifact is placed only when the record shows install wrote it, and the executable is the stable path. An unreadable record is a fault, not a machine where install never ran. --- crates/baley/src/host_doctor/mod.rs | 264 +++++++++++++++++++++---- crates/baley/src/host_doctor/report.rs | 88 +++++---- crates/baley/src/ledger/commands.rs | 19 +- crates/baley/src/ledger/tests.rs | 2 +- 4 files changed, 286 insertions(+), 87 deletions(-) diff --git a/crates/baley/src/host_doctor/mod.rs b/crates/baley/src/host_doctor/mod.rs index f2be149c..f592dbac 100644 --- a/crates/baley/src/host_doctor/mod.rs +++ b/crates/baley/src/host_doctor/mod.rs @@ -17,11 +17,13 @@ //! 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 -//! 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. +//! The placement map is decided by [`installed_placements`] from the paths +//! the guard and `baley install` share and from the latest install record: +//! only an artifact the record shows `baley install` wrote has a place. +//! 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 guard_records; pub mod placed; @@ -34,59 +36,87 @@ pub mod store_health; use std::ffi::OsStr; use std::path::{Path, PathBuf}; -use baley_store::{Health, Ledger, ProjectId}; +use baley_store::{Health, Ledger, ProjectId, StoreError}; +use serde_json::Value; 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::installed::{self, Installed}; use crate::host_artifacts::placement::{Artifact, Placement, PlacementMap}; -use crate::host_artifacts::stubs; -/// Why no placement map could be built for the running binary. +/// Why no placement map could be built. #[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), + /// `HOME` or `CLAUDE_CONFIG_DIR` cannot place the artifacts, or the paths + /// derived from them cannot form a map; the reason as worded for the owner. + Placements(String), + /// The latest install record could not be read; the store's text. An + /// unreadable record is never taken for a machine where install never ran. + Record(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 +/// Decides the placement map from the derivation the guard and install share +/// and from what reading the latest install record for `claude-code` found. +/// +/// The executable is always the derived stable path, never the running +/// binary. With no record every artifact and the versions folder are unknown, +/// so the report says `not installed` for each and raises nothing for it. With +/// a record, a stub takes its derived path when the record lists its identity, +/// the registration when the record holds a registration, the hook when it +/// holds a hook and the settings when it holds a sandbox entry, and the +/// versions folder takes its derived path. A derivation refusal or a failed +/// read of the record is the fault. +pub fn installed_placements( + derived: Result, + record: Result, StoreError>, +) -> Result { + let installed = derived.map_err(|refusal| MapFault::Placements(refusal.to_string()))?; + let record = record.map_err(|error| MapFault::Record(error.to_string()))?; + let versions = match record { + Some(_) => Placement::At(installed.layout.versions_folder().to_path_buf()), + None => Placement::Unknown, + }; + let record = record.unwrap_or(Value::Null); + let lists = |identity: &str| { + record["stubs"] + .as_array() + .is_some_and(|stubs| stubs.iter().any(|stub| stub["identity"] == identity)) + }; + let holds = |value: &Value| !value.is_null(); + let place = |held: bool, path: &Path| { + if held { + Placement::At(path.to_path_buf()) + } else { + Placement::Unknown + } + }; + let stubs = installed + .placements + .expected_files() .into_iter() - .map(|entry| (entry, Placement::Unknown)) + .filter_map(|file| { + file.stub + .map(|entry| (entry.clone(), place(lists(&entry.identity), file.path))) + }) .collect(); PlacementMap::new( - executable, + installed.placements.executable().clone(), stubs, - Placement::Unknown, - Placement::Unknown, - Placement::Unknown, - Placement::Unknown, + place( + holds(&record["registered"]["registration"]), + &installed.registration_file, + ), + place( + holds(&record["registered"]["hook"]), + &installed.settings_file, + ), + place(holds(&record["sandbox"]), &installed.settings_file), + versions, ) - .map_err(|refusal| MapFault::Refused(refusal.to_string())) + .map_err(|refusal| MapFault::Placements(refusal.to_string())) } /// One path read once. @@ -457,6 +487,8 @@ pub fn judge(observation: &Observation) -> Findings { #[cfg(test)] pub(crate) mod fixtures { use super::*; + use crate::host_artifacts::executable::Executable; + use crate::host_artifacts::stubs; pub(crate) const EXECUTABLE: &str = "/usr/local/bin/baley"; pub(crate) const HELP: &str = "/home/o/.claude/skills/bal-help/SKILL.md"; @@ -721,6 +753,155 @@ mod tests { } } } + + fn record_for( + stubs: &[&str], + registration: bool, + hook: bool, + sandbox: bool, + ) -> serde_json::Value { + let settings = "/home/o/.claude/settings.json"; + serde_json::json!({ + "host": "claude-code", + "binary_version": "0.1.0", + "binary_path": "/home/o/.local/bin/baley", + "complete": stubs.len() == 2 && registration && hook && sandbox, + "registered": { + "registration": registration.then(|| serde_json::json!({ + "path": "/home/o/.claude.json", + "entry": {"command": "/home/o/.local/bin/baley", "args": ["serve"]}, + })), + "hook": hook.then(|| serde_json::json!({ + "path": settings, + "item": {"matcher": "Bash", "hooks": [{"type": "command", "command": "baley guard"}]}, + })), + }, + "stubs": stubs.iter().map(|identity| serde_json::json!({ + "identity": identity, + "path": format!("/home/o/.claude/skills/{identity}/SKILL.md"), + "sha256": "a".repeat(64), + })).collect::>(), + "sandbox": sandbox.then(|| serde_json::json!({"settings_path": settings})), + "defaults": {}, + "updates": {"auto": false, "staged_version": null}, + }) + } + + fn derived() -> Result< + crate::host_artifacts::installed::Installed, + crate::host_artifacts::installed::Refusal, + > { + Ok(crate::install::fixtures::installed()) + } + + fn files(map: &PlacementMap) -> Vec<(Artifact, String)> { + map.expected_files() + .into_iter() + .map(|file| (file.artifact, file.path.to_str().unwrap().to_owned())) + .collect() + } + + #[test] + fn an_installed_artifact_reported_not_installed_by_the_doctor_is_caught() { + let record = record_for(&["bal-capture", "bal-help"], true, true, true); + let map = installed_placements(derived(), Ok(Some(record))).unwrap(); + assert_eq!(map.not_installed(), []); + assert_eq!( + files(&map), + [ + ( + Artifact::Stub("bal-capture".into()), + "/home/o/.claude/skills/bal-capture/SKILL.md".to_owned() + ), + ( + Artifact::Stub("bal-help".into()), + "/home/o/.claude/skills/bal-help/SKILL.md".to_owned() + ), + (Artifact::Registration, "/home/o/.claude.json".to_owned()), + (Artifact::Hook, "/home/o/.claude/settings.json".to_owned()), + ( + Artifact::Settings, + "/home/o/.claude/settings.json".to_owned() + ), + ] + ); + assert_eq!(map.executable().as_str(), "/home/o/.local/bin/baley"); + assert_eq!( + map.write_only_folders(), + [PathBuf::from( + "/home/o/.local/lib/crenshawdev/baley/versions" + )] + ); + } + + #[test] + fn a_never_installed_machine_reported_missing_or_judged_by_the_running_binary_is_caught() { + let map = installed_placements(derived(), Ok(None)).unwrap(); + assert_eq!( + map.not_installed(), + [ + Artifact::Stub("bal-capture".into()), + Artifact::Stub("bal-help".into()), + Artifact::Registration, + Artifact::Hook, + Artifact::Settings, + ] + ); + assert_eq!(files(&map), []); + assert_eq!(map.executable().as_str(), "/home/o/.local/bin/baley"); + + let fault = + installed_placements(derived(), Err(baley_store::StoreError::Busy)).unwrap_err(); + assert_eq!( + fault, + MapFault::Record(baley_store::StoreError::Busy.to_string()) + ); + } + + #[test] + fn an_artifact_the_record_does_not_list_reported_installed_is_caught() { + let capture_only = record_for(&["bal-capture"], false, true, true); + let map = installed_placements(derived(), Ok(Some(capture_only))).unwrap(); + assert_eq!( + files(&map), + [ + ( + Artifact::Stub("bal-capture".into()), + "/home/o/.claude/skills/bal-capture/SKILL.md".to_owned() + ), + (Artifact::Hook, "/home/o/.claude/settings.json".to_owned()), + ( + Artifact::Settings, + "/home/o/.claude/settings.json".to_owned() + ), + ] + ); + assert_eq!( + map.not_installed(), + [Artifact::Stub("bal-help".into()), Artifact::Registration] + ); + + let help_only = record_for(&["bal-help"], true, false, false); + let map = installed_placements(derived(), Ok(Some(help_only))).unwrap(); + assert_eq!( + files(&map), + [ + ( + Artifact::Stub("bal-help".into()), + "/home/o/.claude/skills/bal-help/SKILL.md".to_owned() + ), + (Artifact::Registration, "/home/o/.claude.json".to_owned()), + ] + ); + assert_eq!( + map.not_installed(), + [ + Artifact::Stub("bal-capture".into()), + Artifact::Hook, + Artifact::Settings, + ] + ); + } } #[cfg(test)] @@ -738,6 +919,7 @@ mod integration { use super::fixtures::{executable, folders, map}; use super::*; use crate::host_artifacts::compose::compose; + use crate::host_artifacts::stubs; use crate::host_artifacts::{hook, security}; use crate::mcp::capture::{CAPTURE_COMMAND, record}; use crate::mcp::prepare::{WriteRequest, prepared_command}; diff --git a/crates/baley/src/host_doctor/report.rs b/crates/baley/src/host_doctor/report.rs index baf5321f..770206d7 100644 --- a/crates/baley/src/host_doctor/report.rs +++ b/crates/baley/src/host_doctor/report.rs @@ -31,16 +31,12 @@ impl Report { 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::Placements(reason) => format!( + "artifact placements: {reason}, so no artifact can be checked" ), - MapFault::Executable { path, refusal } => format!( - "running binary {}: {refusal}, so no hook or registration can be rendered for it", - path.display() + MapFault::Record(cause) => format!( + "install record: it could not be read: {cause}, so an installation cannot be told from none" ), - MapFault::Refused(reason) => { - format!("running binary: the placement map was refused: {reason}") - } }); } for (artifact, state) in &findings.artifacts { @@ -392,16 +388,17 @@ mod tests { use super::super::fixtures::*; use super::super::placed::{Fault, FileState}; - use super::super::{MapFault, Observation, PlacedRead, all_unknown, judge}; + use super::super::{Observation, PlacedRead, installed_placements, judge}; use super::*; + use crate::folders::Environment; use crate::host_artifacts::compose::compose; - use crate::host_artifacts::executable::{MissingPrerequisite, PathFault}; + use crate::host_artifacts::installed::resolve; use crate::host_artifacts::stubs; use crate::host_artifacts::{hook, security}; + use crate::install::fixtures::installed; fn unknown_observation() -> Observation { - let map = all_unknown(Ok(PathBuf::from(EXECUTABLE))).unwrap(); - observed(map, vec![]) + observed(map(None, None, None, None, None), vec![]) } fn report_of(observation: &Observation) -> Report { @@ -460,33 +457,46 @@ mod tests { #[test] fn an_unusable_binary_path_dropped_from_the_report_is_caught() { - 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 - .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 = unmapped(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))); + let manifest = stubs::manifest(&stubs::front_doors()).unwrap(); + let home = Environment { + home: Some("/home/o".into()), + ..Environment::default() + }; + let no_home = resolve(&Environment::default(), None, &manifest).unwrap_err(); + let bad_config = resolve(&home, Some("claude".into()), &manifest).unwrap_err(); + for (fault, text) in [ + ( + installed_placements(Err(no_home), Ok(None)).unwrap_err(), + "HOME is not set", + ), + ( + installed_placements(Err(bad_config), Ok(None)).unwrap_err(), + "CLAUDE_CONFIG_DIR is not absolute", + ), + ( + installed_placements(Ok(installed()), Err(baley_store::StoreError::Busy)) + .unwrap_err(), + "store busy, retry", + ), + ] { + let report = report_of(&unmapped(fault)); + let named: Vec<&String> = report + .lines + .iter() + .filter(|line| line.contains(text)) + .collect(); + assert_eq!(named.len(), 1, "{text}: {:?}", report.lines); + assert_eq!(report.code, 1, "{text}"); + assert!( + !report + .lines + .iter() + .any(|line| line.contains("running binary")), + "{text}: {:?}", + report.lines + ); + assert!(!report.lines.iter().any(|line| says_installed(line))); + } } #[test] diff --git a/crates/baley/src/ledger/commands.rs b/crates/baley/src/ledger/commands.rs index 82db05c2..f2b0f581 100644 --- a/crates/baley/src/ledger/commands.rs +++ b/crates/baley/src/ledger/commands.rs @@ -12,10 +12,12 @@ use super::{ }; use crate::checkout::{Site, gather_and_admit}; use crate::discovery::{self, Discovery, PROJECT_FILE}; -use crate::folders::Folders; +use crate::folders::{Environment, Folders}; +use crate::host_artifacts::{installed, stubs}; +use crate::install::record as install_record; 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::policy::{CONFIG_UNAVAILABLE, EffectivePolicy, Host, ProjectIdentity, Unavailable}; use baley_core::*; use baley_store::*; use baley_store_sqlite::SqliteStore; @@ -158,9 +160,10 @@ fn doctor( 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, and the -/// server's context from the last server call the ledger holds. +/// The host section: the stable path and the artifacts the latest install +/// record shows `baley install` wrote, each reported as not installed when the +/// record does not show it, and the server's context from the last server +/// call the ledger holds. fn host_report( folders: Folders, checkout_file: Option, @@ -168,8 +171,12 @@ fn host_report( projects: &[ProjectId], health: &Health, ) -> host_doctor::Report { + let env = Environment::read(); + let manifest = stubs::manifest(&stubs::front_doors()).expect("unique compiled front doors"); + let derived = installed::resolve(&env, std::env::var_os("CLAUDE_CONFIG_DIR"), &manifest); + let record = install_record::read(store, Host::ClaudeCode.name()); let observation = host_doctor::gather( - host_doctor::all_unknown(std::env::current_exe()), + host_doctor::installed_placements(derived, record), folders, checkout_file, std::env::var_os("PATH").as_deref(), diff --git a/crates/baley/src/ledger/tests.rs b/crates/baley/src/ledger/tests.rs index 2f0cefbc..6a9d5c3a 100644 --- a/crates/baley/src/ledger/tests.rs +++ b/crates/baley/src/ledger/tests.rs @@ -567,7 +567,7 @@ 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 map = crate::host_doctor::fixtures::map(None, None, None, None, None); let host = crate::host_doctor::Report::new(&crate::host_doctor::judge( &crate::host_doctor::fixtures::observed(map, vec![]), )); From bc98a349d8b44b77c10676bdf19ba8fb113a1f32 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 13:34:04 -0400 Subject: [PATCH 22/35] feat(install): end the install script by running baley install The script placed the binary and stopped, so the Claude wiring still needed a second command. It now runs install through the stable path and exits with install's status. --- install.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/install.sh b/install.sh index 660f847e..c61768ac 100755 --- a/install.sh +++ b/install.sh @@ -1,7 +1,7 @@ #!/bin/sh # Install Baley from unsigned development artifacts only. # Issue #14 adds signature verification inside verify_download before any release. -# Running baley install at the end belongs to Build 3 T15. +# The script ends by running baley install through the stable path, which writes Claude Code's wiring. set -eu LC_ALL=C @@ -208,3 +208,5 @@ for folder in "$versions"/*; do valid_version "$present" || continue printf ' %s\n' "$present" done + +"$stable" install From 49ab6ee6bd7ddbadce810eef8b17fe3aa481e03b Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 13:35:23 -0400 Subject: [PATCH 23/35] chore(guard): remove the tracked hook file now that install writes the hook baley install renders the hook from host_artifacts::hook::render, so the hand-written copy and the test that held it to the renderer have nothing left to guard. --- crates/baley/src/guard_budget.rs | 17 +++++++++-------- crates/baley/src/host_artifacts/hook.rs | 16 ---------------- hooks/hooks.json | 16 ---------------- 3 files changed, 9 insertions(+), 40 deletions(-) delete mode 100644 hooks/hooks.json diff --git a/crates/baley/src/guard_budget.rs b/crates/baley/src/guard_budget.rs index e43c1dc4..0cdd9869 100644 --- a/crates/baley/src/guard_budget.rs +++ b/crates/baley/src/guard_budget.rs @@ -1,13 +1,13 @@ //! The guard's one time budget (design 0010, GRD-R14). //! //! One guard call has to answer inside the host's hook timeout, the -//! `"timeout": 10` that `hooks/hooks.json` gives `baley guard`. The budget -//! starts when the guard starts, before it reads its input. Git and storage -//! waits each have one allowance for the whole call, and no step runs past the -//! end of the work time, so the reserve after it is left for killing and -//! reaping a child and writing the answer. Each grant is decided from the time -//! elapsed since the start and the time already spent, so no step resets the -//! total. +//! `"timeout": 10` that `host_artifacts::hook::render` writes into the hook +//! `baley install` places for `baley guard`. The budget starts when the guard +//! starts, before it reads its input. Git and storage waits each have one +//! allowance for the whole call, and no step runs past the end of the work +//! time, so the reserve after it is left for killing and reaping a child and +//! writing the answer. Each grant is decided from the time elapsed since the +//! start and the time already spent, so no step resets the total. //! //! The input is bounded by size, not time: at most //! [`crate::hook_input::MAX_INPUT_BYTES`] bytes, read inside the work time. @@ -17,7 +17,8 @@ use std::time::{Duration, Instant}; -/// The host's hook timeout for `baley guard`, from `hooks/hooks.json`. +/// The host's hook timeout for `baley guard`: the value +/// `host_artifacts::hook::render` writes into the installed hook. pub const HOST_TIMEOUT: Duration = Duration::from_secs(10); /// Work ends this long after the guard starts. pub const WORK: Duration = Duration::from_secs(8); diff --git a/crates/baley/src/host_artifacts/hook.rs b/crates/baley/src/host_artifacts/hook.rs index d038fc84..5c71bc1b 100644 --- a/crates/baley/src/host_artifacts/hook.rs +++ b/crates/baley/src/host_artifacts/hook.rs @@ -101,22 +101,6 @@ mod tests { assert_eq!(timeout.as_u64(), Some(HOST_TIMEOUT.as_secs())); } - #[test] - fn drift_between_the_tracked_hooks_json_and_the_renderer_is_caught() { - // The tracked file is what T12 runs by hand; its command is a bare - // `baley` on purpose and is not compared. T15 removes the file and - // this test once `baley install` writes the hook from `render`. - let tracked: Value = serde_json::from_str(include_str!("../../../../hooks/hooks.json")) - .expect("hooks/hooks.json parses"); - let item = &tracked["hooks"][EVENT][0]; - let ours = rendered("/usr/local/bin/baley"); - assert_eq!(item["matcher"], ours["hooks"][EVENT][0]["matcher"]); - assert_eq!( - item["hooks"][0]["timeout"], - ours["hooks"][EVENT][0]["hooks"][0]["timeout"] - ); - } - #[test] fn a_second_hook_or_another_event_is_caught() { let ours = rendered("/usr/local/bin/baley"); diff --git a/hooks/hooks.json b/hooks/hooks.json deleted file mode 100644 index 7c0da42d..00000000 --- a/hooks/hooks.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "hooks": { - "PreToolUse": [ - { - "matcher": "Bash|Monitor|PowerShell|Read|Grep|Glob|Write|Edit|NotebookEdit", - "hooks": [ - { - "type": "command", - "command": "baley guard", - "timeout": 10 - } - ] - } - ] - } -} From eb32aa87b6eb29d70fd39d28c6d9204c5ecc3e53 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 14:05:18 -0400 Subject: [PATCH 24/35] docs(design): describe baley install as built in the host interface design Section 5, the sandbox text, the update and doctor sections and figures 7 and 8 still said install was future work. They now say what install writes and where, how it proves a file is Baley's, when it refuses, what a partial install leaves, and what the view set rise costs older binaries. --- docs/design/0012-host-interface.md | 134 ++++++++++++++++++++++------- 1 file changed, 103 insertions(+), 31 deletions(-) diff --git a/docs/design/0012-host-interface.md b/docs/design/0012-host-interface.md index 79248e36..e0eebcd9 100644 --- a/docs/design/0012-host-interface.md +++ b/docs/design/0012-host-interface.md @@ -150,22 +150,82 @@ sha256 <64 lowercase hex digits> The placeholder on the second line stands for the executable's SHA-256. A version has exactly three decimal parts, `major.minor.patch`, each fitting `u64`, with no sign or leading zero except a lone `0`. The installer's `verify_download` compares the downloaded executable's SHA-256 with the manifest, using `sha256sum` or `shasum -a 256`. It has no verification bypass. This checks unsigned development bytes only. #14 owns signature verification in both delivery paths at `verify_download`, the trust root, release format and publication (`install.sh:37-71, 147-171`, `crates/baley/src/update/manifest.rs:101-133, 178-201`). -The stable path is `$HOME/.local/bin/baley`, a symbolic link to `$HOME/.local/lib/crenshawdev/baley/versions//baley`. These paths are the same on Linux and macOS. `BALEY_HOME` and `XDG_*` move neither. `HOME` must be absolute, nonempty UTF-8 without a `..` component. Repeated slashes and `.` components are removed without following folder links. The installation's identity is the resulting stable path text, never its target (`crates/baley/src/update/installation.rs:74-127`). +The stable path is `$HOME/.local/bin/baley`, a symbolic link to `$HOME/.local/lib/crenshawdev/baley/versions//baley`. These paths are the same on Linux and macOS. `BALEY_HOME` and `XDG_*` move neither. `HOME` must be absolute, nonempty UTF-8 without a `..` component. Repeated slashes and `.` components are removed without following folder links. The installation's identity is the resulting stable path text, never its target (`crates/baley/src/update/installation.rs:75-134`). -The script allows an absent stable path or a link whose stored target is exactly the absolute `//baley`, with the version spelled by the same rule as the updater. It refuses other occupants and links to folders, and rechecks before activation. A reused staged file must be regular, not a link, have the manifest's digest and be executable. For a new staged file, the script sets mode `755` on the downloaded file before moving it into the versions folder, then moves it to its final name. It creates a temporary link in `~/.local/bin` and moves that link onto the stable path. It removes no version. Its receipt prints `baley: placed version at `, then `baley: versions present in :` and the version folders holding regular binaries. A failure exits 1. The script writes no host wiring, records no install event, seeds no catalog and does not call `baley install` (`install.sh:73-90, 173-210`). +The script allows an absent stable path or a link whose stored target is exactly the absolute `//baley`, with the version spelled by the same rule as the updater. It refuses other occupants and links to folders, and rechecks before activation. A reused staged file must be regular, not a link, have the manifest's digest and be executable. For a new staged file, the script sets mode `755` on the downloaded file before moving it into the versions folder, then moves it to its final name. It creates a temporary link in `~/.local/bin` and moves that link onto the stable path. It removes no version. Its receipt prints `baley: placed version at `, then `baley: versions present in :` and the version folders holding regular binaries. A failure exits 1. The script's last step runs `"$stable" install`, after its receipt, and the installer ends with that command's exit status, so a refused or partial install fails the installer. The script itself writes no host wiring, records no install event and seeds no catalog: `baley install` does that (`install.sh:73-90, 173-212`). -`baley install` and the script's call to it belong to T15. The following is that command's contract, not output of the development script. T15 writes the wiring and takes setup defaults, T16 adds provider choice and acknowledgement, and T17 checks the installed result and runs the script live. Missing Linux sandbox prerequisites (`bubblewrap` or `socat`) are to be reported with the fix, never treated as a successful protected install. A new Claude Code session loads the wiring; each checkout still needs the owner's `baley init`. +`baley install` writes Claude Code's wiring at the stable path and takes setup defaults. T16 adds provider choice and acknowledgement, and T17 checks the installed result and runs the script live. A new Claude Code session loads the wiring, and each checkout still needs the owner's `baley init`. The command is `baley install [--host ]` (`crates/baley/src/install/command.rs:28-52`, `crates/baley/src/main.rs:37-38, 120`). -- **Inputs:** the host, default `claude-code`, and the resolved Baley folders. The installer and all written executable references use the absolute stable path. -- **Outputs:** user-level MCP registration for `baley serve`, the `baley guard` pre-tool hook with GRD-R1's nine-tool matcher and 10-second timeout, stubs, sandbox and file-tool denials, default settings, catalog seed and `install.recorded`. Existing owner settings and separately owned registrations are preserved. Replacement needs evidence of Baley's ownership, never a matching name alone (UPK-R2 in [0015](0015-repository-upkeep-and-build-gate.md)). -- **Receipt:** version, stable path, every installed artifact, checks and any prerequisite still missing, the need for a new Claude Code session and per-checkout `baley init`, plus the `updates.auto` switch, the manual `baley update` command and `baley config interview` for later choices. A partial install is not reported complete. -- **Refusals:** `unknown-host`, `not-writable` naming the location, `install-ownership-conflict` when an artifact is not provably Baley-owned, or `install-settings-conflict` when settings cannot be safely merged. Signature or checksum failure activates nothing. +- **Inputs:** the host, default `claude-code`, and `HOME`, `CLAUDE_CONFIG_DIR` and the resolved Baley folders. Any other host is refused `unknown-host` before anything else runs, naming the supported hosts from `Host::ALL`. Every executable reference install writes is the absolute stable path, `~/.local/bin/baley`, from the installation layout and never from the running binary's own path (`crates/baley/src/install/command.rs:116-126`, `crates/baley/src/host_artifacts/installed.rs:55-107`). +- **Outputs:** the artifacts in the table below, then the catalog seed and `install.recorded`. Existing owner settings and separately owned registrations are preserved. Replacement needs evidence of Baley's ownership, never a matching name alone (UPK-R2 in [0015](0015-repository-upkeep-and-build-gate.md)). +- **Receipt:** see below. A partial install is never reported complete. +- **Refusals:** see below. Signature or checksum failure activates nothing, in the installer before install runs. + +**Where each artifact goes.** The Claude folder is `CLAUDE_CONFIG_DIR` when it is set, else `~/.claude`. A set value must be non-empty, absolute and UTF-8, and its trailing slashes are dropped. `HOME` must be usable even then, because it places the stable path. Nothing else moves these paths: `BALEY_HOME` and `XDG_*` move only Baley's own folders. + +| Artifact | Place | Written by | +|---|---|---| +| Guard hook, `permissions.deny` rules and `sandbox` block | `/settings.json`, one document | Install, through `replace::replace` (a temporary file in the same folder, renamed only if the file's digest is still the one read, never through a link) | +| Skill stubs | `/skills//SKILL.md`, one folder per served front door | Install, through the same replacement | +| MCP registration `mcpServers.baley` | `$CLAUDE_CONFIG_DIR/.claude.json` when the variable is set, else `~/.claude.json` | Claude Code, by `claude mcp add-json --scope user baley `. Install never writes this file | + +Claude Code rewrites `.claude.json` whole, by rename, again and again while a session runs (`spikes/host-matrix/claude-live-2026-10-08-run1.md:675-679, 759-763`), so a write by Baley could be refused for a changed digest or be written back over by an open session. Baley asks Claude Code to make the change instead. `claude mcp add-json` refuses a name that exists, so replacing Baley's older entry runs `claude mcp remove --scope user baley` first, after the file is read and judged a second time. Each `claude` launch has its own process group, a 30-second timeout, 64 KiB kept per stream, no input and install's own environment. Claude Code relocates its configuration under `CLAUDE_CONFIG_DIR`, and user-scope servers then live in `$CLAUDE_CONFIG_DIR/.claude.json` (read from the installed binary, and a live run stored a user-scope registration in an isolated folder, `spikes/host-matrix/live-claude.md:177`, `spikes/host-matrix/claude-live-2026-10-08-run3.md#ses.b.registered`). Install itself has not run against a live Claude Code, and T17 does that. The entry has `command` the stable path and `args` `["serve"]`, with no `alwaysLoad`, `env` or `type` key. The hook item is the nine-tool matcher with a single command hook that runs the single-quoted stable path and `guard` within 10 seconds (`crates/baley/src/install/registration.rs:19-57`, `crates/baley/src/host_artifacts/registration.rs:20-26`, `crates/baley/src/host_artifacts/hook.rs:13-50`). + +**Ownership.** Install proves an artifact is Baley's from the latest `install.recorded` for the host and from this binary's own render. A name or a folder proves nothing. + +- A stub is Baley's when its bytes equal this binary's render, or when its SHA-256 is the hash the latest record holds for that identity. A stub that holds other bytes, is a link, or cannot be read refuses the install (`crates/baley/src/install/stubs.rs:26-71`). +- A JSON entry is Baley's when it equals this binary's render or what the latest record holds for it. The registration is compared by one rule, shared with composition and the doctor: the same `command` and `args`, `env` absent or empty, and `type` absent or `stdio`. Every other key, `alwaysLoad` included, is ignored. So an `mcpServers.baley` entry that sets any `env` entry is never this binary's, because it can point the server at another ledger, and an `http` or `sse` entry that still holds Baley's command and arguments is not Baley's either, because Claude Code does not launch it through `command` (`crates/baley/src/host_artifacts/compose.rs:177-196`, `crates/baley/src/install/registration.rs:66-115`). +- Older Baley entries leave only on the record's evidence. The `PreToolUse` item the record holds, when it differs from this binary's, and each `permissions.deny`, `sandbox.filesystem.denyRead` and `denyWrite` string the record lists that this binary does not render, are removed by whole-value comparison. With no record, nothing is removed. The owner's own items and rules stay (`crates/baley/src/install/settings.rs:120-170`). +- Running install again on a completed installation changes nothing: composition adds only entries that are absent, a stub whose bytes match is not written, and a registration that is this binary's runs no `claude` command. + +**Refusals.** A refusal is judged before any write and places no file under the Claude folder, runs no `claude` command, seeds no catalog and records no `install.recorded`. It prints one line per conflict, names the artifact and its path, and exits 2. Baley's own lock file and ledger store may already exist. Every conflict found is listed, so one run shows all of them (`crates/baley/src/install/plan.rs:109-256`). + +| Code | When | +|---|---| +| `unknown-host` | `--host` names anything but `claude-code` | +| `install-ownership-conflict` | A stub holds bytes that match neither this binary's stub nor a hash the record lists for it, or is a link. `mcpServers.baley` in `.claude.json` or in the settings file is not this binary's entry or the record's. A `PreToolUse` item runs another `guard` command and the record does not list it | +| `install-settings-conflict` | The settings file or `.claude.json` is not JSON, is JSON but not an object, is not a regular file, is a dangling link, or cannot be read. A key install adds entries under holds another kind of value. The settings file is a link and a write is needed. A Baley folder is not valid UTF-8, so its deny rules could not be written | +| `install-running` | Another `baley install` holds the install lock | +| `install-lock-unavailable` | The lock file cannot be created, opened or locked, with its path and cause | + +Beside these, a `HOME`, `CLAUDE_CONFIG_DIR` or Baley folder value that cannot place the artifacts is refused with its own text, and a store that cannot be opened is reported by the shared renderer with its exit class. + +**One install at a time.** Install is exclusive. After it has resolved the folders and the artifact paths, and before it opens the ledger, it takes an exclusive, non-waiting lock on `/install.lock`, a file created with mode `0600` that does not follow a link, and holds it until the record is written. A second install is refused `install-running` before it reads anything, because two runs planning from the same record could leave the record naming hashes the files no longer hold. Closing the file releases the lock, so a crashed install leaves nothing to clear (`crates/baley/src/install/command.rs:54-114, 126`). + +**Partial installs.** A partial install writes what it can, names each gap with its fix and exits 1. + +- Owner settings that weaken protection are kept and named, never overwritten: `disableAllHooks` or `sandbox.filesystem.disabled` true, any `sandbox.excludedCommands` entry, an absolute `allowRead` or `allowWrite` entry that re-opens a Baley folder, a `~/` or relative entry composition cannot judge, and a path the deny rules cannot render. Each gap says what to remove or change. +- Baley's secure value replaces the owner's `sandbox.enabled` false, `sandbox.failIfUnavailable` false or `sandbox.allowUnsandboxedCommands` true. The replacement is written and the receipt reports the old value, but only when the settings write placed it. +- On Linux without `bwrap` or `socat`, and on a platform without Claude Code's sandbox, the `sandbox` block is held back and the owner's own `sandbox` block is left as it was. The hook, the deny rules, the stubs and the registration are still written. The gap carries the doctor's fix text, which names the package and the commands that install it, and says to run install again. +- While nothing runnable sits at the stable path, the hook and the registration are held back, because they would run a path that does not exist. The stubs, the deny rules and the sandbox block are written. The gap says to run `install.sh`, or to place the binary or a link to it at that path, and run install again. +- A write that fails stops the run. The stubs and the registration after it are reported `not written`, with the cause, and the run still seeds the catalog and records what it did own. A failed `claude` command prints the full `claude mcp add-json` line for the owner to run by hand. Registration is attempted only when every file write was reached. + +**Write order.** The settings file, then the stubs in manifest order, then the registration through `claude`. The catalog seed follows, in process through `models::seed`, then the record. A refused settings write, such as a file that changed after it was read, is reported in the receipt as `install-settings-conflict`, and an I/O failure as `not-writable` with the path (`crates/baley/src/install/plan.rs:213-233, 277-351`, `crates/baley/src/install/apply.rs:27-123`, `crates/baley/src/install/command.rs:133-209`). + +**The record.** After the seed, install records `install.recorded` (section 6) with the ownership facts the run left: for each stub the hash of the bytes left at its path, the registration entry and the hook item that are Baley's after the run, and the entries Baley owns in the sandbox and deny lists. A step that did not complete keeps the previous record's entry for that artifact while the file still holds it, so the next run can still recognise Baley's older entry. The record is written only when it differs from the latest one. A run also names the record it planned from, so a run that planned from an older record cannot overwrite a newer one, which is a second guard beside the lock: it records nothing and the receipt says the record was not written (`crates/baley/src/install/plan.rs:371-472`, `crates/baley/src/install/record.rs:47-118`). + +**Receipt.** The lines print in this order (`crates/baley/src/install/receipt.rs:69-218`): + +1. `install outcome: complete` or `install outcome: partial`. +2. `baley at `. +3. One line per artifact in map order, ` at : `: the stubs by identity, the registration, the hook and the settings. The states are `written`, `registered`, `replaced`, `unchanged`, `written without the sandbox block` and `not written` with the cause. +4. The catalog seed line, then any write failure and a `the install record was not written` line. +5. A `replaced ...` line for each owner value Baley's secure value replaced. +6. A `gap: ...` line for each gap, with its fix. +7. When the sandbox block is in the file, two lines saying the sandbox settings apply to every Claude Code session on this machine, including projects Baley does not manage, and that managed settings, command-line settings and a project's own `.claude/settings.json` and `.claude/settings.local.json` take precedence over the user settings file for single values such as `sandbox.enabled`. +8. The next steps: start a new Claude Code session, run `baley init` in each checkout, the `updates.auto` switch, `baley update`, `baley config interview` and `baley doctor`. + +The outcome is complete only when every artifact is Baley's after the run, there is no gap, the catalog seed is recorded and no write or record failed. The exit status is 0 for complete and 1 for partial, 2 for a refusal, and a store failure uses the shared classes (2 for a refused or blocked store, 3 for a busy, unavailable or read-only one). + +**The settings file.** Claude Code reads the user settings file below managed settings, command-line settings, a project's local settings and its shared settings. A project's own settings can therefore override single sandbox values such as `sandbox.enabled`, and a managed policy overrides them all. The sandbox in the user file applies to every Claude Code session on the machine, including projects Baley does not manage, and the receipt says so. Baley chose this file because it needs no administrator rights and holds the hook, the deny rules and the sandbox as one document that is composed once. Install writes nothing to `config.toml`: the interview's defaults are the compiled defaults, and a setting the owner changed later is not touched. Provider hosts and `review.reviewers` are T16's. + +**Limits.** Two cases are not closed. Between install's second read of `mcpServers.baley` and the `claude mcp remove` that follows it, another writer can change the entry, so the remove can delete an entry install did not judge ([#251](https://github.com/crenshawdev/baley/issues/251)). When nothing runnable is at the stable path, install withholds the new hook but still removes an older hook the record lists, so a machine can end with no hook ([#252](https://github.com/crenshawdev/baley/issues/252)); the gap says so only by naming the empty stable path. The live behaviour of the written wiring in a Claude Code session is T17's to observe. Install resolves the folders for its deny paths without pinning folder environment values into the MCP entry or hook. The server, hook and owner commands resolve their own runtime environment and must resolve the same `BALEY_HOME`, `XDG_CONFIG_HOME`, `XDG_DATA_HOME` and `HOME` inputs, and the sandbox paths must match the resulting folders. `doctor` reports disagreement instead of certifying a different, unprotected home. ### Sandbox and provider access -The library renders the filesystem and permission proposal below. T15's `baley install` merges it into Claude Code's settings, replacing the example paths with the resolved absolute folders, and selects the settings file and documents its precedence before applying the merge (section 12). The example shows Linux defaults and the provider hosts T16 writes when both providers are selected and acknowledged. Without outside providers, install adds neither API host. Existing unrelated settings and hosts are preserved. The fields follow Claude Code's [sandbox settings](https://code.claude.com/docs/en/sandboxing). +The library renders the filesystem and permission proposal below, and `baley install` merges it into the user settings file `/settings.json`, replacing the example paths with the resolved absolute folders (section 5). The example shows Linux defaults for a home folder of `/home/owner`, with the files a default installation places and, last, the provider hosts T16 writes when both providers are selected and acknowledged. Install itself writes no `network` block: without outside providers it adds neither API host. Existing unrelated settings and hosts are preserved. The fields follow Claude Code's [sandbox settings](https://code.claude.com/docs/en/sandboxing). ```json { @@ -182,6 +242,10 @@ The library renders the filesystem and permission proposal below. T15's `baley i "/home/owner/.local/share/crenshawdev/baley", "/home/owner/.config/crenshawdev/baley", "/home/owner/.local/lib/crenshawdev/baley/versions", + "/home/owner/.claude/skills/bal-capture/SKILL.md", + "/home/owner/.claude/skills/bal-help/SKILL.md", + "/home/owner/.claude.json", + "/home/owner/.claude/settings.json", "/home/owner/.local/bin/baley" ] }, @@ -196,15 +260,19 @@ The library renders the filesystem and permission proposal below. T15's `baley i "Read(//home/owner/.config/crenshawdev/baley/**)", "Edit(//home/owner/.config/crenshawdev/baley/**)", "Edit(//home/owner/.local/lib/crenshawdev/baley/versions/**)", + "Edit(//home/owner/.claude/skills/bal-capture/SKILL.md)", + "Edit(//home/owner/.claude/skills/bal-help/SKILL.md)", + "Edit(//home/owner/.claude.json)", + "Edit(//home/owner/.claude/settings.json)", "Edit(//home/owner/.local/bin/baley)" ] } } ``` -Sandbox filesystem paths use ordinary absolute paths; `Read` and `Edit` rules use `//` for an absolute path. `Edit` covers Write and NotebookEdit. The guard also covers Read, Grep and Glob over the home and config folder, and protects the project file ([0010](0010-guard.md), GRD-R11 and GRD-R13); the placed stubs, the placed settings and registration files and the installed executable join its list when T15 passes the placement projection in. Each write-only file gets a `denyWrite` entry and an `Edit` file rule, with no `Read` rule. In the default layout, the versions folder is `~/.local/lib/crenshawdev/baley/versions`, outside both the PATH folder and Baley's ledger home. Setting `BALEY_HOME` to the versions folder, a folder that contains it, or a version folder inside it is not supported: the home's read denials would cover the staged binaries, so sandboxed commands could not run them. The proposal protects it as a folder: its absolute path in `denyWrite` and `Edit(///**)`, with no `denyRead` entry or `Read` rule, so sandboxed commands can run the binaries inside it. `PlacementMap::write_only_folders` supplies that folder separately from protected files, and the guard's write decision denies writes to the folder and its contents (`crates/baley/src/host_artifacts/placement.rs:290-330`, `crates/baley/src/protected_paths/write.rs:13-31, 75-103`). The command-line placement still leaves it `Unknown`, and the live guard's list is empty until T15 supplies it at `guard_hook::context::judge` (`crates/baley/src/host_doctor/mod.rs:65-90`, `crates/baley/src/guard_hook/context.rs:126-133`). The built projection and renderer do not install these protections. There is no key folder, no provider-tool state exception and no `~/.codex` allowance. +Sandbox filesystem paths use ordinary absolute paths; `Read` and `Edit` rules use `//` for an absolute path. `Edit` covers Write and NotebookEdit. The guard also covers Read, Grep and Glob over the home and config folder, and protects the project file ([0010](0010-guard.md), GRD-R11 and GRD-R13); the placed stubs, the placed settings and registration files and the installed executable are on its list, and the versions folder is its write-only folder. Each write-only file gets a `denyWrite` entry and an `Edit` file rule, with no `Read` rule. In the default layout, the versions folder is `~/.local/lib/crenshawdev/baley/versions`, outside both the PATH folder and Baley's ledger home. Setting `BALEY_HOME` to the versions folder, a folder that contains it, or a version folder inside it is not supported: the home's read denials would cover the staged binaries, so sandboxed commands could not run them. The proposal protects it as a folder: its absolute path in `denyWrite` and `Edit(///**)`, with no `denyRead` entry or `Read` rule, so sandboxed commands can run the binaries inside it. `PlacementMap::write_only_folders` supplies that folder separately from protected files, and the guard's write decision denies writes to the folder and its contents (`crates/baley/src/host_artifacts/placement.rs:290-330`, `crates/baley/src/protected_paths/write.rs:13-31, 75-103`). The live guard derives the same placements from the environment: the stable path and versions folder from `HOME`, and the Claude folder from `CLAUDE_CONFIG_DIR`. It reads no store for them and passes them at `guard_hook::context::judge`, which protects the files once each and the versions folder as a write-only folder. When the placements cannot be derived, because `HOME` or `CLAUDE_CONFIG_DIR` is unusable, the guard cannot clear any path call and fails closed: it denies the six path tools and cannot record, so an ask becomes a deny (`crates/baley/src/guard_hook/context.rs:65-90, 103-174`, `crates/baley/src/host_artifacts/installed.rs:55-107`). The doctor builds its map from the install record (see `baley doctor`). There is no key folder, no provider-tool state exception and no `~/.codex` allowance. -The library renders this shape, without the `network` block, from the resolved folders, the executable, the write-only files and the write-only folders, and reports a supplied path that is not absolute UTF-8, that a rule would read as a pattern, or that ends in a space a deny rule would drop, instead of rendering it (`crates/baley/src/host_artifacts/security.rs:91-109, 143-222`). It composes the shape into an existing settings document, keeping every unrelated key, `sandbox.network` included. Where the shape is composed, Baley's secure values replace a disabled sandbox, `failIfUnavailable` false and `allowUnsandboxedCommands` true, with each replacement reported. Composition reports and retains `sandbox.filesystem.disabled` true, `disableAllHooks` true, and `sandbox.enabled` false, `failIfUnavailable` false or `allowUnsandboxedCommands` true in a document the shape is not composed into, such as one holding only the hook or the registration. It also reports and retains every `sandbox.excludedCommands` entry, whatever it names, since Claude Code runs a listed command outside the sandbox even with `allowUnsandboxedCommands` false; the home or config folder re-opened by an absolute `allowRead` or `allowWrite` entry; a `~/` or relative `allowRead` or `allowWrite` entry, which Claude Code resolves against the owner's home folder or the settings file's place and which composition therefore cannot judge; a differing hook running `guard`; and a `mcpServers.baley` entry with another command or arguments. A conflict marks the result incomplete (`crates/baley/src/host_artifacts/compose.rs:98-175, 192-273`). The coverage judge reports what the resulting document covers per tool and per read or write (`crates/baley/src/host_artifacts/coverage.rs:281-356`). Any excluded command leaves Bash, Monitor and PowerShell and every write-only file a gap (`crates/baley/src/host_artifacts/coverage.rs:375-398`), and a `~/` or relative allow entry leaves each verdict its list can open a gap, reported as not judged (`crates/baley/src/host_artifacts/coverage.rs:429-442`). Both read only these settings, in the one document they are given: a setting Claude Code also honours, such as `sandbox.enabledPlatforms` leaving out this platform, or one in another settings file, is not seen. The placement projection carries the installed executable path and the separate versions folder (`crates/baley/src/host_artifacts/placement.rs:290-330`). The coverage judge currently takes only home, config and write-only files, so the installed versions folder needs T17's checks. T15 selects the settings file, applies the composed result and passes the projection to the guard. The `network` block is `baley install`'s, written for the chosen providers (T15 and T16); the library renders no network key. +The library renders this shape, without the `network` block, from the resolved folders, the executable, the write-only files and the write-only folders, and reports a supplied path that is not absolute UTF-8, that a rule would read as a pattern, or that ends in a space a deny rule would drop, instead of rendering it (`crates/baley/src/host_artifacts/security.rs:91-109, 143-222`). It composes the shape into an existing settings document, keeping every unrelated key, `sandbox.network` included. Where the shape is composed, Baley's secure values replace a disabled sandbox, `failIfUnavailable` false and `allowUnsandboxedCommands` true, with each replacement reported. Composition reports and retains `sandbox.filesystem.disabled` true, `disableAllHooks` true, and `sandbox.enabled` false, `failIfUnavailable` false or `allowUnsandboxedCommands` true in a document the shape is not composed into, such as one holding only the hook or the registration. It also reports and retains every `sandbox.excludedCommands` entry, whatever it names, since Claude Code runs a listed command outside the sandbox even with `allowUnsandboxedCommands` false; the home or config folder re-opened by an absolute `allowRead` or `allowWrite` entry; a `~/` or relative `allowRead` or `allowWrite` entry, which Claude Code resolves against the owner's home folder or the settings file's place and which composition therefore cannot judge; a differing hook running `guard`; and a `mcpServers.baley` entry with another command or arguments, with `env` entries, or of a transport other than `stdio`. A conflict marks the result incomplete. `baley install` sorts what composition reports into refusals, replacements and gaps (section 5) (`crates/baley/src/host_artifacts/compose.rs:98-175, 206-287`, `crates/baley/src/install/settings.rs:352-489`). The coverage judge reports what the resulting document covers per tool and per read or write (`crates/baley/src/host_artifacts/coverage.rs:281-356`). Any excluded command leaves Bash, Monitor and PowerShell and every write-only file a gap (`crates/baley/src/host_artifacts/coverage.rs:375-398`), and a `~/` or relative allow entry leaves each verdict its list can open a gap, reported as not judged (`crates/baley/src/host_artifacts/coverage.rs:429-442`). Both read only these settings, in the one document they are given: a setting Claude Code also honours, such as `sandbox.enabledPlatforms` leaving out this platform, or one in another settings file, is not seen. The placement projection carries the installed executable path and the separate versions folder (`crates/baley/src/host_artifacts/placement.rs:290-330`). The coverage judge currently takes only home, config and write-only files, so the installed versions folder needs T17's checks. Install applies the composed result to the settings file, and the guard protects the same placements. The `network` block is T16's, written for the chosen providers; neither the library nor install renders a network key. Build 3 T16 extends the interview with provider choice and typed acknowledgement ([0003](0003-configuration-and-routing.md), CFG-R28). The summary before the final `yes` lists every file to be written. That `yes` records the acknowledgement first, writes API-host allowances through the installation writer second, writes the global `review.reviewers` list third, and runs the settings `config set` last. These are separate writes. A failure stops at that step and reports what was written; re-running the interview reads the completed state and safely applies the remaining changes. It states: Baley never reads your keys. Every program Claude Code starts can see keys in your environment, agents and subagents included. Nothing scrubs a key a command prints. Your code goes to the chosen provider under its terms. The acknowledgement is recorded in the ledger and asked again when the warning changes. A fresh install enables no outside provider; a reinstall keeps acknowledged choices. Install never acknowledges a warning. @@ -214,7 +282,7 @@ Manual `baley update` runs in the foreground whether `updates.auto` is on or off The updater reads `updates.source` from global settings. It is an HTTPS address with no default, set with `baley config set --global updates.source=https://`. An absent value refuses `update-source-unset` before opening the store or claiming. The schema requires `https://` followed by at least one character and rejects whitespace, control characters, queries and fragments. The source uses the development layout and two-line manifest above. No host argument or project is required. The CLI resolves the installation from `HOME`, obtains the UTC time and opens the per-user store (`crates/baley/src/update/command.rs:35-70, 108-161`). -The check takes an `update.check` claim before any fetch. Only a new claim permits a download. It judges the stable link as managed only when the stored target is exactly this installation's absolute `//baley` and following it finds a regular file. It fetches and parses the manifest, then compares the offered version numerically, major first. An equal or lower version records `current` without fetching the binary. A strictly higher version follows these steps (`crates/baley/src/update/command.rs:241-326`, `crates/baley/src/update/installation.rs:186-218`): +The check takes an `update.check` claim before any fetch. Only a new claim permits a download. It judges the stable link as managed only when the stored target is exactly this installation's absolute `//baley` and following it finds a regular file. It fetches and parses the manifest, then compares the offered version numerically, major first. An equal or lower version records `current` without fetching the binary. A strictly higher version follows these steps (`crates/baley/src/update/command.rs:241-326`, `crates/baley/src/update/installation.rs:193-225`): 1. Fetch the plain executable through ADR 0028's `reqwest` client, with one client and a separate current-thread Tokio runtime for the check. Requests use HTTPS only, with no proxy, referer, authorization header, project content or provider credential. A manifest has a 20-second request bound and a 64 KiB body bound; the binary has a ten-minute request bound and a 256 MiB body bound. Connection setup is bounded at 20 seconds. Only a complete HTTP 200 response within the body bound is accepted (`crates/baley/src/update/fetch.rs:12-16, 66-83, 94-190`). 2. Pass the bytes through `verify_download`, which checks SHA-256 against the unsigned manifest, with no flag, setting or environment bypass. #14 replaces this verification seam before any release. @@ -222,7 +290,7 @@ The check takes an `update.check` claim before any fetch. Only a new claim permi 4. Recheck the stable link, create a temporary link beside it and activate with one atomic rename exchange of the two links (`renameat2` with `RENAME_EXCHANGE` on Linux, `renamex_np` with `RENAME_SWAP` on macOS). Sync the stable folder and inspect the displaced link. An unexpected occupant is restored by another exchange. An activation error attempts restoration where an exchange occurred; a failed restoration reports the retained temporary entry for inspection. No running executable is overwritten (`crates/baley/src/update/deliver.rs:159-195, 290-408`). 5. Stop lease renewal and complete the claim with its `update.checked` or `update.failed` event in the same transaction. Only after that completion attempt returns does a successfully activated version run its own hidden `baley update seed`, directly from its staged path, to record its compiled catalog without a provider call. The seed has a 60-second deadline and a 4 KiB output bound. A newer view set raised during seeding therefore cannot fence the older updater's completion attempt. The seed still runs if completion failed; the receipt then reports an incomplete record and the new active version (`crates/baley/src/update/command.rs:215-239`, `crates/baley/src/update/record.rs:51-82`, `crates/baley/src/update/seed.rs:34-101`). -Every version is kept. An activation receipt lists the version folders holding regular binaries, including the active version, in numeric order. #14 owns pruning at activation. New sessions start the newly active server, while running servers keep their executable and loaded instructions. Each new hook call follows the stable path. The hook never rebuilds views, the view set remains 7, and update events go only into `user`, so the hook adds no new event type to the running session's project. Once an update event is recorded in `user`, a binary from before T14 is fenced from writing that project because it does not know the event type. No pre-T14 binary was released. A later binary that raises the ledger epoch can still make an older server read-only under [ADR 0034](../adr/0034-one-server-per-session.md). T15 owns stub installation and the new-session boundary for loading them; T17 qualifies the newer hook beside an older server (`crates/baley/src/ledger/open.rs:17-74`). +Every version is kept. An activation receipt lists the version folders holding regular binaries, including the active version, in numeric order. #14 owns pruning at activation. New sessions start the newly active server, while running servers keep their executable and loaded instructions. Each new hook call follows the stable path. The hook never rebuilds views. The view set is 8, raised once by T15's `install` view, and update events go only into `user`, so the hook adds no new event type to the running session's project. The rise has a cost. A binary at view set 7 is read-only on `user` once a newer binary has written there and brought its views to 8, so it cannot write the guard's records, the model catalog or update outcomes that live in `user`. A project is fenced for older binaries only after a view-set-8 binary has written to it, and install writes only `user`, so the session's own project stays writable for an older server. Binaries built before T14 cannot write `user` once it holds an update event, and binaries built before T15 cannot write it once it holds `install.recorded`, because they do not know the event types. No such binary was released, since only development binaries exist. A later binary that raises the ledger epoch can still make an older server read-only under [ADR 0034](../adr/0034-one-server-per-session.md). `baley install` writes the stubs new sessions load, and a running session keeps what it loaded; T17 qualifies the newer hook beside an older server (`crates/baley/src/ledger/open.rs:19-78`). The claim protocol in section 6 prevents manual and detached checks from overlapping. Only a detached check refuses a day already claimed, including one claimed manually. Manual checks can retry that day under a fresh request once the installation scope is free. @@ -265,19 +333,19 @@ With no owner hold it prints `no update check is held for this installation` and ### baley doctor -- **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:65-90`). -- **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-453`); +- **Inputs:** the resolved home and config folders, the latest install record for `claude-code` and the placements derived from `HOME` and `CLAUDE_CONFIG_DIR`, the same derivation install and the guard use. The command takes no flags, so the host is Claude Code. The executable is always the derived stable path, never the running binary. The install record tells installed from never installed: with no record every artifact is reported not installed, so a machine that has the binary and never ran install still reports each one so and raises nothing. With a record, a stub has its place when the record lists its identity, the registration, the hook and the settings when the record holds them, and the versions folder whenever a record exists. A `HOME` or `CLAUDE_CONFIG_DIR` that cannot place the artifacts, or a record that cannot be read, is reported and raises the exit status to 1, since an unreadable record is never taken for a machine where install never ran (`crates/baley/src/host_doctor/mod.rs:61-120`, `crates/baley/src/ledger/commands.rs:163-190`, `crates/baley/src/host_doctor/report.rs:26-40`). +- **Outputs, built:** the ledger lines first, then a host section headed `Claude Code host checks` (`crates/baley/src/host_doctor/report.rs:26-144`): + - each artifact (the stubs, the registration, the hook and the settings) as not installed when the install record does not show it, 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:353-484`); - 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 registration against the entry Baley renders for this binary, by the one rule install and composition use: the same `command` and `args`, `env` absent or empty, and `type` absent or `stdio`. An entry that sets `env` entries, or is of another transport, is reported as differing, with the entry found (`crates/baley/src/host_doctor/placed.rs:206-222`); - 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`); + - `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-193`); - 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`); + - 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:181-212`); - 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, 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. +- **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 the placement map built from the install record. The doctor reads no key and tests no credential. +- **Exit status:** placements or an install record that cannot be read, 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 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. @@ -499,17 +567,21 @@ sequenceDiagram participant O as Owner participant I as Installer script participant B as Baley command line - participant HC as Claude Code settings and stubs + participant HC as Claude Code settings file and stubs + participant CC as claude command participant L as Ledger participant C as Global config.toml and settings target O->>I: one installer command - I->>I: download, verify signature and checksum, stage binary - I->>B: run through the stable absolute path - B->>HC: merge MCP entry and pre-tool hook using the stable path - B->>HC: write stubs, enabled sandbox and filesystem and file-tool denials - B->>B: take defaults on fresh install, keep acknowledged choices on reinstall - B->>L: seed model catalog and record install - B-->>O: receipt, prerequisites, new session and per-checkout init steps + I->>I: download, verify checksum, stage binary + I->>B: run baley install through the stable absolute path + B->>B: take the install lock, read the install record and each placed file + B->>HC: write the settings file with hook, deny rules and sandbox, then the stubs + B->>CC: claude mcp add-json at user scope, after a remove when replacing Baley's older entry + CC->>CC: write mcpServers.baley in its own configuration file + B->>L: seed the model catalog + B->>L: record install.recorded when it differs from the latest record + B-->>O: receipt, gaps with fixes, new session and per-checkout init steps + Note over O,C: Planned for T16, not built O->>B: later config interview, choose providers and type acknowledgement B-->>O: summary naming every file and change, ask final confirmation O->>B: yes @@ -520,7 +592,7 @@ sequenceDiagram Note over B,C: Stop at the first failure and report completed writes, rerunning is safe ``` -*Figure 7. Planned one-command installation and T16 provider selection. The final confirmation authorizes separate writes in order; a failure stops that sequence. Default setup never supplies acknowledgement. The released installer verifies every download through #14's verifier; T14 and T17 use unsigned development artifacts only before #14.* +*Figure 7. The one-command installation as built, and T16's provider selection as planned. The script runs `baley install` through the stable path. Install takes its lock, reads the install record and the placed files, then writes the settings file and the stubs, asks `claude` to register the server, seeds the catalog and records the result. A refusal ends it before any write, and a write that fails stops the run and leaves a partial receipt. Default setup never supplies acknowledgement. The planned final confirmation authorizes separate writes in order, and a failure stops that sequence. The released installer verifies every download through #14's verifier; T14 and T17 use unsigned development artifacts only before #14.* ```mermaid sequenceDiagram @@ -577,10 +649,10 @@ sequenceDiagram U-->>O: receipt with versions, seed result and any incomplete record end Note over S,V: Running servers keep their version and loaded instructions - Note over S,V: The next hook uses the stable path without raising the view set + Note over S,V: The next hook uses the stable path and never rebuilds views ``` -*Figure 8. Manual and opt-in detached checks share the claim, verification and activation path. Only a detached check applies the daily refusal, and a manual claim also covers the day's automatic check. Delivery stops on failure. The completion attempt returns before the newly activated binary seeds its catalog, even when recording failed, so a newer view set cannot fence that attempt. The detached process discards its receipt, and the server never waits for it. Running servers keep their binaries, the next hook follows the stable path, and the view set stays at 7. T15 installs stubs for new sessions and T17 qualifies the newer hook beside an older server. #14 supplies signature verification at both download-to-staging seams before any release.* +*Figure 8. Manual and opt-in detached checks share the claim, verification and activation path. Only a detached check applies the daily refusal, and a manual claim also covers the day's automatic check. Delivery stops on failure. The completion attempt returns before the newly activated binary seeds its catalog, even when recording failed, so a newer view set cannot fence that attempt. The detached process discards its receipt, and the server never waits for it. Running servers keep their binaries, the next hook follows the stable path, and the view set is 8, raised once by T15's `install` view and not by an update. `baley install` writes the stubs new sessions load, and T17 qualifies the newer hook beside an older server. #14 supplies signature verification at both download-to-staging seams before any release.* ## 9. Settings From a77a304b6e69c91447e80bf743c0544f662d58e8 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 14:07:09 -0400 Subject: [PATCH 25/35] docs(design): list the install record's keys and the built wiring in design 0012 The records section had an install.recorded table that matched no code, a view that mixed install records with update outcomes, and build rows that still said the writer, the script's call and the guard's placements were unbuilt. They now follow what install records, what the view holds and what is built. --- docs/design/0012-host-interface.md | 50 ++++++++++++++++-------------- 1 file changed, 27 insertions(+), 23 deletions(-) diff --git a/docs/design/0012-host-interface.md b/docs/design/0012-host-interface.md index e0eebcd9..2014c81e 100644 --- a/docs/design/0012-host-interface.md +++ b/docs/design/0012-host-interface.md @@ -353,16 +353,21 @@ With no owner hold it prints `no update check is held for this installation` and ### install.recorded (event, per-user, `install` stream) +`baley install` records the ownership facts a run left, as the owner. The event goes on stream `install` in `user` at type version 1, with no upcasters, under command kind `install.record`, actor `owner`, policy version 0 and no caller. It is appended only when its payload differs from the latest record for the host, and a run that planned from an older record than the one now stored appends nothing (`crates/baley/src/install/event.rs:13-21, 111-140`, `crates/baley/src/install/record.rs:17, 47-118`). + | Field | Type | Meaning | |---|---|---| -| `host` | `claude-code` | | -| `binary_version` | version | The installed version | +| `host` | `claude-code` | The host whose wiring was installed | +| `binary_version` | version | The version of the binary that ran install | | `binary_path` | absolute path | The stable executable path | -| `registered` | table | Where the MCP registration and the hook were written | -| `stubs` | list | Path and hash of every stub rendered | -| `sandbox` | table | Resolved protected paths, provider hosts and the settings written | -| `defaults` | table | Settings defaults applied, with no synthetic provider acknowledgement | -| `updates` | table | Opt-in state and staged version, if any | +| `complete` | bool | True only when every artifact is Baley's after the run, there is no gap, the catalog seed is recorded and no write failed | +| `registered` | table | `registration`: the file's path and the whole `mcpServers.baley` entry that is Baley's after the run, or null. `hook`: the settings file's path and the whole `PreToolUse` item that is Baley's, or null | +| `stubs` | list | `identity`, `path` and `sha256` of every stub that is Baley's after the run, in manifest order. A stub whose write failed keeps the previous record's entry | +| `sandbox` | table or null | `settings_path`, `sha256` of the settings file's bytes after the run, Baley's `home` and `config` folders, `write_only_files`, `write_only_folders`, and the entries Baley owns in `permissions_deny`, `deny_read` and `deny_write`. `held_back` says why the sandbox block was left out, or is null when it was written. Null when the settings file holds none of Baley's entries | +| `defaults` | table | Always empty: install applies no setting and records no provider acknowledgement | +| `updates` | table | `auto`, the effective `updates.auto` or null when the global settings could not be read, and `staged_version`, the version the stable path runs or null | + +The whole entries are kept so a later run can recognise an older Baley entry and replace it. A matching name alone never does (section 5). ### The caller on every recorded event @@ -383,11 +388,11 @@ Handle, operation, request id, claim, started at, state, result reference. | View | Key | Content | |---|---|---| -| `install` | user, host | The current install record and latest recorded update outcome | +| `install` | host, in `user` | The latest `install.recorded` for the host, as `{host, seq, payload}`. It holds install records only: the latest update outcome is read from its stream (`crates/baley/src/install/view.rs:11-90`) | ### Update checks -Update checks are ordinary events in the per-user project `user`, with actor `owner` for a manual `baley update` and `baley` for a detached check, policy version 0 and no caller. The store records the attempt as `command.claimed` on `command/update.check`. Completion records one `update.checked` or `update.failed`, version 1, on `install` and closes the request with `command.completed` in the same transaction. Reconciliation also records the store's `command.reconciled`. There is no separate update-check table, `install` view or projector. The `install` view above and `install.recorded` remain the T15 and T16 installation contract; update events are read from their stream (`crates/baley/src/update/events.rs:17-30`, `crates/baley/src/update/record.rs:32-82`). +Update checks are ordinary events in the per-user project `user`, with actor `owner` for a manual `baley update` and `baley` for a detached check, policy version 0 and no caller. The store records the attempt as `command.claimed` on `command/update.check`. Completion records one `update.checked` or `update.failed`, version 1, on `install` and closes the request with `command.completed` in the same transaction. Reconciliation also records the store's `command.reconciled`. There is no separate update-check table, and update events have no projector. The `install` view above holds install records only; update events are read from their stream (`crates/baley/src/update/events.rs:17-30`, `crates/baley/src/update/record.rs:32-82`). | Event | Inline facts | |---|---| @@ -665,7 +670,7 @@ sequenceDiagram Both update settings are in the standard schema used by `baley config set` and the settings interview (`crates/baley-core/src/policy/schema.rs:188-196, 332-346`). `owner.name` remains the approval build's setting. -No setting selects, loads or overrides an instruction, because every instruction is compiled in (HST-R11). No setting chooses an artifact's content or placement either: the content is rendered from the compiled tables and supplied values (section 10), and `baley install` supplies the placement (Build 3 T15). +No setting selects, loads or overrides an instruction, because every instruction is compiled in (HST-R11). No setting chooses an artifact's content or placement either: the content is rendered from the compiled tables and supplied values (section 10), and the placement is derived from `HOME` and `CLAUDE_CONFIG_DIR` by `baley install`, the guard and the doctor alike, not set (section 5). ## 10. Instructions served @@ -681,24 +686,24 @@ The text of every instruction is owned by the area it serves; this area serves i Each instruction is compiled into the binary with an identity, a version and a hash. A front door's identity is its help-table name, and the read contract's is `bal-read-contract`. The version is a number pinned beside the text, raised whenever the text changes, and the hash is the lowercase hex SHA-256 of the text, which is every part joined. `instruction` serves the text by identity in parts, and answers an identity whose work is not built yet with the build that owns it. A front door's text asks the session to send its identity as `instruction` on each `baley_apply` call made under it. -Claude Code loads four artifacts Baley renders as content. Each is built from the compiled tables and supplied values only, and the library writes none of them. +Claude Code loads four artifacts Baley renders as content. Each is built from the compiled tables and supplied values only. The library renders them and writes none of them, and `baley install` writes them (section 5). | Artifact | Inputs | Content | Requirement | Placement | |---|---|---|---|---| -| Skill stub, one per served front door (`bal-help`, `bal-capture`) | The compiled help table and instruction registry | Frontmatter `name` and `description`, then the one-line `baley_query` `instruction` call. There is no `allowed-tools` line until T15 settles whether stubs carry `mcp__baley__baley_query` (`crates/baley/src/host_artifacts/stubs.rs:20-44`) | HST-R12 | A supplied absolute path per stub, from `baley install` (Build 3 T15) | -| MCP registration | The absolute executable, and whether to set `alwaysLoad` | `mcpServers.baley` with `command` the executable and `args` `["serve"]`, `alwaysLoad: true` only on request, and no `cwd`, `env`, URL, headers or token (`crates/baley/src/host_artifacts/registration.rs:7-26`) | HST-R17, HST-R20 | Supplied by `baley install` (T15) | -| Guard hook | The absolute executable | One `PreToolUse` item matching `Bash\|Monitor\|PowerShell\|Read\|Grep\|Glob\|Write\|Edit\|NotebookEdit`, with one command hook that runs the single-quoted executable and `guard` within 10 seconds (`crates/baley/src/host_artifacts/hook.rs:13-50`) | GRD-R1 | Supplied by `baley install` (T15) | -| Sandbox and deny settings | The home and config folders, the executable, the write-only files and the write-only folders | The shape in section 5 without `sandbox.network`: home and config denied to reads and writes, the executable and each write-only file denied to writes, and the versions folder denied to writes with a folder rule and no read rule (`crates/baley/src/host_artifacts/security.rs:91-109, 143-222`) | GRD-R13, EVD-R24, CFG-R11, HST-R17 | Supplied by `baley install` (T15) | +| Skill stub, one per served front door (`bal-help`, `bal-capture`) | The compiled help table and instruction registry | Frontmatter `name` and `description`, then the one-line `baley_query` `instruction` call. There is no `allowed-tools` line, so each `baley_query` call is approved by the owner (`crates/baley/src/host_artifacts/stubs.rs:20-44`) | HST-R12 | `/skills//SKILL.md`, written by `baley install` | +| MCP registration | The absolute executable, and whether to set `alwaysLoad` | `mcpServers.baley` with `command` the executable and `args` `["serve"]`, `alwaysLoad: true` only on request, and no `cwd`, `env`, URL, headers or token (`crates/baley/src/host_artifacts/registration.rs:7-26`) | HST-R17, HST-R20 | `mcpServers.baley` in `$CLAUDE_CONFIG_DIR/.claude.json`, else `~/.claude.json`, written by Claude Code through `claude mcp add-json --scope user`, which `baley install` runs. Install sets no `alwaysLoad` | +| Guard hook | The absolute executable | One `PreToolUse` item matching `Bash\|Monitor\|PowerShell\|Read\|Grep\|Glob\|Write\|Edit\|NotebookEdit`, with one command hook that runs the single-quoted executable and `guard` within 10 seconds (`crates/baley/src/host_artifacts/hook.rs:13-50`) | GRD-R1 | The `hooks.PreToolUse` array of `/settings.json`, written by `baley install` | +| Sandbox and deny settings | The home and config folders, the executable, the write-only files and the write-only folders | The shape in section 5 without `sandbox.network`: home and config denied to reads and writes, the executable and each write-only file denied to writes, and the versions folder denied to writes with a folder rule and no read rule (`crates/baley/src/host_artifacts/security.rs:91-109, 143-222`) | GRD-R13, EVD-R24, CFG-R11, HST-R17 | The `sandbox` and `permissions.deny` keys of the same settings file, written by `baley install` | -The manifest lists each stub's host, identity, bytes and lowercase hex SHA-256, and no placement enters it, so the same binary always gives the same manifest (`crates/baley/src/host_artifacts/stubs.rs:46-88`). The placement map links each artifact to a supplied absolute path or `Unknown` and yields the files the doctor expects and the protected list, which holds the placed stubs, the placed settings and registration files and the executable; an `Unknown` placement yields neither and is reported as not installed, never as protection. The versions folder has its own supplied placement or `Unknown` and yields a write-only folder, never an expected file. The map refuses a stub whose file, with `.` and `..` resolved from the text and no link followed, is another artifact's file or the executable (`crates/baley/src/host_artifacts/placement.rs:154-223, 250-339`). +The manifest lists each stub's host, identity, bytes and lowercase hex SHA-256, and no placement enters it, so the same binary always gives the same manifest (`crates/baley/src/host_artifacts/stubs.rs:46-88`). The placement map links each artifact to a supplied absolute path or `Unknown` and yields the files the doctor expects and the protected list, which holds the placed stubs, the placed settings and registration files and the executable; an `Unknown` placement yields neither and is reported as not installed, never as protection. Install and the guard build the map from the environment (`HOME`, `CLAUDE_CONFIG_DIR` and the compiled manifest), and the doctor builds it from the same derivation and leaves an artifact `Unknown` until the install record shows it (`crates/baley/src/host_artifacts/installed.rs:55-107`, `crates/baley/src/host_doctor/mod.rs:61-120`). The versions folder has its own supplied placement or `Unknown` and yields a write-only folder, never an expected file. The map refuses a stub whose file, with `.` and `..` resolved from the text and no link followed, is another artifact's file or the executable (`crates/baley/src/host_artifacts/placement.rs:154-223, 250-339`). ## 11. Build status -The library holds the per-session server (`crates/baley/src/mcp/`), and `baley serve` starts it. The same module prepares a project read or write from the session's own project (`crates/baley/src/mcp/prepare.rs`), and two served operations reach it: `capture`, a write, and `document`, a read (`crates/baley/src/mcp/handler.rs:154-179`). The library also holds the guard hook (`crates/baley/src/guard_hook/`), which `baley guard` runs once per tool call ([0010](0010-guard.md) section 11). It renders Claude Code's artifacts as content and writes none of them (`crates/baley/src/host_artifacts/`, section 10), and `baley artifact` prints each one to standard output (`crates/baley/src/main.rs:27-28, 113`, `crates/baley/src/host_artifacts/command.rs:107-243`). The binary also holds the inherited engine, parked for Build 9 to delete (`crates/baley/src/inherited.rs:1-4`). Nothing in production reaches it, and its tests still run. +The library holds the per-session server (`crates/baley/src/mcp/`), and `baley serve` starts it. The same module prepares a project read or write from the session's own project (`crates/baley/src/mcp/prepare.rs`), and two served operations reach it: `capture`, a write, and `document`, a read (`crates/baley/src/mcp/handler.rs:154-179`). The library also holds the guard hook (`crates/baley/src/guard_hook/`), which `baley guard` runs once per tool call ([0010](0010-guard.md) section 11). It renders Claude Code's artifacts as content (`crates/baley/src/host_artifacts/`, section 10), and `baley install` writes them (`crates/baley/src/install/`, section 5). `baley artifact` prints each one to standard output and writes no file (`crates/baley/src/main.rs:27-28, 115`, `crates/baley/src/host_artifacts/command.rs:107-243`). The binary also holds the inherited engine, parked for Build 9 to delete (`crates/baley/src/inherited.rs:1-4`). Nothing in production reaches it, and its tests still run. | Requirement | Status | Where | |---|---|---| -| HST-R1 | Built | `baley serve` runs one stdio server for the session (`crates/baley/src/main.rs:64-65, 194-207`, `crates/baley/src/mcp/serve.rs:146-246`), and it advertises only the two tested revisions (`crates/baley/src/mcp/tools.rs:65-68`, `crates/baley/src/mcp/handler.rs:186-188`) | +| HST-R1 | Built | `baley serve` runs one stdio server for the session (`crates/baley/src/main.rs:66-67, 198-211`, `crates/baley/src/mcp/serve.rs:146-246`), and it advertises only the two tested revisions (`crates/baley/src/mcp/tools.rs:65-68`, `crates/baley/src/mcp/handler.rs:186-188`) | | HST-R2, HST-R3 | Withdrawn | Nothing to build: each session starts its own stdio server (ADR 0034) | | HST-R4 | Built | The 2025 decoder reads `initialize` and the 2026 decoder reads the request's `_meta` (`crates/baley/src/mcp/client.rs:24-40`). The host is selected per call (`crates/baley/src/mcp/client.rs:95-126`, `crates/baley/src/mcp/handler.rs:97-122, 190-198, 227`), and a missing or unsupported client is answered `failed` `unknown-host` before the queue (`crates/baley/src/mcp/gate.rs:71-110, 162-178`). The supported hosts are `Host::ALL` (`crates/baley-core/src/policy/schema.rs:134-140`) | | HST-R5 | Built | Three tools in a fixed order (`crates/baley/src/mcp/tools.rs:83-120`), append-only operation names, with `instruction` appended last to the query baseline (`crates/baley/src/mcp/operations.rs:127`), held by the expected spellings and the test that checks both baselines against them (`crates/baley/src/mcp/operations.rs:479-592, 605-609`), a flat schema that types each served operation's arguments from the request shapes `schema` serves, with no combinator at the top (`crates/baley/src/mcp/tools.rs:122-227`, held by the tests at `crates/baley/src/mcp/tools.rs:303-477`), plus the `schema` operation, which serves each served operation's request shape, the `capture` and `document` shapes included (`crates/baley/src/mcp/operations.rs:290-327, 404-474`, `crates/baley/src/mcp/capture.rs:43-61`, `crates/baley/src/mcp/document.rs:26-49`), and `failed` as its own arm of the envelope (`crates/baley/src/envelope.rs:82-153`). The gate raises a protocol error only for an unknown tool (`crates/baley/src/mcp/gate.rs:77, 153-160`) | @@ -707,14 +712,14 @@ 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:250-263, 290-330`). `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:146-183`). 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-133`). 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-453`), over a placement map T15 supplies. Until then it reports each stub as not installed (`crates/baley/src/host_doctor/mod.rs:65-90`). | +| 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:250-263, 290-330`). `baley install` writes the stubs to `/skills//SKILL.md` through the digest-checked replacement. It replaces one only when its bytes equal this binary's render or a hash the install record holds for that identity, and refuses the whole install otherwise (`crates/baley/src/install/stubs.rs:26-71`, `crates/baley/src/install/plan.rs:109-256`, `crates/baley/src/install/apply.rs:46-67`). The guard's protected list holds each placed stub, derived from the environment and passed at `guard_hook::context::judge` (`crates/baley/src/guard_hook/context.rs:103-174`). 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:353-484`), over a map built from the install record, so a stub the record does not list is reported as not installed (`crates/baley/src/host_doctor/mod.rs:61-120`). `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:150-187`). A running session keeps the stubs it loaded and a new session loads the installed ones, which no live run has yet observed (T17). The agent definition renderer, the effort table and the route records are Build 4's. | | 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:131`, `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`), `models` (`list`, `add`, `remove` and `update`) and `update` (the foreground check and `resolve`, with hidden `detached` and `seed` subcommands) (`crates/baley/src/main.rs:23-98`, `crates/baley/src/update/command.rs:35-54`). The `exec` execution group is Build 5's. It has no `service` command, and the other commands are later builds. | -| HST-R17 | Partly built | Binary delivery is built for unsigned development artifacts: the installer verifies SHA-256, stages versions and places the stable link (`install.sh:37-210`), and `baley update` claims, downloads, verifies, stages, exchanges the active link, attempts completion with an update event and only then launches the new binary's catalog seed (`crates/baley/src/update/command.rs:108-326`, `crates/baley/src/update/deliver.rs:197-253, 325-408`). `baley serve` starts the opt-in detached check (`crates/baley/src/mcp/serve.rs:163-178`, `crates/baley/src/update/detached.rs:9-58`). Daily claims, update outcome events and owner recovery through `baley update resolve` are built (`crates/baley/src/update/claim.rs:62-169`, `crates/baley/src/update/events.rs:17-30, 127-180`, `crates/baley/src/update/reconcile.rs:200-340`). The view set stays at 7 with no install projector, and the hook opens without rebuilding views (`crates/baley/src/ledger/open.rs:17-74`). The versions folder is carried separately as a write-only folder and rendered with no read denial (`crates/baley/src/host_artifacts/placement.rs:290-330`, `crates/baley/src/host_artifacts/security.rs:143-222`). The registration, hook, security proposal, composition and coverage judge are built (`crates/baley/src/host_artifacts/registration.rs:12-26`, `crates/baley/src/host_artifacts/hook.rs:21-37`, `crates/baley/src/host_artifacts/compose.rs:98-126`, `crates/baley/src/host_artifacts/coverage.rs:281-356`). `baley install`, its call from the script, the wiring writer and passing the placement projection to the live guard remain T15's. `install.recorded` and its view are not built and remain with T15 and T16. T16 owns provider choice, typed acknowledgement, installation-writer API-host allowances and the separate global reviewer-list write before settings `config set`. `hooks/hooks.json:3-14` stays hand-written, with its nine-tool matcher and 10-second timeout held to the renderer by a test (`crates/baley/src/host_artifacts/hook.rs:104-118`) until T15 writes it. The runtime doctor gathers, judges and reports the documents and ledger it is given, and its unknown placement reports artifacts as not installed (`crates/baley/src/host_doctor/mod.rs:65-90, 142-181, 322-453`, `crates/baley/src/host_doctor/report.rs:26-148`). T17 owns installed-result checks of the stable path and version, registration, hook, settings, provider hosts, warning acknowledgement and update setting, plus live qualification of the script and the newer hook beside an older server. #14 owns signature verification at both `verify_download` seams, the trust root, release format and publication. Both verifiers must be supplied and qualified before any release. #14 also owns pruning at activation. Removal of the built key paths belongs to Build 4 (#25). | +| 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:135`, `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, `install`, which writes Claude Code's wiring at the stable path (`crates/baley/src/install/command.rs:28-52, 116-218`), `init`, `config` (`show`, `set` and `interview`), `models` (`list`, `add`, `remove` and `update`) and `update` (the foreground check and `resolve`, with hidden `detached` and `seed` subcommands) (`crates/baley/src/main.rs:23-100`, `crates/baley/src/update/command.rs:35-54`). The `exec` execution group is Build 5's. It has no `service` command, and the other commands are later builds. | +| HST-R17 | Partly built | Binary delivery is built for unsigned development artifacts: the installer verifies SHA-256, stages versions and places the stable link (`install.sh:37-210`), and `baley update` claims, downloads, verifies, stages, exchanges the active link, attempts completion with an update event and only then launches the new binary's catalog seed (`crates/baley/src/update/command.rs:108-326`, `crates/baley/src/update/deliver.rs:197-253, 325-408`). `baley serve` starts the opt-in detached check (`crates/baley/src/mcp/serve.rs:163-178`, `crates/baley/src/update/detached.rs:9-58`). Daily claims, update outcome events and owner recovery through `baley update resolve` are built (`crates/baley/src/update/claim.rs:62-169`, `crates/baley/src/update/events.rs:17-30, 127-180`, `crates/baley/src/update/reconcile.rs:200-340`). The view set is 8, raised once by the install projector, and the hook opens without rebuilding views (`crates/baley/src/ledger/open.rs:19-78`, `crates/baley/src/install/view.rs:11-90`). The versions folder is carried separately as a write-only folder and rendered with no read denial (`crates/baley/src/host_artifacts/placement.rs:290-330`, `crates/baley/src/host_artifacts/security.rs:143-222`). The registration, hook, security proposal, composition and coverage judge are built (`crates/baley/src/host_artifacts/registration.rs:12-26`, `crates/baley/src/host_artifacts/hook.rs:21-36`, `crates/baley/src/host_artifacts/compose.rs:98-126`, `crates/baley/src/host_artifacts/coverage.rs:281-356`). `baley install` is built: it writes the settings file with the hook, the deny rules and the sandbox block, writes the stubs, runs `claude mcp add-json --scope user baley`, seeds the catalog and records `install.recorded` (`crates/baley/src/install/command.rs:116-218`, `crates/baley/src/install/plan.rs:109-256`, `crates/baley/src/install/registration.rs:66-115`, `crates/baley/src/install/record.rs:47-118`). It proves ownership from the install record before replacing anything, refuses an artifact it cannot prove, reports gaps as a partial install, and holds one lock for its whole run (`crates/baley/src/install/stubs.rs:26-71`, `crates/baley/src/install/settings.rs:352-489`, `crates/baley/src/install/command.rs:54-114`). The script's last step runs it through the stable path (`install.sh:212`). The live guard derives the installed placements from the environment and protects them (`crates/baley/src/guard_hook/context.rs:103-174`). No hook file is tracked in the repository any more, and no test holds one to the renderer: the hook exists as the item install writes from the renderer (`crates/baley/src/host_artifacts/hook.rs:21-36`, `crates/baley/src/guard_budget.rs:20-22`). Binaries built before T15 cannot write `user` once it holds an `install.recorded`. T16 owns provider choice, typed acknowledgement, installation-writer API-host allowances and the separate global reviewer-list write before settings `config set`. The runtime doctor gathers, judges and reports the documents and ledger it is given over a placement map built from the install record, so an artifact the record does not show is reported as not installed (`crates/baley/src/host_doctor/mod.rs:61-120, 172-211, 353-484`, `crates/baley/src/host_doctor/report.rs:26-144`). T17 owns installed-result checks of the stable path and version, registration, hook, settings, provider hosts, warning acknowledgement and update setting, plus live qualification of the script and the newer hook beside an older server. #14 owns signature verification at both `verify_download` seams, the trust root, release format and publication. Both verifiers must be supplied and qualified before any release. #14 also owns pruning at activation. 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:73-100`). 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:243-284`, `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:132-142`, `crates/baley/src/guard_budget.rs:33-115`). The detached updater has no process deadline and is never waited on (`crates/baley/src/update/detached.rs:21-25, 47-50`); its downloads are bounded, and the catalog-seed child has a 60-second deadline (`crates/baley/src/update/fetch.rs:12-16`, `crates/baley/src/update/seed.rs:34-44`). 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-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:243-284`, `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:132-142`, `crates/baley/src/guard_budget.rs:34-116`). The detached updater has no process deadline and is never waited on (`crates/baley/src/update/detached.rs:21-25, 47-50`); its downloads are bounded, and the catalog-seed child has a 60-second deadline (`crates/baley/src/update/fetch.rs:12-16`, `crates/baley/src/update/seed.rs:34-44`). 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`) | ## 12. Open questions @@ -724,4 +729,3 @@ The library holds the per-session server (`crates/baley/src/mcp/`), and `baley s | Whether elicitation reaches the person from a subagent on Claude Code | A probe on Claude Code before the adapter may choose it (HST-R9) | | The report file formats Baley reads beside the exit code, per language | The process port's first languages, recorded in [0006](0006-execution.md) | | Which signature format, installer-side verifier available before Baley exists, and updater verifier authenticate the signed checksum manifest? | The release design [#14](https://github.com/crenshawdev/baley/issues/14) owns the signature format, trust root and both verifier implementations and qualification, including any new dependency. The shell installer needs an available verifier or a separately authenticated bootstrap verifier; POSIX shell alone cannot verify a signature. The updater may need a crate dependency. Neither is selected here. Both delivery paths have `verify_download` between download and staging, checking unsigned development artifacts only. T17 qualifies delivery with those artifacts. No release exists before #14. | -| Which Claude Code settings file receives the sandbox and file-tool deny rules, and how does its precedence affect existing settings? | Build 3 T15, at the installation writer; T17 checks the effective installed settings. | From 25870ad2d040e8ee73d26ba54767c071c42ed22b Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 14:09:39 -0400 Subject: [PATCH 26/35] docs(design): say the guard protects the installed placements and install proves ownership The guard design still told the reader the hook file is hand-written, the protected list waits for the installer and the doctor reads an unknown placement. The upkeep design left ownership to a build that has landed. Both now describe the derived placements, the hook install writes and the record-based ownership proof. --- docs/design/0010-guard.md | 42 +++++++++---------- .../0015-repository-upkeep-and-build-gate.md | 8 ++-- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/docs/design/0010-guard.md b/docs/design/0010-guard.md index a01820cd..afce3018 100644 --- a/docs/design/0010-guard.md +++ b/docs/design/0010-guard.md @@ -55,9 +55,9 @@ In the component view of [0002](0002-system-design.md) (Figure 4) the guard is t | GRD-R6 | For a detected commit whose target GRD-R3 establishes, when git or the branch at that target cannot be read, the guard passes, prints a loud line on stderr, and records a guard failure. When `git.guard_hard_fail` is set and the branch is provably protected from what could be read, it denies instead. This rule handles failures of those reads, not command shapes outside GRD-R3: a command that moves a detected commit by a means outside that list is judged as though it ran from the hook cwd, and a declined or undetected command passes with nothing recorded. | A guard that cannot decide must not silently become a wall or a hole. Commands outside GRD-R3's recognised shapes are a stated limit of the scan. | | Active | | GRD-R7 | When a settings file is torn, the guard asks, naming the file. A nearest `baley.toml` that is not a regular file or cannot be read is torn, and a dangling link is named as a link to a missing file. None hands the project to an outer file. Two denials from the remembered policy still stand: a remembered `refuse` on a branch git read that is in the remembered protected list, and a remembered hard fail on a provably protected branch when git cannot read the branch. A remembered `allow` or `ask` never relaxes the ask. The remembered policy keeps only denials, never an allow. | Torn settings must not open the door. | CFG-R9 | Active | | GRD-R8 | Every `ask`, `deny` and guard failure is recorded before it is answered, for every tool: a path tool's denial and a `PowerShell` ask are recorded as a commit's answer is. The record holds the host, the session and the call id; `CLAUDE_PROJECT_DIR` as given and the working directory, as separate facts; the tool and the digest of the input fields the guard read, never the command; the nullable `target` as text, holding a path tool's resolved target or a redirected commit's resolved directory; the verb, the branch, the settings in force, the outcome and the reason. When HEAD's copy of `baley.toml` is torn, the record keeps Baley's words for it with git's stderr excerpt replaced by `[redacted]`, while the live answer keeps the bounded excerpt. Records go to the per-user project `user` at policy version 0, so recording needs no project in this machine's ledger and admits no checkout. A plain pass, a command the scan declines and a `Monitor` watch record nothing, and input the guard cannot read is denied with nothing recorded, since it carries no call to record it by. | The record shows what the guard held and why, and keeps no command text and no output of git beyond Baley's own words. | SYS-P6 | Active | -| GRD-R9 | When the decision cannot be recorded, an `ask` becomes `deny` with the reason that the guard could not record its decision, and a loud stderr line reports the recording failure. A decision is unrecordable when a store read or write fails, a guard store that stays busy past its storage time included; when `user`'s views need a rebuild, and then the line names `baley rebuild user`; when the call has no envelope or call id, or caller text (call id, working directory, `CLAUDE_PROJECT_DIR` or session) cannot be recorded as given; when its call id was already recorded for another input digest, project directory or working directory; when a redirected commit's checkout root cannot be read, was not walked or is not UTF-8; when the session project root or cwd checkout root is not UTF-8; when settings have no bound session project or torn settings lack a branch or git verb; when the transaction returns successfully without staging or replaying an answer; or when Baley's home folder cannot be resolved. When the transaction returns successfully without staging or replaying an answer, the stderr line reports a call-id clash: the call id was answered before for another input, project directory or cwd. Nothing is appended for an unrecordable decision, so one call id never has two records. A `deny` stays a deny, a pass on failure stays a loud pass and a plain pass stays a pass. A decision that was recorded is not changed, so a recorded ask about torn settings stays an ask. | An unrecorded ask is the gap the record exists to close. | GRD-R8 | Active | +| GRD-R9 | When the decision cannot be recorded, an `ask` becomes `deny` with the reason that the guard could not record its decision, and a loud stderr line reports the recording failure. A decision is unrecordable when a store read or write fails, a guard store that stays busy past its storage time included; when `user`'s views need a rebuild, and then the line names `baley rebuild user`; when the call has no envelope or call id, or caller text (call id, working directory, `CLAUDE_PROJECT_DIR` or session) cannot be recorded as given; when its call id was already recorded for another input digest, project directory or working directory; when a redirected commit's checkout root cannot be read, was not walked or is not UTF-8; when the session project root or cwd checkout root is not UTF-8; when settings have no bound session project or torn settings lack a branch or git verb; when the transaction returns successfully without staging or replaying an answer; or when the protected paths cannot be resolved (Baley's home or config folder, or the installed placements). When the transaction returns successfully without staging or replaying an answer, the stderr line reports a call-id clash: the call id was answered before for another input, project directory or cwd. Nothing is appended for an unrecordable decision, so one call id never has two records. A `deny` stays a deny, a pass on failure stays a loud pass and a plain pass stays a pass. A decision that was recorded is not changed, so a recorded ask about torn settings stays an ask. | An unrecorded ask is the gap the record exists to close. | GRD-R8 | Active | | GRD-R10 | A call whose host, session and call id match a recorded answer, with the same input digest, project directory and working directory, gets that answer again from the record, even after the policy changed. A command with a commit or push verb, or a `PowerShell` call, in a project is looked up before any git or policy read, and the transaction that would record a call looks it up again before it appends, so two deliveries racing each other get one record and one answer. | The host may deliver a call twice; the answer must not differ. | EVD-R26 | Active | -| GRD-R11 | The file guard denies a `Write`, `Edit` or `NotebookEdit` call whose target is inside or is one of these protected paths: Baley's home folder; Baley's config folder (the global settings file `config.toml`, [0003](0003-configuration-and-routing.md) CFG-R2); the `baley.toml` of the session's project and the `baley.toml` of the checkout the hook's working directory is in, by path or by file identity, but no other file of that name; the stub paths Baley supplies; the Claude Code settings and registration files Baley places; the supplied installed executable path; and the staged-version folder once T14 defines it. The placement projection supplies the stub paths, the placed settings and registration files and the installed executable path, and T15 passes that list to the guard; T14 adds the staged-version folder. It judges them from any working directory, with or without a project. Path resolution canonicalizes the existing prefix, reads both the spelling given and the spelling with each backslash taken as a slash, and refuses an empty value, control bytes, a leading `//` or drive-letter prefix, and a non-directory parent. It judges containment by component and by the (device, inode) identity of existing ancestors, so a sibling such as `baley-old` is not inside `baley` and a differently cased spelling on a case-insensitive volume is still caught. A path it cannot resolve is denied. During an active dispatch, a path outside the dispatch's lease is denied (EXE-R8). Build 5 owns the lease: the decision takes it as an input whose only value today is no active dispatch, and Build 5 defines what a lease names and the deny for a write outside it. | The owner sets policy, Baley renders stubs, and the lease means something as the write happens. | CFG-R11, ADR 0009, EXE-R8 | Active | +| GRD-R11 | The file guard denies a `Write`, `Edit` or `NotebookEdit` call whose target is inside or is one of these protected paths: Baley's home folder; Baley's config folder (the global settings file `config.toml`, [0003](0003-configuration-and-routing.md) CFG-R2); the `baley.toml` of the session's project and the `baley.toml` of the checkout the hook's working directory is in, by path or by file identity, but no other file of that name; the stub paths Baley supplies; the Claude Code settings and registration files Baley places; the supplied installed executable path; and the staged-version folder, which is protected as a write-only folder: a write inside it is denied and a read is left alone. The guard derives the stub paths, the placed settings and registration files, the installed executable path and the staged-version folder from `HOME` and `CLAUDE_CONFIG_DIR`, the derivation `baley install` and the doctor share, and reads no store for them. When it cannot derive them it can clear no path call and denies the path tools. It judges the paths from any working directory, with or without a project. Path resolution canonicalizes the existing prefix, reads both the spelling given and the spelling with each backslash taken as a slash, and refuses an empty value, control bytes, a leading `//` or drive-letter prefix, and a non-directory parent. It judges containment by component and by the (device, inode) identity of existing ancestors, so a sibling such as `baley-old` is not inside `baley` and a differently cased spelling on a case-insensitive volume is still caught. A path it cannot resolve is denied. During an active dispatch, a path outside the dispatch's lease is denied (EXE-R8). Build 5 owns the lease: the decision takes it as an input whose only value today is no active dispatch, and Build 5 defines what a lease names and the deny for a write outside it. | The owner sets policy, Baley renders stubs, and the lease means something as the write happens. | CFG-R11, ADR 0009, EXE-R8 | Active | | GRD-R12 | The answer adapter renders each answer in Claude Code's pre-tool hook form. An `ask` or `deny` is a `hookSpecificOutput` object with `hookEventName` `PreToolUse`, `permissionDecision` `ask` or `deny`, and the reason as `permissionDecisionReason`, cut to 10,000 bytes on a character boundary, on stdout with exit 0. A plain pass prints nothing and exits 0, so the host's own permission rules decide. It is never `allow`, which would skip them. A pass on failure prints its reason as one stderr line and exits 0. When an `ask` or `deny` cannot be written to stdout, the guard exits 2 with the reason on stderr, which blocks the call. | One guard decision, rendered in the host's form, and a pass never widens what the host allows. | SYS-P8 | Active | | GRD-R13 | Agents can neither read nor write Baley's home or its config folder. Three mechanisms carry it, configured at install: Claude Code's sandbox, for shell commands and their children (`Bash`, `Monitor`, `PowerShell`); its `Read` and `Edit` deny rules, for the built-in file tools; and the guard's refusal of a `Read`, `Grep` or `Glob` call that reaches either folder, because Claude Code applies `Read` rules to `Grep` and `Glob` only on a best-effort basis. The guard takes the call's target from `Read`'s `file_path` or from `Grep`'s or `Glob`'s `path`, which is the hook's working directory when absent. It also takes the folders a `Grep` `glob` or a `Glob` `pattern` names before its first wildcard, joined to that target. It refuses when a target lies inside either folder or holds one, compared by component and by file identity, so a sibling such as `baley-old` is not matched. It refuses a pattern with a `..` component after a wildcard, and a path it cannot resolve. An unavailable sandbox is reported, never passed over. API keys stay in the owner's environment, which these folder denials do not protect (CFG-R24, ADR 0039). `baley doctor` checks the configuration and reports what an agent can reach, reads included. | The record and global settings are protected by the host and the guard together, and tampering is detected by the chain and its anchors. | ADR 0008, ADR 0033 | Active | | GRD-R14 | The guard reads at most 64 KiB of the hook's input and answers within the hook's 10-second timeout, on one budget that starts when the guard starts: 8 seconds of work; git at most 5 seconds in total, across the branch lookup and the up to three launches that read HEAD's copy of `baley.toml`; storage waits at most 2 seconds; and at least 1 second kept after the work for ending and reaping a child and writing the answer. Every limit shrinks with the work already done, and none is reset for a later step. The guard launches no program except git, for the branch and for HEAD's copy of the settings. When git gives no branch, because it cannot run, fails or has no time left, the guard reads the branch from `.git/HEAD` directly, bounded and without following symbolic links. It collects no remote and no root commit. Input it cannot read, or that is over the bound, is denied for `Read`, `Grep`, `Glob`, `Write`, `Edit` and `NotebookEdit`, and passes for the command tools. Standard input that fails to read at all is denied, since the tool is then unknown. | The guard must answer fast and must not become a way to run things. | | Active | @@ -101,7 +101,7 @@ No model is dispatched by this area. From its environment it reads `CLAUDE_PROJECT_DIR`, kept as given for the record. - **Project and target:** the policy comes from the session project at `CLAUDE_PROJECT_DIR`, found by the walk of GRD-R2. The hook's working directory is the base that tool targets resolve against. For a commit, each `-C` operand is joined to the preceding directory, starting at that base, and an absolute operand replaces it. The branch and the bounded `.git/HEAD` fallback are read there, on the existing single branch launch and budget grant. A target the scan cannot establish asks without reading a branch or policy. A walk from the resolved commit directory supplies the canonical checkout root for remembered denials, never a policy. The separate walk from the hook's working directory still finds the `baley.toml` the file guard protects beside the session project's. -- **Protected paths:** the live file guard protects Baley's 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:106-114, 126-132`). The library write decision also takes write-only folders, denying a write inside one and leaving reads alone (`crates/baley/src/protected_paths/write.rs:18-117`). The placement map projects the placed stubs, the placed Claude Code settings and registration files and the installed executable as files, and the supplied versions folder as a write-only folder (`crates/baley/src/host_artifacts/placement.rs:290-330`). Build 3 T15 passes both lists to `judge`; until then the live guard uses an empty write-only folder list. +- **Protected paths:** the live file guard protects Baley's home and config folders, the session project's `baley.toml` and the one that binds the working directory's checkout, and the installed placements: the placed stubs, the placed Claude Code settings and registration files and the installed executable, each file once. It also treats the versions folder as a write-only folder (`crates/baley/src/guard_hook/context.rs:103-174`). The library write decision denies a write inside a write-only folder and leaves reads alone (`crates/baley/src/protected_paths/write.rs:18-117`). The placement map projects the files and the folder (`crates/baley/src/host_artifacts/placement.rs:290-330`), and the guard builds the map from `HOME` and `CLAUDE_CONFIG_DIR` at each call, reading no store (`crates/baley/src/host_artifacts/installed.rs:55-107`, `crates/baley/src/guard_hook/context.rs:65-90`). A `HOME` or `CLAUDE_CONFIG_DIR` it cannot use leaves no protected paths to judge against: the path tools are denied and no decision can be recorded. - **Policy reads:** only a commit with an established target in a project reads a policy, after its branch. The guard reads the global `config.toml` and the working tree's `baley.toml`, each to at most 1 MiB, and HEAD's copy of `baley.toml` at the session project's root through the bounded git reader, and merges them with Claude Code's `[host.claude-code]` sections. A file over the cap, a working-tree file it cannot read or finds gone after the walk found it, and a config folder it cannot find each make the settings torn. It admits no checkout, runs no policy step and records no `policy.effective`. - **Recording:** a guard record is one `guard.record` command in the per-user project `user`, on stream `guard`, at policy version 0, by `baley`, with the hook form of the caller. - A command with a `commit` or `push` verb, or a `PowerShell` call, in a project first looks up the answer recorded under its call id, on a short store open of its own before any git or policy read. A replay is answered from the record, with no git or policy read and nothing appended. With no `user` there is no record, and the lookup creates none. @@ -115,7 +115,7 @@ No model is dispatched by this area. | Answer | When | Requirement | |---|---|---| | `ask` | push; commit whose target cannot be established; protected commit under `ask`; torn settings; a `PowerShell` call in a project | GRD-R3, GRD-R4, GRD-R5, GRD-R7 | - | `deny` | protected commit under `refuse`; hard fail; remembered denial under torn settings; a Write, Edit or NotebookEdit to Baley's home or config folder, a protected `baley.toml`, a placed stub, Claude Code settings or registration file, the installed executable path or staged-version folder as GRD-R11 specifies, or, once Build 5 supplies the lease, outside it during a dispatch; a `Read`, `Grep` or `Glob` call whose target or pattern reaches either folder, with or without a project; malformed, oversized or incomplete input for one of the six path tools; standard input that cannot be read at all; a path call when Baley's home and config folders cannot be found; an ask that is unrecordable for any cause listed in GRD-R9 | GRD-R5, GRD-R6, GRD-R7, GRD-R9, GRD-R11, GRD-R13, GRD-R14 | + | `deny` | protected commit under `refuse`; hard fail; remembered denial under torn settings; a Write, Edit or NotebookEdit to Baley's home or config folder, a protected `baley.toml`, a placed stub, Claude Code settings or registration file, the installed executable path or staged-version folder as GRD-R11 specifies, or, once Build 5 supplies the lease, outside it during a dispatch; a `Read`, `Grep` or `Glob` call whose target or pattern reaches either folder, with or without a project; malformed, oversized or incomplete input for one of the six path tools; standard input that cannot be read at all; a path call when the protected paths cannot be resolved; an ask that is unrecordable for any cause listed in GRD-R9 | GRD-R5, GRD-R6, GRD-R7, GRD-R9, GRD-R11, GRD-R13, GRD-R14 | | `pass on failure` | git or branch unreadable without hard fail | GRD-R6 | | `pass` | everything else, including a declined or unreadable command, a `Monitor` watch, and any `Bash`, `Monitor` or `PowerShell` call outside a project | GRD-R2, GRD-R3 | @@ -129,15 +129,15 @@ No model is dispatched by this area. ### baley doctor (the guard's part) -- **Inputs:** the host name, the placement map's expected files and protected paths (`crates/baley/src/host_artifacts/placement.rs:250-320`), 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-90`), 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: +- **Inputs:** the host name, the placement map's expected files and protected paths (`crates/baley/src/host_artifacts/placement.rs:250-320`), the settings and hook documents the map places, and the `user` project's view stamps from the store's own doctor. The map is built from the placements derived from `HOME` and `CLAUDE_CONFIG_DIR` and from the latest install record (`crates/baley/src/host_doctor/mod.rs:61-120`): an artifact the record does not list has an unknown place, so on a machine where install never ran 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-144`). 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. + - 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:258-315`). 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-193`). + - 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:216-229`). 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)). +- **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 the placement map built from the install record ([ADR 0038](../adr/0038-installer-and-opt-in-updates.md)). - **Refusals:** none; findings are reported (GRD-R13). ## 6. Records @@ -200,7 +200,7 @@ stateDiagram-v2 Ask --> Recorded: guard.answered in user Deny --> Recorded: guard.answered in user PassOnFailure --> Recorded: guard.answered in user - Ask --> Unrecordable: store read or write fails, busy or needs rebuild, no envelope or call id, caller text cannot be recorded as given, call id answered for other input or directories, redirected target root unreadable or not walked, target or session project or cwd checkout root not UTF-8, settings without a session project, torn settings without branch or verb, successful transaction without a staged or replayed answer (call-id clash stderr line), or home folder unresolved + Ask --> Unrecordable: store read or write fails, busy or needs rebuild, no envelope or call id, caller text cannot be recorded as given, call id answered for other input or directories, redirected target root unreadable or not walked, target or session project or cwd checkout root not UTF-8, settings without a session project, torn settings without branch or verb, successful transaction without a staged or replayed answer (call-id clash stderr line), or the protected paths unresolved Deny --> Unrecordable: the same causes PassOnFailure --> Unrecordable: the same causes Unrecordable --> Answered: an ask becomes a deny, a deny or a pass on failure stands, with one loud stderr line @@ -322,24 +322,24 @@ 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:131`, `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:290-320`) 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`). +`baley guard` runs the library hook (`crates/baley/src/main.rs:135`, `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. The live guard protects the installed placements, which are the placed stubs, the placed settings and registration files and the executable, and the versions folder as a write-only folder. It derives them from the environment and passes them at `guard_hook::context::judge`, failing closed when it cannot (`crates/baley/src/guard_hook/context.rs:65-90, 103-174`). What stays open is owned elsewhere: the lease is Build 5's, and the doctor's runtime part is built over the install record (`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 | |---|---|---| -| GRD-R1 | Partly built | One Claude Code `PreToolUse` entry runs `baley guard` with a 10-second timeout for `Bash\|Monitor\|PowerShell\|Read\|Grep\|Glob\|Write\|Edit\|NotebookEdit` (`hooks/hooks.json:3-14`), and the library classifies all nine tools (`crates/baley/src/hook_input/mod.rs:101-195`). The hook renderer gives the same item, with the nine-tool matcher as a constant, the guard's timeout and the absolute executable single-quoted before `guard` (`crates/baley/src/host_artifacts/hook.rs:13-43`), and a test holds the file's matcher and timeout to the renderer's (`crates/baley/src/host_artifacts/hook.rs:104-118`). The file stays hand-written, with a bare `baley guard` command, until T15 writes the hook from the renderer. In the 2026-10-08 runs the rendered nine-tool hook fired for `Bash`, `Monitor` and `Read` (first sheet `claude-live-2026-10-08-run1.md#ctl.hook.bash`, `#ctl.hook.monitor`, `#ctl.hook.read`). In the first run `Write`, `Edit` and `NotebookEdit` never reached the hook, because Claude Code's deny rules and its read-first check refused every call the procedure sent into the two folders (`claude-live-2026-10-08-run1.md#ctl.hook.write`, `#ctl.hook.edit`, `#ctl.hook.notebookedit`). In the third run those three fired the hook once each on unprotected files in the project (`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. In the third run a session launched with `--allowedTools Grep,Glob` offered them, and the hook fired four times for each (`claude-live-2026-10-08-run3.md#ctl.hook.grep`, `#ctl.hook.glob`). `PowerShell` is unverified because `pwsh` was absent (`claude-live-2026-10-08-run1.md#ctl.hook.powershell`, `claude-live-2026-10-08-run3.md#ctl.hook.powershell`). 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`) | -| GRD-R2 | Built | The hook judges `CLAUDE_PROJECT_DIR` with the server's rules (`crates/baley/src/mcp/context.rs:154-167`) and binds a project only when the walk from it finds a managed checkout. The walk treats only not-found as absence for both names, so an unusable nearest `baley.toml` entry still binds the project and a `.git` entry makes the folder the repository root and ends the walk (`crates/baley/src/discovery.rs:44-97`). The walk from the working directory gives the `baley.toml` protected for path tools, never the project. The context walk binds the project and the cwd checkout (`crates/baley/src/guard_hook/context.rs:44-134`). The judge resolves a commit's target directory, the entry reads its branch and walks that directory, and the recorder chooses the checkout root for remembered denials (`crates/baley/src/guard_hook/decide.rs:115-134`, `crates/baley/src/guard_hook/mod.rs:106-110`, `crates/baley/src/guard_hook/record.rs:102-134`). With no project bound, a command or `PowerShell` call passes with nothing recorded, and path calls are judged at the working directory either way (`crates/baley/src/guard_hook/decide.rs:80-204`, `crates/baley-core/src/guard/answer.rs:61-70, 138-144`) | +| GRD-R1 | Partly built | One Claude Code `PreToolUse` entry runs `baley guard` with a 10-second timeout for `Bash\|Monitor\|PowerShell\|Read\|Grep\|Glob\|Write\|Edit\|NotebookEdit` , and the library classifies all nine tools (`crates/baley/src/hook_input/mod.rs:101-195`). `baley install` writes the hook into the settings file from the renderer, with the nine-tool matcher as a constant, the guard's timeout and the absolute stable path single-quoted before `guard` (`crates/baley/src/host_artifacts/hook.rs:13-43`, `crates/baley/src/install/settings.rs:352-489`). The repository tracks no hook file and no test that held one to the renderer, so the item install writes is the hook's one source. In the 2026-10-08 runs the rendered nine-tool hook fired for `Bash`, `Monitor` and `Read` (first sheet `claude-live-2026-10-08-run1.md#ctl.hook.bash`, `#ctl.hook.monitor`, `#ctl.hook.read`). In the first run `Write`, `Edit` and `NotebookEdit` never reached the hook, because Claude Code's deny rules and its read-first check refused every call the procedure sent into the two folders (`claude-live-2026-10-08-run1.md#ctl.hook.write`, `#ctl.hook.edit`, `#ctl.hook.notebookedit`). In the third run those three fired the hook once each on unprotected files in the project (`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. In the third run a session launched with `--allowedTools Grep,Glob` offered them, and the hook fired four times for each (`claude-live-2026-10-08-run3.md#ctl.hook.grep`, `#ctl.hook.glob`). `PowerShell` is unverified because `pwsh` was absent (`claude-live-2026-10-08-run1.md#ctl.hook.powershell`, `claude-live-2026-10-08-run3.md#ctl.hook.powershell`). 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`). The hook as install writes it has not been seen firing in a live session, which T17 qualifies | +| GRD-R2 | Built | The hook judges `CLAUDE_PROJECT_DIR` with the server's rules (`crates/baley/src/mcp/context.rs:154-167`) and binds a project only when the walk from it finds a managed checkout. The walk treats only not-found as absence for both names, so an unusable nearest `baley.toml` entry still binds the project and a `.git` entry makes the folder the repository root and ends the walk (`crates/baley/src/discovery.rs:44-97`). The walk from the working directory gives the `baley.toml` protected for path tools, never the project. The context walk binds the project and the cwd checkout (`crates/baley/src/guard_hook/context.rs:65-174`). The judge resolves a commit's target directory, the entry reads its branch and walks that directory, and the recorder chooses the checkout root for remembered denials (`crates/baley/src/guard_hook/decide.rs:115-134`, `crates/baley/src/guard_hook/mod.rs:106-110`, `crates/baley/src/guard_hook/record.rs:102-134`). With no project bound, a command or `PowerShell` call passes with nothing recorded, and path calls are judged at the working directory either way (`crates/baley/src/guard_hook/decide.rs:80-204`, `crates/baley-core/src/guard/answer.rs:61-70, 138-144`) | | 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:311-331`). 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-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:311-331`). The hook reads the session project's policy with Claude Code's host sections (`crates/baley/src/guard_hook/policy.rs:30-106`) 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`). 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:17-49`) | -| 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-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:60-106`). 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, in a view set that is now 8 with the install view (`crates/baley/src/ledger/open.rs:19-53`) | +| 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 the protected paths 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:216-229`) | | 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 takes its lease input and write-only folders, denying a write inside one and leaving reads alone (`crates/baley/src/protected_paths/write.rs:18-117`), 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:127-149`, `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-134`); production `judge` lists only those two `baley.toml` files and keeps its write-only folder list empty (`crates/baley/src/guard_hook/context.rs:106-114, 126-132`). The placement map projects the placed stubs, the placed settings and registration files and the installed executable as files, and the supplied versions folder as a write-only folder (`crates/baley/src/host_artifacts/placement.rs:290-330`). Build 3 T15 passes both lists in at `judge`. Build 5 owns the lease | +| GRD-R11 | Partly built | The library write decision takes its lease input and write-only folders, denying a write inside one and leaving reads alone (`crates/baley/src/protected_paths/write.rs:18-117`), 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:127-149`, `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 the protected paths 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:103-174`). `judge` also takes the installed placements derived from the environment: it adds the placed stubs, the placed settings and registration files and the installed executable to the protected files once each, and sets the versions folder as the write-only folder (`crates/baley/src/guard_hook/context.rs:135-172`). The placement map projects those files and that folder (`crates/baley/src/host_artifacts/placement.rs:290-330`). A `HOME` or `CLAUDE_CONFIG_DIR` the derivation cannot use becomes a refusal, and the path tools are denied (`crates/baley/src/guard_hook/context.rs:43-59`, `crates/baley/src/guard_hook/decide.rs:181-185`). 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 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:143-222`) 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:132-142, 243-284`, `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:61-74`) 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 | +| 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 hook's matcher puts in front of those tools (`crates/baley/src/host_artifacts/hook.rs:16-19`). The sandbox and `Read` and `Edit` deny-rule content is rendered (`crates/baley/src/host_artifacts/security.rs:143-222`) and composed into an existing settings document (`crates/baley/src/host_artifacts/compose.rs:98-126, 206-287`). 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. `baley install` composes the content into the user settings file and reports each setting above that it keeps as a gap, which makes the install partial. 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:258-315`), 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`). It judges what the install record lists and reports an artifact the record does not list 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-116`). 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:132-142, 243-284`, `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:65-78`) 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/0015-repository-upkeep-and-build-gate.md b/docs/design/0015-repository-upkeep-and-build-gate.md index 1bae9d3a..64fc7a40 100644 --- a/docs/design/0015-repository-upkeep-and-build-gate.md +++ b/docs/design/0015-repository-upkeep-and-build-gate.md @@ -130,7 +130,7 @@ The names below cover the public and stored surfaces of [#53](https://github.com |---|---| | Package and executable | `baley`, in [Cargo.toml:2](../../crates/baley/Cargo.toml#L2) and [main.rs:17](../../crates/baley/src/main.rs#L17); Rust paths use `baley::` | | MCP server and tools | `baley`, `baley_version`, `baley_query`, `baley_apply`, in [mcp/tools.rs:58-63](../../crates/baley/src/mcp/tools.rs#L58) and [83-120](../../crates/baley/src/mcp/tools.rs#L83) | -| Session startup and hook command | `baley serve`, in [main.rs:194-207](../../crates/baley/src/main.rs#L194); `baley guard`, in [hooks.json:9](../../hooks/hooks.json#L9) | +| Session startup and hook command | `baley serve`, in [main.rs:198-211](../../crates/baley/src/main.rs#L198); `baley guard`, in the hook item `baley install` writes into the settings file, [hook.rs:21-36](../../crates/baley/src/host_artifacts/hook.rs#L21) | | Inherited environment controls | `BALEY_GLOBAL_CONFIG`: two reads, in [milestone/release.rs:410](../../crates/baley/src/milestone/release.rs#L410) and [milestone/prune.rs:223](../../crates/baley/src/milestone/prune.rs#L223). Debug-only controls: `BALEY_PRUNE_STOP` in [prune.rs:55](../../crates/baley/src/milestone/prune.rs#L55) and `BALEY_LANDING_EXIT_AFTER_EFFECT` in [landing_service.rs:360](../../crates/baley/src/landing_service.rs#L360) | | Inherited configuration paths | `baley/providers.env`, in [review/provider/credentials.rs:66](../../crates/baley/src/review/provider/credentials.rs#L66) | | Record markers and hash domain | `baley.rail.receipt.v1`, [rail/receipts.rs:400](../../crates/baley/src/rail/receipts.rs#L400); `baley.rail.observation.v1`, [rail/risk.rs:97](../../crates/baley/src/rail/risk.rs#L97); `baley.native_evidence.v1`, [evidence/persistence.rs:12](../../crates/baley/src/evidence/persistence.rs#L12); `baley.pause.risk-surface.v1`, [pause/risk.rs:14](../../crates/baley/src/pause/risk.rs#L14); `baley.lifecycle`, [derivation/memo.rs:4](../../crates/baley/src/derivation/memo.rs#L4) | @@ -176,7 +176,7 @@ The isolated formatting revision is already recorded at [.git-blame-ignore-revs: Claude Code is the only supported host ([ADR 0033](../adr/0033-host-security-bar.md)). It starts `baley serve` once per session, as specified by [0012, HST-R16](0012-host-interface.md#3-requirements). The owner does not start the server. This command is the session server. -The canonical server and tool names remain those in UPK-R1. The installer puts the binary behind `~/.local/bin/baley`; `baley install` writes the user-level MCP entry, hook, stubs, sandbox and deny rules at that stable path, without a plugin. It preserves separately owned entries and replaces only artifacts it can establish as its own. [ADR 0038](../adr/0038-installer-and-opt-in-updates.md) settles who writes them; [Build 3 T15](../roadmap.md#build-3-hosts) supplies the ownership and replacement algorithm under [HST-R17](0012-host-interface.md#3-requirements). This area adds no second set of tool aliases. +The canonical server and tool names remain those in UPK-R1. The installer puts the binary behind `~/.local/bin/baley`; `baley install` writes the user-level MCP entry, hook, stubs, sandbox and deny rules at that stable path, without a plugin. It preserves separately owned entries and replaces only artifacts it can establish as its own, from the install record it wrote earlier and from this binary's own render, and never from a name alone ([0012, section 5](0012-host-interface.md#installer-and-baley-install)). [ADR 0038](../adr/0038-installer-and-opt-in-updates.md) settles who writes them, under [HST-R17](0012-host-interface.md#3-requirements). This area adds no second set of tool aliases. ### Test assessment @@ -239,7 +239,7 @@ Not applicable as a new instruction surface: repository upkeep serves no model i | Requirement | Status | Where | |---|---|---| | UPK-R1 | Built | Package, executable and session server: [Cargo.toml:2](../../crates/baley/Cargo.toml#L2), [main.rs:17](../../crates/baley/src/main.rs#L17), [mcp/tools.rs:58-120](../../crates/baley/src/mcp/tools.rs#L58). Instruction registry: [instruction/mod.rs:65-210](../../crates/baley/src/instruction/mod.rs#L65). Installed stubs are not established by these definitions | -| UPK-R2 | Not built | The CLI has no installer ([main.rs:23-98](../../crates/baley/src/main.rs#L23)). [ADR 0038](../adr/0038-installer-and-opt-in-updates.md) settles delivery and the binary's ownership of wiring; [Build 3 T14 to T17](../roadmap.md#build-3-hosts) implement it. T15 must establish artifact ownership before replacing a registration | +| UPK-R2 | Built | `baley install` proves ownership before it replaces anything ([main.rs:23-100](../../crates/baley/src/main.rs#L23), [command.rs:116-218](../../crates/baley/src/install/command.rs#L116)). A stub is Baley's when its bytes equal this binary's render or a hash the latest install record holds ([stubs.rs:26-71](../../crates/baley/src/install/stubs.rs#L26)). A JSON entry is Baley's when it equals this binary's render or what the record holds, and the registration is compared by the rule the doctor and composition share, so an entry that sets `env` or runs over another transport is never Baley's ([registration.rs:66-115](../../crates/baley/src/install/registration.rs#L66)). An older hook or deny entry leaves only when the record lists it ([settings.rs:120-170](../../crates/baley/src/install/settings.rs#L120)). An `mcpServers.baley` entry or guard hook the record does not show, and a stub holding other bytes, refuse the install and change nothing ([plan.rs:109-256](../../crates/baley/src/install/plan.rs#L109)). Separately owned settings, hooks and registrations stay. [ADR 0038](../adr/0038-installer-and-opt-in-updates.md) settles delivery and the binary's ownership of wiring. [Build 3 T17](../roadmap.md#build-3-hosts) qualifies the installed result live | | UPK-R3 | Built | The definitions in section 6 remain; services are parked at [inherited.rs:1-4](../../crates/baley/src/inherited.rs#L1) | | UPK-R4 | Built | The six retained passages and unnamed benchmark material are cited in section 6 | | UPK-R5 | Built | [test.yml:121-140](../../.github/workflows/test.yml#L121) runs `fmt` beside the three other jobs, and the applied rules require all four from one producer with strict checks, as the note below records | @@ -275,4 +275,4 @@ The count does not expand macros or resolve binary membership. For example, [led | Question | Decided by | |---|---| -| What evidence establishes that an existing registration or artifact belongs to Baley before replacement? | Build 3 T15's artifact ownership and application under [ADR 0038](../adr/0038-installer-and-opt-in-updates.md) and [0012, HST-R17](0012-host-interface.md#3-requirements). The delivery mechanism is decided; the ownership check is not built. | +| What evidence establishes that an existing registration or artifact belongs to Baley before replacement? | Settled by Build 3 T15's artifact ownership and application under [ADR 0038](../adr/0038-installer-and-opt-in-updates.md) and [0012, HST-R17](0012-host-interface.md#3-requirements): the install record plus this binary's own render, never a name alone ([0012, section 5](0012-host-interface.md#installer-and-baley-install)). It stays listed until T17 qualifies it against a live Claude Code. | From 8a054d5f2de0124a4e6d7960ea1b092af6e23dc6 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 14:15:21 -0400 Subject: [PATCH 27/35] docs(design): say install refuses only an entry that matches neither the render nor the record The UPK-R2 row ended by saying an entry or hook the record does not show refuses the install, which contradicts its own earlier sentence and design 0012: an entry equal to this binary's render is Baley's with no record. Its env wording now matches 0012 as well: an entry that sets env entries is not Baley's, an empty env object still is. --- docs/design/0015-repository-upkeep-and-build-gate.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/design/0015-repository-upkeep-and-build-gate.md b/docs/design/0015-repository-upkeep-and-build-gate.md index 64fc7a40..cec55cf2 100644 --- a/docs/design/0015-repository-upkeep-and-build-gate.md +++ b/docs/design/0015-repository-upkeep-and-build-gate.md @@ -239,7 +239,7 @@ Not applicable as a new instruction surface: repository upkeep serves no model i | Requirement | Status | Where | |---|---|---| | UPK-R1 | Built | Package, executable and session server: [Cargo.toml:2](../../crates/baley/Cargo.toml#L2), [main.rs:17](../../crates/baley/src/main.rs#L17), [mcp/tools.rs:58-120](../../crates/baley/src/mcp/tools.rs#L58). Instruction registry: [instruction/mod.rs:65-210](../../crates/baley/src/instruction/mod.rs#L65). Installed stubs are not established by these definitions | -| UPK-R2 | Built | `baley install` proves ownership before it replaces anything ([main.rs:23-100](../../crates/baley/src/main.rs#L23), [command.rs:116-218](../../crates/baley/src/install/command.rs#L116)). A stub is Baley's when its bytes equal this binary's render or a hash the latest install record holds ([stubs.rs:26-71](../../crates/baley/src/install/stubs.rs#L26)). A JSON entry is Baley's when it equals this binary's render or what the record holds, and the registration is compared by the rule the doctor and composition share, so an entry that sets `env` or runs over another transport is never Baley's ([registration.rs:66-115](../../crates/baley/src/install/registration.rs#L66)). An older hook or deny entry leaves only when the record lists it ([settings.rs:120-170](../../crates/baley/src/install/settings.rs#L120)). An `mcpServers.baley` entry or guard hook the record does not show, and a stub holding other bytes, refuse the install and change nothing ([plan.rs:109-256](../../crates/baley/src/install/plan.rs#L109)). Separately owned settings, hooks and registrations stay. [ADR 0038](../adr/0038-installer-and-opt-in-updates.md) settles delivery and the binary's ownership of wiring. [Build 3 T17](../roadmap.md#build-3-hosts) qualifies the installed result live | +| UPK-R2 | Built | `baley install` proves ownership before it replaces anything ([main.rs:23-100](../../crates/baley/src/main.rs#L23), [command.rs:116-218](../../crates/baley/src/install/command.rs#L116)). A stub is Baley's when its bytes equal this binary's render or a hash the latest install record holds ([stubs.rs:26-71](../../crates/baley/src/install/stubs.rs#L26)). A JSON entry is Baley's when it equals this binary's render or what the record holds, and the registration is compared by the rule the doctor and composition share, so an entry that sets `env` entries or runs over another transport is never Baley's, while an empty `env` object still is ([registration.rs:66-115](../../crates/baley/src/install/registration.rs#L66)). An older hook or deny entry leaves only when the record lists it ([settings.rs:120-170](../../crates/baley/src/install/settings.rs#L120)). An `mcpServers.baley` entry or a guard hook that matches neither this binary's render nor what the latest record holds, and a stub whose bytes match neither this binary's render nor a recorded hash, refuse the install and change nothing ([plan.rs:109-256](../../crates/baley/src/install/plan.rs#L109)). Separately owned settings, hooks and registrations stay. [ADR 0038](../adr/0038-installer-and-opt-in-updates.md) settles delivery and the binary's ownership of wiring. [Build 3 T17](../roadmap.md#build-3-hosts) qualifies the installed result live | | UPK-R3 | Built | The definitions in section 6 remain; services are parked at [inherited.rs:1-4](../../crates/baley/src/inherited.rs#L1) | | UPK-R4 | Built | The six retained passages and unnamed benchmark material are cited in section 6 | | UPK-R5 | Built | [test.yml:121-140](../../.github/workflows/test.yml#L121) runs `fmt` beside the three other jobs, and the applied rules require all four from one producer with strict checks, as the note below records | From b5c0eea5f5a1bb31331e448581f05dc5d5a894ac Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 14:17:34 -0400 Subject: [PATCH 28/35] fix(install): say the sandbox reaches the sessions that read the settings file The receipt and design 0012 said the sandbox applied to every Claude Code session on the machine. It is written to one user's settings file, under CLAUDE_CONFIG_DIR when that is set, so another user's sessions and a session run with a different CLAUDE_CONFIG_DIR do not read it. --- crates/baley/src/install/receipt.rs | 10 +++++----- docs/design/0012-host-interface.md | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/baley/src/install/receipt.rs b/crates/baley/src/install/receipt.rs index c099baa1..c22b025f 100644 --- a/crates/baley/src/install/receipt.rs +++ b/crates/baley/src/install/receipt.rs @@ -157,7 +157,7 @@ pub(crate) fn render( && notes.sandbox_held_back.is_none(); if sandbox_written { lines.push(format!( - "the sandbox settings in {settings_path} apply to every Claude Code session on this machine, including projects Baley does not manage" + "the sandbox settings in {settings_path} apply to the Claude Code sessions that read that file, including projects Baley does not manage, but not to another user's sessions or to sessions run with a different CLAUDE_CONFIG_DIR" )); lines.push(format!( "managed settings, command-line settings and a project's own .claude/settings.json and .claude/settings.local.json take precedence over {settings_path} for single values such as sandbox.enabled" @@ -524,7 +524,7 @@ mod tests { }; let line_with = |receipt: &Render, text: &str| receipt.lines.iter().any(|line| line.contains(text)); - let every_session = "apply to every Claude Code session on this machine, including projects Baley does not manage"; + let reach = "apply to the Claude Code sessions that read that file, including projects Baley does not manage, but not to another user's sessions or to sessions run with a different CLAUDE_CONFIG_DIR"; let precedence = [ "managed settings", "command-line settings", @@ -557,7 +557,7 @@ mod tests { && line.contains("false") && line.contains("/home/o/.claude/settings.json") })); - assert!(line_with(&written, every_session)); + assert!(line_with(&written, reach)); assert!( written .lines @@ -574,7 +574,7 @@ mod tests { settings_line(&held).unwrap(), "settings at /home/o/.claude/settings.json: written without the sandbox block" ); - assert!(!line_with(&held, "every Claude Code session")); + assert!(!line_with(&held, "the sandbox settings in")); assert!(!line_with(&held, "take precedence")); let unchanged = render_with(ArtifactOutcome::Unchanged, Notes::default()); @@ -582,7 +582,7 @@ mod tests { settings_line(&unchanged).unwrap(), "settings at /home/o/.claude/settings.json: unchanged" ); - assert!(line_with(&unchanged, every_session)); + assert!(line_with(&unchanged, reach)); assert!( unchanged .lines diff --git a/docs/design/0012-host-interface.md b/docs/design/0012-host-interface.md index 2014c81e..73b1c428 100644 --- a/docs/design/0012-host-interface.md +++ b/docs/design/0012-host-interface.md @@ -212,12 +212,12 @@ Beside these, a `HOME`, `CLAUDE_CONFIG_DIR` or Baley folder value that cannot pl 4. The catalog seed line, then any write failure and a `the install record was not written` line. 5. A `replaced ...` line for each owner value Baley's secure value replaced. 6. A `gap: ...` line for each gap, with its fix. -7. When the sandbox block is in the file, two lines saying the sandbox settings apply to every Claude Code session on this machine, including projects Baley does not manage, and that managed settings, command-line settings and a project's own `.claude/settings.json` and `.claude/settings.local.json` take precedence over the user settings file for single values such as `sandbox.enabled`. +7. When the sandbox block is in the file, two lines saying the sandbox settings apply to the Claude Code sessions that read that file, including projects Baley does not manage, but not to another user's sessions or to sessions run with a different `CLAUDE_CONFIG_DIR`, and that managed settings, command-line settings and a project's own `.claude/settings.json` and `.claude/settings.local.json` take precedence over the user settings file for single values such as `sandbox.enabled`. 8. The next steps: start a new Claude Code session, run `baley init` in each checkout, the `updates.auto` switch, `baley update`, `baley config interview` and `baley doctor`. The outcome is complete only when every artifact is Baley's after the run, there is no gap, the catalog seed is recorded and no write or record failed. The exit status is 0 for complete and 1 for partial, 2 for a refusal, and a store failure uses the shared classes (2 for a refused or blocked store, 3 for a busy, unavailable or read-only one). -**The settings file.** Claude Code reads the user settings file below managed settings, command-line settings, a project's local settings and its shared settings. A project's own settings can therefore override single sandbox values such as `sandbox.enabled`, and a managed policy overrides them all. The sandbox in the user file applies to every Claude Code session on the machine, including projects Baley does not manage, and the receipt says so. Baley chose this file because it needs no administrator rights and holds the hook, the deny rules and the sandbox as one document that is composed once. Install writes nothing to `config.toml`: the interview's defaults are the compiled defaults, and a setting the owner changed later is not touched. Provider hosts and `review.reviewers` are T16's. +**The settings file.** Claude Code reads the user settings file below managed settings, command-line settings, a project's local settings and its shared settings. A project's own settings can therefore override single sandbox values such as `sandbox.enabled`, and a managed policy overrides them all. The sandbox in the user file applies to the Claude Code sessions that read that file: the same user's sessions run with the same `CLAUDE_CONFIG_DIR` value, or with none when none was set at install, including projects Baley does not manage. It does not reach another user's sessions or a session run with a different `CLAUDE_CONFIG_DIR`, and the receipt says so. Baley chose this file because it needs no administrator rights and holds the hook, the deny rules and the sandbox as one document that is composed once. Install writes nothing to `config.toml`: the interview's defaults are the compiled defaults, and a setting the owner changed later is not touched. Provider hosts and `review.reviewers` are T16's. **Limits.** Two cases are not closed. Between install's second read of `mcpServers.baley` and the `claude mcp remove` that follows it, another writer can change the entry, so the remove can delete an entry install did not judge ([#251](https://github.com/crenshawdev/baley/issues/251)). When nothing runnable is at the stable path, install withholds the new hook but still removes an older hook the record lists, so a machine can end with no hook ([#252](https://github.com/crenshawdev/baley/issues/252)); the gap says so only by naming the empty stable path. The live behaviour of the written wiring in a Claude Code session is T17's to observe. From 2e73e2d3ae35143f6a5c8a6b54ef7a6d783cf66d Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 14:17:41 -0400 Subject: [PATCH 29/35] docs(design): limit the script's exit 1 to its own steps The next sentence says the installer ends with baley install's exit status, which is 0, 1, 2 or 3, so exit 1 is only what the script itself returns before that last step. --- docs/design/0012-host-interface.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/design/0012-host-interface.md b/docs/design/0012-host-interface.md index 73b1c428..12e0d654 100644 --- a/docs/design/0012-host-interface.md +++ b/docs/design/0012-host-interface.md @@ -152,7 +152,7 @@ The placeholder on the second line stands for the executable's SHA-256. A versio The stable path is `$HOME/.local/bin/baley`, a symbolic link to `$HOME/.local/lib/crenshawdev/baley/versions//baley`. These paths are the same on Linux and macOS. `BALEY_HOME` and `XDG_*` move neither. `HOME` must be absolute, nonempty UTF-8 without a `..` component. Repeated slashes and `.` components are removed without following folder links. The installation's identity is the resulting stable path text, never its target (`crates/baley/src/update/installation.rs:75-134`). -The script allows an absent stable path or a link whose stored target is exactly the absolute `//baley`, with the version spelled by the same rule as the updater. It refuses other occupants and links to folders, and rechecks before activation. A reused staged file must be regular, not a link, have the manifest's digest and be executable. For a new staged file, the script sets mode `755` on the downloaded file before moving it into the versions folder, then moves it to its final name. It creates a temporary link in `~/.local/bin` and moves that link onto the stable path. It removes no version. Its receipt prints `baley: placed version at `, then `baley: versions present in :` and the version folders holding regular binaries. A failure exits 1. The script's last step runs `"$stable" install`, after its receipt, and the installer ends with that command's exit status, so a refused or partial install fails the installer. The script itself writes no host wiring, records no install event and seeds no catalog: `baley install` does that (`install.sh:73-90, 173-212`). +The script allows an absent stable path or a link whose stored target is exactly the absolute `//baley`, with the version spelled by the same rule as the updater. It refuses other occupants and links to folders, and rechecks before activation. A reused staged file must be regular, not a link, have the manifest's digest and be executable. For a new staged file, the script sets mode `755` on the downloaded file before moving it into the versions folder, then moves it to its final name. It creates a temporary link in `~/.local/bin` and moves that link onto the stable path. It removes no version. Its receipt prints `baley: placed version at `, then `baley: versions present in :` and the version folders holding regular binaries. A failure of the script's own steps, before its last one, exits 1. The last step runs `"$stable" install`, after the receipt, and the installer ends with that command's exit status (0, 1, 2 or 3, as the receipt section below lists), so a refused or partial install fails the installer. The script itself writes no host wiring, records no install event and seeds no catalog: `baley install` does that (`install.sh:73-90, 173-212`). `baley install` writes Claude Code's wiring at the stable path and takes setup defaults. T16 adds provider choice and acknowledgement, and T17 checks the installed result and runs the script live. A new Claude Code session loads the wiring, and each checkout still needs the owner's `baley init`. The command is `baley install [--host ]` (`crates/baley/src/install/command.rs:28-52`, `crates/baley/src/main.rs:37-38, 120`). From 10066d9c98a2eda040b7143bd45efbe176602492 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 14:22:33 -0400 Subject: [PATCH 30/35] fix(install): say the sandbox reaches sessions that read the settings file The receipt and design 0012 said the sandbox misses sessions run with a different CLAUDE_CONFIG_DIR. A different value that names the same folder reads the same file and is covered. The line now says which file a session reads decides it. --- crates/baley/src/install/receipt.rs | 4 ++-- docs/design/0012-host-interface.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/baley/src/install/receipt.rs b/crates/baley/src/install/receipt.rs index c22b025f..1a400981 100644 --- a/crates/baley/src/install/receipt.rs +++ b/crates/baley/src/install/receipt.rs @@ -157,7 +157,7 @@ pub(crate) fn render( && notes.sandbox_held_back.is_none(); if sandbox_written { lines.push(format!( - "the sandbox settings in {settings_path} apply to the Claude Code sessions that read that file, including projects Baley does not manage, but not to another user's sessions or to sessions run with a different CLAUDE_CONFIG_DIR" + "the sandbox settings in {settings_path} apply to the Claude Code sessions that read that file, including projects Baley does not manage, but not to another user's sessions or to sessions that read a different settings file, such as one run with CLAUDE_CONFIG_DIR set to another folder" )); lines.push(format!( "managed settings, command-line settings and a project's own .claude/settings.json and .claude/settings.local.json take precedence over {settings_path} for single values such as sandbox.enabled" @@ -524,7 +524,7 @@ mod tests { }; let line_with = |receipt: &Render, text: &str| receipt.lines.iter().any(|line| line.contains(text)); - let reach = "apply to the Claude Code sessions that read that file, including projects Baley does not manage, but not to another user's sessions or to sessions run with a different CLAUDE_CONFIG_DIR"; + let reach = "apply to the Claude Code sessions that read that file, including projects Baley does not manage, but not to another user's sessions or to sessions that read a different settings file, such as one run with CLAUDE_CONFIG_DIR set to another folder"; let precedence = [ "managed settings", "command-line settings", diff --git a/docs/design/0012-host-interface.md b/docs/design/0012-host-interface.md index 12e0d654..7a5ac559 100644 --- a/docs/design/0012-host-interface.md +++ b/docs/design/0012-host-interface.md @@ -212,12 +212,12 @@ Beside these, a `HOME`, `CLAUDE_CONFIG_DIR` or Baley folder value that cannot pl 4. The catalog seed line, then any write failure and a `the install record was not written` line. 5. A `replaced ...` line for each owner value Baley's secure value replaced. 6. A `gap: ...` line for each gap, with its fix. -7. When the sandbox block is in the file, two lines saying the sandbox settings apply to the Claude Code sessions that read that file, including projects Baley does not manage, but not to another user's sessions or to sessions run with a different `CLAUDE_CONFIG_DIR`, and that managed settings, command-line settings and a project's own `.claude/settings.json` and `.claude/settings.local.json` take precedence over the user settings file for single values such as `sandbox.enabled`. +7. When the sandbox block is in the file, two lines saying the sandbox settings apply to the Claude Code sessions that read that file, including projects Baley does not manage, but not to another user's sessions or to sessions that read a different settings file, such as one run with `CLAUDE_CONFIG_DIR` set to another folder, and that managed settings, command-line settings and a project's own `.claude/settings.json` and `.claude/settings.local.json` take precedence over the user settings file for single values such as `sandbox.enabled`. 8. The next steps: start a new Claude Code session, run `baley init` in each checkout, the `updates.auto` switch, `baley update`, `baley config interview` and `baley doctor`. The outcome is complete only when every artifact is Baley's after the run, there is no gap, the catalog seed is recorded and no write or record failed. The exit status is 0 for complete and 1 for partial, 2 for a refusal, and a store failure uses the shared classes (2 for a refused or blocked store, 3 for a busy, unavailable or read-only one). -**The settings file.** Claude Code reads the user settings file below managed settings, command-line settings, a project's local settings and its shared settings. A project's own settings can therefore override single sandbox values such as `sandbox.enabled`, and a managed policy overrides them all. The sandbox in the user file applies to the Claude Code sessions that read that file: the same user's sessions run with the same `CLAUDE_CONFIG_DIR` value, or with none when none was set at install, including projects Baley does not manage. It does not reach another user's sessions or a session run with a different `CLAUDE_CONFIG_DIR`, and the receipt says so. Baley chose this file because it needs no administrator rights and holds the hook, the deny rules and the sandbox as one document that is composed once. Install writes nothing to `config.toml`: the interview's defaults are the compiled defaults, and a setting the owner changed later is not touched. Provider hosts and `review.reviewers` are T16's. +**The settings file.** Claude Code reads the user settings file below managed settings, command-line settings, a project's local settings and its shared settings. A project's own settings can therefore override single sandbox values such as `sandbox.enabled`, and a managed policy overrides them all. The sandbox in the user file applies to the Claude Code sessions that read that file, including projects Baley does not manage. Which file a session reads depends on `CLAUDE_CONFIG_DIR`: a session run with the value install saw, with none when none was set then, or with another value that names the same folder, reads the same file and is covered. It does not reach another user's sessions or a session that reads a different settings file, such as one run with `CLAUDE_CONFIG_DIR` set to another folder, and the receipt says so. Baley chose this file because it needs no administrator rights and holds the hook, the deny rules and the sandbox as one document that is composed once. Install writes nothing to `config.toml`: the interview's defaults are the compiled defaults, and a setting the owner changed later is not touched. Provider hosts and `review.reviewers` are T16's. **Limits.** Two cases are not closed. Between install's second read of `mcpServers.baley` and the `claude mcp remove` that follows it, another writer can change the entry, so the remove can delete an entry install did not judge ([#251](https://github.com/crenshawdev/baley/issues/251)). When nothing runnable is at the stable path, install withholds the new hook but still removes an older hook the record lists, so a machine can end with no hook ([#252](https://github.com/crenshawdev/baley/issues/252)); the gap says so only by naming the empty stable path. The live behaviour of the written wiring in a Claude Code session is T17's to observe. From 7173c0b5edea6493bc4b0755d9b08623e3cb9237 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 14:26:05 -0400 Subject: [PATCH 31/35] docs(design): say install records ownership and the doctor and guard read the placements Designs 0001 and 0003 still called the install record, the install view, the live guard's placements and the install-time catalog seed future work, and gave the view set as 7. They now say what baley install records, that the view set is 8 and what that costs older binaries, and where the guard, the doctor and the seed get their placements. --- docs/design/0001-evidence-ledger.md | 9 +++++---- docs/design/0003-configuration-and-routing.md | 7 ++++--- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/design/0001-evidence-ledger.md b/docs/design/0001-evidence-ledger.md index 27b82b54..d3ab8343 100644 --- a/docs/design/0001-evidence-ledger.md +++ b/docs/design/0001-evidence-ledger.md @@ -426,7 +426,7 @@ Streams used by the record families: | `command/` | `command.claimed`, `command.completed`, `command.reconciled` | | `retention` | `payload.reduced`, `payload.purged` | | `providers` | `providers.acknowledged`, in the per-user project `user` at policy version 0 ([0003](0003-configuration-and-routing.md#6-records)); Build 3 T16 owns it | -| `install` | `update.checked` and `update.failed` are recorded in the per-user project `user`, at policy version 0. `install.recorded` and the `install` view remain planned for T15 and T16 ([0012](0012-host-interface.md#6-records)) | +| `install` | `update.checked` and `update.failed` are recorded in the per-user project `user`, at policy version 0. `baley install` records `install.recorded` there beside them, as the owner at policy version 0 with no caller, only when its payload differs from the latest record for the host. The `install` view holds the latest install record per host and nothing else, so update outcomes are read from the stream. Adding the view raised the view set to 8, so a binary built before it is read-only on `user` once a newer binary has brought its views to 8 ([0012](0012-host-interface.md#6-records)) | | `models` | `models.seeded`, `models.owner_changed`, `models.detected`, `models.detection_failed`. The stream lives only in the reserved per-user project `user`, whose records carry policy version 0 ([0003](0003-configuration-and-routing.md)) | A plan check records its round-2 work order, scope, selecting policy version, first-check reference, original and revised digests, truth versions and material references in `plan.check_issued` before dispatch ([0005](0005-context-plans-and-acceptance.md), PLN-R16). `plan submit` writes that event on `phase/`; `plan.checked` records the result with its issued binding. Keying and reading a pending binding before a plan number exists, and closing a pending check on draft discard, remain open for Build 4 in 0005 section 12. A review records each round's scope and material references in `review.issued` ([0008](0008-review.md), REV-R10). Scope and the work order's baseline, truth-version and policy bindings are inline facts, while source snapshots and review input are payload references. Replaying these records uses their recorded scope and references, without consulting current settings or source files or opening payload bodies. Plan and truth snapshots and all generated checker input, including the delta, use `record` references, kept for the life of the project unless purged. Generated review input and prompts for reviews in 0008 use `material` references. Retention or purge can remove bodies without removing the recorded scope or references. A re-check that needs an unavailable body is refused. `plan.check_issued` is the only additional checker event; it uses the existing phase stream. @@ -780,6 +780,7 @@ The store-owned `request` view is at version 2. It projects `command.claimed` to | `checkout` | (project, path) | path | Each checkout's latest `checkout.seen`, its root commit and remote URL, which checkout admission judges a fork against | | `guard` | (`user`, host, session, call id) | | The confirmed answer to one hook call, with its input digest, project directory and working directory, for a redelivery ([0010](0010-guard.md), GRD-R10) | | `guard_policy` | (`user`, session project root, target checkout root, host) | | The denials of the last complete policy recorded with an answer for that key, never an allow, read when the settings are torn ([0010](0010-guard.md), GRD-R7) | +| `install` | (`user`, host) | | The latest `install.recorded` for a host, as `{host, seq, payload}`: the ownership facts `baley install` left, which a later install and the doctor read ([0012](0012-host-interface.md#6-records)) | | `capture` | (project, recording sequence) | capture id | Each capture's kind, phase, size, time and short text, or a long text's body hash with its purge state | | `request` | (project, command kind, request id) | state | Each request's open claim, held claim or outcome, for retries and open claims | | `claim_scope` | (project, scope token) | | The open claim holding each scope token, for the scope check | @@ -1259,7 +1260,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` 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:143-222`, `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`). It reads the install record to tell an artifact that was installed from one that never was: on a machine where `baley install` never ran it reports every artifact as not installed, and after an install it judges the placed settings and hook. 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:143-222`, `crates/baley/src/host_artifacts/coverage.rs:281-356`); `baley install` applies the content by merging it into the user settings file under the Claude Code configuration folder, where a project's own settings can still override a single value such as `sandbox.enabled`, 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`). Those runs used the rendered content, and a session loading the file `baley install` itself wrote is observed by T17. 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, to the stubs, the settings file, the registration file and the executable that install places, and into the versions folder, as a best-effort layer ([0010](0010-guard.md) section 5). 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. @@ -1364,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. +- `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 the install record does not show it), 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, 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. @@ -1463,7 +1464,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. 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:65-90`, `crates/baley/src/host_doctor/protection.rs:48-92`). | +| 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 the settings and hook documents the install record shows, and reports every artifact as not installed when no record exists (`crates/baley/src/host_doctor/mod.rs:61-120`, `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 ac347abc..08ba62e6 100644 --- a/docs/design/0003-configuration-and-routing.md +++ b/docs/design/0003-configuration-and-routing.md @@ -155,6 +155,7 @@ Which policy each command runs under (CFG-R8, CFG-R9). Policy version 0 means th | `scrub` | none | no: no event is appended | none | | `baley update` and detached updater (`baley update detached`) | per-user project `user` | no | 0 | | `baley models add`, `remove` and `import`, detection, seeding | the per-user project `user` | no | 0 | +| `baley install` | the per-user project `user`, for the catalog seed and `install.recorded` | no: it admits no checkout and reads no project | 0 | | a session server's project call | discovery at the session's `CLAUDE_PROJECT_DIR` | a write: yes, under the host's key, after checkout admission. A read: no. A write the ledger already holds: no | a write: the version the step returned, recorded on its own command, and checkout admission carries the version stored for the checkout and no host. A read: none, it records nothing. A write the ledger already holds: none, it gets its original receipt | | the guard hook | discovery at the hook's `CLAUDE_PROJECT_DIR`, for the policy a commit is judged under. Its records go to the per-user project `user` | no: it reads the settings within its budget and merges Claude Code's sections, admits no checkout and records no `policy.effective` | 0 on every guard record | | `policy.effective` itself | the command it belongs to | it is the step's own record | the version it replaces, 0 for the first of its key | @@ -733,17 +734,17 @@ The binary parks the inherited engine for Build 9 to delete, and nothing in prod | CFG-R5 | Partly built | Scopes in the schema and the scope diagnostic in the walk (`crates/baley-core/src/policy/schema.rs:155-164`, `crates/baley-core/src/policy/parse.rs:682-733`); the command-line refusal `wrong-layer` is built, judged over every pair of a `config set` before anything is read (`crates/baley-core/src/policy/config_command/set.rs:169-175`, `crates/baley-core/src/policy/config_command/set.rs:232-240`). `git.remote` and the three guard settings are project-scoped (`crates/baley-core/src/policy/schema.rs:304-331`), so the standard schema produces them: a global `config set` of `git.remote`, `git.on_protected` or `git.guard_hard_fail` refuses `wrong-layer`, a set of `git.protected_branches` in either file refuses `list-not-settable` first, and a global file holding any of the four gets the scope diagnostic. `updates.auto` and `updates.source` are global-scoped, so a project `config set` refuses `wrong-layer` and a project file holding either gets the scope diagnostic (`crates/baley-core/src/policy/schema.rs:332-346`). The guard hook's policy is built by the policy module, so the same scopes apply to it (`crates/baley/src/guard_hook/policy.rs:61-107`). The inherited `GLOBAL_ONLY` list, applied at merge (`crates/baley/src/config/mod.rs:14-18`, `crates/baley/src/config/merge.rs:59-89`), and the `repo_only` flag in `crates/baley/src/config/schema.json`, refused at write (`crates/baley/src/config/write.rs:97-101`), are the parked engine's, and both stay until Build 9. | | CFG-R6 | Built | In the policy module: five layers in order and only the connected host's section (`crates/baley-core/src/policy/merge.rs:139-207`); the command line connects no host, so `config show` applies none unless `--host` names one, and then shows the effective values that host would run under (`crates/baley-core/src/policy/config_command/show.rs:208-223`). `config interview` takes `--host` the same way: that host's sections apply to the value in force it shows with each question (`crates/baley/src/config_command/interview.rs:158-187`). The guard hook merges the five layers through the same module with Claude Code's sections (`crates/baley/src/guard_hook/policy.rs:61-107`). The inherited merge of defaults, global and repo layers, which has no host sections (`crates/baley/src/config/merge.rs:131-177`), is reached only by the parked engine, and Build 9 deletes it. | | CFG-R7 | Partly built | An unknown name is ignored with a diagnostic (`crates/baley-core/src/policy/parse.rs:628-661`, `crates/baley-core/src/policy/parse.rs:735-752`); the twelve settings without readers are gone from `crates/baley/src/config/schema.json`; the command-line refusal `unknown-setting` is built for `config set` and `config show` (`crates/baley-core/src/policy/config_command/set.rs:217-224`, `crates/baley-core/src/policy/config_command/show.rs:94-99`). The inherited JSON schema, which only the parked engine's merge and writer read, stays until Build 9. | -| 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-173`); 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`, `guard.policy_recorded`, `update.checked` and `update.failed`, and the `policy`, `checkout`, `capture`, `guard` and `guard_policy` views, at view set version 7 with no install view (`crates/baley/src/ledger/open.rs:17-49`). `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-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-173`); 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`, `guard.policy_recorded`, `install.recorded`, `update.checked` and `update.failed`, and the `policy`, `checkout`, `capture`, `guard`, `guard_policy` and `install` views, at view set version 8 (`crates/baley/src/ledger/open.rs:19-53`). `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:259-368`, `crates/baley-core/src/policy/parse.rs:428-518`), 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:244-256`, `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:45-117`, `crates/baley/src/guard_hook/context.rs:77-134`, `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. It supplies `~/.local/lib/crenshawdev/baley/versions` separately as a write-only folder when placed, protecting its contents without denying reads (`crates/baley/src/host_artifacts/placement.rs:290-330`). The command-line placement leaves that folder unknown, and the live guard's write-only folder list is empty until T15 passes the projection in (`crates/baley/src/host_doctor/mod.rs:65-90`, `crates/baley/src/guard_hook/context.rs:126-133`). 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:143-222`), including a folder-shaped write denial with no read rule for a supplied versions folder, 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-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:45-117`, `crates/baley/src/guard_hook/context.rs:77-134`, `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. It supplies `~/.local/lib/crenshawdev/baley/versions` separately as a write-only folder when placed, protecting its contents without denying reads (`crates/baley/src/host_artifacts/placement.rs:290-330`). The live guard derives the placements from `HOME` and `CLAUDE_CONFIG_DIR` at each call and passes them to its judge, so its protected list holds each placed stub, the placed Claude Code settings and registration files and the installed executable, and its write-only folder list holds the versions folder (`crates/baley/src/guard_hook/context.rs:103-174`, `crates/baley/src/host_artifacts/installed.rs:55-107`). The doctor builds its map from the same derivation and the latest install record, so it places only what the record shows (`crates/baley/src/host_doctor/mod.rs:61-120`). 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:143-222`), including a folder-shaped write denial with no read rule for a supplied versions folder, and `baley install` merges it into the user settings file, with the other owner settings kept (`crates/baley/src/install/settings.rs:352-489`). 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:7-49, 275-303`), 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:267-312`). 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. | | CFG-R16 | Partly built | The escalation rule in `resolve_route` (`crates/baley-core/src/policy/route.rs:212-218`); Build 4 supplies the attempt. The parked engine's resolution (`crates/baley/src/config/roles.rs:127-131`) is called with attempt `None` everywhere (#69). | | CFG-R17 | Partly built | The route type carries every field of section 6 (`crates/baley-core/src/policy/route.rs:72-99`); a work order carrying it is Build 4. The parked engine's resolution (`crates/baley/src/config/roles.rs:143-180`) has no policy or catalog version. | | CFG-R18 | Not built | Only the parked engine raises the default plan gate to blocking when the risk floor applies (`crates/baley/src/config/policy.rs:108-129`, `crates/baley/src/config/floor.rs:511-554`). The session server answers `route` as unavailable (`crates/baley/src/mcp/operations.rs:109`) until Build 4 | -| CFG-R19 | Partly built | The catalog is the `model_catalog` view of the per-user project `user` (`crates/baley-core/src/catalog/mod.rs:49-55`, `crates/baley-core/src/catalog/view.rs:316-458`). `baley models list`, `add` and `remove` run the seeding step first (`crates/baley/src/models.rs:343-356`), and detection runs it only when it will record (`crates/baley/src/detection/mod.rs:74-76`). The step records the hint table when the seeding judge finds its version differs from the latest one recorded (`crates/baley/src/models.rs:128-142`, `crates/baley-core/src/catalog/seed.rs:11-23`). The Claude Code alias table and the hint table with its version are compiled in (`crates/baley-core/src/catalog/tables.rs:9-90`). After activation, the updater attempts claim completion before launching the new version's hidden `baley update seed`, which records its own compiled catalog without a provider call (`crates/baley/src/update/command.rs:215-239`, `crates/baley/src/update/seed.rs:34-43, 77-101`). Seeding at install remains with T15's `baley install`. Build 4 owns default tier selection and the versioned catalog record extension retaining provider `created` dates and hint order (section 6). The parked engine's fixed list (`crates/baley/src/config/roles.rs:16`) stays until Build 9. | +| CFG-R19 | Partly built | The catalog is the `model_catalog` view of the per-user project `user` (`crates/baley-core/src/catalog/mod.rs:49-55`, `crates/baley-core/src/catalog/view.rs:316-458`). `baley models list`, `add` and `remove` run the seeding step first (`crates/baley/src/models.rs:343-356`), and detection runs it only when it will record (`crates/baley/src/detection/mod.rs:74-76`). The step records the hint table when the seeding judge finds its version differs from the latest one recorded (`crates/baley/src/models.rs:128-142`, `crates/baley-core/src/catalog/seed.rs:11-23`). The Claude Code alias table and the hint table with its version are compiled in (`crates/baley-core/src/catalog/tables.rs:9-90`). After activation, the updater attempts claim completion before launching the new version's hidden `baley update seed`, which records its own compiled catalog without a provider call (`crates/baley/src/update/command.rs:215-239`, `crates/baley/src/update/seed.rs:34-43, 77-101`). `baley install` seeds the catalog in its own process through the same seeding step, after its file writes and before it records the install, and the updater's hidden `seed` child stays the updater's (`crates/baley/src/install/command.rs:168, 203-209`). Build 4 owns default tier selection and the versioned catalog record extension retaining provider `created` dates and hint order (section 6). The parked engine's fixed list (`crates/baley/src/config/roles.rs:16`) stays until Build 9. | | CFG-R20, CFG-R21 | Not built | The pure parser, classifier, tagger, diff and event code in `crates/baley-core/src/catalog/detection/` is built and stays. The binary still calls its HTTPS lister (`crates/baley/src/detection/lister.rs`) from `baley models update` and `baley init` (`crates/baley/src/detection/mod.rs`, `crates/baley/src/models.rs`, `crates/baley/src/init.rs`). Build 4 owns the unbuilt session fetch work order, `models-return`, `models import` and fetch-command output. Removal of the lister and credential lookup belongs to Build 4 (#25). | | CFG-R22 | Built | `baley models add` and `baley models remove` record `models.owner_changed` and read the reported catalog version inside the same transaction after the append (`crates/baley/src/models.rs:180-261`, `crates/baley/src/models.rs:466-502`) and refuse a compiled alias and a name the catalog does not hold (`crates/baley-core/src/catalog/owner.rs:11-62`). The projector keeps owner entries over seeds and detection, and keeps an owner removal hidden from both (`crates/baley-core/src/catalog/view.rs:460-637`). | | CFG-R23 | Partly built | Every catalog event records the version before it, read in its own transaction: a seed and an owner change (`crates/baley/src/models.rs:102-116`, `crates/baley/src/models.rs:223-232`), and a detection (`crates/baley/src/detection/record.rs:77-89`), which reports the version its own command committed (`crates/baley/src/detection/record.rs:98-105`). A body cut short at the 4 MiB bound records `incomplete` with that version and removes nothing (`crates/baley-core/src/catalog/detection/classify.rs:102-104`, `crates/baley-core/src/catalog/detection/event.rs:67-72`). The view keeps the version as the sequence of the latest event that changed an accepted name (`crates/baley-core/src/catalog/view.rs:427-444`). The lookup returns it beside the names, which is what a route's `catalog_version` takes (`crates/baley-core/src/policy/route.rs:16-23`, `crates/baley-core/src/policy/route.rs:276`). Routes are recorded in Build 4. | From dcf6702fa8bbf19f6bf3787f1ad5953348eff7f5 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 14:27:40 -0400 Subject: [PATCH 32/35] docs(design): show baley install writing the host's wiring in the C4 model The model had no edge from Baley to Claude Code for the files and registration install writes, and the system design still said the script does not call install. The model, its exported views, their captions and the trust note now describe install as built. --- docs/design/0001-evidence-ledger.md | 8 +++++--- docs/design/0002-system-design.md | 13 ++++++++----- docs/design/0003-configuration-and-routing.md | 3 ++- docs/design/c4/workspace.dsl | 3 +++ 4 files changed, 18 insertions(+), 9 deletions(-) diff --git a/docs/design/0001-evidence-ledger.md b/docs/design/0001-evidence-ledger.md index d3ab8343..c7fb986c 100644 --- a/docs/design/0001-evidence-ledger.md +++ b/docs/design/0001-evidence-ledger.md @@ -143,6 +143,7 @@ graph LR 1-. "
Uses the command line
" .->2 14-. "
Work orders, results,
questions
[MCP over stdio]
" .->2 14-. "
Asks before each tool call
runs
[Pre-tool hook, one process per tool call]
" .->2 + 2-. "
Writes the wiring with baley
install: MCP registration,
pre-tool hook, sandbox and
deny settings, skill stubs
[claude mcp add-json, files under the Claude folder]
" .->14 14-. "
Workers edit source and
commit
" .->15 14-. "
Review and model-list API
calls with the owner's
environment keys
" .->18 2-. "
Reads git facts, runs tests
and git
" .->15 @@ -153,7 +154,7 @@ graph LR ``` -*Figure 1. System context from the shared C4 model. The host reaches Baley over MCP and its pre-tool hook. The session makes review and model-list API calls with the owner's environment keys; Baley receives their results. The foreground `baley update` command and the detached opt-in updater are built and contact the release source. `baley serve` starts the detached check only when `updates.auto` is on, without waiting for it. T14 and T17 use unsigned development artifacts only, and #14 supplies release verification.* +*Figure 1. System context from the shared C4 model. The host reaches Baley over MCP and its pre-tool hook. `baley install` writes the host's wiring: the MCP registration through `claude`, the pre-tool hook, the sandbox and deny settings in the user settings file and the skill stubs. The session makes review and model-list API calls with the owner's environment keys; Baley receives their results. The foreground `baley update` command and the detached opt-in updater are built and contact the release source. `baley serve` starts the detached check only when `updates.auto` is on, without waiting for it. T14 and T17 use unsigned development artifacts only, and #14 supplies release verification.* ```mermaid flowchart TB @@ -163,7 +164,7 @@ flowchart TB direction TB server["MCP server
one per Claude Code session, over stdio; Hardin decides the next step"] guard["Guard hook
one per tool call; refuses unsafe actions"] - cli["CLI
verify, doctor, export, purge, anchor,
acknowledge-restore, rebuild, scrub,
update, update resolve
show in Build 9
"] + cli["CLI
verify, doctor, export, purge, anchor,
acknowledge-restore, rebuild, scrub,
init, config, models, update,
update resolve, install
show in Build 9
"] updater["Detached updater
at most one claimed check
per installation per UTC day
"] db[("Ledger database
SQLite, one per user")] end @@ -178,6 +179,7 @@ flowchart TB server -.->|starts update detached at startup if updates.auto is on, without waiting| updater updater -->|claims and records update outcomes in user| db cli -.->|baley update fetches development artifacts and waits for its receipt| releases + cli -.->|baley install writes the registration, hook, sandbox and deny settings and stubs| host updater -.->|fetches development artifacts after a new claim| releases server -.->|finds project file from CLAUDE_PROJECT_DIR, runs git for a write| checkout guard -.->|finds project file from CLAUDE_PROJECT_DIR, runs bounded git for the commit target's branch and the session project's baley.toml at HEAD| checkout @@ -189,7 +191,7 @@ flowchart TB class host,checkout,releases external ``` -*Figure 2. Containers, in the C4 model's sense. Every solid arrow into the database goes through the same storage port. Each Claude Code session starts its own MCP server, so several can run beside the guard hook, the command line and the detached updater. The updater opens the per-user store itself and claims and records opted-in checks as events. Manual `baley update` runs in the CLI, waiting for the network and its receipt under the same installation scope without the detached check's daily refusal. Both paths fetch unsigned development artifacts from the configured HTTPS `updates.source`, which has no default. They check SHA-256 at `verify_download`, stage under `~/.local/lib/crenshawdev/baley/versions//baley` and activate through the stable link `~/.local/bin/baley`, keeping every old version. #14 supplies release verification before any release. Claude Code's sandbox and its `Read` and `Edit` deny rules keep agents from reading or writing Baley's home and config folder, and only Baley's own processes write the database. The server finds the project file from its session's `CLAUDE_PROJECT_DIR` and runs git only to prepare a write, as Baley with the call's caller. The guard finds the project file the same way, and runs git only for the branch at the commit's target, resolved from the working directory, and for HEAD's copy of the session project's `baley.toml`, within its time budget ([0010](0010-guard.md), GRD-R14). It records its answers in the per-user project `user`, never in the session's project, so a guard record needs no project in this machine's ledger.* +*Figure 2. Containers, in the C4 model's sense. Every solid arrow into the database goes through the same storage port. Each Claude Code session starts its own MCP server, so several can run beside the guard hook, the command line and the detached updater. The updater opens the per-user store itself and claims and records opted-in checks as events. Manual `baley update` runs in the CLI, waiting for the network and its receipt under the same installation scope without the detached check's daily refusal. `baley install` also runs in the CLI: it writes the host's wiring under the Claude Code folder and through `claude mcp add-json`, seeds the catalog and records `install.recorded` in the per-user project `user`. Both paths fetch unsigned development artifacts from the configured HTTPS `updates.source`, which has no default. They check SHA-256 at `verify_download`, stage under `~/.local/lib/crenshawdev/baley/versions//baley` and activate through the stable link `~/.local/bin/baley`, keeping every old version. #14 supplies release verification before any release. Claude Code's sandbox and its `Read` and `Edit` deny rules keep agents from reading or writing Baley's home and config folder, and only Baley's own processes write the database. The server finds the project file from its session's `CLAUDE_PROJECT_DIR` and runs git only to prepare a write, as Baley with the call's caller. The guard finds the project file the same way, and runs git only for the branch at the commit's target, resolved from the working directory, and for HEAD's copy of the session project's `baley.toml`, within its time budget ([0010](0010-guard.md), GRD-R14). It records its answers in the per-user project `user`, never in the session's project, so a guard record needs no project in this machine's ledger.* ### Terms diff --git a/docs/design/0002-system-design.md b/docs/design/0002-system-design.md index 4dbee570..8584ad59 100644 --- a/docs/design/0002-system-design.md +++ b/docs/design/0002-system-design.md @@ -74,6 +74,7 @@ graph LR 1-. "
Uses the command line
" .->2 14-. "
Work orders, results,
questions
[MCP over stdio]
" .->2 14-. "
Asks before each tool call
runs
[Pre-tool hook, one process per tool call]
" .->2 + 2-. "
Writes the wiring with baley
install: MCP registration,
pre-tool hook, sandbox and
deny settings, skill stubs
[claude mcp add-json, files under the Claude folder]
" .->14 14-. "
Workers edit source and
commit
" .->15 14-. "
Review and model-list API
calls with the owner's
environment keys
" .->18 2-. "
Reads git facts, runs tests
and git
" .->15 @@ -84,7 +85,7 @@ graph LR ``` -*Figure 1. System context. The host reaches Baley two ways: over MCP from its session, and through its pre-tool hook before each tool call runs. The session sends review and model-list requests to providers. The foreground `baley update` command and the detached opt-in updater are built and contact the release source. `baley serve` starts the detached check only when `updates.auto` is on, without waiting for it. T14 and T17 use unsigned development artifacts only, and #14 supplies release verification.* +*Figure 1. System context. The host reaches Baley two ways: over MCP from its session, and through its pre-tool hook before each tool call runs. `baley install` writes the host's wiring: the MCP registration through `claude`, the pre-tool hook, the sandbox and deny settings in the user settings file and the skill stubs. The session sends review and model-list requests to providers. The foreground `baley update` command and the detached opt-in updater are built and contact the release source. `baley serve` starts the detached check only when `updates.auto` is on, without waiting for it. T14 and T17 use unsigned development artifacts only, and #14 supplies release verification.* | Actor | What it is | Its part | |---|---|---| @@ -189,6 +190,7 @@ graph LR 1-. "
Uses the command line
" .->3 14-. "
Work orders, results,
questions
[MCP over stdio]
" .->3 14-. "
Asks before each tool call
runs
[Pre-tool hook, one process per tool call]
" .->3 + 3-. "
Writes the wiring with baley
install: MCP registration,
pre-tool hook, sandbox and
deny settings, skill stubs
[claude mcp add-json, files under the Claude folder]
" .->14 14-. "
Workers edit source and
commit
" .->15 14-. "
Review and model-list API
calls with the owner's
environment keys
" .->18 3-. "
Reads, and writes a file
whole for config set
" .->13 @@ -204,7 +206,7 @@ graph LR ``` -*Figure 3. Containers: the Baley server, one process per session over MCP and one per tool call through the pre-tool hook, with the ledger and the settings files, plus the detached updater. The updater opens the same per-user store through the store port to claim and record at most one check per UTC day per installation. Provider calls and credentials stay with the host session. Manual `baley update` waits for its downloads and receipt in the foreground; only checks started by `baley serve` when `updates.auto` is on run in the detached updater. Both update paths are built and use the binary's HTTP client. T14 and T17 use unsigned development artifacts only; #14 supplies signed checksum manifests and release verification. The guard hook records its answers in the per-user ledger ([0010](0010-guard.md)).* +*Figure 3. Containers: the Baley server, one process per session over MCP and one per tool call through the pre-tool hook, with the ledger and the settings files, plus the detached updater. The updater opens the same per-user store through the store port to claim and record at most one check per UTC day per installation. Provider calls and credentials stay with the host session. Manual `baley update` waits for its downloads and receipt in the foreground; only checks started by `baley serve` when `updates.auto` is on run in the detached updater. Both update paths are built and use the binary's HTTP client. T14 and T17 use unsigned development artifacts only; #14 supplies signed checksum manifests and release verification. The guard hook records its answers in the per-user ledger ([0010](0010-guard.md)). `baley install` writes the host's wiring: the MCP registration through `claude`, the pre-tool hook, the sandbox and deny settings in the user settings file and the skill stubs.* ```mermaid @@ -260,6 +262,7 @@ graph LR 1-. "
Uses the command line
" .->4 14-. "
Work orders, results,
questions
[MCP over stdio]
" .->4 14-. "
Asks before each tool call
runs
[Pre-tool hook, one process per tool call]
" .->4 + 4-. "
Writes the wiring with baley
install: MCP registration,
pre-tool hook, sandbox and
deny settings, skill stubs
[claude mcp add-json, files under the Claude folder]
" .->14 14-. "
Workers edit source and
commit
" .->15 4-. "
Asks what may happen next
" .->5 5-. "
Applies the area's rules
" .->6 @@ -285,7 +288,7 @@ graph LR ``` -*Figure 4. Components inside the Baley server, the binary each session starts over stdio and the pre-tool hook starts for each tool call. The model catalog reads imported lists and has no provider connection or key reader. The host interface starts the detached updater at `baley serve` startup only when `updates.auto` is on, without waiting for the child or the network. The updater opens the same per-user ledger through the store port to claim and record at most one check per UTC day per installation. The host interface also runs manual `baley update` in the foreground, waiting for the release source and its receipt. Both update paths are built for unsigned development artifacts only until #14 supplies signed checksum manifests and release verification. Figure 3 shows the updater's download path.* +*Figure 4. Components inside the Baley server, the binary each session starts over stdio and the pre-tool hook starts for each tool call. The model catalog reads imported lists and has no provider connection or key reader. The host interface starts the detached updater at `baley serve` startup only when `updates.auto` is on, without waiting for the child or the network. The updater opens the same per-user ledger through the store port to claim and record at most one check per UTC day per installation. The host interface also runs manual `baley update` in the foreground, waiting for the release source and its receipt. Both update paths are built for unsigned development artifacts only until #14 supplies signed checksum manifests and release verification. Figure 3 shows the updater's download path. The host interface also runs `baley install`, which writes the host's wiring, seeds the catalog and records `install.recorded` in the per-user ledger.* | Component | Responsibility | |---|---| @@ -319,7 +322,7 @@ The one contract between Baley and every worker, including the outside reviews t | SYS-R3 | Withdrawn. There is no install-time choice of start route and no service or launcher: Claude Code starts the server for each session ([ADR 0034](../adr/0034-one-server-per-session.md)). | | | SYS-R4 | Withdrawn. There is no single shared server to join and no idle exit; a server's lifetime is its session's (SYS-R1, SYS-R5). | | | SYS-R5 | A session's calls are handled asynchronously at the edge and decided one at a time on the server's own worker, as synchronous code. A failure in one call never takes the server down. One bounded queue serves the session and every subagent: one call running and four waiting, with at most 16 MiB of raw frames among them, and a call beyond either bound gets a retryable overload answer. Memory stays bounded however large the ledger grows. When its input ends or it gets SIGINT or SIGTERM, the server stops taking calls, lets accepted work run for at most ten seconds and makes one `PASSIVE` checkpoint attempt. | Subagents share their session's connection, so one queue keeps them from starving each other, and the bounds are what a session may cost. | -| SYS-R6 | Writes use optimistic concurrency across processes: each session's server, the guard hook and the command line open the per-user store. The foreground `baley update` command and the detached opt-in updater record `update.check` claims and `update.checked` or `update.failed` outcomes in the per-user project `user` through the same port. Both check unsigned development artifacts only until #14 supplies release verification. Every write takes the writer queue, `BEGIN IMMEDIATE` and the epoch check, and each command's decision is made inside its transaction from inputs read there. Events are appended, never overwritten, and a command whose inputs changed is refused as stale, never merged. No record is locked while an agent works. | Several sessions' servers, the guard hook, the command line and the detached updater write the one per-user ledger from separate processes; contention within a project is low because only one dispatch per phase is active. Builds on 0001 EVD-R6, EVD-R7, EVD-R8 and EVD-R26. | +| SYS-R6 | Writes use optimistic concurrency across processes: each session's server, the guard hook and the command line open the per-user store. The foreground `baley update` command and the detached opt-in updater record `update.check` claims and `update.checked` or `update.failed` outcomes in the per-user project `user` through the same port, and `baley install` records `install.recorded` there. Both check unsigned development artifacts only until #14 supplies release verification. Every write takes the writer queue, `BEGIN IMMEDIATE` and the epoch check, and each command's decision is made inside its transaction from inputs read there. Events are appended, never overwritten, and a command whose inputs changed is refused as stale, never merged. No record is locked while an agent works. | Several sessions' servers, the guard hook, the command line and the detached updater write the one per-user ledger from separate processes; contention within a project is low because only one dispatch per phase is active. Builds on 0001 EVD-R6, EVD-R7, EVD-R8 and EVD-R26. | | SYS-R7 | Long work (test runs, git and forge steps) is claimed and started, then recorded when it ends. Where the host supports being notified when a call finishes, Baley keeps the call open; everywhere else the session waits in steps, each call returning on completion or after a timeout with "still running". The host adapter chooses. | Hosts limit how long a tool call may run. | | SYS-R8 | Baley runs the test suite and each check's command itself. It judges results by exit code, with an optional standard report (such as JUnit XML) for which tests failed. | Evidence is first-hand, and every language works. | | SYS-R9 | Outside models are called by the host session, never by Baley. Baley decides whether an outside review runs and with which providers, builds the complete prompt and request as a work order, and parses and validates the raw response returned through `review return`. | Responsibility stays with the party that acts. | @@ -331,7 +334,7 @@ The one contract between Baley and every worker, including the outside reviews t ## 10. Cross-cutting concerns -- **Trust.** The development install script `install.sh`, foreground `baley update` and detached updater fetch a plain executable and an unsigned two-line manifest naming its version and SHA-256. Both delivery paths check the binary's SHA-256 in `verify_download` before staging. Automatic checks are off by default: `baley serve` reads `updates.auto` once at startup and starts the detached check only when it is on, without waiting for it. These requests send no project content or provider credentials. The foreground command and detached updater open the per-user store to claim and record checks. The release design #14 owns signed checksum manifests, the signature format, trust root and both signature verifiers. T14 and T17 use unsigned development artifacts only, and no release exists before #14 supplies and qualifies verification. `baley install`, which will write the host wiring, belongs to Build 3 T15 and is not called by the script. Baley never reads API keys. Keys in the session's environment are visible to every program Claude Code starts, agents and subagents included. Nothing scrubs a key a command prints. Code sent for review goes to the chosen provider under its terms. Agents run as the owner's user. The design guards against accidental exposure, not a determined agent. The ledger is protected by the host sandbox, and tampering is detected through the hash chain and forge anchors. +- **Trust.** The development install script `install.sh`, foreground `baley update` and detached updater fetch a plain executable and an unsigned two-line manifest naming its version and SHA-256. Both delivery paths check the binary's SHA-256 in `verify_download` before staging. Automatic checks are off by default: `baley serve` reads `updates.auto` once at startup and starts the detached check only when it is on, without waiting for it. These requests send no project content or provider credentials. The foreground command and detached updater open the per-user store to claim and record checks. The release design #14 owns signed checksum manifests, the signature format, trust root and both signature verifiers. T14 and T17 use unsigned development artifacts only, and no release exists before #14 supplies and qualifies verification. The script ends by running `baley install` through the stable path. `baley install` writes Claude Code's wiring: the MCP registration through `claude mcp add-json`, the pre-tool hook, the sandbox and deny settings in the user settings file, and the skill stubs. It replaces an artifact only on evidence that Baley wrote it, from its install record in the per-user ledger, and it reports a gap as a partial install ([0012](0012-host-interface.md) section 5). The sandbox settings reach the Claude Code sessions that read that settings file, including projects Baley does not manage. Baley never reads API keys. Keys in the session's environment are visible to every program Claude Code starts, agents and subagents included. Nothing scrubs a key a command prints. Code sent for review goes to the chosen provider under its terms. Agents run as the owner's user. The design guards against accidental exposure, not a determined agent. The ledger is protected by the host sandbox, and tampering is detected through the hash chain and forge anchors. - **Failure and recovery.** Claim, act, record (SYS-P7). A killed process is never taken as success. The log is good enough to diagnose a live failure. - **Resources.** Memory use and read cost are defects a user sees: no loading the whole store, bounded reads, streaming. - **Concurrency.** One user, one machine, one server process per session; several processes write the one per-user ledger through the store, with no single writer, and optimistic concurrency keeps them from overwriting each other (SYS-R6). No parallel or worktree execution. diff --git a/docs/design/0003-configuration-and-routing.md b/docs/design/0003-configuration-and-routing.md index 08ba62e6..732f3332 100644 --- a/docs/design/0003-configuration-and-routing.md +++ b/docs/design/0003-configuration-and-routing.md @@ -68,6 +68,7 @@ graph LR 1-. "
Uses the command line
" .->4 14-. "
Work orders, results,
questions
[MCP over stdio]
" .->4 14-. "
Asks before each tool call
runs
[Pre-tool hook, one process per tool call]
" .->4 + 4-. "
Writes the wiring with baley
install: MCP registration,
pre-tool hook, sandbox and
deny settings, skill stubs
[claude mcp add-json, files under the Claude folder]
" .->14 14-. "
Review and model-list API
calls with the owner's
environment keys
" .->18 7-. "
Resolves role, model and
effort
" .->8 8-. "
Reads
" .->13 @@ -83,7 +84,7 @@ graph LR ``` -*Figure 1. Policy and the model catalog inside Baley. The host session calls providers with environment credentials and hands list responses to Baley for parsing.* +*Figure 1. Policy and the model catalog inside Baley. The host session calls providers with environment credentials and hands list responses to Baley for parsing. The host interface also runs `baley install`, which writes Claude Code's wiring and seeds the catalog without a list call.* ## 2. Terms diff --git a/docs/design/c4/workspace.dsl b/docs/design/c4/workspace.dsl index 80758f90..95ee6ea2 100644 --- a/docs/design/c4/workspace.dsl +++ b/docs/design/c4/workspace.dsl @@ -32,6 +32,9 @@ workspace "Baley" "The C4 model behind Baley's design documents. Every structure # MCP; the implied edges would carry only the first component relation. host -> baley "Asks before each tool call runs" "Pre-tool hook, one process per tool call" host -> binary "Asks before each tool call runs" "Pre-tool hook, one process per tool call" + # The only relation from a component to the host, so the context and + # container views show it as an implied edge with this text. + hostInterface -> host "Writes the wiring with baley install: MCP registration, pre-tool hook, sandbox and deny settings, skill stubs" "claude mcp add-json, files under the Claude folder" host -> repo "Workers edit source and commit" host -> reviewers "Review and model-list API calls with the owner's environment keys" hostInterface -> hardin "Asks what may happen next" From d934c50b9a124373d42e1fcdf97702c65e5f4ed3 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 14:29:18 -0400 Subject: [PATCH 33/35] docs(roadmap): mark t14 merged and t15 in progress and say what install writes The roadmap still listed t14 as in progress after #250 merged and left install to t15 in three places. The readme told readers the script does not call install. Both now say what install writes, where, and when a run is partial, and the roadmap hands t16 and t17 the settings file, the record and the placements. --- README.md | 2 +- docs/roadmap.md | 18 ++++++++++-------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 77ff32ee..386f1760 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Baley keeps AI coding agents accountable to the person who answers for their wor ## Installing and updating -The development install script [`install.sh`](install.sh) and manual `baley update` are built for unsigned development artifacts only. Both check the downloaded binary's SHA-256 against an unsigned manifest before staging it behind `~/.local/bin/baley`. [#14](https://github.com/crenshawdev/baley/issues/14) adds and qualifies signature verification before any release. The planned one-command install will also run `baley install` to write the MCP entry, pre-tool hook, stubs, sandbox settings and Read/Edit deny rules. That wiring belongs to Build 3 T15, and the script does not call `baley install` yet. No npm or plugin is needed. Install will take settings defaults; `baley config interview` remains available later. A new Claude Code session loads the wiring, and each checkout needs `baley init`. +The development install script [`install.sh`](install.sh) and manual `baley update` are built for unsigned development artifacts only. Both check the downloaded binary's SHA-256 against an unsigned manifest before staging it behind `~/.local/bin/baley`. [#14](https://github.com/crenshawdev/baley/issues/14) adds and qualifies signature verification before any release. The script ends by running `baley install` through `~/.local/bin/baley`. Install writes Claude Code's wiring: the `baley` MCP server registration in `~/.claude.json`, made through `claude mcp add-json --scope user` so that Claude Code stays the only writer of that file, the pre-tool hook, the sandbox settings and the `Read` and `Edit` deny rules in `~/.claude/settings.json`, and one skill stub per front door under `~/.claude/skills/`. When `CLAUDE_CONFIG_DIR` is set, all three live under that folder instead. Install keeps your own settings and replaces an artifact only when it can show Baley wrote it. It refuses, and changes none of your files, when an artifact under Baley's name is not provably Baley's (`install-ownership-conflict`) or the settings file cannot be merged (`install-settings-conflict`). Every run prints a receipt that says whether the install is complete or partial. It is partial, and the script exits non-zero, when one of your settings weakens the protection, when `bwrap` or `socat` is missing on Linux, or when nothing runnable sits at `~/.local/bin/baley`. The receipt names each gap and the fix, and you run install again afterward. The sandbox settings apply to the Claude Code sessions that read that settings file, including projects Baley does not manage. No npm or plugin is needed. Install takes settings defaults and writes nothing to `config.toml`; `baley config interview` remains available later. A new Claude Code session loads the wiring, and each checkout needs `baley init`. Automatic updates are opt-in and off by default. Enable them with `baley config set --global updates.auto=true`. At startup, `baley serve` reads that setting once and starts a detached check without waiting for it. The check runs at most once per UTC day per installation, checks the download's SHA-256 against the unsigned manifest, and stages a newer version beside the old ones. Set the development source with `baley config set --global updates.source=https://...`; there is no default source. New sessions start the new server; running servers keep their version. The hook uses the new version at its next call and must work with an older running server and its ledger state. The updater contacts the configured source without sending project content or provider credentials. The guard never checks for updates or waits for them. Manual `baley update` can run even when that day's check has already run. See [delivery and updates](docs/adr/0038-installer-and-opt-in-updates.md). diff --git a/docs/roadmap.md b/docs/roadmap.md index 7ed92dd0..1ca5a1b1 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 T13 merged and T14 in progress on this branch, based on main `e3ddd485`, dated 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 T15 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 T14 merged (T14 is [#250](https://github.com/crenshawdev/baley/pull/250)) and T15 in progress on this branch, based on main `beba9141`, dated 2026-10-10, 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 T16 and 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 @@ -158,7 +158,7 @@ Figure 3. Build 2's tasks. Arrows point from a task to what waits on it. One pul [#24](https://github.com/crenshawdev/baley/issues/24) · milestone Evidence · in progress -Claude Code is the only host Baley supports in this release, and Build 3 removes Codex support. Each Claude Code session starts its own Baley MCP server over stdio, and the session's subagents reach it through the session's connection. There is no launcher, no HTTP listener and no background service, and every worker in this release is a subagent of its session. The server takes its project from `CLAUDE_PROJECT_DIR`, records its working directory beside it, and creates a session id that every call carries, with the host's own session id beside it when one is set. Ledger events record which caller wrote them. Each project call is prepared from the session's `CLAUDE_PROJECT_DIR` in the order the command line uses: a read appends nothing, and a write runs checkout admission and the host's policy step before its command. T8's `capture` and `document` are the first operations to use it. Another host can connect and list the tools, but every tool call it makes is refused with Claude Code named as the supported host. Sessions share the ledger through the store: every write takes the writer queue and decides inside its transaction ([ADR 0012](adr/0012-optimistic-concurrency.md)), and the build's acceptance includes two live Claude Code sessions writing one project at once, with the record checked afterwards. Agents can neither read nor write Baley's home or its config folder (`config.toml`). Claude Code's sandbox, its file permission rules and Baley's guard hook carry that together, and `baley doctor` reports what an agent can reach. The designed credential boundary keeps keys in the owner's environment ([ADR 0039](adr/0039-session-owned-provider-credentials.md)), which those folder denials do not protect. The compiled instructions are served ([ADR 0009](adr/0009-served-instructions.md)): T7 serves help, the operation schemas and the compiled instructions by identity in parts, and T8, which builds `document`, attaches the instruction identity a session sends on `capture` to the caller it records, with the version and hash taken from the registry. The build renders the stub content that the delivery tasks will write, and it opens with warnings that say what a restore cannot undo. [ADR 0038](adr/0038-installer-and-opt-in-updates.md) specifies one installer command into `~/.local/bin`, with `baley install` writing every artifact at the stable path, no npm or plugin, and default setup with the interview available later. T14's development `install.sh` places the binary behind `~/.local/bin/baley`; calling `baley install` to write the wiring belongs to T15. Updates are off by default and opt-in. At startup, `baley serve` reads `updates.auto` once and starts the detached check without waiting when it is on. The check claims at most one check per UTC day per installation, uses SHA-256 verification against an unsigned manifest, and stages a newer version beside the old ones. The guard never checks for updates. Running servers keep their version; the next hook call follows the stable path to the new binary. T14 keeps the newer hook compatible with an older server and ledger state, and T17 still qualifies that combination and the live install and update paths. Manual `baley update` is built and can run even on a day already claimed. T14 and T17 use unsigned development artifacts only. Both delivery paths have `verify_download` between download and staging. The release design #14 owns the signature format, trust root and both verifiers and must fill and qualify that seam before any release. Captures are recorded. +Claude Code is the only host Baley supports in this release, and Build 3 removes Codex support. Each Claude Code session starts its own Baley MCP server over stdio, and the session's subagents reach it through the session's connection. There is no launcher, no HTTP listener and no background service, and every worker in this release is a subagent of its session. The server takes its project from `CLAUDE_PROJECT_DIR`, records its working directory beside it, and creates a session id that every call carries, with the host's own session id beside it when one is set. Ledger events record which caller wrote them. Each project call is prepared from the session's `CLAUDE_PROJECT_DIR` in the order the command line uses: a read appends nothing, and a write runs checkout admission and the host's policy step before its command. T8's `capture` and `document` are the first operations to use it. Another host can connect and list the tools, but every tool call it makes is refused with Claude Code named as the supported host. Sessions share the ledger through the store: every write takes the writer queue and decides inside its transaction ([ADR 0012](adr/0012-optimistic-concurrency.md)), and the build's acceptance includes two live Claude Code sessions writing one project at once, with the record checked afterwards. Agents can neither read nor write Baley's home or its config folder (`config.toml`). Claude Code's sandbox, its file permission rules and Baley's guard hook carry that together, and `baley doctor` reports what an agent can reach. The designed credential boundary keeps keys in the owner's environment ([ADR 0039](adr/0039-session-owned-provider-credentials.md)), which those folder denials do not protect. The compiled instructions are served ([ADR 0009](adr/0009-served-instructions.md)): T7 serves help, the operation schemas and the compiled instructions by identity in parts, and T8, which builds `document`, attaches the instruction identity a session sends on `capture` to the caller it records, with the version and hash taken from the registry. The build renders the stub content that `baley install` writes, and it opens with warnings that say what a restore cannot undo. [ADR 0038](adr/0038-installer-and-opt-in-updates.md) specifies one installer command into `~/.local/bin`, with `baley install` writing every artifact at the stable path, no npm or plugin, and default setup with the interview available later. T14's development `install.sh` places the binary behind `~/.local/bin/baley` and ends by running `baley install` through that path. T15's `baley install` writes Claude Code's wiring: the user-scope MCP registration, made through `claude mcp add-json`, the pre-tool hook, the sandbox and file-tool denials merged into the user settings file, and the skill stubs. Updates are off by default and opt-in. At startup, `baley serve` reads `updates.auto` once and starts the detached check without waiting when it is on. The check claims at most one check per UTC day per installation, uses SHA-256 verification against an unsigned manifest, and stages a newer version beside the old ones. The guard never checks for updates. Running servers keep their version; the next hook call follows the stable path to the new binary. T14 keeps the newer hook compatible with an older server and ledger state, and T17 still qualifies that combination and the live install and update paths. Manual `baley update` is built and can run even on a day already claimed. T14 and T17 use unsigned development artifacts only. Both delivery paths have `verify_download` between download and staging. The release design #14 owns the signature format, trust root and both verifiers and must fill and qualify that seam before any release. Captures are recorded. - Designs: [0001 evidence ledger](design/0001-evidence-ledger.md), [0002 system design](design/0002-system-design.md), [0003 configuration and routing](design/0003-configuration-and-routing.md), [0010 guard](design/0010-guard.md), [0012 host interface](design/0012-host-interface.md), [0014 support families](design/0014-support-families.md), [0015 repository upkeep and the build gate](design/0015-repository-upkeep-and-build-gate.md) - ADRs: [0008 host sandbox isolation](adr/0008-host-sandbox-isolation.md), [0009 served instructions](adr/0009-served-instructions.md), [0011 one shared server](adr/0011-one-shared-server.md), [0012 optimistic concurrency](adr/0012-optimistic-concurrency.md), [0020 sandbox is a write barrier](adr/0020-sandbox-is-a-write-barrier.md), [0028 one HTTP stack](adr/0028-one-http-stack.md), [0029 a host may offer more](adr/0029-a-host-may-offer-more.md), [0033 host security bar](adr/0033-host-security-bar.md), [0034 one server per session](adr/0034-one-server-per-session.md), [0035 restore purge uncertainty](adr/0035-restore-purge-uncertainty.md), [0036 per-user guard records](adr/0036-per-user-guard-records.md), [0038 installer and opt-in updates](adr/0038-installer-and-opt-in-updates.md), [0039 session-owned provider credentials](adr/0039-session-owned-provider-credentials.md) @@ -222,11 +222,12 @@ flowchart LR class T1,T2,T3,T4,T5,T6,T7,T8,T9,T10,T11,T12 done class Delivery done class T13 done - class T14 progress - class T15,T16,T17,Release14,Verified planned + class T14 done + class T15 progress + class 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. T13 is merged and T14 is in progress. T15 to T17 are planned, 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. T14 is merged ([#250](https://github.com/crenshawdev/baley/pull/250)) and T15 is in progress. T16 and T17 are planned, 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 | |---|---|---|---| @@ -243,8 +244,8 @@ Figure 4. Build 3's tasks. Arrows point from a task to what waits on it. The dot | 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 | [#237](https://github.com/crenshawdev/baley/pull/237) | Merged | -| 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 | | In progress; released delivery depends on #14 | -| T15 | Claude artifact ownership and application | | Planned | +| 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 | [#250](https://github.com/crenshawdev/baley/pull/250) | Merged; released delivery depends on #14 | +| T15 | Claude artifact ownership and application | | In progress | | 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 | | T17 | Delivery doctor and installed qualification with unsigned development artifacts only, including newer-hook/older-server compatibility | | Planned; released-artifact verification and its qualification belong to #14 | @@ -413,4 +414,5 @@ What the records leave open or do not say. - The board's Status field is set only on the build issues, #40 and #14. - Build 4 (#25) owns removal of the built `keys.env` reader, `baley exec --key` credential wrapper and HTTPS model lister and credential lookup; its task breakdown is not written yet. The removal seams are `crates/baley/src/keys.rs`, `crates/baley/src/exec.rs` and `crates/baley/src/detection/`, with `baley init` switching to seeding. - The release design #14 owns the signed checksum manifest's signature format, trust root and both verifiers, including qualification and any new dependency. Both `install.sh` and the updater now check SHA-256 against an unsigned manifest in `verify_download` before staging. T14 and T17 use unsigned development artifacts only, and nothing is released before #14 supplies and qualifies signature verification. Removing old versions also belongs to #14. -- Build 3 T14 now provides `baley update`, the opt-in detached check started by `baley serve`, the development `install.sh`, ledger update claims and events, and staged binaries at `~/.local/lib/crenshawdev/baley/versions//baley`. The placement map projects the versions folder for write-only protection, and the security proposal renders its denials. The live guard still receives an empty folder list. T15 owns `baley install`, the settings file choice and passing the protection projection to the live guard, and preserves new-session loading of stubs. T14 leaves the view set at 7 and records update events only in `user`, adding no event type to the session's project. T17 still qualifies the live install script, the update path and a newer stable-path hook beside an older running server and its ledger state. T11 supplies the installed executable path to the protected-path projection. +- Build 3 T14 now provides `baley update`, the opt-in detached check started by `baley serve`, the development `install.sh`, ledger update claims and events, and staged binaries at `~/.local/lib/crenshawdev/baley/versions//baley`. The placement map projects the versions folder for write-only protection, and the security proposal renders its denials. T14 leaves the view set at 7 and records update events only in `user`, adding no event type to the session's project. T17 still qualifies the live install script, the update path and a newer stable-path hook beside an older running server and its ledger state. T11 supplies the installed executable path to the protected-path projection. +- Build 3 T15 provides `baley install [--host claude-code]`, which writes Claude Code's wiring at the stable path. The Claude folder is `CLAUDE_CONFIG_DIR` when it is set, else `~/.claude`, and one derivation of the stable path and the Claude folder serves install, the live guard and the doctor. Install writes the user settings file `/settings.json` with the pre-tool hook, the `permissions.deny` rules and the sandbox block merged into the owner's settings, the skill stubs under `/skills/`, and the user-scope MCP registration, which it has Claude Code write through `claude mcp add-json` and never writes itself. It replaces an artifact only when this binary's own render or `install.recorded` shows Baley wrote it. Otherwise it refuses `install-ownership-conflict` or `install-settings-conflict` before any write. Then it seeds the catalog and records `install.recorded` in `user`. The `install` view holds those records, so the view set rises from 7 to 8, and a binary built before T15 is read-only on `user` once a newer binary has brought its views to 8. The live guard now protects the placed stubs, the settings file, the registration file and the executable, and the versions folder as write-only. The doctor judges only what the install record shows, so a machine that never ran install still reports every artifact as not installed. An owner setting that weakens protection, a missing `bwrap` or `socat` on Linux, or nothing runnable at the stable path makes the install partial: the receipt names each gap with its fix and the command exits 1. Install writes nothing to `config.toml`. T16 builds on the settings file and the install record: its provider hosts and acknowledgement join them. T17 reads the derived placements and the record in its installed-result checks, and runs the live install and a Claude Code session on the written wiring. Two cases are filed and not closed: a registration changed between install's check and its removal of the old entry ([#251](https://github.com/crenshawdev/baley/issues/251)), and an older recorded hook removed while nothing runnable is at the stable path ([#252](https://github.com/crenshawdev/baley/issues/252)). From 94d5b0d0d667982ee7835b12ac6c753ddfe7e289 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 14:33:18 -0400 Subject: [PATCH 34/35] docs(readme): name the open registration race beside install's refusal promise The README said install changes none of your files when an artifact is not provably Baley's, with no exception. Issue 251 is one: the baley entry can change between install's check and the remove. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 386f1760..e3320e6e 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Baley keeps AI coding agents accountable to the person who answers for their wor ## Installing and updating -The development install script [`install.sh`](install.sh) and manual `baley update` are built for unsigned development artifacts only. Both check the downloaded binary's SHA-256 against an unsigned manifest before staging it behind `~/.local/bin/baley`. [#14](https://github.com/crenshawdev/baley/issues/14) adds and qualifies signature verification before any release. The script ends by running `baley install` through `~/.local/bin/baley`. Install writes Claude Code's wiring: the `baley` MCP server registration in `~/.claude.json`, made through `claude mcp add-json --scope user` so that Claude Code stays the only writer of that file, the pre-tool hook, the sandbox settings and the `Read` and `Edit` deny rules in `~/.claude/settings.json`, and one skill stub per front door under `~/.claude/skills/`. When `CLAUDE_CONFIG_DIR` is set, all three live under that folder instead. Install keeps your own settings and replaces an artifact only when it can show Baley wrote it. It refuses, and changes none of your files, when an artifact under Baley's name is not provably Baley's (`install-ownership-conflict`) or the settings file cannot be merged (`install-settings-conflict`). Every run prints a receipt that says whether the install is complete or partial. It is partial, and the script exits non-zero, when one of your settings weakens the protection, when `bwrap` or `socat` is missing on Linux, or when nothing runnable sits at `~/.local/bin/baley`. The receipt names each gap and the fix, and you run install again afterward. The sandbox settings apply to the Claude Code sessions that read that settings file, including projects Baley does not manage. No npm or plugin is needed. Install takes settings defaults and writes nothing to `config.toml`; `baley config interview` remains available later. A new Claude Code session loads the wiring, and each checkout needs `baley init`. +The development install script [`install.sh`](install.sh) and manual `baley update` are built for unsigned development artifacts only. Both check the downloaded binary's SHA-256 against an unsigned manifest before staging it behind `~/.local/bin/baley`. [#14](https://github.com/crenshawdev/baley/issues/14) adds and qualifies signature verification before any release. The script ends by running `baley install` through `~/.local/bin/baley`. Install writes Claude Code's wiring: the `baley` MCP server registration in `~/.claude.json`, made through `claude mcp add-json --scope user` so that Claude Code stays the only writer of that file, the pre-tool hook, the sandbox settings and the `Read` and `Edit` deny rules in `~/.claude/settings.json`, and one skill stub per front door under `~/.claude/skills/`. When `CLAUDE_CONFIG_DIR` is set, all three live under that folder instead. Install keeps your own settings and replaces an artifact only when it can show Baley wrote it. It refuses, and changes none of your files, when an artifact under Baley's name is not provably Baley's (`install-ownership-conflict`) or the settings file cannot be merged (`install-settings-conflict`). One case is still open: if another program changes the `baley` entry in `~/.claude.json` after install has checked it and before `claude mcp remove` runs, the remove can delete an entry install did not judge ([#251](https://github.com/crenshawdev/baley/issues/251)). Every run prints a receipt that says whether the install is complete or partial. It is partial, and the script exits non-zero, when one of your settings weakens the protection, when `bwrap` or `socat` is missing on Linux, or when nothing runnable sits at `~/.local/bin/baley`. The receipt names each gap and the fix, and you run install again afterward. The sandbox settings apply to the Claude Code sessions that read that settings file, including projects Baley does not manage. No npm or plugin is needed. Install takes settings defaults and writes nothing to `config.toml`; `baley config interview` remains available later. A new Claude Code session loads the wiring, and each checkout needs `baley init`. Automatic updates are opt-in and off by default. Enable them with `baley config set --global updates.auto=true`. At startup, `baley serve` reads that setting once and starts a detached check without waiting for it. The check runs at most once per UTC day per installation, checks the download's SHA-256 against the unsigned manifest, and stages a newer version beside the old ones. Set the development source with `baley config set --global updates.source=https://...`; there is no default source. New sessions start the new server; running servers keep their version. The hook uses the new version at its next call and must work with an older running server and its ledger state. The updater contacts the configured source without sending project content or provider credentials. The guard never checks for updates or waits for them. Manual `baley update` can run even when that day's check has already run. See [delivery and updates](docs/adr/0038-installer-and-opt-in-updates.md). From 324c4cac7a2a360178deee65918ed9e9ad5d2b2e Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 14:50:36 -0400 Subject: [PATCH 35/35] docs(install): correct comments and the roadmap left describing install as future work No plan's files covered these, so after install landed they still said the guard protects only the baley.toml files, that install adds the provider hosts, and that there is no install view, and the roadmap's Build 3 list left out #251 and #252. --- crates/baley/src/host_artifacts/placement.rs | 7 +++---- crates/baley/src/host_artifacts/security.rs | 6 +++--- crates/baley/src/protected_paths/write.rs | 5 +++-- crates/baley/src/update/events.rs | 9 ++++----- docs/roadmap.md | 10 ++++++---- 5 files changed, 19 insertions(+), 18 deletions(-) diff --git a/crates/baley/src/host_artifacts/placement.rs b/crates/baley/src/host_artifacts/placement.rs index 0034de4b..99e684b1 100644 --- a/crates/baley/src/host_artifacts/placement.rs +++ b/crates/baley/src/host_artifacts/placement.rs @@ -292,10 +292,9 @@ impl PlacementMap { /// registration, the hook and the settings in first-seen order, each /// file once, then the executable. /// - /// This is the list T15 appends to `ProtectedPaths.files` at - /// `guard_hook::context::judge`. Until then, production keeps that list - /// to the session project's `baley.toml` and the cwd checkout's - /// `baley.toml` (D-08). Those files are never in this list: the guard + /// `guard_hook::context::judge` appends this list to + /// `ProtectedPaths.files`, after the session project's `baley.toml` and + /// the cwd checkout's `baley.toml` (D-08). Those files are never in this list: the guard /// finds them at runtime, and the map takes no settings-file input, so /// the exact-list test on a full map is what keeps them out. /// diff --git a/crates/baley/src/host_artifacts/security.rs b/crates/baley/src/host_artifacts/security.rs index d7c1eecf..c4e0ff50 100644 --- a/crates/baley/src/host_artifacts/security.rs +++ b/crates/baley/src/host_artifacts/security.rs @@ -4,13 +4,13 @@ //! D-20). //! //! The proposal is content, not a write. `baley install` merges it into -//! Claude Code's settings in Build 3 T15, and T12 applies it by hand. It reads +//! Claude Code's user settings file (Build 3 T15). It reads //! nothing: every path is a supplied value, and a path that cannot be written //! as a rule is reported beside the content instead of rendered. //! //! It holds no `sandbox.network` key. Allowing the owner's chosen providers' -//! API hosts is T15's: once the key ADR and the delivery ADR record the host -//! list, T15 adds it to [`propose`]'s inputs and renders it as +//! API hosts is T16's: once the key ADR and the delivery ADR record the host +//! list, T16 adds it to [`propose`]'s inputs and renders it as //! `sandbox.network.allowedDomains`. //! //! Host facts the keys and spellings depend on, confirmed on 2026-10-06 diff --git a/crates/baley/src/protected_paths/write.rs b/crates/baley/src/protected_paths/write.rs index 4e98b911..b8a5b212 100644 --- a/crates/baley/src/protected_paths/write.rs +++ b/crates/baley/src/protected_paths/write.rs @@ -21,8 +21,9 @@ pub struct ProtectedPaths { /// Baley's configuration folder, protected whole. pub config: PathBuf, /// Files protected one by one: the session project's `baley.toml`, the - /// `baley.toml` of the checkout the hook's cwd is in, and the instruction - /// stubs. Any other file named `baley.toml` is not on the list. + /// `baley.toml` of the checkout the hook's cwd is in, and the installed + /// stubs, settings file, registration file and executable. Any other + /// file named `baley.toml` is not on the list. pub files: Vec, /// Folders protected against writes only, such as the folder of staged /// versions: a write inside one is denied, a read is not, because the diff --git a/crates/baley/src/update/events.rs b/crates/baley/src/update/events.rs index 883d7da0..1bc193ef 100644 --- a/crates/baley/src/update/events.rs +++ b/crates/baley/src/update/events.rs @@ -1,11 +1,10 @@ //! The two update events and their payloads (design 0012 section 6). //! //! `update.checked` and `update.failed` go to the per-user `user` project on -//! stream `install`, at version 1. There is no `install` view and no -//! projector: a new view would raise the view set and fence every older -//! running server, so these events are read by their stream and nothing keeps -//! a document for them. The builders here are pure; recording them is the -//! caller's. +//! stream `install`, at version 1. The `install` view keeps only +//! `install.recorded`, so these events are read by their stream and nothing +//! keeps a document for them. The builders here are pure; recording them is +//! the caller's. use baley_core::{Registry, RegistryError}; use baley_store::{NewEvent, RequestId, StreamName}; diff --git a/docs/roadmap.md b/docs/roadmap.md index 1ca5a1b1..878ae867 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -37,7 +37,7 @@ flowchart TB class B4,B5,B6,B7,B8,B9,OTHER,R2,R3 planned ``` -Figure 1. The path from Build 1 to publishing. Arrows point from a piece of work to what waits on it. Eight other open issues block the release design; the twenty open Build 3 issues are listed below but have no GitHub dependency link to it. +Figure 1. The path from Build 1 to publishing. Arrows point from a piece of work to what waits on it. Eight other open issues block the release design; the twenty-two open Build 3 issues are listed below but have no GitHub dependency link to it. | Status | Meaning | |---|---| @@ -333,7 +333,7 @@ Removes the old JSON record, the intent journal, participants, root binding, the The tag gate (on main, CI green, version matches the crate). Static, reproducible Linux and macOS archives, refused if a checksum differs from the committed pin. Signed artifacts and provenance attestations. Release notes and whether a CHANGELOG starts. How releases relate to forge anchors and tag rules. This work owns the signed checksum manifest's signature format and trust root, both verifier implementations (the installer's available before Baley exists and the updater's), and their qualification. It fills T14's named `verify_download` seam between download and staging in both delivery paths. T14 and T17 will have built and qualified delivery with unsigned development artifacts only; no release exists before #14 completes verification. Installation follows [ADR 0038](adr/0038-installer-and-opt-in-updates.md): one installer command, binary-written wiring at the stable path, and opt-in verified updates for new server sessions, with the hook taking the new stable-path version at its next call. Provider access follows [ADR 0039](adr/0039-session-owned-provider-credentials.md), with no Baley key file or provider call. #14 must name any new signature-verification dependency; none is selected yet. It also picks the first version number. -Blocked by Builds 1 to 9 and by every issue in [Other open issues](#other-open-issues) except the Build 3 issues #190, #203, #214, #219, #220, #221, #227, #235 and #238 to #249. +Blocked by Builds 1 to 9 and by every issue in [Other open issues](#other-open-issues) except the Build 3 issues #190, #203, #214, #219, #220, #221, #227, #235, #238 to #249, #251 and #252. ### R2. Live acceptance run on Claude Code @@ -345,7 +345,7 @@ Planned. Publish Baley and install it on a machine for Claude Code. Milestone Tr ## Other open issues -Every issue here except the Build 3 issues blocks the release design #14. GitHub links none of the twenty Build 3 issues to #14. "Carried by" means that build does the fix. +Every issue here except the Build 3 issues blocks the release design #14. GitHub links none of the twenty-two Build 3 issues to #14. "Carried by" means that build does the fix. ### Upkeep and docs @@ -389,10 +389,12 @@ Every issue here except the Build 3 issues blocks the release design #14. GitHub | [#247](https://github.com/crenshawdev/baley/issues/247) | `install.sh`'s final staging move can overwrite a different binary another installer placed after the existence check | None | Its text names no build | | [#248](https://github.com/crenshawdev/baley/issues/248) | `install.sh` checks only the staged file, so a version folder that is a symbolic link can place the binary outside the versions folder | None | Its text names no build | | [#249](https://github.com/crenshawdev/baley/issues/249) | `install.sh`'s temporary-link check strips trailing newlines from `readlink`, so a crafted directory link can pass it | None | Its text names no build | +| [#251](https://github.com/crenshawdev/baley/issues/251) | Between install's recheck of `mcpServers.baley` and `claude mcp remove`, another writer can change the entry, so the removal can delete an entry install did not judge | None | Its text names no build | +| [#252](https://github.com/crenshawdev/baley/issues/252) | With nothing runnable at the stable path, install withholds the new guard hook but still removes a recorded older hook | None | Its text names no build | ## Milestones -Counts from GitHub on 2026-10-09. #238 to #249 have no milestone. Evidence shows 43 closed and 21 open issues, and Encyclopedists six closed and one open. +Counts from GitHub on 2026-10-09. #238 to #249 have no milestone, and neither do #251 and #252 (checked 2026-10-10). Evidence shows 43 closed and 21 open issues, and Encyclopedists six closed and one open. | Milestone | Theme | Closed | Open | Open issues | State | |---|---|---|---|---|---|