Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
9a8d9b5
feat(policy): add review.reviewers to the settings schema
crenshawdev Oct 10, 2026
c43f287
feat(providers): compile the provider warning and register providers.…
crenshawdev Oct 10, 2026
d2aa3b1
feat(providers): read the owner's name from git for the acknowledgement
crenshawdev Oct 10, 2026
d013ab3
feat(providers): record the acknowledgement unless a current one cove…
crenshawdev Oct 10, 2026
52cccd5
feat(config): ask for outside providers and take a typed yes to each …
crenshawdev Oct 10, 2026
7906b2f
feat(config): record the acknowledgement before the settings write
crenshawdev Oct 10, 2026
cd6bc90
feat(config): refuse config set for the reviewer list and point to th…
crenshawdev Oct 10, 2026
e1aaa6b
test(install): pin that install keeps the owner's provider hosts and …
crenshawdev Oct 10, 2026
2b28ee3
test(config): pin that each provider warning needs its own yes and in…
crenshawdev Oct 10, 2026
9f9b71b
feat(install): decide which provider hosts the settings file still needs
crenshawdev Oct 10, 2026
904c6c1
feat(install): write the provider hosts through the digest-checked re…
crenshawdev Oct 10, 2026
aa340e5
feat(config): decide the reviewer list from the stored one and the ow…
crenshawdev Oct 10, 2026
5845928
feat(config): write the reviewer list through config set's own checks
crenshawdev Oct 10, 2026
5e8090a
feat(config): plan which confirmed writes run and which are already done
crenshawdev Oct 10, 2026
02ce17d
feat(config): list every file the confirmed writes can change in the …
crenshawdev Oct 11, 2026
aaebccb
feat(config): stop the ordered writes at a failure and report what is…
crenshawdev Oct 11, 2026
baf6f0a
feat(config): run the confirmed writes in order after the final yes
crenshawdev Oct 11, 2026
1ed6dba
docs(design): describe the interview's provider choice, warnings and …
crenshawdev Oct 11, 2026
fdcab19
docs(design): bring design 0003's settings table, build status and ci…
crenshawdev Oct 11, 2026
fc55e0e
docs(design): say in design 0008 that the provider warning, choice an…
crenshawdev Oct 11, 2026
41674ed
docs(design): describe the interview's provider setup in design 0012 …
crenshawdev Oct 11, 2026
fb03030
docs(design): say a binary built before T16 cannot write user once it…
crenshawdev Oct 11, 2026
447308a
docs(design): say in design 0001 and the README that the provider cho…
crenshawdev Oct 11, 2026
c8457ee
docs(design): show the interview's host and reviewer-list writes in t…
crenshawdev Oct 11, 2026
f234978
docs(roadmap): show T15 merged and T16 in progress in the roadmap
crenshawdev Oct 11, 2026
799511c
docs(readme): stop the README overclaiming key handling and rerun rec…
crenshawdev Oct 11, 2026
1949311
docs(design): say a rerun of the interview is safe only against the s…
crenshawdev Oct 11, 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
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,19 +27,19 @@ 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`). 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`.
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`, and a reinstall keeps the API hosts the interview added to the settings file; `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).

## API keys and outside-review risks

The design requires that Baley never read, store or send your API keys. You keep them in your environment, where every program Claude Code starts can see them, agents and subagents included. Nothing scrubs a key a command prints. Your code goes to the review provider you choose under its terms.
The design requires that Baley never read, store or send your API keys. Keys live in your environment, where every program Claude Code starts can see them, agents and subagents included. Nothing scrubs a key a command prints. Your code goes to the chosen provider under its terms.

Release 1 outside reviews use provider APIs only. Baley builds the request and names the address, header and environment variable; the session sends it with your key and returns the raw response for Baley to parse and check. The same boundary applies to model-list refreshes. Provider command-line agents are outside this release.

Before an outside provider is enabled, the interview asks which providers to use, states these risks and requires a typed confirmation. It records the acknowledgement in the ledger and asks again when the warning changes. A fresh install enables no outside provider; a reinstall keeps acknowledged choices. Install never acknowledges a warning for you.
The interview records your confirmation of an outside provider, but nothing checks that record yet before a review or fetch. Enforcing it when a review or fetch is issued, including for settings edited outside the interview, is Build 4's (#25). `baley config interview` asks which providers to use (`openai`, `deepseek` or `none`) after the settings questions, shows the warning above for each one you pick and needs a typed `yes` to it. Any other answer declines the whole interview and writes nothing. It then shows a summary that names every file it will change. After the final `yes` it records the acknowledgement in the ledger with your git `user.name` (it refuses, before the summary, when git has none), adds the providers' API hosts to Claude Code's settings file, writes `review.reviewers` in `config.toml`, and then writes the settings. If a step fails, it reports the failure and the writes already completed. Running the interview again against the same file is safe and redoes any write that did not happen. Without `--global` or `--project`, a rerun inside a project writes your settings answers to `baley.toml` once `config.toml` holds a role value, so pass `--global` to rerun against `config.toml`. There is one exception: when the reviewer list was written to `config.toml` and the ledger policy step after it failed, a rerun finds the list already equal and does not redo that step ([#255](https://github.com/crenshawdev/baley/issues/255)). It asks again when the warning changes. A fresh install enables no outside provider; a reinstall keeps acknowledged choices and the hosts the interview added. Install never acknowledges a warning for you.

This boundary is not built yet. The current binary still contains the `keys.env` reader, `baley exec --key` and the HTTPS model lister. Build 4 (#25) removes those paths. The parked review engine also reads environment keys and `providers.env` and makes provider calls; that code goes with the parked engine in Build 9. See [the credential decision](docs/adr/0039-session-owned-provider-credentials.md) and [configuration build status](docs/design/0003-configuration-and-routing.md#11-build-status).
The key boundary the warning describes is not built yet. The current binary still contains the `keys.env` reader, `baley exec --key` and the HTTPS model lister. Build 4 (#25) removes those paths. The parked review engine also reads environment keys and `providers.env` and makes provider calls; that code goes with the parked engine in Build 9. See [the credential decision](docs/adr/0039-session-owned-provider-credentials.md) and [configuration build status](docs/design/0003-configuration-and-routing.md#11-build-status).

## Following the work

Expand Down
10 changes: 10 additions & 0 deletions crates/baley-core/src/catalog/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,16 @@ impl Provider {
.into_iter()
.find(|provider| provider.name() == name)
}

/// The host the provider's API calls reach, which the interview adds to
/// Claude Code's sandbox allowance. It is compiled here and read from no
/// key table (design 0012 section 5).
pub fn api_host(self) -> &'static str {
match self {
Provider::OpenAi => "api.openai.com",
Provider::DeepSeek => "api.deepseek.com",
}
}
}

/// How capable a model is within its provider, from the hint table, best fit
Expand Down
6 changes: 6 additions & 0 deletions crates/baley-core/src/catalog/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -843,3 +843,9 @@ fn removing_a_hosts_owner_entry_is_allowed() {
assert_eq!(judge_alias_removal(catalog, "o-own"), Ok(()));
assert_eq!(judge_held_removal(catalog, "o-own", Some(&claude)), Ok(()));
}

#[test]
fn a_provider_api_host_other_than_its_documented_one_is_caught() {
assert_eq!(Provider::OpenAi.api_host(), "api.openai.com");
assert_eq!(Provider::DeepSeek.api_host(), "api.deepseek.com");
}
10 changes: 5 additions & 5 deletions crates/baley-core/src/policy/config_command/file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,9 @@ fn child<'t>(table: &'t mut Table, key: &str) -> &'t mut Table {
slot.as_table_mut().expect("the slot holds a table")
}

/// The TOML type each kind reads back: a boolean, or a string for a rung
/// name, a model name, a remote name, an `https://` address and an
/// `on_protected` name.
/// The TOML type each kind reads back: a boolean, an array of strings for a
/// branch or reviewer list, or a string for a rung name, a model name, a
/// remote name, an `https://` address and an `on_protected` name.
fn toml_value(value: &Value) -> TomlValue {
match value {
Value::Bool(value) => TomlValue::Boolean(*value),
Expand All @@ -85,7 +85,7 @@ fn toml_value(value: &Value) -> TomlValue {
Value::RemoteName(name) => TomlValue::String(name.clone()),
Value::OnProtected(on) => TomlValue::String(on.name().to_owned()),
Value::HttpsAddress(address) => TomlValue::String(address.clone()),
Value::BranchList(names) => {
Value::BranchList(names) | Value::ReviewerList(names) => {
TomlValue::Array(names.iter().cloned().map(TomlValue::String).collect())
}
}
Expand Down Expand Up @@ -121,7 +121,7 @@ pub(super) fn value_text(value: &Value) -> String {
Value::Bool(value) => value.to_string(),
Value::Rung(rung) => format!("\"{}\"", rung.name()),
Value::OnProtected(on) => format!("\"{}\"", on.name()),
Value::BranchList(names) => {
Value::BranchList(names) | Value::ReviewerList(names) => {
let items: Vec<String> = names.iter().map(|name| quoted(name)).collect();
format!("[{}]", items.join(", "))
}
Expand Down
27 changes: 19 additions & 8 deletions crates/baley-core/src/policy/config_command/set.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,20 @@ impl fmt::Display for SetRefusal {
SetRefusal::UnknownSetting { name } => {
write!(f, "{} is not a setting Baley reads", name.escape_debug())
}
SetRefusal::ListNotSettable { name } => write!(
f,
"{} is a list, and config set does not write lists; edit the list in baley.toml",
name.escape_debug()
),
SetRefusal::ListNotSettable { name } => {
// The reviewer list lives in the global file too, and the
// interview is how an owner chooses its providers.
let advice = if name == "review.reviewers" {
"baley config interview chooses the outside providers, and the list can be edited by hand in config.toml or baley.toml"
} else {
"edit the list in baley.toml"
};
write!(
f,
"{} is a list, and config set does not write lists; {advice}",
name.escape_debug()
)
}
SetRefusal::WrongLayer { name, scope, layer } => {
let (scope, file, flag) = match (scope, layer) {
(Scope::Project, _) => ("project", "global", "--project"),
Expand Down Expand Up @@ -125,6 +134,7 @@ impl fmt::Display for SetRefusal {
format!("one of {}", names.join(", "))
}
Kind::BranchList => "a list of branch names".to_owned(),
Kind::ReviewerList => "a list of host, openai or deepseek".to_owned(),
Kind::HttpsAddress => {
"an https:// address with no spaces, query or fragment".to_owned()
}
Expand Down Expand Up @@ -188,7 +198,7 @@ fn convert(kind: Kind, text: &str) -> Option<Value> {
Kind::RemoteName => (!text.is_empty()).then(|| Value::RemoteName(text.to_owned())),
Kind::OnProtected => OnProtected::parse(text).map(Value::OnProtected),
// A list is edited in `baley.toml`, so no command-line text is one.
Kind::BranchList => None,
Kind::BranchList | Kind::ReviewerList => None,
Kind::HttpsAddress => is_https_address(text).then(|| Value::HttpsAddress(text.to_owned())),
}
}
Expand All @@ -200,7 +210,8 @@ fn convert(kind: Kind, text: &str) -> Option<Value> {
/// the earliest check any pair fails, naming the first pair that fails it:
/// `not-a-project`, `unknown-setting`, `list-not-settable`, `wrong-layer`, then
/// `invalid-value`. A list is refused before its layer is judged, so both files
/// get the same answer, to edit `baley.toml`.
/// get the same answer: to edit the list by hand, and for the reviewer list to
/// run the interview.
/// A host comes only from `host`, so a name written with a `host.<name>.`
/// prefix is unknown. A setting given twice comes back twice, so each value
/// given can still be checked against the catalog.
Expand All @@ -223,7 +234,7 @@ pub fn judge_pairs(
entries.push(entry);
}
for ((name, _), entry) in pairs.iter().zip(&entries) {
if entry.kind == Kind::BranchList {
if matches!(entry.kind, Kind::BranchList | Kind::ReviewerList) {
return Err(SetRefusal::ListNotSettable {
name: (*name).to_owned(),
});
Expand Down
4 changes: 4 additions & 0 deletions crates/baley-core/src/policy/config_command/show.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ fn kind_text(kind: Kind) -> &'static str {
Kind::RemoteName => "remote name",
Kind::OnProtected => "one of ask, refuse or allow",
Kind::BranchList => "list of branch names",
Kind::ReviewerList => "list of host, openai or deepseek",
Kind::HttpsAddress => "https:// address",
}
}
Expand All @@ -177,6 +178,9 @@ fn default_text(default: Builtin) -> String {
Builtin::BranchList(names) => value_text(&Value::BranchList(
names.iter().map(|name| (*name).to_owned()).collect(),
)),
Builtin::ReviewerList(names) => value_text(&Value::ReviewerList(
names.iter().map(|name| (*name).to_owned()).collect(),
)),
}
}

Expand Down
120 changes: 120 additions & 0 deletions crates/baley-core/src/policy/config_command/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1864,3 +1864,123 @@ fn a_config_set_source_with_a_query_or_fragment_the_parser_would_refuse_is_caugh
"[updates]\nsource = \"https://dl.example/dev\"\n"
);
}

fn reviewers(names: &[&str]) -> TypedPair {
pair(
"review.reviewers",
None,
Value::ReviewerList(names.iter().map(|name| name.to_string()).collect()),
)
}

#[test]
fn a_reviewer_list_rendered_as_anything_but_a_toml_array_is_caught() {
let bytes = render_file(
Schema::standard(),
FileLayer::Global,
None,
&[reviewers(&["host", "openai"])],
)
.unwrap();
let read = |bytes: Vec<u8>| {
let file = SettingsFile {
path: "/c/config.toml".into(),
bytes,
digest: "d".into(),
};
parse_layer(&file, FileLayer::Global, Schema::standard()).expect("the render reads back")
};
let layer = read(bytes);
let written: Vec<_> = layer
.values
.iter()
.filter(|written| written.name == "review.reviewers")
.collect();
assert_eq!(written.len(), 1);
assert_eq!(written[0].host, None);
assert_eq!(
written[0].value,
Value::ReviewerList(vec!["host".into(), "openai".into()])
);

let empty = render_file(
Schema::standard(),
FileLayer::Global,
None,
&[reviewers(&[])],
)
.unwrap();
let layer = read(empty);
let written: Vec<_> = layer
.values
.iter()
.filter(|written| written.name == "review.reviewers")
.collect();
assert_eq!(written.len(), 1);
assert_eq!(written[0].value, Value::ReviewerList(Vec::new()));
}

#[test]
fn a_reviewer_list_shown_without_its_kind_default_or_scope_is_caught() {
let layers = ShowLayers {
global: None,
working: None,
head: None,
};
let lines = render_show(&ShowRequest {
schema: Schema::standard(),
host: None,
names: &["review.reviewers"],
layers: &layers,
pending: None,
global_path: std::path::Path::new("/c/config.toml"),
project_path: None,
});
assert_eq!(
lines[0],
"review.reviewers: kind list of host, openai or deepseek, default [\"host\"], scope both"
);
}

#[test]
fn a_reviewer_list_set_from_the_command_line_not_refused_as_a_list_is_caught() {
let cases: [(FileLayer, bool); 2] = [(FileLayer::Global, false), (FileLayer::Project, true)];
for (layer, in_project) in cases {
for pairs in [
vec![("review.reviewers", "openai")],
vec![("review.reviewers", "[\"host\",\"openai\"]")],
vec![
("escalate_on_failure", "true"),
("review.reviewers", "openai"),
],
] {
let refusal = judge_pairs(Schema::standard(), layer, in_project, None, &pairs)
.expect_err("a list is never set from the command line");
assert_eq!(
refusal,
SetRefusal::ListNotSettable {
name: "review.reviewers".into()
},
"{layer:?} {pairs:?}"
);
let text = refusal.to_string();
assert!(
text.starts_with(
"list-not-settable: review.reviewers is a list, and config set does not write lists"
),
"{text}"
);
assert!(text.contains("baley config interview"), "{text}");
assert!(text.contains("config.toml"), "{text}");
assert!(!text.ends_with("edit the list in baley.toml"), "{text}");
}
}
let branches = judge_standard(FileLayer::Project, &[("git.protected_branches", "main")])
.expect_err("a list is never set from the command line");
assert!(
branches
.to_string()
.ends_with("edit the list in baley.toml"),
"{branches}"
);
}
Loading
Loading