Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
a01aa6f
feat(host): derive every installed Claude path from one reading of HO…
crenshawdev Oct 10, 2026
b3c1a10
feat(install): add baley install with host selection and a receipt th…
crenshawdev Oct 10, 2026
5ca6fe4
feat(guard): make the guard protect the installed stubs, settings, re…
crenshawdev Oct 10, 2026
4743243
feat(ledger): record installs as install.recorded and read the latest…
crenshawdev Oct 10, 2026
b0c8f4a
feat(install): append an install record only when it differs from the…
crenshawdev Oct 10, 2026
74f42a1
feat(install): refuse every write when a stub is not provably Baley's
crenshawdev Oct 10, 2026
8c8832c
feat(install): write the stubs through replace and record the install…
crenshawdev Oct 10, 2026
176af59
fix(install): refuse to record install facts planned from an older re…
crenshawdev Oct 10, 2026
7a3fcab
fix(install): hold one install lock so two runs cannot overlap
crenshawdev Oct 10, 2026
223a7e5
feat(install): refuse a settings file that cannot be composed into
crenshawdev Oct 10, 2026
ed9b4dc
feat(install): remove an older baley hook or deny entry only on the r…
crenshawdev Oct 10, 2026
53689e2
feat(install): compose baley's hook and sandbox into the settings fil…
crenshawdev Oct 10, 2026
8e95306
feat(install): hold the sandbox block back when its linux programs ar…
crenshawdev Oct 10, 2026
5a23412
feat(install): write the settings file first and report its gaps in t…
crenshawdev Oct 10, 2026
c1da8b9
feat(install): decide the registration from Claude Code's own file
crenshawdev Oct 10, 2026
059f790
feat(install): run the registration after every file is written
crenshawdev Oct 10, 2026
33410c9
feat(install): hold the hook and registration back from an empty stab…
crenshawdev Oct 10, 2026
ded9252
fix(install): refuse a non-stdio server as Baley's registration
crenshawdev Oct 10, 2026
26323eb
fix(install): report a replaced security value only when the settings…
crenshawdev Oct 10, 2026
65047b7
fix(install): refuse a Baley folder that is not UTF-8 before any write
crenshawdev Oct 10, 2026
432a86a
feat(host): judge the doctor's artifacts against what install recorded
crenshawdev Oct 10, 2026
bc98a34
feat(install): end the install script by running baley install
crenshawdev Oct 10, 2026
49ab6ee
chore(guard): remove the tracked hook file now that install writes th…
crenshawdev Oct 10, 2026
eb32aa8
docs(design): describe baley install as built in the host interface d…
crenshawdev Oct 10, 2026
a77a304
docs(design): list the install record's keys and the built wiring in …
crenshawdev Oct 10, 2026
25870ad
docs(design): say the guard protects the installed placements and ins…
crenshawdev Oct 10, 2026
8a054d5
docs(design): say install refuses only an entry that matches neither …
crenshawdev Oct 10, 2026
b5c0eea
fix(install): say the sandbox reaches the sessions that read the sett…
crenshawdev Oct 10, 2026
2e73e2d
docs(design): limit the script's exit 1 to its own steps
crenshawdev Oct 10, 2026
10066d9
fix(install): say the sandbox reaches sessions that read the settings…
crenshawdev Oct 10, 2026
7173c0b
docs(design): say install records ownership and the doctor and guard …
crenshawdev Oct 10, 2026
dcf6702
docs(design): show baley install writing the host's wiring in the C4 …
crenshawdev Oct 10, 2026
d934c50
docs(roadmap): mark t14 merged and t15 in progress and say what insta…
crenshawdev Oct 10, 2026
94d5b0d
docs(readme): name the open registration race beside install's refusa…
crenshawdev Oct 10, 2026
324c4ca
docs(install): correct comments and the roadmap left describing insta…
crenshawdev Oct 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`). 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).

Expand Down
17 changes: 9 additions & 8 deletions crates/baley/src/guard_budget.rs
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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);
Expand Down
134 changes: 118 additions & 16 deletions crates/baley/src/guard_hook/context.rs
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<PathBuf>,
/// The paths path tools are judged against, or why Baley's folders could
/// not be resolved.
pub protected: Result<ProtectedPaths, FolderRefusal>,
/// The paths path tools are judged against, or why Baley's folders or
/// installed artifact paths could not be resolved.
pub protected: Result<ProtectedPaths, Refusal>,
}

/// 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
Expand All @@ -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),
)
}

Expand All @@ -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<Walk>,
cwd_walk: Walk,
folders: Result<Folders, FolderRefusal>,
installed: Result<Option<installed::Installed>, installed::Refusal>,
) -> HookContext {
let project = match (project, project_walk) {
(ProjectContext::Valid(_), Some(Ok(Discovery::Managed { folder, root }))) => {
Expand Down Expand Up @@ -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,
})
}),
}
}
Expand All @@ -155,7 +195,7 @@ mod tests {
Ok(Discovery::Unmanaged { root: path.into() })
}

fn protecting(files: &[&str]) -> Result<ProtectedPaths, FolderRefusal> {
fn protecting(files: &[&str]) -> Result<ProtectedPaths, Refusal> {
Ok(ProtectedPaths {
home: "/u/.local/share/crenshawdev/baley".into(),
config: "/u/.config/crenshawdev/baley".into(),
Expand All @@ -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<PathBuf> = 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(
Expand All @@ -179,6 +275,7 @@ mod tests {
Some(managed("/p")),
managed("/q"),
folders(),
Ok(None),
);
assert_eq!(
context,
Expand Down Expand Up @@ -207,6 +304,7 @@ mod tests {
None,
managed("/q"),
folders(),
Ok(None),
);
assert_eq!(
context,
Expand All @@ -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);
Expand All @@ -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()));
Expand All @@ -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()));
Expand All @@ -274,14 +375,15 @@ mod tests {
Some(managed("/p")),
managed("/q"),
Err(FolderRefusal::UserHomeUnset),
Ok(None),
);
assert_eq!(
context,
HookContext {
project_directory: Ok(Some("/p".into())),
project: bound("/p"),
checkout: Some("/q".into()),
protected: Err(FolderRefusal::UserHomeUnset),
protected: Err(Refusal::Folders(FolderRefusal::UserHomeUnset)),
}
);
}
Expand Down
Loading
Loading